From bfdb51b4bc099e41f841016959d45ee7a74205f0 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 3 Jun 2026 04:03:46 -0400 Subject: [PATCH 1/2] feat: add ACI provider runner --- cmd/workflow-plugin-azure/plugin.json | 3 +- go.mod | 18 +- go.sum | 22 +++ internal/iacserver.go | 2 + internal/iacserver_mapper_test.go | 4 +- internal/provider.go | 11 ++ internal/runner.go | 264 ++++++++++++++++++++++++++ internal/runner_server.go | 108 +++++++++++ internal/runner_test.go | 163 ++++++++++++++++ plugin.json | 3 +- 10 files changed, 585 insertions(+), 13 deletions(-) create mode 100644 internal/runner.go create mode 100644 internal/runner_server.go create mode 100644 internal/runner_test.go diff --git a/cmd/workflow-plugin-azure/plugin.json b/cmd/workflow-plugin-azure/plugin.json index 9353251..b5210b6 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.69.1", + "minEngineVersion": "0.73.0", "iacServices": [ "workflow.plugin.external.iac.IaCProviderRequired", "workflow.plugin.external.iac.IaCProviderEnumerator", @@ -19,6 +19,7 @@ "workflow.plugin.external.iac.IaCProviderRegionLister", "workflow.plugin.external.iac.IaCProviderOwnership", "workflow.plugin.external.iac.ResourceDriver", + "workflow.plugin.external.iac.IaCProviderRunner", "workflow.plugin.external.iac.IaCStateBackend" ], "keywords": [ diff --git a/go.mod b/go.mod index 4efc803..acea7cf 100644 --- a/go.mod +++ b/go.mod @@ -18,7 +18,7 @@ require ( 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.69.7 + github.com/GoCodeAlone/workflow v0.73.0 google.golang.org/grpc v1.81.1 google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af ) @@ -182,24 +182,24 @@ require ( go.mongodb.org/mongo-driver v1.17.9 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect - go.opentelemetry.io/otel v1.43.0 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 // indirect - go.opentelemetry.io/otel/metric v1.43.0 // indirect - go.opentelemetry.io/otel/sdk v1.43.0 // indirect - go.opentelemetry.io/otel/trace v1.43.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/sdk v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.uber.org/atomic v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.28.0 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect golang.org/x/arch v0.27.0 // indirect - golang.org/x/crypto v0.51.0 // indirect + golang.org/x/crypto v0.52.0 // indirect golang.org/x/mod v0.36.0 // indirect golang.org/x/net v0.54.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.44.0 // indirect + golang.org/x/sys v0.45.0 // indirect golang.org/x/text v0.37.0 // indirect golang.org/x/time v0.15.0 // indirect golang.org/x/tools v0.45.0 // indirect @@ -207,8 +207,8 @@ require ( google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 // indirect google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - modernc.org/libc v1.70.0 // indirect + modernc.org/libc v1.72.3 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect - modernc.org/sqlite v1.47.0 // indirect + modernc.org/sqlite v1.51.0 // indirect ) diff --git a/go.sum b/go.sum index a3b4e84..d2548f6 100644 --- a/go.sum +++ b/go.sum @@ -74,6 +74,8 @@ github.com/GoCodeAlone/modular/modules/reverseproxy/v2 v2.10.0 h1:+2M/ecyCxDiXfJ github.com/GoCodeAlone/modular/modules/reverseproxy/v2 v2.10.0/go.mod h1:tlVH1mA5yuU8CB7R7+HXIRaBixZoNid6h+5tew5u3FU= 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/workflow v0.73.0 h1:hi8PuYujNhcMQ5H/+6e0nAPvmDdjPPzmX7nqnsMMm1k= +github.com/GoCodeAlone/workflow v0.73.0/go.mod h1:i/9ZTfR8YYlmr0+hvIZi4cYw7SxzkQRLlzYQCrRD/2k= 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= @@ -674,18 +676,27 @@ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8V go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo= go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak= go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= @@ -712,6 +723,8 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a h1:+3jdDGGB8NGb1Zktc737jlt3/A5f6UlwSzmvqUuufxw= golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= @@ -777,6 +790,8 @@ golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -856,8 +871,10 @@ k8s.io/utils v0.0.0-20260507154919-ff6756f316d2 h1:wU4tMEhLGgIbLvXQb1cfN+EcM0wf7 k8s.io/utils v0.0.0-20260507154919-ff6756f316d2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY= modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw= modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0= +modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ= modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= @@ -868,16 +885,21 @@ modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw= modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo= +modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU= +modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk= modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig= +modernc.org/sqlite v1.51.0 h1:aH/MMSoayAIhozZ7uJbVTT9QO/VhzBf0J9tymmmuC/U= +modernc.org/sqlite v1.51.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= diff --git a/internal/iacserver.go b/internal/iacserver.go index 3e1b60d..fb5f300 100644 --- a/internal/iacserver.go +++ b/internal/iacserver.go @@ -45,6 +45,7 @@ type azureIaCServer struct { pb.UnimplementedIaCProviderRequirementMapperServer pb.UnimplementedIaCProviderRegionListerServer pb.UnimplementedIaCProviderOwnershipServer + pb.UnimplementedIaCProviderRunnerServer pb.UnimplementedResourceDriverServer pb.UnimplementedIaCStateBackendServer @@ -85,6 +86,7 @@ var ( _ pb.IaCProviderRequirementMapperServer = (*azureIaCServer)(nil) _ pb.IaCProviderRegionListerServer = (*azureIaCServer)(nil) _ pb.IaCProviderOwnershipServer = (*azureIaCServer)(nil) + _ pb.IaCProviderRunnerServer = (*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 diff --git a/internal/iacserver_mapper_test.go b/internal/iacserver_mapper_test.go index 68620e5..2d2091c 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.69.1" { - t.Fatalf("minEngineVersion = %q, want 0.69.1", manifest.MinEngineVersion) + if manifest.MinEngineVersion != "0.73.0" { + t.Fatalf("minEngineVersion = %q, want 0.73.0", manifest.MinEngineVersion) } const mapperService = "workflow.plugin.external.iac.IaCProviderRequirementMapper" for _, svc := range manifest.IaCServices { diff --git a/internal/provider.go b/internal/provider.go index 87a9fe8..1462056 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/containerinstance/armcontainerinstance/v2" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources" "github.com/GoCodeAlone/workflow-plugin-azure/internal/driver" "github.com/GoCodeAlone/workflow/interfaces" @@ -27,6 +28,7 @@ type AzureProvider struct { location string credential azcore.TokenCredential drivers map[string]interfaces.ResourceDriver + runnerClient azureRunnerClient ownershipTags ownershipTagsClient ownershipResources ownershipResourcesClient @@ -92,8 +94,17 @@ func (p *AzureProvider) Initialize(ctx context.Context, config map[string]any) e if err != nil { return fmt.Errorf("azure: init drivers: %w", err) } + groupsClient, err := armcontainerinstance.NewContainerGroupsClient(subID, cred, nil) + if err != nil { + return fmt.Errorf("azure: init runner container groups client: %w", err) + } + containersClient, err := armcontainerinstance.NewContainersClient(subID, cred, nil) + if err != nil { + return fmt.Errorf("azure: init runner containers client: %w", err) + } p.credential = cred p.drivers = drivers + p.runnerClient = &realAzureRunnerClient{groups: groupsClient, containers: containersClient} p.ownershipTags = &azureOwnershipTagsClient{inner: tagsClient} p.ownershipResources = &azureOwnershipResourcesClient{inner: resourcesClient} return nil diff --git a/internal/runner.go b/internal/runner.go new file mode 100644 index 0000000..e99bbda --- /dev/null +++ b/internal/runner.go @@ -0,0 +1,264 @@ +package internal + +import ( + "context" + "fmt" + "regexp" + "sort" + "strings" + "time" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerinstance/armcontainerinstance/v2" + "github.com/GoCodeAlone/workflow/interfaces" +) + +type azureRunnerClient interface { + CreateOrUpdate(ctx context.Context, resourceGroup, name string, cg armcontainerinstance.ContainerGroup) (armcontainerinstance.ContainerGroup, error) + Get(ctx context.Context, resourceGroup, name string) (armcontainerinstance.ContainerGroup, error) + ListLogs(ctx context.Context, resourceGroup, containerGroup, container string, tail int32) (string, error) +} + +type realAzureRunnerClient struct { + groups *armcontainerinstance.ContainerGroupsClient + containers *armcontainerinstance.ContainersClient +} + +func (c *realAzureRunnerClient) CreateOrUpdate(ctx context.Context, rg, name string, cg armcontainerinstance.ContainerGroup) (armcontainerinstance.ContainerGroup, error) { + poller, err := c.groups.BeginCreateOrUpdate(ctx, rg, name, cg, nil) + if err != nil { + return armcontainerinstance.ContainerGroup{}, err + } + res, err := poller.PollUntilDone(ctx, &runtime.PollUntilDoneOptions{Frequency: 5 * time.Second}) + if err != nil { + return armcontainerinstance.ContainerGroup{}, err + } + return res.ContainerGroup, nil +} + +func (c *realAzureRunnerClient) Get(ctx context.Context, rg, name string) (armcontainerinstance.ContainerGroup, error) { + res, err := c.groups.Get(ctx, rg, name, nil) + if err != nil { + return armcontainerinstance.ContainerGroup{}, err + } + return res.ContainerGroup, nil +} + +func (c *realAzureRunnerClient) ListLogs(ctx context.Context, rg, group, container string, tail int32) (string, error) { + res, err := c.containers.ListLogs(ctx, rg, group, container, &armcontainerinstance.ContainersClientListLogsOptions{Tail: &tail}) + if err != nil { + return "", err + } + if res.Content == nil { + return "", nil + } + return *res.Content, nil +} + +var _ interfaces.IaCProviderRunner = (*AzureProvider)(nil) + +func (p *AzureProvider) RunJob(ctx context.Context, spec interfaces.JobSpec) (*interfaces.JobHandle, error) { + p.mu.RLock() + client := p.runnerClient + rg := p.resourceGroup + location := p.location + p.mu.RUnlock() + if client == nil || rg == "" || location == "" { + return nil, fmt.Errorf("azure runner: provider is not initialized") + } + if strings.TrimSpace(spec.Image) == "" { + return nil, fmt.Errorf("azure runner: image is required") + } + if strings.TrimSpace(spec.RunCommand) == "" { + return nil, fmt.Errorf("azure runner: run_command is required") + } + + name := azureJobName(spec.Name) + cg := armcontainerinstance.ContainerGroup{ + Location: azureString(location), + Properties: &armcontainerinstance.ContainerGroupPropertiesProperties{ + Containers: []*armcontainerinstance.Container{{ + Name: azureString(name), + Properties: &armcontainerinstance.ContainerProperties{ + Image: azureString(spec.Image), + Command: []*string{azureString("/bin/sh"), azureString("-c"), azureString(spec.RunCommand)}, + EnvironmentVariables: azureJobEnvironment(spec), + Resources: &armcontainerinstance.ResourceRequirements{ + Requests: &armcontainerinstance.ResourceRequests{ + CPU: azurePtr(float64(1)), + MemoryInGB: azurePtr(float64(1.5)), + }, + }, + }, + }}, + OSType: azurePtr(armcontainerinstance.OperatingSystemTypesLinux), + RestartPolicy: azurePtr(armcontainerinstance.ContainerGroupRestartPolicyNever), + }, + } + + created, err := client.CreateOrUpdate(ctx, rg, name, cg) + if err != nil { + return nil, fmt.Errorf("azure runner: create container group %q: %w", name, err) + } + id := azureStringVal(created.ID) + if id == "" { + id = name + } + return &interfaces.JobHandle{ + ID: id, + Name: name, + Provider: "azure", + Metadata: map[string]string{ + "resource_group": rg, + "container_group": name, + "container": name, + "location": location, + }, + }, nil +} + +func (p *AzureProvider) JobStatus(ctx context.Context, handle interfaces.JobHandle) (*interfaces.JobStatusReply, error) { + p.mu.RLock() + client := p.runnerClient + defaultRG := p.resourceGroup + p.mu.RUnlock() + if client == nil { + return nil, fmt.Errorf("azure runner: provider is not initialized") + } + rg := handle.Metadata["resource_group"] + if rg == "" { + rg = defaultRG + } + group := handle.Metadata["container_group"] + if group == "" { + group = handle.Name + } + if group == "" { + return nil, fmt.Errorf("azure runner: container_group metadata is required") + } + cg, err := client.Get(ctx, rg, group) + if err != nil { + return nil, fmt.Errorf("azure runner: get container group %q: %w", group, err) + } + state, exitCode, message := azureJobState(cg) + return &interfaces.JobStatusReply{Handle: handle, State: state, ExitCode: exitCode, Message: message}, nil +} + +func (p *AzureProvider) JobLogs(ctx context.Context, handle interfaces.JobHandle, sink interfaces.LogCaptureSink) error { + p.mu.RLock() + client := p.runnerClient + defaultRG := p.resourceGroup + p.mu.RUnlock() + if client == nil { + return fmt.Errorf("azure runner: provider is not initialized") + } + rg := handle.Metadata["resource_group"] + if rg == "" { + rg = defaultRG + } + group := handle.Metadata["container_group"] + container := handle.Metadata["container"] + if group == "" { + group = handle.Name + } + if container == "" { + container = group + } + if group == "" || container == "" { + return fmt.Errorf("azure runner: container_group and container metadata are required") + } + content, err := client.ListLogs(ctx, rg, group, container, 200) + if err != nil { + return fmt.Errorf("azure runner: list logs for %q: %w", group, err) + } + if sink == nil { + return nil + } + if content != "" { + if err := sink.WriteLogChunk(interfaces.LogChunk{Data: []byte(content), Source: "stdout"}); err != nil { + return err + } + } + return sink.WriteLogChunk(interfaces.LogChunk{EOF: true}) +} + +func azureJobEnvironment(spec interfaces.JobSpec) []*armcontainerinstance.EnvironmentVariable { + var out []*armcontainerinstance.EnvironmentVariable + for _, key := range sortedStringMapKeys(spec.EnvVars) { + out = append(out, &armcontainerinstance.EnvironmentVariable{Name: azureString(key), Value: azureString(spec.EnvVars[key])}) + } + for _, key := range sortedStringMapKeys(spec.EnvVarsSecret) { + out = append(out, &armcontainerinstance.EnvironmentVariable{Name: azureString(key), SecureValue: azureString(spec.EnvVarsSecret[key])}) + } + return out +} + +func sortedStringMapKeys(values map[string]string) []string { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + +var nonAzureJobName = regexp.MustCompile(`[^a-z0-9-]+`) + +func azureJobName(name string) string { + name = strings.ToLower(strings.TrimSpace(name)) + name = nonAzureJobName.ReplaceAllString(name, "-") + name = strings.Trim(name, "-") + if name == "" { + name = "provider-ephemeral-job" + } + if len(name) > 63 { + name = strings.TrimRight(name[:63], "-") + } + return name +} + +func azureJobState(cg armcontainerinstance.ContainerGroup) (interfaces.JobState, int, string) { + if cg.Properties == nil || len(cg.Properties.Containers) == 0 || cg.Properties.Containers[0].Properties == nil || + cg.Properties.Containers[0].Properties.InstanceView == nil || + cg.Properties.Containers[0].Properties.InstanceView.CurrentState == nil { + return interfaces.JobStatePending, 0, azureProvisioningState(cg) + } + current := cg.Properties.Containers[0].Properties.InstanceView.CurrentState + state := strings.ToLower(azureStringVal(current.State)) + exit := 0 + if current.ExitCode != nil { + exit = int(*current.ExitCode) + } + msg := azureStringVal(current.DetailStatus) + switch state { + case "running": + return interfaces.JobStateRunning, exit, msg + case "terminated": + if exit == 0 { + return interfaces.JobStateSucceeded, exit, msg + } + return interfaces.JobStateFailed, exit, msg + case "waiting": + return interfaces.JobStatePending, exit, msg + default: + return interfaces.JobStateUnknown, exit, msg + } +} + +func azureProvisioningState(cg armcontainerinstance.ContainerGroup) string { + if cg.Properties == nil { + return "" + } + return azureStringVal(cg.Properties.ProvisioningState) +} + +func azureString(s string) *string { return &s } + +func azureStringVal(s *string) string { + if s == nil { + return "" + } + return *s +} + +func azurePtr[T any](v T) *T { return &v } diff --git a/internal/runner_server.go b/internal/runner_server.go new file mode 100644 index 0000000..a86464f --- /dev/null +++ b/internal/runner_server.go @@ -0,0 +1,108 @@ +package internal + +import ( + "context" + + "github.com/GoCodeAlone/workflow/interfaces" + pb "github.com/GoCodeAlone/workflow/plugin/external/proto" +) + +func (s *azureIaCServer) RunJob(ctx context.Context, req *pb.JobSpec) (*pb.JobHandle, error) { + handle, err := s.provider.RunJob(ctx, jobSpecFromPB(req)) + if err != nil { + return nil, err + } + return jobHandleToPB(handle), nil +} + +func (s *azureIaCServer) JobStatus(ctx context.Context, req *pb.JobHandle) (*pb.JobStatusReply, error) { + reply, err := s.provider.JobStatus(ctx, jobHandleFromPB(req)) + if err != nil { + return nil, err + } + return jobStatusToPB(reply), nil +} + +func (s *azureIaCServer) JobLogs(req *pb.JobHandle, stream pb.IaCProviderRunner_JobLogsServer) error { + return s.provider.JobLogs(stream.Context(), jobHandleFromPB(req), azureRunnerLogSink{stream: stream}) +} + +type azureRunnerLogSink struct { + stream pb.IaCProviderRunner_JobLogsServer +} + +func (s azureRunnerLogSink) WriteLogChunk(chunk interfaces.LogChunk) error { + return s.stream.Send(&pb.LogChunk{ + Data: append([]byte(nil), chunk.Data...), + Source: chunk.Source, + Eof: chunk.EOF, + }) +} + +func jobSpecFromPB(req *pb.JobSpec) interfaces.JobSpec { + if req == nil { + return interfaces.JobSpec{} + } + return interfaces.JobSpec{ + Name: req.GetName(), + Kind: req.GetKind(), + Image: req.GetImage(), + RunCommand: req.GetRunCommand(), + EnvVars: copyStringMap(req.GetEnvVars()), + EnvVarsSecret: copyStringMap(req.GetEnvVarsSecret()), + Cron: req.GetCron(), + } +} + +func jobHandleFromPB(req *pb.JobHandle) interfaces.JobHandle { + if req == nil { + return interfaces.JobHandle{} + } + return interfaces.JobHandle{ + ID: req.GetId(), + Name: req.GetName(), + Provider: req.GetProvider(), + Metadata: copyStringMap(req.GetMetadata()), + } +} + +func jobHandleToPB(handle *interfaces.JobHandle) *pb.JobHandle { + if handle == nil { + return nil + } + return &pb.JobHandle{ + Id: handle.ID, + Name: handle.Name, + Provider: handle.Provider, + Metadata: copyStringMap(handle.Metadata), + } +} + +func jobStatusToPB(reply *interfaces.JobStatusReply) *pb.JobStatusReply { + if reply == nil { + return nil + } + return &pb.JobStatusReply{ + Handle: jobHandleToPB(&reply.Handle), + State: jobStateToPB(reply.State), + ExitCode: int32(reply.ExitCode), + Message: reply.Message, + } +} + +func jobStateToPB(state interfaces.JobState) pb.JobState { + switch state { + case interfaces.JobStatePending: + return pb.JobState_JOB_STATE_PENDING + case interfaces.JobStateRunning: + return pb.JobState_JOB_STATE_RUNNING + case interfaces.JobStateSucceeded: + return pb.JobState_JOB_STATE_SUCCEEDED + case interfaces.JobStateFailed: + return pb.JobState_JOB_STATE_FAILED + case interfaces.JobStateCancelled: + return pb.JobState_JOB_STATE_CANCELLED + default: + return pb.JobState_JOB_STATE_UNSPECIFIED + } +} diff --git a/internal/runner_test.go b/internal/runner_test.go new file mode 100644 index 0000000..9aedb90 --- /dev/null +++ b/internal/runner_test.go @@ -0,0 +1,163 @@ +package internal + +import ( + "context" + "strings" + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerinstance/armcontainerinstance/v2" + "github.com/GoCodeAlone/workflow/interfaces" + pb "github.com/GoCodeAlone/workflow/plugin/external/proto" + "github.com/GoCodeAlone/workflow/plugin/external/sdk" + "google.golang.org/grpc" +) + +type fakeAzureRunnerClient struct { + createdName string + createdRG string + createdCG armcontainerinstance.ContainerGroup + gotGroup armcontainerinstance.ContainerGroup + logs string +} + +func (f *fakeAzureRunnerClient) CreateOrUpdate(_ context.Context, rg, name string, cg armcontainerinstance.ContainerGroup) (armcontainerinstance.ContainerGroup, error) { + f.createdRG = rg + f.createdName = name + f.createdCG = cg + cg.ID = azureString("/subscriptions/sub/resourceGroups/" + rg + "/providers/Microsoft.ContainerInstance/containerGroups/" + name) + return cg, nil +} + +func (f *fakeAzureRunnerClient) Get(_ context.Context, _, _ string) (armcontainerinstance.ContainerGroup, error) { + return f.gotGroup, nil +} + +func (f *fakeAzureRunnerClient) ListLogs(_ context.Context, _, _, _ string, _ int32) (string, error) { + return f.logs, nil +} + +func TestAzureRunner_RunJobCreatesNeverRestartContainerGroup(t *testing.T) { + client := &fakeAzureRunnerClient{} + p := &AzureProvider{ + resourceGroup: "rg", + location: "eastus", + runnerClient: client, + } + + handle, err := p.RunJob(context.Background(), interfaces.JobSpec{ + Name: "Migrate DB!", + Image: "example.azurecr.io/app:migrate", + RunCommand: "bin/migrate up", + EnvVars: map[string]string{"PLAIN": "value"}, + EnvVarsSecret: map[string]string{"DATABASE_URL": "secret://database/url"}, + }) + if err != nil { + t.Fatalf("RunJob returned error: %v", err) + } + if handle.Provider != "azure" || handle.Metadata["container_group"] != "migrate-db" { + t.Fatalf("handle = %+v", handle) + } + if client.createdRG != "rg" || client.createdName != "migrate-db" { + t.Fatalf("created %s/%s, want rg/migrate-db", client.createdRG, client.createdName) + } + props := client.createdCG.Properties + if props == nil || props.RestartPolicy == nil || *props.RestartPolicy != armcontainerinstance.ContainerGroupRestartPolicyNever { + t.Fatalf("restart policy = %#v, want Never", props) + } + container := props.Containers[0] + if got := azureStringVal(container.Properties.Image); got != "example.azurecr.io/app:migrate" { + t.Fatalf("image = %q", got) + } + cmd := container.Properties.Command + if len(cmd) != 3 || azureStringVal(cmd[0]) != "/bin/sh" || azureStringVal(cmd[1]) != "-c" || azureStringVal(cmd[2]) != "bin/migrate up" { + t.Fatalf("command = %#v", cmd) + } + if !hasAzureEnv(container.Properties.EnvironmentVariables, "PLAIN", "value", false) { + t.Fatalf("missing plain env: %#v", container.Properties.EnvironmentVariables) + } + if !hasAzureEnv(container.Properties.EnvironmentVariables, "DATABASE_URL", "secret://database/url", true) { + t.Fatalf("missing secure env: %#v", container.Properties.EnvironmentVariables) + } +} + +func TestAzureRunner_StatusAndLogs(t *testing.T) { + exit := int32(7) + state := "Terminated" + detail := "failed" + client := &fakeAzureRunnerClient{ + logs: "migration failed\n", + gotGroup: armcontainerinstance.ContainerGroup{ + Properties: &armcontainerinstance.ContainerGroupPropertiesProperties{ + Containers: []*armcontainerinstance.Container{{ + Properties: &armcontainerinstance.ContainerProperties{ + InstanceView: &armcontainerinstance.ContainerPropertiesInstanceView{ + CurrentState: &armcontainerinstance.ContainerState{ + State: &state, + ExitCode: &exit, + DetailStatus: &detail, + }, + }, + }, + }}, + }, + }, + } + p := &AzureProvider{resourceGroup: "rg", runnerClient: client} + handle := interfaces.JobHandle{Name: "job", Metadata: map[string]string{"container_group": "job", "container": "job"}} + + status, err := p.JobStatus(context.Background(), handle) + if err != nil { + t.Fatalf("JobStatus returned error: %v", err) + } + if status.State != interfaces.JobStateFailed || status.ExitCode != 7 || status.Message != "failed" { + t.Fatalf("status = %+v", status) + } + + sink := &runnerSink{} + if err := p.JobLogs(context.Background(), handle, sink); err != nil { + t.Fatalf("JobLogs returned error: %v", err) + } + if string(sink.data) != "migration failed\n" || !sink.eof { + t.Fatalf("sink data=%q eof=%v", string(sink.data), sink.eof) + } +} + +func TestAzureRunnerServerAutoRegisters(t *testing.T) { + server := grpc.NewServer() + if err := sdk.RegisterAllIaCProviderServices(server, newAzureIaCServer(New(Version))); err != nil { + t.Fatalf("RegisterAllIaCProviderServices: %v", err) + } + if _, ok := server.GetServiceInfo()[pb.IaCProviderRunner_ServiceDesc.ServiceName]; !ok { + t.Fatalf("IaCProviderRunner service was not registered") + } +} + +func hasAzureEnv(values []*armcontainerinstance.EnvironmentVariable, key, value string, secure bool) bool { + for _, env := range values { + if azureStringVal(env.Name) != key { + continue + } + if secure { + return azureStringVal(env.SecureValue) == value && env.Value == nil + } + return azureStringVal(env.Value) == value && env.SecureValue == nil + } + return false +} + +type runnerSink struct { + data []byte + eof bool +} + +func (s *runnerSink) WriteLogChunk(chunk interfaces.LogChunk) error { + if chunk.EOF { + s.eof = true + return nil + } + if strings.Contains(strings.ToLower(chunk.Source), "stderr") { + return nil + } + s.data = append(s.data, chunk.Data...) + return nil +} diff --git a/plugin.json b/plugin.json index f8930b3..572baf7 100644 --- a/plugin.json +++ b/plugin.json @@ -6,7 +6,7 @@ "license": "MIT", "type": "external", "tier": "community", - "minEngineVersion": "0.69.1", + "minEngineVersion": "0.73.0", "required_secrets": [ { "name": "AZURE_CLIENT_ID", @@ -45,6 +45,7 @@ "workflow.plugin.external.iac.IaCProviderRegionLister", "workflow.plugin.external.iac.IaCProviderOwnership", "workflow.plugin.external.iac.ResourceDriver", + "workflow.plugin.external.iac.IaCProviderRunner", "workflow.plugin.external.iac.IaCStateBackend" ], "keywords": [ From 723385da974cd92953ce200b62b5d5af33b18472 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 3 Jun 2026 04:12:12 -0400 Subject: [PATCH 2/2] fix: make ACI runner job names unique --- internal/runner.go | 8 +++++--- internal/runner_test.go | 6 +++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/internal/runner.go b/internal/runner.go index e99bbda..60a343a 100644 --- a/internal/runner.go +++ b/internal/runner.go @@ -211,10 +211,12 @@ func azureJobName(name string) string { if name == "" { name = "provider-ephemeral-job" } - if len(name) > 63 { - name = strings.TrimRight(name[:63], "-") + suffix := fmt.Sprintf("-%d", time.Now().UnixNano()) + maxBase := 63 - len(suffix) + if len(name) > maxBase { + name = strings.TrimRight(name[:maxBase], "-") } - return name + return name + suffix } func azureJobState(cg armcontainerinstance.ContainerGroup) (interfaces.JobState, int, string) { diff --git a/internal/runner_test.go b/internal/runner_test.go index 9aedb90..0dfe973 100644 --- a/internal/runner_test.go +++ b/internal/runner_test.go @@ -54,11 +54,11 @@ func TestAzureRunner_RunJobCreatesNeverRestartContainerGroup(t *testing.T) { if err != nil { t.Fatalf("RunJob returned error: %v", err) } - if handle.Provider != "azure" || handle.Metadata["container_group"] != "migrate-db" { + if handle.Provider != "azure" || !strings.HasPrefix(handle.Metadata["container_group"], "migrate-db-") { t.Fatalf("handle = %+v", handle) } - if client.createdRG != "rg" || client.createdName != "migrate-db" { - t.Fatalf("created %s/%s, want rg/migrate-db", client.createdRG, client.createdName) + if client.createdRG != "rg" || !strings.HasPrefix(client.createdName, "migrate-db-") { + t.Fatalf("created %s/%s, want rg/migrate-db-*", client.createdRG, client.createdName) } props := client.createdCG.Properties if props == nil || props.RestartPolicy == nil || *props.RestartPolicy != armcontainerinstance.ContainerGroupRestartPolicyNever {