diff --git a/docs/migrations/2026-05-14-cloud-sdk-extraction.md b/docs/migrations/2026-05-14-cloud-sdk-extraction.md new file mode 100644 index 00000000..63b80c9a --- /dev/null +++ b/docs/migrations/2026-05-14-cloud-sdk-extraction.md @@ -0,0 +1,99 @@ +# 2026-05-14 — Cloud-SDK extraction: `azure_blob` IaC state backend moves to a plugin + +## What changed + +The in-core `azure_blob` IaC state backend was removed from workflow core. +`module/iac_state_azure.go` (the `AzureBlobIaCStateStore`) and the `azure_blob` +case in `IaCModule.Init` are gone. As a result, `github.com/Azure/azure-sdk-for-go` +is no longer a dependency of the workflow module — `go mod tidy` drops `azcore`, +`storage/azblob`, and their transitive `sdk/internal` entirely. + +`backend: azure_blob` is still a valid `iac.state` config value — but it now +resolves to an `IaCStateBackend` gRPC client served by +[`workflow-plugin-azure`](https://github.com/GoCodeAlone/workflow-plugin-azure) +v1.1.0+. The plugin advertises the backend via the `iacStateBackends` field in +its `plugin.json`; the engine populates the in-core backend registry at +plugin-load time, and `IaCModule.Init` constructs a `grpcIaCStateStore` for it. + +## Why + +Workflow core should own IaC interfaces and orchestration, not provider SDKs. +Dependabot bumps to `azure-sdk-for-go` now target the Azure plugin repo, not +core. This mirrors the pattern established by the godo removal (issue #617) and +the AWS IaC removal (v0.53.0). See the design plan at +`docs/plans/2026-05-14-cloud-sdk-extraction-design.md`. + +## Breaking change + +An `iac.state` module with `backend: azure_blob` now **requires +`workflow-plugin-azure` (>= v1.1.0) to be loaded**. With no plugin loaded, the +module fails to initialize with an actionable error: + +``` +iac.state "": backend "azure_blob" is not built into workflow core +(in-core backends: 'memory', 'filesystem', 'spaces', 'gcs', 'postgres'). +If "azure_blob" is a plugin-provided backend (e.g. 'azure_blob' via +workflow-plugin-azure), install and load that plugin +``` + +The yaml `backend: azure_blob` value itself is **unchanged** — no config +rewrite is needed beyond installing the plugin. The `account_url`, +`account_name`, `account_key`, `container`, and `prefix` config keys are +unchanged and continue to be honored by the plugin's backend. + +## Unaffected backends + +The `memory`, `filesystem`, `spaces`, `gcs`, and `postgres` IaC state backends +remain in workflow core and are **not** affected by this change. Only +`azure_blob` moved to a plugin. + +## Migration recipe + +1. Install the Azure plugin (v1.1.0+): + ```sh + wfctl plugin install workflow-plugin-azure@1.1.0 + ``` + Or declare it in your workflow config under `plugins.external`: + ```yaml + plugins: + external: + - name: workflow-plugin-azure + version: ">=1.1.0" + autoFetch: true + ``` + + To declare the dependency without auto-fetch: + ```yaml + requires: + plugins: + - name: workflow-plugin-azure + ``` + +2. No config rewrite is required. The `iac.state` module keeps its + `backend: azure_blob` value and all its existing config keys: + ```yaml + modules: + - name: iac-state + type: iac.state + config: + backend: azure_blob + container: my-state-container + account_url: https://myaccount.blob.core.windows.net + account_name: myaccount + account_key: + ``` + +## Phases B/C/D + +This is Phase A of the cloud-SDK extraction. Phases B, C, and D apply the same +pattern to the AWS, GCP, and DigitalOcean IaC state backends in subsequent +releases — each backend moves to its provider plugin, and the corresponding +cloud SDK drops from workflow core's `go.mod`. + +## Rollback + +If you need to roll back, revert the commit +`feat(module)!: drop in-core azure_blob IaC state backend` and run +`go mod tidy` — this restores `module/iac_state_azure.go`, the in-core +`azure_blob` case, and re-adds `azure-sdk-for-go` to `go.mod`. Smoke-check with +an `azure_blob` config and no plugin loaded. diff --git a/example/go.mod b/example/go.mod index d347e843..e60336cb 100644 --- a/example/go.mod +++ b/example/go.mod @@ -18,9 +18,6 @@ require ( cloud.google.com/go/iam v1.8.0 // indirect cloud.google.com/go/monitoring v1.26.0 // indirect cloud.google.com/go/storage v1.62.1 // indirect - github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 // indirect - github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 // indirect - github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4 // indirect github.com/BurntSushi/toml v1.6.0 // indirect github.com/DataDog/datadog-go/v5 v5.8.3 // indirect github.com/GoCodeAlone/modular/modules/auth v1.15.0 // indirect diff --git a/example/go.sum b/example/go.sum index 3d8eeecd..95255ba6 100644 --- a/example/go.sum +++ b/example/go.sum @@ -23,20 +23,8 @@ cloud.google.com/go/trace v1.12.0 h1:XvWHYfr9q88cX4pZyou6qCcSagnuASyUq2ej1dB6NzQ cloud.google.com/go/trace v1.12.0/go.mod h1:TOYfyeoyCGsSH0ifXD6Aius24uQI9xV3RyvOdljFIyg= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 h1:fou+2+WFTib47nS+nz/ozhEBnvU96bKHy6LjRsY4E28= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0/go.mod h1:t76Ruy8AHvUAC8GfMWJMa0ElSbuIcO03NLpynfbgsPA= -github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4= -github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0= -github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4= -github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY= -github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1 h1:/Zt+cDPnpC3OVDm/JKLOs7M2DKmLRIIp3XIx9pHHiig= -github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1/go.mod h1:Ng3urmn6dYe8gnbCMoHHVl5APYz2txho3koEkV2o2HA= -github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4 h1:jWQK1GI+LeGGUKBADtcH2rRqPxYB1Ljwms5gFA2LqrM= -github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4/go.mod h1:8mwH4klAm9DUgR2EEHyEEAQlRDvLPyg5fQry3y+cDew= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= -github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs= -github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= @@ -512,8 +500,6 @@ github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144T github.com/philhofer/fwd v1.1.1/go.mod h1:gk3iGcWd9+svBvR0sR+KPcfE+RNWozjowpeBVG3ZVNU= github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY= github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= -github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= -github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= diff --git a/go.mod b/go.mod index 3e93da17..96f1c4af 100644 --- a/go.mod +++ b/go.mod @@ -6,8 +6,6 @@ require ( charm.land/bubbletea/v2 v2.0.2 charm.land/lipgloss/v2 v2.0.2 cloud.google.com/go/storage v1.61.3 - github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 - github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4 github.com/GoCodeAlone/go-plugin v1.7.0 github.com/GoCodeAlone/modular v1.13.0 github.com/GoCodeAlone/modular/modules/auth v1.15.0 @@ -87,7 +85,6 @@ require ( cloud.google.com/go/compute/metadata v0.9.0 // indirect cloud.google.com/go/iam v1.5.3 // indirect cloud.google.com/go/monitoring v1.24.3 // indirect - github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect github.com/BurntSushi/toml v1.6.0 // indirect github.com/DataDog/datadog-go/v5 v5.8.3 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0 // indirect diff --git a/go.sum b/go.sum index 4b4a6fdb..284ba675 100644 --- a/go.sum +++ b/go.sum @@ -27,20 +27,8 @@ cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 h1:fou+2+WFTib47nS+nz/ozhEBnvU96bKHy6LjRsY4E28= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0/go.mod h1:t76Ruy8AHvUAC8GfMWJMa0ElSbuIcO03NLpynfbgsPA= -github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4= -github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0= -github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA= -github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI= -github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1 h1:/Zt+cDPnpC3OVDm/JKLOs7M2DKmLRIIp3XIx9pHHiig= -github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1/go.mod h1:Ng3urmn6dYe8gnbCMoHHVl5APYz2txho3koEkV2o2HA= -github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4 h1:jWQK1GI+LeGGUKBADtcH2rRqPxYB1Ljwms5gFA2LqrM= -github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4/go.mod h1:8mwH4klAm9DUgR2EEHyEEAQlRDvLPyg5fQry3y+cDew= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= -github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs= -github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= @@ -624,8 +612,6 @@ github.com/petermattis/goid v0.0.0-20260226131333-17d1149c6ac6/go.mod h1:pxMtw7c github.com/philhofer/fwd v1.1.1/go.mod h1:gk3iGcWd9+svBvR0sR+KPcfE+RNWozjowpeBVG3ZVNU= github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY= github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= -github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= -github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= diff --git a/module/iac_module.go b/module/iac_module.go index ebc8090f..0287c06f 100644 --- a/module/iac_module.go +++ b/module/iac_module.go @@ -4,19 +4,14 @@ import ( "context" "fmt" - "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob" "github.com/GoCodeAlone/modular" ) -// newAzureSharedKeyCredential is a thin wrapper so iac_module.go doesn't import azblob directly -// in multiple places and can be easily replaced with other credential types. -func newAzureSharedKeyCredential(name, key string) (*azblob.SharedKeyCredential, error) { - return azblob.NewSharedKeyCredential(name, key) -} - // IaCModule registers an IaCStateStore in the service registry. -// Supported backends: "memory" (default), "filesystem", and "spaces" -// (DigitalOcean Spaces / S3-compatible). +// Supported in-core backends: "memory" (default), "filesystem", "spaces" +// (DigitalOcean Spaces / S3-compatible), "gcs", and "postgres" — plus any +// backend provided by a loaded plugin (e.g. "azure_blob" via +// workflow-plugin-azure). // // Config example: // @@ -83,27 +78,6 @@ func (m *IaCModule) Init(app modular.Application) error { return fmt.Errorf("iac.state %q: gcs backend: %w", m.name, err) } m.store = store - case "azure_blob": - container, _ := m.config["container"].(string) - prefix, _ := m.config["prefix"].(string) - accountURL, _ := m.config["account_url"].(string) - accountName, _ := m.config["account_name"].(string) - accountKey, _ := m.config["account_key"].(string) - if container == "" { - return fmt.Errorf("iac.state %q: azure_blob backend requires 'container' config", m.name) - } - if accountURL == "" || accountName == "" || accountKey == "" { - return fmt.Errorf("iac.state %q: azure_blob backend requires 'account_url', 'account_name', and 'account_key' config", m.name) - } - cred, err := newAzureSharedKeyCredential(accountName, accountKey) - if err != nil { - return fmt.Errorf("iac.state %q: azure_blob backend: credential: %w", m.name, err) - } - store, err := NewAzureBlobIaCStateStore(accountURL, container, prefix, *cred) - if err != nil { - return fmt.Errorf("iac.state %q: azure_blob backend: %w", m.name, err) - } - m.store = store case "postgres": dsn, _ := m.config["dsn"].(string) if dsn == "" { @@ -122,7 +96,10 @@ func (m *IaCModule) Init(app modular.Application) error { m.store = newGRPCIaCStateStore(client) break } - return fmt.Errorf("iac.state %q: unsupported backend %q (use 'memory', 'filesystem', 'spaces', 'gcs', 'azure_blob', or 'postgres', or load the plugin that provides it)", m.name, m.backend) + return fmt.Errorf("iac.state %q: backend %q is not built into workflow core "+ + "(in-core backends: 'memory', 'filesystem', 'spaces', 'gcs', 'postgres'). "+ + "If %q is a plugin-provided backend (e.g. 'azure_blob' via workflow-plugin-azure), "+ + "install and load that plugin", m.name, m.backend, m.backend) } return app.RegisterService(m.name, m.store) diff --git a/module/iac_state_azure.go b/module/iac_state_azure.go deleted file mode 100644 index 7a216b32..00000000 --- a/module/iac_state_azure.go +++ /dev/null @@ -1,279 +0,0 @@ -package module - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "strings" - "sync" - - "github.com/Azure/azure-sdk-for-go/sdk/azcore" - "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob" - "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/blob" - "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/lease" -) - -// ErrAzureBlobNotFound is returned when a blob does not exist. -var ErrAzureBlobNotFound = errors.New("azure blob: not found") - -// AzureBlobClient abstracts Azure Blob Storage operations used by AzureBlobIaCStateStore. -type AzureBlobClient interface { - DownloadBlob(ctx context.Context, name string) ([]byte, error) - UploadBlob(ctx context.Context, name string, data []byte, contentType string) error - DeleteBlob(ctx context.Context, name string) error - ListBlobs(ctx context.Context, prefix string) ([]string, error) - AcquireLease(ctx context.Context, name string, durationSeconds int32) (leaseID string, err error) - ReleaseLease(ctx context.Context, name, leaseID string) error -} - -// AzureBlobIaCStateStore persists IaC state as JSON blobs in Azure Blob Storage. -// Locking uses Azure blob leases for atomic advisory locking. -type AzureBlobIaCStateStore struct { - client AzureBlobClient - container string - prefix string - mu sync.Mutex - leaseIDs map[string]string // resourceID -> leaseID -} - -// NewAzureBlobIaCStateStore creates an Azure Blob-backed state store. -// accountURL should be of the form https://.blob.core.windows.net. -func NewAzureBlobIaCStateStore(accountURL, container, prefix string, cred azblob.SharedKeyCredential) (*AzureBlobIaCStateStore, error) { - if container == "" { - return nil, fmt.Errorf("iac azure state: container must not be empty") - } - if prefix == "" { - prefix = "iac-state/" - } - client, err := azblob.NewClientWithSharedKeyCredential(accountURL, &cred, nil) - if err != nil { - return nil, fmt.Errorf("iac azure state: create client: %w", err) - } - return &AzureBlobIaCStateStore{ - client: &azureRealClient{client: client, container: container}, - container: container, - prefix: prefix, - leaseIDs: make(map[string]string), - }, nil -} - -// NewAzureBlobIaCStateStoreWithClient creates a store with an injected client (for testing). -func NewAzureBlobIaCStateStoreWithClient(client AzureBlobClient, container, prefix string) *AzureBlobIaCStateStore { - if prefix == "" { - prefix = "iac-state/" - } - return &AzureBlobIaCStateStore{ - client: client, - container: container, - prefix: prefix, - leaseIDs: make(map[string]string), - } -} - -func (s *AzureBlobIaCStateStore) blobName(resourceID string) string { - return s.prefix + sanitizeID(resourceID) + ".json" -} - -func (s *AzureBlobIaCStateStore) lockBlobName(resourceID string) string { - return s.prefix + sanitizeID(resourceID) + ".lock" -} - -// GetState retrieves a state record by resource ID. Returns nil, nil when not found. -func (s *AzureBlobIaCStateStore) GetState(ctx context.Context, resourceID string) (*IaCState, error) { - data, err := s.client.DownloadBlob(ctx, s.blobName(resourceID)) - if err != nil { - if errors.Is(err, ErrAzureBlobNotFound) { - return nil, nil - } - return nil, fmt.Errorf("iac azure state: GetState %q: %w", resourceID, err) - } - var st IaCState - if err := json.Unmarshal(data, &st); err != nil { - return nil, fmt.Errorf("iac azure state: GetState %q: unmarshal: %w", resourceID, err) - } - return &st, nil -} - -// SaveState writes the state record as a JSON blob. -func (s *AzureBlobIaCStateStore) SaveState(ctx context.Context, state *IaCState) error { - if state == nil { - return fmt.Errorf("iac azure state: SaveState: state must not be nil") - } - if state.ResourceID == "" { - return fmt.Errorf("iac azure state: SaveState: resource_id must not be empty") - } - data, err := json.MarshalIndent(state, "", " ") - if err != nil { - return fmt.Errorf("iac azure state: SaveState %q: marshal: %w", state.ResourceID, err) - } - if err := s.client.UploadBlob(ctx, s.blobName(state.ResourceID), data, "application/json"); err != nil { - return fmt.Errorf("iac azure state: SaveState %q: upload: %w", state.ResourceID, err) - } - return nil -} - -// ListStates lists all state blobs and returns those matching the filter. -func (s *AzureBlobIaCStateStore) ListStates(ctx context.Context, filter map[string]string) ([]*IaCState, error) { - names, err := s.client.ListBlobs(ctx, s.prefix) - if err != nil { - return nil, fmt.Errorf("iac azure state: ListStates: %w", err) - } - var results []*IaCState - for _, name := range names { - if !strings.HasSuffix(name, ".json") { - continue - } - data, err := s.client.DownloadBlob(ctx, name) - if err != nil { - // A canceled / deadlined context must abort the listing rather - // than silently return partial results; only genuinely unreadable - // blobs are skipped. - if ctx.Err() != nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { - return nil, fmt.Errorf("iac azure state: ListStates: %w", err) - } - continue - } - var st IaCState - if err := json.Unmarshal(data, &st); err != nil { - continue - } - if matchesFilter(&st, filter) { - results = append(results, &st) - } - } - return results, nil -} - -// DeleteState removes the state blob for resourceID. -func (s *AzureBlobIaCStateStore) DeleteState(ctx context.Context, resourceID string) error { - if err := s.client.DeleteBlob(ctx, s.blobName(resourceID)); err != nil { - if errors.Is(err, ErrAzureBlobNotFound) { - return fmt.Errorf("iac azure state: DeleteState %q: not found", resourceID) - } - return fmt.Errorf("iac azure state: DeleteState %q: %w", resourceID, err) - } - return nil -} - -// Lock acquires a blob lease on the lock blob for resourceID (60-second duration). -func (s *AzureBlobIaCStateStore) Lock(ctx context.Context, resourceID string) error { - s.mu.Lock() - defer s.mu.Unlock() - - lockBlob := s.lockBlobName(resourceID) - leaseID, err := s.client.AcquireLease(ctx, lockBlob, 60) - if err != nil { - if strings.Contains(err.Error(), "already leased") || strings.Contains(err.Error(), "leased") { - return fmt.Errorf("iac azure state: Lock %q: resource is already locked", resourceID) - } - return fmt.Errorf("iac azure state: Lock %q: %w", resourceID, err) - } - s.leaseIDs[resourceID] = leaseID - return nil -} - -// Unlock releases the lease on the lock blob for resourceID. -func (s *AzureBlobIaCStateStore) Unlock(ctx context.Context, resourceID string) error { - s.mu.Lock() - defer s.mu.Unlock() - - leaseID, held := s.leaseIDs[resourceID] - if !held { - return fmt.Errorf("iac azure state: Unlock %q: not locked", resourceID) - } - lockBlob := s.lockBlobName(resourceID) - if err := s.client.ReleaseLease(ctx, lockBlob, leaseID); err != nil { - return fmt.Errorf("iac azure state: Unlock %q: %w", resourceID, err) - } - delete(s.leaseIDs, resourceID) - return nil -} - -// azureRealClient wraps the actual Azure SDK client. -type azureRealClient struct { - client *azblob.Client - container string -} - -func (c *azureRealClient) DownloadBlob(ctx context.Context, name string) ([]byte, error) { - resp, err := c.client.DownloadStream(ctx, c.container, name, nil) - if err != nil { - if isAzureNotFound(err) { - return nil, ErrAzureBlobNotFound - } - return nil, err - } - defer resp.Body.Close() - buf := new(bytes.Buffer) - if _, err := buf.ReadFrom(resp.Body); err != nil { - return nil, err - } - return buf.Bytes(), nil -} - -func (c *azureRealClient) UploadBlob(ctx context.Context, name string, data []byte, contentType string) error { - _, err := c.client.UploadBuffer(ctx, c.container, name, data, &azblob.UploadBufferOptions{ - HTTPHeaders: &blob.HTTPHeaders{BlobContentType: &contentType}, - }) - return err -} - -func (c *azureRealClient) DeleteBlob(ctx context.Context, name string) error { - _, err := c.client.DeleteBlob(ctx, c.container, name, nil) - if err != nil && isAzureNotFound(err) { - return ErrAzureBlobNotFound - } - return err -} - -func (c *azureRealClient) ListBlobs(ctx context.Context, prefix string) ([]string, error) { - pager := c.client.NewListBlobsFlatPager(c.container, &azblob.ListBlobsFlatOptions{ - Prefix: &prefix, - }) - var names []string - for pager.More() { - page, err := pager.NextPage(ctx) - if err != nil { - return nil, err - } - for _, item := range page.Segment.BlobItems { - if item.Name != nil { - names = append(names, *item.Name) - } - } - } - return names, nil -} - -func (c *azureRealClient) AcquireLease(ctx context.Context, name string, durationSeconds int32) (string, error) { - blobClient := c.client.ServiceClient().NewContainerClient(c.container).NewBlobClient(name) - leaseClient, err := lease.NewBlobClient(blobClient, nil) - if err != nil { - return "", err - } - resp, err := leaseClient.AcquireLease(ctx, int32(durationSeconds), nil) - if err != nil { - return "", err - } - if resp.LeaseID == nil { - return "", fmt.Errorf("no lease ID returned") - } - return *resp.LeaseID, nil -} - -func (c *azureRealClient) ReleaseLease(ctx context.Context, name, leaseID string) error { - blobClient := c.client.ServiceClient().NewContainerClient(c.container).NewBlobClient(name) - leaseClient, err := lease.NewBlobClient(blobClient, &lease.BlobClientOptions{LeaseID: &leaseID}) - if err != nil { - return err - } - _, err = leaseClient.ReleaseLease(ctx, nil) - return err -} - -func isAzureNotFound(err error) bool { - var respErr *azcore.ResponseError - return errors.As(err, &respErr) && respErr.StatusCode == 404 -} diff --git a/module/iac_state_azure_test.go b/module/iac_state_azure_test.go deleted file mode 100644 index 148a686f..00000000 --- a/module/iac_state_azure_test.go +++ /dev/null @@ -1,287 +0,0 @@ -package module_test - -import ( - "context" - "encoding/json" - "fmt" - "strings" - "sync" - "testing" - - "github.com/GoCodeAlone/workflow/module" -) - -// mockAzureClient is an in-memory implementation of AzureBlobClient for testing. -type mockAzureClient struct { - mu sync.Mutex - blobs map[string][]byte // name -> body - leases map[string]string // name -> leaseID (empty = not leased) -} - -func newMockAzureClient() *mockAzureClient { - return &mockAzureClient{ - blobs: make(map[string][]byte), - leases: make(map[string]string), - } -} - -func (m *mockAzureClient) DownloadBlob(_ context.Context, name string) ([]byte, error) { - m.mu.Lock() - defer m.mu.Unlock() - data, ok := m.blobs[name] - if !ok { - return nil, module.ErrAzureBlobNotFound - } - return data, nil -} - -func (m *mockAzureClient) UploadBlob(_ context.Context, name string, data []byte, _ string) error { - m.mu.Lock() - defer m.mu.Unlock() - m.blobs[name] = data - return nil -} - -func (m *mockAzureClient) DeleteBlob(_ context.Context, name string) error { - m.mu.Lock() - defer m.mu.Unlock() - if _, ok := m.blobs[name]; !ok { - return module.ErrAzureBlobNotFound - } - delete(m.blobs, name) - return nil -} - -func (m *mockAzureClient) ListBlobs(_ context.Context, prefix string) ([]string, error) { - m.mu.Lock() - defer m.mu.Unlock() - var names []string - for name := range m.blobs { - if strings.HasPrefix(name, prefix) { - names = append(names, name) - } - } - return names, nil -} - -func (m *mockAzureClient) AcquireLease(_ context.Context, name string, _ int32) (string, error) { - m.mu.Lock() - defer m.mu.Unlock() - // Ensure blob exists (leases require an existing blob). - if _, ok := m.blobs[name]; !ok { - // Create a placeholder for the lock blob. - m.blobs[name] = []byte{} - } - if leaseID := m.leases[name]; leaseID != "" { - return "", fmt.Errorf("blob %q is already leased", name) - } - leaseID := fmt.Sprintf("lease-%s", name) - m.leases[name] = leaseID - return leaseID, nil -} - -func (m *mockAzureClient) ReleaseLease(_ context.Context, name, leaseID string) error { - m.mu.Lock() - defer m.mu.Unlock() - if m.leases[name] != leaseID { - return fmt.Errorf("blob %q has no lease %q", name, leaseID) - } - delete(m.leases, name) - delete(m.blobs, name) - return nil -} - -func newTestAzureStore(client module.AzureBlobClient) *module.AzureBlobIaCStateStore { - return module.NewAzureBlobIaCStateStoreWithClient(client, "test-container", "iac-state/") -} - -func TestAzureBlobIaCStateStore_GetState_NotFound(t *testing.T) { - store := newTestAzureStore(newMockAzureClient()) - st, err := store.GetState(context.Background(), "nonexistent") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if st != nil { - t.Fatalf("expected nil, got %+v", st) - } -} - -func TestAzureBlobIaCStateStore_SaveAndGetState(t *testing.T) { - store := newTestAzureStore(newMockAzureClient()) - - state := &module.IaCState{ - ResourceID: "az-cluster", - ResourceType: "kubernetes", - Provider: "azure", - Status: "active", - } - if err := store.SaveState(context.Background(), state); err != nil { - t.Fatalf("SaveState: %v", err) - } - - got, err := store.GetState(context.Background(), "az-cluster") - if err != nil { - t.Fatalf("GetState: %v", err) - } - if got == nil { - t.Fatal("expected state, got nil") - } - if got.Provider != "azure" { - t.Errorf("Provider = %q, want %q", got.Provider, "azure") - } -} - -func TestAzureBlobIaCStateStore_SaveState_Nil(t *testing.T) { - store := newTestAzureStore(newMockAzureClient()) - if err := store.SaveState(context.Background(), nil); err == nil { - t.Fatal("expected error for nil state") - } -} - -func TestAzureBlobIaCStateStore_SaveState_EmptyID(t *testing.T) { - store := newTestAzureStore(newMockAzureClient()) - if err := store.SaveState(context.Background(), &module.IaCState{}); err == nil { - t.Fatal("expected error for empty resource_id") - } -} - -func TestAzureBlobIaCStateStore_ListStates(t *testing.T) { - store := newTestAzureStore(newMockAzureClient()) - - for _, st := range []*module.IaCState{ - {ResourceID: "r1", ResourceType: "k8s", Provider: "azure", Status: "active"}, - {ResourceID: "r2", ResourceType: "db", Provider: "azure", Status: "active"}, - {ResourceID: "r3", ResourceType: "k8s", Provider: "gcp", Status: "destroyed"}, - } { - if err := store.SaveState(context.Background(), st); err != nil { - t.Fatalf("SaveState %q: %v", st.ResourceID, err) - } - } - - all, err := store.ListStates(context.Background(), nil) - if err != nil { - t.Fatalf("ListStates(nil): %v", err) - } - if len(all) != 3 { - t.Errorf("ListStates = %d, want 3", len(all)) - } - - filtered, err := store.ListStates(context.Background(), map[string]string{"provider": "azure"}) - if err != nil { - t.Fatalf("ListStates(provider=azure): %v", err) - } - if len(filtered) != 2 { - t.Errorf("ListStates(provider=azure) = %d, want 2", len(filtered)) - } -} - -func TestAzureBlobIaCStateStore_ListStates_SkipsLockBlobs(t *testing.T) { - store := newTestAzureStore(newMockAzureClient()) - - if err := store.SaveState(context.Background(), &module.IaCState{ResourceID: "r1", Status: "active"}); err != nil { - t.Fatalf("SaveState: %v", err) - } - if err := store.Lock(context.Background(), "r1"); err != nil { - t.Fatalf("Lock: %v", err) - } - - results, err := store.ListStates(context.Background(), nil) - if err != nil { - t.Fatalf("ListStates: %v", err) - } - if len(results) != 1 { - t.Errorf("expected 1 result (lock blob excluded), got %d", len(results)) - } -} - -func TestAzureBlobIaCStateStore_DeleteState(t *testing.T) { - store := newTestAzureStore(newMockAzureClient()) - - if err := store.SaveState(context.Background(), &module.IaCState{ResourceID: "del-me", Status: "active"}); err != nil { - t.Fatalf("SaveState: %v", err) - } - if err := store.DeleteState(context.Background(), "del-me"); err != nil { - t.Fatalf("DeleteState: %v", err) - } - st, err := store.GetState(context.Background(), "del-me") - if err != nil { - t.Fatalf("GetState after delete: %v", err) - } - if st != nil { - t.Fatal("expected nil after delete") - } -} - -func TestAzureBlobIaCStateStore_DeleteState_NotFound(t *testing.T) { - store := newTestAzureStore(newMockAzureClient()) - if err := store.DeleteState(context.Background(), "nonexistent"); err == nil { - t.Fatal("expected error deleting nonexistent state") - } -} - -func TestAzureBlobIaCStateStore_LockUnlock(t *testing.T) { - store := newTestAzureStore(newMockAzureClient()) - - if err := store.Lock(context.Background(), "res-1"); err != nil { - t.Fatalf("Lock: %v", err) - } - if err := store.Lock(context.Background(), "res-1"); err == nil { - t.Fatal("expected error on double lock") - } - if err := store.Unlock(context.Background(), "res-1"); err != nil { - t.Fatalf("Unlock: %v", err) - } - if err := store.Lock(context.Background(), "res-1"); err != nil { - t.Fatalf("Lock after unlock: %v", err) - } -} - -func TestAzureBlobIaCStateStore_Unlock_NotLocked(t *testing.T) { - store := newTestAzureStore(newMockAzureClient()) - if err := store.Unlock(context.Background(), "not-locked"); err == nil { - t.Fatal("expected error unlocking non-locked resource") - } -} - -// TestAzureBlobIaCStateStore_Unlock_PassesLeaseID verifies that Unlock passes the -// correct leaseID to ReleaseLease. The mock enforces leaseID matching, so this -// test will fail if ReleaseLease ignores the leaseID parameter. -func TestAzureBlobIaCStateStore_Unlock_PassesLeaseID(t *testing.T) { - client := newMockAzureClient() - store := newTestAzureStore(client) - - if err := store.Lock(context.Background(), "res-lease"); err != nil { - t.Fatalf("Lock: %v", err) - } - // Unlock must pass the correct leaseID — mock rejects wrong/empty leaseIDs. - if err := store.Unlock(context.Background(), "res-lease"); err != nil { - t.Fatalf("Unlock with leaseID: %v", err) - } - // After unlock, should be able to re-lock. - if err := store.Lock(context.Background(), "res-lease"); err != nil { - t.Fatalf("Lock after Unlock: %v", err) - } -} - -func TestAzureBlobIaCStateStore_JSONRoundTrip(t *testing.T) { - store := newTestAzureStore(newMockAzureClient()) - - state := &module.IaCState{ - ResourceID: "az-rt", - Provider: "azure", - Status: "active", - Outputs: map[string]any{"fqdn": "myapp.azurewebsites.net"}, - } - if err := store.SaveState(context.Background(), state); err != nil { - t.Fatalf("SaveState: %v", err) - } - got, err := store.GetState(context.Background(), "az-rt") - if err != nil { - t.Fatalf("GetState: %v", err) - } - wantJSON, _ := json.Marshal(state) - gotJSON, _ := json.Marshal(got) - if string(wantJSON) != string(gotJSON) { - t.Errorf("round-trip mismatch:\n want: %s\n got: %s", wantJSON, gotJSON) - } -} diff --git a/module/iac_state_test.go b/module/iac_state_test.go index 1e5c2852..fce913c4 100644 --- a/module/iac_state_test.go +++ b/module/iac_state_test.go @@ -4,6 +4,7 @@ import ( "context" "os" "path/filepath" + "strings" "testing" "github.com/GoCodeAlone/workflow/module" @@ -285,3 +286,28 @@ func TestIaCModule_InvalidBackend(t *testing.T) { t.Error("expected error for unsupported backend, got nil") } } + +// TestIaCModuleAzureBlobRequiresPlugin asserts that backend: azure_blob with no +// plugin registered returns the plugin-guidance error — the in-core azure_blob +// backend has been removed; it is now served by workflow-plugin-azure. +func TestIaCModuleAzureBlobRequiresPlugin(t *testing.T) { + m := module.NewIaCModule("st", map[string]any{ + "backend": "azure_blob", + "container": "c", + "account_url": "https://x", + "account_name": "n", + "account_key": "k", + }) + err := m.Init(module.NewMockApplication()) + if err == nil { + t.Fatal("azure_blob with no plugin loaded must error — in-core backend is gone") + } + if !strings.Contains(err.Error(), "azure_blob") { + t.Fatalf("error should name the missing backend: %v", err) + } + // The error must be the plugin-guidance error, NOT an in-core construction + // failure — the in-core azure_blob backend has been deleted. + if !strings.Contains(err.Error(), "plugin") { + t.Fatalf("error should be the plugin-guidance error (mention loading a plugin), got: %v", err) + } +}