diff --git a/CHANGELOG.md b/CHANGELOG.md index 3af907e..a6b4e06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ All notable changes to `workflow-plugin-azure` are documented here. +## Unreleased + +### Added + +- Added `IaCProviderOwnership` support backed by the Azure ARM `workflow-owner` + tag for cross-driver ownership discovery and assignment. + +### Changed + +- Raised `minEngineVersion` to `0.69.1` for the ownership service contract. + ## v2.0.0-rc1 — 2026-05-17 ### Breaking changes (workflow#699) diff --git a/cmd/workflow-plugin-azure/plugin.json b/cmd/workflow-plugin-azure/plugin.json index 0ba19c3..9353251 100644 --- a/cmd/workflow-plugin-azure/plugin.json +++ b/cmd/workflow-plugin-azure/plugin.json @@ -6,7 +6,7 @@ "license": "MIT", "type": "external", "tier": "community", - "minEngineVersion": "0.68.2", + "minEngineVersion": "0.69.1", "iacServices": [ "workflow.plugin.external.iac.IaCProviderRequired", "workflow.plugin.external.iac.IaCProviderEnumerator", @@ -17,6 +17,7 @@ "workflow.plugin.external.iac.IaCProviderDriftConfigDetector", "workflow.plugin.external.iac.IaCProviderRequirementMapper", "workflow.plugin.external.iac.IaCProviderRegionLister", + "workflow.plugin.external.iac.IaCProviderOwnership", "workflow.plugin.external.iac.ResourceDriver", "workflow.plugin.external.iac.IaCStateBackend" ], diff --git a/go.mod b/go.mod index b82f393..4efc803 100644 --- a/go.mod +++ b/go.mod @@ -14,10 +14,11 @@ require ( github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/msi/armmsi v1.3.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v6 v6.2.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/redis/armredis v1.0.0 + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions v1.3.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/sql/armsql v1.2.0 github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4 - github.com/GoCodeAlone/workflow v0.68.2 + github.com/GoCodeAlone/workflow v0.69.7 google.golang.org/grpc v1.81.1 google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af ) diff --git a/go.sum b/go.sum index 4720e8f..a3b4e84 100644 --- a/go.sum +++ b/go.sum @@ -29,6 +29,8 @@ github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v2 v2.0.0 h1:PTFG github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v2 v2.0.0/go.mod h1:LRr2FzBTQlONPPa5HREE5+RjSCTXl7BwOvYOaWTqCaI= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.1.0 h1:2qsIIvxVT+uE6yrNldntJKlLRgxGbZ85kgtz5SNBhMw= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.1.0/go.mod h1:AW8VEadnhw9xox+VaVd9sP7NjzOAnaZBLRH6Tq3cJ38= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/managementgroups/armmanagementgroups v1.0.0 h1:pPvTJ1dY0sA35JOeFq6TsY2xj6Z85Yo23Pj4wCCvu4o= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/managementgroups/armmanagementgroups v1.0.0/go.mod h1:mLfWfj8v3jfWKsL9G4eoBoXVcsqcIUTapmdKy7uGOp0= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/msi/armmsi v1.3.0 h1:L7G3dExHBgUxsO3qpTGhk/P2dgnYyW48yn7AO33Tbek= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/msi/armmsi v1.3.0/go.mod h1:Ms6gYEy0+A2knfKrwdatsggTXYA2+ICKug8w7STorFw= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v6 v6.2.0 h1:HYGD75g0bQ3VO/Omedm54v4LrD3B1cGImuRF3AJ5wLo= @@ -70,8 +72,8 @@ github.com/GoCodeAlone/modular/modules/jsonschema v1.17.0 h1:zoWioqUvuNNDfnjHA1s github.com/GoCodeAlone/modular/modules/jsonschema v1.17.0/go.mod h1:GDU/jsD6AddmXKedj0wZwieUIaQsTBSGMzuj+XHXMrw= github.com/GoCodeAlone/modular/modules/reverseproxy/v2 v2.10.0 h1:+2M/ecyCxDiXfJM4ibcERuu/BBeIbLTQNcVgRsllR64= github.com/GoCodeAlone/modular/modules/reverseproxy/v2 v2.10.0/go.mod h1:tlVH1mA5yuU8CB7R7+HXIRaBixZoNid6h+5tew5u3FU= -github.com/GoCodeAlone/workflow v0.68.2 h1:U0ksQOkIwDReuw+nz4kRoCeYwahoBaItqLzwYIRm758= -github.com/GoCodeAlone/workflow v0.68.2/go.mod h1:4UwFYm1cM8a/AvGNb1CZAuob0b0gq7552sxcNMdDALA= +github.com/GoCodeAlone/workflow v0.69.7 h1:LgRTJtbicyOeucyQmHw/F7rjfYP8T15C01p7jNm6kP0= +github.com/GoCodeAlone/workflow v0.69.7/go.mod h1:nWB662ILBUUjL2NBlj7RchyiI4CZ2+UxnpQcbIA2tWE= github.com/GoCodeAlone/yaegi v0.17.2 h1:WK6Y6e0t1a6U7r+S2dN3CGWW1PizYD3zO0zneToZPxM= github.com/GoCodeAlone/yaegi v0.17.2/go.mod h1:z5Pr6Wse6QJcQvpgxTxzMAevFarH0N37TG88Y9dprx0= github.com/IBM/sarama v1.47.0 h1:GcQFEd12+KzfPYeLgN69Fh7vLCtYRhVIx0rO4TZO318= diff --git a/internal/driver/blob.go b/internal/driver/blob.go index b4a7f09..5844687 100644 --- a/internal/driver/blob.go +++ b/internal/driver/blob.go @@ -3,6 +3,7 @@ package driver import ( "context" "fmt" + "strings" "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob" "github.com/GoCodeAlone/workflow/interfaces" @@ -45,9 +46,11 @@ func (c *realBlobClient) DeleteContainer(ctx context.Context, containerName stri // BlobDriver manages Azure Blob Storage containers (infra.storage). type BlobDriver struct { - resourceGroup string - location string - client BlobClientInterface + subscriptionID string + resourceGroup string + location string + storageAccount string + client BlobClientInterface } var _ interfaces.ResourceDriver = (*BlobDriver)(nil) @@ -55,8 +58,14 @@ var _ interfaces.ResourceDriver = (*BlobDriver)(nil) // SensitiveKeys returns output keys whose values should be masked in logs and plan output. func (d *BlobDriver) SensitiveKeys() []string { return nil } -func NewBlobDriver(resourceGroup, location string, client BlobClientInterface) *BlobDriver { - return &BlobDriver{resourceGroup: resourceGroup, location: location, client: client} +func NewBlobDriver(subscriptionID, resourceGroup, location, storageAccount string, client BlobClientInterface) *BlobDriver { + return &BlobDriver{ + subscriptionID: subscriptionID, + resourceGroup: resourceGroup, + location: location, + storageAccount: storageAccount, + client: client, + } } func (d *BlobDriver) Create(ctx context.Context, spec interfaces.ResourceSpec) (*interfaces.ResourceOutput, error) { @@ -68,17 +77,14 @@ func (d *BlobDriver) Create(ctx context.Context, spec interfaces.ResourceSpec) ( return &interfaces.ResourceOutput{ Name: spec.Name, Type: "infra.storage", - ProviderID: containerName, + ProviderID: d.containerARMID(containerName), Outputs: map[string]any{"container_name": containerName}, Status: "active", }, nil } func (d *BlobDriver) Read(ctx context.Context, ref interfaces.ResourceRef) (*interfaces.ResourceOutput, error) { - containerName := ref.Name - if ref.ProviderID != "" { - containerName = ref.ProviderID - } + containerName := d.containerNameFromRef(ref) props, err := d.client.GetContainerProperties(ctx, containerName) if err != nil { @@ -91,7 +97,7 @@ func (d *BlobDriver) Read(ctx context.Context, ref interfaces.ResourceRef) (*int return &interfaces.ResourceOutput{ Name: ref.Name, Type: "infra.storage", - ProviderID: containerName, + ProviderID: d.containerARMID(containerName), Outputs: outputs, Status: "active", }, nil @@ -102,10 +108,7 @@ func (d *BlobDriver) Update(ctx context.Context, ref interfaces.ResourceRef, spe } func (d *BlobDriver) Delete(ctx context.Context, ref interfaces.ResourceRef) error { - containerName := ref.Name - if ref.ProviderID != "" { - containerName = ref.ProviderID - } + containerName := d.containerNameFromRef(ref) return d.client.DeleteContainer(ctx, containerName) } @@ -127,3 +130,27 @@ func (d *BlobDriver) HealthCheck(ctx context.Context, ref interfaces.ResourceRef func (d *BlobDriver) Scale(_ context.Context, _ interfaces.ResourceRef, _ int) (*interfaces.ResourceOutput, error) { return nil, fmt.Errorf("blob: scale not supported") } + +func (d *BlobDriver) containerARMID(containerName string) string { + if d.subscriptionID == "" || d.resourceGroup == "" || d.storageAccount == "" { + return containerName + } + return fmt.Sprintf( + "/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Storage/storageAccounts/%s/blobServices/default/containers/%s", + d.subscriptionID, + d.resourceGroup, + d.storageAccount, + containerName, + ) +} + +func (d *BlobDriver) containerNameFromRef(ref interfaces.ResourceRef) string { + if ref.ProviderID == "" { + return ref.Name + } + const marker = "/blobServices/default/containers/" + if idx := strings.LastIndex(ref.ProviderID, marker); idx >= 0 { + return ref.ProviderID[idx+len(marker):] + } + return ref.ProviderID +} diff --git a/internal/driver/blob_test.go b/internal/driver/blob_test.go index 0af435f..c77a16e 100644 --- a/internal/driver/blob_test.go +++ b/internal/driver/blob_test.go @@ -9,20 +9,26 @@ import ( ) type mockBlobClient struct { - createFn func(ctx context.Context, containerName string) error - getFn func(ctx context.Context, containerName string) (map[string]string, error) - deleteFn func(ctx context.Context, containerName string) error + createdName string + readName string + deletedName string + createFn func(ctx context.Context, containerName string) error + getFn func(ctx context.Context, containerName string) (map[string]string, error) + deleteFn func(ctx context.Context, containerName string) error } func (m *mockBlobClient) CreateContainer(ctx context.Context, containerName string) error { + m.createdName = containerName return m.createFn(ctx, containerName) } func (m *mockBlobClient) GetContainerProperties(ctx context.Context, containerName string) (map[string]string, error) { + m.readName = containerName return m.getFn(ctx, containerName) } func (m *mockBlobClient) DeleteContainer(ctx context.Context, containerName string) error { + m.deletedName = containerName return m.deleteFn(ctx, containerName) } @@ -38,7 +44,7 @@ func TestBlobDriver_Create(t *testing.T) { }, } - drv := NewBlobDriver("rg", "eastus", client) + drv := NewBlobDriver("sub", "rg", "eastus", "account", client) out, err := drv.Create(context.Background(), interfaces.ResourceSpec{ Name: "test-blob", Type: "infra.storage", @@ -53,6 +59,10 @@ func TestBlobDriver_Create(t *testing.T) { if out.Outputs["container_name"] != "mycontainer" { t.Errorf("container_name = %v, want mycontainer", out.Outputs["container_name"]) } + wantID := "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/account/blobServices/default/containers/mycontainer" + if out.ProviderID != wantID { + t.Errorf("ProviderID = %q, want %q", out.ProviderID, wantID) + } } func TestBlobDriver_Read(t *testing.T) { @@ -62,11 +72,18 @@ func TestBlobDriver_Read(t *testing.T) { }, } - drv := NewBlobDriver("rg", "eastus", client) - out, err := drv.Read(context.Background(), interfaces.ResourceRef{Name: "test-blob", ProviderID: "mycontainer"}) + drv := NewBlobDriver("sub", "rg", "eastus", "account", client) + id := "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/account/blobServices/default/containers/mycontainer" + out, err := drv.Read(context.Background(), interfaces.ResourceRef{Name: "test-blob", ProviderID: id}) if err != nil { t.Fatalf("Read: %v", err) } + if client.readName != "mycontainer" { + t.Errorf("readName = %q, want mycontainer", client.readName) + } + if out.ProviderID != id { + t.Errorf("ProviderID = %q, want %q", out.ProviderID, id) + } if out.Outputs["custom-tag"] != "value" { t.Errorf("custom-tag = %v, want value", out.Outputs["custom-tag"]) } @@ -79,7 +96,7 @@ func TestBlobDriver_Create_Error(t *testing.T) { }, } - drv := NewBlobDriver("rg", "eastus", client) + drv := NewBlobDriver("sub", "rg", "eastus", "account", client) _, err := drv.Create(context.Background(), interfaces.ResourceSpec{ Name: "test-blob", Config: map[string]any{"container_name": "mycontainer"}, @@ -96,7 +113,7 @@ func TestBlobDriver_Update(t *testing.T) { }, } - drv := NewBlobDriver("rg", "eastus", client) + drv := NewBlobDriver("sub", "rg", "eastus", "account", client) out, err := drv.Update(context.Background(), interfaces.ResourceRef{Name: "test-blob", ProviderID: "mycontainer"}, interfaces.ResourceSpec{ Name: "test-blob", Config: map[string]any{}, @@ -116,7 +133,7 @@ func TestBlobDriver_Update_Error(t *testing.T) { }, } - drv := NewBlobDriver("rg", "eastus", client) + drv := NewBlobDriver("sub", "rg", "eastus", "account", client) _, err := drv.Update(context.Background(), interfaces.ResourceRef{Name: "test-blob", ProviderID: "mycontainer"}, interfaces.ResourceSpec{ Name: "test-blob", Config: map[string]any{}, @@ -135,14 +152,18 @@ func TestBlobDriver_Delete(t *testing.T) { }, } - drv := NewBlobDriver("rg", "eastus", client) - err := drv.Delete(context.Background(), interfaces.ResourceRef{Name: "test-blob", ProviderID: "mycontainer"}) + drv := NewBlobDriver("sub", "rg", "eastus", "account", client) + id := "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/account/blobServices/default/containers/mycontainer" + err := drv.Delete(context.Background(), interfaces.ResourceRef{Name: "test-blob", ProviderID: id}) if err != nil { t.Fatal(err) } if !deleted { t.Error("expected DeleteContainer to be called") } + if client.deletedName != "mycontainer" { + t.Errorf("deletedName = %q, want mycontainer", client.deletedName) + } } func TestBlobDriver_Delete_Error(t *testing.T) { @@ -152,7 +173,7 @@ func TestBlobDriver_Delete_Error(t *testing.T) { }, } - drv := NewBlobDriver("rg", "eastus", client) + drv := NewBlobDriver("sub", "rg", "eastus", "account", client) err := drv.Delete(context.Background(), interfaces.ResourceRef{Name: "test-blob", ProviderID: "mycontainer"}) if err == nil { t.Fatal("expected error, got nil") @@ -160,7 +181,7 @@ func TestBlobDriver_Delete_Error(t *testing.T) { } func TestBlobDriver_Diff_NilCurrent(t *testing.T) { - drv := NewBlobDriver("rg", "eastus", nil) + drv := NewBlobDriver("sub", "rg", "eastus", "account", nil) diff, err := drv.Diff(context.Background(), interfaces.ResourceSpec{Name: "x"}, nil) if err != nil { t.Fatal(err) @@ -171,7 +192,7 @@ func TestBlobDriver_Diff_NilCurrent(t *testing.T) { } func TestBlobDriver_Diff_NoChanges(t *testing.T) { - drv := NewBlobDriver("rg", "eastus", nil) + drv := NewBlobDriver("sub", "rg", "eastus", "account", nil) diff, err := drv.Diff(context.Background(), interfaces.ResourceSpec{Name: "x"}, &interfaces.ResourceOutput{}) if err != nil { t.Fatal(err) @@ -188,7 +209,7 @@ func TestBlobDriver_HealthCheck_Healthy(t *testing.T) { }, } - drv := NewBlobDriver("rg", "eastus", client) + drv := NewBlobDriver("sub", "rg", "eastus", "account", client) h, err := drv.HealthCheck(context.Background(), interfaces.ResourceRef{Name: "test-blob", ProviderID: "mycontainer"}) if err != nil { t.Fatal(err) @@ -205,7 +226,7 @@ func TestBlobDriver_HealthCheck_Unhealthy(t *testing.T) { }, } - drv := NewBlobDriver("rg", "eastus", client) + drv := NewBlobDriver("sub", "rg", "eastus", "account", client) h, err := drv.HealthCheck(context.Background(), interfaces.ResourceRef{Name: "test-blob", ProviderID: "mycontainer"}) if err != nil { t.Fatal(err) diff --git a/internal/driver/driver.go b/internal/driver/driver.go index 3b30702..4ff277f 100644 --- a/internal/driver/driver.go +++ b/internal/driver/driver.go @@ -111,7 +111,7 @@ func NewAll(subscriptionID, resourceGroup, location, storageAccount string, cred "infra.api_gateway": NewAPIMDriver(resourceGroup, location, &realAPIMClient{inner: apimRaw}), "infra.firewall": NewNSGDriver(resourceGroup, location, &realNSGClient{inner: nsgRaw}), "infra.iam_role": NewMSIDriver(resourceGroup, location, &realMSIClient{inner: msiRaw}), - "infra.storage": NewBlobDriver(resourceGroup, location, &realBlobClient{inner: blobRaw}), + "infra.storage": NewBlobDriver(subscriptionID, resourceGroup, location, storageAccount, &realBlobClient{inner: blobRaw}), "infra.certificate": NewCertDriver(resourceGroup, location, &realCertClient{inner: certRaw}), }, nil } diff --git a/internal/iacserver.go b/internal/iacserver.go index 93e4b29..3e1b60d 100644 --- a/internal/iacserver.go +++ b/internal/iacserver.go @@ -44,6 +44,7 @@ type azureIaCServer struct { pb.UnimplementedIaCProviderDriftConfigDetectorServer pb.UnimplementedIaCProviderRequirementMapperServer pb.UnimplementedIaCProviderRegionListerServer + pb.UnimplementedIaCProviderOwnershipServer pb.UnimplementedResourceDriverServer pb.UnimplementedIaCStateBackendServer @@ -83,6 +84,7 @@ var ( _ pb.IaCProviderDriftDetectorServer = (*azureIaCServer)(nil) _ pb.IaCProviderRequirementMapperServer = (*azureIaCServer)(nil) _ pb.IaCProviderRegionListerServer = (*azureIaCServer)(nil) + _ pb.IaCProviderOwnershipServer = (*azureIaCServer)(nil) _ pb.ResourceDriverServer = (*azureIaCServer)(nil) // azureIaCServer also SERVES the typed IaC state-backend contract // (azure_blob backend). The SDK serve hook auto-registers this via @@ -248,6 +250,42 @@ func (s *azureIaCServer) DetectDriftWithSpecs(ctx context.Context, req *pb.Detec return &pb.DetectDriftWithSpecsResponse{Drifts: pbDrifts}, nil } +// ── Optional: Ownership ─────────────────────────────────────────────────── + +func (s *azureIaCServer) GetOwner(ctx context.Context, req *pb.GetOwnerRequest) (*pb.GetOwnerResponse, error) { + owner, err := s.provider.GetOwner(ctx, refFromPB(req.GetRef())) + if err != nil { + return nil, err + } + return &pb.GetOwnerResponse{Owner: owner.Owner, Source: owner.Source}, nil +} + +func (s *azureIaCServer) SetOwner(ctx context.Context, req *pb.SetOwnerRequest) (*pb.SetOwnerResponse, error) { + if err := s.provider.SetOwner(ctx, refFromPB(req.GetRef()), req.GetOwner()); err != nil { + return nil, err + } + return &pb.SetOwnerResponse{}, nil +} + +func (s *azureIaCServer) ListOwners(ctx context.Context, req *pb.ListOwnersRequest) (*pb.ListOwnersResponse, error) { + owners, err := s.provider.ListOwners(ctx, interfaces.OwnerFilter{ + Owner: req.GetOwner(), + ResourceType: req.GetResourceType(), + }) + if err != nil { + return nil, err + } + out := make([]*pb.OwnedResource, 0, len(owners)) + for _, owner := range owners { + out = append(out, &pb.OwnedResource{ + Ref: refToPB(owner.Ref), + Owner: owner.Owner, + Source: owner.Source, + }) + } + return &pb.ListOwnersResponse{Resources: out}, nil +} + // ── Marshalling helpers (pb ↔ Go) ─────────────────────────────────────────── // // These mirror the inverse-direction helpers in cmd/wfctl/iac_typed_adapter.go diff --git a/internal/iacserver_mapper_test.go b/internal/iacserver_mapper_test.go index ee31848..68620e5 100644 --- a/internal/iacserver_mapper_test.go +++ b/internal/iacserver_mapper_test.go @@ -181,8 +181,8 @@ func TestPluginManifestAdvertisesRequirementMapper(t *testing.T) { if err := json.Unmarshal(data, &manifest); err != nil { t.Fatalf("parse plugin.json: %v", err) } - if manifest.MinEngineVersion != "0.68.2" { - t.Fatalf("minEngineVersion = %q, want 0.68.2", manifest.MinEngineVersion) + if manifest.MinEngineVersion != "0.69.1" { + t.Fatalf("minEngineVersion = %q, want 0.69.1", manifest.MinEngineVersion) } const mapperService = "workflow.plugin.external.iac.IaCProviderRequirementMapper" for _, svc := range manifest.IaCServices { diff --git a/internal/iacserver_test.go b/internal/iacserver_test.go index 4b4e26c..46176be 100644 --- a/internal/iacserver_test.go +++ b/internal/iacserver_test.go @@ -73,6 +73,7 @@ func TestIaCServer_CompileTimeGuards(t *testing.T) { // If any of the interface assertions below fail to compile, this file will not build. var _ pb.IaCProviderRequiredServer = (*azureIaCServer)(nil) var _ pb.IaCProviderDriftDetectorServer = (*azureIaCServer)(nil) + var _ pb.IaCProviderOwnershipServer = (*azureIaCServer)(nil) var _ pb.ResourceDriverServer = (*azureIaCServer)(nil) } diff --git a/internal/ownership.go b/internal/ownership.go new file mode 100644 index 0000000..2026100 --- /dev/null +++ b/internal/ownership.go @@ -0,0 +1,267 @@ +package internal + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources" + "github.com/GoCodeAlone/workflow/interfaces" +) + +const ( + ownershipTagKey = "workflow-owner" + ownershipTagSource = "tag:workflow-owner" +) + +var ErrOwnershipARMIDRequired = errors.New("azure ownership requires ResourceRef.ProviderID to be an ARM resource ID") + +type ownershipTagsClient interface { + GetAtScope(context.Context, string) (armresources.TagsResource, error) + UpdateAtScope(context.Context, string, armresources.TagsPatchResource) error +} + +type ownershipResourcesClient interface { + ListByResourceGroup(context.Context, string, string) ([]armresources.GenericResourceExpanded, error) +} + +type azureOwnershipTagsClient struct { + inner *armresources.TagsClient +} + +func (c *azureOwnershipTagsClient) GetAtScope(ctx context.Context, scope string) (armresources.TagsResource, error) { + resp, err := c.inner.GetAtScope(ctx, scope, nil) + if err != nil { + return armresources.TagsResource{}, err + } + return resp.TagsResource, nil +} + +func (c *azureOwnershipTagsClient) UpdateAtScope(ctx context.Context, scope string, parameters armresources.TagsPatchResource) error { + _, err := c.inner.UpdateAtScope(ctx, scope, parameters, nil) + return err +} + +type azureOwnershipResourcesClient struct { + inner *armresources.Client +} + +func (c *azureOwnershipResourcesClient) ListByResourceGroup(ctx context.Context, resourceGroup, filter string) ([]armresources.GenericResourceExpanded, error) { + pager := c.inner.NewListByResourceGroupPager(resourceGroup, &armresources.ClientListByResourceGroupOptions{ + Filter: &filter, + }) + var resources []armresources.GenericResourceExpanded + for pager.More() { + page, err := pager.NextPage(ctx) + if err != nil { + return nil, err + } + for _, resource := range page.Value { + if resource != nil { + resources = append(resources, *resource) + } + } + } + return resources, nil +} + +func (p *AzureProvider) GetOwner(ctx context.Context, ref interfaces.ResourceRef) (*interfaces.ResourceOwner, error) { + p.mu.RLock() + client, err := p.ownershipTagsClientLocked() + p.mu.RUnlock() + if err != nil { + return nil, err + } + scope, err := ownershipARMID(ref) + if err != nil { + return nil, err + } + tags, err := client.GetAtScope(ctx, scope) + if err != nil { + return nil, fmt.Errorf("azure: get ownership tags for %q: %w", ref.Name, err) + } + return &interfaces.ResourceOwner{Ref: ref, Owner: ownerFromAzureTags(tags.Properties), Source: ownershipTagSource}, nil +} + +func (p *AzureProvider) SetOwner(ctx context.Context, ref interfaces.ResourceRef, owner string) error { + if strings.TrimSpace(owner) == "" { + return fmt.Errorf("azure: owner must be non-empty") + } + p.mu.RLock() + client, err := p.ownershipTagsClientLocked() + p.mu.RUnlock() + if err != nil { + return err + } + scope, err := ownershipARMID(ref) + if err != nil { + return err + } + if err := client.UpdateAtScope(ctx, scope, armresources.TagsPatchResource{ + Operation: to.Ptr(armresources.TagsPatchOperationMerge), + Properties: &armresources.Tags{ + Tags: map[string]*string{ownershipTagKey: to.Ptr(owner)}, + }, + }); err != nil { + return fmt.Errorf("azure: tag %s/%s with owner %q: %w", ref.Type, ref.Name, owner, err) + } + return nil +} + +func (p *AzureProvider) ListOwners(ctx context.Context, filter interfaces.OwnerFilter) ([]interfaces.ResourceOwner, error) { + p.mu.RLock() + resourceGroup := p.resourceGroup + client, err := p.ownershipResourcesClientLocked() + p.mu.RUnlock() + if err != nil { + return nil, err + } + + tagFilter := fmt.Sprintf("tagName eq '%s'", azureODataLiteral(ownershipTagKey)) + if filter.Owner != "" { + tagFilter += fmt.Sprintf(" and tagValue eq '%s'", azureODataLiteral(filter.Owner)) + } + + resources, err := client.ListByResourceGroup(ctx, resourceGroup, tagFilter) + if err != nil { + return nil, fmt.Errorf("azure: list owner tags: %w", err) + } + + var owners []interfaces.ResourceOwner + for _, resource := range resources { + owner := ownerFromAzureTagMap(resource.Tags) + if owner == "" && filter.Owner != "" { + owner = filter.Owner + } + if owner == "" { + continue + } + ref := refFromAzureResource(resource) + if ref.ProviderID == "" { + continue + } + if filter.ResourceType != "" && ref.Type != filter.ResourceType { + continue + } + owners = append(owners, interfaces.ResourceOwner{Ref: ref, Owner: owner, Source: ownershipTagSource}) + } + return owners, nil +} + +func (p *AzureProvider) ownershipTagsClientLocked() (ownershipTagsClient, error) { + if p.subscriptionID == "" { + return nil, fmt.Errorf("azure: provider not initialized") + } + if p.ownershipTags == nil { + return nil, fmt.Errorf("azure: ownership tags client not initialized") + } + return p.ownershipTags, nil +} + +func (p *AzureProvider) ownershipResourcesClientLocked() (ownershipResourcesClient, error) { + if p.subscriptionID == "" { + return nil, fmt.Errorf("azure: provider not initialized") + } + if p.resourceGroup == "" { + return nil, fmt.Errorf("azure: resource_group is required") + } + if p.ownershipResources == nil { + return nil, fmt.Errorf("azure: ownership resources client not initialized") + } + return p.ownershipResources, nil +} + +func ownershipARMID(ref interfaces.ResourceRef) (string, error) { + if strings.HasPrefix(ref.ProviderID, "/subscriptions/") && strings.Contains(ref.ProviderID, "/providers/") { + return ref.ProviderID, nil + } + return "", fmt.Errorf("%w for %s/%s: got %q", ErrOwnershipARMIDRequired, ref.Type, ref.Name, ref.ProviderID) +} + +func ownerFromAzureTags(tags *armresources.Tags) string { + if tags == nil { + return "" + } + return ownerFromAzureTagMap(tags.Tags) +} + +func ownerFromAzureTagMap(tags map[string]*string) string { + if tags == nil || tags[ownershipTagKey] == nil { + return "" + } + return *tags[ownershipTagKey] +} + +func refFromAzureResource(resource armresources.GenericResourceExpanded) interfaces.ResourceRef { + id := stringPtrValue(resource.ID) + resourceType := workflowTypeFromAzureResourceType(stringPtrValue(resource.Type)) + if id == "" || resourceType == "" { + return interfaces.ResourceRef{} + } + name := stringPtrValue(resource.Name) + if name == "" { + name = azureResourceNameFromID(id) + } + return interfaces.ResourceRef{ + Name: name, + Type: resourceType, + ProviderID: id, + } +} + +func workflowTypeFromAzureResourceType(resourceType string) string { + switch strings.ToLower(resourceType) { + case "microsoft.containerinstance/containergroups": + return "infra.container_service" + case "microsoft.containerservice/managedclusters": + return "infra.k8s_cluster" + case "microsoft.sql/servers", "microsoft.sql/servers/databases": + return "infra.database" + case "microsoft.cache/redis": + return "infra.cache" + case "microsoft.network/virtualnetworks": + return "infra.vpc" + case "microsoft.network/loadbalancers": + return "infra.load_balancer" + case "microsoft.network/dnszones": + return "infra.dns" + case "microsoft.containerregistry/registries": + return "infra.registry" + case "microsoft.apimanagement/service": + return "infra.api_gateway" + case "microsoft.network/networksecuritygroups": + return "infra.firewall" + case "microsoft.managedidentity/userassignedidentities": + return "infra.iam_role" + case "microsoft.storage/storageaccounts", "microsoft.storage/storageaccounts/blobservices/containers": + return "infra.storage" + case "microsoft.web/certificates": + return "infra.certificate" + default: + return "" + } +} + +func azureResourceNameFromID(id string) string { + id = strings.TrimRight(id, "/") + if id == "" { + return "" + } + parts := strings.Split(id, "/") + return parts[len(parts)-1] +} + +func azureODataLiteral(value string) string { + return strings.ReplaceAll(value, "'", "''") +} + +func stringPtrValue(value *string) string { + if value == nil { + return "" + } + return *value +} + +var _ interfaces.OwnershipProvider = (*AzureProvider)(nil) diff --git a/internal/ownership_test.go b/internal/ownership_test.go new file mode 100644 index 0000000..28d7712 --- /dev/null +++ b/internal/ownership_test.go @@ -0,0 +1,173 @@ +package internal + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources" + "github.com/GoCodeAlone/workflow/interfaces" +) + +type fakeTokenCredential struct{} + +func (fakeTokenCredential) GetToken(context.Context, policy.TokenRequestOptions) (azcore.AccessToken, error) { + return azcore.AccessToken{Token: "test", ExpiresOn: time.Now().Add(time.Hour)}, nil +} + +type fakeOwnershipTagsClient struct { + getScopes []string + getResponses []armresources.TagsResource + updateScopes []string + updateParams []armresources.TagsPatchResource +} + +func (f *fakeOwnershipTagsClient) GetAtScope(ctx context.Context, scope string) (armresources.TagsResource, error) { + f.getScopes = append(f.getScopes, scope) + if len(f.getResponses) == 0 { + return armresources.TagsResource{}, nil + } + out := f.getResponses[0] + f.getResponses = f.getResponses[1:] + return out, nil +} + +func (f *fakeOwnershipTagsClient) UpdateAtScope(ctx context.Context, scope string, parameters armresources.TagsPatchResource) error { + f.updateScopes = append(f.updateScopes, scope) + f.updateParams = append(f.updateParams, parameters) + return nil +} + +type fakeOwnershipResourcesClient struct { + resourceGroup string + filter string + resources []armresources.GenericResourceExpanded +} + +func (f *fakeOwnershipResourcesClient) ListByResourceGroup(ctx context.Context, resourceGroup, filter string) ([]armresources.GenericResourceExpanded, error) { + f.resourceGroup = resourceGroup + f.filter = filter + return f.resources, nil +} + +func TestOwnershipProviderCompileGuard(t *testing.T) { + var _ interfaces.OwnershipProvider = (*AzureProvider)(nil) +} + +func TestSetOwnerMergesWorkflowOwnerTagAtARMScope(t *testing.T) { + tags := &fakeOwnershipTagsClient{} + p := initializedOwnershipProvider(tags, &fakeOwnershipResourcesClient{}) + id := "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.ContainerInstance/containerGroups/api" + + if err := p.SetOwner(context.Background(), interfaces.ResourceRef{Name: "api", Type: "infra.container_service", ProviderID: id}, "workflow"); err != nil { + t.Fatalf("SetOwner: %v", err) + } + + if len(tags.updateScopes) != 1 || tags.updateScopes[0] != id { + t.Fatalf("update scopes = %v, want [%s]", tags.updateScopes, id) + } + param := tags.updateParams[0] + if param.Operation == nil || *param.Operation != armresources.TagsPatchOperationMerge { + t.Fatalf("operation = %v, want Merge", param.Operation) + } + if param.Properties == nil || param.Properties.Tags[ownershipTagKey] == nil || *param.Properties.Tags[ownershipTagKey] != "workflow" { + t.Fatalf("tags = %#v, want %q=workflow", param.Properties, ownershipTagKey) + } +} + +func TestSetOwnerRejectsNonARMProviderID(t *testing.T) { + p := initializedOwnershipProvider(&fakeOwnershipTagsClient{}, &fakeOwnershipResourcesClient{}) + + err := p.SetOwner(context.Background(), interfaces.ResourceRef{Name: "container", Type: "infra.storage", ProviderID: "container-name"}, "workflow") + if err == nil { + t.Fatal("SetOwner returned nil, want ARM ID error") + } + if !errors.Is(err, ErrOwnershipARMIDRequired) { + t.Fatalf("SetOwner error = %v, want ErrOwnershipARMIDRequired", err) + } +} + +func TestGetOwnerReadsWorkflowOwnerTag(t *testing.T) { + id := "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Sql/servers/sql1/databases/app" + tags := &fakeOwnershipTagsClient{ + getResponses: []armresources.TagsResource{ + {Properties: &armresources.Tags{Tags: map[string]*string{ + "env": to.Ptr("prod"), + ownershipTagKey: to.Ptr("data"), + }}}, + }, + } + p := initializedOwnershipProvider(tags, &fakeOwnershipResourcesClient{}) + + owner, err := p.GetOwner(context.Background(), interfaces.ResourceRef{Name: "app", Type: "infra.database", ProviderID: id}) + if err != nil { + t.Fatalf("GetOwner: %v", err) + } + if owner.Owner != "data" { + t.Fatalf("Owner = %q, want data", owner.Owner) + } + if owner.Source != ownershipTagSource { + t.Fatalf("Source = %q, want %q", owner.Source, ownershipTagSource) + } + if owner.Ref.ProviderID != id { + t.Fatalf("Ref.ProviderID = %q, want %q", owner.Ref.ProviderID, id) + } +} + +func TestListOwnersFiltersByOwnerAndResourceType(t *testing.T) { + aciID := "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.ContainerInstance/containerGroups/api" + sqlID := "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Sql/servers/sql1/databases/app" + resources := &fakeOwnershipResourcesClient{ + resources: []armresources.GenericResourceExpanded{ + { + ID: to.Ptr(aciID), + Name: to.Ptr("api"), + Type: to.Ptr("Microsoft.ContainerInstance/containerGroups"), + Tags: map[string]*string{ownershipTagKey: to.Ptr("workflow")}, + }, + { + ID: to.Ptr(sqlID), + Name: to.Ptr("app"), + Type: to.Ptr("Microsoft.Sql/servers/databases"), + Tags: map[string]*string{ownershipTagKey: to.Ptr("workflow")}, + }, + }, + } + p := initializedOwnershipProvider(&fakeOwnershipTagsClient{}, resources) + + owners, err := p.ListOwners(context.Background(), interfaces.OwnerFilter{Owner: "workflow", ResourceType: "infra.container_service"}) + if err != nil { + t.Fatalf("ListOwners: %v", err) + } + if resources.resourceGroup != "rg" { + t.Fatalf("resourceGroup = %q, want rg", resources.resourceGroup) + } + if resources.filter != "tagName eq 'workflow-owner' and tagValue eq 'workflow'" { + t.Fatalf("filter = %q", resources.filter) + } + if len(owners) != 1 { + t.Fatalf("owners len = %d, want 1: %#v", len(owners), owners) + } + got := owners[0] + if got.Owner != "workflow" || got.Source != ownershipTagSource { + t.Fatalf("owner metadata = %#v, want owner workflow source %q", got, ownershipTagSource) + } + if got.Ref.Name != "api" || got.Ref.Type != "infra.container_service" || got.Ref.ProviderID != aciID { + t.Fatalf("ref = %#v, want api infra.container_service %s", got.Ref, aciID) + } +} + +func initializedOwnershipProvider(tags ownershipTagsClient, resources ownershipResourcesClient) *AzureProvider { + return &AzureProvider{ + subscriptionID: "sub", + resourceGroup: "rg", + credential: &fakeTokenCredential{}, + drivers: make(map[string]interfaces.ResourceDriver), + ownershipTags: tags, + ownershipResources: resources, + } +} diff --git a/internal/provider.go b/internal/provider.go index 5f43f01..87a9fe8 100644 --- a/internal/provider.go +++ b/internal/provider.go @@ -9,6 +9,7 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/azidentity" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources" "github.com/GoCodeAlone/workflow-plugin-azure/internal/driver" "github.com/GoCodeAlone/workflow/interfaces" ) @@ -26,6 +27,9 @@ type AzureProvider struct { location string credential azcore.TokenCredential drivers map[string]interfaces.ResourceDriver + + ownershipTags ownershipTagsClient + ownershipResources ownershipResourcesClient } // Ensure AzureProvider satisfies interfaces.IaCProvider. @@ -75,12 +79,23 @@ func (p *AzureProvider) Initialize(ctx context.Context, config map[string]any) e return fmt.Errorf("azure: credential: %w", err) } + tagsClient, err := armresources.NewTagsClient(subID, cred, nil) + if err != nil { + return fmt.Errorf("azure: init ownership tags client: %w", err) + } + resourcesClient, err := armresources.NewClient(subID, cred, nil) + if err != nil { + return fmt.Errorf("azure: init ownership resources client: %w", err) + } + drivers, err := driver.NewAll(subID, rg, loc, storageAccount, cred) if err != nil { return fmt.Errorf("azure: init drivers: %w", err) } p.credential = cred p.drivers = drivers + p.ownershipTags = &azureOwnershipTagsClient{inner: tagsClient} + p.ownershipResources = &azureOwnershipResourcesClient{inner: resourcesClient} return nil } @@ -332,5 +347,7 @@ func (p *AzureProvider) Close() error { p.mu.Lock() defer p.mu.Unlock() p.drivers = make(map[string]interfaces.ResourceDriver) + p.ownershipTags = nil + p.ownershipResources = nil return nil } diff --git a/internal/region_lister_test.go b/internal/region_lister_test.go index 4dd9f5b..be8c148 100644 --- a/internal/region_lister_test.go +++ b/internal/region_lister_test.go @@ -36,11 +36,26 @@ func TestAzureIaCServer_RegistersRegionLister(t *testing.T) { } } +func TestAzureIaCServer_RegistersOwnership(t *testing.T) { + server := grpc.NewServer() + if err := sdk.RegisterAllIaCProviderServices(server, NewIaCServer()); err != nil { + t.Fatalf("RegisterAllIaCProviderServices: %v", err) + } + if _, ok := server.GetServiceInfo()[pb.IaCProviderOwnership_ServiceDesc.ServiceName]; !ok { + t.Fatalf("registered services missing %s", pb.IaCProviderOwnership_ServiceDesc.ServiceName) + } +} + func TestPluginManifestAdvertisesRegionLister(t *testing.T) { assertManifestAdvertisesRegionLister(t, filepath.Join(hostConformanceRepoRoot(t), "plugin.json")) assertManifestAdvertisesRegionLister(t, filepath.Join(hostConformanceRepoRoot(t), "cmd", "workflow-plugin-azure", "plugin.json")) } +func TestPluginManifestAdvertisesOwnership(t *testing.T) { + assertManifestAdvertisesOwnership(t, filepath.Join(hostConformanceRepoRoot(t), "plugin.json")) + assertManifestAdvertisesOwnership(t, filepath.Join(hostConformanceRepoRoot(t), "cmd", "workflow-plugin-azure", "plugin.json")) +} + func assertManifestAdvertisesRegionLister(t *testing.T, path string) { t.Helper() data, err := os.ReadFile(path) @@ -58,6 +73,23 @@ func assertManifestAdvertisesRegionLister(t *testing.T, path string) { } } +func assertManifestAdvertisesOwnership(t *testing.T, path string) { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + var manifest struct { + IaCServices []string `json:"iacServices"` + } + if err := json.Unmarshal(data, &manifest); err != nil { + t.Fatalf("parse %s: %v", path, err) + } + if !containsString(manifest.IaCServices, pb.IaCProviderOwnership_ServiceDesc.ServiceName) { + t.Fatalf("%s iacServices missing %s: %v", path, pb.IaCProviderOwnership_ServiceDesc.ServiceName, manifest.IaCServices) + } +} + func regionNames(regions []*pb.ProviderRegion) []string { out := make([]string, 0, len(regions)) for _, region := range regions { diff --git a/plugin.json b/plugin.json index 3db1b9b..f8930b3 100644 --- a/plugin.json +++ b/plugin.json @@ -6,7 +6,7 @@ "license": "MIT", "type": "external", "tier": "community", - "minEngineVersion": "0.68.2", + "minEngineVersion": "0.69.1", "required_secrets": [ { "name": "AZURE_CLIENT_ID", @@ -43,6 +43,7 @@ "workflow.plugin.external.iac.IaCProviderDriftConfigDetector", "workflow.plugin.external.iac.IaCProviderRequirementMapper", "workflow.plugin.external.iac.IaCProviderRegionLister", + "workflow.plugin.external.iac.IaCProviderOwnership", "workflow.plugin.external.iac.ResourceDriver", "workflow.plugin.external.iac.IaCStateBackend" ],