diff --git a/flake.nix b/flake.nix index 214329cd9..6fd14dfe5 100644 --- a/flake.nix +++ b/flake.nix @@ -17,17 +17,19 @@ with pkgs; mkShell { buildInputs = [ + azure-cli actionlint bashInteractive # full bash with readline/completion so prompts render correctly crane git gnumake - less gnused # force Linux `sed` everywhere go_1_24 # must match GO_VERSION in Dockerfile - gopls golangci-lint + google-cloud-sdk + gopls goreleaser + less nixfmt-rfc-style nodejs_24 # for Pulumi, must match values in package.json openssh @@ -36,8 +38,8 @@ protoc-gen-go protolint pulumi + pulumiPackages.pulumi-go pulumiPackages.pulumi-nodejs - google-cloud-sdk vim ]; shellHook = '' diff --git a/pkgs/defang/cli.nix b/pkgs/defang/cli.nix index 1fba07f07..9f5c977fd 100644 --- a/pkgs/defang/cli.nix +++ b/pkgs/defang/cli.nix @@ -7,7 +7,7 @@ buildGo124Module { pname = "defang-cli"; version = "git"; src = lib.cleanSource ../../src; - vendorHash = "sha256-G23v/mmyRRY2Xqq8N7knKcL4ucfBSuhgvttJ5pRKN/U="; + vendorHash = "sha256-Ch8swBZtZFjj03fx7oC4N7aoHBUI4lQ1tJphpW6N3w8="; subPackages = [ "cmd/cli" ]; diff --git a/src/cmd/cli/command/estimate.go b/src/cmd/cli/command/estimate.go index c81911c30..9a5fdb364 100644 --- a/src/cmd/cli/command/estimate.go +++ b/src/cmd/cli/command/estimate.go @@ -85,11 +85,17 @@ func interactiveSelectProvider(providers []client.ProviderID) (client.ProviderID } // Default to the provider in the environment if available var defaultOption any // not string! - if pkg.AwsInEnv() != "" { + switch { + case pkg.AwsInEnv() != "": defaultOption = client.ProviderAWS.String() - } else if pkg.GcpInEnv() != "" { + case pkg.AzureInEnv() != "": + defaultOption = client.ProviderAzure.String() + case pkg.DoInEnv() != "": + defaultOption = client.ProviderDO.String() + case pkg.GcpInEnv() != "": defaultOption = client.ProviderGCP.String() } + var optionValue string if err := survey.AskOne(&survey.Select{ Default: defaultOption, diff --git a/src/go.mod b/src/go.mod index f34dd1dcf..b5d354856 100644 --- a/src/go.mod +++ b/src/go.mod @@ -20,6 +20,18 @@ require ( cloud.google.com/go/storage v1.50.0 connectrpc.com/connect v1.19.1 github.com/AlecAivazis/survey/v2 v2.3.7 + github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 + github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 + github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig v1.2.0 + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appconfiguration/armappconfiguration v1.1.1 + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appcontainers/armappcontainers v1.1.0 + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v3 v3.0.0-beta.2 + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerinstance/armcontainerinstance/v2 v2.4.0 + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerregistry/armcontainerregistry/v2 v2.0.0 + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/msi/armmsi v1.3.0 + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v2 v2.1.0 + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage/v2 v2.0.0 + github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4 github.com/DefangLabs/secret-detector v0.0.0-20250811234530-d4b4214cd679 github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 github.com/aws/aws-sdk-go-v2 v1.41.5 @@ -41,7 +53,7 @@ require ( github.com/digitalocean/godo v1.131.1 github.com/docker/cli v29.2.0+incompatible github.com/firebase/genkit/go v1.2.0 - github.com/golang-jwt/jwt/v5 v5.2.2 + github.com/golang-jwt/jwt/v5 v5.3.0 github.com/google/uuid v1.6.0 github.com/googleapis/gax-go/v2 v2.14.2 github.com/gorilla/websocket v1.5.3 @@ -81,6 +93,8 @@ require ( cloud.google.com/go/compute/metadata v0.9.0 // indirect cloud.google.com/go/longrunning v0.6.7 // indirect cloud.google.com/go/monitoring v1.24.2 // indirect + github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect + github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.52.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.52.0 // indirect @@ -110,6 +124,7 @@ require ( github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/inhies/go-bytesize v0.0.0-20220417184213-4913239db9cf // indirect github.com/invopop/jsonschema v0.13.0 // indirect + github.com/kylelemons/godebug v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mailru/easyjson v0.9.0 // indirect github.com/mattn/go-runewidth v0.0.14 // indirect diff --git a/src/go.sum b/src/go.sum index d3a23d1c7..151edb885 100644 --- a/src/go.sum +++ b/src/go.sum @@ -34,6 +34,46 @@ connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14= connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w= github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ= github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 h1:JXg2dwJUmPB9JmtVmdEB16APJ7jurfbY5jnfXpJoRMc= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0/go.mod h1:YD5h/ldMsG0XiIw7PdyNhLxaM317eFh5yNLccNfGdyw= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0= +github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY= +github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2/go.mod h1:Pa9ZNPuoNu/GztvBSKk9J1cDJW6vk/n0zLtV4mgd8N8= +github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig v1.2.0 h1:uU4FujKFQAz31AbWOO3INV9qfIanHeIUSsGhRlcJJmg= +github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig v1.2.0/go.mod h1:qr3M3Oy6V98VR0c5tCHKUpaeJTRQh6KYzJewRtFWqfc= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appconfiguration/armappconfiguration v1.1.1 h1:iRc20pGuVlc1HwRO2bg0m1tfP9rkPB0K88trl8Fei2w= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appconfiguration/armappconfiguration v1.1.1/go.mod h1:21Lewei+tg5zp5xmyOxfDY//2tBvWQXee0UoM8xZjr8= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appcontainers/armappcontainers v1.1.0 h1:fdAOz6TFldGDoEcRa975i5L5QvWU8ptut+SJAIfuWUY= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appcontainers/armappcontainers v1.1.0/go.mod h1:qV+BWew22CAalRTwJEAHs+aSLP49k/csNlspqhMIDRU= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v3 v3.0.0-beta.2 h1:qiir/pptnHqp6hV8QwV+IExYIf6cPsXBfUDUXQ27t2Y= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v3 v3.0.0-beta.2/go.mod h1:jVRrRDLCOuif95HDYC23ADTMlvahB7tMdl519m9Iyjc= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerinstance/armcontainerinstance/v2 v2.4.0 h1:+dIXMjlifRbG3d01DF8dwckUSXADuW5dgBNt1fbkpv0= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerinstance/armcontainerinstance/v2 v2.4.0/go.mod h1:FN0UJ15tJ7kV7JYrYAleEq44Ew1cUiyLcJrfrTxHGd0= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerregistry/armcontainerregistry/v2 v2.0.0 h1:1a20YdnQEjzrrKfAXXMY8pKFvVkIDQqHGryKqC0dnuk= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerregistry/armcontainerregistry/v2 v2.0.0/go.mod h1:BVagqxlJtc2zcpd4VenwKpC/ADV23xH34AxBfChVXcc= +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.2.0 h1:akP6VpxJGgQRpDR1P462piz/8OhYLRCreDj48AyNabc= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/managementgroups/armmanagementgroups v1.2.0/go.mod h1:8wzvopPfyZYPaQUoKW87Zfdul7jmJMDfp/k7YY3oJyA= +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/resources/armresources v1.2.0 h1:Dd+RhdJn0OTtVGaeDLZpcumkIVCtA/3/Fo42+eoYvVM= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0/go.mod h1:5kakwfW5CjC9KK+Q4wjXAg+ShuIm2mBMua0ZFj2C8PE= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v2 v2.1.0 h1:seyVIpxalxYmfjoo8MB4rRzWaobMG+KJ2+MAUrEvDGU= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v2 v2.1.0/go.mod h1:M3QD7IyKZBaC4uAKjitTOSOXdcPC6JS1A9oOW3hYjbQ= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1 h1:/Zt+cDPnpC3OVDm/JKLOs7M2DKmLRIIp3XIx9pHHiig= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1/go.mod h1:Ng3urmn6dYe8gnbCMoHHVl5APYz2txho3koEkV2o2HA= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage/v2 v2.0.0 h1:+vh02EiRx2UmL9NDoA36U18Bgwl9luxs6ia0GAI9Rzg= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage/v2 v2.0.0/go.mod h1:iKOtU3WyuNvNc4L1Z4IxHaoO0dGq5tg+uhLix/KRmzE= +github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4 h1:jWQK1GI+LeGGUKBADtcH2rRqPxYB1Ljwms5gFA2LqrM= +github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4/go.mod h1:8mwH4klAm9DUgR2EEHyEEAQlRDvLPyg5fQry3y+cDew= +github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM= +github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE= +github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs= +github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= github.com/DefangLabs/cobra v1.8.0-defang h1:rTzAg1XbEk3yXUmQPumcwkLgi8iNCby5CjyG3sCwzKk= github.com/DefangLabs/cobra v1.8.0-defang/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= github.com/DefangLabs/secret-detector v0.0.0-20250811234530-d4b4214cd679 h1:qNT7R4qrN+5u5ajSbqSW1opHP4LA8lzA+ASyw5MQZjs= @@ -176,8 +216,8 @@ github.com/goccy/go-yaml v1.17.1 h1:LI34wktB2xEE3ONG/2Ar54+/HJVBriAGJ55PHls4YuY= github.com/goccy/go-yaml v1.17.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= -github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/dotprompt/go v0.0.0-20251014011017-8d056e027254 h1:okN800+zMJOGHLJCgry+OGzhhtH6YrjQh1rluHmOacE= @@ -228,6 +268,8 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= +github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= +github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= @@ -237,6 +279,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= diff --git a/src/pkg/agent/tools/estimate_test.go b/src/pkg/agent/tools/estimate_test.go index f1d4e4b6a..65807058e 100644 --- a/src/pkg/agent/tools/estimate_test.go +++ b/src/pkg/agent/tools/estimate_test.go @@ -132,7 +132,7 @@ func TestHandleEstimateTool(t *testing.T) { setupMock: func(m *MockEstimateCLI) { m.Project = &compose.Project{Name: "test-project"} }, - expectedError: "invalid provider: \"invalid-provider\", not one of [auto defang aws digitalocean gcp]", + expectedError: "invalid provider: \"invalid-provider\", not one of [auto defang aws digitalocean gcp azure]", }, { name: "run_estimate_error", diff --git a/src/pkg/cli/client/byoc/aws/byoc.go b/src/pkg/cli/client/byoc/aws/byoc.go index d8a8ac5a1..d6a8aa915 100644 --- a/src/pkg/cli/client/byoc/aws/byoc.go +++ b/src/pkg/cli/client/byoc/aws/byoc.go @@ -476,6 +476,8 @@ func (b *ByocAws) bucketName() string { func (b *ByocAws) environment(projectName string) (map[string]string, error) { region := b.driver.Region // TODO: this should be the destination region, not the CD region; make customizable + + // From https://www.pulumi.com/docs/iac/concepts/state-and-backends/#aws-s3 defangStateUrl := fmt.Sprintf(`s3://%s?region=%s&awssdk=v2`, b.bucketName(), region) pulumiBackendKey, pulumiBackendValue, err := byoc.GetPulumiBackend(defangStateUrl) if err != nil { diff --git a/src/pkg/cli/client/byoc/azure/byoc.go b/src/pkg/cli/client/byoc/azure/byoc.go new file mode 100644 index 000000000..0243cf489 --- /dev/null +++ b/src/pkg/cli/client/byoc/azure/byoc.go @@ -0,0 +1,574 @@ +package azure + +import ( + "bytes" + "context" + "encoding/base64" + "errors" + "fmt" + "io" + "iter" + "net/http" + "os" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerinstance/armcontainerinstance/v2" + "github.com/DefangLabs/defang/src/pkg" + "github.com/DefangLabs/defang/src/pkg/cli/client" + "github.com/DefangLabs/defang/src/pkg/cli/client/byoc" + "github.com/DefangLabs/defang/src/pkg/cli/client/byoc/state" + "github.com/DefangLabs/defang/src/pkg/cli/compose" + cloudazure "github.com/DefangLabs/defang/src/pkg/clouds/azure" + "github.com/DefangLabs/defang/src/pkg/clouds/azure/aca" + "github.com/DefangLabs/defang/src/pkg/clouds/azure/aci" + "github.com/DefangLabs/defang/src/pkg/clouds/azure/appcfg" + defanghttp "github.com/DefangLabs/defang/src/pkg/http" + "github.com/DefangLabs/defang/src/pkg/term" + "github.com/DefangLabs/defang/src/pkg/types" + defangv1 "github.com/DefangLabs/defang/src/protos/io/defang/v1" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/timestamppb" +) + +type ByocAzure struct { + *byoc.ByocBaseClient + + driver *aci.ContainerInstance + appCfg *appcfg.AppConfiguration + cdContainerGroup aci.ContainerGroupName + cdDeploymentID string +} + +var _ client.Provider = (*ByocAzure)(nil) + +func NewByocProvider(ctx context.Context, tenantLabel types.TenantLabel, stack string) *ByocAzure { + b := &ByocAzure{ + driver: aci.NewContainerInstance("defang-cd", ""), // default location => from AZURE_LOCATION env var + } + b.ByocBaseClient = byoc.NewByocBaseClient(tenantLabel, b, stack) + return b +} + +func (b *ByocAzure) Driver() string { + return "azure" +} + +// SetUpCD implements client.Provider. +func (b *ByocAzure) SetUpCD(context.Context, bool) error { + // return fmt.Errorf("SetUpCD: %w", errors.ErrUnsupported) + term.Debugf("SetUpCD: no-op for Azure; CD environment will be set up on demand during Deploy") + return nil +} + +// CdCommand implements byoc.ProjectBackend. +func (b *ByocAzure) CdCommand(ctx context.Context, req client.CdCommandRequest) (*client.CdCommandResponse, error) { + if err := b.setUp(ctx); err != nil { + return nil, err + } + etag := pkg.RandomID() + envMap, err := b.buildCdEnv(req.Project) + if err != nil { + return nil, err + } + containers := b.cdContainers() + taskID, err := b.driver.Run(ctx, containers, envMap, "/app/cd", string(req.Command)) + if err != nil { + return nil, err + } + b.cdContainerGroup = taskID + b.cdDeploymentID = etag + return &client.CdCommandResponse{ + CdId: *taskID, + CdType: defangv1.CdType_CD_TYPE_AZURE_ACI_JOBID, + ETag: etag, + }, nil +} + +// CdList implements byoc.ProjectBackend. +func (b *ByocAzure) CdList(ctx context.Context, _ bool) (iter.Seq[state.Info], error) { + if err := b.setUp(ctx); err != nil { + return nil, err + } + + blobs, err := b.driver.IterateBlobs(ctx, ".pulumi/stacks/") + if err != nil { + return nil, err + } + + return func(yield func(state.Info) bool) { + for item, err := range blobs { + if err != nil { + term.Debugf("Error iterating blobs: %v", err) + return + } + st, err := state.ParsePulumiStateFile(ctx, item, b.driver.BlobContainerName, func(ctx context.Context, _, blobName string) ([]byte, error) { + return b.driver.DownloadBlob(ctx, blobName) + }) + if err != nil { + term.Debugf("Skipping %q: %v", item.Name(), err) + continue + } + if st == nil { + continue + } + if !yield(state.Info{ + Project: st.Project, + Stack: st.Name, + Workspace: string(st.Workspace), + CdRegion: b.driver.Location.String(), + }) { + return + } + } + }, nil +} + +// AccountInfo implements client.Provider. +func (b *ByocAzure) AccountInfo(context.Context) (*client.AccountInfo, error) { + return &client.AccountInfo{ + AccountID: b.driver.SubscriptionID, + Provider: client.ProviderAzure, + Region: b.driver.Location.String(), + }, nil +} + +// CreateUploadURL implements client.Provider. +func (b *ByocAzure) CreateUploadURL(ctx context.Context, req *defangv1.UploadURLRequest) (*defangv1.UploadURLResponse, error) { + if err := b.setUp(ctx); err != nil { + return nil, err + } + + url, err := b.driver.CreateUploadURL(ctx, req.Digest) + if err != nil { + return nil, err + } + + return &defangv1.UploadURLResponse{ + Url: url, + }, nil +} + +// Delete implements client.Provider. +func (b *ByocAzure) Delete(context.Context, *defangv1.DeleteRequest) (*defangv1.DeleteResponse, error) { + return nil, fmt.Errorf("Delete: %w", errors.ErrUnsupported) +} + +// DeleteConfig implements client.Provider. +func (b *ByocAzure) DeleteConfig(ctx context.Context, secrets *defangv1.Secrets) error { + if err := b.setUp(ctx); err != nil { + return err + } + for _, name := range secrets.Names { + key := b.StackDir(secrets.Project, name) + term.Debugf("Deleting App Configuration key %q", key) + if err := b.appCfg.DeleteSetting(ctx, key); err != nil { + return fmt.Errorf("failed to delete config %q: %w", name, err) + } + } + return nil +} + +func (b *ByocAzure) setUp(ctx context.Context) error { + // Lazily initialize location from AZURE_LOCATION env var (set by LoadStackEnv from the stack file). + // Azure SDK does not natively support AZURE_LOCATION, so we handle it ourselves. + if b.driver.Location == "" { + loc := cloudazure.Location(os.Getenv("AZURE_LOCATION")) + if loc == "" { + return errors.New("AZURE_LOCATION is not set; please ensure your stack includes the Azure region") + } + b.driver.SetLocation(loc) + } + // Similarly, AZURE_SUBSCRIPTION_ID may be set by LoadStackEnv after construction. + if b.driver.SubscriptionID == "" { + b.driver.SubscriptionID = os.Getenv("AZURE_SUBSCRIPTION_ID") + } + if err := b.driver.SetUpResourceGroup(ctx); err != nil { + return err + } + + b.driver.ContainerGroupProps = &armcontainerinstance.ContainerGroupPropertiesProperties{ + OSType: to.Ptr(armcontainerinstance.OperatingSystemTypesLinux), // TODO: from Platform + RestartPolicy: to.Ptr(armcontainerinstance.ContainerGroupRestartPolicyNever), + } + if username := os.Getenv("DOCKERHUB_USERNAME"); username != "" { + b.driver.ContainerGroupProps.ImageRegistryCredentials = append(b.driver.ContainerGroupProps.ImageRegistryCredentials, &armcontainerinstance.ImageRegistryCredential{ + Server: to.Ptr("index.docker.io"), + Username: to.Ptr(username), + Password: to.Ptr(pkg.Getenv("DOCKERHUB_TOKEN", os.Getenv("DOCKERHUB_PASSWORD"))), + }) + } + + if _, err := b.driver.SetUpStorageAccount(ctx); err != nil { + return fmt.Errorf("failed to set up storage account: %w", err) + } + if err := b.driver.SetUpManagedIdentity(ctx); err != nil { + return fmt.Errorf("failed to set up managed identity: %w", err) + } + + b.appCfg = appcfg.New(b.driver.ResourceGroupName(), b.driver.Location, b.driver.SubscriptionID) + if err := b.appCfg.SetUp(ctx); err != nil { + return fmt.Errorf("failed to set up App Configuration store: %w", err) + } + return nil +} + +// buildCdEnv returns the environment map that every CD container run needs. +func (b *ByocAzure) buildCdEnv(projectName string) (map[string]string, error) { + defangStateUrl := fmt.Sprintf(`azblob://%s?storage_account=%s`, b.driver.BlobContainerName, b.driver.StorageAccount) + pulumiBackendKey, pulumiBackendValue, err := byoc.GetPulumiBackend(defangStateUrl) + if err != nil { + return nil, err + } + env := map[string]string{ + "AZURE_LOCATION": b.driver.Location.String(), + "AZURE_SUBSCRIPTION_ID": b.driver.SubscriptionID, + "DEFANG_DEBUG": os.Getenv("DEFANG_DEBUG"), + "DEFANG_JSON": os.Getenv("DEFANG_JSON"), + "DEFANG_ORG": string(b.TenantLabel), + "DEFANG_PREFIX": b.Prefix, + "DEFANG_STATE_URL": defangStateUrl, + "NPM_CONFIG_UPDATE_NOTIFIER": "false", + "PROJECT": projectName, + pulumiBackendKey: pulumiBackendValue, // TODO: make secret + "PULUMI_CONFIG_PASSPHRASE": byoc.PulumiConfigPassphrase, // TODO: make secret + "PULUMI_COPILOT": "false", + "PULUMI_SKIP_UPDATE_CHECK": "true", + "STACK": b.PulumiStack, + } + if !term.StdoutCanColor() { + env["NO_COLOR"] = "1" + } + return env, nil +} + +// cdContainers returns the container definition for the CD runner. +func (b *ByocAzure) cdContainers() []*armcontainerinstance.Container { + return []*armcontainerinstance.Container{ + { + Name: to.Ptr("defang-cd"), + Properties: &armcontainerinstance.ContainerProperties{ + Image: to.Ptr(b.CDImage), + Resources: &armcontainerinstance.ResourceRequirements{ + Requests: &armcontainerinstance.ResourceRequests{ + CPU: to.Ptr(2.0), + MemoryInGB: to.Ptr(8.0), + }, + }, + }, + }, + } +} + +// Deploy implements client.Provider. +func (b *ByocAzure) Deploy(ctx context.Context, req *client.DeployRequest) (*client.DeployResponse, error) { + return b.deploy(ctx, req, "up") +} + +func (b *ByocAzure) deploy(ctx context.Context, req *client.DeployRequest, verb string) (*client.DeployResponse, error) { + if b.CDImage == "" { + return nil, errors.New("CD image is not set; please set the DEFANG_CD_IMAGE environment variable") + } + + // If multiple Compose files were provided, req.Compose is the merged representation of all the files + project, err := compose.LoadFromContent(ctx, req.Compose, "") + if err != nil { + return nil, err + } + + if err := b.setUp(ctx); err != nil { + return nil, err + } + + etag := pkg.RandomID() + serviceInfos, err := b.GetServiceInfos(ctx, project.Name, req.DelegateDomain, etag, project.Services) + if err != nil { + return nil, err + } + + data, err := proto.Marshal(&defangv1.ProjectUpdate{ + CdVersion: b.CDImage, + Compose: req.Compose, + Services: serviceInfos, + }) + if err != nil { + return nil, err + } + + envMap, err := b.buildCdEnv(project.Name) + if err != nil { + return nil, err + } + + var payload string + if len(data) < 1000 { + payload = base64.StdEncoding.EncodeToString(data) + } else { + uploadURL, err := b.driver.CreateUploadURL(ctx, etag) + if err != nil { + return nil, err + } + resp, err := defanghttp.PutWithHeader(ctx, uploadURL, http.Header{ + "Content-Type": []string{"application/protobuf"}, + "x-ms-blob-type": []string{"BlockBlob"}, + }, bytes.NewReader(data)) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != 200 && resp.StatusCode != 201 { + return nil, fmt.Errorf("unexpected status code during upload: %s", resp.Status) + } + payload = defanghttp.RemoveQueryParam(uploadURL) // managed identity provides blob read access + } + + // Allow local debug run when DEFANG_PULUMI_DIR is set; returns ErrLocalPulumiStopped when run locally. + // if err := byoc.DebugPulumiNodeJS(ctx, env, verb, payload); err != nil { + // return &defangv1.DeployResponse{Etag: etag, Services: serviceInfos}, err + // } + + taskID, err := b.driver.Run(ctx, b.cdContainers(), envMap, "/app/cd", verb, payload) + if err != nil { + return nil, err + } + b.cdContainerGroup = taskID + b.cdDeploymentID = etag + return &client.DeployResponse{ + CdId: *taskID, + CdType: defangv1.CdType_CD_TYPE_AZURE_ACI_JOBID, + DeployResponse: &defangv1.DeployResponse{ + Etag: etag, Services: serviceInfos, + }, + }, nil +} + +// // Destroy implements client.Provider. +// func (b *ByocAzure) Destroy(ctx context.Context, req *defangv1.DestroyRequest) (types.ETag, error) { +// return b.CdCommand(ctx, client.CdCommandRequest{ +// Command: client.CdCommandDestroy, +// Project: req.Project, +// }) +// } + +// GetDeploymentStatus implements client.Provider. +func (b *ByocAzure) GetDeploymentStatus(ctx context.Context) (bool, error) { + done, err := b.driver.GetContainerGroupStatus(ctx, b.cdContainerGroup) + if err != nil { + return done, client.ErrDeploymentFailed{Message: err.Error()} + } + return done, nil +} + +// GetPrivateDomain implements byoc.ProjectBackend. +func (b *ByocAzure) GetPrivateDomain(projectName string) string { + return b.GetProjectLabel(projectName) + ".internal" +} + +// GetProjectUpdate implements byoc.ProjectBackend. +func (b *ByocAzure) GetProjectUpdate(ctx context.Context, projectName string) (*defangv1.ProjectUpdate, error) { + if projectName == "" { + return nil, client.ErrNotExist + } + if err := b.setUp(ctx); err != nil { + return nil, err + } + + path := b.GetProjectUpdatePath(projectName) + term.Debug("Getting project update from blob:", b.driver.BlobContainerName, path) + pbBytes, err := b.driver.DownloadBlob(ctx, path) + if err != nil { + var respErr *azcore.ResponseError + if errors.As(err, &respErr) && respErr.StatusCode == 404 { + return nil, client.ErrNotExist // no services yet + } + return nil, err + } + + var projUpdate defangv1.ProjectUpdate + if err := proto.Unmarshal(pbBytes, &projUpdate); err != nil { + return nil, err + } + return &projUpdate, nil +} + +// GetService implements client.Provider. +func (b *ByocAzure) GetService(context.Context, *defangv1.GetRequest) (*defangv1.ServiceInfo, error) { + return nil, fmt.Errorf("GetService: %w", errors.ErrUnsupported) +} + +// GetServices implements client.Provider. +func (b *ByocAzure) GetServices(context.Context, *defangv1.GetServicesRequest) (*defangv1.GetServicesResponse, error) { + return nil, fmt.Errorf("GetServices: %w", errors.ErrUnsupported) +} + +// ListConfig implements client.Provider. +func (b *ByocAzure) ListConfig(ctx context.Context, req *defangv1.ListConfigsRequest) (*defangv1.Secrets, error) { + if err := b.setUp(ctx); err != nil { + return nil, err + } + prefix := b.StackDir(req.Project, "") + term.Debugf("Listing App Configuration keys with prefix %q", prefix) + names, err := b.appCfg.ListSettings(ctx, prefix) + if err != nil { + return nil, err + } + return &defangv1.Secrets{Names: names}, nil +} + +// PrepareDomainDelegation implements client.Provider. +func (b *ByocAzure) PrepareDomainDelegation(context.Context, client.PrepareDomainDelegationRequest) (*client.PrepareDomainDelegationResponse, error) { + return nil, nil // TODO: implement domain delegation for Azure +} + +// Preview implements client.Provider. +func (b *ByocAzure) Preview(ctx context.Context, req *client.DeployRequest) (*client.DeployResponse, error) { + return b.deploy(ctx, req, "preview") +} + +// PutConfig implements client.Provider. +func (b *ByocAzure) PutConfig(ctx context.Context, req *defangv1.PutConfigRequest) error { + if err := b.setUp(ctx); err != nil { + return err + } + key := b.StackDir(req.Project, req.Name) + term.Debugf("Putting App Configuration key %q", key) + return b.appCfg.PutSetting(ctx, key, req.Value) +} + +// QueryLogs implements client.Provider. +// Only CD container logs are supported; service logs are not yet implemented. +func (b *ByocAzure) QueryLogs(ctx context.Context, req *defangv1.TailRequest) (iter.Seq2[*defangv1.TailResponse, error], error) { + // Match the request etag to the stored CD etag so we tail the correct container group instance. + if b.cdContainerGroup == nil || (req.Etag != "" && req.Etag != b.cdDeploymentID) { + return nil, fmt.Errorf("QueryLogs: no matching CD deployment for etag %q", req.Etag) + } + + const cdContainerName = "defang-cd" + etag := b.cdDeploymentID + + if req.Follow { + cdCh, err := b.driver.PollLogs(ctx, b.cdContainerGroup, cdContainerName) + if err != nil { + return nil, err + } + + acaClient := &aca.ContainerApp{ + Azure: b.driver.Azure, + ResourceGroup: b.driver.ResourceGroupName(), + } + acaCh := acaClient.WatchLogs(ctx) + + return func(yield func(*defangv1.TailResponse, error) bool) { + for { + select { + case entry, ok := <-cdCh: + if !ok { + cdCh = nil + continue + } + if entry.Err != nil { + if !yield(nil, entry.Err) { + return + } + continue + } + if !yield(&defangv1.TailResponse{ + Entries: []*defangv1.LogEntry{{ + Message: entry.Message, + Stderr: entry.Stderr, + Service: cdContainerName, + Etag: etag, + Timestamp: timestamppb.New(entry.Time), + }}, + Service: cdContainerName, + Etag: etag, + }, nil) { + return + } + case svc, ok := <-acaCh: + if !ok { + acaCh = nil + continue + } + if svc.Err != nil { + term.Debugf("Container Apps log error for %q: %v", svc.AppName, svc.Err) + continue + } + if !yield(&defangv1.TailResponse{ + Entries: []*defangv1.LogEntry{{ + Message: svc.Message, + Service: svc.AppName, + Etag: etag, + Timestamp: timestamppb.Now(), + }}, + Service: svc.AppName, + Etag: etag, + }, nil) { + return + } + case <-ctx.Done(): + return + } + if cdCh == nil && acaCh == nil { + return + } + } + }, nil + } + + // Non-follow: return current log snapshot via ACI ListLogs. + content, err := b.driver.QueryLogs(ctx, b.cdContainerGroup, cdContainerName) + if err != nil && !errors.Is(err, io.EOF) { + return nil, err + } + return func(yield func(*defangv1.TailResponse, error) bool) { + if content == "" { + return + } + yield(&defangv1.TailResponse{ + Entries: []*defangv1.LogEntry{{ + Message: content, + Service: cdContainerName, + Etag: etag, + Timestamp: timestamppb.Now(), + }}, + Service: cdContainerName, + Etag: etag, + }, nil) + }, nil +} + +// RemoteProjectName implements client.Provider. +// Subtle: this method shadows the method (*ByocBaseClient).RemoteProjectName of ByocAzure.ByocBaseClient. +func (b *ByocAzure) RemoteProjectName(context.Context) (string, error) { + return "", fmt.Errorf("RemoteProjectName: %w", errors.ErrUnsupported) +} + +// ServiceDNS implements client.Provider. +// Subtle: this method shadows the method (*ByocBaseClient).ServiceDNS of ByocAzure.ByocBaseClient. +func (b *ByocAzure) ServiceDNS(host string) string { + return host +} + +// Subscribe implements client.Provider. +func (b *ByocAzure) Subscribe(context.Context, *defangv1.SubscribeRequest) (iter.Seq2[*defangv1.SubscribeResponse, error], error) { + // return nil, fmt.Errorf("Subscribe: %w", errors.ErrUnsupported) + return func(yield func(*defangv1.SubscribeResponse, error) bool) { + // TODO: Implement subscription to deployment events for Azure + }, nil +} + +// TearDown implements client.Provider. +func (b *ByocAzure) TearDown(ctx context.Context) error { + return b.driver.TearDown(ctx) +} + +// TearDownCD implements client.Provider. +func (b *ByocAzure) TearDownCD(context.Context) error { + return fmt.Errorf("TearDownCD: %w", errors.ErrUnsupported) +} + +// UpdateShardDomain implements client.DNSResolver. +func (b *ByocAzure) UpdateShardDomain(context.Context) error { + return fmt.Errorf("UpdateShardDomain: %w", errors.ErrUnsupported) +} diff --git a/src/pkg/cli/client/byoc/azure/login.go b/src/pkg/cli/client/byoc/azure/login.go new file mode 100644 index 000000000..c9ca769bc --- /dev/null +++ b/src/pkg/cli/client/byoc/azure/login.go @@ -0,0 +1,7 @@ +package azure + +import "context" + +func (b *ByocAzure) Authenticate(ctx context.Context, interactive bool) error { + return nil +} diff --git a/src/pkg/cli/client/byoc/gcp/byoc.go b/src/pkg/cli/client/byoc/gcp/byoc.go index d952660dc..11480e318 100644 --- a/src/pkg/cli/client/byoc/gcp/byoc.go +++ b/src/pkg/cli/client/byoc/gcp/byoc.go @@ -414,6 +414,7 @@ type CloudBuildStep struct { } func (b *ByocGcp) runCdCommand(ctx context.Context, cmd cdCommand) (string, error) { + // From https://www.pulumi.com/docs/iac/concepts/state-and-backends/#google-cloud-storage defangStateUrl := `gs://` + b.bucket pulumiBackendKey, pulumiBackendValue, err := byoc.GetPulumiBackend(defangStateUrl) if err != nil { diff --git a/src/pkg/cli/client/provider_id.go b/src/pkg/cli/client/provider_id.go index 75932da49..add91f20f 100644 --- a/src/pkg/cli/client/provider_id.go +++ b/src/pkg/cli/client/provider_id.go @@ -11,11 +11,11 @@ type ProviderID string const ( ProviderAuto ProviderID = "auto" - ProviderDefang ProviderID = "defang" ProviderAWS ProviderID = "aws" + ProviderAzure ProviderID = "azure" + ProviderDefang ProviderID = "defang" ProviderDO ProviderID = "digitalocean" ProviderGCP ProviderID = "gcp" - // ProviderAzure ProviderID = "azure" ) var allProviders = []ProviderID{ @@ -24,7 +24,7 @@ var allProviders = []ProviderID{ ProviderAWS, ProviderDO, ProviderGCP, - // ProviderAzure, + ProviderAzure, } func AllProviders() []ProviderID { @@ -39,10 +39,12 @@ func (p ProviderID) Name() string { switch p { case ProviderAuto: return "Auto" - case ProviderDefang: - return "Defang Playground" case ProviderAWS: return "AWS" + case ProviderAzure: + return "Azure" + case ProviderDefang: + return "Defang Playground" case ProviderDO: return "DigitalOcean" case ProviderGCP: @@ -54,10 +56,12 @@ func (p ProviderID) Name() string { func (p ProviderID) Value() defangv1.Provider { switch p { - case ProviderDefang: - return defangv1.Provider_DEFANG case ProviderAWS: return defangv1.Provider_AWS + case ProviderAzure: + return defangv1.Provider_AZURE + case ProviderDefang: + return defangv1.Provider_DEFANG case ProviderDO: return defangv1.Provider_DIGITALOCEAN case ProviderGCP: @@ -80,10 +84,12 @@ func (p *ProviderID) Set(str string) error { func (p *ProviderID) SetValue(val defangv1.Provider) { switch val { - case defangv1.Provider_DEFANG: - *p = ProviderDefang case defangv1.Provider_AWS: *p = ProviderAWS + case defangv1.Provider_AZURE: + *p = ProviderAzure + case defangv1.Provider_DEFANG: + *p = ProviderDefang case defangv1.Provider_DIGITALOCEAN: *p = ProviderDO case defangv1.Provider_GCP: diff --git a/src/pkg/cli/client/region.go b/src/pkg/cli/client/region.go index f2e66d00a..f8152db33 100644 --- a/src/pkg/cli/client/region.go +++ b/src/pkg/cli/client/region.go @@ -5,9 +5,10 @@ import ( ) const ( - RegionDefaultAWS = "us-west-2" - RegionDefaultDO = "nyc3" - RegionDefaultGCP = "us-central1" // Defaults to us-central1 for lower price + RegionDefaultAWS = "us-west-2" + RegionDefaultAzure = "westus" // Default region for Azure + RegionDefaultDO = "nyc3" + RegionDefaultGCP = "us-central1" // Defaults to us-central1 for lower price ) func GetRegion(provider ProviderID) string { @@ -15,6 +16,8 @@ func GetRegion(provider ProviderID) string { switch provider { case ProviderAWS: defaultRegion = RegionDefaultAWS + case ProviderAzure: + defaultRegion = RegionDefaultAzure case ProviderGCP: defaultRegion = RegionDefaultGCP case ProviderDO: @@ -33,6 +36,8 @@ func GetRegionVarName(provider ProviderID) string { switch provider { case ProviderAWS: return "AWS_REGION" + case ProviderAzure: + return "AZURE_LOCATION" case ProviderGCP: // Try standard GCP environment variables in order of precedence GCPRegionEnvVar, _ := pkg.GetFirstEnv(pkg.GCPRegionEnvVars...) diff --git a/src/pkg/cli/compose/context.go b/src/pkg/cli/compose/context.go index 40d1106a1..421740c63 100644 --- a/src/pkg/cli/compose/context.go +++ b/src/pkg/cli/compose/context.go @@ -259,20 +259,22 @@ func uploadArchive(ctx context.Context, provider client.Provider, projectName st } // Do an HTTP PUT to the generated URL - resp, err := http.Put(ctx, res.Url, string(archiveType.MimeType), body) + header := http.Header{"Content-Type": []string{string(archiveType.MimeType)}} + header.Set("X-Ms-Blob-Type", "BlockBlob") // HACK: move to Azure provider + resp, err := http.PutWithHeader(ctx, res.Url, header, body) if err != nil { return "", err } defer resp.Body.Close() - if resp.StatusCode != 200 { + if resp.StatusCode != 200 && resp.StatusCode != 201 { return "", fmt.Errorf("HTTP PUT failed with status code %v", resp.Status) } - url := http.RemoveQueryParam(res.Url) - const gcpPrefix = "https://storage.googleapis.com/" - if strings.HasPrefix(url, gcpPrefix) { - url = "gs://" + url[len(gcpPrefix):] - } + url := res.Url //http.RemoveQueryParam(res.Url) // remove any access signature + + const gcpPrefix = "https://storage.googleapis.com/" // HACK: move to GCP provider + url = strings.Replace(url, gcpPrefix, "gs://", 1) + return url, nil } diff --git a/src/pkg/cli/connect.go b/src/pkg/cli/connect.go index e46ebcf77..6336f2e90 100644 --- a/src/pkg/cli/connect.go +++ b/src/pkg/cli/connect.go @@ -5,6 +5,7 @@ import ( "github.com/DefangLabs/defang/src/pkg/cli/client" "github.com/DefangLabs/defang/src/pkg/cli/client/byoc/aws" + "github.com/DefangLabs/defang/src/pkg/cli/client/byoc/azure" "github.com/DefangLabs/defang/src/pkg/cli/client/byoc/do" "github.com/DefangLabs/defang/src/pkg/cli/client/byoc/gcp" "github.com/DefangLabs/defang/src/pkg/term" @@ -43,6 +44,8 @@ func NewProvider(ctx context.Context, providerID client.ProviderID, fabricClient provider = do.NewByocProvider(ctx, fabricClient.GetTenantName(), stack) case client.ProviderGCP: provider = gcp.NewByocProvider(ctx, fabricClient.GetTenantName(), stack) + case client.ProviderAzure: + provider = azure.NewByocProvider(ctx, fabricClient.GetTenantName(), stack) default: provider = client.NewPlaygroundProvider(fabricClient, stack) } diff --git a/src/pkg/cli/tailAndMonitor.go b/src/pkg/cli/tailAndMonitor.go index 1c922446f..968cce7b2 100644 --- a/src/pkg/cli/tailAndMonitor.go +++ b/src/pkg/cli/tailAndMonitor.go @@ -62,7 +62,7 @@ func TailAndMonitor(ctx context.Context, project *compose.Project, provider clie go func() { wg.Wait() - pkg.SleepWithContext(ctx, 2*time.Second) // a delay before cancelling tail to make sure we get last status messages + pkg.SleepWithContext(ctx, 6*time.Second) // a delay before cancelling tail to make sure we get last log messages cancelTail(errMonitoringDone) // cancel the tail when both goroutines are done }() diff --git a/src/pkg/clouds/azure/aca/common.go b/src/pkg/clouds/azure/aca/common.go new file mode 100644 index 000000000..f479212a3 --- /dev/null +++ b/src/pkg/clouds/azure/aca/common.go @@ -0,0 +1,187 @@ +package aca + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appcontainers/armappcontainers" + cloudazure "github.com/DefangLabs/defang/src/pkg/clouds/azure" +) + +const apiVersion = "2023-05-01" + +type ContainerApp struct { + cloudazure.Azure + ResourceGroup string +} + +func (c *ContainerApp) newContainerAppsClient() (*armappcontainers.ContainerAppsClient, error) { + cred, err := c.NewCreds() + if err != nil { + return nil, err + } + return armappcontainers.NewContainerAppsClient(c.SubscriptionID, cred, nil) +} + +func (c *ContainerApp) newReplicasClient() (*armappcontainers.ContainerAppsRevisionReplicasClient, error) { + cred, err := c.NewCreds() + if err != nil { + return nil, err + } + return armappcontainers.NewContainerAppsRevisionReplicasClient(c.SubscriptionID, cred, nil) +} + +// armToken returns a Bearer token scoped to the Azure management endpoint. +func (c *ContainerApp) armToken(ctx context.Context) (string, error) { + cred, err := c.NewCreds() + if err != nil { + return "", err + } + tok, err := cred.GetToken(ctx, policy.TokenRequestOptions{ + Scopes: []string{"https://management.azure.com/.default"}, + }) + if err != nil { + return "", err + } + return tok.Token, nil +} + +// getAuthToken fetches a short-lived token for the Container Apps log-stream endpoint. +// This operation is not yet exposed in the ARM Go SDK, so we call the REST API directly. +func (c *ContainerApp) getAuthToken(ctx context.Context, appName string) (string, error) { + armTok, err := c.armToken(ctx) + if err != nil { + return "", err + } + + url := fmt.Sprintf( + "https://management.azure.com/subscriptions/%s/resourceGroups/%s/providers/Microsoft.App/containerApps/%s/getAuthToken?api-version=%s", + c.SubscriptionID, c.ResourceGroup, appName, apiVersion, + ) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, http.NoBody) + if err != nil { + return "", err + } + req.Header.Set("Authorization", "Bearer "+armTok) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("getAuthToken: HTTP %s", resp.Status) + } + + var result struct { + Properties struct { + Token string `json:"token"` + } `json:"properties"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", fmt.Errorf("getAuthToken: decode response: %w", err) + } + return result.Properties.Token, nil +} + +// getEventStreamBase returns the host portion of the container app's eventStreamEndpoint +// (everything before "/subscriptions/"). This is not in SDK v1.1.0, so we call the REST API directly. +func (c *ContainerApp) getEventStreamBase(ctx context.Context, appName string) (string, error) { + armTok, err := c.armToken(ctx) + if err != nil { + return "", err + } + + url := fmt.Sprintf( + "https://management.azure.com/subscriptions/%s/resourceGroups/%s/providers/Microsoft.App/containerApps/%s?api-version=%s", + c.SubscriptionID, c.ResourceGroup, appName, apiVersion, + ) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return "", err + } + req.Header.Set("Authorization", "Bearer "+armTok) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("getContainerApp: HTTP %s", resp.Status) + } + + var result struct { + Properties struct { + EventStreamEndpoint string `json:"eventStreamEndpoint"` + } `json:"properties"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", fmt.Errorf("getContainerApp: decode response: %w", err) + } + endpoint := result.Properties.EventStreamEndpoint + idx := strings.Index(endpoint, "/subscriptions/") + if idx < 0 { + return "", fmt.Errorf("unexpected eventStreamEndpoint format: %q", endpoint) + } + return endpoint[:idx], nil +} + +// ResolveLogTarget resolves the latest active revision, first replica, and first container +// name for the given app. Any of the return values that were already provided as non-empty +// strings are passed through unchanged. +func (c *ContainerApp) ResolveLogTarget(ctx context.Context, appName, revision, replica, container string) (string, string, string, error) { + if revision == "" { + appsClient, err := c.newContainerAppsClient() + if err != nil { + return "", "", "", err + } + app, err := appsClient.Get(ctx, c.ResourceGroup, appName, nil) + if err != nil { + return "", "", "", fmt.Errorf("get container app: %w", err) + } + if app.Properties == nil || app.Properties.LatestRevisionName == nil { + return "", "", "", fmt.Errorf("container app %q has no active revision", appName) + } + revision = *app.Properties.LatestRevisionName + + // Opportunistically pick the container name from the app template. + if container == "" && app.Properties.Template != nil && len(app.Properties.Template.Containers) > 0 && app.Properties.Template.Containers[0].Name != nil { + container = *app.Properties.Template.Containers[0].Name + } + } + + if replica == "" { + replicasClient, err := c.newReplicasClient() + if err != nil { + return "", "", "", err + } + list, err := replicasClient.ListReplicas(ctx, c.ResourceGroup, appName, revision, nil) + if err != nil { + return "", "", "", fmt.Errorf("list replicas: %w", err) + } + if len(list.Value) == 0 { + return "", "", "", fmt.Errorf("no replicas found for revision %q", revision) + } + if list.Value[0].Name == nil { + return "", "", "", errors.New("replica has no name") + } + replica = *list.Value[0].Name + + // Opportunistically pick the container from the replica if still unset. + if container == "" && list.Value[0].Properties != nil && len(list.Value[0].Properties.Containers) > 0 && list.Value[0].Properties.Containers[0].Name != nil { + container = *list.Value[0].Properties.Containers[0].Name + } + } + + if container == "" { + return "", "", "", fmt.Errorf("could not determine container name for app %q", appName) + } + + return revision, replica, container, nil +} diff --git a/src/pkg/clouds/azure/aca/tail.go b/src/pkg/clouds/azure/aca/tail.go new file mode 100644 index 000000000..57a05c193 --- /dev/null +++ b/src/pkg/clouds/azure/aca/tail.go @@ -0,0 +1,162 @@ +package aca + +import ( + "bufio" + "context" + "fmt" + "net/http" + "strconv" + "time" +) + +const watchInterval = 5 * time.Second + +type LogEntry struct { + Message string + Err error +} + +// ServiceLogEntry is a LogEntry annotated with the Container App name it came from. +type ServiceLogEntry struct { + AppName string + LogEntry +} + +// WatchLogs polls the resource group for Container Apps every watchInterval and streams +// logs from each one as soon as it is discovered. New apps that appear after the initial +// poll are picked up automatically. +func (c *ContainerApp) WatchLogs(ctx context.Context) <-chan ServiceLogEntry { + out := make(chan ServiceLogEntry) + go func() { + defer close(out) + known := map[string]struct{}{} + + startTailing := func(appName string) { + go func() { + appCh, err := c.StreamLogs(ctx, appName, "", "", "", true) + if err != nil { + select { + case out <- ServiceLogEntry{AppName: appName, LogEntry: LogEntry{Err: err}}: + case <-ctx.Done(): + } + return + } + for entry := range appCh { + select { + case out <- ServiceLogEntry{AppName: appName, LogEntry: entry}: + case <-ctx.Done(): + return + } + } + }() + } + + poll := func() { + client, err := c.newContainerAppsClient() + if err != nil { + return + } + pager := client.NewListByResourceGroupPager(c.ResourceGroup, nil) + for pager.More() { + page, err := pager.NextPage(ctx) + if err != nil { + return + } + for _, app := range page.Value { + name := *app.Name + if _, seen := known[name]; seen { + continue + } + known[name] = struct{}{} + startTailing(name) + } + } + } + + poll() + ticker := time.NewTicker(watchInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + poll() + } + } + }() + return out +} + +// StreamLogs streams real-time logs from a Container App container via Server-Sent Events. +// revision, replica, and container may be empty; they will be resolved to the latest active +// revision, first replica, and first container automatically. +// When follow is false, the stream ends when there are no more buffered log lines. +func (c *ContainerApp) StreamLogs(ctx context.Context, appName, revision, replica, container string, follow bool) (<-chan LogEntry, error) { + var err error + revision, replica, container, err = c.ResolveLogTarget(ctx, appName, revision, replica, container) + if err != nil { + return nil, err + } + + baseURL, err := c.getEventStreamBase(ctx, appName) + if err != nil { + return nil, err + } + + authToken, err := c.getAuthToken(ctx, appName) + if err != nil { + return nil, err + } + + streamURL := fmt.Sprintf( + "%s/subscriptions/%s/resourceGroups/%s/containerApps/%s/revisions/%s/replicas/%s/containers/%s/logstream", + baseURL, c.SubscriptionID, c.ResourceGroup, appName, revision, replica, container, + ) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, streamURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+authToken) + + q := req.URL.Query() + q.Set("follow", strconv.FormatBool(follow)) + q.Set("output", "text") + req.URL.RawQuery = q.Encode() + + resp, err := http.DefaultClient.Do(req) // nolint resp.Body is closed by the goroutine below via defer resp.Body.Close() + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + _ = resp.Body.Close() + return nil, fmt.Errorf("log stream: HTTP %s", resp.Status) + } + + ch := make(chan LogEntry) + go func() { + defer close(ch) + defer resp.Body.Close() + + scanner := bufio.NewScanner(resp.Body) + for scanner.Scan() { + line := scanner.Text() + if line == "" { + continue + } + select { + case ch <- LogEntry{Message: line}: + case <-ctx.Done(): + return + } + } + if err := scanner.Err(); err != nil && ctx.Err() == nil { + select { + case ch <- LogEntry{Err: err}: + case <-ctx.Done(): + } + } + }() + return ch, nil +} diff --git a/src/pkg/clouds/azure/aci/blob.go b/src/pkg/clouds/azure/aci/blob.go new file mode 100644 index 000000000..4f74072a5 --- /dev/null +++ b/src/pkg/clouds/azure/aci/blob.go @@ -0,0 +1,98 @@ +package aci + +import ( + "context" + "errors" + "fmt" + "io" + "iter" + "os" + + "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob" + "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/container" +) + +const maxBlobDownloadSize = 32 * 1024 * 1024 // 32 MiB + +// BlobItem represents a blob in the storage account container. +type BlobItem struct { + name string + size int64 +} + +func (b BlobItem) Name() string { return b.name } +func (b BlobItem) Size() int64 { return b.size } + +func (c *ContainerInstance) newSharedKeyCredential(ctx context.Context) (*azblob.SharedKeyCredential, error) { + storageKey := os.Getenv("AZURE_STORAGE_KEY") + if storageKey == "" { + accountsClient, err := c.NewStorageAccountsClient() + if err != nil { + return nil, err + } + keys, err := accountsClient.ListKeys(ctx, c.resourceGroupName, c.StorageAccount, nil) + if err != nil { + return nil, err + } + if len(keys.Keys) == 0 || keys.Keys[0].Value == nil { + return nil, errors.New("no storage account keys returned") + } + storageKey = *keys.Keys[0].Value + } + return azblob.NewSharedKeyCredential(c.StorageAccount, storageKey) +} + +func (c *ContainerInstance) newBlobContainerClient(ctx context.Context) (*container.Client, error) { + keyCred, err := c.newSharedKeyCredential(ctx) + if err != nil { + return nil, err + } + containerURL := fmt.Sprintf("https://%s.blob.core.windows.net/%s", c.StorageAccount, c.BlobContainerName) + return container.NewClientWithSharedKeyCredential(containerURL, keyCred, nil) +} + +// IterateBlobs returns an iterator over blobs with the given prefix. +func (c *ContainerInstance) IterateBlobs(ctx context.Context, prefix string) (iter.Seq2[BlobItem, error], error) { + client, err := c.newBlobContainerClient(ctx) + if err != nil { + return nil, err + } + pager := client.NewListBlobsFlatPager(&container.ListBlobsFlatOptions{ + Prefix: &prefix, + }) + return func(yield func(BlobItem, error) bool) { + for pager.More() { + page, err := pager.NextPage(ctx) + if err != nil { + yield(BlobItem{}, err) + return + } + for _, item := range page.Segment.BlobItems { + if item.Name == nil { + continue + } + var size int64 + if item.Properties != nil && item.Properties.ContentLength != nil { + size = *item.Properties.ContentLength + } + if !yield(BlobItem{name: *item.Name, size: size}, nil) { + return + } + } + } + }, nil +} + +// DownloadBlob fetches the contents of a blob by name. +func (c *ContainerInstance) DownloadBlob(ctx context.Context, blobName string) ([]byte, error) { + client, err := c.newBlobContainerClient(ctx) + if err != nil { + return nil, err + } + resp, err := client.NewBlobClient(blobName).DownloadStream(ctx, nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + return io.ReadAll(io.LimitReader(resp.Body, maxBlobDownloadSize)) +} diff --git a/src/pkg/clouds/azure/aci/common.go b/src/pkg/clouds/azure/aci/common.go new file mode 100644 index 000000000..a6c32f066 --- /dev/null +++ b/src/pkg/clouds/azure/aci/common.go @@ -0,0 +1,84 @@ +package aci + +import ( + "fmt" + "os" + + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerinstance/armcontainerinstance/v2" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v2" + "github.com/DefangLabs/defang/src/pkg/clouds/azure" +) + +type ContainerInstance struct { + azure.Azure + ContainerGroupProps *armcontainerinstance.ContainerGroupPropertiesProperties + resourceGroupPrefix string + resourceGroupName string + StorageAccount string + BlobContainerName string + ManagedIdentityID string // resource ID of the user-assigned managed identity +} + +func NewContainerInstance(resourceGroupPrefix string, location azure.Location) *ContainerInstance { + if location == "" { + location = azure.Location(os.Getenv("AZURE_LOCATION")) + } + ci := &ContainerInstance{ + Azure: azure.Azure{ + SubscriptionID: os.Getenv("AZURE_SUBSCRIPTION_ID"), + }, + resourceGroupPrefix: resourceGroupPrefix, + StorageAccount: os.Getenv("DEFANG_CD_BUCKET"), + } + ci.SetLocation(location) + return ci +} + +// SetLocation updates the location and recomputes the resource group name. +func (c *ContainerInstance) SetLocation(loc azure.Location) { + c.Location = loc + c.resourceGroupName = c.resourceGroupPrefix + loc.String() +} + +func (c *ContainerInstance) ResourceGroupName() string { + return c.resourceGroupName +} + +func (c ContainerInstance) newContainerGroupClient() (*armcontainerinstance.ContainerGroupsClient, error) { + cred, err := c.NewCreds() + if err != nil { + return nil, err + } + + clientFactory, err := armcontainerinstance.NewClientFactory(c.SubscriptionID, cred, nil) + if err != nil { + return nil, fmt.Errorf("failed to create container group client: %w", err) + } + return clientFactory.NewContainerGroupsClient(), nil +} + +func (c ContainerInstance) newContainerClient() (*armcontainerinstance.ContainersClient, error) { + cred, err := c.NewCreds() + if err != nil { + return nil, err + } + + clientFactory, err := armcontainerinstance.NewClientFactory(c.SubscriptionID, cred, nil) + if err != nil { + return nil, fmt.Errorf("failed to create container client: %w", err) + } + return clientFactory.NewContainersClient(), nil +} + +func (c ContainerInstance) newResourceGroupClient() (*armresources.ResourceGroupsClient, error) { + cred, err := c.NewCreds() + if err != nil { + return nil, err + } + + resourcesClientFactory, err := armresources.NewClientFactory(c.SubscriptionID, cred, nil) + if err != nil { + return nil, fmt.Errorf("failed to create resource group client: %w", err) + } + return resourcesClientFactory.NewResourceGroupsClient(), nil +} diff --git a/src/pkg/clouds/azure/aci/common_test.go b/src/pkg/clouds/azure/aci/common_test.go new file mode 100644 index 000000000..8d1db7b3b --- /dev/null +++ b/src/pkg/clouds/azure/aci/common_test.go @@ -0,0 +1,24 @@ +package aci + +import ( + "testing" + + "github.com/DefangLabs/defang/src/pkg" + "github.com/google/uuid" +) + +var testResourceGroupName = "crun-test-" + pkg.GetCurrentUser() // avoid conflict with other users in the same account + +func TestNewClient(t *testing.T) { + t.Setenv("AZURE_SUBSCRIPTION_ID", uuid.NewString()) + + c := NewContainerInstance(testResourceGroupName, "") + + client, err := c.newContainerGroupClient() + if err != nil { + t.Fatalf("Failed to create client: %v", err) + } + if client == nil { + t.Fatal("Expected non-nil client") + } +} diff --git a/src/pkg/clouds/azure/aci/run.go b/src/pkg/clouds/azure/aci/run.go new file mode 100644 index 000000000..2f9d1a5cb --- /dev/null +++ b/src/pkg/clouds/azure/aci/run.go @@ -0,0 +1,88 @@ +package aci + +import ( + "context" + "fmt" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerinstance/armcontainerinstance/v2" +) + +const cdContainerGroupName = "defang-cd" + +// containerGroupIdentity returns a user-assigned identity block if one has been configured, +// or nil if no managed identity is set up yet. +func (c *ContainerInstance) containerGroupIdentity() *armcontainerinstance.ContainerGroupIdentity { + if c.ManagedIdentityID == "" { + return nil + } + return &armcontainerinstance.ContainerGroupIdentity{ + Type: to.Ptr(armcontainerinstance.ResourceIdentityTypeUserAssigned), + UserAssignedIdentities: map[string]*armcontainerinstance.UserAssignedIdentities{ + c.ManagedIdentityID: {}, + }, + } +} + +type ContainerGroupName = *string + +func (c *ContainerInstance) Run(ctx context.Context, containers []*armcontainerinstance.Container, env map[string]string, args ...string) (ContainerGroupName, error) { + containerGroupClient, err := c.newContainerGroupClient() + if err != nil { + return nil, err + } + + commandArgs := to.SliceOfPtrs(args...) + var envVars []*armcontainerinstance.EnvironmentVariable + for key, value := range env { + envVars = append(envVars, &armcontainerinstance.EnvironmentVariable{ + Name: to.Ptr(key), + Value: to.Ptr(value), + }) + } + + clone := *c.ContainerGroupProps + if len(containers) == 0 { + containers = c.ContainerGroupProps.Containers + } + clone.Containers = make([]*armcontainerinstance.Container, len(containers)) + for i, container := range containers { + if container == nil || container.Properties == nil { + return nil, fmt.Errorf("container %d has nil properties", i) + } + newProps := *container.Properties + if i == 0 { + newProps.Command = append(newProps.Command, commandArgs...) + } + newProps.EnvironmentVariables = append(newProps.EnvironmentVariables, envVars...) + clone.Containers[i] = &armcontainerinstance.Container{ + Name: container.Name, + Properties: &newProps, + } + } + + groupName := cdContainerGroupName + group := armcontainerinstance.ContainerGroup{ + Name: to.Ptr(groupName), + Location: c.Location.Ptr(), + Identity: c.containerGroupIdentity(), + Properties: &clone, + } + createPoller, err := containerGroupClient.BeginCreateOrUpdate(ctx, c.resourceGroupName, groupName, group, nil) + if err != nil { + return nil, fmt.Errorf("failed to create container group: %w", err) + } + if _, err := createPoller.PollUntilDone(ctx, nil); err != nil { + return nil, fmt.Errorf("failed to complete container group creation: %w", err) + } + + startPoller, err := containerGroupClient.BeginStart(ctx, c.resourceGroupName, groupName, nil) + if err != nil { + return nil, fmt.Errorf("failed to start container group: %w", err) + } + if _, err := startPoller.PollUntilDone(ctx, nil); err != nil { + return nil, fmt.Errorf("failed to complete container group start: %w", err) + } + + return &groupName, nil +} diff --git a/src/pkg/clouds/azure/aci/run_test.go b/src/pkg/clouds/azure/aci/run_test.go new file mode 100644 index 000000000..15b33de0c --- /dev/null +++ b/src/pkg/clouds/azure/aci/run_test.go @@ -0,0 +1,38 @@ +//go:build integration + +package aci + +import ( + "context" + "testing" +) + +func TestRun(t *testing.T) { + if testing.Short() { + t.Skip("skipping slow integration test in short mode") + } + + ctx := context.Background() + + containerInstance := NewContainerInstance(testResourceGroupName, "westeurope") + + err := containerInstance.SetUpResourceGroup(ctx) + if err != nil { + t.Fatalf("SetUpResourceGroup failed: %v", err) + } + + t.Cleanup(func() { + // err := containerInstance.TearDown(ctx) + // if err != nil { + // t.Fatalf("Failed to tear down container instance: %v", err) + // } + }) + + taskID, err := containerInstance.Run(ctx, nil, nil) + if err != nil { + t.Fatalf("Run failed: %v", err) + } + if taskID == nil { + t.Fatal("Expected non-nil task ID") + } +} diff --git a/src/pkg/clouds/azure/aci/setup.go b/src/pkg/clouds/azure/aci/setup.go new file mode 100644 index 000000000..b9775e5b9 --- /dev/null +++ b/src/pkg/clouds/azure/aci/setup.go @@ -0,0 +1,234 @@ +package aci + +import ( + "context" + "errors" + "fmt" + "os" + "strings" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v3" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/msi/armmsi" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v2" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage/v2" + "github.com/DefangLabs/defang/src/pkg" + "github.com/DefangLabs/defang/src/pkg/term" + "github.com/google/uuid" +) + +const storageAccountPrefix = "defangcd" +const blobContainerName = "uploads" + +func (c *ContainerInstance) SetUpResourceGroup(ctx context.Context) error { + resourceGroupClient, err := c.newResourceGroupClient() + if err != nil { + return err + } + _, err = resourceGroupClient.CreateOrUpdate(ctx, c.resourceGroupName, armresources.ResourceGroup{ + Location: c.Location.Ptr(), + }, nil) + if err != nil { + return fmt.Errorf("failed to create resource group: %w", err) + } + return nil +} + +func (c *ContainerInstance) TearDown(ctx context.Context) error { + resourceGroupClient, err := c.newResourceGroupClient() + if err != nil { + return err + } + deleteResponse, err := resourceGroupClient.BeginDelete(ctx, c.resourceGroupName, nil) + if err != nil { + return fmt.Errorf("failed to delete resource group: %w", err) + } + _, err = deleteResponse.PollUntilDone(ctx, nil) + if err != nil { + return err + } + + // TODO: delete storage account? + return nil +} + +func (c *ContainerInstance) getStorageAccount(ctx context.Context, accountsClient *armstorage.AccountsClient) (string, error) { + if c.StorageAccount != "" { + return c.StorageAccount, nil + } + + if sa := os.Getenv("AZURE_STORAGE_ACCOUNT"); sa != "" { + return sa, nil + } + + for pager := accountsClient.NewListByResourceGroupPager(c.resourceGroupName, nil); pager.More(); { + page, err := pager.NextPage(ctx) + if err != nil { + return "", fmt.Errorf("failed to list storage accounts: %w", err) + } + for _, account := range page.Value { + if strings.HasPrefix(*account.Name, storageAccountPrefix) && *account.Location == c.Location.String() { + return *account.Name, nil + } + } + } + return "", nil +} + +func (c *ContainerInstance) SetUpStorageAccount(ctx context.Context) (string, error) { + accountsClient, err := c.NewStorageAccountsClient() + if err != nil { + return "", err + } + + storageAccount, err := c.getStorageAccount(ctx, accountsClient) + if err != nil { + return "", err + } + + if storageAccount == "" { + storageAccount = storageAccountPrefix + pkg.RandomID() // unique storage account name + createResponse, err := accountsClient.BeginCreate(ctx, c.resourceGroupName, storageAccount, armstorage.AccountCreateParameters{ + Kind: to.Ptr(armstorage.KindStorageV2), + Location: c.Location.Ptr(), + SKU: &armstorage.SKU{Name: to.Ptr(armstorage.SKUNameStandardLRS)}, + }, nil) + if err != nil { + return "", fmt.Errorf("failed to create storage account: %w", err) + } + + _, err = createResponse.PollUntilDone(ctx, nil) + if err != nil { + return "", fmt.Errorf("failed to poll storage account creation: %w", err) + } + } + c.StorageAccount = storageAccount + + // Assign permissions + // objectId, err := getCurrentUserObjectID(ctx) + // if err != nil { + // return "", fmt.Errorf("failed to get current user object ID: %w", err) + // } + // println("Current user object ID:", objectId) + + // if err := c.assignBlobDataContributor(ctx, objectId); err != nil { + // return "", fmt.Errorf("failed to assign blob data contributor role: %w", err) + // } + + // client, err := c.NewStorageAccountsClient() + // if err != nil { + // return "", fmt.Errorf("failed to create storage accounts client: %w", err) + // } + // lk, err := client.ListKeys(ctx, c.resourceGroupName, storageAccount, nil) + // if err != nil { + // return "", fmt.Errorf("failed to list storage account keys: %w", err) + // } + + // ss, err := client.RegenerateKey(ctx, c.resourceGroupName, storageAccount, armstorage.RegenerateKeyParameters{ + // KeyName: to.Ptr("key1"), + // }, nil) + // if err != nil { + // return "", fmt.Errorf("failed to regenerate storage account key: %w", err) + // } + + containerClient, err := c.NewBlobContainersClient() + if err != nil { + return "", fmt.Errorf("failed to create storage client: %w", err) + } + container, err := containerClient.Create(ctx, c.resourceGroupName, storageAccount, blobContainerName, armstorage.BlobContainer{}, nil) + if err != nil { + var respErr *azcore.ResponseError + if errors.As(err, &respErr) && respErr.ErrorCode == "ContainerAlreadyExists" { + c.BlobContainerName = blobContainerName + } else { + return "", fmt.Errorf("failed to create blob container: %w", err) + } + } else { + c.BlobContainerName = *container.Name + } + + term.Infof("Using storage account %s and blob container %s", storageAccount, blobContainerName) + + return storageAccount, nil +} + +const managedIdentityName = "defang-cd-identity" + +// Well-known Azure built-in role definition IDs. +const ( + storageBlobDataContributorRoleID = "ba92f5b4-2d11-453d-a403-e96b0029c9fe" + contributorRoleID = "b24988ac-6180-42a0-ab88-20f7382dd24c" + userAccessAdministratorRoleID = "18d7d88d-d35e-4fb5-a5c3-7773c20a72d9" +) + +// assignRole assigns a built-in role to the given principal at the given scope. +// It silently ignores RoleAssignmentExists errors (idempotent). +func assignRole(ctx context.Context, raClient *armauthorization.RoleAssignmentsClient, subscriptionID, scope, roleDefID, principalID string) error { + fullRoleDefID := fmt.Sprintf("/subscriptions/%s/providers/Microsoft.Authorization/roleDefinitions/%s", subscriptionID, roleDefID) + _, err := raClient.Create(ctx, scope, uuid.NewString(), armauthorization.RoleAssignmentCreateParameters{ + Properties: &armauthorization.RoleAssignmentProperties{ + PrincipalID: to.Ptr(principalID), + RoleDefinitionID: to.Ptr(fullRoleDefID), + PrincipalType: to.Ptr(armauthorization.PrincipalTypeServicePrincipal), + }, + }, nil) + if err != nil { + var respErr *azcore.ResponseError + if !errors.As(err, &respErr) || respErr.ErrorCode != "RoleAssignmentExists" { + return err + } + } + return nil +} + +// SetUpManagedIdentity creates (or retrieves) a user-assigned managed identity, assigns it +// Contributor on the subscription (for Pulumi to provision resources), and Storage Blob Data +// Contributor on the storage account (for Pulumi state and payload access). +// The identity resource ID is stored in c.ManagedIdentityID. +func (c *ContainerInstance) SetUpManagedIdentity(ctx context.Context) error { + cred, err := c.NewCreds() + if err != nil { + return err + } + + msiClient, err := armmsi.NewUserAssignedIdentitiesClient(c.SubscriptionID, cred, nil) + if err != nil { + return fmt.Errorf("failed to create MSI client: %w", err) + } + + identity, err := msiClient.CreateOrUpdate(ctx, c.resourceGroupName, managedIdentityName, armmsi.Identity{ + Location: c.Location.Ptr(), + }, nil) + if err != nil { + return fmt.Errorf("failed to create managed identity: %w", err) + } + c.ManagedIdentityID = *identity.ID + principalID := *identity.Properties.PrincipalID + + raClient, err := armauthorization.NewRoleAssignmentsClient(c.SubscriptionID, cred, nil) + if err != nil { + return fmt.Errorf("failed to create role assignments client: %w", err) + } + + // Contributor + User Access Administrator on the subscription so Pulumi can provision any + // Azure resource and create role assignments (e.g. ACR pull role for Container Apps). + subscriptionScope := "/subscriptions/" + c.SubscriptionID + if err := assignRole(ctx, raClient, c.SubscriptionID, subscriptionScope, contributorRoleID, principalID); err != nil { + return fmt.Errorf("failed to assign Contributor role: %w", err) + } + if err := assignRole(ctx, raClient, c.SubscriptionID, subscriptionScope, userAccessAdministratorRoleID, principalID); err != nil { + return fmt.Errorf("failed to assign User Access Administrator role: %w", err) + } + + // Storage Blob Data Contributor on the storage account for Pulumi state and payload access. + storageScope := fmt.Sprintf( + "/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Storage/storageAccounts/%s", + c.SubscriptionID, c.resourceGroupName, c.StorageAccount, + ) + if err := assignRole(ctx, raClient, c.SubscriptionID, storageScope, storageBlobDataContributorRoleID, principalID); err != nil { + return fmt.Errorf("failed to assign Storage Blob Data Contributor role: %w", err) + } + + return nil +} diff --git a/src/pkg/clouds/azure/aci/setup_test.go b/src/pkg/clouds/azure/aci/setup_test.go new file mode 100644 index 000000000..c967e5a75 --- /dev/null +++ b/src/pkg/clouds/azure/aci/setup_test.go @@ -0,0 +1,30 @@ +//go:build integration + +package aci + +import ( + "context" + "testing" +) + +func TestSetup(t *testing.T) { + if testing.Short() { + t.Skip("skipping slow integration test in short mode") + } + + c := NewContainerInstance(testResourceGroupName, "westeurope") + + t.Run("SetUpResourceGroup", func(t *testing.T) { + err := c.SetUpResourceGroup(context.Background()) + if err != nil { + t.Errorf("Failed to set up resource group: %v", err) + } + }) + + t.Run("TearDown", func(t *testing.T) { + err := c.TearDown(context.Background()) + if err != nil { + t.Fatalf("Failed to tear down container instance: %v", err) + } + }) +} diff --git a/src/pkg/clouds/azure/aci/status.go b/src/pkg/clouds/azure/aci/status.go new file mode 100644 index 000000000..ddeeef332 --- /dev/null +++ b/src/pkg/clouds/azure/aci/status.go @@ -0,0 +1,70 @@ +package aci + +import ( + "context" + "fmt" +) + +// GetContainerGroupStatus checks the current state of a container group. +// Returns (true, nil) when all containers finished with exit code 0, +// (true, error) when one or more containers failed, and (false, nil) when still running. +func (c *ContainerInstance) GetContainerGroupStatus(ctx context.Context, groupName ContainerGroupName) (bool, error) { + if groupName == nil { + return false, nil + } + + client, err := c.newContainerGroupClient() + if err != nil { + return false, err + } + + resp, err := client.Get(ctx, c.resourceGroupName, *groupName, nil) + if err != nil { + return false, fmt.Errorf("failed to get container group: %w", err) + } + + props := resp.ContainerGroup.Properties + if props == nil { + return false, nil + } + + // Check group-level instance view state + if props.InstanceView != nil && props.InstanceView.State != nil { + switch *props.InstanceView.State { + case "Stopped", "Succeeded": + // All containers have exited; check exit codes + case "Failed": + return true, fmt.Errorf("container group %q failed", *groupName) + default: + // Still provisioning or running + return false, nil + } + } else { + // InstanceView not yet available + return false, nil + } + + // Check each container's exit code + for _, container := range props.Containers { + if container.Properties == nil || container.Properties.InstanceView == nil { + continue + } + state := container.Properties.InstanceView.CurrentState + if state == nil { + continue + } + if state.ExitCode != nil && *state.ExitCode != 0 { + name := "" + if container.Name != nil { + name = *container.Name + } + detail := "" + if state.DetailStatus != nil { + detail = ": " + *state.DetailStatus + } + return true, fmt.Errorf("container %q exited with code %d%s", name, *state.ExitCode, detail) + } + } + + return true, nil +} diff --git a/src/pkg/clouds/azure/aci/stop.go b/src/pkg/clouds/azure/aci/stop.go new file mode 100644 index 000000000..3d288290d --- /dev/null +++ b/src/pkg/clouds/azure/aci/stop.go @@ -0,0 +1,22 @@ +package aci + +import ( + "context" + "errors" +) + +func (c *ContainerInstance) Stop(ctx context.Context, groupName ContainerGroupName) error { + if groupName == nil { + return errors.New("container group name is nil") + } + containerGroupClient, err := c.newContainerGroupClient() + if err != nil { + return err + } + + _, err = containerGroupClient.Stop(ctx, c.resourceGroupName, *groupName, nil) + if err != nil { + return err + } + return nil +} diff --git a/src/pkg/clouds/azure/aci/tail.go b/src/pkg/clouds/azure/aci/tail.go new file mode 100644 index 000000000..b8decd10e --- /dev/null +++ b/src/pkg/clouds/azure/aci/tail.go @@ -0,0 +1,199 @@ +package aci + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerinstance/armcontainerinstance/v2" + "github.com/gorilla/websocket" +) + +type LogEntry struct { + Message string + Stderr bool + Err error + Time time.Time +} + +func (c *ContainerInstance) Tail(ctx context.Context, groupName ContainerGroupName, containerName string) error { + ch, err := c.StreamLogs(ctx, groupName, containerName) + if err != nil { + return err + } + + for entry := range ch { + if entry.Err != nil { + return entry.Err + } + if entry.Stderr { + fmt.Fprint(os.Stderr, entry.Message) + } else { + fmt.Print(entry.Message) + } + } + return io.EOF +} + +func (c *ContainerInstance) QueryLogs(ctx context.Context, groupName ContainerGroupName, containerName string) (string, error) { + client, err := c.newContainerClient() + if err != nil { + return "", err + } + + for { + logResponse, err := client.ListLogs(ctx, c.resourceGroupName, *groupName, containerName, nil) + if err != nil { + var respErr *azcore.ResponseError + if errors.As(err, &respErr) && respErr.ErrorCode == "ContainerGroupDeploymentNotReady" { + select { + case <-ctx.Done(): + return "", ctx.Err() + case <-time.After(time.Second): + continue // Retry if the deployment is not ready yet + } + } + return "", fmt.Errorf("failed to list logs: %w", err) + } + if logResponse.Logs.Content == nil { + return "", io.EOF + } + return *logResponse.Logs.Content, nil + } +} + +const pollInterval = 2 * time.Second + +// PollLogs streams container logs by periodically calling ListLogs and emitting new content. +// Unlike the websocket attach, this captures output produced before the call was made. +func (c *ContainerInstance) PollLogs(ctx context.Context, groupName ContainerGroupName, containerName string) (<-chan LogEntry, error) { + ch := make(chan LogEntry) + go func() { + defer close(ch) + var offset int + poll := func() bool { + content, err := c.QueryLogs(ctx, groupName, containerName) + if err != nil { + if errors.Is(err, io.EOF) || errors.Is(err, context.Canceled) { + return false + } + // Container may still be starting; keep polling. + return true + } + if len(content) <= offset { + return true + } + newContent := content[offset:] + offset = len(content) + now := time.Now() + for line := range strings.SplitSeq(newContent, "\n") { + if line == "" { + continue + } + select { + case ch <- LogEntry{Message: line, Time: now}: + case <-ctx.Done(): + return false + } + } + return true + } + + // Query immediately before the first tick so we don't miss early output. + if !poll() { + return + } + ticker := time.NewTicker(pollInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if !poll() { + return + } + } + } + }() + return ch, nil +} + +func (c *ContainerInstance) StreamLogs(ctx context.Context, groupName ContainerGroupName, containerName string) (<-chan LogEntry, error) { + client, err := c.newContainerClient() + if err != nil { + return nil, err + } + + var attachResponse armcontainerinstance.ContainersClientAttachResponse + for { + attachResponse, err = client.Attach(ctx, c.resourceGroupName, *groupName, containerName, nil) + if err != nil { + var respErr *azcore.ResponseError + if errors.As(err, &respErr) && respErr.ErrorCode == "ContainerNotFound" { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(time.Second): + continue // Retry if the container is not found yet + } + } + return nil, fmt.Errorf("failed to attach to container: %w", err) + } + break + } + + header := http.Header{} + header.Set("Authorization", *attachResponse.Password) + conn, resp, err := websocket.DefaultDialer.DialContext(ctx, *attachResponse.WebSocketURI, header) + if err != nil { + if resp != nil { + resp.Body.Close() + return nil, fmt.Errorf("failed to connect to websocket (%s): %w", resp.Status, err) + } + return nil, fmt.Errorf("failed to connect to websocket: %w", err) + } + defer resp.Body.Close() + + ctx, cancel := context.WithCancel(ctx) + + go func() { + <-ctx.Done() + _ = conn.Close() // unblock conn.ReadMessage + }() + + ch := make(chan LogEntry) + go func() { + defer close(ch) + defer cancel() + + for { + _, logLine, err := conn.ReadMessage() + if err != nil { + if !websocket.IsCloseError(err, websocket.CloseNormalClosure) { + select { + case ch <- LogEntry{Err: err}: + case <-ctx.Done(): + } + } + return + } + if len(logLine) == 0 { + continue + } + stdioFd := logLine[0] + select { + case ch <- LogEntry{Message: string(logLine[1:]), Stderr: stdioFd == 2}: + case <-ctx.Done(): + return + } + } + }() + return ch, nil +} diff --git a/src/pkg/clouds/azure/aci/tail_test.go b/src/pkg/clouds/azure/aci/tail_test.go new file mode 100644 index 000000000..8b77f3882 --- /dev/null +++ b/src/pkg/clouds/azure/aci/tail_test.go @@ -0,0 +1,51 @@ +//go:build integration + +package aci + +import ( + "context" + "io" + "testing" +) + +func TestTail(t *testing.T) { + if testing.Short() { + t.Skip("skipping slow integration test in short mode") + } + + ctx := context.Background() + + containerInstance := NewContainerInstance(testResourceGroupName, "westeurope") + + err := containerInstance.SetUpResourceGroup(ctx) + if err != nil { + t.Fatalf("SetUpResourceGroup failed: %v", err) + } + + t.Cleanup(func() { + // err := containerInstance.TearDown(ctx) + // if err != nil { + // t.Fatalf("Failed to tear down container instance: %v", err) + // } + }) + + taskID, err := containerInstance.Run(ctx, nil, nil) + if err != nil { + t.Fatalf("Run failed: %v", err) + } + if taskID == nil { + t.Fatal("Expected non-nil task ID") + } + + t.Cleanup(func() { + err := containerInstance.Stop(ctx, taskID) + if err != nil { + t.Fatalf("Failed to stop container instance: %v", err) + } + }) + + err = containerInstance.Tail(ctx, taskID, "") + if err != io.EOF { + t.Fatalf("Tail failed: %v", err) + } +} diff --git a/src/pkg/clouds/azure/aci/upload.go b/src/pkg/clouds/azure/aci/upload.go new file mode 100644 index 000000000..5070794d6 --- /dev/null +++ b/src/pkg/clouds/azure/aci/upload.go @@ -0,0 +1,84 @@ +package aci + +import ( + "context" + "errors" + "fmt" + "net/url" + "os" + "strings" + "time" + + "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob" + "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/sas" + "github.com/google/uuid" +) + +func (c *ContainerInstance) CreateUploadURL(ctx context.Context, blobName string) (string, error) { + if blobName == "" { + blobName = uuid.NewString() + } else { + if len(blobName) > 64 { + return "", errors.New("name must be less than 64 characters") + } + // Sanitize the digest so it's safe to use as a file name + blobName = strings.ReplaceAll(blobName, "/", "_") + // name = path.Join(buildsPath, tenantName.String(), digest); TODO: avoid collisions between tenants + } + if _, err := c.SetUpStorageAccount(ctx); err != nil { + return "", err + } + + now := time.Now().UTC() + expiry := now.Add(1 * time.Hour) + + // TODO: using user delegation is more secure than shared key, but requires the user to reauth to a OAuth2 client with the appropriate permissions to discover the user's ObjectID + // userCred, err := client.GetUserDelegationCredential(ctx, service.KeyInfo{ + // Start: to.Ptr(now.Format(time.RFC3339)), + // Expiry: to.Ptr(expiry.Format(time.RFC3339)), + // }, nil) + // if err != nil { + // return "", err + // } + + storageKey := os.Getenv("AZURE_STORAGE_KEY") + if storageKey == "" { + accountsClient, err := c.NewStorageAccountsClient() + if err != nil { + return "", err + } + + keys, err := accountsClient.ListKeys(ctx, c.resourceGroupName, c.StorageAccount, nil) + if err != nil { + return "", err + } + if len(keys.Keys) == 0 || keys.Keys[0].Value == nil { + return "", errors.New("no storage account keys returned") + } + storageKey = *keys.Keys[0].Value + } + + keyCred, err := azblob.NewSharedKeyCredential(c.StorageAccount, storageKey) + if err != nil { + return "", err + } + + // Read permission is required: ACR tasks fetch the build context directly from the blob URL. + // The CD container payload uses managed identity for read access, but ACR has no identity + // attached and relies solely on the SAS token to download the build context archive. + perms := sas.BlobPermissions{Create: true, Write: true, Read: true} + sasQueryParams, err := sas.BlobSignatureValues{ + BlobName: blobName, + ContainerName: c.BlobContainerName, + ExpiryTime: expiry, + Permissions: perms.String(), + Protocol: sas.ProtocolHTTPS, + }.SignWithSharedKey(keyCred) + if err != nil { + return "", err + } + + serviceURL := fmt.Sprintf("https://%s.blob.core.windows.net/", c.StorageAccount) + sasURL := fmt.Sprintf("%s%s/%s?%s", serviceURL, c.BlobContainerName, url.PathEscape(blobName), sasQueryParams.Encode()) + return sasURL, nil +} diff --git a/src/pkg/clouds/azure/aci/upload_test.go b/src/pkg/clouds/azure/aci/upload_test.go new file mode 100644 index 000000000..b553d0d8b --- /dev/null +++ b/src/pkg/clouds/azure/aci/upload_test.go @@ -0,0 +1,43 @@ +//go:build integration + +package aci + +import ( + "context" + "io" + "strings" + "testing" + + "github.com/DefangLabs/defang/src/pkg/http" +) + +func TestCreateUploadURL(t *testing.T) { + // Create a new container instance + container := NewContainerInstance("defang-cd", "westeurope") + + // Call the CreateUploadURL method + url, err := container.CreateUploadURL(context.Background(), "sha256-Jv4+base64/encoded_digest=") + if err != nil { + t.Fatalf("failed to create upload URL: %v", err) + } + + // Verify the URL is not empty + if url == "" { + t.Fatal("expected non-empty upload URL") + } + + t.Log("Upload URL generated") + header := http.Header{"Content-Type": []string{"application/text"}} + header.Set("X-Ms-Blob-Type", "BlockBlob") + resp, err := http.PutWithHeader(context.Background(), url, header, strings.NewReader("test content")) + if err != nil { + t.Fatalf("failed to upload content: %v", err) + } + defer resp.Body.Close() + + // Verify the response + if resp.StatusCode != 201 { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("expected status OK, got %v: %s", resp.Status, body) + } +} diff --git a/src/pkg/clouds/azure/acr/acr.go b/src/pkg/clouds/azure/acr/acr.go new file mode 100644 index 000000000..2e7175e61 --- /dev/null +++ b/src/pkg/clouds/azure/acr/acr.go @@ -0,0 +1,254 @@ +package acr + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "os" + "strings" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerregistry/armcontainerregistry/v2" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v2" + "github.com/DefangLabs/defang/src/pkg/clouds/azure" +) + +type ACR struct { + azure.Azure + resourceGroupName string + RegistryName string +} + +func New(resourceGroupPrefix string, location azure.Location) *ACR { + if location == "" { + location = azure.Location(os.Getenv("AZURE_LOCATION")) + } + return &ACR{ + Azure: azure.Azure{ + Location: location, + SubscriptionID: os.Getenv("AZURE_SUBSCRIPTION_ID"), + }, + resourceGroupName: resourceGroupPrefix + location.String(), + } +} + +func (a *ACR) newRegistriesClient() (*armcontainerregistry.RegistriesClient, error) { + cred, err := a.NewCreds() + if err != nil { + return nil, err + } + client, err := armcontainerregistry.NewRegistriesClient(a.SubscriptionID, cred, nil) + if err != nil { + return nil, fmt.Errorf("failed to create registries client: %w", err) + } + return client, nil +} + +func (a *ACR) newResourceGroupClient() (*armresources.ResourceGroupsClient, error) { + cred, err := a.NewCreds() + if err != nil { + return nil, err + } + client, err := armresources.NewResourceGroupsClient(a.SubscriptionID, cred, nil) + if err != nil { + return nil, fmt.Errorf("failed to create resource group client: %w", err) + } + return client, nil +} + +func (a *ACR) newRunsClient() (*armcontainerregistry.RunsClient, error) { + cred, err := a.NewCreds() + if err != nil { + return nil, err + } + client, err := armcontainerregistry.NewRunsClient(a.SubscriptionID, cred, nil) + if err != nil { + return nil, fmt.Errorf("failed to create runs client: %w", err) + } + return client, nil +} + +// SetUpRegistry ensures the resource group exists and creates a Basic-tier ACR. +func (a *ACR) SetUpRegistry(ctx context.Context, registryName string) error { + rgClient, err := a.newResourceGroupClient() + if err != nil { + return err + } + _, err = rgClient.CreateOrUpdate(ctx, a.resourceGroupName, armresources.ResourceGroup{ + Location: a.Location.Ptr(), + }, nil) + if err != nil { + return fmt.Errorf("failed to create resource group: %w", err) + } + + client, err := a.newRegistriesClient() + if err != nil { + return err + } + + a.RegistryName = registryName + + poller, err := client.BeginCreate(ctx, a.resourceGroupName, registryName, armcontainerregistry.Registry{ + Location: a.Location.Ptr(), + SKU: &armcontainerregistry.SKU{ + Name: to.Ptr(armcontainerregistry.SKUNameBasic), + }, + }, nil) + if err != nil { + return fmt.Errorf("failed to create container registry: %w", err) + } + + _, err = poller.PollUntilDone(ctx, nil) + if err != nil { + return fmt.Errorf("failed to poll registry creation: %w", err) + } + + return nil +} + +// GetBuildSourceUploadURL returns a URL where the build context can be uploaded. +func (a *ACR) GetBuildSourceUploadURL(ctx context.Context) (uploadURL, relativePath string, err error) { + client, err := a.newRegistriesClient() + if err != nil { + return "", "", err + } + + resp, err := client.GetBuildSourceUploadURL(ctx, a.resourceGroupName, a.RegistryName, nil) + if err != nil { + return "", "", fmt.Errorf("failed to get source upload URL: %w", err) + } + + return *resp.UploadURL, *resp.RelativePath, nil +} + +// RunTask schedules an ACR task that runs a container image and returns the run ID. +func (a *ACR) RunTask(ctx context.Context, req TaskRequest) (string, error) { + client, err := a.newRegistriesClient() + if err != nil { + return "", err + } + + // Build the YAML task definition with a single cmd step + var yaml strings.Builder + yaml.WriteString("steps:\n") + yaml.WriteString(" - cmd: " + req.Image) + if len(req.Command) > 0 { + // Arguments after the image are passed as the command + yaml.WriteString(" " + strings.Join(req.Command, " ")) + } + yaml.WriteString("\n") + + runRequest := &armcontainerregistry.EncodedTaskRunRequest{ + Type: to.Ptr("EncodedTaskRunRequest"), + EncodedTaskContent: to.Ptr(base64.StdEncoding.EncodeToString([]byte(yaml.String()))), + Platform: &armcontainerregistry.PlatformProperties{ + OS: to.Ptr(armcontainerregistry.OSLinux), + Architecture: to.Ptr(armcontainerregistry.ArchitectureAmd64), + }, + IsArchiveEnabled: to.Ptr(false), + Timeout: to.Ptr(int32(req.Timeout.Seconds())), + } + + if req.SourceLocation != "" { + runRequest.SourceLocation = to.Ptr(req.SourceLocation) + } + + // Pass env vars and secrets as task values + for k, v := range req.Envs { + runRequest.Values = append(runRequest.Values, &armcontainerregistry.SetValue{ + Name: to.Ptr(k), + Value: to.Ptr(v), + IsSecret: to.Ptr(false), + }) + } + for k, v := range req.SecretEnvs { + runRequest.Values = append(runRequest.Values, &armcontainerregistry.SetValue{ + Name: to.Ptr(k), + Value: to.Ptr(v), + IsSecret: to.Ptr(true), + }) + } + + poller, err := client.BeginScheduleRun(ctx, a.resourceGroupName, a.RegistryName, runRequest, nil) + if err != nil { + return "", fmt.Errorf("failed to schedule task: %w", err) + } + + result, err := poller.PollUntilDone(ctx, nil) + if err != nil { + return "", fmt.Errorf("failed to poll scheduled run: %w", err) + } + + if result.Run.Properties == nil || result.Run.Properties.RunID == nil { + return "", errors.New("scheduled run has no run ID") + } + + return *result.Run.Properties.RunID, nil +} + +// GetRunStatus returns the current status of a run. +func (a *ACR) GetRunStatus(ctx context.Context, runID string) (*RunStatus, error) { + client, err := a.newRunsClient() + if err != nil { + return nil, err + } + + resp, err := client.Get(ctx, a.resourceGroupName, a.RegistryName, runID, nil) + if err != nil { + return nil, fmt.Errorf("failed to get run: %w", err) + } + + props := resp.Run.Properties + if props == nil { + return nil, fmt.Errorf("run %s has no properties", runID) + } + + status := &RunStatus{ + RunID: runID, + Status: *props.Status, + } + if props.RunErrorMessage != nil { + status.ErrorMessage = *props.RunErrorMessage + } + return status, nil +} + +// GetRunLogURL returns a SAS URL to download the run's logs. +func (a *ACR) GetRunLogURL(ctx context.Context, runID string) (string, error) { + client, err := a.newRunsClient() + if err != nil { + return "", err + } + + resp, err := client.GetLogSasURL(ctx, a.resourceGroupName, a.RegistryName, runID, nil) + if err != nil { + return "", fmt.Errorf("failed to get log URL: %w", err) + } + + if resp.LogLink == nil { + return "", fmt.Errorf("no log URL for run %s", runID) + } + return *resp.LogLink, nil +} + +// CancelRun cancels a running ACR task. +func (a *ACR) CancelRun(ctx context.Context, runID string) error { + client, err := a.newRunsClient() + if err != nil { + return err + } + + poller, err := client.BeginCancel(ctx, a.resourceGroupName, a.RegistryName, runID, nil) + if err != nil { + return fmt.Errorf("failed to cancel run: %w", err) + } + + _, err = poller.PollUntilDone(ctx, nil) + return err +} + +// LoginServer returns the ACR login server URL (e.g. "myregistry.azurecr.io"). +func (a *ACR) LoginServer() string { + return a.RegistryName + ".azurecr.io" +} diff --git a/src/pkg/clouds/azure/acr/acr_test.go b/src/pkg/clouds/azure/acr/acr_test.go new file mode 100644 index 000000000..2008f251e --- /dev/null +++ b/src/pkg/clouds/azure/acr/acr_test.go @@ -0,0 +1,122 @@ +//go:build integration + +package acr + +import ( + "context" + "testing" + "time" + + "github.com/DefangLabs/defang/src/pkg" +) + +var testResourceGroupName = "crun-test-" + pkg.GetCurrentUser() + +func TestSetUpRegistry(t *testing.T) { + if testing.Short() { + t.Skip("skipping slow integration test in short mode") + } + + ctx := t.Context() + + acr := New(testResourceGroupName, "westus2") + registryName := "defangtest" + pkg.RandomID() + + err := acr.SetUpRegistry(ctx, registryName) + if err != nil { + t.Fatalf("SetUpRegistry failed: %v", err) + } + + t.Cleanup(func() { + ctx := context.Background() + client, err := acr.newRegistriesClient() + if err != nil { + t.Logf("cleanup: failed to create client: %v", err) + return + } + poller, err := client.BeginDelete(ctx, acr.resourceGroupName, registryName, nil) + if err != nil { + t.Logf("cleanup: failed to delete registry: %v", err) + return + } + _, _ = poller.PollUntilDone(ctx, nil) + }) + + if acr.LoginServer() == "" { + t.Fatal("expected non-empty login server") + } + t.Logf("Registry login server: %s", acr.LoginServer()) +} + +func TestRunTask(t *testing.T) { + if testing.Short() { + t.Skip("skipping slow integration test in short mode") + } + + ctx := t.Context() + + acr := New(testResourceGroupName, "westus2") + registryName := "defangtest" + pkg.RandomID() + + err := acr.SetUpRegistry(ctx, registryName) + if err != nil { + t.Fatalf("SetUpRegistry failed: %v", err) + } + + t.Cleanup(func() { + ctx := context.Background() + client, err := acr.newRegistriesClient() + if err != nil { + return + } + poller, _ := client.BeginDelete(ctx, acr.resourceGroupName, registryName, nil) + if poller != nil { + _, _ = poller.PollUntilDone(ctx, nil) + } + }) + + runID, err := acr.RunTask(ctx, TaskRequest{ + Image: "alpine:latest", + Command: []string{"echo", "hello from ACR task"}, + Timeout: 1 * time.Minute, + }) + if err != nil { + t.Fatalf("RunTask failed: %v", err) + } + t.Logf("Run ID: %s", runID) + + t.Run("GetRunStatus", func(t *testing.T) { + status, err := acr.GetRunStatus(ctx, runID) + if err != nil { + t.Fatalf("GetRunStatus failed: %v", err) + } + t.Logf("Status: %s (terminal=%v, success=%v)", status.Status, status.IsTerminal(), status.IsSuccess()) + }) + + t.Run("GetRunLogURL", func(t *testing.T) { + logURL, err := acr.GetRunLogURL(ctx, runID) + if err != nil { + t.Fatalf("GetRunLogURL failed: %v", err) + } + t.Logf("Log URL: %s", logURL) + }) + + t.Run("TailRunLogs", func(t *testing.T) { + logIter, err := acr.TailRunLogs(ctx, runID) + if err != nil { + t.Fatalf("TailRunLogs failed: %v", err) + } + var lineCount int + for line, err := range logIter { + if err != nil { + t.Fatalf("TailRunLogs yielded error: %v", err) + } + t.Logf(" %s", line) + lineCount++ + } + t.Logf("Total log lines: %d", lineCount) + if lineCount == 0 { + t.Fatal("expected at least one log line") + } + }) +} diff --git a/src/pkg/clouds/azure/acr/tail.go b/src/pkg/clouds/azure/acr/tail.go new file mode 100644 index 000000000..f3aa6c76b --- /dev/null +++ b/src/pkg/clouds/azure/acr/tail.go @@ -0,0 +1,133 @@ +package acr + +import ( + "context" + "errors" + "fmt" + "io" + "iter" + "net/http" + "strconv" + "strings" + "time" +) + +const logPollInterval = 2 * time.Second + +// TailRunLogs returns an iterator that streams log lines from an ACR task run. +// It polls the log blob with range requests and checks run status to detect completion. +// The iterator yields one line at a time. It stops when the run reaches a terminal +// state and all available log content has been read. +func (a *ACR) TailRunLogs(ctx context.Context, runID string) (iter.Seq2[string, error], error) { + logURL, err := a.GetRunLogURL(ctx, runID) + if err != nil { + return nil, fmt.Errorf("failed to get log URL: %w", err) + } + + return func(yield func(string, error) bool) { + var offset int64 + + for { + newOffset, err := readLogChunk(ctx, logURL, offset, yield) + offset = newOffset + if err != nil { + return // yield returned false, stop iteration + } + + // Check if the run is done + status, err := a.GetRunStatus(ctx, runID) + if err != nil { + yield("", fmt.Errorf("failed to get run status: %w", err)) + return + } + if status.IsTerminal() { + // Read any remaining log content + readLogChunk(ctx, logURL, offset, yield) + if !status.IsSuccess() { + msg := string(status.Status) + if status.ErrorMessage != "" { + msg += ": " + status.ErrorMessage + } + yield("", fmt.Errorf("build %s: %s", runID, msg)) + } + return + } + + select { + case <-ctx.Done(): + yield("", ctx.Err()) + return + case <-time.After(logPollInterval): + } + } + }, nil +} + +// readLogChunk fetches new log content starting at offset and yields each line. +// Returns the new offset and a non-nil error if yield returned false. +// Incomplete lines (no trailing newline) are held back until the next poll. +func readLogChunk(ctx context.Context, logURL string, offset int64, yield func(string, error) bool) (int64, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, logURL, nil) + if err != nil { + if !yield("", err) { + return offset, errStopped + } + return offset, nil + } + req.Header.Set("Range", "bytes="+strconv.FormatInt(offset, 10)+"-") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + if !yield("", fmt.Errorf("failed to fetch logs: %w", err)) { + return offset, errStopped + } + return offset, nil + } + defer resp.Body.Close() + + // 206 Partial Content = new data; 416 Range Not Satisfiable = no new data yet + if resp.StatusCode == http.StatusRequestedRangeNotSatisfiable { + return offset, nil + } + if resp.StatusCode != http.StatusPartialContent && resp.StatusCode != http.StatusOK { + if !yield("", fmt.Errorf("unexpected log response status: %s", resp.Status)) { + return offset, errStopped + } + return offset, nil + } + + // Read all new content and split into lines. Only yield complete lines + // (terminated by \n). Hold back the last incomplete line by not advancing + // offset past it — it will be re-fetched on the next poll. + body, err := io.ReadAll(resp.Body) + if err != nil { + if !yield("", fmt.Errorf("failed to read log body: %w", err)) { + return offset, errStopped + } + return offset, nil + } + + text := string(body) + if len(text) == 0 { + return offset, nil + } + lines := strings.Split(text, "\n") + + // If the last byte is not a newline, the last line is incomplete — hold it back + complete := lines + if text[len(text)-1] != '\n' { + complete = lines[:len(lines)-1] + } + + for _, line := range complete { + offset += int64(len(line) + 1) // +1 for the newline + if !yield(line, nil) { + return offset, errStopped + } + } + + return offset, nil +} + +// errStopped is a sentinel error indicating the consumer stopped iteration. +var errStopped = errors.New("iteration stopped") diff --git a/src/pkg/clouds/azure/acr/tail_test.go b/src/pkg/clouds/azure/acr/tail_test.go new file mode 100644 index 000000000..3a64bfba8 --- /dev/null +++ b/src/pkg/clouds/azure/acr/tail_test.go @@ -0,0 +1,163 @@ +package acr + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" +) + +func TestReadLogChunk_EmptyBody(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusPartialContent) + // empty body + })) + defer srv.Close() + + var yielded []string + yield := func(line string, err error) bool { + if err != nil { + t.Errorf("unexpected error: %v", err) + } + yielded = append(yielded, line) + return true + } + + newOffset, err := readLogChunk(t.Context(), srv.URL, 0, yield) + if err != nil { + t.Fatalf("unexpected sentinel error: %v", err) + } + if newOffset != 0 { + t.Errorf("expected offset 0, got %d", newOffset) + } + if len(yielded) != 0 { + t.Errorf("expected no lines yielded, got %d", len(yielded)) + } +} + +func TestReadLogChunk_CompleteLines(t *testing.T) { + body := "line1\nline2\nline3\n" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusPartialContent) + fmt.Fprint(w, body) + })) + defer srv.Close() + + var yielded []string + yield := func(line string, err error) bool { + if err != nil { + t.Errorf("unexpected error: %v", err) + } + yielded = append(yielded, line) + return true + } + + newOffset, err := readLogChunk(t.Context(), srv.URL, 0, yield) + if err != nil { + t.Fatalf("unexpected sentinel error: %v", err) + } + // strings.Split on a trailing-newline body produces one extra empty element, + // each element (including the empty one) adds len(line)+1 to the offset. + expectedOffset := int64(len(body)) + 1 // trailing empty element adds +1 + if newOffset != expectedOffset { + t.Errorf("expected offset %d, got %d", expectedOffset, newOffset) + } + // 3 real lines + 1 empty string from the trailing newline split + if len(yielded) != 4 { + t.Errorf("expected 4 yielded elements, got %d: %v", len(yielded), yielded) + } +} + +func TestReadLogChunk_IncompleteLastLine(t *testing.T) { + // Last line has no trailing newline — should be held back. + body := "line1\nline2\nincomplete" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusPartialContent) + fmt.Fprint(w, body) + })) + defer srv.Close() + + var yielded []string + yield := func(line string, err error) bool { + if err != nil { + t.Errorf("unexpected error: %v", err) + } + yielded = append(yielded, line) + return true + } + + newOffset, _ := readLogChunk(t.Context(), srv.URL, 0, yield) + // Only complete lines (line1, line2) should advance the offset. + expectedOffset := int64(len("line1\nline2\n")) + if newOffset != expectedOffset { + t.Errorf("expected offset %d, got %d", expectedOffset, newOffset) + } + if len(yielded) != 2 { + t.Errorf("expected 2 lines, got %d: %v", len(yielded), yielded) + } +} + +func TestReadLogChunk_RangeNotSatisfiable(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusRequestedRangeNotSatisfiable) + })) + defer srv.Close() + + called := false + yield := func(line string, err error) bool { + called = true + return true + } + + newOffset, err := readLogChunk(t.Context(), srv.URL, 42, yield) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if newOffset != 42 { + t.Errorf("expected offset unchanged at 42, got %d", newOffset) + } + if called { + t.Error("yield should not have been called for 416 response") + } +} + +func TestReadLogChunk_UnexpectedStatus(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + var gotErr error + yield := func(line string, err error) bool { + gotErr = err + return true + } + + readLogChunk(t.Context(), srv.URL, 0, yield) + if gotErr == nil { + t.Error("expected an error for unexpected status code") + } +} + +func TestReadLogChunk_YieldStopsIteration(t *testing.T) { + body := "line1\nline2\n" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusPartialContent) + fmt.Fprint(w, body) + })) + defer srv.Close() + + callCount := 0 + yield := func(line string, err error) bool { + callCount++ + return false // stop after first line + } + + _, sentinelErr := readLogChunk(t.Context(), srv.URL, 0, yield) + if sentinelErr != errStopped { + t.Errorf("expected errStopped, got %v", sentinelErr) + } + if callCount != 1 { + t.Errorf("expected yield called once, got %d", callCount) + } +} diff --git a/src/pkg/clouds/azure/acr/types.go b/src/pkg/clouds/azure/acr/types.go new file mode 100644 index 000000000..ff19cf0d7 --- /dev/null +++ b/src/pkg/clouds/azure/acr/types.go @@ -0,0 +1,49 @@ +package acr + +import ( + "time" + + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerregistry/armcontainerregistry/v2" +) + +// TaskRequest contains parameters for scheduling an ACR task run. +type TaskRequest struct { + // Image is the container image to run (e.g. "myregistry.azurecr.io/pulumi:latest"). + Image string + // Command is the command to run in the container. + Command []string + // Envs are environment variables passed to the task as template values. + Envs map[string]string + // SecretEnvs are secret environment variables (not shown in logs/API responses). + SecretEnvs map[string]string + // SourceLocation is the relative path returned by GetBuildSourceUploadURL, + // or an absolute URL to a tar.gz or git repo. Optional for tasks that don't need source. + SourceLocation string + // Timeout is the task timeout (default 1h). + Timeout time.Duration +} + +// RunStatus represents the status of an ACR task run. +type RunStatus struct { + RunID string + Status armcontainerregistry.RunStatus + ErrorMessage string +} + +// IsTerminal returns true if the run has reached a final state. +func (s RunStatus) IsTerminal() bool { + switch s.Status { + case armcontainerregistry.RunStatusSucceeded, + armcontainerregistry.RunStatusFailed, + armcontainerregistry.RunStatusCanceled, + armcontainerregistry.RunStatusError, + armcontainerregistry.RunStatusTimeout: + return true + } + return false +} + +// IsSuccess returns true if the run completed successfully. +func (s RunStatus) IsSuccess() bool { + return s.Status == armcontainerregistry.RunStatusSucceeded +} diff --git a/src/pkg/clouds/azure/acr/types_test.go b/src/pkg/clouds/azure/acr/types_test.go new file mode 100644 index 000000000..e8df2064a --- /dev/null +++ b/src/pkg/clouds/azure/acr/types_test.go @@ -0,0 +1,34 @@ +package acr + +import ( + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerregistry/armcontainerregistry/v2" +) + +func TestRunStatusHelpers(t *testing.T) { + tests := []struct { + status armcontainerregistry.RunStatus + isTerminal bool + isSuccess bool + }{ + {armcontainerregistry.RunStatusQueued, false, false}, + {armcontainerregistry.RunStatusStarted, false, false}, + {armcontainerregistry.RunStatusRunning, false, false}, + {armcontainerregistry.RunStatusSucceeded, true, true}, + {armcontainerregistry.RunStatusFailed, true, false}, + {armcontainerregistry.RunStatusCanceled, true, false}, + {armcontainerregistry.RunStatusError, true, false}, + {armcontainerregistry.RunStatusTimeout, true, false}, + } + + for _, tt := range tests { + s := RunStatus{Status: tt.status} + if s.IsTerminal() != tt.isTerminal { + t.Errorf("RunStatus(%s).IsTerminal() = %v, want %v", tt.status, s.IsTerminal(), tt.isTerminal) + } + if s.IsSuccess() != tt.isSuccess { + t.Errorf("RunStatus(%s).IsSuccess() = %v, want %v", tt.status, s.IsSuccess(), tt.isSuccess) + } + } +} diff --git a/src/pkg/clouds/azure/appcfg/appcfg.go b/src/pkg/clouds/azure/appcfg/appcfg.go new file mode 100644 index 000000000..c1e669732 --- /dev/null +++ b/src/pkg/clouds/azure/appcfg/appcfg.go @@ -0,0 +1,151 @@ +package appcfg + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" + "github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appconfiguration/armappconfiguration" + "github.com/DefangLabs/defang/src/pkg" + "github.com/DefangLabs/defang/src/pkg/clouds/azure" + "github.com/DefangLabs/defang/src/pkg/term" +) + +const storeNamePrefix = "defangcfg" + +// AppConfiguration wraps an Azure App Configuration store. +type AppConfiguration struct { + azure.Azure + resourceGroupName string + StoreName string + connectionString string // read-write access key for the data plane +} + +func New(resourceGroupName string, loc azure.Location, subscriptionID string) *AppConfiguration { + return &AppConfiguration{ + Azure: azure.Azure{ + Location: loc, + SubscriptionID: subscriptionID, + }, + resourceGroupName: resourceGroupName, + } +} + +func (a *AppConfiguration) newDataClient() (*azappconfig.Client, error) { + if a.connectionString == "" { + return nil, errors.New("App Configuration store not set up") + } + return azappconfig.NewClientFromConnectionString(a.connectionString, nil) +} + +// fetchConnectionString retrieves the read-write access key for the store. +func (a *AppConfiguration) fetchConnectionString(ctx context.Context, client *armappconfiguration.ConfigurationStoresClient) error { + pager := client.NewListKeysPager(a.resourceGroupName, a.StoreName, nil) + for pager.More() { + page, err := pager.NextPage(ctx) + if err != nil { + return fmt.Errorf("failed to list App Configuration keys: %w", err) + } + for _, key := range page.Value { + if key.ConnectionString != nil && (key.ReadOnly == nil || !*key.ReadOnly) { + a.connectionString = *key.ConnectionString + return nil + } + } + } + return fmt.Errorf("no read-write access key found for App Configuration store %s", a.StoreName) +} + +// SetUp creates the App Configuration store if it doesn't already exist in the resource group, +// fetches its read-write connection string, and populates StoreName and connectionString. +func (a *AppConfiguration) SetUp(ctx context.Context) error { + cred, err := a.NewCreds() + if err != nil { + return err + } + + client, err := armappconfiguration.NewConfigurationStoresClient(a.SubscriptionID, cred, nil) + if err != nil { + return err + } + + // Look for an existing store in the resource group. + pager := client.NewListByResourceGroupPager(a.resourceGroupName, nil) + for pager.More() { + page, err := pager.NextPage(ctx) + if err != nil { + return fmt.Errorf("failed to list App Configuration stores: %w", err) + } + for _, store := range page.Value { + if store.Name != nil && strings.HasPrefix(*store.Name, storeNamePrefix) { + a.StoreName = *store.Name + term.Debugf("Using existing App Configuration store %s", a.StoreName) + return a.fetchConnectionString(ctx, client) + } + } + } + + // None found — create one. + a.StoreName = storeNamePrefix + pkg.RandomID() + term.Debugf("Creating App Configuration store %s", a.StoreName) + poller, err := client.BeginCreate(ctx, a.resourceGroupName, a.StoreName, armappconfiguration.ConfigurationStore{ + Location: a.Location.Ptr(), + SKU: &armappconfiguration.SKU{Name: to.Ptr("Free")}, + }, nil) + if err != nil { + return fmt.Errorf("failed to create App Configuration store: %w", err) + } + if _, err := poller.PollUntilDone(ctx, nil); err != nil { + return fmt.Errorf("failed to poll App Configuration store creation: %w", err) + } + return a.fetchConnectionString(ctx, client) +} + +// PutSetting creates or updates a key in the App Configuration store. +func (a *AppConfiguration) PutSetting(ctx context.Context, key, value string) error { + client, err := a.newDataClient() + if err != nil { + return err + } + _, err = client.SetSetting(ctx, key, &value, nil) + return err +} + +// DeleteSetting removes a key from the App Configuration store. +func (a *AppConfiguration) DeleteSetting(ctx context.Context, key string) error { + client, err := a.newDataClient() + if err != nil { + return err + } + _, err = client.DeleteSetting(ctx, key, nil) + return err +} + +// ListSettings returns all keys with the given prefix, with the prefix stripped. +func (a *AppConfiguration) ListSettings(ctx context.Context, keyPrefix string) ([]string, error) { + client, err := a.newDataClient() + if err != nil { + return nil, err + } + + pager := client.NewListSettingsPager(azappconfig.SettingSelector{ + KeyFilter: to.Ptr(keyPrefix + "*"), + }, nil) + + var keys []string + for pager.More() { + page, err := pager.NextPage(ctx) + if err != nil { + return nil, fmt.Errorf("failed to list settings: %w", err) + } + for _, setting := range page.Settings { + if setting.Key != nil { + keys = append(keys, strings.TrimPrefix(*setting.Key, keyPrefix)) + } + } + } + return keys, nil +} diff --git a/src/pkg/clouds/azure/common.go b/src/pkg/clouds/azure/common.go new file mode 100644 index 000000000..1de8035b6 --- /dev/null +++ b/src/pkg/clouds/azure/common.go @@ -0,0 +1,93 @@ +package azure + +import ( + "context" + "errors" + "fmt" + "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/azidentity" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage/v2" +) + +// cliTimeout overrides the default 10s timeout for CLI-based credentials. +// The Azure CLI can be slow to start, especially when installed via Nix. +const cliTimeout = 30 * time.Second + +type Azure struct { + Location Location + SubscriptionID string +} + +// tokenCredentialWithTimeout wraps an azcore.TokenCredential to ensure +// GetToken has a minimum deadline, overriding the SDK's default 10s CLI timeout. +type tokenCredentialWithTimeout struct { + cred azcore.TokenCredential + timeout time.Duration +} + +func (t *tokenCredentialWithTimeout) GetToken(ctx context.Context, opts policy.TokenRequestOptions) (azcore.AccessToken, error) { + if _, hasDeadline := ctx.Deadline(); !hasDeadline { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, t.timeout) + defer cancel() + } + return t.cred.GetToken(ctx, opts) +} + +func (a Azure) NewCreds() (azcore.TokenCredential, error) { + if len(a.SubscriptionID) == 0 { + return nil, errors.New("environment variable AZURE_SUBSCRIPTION_ID is not set") + } + + cred, err := azidentity.NewDefaultAzureCredential(nil) + if err != nil { + return nil, fmt.Errorf("failed to create default Azure credentials: %w", err) + } + + return &tokenCredentialWithTimeout{cred: cred, timeout: cliTimeout}, nil +} + +func (a Azure) NewStorageAccountsClient() (*armstorage.AccountsClient, error) { + cred, err := a.NewCreds() + if err != nil { + return nil, err + } + + clientFactory, err := armstorage.NewClientFactory(a.SubscriptionID, cred, nil) + if err != nil { + return nil, fmt.Errorf("failed to create storage client: %w", err) + } + + return clientFactory.NewAccountsClient(), nil +} + +func (a Azure) NewBlobContainersClient() (*armstorage.BlobContainersClient, error) { + cred, err := a.NewCreds() + if err != nil { + return nil, err + } + + clientFactory, err := armstorage.NewClientFactory(a.SubscriptionID, cred, nil) + if err != nil { + return nil, fmt.Errorf("failed to create storage client: %w", err) + } + + return clientFactory.NewBlobContainersClient(), nil +} + +// func (a Azure) NewRoleAssignmentsClient() (*armauthorization.RoleAssignmentsClient, error) { +// cred, err := a.NewCreds() +// if err != nil { +// return nil, err +// } + +// clientFactory, err := armauthorization.NewRoleAssignmentsClient(a.SubscriptionID, cred, nil) +// if err != nil { +// return nil, fmt.Errorf("failed to create role assignments client: %w", err) +// } + +// return clientFactory, nil +// } diff --git a/src/pkg/clouds/azure/location.go b/src/pkg/clouds/azure/location.go new file mode 100644 index 000000000..d1023e243 --- /dev/null +++ b/src/pkg/clouds/azure/location.go @@ -0,0 +1,120 @@ +package azure + +type Location string + +const ( + LocationAsia Location = "asia" + LocationAsiaPacific Location = "asiapacific" + LocationAustralia Location = "australia" + LocationAustraliaCentral Location = "australiacentral" + LocationAustraliaCentral2 Location = "australiacentral2" + LocationAustraliaEast Location = "australiaeast" + LocationAustraliaSouthEast Location = "australiasoutheast" + LocationAustriaEast Location = "austriaeast" + LocationBrazil Location = "brazil" + LocationBrazilSouth Location = "brazilsouth" + LocationBrazilSouthEast Location = "brazilsoutheast" + LocationBrazilUS Location = "brazilus" + LocationCanada Location = "canada" + LocationCanadaCentral Location = "canadacentral" + LocationCanadaEast Location = "canadaeast" + LocationCentralIndia Location = "centralindia" + LocationCentralUS Location = "centralus" + LocationCentralUSEuap Location = "centraluseuap" + LocationCentralUSStage Location = "centralusstage" + LocationChileCentral Location = "chilecentral" + LocationEastAsia Location = "eastasia" + LocationEastAsiaStage Location = "eastasiastage" + LocationEastUS Location = "eastus" + LocationEastUS2 Location = "eastus2" + LocationEastUS2Euap Location = "eastus2euap" + LocationEastUS2Stage Location = "eastus2stage" + LocationEastUSStage Location = "eastusstage" + LocationEastUSStg Location = "eastusstg" + LocationEurope Location = "europe" + LocationFrance Location = "france" + LocationFranceCentral Location = "francecentral" + LocationFranceSouth Location = "francesouth" + LocationGermany Location = "germany" + LocationGermanyNorth Location = "germanynorth" + LocationGermanyWestCentral Location = "germanywestcentral" + LocationGlobal Location = "global" + LocationIndia Location = "india" + LocationIndonesia Location = "indonesia" + LocationIndonesiaCentral Location = "indonesiacentral" + LocationIsrael Location = "israel" + LocationIsraelCentral Location = "israelcentral" + LocationItaly Location = "italy" + LocationItalyNorth Location = "italynorth" + LocationJapan Location = "japan" + LocationJapanEast Location = "japaneast" + LocationJapanWest Location = "japanwest" + LocationJioIndiaCentral Location = "jioindiacentral" + LocationJioIndiaWest Location = "jioindiawest" + LocationKorea Location = "korea" + LocationKoreaCentral Location = "koreacentral" + LocationKoreaSouth Location = "koreasouth" + LocationMalaysia Location = "malaysia" + LocationMalaysiaWest Location = "malaysiawest" + LocationMexico Location = "mexico" + LocationMexicoCentral Location = "mexicocentral" + LocationNewZealand Location = "newzealand" + LocationNewZealandNorth Location = "newzealandnorth" + LocationNorthCentralUS Location = "northcentralus" + LocationNorthCentralUSStage Location = "northcentralusstage" + LocationNorthEurope Location = "northeurope" + LocationNorway Location = "norway" + LocationNorwayEast Location = "norwayeast" + LocationNorwayWest Location = "norwaywest" + LocationPoland Location = "poland" + LocationPolandCentral Location = "polandcentral" + LocationQatar Location = "qatar" + LocationQatarCentral Location = "qatarcentral" + LocationSingapore Location = "singapore" + LocationSouthAfrica Location = "southafrica" + LocationSouthAfricaNorth Location = "southafricanorth" + LocationSouthAfricaWest Location = "southafricawest" + LocationSouthCentralUS Location = "southcentralus" + LocationSouthCentralUSStage Location = "southcentralusstage" + LocationSouthCentralUSStg Location = "southcentralusstg" + LocationSoutheastAsia Location = "southeastasia" + LocationSoutheastAsiaStage Location = "southeastasiastage" + LocationSouthIndia Location = "southindia" + LocationSpain Location = "spain" + LocationSpainCentral Location = "spaincentral" + LocationSweden Location = "sweden" + LocationSwedenCentral Location = "swedencentral" + LocationSwedenSouth Location = "swedensouth" + LocationSwitzerland Location = "switzerland" + LocationSwitzerlandNorth Location = "switzerlandnorth" + LocationSwitzerlandWest Location = "switzerlandwest" + LocationTaiwan Location = "taiwan" + LocationUae Location = "uae" + LocationUaeCentral Location = "uaecentral" + LocationUaeNorth Location = "uaenorth" + LocationUK Location = "uk" + LocationUKSouth Location = "uksouth" + LocationUKWest Location = "ukwest" + LocationUnitedStates Location = "unitedstates" + LocationUnitedStatesEuap Location = "unitedstateseuap" + LocationWestCentralUS Location = "westcentralus" + LocationWestEurope Location = "westeurope" + LocationWestIndia Location = "westindia" + LocationWestUS Location = "westus" + LocationWestUS2 Location = "westus2" + LocationWestUS2Stage Location = "westus2stage" + LocationWestUS3 Location = "westus3" + LocationWestUSStage Location = "westusstage" +) + +func (l Location) String() string { + return string(l) +} + +func (l Location) Ptr() *string { + if l == "" { + return nil + } + s := string(l) + return &s +} diff --git a/src/pkg/http/post.go b/src/pkg/http/post.go index 3adb34019..67fae830d 100644 --- a/src/pkg/http/post.go +++ b/src/pkg/http/post.go @@ -36,13 +36,15 @@ func PostFormWithContext(ctx context.Context, url string, data url.Values) (*htt return PostWithContext(ctx, url, "application/x-www-form-urlencoded", strings.NewReader(data.Encode())) } -func PostWithContext(ctx context.Context, url, contentType string, body io.Reader) (*http.Response, error) { +func PostWithHeader(ctx context.Context, url string, header http.Header, body io.Reader) (*http.Response, error) { hreq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, body) if err != nil { return nil, err } - if contentType != "" { - hreq.Header.Set("Content-Type", contentType) - } + hreq.Header = header return DefaultClient.Do(hreq) } + +func PostWithContext(ctx context.Context, url, contentType string, body io.Reader) (*http.Response, error) { + return PostWithHeader(ctx, url, http.Header{"Content-Type": []string{contentType}}, body) +} diff --git a/src/pkg/http/put.go b/src/pkg/http/put.go index 8304b72b4..7048863d5 100644 --- a/src/pkg/http/put.go +++ b/src/pkg/http/put.go @@ -18,10 +18,14 @@ import ( // See the Client.Do method documentation for details on how redirects // are handled. func Put(ctx context.Context, url string, contentType string, body io.Reader) (*http.Response, error) { + return PutWithHeader(ctx, url, http.Header{"Content-Type": []string{contentType}}, body) +} + +func PutWithHeader(ctx context.Context, url string, header http.Header, body io.Reader) (*http.Response, error) { req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, body) if err != nil { return nil, err } - req.Header.Set("Content-Type", contentType) + req.Header = header return DefaultClient.Do(req) } diff --git a/src/pkg/session/session.go b/src/pkg/session/session.go index 0a2c34fae..afb9e70a6 100644 --- a/src/pkg/session/session.go +++ b/src/pkg/session/session.go @@ -114,6 +114,9 @@ func printProviderMismatchWarnings(ctx context.Context, provider client.Provider if env := pkg.GcpInEnv(); env != "" { term.Warnf("GCP project environment variable was detected (%v); did you forget --provider=gcp or DEFANG_PROVIDER=gcp?", env) } + if env := pkg.AzureInEnv(); env != "" { + term.Warnf("Azure environment variables were detected (%v); did you forget --provider=azure or DEFANG_PROVIDER=azure?", env) + } } switch provider { @@ -129,6 +132,10 @@ func printProviderMismatchWarnings(ctx context.Context, provider client.Provider if env := pkg.GcpInEnv(); env == "" { term.Warnf("GCP provider was selected, but no GCP project environment variable is set (%v)", pkg.GCPProjectEnvVars) } + case client.ProviderAzure: + if env := pkg.AzureInEnv(); env == "" { + term.Warn("Azure provider was selected, but no Azure environment variables are set") + } } } diff --git a/src/pkg/stacks/selector_test.go b/src/pkg/stacks/selector_test.go index 1f9daaa5b..865acaf87 100644 --- a/src/pkg/stacks/selector_test.go +++ b/src/pkg/stacks/selector_test.go @@ -183,7 +183,7 @@ func TestStackSelector_SelectStack_CreateNewStack(t *testing.T) { mockEC.On("RequestEnum", ctx, "Select a stack", "stack", expectedOptions).Return(CreateNewStack, nil) // Mock wizard parameter collection - provider selection - providerOptions := []string{"Defang Playground", "AWS", "DigitalOcean", "Google Cloud Platform"} + providerOptions := []string{"Defang Playground", "AWS", "DigitalOcean", "Google Cloud Platform", "Azure"} mockEC.On("RequestEnum", ctx, "Where do you want to deploy?", "provider", providerOptions).Return("AWS", nil) // Mock wizard parameter collection - region selection (default is us-west-2 for AWS) @@ -259,7 +259,7 @@ func TestStackSelector_SelectStack_NoExistingStacks(t *testing.T) { mockSM.On("List", ctx).Return([]ListItem{}, nil) // Mock wizard parameter collection - provider selection - providerOptions := []string{"Defang Playground", "AWS", "DigitalOcean", "Google Cloud Platform"} + providerOptions := []string{"Defang Playground", "AWS", "DigitalOcean", "Google Cloud Platform", "Azure"} mockEC.On("RequestEnum", ctx, "Where do you want to deploy?", "provider", providerOptions).Return("AWS", nil) // Mock wizard parameter collection - region selection @@ -418,7 +418,7 @@ func TestStackSelector_SelectStack_WizardError(t *testing.T) { mockEC.On("RequestEnum", ctx, "Select a stack", "stack", expectedOptions).Return(CreateNewStack, nil) // Mock wizard parameter collection - provider selection fails - providerOptions := []string{"Defang Playground", "AWS", "DigitalOcean", "Google Cloud Platform"} + providerOptions := []string{"Defang Playground", "AWS", "DigitalOcean", "Google Cloud Platform", "Azure"} mockEC.On("RequestEnum", ctx, "Where do you want to deploy?", "provider", providerOptions).Return("", errors.New("user cancelled wizard")) selector := NewSelector(mockEC, mockSM) @@ -455,7 +455,7 @@ func TestStackSelector_SelectStack_CreateStackError(t *testing.T) { mockEC.On("RequestEnum", ctx, "Select a stack", "stack", expectedOptions).Return(CreateNewStack, nil) // Mock wizard parameter collection - provider selection - providerOptions := []string{"Defang Playground", "AWS", "DigitalOcean", "Google Cloud Platform"} + providerOptions := []string{"Defang Playground", "AWS", "DigitalOcean", "Google Cloud Platform", "Azure"} mockEC.On("RequestEnum", ctx, "Where do you want to deploy?", "provider", providerOptions).Return("AWS", nil) // Mock wizard parameter collection - region selection diff --git a/src/pkg/stacks/wizard.go b/src/pkg/stacks/wizard.go index c789c25f7..37532a2ca 100644 --- a/src/pkg/stacks/wizard.go +++ b/src/pkg/stacks/wizard.go @@ -143,6 +143,16 @@ func (w *Wizard) CollectRemainingParameters(ctx context.Context, params *Paramet } params.Variables["GCP_PROJECT_ID"] = projectID } + case client.ProviderAzure: + if params.Variables["AZURE_SUBSCRIPTION_ID"] == "" { + subscriptionID, err := w.ec.RequestString(ctx, "What is your Azure Subscription ID?:", "azure_subscription_id", + elicitations.WithDefault(os.Getenv("AZURE_SUBSCRIPTION_ID")), + ) + if err != nil { + return nil, fmt.Errorf("failed to elicit Azure Subscription ID: %w", err) + } + params.Variables["AZURE_SUBSCRIPTION_ID"] = subscriptionID + } } return params, nil diff --git a/src/pkg/utils.go b/src/pkg/utils.go index fc3eb98a8..8c8926f75 100644 --- a/src/pkg/utils.go +++ b/src/pkg/utils.go @@ -226,3 +226,8 @@ func GcpInEnv() string { env, _ := GetFirstEnv(GCPProjectEnvVars...) return env } + +func AzureInEnv() string { + env, _ := GetFirstEnv("AZURE_SUBSCRIPTION_ID", "AZURE_TENANT_ID", "AZURE_CLIENT_ID", "AZURE_CLIENT_SECRET") + return env +} diff --git a/src/protos/io/defang/v1/fabric.proto b/src/protos/io/defang/v1/fabric.proto index d69a626d8..9b390a661 100644 --- a/src/protos/io/defang/v1/fabric.proto +++ b/src/protos/io/defang/v1/fabric.proto @@ -667,7 +667,8 @@ message GetRequest { // was ServiceID } message Service { - option deprecated = true; // still used by pulumi-defang provider in state files + option deprecated = + true; // still used by pulumi-defang provider in state files string name = 1; reserved 2; // was: string image reserved 3; // was: Platform platform