diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 805889a..fa9952f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,8 +10,12 @@ env: GONOSUMDB: github.com/GoCodeAlone/* GOPRIVATE: github.com/GoCodeAlone/* +permissions: + contents: read + jobs: test: + name: Build & Test runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -22,3 +26,18 @@ jobs: run: git config --global url."https://${{ secrets.RELEASES_TOKEN }}@github.com/".insteadOf "https://github.com/" - run: go build ./... - run: go test ./... -v -race -count=1 + + wfctl-strict-contracts: + name: Strict Contract Validation + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: Configure git for private modules + run: git config --global url."https://${{ secrets.RELEASES_TOKEN }}@github.com/".insteadOf "https://github.com/" + - name: Validate strict plugin contracts + run: go run github.com/GoCodeAlone/workflow/cmd/wfctl@v0.64.3 plugin validate --file plugin.json --strict-contracts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b52eb8e..0e6c2fb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -43,7 +43,7 @@ jobs: - uses: GoCodeAlone/setup-wfctl@v1 with: - version: v0.64.0 + version: v0.64.3 - name: Validate plugin contract for publish (pre-build) run: wfctl plugin validate-contract --for-publish --tag "${{ inputs.tag || github.ref_name }}" . diff --git a/.goreleaser.yaml b/.goreleaser.yaml index fa56902..d292fc2 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -1,9 +1,13 @@ version: 2 +project_name: workflow-plugin-azure + before: hooks: - - "cp plugin.json plugin.json.orig" - - "sed -i.bak 's/\"version\": \".*\"/\"version\": \"{{ .Version }}\"/' plugin.json && rm -f plugin.json.bak" + - "sh -c \"cp plugin.json cmd/workflow-plugin-azure/plugin.json\"" + - "sh -c \"rm -rf .release && mkdir -p .release && cp plugin.json .release/plugin.json && cp plugin.contracts.json .release/plugin.contracts.json && sed -i.bak 's/\\\"version\\\": \\\".*\\\"/\\\"version\\\": \\\"{{ .Version }}\\\"/' .release/plugin.json && rm -f .release/plugin.json.bak\"" + - "sh -c \"sed -i.bak 's|/releases/download/v[^/]*/|/releases/download/{{ .Tag }}/|g' .release/plugin.json && rm -f .release/plugin.json.bak\"" + - "sh -c \"export GOPRIVATE=github.com/GoCodeAlone/*; WFCTL_VERSION=$(GOWORK=off go list -m github.com/GoCodeAlone/workflow | awk '{print $2}') && GOWORK=off go run github.com/GoCodeAlone/workflow/cmd/wfctl@${WFCTL_VERSION} plugin validate --file .release/plugin.json --strict-contracts\"" builds: - main: ./cmd/workflow-plugin-azure @@ -23,7 +27,10 @@ archives: - formats: [tar.gz] name_template: "{{ .ProjectName }}-{{ .Os }}-{{ .Arch }}" files: - - plugin.json + - src: .release/plugin.json + dst: plugin.json + - plugin.contracts.json + - LICENSE checksum: name_template: checksums.txt diff --git a/cmd/workflow-plugin-azure/main.go b/cmd/workflow-plugin-azure/main.go index a7f3ac4..1c3ff3b 100644 --- a/cmd/workflow-plugin-azure/main.go +++ b/cmd/workflow-plugin-azure/main.go @@ -11,12 +11,21 @@ package main import ( + _ "embed" + "github.com/GoCodeAlone/workflow-plugin-azure/internal" sdk "github.com/GoCodeAlone/workflow/plugin/external/sdk" ) +// pluginJSON is copied from the repository root by GoReleaser before builds +// and is committed for local builds/tests. +// +//go:embed plugin.json +var pluginJSON []byte + func main() { sdk.ServeIaCPlugin(internal.NewIaCServer(), sdk.IaCServeOptions{ - BuildVersion: sdk.ResolveBuildVersion(internal.Version), + ManifestProvider: sdk.MustEmbedManifest(pluginJSON), + BuildVersion: sdk.ResolveBuildVersion(internal.Version), }) } diff --git a/cmd/workflow-plugin-azure/plugin.json b/cmd/workflow-plugin-azure/plugin.json new file mode 100644 index 0000000..9c7bf0f --- /dev/null +++ b/cmd/workflow-plugin-azure/plugin.json @@ -0,0 +1,68 @@ +{ + "name": "workflow-plugin-azure", + "version": "0.0.0", + "author": "GoCodeAlone", + "description": "Microsoft Azure infrastructure provider: ACI, AKS, SQL, Redis, VNet, LB, DNS, ACR, APIM, NSG, MSI, Blob Storage, App Service Certificates", + "license": "MIT", + "type": "external", + "tier": "community", + "minEngineVersion": "0.64.3", + "iacServices": [ + "workflow.plugin.external.iac.IaCProviderRequired", + "workflow.plugin.external.iac.IaCProviderEnumerator", + "workflow.plugin.external.iac.IaCProviderDriftDetector", + "workflow.plugin.external.iac.IaCProviderCredentialRevoker", + "workflow.plugin.external.iac.IaCProviderMigrationRepairer", + "workflow.plugin.external.iac.IaCProviderValidator", + "workflow.plugin.external.iac.IaCProviderDriftConfigDetector", + "workflow.plugin.external.iac.IaCProviderRequirementMapper", + "workflow.plugin.external.iac.ResourceDriver", + "workflow.plugin.external.iac.IaCStateBackend" + ], + "keywords": [ + "azure", + "iac", + "infrastructure", + "cloud", + "aci", + "aks", + "sql", + "redis", + "vnet" + ], + "homepage": "https://github.com/GoCodeAlone/workflow-plugin-azure", + "repository": "https://github.com/GoCodeAlone/workflow-plugin-azure", + "downloads": [ + { + "os": "linux", + "arch": "amd64", + "url": "https://github.com/GoCodeAlone/workflow-plugin-azure/releases/download/v0.0.0/workflow-plugin-azure-linux-amd64.tar.gz" + }, + { + "os": "linux", + "arch": "arm64", + "url": "https://github.com/GoCodeAlone/workflow-plugin-azure/releases/download/v0.0.0/workflow-plugin-azure-linux-arm64.tar.gz" + }, + { + "os": "darwin", + "arch": "amd64", + "url": "https://github.com/GoCodeAlone/workflow-plugin-azure/releases/download/v0.0.0/workflow-plugin-azure-darwin-amd64.tar.gz" + }, + { + "os": "darwin", + "arch": "arm64", + "url": "https://github.com/GoCodeAlone/workflow-plugin-azure/releases/download/v0.0.0/workflow-plugin-azure-darwin-arm64.tar.gz" + } + ], + "capabilities": { + "configProvider": false, + "moduleTypes": [ + "iac.provider" + ], + "stepTypes": [], + "triggerTypes": [], + "iacStateBackends": [ + "azure_blob" + ] + } +} diff --git a/go.mod b/go.mod index e40325c..32cb66b 100644 --- a/go.mod +++ b/go.mod @@ -16,7 +16,7 @@ require ( github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/redis/armredis v1.0.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/sql/armsql v1.2.0 github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4 - github.com/GoCodeAlone/workflow v0.64.0 + github.com/GoCodeAlone/workflow v0.64.3 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 df9d267..3248872 100644 --- a/go.sum +++ b/go.sum @@ -68,8 +68,8 @@ github.com/GoCodeAlone/modular/modules/jsonschema v1.15.0 h1:xb1mI4NZkzvNKQ2F6nk github.com/GoCodeAlone/modular/modules/jsonschema v1.15.0/go.mod h1:hhGouwAVsonmJ4Lain4jINZ9nZCoc9l9eF3BHbmR8eE= github.com/GoCodeAlone/modular/modules/reverseproxy/v2 v2.8.0 h1:cvdLHbM/vzvygQTcAWSJsy+dAPzzwWyjzKMmTBFcFIo= github.com/GoCodeAlone/modular/modules/reverseproxy/v2 v2.8.0/go.mod h1:/9ipMG4qM2CHQ14BfXKdVlYRJelef6M8MFI5TbZv67M= -github.com/GoCodeAlone/workflow v0.64.0 h1:2CpbYPwIqdGDb3xi3YJpwcteIum4ehBSrnRql/1YvB4= -github.com/GoCodeAlone/workflow v0.64.0/go.mod h1:659GGDrw3QJ7b625y9rf8QhKIpt1VCoEG0MxKu5tGQs= +github.com/GoCodeAlone/workflow v0.64.3 h1:r0jMoRJXJI8lz44c70mFjGcpy24IWpOTtkX7BC0/fas= +github.com/GoCodeAlone/workflow v0.64.3/go.mod h1:659GGDrw3QJ7b625y9rf8QhKIpt1VCoEG0MxKu5tGQs= 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/aci.go b/internal/driver/aci.go index 06f63e3..d792648 100644 --- a/internal/driver/aci.go +++ b/internal/driver/aci.go @@ -3,6 +3,7 @@ package driver import ( "context" "fmt" + "sort" "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerinstance/armcontainerinstance/v2" @@ -79,7 +80,10 @@ func (d *ACIDriver) Create(ctx context.Context, spec interfaces.ResourceSpec) (* { Name: str(spec.Name), Properties: &armcontainerinstance.ContainerProperties{ - Image: str(image), + Image: str(image), + Command: aciCommand(spec.Config), + EnvironmentVariables: aciEnvironmentVariables(spec.Config), + Ports: aciContainerPorts(spec.Config), Resources: &armcontainerinstance.ResourceRequirements{ Requests: &armcontainerinstance.ResourceRequests{ CPU: &cpu, @@ -89,6 +93,7 @@ func (d *ACIDriver) Create(ctx context.Context, spec interfaces.ResourceSpec) (* }, }, }, + IPAddress: aciIPAddress(spec.Config), OSType: ptrOf(armcontainerinstance.OperatingSystemTypesLinux), RestartPolicy: ptrOf(armcontainerinstance.ContainerGroupRestartPolicyAlways), }, @@ -165,3 +170,171 @@ func aciToOutput(name string, cg armcontainerinstance.ContainerGroup) *interface } func ptrOf[T any](v T) *T { return &v } + +func aciCommand(config map[string]any) []*string { + raw := configList(config, "command") + out := make([]*string, 0, len(raw)) + for _, value := range raw { + if strValue, ok := value.(string); ok && strValue != "" { + out = append(out, str(strValue)) + } + } + return out +} + +func aciEnvironmentVariables(config map[string]any) []*armcontainerinstance.EnvironmentVariable { + vars := make([]*armcontainerinstance.EnvironmentVariable, 0) + vars = append(vars, aciPlainEnvironmentVariables(configMap(config, "env_vars"))...) + vars = append(vars, aciSecureEnvironmentVariables(configMap(config, "env_vars_secret"))...) + return vars +} + +func aciPlainEnvironmentVariables(values map[string]any) []*armcontainerinstance.EnvironmentVariable { + keys := sortedConfigMapKeys(values) + out := make([]*armcontainerinstance.EnvironmentVariable, 0, len(keys)) + for _, key := range keys { + value, ok := values[key].(string) + if !ok { + continue + } + out = append(out, &armcontainerinstance.EnvironmentVariable{ + Name: str(key), + Value: str(value), + }) + } + return out +} + +func aciSecureEnvironmentVariables(values map[string]any) []*armcontainerinstance.EnvironmentVariable { + keys := sortedConfigMapKeys(values) + out := make([]*armcontainerinstance.EnvironmentVariable, 0, len(keys)) + for _, key := range keys { + value, ok := values[key].(string) + if !ok { + continue + } + out = append(out, &armcontainerinstance.EnvironmentVariable{ + Name: str(key), + SecureValue: str(value), + }) + } + return out +} + +func aciContainerPorts(config map[string]any) []*armcontainerinstance.ContainerPort { + portConfigs := aciPortConfigs(config) + out := make([]*armcontainerinstance.ContainerPort, 0, len(portConfigs)) + for _, port := range portConfigs { + portValue := port.port + out = append(out, &armcontainerinstance.ContainerPort{ + Port: &portValue, + Protocol: ptrOf(armcontainerinstance.ContainerNetworkProtocolTCP), + }) + } + return out +} + +func aciIPAddress(config map[string]any) *armcontainerinstance.IPAddress { + portConfigs := aciPortConfigs(config) + var publicPorts []*armcontainerinstance.Port + for _, port := range portConfigs { + if !port.public { + continue + } + portValue := port.port + publicPorts = append(publicPorts, &armcontainerinstance.Port{ + Port: &portValue, + Protocol: ptrOf(armcontainerinstance.ContainerGroupNetworkProtocolTCP), + }) + } + if len(publicPorts) == 0 { + return nil + } + return &armcontainerinstance.IPAddress{ + Type: ptrOf(armcontainerinstance.ContainerGroupIPAddressTypePublic), + Ports: publicPorts, + } +} + +type aciPortConfig struct { + port int32 + public bool +} + +func aciPortConfigs(config map[string]any) []aciPortConfig { + raw := configList(config, "ports") + out := make([]aciPortConfig, 0, len(raw)) + for _, item := range raw { + values, ok := item.(map[string]any) + if !ok { + continue + } + port, ok := aciPortNumber(values["port"]) + if !ok { + continue + } + out = append(out, aciPortConfig{ + port: port, + public: boolConfigValue(values["public"]), + }) + } + return out +} + +func aciPortNumber(value any) (int32, bool) { + const maxPort = 65535 + switch typed := value.(type) { + case int: + if typed > 0 && typed <= maxPort { + return int32(typed), true + } + case int32: + if typed > 0 && typed <= maxPort { + return typed, true + } + case int64: + if typed > 0 && typed <= maxPort { + return int32(typed), true + } + case float64: + if typed > 0 && typed <= maxPort { + return int32(typed), true + } + } + return 0, false +} + +func configMap(config map[string]any, key string) map[string]any { + switch values := config[key].(type) { + case map[string]any: + return values + case map[string]string: + out := make(map[string]any, len(values)) + for name, value := range values { + out[name] = value + } + return out + default: + return nil + } +} + +func sortedConfigMapKeys(values map[string]any) []string { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + +func boolConfigValue(value any) bool { + switch typed := value.(type) { + case bool: + return typed + case string: + return typed == "true" + default: + return false + } +} diff --git a/internal/driver/aci_test.go b/internal/driver/aci_test.go index 61abdea..be7757a 100644 --- a/internal/driver/aci_test.go +++ b/internal/driver/aci_test.go @@ -67,6 +67,81 @@ func TestACIDriver_Create(t *testing.T) { } } +func TestACIDriver_Create_CollectorConfig(t *testing.T) { + var created armcontainerinstance.ContainerGroup + client := &mockACIClient{ + createFn: func(_ context.Context, _, name string, cg armcontainerinstance.ContainerGroup) (armcontainerinstance.ContainerGroup, error) { + created = cg + provisioningState := "Succeeded" + image := "otel/opentelemetry-collector-contrib:latest" + return armcontainerinstance.ContainerGroup{ + ID: str("/subscriptions/sub/resourceGroups/rg/providers/Microsoft.ContainerInstance/containerGroups/" + name), + Properties: &armcontainerinstance.ContainerGroupPropertiesProperties{ + ProvisioningState: &provisioningState, + Containers: []*armcontainerinstance.Container{{ + Name: &name, + Properties: &armcontainerinstance.ContainerProperties{Image: &image}, + }}, + }, + }, nil + }, + } + + drv := NewACIDriver("rg", "eastus", client) + _, err := drv.Create(context.Background(), interfaces.ResourceSpec{ + Name: "observability-collector", + Type: "infra.container_service", + Config: map[string]any{ + "image": "otel/opentelemetry-collector-contrib:latest", + "command": []any{"otelcol-contrib", "--config=env:OTELCOL_CONFIG"}, + "ports": []any{ + map[string]any{"port": 4317, "public": false}, + map[string]any{"port": 4318, "public": false}, + map[string]any{"port": 9464, "public": false}, + map[string]any{"port": 0, "public": false}, + map[string]any{"port": 70000, "public": false}, + }, + "env_vars": map[string]any{ + "OTELCOL_CONFIG": "receivers: {}", + "OTEL_EXPORTER_OTLP_ENDPOINT": "${vars.otel_exporter_otlp_endpoint}", + "LOKI_ENDPOINT": "${vars.loki_endpoint}", + "GRAFANA_OTLP_ENDPOINT": "${vars.grafana_otlp_endpoint}", + "NON_STRING_SHOULD_BE_IGNORED": 42, + }, + "env_vars_secret": map[string]any{ + "DD_API_KEY": "${secrets.datadog_api_key}", + "GRAFANA_OTLP_HEADERS": "${secrets.grafana_otlp_headers}", + "NON_STRING_SECRET_SKIP": 42, + }, + }, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + if created.Properties == nil || len(created.Properties.Containers) != 1 { + t.Fatalf("created container group missing container: %+v", created.Properties) + } + container := created.Properties.Containers[0] + if container.Properties == nil { + t.Fatal("created container missing properties") + } + if got := ptrStrings(container.Properties.Command); len(got) != 2 || got[0] != "otelcol-contrib" || got[1] != "--config=env:OTELCOL_CONFIG" { + t.Fatalf("command = %v, want collector command", got) + } + if got := envValue(container.Properties.EnvironmentVariables, "OTELCOL_CONFIG"); got != "receivers: {}" { + t.Fatalf("OTELCOL_CONFIG = %q, want receivers config", got) + } + if got := secureEnvValue(container.Properties.EnvironmentVariables, "DD_API_KEY"); got != "${secrets.datadog_api_key}" { + t.Fatalf("DD_API_KEY secure value = %q, want secret reference", got) + } + if got := containerPorts(container.Properties.Ports); len(got) != 3 || got[0] != 4317 || got[1] != 4318 || got[2] != 9464 { + t.Fatalf("container ports = %v, want [4317 4318 9464]", got) + } + if created.Properties.IPAddress != nil { + t.Fatalf("IPAddress = %+v, want none when all ports are private", created.Properties.IPAddress) + } +} + func TestACIDriver_Read(t *testing.T) { provisioningState := "Running" client := &mockACIClient{ @@ -272,3 +347,41 @@ func TestACIDriver_Scale_NotSupported(t *testing.T) { t.Fatal("expected error for Scale") } } + +func ptrStrings(values []*string) []string { + out := make([]string, 0, len(values)) + for _, value := range values { + if value != nil { + out = append(out, *value) + } + } + return out +} + +func envValue(values []*armcontainerinstance.EnvironmentVariable, name string) string { + for _, value := range values { + if value != nil && value.Name != nil && *value.Name == name && value.Value != nil { + return *value.Value + } + } + return "" +} + +func secureEnvValue(values []*armcontainerinstance.EnvironmentVariable, name string) string { + for _, value := range values { + if value != nil && value.Name != nil && *value.Name == name && value.SecureValue != nil { + return *value.SecureValue + } + } + return "" +} + +func containerPorts(values []*armcontainerinstance.ContainerPort) []int32 { + out := make([]int32, 0, len(values)) + for _, value := range values { + if value != nil && value.Port != nil { + out = append(out, *value.Port) + } + } + return out +} diff --git a/internal/iacserver.go b/internal/iacserver.go index 249d9bc..6423208 100644 --- a/internal/iacserver.go +++ b/internal/iacserver.go @@ -42,6 +42,7 @@ type azureIaCServer struct { pb.UnimplementedIaCProviderMigrationRepairerServer pb.UnimplementedIaCProviderValidatorServer pb.UnimplementedIaCProviderDriftConfigDetectorServer + pb.UnimplementedIaCProviderRequirementMapperServer pb.UnimplementedResourceDriverServer pb.UnimplementedIaCStateBackendServer @@ -78,8 +79,9 @@ var ( // IaCProviderDriftDetectorServer requires BOTH DetectDrift AND DetectDriftWithSpecs. // Both are implemented below: DetectDrift is the real check; DetectDriftWithSpecs // delegates to DetectDrift (existence-only behavior; ignores the specs map). - _ pb.IaCProviderDriftDetectorServer = (*azureIaCServer)(nil) - _ pb.ResourceDriverServer = (*azureIaCServer)(nil) + _ pb.IaCProviderDriftDetectorServer = (*azureIaCServer)(nil) + _ pb.IaCProviderRequirementMapperServer = (*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 // type-assertion at plugin startup — see cmd/workflow-plugin-azure/main.go. diff --git a/internal/iacserver_mapper.go b/internal/iacserver_mapper.go new file mode 100644 index 0000000..3820fb3 --- /dev/null +++ b/internal/iacserver_mapper.go @@ -0,0 +1,287 @@ +package internal + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "strings" + + pb "github.com/GoCodeAlone/workflow/plugin/external/proto" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +const ( + azureCollectorModuleName = "observability-collector" + azureCollectorImage = "otel/opentelemetry-collector-contrib:latest" + azureCollectorType = "infra.container_service" +) + +// MapRequirements maps canonical derived-IaC requirements into Azure-owned +// resource shapes. The v1 mapper emits a generic collector container service. +// If an application needs a true Azure Container Apps sidecar, it can supply an +// explicit module with the same satisfies keys. +func (s *azureIaCServer) MapRequirements(_ context.Context, req *pb.MapRequirementsRequest) (*pb.MapRequirementsResponse, error) { + if req.GetProvider() != "" && req.GetProvider() != "azure" { + return nil, status.Errorf(codes.InvalidArgument, "azure mapper cannot satisfy provider %q", req.GetProvider()) + } + + resp := &pb.MapRequirementsResponse{} + var accepted []*pb.IaCRequirement + for _, requirement := range req.GetRequirements() { + switch diag := azureRejectUnsupportedRequirement(req.GetRuntime(), requirement); { + case diag != nil: + resp.Rejected = append(resp.Rejected, diag) + default: + accepted = append(accepted, requirement) + resp.AcceptedKeys = append(resp.AcceptedKeys, requirement.GetKey()) + } + } + if len(accepted) == 0 { + return resp, nil + } + + configJSON, err := json.Marshal(azureCollectorModuleConfig(accepted)) + if err != nil { + return nil, fmt.Errorf("azure requirement mapper: encode collector config: %w", err) + } + resp.Modules = append(resp.Modules, &pb.DerivedModuleSpec{ + Name: azureCollectorModuleName, + Type: azureCollectorType, + Satisfies: append([]string(nil), resp.GetAcceptedKeys()...), + ConfigJson: configJSON, + }) + resp.Notes = append(resp.Notes, &pb.RequirementNote{ + Key: strings.Join(resp.GetAcceptedKeys(), ","), + Message: "Azure derivation emits a generic OTel Collector container service. Use an explicit infra.container_service module with the same satisfies keys when an application needs a provider-specific sidecar shape.", + Interactive: false, + }) + return resp, nil +} + +func azureRejectUnsupportedRequirement(runtime pb.RequirementRuntime, req *pb.IaCRequirement) *pb.RequirementDiagnostic { + key := req.GetKey() + if req.GetKind() != pb.RequirementKind_REQUIREMENT_KIND_OBSERVABILITY { + return azureRequirementDiagnostic(key, "unsupported_kind", "azure can only derive observability requirements today") + } + if hint := req.GetResourceTypeHint(); hint != "" && hint != azureCollectorType { + return azureRequirementDiagnostic(key, "unsupported_resource_type_hint", + fmt.Sprintf("azure observability derivation emits %s, not %s", azureCollectorType, hint)) + } + if runtime != pb.RequirementRuntime_REQUIREMENT_RUNTIME_AZURE_CONTAINER_APPS { + return azureRequirementDiagnostic(key, "unsupported_runtime", "azure observability derivation currently targets Azure Container Apps intent") + } + if !azureRequirementAllowsRuntime(req, runtime) { + return azureRequirementDiagnostic(key, "unsupported_runtime", "requirement does not allow Azure Container Apps") + } + if !azureRequirementAllowsDeploymentMode(req) { + return azureRequirementDiagnostic(key, "unsupported_deployment_mode", + "azure maps sidecar intent to an explicit or sibling collector service; daemonset mode belongs to AKS and is not emitted by this mapper yet") + } + return nil +} + +func azureRequirementAllowsRuntime(req *pb.IaCRequirement, runtime pb.RequirementRuntime) bool { + if len(req.GetRuntimes()) == 0 { + return true + } + for _, candidate := range req.GetRuntimes() { + if candidate == runtime { + return true + } + } + return false +} + +func azureRequirementAllowsDeploymentMode(req *pb.IaCRequirement) bool { + modes := req.GetDeploymentModes() + if len(modes) == 0 { + return true + } + for _, mode := range modes { + switch mode { + case pb.DeploymentMode_DEPLOYMENT_MODE_SIDECAR, + pb.DeploymentMode_DEPLOYMENT_MODE_SIBLING_SERVICE, + pb.DeploymentMode_DEPLOYMENT_MODE_MANAGED: + return true + } + } + return false +} + +func azureRequirementDiagnostic(key, code, message string) *pb.RequirementDiagnostic { + return &pb.RequirementDiagnostic{Key: key, Code: code, Message: message} +} + +func azureCollectorModuleConfig(reqs []*pb.IaCRequirement) map[string]any { + signals := azureRequestedSignals(reqs) + backends := azureRequestedBackends(reqs) + collectorConfig := azureBuildCollectorConfig(signals, backends) + + envVars := map[string]any{ + "OTELCOL_CONFIG": collectorConfig, + } + secretVars := make(map[string]any) + if azureHasBackend(backends, pb.ObservabilityBackend_OBSERVABILITY_BACKEND_OTEL) { + envVars["OTEL_EXPORTER_OTLP_ENDPOINT"] = "${vars.otel_exporter_otlp_endpoint}" + } + if azureHasBackend(backends, pb.ObservabilityBackend_OBSERVABILITY_BACKEND_DATADOG) { + envVars["DD_SITE"] = "${vars.datadog_site}" + secretVars["DD_API_KEY"] = "${secrets.datadog_api_key}" + } + if azureHasBackend(backends, pb.ObservabilityBackend_OBSERVABILITY_BACKEND_LOKI) { + envVars["LOKI_ENDPOINT"] = "${vars.loki_endpoint}" + } + if azureHasBackend(backends, pb.ObservabilityBackend_OBSERVABILITY_BACKEND_GRAFANA) { + envVars["GRAFANA_OTLP_ENDPOINT"] = "${vars.grafana_otlp_endpoint}" + secretVars["GRAFANA_OTLP_HEADERS"] = "${secrets.grafana_otlp_headers}" + } + + return map[string]any{ + "image": azureCollectorImage, + "command": []any{"otelcol-contrib", "--config=env:OTELCOL_CONFIG"}, + "replicas": 1, + "ports": azureCollectorPorts(backends), + "env_vars": envVars, + "env_vars_secret": secretVars, + } +} + +func azureRequestedSignals(reqs []*pb.IaCRequirement) map[pb.TelemetrySignal]bool { + out := make(map[pb.TelemetrySignal]bool) + for _, req := range reqs { + for _, signal := range req.GetTelemetrySignals() { + if signal != pb.TelemetrySignal_TELEMETRY_SIGNAL_UNSPECIFIED { + out[signal] = true + } + } + } + if len(out) == 0 { + out[pb.TelemetrySignal_TELEMETRY_SIGNAL_TRACES] = true + out[pb.TelemetrySignal_TELEMETRY_SIGNAL_METRICS] = true + out[pb.TelemetrySignal_TELEMETRY_SIGNAL_LOGS] = true + } + return out +} + +func azureRequestedBackends(reqs []*pb.IaCRequirement) map[pb.ObservabilityBackend]bool { + out := make(map[pb.ObservabilityBackend]bool) + for _, req := range reqs { + for _, backend := range req.GetObservabilityBackends() { + if backend != pb.ObservabilityBackend_OBSERVABILITY_BACKEND_UNSPECIFIED { + out[backend] = true + } + } + } + if len(out) == 0 { + out[pb.ObservabilityBackend_OBSERVABILITY_BACKEND_OTEL] = true + } + return out +} + +func azureCollectorPorts(backends map[pb.ObservabilityBackend]bool) []any { + ports := []any{ + map[string]any{"port": 4317, "public": false}, + map[string]any{"port": 4318, "public": false}, + } + if azureHasBackend(backends, pb.ObservabilityBackend_OBSERVABILITY_BACKEND_PROMETHEUS) { + ports = append(ports, map[string]any{"port": 9464, "public": false}) + } + return ports +} + +func azureBuildCollectorConfig(signals map[pb.TelemetrySignal]bool, backends map[pb.ObservabilityBackend]bool) string { + var b strings.Builder + b.WriteString("receivers:\n") + b.WriteString(" otlp:\n") + b.WriteString(" protocols:\n") + b.WriteString(" grpc:\n") + b.WriteString(" endpoint: 0.0.0.0:4317\n") + b.WriteString(" http:\n") + b.WriteString(" endpoint: 0.0.0.0:4318\n") + b.WriteString("processors:\n") + b.WriteString(" batch: {}\n") + b.WriteString("exporters:\n") + azureWriteExporters(&b, backends) + b.WriteString("service:\n") + b.WriteString(" pipelines:\n") + if signals[pb.TelemetrySignal_TELEMETRY_SIGNAL_TRACES] { + azureWritePipeline(&b, "traces", azureExportersForSignal(pb.TelemetrySignal_TELEMETRY_SIGNAL_TRACES, backends)) + } + if signals[pb.TelemetrySignal_TELEMETRY_SIGNAL_METRICS] { + azureWritePipeline(&b, "metrics", azureExportersForSignal(pb.TelemetrySignal_TELEMETRY_SIGNAL_METRICS, backends)) + } + if signals[pb.TelemetrySignal_TELEMETRY_SIGNAL_LOGS] { + azureWritePipeline(&b, "logs", azureExportersForSignal(pb.TelemetrySignal_TELEMETRY_SIGNAL_LOGS, backends)) + } + return b.String() +} + +func azureWriteExporters(b *strings.Builder, backends map[pb.ObservabilityBackend]bool) { + if azureHasBackend(backends, pb.ObservabilityBackend_OBSERVABILITY_BACKEND_OTEL) { + b.WriteString(" otlp:\n") + b.WriteString(" endpoint: ${env:OTEL_EXPORTER_OTLP_ENDPOINT}\n") + } + if azureHasBackend(backends, pb.ObservabilityBackend_OBSERVABILITY_BACKEND_DATADOG) { + b.WriteString(" datadog:\n") + b.WriteString(" api:\n") + b.WriteString(" key: ${env:DD_API_KEY}\n") + b.WriteString(" site: ${env:DD_SITE}\n") + } + if azureHasBackend(backends, pb.ObservabilityBackend_OBSERVABILITY_BACKEND_PROMETHEUS) { + b.WriteString(" prometheus:\n") + b.WriteString(" endpoint: 0.0.0.0:9464\n") + } + if azureHasBackend(backends, pb.ObservabilityBackend_OBSERVABILITY_BACKEND_LOKI) { + b.WriteString(" loki:\n") + b.WriteString(" endpoint: ${env:LOKI_ENDPOINT}\n") + } + if azureHasBackend(backends, pb.ObservabilityBackend_OBSERVABILITY_BACKEND_GRAFANA) { + b.WriteString(" otlp/grafana_otlp:\n") + b.WriteString(" endpoint: ${env:GRAFANA_OTLP_ENDPOINT}\n") + b.WriteString(" headers:\n") + b.WriteString(" authorization: ${env:GRAFANA_OTLP_HEADERS}\n") + } +} + +func azureWritePipeline(b *strings.Builder, name string, exporters []string) { + if len(exporters) == 0 { + return + } + b.WriteString(" ") + b.WriteString(name) + b.WriteString(":\n") + b.WriteString(" receivers: [otlp]\n") + b.WriteString(" processors: [batch]\n") + b.WriteString(" exporters: [") + b.WriteString(strings.Join(exporters, ", ")) + b.WriteString("]\n") +} + +func azureExportersForSignal(signal pb.TelemetrySignal, backends map[pb.ObservabilityBackend]bool) []string { + var exporters []string + if azureHasBackend(backends, pb.ObservabilityBackend_OBSERVABILITY_BACKEND_OTEL) { + exporters = append(exporters, "otlp") + } + if azureHasBackend(backends, pb.ObservabilityBackend_OBSERVABILITY_BACKEND_DATADOG) { + exporters = append(exporters, "datadog") + } + if signal == pb.TelemetrySignal_TELEMETRY_SIGNAL_METRICS && + azureHasBackend(backends, pb.ObservabilityBackend_OBSERVABILITY_BACKEND_PROMETHEUS) { + exporters = append(exporters, "prometheus") + } + if signal == pb.TelemetrySignal_TELEMETRY_SIGNAL_LOGS && + azureHasBackend(backends, pb.ObservabilityBackend_OBSERVABILITY_BACKEND_LOKI) { + exporters = append(exporters, "loki") + } + if azureHasBackend(backends, pb.ObservabilityBackend_OBSERVABILITY_BACKEND_GRAFANA) { + exporters = append(exporters, "otlp/grafana_otlp") + } + sort.Strings(exporters) + return exporters +} + +func azureHasBackend(backends map[pb.ObservabilityBackend]bool, backend pb.ObservabilityBackend) bool { + return backends[backend] +} diff --git a/internal/iacserver_mapper_test.go b/internal/iacserver_mapper_test.go new file mode 100644 index 0000000..b39c2f4 --- /dev/null +++ b/internal/iacserver_mapper_test.go @@ -0,0 +1,220 @@ +package internal + +import ( + "context" + "encoding/json" + "net" + "os" + "path/filepath" + "strings" + "testing" + + pb "github.com/GoCodeAlone/workflow/plugin/external/proto" + sdk "github.com/GoCodeAlone/workflow/plugin/external/sdk" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/status" + "google.golang.org/grpc/test/bufconn" +) + +const mapperTestBufSize = 1024 * 1024 + +func TestAzureRequirementMapper_MapsObservabilityToCollector(t *testing.T) { + conn := newMapperTestConn(t) + client := pb.NewIaCProviderRequirementMapperClient(conn) + + resp, err := client.MapRequirements(context.Background(), &pb.MapRequirementsRequest{ + Provider: "azure", + Runtime: pb.RequirementRuntime_REQUIREMENT_RUNTIME_AZURE_CONTAINER_APPS, + Environment: "prod", + Requirements: []*pb.IaCRequirement{{ + Key: "observability.telemetry.default", + Kind: pb.RequirementKind_REQUIREMENT_KIND_OBSERVABILITY, + Runtimes: []pb.RequirementRuntime{ + pb.RequirementRuntime_REQUIREMENT_RUNTIME_AZURE_CONTAINER_APPS, + }, + TelemetrySignals: []pb.TelemetrySignal{ + pb.TelemetrySignal_TELEMETRY_SIGNAL_TRACES, + pb.TelemetrySignal_TELEMETRY_SIGNAL_METRICS, + pb.TelemetrySignal_TELEMETRY_SIGNAL_LOGS, + }, + ObservabilityBackends: []pb.ObservabilityBackend{ + pb.ObservabilityBackend_OBSERVABILITY_BACKEND_OTEL, + pb.ObservabilityBackend_OBSERVABILITY_BACKEND_DATADOG, + pb.ObservabilityBackend_OBSERVABILITY_BACKEND_PROMETHEUS, + pb.ObservabilityBackend_OBSERVABILITY_BACKEND_LOKI, + pb.ObservabilityBackend_OBSERVABILITY_BACKEND_GRAFANA, + }, + DeploymentModes: []pb.DeploymentMode{ + pb.DeploymentMode_DEPLOYMENT_MODE_SIDECAR, + pb.DeploymentMode_DEPLOYMENT_MODE_SIBLING_SERVICE, + }, + }}, + }) + if err != nil { + t.Fatalf("MapRequirements: %v", err) + } + if got := resp.GetAcceptedKeys(); len(got) != 1 || got[0] != "observability.telemetry.default" { + t.Fatalf("accepted_keys = %v, want [observability.telemetry.default]", got) + } + if len(resp.GetRejected()) != 0 { + t.Fatalf("rejected = %+v, want none", resp.GetRejected()) + } + if len(resp.GetModules()) != 1 { + t.Fatalf("modules len = %d, want 1", len(resp.GetModules())) + } + mod := resp.GetModules()[0] + if mod.GetName() != "observability-collector" { + t.Errorf("module name = %q, want observability-collector", mod.GetName()) + } + if mod.GetType() != "infra.container_service" { + t.Errorf("module type = %q, want infra.container_service", mod.GetType()) + } + if got := mod.GetSatisfies(); len(got) != 1 || got[0] != "observability.telemetry.default" { + t.Errorf("module satisfies = %v, want [observability.telemetry.default]", got) + } + + var cfg map[string]any + if err := json.Unmarshal(mod.GetConfigJson(), &cfg); err != nil { + t.Fatalf("config_json: %v", err) + } + if cfg["image"] != "otel/opentelemetry-collector-contrib:latest" { + t.Errorf("image = %v", cfg["image"]) + } + envVars, ok := cfg["env_vars"].(map[string]any) + if !ok { + t.Fatalf("env_vars missing or wrong type: %#v", cfg["env_vars"]) + } + collectorConfig, _ := envVars["OTELCOL_CONFIG"].(string) + for _, want := range []string{"otlp:", "datadog:", "prometheus:", "loki:", "grafana_otlp:", "traces:", "metrics:", "logs:"} { + if !strings.Contains(collectorConfig, want) { + t.Fatalf("collector config missing %q:\n%s", want, collectorConfig) + } + } + secretVars, ok := cfg["env_vars_secret"].(map[string]any) + if !ok { + t.Fatalf("env_vars_secret missing or wrong type: %#v", cfg["env_vars_secret"]) + } + if secretVars["DD_API_KEY"] != "${secrets.datadog_api_key}" { + t.Errorf("DD_API_KEY = %v", secretVars["DD_API_KEY"]) + } +} + +func TestAzureRequirementMapper_RejectsUnsupportedRuntime(t *testing.T) { + conn := newMapperTestConn(t) + client := pb.NewIaCProviderRequirementMapperClient(conn) + + resp, err := client.MapRequirements(context.Background(), &pb.MapRequirementsRequest{ + Provider: "azure", + Runtime: pb.RequirementRuntime_REQUIREMENT_RUNTIME_ECS, + Requirements: []*pb.IaCRequirement{{ + Key: "observability.telemetry.default", + Kind: pb.RequirementKind_REQUIREMENT_KIND_OBSERVABILITY, + }}, + }) + if err != nil { + t.Fatalf("MapRequirements: %v", err) + } + if len(resp.GetModules()) != 0 { + t.Fatalf("modules = %+v, want none", resp.GetModules()) + } + if got := resp.GetRejected(); len(got) != 1 || got[0].GetCode() != "unsupported_runtime" { + t.Fatalf("rejected = %+v, want unsupported_runtime", got) + } +} + +func TestAzureRequirementMapper_RejectsUnsupportedDeploymentMode(t *testing.T) { + conn := newMapperTestConn(t) + client := pb.NewIaCProviderRequirementMapperClient(conn) + + resp, err := client.MapRequirements(context.Background(), &pb.MapRequirementsRequest{ + Provider: "azure", + Runtime: pb.RequirementRuntime_REQUIREMENT_RUNTIME_AZURE_CONTAINER_APPS, + Requirements: []*pb.IaCRequirement{{ + Key: "observability.telemetry.default", + Kind: pb.RequirementKind_REQUIREMENT_KIND_OBSERVABILITY, + DeploymentModes: []pb.DeploymentMode{ + pb.DeploymentMode_DEPLOYMENT_MODE_DAEMONSET, + }, + }}, + }) + if err != nil { + t.Fatalf("MapRequirements: %v", err) + } + if len(resp.GetModules()) != 0 { + t.Fatalf("modules = %+v, want none", resp.GetModules()) + } + if got := resp.GetRejected(); len(got) != 1 || got[0].GetCode() != "unsupported_deployment_mode" { + t.Fatalf("rejected = %+v, want unsupported_deployment_mode", got) + } +} + +func TestAzureRequirementMapper_UnregisteredProviderName(t *testing.T) { + conn := newMapperTestConn(t) + client := pb.NewIaCProviderRequirementMapperClient(conn) + + _, err := client.MapRequirements(context.Background(), &pb.MapRequirementsRequest{ + Provider: "aws", + Requirements: []*pb.IaCRequirement{{ + Key: "observability.telemetry.default", + Kind: pb.RequirementKind_REQUIREMENT_KIND_OBSERVABILITY, + }}, + }) + if err == nil { + t.Fatal("MapRequirements: expected provider mismatch error") + } + if status.Code(err) != codes.InvalidArgument { + t.Fatalf("MapRequirements code = %v, want InvalidArgument; err=%v", status.Code(err), err) + } +} + +func TestPluginManifestAdvertisesRequirementMapper(t *testing.T) { + data, err := os.ReadFile(filepath.Join(hostConformanceRepoRoot(t), "plugin.json")) + if err != nil { + t.Fatalf("read plugin.json: %v", err) + } + var manifest struct { + MinEngineVersion string `json:"minEngineVersion"` + IaCServices []string `json:"iacServices"` + } + if err := json.Unmarshal(data, &manifest); err != nil { + t.Fatalf("parse plugin.json: %v", err) + } + if manifest.MinEngineVersion != "0.64.3" { + t.Fatalf("minEngineVersion = %q, want 0.64.3", manifest.MinEngineVersion) + } + const mapperService = "workflow.plugin.external.iac.IaCProviderRequirementMapper" + for _, svc := range manifest.IaCServices { + if svc == mapperService { + return + } + } + t.Fatalf("iacServices missing %s: %v", mapperService, manifest.IaCServices) +} + +func newMapperTestConn(t *testing.T) *grpc.ClientConn { + t.Helper() + + listener := bufconn.Listen(mapperTestBufSize) + t.Cleanup(func() { _ = listener.Close() }) + + server := grpc.NewServer() + if err := sdk.RegisterAllIaCProviderServices(server, newAzureIaCServer(New(Version))); err != nil { + t.Fatalf("RegisterAllIaCProviderServices: %v", err) + } + go func() { _ = server.Serve(listener) }() + t.Cleanup(server.Stop) + + conn, err := grpc.NewClient("passthrough:///bufnet", + grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) { + return listener.DialContext(ctx) + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + if err != nil { + t.Fatalf("grpc.NewClient: %v", err) + } + t.Cleanup(func() { _ = conn.Close() }) + return conn +} diff --git a/plugin.json b/plugin.json index 5f7b541..9c7bf0f 100644 --- a/plugin.json +++ b/plugin.json @@ -6,7 +6,7 @@ "license": "MIT", "type": "external", "tier": "community", - "minEngineVersion": "0.64.0", + "minEngineVersion": "0.64.3", "iacServices": [ "workflow.plugin.external.iac.IaCProviderRequired", "workflow.plugin.external.iac.IaCProviderEnumerator", @@ -15,6 +15,8 @@ "workflow.plugin.external.iac.IaCProviderMigrationRepairer", "workflow.plugin.external.iac.IaCProviderValidator", "workflow.plugin.external.iac.IaCProviderDriftConfigDetector", + "workflow.plugin.external.iac.IaCProviderRequirementMapper", + "workflow.plugin.external.iac.ResourceDriver", "workflow.plugin.external.iac.IaCStateBackend" ], "keywords": [ @@ -30,6 +32,28 @@ ], "homepage": "https://github.com/GoCodeAlone/workflow-plugin-azure", "repository": "https://github.com/GoCodeAlone/workflow-plugin-azure", + "downloads": [ + { + "os": "linux", + "arch": "amd64", + "url": "https://github.com/GoCodeAlone/workflow-plugin-azure/releases/download/v0.0.0/workflow-plugin-azure-linux-amd64.tar.gz" + }, + { + "os": "linux", + "arch": "arm64", + "url": "https://github.com/GoCodeAlone/workflow-plugin-azure/releases/download/v0.0.0/workflow-plugin-azure-linux-arm64.tar.gz" + }, + { + "os": "darwin", + "arch": "amd64", + "url": "https://github.com/GoCodeAlone/workflow-plugin-azure/releases/download/v0.0.0/workflow-plugin-azure-darwin-amd64.tar.gz" + }, + { + "os": "darwin", + "arch": "arm64", + "url": "https://github.com/GoCodeAlone/workflow-plugin-azure/releases/download/v0.0.0/workflow-plugin-azure-darwin-arm64.tar.gz" + } + ], "capabilities": { "configProvider": false, "moduleTypes": [