diff --git a/.github/workflows/debug-conformance.yml b/.github/workflows/debug-conformance.yml index b7cc3e60..f6bc5245 100644 --- a/.github/workflows/debug-conformance.yml +++ b/.github/workflows/debug-conformance.yml @@ -23,6 +23,11 @@ on: required: false default: true type: boolean + formae_branch: + description: "Build formae from this branch instead of using the released binary (e.g. main) - use it to reproduce a nightly-only failure" + required: false + default: "" + type: string # Share the serialization group with ci.yml and nightly.yml so runs never race # for the same GCP test project. A debug invocation queues behind an in-flight @@ -117,6 +122,38 @@ jobs: workload_identity_provider: ${{ secrets.GCP_WORKLOAD_IDENTITY_PROVIDER }} service_account: ${{ secrets.GCP_SERVICE_ACCOUNT }} + # Without this the run uses the released formae the SDK installs, which is + # the wrong binary for reproducing a nightly failure: the nightly validates + # plugin main against formae main, so an agent-side change that has not been + # released yet is only visible here. Mirrors the nightly's build step. + - name: Build formae from branch + if: inputs.formae_branch != '' + run: | + BRANCH="${{ inputs.formae_branch }}" + git clone --branch "${BRANCH}" --depth 1 https://github.com/platform-engineering-labs/formae.git /tmp/formae + cd /tmp/formae + git fetch --tags + # The baked VERSION becomes FORMAE_VERSION for plugins, which resolve + # their formae PKL dep against a published schema of that version, so + # it has to be a released X.Y.Z - the latest tag with any -dev.N + # suffix stripped, exactly as the nightly picks it. + LATEST_TAG=$(git tag -l "[0-9]*" --sort=-version:refname | head -1) + VERSION=$(echo "$LATEST_TAG" | cut -d'-' -f1) + echo "Building formae ${BRANCH} ($(git rev-parse --short HEAD)) with VERSION=${VERSION}" + make build VERSION="${VERSION}" + echo "FORMAE_BINARY=/tmp/formae/formae" >> $GITHUB_ENV + + - name: Inject formae replace directives + if: inputs.formae_branch != '' + run: | + for pkg in pkg/auth pkg/model pkg/plugin pkg/plugin-conformance-tests; do + if grep -q "formae/$pkg" go.mod 2>/dev/null; then + echo "Injecting replace for $pkg -> /tmp/formae/$pkg" + go mod edit -replace "github.com/platform-engineering-labs/formae/$pkg=/tmp/formae/$pkg" + fi + done + go mod tidy + - name: Install plugin run: make install diff --git a/CHANGELOG.md b/CHANGELOG.md index db845d9b..2435bce6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,14 +10,20 @@ formae agent. ## [Unreleased] -### Fixed - -- A `GCP::Compute::DiskAsyncReplication` no longer plans a replacement of itself. - Its two disk references are createOnly, and an extracted forma writes a - reference to another resource unresolved, so comparing it against the URL - already in state read as a change to an immutable field. Which disks a pair - joins is fixed at creation and is what its native ID is made of, so the two - fields are now write-only - excluded from drift detection, unchanged on create. +### Removed + +- `GCP::Compute::DiskAsyncReplication` is withdrawn. It never shipped in a stable + release, only in 0.1.13-dev.1 and -dev.2. Its two properties are the disks the + pair joins: both immutable, and both declared as references to the disk + resources, which is how a forma names them. An extracted forma writes such a + reference unresolved, so the re-apply compares a reference against the URL in + state on an immutable path and plans a replacement of the pair already in + place. Suppressing that comparison fixes the lifecycle and makes the pair + undiscoverable, because the agent requires both fields on a resource it + persists; reporting them keeps discovery and brings the replacement back. No + plugin-side shape satisfies both, so the type is out until the agent can + resolve a reference at plan time. The work, the four conformance runs behind + that conclusion, and the two agent-side asks are in the draft PR. ### Fixed diff --git a/README.md b/README.md index e3142b93..018b3955 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,6 @@ This plugin supports **154 GCP resource types** across 32 services. See | `GCP::Compute::BackendService` | Global backend service for load balancers | | `GCP::Compute::BackendServiceSignedUrlKey` | Cloud CDN signed-URL key on a backend service | | `GCP::Compute::Disk` | Persistent disk | -| `GCP::Compute::DiskAsyncReplication` | Replication link between a primary disk and a secondary disk in another region | | `GCP::Compute::DiskResourcePolicyAttachment` | Attaches a `resourcePolicy` to a disk | | `GCP::Compute::ExternalVpnGateway` | Describes the *other* end of a VPN: the on-prem or other-cloud device, by its public IP(s) | | `GCP::Compute::Firewall` | VPC firewall rule | diff --git a/pkg/resources/compute/disk_async_replication.go b/pkg/resources/compute/disk_async_replication.go deleted file mode 100644 index f6fc926a..00000000 --- a/pkg/resources/compute/disk_async_replication.go +++ /dev/null @@ -1,369 +0,0 @@ -// © 2025 Platform Engineering Labs Inc. -// -// SPDX-License-Identifier: FSL-1.1-ALv2 - -package compute - -import ( - "context" - "encoding/json" - "fmt" - "strings" - - "github.com/platform-engineering-labs/formae-plugin-gcp/pkg/config" - "github.com/platform-engineering-labs/formae-plugin-gcp/pkg/resources/base" - "github.com/platform-engineering-labs/formae-plugin-gcp/pkg/resources/prov" - "github.com/platform-engineering-labs/formae-plugin-gcp/pkg/resources/registry" - "github.com/platform-engineering-labs/formae-plugin-gcp/pkg/transport" - "github.com/platform-engineering-labs/formae/pkg/plugin/resource" -) - -// DiskAsyncReplicationProvisioner manages the replication link between a primary -// disk and a secondary disk in another region — the pairing that makes -// cross-region disaster recovery work. The disks themselves are ordinary -// `Disk` resources; this is the relationship between them. -// -// It is not a REST resource: startAsyncReplication and stopAsyncReplication are -// verbs on the primary disk. The one subtlety worth knowing is that stopping -// replication does not clear `asyncPrimaryDisk` from the secondary — only -// `resourceStatus.asyncPrimaryDisk.state` changes, from ACTIVE to STOPPED. So a -// read has to judge by state, not by whether the field is present, or a stopped -// pair would look like a live one forever. -type DiskAsyncReplicationProvisioner struct { - *base.BaseResource -} - -var _ prov.Provisioner = (*DiskAsyncReplicationProvisioner)(nil) - -// asyncReplicationActiveState is the only state that counts as replicating. -const asyncReplicationActiveState = "ACTIVE" - -func init() { - registry.Register(DiskAsyncReplicationResourceType, - []resource.Operation{ - resource.OperationCreate, - resource.OperationRead, - resource.OperationDelete, - resource.OperationList, - resource.OperationCheckStatus, - }, - func(cfg *config.Config) prov.Provisioner { - return &DiskAsyncReplicationProvisioner{ - BaseResource: &base.BaseResource{ - Config: cfg, - APIConfig: ComputeAPI, - OperationConfig: ComputeOperations, - ResourceConfig: base.ResourceConfig{ - ResourceType: "disks", - Scope: &base.ScopeConfig{Type: base.ScopeZonal}, - }, - NativeIDConfig: ComputeNativeID, - }, - } - }) -} - -// buildAsyncReplicationNativeID composes -// "projects/{p}/zones/{primaryZone}/disks/{primary}/asyncReplication/{secondaryZone}/{secondary}". -// Both ends have to survive: the verbs live on the primary, and only the -// secondary reports the state. -func buildAsyncReplicationNativeID(project, primaryZone, primary, secondaryZone, secondary string) string { - return fmt.Sprintf("projects/%s/zones/%s/disks/%s/asyncReplication/%s/%s", - project, primaryZone, primary, secondaryZone, secondary) -} - -func parseAsyncReplicationNativeID(nativeID string) (project, primaryZone, primary, secondaryZone, secondary string, err error) { - parts := strings.Split(nativeID, "/") - if len(parts) != 9 || parts[0] != "projects" || parts[2] != "zones" || - parts[4] != "disks" || parts[6] != "asyncReplication" || - parts[3] == "" || parts[5] == "" || parts[7] == "" || parts[8] == "" { - return "", "", "", "", "", fmt.Errorf("invalid disk async replication native ID: %s", nativeID) - } - return parts[1], parts[3], parts[5], parts[7], parts[8], nil -} - -// diskRefParts pulls the zone and name out of a disk reference, which may be a -// full URL or a bare "zones/{z}/disks/{n}" path. -func diskRefParts(ref string) (zone, name string, ok bool) { - parts := strings.Split(strings.TrimSuffix(ref, "/"), "/") - for i := len(parts) - 1; i >= 1; i-- { - if parts[i-1] == "disks" { - name = parts[i] - break - } - } - for i := 0; i+1 < len(parts); i++ { - if parts[i] == "zones" { - zone = parts[i+1] - } - } - return zone, name, zone != "" && name != "" -} - -// asyncReplicationState reads the secondary disk's replication state, which is -// the only place the live/stopped distinction is reported. -func asyncReplicationState(secondaryDisk map[string]interface{}) string { - status, ok := secondaryDisk["resourceStatus"].(map[string]interface{}) - if !ok { - return "" - } - primary, ok := status["asyncPrimaryDisk"].(map[string]interface{}) - if !ok { - return "" - } - state, _ := primary["state"].(string) - return state -} - -// asyncPrimaryDiskRef reads which primary the secondary is paired with. -func asyncPrimaryDiskRef(secondaryDisk map[string]interface{}) string { - primary, ok := secondaryDisk["asyncPrimaryDisk"].(map[string]interface{}) - if !ok { - return "" - } - disk, _ := primary["disk"].(string) - return disk -} - -func (p *DiskAsyncReplicationProvisioner) diskURL(project, zone, disk string) string { - return fmt.Sprintf("%s/projects/%s/zones/%s/disks/%s", - p.APIConfig.BaseURL, project, zone, disk) -} - -func (p *DiskAsyncReplicationProvisioner) projectFor(targetConfig json.RawMessage, fallback string) string { - if cfg := config.PathFromTargetConfig(targetConfig); cfg.Project != "" { - return cfg.Project - } - return fallback -} - -func (p *DiskAsyncReplicationProvisioner) issueVerb( - ctx context.Context, url string, body map[string]interface{}, project, zone string, -) (string, *transport.Error) { - client, err := transport.NewClient(ctx, p.Config) - if err != nil { - return "", transport.WrapError(err, "failed to create transport client") - } - resp, sErr := client.SendRequest(ctx, transport.RequestOptions{ - Method: "POST", - URL: url, - Body: body, - }) - if sErr != nil { - return "", transport.WrapError(sErr, "disk async replication verb failed") - } - opID := p.OperationConfig.OperationIDExtractor(resp.Body) - return p.OperationConfig.OperationURLBuilder( - base.PathContext{Project: project, Zone: zone}, opID), nil -} - -func (p *DiskAsyncReplicationProvisioner) fetchDisk( - ctx context.Context, project, zone, disk string, -) (map[string]interface{}, bool, *transport.Error) { - client, cErr := transport.NewClient(ctx, p.Config) - if cErr != nil { - return nil, false, transport.WrapError(cErr, "failed to create transport client") - } - resp, rErr := client.SendRequest(ctx, transport.RequestOptions{ - Method: "GET", - URL: p.diskURL(project, zone, disk), - }) - if rErr != nil { - wrapped := transport.WrapError(rErr, "failed to read disk") - if transport.ToResourceErrorCode(wrapped.Code) == resource.OperationErrorCodeNotFound { - return nil, true, nil - } - return nil, false, wrapped - } - return resp.Body, false, nil -} - -func (p *DiskAsyncReplicationProvisioner) Create( - ctx context.Context, request *resource.CreateRequest, -) (*resource.CreateResult, error) { - var props map[string]interface{} - if err := json.Unmarshal(request.Properties, &props); err != nil { - return createFailure(resource.OperationErrorCodeInvalidRequest, - fmt.Sprintf("invalid properties: %v", err)), nil - } - primaryRef, _ := props["primaryDisk"].(string) - secondaryRef, _ := props["secondaryDisk"].(string) - primaryZone, primary, okP := diskRefParts(primaryRef) - secondaryZone, secondary, okS := diskRefParts(secondaryRef) - if !okP || !okS { - return createFailure(resource.OperationErrorCodeInvalidRequest, - "primaryDisk and secondaryDisk must be zonal disk references"), nil - } - project := p.projectFor(request.TargetConfig, "") - if project == "" { - return createFailure(resource.OperationErrorCodeInvalidRequest, - "target project is required"), nil - } - - requestID, verbErr := p.issueVerb(ctx, - p.diskURL(project, primaryZone, primary)+"/startAsyncReplication", - map[string]interface{}{"asyncSecondaryDisk": secondaryRef}, project, primaryZone) - if verbErr != nil { - return createFailure(transport.ToResourceErrorCode(verbErr.Code), verbErr.Message), nil - } - return &resource.CreateResult{ - ProgressResult: &resource.ProgressResult{ - Operation: resource.OperationCreate, - OperationStatus: resource.OperationStatusInProgress, - NativeID: buildAsyncReplicationNativeID(project, primaryZone, primary, - secondaryZone, secondary), - RequestID: requestID, - StatusMessage: "disk async replication start in progress", - }, - }, nil -} - -// Read judges by the secondary's replication state. Stopping replication leaves -// asyncPrimaryDisk in place, so presence of the field proves nothing. -func (p *DiskAsyncReplicationProvisioner) Read( - ctx context.Context, request *resource.ReadRequest, -) (*resource.ReadResult, error) { - project, primaryZone, primary, secondaryZone, secondary, err := - parseAsyncReplicationNativeID(request.NativeID) - if err != nil { - return &resource.ReadResult{ErrorCode: resource.OperationErrorCodeInvalidRequest}, nil - } - project = p.projectFor(request.TargetConfig, project) - - secondaryDisk, gone, fErr := p.fetchDisk(ctx, project, secondaryZone, secondary) - if fErr != nil { - return &resource.ReadResult{ErrorCode: transport.ToResourceErrorCode(fErr.Code)}, nil - } - if gone { - return &resource.ReadResult{ErrorCode: resource.OperationErrorCodeNotFound}, nil - } - if asyncReplicationState(secondaryDisk) != asyncReplicationActiveState { - return &resource.ReadResult{ErrorCode: resource.OperationErrorCodeNotFound}, nil - } - // Guard against the secondary having been re-paired with a different primary. - if ref := asyncPrimaryDiskRef(secondaryDisk); ref != "" { - zone, name, ok := diskRefParts(ref) - if ok && (zone != primaryZone || name != primary) { - return &resource.ReadResult{ErrorCode: resource.OperationErrorCodeNotFound}, nil - } - } - - encoded, mErr := json.Marshal(map[string]interface{}{ - "primaryDisk": p.diskURL(project, primaryZone, primary), - "secondaryDisk": p.diskURL(project, secondaryZone, secondary), - }) - if mErr != nil { - return nil, fmt.Errorf("failed to marshal async replication properties: %w", mErr) - } - return &resource.ReadResult{Properties: string(encoded)}, nil -} - -func (p *DiskAsyncReplicationProvisioner) Update( - ctx context.Context, request *resource.UpdateRequest, -) (*resource.UpdateResult, error) { - return updateFailure(resource.OperationErrorCodeNotUpdatable, - "disk async replication is a (primary, secondary) pair; a change replaces it"), nil -} - -// Delete stops replication. The verb is idempotent, so stopping an -// already-stopped pair is not an error. -func (p *DiskAsyncReplicationProvisioner) Delete( - ctx context.Context, request *resource.DeleteRequest, -) (*resource.DeleteResult, error) { - project, primaryZone, primary, _, _, err := parseAsyncReplicationNativeID(request.NativeID) - if err != nil { - return deleteFailure(resource.OperationErrorCodeInvalidRequest, err.Error()), nil - } - project = p.projectFor(request.TargetConfig, project) - - requestID, verbErr := p.issueVerb(ctx, - p.diskURL(project, primaryZone, primary)+"/stopAsyncReplication", nil, project, primaryZone) - if verbErr != nil { - // A primary that is already gone took the pairing with it. - if transport.ToResourceErrorCode(verbErr.Code) == resource.OperationErrorCodeNotFound { - return &resource.DeleteResult{ - ProgressResult: &resource.ProgressResult{ - Operation: resource.OperationDelete, - OperationStatus: resource.OperationStatusSuccess, - NativeID: request.NativeID, - StatusMessage: "primary disk already deleted", - }, - }, nil - } - return deleteFailure(transport.ToResourceErrorCode(verbErr.Code), verbErr.Message), nil - } - return &resource.DeleteResult{ - ProgressResult: &resource.ProgressResult{ - Operation: resource.OperationDelete, - OperationStatus: resource.OperationStatusInProgress, - NativeID: request.NativeID, - RequestID: requestID, - StatusMessage: "disk async replication stop in progress", - }, - }, nil -} - -// List reports every active replication pair in the project. Discovery calls -// this with no hints, so it walks the aggregated disk list and reports each -// secondary whose replication is ACTIVE, naming the primary it is paired with. -// Stopped pairs are deliberately absent: Read treats them as gone, so listing -// them would produce ids that immediately read as not-found. -func (p *DiskAsyncReplicationProvisioner) List( - ctx context.Context, request *resource.ListRequest, -) (*resource.ListResult, error) { - project := p.projectFor(request.TargetConfig, "") - if project == "" { - return &resource.ListResult{NativeIDs: []string{}}, nil - } - client, err := transport.NewClient(ctx, p.Config) - if err != nil { - return nil, fmt.Errorf("failed to create transport client: %w", err) - } - resp, rErr := client.SendRequest(ctx, transport.RequestOptions{ - Method: "GET", - URL: fmt.Sprintf("%s/projects/%s/aggregated/disks", p.APIConfig.BaseURL, project), - }) - if rErr != nil { - wrapped := transport.WrapError(rErr, "failed to list disks") - return nil, fmt.Errorf("%s", wrapped.Message) - } - - nativeIDs := []string{} - scopes, _ := resp.Body["items"].(map[string]interface{}) - for scope, payload := range scopes { - if !strings.HasPrefix(scope, "zones/") { - continue - } - secondaryZone := strings.TrimPrefix(scope, "zones/") - entry, ok := payload.(map[string]interface{}) - if !ok { - continue - } - disks, _ := entry["disks"].([]interface{}) - for _, raw := range disks { - disk, ok := raw.(map[string]interface{}) - if !ok { - continue - } - if asyncReplicationState(disk) != asyncReplicationActiveState { - continue - } - secondary, _ := disk["name"].(string) - primaryZone, primary, ok := diskRefParts(asyncPrimaryDiskRef(disk)) - if !ok || secondary == "" { - continue - } - nativeIDs = append(nativeIDs, buildAsyncReplicationNativeID( - project, primaryZone, primary, secondaryZone, secondary)) - } - } - return &resource.ListResult{NativeIDs: nativeIDs}, nil -} - -// Status routes through the shared read-back so post-create state reflects the -// live pairing rather than only what was declared. -func (p *DiskAsyncReplicationProvisioner) Status( - ctx context.Context, request *resource.StatusRequest, -) (*resource.StatusResult, error) { - return base.StatusWithRead(ctx, p.BaseResource, p.Read, request) -} diff --git a/pkg/resources/compute/disk_async_replication_test.go b/pkg/resources/compute/disk_async_replication_test.go deleted file mode 100644 index c76a0fb5..00000000 --- a/pkg/resources/compute/disk_async_replication_test.go +++ /dev/null @@ -1,102 +0,0 @@ -// © 2025 Platform Engineering Labs Inc. -// -// SPDX-License-Identifier: FSL-1.1-ALv2 - -package compute - -import "testing" - -const asyncReplicationPath = "projects/dev-1/zones/europe-central2-a/disks/pri/asyncReplication/europe-west1-b/sec" - -func TestAsyncReplicationNativeIDRoundTrip(t *testing.T) { - got := buildAsyncReplicationNativeID("dev-1", "europe-central2-a", "pri", "europe-west1-b", "sec") - if got != asyncReplicationPath { - t.Fatalf("build: %q", got) - } - project, pz, pri, sz, sec, err := parseAsyncReplicationNativeID(asyncReplicationPath) - if err != nil { - t.Fatal(err) - } - if project != "dev-1" || pz != "europe-central2-a" || pri != "pri" || - sz != "europe-west1-b" || sec != "sec" { - t.Errorf("parse: %q %q %q %q %q", project, pz, pri, sz, sec) - } - for _, bad := range []string{ - "projects/dev-1/zones/europe-central2-a/disks/pri", // just the disk - "projects/dev-1/zones/europe-central2-a/disks/pri/asyncReplication/sec", // no secondary zone - "projects/dev-1/zones/europe-central2-a/disks/pri/asyncReplication//sec", // empty zone - "projects/dev-1/regions/europe-central2/disks/pri/asyncReplication/z/sec", // regional disk - "", - } { - if _, _, _, _, _, err := parseAsyncReplicationNativeID(bad); err == nil { - t.Errorf("expected error for %q", bad) - } - } -} - -func TestDiskRefParts(t *testing.T) { - cases := map[string][2]string{ - "https://www.googleapis.com/compute/v1/projects/dev-1/zones/europe-west1-b/disks/sec": {"europe-west1-b", "sec"}, - "projects/dev-1/zones/europe-central2-a/disks/pri": {"europe-central2-a", "pri"}, - "zones/us-central1-f/disks/d": {"us-central1-f", "d"}, - } - for ref, want := range cases { - zone, name, ok := diskRefParts(ref) - if !ok || zone != want[0] || name != want[1] { - t.Errorf("%q -> %q %q %v", ref, zone, name, ok) - } - } - for _, bad := range []string{ - "projects/dev-1/regions/europe-west1/disks/d", // regional: no zone - "projects/dev-1/zones/europe-west1-b", // no disk - "", - } { - if _, _, ok := diskRefParts(bad); ok { - t.Errorf("expected failure for %q", bad) - } - } -} - -// Stopping replication leaves asyncPrimaryDisk in place and only flips the -// state, so a read that keyed on the field would report a dead pair as live -// forever. This is the single most important behaviour of this resource. -func TestAsyncReplicationStateDistinguishesStopped(t *testing.T) { - active := map[string]interface{}{ - "asyncPrimaryDisk": map[string]interface{}{"disk": "projects/dev-1/zones/europe-central2-a/disks/pri"}, - "resourceStatus": map[string]interface{}{ - "asyncPrimaryDisk": map[string]interface{}{"state": "ACTIVE"}, - }, - } - stopped := map[string]interface{}{ - "asyncPrimaryDisk": map[string]interface{}{"disk": "projects/dev-1/zones/europe-central2-a/disks/pri"}, - "resourceStatus": map[string]interface{}{ - "asyncPrimaryDisk": map[string]interface{}{"state": "STOPPED"}, - }, - } - if asyncReplicationState(active) != asyncReplicationActiveState { - t.Error("active pair not reported active") - } - if asyncReplicationState(stopped) == asyncReplicationActiveState { - t.Error("stopped pair reported active - the field survives the stop, only state changes") - } - // Both still name the primary, which is exactly why state is the deciding - // factor rather than presence. - if asyncPrimaryDiskRef(stopped) == "" { - t.Error("stopped pair should still name its primary") - } - - // A disk that never replicated, and junk shapes, must be quiet. - for _, empty := range []map[string]interface{}{ - {"name": "plain-disk"}, - {"resourceStatus": map[string]interface{}{}}, - {"resourceStatus": "not-an-object"}, - {"asyncPrimaryDisk": "not-an-object"}, - } { - if asyncReplicationState(empty) != "" { - t.Errorf("unexpected state for %#v", empty) - } - if asyncPrimaryDiskRef(empty) != "" { - t.Errorf("unexpected primary for %#v", empty) - } - } -} diff --git a/pkg/resources/compute/resources.go b/pkg/resources/compute/resources.go index e72642eb..a0c33995 100644 --- a/pkg/resources/compute/resources.go +++ b/pkg/resources/compute/resources.go @@ -64,9 +64,7 @@ const ( BackendBucketResourceType = "GCP::Compute::BackendBucket" ResourcePolicyResourceType = "GCP::Compute::ResourcePolicy" // Registered in disk_resource_policy_attachment.go: add/removeResourcePolicies verbs. - DiskResourcePolicyAttachmentResourceType = "GCP::Compute::DiskResourcePolicyAttachment" - // Registered in disk_async_replication.go: start/stopAsyncReplication verbs. - DiskAsyncReplicationResourceType = "GCP::Compute::DiskAsyncReplication" + DiskResourcePolicyAttachmentResourceType = "GCP::Compute::DiskResourcePolicyAttachment" RegionDiskResourcePolicyAttachmentResourceType = "GCP::Compute::RegionDiskResourcePolicyAttachment" ImageResourceType = "GCP::Compute::Image" MachineImageResourceType = "GCP::Compute::MachineImage" diff --git a/schema/pkl/compute/diskAsyncReplication.pkl b/schema/pkl/compute/diskAsyncReplication.pkl deleted file mode 100644 index 99eef63c..00000000 --- a/schema/pkl/compute/diskAsyncReplication.pkl +++ /dev/null @@ -1,69 +0,0 @@ -/* - * © 2025 Platform Engineering Labs Inc. - * - * SPDX-License-Identifier: FSL-1.1-ALv2 - */ - -/// GCP Compute DiskAsyncReplication -/// -/// The replication link between a primary disk and a secondary disk in another -/// region — the pairing behind cross-region disaster recovery. The disks are -/// ordinary `Disk` resources; this is the relationship between them, started and -/// stopped with the startAsyncReplication / stopAsyncReplication verbs on the -/// primary. -/// -/// The secondary disk must be created with `asyncPrimaryDisk` naming the -/// primary, and must live in a different region. -/// -/// ponytail: nothing is updatable — the link is a (primary, secondary) pair, so -/// a change stops replication and starts it again. -module gcp.compute.diskAsyncReplication - -import "../gcp.pkl" -import "@formae/formae.pkl" - -const type = "GCP::Compute::DiskAsyncReplication" - -open class DiskAsyncReplicationResolvable extends formae.Resolvable { - hidden type = module.type - - hidden primaryDisk: DiskAsyncReplicationResolvable = (this) { - property = "primaryDisk" - } -} - -@gcp.ResourceHint { - type = module.type - identifier = "primaryDisk" -} -open class DiskAsyncReplication extends formae.Resource { - hidden parent = this - - /// URL of the disk being replicated. Pass `primary.res.selfLink` so formae - /// creates it first. - /// - /// Write-only: an extracted forma writes this reference unresolved, so - /// comparing it against the URL already in state reads as a change to a - /// createOnly field and plans a replacement of the pair that is already in - /// place. Which disks a pair joins is fixed at creation and is what its - /// native ID is made of, so there is no drift to detect here. - @gcp.FieldHint { - createOnly = true - writeOnly = true - } - primaryDisk: (String|formae.Resolvable) - - /// URL of the disk receiving the replica, in another region. Pass - /// `secondary.res.selfLink`. Write-only for the same reason as - /// `primaryDisk`. - @gcp.FieldHint { - createOnly = true - writeOnly = true - } - secondaryDisk: (String|formae.Resolvable) - - hidden res: DiskAsyncReplicationResolvable = new { - label = parent.label - stack = parent.stack?.label - } -} diff --git a/testdata/disk-async-replication.pkl b/testdata/disk-async-replication.pkl deleted file mode 100644 index 995b0fda..00000000 --- a/testdata/disk-async-replication.pkl +++ /dev/null @@ -1,55 +0,0 @@ -/* - * © 2025 Platform Engineering Labs Inc. - * - * SPDX-License-Identifier: FSL-1.1-ALv2 - */ - -amends "@formae/forma.pkl" - -import "@gcp/compute/disk.pkl" -import "@gcp/compute/diskAsyncReplication.pkl" -import "./config/vars.pkl" as v - -// A replicated disk pair. Async replication requires the two disks in different -// regions, so the secondary is pinned to a zone away from GCP_ZONE rather than -// derived from it. -local secondaryZone = "europe-west4-a" - -local primary = new disk.Disk { - label = "plugin-sdk-test-dar-primary" - project = v.gcpProject - zone = v.gcpZone - name = "formae-plugin-sdk-test-dar-pri-\(v.testRunID)" - description = "Primary disk of the replicated pair" - sizeGb = 10 - type = "pd-balanced" -} - -// The secondary must name its primary at creation; it cannot be paired later. -local secondary = new disk.Disk { - label = "plugin-sdk-test-dar-secondary" - project = v.gcpProject - zone = secondaryZone - name = "formae-plugin-sdk-test-dar-sec-\(v.testRunID)" - description = "Secondary disk receiving the replica" - sizeGb = 10 - type = "pd-balanced" - asyncPrimaryDisk = new disk.AsyncPrimaryDisk { - disk = primary.res.selfLink - } -} - -forma { - v.stack - v.target - - primary - secondary - - new diskAsyncReplication.DiskAsyncReplication { - label = "plugin-sdk-test-disk-async-replication" - // Resolvables -> both disks exist before the start verb runs. - primaryDisk = primary.res.selfLink - secondaryDisk = secondary.res.selfLink - } -}