mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-30 07:56:04 +00:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 33fa3a03ee | |||
| 749ec956d9 | |||
| a77c1e2bc6 | |||
| fa256d5c27 | |||
| ba941f3742 | |||
| 5ae97cb8be | |||
| bd2d162860 | |||
| 1fa11b113e | |||
| 11ef3b63b8 | |||
| d5fbb7b628 | |||
| d953d0a973 | |||
| 43e84874df | |||
| 3f7664ddf9 | |||
| 7528c063cb | |||
| 772e1b4c37 | |||
| 05256f1cfc | |||
| 920f0487a3 | |||
| 919d083bbf | |||
| 816b3168b7 | |||
| a33fbcc696 |
@@ -73,7 +73,6 @@ workflows:
|
||||
'.github/workflows/ui.yml':
|
||||
- 'actions/checkout@v7.0.1'
|
||||
- 'actions/setup-node@v7.1.0'
|
||||
- 'actions/upload-artifact@v7.0.2'
|
||||
- 'pnpm/action-setup@v6.1.0'
|
||||
dependencies:
|
||||
'actions/cache@v6.1.0':
|
||||
|
||||
@@ -69,85 +69,3 @@ jobs:
|
||||
echo "$drift"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The pinned image lives in one file that scripts/ui-playwright.sh also reads,
|
||||
# so make ui-baselines and the job below draw screenshots in the same image.
|
||||
ui_e2e_image:
|
||||
name: UI End-to-End Image
|
||||
runs-on: ubuntu-26.04
|
||||
outputs:
|
||||
image: ${{ steps.read.outputs.image }}
|
||||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
sparse-checkout: spinifex/runtime/roles/spinifexui/frontend/playwright.image
|
||||
sparse-checkout-cone-mode: false
|
||||
|
||||
- name: Read Playwright Image
|
||||
id: read
|
||||
run: echo "image=$(tr -d '[:space:]' < spinifex/runtime/roles/spinifexui/frontend/playwright.image)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# The browser suite: the console against a faked API on Vite dev, plus the
|
||||
# built dist under the production CSP. It runs in Playwright's own image, which
|
||||
# carries the browsers, so screenshots rasterise as they did for the baselines.
|
||||
ui_e2e:
|
||||
name: UI End-to-End
|
||||
needs: ui_e2e_image
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 20
|
||||
container:
|
||||
image: ${{ needs.ui_e2e_image.outputs.image }}
|
||||
options: --ipc=host
|
||||
defaults:
|
||||
run:
|
||||
working-directory: spinifex/runtime/roles/spinifexui/frontend
|
||||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
sparse-checkout: spinifex/runtime/roles/spinifexui/frontend
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.1.0
|
||||
with:
|
||||
package_json_file: spinifex/runtime/roles/spinifexui/frontend/package.json
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7.1.0
|
||||
with:
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: spinifex/runtime/roles/spinifexui/frontend/pnpm-lock.yaml
|
||||
node-version-file: spinifex/runtime/roles/spinifexui/frontend/.nvmrc
|
||||
|
||||
- name: Install Dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
# The image's browsers belong to one Playwright release, so a lockfile
|
||||
# bump without an image bump would otherwise fail on a missing executable.
|
||||
- name: Check Playwright Matches the Image
|
||||
env:
|
||||
IMAGE: ${{ needs.ui_e2e_image.outputs.image }}
|
||||
run: |
|
||||
version=$(pnpm exec playwright --version | awk '{print $2}')
|
||||
case "$IMAGE" in
|
||||
*":v$version-"*) ;;
|
||||
*) echo "::error::Lockfile has Playwright $version but the image is $IMAGE. Update playwright.image."; exit 1 ;;
|
||||
esac
|
||||
|
||||
- name: Build Frontend
|
||||
run: pnpm build
|
||||
|
||||
- name: Run End-to-End Suite
|
||||
run: pnpm e2e
|
||||
|
||||
- name: Upload Screenshot Diffs
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v7.0.2
|
||||
with:
|
||||
name: ui-e2e-test-results
|
||||
path: spinifex/runtime/roles/spinifexui/frontend/test-results/
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
|
||||
@@ -56,19 +56,6 @@ build-ui:
|
||||
@echo -e "\n....Building spinifex-ui frontend...."
|
||||
cd spinifex/runtime/roles/spinifexui/frontend && pnpm build
|
||||
|
||||
# Playwright suite for the console, against Vite dev and the built dist under
|
||||
# the console's CSP. Not part of preflight; needs `pnpm exec playwright install chromium`.
|
||||
test-ui-e2e: build-ui
|
||||
cd spinifex/runtime/roles/spinifexui/frontend && pnpm e2e
|
||||
|
||||
# The same suite inside the pinned Playwright image CI uses (podman or docker),
|
||||
# so screenshots compare as they will on the runner. ui-baselines redraws them.
|
||||
ui-e2e-container:
|
||||
scripts/ui-playwright.sh compare
|
||||
|
||||
ui-baselines:
|
||||
scripts/ui-playwright.sh baselines
|
||||
|
||||
# GO commands
|
||||
VERSION ?= $(shell git describe --tags --always --dirty --exclude '*-dev')
|
||||
COMMIT ?= $(shell git rev-parse --short HEAD)
|
||||
@@ -499,7 +486,7 @@ distro-arm64:
|
||||
distro-clean:
|
||||
rm -rf dist/
|
||||
|
||||
.PHONY: test-package-check build build-ui test-ui-e2e ui-e2e-container ui-baselines build-installer build-lb-agent build-ecs-agent build-system-image build-eks-node-image import-eks-node-image publish-eks-node-image build-ecs-node-image import-ecs-node-image build-rds-postgres-image import-rds-postgres-image build-rds-mariadb-image import-rds-mariadb-image build-microvm-image install-microvm go_build preflight test test-cover test-race diff-coverage bench fuzz test-actions test-images test-build-scripts test-harness test-integration generate-aws-model-coverage aws-model-coverage test-segscan-oracle manifest-check manifest-lint manifest-lint-update \
|
||||
.PHONY: test-package-check build build-ui build-installer build-lb-agent build-ecs-agent build-system-image build-eks-node-image import-eks-node-image publish-eks-node-image build-ecs-node-image import-ecs-node-image build-rds-postgres-image import-rds-postgres-image build-rds-mariadb-image import-rds-mariadb-image build-microvm-image install-microvm go_build preflight test test-cover test-race diff-coverage bench fuzz test-actions test-images test-build-scripts test-harness test-integration generate-aws-model-coverage aws-model-coverage test-segscan-oracle manifest-check manifest-lint manifest-lint-update \
|
||||
deploy reinstall clean \
|
||||
install-system install-go install-aws quickinstall \
|
||||
lint fix govulncheck nilaway \
|
||||
|
||||
@@ -29,8 +29,9 @@ var allowlist = []allowance{
|
||||
|
||||
{From: "agents/rds/agent", To: "domains/rds/instance", Debt: "agents/rds/agent (dependency, narrowed)", Reason: "command protocol, wire and record types; needs a guest/controller wire contract"},
|
||||
|
||||
{From: "cmd/aws-model-coverage", To: "domains/acm/awsapi", Debt: "cmd/aws-model-coverage", Reason: "reads declared ECR/ACM inventories; named exception, must not construct a registration"},
|
||||
{From: "cmd/aws-model-coverage", To: "domains/ecr/awsapi", Debt: "cmd/aws-model-coverage", Reason: "reads declared ECR/ACM inventories; named exception, must not construct a registration"},
|
||||
{From: "cmd/aws-model-coverage", To: "domains/acm/awsapi", Debt: "cmd/aws-model-coverage", Reason: "reads declared ECR/ACM/Route 53 inventories; named exception, must not construct a registration"},
|
||||
{From: "cmd/aws-model-coverage", To: "domains/ecr/awsapi", Debt: "cmd/aws-model-coverage", Reason: "reads declared ECR/ACM/Route 53 inventories; named exception, must not construct a registration"},
|
||||
{From: "cmd/aws-model-coverage", To: "domains/route53/awsapi", Debt: "cmd/aws-model-coverage", Reason: "reads declared ECR/ACM/Route 53 inventories; named exception, must not construct a registration"},
|
||||
|
||||
{From: "runtime/daemon", To: "domains/ec2/awsapi/instance", Debt: "Instance adapter consumers outside EC2", Reason: "runtime/daemon/eks_cp_control.go and rds_deps.go call the instance adapter; needs an EC2 capability"},
|
||||
|
||||
|
||||
@@ -4,11 +4,13 @@ import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"slices"
|
||||
|
||||
"github.com/mulgadc/predastore/s3api"
|
||||
"github.com/mulgadc/spinifex/internal/awsmodel"
|
||||
acmawsapi "github.com/mulgadc/spinifex/spinifex/domains/acm/awsapi"
|
||||
awsapi "github.com/mulgadc/spinifex/spinifex/domains/ecr/awsapi"
|
||||
route53awsapi "github.com/mulgadc/spinifex/spinifex/domains/route53/awsapi"
|
||||
"github.com/mulgadc/spinifex/spinifex/gateway"
|
||||
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
|
||||
|
||||
@@ -49,9 +51,17 @@ func compareAll() ([]awsmodel.OperationCoverage, error) {
|
||||
// Registered services contribute their declared inventory, which needs no
|
||||
// live dependencies; nothing here serves a request.
|
||||
inventories := gateway.AWSOperationInventory(map[string]dispatch.Inventory{
|
||||
awsapi.ServiceName: awsapi.OperationInventory(),
|
||||
acmawsapi.ServiceName: acmawsapi.OperationInventory(),
|
||||
awsapi.ServiceName: awsapi.OperationInventory(),
|
||||
acmawsapi.ServiceName: acmawsapi.OperationInventory(),
|
||||
route53awsapi.ServiceName: route53awsapi.OperationInventory(),
|
||||
})
|
||||
// The loop below walks the embedded models, so a served service with no
|
||||
// model would otherwise drop out of the report without a word.
|
||||
for name := range inventories {
|
||||
if !slices.Contains(awsmodel.Services(), awsmodel.Service(name)) {
|
||||
return nil, fmt.Errorf("no embedded AWS model for served service %s", name)
|
||||
}
|
||||
}
|
||||
coverages := make([]awsmodel.OperationCoverage, 0, len(awsmodel.Services()))
|
||||
for _, service := range awsmodel.Services() {
|
||||
inventory, ok := inventories[string(service)]
|
||||
|
||||
@@ -438,7 +438,7 @@ These residuals were recorded by individual slices and must be resolved once the
|
||||
- `runtime/roles/vpcd` (placement): the role still mixes process/role supervision with network-controller responsibilities. `lease_reaper.go` (orphaned external-pool DHCP lease sweep), `ovn_controller_watchdog.go` (OVN controller liveness and restart), `host_eip_loop.go` (host-EIP reconcile loop) and `lease_datapath_reconcile.go` (lease datapath reconcile) are periodic network-control loops, not role wiring; `vpcd.go` itself also carries bridge-mode detection and verification, chassis-list parsing, external-CIDR resolution, DHCP manager startup and lease/IP-change handling, and gateway/host-EIP allocator and neighbour flush/prime construction alongside `Service.Start`. The future placement question is whether these belong in `domains/network`. Removal condition: an accepted decision on network-controller placement, and extraction of those loops and helpers behind the role.
|
||||
- ECR control-plane registration: `awsapi.Deps.NATS` is a raw `*nats.Conn`, not a narrow capability. Every NATS-backed `Handler` passes it to `domains/ecr.NewNATSMetaStore`, which calls `natsmsg.NATSRequest`, so narrowing it means changing the exported `Handler` type, every handler and the meta store; it must not be broadened further meanwhile.
|
||||
- ACM control-plane registration: `domains/acm/awsapi.Deps.NATS` (`registration.go`) is likewise a raw `*nats.Conn` carried by every `Handler`, for the same reason; it must not be broadened further meanwhile.
|
||||
- `cmd/aws-model-coverage` imports `ingress/aws/dispatch`, `domains/ecr/awsapi`, `domains/acm/awsapi` and `gateway` to read ECR's and ACM's declared inventories. It is a non-serving tool and the only named exception to the ADR-0002 composition invariant; it must not construct a registration.
|
||||
- `cmd/aws-model-coverage` imports `ingress/aws/dispatch`, `domains/ecr/awsapi`, `domains/acm/awsapi`, `domains/route53/awsapi` and `gateway` to read ECR's, ACM's and Route 53's declared inventories. It is a non-serving tool and the only named exception to the ADR-0002 composition invariant; it must not construct a registration.
|
||||
- Ingress pipeline (ADR-0002 phase 4): the request-audit record, the trace action enricher, the throttle key functions and the gateway request-context keys stay in `gateway` until the auth/IAM and request-context boundary is designed. SigV4 authentication writes the audit record and context keys from four places, so moving them now would export exactly the setters ADR-0002 says must not be exported solely to make a directory move possible.
|
||||
- NAT projection delivery is inconsistent and is preserved as found: EIP `AssociateAddress` sends `vpc.add-nat` unacknowledged (`AnnounceNATAdd`), skipping the 45s OVN barrier every other add path waits on, and `DeleteNetworkInterface` sends `vpc.delete-nat` unacknowledged (`AnnounceNATDelete`), skipping the ack and the ErrNoResponders retry. The reconciler's orphan sweep is the only backstop for both. Clears when those call sites use `AddNAT`/`AddNATBestEffort` and `RemoveNAT`, a reliability change of its own.
|
||||
- `vpc.add-system-egress` has a `vpcd` subscriber and a contract entry but no producer (its former `domains/ec2/eip` publisher is gone); `domains/eks/cluster` still publishes the delete. Either a producer is missing or the add route is dead; it needs an owner decision.
|
||||
|
||||
@@ -123,7 +123,7 @@ aws eks describe-cluster --name demo --query 'cluster.status'
|
||||
aws eks create-nodegroup \
|
||||
--cluster-name demo \
|
||||
--nodegroup-name default \
|
||||
--node-role-arn arn:aws:iam::000000000000:role/eks-node-role \
|
||||
--node-role arn:aws:iam::000000000000:role/eks-node-role \
|
||||
--subnets subnet-aaaa subnet-bbbb \
|
||||
--scaling-config minSize=1,maxSize=2,desiredSize=1 \
|
||||
--instance-types t3.medium
|
||||
|
||||
@@ -17,7 +17,7 @@ tags:
|
||||
|
||||
## Overview
|
||||
|
||||
The platform serves **392 operations** across the AWS APIs below. Every page names the operations its service implements from the pinned model, generated from the dispatch tables on each build rather than written by hand.
|
||||
The platform serves **405 operations** across the AWS APIs below. Every page names the operations its service implements from the pinned model, generated from the dispatch tables on each build rather than written by hand.
|
||||
|
||||
| Service | Operations |
|
||||
|---|---:|
|
||||
@@ -29,6 +29,7 @@ The platform serves **392 operations** across the AWS APIs below. Every page nam
|
||||
| [ELBv2](/coverage/elbv2) | 36 |
|
||||
| [IAM](/coverage/iam) | 81 |
|
||||
| [RDS](/coverage/rds) | 27 |
|
||||
| [Route 53](/coverage/route53) | 13 |
|
||||
| [S3](/coverage/s3) | 26 |
|
||||
| [STS](/coverage/sts) | 5 |
|
||||
| **Total** | **392** |
|
||||
| **Total** | **405** |
|
||||
|
||||
@@ -3483,6 +3483,180 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"service": "route53",
|
||||
"api_version": "2013-04-01",
|
||||
"opaque": false,
|
||||
"note": "",
|
||||
"operations": {
|
||||
"modelled": [
|
||||
"ActivateKeySigningKey",
|
||||
"AssociateVPCWithHostedZone",
|
||||
"ChangeCidrCollection",
|
||||
"ChangeResourceRecordSets",
|
||||
"ChangeTagsForResource",
|
||||
"CreateCidrCollection",
|
||||
"CreateHealthCheck",
|
||||
"CreateHostedZone",
|
||||
"CreateKeySigningKey",
|
||||
"CreateQueryLoggingConfig",
|
||||
"CreateReusableDelegationSet",
|
||||
"CreateTrafficPolicy",
|
||||
"CreateTrafficPolicyInstance",
|
||||
"CreateTrafficPolicyVersion",
|
||||
"CreateVPCAssociationAuthorization",
|
||||
"DeactivateKeySigningKey",
|
||||
"DeleteCidrCollection",
|
||||
"DeleteHealthCheck",
|
||||
"DeleteHostedZone",
|
||||
"DeleteKeySigningKey",
|
||||
"DeleteQueryLoggingConfig",
|
||||
"DeleteReusableDelegationSet",
|
||||
"DeleteTrafficPolicy",
|
||||
"DeleteTrafficPolicyInstance",
|
||||
"DeleteVPCAssociationAuthorization",
|
||||
"DisableHostedZoneDNSSEC",
|
||||
"DisassociateVPCFromHostedZone",
|
||||
"EnableHostedZoneDNSSEC",
|
||||
"GetAccountLimit",
|
||||
"GetChange",
|
||||
"GetCheckerIpRanges",
|
||||
"GetDNSSEC",
|
||||
"GetGeoLocation",
|
||||
"GetHealthCheck",
|
||||
"GetHealthCheckCount",
|
||||
"GetHealthCheckLastFailureReason",
|
||||
"GetHealthCheckStatus",
|
||||
"GetHostedZone",
|
||||
"GetHostedZoneCount",
|
||||
"GetHostedZoneLimit",
|
||||
"GetQueryLoggingConfig",
|
||||
"GetReusableDelegationSet",
|
||||
"GetReusableDelegationSetLimit",
|
||||
"GetTrafficPolicy",
|
||||
"GetTrafficPolicyInstance",
|
||||
"GetTrafficPolicyInstanceCount",
|
||||
"ListCidrBlocks",
|
||||
"ListCidrCollections",
|
||||
"ListCidrLocations",
|
||||
"ListGeoLocations",
|
||||
"ListHealthChecks",
|
||||
"ListHostedZones",
|
||||
"ListHostedZonesByName",
|
||||
"ListHostedZonesByVPC",
|
||||
"ListQueryLoggingConfigs",
|
||||
"ListResourceRecordSets",
|
||||
"ListReusableDelegationSets",
|
||||
"ListTagsForResource",
|
||||
"ListTagsForResources",
|
||||
"ListTrafficPolicies",
|
||||
"ListTrafficPolicyInstances",
|
||||
"ListTrafficPolicyInstancesByHostedZone",
|
||||
"ListTrafficPolicyInstancesByPolicy",
|
||||
"ListTrafficPolicyVersions",
|
||||
"ListVPCAssociationAuthorizations",
|
||||
"TestDNSAnswer",
|
||||
"UpdateHealthCheck",
|
||||
"UpdateHostedZoneComment",
|
||||
"UpdateHostedZoneFeatures",
|
||||
"UpdateTrafficPolicyComment",
|
||||
"UpdateTrafficPolicyInstance"
|
||||
],
|
||||
"registered": [
|
||||
"ChangeResourceRecordSets",
|
||||
"ChangeTagsForResource",
|
||||
"CreateHostedZone",
|
||||
"DeleteHostedZone",
|
||||
"GetChange",
|
||||
"GetHostedZone",
|
||||
"GetHostedZoneCount",
|
||||
"ListHostedZones",
|
||||
"ListHostedZonesByName",
|
||||
"ListResourceRecordSets",
|
||||
"ListTagsForResource",
|
||||
"ListTagsForResources",
|
||||
"UpdateHostedZoneComment"
|
||||
],
|
||||
"implemented": [
|
||||
"ChangeResourceRecordSets",
|
||||
"ChangeTagsForResource",
|
||||
"CreateHostedZone",
|
||||
"DeleteHostedZone",
|
||||
"GetChange",
|
||||
"GetHostedZone",
|
||||
"GetHostedZoneCount",
|
||||
"ListHostedZones",
|
||||
"ListHostedZonesByName",
|
||||
"ListResourceRecordSets",
|
||||
"ListTagsForResource",
|
||||
"ListTagsForResources",
|
||||
"UpdateHostedZoneComment"
|
||||
],
|
||||
"stubbed": [],
|
||||
"unsupported": [],
|
||||
"missing": [
|
||||
"ActivateKeySigningKey",
|
||||
"AssociateVPCWithHostedZone",
|
||||
"ChangeCidrCollection",
|
||||
"CreateCidrCollection",
|
||||
"CreateHealthCheck",
|
||||
"CreateKeySigningKey",
|
||||
"CreateQueryLoggingConfig",
|
||||
"CreateReusableDelegationSet",
|
||||
"CreateTrafficPolicy",
|
||||
"CreateTrafficPolicyInstance",
|
||||
"CreateTrafficPolicyVersion",
|
||||
"CreateVPCAssociationAuthorization",
|
||||
"DeactivateKeySigningKey",
|
||||
"DeleteCidrCollection",
|
||||
"DeleteHealthCheck",
|
||||
"DeleteKeySigningKey",
|
||||
"DeleteQueryLoggingConfig",
|
||||
"DeleteReusableDelegationSet",
|
||||
"DeleteTrafficPolicy",
|
||||
"DeleteTrafficPolicyInstance",
|
||||
"DeleteVPCAssociationAuthorization",
|
||||
"DisableHostedZoneDNSSEC",
|
||||
"DisassociateVPCFromHostedZone",
|
||||
"EnableHostedZoneDNSSEC",
|
||||
"GetAccountLimit",
|
||||
"GetCheckerIpRanges",
|
||||
"GetDNSSEC",
|
||||
"GetGeoLocation",
|
||||
"GetHealthCheck",
|
||||
"GetHealthCheckCount",
|
||||
"GetHealthCheckLastFailureReason",
|
||||
"GetHealthCheckStatus",
|
||||
"GetHostedZoneLimit",
|
||||
"GetQueryLoggingConfig",
|
||||
"GetReusableDelegationSet",
|
||||
"GetReusableDelegationSetLimit",
|
||||
"GetTrafficPolicy",
|
||||
"GetTrafficPolicyInstance",
|
||||
"GetTrafficPolicyInstanceCount",
|
||||
"ListCidrBlocks",
|
||||
"ListCidrCollections",
|
||||
"ListCidrLocations",
|
||||
"ListGeoLocations",
|
||||
"ListHealthChecks",
|
||||
"ListHostedZonesByVPC",
|
||||
"ListQueryLoggingConfigs",
|
||||
"ListReusableDelegationSets",
|
||||
"ListTrafficPolicies",
|
||||
"ListTrafficPolicyInstances",
|
||||
"ListTrafficPolicyInstancesByHostedZone",
|
||||
"ListTrafficPolicyInstancesByPolicy",
|
||||
"ListTrafficPolicyVersions",
|
||||
"ListVPCAssociationAuthorizations",
|
||||
"TestDNSAnswer",
|
||||
"UpdateHealthCheck",
|
||||
"UpdateHostedZoneFeatures",
|
||||
"UpdateTrafficPolicyComment",
|
||||
"UpdateTrafficPolicyInstance"
|
||||
],
|
||||
"extra": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"service": "s3",
|
||||
"api_version": "2006-03-01",
|
||||
|
||||
@@ -1243,6 +1243,71 @@ option-groups: Option groups configure engine add-ons for engines this platform
|
||||
- `DescribeOptionGroups`
|
||||
- `ModifyOptionGroup`
|
||||
|
||||
## route53
|
||||
|
||||
`2013-04-01` — 13 of 71 modelled operations implemented (18.3%), 0 stubbed, 0 not applicable, 0 registered outside the pinned model.
|
||||
|
||||
### Not implemented
|
||||
|
||||
- `ActivateKeySigningKey`
|
||||
- `AssociateVPCWithHostedZone`
|
||||
- `ChangeCidrCollection`
|
||||
- `CreateCidrCollection`
|
||||
- `CreateHealthCheck`
|
||||
- `CreateKeySigningKey`
|
||||
- `CreateQueryLoggingConfig`
|
||||
- `CreateReusableDelegationSet`
|
||||
- `CreateTrafficPolicy`
|
||||
- `CreateTrafficPolicyInstance`
|
||||
- `CreateTrafficPolicyVersion`
|
||||
- `CreateVPCAssociationAuthorization`
|
||||
- `DeactivateKeySigningKey`
|
||||
- `DeleteCidrCollection`
|
||||
- `DeleteHealthCheck`
|
||||
- `DeleteKeySigningKey`
|
||||
- `DeleteQueryLoggingConfig`
|
||||
- `DeleteReusableDelegationSet`
|
||||
- `DeleteTrafficPolicy`
|
||||
- `DeleteTrafficPolicyInstance`
|
||||
- `DeleteVPCAssociationAuthorization`
|
||||
- `DisableHostedZoneDNSSEC`
|
||||
- `DisassociateVPCFromHostedZone`
|
||||
- `EnableHostedZoneDNSSEC`
|
||||
- `GetAccountLimit`
|
||||
- `GetCheckerIpRanges`
|
||||
- `GetDNSSEC`
|
||||
- `GetGeoLocation`
|
||||
- `GetHealthCheck`
|
||||
- `GetHealthCheckCount`
|
||||
- `GetHealthCheckLastFailureReason`
|
||||
- `GetHealthCheckStatus`
|
||||
- `GetHostedZoneLimit`
|
||||
- `GetQueryLoggingConfig`
|
||||
- `GetReusableDelegationSet`
|
||||
- `GetReusableDelegationSetLimit`
|
||||
- `GetTrafficPolicy`
|
||||
- `GetTrafficPolicyInstance`
|
||||
- `GetTrafficPolicyInstanceCount`
|
||||
- `ListCidrBlocks`
|
||||
- `ListCidrCollections`
|
||||
- `ListCidrLocations`
|
||||
- `ListGeoLocations`
|
||||
- `ListHealthChecks`
|
||||
- `ListHostedZonesByVPC`
|
||||
- `ListQueryLoggingConfigs`
|
||||
- `ListReusableDelegationSets`
|
||||
- `ListTrafficPolicies`
|
||||
- `ListTrafficPolicyInstances`
|
||||
- `ListTrafficPolicyInstancesByHostedZone`
|
||||
- `ListTrafficPolicyInstancesByPolicy`
|
||||
- `ListTrafficPolicyVersions`
|
||||
- `ListVPCAssociationAuthorizations`
|
||||
- `TestDNSAnswer`
|
||||
- `UpdateHealthCheck`
|
||||
- `UpdateHostedZoneFeatures`
|
||||
- `UpdateTrafficPolicyComment`
|
||||
- `UpdateTrafficPolicyInstance`
|
||||
|
||||
## s3
|
||||
|
||||
`2006-03-01` — 26 of 112 modelled operations implemented (23.2%), 0 stubbed, 6 not applicable, 0 registered outside the pinned model.
|
||||
|
||||
@@ -247,6 +247,20 @@
|
||||
"option-groups": "Option groups configure engine add-ons for engines this platform does not offer, such as Oracle and SQL Server."
|
||||
}
|
||||
},
|
||||
"route53": {
|
||||
"slug": "route53",
|
||||
"name": "Route 53",
|
||||
"title": "Route 53 API Coverage",
|
||||
"seoTitle": "Amazon Route 53 API Coverage on Spinifex — Spinifex Docs",
|
||||
"description": "The Amazon Route 53 API operations Spinifex implements, covering the hosted zones it serves, their resource record sets, change status and tags on each zone.",
|
||||
"tags": [
|
||||
"aws",
|
||||
"compatibility",
|
||||
"coverage",
|
||||
"route53",
|
||||
"dns"
|
||||
]
|
||||
},
|
||||
"s3": {
|
||||
"slug": "s3",
|
||||
"name": "S3",
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
title: "Route 53 API Coverage"
|
||||
seoTitle: "Amazon Route 53 API Coverage on Spinifex — Spinifex Docs"
|
||||
description: "The Amazon Route 53 API operations Spinifex implements, covering the hosted zones it serves, their resource record sets, change status and tags on each zone."
|
||||
category: "Coverage"
|
||||
sections:
|
||||
- overview
|
||||
tags:
|
||||
- aws
|
||||
- compatibility
|
||||
- coverage
|
||||
- route53
|
||||
- dns
|
||||
---
|
||||
|
||||
# Route 53 API Coverage
|
||||
|
||||
## Overview
|
||||
|
||||
Spinifex implements **13 operations** in the Route 53 `2013-04-01` API model.
|
||||
|
||||
### Operations
|
||||
|
||||
| Operation |
|
||||
|---|
|
||||
| `ChangeResourceRecordSets` |
|
||||
| `ChangeTagsForResource` |
|
||||
| `CreateHostedZone` |
|
||||
| `DeleteHostedZone` |
|
||||
| `GetChange` |
|
||||
| `GetHostedZone` |
|
||||
| `GetHostedZoneCount` |
|
||||
| `ListHostedZones` |
|
||||
| `ListHostedZonesByName` |
|
||||
| `ListResourceRecordSets` |
|
||||
| `ListTagsForResource` |
|
||||
| `ListTagsForResources` |
|
||||
| `UpdateHostedZoneComment` |
|
||||
@@ -50,6 +50,14 @@ Three nightly cells run it: `nat-single` (cell 19), its Oracle Linux twin (cell
|
||||
|
||||
**`TestMultinodeDNS` convergence budgets are env-tunable.** `SPINIFEX_MULTINODE_DNS_TOPOLOGY_TIMEOUT` (default `90s`) bounds every node agreeing on the NS topology, `SPINIFEX_MULTINODE_DNS_TIMEOUT` (default `2m`) bounds a record becoming answerable from a guest or a node, and `SPINIFEX_MULTINODE_DNS_POLL` (default `3s`) is the poll interval for both. Values are Go durations; a malformed or non-positive value panics rather than falling back to the default.
|
||||
|
||||
**`TestMultinodeIPSec` proves guest traffic is encrypted on the wire, not only that IPsec is configured.**
|
||||
It first requires every node to carry the OVS certificate, key and CA pointers, `NB_Global.ipsec=true` never asserted over an unconfigured chassis, and an AES-GCM SA in `ip xfrm state`.
|
||||
None of those fails when SAs exist but traffic bypasses them, so it then launches two instances on distinct nodes and sends 200 pings of 1000 bytes between them.
|
||||
For each direction of that pair it fails unless the outbound SAs to the peer's `ovn-encap-ip` carried at least the payload sent, an outbound-only capture on the interface routing to the peer saw ESP (raw or UDP/4500-encapsulated), and that capture saw no plaintext Geneve (UDP/6081).
|
||||
The capture is outbound-only because inbound ESP is decrypted and seen again as Geneve on the same interface, which would read as plaintext on a healthy node.
|
||||
It proves the node pair the guests landed on; the SA check covers every node.
|
||||
It skips when the node config sets `network.ipsec_enabled=false`, or when the scheduler places all four launches on one node.
|
||||
|
||||
### `kvquorum` — the cluster's own state survives losing a node (multi-node, needs three)
|
||||
|
||||
**The control plane keeps its state in JetStream KV buckets, and this is the suite that proves those buckets are actually replicated.** A bucket on one replica lives on one JetStream-chosen server recorded in no config, so losing that node makes it return `nats: no responders available for request` from *every* node at once — a cluster-wide outage caused by a single-node event. Leader leases are the worst case: acquiring one is a write, so a lease bucket without a quorum cannot be acquired by anyone, and every reconciler sharing it stops cluster-wide at exactly the moment a node has failed and there is repair work to do.
|
||||
|
||||
@@ -1,254 +0,0 @@
|
||||
# Spinifex UI design system
|
||||
|
||||
The console's tokens, primitives and the rules that hold pages to them.
|
||||
The live catalogue is the `/design` route: every token and primitive below rendered in both themes.
|
||||
It is open to admins in production and to anyone under `vite dev`.
|
||||
|
||||
Source: `spinifex/runtime/roles/spinifexui/frontend/`.
|
||||
Tokens are in `src/styles.css`, primitives in `src/components/ui/`, and the catalogue in `src/routes/_auth/-components/design/`.
|
||||
|
||||
## Principles
|
||||
|
||||
- **Pages compose primitives; they do not restyle them.** If a page needs something the system lacks, add it to the system first.
|
||||
- **Colour is for status.** Surfaces are neutral, and brand orange marks the one primary action on a view.
|
||||
- **Borders, not shadows.** Overlays (dialogs, sheets, popovers, menus) are the only elevated surfaces.
|
||||
- **AWS semantics, our presentation.** States, field names and actions follow AWS.
|
||||
|
||||
## Tokens
|
||||
|
||||
All colours are OKLCH and come from mulgadc.com, so product and site match.
|
||||
|
||||
### Surfaces
|
||||
|
||||
| Token | Use |
|
||||
|---|---|
|
||||
| `background` | The app page. Warm off-white in light, ink `#14181D` in dark. |
|
||||
| `card`, `popover` | Raised surfaces. White in light, about `#1B2027` in dark. |
|
||||
| `muted`, `secondary`, `accent` | Hover rows, secondary buttons, quiet fills. |
|
||||
| `border` | Every divider and card edge. `#DCD8CE` in light, `oklch(1 0 0 / 8%)` in dark. |
|
||||
| `brand` | Brand orange `#EE4B12`, for the logo and decoration only. It fails text contrast, so it never carries text. |
|
||||
| `primary` | Darker orange in light, brighter orange in dark: primary button, link, focus ring, active tab. |
|
||||
| `sidebar` | The navigation column. |
|
||||
| `console`, `console-foreground` | A serial console log. Light grey on the light theme and near-black on the dark one, so the log follows the theme. |
|
||||
| `console-stamp`, `console-muted`, `console-ok`, `console-fail` | The log's tones: the kernel timestamp (brand yellow in dark, a darker amber in light), the subsystem or device that logged the line, and systemd's `[ OK ]` and `[FAILED]` markers. |
|
||||
|
||||
### Status
|
||||
|
||||
Each status has a solid token plus `-foreground` (text on a tinted background), `-subtle` (tinted background) and `-border`.
|
||||
|
||||
| Status | Hue | AWS states |
|
||||
|---|---|---|
|
||||
| `success` | green | running, available, completed, healthy |
|
||||
| `warning` | brand yellow `#F5B500` | pending, stopping, creating, modifying, deleting, draining |
|
||||
| `danger` | cool red, hue 17 | stopped, error, failed, unhealthy, unavailable |
|
||||
| `info` | blue | informational notes |
|
||||
| `neutral` | grey `#9AA1A8` | terminated (muted text), and any unmapped state |
|
||||
|
||||
`stopped` is danger because EC2 reads it that way, although RDS treats it as a resting state; splitting the map by service is its own change.
|
||||
|
||||
`StateBadge` (`src/components/state-badge.tsx`) is the only mapping from an AWS state to a tone, and it exposes the result as `data-tone` for tests.
|
||||
Every status badge measures above 4.8:1 against its tinted background in both themes.
|
||||
|
||||
**Danger is separated from the primary accent by hue alone in dark mode**: the two have the same lightness.
|
||||
So a destructive action is never a solid danger fill, and a danger state always carries a text label or an icon.
|
||||
|
||||
### Chart palette
|
||||
|
||||
`--chart-1` to `--chart-8` are categorical, with lightness spread so adjacent series separate under protanopia, deuteranopia and tritanopia.
|
||||
Measured with a simulated CVD ΔE, the worst pair is 7.4 in light and 7.2 in dark.
|
||||
Colour is never the only key: label every series.
|
||||
|
||||
### Typography
|
||||
|
||||
| Role | Font |
|
||||
|---|---|
|
||||
| Page titles and section headings (`h1`–`h3`) | Archivo (`font-heading`) |
|
||||
| UI text, tables and forms | Geist (`font-sans`) |
|
||||
| IDs, ARNs, IPs, CIDRs, code and eyebrow labels | JetBrains Mono (`font-mono`), with `tabular-nums` for figures |
|
||||
|
||||
| Step | Size | Use |
|
||||
|---|---|---|
|
||||
| `2xl` | 24px | page title (`PageHeading`) |
|
||||
| `xl` | 20px | section heading |
|
||||
| `lg` | 16px | |
|
||||
| `base` | 14px | |
|
||||
| `sm` | 13px | body and table default |
|
||||
| `xs` | 12px | badges, secondary text |
|
||||
| `2xs` | 11px | mono eyebrow labels |
|
||||
|
||||
### Primary and contrast
|
||||
|
||||
The brand orange cannot be the primary: white on `#EE4B12` is 3.72:1, and as link text on a light card it is also 3.72:1, against WCAG AA's 4.5:1.
|
||||
So `primary` is its own token, and `brand` keeps the exact site colour for decoration.
|
||||
|
||||
| Theme | `primary` | Fill text | Fill contrast | As a link |
|
||||
|---|---|---|---|---|
|
||||
| light | `oklch(55% 0.19 36.6)` | white | 5.30:1 | 5.30:1 on a card, 4.69:1 on the page |
|
||||
| dark | `oklch(68% 0.18 38)` | ink | 5.70:1 | 5.27:1 on a card |
|
||||
|
||||
Dark mode puts ink on the fill rather than white, because no orange is both bright enough to read as a link on ink and dark enough to carry white text.
|
||||
|
||||
### Radius
|
||||
|
||||
`--radius` is 0.5rem; `rounded-md` is that, `rounded-sm` and `rounded-lg` step 2px either side, and status pills are `rounded-full`.
|
||||
|
||||
### Themes
|
||||
|
||||
The console defaults to dark, and the header switch toggles light and dark (`localStorage["spinifex-ui-theme"]`).
|
||||
`public/preboot.js` runs before the bundle. It applies the stored theme and `color-scheme` before first paint, so a dark-mode user never sees a white page while the bundle loads, and it turns off zod's eval probe (below).
|
||||
It is an external file because the console's CSP forbids inline scripts.
|
||||
|
||||
## Constraints from the console's CSP
|
||||
|
||||
The Go console serves `script-src 'self'; style-src 'self'`.
|
||||
|
||||
- **No inline `<script>` or `<style>` elements.** A library that injects either breaks in production while working under `vite dev`, which applies no CSP. This is why `sonner` is not used.
|
||||
- **No runtime CSS variables set through `style`.** Primitives use fixed class maps instead, as `ToggleGroup` does for `spacing`.
|
||||
- **Base UI's `ScrollArea` injects a `<style>` element** to hide native scrollbars. `main.tsx` wraps the app in `CSPProvider disableStyleElements`, and `styles.css` carries the rule instead (`.base-ui-disable-scrollbar`).
|
||||
- **zod compiles validators with `new Function`** unless it is told not to, and it probes for that at module load, before any app code runs. `public/preboot.js` sets `globalThis.__zod_globalConfig = { jitless: true }` first.
|
||||
- **Chrome reports a violation whenever `DOMParser` meets a body that is not XML**, because the `<parsererror>` element it builds carries an inline style. The AWS SDK parses every query-protocol error body, so a proxy that answers with plain text or HTML produces a console violation as well as the error. Harmless, but it is noise in exactly the place someone debugging a failed call will look.
|
||||
- Base UI overlays animate through `data-starting-style` and `data-ending-style` transitions, not keyframe animation libraries, and every transition has `motion-reduce:transition-none`.
|
||||
|
||||
## Primitives
|
||||
|
||||
`src/components/ui/` holds the shadcn primitives on Base UI.
|
||||
Added in Phase 1: breadcrumb, checkbox, command (`cmdk`), dialog, hover-card, popover, resizable (`react-resizable-panels`), scroll-area, slider, switch, table, toggle and toggle-group.
|
||||
`button-group` joins buttons edge to edge, as the instance power buttons are.
|
||||
|
||||
A disabled control stays visible and shows `cursor: not-allowed`, so it says why it does nothing rather than vanishing.
|
||||
An on/off setting is a `Switch`, orange when on; a `Checkbox` only picks items from a list, such as table rows, subnets or security groups.
|
||||
|
||||
`InfoTip` (`src/components/info-tip.tsx`) is UniFi's (i): a small button beside a label that opens a short explanation on hover or click, with `InfoList` for the facts inside it.
|
||||
`PanelField` takes it through `info`, so the login user names the image's distribution and the image names its AMI details.
|
||||
|
||||
Base UI marks orientation with `data-orientation`, so `styles.css` defines the `data-horizontal` and `data-vertical` variants the shadcn sources expect.
|
||||
|
||||
Base UI marks the selected tab with `data-active`, not `data-selected`, so `ui/tabs` styles that attribute.
|
||||
|
||||
shadcn's `chart` is not used: it pulls `recharts`, whose `reselect@5.1.1` dependency fails the workspace's `trustPolicy: no-downgrade`.
|
||||
Charts are uPlot instead (see Charts).
|
||||
|
||||
## Enforcement
|
||||
|
||||
Four lint layers keep pages on the system.
|
||||
|
||||
| Rule | Where | What it catches |
|
||||
|---|---|---|
|
||||
| `shadcn/no-unknown-classes` | all source, not tests | a class Tailwind does not generate, such as a typo or a removed token |
|
||||
| `shadcn/no-restyle` | all source | a page passing layout or colour classes to a primitive |
|
||||
| `eslint/no-restricted-imports` | `src/routes/**` | a route importing a chart library directly |
|
||||
| `src/routes/route-files.test.ts` | test suite | a new hand-written `<table>` in a route |
|
||||
|
||||
The table test is a ratchet: `HAND_WRITTEN_TABLES` lists the routes that still have one, it fails when a new file adds one, and it fails when a listed file no longer has one so the list only shrinks.
|
||||
|
||||
## Shell
|
||||
|
||||
`src/lib/nav.ts` is the one description of the console's navigation.
|
||||
The sidebar, the command palette and the breadcrumbs all read it, and `nav.test.ts` checks every destination against the generated route tree, so a renamed route fails a test rather than leaving a dead link.
|
||||
|
||||
- **Sidebar.** Each service group folds, and a folded group stays folded across reloads (`localStorage["spinifex-ui-nav-collapsed"]`). The group holding the current page is always open, so the active item is always visible. On a phone the sidebar is a sheet, and any navigation closes it.
|
||||
- **Command palette.** ⌘K or Ctrl K, or the Search button in the header. It lists every page the user can reach, every create flow (`create` on a nav item), and a theme toggle. It is navigation only, with no resource search yet.
|
||||
- **Breadcrumbs.** Service, then list page, then the resource or the create flow. A resource ID is set in mono. On a phone only the last crumb shows.
|
||||
|
||||
## Layout
|
||||
|
||||
The app shell's `<main>` is `min-w-0`, so a wide table scrolls inside its card instead of widening the page.
|
||||
The page gutter is 16px on a phone, 24px from `md` and 32px from `xl`.
|
||||
`PageHeading` wraps its actions below the title when they do not fit beside it.
|
||||
Every page must fit at 390px with `document.documentElement.scrollWidth` equal to the window width.
|
||||
|
||||
## Data tables
|
||||
|
||||
`DataTable` (`src/components/data-table.tsx`) renders `@tanstack/react-table` over the `ui/table` primitives, so sorting, row selection and shift-click range selection are the library's and not ours to maintain.
|
||||
Pages describe columns with the small `DataColumn` shape (`id`, `header`, `cell`, `sortValue`, `hideBelow`, `align`), which the component maps to TanStack column definitions; a column with a `sortValue` sorts, with missing values last and numbers compared as numbers.
|
||||
Sort and selection stay controlled by the caller, so a page can keep them in the URL.
|
||||
|
||||
A column hides itself through `hideBelow`, which is a **container** query on the table's card (`@max-lg`, `@max-xl`, `@max-4xl`, `@max-5xl`), not a viewport one.
|
||||
So columns drop out when a side panel narrows the list as well as on a small screen.
|
||||
|
||||
List pages keep their filter, state and sort in the URL, so a filtered view can be bookmarked, shared and survives a reload.
|
||||
EC2 Instances is the reference page (`ec2/(instances)/-components/instances-page.tsx`):
|
||||
|
||||
- The first column names the resource: the `Name` tag in the primary colour, with the ID in mono beneath it, opening its side panel.
|
||||
- Bulk actions appear above the table once a row is selected. Each action counts only the selected instances it applies to, so a mixed selection never sends a call that must fail, and Terminate asks first and lists every ID.
|
||||
- An empty account gets a call to action, and an empty filter result says the filters are why.
|
||||
|
||||
VPCs and Internet gateways follow the same pattern.
|
||||
|
||||
### Selecting rows to delete
|
||||
|
||||
Every list with a delete action has row checkboxes. Selecting a row shows `SelectionBar` (`src/components/selection-bar.tsx`) above the table, with the count, the actions and a way to clear, and `useRowSelection` (`src/hooks/use-row-selection.ts`) keeps the selection to rows still in the list.
|
||||
`BulkDelete` (`src/components/bulk-delete.tsx`) is the bar's destructive action, and its dialog names every selected resource before anything is sent.
|
||||
|
||||
- **One call per resource, one at a time.** Most AWS delete calls take a single ID, so each refusal lands against its own row with AWS's message. A partial failure keeps the dialog open, and its confirm becomes "Retry N" for only the ones that failed.
|
||||
- **No pre-filtering.** The bar sends what was selected and lets AWS refuse, rather than guessing which deletes would fail. Instances are the exception, since one call takes many IDs and a mixed selection would fail as a whole.
|
||||
- **The bulk delete and the panel's delete are the same function.** Where a single delete is several calls, such as removing dependants first, the sequence lives in one place and both use it.
|
||||
- The verb follows the AWS console: Release for an Elastic IP, Deregister for an image.
|
||||
|
||||
Key pairs is the smallest example (`ec2/(key)/-components/key-pairs-page.tsx`).
|
||||
|
||||
### Instance launch
|
||||
|
||||
The image picker shows each distribution's own mark from `src/assets/os/` (Ubuntu, Debian, Rocky, Alpine, CentOS), imported with `?no-inline` so it is served as a file and satisfies `img-src 'self'`. Any other distribution gets a generic server icon, and the "Other images" tile a disc.
|
||||
The instance type select states the chosen type's vCPUs and memory beneath it, from `DescribeInstanceTypes`.
|
||||
|
||||
## Side panels
|
||||
|
||||
Modelled on UniFi's console: a click on a row opens the resource in a panel beside the list, and a create flow opens there too.
|
||||
|
||||
- **URL.** The panel is search state on the list route: `selected=<id>`, `tab=overview|monitoring|settings`, and `create=true`. Defaults are stripped, so a closed panel leaves the plain list address. Opening or closing a panel pushes a history entry, so Back closes it; a tab or filter change replaces the entry.
|
||||
- **Layout.** From `lg` (1024px) the panel is fixed to the right edge, full height below the header, flush with no outer margin, and it slides in over the list. The list keeps its width and columns underneath, so nothing reflows when a panel opens, and a soft gradient on the panel's left edge reads as the panel lying on top. Below `lg` there is no room to share, so the panel replaces the list as a full page with a back arrow. `useWideLayout` decides, through `matchMedia`.
|
||||
- **`DetailPanel`.** A header with the resource's own page (left) and close (right), the title and its ID, a summary of state and quick actions, then icon tabs with tooltips. Escape closes it unless a dialog or menu is open over it. Closing slides the panel out over 150ms, or at once under reduced motion. Each tab, and a create form, sits in its own error boundary, so a failed query shows in the panel and the list keeps working.
|
||||
- **Contents.** `PanelSection` is a folding card, `PanelFields` and `PanelField` a label and value list, with `copy` for a one-click copy of IDs and addresses, and `PanelTags` the tag list. Create forms take an `onClose(id?)` prop: embedded, they hide their back link and heading, and a create selects the new resource.
|
||||
- **One component is both the panel and the page.** Each resource has a single details component (`InstanceDetails`, `VpcDetails`, `InternetGatewayDetails`) that takes `layout="auto" | "page"`. The list renders it as the panel; `/ec2/describe-instances/$id` and its kind render the same component as a page, as one centred column under a standard left-aligned heading, with state and actions on the right and text tabs. A feature added to the panel is on the page by construction, and the page ignores Escape and has a back arrow in place of close. Expanding the panel to its page keeps the tab, and query results such as fetched console output come along from the cache.
|
||||
- **A click on the list's background closes the panel**, as UniFi's does. A row selects another resource instead, and links, buttons and inputs keep their own behaviour. Selecting rows for a bulk action closes it too, so the bulk bar and the panel never compete.
|
||||
- **Actions.** State actions sit in the summary as a button group, and the rest go in an Actions menu, Unikraft style. Stop, Reboot and every destructive action confirm in a dialog.
|
||||
- **Loading.** A panel or page still loading shows `PanelSpinner`, a centred spinner on the background, not a grey skeleton block.
|
||||
- **`DetailCard`, `DetailRow` and `ListCard` are retired.** Every resource page uses the panel sections instead.
|
||||
|
||||
## Charts
|
||||
|
||||
`MetricChart` (`src/components/charts/metric-chart.tsx`) draws a time series with uPlot, chosen for size and because it draws on a canvas, which the CSP allows where inline style attributes are not; `csp.e2e.ts` renders one under the production header.
|
||||
The look follows UniFi's statistics: smoothed lines over a fading fill, a light grid, a legend chip per series that reads the value under the cursor (or the latest), and `-24 hrs … Now` beneath.
|
||||
Series colours are the chart palette, and the chart redraws when the theme changes.
|
||||
|
||||
Instance monitoring shows EC2's agentless basic metrics live: CPU utilisation, network bytes in and out, and packets in and out, one sample a second over the last five minutes, with no history to range over.
|
||||
The chart is built once and fed with `setData`, so a second's tick does not rebuild it, and polling stops while the tab is hidden.
|
||||
**The data is simulated** (`src/lib/simulated-metrics.ts`, seeded by instance ID and second) and the panel says so with a badge, until awsgw serves `GetMetricData` at a one-second period.
|
||||
|
||||
## Dates
|
||||
|
||||
`formatDateTime` writes the AWS console's form in the viewer's zone, `2026/06/04 02:00 GMT-5`, with a 24-hour clock and UTC as plain `GMT`.
|
||||
|
||||
## Testing
|
||||
|
||||
`make test-ui-e2e` rebuilds `dist` and runs `pnpm e2e`, which drives Playwright against two servers (`playwright.config.ts`):
|
||||
|
||||
- **dev** (Vite on 3101) for behaviour: the `/design` catalogue in both themes and four widths, theme bootstrap, primitives' keyboard behaviour, the shell, and the Instances, VPC and Internet gateway list and panel flows.
|
||||
- **preview** (the built `dist` on 3102) for `csp.e2e.ts`, which serves the console's own CSP header and fails on any violation. Vite dev applies no CSP, so this is the only place an inline style or `eval` is caught before production.
|
||||
|
||||
`e2e/console.ts` is the fixture. It seeds a fake session and answers `/proxy/**` by action, with empty defaults for what the dashboard and shell ask for; a test adds handlers for its own data, and any call nobody answered fails the test through `api.unhandled`.
|
||||
Unhandled calls get an error in the protocol's own shape, since a plain-text body would itself trip the CSP test.
|
||||
|
||||
### Screenshots
|
||||
|
||||
Screenshot baselines live in `e2e/__screenshots__/` and are Chromium renders taken in `en-AU` and UTC, so dates do not move with the host.
|
||||
They are drawn inside Playwright's own container image, because font rasterisation differs between hosts and a baseline drawn on a workstation fails on the runner.
|
||||
`playwright.image` in the frontend directory pins that image by version and digest, and is the one place it is named.
|
||||
Its version must equal the lockfile's `@playwright/test`, since the image carries the browsers for exactly one release, so bump both together.
|
||||
|
||||
- `make ui-baselines` redraws every baseline in the pinned image, with `pnpm e2e --update-snapshots=all`, since the default mode keeps any baseline within the diff tolerance and so would keep one drawn on another host. Commit the changed files under `e2e/__screenshots__/` with the change that moved them.
|
||||
- `make ui-e2e-container` runs the suite in the same image and compares the screenshots, exactly as CI does.
|
||||
|
||||
Both run `scripts/ui-playwright.sh`, which uses podman, or docker where podman is absent, and `CONTAINER_RUNTIME` overrides the choice.
|
||||
It mounts the frontend directory, installs with `pnpm install --frozen-lockfile` into a separate `node_modules` under `~/.cache/spinifex-ui-playwright`, so the host's `node_modules` is never touched, then builds `dist` and runs the suite.
|
||||
It refuses to run when the image's Node differs from `.nvmrc` or its Playwright differs from the lockfile's.
|
||||
`make test-ui-e2e` and `pnpm e2e` still run on the host, where screenshots compare reliably only against baselines drawn on that host, so do not commit baselines from `pnpm e2e:update`.
|
||||
|
||||
The `UI End-to-End` job in `.github/workflows/ui.yml` runs the suite inside the pinned image on every pull request touching the frontend, and screenshots gate the merge with every behavioural and CSP test.
|
||||
A preceding `UI End-to-End Image` job reads `playwright.image`, so the workflow and the `make` targets cannot disagree about the image.
|
||||
On a failure the job uploads `test-results/` as the `ui-e2e-test-results` artifact, holding each failed screenshot's expected, actual and diff images and the trace.
|
||||
|
||||
## Open
|
||||
|
||||
- **Monitoring data is simulated.** Real samples need a source in the daemon; the plan is `docs/development/feature/ec2-basic-metrics.md` in the mulga repository.
|
||||
@@ -17,6 +17,7 @@ var modelFiles = map[Service]string{
|
||||
ElasticLoadBalancingV2: "models/elastic-load-balancing-v2-2015-12-01.json.gz",
|
||||
IAM: "models/iam-2010-05-08.json.gz",
|
||||
RDS: "models/rds-2014-10-31.json.gz",
|
||||
Route53: "models/route-53-2013-04-01.json.gz",
|
||||
S3: "models/s3-2006-03-01.json.gz",
|
||||
STS: "models/sts-2011-06-15.json.gz",
|
||||
}
|
||||
|
||||
+247
-30
@@ -1,8 +1,10 @@
|
||||
package awsmodel
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/http"
|
||||
@@ -39,7 +41,7 @@ func (r *EncodedRequest) URL(endpoint string) string {
|
||||
}
|
||||
|
||||
// EncodeRequest serializes input, as produced by GenerateRequests, for the
|
||||
// service's protocol. S3's rest-xml is not supported.
|
||||
// service's protocol: query, ec2, json, rest-json or rest-xml.
|
||||
func EncodeRequest(service Service, operationName string, input map[string]any) (*EncodedRequest, error) {
|
||||
model, err := Load(service)
|
||||
if err != nil {
|
||||
@@ -57,6 +59,8 @@ func EncodeRequest(service Service, operationName string, input map[string]any)
|
||||
return encoder.json(operation, input)
|
||||
case "rest-json":
|
||||
return encoder.restJSON(operation, input)
|
||||
case "rest-xml":
|
||||
return encoder.restXML(operation, input)
|
||||
default:
|
||||
return nil, fmt.Errorf("awsmodel: encoding %s requests is not implemented", model.metadata.Protocol)
|
||||
}
|
||||
@@ -282,18 +286,50 @@ func (e requestEncoder) jsonValue(ref ShapeRef, value any) (any, error) {
|
||||
}
|
||||
|
||||
func (e requestEncoder) restJSON(operation *Operation, input map[string]any) (*EncodedRequest, error) {
|
||||
request, members, err := e.restBindings(operation, input)
|
||||
if err != nil || len(members) == 0 {
|
||||
return request, err
|
||||
}
|
||||
shape := e.model.shapes[operation.Input.Shape]
|
||||
var document any
|
||||
if payload, ok := members[shape.Payload]; ok && shape.Payload != "" {
|
||||
if document, err = e.jsonValue(shape.Members[shape.Payload], payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
body := make(map[string]any, len(members))
|
||||
for member, value := range members {
|
||||
ref := shape.Members[member]
|
||||
encoded, err := e.jsonValue(ref, value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body[elementName(member, ref)] = encoded
|
||||
}
|
||||
document = body
|
||||
}
|
||||
if request.Body, err = json.Marshal(document); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
return request, nil
|
||||
}
|
||||
|
||||
// restBindings places the URI, query string and header members of input on a
|
||||
// new request, and returns the members left for the body.
|
||||
func (e requestEncoder) restBindings(operation *Operation, input map[string]any) (*EncodedRequest, map[string]any, error) {
|
||||
request := &EncodedRequest{Method: operation.HTTP.Method, Query: url.Values{}, Header: http.Header{}}
|
||||
path := operation.HTTP.RequestURI
|
||||
if operation.Input == nil {
|
||||
request.Path = path
|
||||
return request, nil
|
||||
return request, nil, nil
|
||||
}
|
||||
shape := e.model.shapes[operation.Input.Shape]
|
||||
body := map[string]any{}
|
||||
for _, member := range slices.Sorted(maps.Keys(input)) {
|
||||
ref, ok := shape.Members[member]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s has no member %q", operation.Input.Shape, member)
|
||||
return nil, nil, fmt.Errorf("%s has no member %q", operation.Input.Shape, member)
|
||||
}
|
||||
value := input[member]
|
||||
target := e.model.shapes[ref.Shape]
|
||||
@@ -301,18 +337,18 @@ func (e requestEncoder) restJSON(operation *Operation, input map[string]any) (*E
|
||||
case "uri":
|
||||
text, err := scalarString(value, ref, target, "iso8601")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", member, err)
|
||||
return nil, nil, fmt.Errorf("%s: %w", member, err)
|
||||
}
|
||||
path = strings.Replace(path, "{"+member+"+}", escapeGreedyLabel(text), 1)
|
||||
path = strings.Replace(path, "{"+member+"}", url.PathEscape(text), 1)
|
||||
case "querystring":
|
||||
if err := e.restQuery(request.Query, ref, target, value); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", member, err)
|
||||
return nil, nil, fmt.Errorf("%s: %w", member, err)
|
||||
}
|
||||
case "header":
|
||||
text, err := e.headerValue(ref, target, value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", member, err)
|
||||
return nil, nil, fmt.Errorf("%s: %w", member, err)
|
||||
}
|
||||
request.Header.Set(ref.LocationName, text)
|
||||
case "headers":
|
||||
@@ -321,41 +357,222 @@ func (e requestEncoder) restJSON(operation *Operation, input map[string]any) (*E
|
||||
request.Header.Set(ref.LocationName+key, fmt.Sprint(entry))
|
||||
}
|
||||
default:
|
||||
encoded, err := e.jsonValue(ref, value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
name := member
|
||||
if ref.LocationName != "" {
|
||||
name = ref.LocationName
|
||||
}
|
||||
body[name] = encoded
|
||||
body[member] = value
|
||||
}
|
||||
}
|
||||
request.Path = path
|
||||
if payload := shape.Payload; payload != "" {
|
||||
if document, ok := body[payload]; ok {
|
||||
body = nil
|
||||
encoded, err := json.Marshal(document)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
request.Body = encoded
|
||||
}
|
||||
return request, body, nil
|
||||
}
|
||||
|
||||
// restXML encodes the body as AWS SDKs do: a root element named for the input
|
||||
// shape, or for the payload member, in the service's namespace. An input with
|
||||
// body members sends its root element even when none of them is set.
|
||||
func (e requestEncoder) restXML(operation *Operation, input map[string]any) (*EncodedRequest, error) {
|
||||
request, members, err := e.restBindings(operation, input)
|
||||
if err != nil || operation.Input == nil {
|
||||
return request, err
|
||||
}
|
||||
if len(body) > 0 {
|
||||
encoded, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
shape := e.model.shapes[operation.Input.Shape]
|
||||
var root *xmlElement
|
||||
switch {
|
||||
case shape.Payload != "":
|
||||
value, ok := members[shape.Payload]
|
||||
if !ok {
|
||||
return request, nil
|
||||
}
|
||||
ref := shape.Members[shape.Payload]
|
||||
target := e.model.shapes[ref.Shape]
|
||||
if target.Type != "structure" {
|
||||
switch value := value.(type) {
|
||||
case []byte:
|
||||
request.Body = value
|
||||
case string:
|
||||
request.Body = []byte(value)
|
||||
default:
|
||||
return nil, fmt.Errorf("%s: want a blob or string payload, got %T", shape.Payload, value)
|
||||
}
|
||||
return request, nil
|
||||
}
|
||||
name := ref.LocationName
|
||||
if name == "" {
|
||||
name = elementName(ref.Shape, ShapeRef{LocationName: target.LocationName})
|
||||
}
|
||||
if root, err = e.xmlRoot(name, ref, value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
request.Body = encoded
|
||||
case hasBodyMembers(shape):
|
||||
name := elementName(operation.Input.Shape, ShapeRef{LocationName: shape.LocationName})
|
||||
if root, err = e.xmlRoot(name, *operation.Input, members); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
default:
|
||||
return request, nil
|
||||
}
|
||||
if request.Body != nil {
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
var body bytes.Buffer
|
||||
encoder := xml.NewEncoder(&body)
|
||||
if err := root.encode(encoder); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := encoder.Flush(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
request.Body = body.Bytes()
|
||||
request.Header.Set("Content-Type", "application/xml")
|
||||
return request, nil
|
||||
}
|
||||
|
||||
func hasBodyMembers(shape *Shape) bool {
|
||||
for _, ref := range shape.Members {
|
||||
if ref.Location == "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// xmlRoot is the structure value encoded under name, in its shape's namespace
|
||||
// or else the service's.
|
||||
func (e requestEncoder) xmlRoot(name string, ref ShapeRef, value any) (*xmlElement, error) {
|
||||
holder := &xmlElement{}
|
||||
if err := e.xmlValue(holder, name, ref, value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
root := holder.children[0]
|
||||
if namespace := e.model.metadata.XMLNamespace; namespace.URI != "" && e.model.shapes[ref.Shape].XMLNamespace.URI == "" {
|
||||
root.attrs = append([]xml.Attr{namespaceAttr(namespace)}, root.attrs...)
|
||||
}
|
||||
return root, nil
|
||||
}
|
||||
|
||||
func (e requestEncoder) xmlStructure(parent *xmlElement, shapeName string, fields map[string]any) error {
|
||||
shape := e.model.shapes[shapeName]
|
||||
for _, member := range slices.Sorted(maps.Keys(fields)) {
|
||||
ref, ok := shape.Members[member]
|
||||
if !ok {
|
||||
return fmt.Errorf("%s has no member %q", shapeName, member)
|
||||
}
|
||||
name := elementName(member, ref)
|
||||
if ref.XMLAttribute {
|
||||
text, err := scalarString(fields[member], ref, e.model.shapes[ref.Shape], "iso8601")
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
parent.attrs = append(parent.attrs, xml.Attr{Name: xml.Name{Local: name}, Value: text})
|
||||
continue
|
||||
}
|
||||
if err := e.xmlValue(parent, name, ref, fields[member]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// xmlValue appends the elements value is encoded as to parent. A list or map
|
||||
// is wrapped in an element named name unless it is flattened.
|
||||
func (e requestEncoder) xmlValue(parent *xmlElement, name string, ref ShapeRef, value any) error {
|
||||
shape := e.model.shapes[ref.Shape]
|
||||
switch shape.Type {
|
||||
case "structure":
|
||||
fields, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("%s: want a structure, got %T", name, value)
|
||||
}
|
||||
element := parent.add(name)
|
||||
if shape.XMLNamespace.URI != "" {
|
||||
element.attrs = append(element.attrs, namespaceAttr(shape.XMLNamespace))
|
||||
}
|
||||
return e.xmlStructure(element, ref.Shape, fields)
|
||||
case "list":
|
||||
items, ok := value.([]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("%s: want a list, got %T", name, value)
|
||||
}
|
||||
container, itemName := parent, name
|
||||
if !ref.Flattened {
|
||||
container, itemName = parent.add(name), elementName("member", *shape.Member)
|
||||
}
|
||||
for _, item := range items {
|
||||
if err := e.xmlValue(container, itemName, *shape.Member, item); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
case "map":
|
||||
entries, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("%s: want a map, got %T", name, value)
|
||||
}
|
||||
container, entryName := parent, name
|
||||
if !ref.Flattened {
|
||||
container, entryName = parent.add(name), "entry"
|
||||
}
|
||||
keyName, valueName := elementName("key", *shape.Key), elementName("value", *shape.Value)
|
||||
for _, key := range slices.Sorted(maps.Keys(entries)) {
|
||||
entry := container.add(entryName)
|
||||
entry.add(keyName).text = key
|
||||
if err := e.xmlValue(entry, valueName, *shape.Value, entries[key]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
text, err := scalarString(value, ref, shape, "iso8601")
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
parent.add(name).text = text
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// elementName is a member's wire name: its xmlName or jsonName, else its own.
|
||||
func elementName(member string, ref ShapeRef) string {
|
||||
if ref.LocationName != "" {
|
||||
return ref.LocationName
|
||||
}
|
||||
return member
|
||||
}
|
||||
|
||||
func namespaceAttr(namespace XMLNamespace) xml.Attr {
|
||||
name := "xmlns"
|
||||
if namespace.Prefix != "" {
|
||||
name += ":" + namespace.Prefix
|
||||
}
|
||||
return xml.Attr{Name: xml.Name{Local: name}, Value: namespace.URI}
|
||||
}
|
||||
|
||||
// xmlElement is one element of an encoded rest-xml body.
|
||||
type xmlElement struct {
|
||||
name string
|
||||
attrs []xml.Attr
|
||||
text string
|
||||
children []*xmlElement
|
||||
}
|
||||
|
||||
func (x *xmlElement) add(name string) *xmlElement {
|
||||
child := &xmlElement{name: name}
|
||||
x.children = append(x.children, child)
|
||||
return child
|
||||
}
|
||||
|
||||
func (x *xmlElement) encode(encoder *xml.Encoder) error {
|
||||
start := xml.StartElement{Name: xml.Name{Local: x.name}, Attr: x.attrs}
|
||||
if err := encoder.EncodeToken(start); err != nil {
|
||||
return err
|
||||
}
|
||||
if x.text != "" {
|
||||
if err := encoder.EncodeToken(xml.CharData(x.text)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, child := range x.children {
|
||||
if err := child.encode(encoder); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return encoder.EncodeToken(start.End())
|
||||
}
|
||||
|
||||
func (e requestEncoder) restQuery(query url.Values, ref ShapeRef, shape *Shape, value any) error {
|
||||
switch shape.Type {
|
||||
case "list":
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
package awsmodel_test
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"encoding/xml"
|
||||
"io"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/aws/credentials"
|
||||
"github.com/aws/aws-sdk-go/aws/request"
|
||||
"github.com/aws/aws-sdk-go/aws/session"
|
||||
"github.com/aws/aws-sdk-go/service/route53"
|
||||
"github.com/aws/aws-sdk-go/service/s3"
|
||||
"github.com/mulgadc/spinifex/internal/awsmodel"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const sdkEndpoint = "https://aws.example"
|
||||
|
||||
var sdkSession = session.Must(session.NewSession(&aws.Config{
|
||||
Region: aws.String("us-east-1"),
|
||||
Endpoint: aws.String(sdkEndpoint),
|
||||
Credentials: credentials.AnonymousCredentials,
|
||||
S3ForcePathStyle: aws.Bool(true),
|
||||
}))
|
||||
|
||||
// TestEncodeRequestRestXMLMatchesSDK checks the encoder against aws-sdk-go's
|
||||
// restxml marshaller for the same input, so the expected wire form is the
|
||||
// SDK's rather than one written by hand.
|
||||
func TestEncodeRequestRestXMLMatchesSDK(t *testing.T) {
|
||||
r53 := route53.New(sdkSession)
|
||||
tests := []struct {
|
||||
name string
|
||||
service awsmodel.Service
|
||||
operation string
|
||||
input map[string]any
|
||||
sdk *request.Request
|
||||
}{
|
||||
{
|
||||
name: "CreateHostedZone",
|
||||
service: awsmodel.Route53,
|
||||
operation: "CreateHostedZone",
|
||||
input: map[string]any{
|
||||
"Name": "example.com",
|
||||
"CallerReference": "ref-1",
|
||||
"HostedZoneConfig": map[string]any{"Comment": "a & b", "PrivateZone": true},
|
||||
"VPC": map[string]any{"VPCRegion": "us-east-1", "VPCId": "vpc-1"},
|
||||
},
|
||||
sdk: requestOf(r53.CreateHostedZoneRequest(&route53.CreateHostedZoneInput{
|
||||
Name: aws.String("example.com"),
|
||||
CallerReference: aws.String("ref-1"),
|
||||
HostedZoneConfig: &route53.HostedZoneConfig{Comment: aws.String("a & b"), PrivateZone: aws.Bool(true)},
|
||||
VPC: &route53.VPC{VPCRegion: aws.String("us-east-1"), VPCId: aws.String("vpc-1")},
|
||||
})),
|
||||
},
|
||||
{
|
||||
name: "ChangeResourceRecordSets",
|
||||
service: awsmodel.Route53,
|
||||
operation: "ChangeResourceRecordSets",
|
||||
input: map[string]any{
|
||||
"HostedZoneId": "Z0123456789",
|
||||
"ChangeBatch": map[string]any{
|
||||
"Comment": "two changes",
|
||||
"Changes": []any{
|
||||
map[string]any{"Action": "UPSERT", "ResourceRecordSet": map[string]any{
|
||||
"Name": "www.example.com", "Type": "A", "TTL": int64(300),
|
||||
"ResourceRecords": []any{map[string]any{"Value": "192.0.2.1"}, map[string]any{"Value": "192.0.2.2"}},
|
||||
}},
|
||||
map[string]any{"Action": "DELETE", "ResourceRecordSet": map[string]any{
|
||||
"Name": "old.example.com", "Type": "TXT", "TTL": int64(60),
|
||||
"ResourceRecords": []any{map[string]any{"Value": `"v=1"`}},
|
||||
}},
|
||||
},
|
||||
},
|
||||
},
|
||||
sdk: requestOf(r53.ChangeResourceRecordSetsRequest(&route53.ChangeResourceRecordSetsInput{
|
||||
HostedZoneId: aws.String("Z0123456789"),
|
||||
ChangeBatch: &route53.ChangeBatch{
|
||||
Comment: aws.String("two changes"),
|
||||
Changes: []*route53.Change{
|
||||
{Action: aws.String("UPSERT"), ResourceRecordSet: &route53.ResourceRecordSet{
|
||||
Name: aws.String("www.example.com"), Type: aws.String("A"), TTL: aws.Int64(300),
|
||||
ResourceRecords: []*route53.ResourceRecord{{Value: aws.String("192.0.2.1")}, {Value: aws.String("192.0.2.2")}},
|
||||
}},
|
||||
{Action: aws.String("DELETE"), ResourceRecordSet: &route53.ResourceRecordSet{
|
||||
Name: aws.String("old.example.com"), Type: aws.String("TXT"), TTL: aws.Int64(60),
|
||||
ResourceRecords: []*route53.ResourceRecord{{Value: aws.String(`"v=1"`)}},
|
||||
}},
|
||||
},
|
||||
},
|
||||
})),
|
||||
},
|
||||
{
|
||||
name: "ChangeTagsForResource",
|
||||
service: awsmodel.Route53,
|
||||
operation: "ChangeTagsForResource",
|
||||
input: map[string]any{
|
||||
"ResourceType": "hostedzone",
|
||||
"ResourceId": "Z0123456789",
|
||||
"AddTags": []any{map[string]any{"Key": "team", "Value": "dns"}, map[string]any{"Key": "env", "Value": "prod"}},
|
||||
"RemoveTagKeys": []any{"old", "stale"},
|
||||
},
|
||||
sdk: requestOf(r53.ChangeTagsForResourceRequest(&route53.ChangeTagsForResourceInput{
|
||||
ResourceType: aws.String("hostedzone"),
|
||||
ResourceId: aws.String("Z0123456789"),
|
||||
AddTags: []*route53.Tag{{Key: aws.String("team"), Value: aws.String("dns")}, {Key: aws.String("env"), Value: aws.String("prod")}},
|
||||
RemoveTagKeys: []*string{aws.String("old"), aws.String("stale")},
|
||||
})),
|
||||
},
|
||||
{
|
||||
name: "UpdateHostedZoneComment without a comment",
|
||||
service: awsmodel.Route53,
|
||||
operation: "UpdateHostedZoneComment",
|
||||
input: map[string]any{"Id": "Z0123456789"},
|
||||
sdk: requestOf(r53.UpdateHostedZoneCommentRequest(&route53.UpdateHostedZoneCommentInput{Id: aws.String("Z0123456789")})),
|
||||
},
|
||||
{
|
||||
name: "ListResourceRecordSets",
|
||||
service: awsmodel.Route53,
|
||||
operation: "ListResourceRecordSets",
|
||||
input: map[string]any{"HostedZoneId": "Z0123456789", "StartRecordName": "www.example.com", "StartRecordType": "A", "MaxItems": int64(5)},
|
||||
sdk: requestOf(r53.ListResourceRecordSetsRequest(&route53.ListResourceRecordSetsInput{
|
||||
HostedZoneId: aws.String("Z0123456789"), StartRecordName: aws.String("www.example.com"), StartRecordType: aws.String("A"), MaxItems: aws.String("5"),
|
||||
})),
|
||||
},
|
||||
{
|
||||
name: "DeleteHostedZone",
|
||||
service: awsmodel.Route53,
|
||||
operation: "DeleteHostedZone",
|
||||
input: map[string]any{"Id": "Z0123456789"},
|
||||
sdk: requestOf(r53.DeleteHostedZoneRequest(&route53.DeleteHostedZoneInput{Id: aws.String("Z0123456789")})),
|
||||
},
|
||||
{
|
||||
name: "S3 structure payload",
|
||||
service: awsmodel.S3,
|
||||
operation: "PutBucketTagging",
|
||||
input: map[string]any{
|
||||
"Bucket": "bucket",
|
||||
"Tagging": map[string]any{"TagSet": []any{map[string]any{"Key": "team", "Value": "storage"}}},
|
||||
},
|
||||
sdk: requestOf(s3.New(sdkSession).PutBucketTaggingRequest(&s3.PutBucketTaggingInput{
|
||||
Bucket: aws.String("bucket"),
|
||||
Tagging: &s3.Tagging{TagSet: []*s3.Tag{{Key: aws.String("team"), Value: aws.String("storage")}}},
|
||||
})),
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
require.NoError(t, test.sdk.Build())
|
||||
want := test.sdk.HTTPRequest
|
||||
wantBody, err := io.ReadAll(test.sdk.GetBody())
|
||||
require.NoError(t, err)
|
||||
|
||||
got, err := awsmodel.EncodeRequest(test.service, test.operation, test.input)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, want.Method, got.Method)
|
||||
gotURL, err := url.Parse(got.URL(sdkEndpoint))
|
||||
require.NoError(t, err)
|
||||
// The SDK's api-2.json ends ChangeResourceRecordSets' path in a
|
||||
// slash that the Smithy model drops; the gateway accepts both.
|
||||
require.Equal(t, strings.TrimSuffix(want.URL.Path, "/"), gotURL.Path)
|
||||
require.Equal(t, want.URL.Query(), gotURL.Query())
|
||||
if len(wantBody) == 0 {
|
||||
require.Empty(t, got.Body)
|
||||
require.Empty(t, got.Header.Get("Content-Type"))
|
||||
return
|
||||
}
|
||||
require.Equal(t, canonicalXML(t, wantBody), canonicalXML(t, got.Body))
|
||||
require.Equal(t, "application/xml", got.Header.Get("Content-Type"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func requestOf[O any](r *request.Request, _ O) *request.Request { return r }
|
||||
|
||||
// xmlTree is a parsed XML element. The SDK emits sibling elements in map
|
||||
// order, so canonicalXML sorts them by name, keeping list items in order.
|
||||
type xmlTree struct {
|
||||
XMLName xml.Name `xml:""`
|
||||
Attrs []xml.Attr `xml:",any,attr"`
|
||||
Text string `xml:",chardata"`
|
||||
Children []xmlTree `xml:",any"`
|
||||
}
|
||||
|
||||
func canonicalXML(t *testing.T, body []byte) xmlTree {
|
||||
t.Helper()
|
||||
var tree xmlTree
|
||||
require.NoError(t, xml.Unmarshal(body, &tree), string(body))
|
||||
var canonicalise func(*xmlTree)
|
||||
canonicalise = func(node *xmlTree) {
|
||||
node.Text = strings.TrimSpace(node.Text)
|
||||
slices.SortFunc(node.Attrs, func(a, b xml.Attr) int { return cmp.Compare(a.Name.Space+a.Name.Local, b.Name.Space+b.Name.Local) })
|
||||
slices.SortStableFunc(node.Children, func(a, b xmlTree) int { return cmp.Compare(a.XMLName.Local, b.XMLName.Local) })
|
||||
for i := range node.Children {
|
||||
canonicalise(&node.Children[i])
|
||||
}
|
||||
}
|
||||
canonicalise(&tree)
|
||||
return tree
|
||||
}
|
||||
|
||||
func TestEncodeRequestRestXMLPutsRootInServiceNamespace(t *testing.T) {
|
||||
request, err := awsmodel.EncodeRequest(awsmodel.Route53, "CreateHostedZone", map[string]any{"Name": "example.com", "CallerReference": "ref"})
|
||||
require.NoError(t, err)
|
||||
tree := canonicalXML(t, request.Body)
|
||||
require.Equal(t, xml.Name{Space: "https://route53.amazonaws.com/doc/2013-04-01/", Local: "CreateHostedZoneRequest"}, tree.XMLName)
|
||||
require.Equal(t, "/2013-04-01/hostedzone", request.Path)
|
||||
}
|
||||
@@ -117,8 +117,3 @@ func TestEncodeRequestRejectsUnmodelledMember(t *testing.T) {
|
||||
_, err := awsmodel.EncodeRequest(awsmodel.IAM, "GetRole", map[string]any{"RoleName": "app", "Bogus": "x"})
|
||||
require.ErrorContains(t, err, `no member "Bogus"`)
|
||||
}
|
||||
|
||||
func TestEncodeRequestS3IsNotSupported(t *testing.T) {
|
||||
_, err := awsmodel.EncodeRequest(awsmodel.S3, "ListBuckets", map[string]any{})
|
||||
require.ErrorContains(t, err, "not implemented")
|
||||
}
|
||||
|
||||
@@ -119,7 +119,7 @@ func (m *Model) decodeErrorCode(body []byte) (string, error) {
|
||||
return "", fmt.Errorf("JSON error envelope has no code")
|
||||
}
|
||||
return code, nil
|
||||
case "ec2", "query":
|
||||
case "ec2", "query", "rest-xml":
|
||||
root, err := parseXML(body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
||||
@@ -123,3 +123,16 @@ func TestValidateErrorResponseClassifiesCommonErrorsAsUnmodelled(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeRequestResultReadsRestXMLErrorEnvelope(t *testing.T) {
|
||||
body := []byte(`<?xml version="1.0"?>
|
||||
<ErrorResponse xmlns="https://route53.amazonaws.com/doc/2013-04-01/"><Error><Type>Sender</Type><Code>NoSuchHostedZone</Code><Message>No hosted zone found with ID: Z1</Message></Error><RequestId>r-1</RequestId></ErrorResponse>`)
|
||||
result := DecodeRequestResult(Route53, 404, nil, body)
|
||||
if result.Code != "NoSuchHostedZone" || result.Message != "No hosted zone found with ID: Z1" {
|
||||
t.Fatalf("result = %+v", result)
|
||||
}
|
||||
violations, modelled, err := ValidateErrorResponse(Route53, "GetHostedZone", body)
|
||||
if err != nil || !modelled || len(violations) != 0 {
|
||||
t.Fatalf("ValidateErrorResponse = %v, %v, %v; want a declared code", violations, modelled, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ const (
|
||||
ElasticLoadBalancingV2 Service = "elasticloadbalancingv2"
|
||||
IAM Service = "iam"
|
||||
RDS Service = "rds"
|
||||
Route53 Service = "route53"
|
||||
S3 Service = "s3"
|
||||
STS Service = "sts"
|
||||
)
|
||||
@@ -50,6 +51,14 @@ type Metadata struct {
|
||||
ServiceID string
|
||||
SigningName string
|
||||
TargetPrefix string
|
||||
XMLNamespace XMLNamespace
|
||||
}
|
||||
|
||||
// XMLNamespace is the namespace an XML element is serialized in. URI is empty
|
||||
// when none is declared.
|
||||
type XMLNamespace struct {
|
||||
URI string `json:"uri"`
|
||||
Prefix string `json:"prefix"`
|
||||
}
|
||||
|
||||
// Operation describes an AWS API operation and the shapes used by its input,
|
||||
@@ -84,6 +93,7 @@ type Shape struct {
|
||||
Pattern string
|
||||
LocationName string
|
||||
TimestampFormat string
|
||||
XMLNamespace XMLNamespace
|
||||
Payload string
|
||||
Flattened bool
|
||||
Sensitive bool
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
)
|
||||
|
||||
func TestServices(t *testing.T) {
|
||||
want := []Service{ACM, EC2, ECR, ECS, EKS, ElasticLoadBalancingV2, IAM, RDS, S3, STS}
|
||||
want := []Service{ACM, EC2, ECR, ECS, EKS, ElasticLoadBalancingV2, IAM, RDS, Route53, S3, STS}
|
||||
if got := Services(); !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("Services() = %v, want %v", got, want)
|
||||
}
|
||||
@@ -28,6 +28,7 @@ func TestLoadAllModels(t *testing.T) {
|
||||
ElasticLoadBalancingV2: {"2015-12-01", 51, 418},
|
||||
IAM: {"2010-05-08", 180, 624},
|
||||
RDS: {"2014-10-31", 164, 791},
|
||||
Route53: {"2013-04-01", 71, 409},
|
||||
S3: {"2006-03-01", 112, 710},
|
||||
STS: {"2011-06-15", 11, 101},
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# AWS models
|
||||
|
||||
The `*.json.gz` files are the Smithy JSON AST service models from [`aws/api-models-aws`](https://github.com/aws/api-models-aws), gzipped and embedded into the binary, so loading them needs no network or Go module cache. All ten come from the one commit recorded in `../model_source.go`.
|
||||
The `*.json.gz` files are the Smithy JSON AST service models from [`aws/api-models-aws`](https://github.com/aws/api-models-aws), gzipped and embedded into the binary, so loading them needs no network or Go module cache. All eleven come from the one commit recorded in `../model_source.go`.
|
||||
|
||||
Do not edit them by hand. To move to a newer commit, run `scripts/sync-aws-models.sh <commit-sha>`, then `make generate-aws-model-coverage` and review the regenerated `docs/coverage` and the `awsmodel` test expectations: a newer model can add operations, required members and enum values, and each resulting finding is a real gap to triage rather than a reason to stay on an older commit.
|
||||
|
||||
|
||||
Binary file not shown.
@@ -374,6 +374,10 @@ func (g *requestGenerator) structure(name string, path []pathStep, depth int) (m
|
||||
for _, member := range slices.Sorted(maps.Keys(shape.Members)) {
|
||||
ref := shape.Members[member]
|
||||
memberPath := append(slices.Clone(path), member)
|
||||
if !required[member] && slices.Contains(exclusiveMembers[g.model.service][name], member) {
|
||||
g.skip(memberPath, "AWS refuses it alongside the members a valid request carries")
|
||||
continue
|
||||
}
|
||||
memberValue, ok := g.value(ref, memberPath, depth)
|
||||
if !ok {
|
||||
if required[member] {
|
||||
|
||||
@@ -18,7 +18,8 @@ import (
|
||||
|
||||
// RequestOptions places generated ARNs in the account and region the
|
||||
// requests are sent as. AccessKeyID is the key the requests are signed with.
|
||||
// Fixtures maps a member to an existing resource it can name.
|
||||
// Fixtures maps a member, or an "Operation.Member" that overrides it for one
|
||||
// operation, to an existing resource it can name.
|
||||
type RequestOptions struct {
|
||||
AccountID string
|
||||
Region string
|
||||
@@ -73,6 +74,12 @@ var ec2IDMember = regexp.MustCompile(`^(.*?)Ids?$`)
|
||||
// with a code the judge cannot tell from a real finding.
|
||||
var conditionallyRequired = map[Service]map[string][]string{
|
||||
EC2: {"CreateCapacityReservation": {"AvailabilityZone"}},
|
||||
// A listing that starts at a zone ID needs the name it starts at, and
|
||||
// one that starts at a record type or identifier needs a name and type.
|
||||
Route53: {
|
||||
"ListHostedZonesByName": {"DNSName"},
|
||||
"ListResourceRecordSets": {"StartRecordName", "StartRecordType"},
|
||||
},
|
||||
ECS: {
|
||||
"CreateCapacityProvider": {"autoScalingGroupProvider"},
|
||||
"CreateService": {"taskDefinition"},
|
||||
@@ -123,14 +130,47 @@ var ec2TaggedTypes = map[string]string{
|
||||
"RunInstances": "instance",
|
||||
}
|
||||
|
||||
// exclusiveMembers are optional members a valid request leaves out of the
|
||||
// structure that holds them, because AWS refuses them in combination with
|
||||
// the rest. Generation skips them, so their constraints are not broken.
|
||||
var exclusiveMembers = map[Service]map[string][]string{
|
||||
// A record set takes one routing policy and either records or an alias.
|
||||
Route53: {"ResourceRecordSet": {
|
||||
"AliasTarget", "CidrRoutingConfig", "Failover", "GeoLocation", "GeoProximityLocation", "HealthCheckId",
|
||||
"MultiValueAnswer", "Region", "SetIdentifier", "TrafficPolicyInstanceId", "Weight",
|
||||
}},
|
||||
}
|
||||
|
||||
// fixture returns the fixture the member at path names, preferring one scoped
|
||||
// to the operation.
|
||||
func (g *requestGenerator) fixture(member string) (string, bool) {
|
||||
if fixture, ok := g.options.Fixtures[g.operation+"."+member]; ok {
|
||||
return fixture, true
|
||||
}
|
||||
fixture, ok := g.options.Fixtures[member]
|
||||
return fixture, ok
|
||||
}
|
||||
|
||||
// enumHint returns the enum value a valid request needs at path, where the
|
||||
// first value would not do.
|
||||
func (g *requestGenerator) enumHint(path []pathStep) (string, bool) {
|
||||
if g.model.service != EC2 || len(path) == 0 || path[0] != "TagSpecifications" || memberName(path) != "ResourceType" {
|
||||
return "", false
|
||||
member := memberName(path)
|
||||
if fixture, ok := g.fixture(member); ok {
|
||||
return fixture, true
|
||||
}
|
||||
value, ok := ec2TaggedTypes[g.operation]
|
||||
return value, ok
|
||||
switch g.model.service {
|
||||
case EC2:
|
||||
if len(path) > 0 && path[0] == "TagSpecifications" && member == "ResourceType" {
|
||||
value, ok := ec2TaggedTypes[g.operation]
|
||||
return value, ok
|
||||
}
|
||||
case Route53:
|
||||
// The first RRType, SOA, is one a zone already has and cannot gain.
|
||||
if member == "Type" {
|
||||
return "A", true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// hint returns a value of the right form for the member at path, if one is
|
||||
@@ -141,7 +181,7 @@ func (g *requestGenerator) hint(path []pathStep) (string, bool) {
|
||||
return "", false
|
||||
}
|
||||
options := g.options
|
||||
if fixture, ok := options.Fixtures[member]; ok {
|
||||
if fixture, ok := g.fixture(member); ok {
|
||||
return fixture, true
|
||||
}
|
||||
suffix := fmt.Sprintf("%017x", g.saltNumber)
|
||||
@@ -230,6 +270,17 @@ func (g *requestGenerator) hint(path []pathStep) (string, bool) {
|
||||
if member == "type" && len(path) == 1 {
|
||||
return "STANDARD", true
|
||||
}
|
||||
case Route53:
|
||||
// A generated name is a top-level domain, which cannot be a zone.
|
||||
// Record names fall inside the fixture zone, example.com.
|
||||
switch member {
|
||||
case "Name", "DNSName", "StartRecordName":
|
||||
return name + ".example.com", true
|
||||
case "Value":
|
||||
if slices.Contains(path, pathStep("ResourceRecords")) {
|
||||
return "192.0.2.1", true
|
||||
}
|
||||
}
|
||||
case STS:
|
||||
// GetAccessKeyInfo answers only for a key that exists.
|
||||
if member == "AccessKeyId" && options.AccessKeyID != "" {
|
||||
|
||||
@@ -131,6 +131,7 @@ func TestGeneratedRequestsBreakOnlyTheirConstraint(t *testing.T) {
|
||||
for _, request := range plan.Cases {
|
||||
require.False(t, rejecting && request.Acceptance(), "%s acceptance %s follows a rejection", operationName, request.Path)
|
||||
rejecting = !request.Acceptance()
|
||||
// Predastore serves S3, so its requests are never sent.
|
||||
if service != S3 {
|
||||
_, err := EncodeRequest(service, operationName, request.Input)
|
||||
require.NoError(t, err, "%s %s %s encodes", operationName, request.Constraint, request.Path)
|
||||
@@ -241,6 +242,17 @@ func TestGenerateRequestsTargetsFixturesOutsideCreateAndDelete(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateRequestsPrefersOperationScopedFixtures(t *testing.T) {
|
||||
options := testRequestOptions
|
||||
options.Fixtures = map[string]string{"Id": "Z0FIXTURE", "GetChange.Id": "C0FIXTURE"}
|
||||
for operation, want := range map[string]string{"GetChange": "C0FIXTURE", "GetHostedZone": "Z0FIXTURE"} {
|
||||
plan, err := GenerateRequests(Route53, operation, options)
|
||||
require.NoError(t, err)
|
||||
require.True(t, plan.Cases[0].Acceptance())
|
||||
require.Equal(t, want, plan.Cases[0].Input["Id"], operation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateRequestsAttributesRejections(t *testing.T) {
|
||||
plan, err := GenerateRequests(IAM, "CreateRole", testRequestOptions)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -281,6 +281,9 @@ func (t *smithyTranslator) metadata() (Metadata, error) {
|
||||
if _, err := service.Traits.decode("aws.auth#sigv4", &sigv4); err != nil {
|
||||
return Metadata{}, err
|
||||
}
|
||||
if _, err := service.Traits.decode("smithy.api#xmlNamespace", &metadata.XMLNamespace); err != nil {
|
||||
return Metadata{}, err
|
||||
}
|
||||
metadata.EndpointPrefix = awsService.EndpointPrefix
|
||||
metadata.ServiceID = awsService.SDKID
|
||||
metadata.ServiceFullName = title
|
||||
@@ -443,6 +446,9 @@ func (t *smithyTranslator) applyShapeTraits(shape *Shape, traits smithyTraits) e
|
||||
if shape.LocationName, err = traits.string("smithy.api#xmlName"); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := traits.decode("smithy.api#xmlNamespace", &shape.XMLNamespace); err != nil {
|
||||
return err
|
||||
}
|
||||
if shape.TimestampFormat, err = timestampFormat(traits); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -85,7 +85,6 @@ Both ship to `/usr/local/share/spinifex/` on every node, so they are available o
|
||||
| `check-coverage.sh` | Fails the build if total Go coverage falls below the minimum threshold, excluding packages exempt from the requirement. |
|
||||
| `diff-coverage.sh` | Coverage check scoped to changed lines only, against an auto-detected base ref (`HEAD~1` on main, `origin/main` on dev, otherwise `origin/dev`). |
|
||||
| `sync-aws-models.sh` | Replaces the embedded AWS Smithy models under `internal/awsmodel/models/` with the ten service files from `aws/api-models-aws` at the given commit, and records that commit in `internal/awsmodel/model_source.go`. Takes a full 40-character SHA. |
|
||||
| `ui-playwright.sh compare\|baselines [args...]` | Runs the console's Playwright suite inside the image pinned in the frontend's `playwright.image`, the one CI's `UI End-to-End` job uses, with podman or docker. `compare` checks screenshots as CI does and `baselines` redraws them; extra arguments go to `pnpm e2e`. Installs into its own `node_modules` under `~/.cache/spinifex-ui-playwright`, leaving the host's alone. `make ui-e2e-container` and `make ui-baselines` run it. |
|
||||
| `run-bench.sh` | Launches a benchmark against running Ubuntu instances, auto-detecting the AMI for the host architecture. Tracks nbdkit and perf alongside the run. |
|
||||
| `disk-performance.sh` | Runs a `fio` random 70/30 read-write benchmark swept across 4k/16k/128k/1M. Writes JSON results to `/tmp/spinifex-disk-bench`; override with `BENCH_DIR`, `OUT_DIR`, `SIZE`, `JOBS`, `BLOCK_SIZES`. |
|
||||
| `network-performance.sh` | iperf throughput from several clients to one server, driven over SSH, writing a `summary.txt` of Gbit/s per client. `--server-ip` sets the address clients dial, so pointing it at instance private addresses measures the VPC overlay rather than the external pool. |
|
||||
|
||||
+11
-2
@@ -30,10 +30,19 @@ distro_pkg_manager() {
|
||||
}
|
||||
|
||||
# The OVS umbrella unit: openvswitch-switch.service on Debian, openvswitch.service on EL.
|
||||
# Ubuntu's openvswitch-switch declares Alias=openvswitch.service; the symlink that
|
||||
# enabling it leaves points back at openvswitch-switch.service and is not the EL unit.
|
||||
distro_ovs_unit() {
|
||||
local deb=false el=false
|
||||
local deb=false el=false dir
|
||||
distro_unit_exists openvswitch-switch.service && deb=true
|
||||
distro_unit_exists openvswitch.service && el=true
|
||||
for dir in /etc/systemd/system /run/systemd/system /usr/lib/systemd/system /lib/systemd/system; do
|
||||
if [ -e "${DISTRO_ROOT:-}$dir/openvswitch.service" ] || [ -L "${DISTRO_ROOT:-}$dir/openvswitch.service" ]; then
|
||||
case $(readlink "${DISTRO_ROOT:-}$dir/openvswitch.service" || true) in
|
||||
openvswitch-switch.service | */openvswitch-switch.service) ;;
|
||||
*) el=true ;;
|
||||
esac
|
||||
fi
|
||||
done
|
||||
if $deb && $el; then
|
||||
echo "distro.sh: OVS unit: found both openvswitch-switch.service and openvswitch.service; cannot tell which one owns OVS" >&2
|
||||
return 1
|
||||
|
||||
@@ -26,6 +26,8 @@ fixture() {
|
||||
unit() { mkdir -p "$ROOT/usr/lib/systemd/system" && : > "$ROOT/usr/lib/systemd/system/$1"; }
|
||||
file() { mkdir -p "$ROOT$(dirname "$1")" && : > "$ROOT$1"; }
|
||||
cmd() { printf '#!/bin/sh\nexit 0\n' > "$BIN/$1" && chmod +x "$BIN/$1"; }
|
||||
# realcmd <name> — the host's real command, for probes that read the fixture with it.
|
||||
realcmd() { ln -s "$(command -v "$1")" "$BIN/$1"; }
|
||||
|
||||
# probe <fn> [arg...] — runs one probe in bash against the current fixture,
|
||||
# with only the stub dir on PATH. Sets OUT, ERR and RC.
|
||||
@@ -73,13 +75,22 @@ expect_ok "OVS unit: Debian name" openvswitch-switch.service
|
||||
fixture ovs-el; unit openvswitch.service; probe distro_ovs_unit
|
||||
expect_ok "OVS unit: EL name" openvswitch.service
|
||||
fixture ovs-masked; mkdir -p "$ROOT/etc/systemd/system"
|
||||
ln -s /dev/null "$ROOT/etc/systemd/system/openvswitch.service"; probe distro_ovs_unit
|
||||
ln -s /dev/null "$ROOT/etc/systemd/system/openvswitch.service"; realcmd readlink; probe distro_ovs_unit
|
||||
expect_ok "OVS unit: a masked unit in /etc counts" openvswitch.service
|
||||
fixture ovs-lib; mkdir -p "$ROOT/lib/systemd/system"
|
||||
: > "$ROOT/lib/systemd/system/openvswitch-switch.service"; probe distro_ovs_unit
|
||||
expect_ok "OVS unit: found under /lib on an unmerged-/usr host" openvswitch-switch.service
|
||||
fixture ovs-both; unit openvswitch-switch.service; unit openvswitch.service; probe distro_ovs_unit
|
||||
expect_fail "OVS unit: both fails naming both" openvswitch-switch.service openvswitch.service
|
||||
fixture ovs-ubuntu-alias; unit openvswitch-switch.service; mkdir -p "$ROOT/etc/systemd/system"
|
||||
ln -s /usr/lib/systemd/system/openvswitch-switch.service "$ROOT/etc/systemd/system/openvswitch.service"; realcmd readlink; probe distro_ovs_unit
|
||||
expect_ok "OVS unit: Ubuntu's openvswitch.service alias is the Debian unit" openvswitch-switch.service
|
||||
fixture ovs-relative-alias; unit openvswitch-switch.service; mkdir -p "$ROOT/etc/systemd/system"
|
||||
ln -s openvswitch-switch.service "$ROOT/etc/systemd/system/openvswitch.service"; realcmd readlink; probe distro_ovs_unit
|
||||
expect_ok "OVS unit: a relative alias symlink is the Debian unit" openvswitch-switch.service
|
||||
fixture ovs-alias-and-el; unit openvswitch-switch.service; unit openvswitch.service; mkdir -p "$ROOT/etc/systemd/system"
|
||||
ln -s /usr/lib/systemd/system/openvswitch-switch.service "$ROOT/etc/systemd/system/openvswitch.service"; realcmd readlink; probe distro_ovs_unit
|
||||
expect_fail "OVS unit: an alias beside a real EL unit still fails naming both" openvswitch-switch.service openvswitch.service
|
||||
fixture ovs-none; probe distro_ovs_unit
|
||||
expect_fail "OVS unit: neither fails naming both" openvswitch-switch.service openvswitch.service
|
||||
|
||||
|
||||
@@ -71,9 +71,16 @@ distro_unit_exists() {
|
||||
}
|
||||
|
||||
distro_ovs_unit() {
|
||||
local deb=false el=false
|
||||
local deb=false el=false dir
|
||||
distro_unit_exists openvswitch-switch.service && deb=true
|
||||
distro_unit_exists openvswitch.service && el=true
|
||||
for dir in /etc/systemd/system /run/systemd/system /usr/lib/systemd/system /lib/systemd/system; do
|
||||
if [ -e "${DISTRO_ROOT:-}$dir/openvswitch.service" ] || [ -L "${DISTRO_ROOT:-}$dir/openvswitch.service" ]; then
|
||||
case $(readlink "${DISTRO_ROOT:-}$dir/openvswitch.service" || true) in
|
||||
openvswitch-switch.service | */openvswitch-switch.service) ;;
|
||||
*) el=true ;;
|
||||
esac
|
||||
fi
|
||||
done
|
||||
if $deb && $el; then
|
||||
echo "distro.sh: OVS unit: found both openvswitch-switch.service and openvswitch.service; cannot tell which one owns OVS" >&2
|
||||
return 1
|
||||
|
||||
@@ -27,6 +27,7 @@ MODELS=(
|
||||
elastic-load-balancing-v2/2015-12-01
|
||||
iam/2010-05-08
|
||||
rds/2014-10-31
|
||||
route-53/2013-04-01
|
||||
s3/2006-03-01
|
||||
sts/2011-06-15
|
||||
)
|
||||
@@ -34,7 +35,7 @@ MODELS=(
|
||||
TMP="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
# A blobless shallow fetch pulls only the ten files read below.
|
||||
# A blobless shallow fetch pulls only the files read below.
|
||||
git -C "$TMP" init --quiet repo
|
||||
git -C "$TMP/repo" fetch --quiet --depth=1 --filter=blob:none "$REPO" "$COMMIT"
|
||||
COMMITTED="$(git -C "$TMP/repo" log -1 --format=%cs "$COMMIT")"
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Runs the console's Playwright suite in the pinned image CI uses, so screenshots
|
||||
# rasterise identically here and on the runner. `compare` runs it as CI does;
|
||||
# `baselines` redraws e2e/__screenshots__/. The host's node_modules is not used.
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
ui_dir="${repo_root}/spinifex/runtime/roles/spinifexui/frontend"
|
||||
cache="${UI_PLAYWRIGHT_CACHE:-${XDG_CACHE_HOME:-${HOME}/.cache}/spinifex-ui-playwright}"
|
||||
|
||||
die() { echo "ui-playwright: $*" >&2; exit 1; }
|
||||
|
||||
mode="${1:-}"
|
||||
case "${mode}" in
|
||||
compare) e2e_args=() ;;
|
||||
baselines) e2e_args=(--update-snapshots=all) ;;
|
||||
*) die "usage: $0 compare|baselines [playwright args...]" ;;
|
||||
esac
|
||||
shift
|
||||
e2e_args+=("$@")
|
||||
|
||||
image="$(tr -d '[:space:]' < "${ui_dir}/playwright.image")"
|
||||
[ -n "${image}" ] || die "${ui_dir}/playwright.image is empty"
|
||||
|
||||
runtime="${CONTAINER_RUNTIME:-}"
|
||||
if [ -z "${runtime}" ]; then
|
||||
runtime="$(command -v podman || command -v docker || true)"
|
||||
fi
|
||||
[ -n "${runtime}" ] || die "neither podman nor docker is installed"
|
||||
|
||||
# Files the suite writes (dist, screenshots, test-results) must stay owned by
|
||||
# the invoking user, which rootless podman and docker reach differently.
|
||||
case "$(basename "${runtime}")" in
|
||||
podman) user_args=(--userns=keep-id) ;;
|
||||
*) user_args=(--user "$(id -u):$(id -g)") ;;
|
||||
esac
|
||||
|
||||
mkdir -p "${cache}/node_modules" "${cache}/pnpm-store" "${cache}/corepack"
|
||||
|
||||
# Runs in the container: check the image against .nvmrc and the lockfile's
|
||||
# Playwright, then install, build dist for the CSP test, and run the suite.
|
||||
# shellcheck disable=SC2016
|
||||
inner='
|
||||
set -eu
|
||||
want_node="v$(cat .nvmrc)"
|
||||
[ "$(node --version)" = "${want_node}" ] ||
|
||||
{ echo "ui-playwright: image has node $(node --version), .nvmrc wants ${want_node}" >&2; exit 1; }
|
||||
corepack pnpm install --frozen-lockfile --store-dir /cache/pnpm-store
|
||||
pw="$(corepack pnpm exec playwright --version | awk "{print \$2}")"
|
||||
case "${UI_PLAYWRIGHT_IMAGE}" in
|
||||
*":v${pw}-"*) ;;
|
||||
*) echo "ui-playwright: lockfile has Playwright ${pw}, image is ${UI_PLAYWRIGHT_IMAGE}" >&2; exit 1 ;;
|
||||
esac
|
||||
corepack pnpm build
|
||||
exec corepack pnpm e2e "$@"
|
||||
'
|
||||
|
||||
exec "${runtime}" run --rm --init --ipc=host "${user_args[@]}" \
|
||||
-e CI=1 \
|
||||
-e HOME=/tmp/home \
|
||||
-e COREPACK_HOME=/cache/corepack \
|
||||
-e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
||||
-e UI_PLAYWRIGHT_IMAGE="${image}" \
|
||||
-v "${ui_dir}:/work" \
|
||||
-v "${cache}/node_modules:/work/node_modules" \
|
||||
-v "${cache}:/cache" \
|
||||
-w /work \
|
||||
"${image}" bash -c "${inner}" ui-playwright "${e2e_args[@]}"
|
||||
@@ -0,0 +1,27 @@
|
||||
------------------------------------------------- MODULE Capabilities -------------------------------------------------
|
||||
(*********************************************************************************************************************)
|
||||
(* The optional behaviour that an implementation advertises. *)
|
||||
(*********************************************************************************************************************)
|
||||
EXTENDS EBSProvider
|
||||
|
||||
-----------------------------------------------------------------------------------------------------------------------
|
||||
\* Interface methods
|
||||
|
||||
GetCapabilitiesRequests == {<<>>}
|
||||
|
||||
GetCapabilities(req, val, err) ==
|
||||
(*******************************************************************************************************************)
|
||||
(* The implementation reports which optional behaviour it supports. *)
|
||||
(*******************************************************************************************************************)
|
||||
\* The provider failed to answer.
|
||||
\/ /\ val = None
|
||||
/\ err \in ProviderErrors
|
||||
/\ UNCHANGED vars
|
||||
|
||||
\* The request was accepted.
|
||||
\* Every answer is the same, so an earlier one may be cached.
|
||||
\/ /\ val = Capabilities
|
||||
/\ err = None
|
||||
/\ UNCHANGED vars
|
||||
|
||||
=======================================================================================================================
|
||||
@@ -0,0 +1,164 @@
|
||||
------------------------------------------------- MODULE EBSProvider --------------------------------------------------
|
||||
(*********************************************************************************************************************)
|
||||
(* The state that an implementation exposes, and the types and constants that every other module shares. *)
|
||||
(*********************************************************************************************************************)
|
||||
EXTENDS
|
||||
Naturals,
|
||||
Sequences,
|
||||
FiniteSets,
|
||||
Functions,
|
||||
TLC
|
||||
|
||||
-----------------------------------------------------------------------------------------------------------------------
|
||||
\* Constants
|
||||
|
||||
CONSTANTS
|
||||
MaxBytes,
|
||||
VolumeIDs, VolumeHandles,
|
||||
NodeIDs, NBDURIs, DevicePaths,
|
||||
SnapshotIDs, SnapshotHandles,
|
||||
AvailabilityZones, Timestamps,
|
||||
Parameters, ProviderData,
|
||||
ListTokens, MaxListResults,
|
||||
\* The optional behaviour that the implementation advertises, which never changes.
|
||||
Capabilities,
|
||||
\* Whether the provider may fail to answer at any point in a behaviour.
|
||||
ProviderMayFail,
|
||||
\* Stands in for an optional field that a request or response omitted.
|
||||
None
|
||||
|
||||
ASSUME MaxBytes \in Nat \ {0}
|
||||
ASSUME VolumeIDs /= {} /\ VolumeHandles /= {}
|
||||
ASSUME NodeIDs /= {} => NBDURIs /= {} \/ DevicePaths /= {}
|
||||
ASSUME SnapshotIDs /= {} => SnapshotHandles /= {} /\ Timestamps /= {}
|
||||
ASSUME ListTokens /= {}
|
||||
ASSUME MaxListResults \in Nat \ {0}
|
||||
ASSUME ListTokens /= {}
|
||||
ASSUME None \notin UNION {VolumeIDs, VolumeHandles, NodeIDs, SnapshotIDs, SnapshotHandles,
|
||||
AvailabilityZones, Timestamps, Parameters, ProviderData, ListTokens}
|
||||
ASSUME Capabilities \in [online_expansion : BOOLEAN,
|
||||
sparse_extent_reporting : BOOLEAN,
|
||||
crash_consistent_snapshot : BOOLEAN,
|
||||
volume_seeding : BOOLEAN,
|
||||
read_only_publish : BOOLEAN,
|
||||
volume_enumeration : BOOLEAN,
|
||||
snapshot_enumeration : BOOLEAN,
|
||||
owner_routing : BOOLEAN,
|
||||
exclusion : [scope : {"none", "node", "cluster"},
|
||||
claim_ttl_seconds : Nat,
|
||||
fences_lost_claim : BOOLEAN]]
|
||||
ASSUME ProviderMayFail \in BOOLEAN
|
||||
|
||||
-----------------------------------------------------------------------------------------------------------------------
|
||||
\* Variables
|
||||
|
||||
VARIABLES
|
||||
\* Every volume that the implementation exposes, keyed by volume ID.
|
||||
volumes,
|
||||
\* Every publication that the implementation exposes, keyed by the volume and the node it is published to.
|
||||
publications,
|
||||
\* Every snapshot that the implementation exposes, keyed by snapshot ID.
|
||||
snapshots
|
||||
|
||||
vars == <<volumes, publications, snapshots>>
|
||||
|
||||
-----------------------------------------------------------------------------------------------------------------------
|
||||
\* Miscellaneous
|
||||
|
||||
IsPartialFn(f, D, R) == DOMAIN f \subseteq D /\ \A x \in DOMAIN f : f[x] \in R
|
||||
|
||||
Optional(S) == S \union {None}
|
||||
|
||||
InsertKey(f, x, y) == f @@ (x :> y)
|
||||
|
||||
RemoveKey(f, x) == RestrictDomain(f, LAMBDA x_2 : x_2 /= x)
|
||||
|
||||
KeyExists(f, x) == x \in DOMAIN f
|
||||
|
||||
-----------------------------------------------------------------------------------------------------------------------
|
||||
\* Types
|
||||
|
||||
Bytes == 0..MaxBytes
|
||||
|
||||
\* The errors that an implementation may return when its provider fails to answer.
|
||||
ProviderErrors == IF ProviderMayFail
|
||||
THEN {"internal", "unavailable"}
|
||||
\* Otherwise an implementation could conform by refusing to answer.
|
||||
ELSE {}
|
||||
|
||||
CapacityRanges == [required_bytes : Bytes, limit_bytes : Bytes]
|
||||
|
||||
VolumeStates == {"available", "in-use"}
|
||||
|
||||
Volumes == [capacity_bytes : 1..MaxBytes,
|
||||
state : VolumeStates,
|
||||
handle : VolumeHandles,
|
||||
availability_zone : Optional(AvailabilityZones),
|
||||
|
||||
\* Never returned, but kept to tell a retry from a conflicting request.
|
||||
source_snapshot_id : Optional(SnapshotIDs),
|
||||
parameters : Optional(Parameters),
|
||||
provider_data : Optional(ProviderData)]
|
||||
|
||||
\* How a published volume is reached on its node, either over NBD or as a host block device.
|
||||
Exports == [kind : {"nbd"},
|
||||
nbd_uri : NBDURIs]
|
||||
\union
|
||||
[kind : {"block"},
|
||||
device_path : DevicePaths,
|
||||
size_bytes : 1..MaxBytes]
|
||||
|
||||
\* A volume published to a node, and whether it was published read-only.
|
||||
Publications == [export : Exports,
|
||||
read_only : BOOLEAN]
|
||||
|
||||
SnapshotStates == {"pending", "completed", "error"}
|
||||
|
||||
Snapshots == [size_bytes : 1..MaxBytes,
|
||||
created_at : Timestamps,
|
||||
state : SnapshotStates,
|
||||
handle : SnapshotHandles,
|
||||
source_volume_id : VolumeIDs]
|
||||
|
||||
-----------------------------------------------------------------------------------------------------------------------
|
||||
\* State helpers
|
||||
|
||||
\* The part of a volume that the implementation returns.
|
||||
VolumeView(volID) ==
|
||||
("id" :> volID) @@ Restrict(volumes[volID], {"capacity_bytes",
|
||||
"state",
|
||||
"handle",
|
||||
"availability_zone",
|
||||
"provider_data"})
|
||||
|
||||
\* The nodes that a volume is published to.
|
||||
PublishedNodes(volID) == {n \in NodeIDs : KeyExists(publications, <<volID, n>>)}
|
||||
|
||||
\* The part of a publication that the implementation returns.
|
||||
PublicationView(volID, nodeID) == [volume_id |-> volID,
|
||||
node_id |-> nodeID,
|
||||
export |-> publications[volID, nodeID].export]
|
||||
|
||||
\* The part of a snapshot that the implementation returns.
|
||||
SnapshotView(snapID) == ("id" :> snapID) @@ snapshots[snapID]
|
||||
|
||||
\* The number of refs that a page may hold, with an omitted or oversized request clamped to the maximum.
|
||||
PageSize(m) == IF m <= 0 \/ m > MaxListResults THEN MaxListResults ELSE m
|
||||
|
||||
-----------------------------------------------------------------------------------------------------------------------
|
||||
\* Invariants
|
||||
|
||||
TypeOK ==
|
||||
/\ IsPartialFn(volumes, VolumeIDs, Volumes)
|
||||
/\ IsPartialFn(publications, VolumeIDs \X NodeIDs, Publications)
|
||||
/\ IsPartialFn(snapshots, SnapshotIDs, Snapshots)
|
||||
|
||||
-----------------------------------------------------------------------------------------------------------------------
|
||||
\* Initial state
|
||||
|
||||
Init ==
|
||||
/\ volumes = <<>>
|
||||
/\ publications = <<>>
|
||||
/\ snapshots = <<>>
|
||||
|
||||
=======================================================================================================================
|
||||
@@ -0,0 +1,140 @@
|
||||
# EBSProvider specification
|
||||
|
||||
The contract that every implementation of the `EBSProvider` interface must meet.
|
||||
|
||||
## Terminology
|
||||
|
||||
- **Provider:** the service that backs an implementation's operations, such as Viperblock.
|
||||
The memory implementation has none.
|
||||
- **Implementation:** a Go implementation of the `EBSProvider` interface, which adapts between the shapes of the contract and the shapes that its provider expects.
|
||||
- **Contract:** the behaviour that this specification requires of every implementation, as observed through the interface.
|
||||
|
||||
## Open questions
|
||||
|
||||
### EBSProvider
|
||||
|
||||
- What may a failed call change?
|
||||
A failed call leaves the state unchanged, but a timeout can arrive after the provider acted, and a crash can leave work half done.
|
||||
- Is the schema version part of the contract?
|
||||
Every method refuses an unsupported version with `unsupported_version`, and the conformance suite tests it, but the specification does not model versions.
|
||||
- Are `internal` and `unavailable` the only errors that a failed provider may cause?
|
||||
- Must a present but malformed ID be refused with `invalid_argument`?
|
||||
IDs are opaque here, so only an omitted one is refused.
|
||||
|
||||
### Capabilities
|
||||
|
||||
#### GetCapabilities
|
||||
|
||||
- Is it a lasting promise that capabilities never change, even when an implementation or its provider is upgraded or reconfigured after an earlier answer was cached?
|
||||
|
||||
### Volumes
|
||||
|
||||
- Who decides whether a volume is `in-use`?
|
||||
An implementation may track attachments itself, or derive the state from whether a publication claim is held.
|
||||
|
||||
#### CreateVolume
|
||||
|
||||
- May the capacity be anywhere in the requested range, as CSI allows?
|
||||
An implementation that rounds up would then fail the comparison that tells a retry from a conflicting request.
|
||||
- May seed data be combined with a source snapshot?
|
||||
The Viperblock implementation refuses, the memory implementation accepts.
|
||||
- Must the source snapshot be completed?
|
||||
AWS refuses one that is not.
|
||||
- Must an implementation store the availability zone, parameters and source snapshot, so that it can detect a conflicting retry?
|
||||
The Viperblock implementation does not.
|
||||
- Should a retry be compared against the original request rather than the current volume?
|
||||
A retry after ExpandVolume conflicts on capacity.
|
||||
- Should a retry with a different source snapshot volume ID conflict?
|
||||
It is not compared.
|
||||
- Is byte equality the meaning of equal parameters, or may an implementation compare them semantically?
|
||||
- Should `MaxSeedBytes` be modelled?
|
||||
Seed data larger than it is refused with `invalid_argument`.
|
||||
|
||||
#### GetVolume
|
||||
|
||||
- Must an implementation compare the handle?
|
||||
The Viperblock implementation ignores it.
|
||||
- Is `not_found` the right error for a volume that was deleted and recreated under the same ID?
|
||||
|
||||
#### ListVolumes
|
||||
|
||||
- Must following next tokens list every volume that exists throughout the walk exactly once?
|
||||
The conformance suite tests it, but the specification describes each call alone, so it does not say how one page relates to the next.
|
||||
- What should a starting token that the implementation never issued do?
|
||||
Every implementation treats any token as a position to resume after, while CSI refuses an invalid one with `ABORTED`.
|
||||
- May a page hold fewer refs than requested yet carry a next token?
|
||||
AWS and CSI allow it, but here a next token comes only with a full page, so that a walk cannot stall on empty pages.
|
||||
|
||||
#### ExpandVolume
|
||||
|
||||
- Should a request below the current capacity succeed without change, as CSI recommends, rather than be refused with `invalid_argument`?
|
||||
It is refused here, as AWS refuses `ModifyVolume` to a smaller size and the conformance suite tests, while CSI says the plugin should reply OK when the volume is already at least the requested size.
|
||||
- Must a block export's size follow an online expansion?
|
||||
A published volume keeps its export's `size_bytes`, so after it grows the export reports the old size.
|
||||
|
||||
#### PublishVolume
|
||||
|
||||
- Should `volume_in_use` depend on the exclusion scope?
|
||||
An implementation whose scope is `none` lets two writers in, and one whose scope is `node` cannot see another node, yet both are refused here.
|
||||
- Must a republish to the same node with a different `read_only` be refused?
|
||||
The Viperblock implementation refuses it with `volume_in_use`, while the memory implementation ignores the flag.
|
||||
- May two volumes on one node share an NBD URI or a device path?
|
||||
- Must a block export's size equal the volume's capacity?
|
||||
|
||||
#### UnpublishVolume
|
||||
|
||||
- Must an implementation compare the handle?
|
||||
Both the Viperblock and memory implementations ignore it.
|
||||
- Is a provider that fails to seal the volume a failed provider?
|
||||
The Viperblock implementation answers `internal` and leaves the volume published, which the specification allows only when the provider may fail.
|
||||
- Should a claim that lapses through its TTL be modelled, as a step that happens without a call?
|
||||
- Should a volume that does not exist be refused with `not_found`?
|
||||
It succeeds here, as CSI requires and the conformance suite tests, while AWS refuses `DetachVolume` on a missing volume with `InvalidVolume.NotFound`.
|
||||
|
||||
#### DeleteVolume
|
||||
|
||||
- Must an implementation compare the handle before refusing a published volume?
|
||||
The memory implementation does, and succeeds without deleting it, while the Viperblock implementation ignores the handle and refuses with `volume_in_use`.
|
||||
- Should a volume that does not exist be refused with `not_found`?
|
||||
It succeeds here, as CSI requires and the conformance suite tests, while AWS refuses `DeleteVolume` on a missing volume with `InvalidVolume.NotFound`.
|
||||
|
||||
### Snapshots
|
||||
|
||||
- Should a pending snapshot's move to `completed` or `error` be modelled, as a step that happens without a call?
|
||||
Without it a pending snapshot never completes, and no snapshot is ever in `error`.
|
||||
|
||||
#### CreateSnapshot
|
||||
|
||||
- May success return a `pending` snapshot?
|
||||
AWS starts a snapshot as `pending`, while the memory implementation returns `completed` and the NATS implementation waits for completion and refuses anything else.
|
||||
- Must a retry compare the volume handle?
|
||||
It is ignored, as in the memory implementation, so a volume deleted and recreated under the same ID returns the old snapshot.
|
||||
- Should a copy's ID count as a retry?
|
||||
A copy keeps its source's volume ID, so creating a snapshot of that volume under the copy's ID returns the copy.
|
||||
- Should `crash_consistent_snapshot` be modelled?
|
||||
The specification does not model volume data, so it cannot say what a snapshot of a published volume holds.
|
||||
|
||||
#### CopySnapshot
|
||||
|
||||
- Should a retried copy return the existing copy, as a retried CreateVolume does, rather than `already_exists`?
|
||||
- May a source that is not completed be copied?
|
||||
AWS refuses until it is.
|
||||
- What state does a copy start in?
|
||||
The memory implementation always returns `completed`, while AWS starts a copy as `pending`.
|
||||
|
||||
#### ListSnapshots
|
||||
|
||||
- The questions for ListVolumes apply here too.
|
||||
- Must a ref carry the source volume ID?
|
||||
The Viperblock implementation leaves it empty, since reading it costs a fetch per snapshot, yet reconciliation needs it to act on a snapshot whose metadata is lost.
|
||||
|
||||
#### DeleteSnapshot
|
||||
|
||||
- Is the handle check part of the contract?
|
||||
The Viperblock implementation ignores the handle and deletes anyway.
|
||||
- May a pending snapshot be deleted?
|
||||
AWS allows it.
|
||||
- What does success promise?
|
||||
The Go docs say the snapshot is gone, but a snapshot whose handle did not match is left in place.
|
||||
- Should a snapshot that does not exist be refused with `not_found`?
|
||||
It succeeds here, as CSI requires and the conformance suite tests, while AWS refuses `DeleteSnapshot` on a missing snapshot with `InvalidSnapshot.NotFound`.
|
||||
@@ -0,0 +1,159 @@
|
||||
-------------------------------------------------- MODULE Snapshots ---------------------------------------------------
|
||||
(*********************************************************************************************************************)
|
||||
(* The lifecycle of a snapshot, from its creation to its deletion. *)
|
||||
(*********************************************************************************************************************)
|
||||
EXTENDS EBSProvider
|
||||
|
||||
-----------------------------------------------------------------------------------------------------------------------
|
||||
\* Interface methods
|
||||
|
||||
CreateSnapshotRequests == [snapshot_id : Optional(SnapshotIDs),
|
||||
volume_id : Optional(VolumeIDs),
|
||||
volume_handle : Optional(VolumeHandles)]
|
||||
|
||||
CreateSnapshot(req, val, err) ==
|
||||
(*******************************************************************************************************************)
|
||||
(* A volume's data is frozen as a snapshot, from which new volumes can be restored. A retry of a call that *)
|
||||
(* already succeeded returns the same snapshot. *)
|
||||
(*******************************************************************************************************************)
|
||||
LET snapID == req.snapshot_id
|
||||
volID == req.volume_id
|
||||
|
||||
Errors ==
|
||||
LET checks == [invalid_argument |-> None \in {snapID, volID},
|
||||
already_exists |-> /\ KeyExists(snapshots, snapID)
|
||||
/\ snapshots[snapID].source_volume_id /= volID,
|
||||
\* Only checked for a new snapshot, so a retry outlives the volume.
|
||||
not_found |-> /\ ~KeyExists(snapshots, snapID)
|
||||
/\ \/ ~KeyExists(volumes, volID)
|
||||
\/ req.volume_handle \notin {None, volumes[volID].handle}]
|
||||
IN {e \in DOMAIN checks : checks[e]}
|
||||
|
||||
\* The request was rejected, or the provider failed to answer.
|
||||
IN \/ /\ val = None
|
||||
/\ err \in Errors \union ProviderErrors
|
||||
/\ UNCHANGED vars
|
||||
|
||||
\* The request was accepted.
|
||||
\/ /\ Errors = {}
|
||||
\* A retry returns the existing snapshot.
|
||||
/\ \/ /\ KeyExists(snapshots, snapID)
|
||||
/\ UNCHANGED snapshots
|
||||
|
||||
\/ /\ ~KeyExists(snapshots, snapID)
|
||||
\* The handle, creation time and state are the implementation's choice.
|
||||
/\ \E h \in SnapshotHandles \ {snapshots[x].handle : x \in DOMAIN snapshots},
|
||||
t \in Timestamps,
|
||||
s \in {"pending", "completed"} :
|
||||
snapshots' = InsertKey(snapshots, snapID, [size_bytes |-> volumes[volID].capacity_bytes,
|
||||
created_at |-> t,
|
||||
state |-> s,
|
||||
handle |-> h,
|
||||
source_volume_id |-> volID])
|
||||
/\ val = SnapshotView(snapID)'
|
||||
/\ err = None
|
||||
/\ UNCHANGED <<volumes, publications>>
|
||||
|
||||
|
||||
CopySnapshotRequests == [source_snapshot_id : Optional(SnapshotIDs),
|
||||
destination_snapshot_id : Optional(SnapshotIDs),
|
||||
volume_id : Optional(VolumeIDs)]
|
||||
|
||||
CopySnapshot(req, val, err) ==
|
||||
(*******************************************************************************************************************)
|
||||
(* A snapshot is copied under a new ID, sharing its frozen data, and the copy can be deleted independently *)
|
||||
(* of it. *)
|
||||
(*******************************************************************************************************************)
|
||||
LET srcSnapID == req.source_snapshot_id
|
||||
dstSnapID == req.destination_snapshot_id
|
||||
volID == req.volume_id
|
||||
|
||||
Errors ==
|
||||
LET checks == [invalid_argument |-> \/ None \in {srcSnapID, dstSnapID, volID}
|
||||
\/ srcSnapID = dstSnapID
|
||||
\* Only compared, not looked up, since a snapshot outlives its volume.
|
||||
\/ /\ KeyExists(snapshots, srcSnapID)
|
||||
/\ snapshots[srcSnapID].source_volume_id /= volID,
|
||||
already_exists |-> KeyExists(snapshots, dstSnapID),
|
||||
not_found |-> srcSnapID \notin Optional(DOMAIN snapshots)]
|
||||
IN {e \in DOMAIN checks : checks[e]}
|
||||
|
||||
\* The request was rejected, or the provider failed to answer.
|
||||
IN \/ /\ val = None
|
||||
/\ err \in Errors \union ProviderErrors
|
||||
/\ UNCHANGED vars
|
||||
|
||||
\* The request was accepted.
|
||||
\/ /\ Errors = {}
|
||||
\* The handle and creation time are the implementation's choice.
|
||||
/\ \E h \in SnapshotHandles \ {snapshots[x].handle : x \in DOMAIN snapshots},
|
||||
t \in Timestamps :
|
||||
snapshots' = InsertKey(snapshots, dstSnapID, [created_at |-> t,
|
||||
handle |-> h]
|
||||
@@ snapshots[srcSnapID])
|
||||
/\ val = SnapshotView(dstSnapID)'
|
||||
/\ err = None
|
||||
/\ UNCHANGED <<volumes, publications>>
|
||||
|
||||
|
||||
ListSnapshotsRequests == [max_results : -1..(MaxListResults + 1),
|
||||
starting_token : Optional(ListTokens)]
|
||||
|
||||
ListSnapshots(req, val, err) ==
|
||||
(*******************************************************************************************************************)
|
||||
(* Some of the snapshots are listed, a page at a time, and nothing changes. A call with no starting token that *)
|
||||
(* returns no next token lists every snapshot. *)
|
||||
(*******************************************************************************************************************)
|
||||
LET Errors ==
|
||||
LET checks == [unsupported_capability |-> ~Capabilities.snapshot_enumeration]
|
||||
IN {e \in DOMAIN checks : checks[e]}
|
||||
|
||||
\* The request was rejected, or the provider failed to answer.
|
||||
IN \/ /\ val = None
|
||||
/\ err \in Errors \union ProviderErrors
|
||||
/\ UNCHANGED vars
|
||||
|
||||
\* The request was accepted.
|
||||
\/ /\ Errors = {}
|
||||
/\ LET refs == {[id |-> id,
|
||||
source_volume_id |-> snapshots[id].source_volume_id,
|
||||
handle |-> snapshots[id].handle] : id \in DOMAIN snapshots}
|
||||
\* The page and the next token are the implementation's choice.
|
||||
IN \E page \in SUBSET refs, next \in Optional(ListTokens) :
|
||||
/\ Cardinality(page) <= PageSize(req.max_results)
|
||||
/\ next /= None => Cardinality(page) = PageSize(req.max_results)
|
||||
/\ req.starting_token = None /\ next = None => page = refs
|
||||
/\ val = [snapshots |-> page, next_token |-> next]
|
||||
/\ err = None
|
||||
/\ UNCHANGED vars
|
||||
|
||||
|
||||
DeleteSnapshotRequests == [snapshot_id : Optional(SnapshotIDs),
|
||||
handle : Optional(SnapshotHandles)]
|
||||
|
||||
DeleteSnapshot(req, err) ==
|
||||
(*******************************************************************************************************************)
|
||||
(* A snapshot is deleted, so that no new volume can be restored from it. Volumes already restored from it, *)
|
||||
(* and copies of it, are unaffected. *)
|
||||
(*******************************************************************************************************************)
|
||||
LET snapID == req.snapshot_id
|
||||
|
||||
Errors ==
|
||||
LET checks == [invalid_argument |-> snapID = None]
|
||||
IN {e \in DOMAIN checks : checks[e]}
|
||||
|
||||
\* The request was rejected, or the provider failed to answer.
|
||||
IN \/ /\ err \in Errors \union ProviderErrors
|
||||
/\ UNCHANGED vars
|
||||
|
||||
\* The request was accepted.
|
||||
\/ /\ Errors = {}
|
||||
/\ snapshots' = IF /\ KeyExists(snapshots, snapID)
|
||||
/\ req.handle \notin {None, snapshots[snapID].handle}
|
||||
THEN snapshots
|
||||
\* Record removal is idempotent so that retries succeed.
|
||||
ELSE RemoveKey(snapshots, snapID)
|
||||
/\ err = None
|
||||
/\ UNCHANGED <<volumes, publications>>
|
||||
|
||||
=======================================================================================================================
|
||||
@@ -0,0 +1,286 @@
|
||||
--------------------------------------------------- MODULE Volumes ----------------------------------------------------
|
||||
(*********************************************************************************************************************)
|
||||
(* The lifecycle of a volume, from its creation to its deletion. *)
|
||||
(*********************************************************************************************************************)
|
||||
EXTENDS EBSProvider
|
||||
|
||||
-----------------------------------------------------------------------------------------------------------------------
|
||||
\* Control plane operations
|
||||
|
||||
CreateVolumeRequests == [volume_id : Optional(VolumeIDs),
|
||||
capacity_range : CapacityRanges,
|
||||
availability_zone : Optional(AvailabilityZones),
|
||||
source_snapshot_id : Optional(SnapshotIDs),
|
||||
source_snapshot_volume_id : Optional(VolumeIDs),
|
||||
parameters : Optional(Parameters),
|
||||
seed_data : Bytes]
|
||||
|
||||
CreateVolume(req, val, err) ==
|
||||
(*******************************************************************************************************************)
|
||||
(* A volume of the requested capacity is created, either blank or holding a snapshot's data, optionally *)
|
||||
(* with seed data written at its start. A retry of a call that already succeeded returns the same volume. *)
|
||||
(*******************************************************************************************************************)
|
||||
LET volID == req.volume_id
|
||||
snapID == req.source_snapshot_id
|
||||
snapVolID == req.source_snapshot_volume_id
|
||||
capacity == req.capacity_range
|
||||
|
||||
\* Any warranted error may be returned, since the order of checks is not part of the contract.
|
||||
Errors ==
|
||||
LET checks == [invalid_argument |-> \/ volID = None
|
||||
\/ capacity.required_bytes <= 0
|
||||
\/ /\ capacity.limit_bytes > 0
|
||||
/\ capacity.required_bytes > capacity.limit_bytes
|
||||
\/ req.seed_data > capacity.required_bytes
|
||||
\/ snapID /= None /\ snapVolID = None
|
||||
\* Only checked for a new volume, so a retry outlives the snapshot.
|
||||
\/ /\ ~KeyExists(volumes, volID)
|
||||
/\ KeyExists(snapshots, snapID)
|
||||
/\ \/ capacity.required_bytes < snapshots[snapID].size_bytes
|
||||
\/ snapshots[snapID].source_volume_id /= snapVolID,
|
||||
\* Seed data is not compared, since it only applies on creation.
|
||||
already_exists |-> /\ KeyExists(volumes, volID)
|
||||
/\ \/ volumes[volID].capacity_bytes /= capacity.required_bytes
|
||||
\/ volumes[volID].availability_zone /= req.availability_zone
|
||||
\/ volumes[volID].source_snapshot_id /= snapID
|
||||
\/ volumes[volID].parameters /= req.parameters,
|
||||
not_found |-> /\ ~KeyExists(volumes, volID)
|
||||
/\ snapID \notin Optional(DOMAIN snapshots),
|
||||
unsupported_capability |-> /\ req.seed_data > 0
|
||||
/\ ~Capabilities.volume_seeding]
|
||||
IN {e \in DOMAIN checks : checks[e]}
|
||||
|
||||
\* The request was rejected, or the provider failed to answer.
|
||||
IN \/ /\ val = None
|
||||
/\ err \in Errors \union ProviderErrors
|
||||
/\ UNCHANGED vars
|
||||
|
||||
\* The request was accepted.
|
||||
\/ /\ Errors = {}
|
||||
\* A retry returns the existing volume.
|
||||
/\ \/ /\ KeyExists(volumes, volID)
|
||||
/\ UNCHANGED volumes
|
||||
|
||||
\/ /\ ~KeyExists(volumes, volID)
|
||||
\* The handle and provider data are the implementation's choice.
|
||||
/\ \E h \in VolumeHandles \ {volumes[x].handle : x \in DOMAIN volumes},
|
||||
pd \in Optional(ProviderData) :
|
||||
volumes' = InsertKey(volumes, volID, [capacity_bytes |-> capacity.required_bytes,
|
||||
state |-> "available",
|
||||
handle |-> h,
|
||||
availability_zone |-> req.availability_zone,
|
||||
source_snapshot_id |-> snapID,
|
||||
parameters |-> req.parameters,
|
||||
provider_data |-> pd])
|
||||
/\ val = VolumeView(volID)'
|
||||
/\ err = None
|
||||
/\ UNCHANGED <<publications, snapshots>>
|
||||
|
||||
|
||||
GetVolumeRequests == [volume_id : Optional(VolumeIDs),
|
||||
handle : Optional(VolumeHandles)]
|
||||
|
||||
GetVolume(req, val, err) ==
|
||||
(*******************************************************************************************************************)
|
||||
(* A volume's current capacity, state and handle are read, and nothing changes. *)
|
||||
(*******************************************************************************************************************)
|
||||
LET volID == req.volume_id
|
||||
|
||||
Errors ==
|
||||
LET checks == [invalid_argument |-> volID = None,
|
||||
not_found |-> \/ ~KeyExists(volumes, volID)
|
||||
\/ req.handle \notin {None, volumes[volID].handle}]
|
||||
IN {e \in DOMAIN checks : checks[e]}
|
||||
|
||||
\* The request was rejected, or the provider failed to answer.
|
||||
IN \/ /\ val = None
|
||||
/\ err \in Errors \union ProviderErrors
|
||||
/\ UNCHANGED vars
|
||||
|
||||
\* The request was accepted.
|
||||
\/ /\ Errors = {}
|
||||
/\ val = VolumeView(volID)
|
||||
/\ err = None
|
||||
/\ UNCHANGED vars
|
||||
|
||||
|
||||
ListVolumesRequests == [max_results : -1..(MaxListResults + 1),
|
||||
starting_token : Optional(ListTokens)]
|
||||
|
||||
ListVolumes(req, val, err) ==
|
||||
(*******************************************************************************************************************)
|
||||
(* Some of the volumes are listed, a page at a time, and nothing changes. A call with no starting token that *)
|
||||
(* returns no next token lists every volume. *)
|
||||
(*******************************************************************************************************************)
|
||||
LET Errors ==
|
||||
LET checks == [unsupported_capability |-> ~Capabilities.volume_enumeration]
|
||||
IN {e \in DOMAIN checks : checks[e]}
|
||||
|
||||
\* The request was rejected, or the provider failed to answer.
|
||||
IN \/ /\ val = None
|
||||
/\ err \in Errors \union ProviderErrors
|
||||
/\ UNCHANGED vars
|
||||
|
||||
\* The request was accepted.
|
||||
\/ /\ Errors = {}
|
||||
/\ LET refs == {[id |-> id, handle |-> volumes[id].handle] : id \in DOMAIN volumes}
|
||||
\* The page and the next token are the implementation's choice.
|
||||
IN \E page \in SUBSET refs, next \in Optional(ListTokens) :
|
||||
/\ Cardinality(page) <= PageSize(req.max_results)
|
||||
/\ next /= None => Cardinality(page) = PageSize(req.max_results)
|
||||
/\ req.starting_token = None /\ next = None => page = refs
|
||||
/\ val = [volumes |-> page, next_token |-> next]
|
||||
/\ err = None
|
||||
/\ UNCHANGED vars
|
||||
|
||||
|
||||
ExpandVolumeRequests == [volume_id : Optional(VolumeIDs),
|
||||
handle : Optional(VolumeHandles),
|
||||
capacity_range : CapacityRanges]
|
||||
|
||||
ExpandVolume(req, val, err) ==
|
||||
(*******************************************************************************************************************)
|
||||
(* A volume grows to the requested capacity, keeping its data. A published volume grows only if the *)
|
||||
(* implementation supports online expansion, and a request for the current capacity succeeds without change. *)
|
||||
(*******************************************************************************************************************)
|
||||
LET volID == req.volume_id
|
||||
capacity == req.capacity_range
|
||||
|
||||
Errors ==
|
||||
LET checks == [invalid_argument |-> \/ volID = None
|
||||
\/ capacity.required_bytes = 0
|
||||
\/ /\ capacity.limit_bytes > 0
|
||||
/\ capacity.required_bytes > capacity.limit_bytes
|
||||
\/ /\ KeyExists(volumes, volID)
|
||||
/\ capacity.required_bytes < volumes[volID].capacity_bytes,
|
||||
not_found |-> \/ ~KeyExists(volumes, volID)
|
||||
\/ req.handle \notin {None, volumes[volID].handle},
|
||||
volume_in_use |-> /\ PublishedNodes(volID) /= {}
|
||||
/\ ~Capabilities.online_expansion
|
||||
/\ capacity.required_bytes > volumes[volID].capacity_bytes]
|
||||
IN {e \in DOMAIN checks : checks[e]}
|
||||
|
||||
\* The request was rejected, or the provider failed to answer.
|
||||
IN \/ /\ val = None
|
||||
/\ err \in Errors \union ProviderErrors
|
||||
/\ UNCHANGED vars
|
||||
|
||||
\* The request was accepted.
|
||||
\/ /\ Errors = {}
|
||||
/\ volumes' = [volumes EXCEPT ![volID].capacity_bytes = capacity.required_bytes]
|
||||
/\ val = VolumeView(volID)'
|
||||
/\ err = None
|
||||
/\ UNCHANGED <<publications, snapshots>>
|
||||
|
||||
|
||||
PublishVolumeRequests == [volume_id : Optional(VolumeIDs),
|
||||
handle : Optional(VolumeHandles),
|
||||
node_id : Optional(NodeIDs),
|
||||
read_only : BOOLEAN]
|
||||
|
||||
PublishVolume(req, val, err) ==
|
||||
(*******************************************************************************************************************)
|
||||
(* A volume's data becomes reachable on a node, through an export that a VM there can attach, either an NBD *)
|
||||
(* URI or a host block device. Reads and writes through the export reach the volume, and a read-only export *)
|
||||
(* refuses writes. Only one node at a time may reach a volume. *)
|
||||
(*******************************************************************************************************************)
|
||||
LET volID == req.volume_id
|
||||
nodeID == req.node_id
|
||||
|
||||
Errors ==
|
||||
LET checks == [invalid_argument |-> None \in {volID, nodeID},
|
||||
not_found |-> \/ ~KeyExists(volumes, volID)
|
||||
\/ req.handle \notin {None, volumes[volID].handle},
|
||||
unsupported_capability |-> /\ req.read_only
|
||||
/\ ~Capabilities.read_only_publish,
|
||||
volume_in_use |-> PublishedNodes(volID) \ {nodeID} /= {}]
|
||||
IN {e \in DOMAIN checks : checks[e]}
|
||||
|
||||
\* The request was rejected, or the provider failed to answer.
|
||||
IN \/ /\ val = None
|
||||
/\ err \in Errors \union ProviderErrors
|
||||
/\ UNCHANGED vars
|
||||
|
||||
\* The request was accepted.
|
||||
\/ /\ Errors = {}
|
||||
\* A retry returns the existing publication.
|
||||
/\ \/ /\ KeyExists(publications, <<volID, nodeID>>)
|
||||
/\ UNCHANGED publications
|
||||
|
||||
\/ /\ ~KeyExists(publications, <<volID, nodeID>>)
|
||||
\* The type of export is the implementation's choice.
|
||||
/\ \E x \in Exports :
|
||||
publications' = InsertKey(publications, <<volID, nodeID>>, [export |-> x,
|
||||
read_only |-> req.read_only])
|
||||
/\ volumes' = [volumes EXCEPT ![volID].state = "in-use"]
|
||||
/\ val = PublicationView(volID, nodeID)'
|
||||
/\ err = None
|
||||
/\ UNCHANGED snapshots
|
||||
|
||||
|
||||
UnpublishVolumeRequests == [volume_id : Optional(VolumeIDs),
|
||||
handle : Optional(VolumeHandles),
|
||||
node_id : Optional(NodeIDs)]
|
||||
|
||||
UnpublishVolume(req, err) ==
|
||||
(*******************************************************************************************************************)
|
||||
(* A volume's export on a node is torn down, so that its data is no longer reachable there. Once no node *)
|
||||
(* can reach the volume, it is available again. *)
|
||||
(*******************************************************************************************************************)
|
||||
LET volID == req.volume_id
|
||||
nodeID == req.node_id
|
||||
|
||||
Errors ==
|
||||
LET checks == [invalid_argument |-> None \in {volID, nodeID}]
|
||||
IN {e \in DOMAIN checks : checks[e]}
|
||||
|
||||
\* The request was rejected, or the provider failed to answer.
|
||||
IN \/ /\ err \in Errors \union ProviderErrors
|
||||
/\ UNCHANGED vars
|
||||
|
||||
\* The request was accepted.
|
||||
\/ /\ Errors = {}
|
||||
\* Record removal is idempotent so that retries succeed.
|
||||
/\ publications' = RemoveKey(publications, <<volID, nodeID>>)
|
||||
\* The volume is available again once no node holds it.
|
||||
/\ volumes' = IF \/ ~KeyExists(volumes, volID)
|
||||
\/ PublishedNodes(volID)' /= {}
|
||||
THEN volumes
|
||||
ELSE [volumes EXCEPT ![volID].state = "available"]
|
||||
/\ err = None
|
||||
/\ UNCHANGED snapshots
|
||||
|
||||
|
||||
DeleteVolumeRequests == [volume_id : Optional(VolumeIDs),
|
||||
handle : Optional(VolumeHandles)]
|
||||
|
||||
DeleteVolume(req, err) ==
|
||||
(*******************************************************************************************************************)
|
||||
(* A volume is deleted, so that its data is gone. Snapshots taken from it are unaffected. *)
|
||||
(*******************************************************************************************************************)
|
||||
LET volID == req.volume_id
|
||||
|
||||
Errors ==
|
||||
LET checks == [invalid_argument |-> volID = None,
|
||||
volume_in_use |-> /\ PublishedNodes(volID) /= {}
|
||||
/\ req.handle \in {None, volumes[volID].handle}]
|
||||
IN {e \in DOMAIN checks : checks[e]}
|
||||
|
||||
\* The request was rejected, or the provider failed to answer.
|
||||
IN \/ /\ err \in Errors \union ProviderErrors
|
||||
/\ UNCHANGED vars
|
||||
|
||||
\* The request was accepted.
|
||||
\/ /\ Errors = {}
|
||||
\* Record removal is idempotent so that retries succeed.
|
||||
/\ volumes' = IF /\ KeyExists(volumes, volID)
|
||||
/\ req.handle \notin {None, volumes[volID].handle}
|
||||
THEN volumes
|
||||
ELSE RemoveKey(volumes, volID)
|
||||
/\ err = None
|
||||
/\ UNCHANGED <<publications, snapshots>>
|
||||
|
||||
-----------------------------------------------------------------------------------------------------------------------
|
||||
\* Specification
|
||||
|
||||
=======================================================================================================================
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -120,7 +121,9 @@ func TestZonePublisherReloadCountsEveryNorthstarNode(t *testing.T) {
|
||||
return acks
|
||||
})
|
||||
|
||||
served, complete, err := pub.Reload(context.Background(), "example.com")
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 200*time.Millisecond)
|
||||
defer cancel()
|
||||
served, complete, err := pub.Reload(ctx, "example.com")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.complete, complete)
|
||||
assert.ElementsMatch(t, tc.served, served)
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
|
||||
"io"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -99,6 +100,16 @@ func (s *Store) ListAllVolumesStrict(ctx context.Context) ([]Volume, error) {
|
||||
return listDocuments(ctx, s, volumePrefix, "volume", UnmarshalVolume, false)
|
||||
}
|
||||
|
||||
// ListAllVolumesPartial is ListAllVolumes that hands back what it skipped
|
||||
// instead of logging it, for a caller that must decide what an unreadable
|
||||
// document means for its own answer. A document deleted mid-listing is absent.
|
||||
func (s *Store) ListAllVolumesPartial(ctx context.Context) ([]Volume, []UnreadableDocument, error) {
|
||||
if s == nil || s.objects == nil {
|
||||
return nil, nil, errors.New("metadata store is not configured")
|
||||
}
|
||||
return collectDocuments(ctx, s, volumePrefix, UnmarshalVolume, listPartial)
|
||||
}
|
||||
|
||||
func (s *Store) listVolumeDocuments(ctx context.Context, accountID string, skipCorrupt bool) ([]Volume, error) {
|
||||
if s == nil || s.objects == nil {
|
||||
return nil, errors.New("metadata store is not configured")
|
||||
@@ -219,6 +230,14 @@ func (s *Store) ListAMIsStrict(ctx context.Context) ([]AMI, error) {
|
||||
return s.listAMIDocuments(ctx, false)
|
||||
}
|
||||
|
||||
// ListAMIsPartial is ListAllVolumesPartial for AMI documents.
|
||||
func (s *Store) ListAMIsPartial(ctx context.Context) ([]AMI, []UnreadableDocument, error) {
|
||||
if s == nil || s.objects == nil {
|
||||
return nil, nil, errors.New("metadata store is not configured")
|
||||
}
|
||||
return collectDocuments(ctx, s, amiPrefix, UnmarshalAMI, listPartial)
|
||||
}
|
||||
|
||||
func (s *Store) listAMIDocuments(ctx context.Context, skipCorrupt bool) ([]AMI, error) {
|
||||
return listDocuments(ctx, s, amiPrefix, "AMI", UnmarshalAMI, skipCorrupt)
|
||||
}
|
||||
@@ -285,15 +304,43 @@ func listKeys(ctx context.Context, objects objectstore.ObjectStore, bucket, pref
|
||||
return nil, fmt.Errorf("listing %s did not finish within %d pages", prefix, maxListPages)
|
||||
}
|
||||
|
||||
// UnreadableDocument is a document a partial listing could not use. AccountID
|
||||
// is empty for an unpartitioned kind. Err wraps ErrCorruptDocument when the
|
||||
// bytes arrived but would not decode.
|
||||
type UnreadableDocument struct {
|
||||
Key string
|
||||
AccountID string
|
||||
ID string
|
||||
Err error
|
||||
}
|
||||
|
||||
// documentIdentity recovers the account and ID a document key was built from.
|
||||
func documentIdentity(prefix, key string) (accountID, id string) {
|
||||
rest := strings.TrimSuffix(strings.TrimPrefix(key, prefix), ".json")
|
||||
if account, docID, found := strings.Cut(rest, "/"); found {
|
||||
return account, docID
|
||||
}
|
||||
return "", rest
|
||||
}
|
||||
|
||||
// listMode says what a listing does with a document it cannot use.
|
||||
type listMode int
|
||||
|
||||
const (
|
||||
// listStrict fails the listing.
|
||||
listStrict listMode = iota
|
||||
// listTolerant skips it and logs the skip.
|
||||
listTolerant
|
||||
// listPartial skips it and reports it, treating a deleted one as absent.
|
||||
listPartial
|
||||
)
|
||||
|
||||
// listDocuments reads and decodes every document under prefix.
|
||||
//
|
||||
// skipCorrupt tolerates a document that cannot be fetched as well as one that
|
||||
// cannot be decoded: an object whose shards no longer join is exactly as
|
||||
// unusable as one whose bytes will not parse, and either kind read wholesale
|
||||
// as a failure turns a single bad document into a cluster-wide outage.
|
||||
//
|
||||
// Fetches run concurrently but results are assembled in key order, so a strict
|
||||
// listing fails on the same document it would have failed on serially.
|
||||
func listDocuments[T any](
|
||||
ctx context.Context,
|
||||
s *Store,
|
||||
@@ -301,9 +348,35 @@ func listDocuments[T any](
|
||||
unmarshal func([]byte) (T, error),
|
||||
skipCorrupt bool,
|
||||
) ([]T, error) {
|
||||
if !skipCorrupt {
|
||||
documents, _, err := collectDocuments(ctx, s, prefix, unmarshal, listStrict)
|
||||
return documents, err
|
||||
}
|
||||
documents, skipped, err := collectDocuments(ctx, s, prefix, unmarshal, listTolerant)
|
||||
for _, doc := range skipped {
|
||||
if errors.Is(doc.Err, ErrCorruptDocument) {
|
||||
slog.WarnContext(ctx, "skipping undecodable "+kind+" document", "key", doc.Key, "err", doc.Err)
|
||||
} else {
|
||||
slog.WarnContext(ctx, "skipping unreadable "+kind+" document", "key", doc.Key, "err", doc.Err)
|
||||
}
|
||||
}
|
||||
return documents, err
|
||||
}
|
||||
|
||||
// collectDocuments is listDocuments returning the documents it skipped.
|
||||
//
|
||||
// Fetches run concurrently but results are assembled in key order, so a strict
|
||||
// listing fails on the same document it would have failed on serially.
|
||||
func collectDocuments[T any](
|
||||
ctx context.Context,
|
||||
s *Store,
|
||||
prefix string,
|
||||
unmarshal func([]byte) (T, error),
|
||||
mode listMode,
|
||||
) ([]T, []UnreadableDocument, error) {
|
||||
keys, err := listKeys(ctx, s.objects, s.bucket, prefix)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// fetchErr and decodeErr are kept apart because they are tolerated on
|
||||
@@ -341,23 +414,31 @@ func listDocuments[T any](
|
||||
wg.Wait()
|
||||
|
||||
documents := make([]T, 0, len(results))
|
||||
var skipped []UnreadableDocument
|
||||
skip := func(key string, err error) {
|
||||
accountID, id := documentIdentity(prefix, key)
|
||||
skipped = append(skipped, UnreadableDocument{Key: key, AccountID: accountID, ID: id, Err: err})
|
||||
}
|
||||
for _, r := range results {
|
||||
switch {
|
||||
case r.fetchErr != nil:
|
||||
if !skipCorrupt {
|
||||
return nil, r.fetchErr
|
||||
if mode == listStrict {
|
||||
return nil, nil, r.fetchErr
|
||||
}
|
||||
slog.WarnContext(ctx, "skipping unreadable "+kind+" document", "key", r.key, "err", r.fetchErr)
|
||||
if mode == listPartial && objectstore.IsNoSuchKeyError(r.fetchErr) {
|
||||
continue
|
||||
}
|
||||
skip(r.key, r.fetchErr)
|
||||
case r.decodeErr != nil:
|
||||
if !skipCorrupt || !errors.Is(r.decodeErr, ErrCorruptDocument) {
|
||||
return nil, r.decodeErr
|
||||
if mode == listStrict || !errors.Is(r.decodeErr, ErrCorruptDocument) {
|
||||
return nil, nil, r.decodeErr
|
||||
}
|
||||
slog.WarnContext(ctx, "skipping undecodable "+kind+" document", "key", r.key, "err", r.decodeErr)
|
||||
skip(r.key, r.decodeErr)
|
||||
default:
|
||||
documents = append(documents, r.document)
|
||||
}
|
||||
}
|
||||
return documents, nil
|
||||
return documents, skipped, nil
|
||||
}
|
||||
|
||||
// FetchResult is one document from an explicit-ID fan-out. Err is that ID's
|
||||
|
||||
@@ -706,3 +706,74 @@ func (s *truncatedNoTokenStore) ListObjectsV2(_ context.Context, _ *s3.ListObjec
|
||||
}
|
||||
return &s3.ListObjectsV2Output{Contents: contents, IsTruncated: aws.Bool(true)}, nil
|
||||
}
|
||||
|
||||
// A partial listing hands back each document it could not use, identified by
|
||||
// the account and ID its key was built from, and leaves out one deleted
|
||||
// between listing and read.
|
||||
func TestListPartial_ReportsUnreadableAndDropsDeleted(t *testing.T) {
|
||||
objects := objectstore.NewMemoryObjectStore()
|
||||
store := NewStore(objects, "control-plane")
|
||||
ctx := context.Background()
|
||||
|
||||
require.NoError(t, store.PutVolume(ctx, Volume{VolumeID: "vol-good", TenantID: "000000000001", CapacityGiB: 1}))
|
||||
require.NoError(t, store.PutVolume(ctx, Volume{VolumeID: "vol-unreadable", TenantID: "000000000002", CapacityGiB: 1}))
|
||||
unreadableKey, err := VolumeKey("000000000002", "vol-unreadable")
|
||||
require.NoError(t, err)
|
||||
corruptKey, err := VolumeKey("000000000001", "vol-corrupt")
|
||||
require.NoError(t, err)
|
||||
writeRaw(t, objects, corruptKey, []byte("{not json"))
|
||||
badAMIKey, err := AMIKey("ami-bad")
|
||||
require.NoError(t, err)
|
||||
writeRaw(t, objects, badAMIKey, []byte("{not json"))
|
||||
require.NoError(t, store.PutAMI(ctx, AMI{ImageID: "ami-good"}))
|
||||
deletedKey, err := AMIKey("ami-deleted")
|
||||
require.NoError(t, err)
|
||||
writeRaw(t, objects, deletedKey, []byte("{}"))
|
||||
|
||||
broken := NewStore(&deletedAndFailingStore{
|
||||
ObjectStore: objects, failKey: unreadableKey, deletedKey: deletedKey,
|
||||
}, "control-plane")
|
||||
|
||||
volumes, unreadable, err := broken.ListAllVolumesPartial(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"vol-good"}, volumeIDs(volumes))
|
||||
require.Len(t, unreadable, 2)
|
||||
byID := map[string]UnreadableDocument{}
|
||||
for _, doc := range unreadable {
|
||||
byID[doc.ID] = doc
|
||||
}
|
||||
assert.Equal(t, "000000000001", byID["vol-corrupt"].AccountID)
|
||||
assert.ErrorIs(t, byID["vol-corrupt"].Err, ErrCorruptDocument)
|
||||
assert.Equal(t, "000000000002", byID["vol-unreadable"].AccountID)
|
||||
assert.NotErrorIs(t, byID["vol-unreadable"].Err, ErrCorruptDocument)
|
||||
|
||||
amis, unreadable, err := broken.ListAMIsPartial(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, amis, 1)
|
||||
assert.Equal(t, "ami-good", amis[0].ImageID)
|
||||
require.Len(t, unreadable, 1, "a deleted document is absent, not unreadable")
|
||||
assert.Equal(t, UnreadableDocument{Key: badAMIKey, ID: "ami-bad", Err: unreadable[0].Err}, unreadable[0])
|
||||
|
||||
_, _, err = (&Store{}).ListAllVolumesPartial(ctx)
|
||||
require.Error(t, err)
|
||||
_, _, err = (&Store{}).ListAMIsPartial(ctx)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
// deletedAndFailingStore fails one key's read and reports another as already
|
||||
// deleted, while still listing both.
|
||||
type deletedAndFailingStore struct {
|
||||
objectstore.ObjectStore
|
||||
|
||||
failKey, deletedKey string
|
||||
}
|
||||
|
||||
func (s *deletedAndFailingStore) GetObject(ctx context.Context, in *s3.GetObjectInput) (*s3.GetObjectOutput, error) {
|
||||
switch aws.StringValue(in.Key) {
|
||||
case s.failKey:
|
||||
return nil, errors.New("reconstruction failed")
|
||||
case s.deletedKey:
|
||||
return nil, &objectstore.NoSuchKeyError{Key: s.deletedKey}
|
||||
}
|
||||
return s.ObjectStore.GetObject(ctx, in)
|
||||
}
|
||||
|
||||
@@ -246,13 +246,13 @@ func (s *IMDSServiceImpl) dispatch(w http.ResponseWriter, r *http.Request, eni *
|
||||
case prefixMetaData + "security-groups":
|
||||
writeText(w, strings.Join(s.resolver.resolveSGNames(ctx, eni.accountID, eni.securityGroupIDs), "\n"))
|
||||
case prefixMetaData + "hostname", prefixMetaData + "local-hostname":
|
||||
writeText(w, s.privateHostname(eni.privateIP, regionFromAZ(eni.availabilityZone)))
|
||||
writeText(w, s.privateHostname(eni.privateIP, s.region))
|
||||
case prefixMetaData + "placement", prefixMetaData + "placement/":
|
||||
writeText(w, "availability-zone\nregion")
|
||||
case prefixMetaData + "placement/availability-zone":
|
||||
writeText(w, eni.availabilityZone)
|
||||
case prefixMetaData + "placement/region":
|
||||
writeText(w, regionFromAZ(eni.availabilityZone))
|
||||
writeText(w, s.region)
|
||||
case prefixMetaData + "instance-type":
|
||||
s.serveInstanceField(ctx, w, eni, func(i *instanceFacts) string { return i.instanceType })
|
||||
case prefixMetaData + "ami-id":
|
||||
@@ -419,7 +419,7 @@ func (s *IMDSServiceImpl) serveInstanceIdentityDocument(ctx context.Context, w h
|
||||
InstanceType: inst.instanceType,
|
||||
PendingTime: inst.pendingTime.UTC().Format("2006-01-02T15:04:05Z"),
|
||||
PrivateIP: eni.privateIP,
|
||||
Region: regionFromAZ(eni.availabilityZone),
|
||||
Region: s.region,
|
||||
Version: identityDocSchemaVersion,
|
||||
}
|
||||
writeIdentityDocument(w, doc)
|
||||
@@ -587,7 +587,7 @@ func (s *IMDSServiceImpl) serveNetworkInterface(ctx context.Context, w http.Resp
|
||||
case "local-ipv4s":
|
||||
writeText(w, eni.privateIP)
|
||||
case "local-hostname":
|
||||
writeText(w, s.privateHostname(eni.privateIP, regionFromAZ(eni.availabilityZone)))
|
||||
writeText(w, s.privateHostname(eni.privateIP, s.region))
|
||||
case "security-group-ids":
|
||||
writeText(w, strings.Join(eni.securityGroupIDs, "\n"))
|
||||
case "security-groups":
|
||||
@@ -697,7 +697,7 @@ func (s *IMDSServiceImpl) servePublicHostname(w http.ResponseWriter, eni *eniFac
|
||||
w.WriteHeader(http.StatusNotFound) // no public IP → no public hostname
|
||||
return
|
||||
}
|
||||
if h := s.publicHostname(eni.publicIP, regionFromAZ(eni.availabilityZone)); h != "" {
|
||||
if h := s.publicHostname(eni.publicIP, s.region); h != "" {
|
||||
writeText(w, h)
|
||||
return
|
||||
}
|
||||
@@ -772,18 +772,6 @@ func parseTokenTTL(raw string) (time.Duration, bool) {
|
||||
return time.Duration(n) * time.Second, true
|
||||
}
|
||||
|
||||
// regionFromAZ strips the trailing AZ letter from an AZ name to get the region.
|
||||
func regionFromAZ(az string) string {
|
||||
if az == "" {
|
||||
return ""
|
||||
}
|
||||
last := az[len(az)-1]
|
||||
if last >= 'a' && last <= 'z' {
|
||||
return az[:len(az)-1]
|
||||
}
|
||||
return az
|
||||
}
|
||||
|
||||
// privateHostname is the AWS-parity local hostname (ip-<dashed>.<region>.<internal
|
||||
// domain>) honoring the configured internal_domain, so it matches the record the
|
||||
// DNS writer publishes. Empty when the IP or region is missing.
|
||||
|
||||
@@ -159,6 +159,7 @@ func newTestService(res eniResolver, fIAM profileLookup, assumer stsAssumer) (*I
|
||||
roleMiss: newRoleMissLogger(func() time.Time { return now }),
|
||||
pubKeys: &fakePublicKeys{},
|
||||
now: func() time.Time { return now },
|
||||
region: "ap-southeast-2",
|
||||
baseDomain: "spx3.net",
|
||||
servicesDomain: testServicesDomain,
|
||||
}, now
|
||||
@@ -513,6 +514,43 @@ func TestHTTP_ServicesDomainEmpty404(t *testing.T) {
|
||||
assert.NotContains(t, rec.Body.String(), "amazonaws.com")
|
||||
}
|
||||
|
||||
// The region comes from the node's configuration, not the zone name, so a zone
|
||||
// named <region>-azN (no trailing letter) still reports the bare region.
|
||||
func TestHTTP_RegionFromNodeNotZoneName(t *testing.T) {
|
||||
for _, az := range []string{"us-west-1a", "us-west-1-az2"} {
|
||||
t.Run(az, func(t *testing.T) {
|
||||
eni := testENI()
|
||||
eni.availabilityZone = az
|
||||
res := &fakeResolver{eni: eni, inst: &instanceFacts{instanceType: "t3.micro", imageID: "ami-12345"}}
|
||||
svc, _ := newTestService(res, &fakeIAM{}, &fakeAssumer{})
|
||||
svc.region = "us-west-1"
|
||||
h := withTapENI(svc.httpHandler(), eni)
|
||||
token := issueToken(t, h)
|
||||
|
||||
for path, want := range map[string]string{
|
||||
prefixMetaData + "placement/availability-zone": az,
|
||||
prefixMetaData + "placement/region": "us-west-1",
|
||||
prefixMetaData + "hostname": "ip-10-0-1-5.us-west-1.compute.internal",
|
||||
prefixMetaData + "local-hostname": "ip-10-0-1-5.us-west-1.compute.internal",
|
||||
prefixMetaData + "public-hostname": "ec2-203-0-113-7.us-west-1.compute.spx3.net",
|
||||
macPath(eni.mac, "local-hostname"): "ip-10-0-1-5.us-west-1.compute.internal",
|
||||
macPath(eni.mac, "public-hostname"): "ec2-203-0-113-7.us-west-1.compute.spx3.net",
|
||||
} {
|
||||
rec := get(t, h, path, token)
|
||||
assert.Equal(t, http.StatusOK, rec.Code, "path=%s", path)
|
||||
assert.Equal(t, want, rec.Body.String(), "path=%s", path)
|
||||
}
|
||||
|
||||
rec := get(t, h, pathIdentityDocument, token)
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var doc map[string]any
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &doc))
|
||||
assert.Equal(t, "us-west-1", doc["region"])
|
||||
assert.Equal(t, az, doc["availabilityZone"])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// An instance with no public IP has no public hostname or public IPv4: both leaves
|
||||
// 404, not an empty 200, matching real EC2 and the meta-data/ listing that omits them.
|
||||
func TestHTTP_PublicFieldsAbsent404(t *testing.T) {
|
||||
|
||||
@@ -75,6 +75,7 @@ type IMDSServiceImpl struct {
|
||||
tapResp *tapResponderManager
|
||||
listTaps listTapsFunc
|
||||
now func() time.Time
|
||||
region string
|
||||
baseDomain string
|
||||
internalDomain string
|
||||
servicesDomain string
|
||||
@@ -82,7 +83,9 @@ type IMDSServiceImpl struct {
|
||||
}
|
||||
|
||||
// NewIMDSServiceImpl wires the IMDS service. listTaps is injected to avoid a
|
||||
// network/host import cycle. baseDomain and internalDomain are the cluster's
|
||||
// network/host import cycle. region is this node's configured region, served as
|
||||
// placement/region and in the identity document and hostnames; it is not
|
||||
// derived from the zone name. baseDomain and internalDomain are the cluster's
|
||||
// authoritative public and private (AWS-parity) DNS domains, used for
|
||||
// public-hostname and local-hostname so IMDS matches the records the DNS writer
|
||||
// publishes. servicesDomain is the cluster's configured AWS.ServicesDomain,
|
||||
@@ -99,7 +102,7 @@ type IMDSServiceImpl struct {
|
||||
// nil, degrading that source to a miss rather than failing IMDS to start —
|
||||
// the caller (vpcd) owns their construction and availability.
|
||||
// ctx bounds the bucket opens only; each served request carries its own.
|
||||
func NewIMDSServiceImpl(ctx context.Context, natsConn *nats.Conn, sts stsAssumer, iamSvc profileLookup, pubKeys publicKeyLookup, localState LocalStateReader, records RecordLoader, listTaps listTapsFunc, baseDomain, internalDomain, servicesDomain, caCertPath string, resolverIPs []string, bind HostBindAddrs) (*IMDSServiceImpl, error) {
|
||||
func NewIMDSServiceImpl(ctx context.Context, natsConn *nats.Conn, sts stsAssumer, iamSvc profileLookup, pubKeys publicKeyLookup, localState LocalStateReader, records RecordLoader, listTaps listTapsFunc, region, baseDomain, internalDomain, servicesDomain, caCertPath string, resolverIPs []string, bind HostBindAddrs) (*IMDSServiceImpl, error) {
|
||||
if bind.Meta == "" || bind.DNS == "" {
|
||||
return nil, errors.New("IMDS host bind addresses required (use NewHostBindAddrs)")
|
||||
}
|
||||
@@ -173,6 +176,7 @@ func NewIMDSServiceImpl(ctx context.Context, natsConn *nats.Conn, sts stsAssumer
|
||||
pubKeys: pubKeys,
|
||||
listTaps: listTaps,
|
||||
now: time.Now,
|
||||
region: region,
|
||||
baseDomain: baseDomain,
|
||||
internalDomain: internalDomain,
|
||||
servicesDomain: servicesDomain,
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
package snapshot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/aws/aws-sdk-go/service/s3"
|
||||
"github.com/mulgadc/spinifex/spinifex/bootstrap/config"
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/ec2/ebs/metadata"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/mulgadc/spinifex/spinifex/providers/ebs"
|
||||
"github.com/mulgadc/spinifex/spinifex/providers/objectstore"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// putCorruptDocument writes bytes that will not decode under key.
|
||||
func putCorruptDocument(t *testing.T, store objectstore.ObjectStore, key string) {
|
||||
t.Helper()
|
||||
_, err := store.PutObject(context.Background(), &s3.PutObjectInput{
|
||||
Bucket: aws.String("test-bucket"),
|
||||
Key: aws.String(key),
|
||||
Body: strings.NewReader("not-json"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// unreferencedSnapshot returns a snapshot nothing in the cluster reads from.
|
||||
func unreferencedSnapshot(t *testing.T, svc *SnapshotServiceImpl, store *objectstore.MemoryObjectStore) string {
|
||||
t.Helper()
|
||||
createTestVolume(t, svc, store, "vol-source", 50)
|
||||
snap, err := svc.CreateSnapshot(context.Background(), &ec2.CreateSnapshotInput{VolumeId: aws.String("vol-source")}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
return aws.StringValue(snap.SnapshotId)
|
||||
}
|
||||
|
||||
func deleteSnapshot(svc *SnapshotServiceImpl, snapshotID string) error {
|
||||
_, err := svc.DeleteSnapshot(context.Background(), &ec2.DeleteSnapshotInput{SnapshotId: aws.String(snapshotID)}, testAccountID)
|
||||
return err
|
||||
}
|
||||
|
||||
func requireSnapshotSurvives(t *testing.T, svc *SnapshotServiceImpl, snapshotID string) {
|
||||
t.Helper()
|
||||
out, err := svc.DescribeSnapshots(context.Background(), &ec2.DescribeSnapshotsInput{SnapshotIds: []*string{aws.String(snapshotID)}}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out.Snapshots, 1)
|
||||
}
|
||||
|
||||
// An unreadable volume record cannot be ruled out as a clone of the snapshot,
|
||||
// so the delete is refused, but with the record named rather than a bare code.
|
||||
func TestDeleteSnapshot_UnreadableOwnVolumeRecordIsNamed(t *testing.T) {
|
||||
svc, store := setupTestSnapshotService(t)
|
||||
snapshotID := unreferencedSnapshot(t, svc, store)
|
||||
key, err := ebsmetadata.VolumeKey(testAccountID, "vol-corrupt")
|
||||
require.NoError(t, err)
|
||||
putCorruptDocument(t, store, key)
|
||||
|
||||
err = deleteSnapshot(svc, snapshotID)
|
||||
code, message := clientErrorDetail(t, err)
|
||||
assert.Equal(t, awserrors.ErrorServerInternal, code)
|
||||
assert.Equal(t, "Cannot confirm that snapshot '"+snapshotID+"' is not in use: the record for volume 'vol-corrupt' could not be read", message)
|
||||
requireSnapshotSurvives(t, svc, snapshotID)
|
||||
}
|
||||
|
||||
// Another account's unreadable volume blocks the delete without its ID
|
||||
// reaching the caller.
|
||||
func TestDeleteSnapshot_UnreadableForeignVolumeRecordIsNotNamed(t *testing.T) {
|
||||
svc, store := setupTestSnapshotService(t)
|
||||
snapshotID := unreferencedSnapshot(t, svc, store)
|
||||
key, err := ebsmetadata.VolumeKey(otherAccountID, "vol-foreign-corrupt")
|
||||
require.NoError(t, err)
|
||||
putCorruptDocument(t, store, key)
|
||||
|
||||
err = deleteSnapshot(svc, snapshotID)
|
||||
code, message := clientErrorDetail(t, err)
|
||||
assert.Equal(t, awserrors.ErrorServerInternal, code)
|
||||
assert.Equal(t, "Cannot confirm that snapshot '"+snapshotID+"' is not in use: 1 volume record(s) could not be read", message)
|
||||
assert.NotContains(t, message, "vol-foreign-corrupt")
|
||||
requireSnapshotSurvives(t, svc, snapshotID)
|
||||
}
|
||||
|
||||
// A readable clone settles the answer whatever else failed to read, so the
|
||||
// caller gets the AWS refusal rather than an internal error.
|
||||
func TestDeleteSnapshot_ReadableCloneWinsOverUnreadableRecord(t *testing.T) {
|
||||
svc, store := setupTestSnapshotService(t)
|
||||
snapshotID := unreferencedSnapshot(t, svc, store)
|
||||
key, err := ebsmetadata.VolumeKey(testAccountID, "vol-corrupt")
|
||||
require.NoError(t, err)
|
||||
putCorruptDocument(t, store, key)
|
||||
seedVolumeDocument(t, store, ebsmetadata.Volume{VolumeID: "vol-clone", CapacityGiB: 50, SnapshotID: snapshotID})
|
||||
|
||||
code, message := clientErrorDetail(t, deleteSnapshot(svc, snapshotID))
|
||||
assert.Equal(t, awserrors.ErrorInvalidSnapshotInUse, code)
|
||||
assert.Equal(t, "The snapshot '"+snapshotID+"' is in use by volume 'vol-clone'.", message)
|
||||
}
|
||||
|
||||
// An unreadable AMI record blocks the delete, and says so.
|
||||
func TestDeleteSnapshot_UnreadableAMIRecordIsReported(t *testing.T) {
|
||||
svc, store := setupTestSnapshotService(t)
|
||||
snapshotID := unreferencedSnapshot(t, svc, store)
|
||||
key, err := ebsmetadata.AMIKey("ami-corrupt")
|
||||
require.NoError(t, err)
|
||||
putCorruptDocument(t, store, key)
|
||||
|
||||
code, message := clientErrorDetail(t, deleteSnapshot(svc, snapshotID))
|
||||
assert.Equal(t, awserrors.ErrorServerInternal, code)
|
||||
assert.Equal(t, "Cannot confirm that snapshot '"+snapshotID+"' is not in use: 1 AMI record(s) could not be read", message)
|
||||
requireSnapshotSurvives(t, svc, snapshotID)
|
||||
}
|
||||
|
||||
// A registered AMI naming the snapshot is still the AWS refusal when another
|
||||
// AMI record is unreadable.
|
||||
func TestDeleteSnapshot_RegisteredAMIWinsOverUnreadableAMI(t *testing.T) {
|
||||
svc, store := setupTestSnapshotService(t)
|
||||
snapshotID := unreferencedSnapshot(t, svc, store)
|
||||
key, err := ebsmetadata.AMIKey("ami-corrupt")
|
||||
require.NoError(t, err)
|
||||
putCorruptDocument(t, store, key)
|
||||
require.NoError(t, svc.metadata.PutAMI(context.Background(), ebsmetadata.AMI{ImageID: "ami-registered", SnapshotID: snapshotID}))
|
||||
|
||||
code, _ := clientErrorDetail(t, deleteSnapshot(svc, snapshotID))
|
||||
assert.Equal(t, awserrors.ErrorInvalidSnapshotInUse, code)
|
||||
}
|
||||
|
||||
// A registered AMI naming the snapshot is the AWS refusal even when the volume
|
||||
// scan, which runs first, could not read a record.
|
||||
func TestDeleteSnapshot_RegisteredAMIWinsOverUnreadableVolume(t *testing.T) {
|
||||
svc, store := setupTestSnapshotService(t)
|
||||
snapshotID := unreferencedSnapshot(t, svc, store)
|
||||
key, err := ebsmetadata.VolumeKey(otherAccountID, "vol-foreign-corrupt")
|
||||
require.NoError(t, err)
|
||||
putCorruptDocument(t, store, key)
|
||||
require.NoError(t, svc.metadata.PutAMI(context.Background(), ebsmetadata.AMI{ImageID: "ami-registered", SnapshotID: snapshotID}))
|
||||
|
||||
code, _ := clientErrorDetail(t, deleteSnapshot(svc, snapshotID))
|
||||
assert.Equal(t, awserrors.ErrorInvalidSnapshotInUse, code)
|
||||
requireSnapshotSurvives(t, svc, snapshotID)
|
||||
}
|
||||
|
||||
// vanishingObjectStore lists keys whose documents have already been deleted,
|
||||
// as a listing racing a DeleteVolume or DeregisterImage sees them.
|
||||
type vanishingObjectStore struct {
|
||||
objectstore.ObjectStore
|
||||
|
||||
gone map[string]bool
|
||||
}
|
||||
|
||||
func (s *vanishingObjectStore) GetObject(ctx context.Context, input *s3.GetObjectInput) (*s3.GetObjectOutput, error) {
|
||||
if key := aws.StringValue(input.Key); s.gone[key] {
|
||||
return nil, &objectstore.NoSuchKeyError{Key: key}
|
||||
}
|
||||
return s.ObjectStore.GetObject(ctx, input)
|
||||
}
|
||||
|
||||
// A record deleted between listing and read is absent, not unreadable, and
|
||||
// does not stop an unreferenced snapshot from being deleted.
|
||||
func TestDeleteSnapshot_RecordDeletedMidScanIsAbsent(t *testing.T) {
|
||||
memory := objectstore.NewMemoryObjectStore()
|
||||
volumeKey, err := ebsmetadata.VolumeKey(otherAccountID, "vol-deleted")
|
||||
require.NoError(t, err)
|
||||
amiKey, err := ebsmetadata.AMIKey("ami-deregistered")
|
||||
require.NoError(t, err)
|
||||
store := &vanishingObjectStore{ObjectStore: memory, gone: map[string]bool{volumeKey: true, amiKey: true}}
|
||||
|
||||
cfg := &config.Config{Predastore: config.PredastoreConfig{Bucket: "test-bucket"}}
|
||||
svc := NewSnapshotServiceImplWithStore(cfg, store, nil)
|
||||
svc.SetEBSProvider(ebsprovider.NewMemoryProvider(ebsprovider.Capabilities{}))
|
||||
snapshotID := unreferencedSnapshot(t, svc, memory)
|
||||
putCorruptDocument(t, memory, volumeKey)
|
||||
putCorruptDocument(t, memory, amiKey)
|
||||
|
||||
require.NoError(t, deleteSnapshot(svc, snapshotID))
|
||||
}
|
||||
|
||||
// deleteFailingProvider fails only DeleteSnapshot, with err.
|
||||
type deleteFailingProvider struct {
|
||||
ebsprovider.EBSProvider
|
||||
|
||||
err error
|
||||
}
|
||||
|
||||
func (p *deleteFailingProvider) DeleteSnapshot(context.Context, ebsprovider.DeleteSnapshotRequest) error {
|
||||
return p.err
|
||||
}
|
||||
|
||||
// A provider failure maps to a code the caller can act on, and leaves the
|
||||
// control-plane document so the delete can be retried.
|
||||
func TestDeleteSnapshot_ProviderFailureMapsToSpecificError(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
err error
|
||||
wantCode string
|
||||
}{
|
||||
{"provider unavailable", &ebsprovider.ProviderError{Code: ebsprovider.ErrorCodeUnavailable, Message: "busy"}, awserrors.ErrorServiceUnavailable},
|
||||
{"no provider responding", errors.Join(errors.New("request ebs.provider.v1.snapshot.delete"), nats.ErrNoResponders), awserrors.ErrorServiceUnavailable},
|
||||
{"request timed out", context.DeadlineExceeded, awserrors.ErrorServiceUnavailable},
|
||||
{"connection closed", nats.ErrConnectionClosed, awserrors.ErrorServiceUnavailable},
|
||||
{"nats timeout", nats.ErrTimeout, awserrors.ErrorServiceUnavailable},
|
||||
{"backend reports in use", &ebsprovider.ProviderError{Code: ebsprovider.ErrorCodeVolumeInUse, Message: "clone"}, awserrors.ErrorInvalidSnapshotInUse},
|
||||
{"internal failure", &ebsprovider.ProviderError{Code: ebsprovider.ErrorCodeInternal, Message: "delete snapshot objects: boom"}, awserrors.ErrorServerInternal},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
svc, store := setupTestSnapshotService(t)
|
||||
snapshotID := unreferencedSnapshot(t, svc, store)
|
||||
svc.SetEBSProvider(&deleteFailingProvider{EBSProvider: svc.provider, err: tc.err})
|
||||
|
||||
code, message := clientErrorDetail(t, deleteSnapshot(svc, snapshotID))
|
||||
assert.Equal(t, tc.wantCode, code)
|
||||
assert.Contains(t, message, snapshotID)
|
||||
requireSnapshotSurvives(t, svc, snapshotID)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -670,12 +670,9 @@ func (s *SnapshotServiceImpl) snapshotInUseByVolumes(ctx context.Context, snapsh
|
||||
// point: launching from a system AMI writes a root volume owned by the
|
||||
// launching tenant whose SnapshotID is the system snapshot, so a clone
|
||||
// routinely lives outside the snapshot owner's prefix.
|
||||
//
|
||||
// Strict: a volume that failed to read is not evidence that no clone reads
|
||||
// the snapshot's chunks, and this answer gates deleting them.
|
||||
volumes, err := s.metadata.ListAllVolumesStrict(ctx)
|
||||
volumes, unreadable, err := s.metadata.ListAllVolumesPartial(ctx)
|
||||
if err != nil {
|
||||
return false, "", fmt.Errorf("snapshotInUseByVolumes: failed to list volumes: %w", err)
|
||||
return false, "", awserrors.Errorf(awserrors.ErrorServerInternal, "check whether snapshot %s is in use: list volumes: %v", snapshotID, err)
|
||||
}
|
||||
inUse := false
|
||||
for _, volume := range volumes {
|
||||
@@ -687,28 +684,71 @@ func (s *SnapshotServiceImpl) snapshotInUseByVolumes(ctx context.Context, snapsh
|
||||
}
|
||||
inUse = true
|
||||
}
|
||||
return inUse, "", nil
|
||||
if inUse {
|
||||
return true, "", nil
|
||||
}
|
||||
// A volume that failed to read is not evidence that no clone reads the
|
||||
// snapshot's chunks, and this answer gates deleting them.
|
||||
if len(unreadable) > 0 {
|
||||
return false, "", unreadableRecordError(ctx, snapshotID, accountID, "volume", unreadable)
|
||||
}
|
||||
return false, "", nil
|
||||
}
|
||||
|
||||
// amiInUseBySnapshot checks if any registered AMI names the given snapshot as
|
||||
// its root device. AWS refuses DeleteSnapshot while a registered AMI
|
||||
// references the snapshot, the same way it refuses one backing a volume.
|
||||
func (s *SnapshotServiceImpl) amiInUseBySnapshot(ctx context.Context, snapshotID string) (bool, error) {
|
||||
// Whole-cluster and strict for the same reason as the volume check above:
|
||||
// a system AMI can be registered by an account other than the snapshot's
|
||||
// owner, and an AMI this cannot read is not evidence that none exists.
|
||||
amis, err := s.metadata.ListAMIsStrict(ctx)
|
||||
func (s *SnapshotServiceImpl) amiInUseBySnapshot(ctx context.Context, snapshotID, accountID string) (bool, error) {
|
||||
// Whole-cluster and fail-closed for the same reason as the volume check
|
||||
// above: a system AMI can be registered by an account other than the
|
||||
// snapshot's owner, and an AMI this cannot read is not evidence none exists.
|
||||
amis, unreadable, err := s.metadata.ListAMIsPartial(ctx)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("amiInUseBySnapshot: failed to list AMIs: %w", err)
|
||||
return false, awserrors.Errorf(awserrors.ErrorServerInternal, "check whether snapshot %s is in use: list AMIs: %v", snapshotID, err)
|
||||
}
|
||||
for _, ami := range amis {
|
||||
if ami.SnapshotID == snapshotID {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
if len(unreadable) > 0 {
|
||||
return false, unreadableRecordError(ctx, snapshotID, accountID, "AMI", unreadable)
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// unreadableRecordError refuses a delete the in-use scan could not clear. It
|
||||
// names a record only when the caller owns it: the scan is whole-cluster, and
|
||||
// an AMI record that will not read cannot say who owns it.
|
||||
func unreadableRecordError(ctx context.Context, snapshotID, accountID, kind string, unreadable []ebsmetadata.UnreadableDocument) error {
|
||||
for _, doc := range unreadable {
|
||||
slog.ErrorContext(ctx, "DeleteSnapshot cannot read "+kind+" record", "snapshotId", snapshotID, "key", doc.Key, "err", doc.Err)
|
||||
}
|
||||
for _, doc := range unreadable {
|
||||
if doc.AccountID != "" && doc.AccountID == accountID {
|
||||
return awserrors.Errorf(awserrors.ErrorServerInternal,
|
||||
"Cannot confirm that snapshot '%s' is not in use: the record for %s '%s' could not be read", snapshotID, kind, doc.ID)
|
||||
}
|
||||
}
|
||||
return awserrors.Errorf(awserrors.ErrorServerInternal,
|
||||
"Cannot confirm that snapshot '%s' is not in use: %d %s record(s) could not be read", snapshotID, len(unreadable), kind)
|
||||
}
|
||||
|
||||
// providerDeleteError maps a provider DeleteSnapshot failure to the code a
|
||||
// client can act on. A transport failure is retryable: the delete is idempotent.
|
||||
func providerDeleteError(snapshotID string, err error) error {
|
||||
switch {
|
||||
case errors.Is(err, ebsprovider.ErrVolumeInUse):
|
||||
return awserrors.Errorf(awserrors.ErrorInvalidSnapshotInUse, "The snapshot '%s' is in use by a volume.", snapshotID)
|
||||
case errors.Is(err, ebsprovider.ErrUnavailable), errors.Is(err, nats.ErrNoResponders),
|
||||
errors.Is(err, nats.ErrConnectionClosed), errors.Is(err, nats.ErrTimeout),
|
||||
errors.Is(err, context.DeadlineExceeded):
|
||||
return awserrors.Errorf(awserrors.ErrorServiceUnavailable, "delete snapshot %s: storage provider unavailable: %v", snapshotID, err)
|
||||
default:
|
||||
return awserrors.Errorf(awserrors.ErrorServerInternal, "delete snapshot %s: %v", snapshotID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteSnapshot deletes a snapshot after verifying the caller owns it.
|
||||
func (s *SnapshotServiceImpl) DeleteSnapshot(ctx context.Context, input *ec2.DeleteSnapshotInput, accountID string) (*ec2.DeleteSnapshotOutput, error) {
|
||||
if input == nil || input.SnapshotId == nil {
|
||||
@@ -739,11 +779,9 @@ func (s *SnapshotServiceImpl) DeleteSnapshot(ctx context.Context, input *ec2.Del
|
||||
}
|
||||
|
||||
// Check if any volumes were created from this snapshot
|
||||
inUse, volumeID, err := s.snapshotInUseByVolumes(ctx, snapshotID, accountID)
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "DeleteSnapshot failed to check snapshot usage", "snapshotId", snapshotID, "err", err)
|
||||
return nil, errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
// A scan's failure is held until both have run, so a readable reference
|
||||
// found by either answers InvalidSnapshot.InUse.
|
||||
inUse, volumeID, volumeErr := s.snapshotInUseByVolumes(ctx, snapshotID, accountID)
|
||||
if inUse {
|
||||
slog.InfoContext(ctx, "DeleteSnapshot blocked: snapshot in use by volume", "snapshotId", snapshotID, "volumeId", volumeID)
|
||||
// The registered message blames AMIs. Name the volume only when the
|
||||
@@ -755,21 +793,26 @@ func (s *SnapshotServiceImpl) DeleteSnapshot(ctx context.Context, input *ec2.Del
|
||||
}
|
||||
|
||||
// Check if any registered AMI names this snapshot as its root device
|
||||
amiInUse, err := s.amiInUseBySnapshot(ctx, snapshotID)
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "DeleteSnapshot failed to check AMI usage", "snapshotId", snapshotID, "err", err)
|
||||
return nil, errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
amiInUse, amiErr := s.amiInUseBySnapshot(ctx, snapshotID, accountID)
|
||||
if amiInUse {
|
||||
slog.InfoContext(ctx, "DeleteSnapshot blocked: snapshot in use by registered AMI", "snapshotId", snapshotID)
|
||||
return nil, errors.New(awserrors.ErrorInvalidSnapshotInUse)
|
||||
}
|
||||
if volumeErr != nil {
|
||||
slog.ErrorContext(ctx, "DeleteSnapshot failed to check snapshot usage", "snapshotId", snapshotID, "err", volumeErr)
|
||||
return nil, volumeErr
|
||||
}
|
||||
if amiErr != nil {
|
||||
slog.ErrorContext(ctx, "DeleteSnapshot failed to check AMI usage", "snapshotId", snapshotID, "err", amiErr)
|
||||
return nil, amiErr
|
||||
}
|
||||
if s.provider == nil {
|
||||
slog.ErrorContext(ctx, "no EBS provider configured", "op", "DeleteSnapshot")
|
||||
return nil, errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
if err := s.provider.DeleteSnapshot(ctx, ebsprovider.DeleteSnapshotRequest{Versioned: ebsprovider.NewVersioned(), SnapshotID: snapshotID, Handle: cfg.ProviderHandle}); err != nil {
|
||||
return nil, errors.New(awserrors.ErrorServerInternal)
|
||||
slog.ErrorContext(ctx, "DeleteSnapshot provider deletion failed", "snapshotId", snapshotID, "err", err)
|
||||
return nil, providerDeleteError(snapshotID, err)
|
||||
}
|
||||
|
||||
objects, _, err := objectstore.ListAll(ctx, s.store, &s3.ListObjectsV2Input{
|
||||
|
||||
@@ -602,42 +602,6 @@ func TestDeleteSnapshot_InUseByRegisteredAMI_AnotherAccount(t *testing.T) {
|
||||
assert.Contains(t, err.Error(), awserrors.ErrorInvalidSnapshotInUse)
|
||||
}
|
||||
|
||||
// TestDeleteSnapshot_AMIReadFailureFailsClosed tests that an AMI document
|
||||
// this cannot decode aborts the delete rather than being treated as absent:
|
||||
// a partial AMI listing is not evidence that no AMI names the snapshot.
|
||||
func TestDeleteSnapshot_AMIReadFailureFailsClosed(t *testing.T) {
|
||||
svc, store := setupTestSnapshotService(t)
|
||||
|
||||
createTestVolume(t, svc, store, "vol-source", 50)
|
||||
snap, err := svc.CreateSnapshot(context.Background(), &ec2.CreateSnapshotInput{
|
||||
VolumeId: aws.String("vol-source"),
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
key, err := ebsmetadata.AMIKey("ami-corrupt")
|
||||
require.NoError(t, err)
|
||||
_, err = store.PutObject(context.Background(), &s3.PutObjectInput{
|
||||
Bucket: aws.String("test-bucket"),
|
||||
Key: aws.String(key),
|
||||
Body: strings.NewReader("not-json"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = svc.DeleteSnapshot(context.Background(), &ec2.DeleteSnapshotInput{
|
||||
SnapshotId: snap.SnapshotId,
|
||||
}, testAccountID)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), awserrors.ErrorServerInternal)
|
||||
|
||||
// Nothing was deleted: the snapshot survives in both the provider and the
|
||||
// control-plane document.
|
||||
result, err := svc.DescribeSnapshots(context.Background(), &ec2.DescribeSnapshotsInput{
|
||||
SnapshotIds: []*string{snap.SnapshotId},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, result.Snapshots, 1)
|
||||
}
|
||||
|
||||
// TestDeleteSnapshot_NotFound tests deleting a non-existent snapshot.
|
||||
func TestDeleteSnapshot_NotFound(t *testing.T) {
|
||||
svc, _ := setupTestSnapshotService(t)
|
||||
|
||||
@@ -86,6 +86,23 @@ const ngCASMaxRetries = 16
|
||||
// service boundary.
|
||||
var ErrNodegroupNotFound = errors.New("eks: nodegroup not found")
|
||||
|
||||
// nodegroupNotFound is the AWS answer for a missing node group on an existing
|
||||
// cluster; a missing cluster keeps the code's default cluster message.
|
||||
func nodegroupNotFound(ng string) error {
|
||||
return awserrors.Errorf(awserrors.ErrorEKSResourceNotFound, "No node group found for name: %s.", ng)
|
||||
}
|
||||
|
||||
// requireCluster returns ResourceNotFoundException when the cluster is absent.
|
||||
func requireCluster(ctx context.Context, kv jetstream.KeyValue, cluster string) error {
|
||||
if _, err := GetClusterMeta(ctx, kv, cluster); err != nil {
|
||||
if errors.Is(err, ErrClusterNotFound) {
|
||||
return errors.New(awserrors.ErrorEKSResourceNotFound)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PutNodegroupRecord writes the record unconditionally.
|
||||
func PutNodegroupRecord(ctx context.Context, kv jetstream.KeyValue, rec *NodegroupRecord) error {
|
||||
if rec == nil {
|
||||
@@ -841,10 +858,13 @@ func (s *EKSServiceImpl) describeNodegroup(ctx context.Context, acctKV jetstream
|
||||
if cluster == "" || ng == "" {
|
||||
return nil, errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
if err := requireCluster(ctx, acctKV, cluster); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rec, err := GetNodegroupRecord(ctx, acctKV, cluster, ng)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNodegroupNotFound) {
|
||||
return nil, errors.New(awserrors.ErrorEKSResourceNotFound)
|
||||
return nil, nodegroupNotFound(ng)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
@@ -914,7 +934,7 @@ func (s *EKSServiceImpl) reconcileNodegroup(ctx context.Context, acctKV jetstrea
|
||||
rec, rev, err := getNodegroupEntry(ctx, acctKV, cluster, ng)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNodegroupNotFound) {
|
||||
return nil, errors.New(awserrors.ErrorEKSResourceNotFound)
|
||||
return nil, nodegroupNotFound(ng)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
@@ -1124,10 +1144,13 @@ func (s *EKSServiceImpl) deleteNodegroup(ctx context.Context, acctKV jetstream.K
|
||||
if cluster == "" || ng == "" {
|
||||
return nil, errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
if err := requireCluster(ctx, acctKV, cluster); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rec, err := GetNodegroupRecord(ctx, acctKV, cluster, ng)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNodegroupNotFound) {
|
||||
return nil, errors.New(awserrors.ErrorEKSResourceNotFound)
|
||||
return nil, nodegroupNotFound(ng)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -768,7 +768,9 @@ func TestDescribeAndListNodegroups(t *testing.T) {
|
||||
_, err = f.svc.DescribeNodegroup(context.Background(), &eks.DescribeNodegroupInput{
|
||||
ClusterName: aws.String("c1"), NodegroupName: aws.String("ghost"),
|
||||
}, testAccountID)
|
||||
require.EqualError(t, err, awserrors.ErrorEKSResourceNotFound)
|
||||
code, ok := awserrors.ResolveErrorCode(err)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, awserrors.ErrorEKSResourceNotFound, code)
|
||||
}
|
||||
|
||||
func TestUpdateNodegroupConfig_ScaleUpAndDown(t *testing.T) {
|
||||
@@ -922,7 +924,9 @@ func TestDeleteNodegroup_TerminatesAndIsIdempotent(t *testing.T) {
|
||||
_, err = f.svc.DeleteNodegroup(context.Background(), &eks.DeleteNodegroupInput{
|
||||
ClusterName: aws.String("c1"), NodegroupName: aws.String("ng1"),
|
||||
}, testAccountID)
|
||||
require.EqualError(t, err, awserrors.ErrorEKSResourceNotFound)
|
||||
code, ok := awserrors.ResolveErrorCode(err)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, awserrors.ErrorEKSResourceNotFound, code)
|
||||
}
|
||||
|
||||
// TestSelectWorkerHost_NoSchedulerOrCapacity returns "" so the caller falls back
|
||||
@@ -1335,3 +1339,69 @@ func TestUpdateNodegroupConfig_HonoursTaintsAndUpdateConfig(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, rec.Taints)
|
||||
}
|
||||
|
||||
func TestNodegroupCalls_MissingNodegroupVsMissingCluster(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := newEKSServiceFixture(t)
|
||||
seedActiveClusterWithToken(t, f, "c1")
|
||||
ctx := context.Background()
|
||||
|
||||
calls := map[string]func(cluster string) error{
|
||||
"DescribeNodegroup": func(cluster string) error {
|
||||
_, err := f.svc.DescribeNodegroup(ctx, &eks.DescribeNodegroupInput{
|
||||
ClusterName: aws.String(cluster), NodegroupName: aws.String("ghost"),
|
||||
}, testAccountID)
|
||||
return err
|
||||
},
|
||||
"UpdateNodegroupConfig": func(cluster string) error {
|
||||
_, err := f.svc.UpdateNodegroupConfig(ctx, &eks.UpdateNodegroupConfigInput{
|
||||
ClusterName: aws.String(cluster), NodegroupName: aws.String("ghost"),
|
||||
ScalingConfig: &eks.NodegroupScalingConfig{DesiredSize: aws.Int64(1)},
|
||||
}, testAccountID)
|
||||
return err
|
||||
},
|
||||
"DeleteNodegroup": func(cluster string) error {
|
||||
_, err := f.svc.DeleteNodegroup(ctx, &eks.DeleteNodegroupInput{
|
||||
ClusterName: aws.String(cluster), NodegroupName: aws.String("ghost"),
|
||||
}, testAccountID)
|
||||
return err
|
||||
},
|
||||
}
|
||||
for name, call := range calls {
|
||||
t.Run(name+"/missing nodegroup", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
code, msg, ok := awserrors.ResolveErrorDetail(call("c1"))
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, awserrors.ErrorEKSResourceNotFound, code)
|
||||
assert.Equal(t, "No node group found for name: ghost.", msg)
|
||||
})
|
||||
t.Run(name+"/missing cluster", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
code, msg, ok := awserrors.ResolveErrorDetail(call("nope"))
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, awserrors.ErrorEKSResourceNotFound, code)
|
||||
assert.Empty(t, msg, "a missing cluster keeps the default cluster message")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodegroupCalls_UnreadableClusterSurfacesError(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := newEKSServiceFixture(t)
|
||||
ctx := t.Context()
|
||||
_, err := f.kv.Put(ctx, ClusterMetaKey("c1"), []byte("{not json"))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, PutNodegroupRecord(ctx, f.kv, &NodegroupRecord{ClusterName: "c1", Name: "ng1"}))
|
||||
|
||||
_, err = f.svc.DescribeNodegroup(ctx, &eks.DescribeNodegroupInput{
|
||||
ClusterName: aws.String("c1"), NodegroupName: aws.String("ng1"),
|
||||
}, testAccountID)
|
||||
require.ErrorContains(t, err, "unmarshal cluster meta")
|
||||
|
||||
_, err = f.svc.DeleteNodegroup(ctx, &eks.DeleteNodegroupInput{
|
||||
ClusterName: aws.String("c1"), NodegroupName: aws.String("ng1"),
|
||||
}, testAccountID)
|
||||
require.ErrorContains(t, err, "unmarshal cluster meta")
|
||||
_, err = GetNodegroupRecord(ctx, f.kv, "c1", "ng1")
|
||||
require.NoError(t, err, "a failed cluster read must not delete the node group")
|
||||
}
|
||||
|
||||
@@ -505,6 +505,7 @@ func TestEKSServiceImpl_NodegroupTagRoundTrip(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
const arn = "arn:aws:eks:us-east-1:111122223333:nodegroup/c1/ng1/abc123"
|
||||
require.NoError(t, PutClusterMeta(t.Context(), kv, &ClusterMeta{Name: "c1", Status: ClusterStatusActive}))
|
||||
require.NoError(t, PutNodegroupRecord(t.Context(), kv, &NodegroupRecord{
|
||||
ClusterName: "c1",
|
||||
Name: "ng1",
|
||||
|
||||
@@ -377,6 +377,7 @@ func (t *countingErrTransport) RoundTrip(req *http.Request) (*http.Response, err
|
||||
}
|
||||
|
||||
func TestEmbeddingsProvider_EmbedBatch_RetriesTransportErrorThenSucceeds(t *testing.T) {
|
||||
t.Parallel()
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
@@ -402,6 +403,7 @@ func TestEmbeddingsProvider_EmbedBatch_RetriesTransportErrorThenSucceeds(t *test
|
||||
}
|
||||
|
||||
func TestEmbeddingsProvider_EmbedBatch_GivesUpAfterTransportRetryCap(t *testing.T) {
|
||||
t.Parallel()
|
||||
transport := &countingErrTransport{
|
||||
failures: embedTransportRetries + 10, // always fail
|
||||
err: &net.OpError{Op: "dial", Net: "tcp", Err: syscall.ECONNREFUSED},
|
||||
|
||||
@@ -143,6 +143,7 @@ func TestModelInvocationLoggingConfiguration_RoundTripsViaSDKStructs(t *testing.
|
||||
// failure must never fail the invocation" contract: Record has no error
|
||||
// return at all, so the only way to observe a failed publish is Dropped().
|
||||
func TestStreamRecorder_DropsAndCountsWhenStreamMissing(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, js := testutil.StartTestJetStream(t)
|
||||
// Deliberately skip EnsureInvocationStream: publishing to a subject no
|
||||
// stream captures must fail cleanly (no responders), not panic or block.
|
||||
|
||||
@@ -296,6 +296,7 @@ func TestDynamicEndpointResolver_ColdStartWaitDefaultsToNoWait(t *testing.T) {
|
||||
// With a wait configured, a launch that lands inside the budget is served on
|
||||
// the original call rather than a retry.
|
||||
func TestDynamicEndpointResolver_ColdStartWaitResolvesWhenReady(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := &fakeEndpointService{record: EndpointRecord{ModelID: resolverTestModel, State: StateAbsent}}
|
||||
r := NewDynamicEndpointResolver(svc, nil, 0, WithColdStartWait(5*time.Second))
|
||||
|
||||
@@ -314,6 +315,7 @@ func TestDynamicEndpointResolver_ColdStartWaitResolvesWhenReady(t *testing.T) {
|
||||
// A launch slower than the budget still gets the retryable answer, and the
|
||||
// budget is what bounds the hold.
|
||||
func TestDynamicEndpointResolver_ColdStartWaitGivesUpAtTheBudget(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := &fakeEndpointService{record: EndpointRecord{ModelID: resolverTestModel, State: StateAbsent}}
|
||||
r := NewDynamicEndpointResolver(svc, nil, 0, WithColdStartWait(600*time.Millisecond))
|
||||
|
||||
@@ -329,6 +331,7 @@ func TestDynamicEndpointResolver_ColdStartWaitGivesUpAtTheBudget(t *testing.T) {
|
||||
// A client that disconnects mid-wait releases the goroutine without turning
|
||||
// its own departure into a server error; the launch it triggered carries on.
|
||||
func TestDynamicEndpointResolver_ColdStartWaitStopsOnClientCancel(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := &fakeEndpointService{record: EndpointRecord{ModelID: resolverTestModel, State: StateAbsent}}
|
||||
r := NewDynamicEndpointResolver(svc, nil, 0, WithColdStartWait(time.Minute))
|
||||
|
||||
@@ -346,6 +349,7 @@ func TestDynamicEndpointResolver_ColdStartWaitStopsOnClientCancel(t *testing.T)
|
||||
|
||||
// A burst of cold callers shares one wait, not one describe loop each.
|
||||
func TestDynamicEndpointResolver_ColdStartWaitIsSharedAcrossCallers(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := &fakeEndpointService{record: EndpointRecord{ModelID: resolverTestModel, State: StateAbsent}}
|
||||
r := NewDynamicEndpointResolver(svc, nil, 0, WithColdStartWait(3*time.Second))
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ const (
|
||||
maxListRecordSets = 300
|
||||
maxTagsPerResource = 50
|
||||
maxTagResources = 10
|
||||
maxTagsPerChange = 10
|
||||
maxTagKeyLength = 128
|
||||
changeRetention = 7 * 24 * time.Hour
|
||||
commitAttempts = 3
|
||||
)
|
||||
@@ -380,12 +382,22 @@ func (s *Service) ChangeTagsForResource(ctx context.Context, in *route53.ChangeT
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// A list that is sent carries 1 to 10 entries, as the model bounds it.
|
||||
if in.AddTags != nil && (len(in.AddTags) == 0 || len(in.AddTags) > maxTagsPerChange) ||
|
||||
in.RemoveTagKeys != nil && (len(in.RemoveTagKeys) == 0 || len(in.RemoveTagKeys) > maxTagsPerChange) {
|
||||
return nil, awserrors.Errorf(awserrors.ErrorInvalidInput, "AddTags and RemoveTagKeys must each hold 1 to %d entries", maxTagsPerChange)
|
||||
}
|
||||
for _, t := range in.AddTags {
|
||||
key := aws.StringValue(t.Key)
|
||||
if key == "" || len(key) > 128 || len(aws.StringValue(t.Value)) > 256 {
|
||||
if key == "" || len(key) > maxTagKeyLength || len(aws.StringValue(t.Value)) > 256 {
|
||||
return nil, awserrors.Errorf(awserrors.ErrorInvalidInput, "tag keys must be 1 to 128 characters and values at most 256")
|
||||
}
|
||||
}
|
||||
for _, k := range in.RemoveTagKeys {
|
||||
if len(aws.StringValue(k)) > maxTagKeyLength {
|
||||
return nil, awserrors.Errorf(awserrors.ErrorInvalidInput, "tag keys must be at most %d characters", maxTagKeyLength)
|
||||
}
|
||||
}
|
||||
err = s.store.MutateZone(ctx, account, id, func(z *ZoneRecord) (bool, error) {
|
||||
if z.Tags == nil {
|
||||
z.Tags = map[string]string{}
|
||||
|
||||
@@ -454,14 +454,47 @@ func TestHostedZoneTags(t *testing.T) {
|
||||
_, err = svc.ListTagsForResource(ctx, &r53.ListTagsForResourceInput{ResourceType: aws.String("healthcheck"), ResourceId: aws.String("hc")}, acctA)
|
||||
requireCode(t, err, awserrors.ErrorRoute53NoSuchHealthCheck)
|
||||
|
||||
var tags []*r53.Tag
|
||||
for i := range 51 {
|
||||
tags = append(tags, &r53.Tag{Key: aws.String(fmt.Sprintf("k%d", i)), Value: aws.String("v")})
|
||||
tagged := func(keys ...string) *r53.ChangeTagsForResourceInput {
|
||||
in := &r53.ChangeTagsForResourceInput{ResourceType: aws.String("hostedzone"), ResourceId: aws.String(bare)}
|
||||
for _, k := range keys {
|
||||
in.AddTags = append(in.AddTags, &r53.Tag{Key: aws.String(k), Value: aws.String("v")})
|
||||
}
|
||||
return in
|
||||
}
|
||||
_, err = svc.ChangeTagsForResource(ctx, &r53.ChangeTagsForResourceInput{ResourceType: aws.String("hostedzone"), ResourceId: aws.String(bare), AddTags: tags}, acctA)
|
||||
// One change carries at most 10 tags; with env, 49 more fill the zone.
|
||||
for first := 0; first < 49; first += 10 {
|
||||
var keys []string
|
||||
for i := first; i < min(first+10, 49); i++ {
|
||||
keys = append(keys, fmt.Sprintf("k%d", i))
|
||||
}
|
||||
_, err = svc.ChangeTagsForResource(ctx, tagged(keys...), acctA)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
_, err = svc.ChangeTagsForResource(ctx, tagged("k49"), acctA)
|
||||
requireCode(t, err, awserrors.ErrorRoute53TooManyTagsForZone)
|
||||
}
|
||||
|
||||
func TestChangeTagsForResourceBoundsItsLists(t *testing.T) {
|
||||
svc := newService(t)
|
||||
bare := strings.TrimPrefix(createZone(t, svc, acctA, "example.com"), "/hostedzone/")
|
||||
eleven := make([]*r53.Tag, 11)
|
||||
for i := range eleven {
|
||||
eleven[i] = &r53.Tag{Key: aws.String(fmt.Sprintf("k%d", i)), Value: aws.String("v")}
|
||||
}
|
||||
for name, in := range map[string]*r53.ChangeTagsForResourceInput{
|
||||
"empty AddTags": {AddTags: []*r53.Tag{}},
|
||||
"11 AddTags": {AddTags: eleven},
|
||||
"empty RemoveTagKeys": {RemoveTagKeys: []*string{}},
|
||||
"11 RemoveTagKeys": {RemoveTagKeys: aws.StringSlice(strings.Split("a b c d e f g h i j k", " "))},
|
||||
"long RemoveTagKey": {RemoveTagKeys: aws.StringSlice([]string{strings.Repeat("k", 129)})},
|
||||
} {
|
||||
in.ResourceType, in.ResourceId = aws.String("hostedzone"), aws.String(bare)
|
||||
_, err := svc.ChangeTagsForResource(ctx, in, acctA)
|
||||
require.Error(t, err, name)
|
||||
requireCode(t, err, awserrors.ErrorInvalidInput)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangesOnAnActiveZoneWaitForSync(t *testing.T) {
|
||||
svc := newService(t)
|
||||
id := createZone(t, svc, acctA, "example.com")
|
||||
@@ -510,3 +543,20 @@ func TestChangeRecordsArePrunedAfterRetention(t *testing.T) {
|
||||
_, err = svc.GetChange(ctx, &r53.GetChangeInput{Id: first.ChangeInfo.Id}, acctA)
|
||||
requireCode(t, err, awserrors.ErrorNoSuchChange)
|
||||
}
|
||||
|
||||
func TestLookupsOfUnissuableIDsNameNothing(t *testing.T) {
|
||||
svc := newService(t)
|
||||
id := createZone(t, svc, acctA, "example.com")
|
||||
|
||||
// A key this long would exceed the NATS control line and close the
|
||||
// connection every later request shares.
|
||||
_, err := svc.GetChange(ctx, &r53.GetChangeInput{Id: aws.String(strings.Repeat("C", 6500))}, acctA)
|
||||
requireCode(t, err, awserrors.ErrorNoSuchChange)
|
||||
for _, bad := range []string{strings.Repeat("Z", 65), "Z1.*", "Z1 >"} {
|
||||
_, err = svc.GetHostedZone(ctx, &r53.GetHostedZoneInput{Id: aws.String(bad)}, acctA)
|
||||
requireCode(t, err, awserrors.ErrorNoSuchHostedZone)
|
||||
}
|
||||
|
||||
_, err = svc.GetHostedZone(ctx, &r53.GetHostedZoneInput{Id: aws.String(id)}, acctA)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
@@ -152,8 +152,31 @@ func refKey(account, ref string) string {
|
||||
return keyPrefixCallerRf + kvutil.AccountKey(account, refDigest(ref))
|
||||
}
|
||||
|
||||
// maxIssuedIDLength bounds the IDs a caller can look up. An overlong key
|
||||
// exceeds the NATS control line, and the server closes the connection.
|
||||
const maxIssuedIDLength = 64
|
||||
|
||||
// issuable reports whether id has the form of a zone or change ID, which is
|
||||
// all a lookup key may hold. Anything else names nothing.
|
||||
func issuable(id string) bool {
|
||||
if id == "" || len(id) > maxIssuedIDLength {
|
||||
return false
|
||||
}
|
||||
for _, r := range id {
|
||||
if (r < 'A' || r > 'Z') && (r < 'a' || r > 'z') && (r < '0' || r > '9') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func notIssued(id string) error { return fmt.Errorf("%w: %.64q", kvstore.ErrNotFound, id) }
|
||||
|
||||
// GetZone returns an account's zone, or kvstore.ErrNotFound.
|
||||
func (s *Store) GetZone(ctx context.Context, account, id string) (*ZoneRecord, error) {
|
||||
if !issuable(id) {
|
||||
return nil, notIssued(id)
|
||||
}
|
||||
z, _, err := s.zones.Get(ctx, zoneKey(account, id))
|
||||
return z, err
|
||||
}
|
||||
@@ -171,11 +194,17 @@ func (s *Store) ListAllZones(ctx context.Context) ([]ZoneRecord, error) {
|
||||
|
||||
// MutateZone applies fn to a zone under CAS.
|
||||
func (s *Store) MutateZone(ctx context.Context, account, id string, fn func(*ZoneRecord) (bool, error)) error {
|
||||
if !issuable(id) {
|
||||
return notIssued(id)
|
||||
}
|
||||
return s.zones.Mutate(ctx, zoneKey(account, id), fn)
|
||||
}
|
||||
|
||||
// GetRRSets returns a zone's record sets and the revision to CAS against.
|
||||
func (s *Store) GetRRSets(ctx context.Context, account, id string) (*RRSetsDoc, uint64, error) {
|
||||
if !issuable(id) {
|
||||
return nil, 0, notIssued(id)
|
||||
}
|
||||
return s.rrsets.Get(ctx, rrsetsKey(account, id))
|
||||
}
|
||||
|
||||
@@ -187,6 +216,9 @@ func (s *Store) CommitRRSets(ctx context.Context, account string, doc *RRSetsDoc
|
||||
|
||||
// GetChange returns an account's change, or kvstore.ErrNotFound.
|
||||
func (s *Store) GetChange(ctx context.Context, account, id string) (*ChangeRecord, error) {
|
||||
if !issuable(id) {
|
||||
return nil, notIssued(id)
|
||||
}
|
||||
c, _, err := s.changes.Get(ctx, changeKey(account, id))
|
||||
return c, err
|
||||
}
|
||||
|
||||
@@ -14,8 +14,13 @@ import (
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
)
|
||||
|
||||
// defaultResourcesPerPage is used when the caller omits ResourcesPerPage (AWS max is 100).
|
||||
const defaultResourcesPerPage = 100
|
||||
// ResourcesPerPage bounds, from the AWS API reference; the default applies
|
||||
// when the caller omits it.
|
||||
const (
|
||||
minResourcesPerPage = 1
|
||||
maxResourcesPerPage = 100
|
||||
defaultResourcesPerPage = maxResourcesPerPage
|
||||
)
|
||||
|
||||
// Aggregator serves GetResources over the owner listers the composition root
|
||||
// wires. Listers are called in the order given; it holds no state of its own.
|
||||
@@ -114,11 +119,20 @@ func matchesAllTagFilters(tags []*rgt.Tag, filters []*rgt.TagFilter) bool {
|
||||
// paginate slices a page from the sorted mappings and returns the page and the
|
||||
// next token (empty when exhausted).
|
||||
func paginate(mappings []*rgt.ResourceTagMapping, token *string, perPage *int64) ([]*rgt.ResourceTagMapping, string, error) {
|
||||
size := defaultResourcesPerPage
|
||||
if perPage != nil {
|
||||
if *perPage < minResourcesPerPage || *perPage > maxResourcesPerPage {
|
||||
return nil, "", awserrors.Errorf(awserrors.ErrorTaggingInvalidParameter,
|
||||
"ResourcesPerPage must be between %d and %d", minResourcesPerPage, maxResourcesPerPage)
|
||||
}
|
||||
size = int(*perPage)
|
||||
}
|
||||
|
||||
start := 0
|
||||
if token != nil && *token != "" {
|
||||
n, err := strconv.Atoi(*token)
|
||||
if err != nil || n < 0 {
|
||||
return nil, "", errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
return nil, "", awserrors.Errorf(awserrors.ErrorTaggingInvalidParameter, "PaginationToken is not valid")
|
||||
}
|
||||
start = n
|
||||
}
|
||||
@@ -126,11 +140,6 @@ func paginate(mappings []*rgt.ResourceTagMapping, token *string, perPage *int64)
|
||||
start = len(mappings)
|
||||
}
|
||||
|
||||
size := defaultResourcesPerPage
|
||||
if perPage != nil && *perPage > 0 {
|
||||
size = int(*perPage)
|
||||
}
|
||||
|
||||
end := start + size
|
||||
if end >= len(mappings) {
|
||||
return mappings[start:], "", nil
|
||||
|
||||
@@ -6,12 +6,15 @@ package tagging
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
rgt "github.com/aws/aws-sdk-go/service/resourcegroupstaggingapi"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
)
|
||||
|
||||
const testAccountID = "123456789012"
|
||||
@@ -110,9 +113,42 @@ func TestPaginate(t *testing.T) {
|
||||
assert.Len(t, page, 5)
|
||||
assert.Empty(t, next)
|
||||
|
||||
// Bad token is rejected.
|
||||
_, _, err = paginate(all, aws.String("not-a-number"), nil)
|
||||
require.Error(t, err)
|
||||
// The page-size bounds themselves are accepted.
|
||||
page, next, err = paginate(all, nil, aws.Int64(1))
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, page, 1)
|
||||
assert.Equal(t, "1", next)
|
||||
|
||||
page, next, err = paginate(all, nil, aws.Int64(100))
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, page, 5)
|
||||
assert.Empty(t, next)
|
||||
}
|
||||
|
||||
// AWS accepts ResourcesPerPage of 1 to 100 and reports out-of-range or
|
||||
// malformed pagination input as InvalidParameterException.
|
||||
func TestPaginate_RejectsInvalidInputAsInvalidParameterException(t *testing.T) {
|
||||
all := []*rgt.ResourceTagMapping{mapping("arn:0"), mapping("arn:1")}
|
||||
|
||||
cases := map[string]struct {
|
||||
token *string
|
||||
perPage *int64
|
||||
}{
|
||||
"zero page size": {perPage: aws.Int64(0)},
|
||||
"negative page size": {perPage: aws.Int64(-1)},
|
||||
"page size above 100": {perPage: aws.Int64(101)},
|
||||
"max int64 page with offset": {token: aws.String("1"), perPage: aws.Int64(math.MaxInt64)},
|
||||
"non-numeric token": {token: aws.String("not-a-number")},
|
||||
"negative token": {token: aws.String("-1")},
|
||||
}
|
||||
for name, tc := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
_, _, err := paginate(all, tc.token, tc.perPage)
|
||||
code, ok := awserrors.ResolveErrorCode(err)
|
||||
require.True(t, ok, "error %v carries no AWS code", err)
|
||||
assert.Equal(t, "InvalidParameterException", code)
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestGetResources_MergesFiltersSortsPaginates(t *testing.T) {
|
||||
elbv2 := &fakeLister{out: []TaggedResource{
|
||||
|
||||
@@ -396,6 +396,10 @@ var (
|
||||
// ErrorECRInvalidParameter aliases ErrorECSInvalidParameter: every ECR
|
||||
// operation declares the AWS JSON 1.1 wire code "InvalidParameterException".
|
||||
ErrorECRInvalidParameter = ErrorECSInvalidParameter
|
||||
// ErrorTaggingInvalidParameter aliases ErrorECSInvalidParameter: the Resource
|
||||
// Groups Tagging API reports out-of-range and malformed input as the AWS JSON
|
||||
// 1.1 wire code "InvalidParameterException".
|
||||
ErrorTaggingInvalidParameter = ErrorECSInvalidParameter
|
||||
// ErrorACMResourceInUse aliases ErrorEKSResourceInUse: real ACM's
|
||||
// DeleteCertificate and real EKS's CreateCluster both legitimately use the
|
||||
// wire code "ResourceInUseException", so this is a distinct, self-documenting
|
||||
|
||||
@@ -220,6 +220,7 @@ func TestAdminDeleteAccountDryRunChangesNothing(t *testing.T) {
|
||||
// and holding the call open for it would make every timeout-and-retry start a
|
||||
// second one.
|
||||
func TestAdminDeleteAccountReturnsBeforeTheTeardownFinishes(t *testing.T) {
|
||||
t.Parallel()
|
||||
gw := newDeleteAccountGateway(t)
|
||||
account := newTenantAccount(t, gw, "tenant@example.com")
|
||||
|
||||
@@ -251,6 +252,7 @@ func TestAdminDeleteAccountReturnsBeforeTheTeardownFinishes(t *testing.T) {
|
||||
// The record outlives the account it describes, so a retry that arrives after
|
||||
// the teardown finished is answered from it rather than as a missing account.
|
||||
func TestAdminDeleteAccountReplaysAfterTheAccountIsGone(t *testing.T) {
|
||||
t.Parallel()
|
||||
gw := newDeleteAccountGateway(t)
|
||||
account := newTenantAccount(t, gw, "tenant@example.com")
|
||||
body := deleteAccountBody(t, account.AccountID, "tenant@example.com", deleteClientToken)
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
// a guest there could still map the volume. The public action must refuse with
|
||||
// a retryable ServiceUnavailable and never reach the volume service.
|
||||
func TestDeleteVolumeAction_MissingConfiguredNodeIsRetryableAndDeletesNothing(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, nc := testutil.StartTestNATS(t)
|
||||
answer := func(subject, node string) {
|
||||
sub, err := nc.Subscribe(subject, func(msg *nats.Msg) {
|
||||
|
||||
@@ -293,8 +293,9 @@ func (f *publishFixture) publish(t *testing.T, ctx context.Context, cluster, bod
|
||||
func (f *publishFixture) requireNothingPublished(t *testing.T) {
|
||||
t.Helper()
|
||||
require.NoError(t, f.nc.Flush())
|
||||
msg, err := f.sub.NextMsg(100 * time.Millisecond)
|
||||
require.ErrorIs(t, err, nats.ErrTimeout, "relayed onto %v", msg)
|
||||
pending, _, err := f.sub.Pending()
|
||||
require.NoError(t, err)
|
||||
require.Zero(t, pending, "relayed onto eks.>")
|
||||
}
|
||||
|
||||
// cpAgentContext is the auth context a CP VM's IMDS credentials produce.
|
||||
@@ -528,7 +529,7 @@ func (f *tokenReviewFixture) requireNothingVerified(t *testing.T) {
|
||||
select {
|
||||
case cluster := <-f.verified:
|
||||
t.Fatalf("token verified against cluster %q", cluster)
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -110,6 +110,7 @@ func TestSealReaperNoResponderKeepsAccountTerminating(t *testing.T) {
|
||||
// refusal, a timeout, and each reply shape the client rejects (wrong account,
|
||||
// wrong disposition, no revision).
|
||||
func TestSealReaperOwnerFailureKeepsAccountTerminating(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
err error
|
||||
@@ -123,6 +124,7 @@ func TestSealReaperOwnerFailureKeepsAccountTerminating(t *testing.T) {
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sealer := &fakeSealer{err: tc.err}
|
||||
reaper := &accountSettingsSealReaper{sealer: sealer, capability: &fakeSealCapability{report: completeReport("node-1")}}
|
||||
accounts := newFakeAccounts()
|
||||
|
||||
@@ -364,6 +364,7 @@ func TestVolumeReaperForceRetainsAVolumeALiveInstanceHolds(t *testing.T) {
|
||||
// "Cannot prove gone" is not "gone": every way the proof can fall short keeps
|
||||
// the volume and its attachment exactly as they were.
|
||||
func TestVolumeReaperForceRetainsAVolumeWhenTheHolderCannotBeProvenGone(t *testing.T) {
|
||||
t.Parallel()
|
||||
twoNodes := []string{fakeClusterNode, "node-2"}
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -396,6 +397,7 @@ func TestVolumeReaperForceRetainsAVolumeWhenTheHolderCannotBeProvenGone(t *testi
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
cluster := newFakeCluster(t)
|
||||
volume := newHeldVolume(cluster, "vol-held", "i-held")
|
||||
tc.setup(cluster)
|
||||
@@ -966,6 +968,7 @@ func newVolumeReaper(nc *nats.Conn, nodeIDs ...string) *volumeReaper {
|
||||
// fan-out twice. A count of replies reads that as complete and the holder as
|
||||
// gone; only judging by responder identity sees that node-2 is missing.
|
||||
func TestVolumeReaperForceRetainsAVolumeWhenOneNodeAnswersTwiceAndTheHolderNodeIsSilent(t *testing.T) {
|
||||
t.Parallel()
|
||||
cluster := newFakeCluster(t)
|
||||
volume := newHeldVolume(cluster, "vol-held", "i-on-silent-node")
|
||||
cluster.echoAsNode(t, "ec2.DescribeInstances", fakeClusterNode)
|
||||
@@ -984,6 +987,7 @@ func TestVolumeReaperForceRetainsAVolumeWhenOneNodeAnswersTwiceAndTheHolderNodeI
|
||||
}
|
||||
|
||||
func TestForcedTeardownKeepsTheAccountWhenOneNodeAnswersTwiceAndTheHolderNodeIsSilent(t *testing.T) {
|
||||
t.Parallel()
|
||||
cluster := newFakeCluster(t)
|
||||
volume := newHeldVolume(cluster, "vol-held", "i-on-silent-node")
|
||||
cluster.echoAsNode(t, "ec2.DescribeInstances", fakeClusterNode)
|
||||
|
||||
@@ -35,6 +35,7 @@ func certIPs(cert *x509.Certificate) []string {
|
||||
// A renew must keep every SAN init minted, taking region and services domain
|
||||
// from the config, and add the operator's extras on top.
|
||||
func TestRenewServerCert_KeepsInitSANs(t *testing.T) {
|
||||
admin.ShortenCertKeyBitsForTest(t, 2048)
|
||||
configDir := t.TempDir()
|
||||
const (
|
||||
region = "ap-southeast-2"
|
||||
@@ -68,6 +69,7 @@ func TestRenewServerCert_KeepsInitSANs(t *testing.T) {
|
||||
// The daemon publishes service names under the node's region, so renew must
|
||||
// mint them for that region even when [aws] region says otherwise.
|
||||
func TestRenewServerCert_UsesNodeRegion(t *testing.T) {
|
||||
admin.ShortenCertKeyBitsForTest(t, 2048)
|
||||
configDir := t.TempDir()
|
||||
const nodeRegion = "ap-southeast-2"
|
||||
require.NoError(t, admin.GenerateCACert(filepath.Join(configDir, "ca.pem"), filepath.Join(configDir, "ca.key")))
|
||||
@@ -87,6 +89,7 @@ func TestRenewServerCert_UsesNodeRegion(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRenewServerCert_MissingNodeEntry(t *testing.T) {
|
||||
admin.ShortenCertKeyBitsForTest(t, 2048)
|
||||
configDir := t.TempDir()
|
||||
require.NoError(t, admin.GenerateCACert(filepath.Join(configDir, "ca.pem"), filepath.Join(configDir, "ca.key")))
|
||||
|
||||
|
||||
@@ -816,6 +816,7 @@ var vpcdStartCmd = &cobra.Command{
|
||||
ExternalInterface: nodeConfig.VPCD.ExternalInterface,
|
||||
BridgeMode: nodeConfig.VPCD.BridgeMode,
|
||||
AZ: nodeConfig.AZ,
|
||||
Region: nodeConfig.Region,
|
||||
NorthstarBaseDomain: dns.ResolveBaseDomain(&nodeConfig),
|
||||
NorthstarInternalDomain: dns.ResolveInternalDomain(&nodeConfig),
|
||||
ServicesDomain: clusterConfig.AWS.ServicesDomain,
|
||||
|
||||
@@ -180,17 +180,25 @@ func TestCurrentBehaviour_Tagging_GetResourcesOffsetTokenRepeatsAfterInterleaved
|
||||
assert.Equal(t, pageARNs(first), pageARNs(second), "the second page repeats the first")
|
||||
}
|
||||
|
||||
// A token that is not a non-negative integer is InvalidParameterValue, and a
|
||||
// page size above 100 is accepted rather than refused.
|
||||
// A token that is not a non-negative integer, and a page size outside 1 to
|
||||
// 100, are both refused with InvalidParameterException, as AWS does.
|
||||
func TestCurrentBehaviour_Tagging_GetResourcesPaginationValidation(t *testing.T) {
|
||||
agg, tags := newTaggingCBAggregator(t)
|
||||
createTags(t, tags, []string{"sg-0000000000000000a"}, map[string]string{"env": "prod"})
|
||||
|
||||
_, err := getResources(t, agg, `{"PaginationToken":"not-a-token","ResourceTypeFilters":["ec2"]}`)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorInvalidParameterValue, err.Error())
|
||||
for _, body := range []string{
|
||||
`{"PaginationToken":"not-a-token","ResourceTypeFilters":["ec2"]}`,
|
||||
`{"ResourcesPerPage":500,"ResourceTypeFilters":["ec2"]}`,
|
||||
`{"ResourcesPerPage":0,"ResourceTypeFilters":["ec2"]}`,
|
||||
`{"ResourcesPerPage":9223372036854775807,"PaginationToken":"1","ResourceTypeFilters":["ec2"]}`,
|
||||
} {
|
||||
_, err := getResources(t, agg, body)
|
||||
code, ok := awserrors.ResolveErrorCode(err)
|
||||
require.True(t, ok, "%s: error %v carries no AWS code", body, err)
|
||||
assert.Equal(t, "InvalidParameterException", code, body)
|
||||
}
|
||||
|
||||
page, err := getResources(t, agg, `{"ResourcesPerPage":500,"ResourceTypeFilters":["ec2"]}`)
|
||||
page, err := getResources(t, agg, `{"ResourcesPerPage":100,"ResourceTypeFilters":["ec2"]}`)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, page.ResourceTagMappingList, 1)
|
||||
}
|
||||
|
||||
@@ -3,5 +3,3 @@ node_modules
|
||||
*.tsbuildinfo
|
||||
.pnpm-store
|
||||
coverage
|
||||
test-results
|
||||
playwright-report
|
||||
|
||||
+1
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
+1
@@ -0,0 +1 @@
|
||||
import{R as e,V as t}from"./httpRequest-Dr-qoOs4.js";var n=t(e(),1),r=n.createContext(void 0);function i(){return n.useContext(r)?.direction??`ltr`}export{i as t};
|
||||
-1
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
import{D as e,O as t,i as n}from"./utils-DWZT3JVe.js";import{t as r}from"./link-BGxJCEND.js";import{n as i,t as a}from"./upload-button-DTMsV-ZJ.js";import{r as o}from"./s3-B5RLvcuq.js";import{t as s}from"./error-banner-VBz3AqJt.js";import{t as c}from"./back-link-uAOd4aC_.js";import{t as l}from"./_-weQs9qjm.js";var u=e(),d=t();function f(){let e=(0,u.c)(14),{bucket:t,_splat:f}=l.useParams(),p=n(f??``),m=o(),h=p.split(`/`).filter(Boolean),g=[{name:t,path:`/s3/ls/${t}`},...h.map((e,n)=>{let r=`${h.slice(0,n+1).join(`/`)}/`;return{name:e,path:`/s3/ls/${t}/${r}`}})],_=`${h.slice(0,-1).join(`/`)}/`,v;e[0]!==t||e[1]!==_||e[2]!==h.length?(v=h.length>1?(0,d.jsx)(c,{params:{bucket:t,_splat:_},to:`/s3/ls/$bucket/$`,children:`Back`}):(0,d.jsx)(c,{params:{bucket:t},to:`/s3/ls/$bucket`,children:`Back`}),e[0]=t,e[1]=_,e[2]=h.length,e[3]=v):v=e[3];let y;e[4]!==t||e[5]!==p||e[6]!==m.isPending||e[7]!==m.mutateAsync?(y=(0,d.jsx)(a,{bucket:t,isPending:m.isPending,onUpload:m.mutateAsync,prefix:p}),e[4]=t,e[5]=p,e[6]=m.isPending,e[7]=m.mutateAsync,e[8]=y):y=e[8];let b;e[9]===m.error?b=e[10]:(b=m.error&&(0,d.jsx)(s,{error:m.error,msg:`Failed to upload object`}),e[9]=m.error,e[10]=b);let x;return e[11]!==t||e[12]!==p?(x=(0,d.jsx)(i,{bucket:t,prefix:p}),e[11]=t,e[12]=p,e[13]=x):x=e[13],(0,d.jsxs)(d.Fragment,{children:[v,(0,d.jsx)(`div`,{className:`mb-6`,children:(0,d.jsxs)(`div`,{className:`mb-2 flex items-center justify-between`,children:[(0,d.jsx)(`div`,{className:`flex items-center gap-2 text-sm text-muted-foreground`,children:g.map((e,t)=>(0,d.jsxs)(`div`,{className:`flex items-center gap-2`,children:[t>0&&(0,d.jsx)(`span`,{children:`/`}),t===g.length-1?(0,d.jsx)(`span`,{className:`text-foreground`,children:e.name}):(0,d.jsx)(r,{className:`hover:text-foreground`,to:e.path,children:e.name})]},e.path))}),y]})}),b,x]})}export{f as component};
|
||||
@@ -0,0 +1,2 @@
|
||||
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/_-CwtkeGXR.js","assets/httpRequest-Dr-qoOs4.js","assets/link-CWdxFyZC.js","assets/back-link-C3QOsdXo.js","assets/createLucideIcon-Dgwegtfa.js","assets/upload-button-DH_PkIXd.js","assets/useSuspenseQuery-DwsF_otq.js","assets/useBaseQuery-CE1m2pVx.js","assets/removable-BrmR30Xo.js","assets/download-1EHshqR_.js","assets/trash-DF02nms9.js","assets/button-BGIWso6R.js","assets/dist-BdB_tTMf.js","assets/awsClient-cDSIkGhJ.js","assets/index.browser-BEm-79SP.js","assets/s3-Cf8-GH_Z.js","assets/useMutation-DwiSMcUf.js","assets/mutation-BPTwFI3g.js","assets/s3-Bd8Plg1y.js","assets/queryOptions-Dfvzj6n2.js","assets/error-banner-Peby0Lty.js","assets/delete-confirmation-dialog-y6KuQeVG.js","assets/alert-dialog-DJMXYx5d.js","assets/DialogTitle-8-mIxSiF.js","assets/useValueAsRef-f_QUos9a.js","assets/reason-parts-CrUlyFi3.js","assets/useScrollLock-CqzK4Gu2.js","assets/with-selector-DlOquSVb.js"])))=>i.map(i=>d[i]);
|
||||
import{w as e,x as t}from"./httpRequest-Dr-qoOs4.js";import{n,t as r}from"./lazyRouteComponent-Cf7L8vmE.js";import{wo as i}from"./awsClient-cDSIkGhJ.js";import{t as a}from"./s3-Bd8Plg1y.js";var o=n(`/_auth/s3/ls/$bucket/$`)({loader:async({context:t,params:n})=>{let r=e(n._splat??``);await t.queryClient.query({...a(n.bucket,r),staleTime:`static`})},head:({params:e})=>({meta:[{title:`${t(e._splat??``,`${e.bucket}/`)} | S3 | Mulga`}]}),component:r(()=>i(()=>import(`./_-CwtkeGXR.js`),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27])),`component`)});export{o as t};
|
||||
@@ -0,0 +1 @@
|
||||
import{I as e,L as t,w as n}from"./httpRequest-Dr-qoOs4.js";import{t as r}from"./link-CWdxFyZC.js";import{t as i}from"./back-link-C3QOsdXo.js";import{n as a,t as o}from"./upload-button-DH_PkIXd.js";import{r as s}from"./s3-Cf8-GH_Z.js";import{t as c}from"./_-BaG4o-sb.js";import{t as l}from"./error-banner-Peby0Lty.js";var u=e(),d=t();function f(){let e=(0,u.c)(14),{bucket:t,_splat:f}=c.useParams(),p=n(f??``),m=s(),h=p.split(`/`).filter(Boolean),g=[{name:t,path:`/s3/ls/${t}`},...h.map((e,n)=>{let r=`${h.slice(0,n+1).join(`/`)}/`;return{name:e,path:`/s3/ls/${t}/${r}`}})],_=`${h.slice(0,-1).join(`/`)}/`,v;e[0]!==t||e[1]!==_||e[2]!==h.length?(v=h.length>1?(0,d.jsx)(i,{params:{bucket:t,_splat:_},to:`/s3/ls/$bucket/$`,children:`Back`}):(0,d.jsx)(i,{params:{bucket:t},to:`/s3/ls/$bucket`,children:`Back`}),e[0]=t,e[1]=_,e[2]=h.length,e[3]=v):v=e[3];let y;e[4]!==t||e[5]!==p||e[6]!==m.isPending||e[7]!==m.mutateAsync?(y=(0,d.jsx)(o,{bucket:t,isPending:m.isPending,onUpload:m.mutateAsync,prefix:p}),e[4]=t,e[5]=p,e[6]=m.isPending,e[7]=m.mutateAsync,e[8]=y):y=e[8];let b;e[9]===m.error?b=e[10]:(b=m.error&&(0,d.jsx)(l,{error:m.error,msg:`Failed to upload object`}),e[9]=m.error,e[10]=b);let x;return e[11]!==t||e[12]!==p?(x=(0,d.jsx)(a,{bucket:t,prefix:p}),e[11]=t,e[12]=p,e[13]=x):x=e[13],(0,d.jsxs)(d.Fragment,{children:[v,(0,d.jsx)(`div`,{className:`mb-6`,children:(0,d.jsxs)(`div`,{className:`mb-2 flex items-center justify-between`,children:[(0,d.jsx)(`div`,{className:`flex items-center gap-2 text-sm text-muted-foreground`,children:g.map((e,t)=>(0,d.jsxs)(`div`,{className:`flex items-center gap-2`,children:[t>0&&(0,d.jsx)(`span`,{children:`/`}),t===g.length-1?(0,d.jsx)(`span`,{className:`text-foreground`,children:e.name}):(0,d.jsx)(r,{className:`hover:text-foreground`,to:e.path,children:e.name})]},e.path))}),y]})}),b,x]})}export{f as component};
|
||||
@@ -1,2 +0,0 @@
|
||||
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/_-BCOYlZmN.js","assets/utils-DWZT3JVe.js","assets/link-BGxJCEND.js","assets/upload-button-DTMsV-ZJ.js","assets/useSuspenseQuery-Bmlpew-m.js","assets/useBaseQuery-BSM_7Lt0.js","assets/removable-BFKPzTxX.js","assets/createLucideIcon-tJJfUBq0.js","assets/download-CGk9JKSH.js","assets/trash-Cld2LszV.js","assets/upload-C2P5Ii-M.js","assets/button-BbwngWdY.js","assets/reason-parts-8YFx1EZT.js","assets/awsClient-Bfha5_4q.js","assets/httpRequest-BWGSDCcn.js","assets/index.browser-BEm-79SP.js","assets/s3-B5RLvcuq.js","assets/useMutation-DUoxMVOX.js","assets/s3-B-QFM4Fd.js","assets/queryOptions-Dfvzj6n2.js","assets/delete-confirmation-dialog-B0cn0SrY.js","assets/alert-dialog-BjdCxp9B.js","assets/DialogTitle-C7C5rGOv.js","assets/useTransitionStatus-DyzTRRUD.js","assets/useScrollLock-Cjev-BGV.js","assets/with-selector-BqsWf3iS.js","assets/useTimeout-BGqaqlit.js","assets/useValueAsRef-DRGL6zPa.js","assets/visuallyHidden-CmQ9ItcI.js","assets/store-C5W6n22f.js","assets/error-banner-VBz3AqJt.js","assets/back-link-uAOd4aC_.js","assets/arrow-left-JquoanQ-.js"])))=>i.map(i=>d[i]);
|
||||
import{i as e,n as t}from"./utils-DWZT3JVe.js";import{n,t as r}from"./lazyRouteComponent-CARaY65b.js";import{wo as i}from"./awsClient-Bfha5_4q.js";import{t as a}from"./s3-B-QFM4Fd.js";var o=n(`/_auth/s3/ls/$bucket/$`)({loader:async({context:t,params:n})=>{let r=e(n._splat??``);await t.queryClient.query({...a(n.bucket,r),staleTime:`static`})},head:({params:e})=>({meta:[{title:`${t(e._splat??``,`${e.bucket}/`)} | S3 | Mulga`}]}),component:r(()=>i(()=>import(`./_-BCOYlZmN.js`),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32])),`component`)});export{o as t};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
import{I as e,L as t}from"./httpRequest-Dr-qoOs4.js";import{t as n}from"./back-link-C3QOsdXo.js";import{n as r,t as i}from"./upload-button-DH_PkIXd.js";import{r as a}from"./s3-Cf8-GH_Z.js";import{t as o}from"./_bucket-DF0usGLj.js";import{t as s}from"./page-heading-CZgcG0Ab.js";import{t as c}from"./error-banner-Peby0Lty.js";var l=e(),u=t();function d(){let e=(0,l.c)(16),{bucket:t}=o.useParams(),d=a(),f;e[0]===Symbol.for(`react.memo_cache_sentinel`)?(f=(0,u.jsx)(n,{to:`/s3/ls`,children:`Back to buckets`}),e[0]=f):f=e[0];let p;e[1]!==t||e[2]!==d.isPending||e[3]!==d.mutateAsync?(p=(0,u.jsx)(i,{bucket:t,isPending:d.isPending,onUpload:d.mutateAsync}),e[1]=t,e[2]=d.isPending,e[3]=d.mutateAsync,e[4]=p):p=e[4];let m;e[5]!==t||e[6]!==p?(m=(0,u.jsx)(s,{actions:p,title:t}),e[5]=t,e[6]=p,e[7]=m):m=e[7];let h;e[8]===d.error?h=e[9]:(h=d.error&&(0,u.jsx)(c,{error:d.error,msg:`Failed to upload object`}),e[8]=d.error,e[9]=h);let g;e[10]===t?g=e[11]:(g=(0,u.jsx)(r,{bucket:t,prefix:``}),e[10]=t,e[11]=g);let _;return e[12]!==m||e[13]!==h||e[14]!==g?(_=(0,u.jsxs)(u.Fragment,{children:[f,m,h,g]}),e[12]=m,e[13]=h,e[14]=g,e[15]=_):_=e[15],_}export{d as component};
|
||||
@@ -1,2 +0,0 @@
|
||||
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/_bucket-orkpWL0L.js","assets/utils-DWZT3JVe.js","assets/upload-button-DTMsV-ZJ.js","assets/useSuspenseQuery-Bmlpew-m.js","assets/useBaseQuery-BSM_7Lt0.js","assets/removable-BFKPzTxX.js","assets/link-BGxJCEND.js","assets/createLucideIcon-tJJfUBq0.js","assets/download-CGk9JKSH.js","assets/trash-Cld2LszV.js","assets/upload-C2P5Ii-M.js","assets/button-BbwngWdY.js","assets/reason-parts-8YFx1EZT.js","assets/awsClient-Bfha5_4q.js","assets/httpRequest-BWGSDCcn.js","assets/index.browser-BEm-79SP.js","assets/s3-B5RLvcuq.js","assets/useMutation-DUoxMVOX.js","assets/s3-B-QFM4Fd.js","assets/queryOptions-Dfvzj6n2.js","assets/delete-confirmation-dialog-B0cn0SrY.js","assets/alert-dialog-BjdCxp9B.js","assets/DialogTitle-C7C5rGOv.js","assets/useTransitionStatus-DyzTRRUD.js","assets/useScrollLock-Cjev-BGV.js","assets/with-selector-BqsWf3iS.js","assets/useTimeout-BGqaqlit.js","assets/useValueAsRef-DRGL6zPa.js","assets/visuallyHidden-CmQ9ItcI.js","assets/store-C5W6n22f.js","assets/error-banner-VBz3AqJt.js","assets/page-heading-gkoGGkaM.js","assets/back-link-uAOd4aC_.js","assets/arrow-left-JquoanQ-.js"])))=>i.map(i=>d[i]);
|
||||
import{n as e,t}from"./lazyRouteComponent-CARaY65b.js";import{wo as n}from"./awsClient-Bfha5_4q.js";import{t as r}from"./s3-B-QFM4Fd.js";var i=e(`/_auth/s3/ls/$bucket/`)({loader:async({context:e,params:t})=>{await e.queryClient.query({...r(t.bucket),staleTime:`static`})},head:({params:e})=>({meta:[{title:`${e.bucket} | S3 | Mulga`}]}),component:t(()=>n(()=>import(`./_bucket-orkpWL0L.js`),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33])),`component`)});export{i as t};
|
||||
@@ -0,0 +1,2 @@
|
||||
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/_bucket-CRS801Xu.js","assets/httpRequest-Dr-qoOs4.js","assets/back-link-C3QOsdXo.js","assets/link-CWdxFyZC.js","assets/createLucideIcon-Dgwegtfa.js","assets/upload-button-DH_PkIXd.js","assets/useSuspenseQuery-DwsF_otq.js","assets/useBaseQuery-CE1m2pVx.js","assets/removable-BrmR30Xo.js","assets/download-1EHshqR_.js","assets/trash-DF02nms9.js","assets/button-BGIWso6R.js","assets/dist-BdB_tTMf.js","assets/awsClient-cDSIkGhJ.js","assets/index.browser-BEm-79SP.js","assets/s3-Cf8-GH_Z.js","assets/useMutation-DwiSMcUf.js","assets/mutation-BPTwFI3g.js","assets/s3-Bd8Plg1y.js","assets/queryOptions-Dfvzj6n2.js","assets/error-banner-Peby0Lty.js","assets/delete-confirmation-dialog-y6KuQeVG.js","assets/alert-dialog-DJMXYx5d.js","assets/DialogTitle-8-mIxSiF.js","assets/useValueAsRef-f_QUos9a.js","assets/reason-parts-CrUlyFi3.js","assets/useScrollLock-CqzK4Gu2.js","assets/with-selector-DlOquSVb.js","assets/page-heading-CZgcG0Ab.js"])))=>i.map(i=>d[i]);
|
||||
import{n as e,t}from"./lazyRouteComponent-Cf7L8vmE.js";import{wo as n}from"./awsClient-cDSIkGhJ.js";import{t as r}from"./s3-Bd8Plg1y.js";var i=e(`/_auth/s3/ls/$bucket/`)({loader:async({context:e,params:t})=>{await e.queryClient.query({...r(t.bucket),staleTime:`static`})},head:({params:e})=>({meta:[{title:`${e.bucket} | S3 | Mulga`}]}),component:t(()=>n(()=>import(`./_bucket-CRS801Xu.js`),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28])),`component`)});export{i as t};
|
||||
@@ -1 +0,0 @@
|
||||
import{D as e,O as t}from"./utils-DWZT3JVe.js";import{n,t as r}from"./upload-button-DTMsV-ZJ.js";import{r as i}from"./s3-B5RLvcuq.js";import{t as a}from"./error-banner-VBz3AqJt.js";import{t as o}from"./page-heading-gkoGGkaM.js";import{t as s}from"./back-link-uAOd4aC_.js";import{t as c}from"./_bucket-DCO69Fik.js";var l=e(),u=t();function d(){let e=(0,l.c)(16),{bucket:t}=c.useParams(),d=i(),f;e[0]===Symbol.for(`react.memo_cache_sentinel`)?(f=(0,u.jsx)(s,{to:`/s3/ls`,children:`Back to buckets`}),e[0]=f):f=e[0];let p;e[1]!==t||e[2]!==d.isPending||e[3]!==d.mutateAsync?(p=(0,u.jsx)(r,{bucket:t,isPending:d.isPending,onUpload:d.mutateAsync}),e[1]=t,e[2]=d.isPending,e[3]=d.mutateAsync,e[4]=p):p=e[4];let m;e[5]!==t||e[6]!==p?(m=(0,u.jsx)(o,{actions:p,title:t}),e[5]=t,e[6]=p,e[7]=m):m=e[7];let h;e[8]===d.error?h=e[9]:(h=d.error&&(0,u.jsx)(a,{error:d.error,msg:`Failed to upload object`}),e[8]=d.error,e[9]=h);let g;e[10]===t?g=e[11]:(g=(0,u.jsx)(n,{bucket:t,prefix:``}),e[10]=t,e[11]=g);let _;return e[12]!==m||e[13]!==h||e[14]!==g?(_=(0,u.jsxs)(u.Fragment,{children:[f,m,h,g]}),e[12]=m,e[13]=h,e[14]=g,e[15]=_):_=e[15],_}export{d as component};
|
||||
+1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+2
@@ -0,0 +1,2 @@
|
||||
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/_clusterName-CHlposBJ.js","assets/httpRequest-Dr-qoOs4.js","assets/useQuery-ChxTuGg1.js","assets/useBaseQuery-CE1m2pVx.js","assets/removable-BrmR30Xo.js","assets/useSuspenseQuery-DwsF_otq.js","assets/link-CWdxFyZC.js","assets/useNavigate-CsLoTHJy.js","assets/back-link-C3QOsdXo.js","assets/createLucideIcon-Dgwegtfa.js","assets/circle-arrow-up-Dzo3sx-3.js","assets/cli-command-panel-BuPajOsj.js","assets/check-C91NAfOp.js","assets/chevron-down-Nw5hQB-o.js","assets/copy-BRhrvNhm.js","assets/button-BGIWso6R.js","assets/dist-BdB_tTMf.js","assets/use-copy-to-clipboard-DCZex1DX.js","assets/trash-DF02nms9.js","assets/triangle-alert-CF-HX3ai.js","assets/input-BwqTIkib.js","assets/useValueChanged-CmZ_T1Ux.js","assets/reason-parts-CrUlyFi3.js","assets/ec2-XHwEzTpV.js","assets/queryOptions-Dfvzj6n2.js","assets/awsClient-cDSIkGhJ.js","assets/index.browser-BEm-79SP.js","assets/eks-CLH1ie7V.js","assets/useMutation-DwiSMcUf.js","assets/mutation-BPTwFI3g.js","assets/commandBuilder-Bvnumn8i.js","assets/json-Brvpv93s.js","assets/subnet-calculator-DDrAb5xp.js","assets/eks-CuP60ITR.js","assets/iam-DAHbuObM.js","assets/commandBuilder-Dlr2ruMS.js","assets/page-heading-CZgcG0Ab.js","assets/badge-C6Y_Vw3S.js","assets/useRender-ljfxW3PF.js","assets/zod-B9u9z7RI.js","assets/error-banner-Peby0Lty.js","assets/field-BPwqN4_m.js","assets/separator-AD26D8dQ.js","assets/alert-dialog-DJMXYx5d.js","assets/DialogTitle-8-mIxSiF.js","assets/useValueAsRef-f_QUos9a.js","assets/useScrollLock-CqzK4Gu2.js","assets/with-selector-DlOquSVb.js","assets/delete-confirmation-dialog-y6KuQeVG.js","assets/detail-row-BNG8u2EX.js","assets/gpu-instance-type-select-BYQCOzMs.js","assets/select-DRJzHz-U.js","assets/usePositioner-BW8fBIgP.js","assets/DirectionContext-CJn2gj-2.js","assets/useCompositeListItem-D4FWBaCx.js","assets/useControlled-Cbjuk53h.js","assets/tabs-UhByR8M0.js","assets/json-editor-HnrPKfFL.js","assets/textarea-CAbwCjSM.js"])))=>i.map(i=>d[i]);
|
||||
import{n as e,t}from"./lazyRouteComponent-Cf7L8vmE.js";import{t as n}from"./createLucideIcon-Dgwegtfa.js";import{wo as r}from"./awsClient-cDSIkGhJ.js";import{s as i,t as a,u as o}from"./eks-CuP60ITR.js";var s={name:`sliders-horizontal`,size:24,node:[[`path`,{d:`M10 5H3`,key:`1qgfaw`}],[`path`,{d:`M12 19H3`,key:`yhmn1j`}],[`path`,{d:`M14 3v4`,key:`1sua03`}],[`path`,{d:`M16 17v4`,key:`1q0r14`}],[`path`,{d:`M21 12h-9`,key:`1o4lsq`}],[`path`,{d:`M21 19h-5`,key:`1rlt1p`}],[`path`,{d:`M21 5h-7`,key:`1oszz2`}],[`path`,{d:`M8 10v4`,key:`tgpxqk`}],[`path`,{d:`M8 12H3`,key:`a7s4jb`}]]};s.node;var c=n(s),l=e(`/_auth/eks/(clusters)/list-clusters/$clusterName`)({loader:async({context:e,params:t})=>{await Promise.all([e.queryClient.query({...i(t.clusterName),staleTime:`static`}),e.queryClient.query({...o(t.clusterName),staleTime:`static`}),e.queryClient.query({...a(t.clusterName),staleTime:`static`})])},head:({params:e})=>({meta:[{title:`${e.clusterName} | EKS | Mulga`}]}),component:t(()=>r(()=>import(`./_clusterName-CHlposBJ.js`),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58])),`component`)});export{c as n,l as t};
|
||||
-1
@@ -1 +0,0 @@
|
||||
import{D as e,O as t}from"./utils-DWZT3JVe.js";import{t as n}from"./useSuspenseQuery-Bmlpew-m.js";import{t as r}from"./ecs-D0GX3W7k.js";import{t as i}from"./back-link-uAOd4aC_.js";import{t as a}from"./_clusterName-DrUrwTx8.js";import{t as o}from"./cluster-details-BW_wFsH_.js";var s=e(),c=t();function l(){let e=(0,s.c)(11),{clusterName:t}=a.useParams(),{tab:l}=a.useSearch(),u=a.useNavigate(),d;e[0]===t?d=e[1]:(d=r(t),e[0]=t,e[1]=d);let{data:f}=n(d);if(!f){let t;return e[2]===Symbol.for(`react.memo_cache_sentinel`)?(t=(0,c.jsxs)(c.Fragment,{children:[(0,c.jsx)(i,{to:`/ecs/list-clusters`,children:`Back to clusters`}),(0,c.jsx)(`p`,{className:`text-muted-foreground`,children:`Cluster not found.`})]}),e[2]=t):t=e[2],t}let p,m;e[3]===u?(p=e[4],m=e[5]):(p=()=>{u({to:`/ecs/list-clusters`})},m=e=>{u({search:{tab:e},replace:!0})},e[3]=u,e[4]=p,e[5]=m);let h;return e[6]!==f||e[7]!==p||e[8]!==m||e[9]!==l?(h=(0,c.jsx)(o,{cluster:f,layout:`page`,onClose:p,onTabChange:m,tab:l}),e[6]=f,e[7]=p,e[8]=m,e[9]=l,e[10]=h):h=e[10],h}export{l as component};
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/_clusterName-B3KkRk0-.js","assets/httpRequest-Dr-qoOs4.js","assets/useQuery-ChxTuGg1.js","assets/useBaseQuery-CE1m2pVx.js","assets/removable-BrmR30Xo.js","assets/useSuspenseQuery-DwsF_otq.js","assets/link-CWdxFyZC.js","assets/useNavigate-CsLoTHJy.js","assets/back-link-C3QOsdXo.js","assets/createLucideIcon-Dgwegtfa.js","assets/trash-DF02nms9.js","assets/button-BGIWso6R.js","assets/dist-BdB_tTMf.js","assets/input-BwqTIkib.js","assets/useValueChanged-CmZ_T1Ux.js","assets/reason-parts-CrUlyFi3.js","assets/ec2-XHwEzTpV.js","assets/queryOptions-Dfvzj6n2.js","assets/awsClient-cDSIkGhJ.js","assets/index.browser-BEm-79SP.js","assets/ecs-BZ1CHEjT.js","assets/ecs-ECmRO3MW.js","assets/useMutation-DwiSMcUf.js","assets/mutation-BPTwFI3g.js","assets/signed-fetch-CZLhUh5_.js","assets/page-heading-CZgcG0Ab.js","assets/error-banner-Peby0Lty.js","assets/field-BPwqN4_m.js","assets/separator-AD26D8dQ.js","assets/alert-dialog-DJMXYx5d.js","assets/DialogTitle-8-mIxSiF.js","assets/useValueAsRef-f_QUos9a.js","assets/useScrollLock-CqzK4Gu2.js","assets/with-selector-DlOquSVb.js","assets/delete-confirmation-dialog-y6KuQeVG.js","assets/detail-row-BNG8u2EX.js","assets/state-badge-Bb0HJeLD.js","assets/gpu-instance-type-select-BYQCOzMs.js","assets/select-DRJzHz-U.js","assets/check-C91NAfOp.js","assets/chevron-down-Nw5hQB-o.js","assets/usePositioner-BW8fBIgP.js","assets/DirectionContext-CJn2gj-2.js","assets/useCompositeListItem-D4FWBaCx.js","assets/useControlled-Cbjuk53h.js","assets/tabs-UhByR8M0.js"])))=>i.map(i=>d[i]);
|
||||
import{n as e,t}from"./lazyRouteComponent-Cf7L8vmE.js";import{wo as n}from"./awsClient-cDSIkGhJ.js";import{i as r,s as i,t as a}from"./ecs-BZ1CHEjT.js";var o=e(`/_auth/ecs/(clusters)/list-clusters/$clusterName`)({loader:async({context:e,params:t})=>{await Promise.all([e.queryClient.query({...a(t.clusterName),staleTime:`static`}),e.queryClient.query({...r(t.clusterName),staleTime:`static`}),e.queryClient.query({...i(t.clusterName),staleTime:`static`})])},head:({params:e})=>({meta:[{title:`${e.clusterName} | ECS | Mulga`}]}),component:t(()=>n(()=>import(`./_clusterName-B3KkRk0-.js`),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45])),`component`)});export{o as t};
|
||||
-2
@@ -1,2 +0,0 @@
|
||||
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/_clusterName-DWUN-qay.js","assets/utils-DWZT3JVe.js","assets/useSuspenseQuery-Bmlpew-m.js","assets/useBaseQuery-BSM_7Lt0.js","assets/removable-BFKPzTxX.js","assets/ecs-D0GX3W7k.js","assets/queryOptions-Dfvzj6n2.js","assets/awsClient-Bfha5_4q.js","assets/httpRequest-BWGSDCcn.js","assets/index.browser-BEm-79SP.js","assets/back-link-uAOd4aC_.js","assets/link-BGxJCEND.js","assets/arrow-left-JquoanQ-.js","assets/createLucideIcon-tJJfUBq0.js","assets/cluster-details-BW_wFsH_.js","assets/useQuery-CktWjZg4.js","assets/sliders-horizontal-BEXi-wIi.js","assets/detail-panel-CrYYi78V.js","assets/check-Bw3sTMpr.js","assets/chevron-down-BeOzYI5_.js","assets/copy-CPg1QJTq.js","assets/popover-ijVU93mX.js","assets/with-selector-BqsWf3iS.js","assets/useTransitionStatus-DyzTRRUD.js","assets/reason-parts-8YFx1EZT.js","assets/useScrollLock-Cjev-BGV.js","assets/useTimeout-BGqaqlit.js","assets/useValueAsRef-DRGL6zPa.js","assets/visuallyHidden-CmQ9ItcI.js","assets/ToolbarRootContext-C3Y20QkS.js","assets/useValueChanged-CrBHhQDK.js","assets/isElementDisabled-C16xL2fI.js","assets/store-C5W6n22f.js","assets/button-BbwngWdY.js","assets/collapsible-DVIlVwfv.js","assets/useControlled-B2BodiHe.js","assets/error-banner-VBz3AqJt.js","assets/tabs-BQS5cQJb.js","assets/use-copy-to-clipboard-B_hLBe4B.js","assets/play-B7mxhtDP.js","assets/plus-B2bCpZwK.js","assets/server-Cfqe7knz.js","assets/trash-Cld2LszV.js","assets/input-BQ0Z_3Rn.js","assets/useRegisterFieldControl-2wfx_hff.js","assets/field-CdWgmJnb.js","assets/label-nsvgDMlM.js","assets/ec2-CFH_oZL-.js","assets/ecs-Hdx_fOA1.js","assets/useMutation-DUoxMVOX.js","assets/signed-fetch-CMHm78r_.js","assets/tslib.es6-CT3DuY1l.js","assets/alert-dialog-BjdCxp9B.js","assets/DialogTitle-C7C5rGOv.js","assets/delete-confirmation-dialog-B0cn0SrY.js","assets/state-badge-DBoxk51U.js","assets/gpu-instance-type-select-DlDpx87Z.js","assets/select-BIT7N5Xs.js","assets/panel-list-DOTSH7b8.js","assets/stop-task-dialog-B_ljCy9n.js"])))=>i.map(i=>d[i]);
|
||||
import{n as e,t}from"./lazyRouteComponent-CARaY65b.js";import{i as n}from"./sliders-horizontal-BEXi-wIi.js";import{d as r}from"./detail-panel-CrYYi78V.js";import{_ as i}from"./httpRequest-BWGSDCcn.js";import{wo as a}from"./awsClient-Bfha5_4q.js";import{i as o,s,t as c}from"./ecs-D0GX3W7k.js";var l=e(`/_auth/ecs/(clusters)/list-clusters/$clusterName`)({validateSearch:i({tab:r.tab}),search:{middlewares:[n({tab:`overview`})]},loader:async({context:e,params:t})=>{await Promise.all([e.queryClient.query({...c(t.clusterName),staleTime:`static`}),e.queryClient.query({...o(t.clusterName),staleTime:`static`}),e.queryClient.query({...s(t.clusterName),staleTime:`static`})])},head:({params:e})=>({meta:[{title:`${e.clusterName} | ECS | Mulga`}]}),component:t(()=>a(()=>import(`./_clusterName-DWUN-qay.js`),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59])),`component`)});export{l as t};
|
||||
-2
@@ -1,2 +0,0 @@
|
||||
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/_clusterName-O91FUfO3.js","assets/utils-DWZT3JVe.js","assets/cluster-details-DFT4POHi.js","assets/useQuery-CktWjZg4.js","assets/useBaseQuery-BSM_7Lt0.js","assets/removable-BFKPzTxX.js","assets/useSuspenseQuery-Bmlpew-m.js","assets/link-BGxJCEND.js","assets/sliders-horizontal-BEXi-wIi.js","assets/createLucideIcon-tJJfUBq0.js","assets/detail-panel-CrYYi78V.js","assets/arrow-left-JquoanQ-.js","assets/check-Bw3sTMpr.js","assets/chevron-down-BeOzYI5_.js","assets/copy-CPg1QJTq.js","assets/popover-ijVU93mX.js","assets/with-selector-BqsWf3iS.js","assets/useTransitionStatus-DyzTRRUD.js","assets/reason-parts-8YFx1EZT.js","assets/useScrollLock-Cjev-BGV.js","assets/useTimeout-BGqaqlit.js","assets/useValueAsRef-DRGL6zPa.js","assets/visuallyHidden-CmQ9ItcI.js","assets/ToolbarRootContext-C3Y20QkS.js","assets/useValueChanged-CrBHhQDK.js","assets/isElementDisabled-C16xL2fI.js","assets/store-C5W6n22f.js","assets/button-BbwngWdY.js","assets/httpRequest-BWGSDCcn.js","assets/collapsible-DVIlVwfv.js","assets/useControlled-B2BodiHe.js","assets/error-banner-VBz3AqJt.js","assets/tabs-BQS5cQJb.js","assets/use-copy-to-clipboard-B_hLBe4B.js","assets/badge-BKSMHkp0.js","assets/circle-arrow-up-CIbUxWQH.js","assets/plus-B2bCpZwK.js","assets/cli-command-panel-6y7lIIEk.js","assets/trash-Cld2LszV.js","assets/triangle-alert-C1TQEnVr.js","assets/input-BQ0Z_3Rn.js","assets/useRegisterFieldControl-2wfx_hff.js","assets/field-CdWgmJnb.js","assets/label-nsvgDMlM.js","assets/ec2-CFH_oZL-.js","assets/queryOptions-Dfvzj6n2.js","assets/awsClient-Bfha5_4q.js","assets/index.browser-BEm-79SP.js","assets/eks-j8s21sCa.js","assets/useMutation-DUoxMVOX.js","assets/commandBuilder-CESzk-i-.js","assets/json-Bxiz7ri1.js","assets/subnet-calculator-DDrAb5xp.js","assets/eks-mDgzhwRj.js","assets/iam-DJ1v8m_E.js","assets/commandBuilder-BAvLwAkh.js","assets/alert-dialog-BjdCxp9B.js","assets/DialogTitle-C7C5rGOv.js","assets/delete-confirmation-dialog-B0cn0SrY.js","assets/state-badge-DBoxk51U.js","assets/gpu-instance-type-select-DlDpx87Z.js","assets/select-BIT7N5Xs.js","assets/zod-MSeC4rDe.js","assets/json-editor-S6v1L2dQ.js","assets/textarea-BSJQEqvD.js"])))=>i.map(i=>d[i]);
|
||||
import{n as e,t}from"./lazyRouteComponent-CARaY65b.js";import{i as n}from"./sliders-horizontal-BEXi-wIi.js";import{d as r}from"./detail-panel-CrYYi78V.js";import{_ as i}from"./httpRequest-BWGSDCcn.js";import{wo as a}from"./awsClient-Bfha5_4q.js";import{s as o,t as s,u as c}from"./eks-mDgzhwRj.js";var l=e(`/_auth/eks/(clusters)/list-clusters/$clusterName`)({validateSearch:i({tab:r.tab}),search:{middlewares:[n({tab:`overview`})]},loader:async({context:e,params:t})=>{await Promise.all([e.queryClient.query({...o(t.clusterName),staleTime:`static`}),e.queryClient.query({...c(t.clusterName),staleTime:`static`}),e.queryClient.query({...s(t.clusterName),staleTime:`static`})])},head:({params:e})=>({meta:[{title:`${e.clusterName} | EKS | Mulga`}]}),component:t(()=>a(()=>import(`./_clusterName-O91FUfO3.js`),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64])),`component`)});export{l as t};
|
||||
-1
@@ -1 +0,0 @@
|
||||
import{D as e,O as t}from"./utils-DWZT3JVe.js";import{t as n}from"./_clusterName-N1PVGTEF.js";import{t as r}from"./cluster-details-DFT4POHi.js";var i=e(),a=t();function o(){let e=(0,i.c)(8),{clusterName:t}=n.useParams(),{tab:o}=n.useSearch(),s=n.useNavigate(),c,l;e[0]===s?(c=e[1],l=e[2]):(c=()=>{s({to:`/eks/list-clusters`})},l=e=>{s({search:{tab:e},replace:!0})},e[0]=s,e[1]=c,e[2]=l);let u;return e[3]!==t||e[4]!==c||e[5]!==l||e[6]!==o?(u=(0,a.jsx)(r,{clusterName:t,layout:`page`,onClose:c,onTabChange:l,tab:o}),e[3]=t,e[4]=c,e[5]=l,e[6]=o,e[7]=u):u=e[7],u}export{o as component};
|
||||
@@ -1,2 +0,0 @@
|
||||
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/_groupName-BSMhLF7N.js","assets/utils-DWZT3JVe.js","assets/useSuspenseQuery-Bmlpew-m.js","assets/useBaseQuery-BSM_7Lt0.js","assets/removable-BFKPzTxX.js","assets/group-details-Dm3zqNBL.js","assets/link-BGxJCEND.js","assets/sliders-horizontal-BEXi-wIi.js","assets/createLucideIcon-tJJfUBq0.js","assets/detail-panel-CrYYi78V.js","assets/arrow-left-JquoanQ-.js","assets/check-Bw3sTMpr.js","assets/chevron-down-BeOzYI5_.js","assets/copy-CPg1QJTq.js","assets/popover-ijVU93mX.js","assets/with-selector-BqsWf3iS.js","assets/useTransitionStatus-DyzTRRUD.js","assets/reason-parts-8YFx1EZT.js","assets/useScrollLock-Cjev-BGV.js","assets/useTimeout-BGqaqlit.js","assets/useValueAsRef-DRGL6zPa.js","assets/visuallyHidden-CmQ9ItcI.js","assets/ToolbarRootContext-C3Y20QkS.js","assets/useValueChanged-CrBHhQDK.js","assets/isElementDisabled-C16xL2fI.js","assets/store-C5W6n22f.js","assets/button-BbwngWdY.js","assets/httpRequest-BWGSDCcn.js","assets/collapsible-DVIlVwfv.js","assets/useControlled-B2BodiHe.js","assets/error-banner-VBz3AqJt.js","assets/tabs-BQS5cQJb.js","assets/use-copy-to-clipboard-B_hLBe4B.js","assets/trash-Cld2LszV.js","assets/iam-BwQWIl-Z.js","assets/useMutation-DUoxMVOX.js","assets/awsClient-Bfha5_4q.js","assets/index.browser-BEm-79SP.js","assets/commandBuilder-BAvLwAkh.js","assets/iam-DJ1v8m_E.js","assets/queryOptions-Dfvzj6n2.js","assets/json-Bxiz7ri1.js","assets/delete-confirmation-dialog-B0cn0SrY.js","assets/alert-dialog-BjdCxp9B.js","assets/DialogTitle-C7C5rGOv.js","assets/resource-delete-dialog-DBpHBWfY.js","assets/iam-items-CuRnA_Xq.js","assets/inline-policies-panel-T6-2vH-a.js","assets/useQuery-CktWjZg4.js","assets/plus-B2bCpZwK.js","assets/unlink-vJE1WvZ0.js","assets/input-BQ0Z_3Rn.js","assets/useRegisterFieldControl-2wfx_hff.js","assets/field-CdWgmJnb.js","assets/label-nsvgDMlM.js","assets/json-editor-S6v1L2dQ.js","assets/textarea-BSJQEqvD.js","assets/iam-9Q8I8Mc9.js","assets/back-link-uAOd4aC_.js"])))=>i.map(i=>d[i]);
|
||||
import{n as e,t}from"./lazyRouteComponent-CARaY65b.js";import{i as n}from"./sliders-horizontal-BEXi-wIi.js";import{d as r}from"./detail-panel-CrYYi78V.js";import{_ as i}from"./httpRequest-BWGSDCcn.js";import{wo as a}from"./awsClient-Bfha5_4q.js";import{C as o,a as s,n as c,p as l,s as u}from"./iam-DJ1v8m_E.js";var d=e(`/_auth/iam/(groups)/list-groups/$groupName`)({validateSearch:i({tab:r.tab}),search:{middlewares:[n({tab:`overview`})]},loader:async({context:e,params:t})=>{await Promise.all([e.queryClient.query({...u(t.groupName),staleTime:`static`}),e.queryClient.query({...c(t.groupName),staleTime:`static`}),e.queryClient.query({...l,staleTime:`static`}),e.queryClient.query({...o,staleTime:`static`}),e.queryClient.query({...s(t.groupName),staleTime:`static`})])},head:({params:e})=>({meta:[{title:`${e.groupName} | IAM | Mulga`}]}),component:t(()=>a(()=>import(`./_groupName-BSMhLF7N.js`),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58])),`component`)});export{d as t};
|
||||
@@ -1 +0,0 @@
|
||||
import{D as e,O as t}from"./utils-DWZT3JVe.js";import{t as n}from"./useSuspenseQuery-Bmlpew-m.js";import{t as r}from"./group-details-Dm3zqNBL.js";import{s as i}from"./iam-DJ1v8m_E.js";import{t as a}from"./back-link-uAOd4aC_.js";import{t as o}from"./_groupName-B7Qsd3n4.js";var s=e(),c=t();function l(){let e=(0,s.c)(11),{groupName:t}=o.useParams(),{tab:l}=o.useSearch(),u=o.useNavigate(),d;e[0]===t?d=e[1]:(d=i(t),e[0]=t,e[1]=d);let{data:f}=n(d),p=f.Group;if(!p){let t;return e[2]===Symbol.for(`react.memo_cache_sentinel`)?(t=(0,c.jsxs)(c.Fragment,{children:[(0,c.jsx)(a,{to:`/iam/list-groups`,children:`Back to groups`}),(0,c.jsx)(`p`,{className:`text-muted-foreground`,children:`Group not found.`})]}),e[2]=t):t=e[2],t}let m,h;e[3]===u?(m=e[4],h=e[5]):(m=()=>{u({to:`/iam/list-groups`})},h=e=>{u({search:{tab:e},replace:!0})},e[3]=u,e[4]=m,e[5]=h);let g;return e[6]!==p||e[7]!==m||e[8]!==h||e[9]!==l?(g=(0,c.jsx)(r,{layout:`page`,onClose:m,onTabChange:h,group:p,tab:l}),e[6]=p,e[7]=m,e[8]=h,e[9]=l,e[10]=g):g=e[10],g}export{l as component};
|
||||
@@ -0,0 +1,2 @@
|
||||
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/_groupName-Zt7ukcEk.js","assets/httpRequest-Dr-qoOs4.js","assets/useSuspenseQuery-DwsF_otq.js","assets/useBaseQuery-CE1m2pVx.js","assets/removable-BrmR30Xo.js","assets/link-CWdxFyZC.js","assets/useNavigate-CsLoTHJy.js","assets/back-link-C3QOsdXo.js","assets/createLucideIcon-Dgwegtfa.js","assets/trash-DF02nms9.js","assets/button-BGIWso6R.js","assets/dist-BdB_tTMf.js","assets/iam-Bd10-tKI.js","assets/useMutation-DwiSMcUf.js","assets/mutation-BPTwFI3g.js","assets/awsClient-cDSIkGhJ.js","assets/index.browser-BEm-79SP.js","assets/commandBuilder-Dlr2ruMS.js","assets/iam-DAHbuObM.js","assets/queryOptions-Dfvzj6n2.js","assets/json-Brvpv93s.js","assets/page-heading-CZgcG0Ab.js","assets/error-banner-Peby0Lty.js","assets/delete-confirmation-dialog-y6KuQeVG.js","assets/alert-dialog-DJMXYx5d.js","assets/DialogTitle-8-mIxSiF.js","assets/useValueAsRef-f_QUos9a.js","assets/reason-parts-CrUlyFi3.js","assets/useScrollLock-CqzK4Gu2.js","assets/with-selector-DlOquSVb.js","assets/detail-row-BNG8u2EX.js","assets/inline-policies-panel-DOpRel7m.js","assets/useQuery-ChxTuGg1.js","assets/input-BwqTIkib.js","assets/useValueChanged-CmZ_T1Ux.js","assets/field-BPwqN4_m.js","assets/separator-AD26D8dQ.js","assets/json-editor-HnrPKfFL.js","assets/textarea-CAbwCjSM.js","assets/iam-Bet2c29S.js"])))=>i.map(i=>d[i]);
|
||||
import{n as e,t}from"./lazyRouteComponent-Cf7L8vmE.js";import{wo as n}from"./awsClient-cDSIkGhJ.js";import{C as r,a as i,n as a,p as o,s}from"./iam-DAHbuObM.js";var c=e(`/_auth/iam/(groups)/list-groups/$groupName`)({loader:async({context:e,params:t})=>{await Promise.all([e.queryClient.query({...s(t.groupName),staleTime:`static`}),e.queryClient.query({...a(t.groupName),staleTime:`static`}),e.queryClient.query({...o,staleTime:`static`}),e.queryClient.query({...r,staleTime:`static`}),e.queryClient.query({...i(t.groupName),staleTime:`static`})])},head:({params:e})=>({meta:[{title:`${e.groupName} | IAM | Mulga`}]}),component:t(()=>n(()=>import(`./_groupName-Zt7ukcEk.js`),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39])),`component`)});export{c as t};
|
||||
@@ -0,0 +1 @@
|
||||
import{E as e,L as t,R as n,V as r}from"./httpRequest-Dr-qoOs4.js";import{t as i}from"./useSuspenseQuery-DwsF_otq.js";import{t as a}from"./link-CWdxFyZC.js";import{t as o}from"./useNavigate-CsLoTHJy.js";import{t as s}from"./back-link-C3QOsdXo.js";import{t as c}from"./trash-DF02nms9.js";import{t as l}from"./button-BGIWso6R.js";import{b as u,d,n as f}from"./iam-Bd10-tKI.js";import{C as p,s as m}from"./iam-DAHbuObM.js";import{t as h}from"./_groupName-C-lVPvpd.js";import{t as g}from"./page-heading-CZgcG0Ab.js";import{t as _}from"./error-banner-Peby0Lty.js";import{t as v}from"./delete-confirmation-dialog-y6KuQeVG.js";import{n as y,t as b}from"./detail-row-BNG8u2EX.js";import{n as x,t as S}from"./inline-policies-panel-DOpRel7m.js";var C=r(n()),w=t();function T(){let{groupName:t}=h.useParams(),n=o(),{data:r}=i(m(t)),{data:T}=i(p),E=d(),D=f(),O=u(),[k,A]=(0,C.useState)(!1),[j,M]=(0,C.useState)(!1),[N,P]=(0,C.useState)(null),F=r.Group,I=r.Users??[],L=T.Users??[],R=new Set(I.map(e=>e.UserName)),z=L.filter(e=>e.UserName&&!R.has(e.UserName)),B=async()=>{try{await E.mutateAsync(t),n({to:`/iam/list-groups`})}finally{A(!1)}},V=async e=>{await D.mutateAsync({groupName:t,userName:e}),M(!1)},H=async e=>{P(e);try{await O.mutateAsync({groupName:t,userName:e})}finally{P(null)}};return F?(0,w.jsxs)(w.Fragment,{children:[(0,w.jsx)(s,{to:`/iam/list-groups`,children:`Back to groups`}),E.error&&(0,w.jsx)(_,{error:E.error,msg:`Failed to delete group`}),D.error&&(0,w.jsx)(_,{error:D.error,msg:`Failed to add user`}),O.error&&(0,w.jsx)(_,{error:O.error,msg:`Failed to remove user`}),(0,w.jsxs)(`div`,{className:`space-y-6`,children:[(0,w.jsx)(g,{actions:(0,w.jsxs)(l,{onClick:()=>{A(!0)},size:`sm`,variant:`destructive`,children:[(0,w.jsx)(c,{className:`size-4`}),`Delete`]}),subtitle:`Group Details`,title:F.GroupName??``}),(0,w.jsxs)(y,{children:[(0,w.jsx)(y.Header,{children:`Group Information`}),(0,w.jsxs)(y.Content,{children:[(0,w.jsx)(b,{label:`Group Name`,value:F.GroupName}),(0,w.jsx)(b,{label:`Group ID`,value:F.GroupId}),(0,w.jsx)(b,{label:`ARN`,value:F.Arn}),(0,w.jsx)(b,{label:`Path`,value:F.Path}),(0,w.jsx)(b,{label:`Created`,value:e(F.CreateDate)})]})]}),(0,w.jsxs)(y,{children:[(0,w.jsx)(y.Header,{children:(0,w.jsxs)(`div`,{className:`flex items-center justify-between`,children:[(0,w.jsx)(`span`,{children:`Members`}),(0,w.jsx)(l,{onClick:()=>{M(!j)},size:`sm`,children:`Add User`})]})}),(0,w.jsxs)(y.Content,{children:[j&&z.length>0&&(0,w.jsxs)(`div`,{className:`col-span-2 space-y-2 rounded-md border p-3`,children:[(0,w.jsx)(`p`,{className:`text-sm font-medium`,children:`Select a user to add:`}),(0,w.jsx)(`div`,{className:`space-y-1`,children:z.map(e=>(0,w.jsxs)(`button`,{className:`flex w-full items-center justify-between rounded-md p-2 text-left text-sm hover:bg-accent`,disabled:D.isPending,onClick:()=>{e.UserName&&V(e.UserName)},type:`button`,children:[(0,w.jsx)(`span`,{children:e.UserName}),(0,w.jsx)(`span`,{className:`text-xs text-muted-foreground`,children:e.Arn})]},e.UserName))})]}),j&&z.length===0&&(0,w.jsx)(`p`,{className:`col-span-2 text-sm text-muted-foreground`,children:`No users available to add.`}),I.length>0?(0,w.jsx)(`div`,{className:`col-span-2 space-y-3`,children:I.map(e=>(0,w.jsxs)(`div`,{className:`flex items-center justify-between rounded-md border p-3`,children:[(0,w.jsx)(a,{className:`text-sm font-medium hover:underline`,params:{userName:e.UserName??``},to:`/iam/list-users/$userName`,children:e.UserName}),(0,w.jsx)(l,{disabled:N===e.UserName,onClick:()=>{e.UserName&&H(e.UserName)},size:`sm`,variant:`outline`,children:`Remove`})]},e.UserName))}):(0,w.jsx)(`p`,{className:`col-span-2 text-sm text-muted-foreground`,children:`No members.`})]})]}),(0,w.jsx)(x,{kind:`group`,name:t}),(0,w.jsx)(S,{kind:`group`,name:t})]}),(0,w.jsx)(v,{description:`Are you sure you want to delete the group "${t}"? This action cannot be undone. You must remove all members and detach all policies first.`,isPending:E.isPending,onConfirm:B,onOpenChange:A,open:k,title:`Delete Group`})]}):(0,w.jsxs)(w.Fragment,{children:[(0,w.jsx)(s,{to:`/iam/list-groups`,children:`Back to groups`}),(0,w.jsx)(`p`,{className:`text-muted-foreground`,children:`Group not found.`})]})}export{T as component};
|
||||
-2
@@ -1,2 +0,0 @@
|
||||
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/_guardrailId.edit-Dh1OVCyu.js","assets/utils-DWZT3JVe.js","assets/useSuspenseQuery-Bmlpew-m.js","assets/useBaseQuery-BSM_7Lt0.js","assets/removable-BFKPzTxX.js","assets/bedrock-cP1tuPJe.js","assets/useMutation-DUoxMVOX.js","assets/httpRequest-BWGSDCcn.js","assets/awsClient-Bfha5_4q.js","assets/index.browser-BEm-79SP.js","assets/commandBuilder-McIhcE9L.js","assets/tags-OXBfmIj9.js","assets/bedrock-TVh1n8Zu.js","assets/queryOptions-Dfvzj6n2.js","assets/guardrail-form-page-DUzMmz9u.js","assets/useNavigate-nOPPGs9C.js","assets/link-BGxJCEND.js","assets/plus-B2bCpZwK.js","assets/createLucideIcon-tJJfUBq0.js","assets/trash-Cld2LszV.js","assets/button-BbwngWdY.js","assets/reason-parts-8YFx1EZT.js","assets/input-BQ0Z_3Rn.js","assets/useRegisterFieldControl-2wfx_hff.js","assets/useTimeout-BGqaqlit.js","assets/useValueChanged-CrBHhQDK.js","assets/label-nsvgDMlM.js","assets/field-CdWgmJnb.js","assets/textarea-BSJQEqvD.js","assets/error-banner-VBz3AqJt.js","assets/page-heading-gkoGGkaM.js","assets/zod-MSeC4rDe.js","assets/back-link-uAOd4aC_.js","assets/arrow-left-JquoanQ-.js","assets/switch-aH9x9eyI.js","assets/useAriaLabelledBy-BEnyJHxq.js","assets/visuallyHidden-CmQ9ItcI.js","assets/useControlled-B2BodiHe.js","assets/form-actions-DzQ-ZPuD.js","assets/form-fields-DepfpjET.js","assets/select-BIT7N5Xs.js","assets/useTransitionStatus-DyzTRRUD.js","assets/check-Bw3sTMpr.js","assets/chevron-down-BeOzYI5_.js","assets/useScrollLock-Cjev-BGV.js","assets/with-selector-BqsWf3iS.js","assets/useValueAsRef-DRGL6zPa.js","assets/ToolbarRootContext-C3Y20QkS.js","assets/isElementDisabled-C16xL2fI.js","assets/tags-field-array-DZNvVWQs.js"])))=>i.map(i=>d[i]);
|
||||
import{n as e,t}from"./lazyRouteComponent-CARaY65b.js";import{wo as n}from"./awsClient-Bfha5_4q.js";import{n as r}from"./bedrock-TVh1n8Zu.js";var i=e(`/_auth/bedrock/(guardrails)/list-guardrails/$guardrailId/edit`)({loader:async({context:e,params:t})=>{await e.queryClient.query({...r(t.guardrailId),staleTime:`static`})},head:({params:e})=>({meta:[{title:`Edit ${e.guardrailId} | Guardrail | Mulga`}]}),component:t(()=>n(()=>import(`./_guardrailId.edit-Dh1OVCyu.js`),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49])),`component`)});export{i as t};
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/_guardrailId.edit-CK5cOZc8.js","assets/httpRequest-Dr-qoOs4.js","assets/useSuspenseQuery-DwsF_otq.js","assets/useBaseQuery-CE1m2pVx.js","assets/removable-BrmR30Xo.js","assets/bedrock-BV2ZQzbN.js","assets/useMutation-DwiSMcUf.js","assets/mutation-BPTwFI3g.js","assets/awsClient-cDSIkGhJ.js","assets/index.browser-BEm-79SP.js","assets/commandBuilder-Be-223w5.js","assets/tags-3YIma8D8.js","assets/bedrock-C4_IU4jK.js","assets/queryOptions-Dfvzj6n2.js","assets/guardrail-form-page-DhoIHbGq.js","assets/useNavigate-CsLoTHJy.js","assets/link-CWdxFyZC.js","assets/back-link-C3QOsdXo.js","assets/createLucideIcon-Dgwegtfa.js","assets/plus-B4RXirTR.js","assets/trash-DF02nms9.js","assets/button-BGIWso6R.js","assets/dist-BdB_tTMf.js","assets/input-BwqTIkib.js","assets/useValueChanged-CmZ_T1Ux.js","assets/reason-parts-CrUlyFi3.js","assets/page-heading-CZgcG0Ab.js","assets/zod-B9u9z7RI.js","assets/error-banner-Peby0Lty.js","assets/form-actions-DlsOlY4t.js","assets/form-fields-DpAjHc0Q.js","assets/select-DRJzHz-U.js","assets/useValueAsRef-f_QUos9a.js","assets/check-C91NAfOp.js","assets/chevron-down-Nw5hQB-o.js","assets/useScrollLock-CqzK4Gu2.js","assets/with-selector-DlOquSVb.js","assets/usePositioner-BW8fBIgP.js","assets/DirectionContext-CJn2gj-2.js","assets/useCompositeListItem-D4FWBaCx.js","assets/useControlled-Cbjuk53h.js","assets/field-BPwqN4_m.js","assets/separator-AD26D8dQ.js","assets/tags-field-array-B74axfKt.js","assets/textarea-CAbwCjSM.js"])))=>i.map(i=>d[i]);
|
||||
import{n as e,t}from"./lazyRouteComponent-Cf7L8vmE.js";import{wo as n}from"./awsClient-cDSIkGhJ.js";import{n as r}from"./bedrock-C4_IU4jK.js";var i=e(`/_auth/bedrock/(guardrails)/list-guardrails/$guardrailId/edit`)({loader:async({context:e,params:t})=>{await e.queryClient.query({...r(t.guardrailId),staleTime:`static`})},head:({params:e})=>({meta:[{title:`Edit ${e.guardrailId} | Guardrail | Mulga`}]}),component:t(()=>n(()=>import(`./_guardrailId.edit-CK5cOZc8.js`),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44])),`component`)});export{i as t};
|
||||
+1
@@ -0,0 +1 @@
|
||||
import{I as e,L as t}from"./httpRequest-Dr-qoOs4.js";import{t as n}from"./useSuspenseQuery-DwsF_otq.js";import{f as r}from"./bedrock-BV2ZQzbN.js";import{n as i}from"./bedrock-C4_IU4jK.js";import{t as a}from"./_guardrailId.edit-C-5diXgZ.js";import{t as o}from"./guardrail-form-page-DhoIHbGq.js";var s=e(),c=t();function l(){let e=(0,s.c)(7),{guardrailId:t}=a.useParams(),l;e[0]===t?l=e[1]:(l=i(t),e[0]=t,e[1]=l);let{data:u}=n(l),d;e[2]===u?d=e[3]:(d=r(u),e[2]=u,e[3]=d);let f;return e[4]!==t||e[5]!==d?(f=(0,c.jsx)(o,{defaultValues:d,guardrailId:t,mode:`edit`}),e[4]=t,e[5]=d,e[6]=f):f=e[6],f}export{l as component};
|
||||
-1
@@ -1 +0,0 @@
|
||||
import{D as e,O as t}from"./utils-DWZT3JVe.js";import{t as n}from"./useSuspenseQuery-Bmlpew-m.js";import{f as r}from"./bedrock-cP1tuPJe.js";import{n as i}from"./bedrock-TVh1n8Zu.js";import{t as a}from"./_guardrailId.edit-BiGM7c1g.js";import{t as o}from"./guardrail-form-page-DUzMmz9u.js";var s=e(),c=t();function l(){let e=(0,s.c)(7),{guardrailId:t}=a.useParams(),l;e[0]===t?l=e[1]:(l=i(t),e[0]=t,e[1]=l);let{data:u}=n(l),d;e[2]===u?d=e[3]:(d=r(u),e[2]=u,e[3]=d);let f;return e[4]!==t||e[5]!==d?(f=(0,c.jsx)(o,{defaultValues:d,guardrailId:t,mode:`edit`}),e[4]=t,e[5]=d,e[6]=f):f=e[6],f}export{l as component};
|
||||
+1
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user