From cb18a4b812c0da96c08af9c202850e8eaeec9465 Mon Sep 17 00:00:00 2001 From: Eron Wright Date: Fri, 22 Aug 2025 10:21:53 -0700 Subject: [PATCH 01/26] slow 1 --- .github/workflows/build-test.yml | 56 +++++-- provider/pkg/provider/provider_e2e_test.go | 158 ++++++++++++++++++ .../test-programs/azidentity/Pulumi.yaml | 13 ++ 3 files changed, 217 insertions(+), 10 deletions(-) create mode 100644 provider/pkg/provider/test-programs/azidentity/Pulumi.yaml diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index e6b471241d66..bcce817176f8 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -64,6 +64,7 @@ env: jobs: prerequisites: + if: false runs-on: ubuntu-latest name: Build binaries and schema steps: @@ -103,7 +104,8 @@ jobs: status: ${{ job.status }} build_sdks: - needs: prerequisites + if: false + needs: [prerequisites,test_provider] # Use big runner for dotnet and nodejs because we need more memory and more compute, respectively runs-on: ${{ (matrix.language == 'dotnet' || matrix.language == 'nodejs' || matrix.language == 'go') && 'pulumi-ubuntu-8core' || 'ubuntu-latest' }} strategy: @@ -184,6 +186,7 @@ jobs: status: ${{ job.status }} test_sdks: + if: false needs: build_sdks # Use big runner for dotnet and nodejs because we need more memory and more compute, respectively runs-on: ${{ (matrix.language == 'dotnet' || matrix.language == 'nodejs' || matrix.language == 'go') && 'pulumi-ubuntu-8core' || 'ubuntu-latest' }} @@ -217,6 +220,7 @@ jobs: # We store the base64-encoded cert as a secret, decode it here, and write it out to a file. run: | echo "${{ secrets.ARM_CLIENT_CERTIFICATE }}" | base64 -d > "${{ runner.temp }}/azure-client-certificate.pfx" + echo "ARM_CLIENT_CERTIFICATE_PATH_FOR_TEST=${{ runner.temp }}/azure-client-certificate.pfx" >> "$GITHUB_ENV" - name: Write .azure.tmp folder # We write to .azure.tmp, not directly to .azure, because we want only one test to use @@ -229,13 +233,13 @@ jobs: # We only want one specific test to use it. unzip -d "$HOME/.azure.tmp" "${{ runner.temp }}/azure-cli-folder.zip" rm "${{ runner.temp }}/azure-cli-folder.zip" + echo "AZURE_CONFIG_DIR_FOR_TEST=$HOME/.azure.tmp" >> "$GITHUB_ENV" - name: Run tests if: ${{ ! inputs.short_test }} env: # specifying this id will cause the OIDC test(s) to run against this AD application OIDC_ARM_CLIENT_ID: ${{ inputs.oidc_arm_client_id }} - ARM_CLIENT_CERTIFICATE_PATH_FOR_TEST: "${{ runner.temp }}/azure-client-certificate.pfx" run: | set -euo pipefail cd examples && go test -cover -timeout 2h -tags=${{ matrix.language }} -skip TestPulumiExamples -parallel 16 . 2>&1 | tee /tmp/gotest.log @@ -247,6 +251,7 @@ jobs: cd examples && go test -cover -timeout 15m -short -tags=${{ matrix.language }} -skip TestPulumiExamples -parallel 16 . 2>&1 | tee /tmp/gotest.log test_examples: + if: false needs: build_sdks runs-on: ubuntu-latest name: Test pulumi/examples @@ -302,7 +307,9 @@ jobs: test_provider: runs-on: ubuntu-latest name: Test Provider - needs: prerequisites + # needs: prerequisites + permissions: + id-token: write # required for OIDC auth steps: - name: Checkout Repo uses: actions/checkout@v4 @@ -317,20 +324,48 @@ jobs: - run: make ensure - - name: Prerequisites artifact restore - uses: ./.github/actions/prerequisites-artifact-restore + - name: Build schema and binaries + run: make codegen schema provider + + # - name: Prerequisites artifact restore + # uses: ./.github/actions/prerequisites-artifact-restore # This is essentially just copying files from bin to the provider folder - - name: Prebuild provider prerequisites + # - name: Prebuild provider prerequisites + # run: | + # make prebuild + # make --touch codegen schema + # make provider_prebuild + + - name: Write client certificate + # The provider wants the cert as a path to a cert file but GH secrets can only be strings. + # We store the base64-encoded cert as a secret, decode it here, and write it out to a file. run: | - make prebuild - make --touch codegen schema - make provider_prebuild + echo "${{ secrets.ARM_CLIENT_CERTIFICATE }}" | base64 -d > "${{ runner.temp }}/azure-client-certificate.pfx" + echo "ARM_CLIENT_CERTIFICATE_PATH_FOR_TEST=${{ runner.temp }}/azure-client-certificate.pfx" >> "$GITHUB_ENV" + + - name: Write .azure.tmp folder + # We write to .azure.tmp, not directly to .azure, because we want only one test to use + # this folder. The test needs to rename it to .azure and then back. This is to avoid other + # tests using it unintentionally since CLI is the fallback auth method. + run: | + set -euxo pipefail + echo "${{ secrets.AZURE_CLI_FOLDER }}" | base64 -d > "${{ runner.temp }}/azure-cli-folder.zip" + # Unzip it to a temp folder to avoid other tests using it unintentionally (since CLI auth is the fallback method). + # We only want one specific test to use it. + unzip -d "$HOME/.azure.tmp" "${{ runner.temp }}/azure-cli-folder.zip" + rm "${{ runner.temp }}/azure-cli-folder.zip" + echo "AZURE_CONFIG_DIR_FOR_TEST=$HOME/.azure.tmp" >> "$GITHUB_ENV" - name: Test Provider Library + env: + # specifying this id will cause the OIDC test(s) to run against this AD application + OIDC_ARM_CLIENT_ID: ${{ inputs.oidc_arm_client_id }} + PULUMITEST_SKIP_DESTROY_ON_FAILURE: true + PULUMITEST_RETAIN_FILES_ON_FAILURE: true run: | set -euo pipefail - cd provider && go test -coverprofile="coverage.txt" -coverpkg=./... -timeout 1h -parallel 16 ./... 2>&1 | tee /tmp/gotest.log + cd provider && go test -run ^TestAzidentity$ -coverprofile="coverage.txt" -coverpkg=./... -timeout 1h -parallel 16 ./... 2>&1 | tee /tmp/gotest.log - name: Upload coverage reports to Codecov uses: codecov/codecov-action@v4 @@ -339,6 +374,7 @@ jobs: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} dist: + if: false runs-on: ubuntu-latest name: Provider Dist needs: prerequisites diff --git a/provider/pkg/provider/provider_e2e_test.go b/provider/pkg/provider/provider_e2e_test.go index 8c854be8bef9..9f9e5deedc09 100644 --- a/provider/pkg/provider/provider_e2e_test.go +++ b/provider/pkg/provider/provider_e2e_test.go @@ -7,14 +7,17 @@ package provider import ( + "encoding/json" "fmt" "os" "path/filepath" "testing" + "github.com/golang-jwt/jwt" "github.com/pulumi/pulumi-azure-native/v2/provider/pkg/util" "github.com/pulumi/pulumi-azure-native/v2/provider/pkg/version" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/pulumi/providertest" "github.com/pulumi/providertest/optproviderupgrade" @@ -25,6 +28,8 @@ import ( "github.com/pulumi/providertest/pulumitest/changesummary" "github.com/pulumi/providertest/pulumitest/opttest" + "github.com/pulumi/pulumi/sdk/v3/go/auto" + "github.com/pulumi/pulumi/sdk/v3/go/auto/debug" "github.com/pulumi/pulumi/sdk/v3/go/auto/optup" "github.com/pulumi/pulumi/sdk/v3/go/common/apitype" pulumirpc "github.com/pulumi/pulumi/sdk/v3/proto/go" @@ -134,6 +139,140 @@ func TestTagging(t *testing.T) { assert.Equal(t, map[string]any{"owner": "tag_2"}, rg2Tags) } +func TestAzidentity(t *testing.T) { + + validate := func(t *testing.T, up auto.UpResult) (map[string]interface{}, jwt.MapClaims) { + // validate clientConfig + require.Contains(t, up.Outputs, "clientConfig", "expected clientConfig output") + clientConfig, _ := up.Outputs["clientConfig"].Value.(map[string]interface{}) + clientConfigJSON, _ := json.Marshal(clientConfig) + t.Logf("clientConfig: %s", clientConfigJSON) + + assert.Contains(t, clientConfig, "clientId") + assert.Contains(t, clientConfig, "objectId") + assert.Contains(t, clientConfig, "subscriptionId") + assert.Contains(t, clientConfig, "tenantId") + + // validate clientToken + require.Contains(t, up.Outputs, "clientToken", "expected clientToken output") + clientToken, _ := up.Outputs["clientToken"].Value.(map[string]interface{}) + claims, err := parseJwtUnverified(clientToken["token"].(string)) + require.NoError(t, err) + claimsJSON, _ := json.Marshal(claims) + t.Logf("clientToken: %s", claimsJSON) + + return clientConfig, claims + } + + t.Run("OIDC", func(t *testing.T) { + oidcClientId := os.Getenv("OIDC_ARM_CLIENT_ID") + if oidcClientId == "" { + t.Skip("Skipping OIDC test without OIDC_ARM_CLIENT_ID") + } + + t.Setenv("ARM_USE_OIDC", "true") + t.Setenv("ARM_CLIENT_ID", oidcClientId) + // Make sure we test the OIDC method + t.Setenv("ARM_CLIENT_SECRET", "") + t.Setenv("ARM_CLIENT_CERTIFICATE_PATH", "") + t.Setenv("ARM_CLIENT_CERTIFICATE_PASSWORD", "") + + pt := newPulumiTest(t, "azidentity") + + up := pt.Up(t) + clientConfig, clientToken := validate(t, up) + assert.Equal(t, os.Getenv("ARM_CLIENT_ID"), clientConfig["clientId"]) + assert.Equal(t, os.Getenv("ARM_CLIENT_ID"), clientToken["appid"]) + assert.Equal(t, "app", clientToken["idtyp"]) + }) + + t.Run("SP_clientsecret", func(t *testing.T) { + clientSecret := os.Getenv("ARM_CLIENT_SECRET") + if clientSecret == "" { + t.Skip("Skipping SP test without ARM_CLIENT_SECRET") + } + + t.Setenv("ARM_CLIENT_ID", os.Getenv("ARM_CLIENT_ID")) + t.Setenv("ARM_CLIENT_SECRET", clientSecret) + // Make sure we test the client secret method + t.Setenv("ARM_CLIENT_CERTIFICATE_PASSWORD", "") + t.Setenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "") + t.Setenv("ACTIONS_ID_TOKEN_REQUEST_URL", "") + + pt := newPulumiTest(t, "azidentity") + + up := pt.Up(t) + clientConfig, clientToken := validate(t, up) + assert.Equal(t, os.Getenv("ARM_CLIENT_ID"), clientConfig["clientId"]) + assert.Equal(t, os.Getenv("ARM_CLIENT_ID"), clientToken["appid"]) + assert.Equal(t, "app", clientToken["idtyp"]) + }) + + t.Run("SP_clientcert", func(t *testing.T) { + certPath := os.Getenv("ARM_CLIENT_CERTIFICATE_PATH_FOR_TEST") + if certPath == "" { + t.Skip("Skipping SP test without ARM_CLIENT_CERTIFICATE_PATH_FOR_TEST") + } + + t.Setenv("ARM_CLIENT_ID", os.Getenv("ARM_CLIENT_ID")) + t.Setenv("ARM_CLIENT_CERTIFICATE_PATH", certPath) + t.Setenv("ARM_CLIENT_CERTIFICATE_PASSWORD", os.Getenv("ARM_CLIENT_CERTIFICATE_PASSWORD_FOR_TEST")) + // Make sure we test the client certificate method + t.Setenv("ARM_CLIENT_SECRET", "") + t.Setenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "") + t.Setenv("ACTIONS_ID_TOKEN_REQUEST_URL", "") + + pt := newPulumiTest(t, "azidentity") + + up := pt.Up(t) + clientConfig, clientToken := validate(t, up) + assert.Equal(t, os.Getenv("ARM_CLIENT_ID"), clientConfig["clientId"]) + assert.Equal(t, os.Getenv("ARM_CLIENT_ID"), clientToken["appid"]) + assert.Equal(t, "app", clientToken["idtyp"]) + }) + + t.Run("CLI", func(t *testing.T) { + // AZURE_CONFIG_DIR_FOR_TEST is set by the GH workflow build-test.yml + // to provide an isolated configuration directory for the Azure CLI. + configDir := os.Getenv("AZURE_CONFIG_DIR_FOR_TEST") + if configDir == "" { + t.Skip("Skipping CLI test without AZURE_CONFIG_DIR_FOR_TEST") + } + t.Setenv("AZURE_CONFIG_DIR", configDir) + + // usr, err := user.Current() + // require.NoError(t, err) + // // .azure.tmp is created by the GH workflow build-test.yml, from the GH secret AZURE_CLI_FOLDER + // // which is also documented in the workflow. We rename it to .azure so the `az` CLI can find it. + // err = os.Rename(filepath.Join(usr.HomeDir, ".azure.tmp"), filepath.Join(usr.HomeDir, ".azure")) + // require.NoError(t, err) + + // // Prevent later tests from accidentally picking up the .azure folder because authentication + // // falls back to CLI when other methods are misconfigured. + // defer func() { + // _ = os.Rename(filepath.Join(usr.HomeDir, ".azure"), filepath.Join(usr.HomeDir, ".azure.tmp")) + // }() + + // Make sure we test the CLI method + t.Setenv("ARM_USE_MSI", "false") + t.Setenv("ARM_USE_OIDC", "false") + t.Setenv("ARM_TENANT_ID", "") + t.Setenv("ARM_CLIENT_ID", "") + t.Setenv("ARM_CLIENT_SECRET", "") + t.Setenv("ARM_CLIENT_CERTIFICATE_PATH", "") + t.Setenv("ARM_CLIENT_CERTIFICATE_PASSWORD", "") + t.Setenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "") + t.Setenv("ACTIONS_ID_TOKEN_REQUEST_URL", "") + + pt := newPulumiTest(t, "azidentity") + up := pt.Up(t, optup.DebugLogging(debugLogging())) + clientConfig, clientToken := validate(t, up) + assert.Equal(t, "04b07795-8ddb-461a-bbee-02f9e1bf7b46", clientConfig["clientId"]) + assert.Equal(t, "04b07795-8ddb-461a-bbee-02f9e1bf7b46", clientToken["appid"]) + assert.Equal(t, "user", clientToken["idtyp"]) + }) +} + func TestUpgradeKeyVault_2_76_0(t *testing.T) { upgradeTest(t, "upgrade-keyvault", "2.76.0") } @@ -227,3 +366,22 @@ func getLocation() string { return azureLocation } + +func parseJwtUnverified(tokenString string) (jwt.MapClaims, error) { + token, _, err := new(jwt.Parser).ParseUnverified(tokenString, jwt.MapClaims{}) + if err != nil { + return nil, err + } + claims, _ := token.Claims.(jwt.MapClaims) + return claims, nil +} + +func debugLogging() debug.LoggingOptions { + var level uint = 11 + return debug.LoggingOptions{ + LogLevel: &level, + Debug: true, + FlowToPlugins: true, + LogToStdErr: true, + } +} diff --git a/provider/pkg/provider/test-programs/azidentity/Pulumi.yaml b/provider/pkg/provider/test-programs/azidentity/Pulumi.yaml new file mode 100644 index 000000000000..c85fffb1812f --- /dev/null +++ b/provider/pkg/provider/test-programs/azidentity/Pulumi.yaml @@ -0,0 +1,13 @@ +name: azidentity +runtime: yaml +description: Tests the azidentity subsystem of the provider. +variables: + clientConfig: + fn::invoke: + function: azure-native:authorization:getClientConfig + clientToken: + fn::invoke: + function: azure-native:authorization:getClientToken +outputs: + clientConfig: ${clientConfig} + clientToken: ${clientToken} \ No newline at end of file From b38272705d21e780867b162b69eea318634ce78a Mon Sep 17 00:00:00 2001 From: Eron Wright Date: Fri, 22 Aug 2025 10:32:18 -0700 Subject: [PATCH 02/26] slow 2 --- .github/workflows/build-test.yml | 2 +- provider/go.mod | 17 +++++++++++++--- provider/go.sum | 34 ++++++++++++++++++++++++++------ 3 files changed, 43 insertions(+), 10 deletions(-) diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index bcce817176f8..a74f15a1db69 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -325,7 +325,7 @@ jobs: - run: make ensure - name: Build schema and binaries - run: make codegen schema provider + run: make codegen schema provider install_provider # - name: Prerequisites artifact restore # uses: ./.github/actions/prerequisites-artifact-restore diff --git a/provider/go.mod b/provider/go.mod index 99d6799b65d9..a10e8ab2545b 100644 --- a/provider/go.mod +++ b/provider/go.mod @@ -6,7 +6,7 @@ toolchain go1.24.1 require ( github.com/Azure/azure-sdk-for-go v68.0.0+incompatible - github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 + github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.1 github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.2 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/keyvault/armkeyvault v1.4.0 @@ -24,6 +24,7 @@ require ( github.com/go-openapi/jsonreference v0.19.6 github.com/go-openapi/spec v0.20.4 github.com/go-openapi/swag v0.21.1 + github.com/golang-jwt/jwt v3.2.1+incompatible github.com/golang/protobuf v1.5.4 github.com/google/uuid v1.6.0 github.com/hashicorp/go-azure-helpers v0.51.0 @@ -31,6 +32,8 @@ require ( github.com/hashicorp/hcl/v2 v2.23.0 github.com/manicminer/hamilton v0.57.1 github.com/manicminer/hamilton-autorest v0.3.0 + github.com/microsoftgraph/msgraph-sdk-go v1.81.0 + github.com/microsoftgraph/msgraph-sdk-go-core v1.3.2 github.com/pkg/errors v0.9.1 github.com/pulumi/providertest v0.1.5 github.com/pulumi/pulumi-java/pkg v1.16.0 @@ -58,12 +61,12 @@ require ( cloud.google.com/go/longrunning v0.5.5 // indirect cloud.google.com/go/storage v1.39.1 // indirect dario.cat/mergo v1.0.1 // indirect - github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/keyvault/azkeys v0.10.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.7.1 // indirect github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.1 // indirect github.com/Azure/go-autorest/autorest/adal v0.9.23 // indirect - github.com/AzureAD/microsoft-authentication-library-for-go v1.3.3 // indirect + github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 // indirect github.com/BurntSushi/toml v1.4.0 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect github.com/atotto/clipboard v0.1.4 // indirect @@ -120,6 +123,13 @@ require ( github.com/kylelemons/godebug v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-localereader v0.0.1 // indirect + github.com/microsoft/kiota-abstractions-go v1.9.3 // indirect + github.com/microsoft/kiota-authentication-azure-go v1.3.0 // indirect + github.com/microsoft/kiota-http-go v1.5.2 // indirect + github.com/microsoft/kiota-serialization-form-go v1.1.2 // indirect + github.com/microsoft/kiota-serialization-json-go v1.1.2 // indirect + github.com/microsoft/kiota-serialization-multipart-go v1.1.2 // indirect + github.com/microsoft/kiota-serialization-text-go v1.1.2 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect @@ -136,6 +146,7 @@ require ( github.com/ryanuber/go-glob v1.0.0 // indirect github.com/skeema/knownhosts v1.3.1 // indirect github.com/spf13/afero v1.9.5 // indirect + github.com/std-uritemplate/std-uritemplate/go/v2 v2.0.3 // indirect github.com/tidwall/gjson v1.17.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect diff --git a/provider/go.sum b/provider/go.sum index c5d5d6c796bb..1b126eb13706 100644 --- a/provider/go.sum +++ b/provider/go.sum @@ -56,14 +56,14 @@ github.com/Azure/azure-sdk-for-go v45.0.0+incompatible/go.mod h1:9XXNKU+eRnpl9mo github.com/Azure/azure-sdk-for-go v47.1.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= github.com/Azure/azure-sdk-for-go v68.0.0+incompatible h1:fcYLmCpyNYRnvJbPerq7U0hS+6+I79yEDJBqVNcqUzU= github.com/Azure/azure-sdk-for-go v68.0.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 h1:g0EZJwz7xkXQiZAI5xi9f3WWFYBlX1CPTrR+NDToRkQ= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0/go.mod h1:XCW7KnZet0Opnr7HccfUw1PLc4CjHqpcaxW8DHklNkQ= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.1 h1:Wc1ml6QlJs2BHQ/9Bqu1jiyggbsSjramq2oUmp5WeIo= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.1/go.mod h1:Ot/6aikWnKWi4l9QB7qVSwa8iMphQNqkWALMoNT3rzM= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.2 h1:F0gBpfdPLGsw+nsgk6aqqkZS1jiixa5WwFe3fk/T3Ys= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.2/go.mod h1:SqINnQ9lVVdRlyC8cd1lCI0SdX4n2paeABd2K8ggfnE= 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/internal v1.10.0 h1:ywEEhmNahHBihViHepv3xPBn1663uRv2t2q/ESv9seY= -github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0/go.mod h1:iZDifYGJTIgIIkYRNWPENUnqx6bJ2xnSDFI2tjwZNuY= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.0 h1:Bg8m3nq/X1DeePkAbCfb6ml6F3F0IunEhE8TMh+lY48= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.0/go.mod h1:j2chePtV91HrC22tGoRX3sGY42uF13WzmmV80/OdVAA= github.com/Azure/azure-sdk-for-go/sdk/keyvault/azkeys v0.10.0 h1:m/sWOGCREuSBqg2htVQTBY8nOZpyajYztF0vUvSZTuM= github.com/Azure/azure-sdk-for-go/sdk/keyvault/azkeys v0.10.0/go.mod h1:Pu5Zksi2KrU7LPbZbNINx6fuVrUp/ffvpxdDj+i8LeE= github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.7.1 h1:FbH3BbSb4bvGluTesZZ+ttN/MDsnMmQP36OSnDuSXqw= @@ -125,8 +125,8 @@ github.com/Azure/go-autorest/tracing v0.6.0 h1:TYi4+3m5t6K48TGI9AUdb+IzbnSxvnvUM github.com/Azure/go-autorest/tracing v0.6.0/go.mod h1:+vhtPC754Xsa23ID7GlGsrdKBpUA79WCAKPPZVC2DeU= 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.3.3 h1:H5xDQaE3XowWfhZRUpnfC+rGZMEVoSiji+b+/HFAPU4= -github.com/AzureAD/microsoft-authentication-library-for-go v1.3.3/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= +github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs= +github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0= github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= @@ -320,6 +320,8 @@ github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRx github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= 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 v3.2.1+incompatible h1:73Z+4BJcrTC+KczS6WvTPvRGOp1WmfEP4Q1lOd9Z/+c= +github.com/golang-jwt/jwt v3.2.1+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/golang-jwt/jwt/v4 v4.0.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= github.com/golang-jwt/jwt/v4 v4.2.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= @@ -531,6 +533,24 @@ github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzp github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/microsoft/kiota-abstractions-go v1.9.3 h1:cqhbqro+VynJ7kObmo7850h3WN2SbvoyhypPn8uJ1SE= +github.com/microsoft/kiota-abstractions-go v1.9.3/go.mod h1:f06pl3qSyvUHEfVNkiRpXPkafx7khZqQEb71hN/pmuU= +github.com/microsoft/kiota-authentication-azure-go v1.3.0 h1:PWH6PgtzhJjnmvR6N1CFjriwX09Kv7S5K3vL6VbPVrg= +github.com/microsoft/kiota-authentication-azure-go v1.3.0/go.mod h1:l/MPGUVvD7xfQ+MYSdZaFPv0CsLDqgSOp8mXwVgArIs= +github.com/microsoft/kiota-http-go v1.5.2 h1:xqvo4ssWwSvCJw2yuRocKFTxm3Y1iN+a4rrhuTYtBWg= +github.com/microsoft/kiota-http-go v1.5.2/go.mod h1:L+5Ri+SzwELnUcNA0cpbFKp/pBbvypLh3Cd1PR6sjx0= +github.com/microsoft/kiota-serialization-form-go v1.1.2 h1:SD6MATqNw+Dc5beILlsb/D87C36HKC/Zw7l+N9+HY2A= +github.com/microsoft/kiota-serialization-form-go v1.1.2/go.mod h1:m4tY2JT42jAZmgbqFwPy3zGDF+NPJACuyzmjNXeuHio= +github.com/microsoft/kiota-serialization-json-go v1.1.2 h1:eJrPWeQ665nbjO0gsHWJ0Bw6V/ZHHU1OfFPaYfRG39k= +github.com/microsoft/kiota-serialization-json-go v1.1.2/go.mod h1:deaGt7fjZarywyp7TOTiRsjfYiyWxwJJPQZytXwYQn8= +github.com/microsoft/kiota-serialization-multipart-go v1.1.2 h1:1pUyA1QgIeKslQwbk7/ox1TehjlCUUT3r1f8cNlkvn4= +github.com/microsoft/kiota-serialization-multipart-go v1.1.2/go.mod h1:j2K7ZyYErloDu7Kuuk993DsvfoP7LPWvAo7rfDpdPio= +github.com/microsoft/kiota-serialization-text-go v1.1.2 h1:7OfKFlzdjpPygca/+OtqafkEqCWR7+94efUFGC28cLw= +github.com/microsoft/kiota-serialization-text-go v1.1.2/go.mod h1:QNTcswkBPFY3QVBFmzfk00UMNViKQtV0AQKCrRw5ibM= +github.com/microsoftgraph/msgraph-sdk-go v1.81.0 h1:TZ+YbXGCOyRU2A5IWJLOIIKMECMyeRQBr6mcExLne80= +github.com/microsoftgraph/msgraph-sdk-go v1.81.0/go.mod h1:1V9jKcRL+Czs3u8gI2XjUn7xJCAWRKGizA7l14Bg9zQ= +github.com/microsoftgraph/msgraph-sdk-go-core v1.3.2 h1:5jCUSosTKaINzPPQXsz7wsHWwknyBmJSu8+ZWxx3kdQ= +github.com/microsoftgraph/msgraph-sdk-go-core v1.3.2/go.mod h1:iD75MK3LX8EuwjDYCmh0hkojKXK6VKME33u4daCo3cE= github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ= github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= @@ -637,6 +657,8 @@ github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/std-uritemplate/std-uritemplate/go/v2 v2.0.3 h1:7hth9376EoQEd1hH4lAp3vnaLP2UMyxuMMghLKzDHyU= +github.com/std-uritemplate/std-uritemplate/go/v2 v2.0.3/go.mod h1:Z5KcoM0YLC7INlNhEezeIZ0TZNYf7WSNO0Lvah4DSeQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= From 1d571aafe270dfad702318883ed2193a05acf1ed Mon Sep 17 00:00:00 2001 From: Eron Wright Date: Fri, 22 Aug 2025 10:45:29 -0700 Subject: [PATCH 03/26] slow 3 --- .github/workflows/build-test.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index a74f15a1db69..5e0dbbeb963f 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -365,7 +365,14 @@ jobs: PULUMITEST_RETAIN_FILES_ON_FAILURE: true run: | set -euo pipefail - cd provider && go test -run ^TestAzidentity$ -coverprofile="coverage.txt" -coverpkg=./... -timeout 1h -parallel 16 ./... 2>&1 | tee /tmp/gotest.log + cd provider && go test -test.v -tags all -run ^TestAzidentity$ -coverprofile="coverage.txt" -coverpkg=./... -timeout 1h -parallel 16 ./... 2>&1 | tee /tmp/gotest.log + + - name: Upload test log + uses: actions/upload-artifact@v4 + with: + name: provider-gotest-log + path: /tmp/gotest.log + retention-days: ${{ inputs.retention_days }} - name: Upload coverage reports to Codecov uses: codecov/codecov-action@v4 From 599ef8f61bbdf73aff02ab8426f465e3666137d5 Mon Sep 17 00:00:00 2001 From: Eron Wright Date: Fri, 22 Aug 2025 10:56:35 -0700 Subject: [PATCH 04/26] slow 4 --- provider/pkg/azure/azure.go | 67 +++++++++++++- provider/pkg/azure/azure_test.go | 113 +++++++++++++++++++++++ provider/pkg/azure/client_azcore.go | 19 ++-- provider/pkg/azure/client_azcore_test.go | 39 ++++++-- 4 files changed, 218 insertions(+), 20 deletions(-) diff --git a/provider/pkg/azure/azure.go b/provider/pkg/azure/azure.go index 34c90bad26e4..c1da44b51eb2 100644 --- a/provider/pkg/azure/azure.go +++ b/provider/pkg/azure/azure.go @@ -4,6 +4,8 @@ package azure import ( "context" + "encoding/base64" + "encoding/json" "errors" "fmt" "net/http" @@ -14,23 +16,32 @@ import ( azcloud "github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud" "github.com/Azure/go-autorest/autorest" "github.com/Azure/go-autorest/autorest/azure" + "github.com/pulumi/pulumi-azure-native/v2/provider/pkg/util" "github.com/pulumi/pulumi-azure-native/v2/provider/pkg/version" "github.com/pulumi/pulumi/sdk/v3/go/common/util/logging" ) // BuildUserAgent composes a User Agent string with the provided partner ID. +// see: https://azure.github.io/azure-sdk/general_azurecore.html#telemetry-policy func BuildUserAgent(partnerID string) (userAgent string) { - userAgent = strings.TrimSpace(fmt.Sprintf("%s pulumi-azure-native/%s", - autorest.UserAgent(), version.Version)) + if !util.EnableAzcoreBackend() { + userAgent = strings.TrimSpace(fmt.Sprintf("%s pulumi-azure-native/%s", + autorest.UserAgent(), version.GetVersion())) + } + + // azure-sdk-for-go sets a user agent string as per the telemetry policy, resembling: + // pulumi-azure-native/3.0.0 azsdk-go-azcore/1.0.0 go/1.16.5 (darwin; amd64) + // Anything we add here will be appended to that. // append the CloudShell version to the user agent if it exists + // https://github.com/Azure/azure-cli/issues/21808 if azureAgent := os.Getenv("AZURE_HTTP_USER_AGENT"); azureAgent != "" { - userAgent = fmt.Sprintf("%s %s", userAgent, azureAgent) + userAgent = strings.TrimSpace(fmt.Sprintf("%s %s", userAgent, azureAgent)) } // Append partner ID, if it's defined. if partnerID != "" { - userAgent = fmt.Sprintf("%s pid-%s", userAgent, partnerID) + userAgent = strings.TrimSpace(fmt.Sprintf("%s pid-%s", userAgent, partnerID)) } logging.V(9).Infof("AzureNative User Agent: %s", userAgent) @@ -93,3 +104,51 @@ func GetCloudByName(cloudName string) azcloud.Configuration { } return azcloud.AzurePublic } + +// GetCloudName returns the standard name for a given azcloud.Configuration. +func GetCloudName(cloud azcloud.Configuration) string { + switch cloud.ActiveDirectoryAuthorityHost { + case azcloud.AzureChina.ActiveDirectoryAuthorityHost: + return "AzureChinaCloud" + case azcloud.AzureGovernment.ActiveDirectoryAuthorityHost: + return "AzureUSGovernment" + case azcloud.AzurePublic.ActiveDirectoryAuthorityHost: + return "AzureCloud" + } + return "AzureCloud" +} + +// Claims is used to unmarshall the claims from a JWT issued by the Microsoft Identity Platform. +type Claims struct { + Audience string `json:"aud"` + Issuer string `json:"iss"` + IdentityProvider string `json:"idp"` + ObjectId string `json:"oid"` + Roles []string `json:"roles"` + Scopes string `json:"scp"` + Subject string `json:"sub"` + TenantRegionScope string `json:"tenant_region_scope"` + TenantId string `json:"tid"` + Version string `json:"ver"` + + AppDisplayName string `json:"app_displayname,omitempty"` + AppId string `json:"appid,omitempty"` + IdType string `json:"idtyp,omitempty"` +} + +// ParseClaims retrieves and parses the claims from a JWT issued by the Microsoft Identity Platform. +func ParseClaims(token azcore.AccessToken) (Claims, error) { + jwt := strings.Split(token.Token, ".") + if len(jwt) != 3 { + return Claims{}, errors.New("unexpected token format: does not have 3 parts") + } + + payload, err := base64.RawURLEncoding.DecodeString(jwt[1]) + if err != nil { + return Claims{}, err + } + + var claims Claims + err = json.Unmarshal(payload, &claims) + return claims, err +} diff --git a/provider/pkg/azure/azure_test.go b/provider/pkg/azure/azure_test.go index cdcc729749cc..63c7b3112f56 100644 --- a/provider/pkg/azure/azure_test.go +++ b/provider/pkg/azure/azure_test.go @@ -3,7 +3,12 @@ package azure import ( + "encoding/base64" + "encoding/json" "net/http" + "os" + "regexp" + "strconv" "testing" "github.com/Azure/azure-sdk-for-go/sdk/azcore" @@ -35,10 +40,69 @@ func TestGetCloudByName(t *testing.T) { } } +func TestBuildUserAgent(t *testing.T) { + tests := []struct { + azcore bool + name string + partnerID string + ExtraUA string + wantRegex string + }{ + { + azcore: true, + name: "default", + wantRegex: ``, + }, + { + azcore: true, + name: "PartnerID", + partnerID: "12345", + wantRegex: `pid-12345`, + }, + { + azcore: true, + name: "UserAgentPassthrough", + ExtraUA: "a/1.2.3 b-c", + wantRegex: `a/(.+) b-c`, + }, + { + azcore: false, + name: "legacy:default", + wantRegex: `go-autorest/(.+) pulumi-azure-native/(.+)`, + }, + { + azcore: false, + name: "legacy:PartnerID", + partnerID: "12345", + wantRegex: `go-autorest/(.+) pulumi-azure-native/(.+) pid-12345`, + }, + { + azcore: false, + name: "legacy:UserAgentPassthrough", + ExtraUA: "a/1.2.3 b-c", + wantRegex: `go-autorest/(.+) pulumi-azure-native/(.+) a/(.+) b-c`, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("PULUMI_ENABLE_AZCORE_BACKEND", strconv.FormatBool(tc.azcore)) + os.Setenv("AZURE_HTTP_USER_AGENT", tc.ExtraUA) + + ua := BuildUserAgent(tc.partnerID) + matched, err := regexp.MatchString(tc.wantRegex, ua) + if err != nil || !matched { + t.Errorf("user agent mismatch, expected %q: got %q", tc.wantRegex, ua) + } + }) + } +} + func TestIsNotFound(t *testing.T) { t.Run("autorest", func(t *testing.T) { assert.True(t, IsNotFound(&autorestAzure.RequestError{ DetailedError: autorest.DetailedError{ + StatusCode: http.StatusNotFound, }, })) @@ -69,3 +133,52 @@ func TestIsNotFound(t *testing.T) { })) }) } + +func TestParseClaims(t *testing.T) { + // Create a Claims struct and marshal it to JSON + expectedClaims := Claims{ + Audience: "audience", + Issuer: "issuer", + IdentityProvider: "idp", + ObjectId: "objectid", + Roles: []string{"role1", "role2"}, + Scopes: "scope", + Subject: "subject", + TenantRegionScope: "region", + TenantId: "tenantid", + Version: "1.0", + AppDisplayName: "appdisplayname", + AppId: "appid", + IdType: "idtype", + } + payload, err := json.Marshal(expectedClaims) + assert.NoError(t, err) + + // JWT: header.payload.signature (all base64url, but only payload matters) + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none"}`)) + payloadEnc := base64.RawURLEncoding.EncodeToString(payload) + tokenStr := header + "." + payloadEnc + ".signature" + token := azcore.AccessToken{Token: tokenStr} + + claims, err := ParseClaims(token) + assert.NoError(t, err) + assert.Equal(t, expectedClaims, claims) +} + +func TestParseClaims_InvalidToken(t *testing.T) { + // Token with not enough segments + token := azcore.AccessToken{Token: "invalidtoken"} + _, err := ParseClaims(token) + assert.Error(t, err) + + // Token with invalid base64 payload + token = azcore.AccessToken{Token: "a.b@d!.c"} + _, err = ParseClaims(token) + assert.Error(t, err) + + // Token with invalid JSON in payload + badPayload := base64.RawURLEncoding.EncodeToString([]byte("notjson")) + token = azcore.AccessToken{Token: "a." + badPayload + ".c"} + _, err = ParseClaims(token) + assert.Error(t, err) +} diff --git a/provider/pkg/azure/client_azcore.go b/provider/pkg/azure/client_azcore.go index cfe26934ab34..97b73e584599 100644 --- a/provider/pkg/azure/client_azcore.go +++ b/provider/pkg/azure/client_azcore.go @@ -28,9 +28,9 @@ import ( ) type azCoreClient struct { - host string - pipeline runtime.Pipeline - userAgent string + host string + pipeline runtime.Pipeline + extraUserAgent string // Exposed internally for tests, to set it at the minimum value for fast tests. deletePollingIntervalSeconds int64 @@ -42,6 +42,9 @@ func initPipelineOpts(azureCloud cloud.Configuration, opts *arm.ClientOptions) * opts = &arm.ClientOptions{ ClientOptions: policy.ClientOptions{ Cloud: azureCloud, + Telemetry: policy.TelemetryOptions{ + ApplicationID: fmt.Sprintf("pulumi-azure-native/%s", version.Version), + }, }, } } @@ -86,7 +89,7 @@ func initPipelineOpts(azureCloud cloud.Configuration, opts *arm.ClientOptions) * // NewAzCoreClient creates a new AzureClient using the azcore SDK. For general use, leave userOpts // nil to use the default options. If you do set it, make sure to set its ClientOptions.Cloud field. -func NewAzCoreClient(tokenCredential azcore.TokenCredential, userAgent string, azureCloud cloud.Configuration, userOpts *arm.ClientOptions, +func NewAzCoreClient(tokenCredential azcore.TokenCredential, extraUserAgent string, azureCloud cloud.Configuration, userOpts *arm.ClientOptions, ) (AzureClient, error) { // Hook our logging up to the azcore logger. log.SetListener(func(event log.Event, msg string) { @@ -98,7 +101,7 @@ func NewAzCoreClient(tokenCredential azcore.TokenCredential, userAgent string, a }) opts := initPipelineOpts(azureCloud, userOpts) - pipeline, err := armruntime.NewPipeline("pulumi-azure-native", version.Version, tokenCredential, + pipeline, err := armruntime.NewPipeline("azcore", "v1.17.0", tokenCredential, runtime.PipelineOptions{}, opts) if err != nil { return nil, err @@ -107,7 +110,7 @@ func NewAzCoreClient(tokenCredential azcore.TokenCredential, userAgent string, a return &azCoreClient{ host: azureCloud.Services[cloud.ResourceManager].Endpoint, pipeline: pipeline, - userAgent: userAgent, + extraUserAgent: extraUserAgent, deletePollingIntervalSeconds: 30, // same as autorest.DefaultPollingDelay updatePollingIntervalSeconds: 10, }, nil @@ -127,7 +130,7 @@ func shouldRetryConflict(resp *http.Response) bool { func (c *azCoreClient) setHeaders(req *policy.Request, contentTypeJson bool) { req.Raw().Header.Set("Accept", "application/json") - req.Raw().Header.Set("User-Agent", c.userAgent) + req.Raw().Header.Set("User-Agent", c.extraUserAgent) // note: azure-sdk-for-go will append standard info to this header if contentTypeJson { req.Raw().Header.Set("Content-Type", "application/json; charset=utf-8") } @@ -520,7 +523,7 @@ func CreateTestClient(t *testing.T, assertions func(t *testing.T, req *http.Requ }, DisableRPRegistration: true, } - return NewAzCoreClient(&fake.TokenCredential{}, "pulumi", cloud.AzurePublic, &opts) + return NewAzCoreClient(&fake.TokenCredential{}, "pid-12345", cloud.AzurePublic, &opts) } type requestAssertingTransporter struct { diff --git a/provider/pkg/azure/client_azcore_test.go b/provider/pkg/azure/client_azcore_test.go index f43d359ecead..441821de78b9 100644 --- a/provider/pkg/azure/client_azcore_test.go +++ b/provider/pkg/azure/client_azcore_test.go @@ -5,6 +5,7 @@ package azure import ( "context" "errors" + "fmt" "io" "net/http" "net/url" @@ -17,6 +18,7 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/azcore/fake" "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" + "github.com/pulumi/pulumi-azure-native/v2/provider/pkg/version" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -134,7 +136,7 @@ func TestNormalizeLocationHeader(t *testing.T) { } func TestInitRequestQueryParams(t *testing.T) { - c, err := NewAzCoreClient(&fake.TokenCredential{}, "pulumi", cloud.AzurePublic, nil) + c, err := NewAzCoreClient(&fake.TokenCredential{}, "", cloud.AzurePublic, nil) require.NoError(t, err) client := c.(*azCoreClient) @@ -161,7 +163,7 @@ func TestInitRequestQueryParams(t *testing.T) { } func TestInitRequestHeaders(t *testing.T) { - c, err := NewAzCoreClient(&fake.TokenCredential{}, "pulumi-agent", cloud.AzurePublic, nil) + c, err := NewAzCoreClient(&fake.TokenCredential{}, "extra", cloud.AzurePublic, nil) require.NoError(t, err) client := c.(*azCoreClient) @@ -178,7 +180,7 @@ func TestInitRequestHeaders(t *testing.T) { require.NoError(t, err) headers := req.Raw().Header - assert.Equal(t, "pulumi-agent", headers.Get("User-Agent")) + assert.Equal(t, "extra", headers.Get("User-Agent")) assert.Equal(t, "application/json", headers.Get("Accept")) assert.Equal(t, contentType, headers.Get("Content-Type")) } @@ -254,6 +256,17 @@ func TestRequestQueryParams(t *testing.T) { }) } +func TestRequestUserAgent(t *testing.T) { + fake := &fakeTransporter{ + responses: []*http.Response{{StatusCode: 200}}, + } + client := newClientWithFakeTransport(fake) + _, err := client.Post(context.Background(), "/subscriptions/123", nil, map[string]any{"api-version": "2022-09-01"}) + require.NoError(t, err) + + require.Regexp(t, `^pulumi-azure-native/(.+) azsdk-go-azcore/(.+) \(.+\) pid-12345$`, fake.requests[0].Header.Get("User-Agent")) +} + func TestErrorStatusCodes(t *testing.T) { t.Run("POST ok", func(t *testing.T) { for _, statusCode := range []int{200, 201} { @@ -652,33 +665,43 @@ func TestCanCreate_Responses(t *testing.T) { // Implements azcore's policy.Transporter by returning the given responses in order. type fakeTransporter struct { + requests []*http.Request responses []*http.Response index int } func (f *fakeTransporter) Do(req *http.Request) (*http.Response, error) { + f.requests = append(f.requests, req) cur := f.responses[f.index] f.index++ return cur, nil } -func newClientWithPreparedResponses(responses []*http.Response) *azCoreClient { +func newClientWithFakeTransport(transport *fakeTransporter) *azCoreClient { opts := arm.ClientOptions{ ClientOptions: policy.ClientOptions{ - Transport: &fakeTransporter{ - responses: responses, + Transport: transport, + Retry: policy.RetryOptions{MaxRetries: -1}, + Cloud: cloud.AzurePublic, + Telemetry: policy.TelemetryOptions{ + ApplicationID: fmt.Sprintf("pulumi-azure-native/%s", version.GetVersion()), }, - Retry: policy.RetryOptions{MaxRetries: -1}, }, } - client, _ := NewAzCoreClient(&fake.TokenCredential{}, "pulumi", cloud.AzurePublic, &opts) + client, _ := NewAzCoreClient(&fake.TokenCredential{}, "pid-12345", cloud.AzurePublic, &opts) azCoreClient := client.(*azCoreClient) azCoreClient.updatePollingIntervalSeconds = 1 azCoreClient.deletePollingIntervalSeconds = 1 return azCoreClient } +func newClientWithPreparedResponses(responses []*http.Response) *azCoreClient { + return newClientWithFakeTransport(&fakeTransporter{ + responses: responses, + }) +} + func TestHandleResponseError(t *testing.T) { t.Run("Cannot unmarshal JSON", func(t *testing.T) { resp := http.Response{ From cf126cffca24a1fb6fea097f10d66e660c5209a6 Mon Sep 17 00:00:00 2001 From: Eron Wright Date: Fri, 22 Aug 2025 11:39:54 -0700 Subject: [PATCH 05/26] slow 6 --- provider/pkg/provider/auth_azidentity.go | 188 ++++++++++++++---- provider/pkg/provider/auth_azidentity_test.go | 160 +++++++++++---- provider/pkg/provider/auth_getclientconfig.go | 167 ++++++++++++++++ provider/pkg/provider/auth_test.go | 14 +- provider/pkg/provider/azure_cli.go | 104 ++++++++++ provider/pkg/provider/provider.go | 103 +++++++--- provider/pkg/provider/provider_test.go | 57 +++--- .../customresources/custom_keyvault.go | 27 ++- .../customresources/customresources.go | 17 +- 9 files changed, 681 insertions(+), 156 deletions(-) create mode 100644 provider/pkg/provider/auth_getclientconfig.go create mode 100644 provider/pkg/provider/azure_cli.go diff --git a/provider/pkg/provider/auth_azidentity.go b/provider/pkg/provider/auth_azidentity.go index 8af47cf56ab2..44fea64413dc 100644 --- a/provider/pkg/provider/auth_azidentity.go +++ b/provider/pkg/provider/auth_azidentity.go @@ -15,20 +15,79 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/azcore" azcloud "github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" "github.com/Azure/azure-sdk-for-go/sdk/azidentity" "github.com/pkg/errors" + "github.com/pulumi/pulumi-azure-native/v2/provider/pkg/azure" "github.com/pulumi/pulumi/sdk/v3/go/common/util/logging" ) -// newTokenCredential is the main entry to the new azcore/azidentity-based authenticattion stack. It returns a +const ( + cliCloudMismatchMessage = ` +The configured Azure cloud '%s' does not match the active cloud '%s'. +When authenticating using the Azure CLI, the configured cloud needs to match the one shown by 'az account show'. +You can change clouds using 'az cloud set --name '.` +) + +// azAccount is the provider's resolved Azure account for authentication and for resource management. +type azAccount struct { + azcore.TokenCredential + + // The Azure cloud configuration to use for resource management operations. + Cloud azcloud.Configuration + + // The subscription ID to use for resource management operations; empty if not configured or auto-detected. + SubscriptionId string +} + +// NewAzCoreIdentity is the main entry to the new azcore/azidentity-based authentication stack. +// It determines the provider's Azure account such as the cloud and subscription ID, and a // TokenCredential which can be passed into various Azure Go SDKs. -func (k *azureNativeProvider) newTokenCredential() (azcore.TokenCredential, error) { - authConf, err := k.readAuthConfig() +func NewAzCoreIdentity(ctx context.Context, authConf *authConfiguration, baseClientOpts policy.ClientOptions) (*azAccount, error) { + account := &azAccount{} + + if authConf.cloud != nil { + baseClientOpts.Cloud = *authConf.cloud + } + + // Create the azcore.TokenCredential implementation based on the auth configuration. + // This routine evaluates the auth configuration and other environment variables, + // and ultimately resolves the Azure cloud and subscription ID. + cred, err := newSingleMethodAuthCredential(authConf, baseClientOpts) if err != nil { return nil, err } + account.TokenCredential = cred - return newSingleMethodAuthCredential(authConf) + // Based on the credential type, we may be able to resolve the Azure cloud and subscription ID. + if _, ok := cred.(*azidentity.AzureCLICredential); ok { + // get the given (or default) account from the Azure CLI + activeSubscription, err := authConf.showSubscription(ctx, authConf.subscriptionId) + if err != nil { + return nil, err + } + logging.V(6).Infof("Using Az account %q", activeSubscription.Name) + + // automatically configure the environment and/or subscription ID based on the Azure CLI account. + sc := azure.GetCloudByName(activeSubscription.EnvironmentName) + if authConf.cloud != nil && sc.ActiveDirectoryAuthorityHost != authConf.cloud.ActiveDirectoryAuthorityHost { + return nil, fmt.Errorf(cliCloudMismatchMessage, azure.GetCloudName(*authConf.cloud), activeSubscription.EnvironmentName) + } + account.Cloud = sc + account.SubscriptionId = activeSubscription.ID + } else { + // use the configured values and/or defaults + if authConf.cloud == nil { + // if the cloud is not set, use the default Azure cloud as does newSingleMethodAuthCredential. + account.Cloud = azcloud.AzurePublic + } else { + account.Cloud = *authConf.cloud + } + account.SubscriptionId = authConf.subscriptionId + } + + logging.V(6).Infof("Using Az cloud %q with subscription ID %q", azure.GetCloudName(account.Cloud), account.SubscriptionId) + return account, nil } // newSingleMethodAuthCredential creates an azcore.TokenCredential. Depending on the given authConfiguration, it is @@ -46,16 +105,22 @@ func (k *azureNativeProvider) newTokenCredential() (azcore.TokenCredential, erro // - When a method is configured but instantiating the credential fails, we return an error and do not fall through to // the next method. // - Auxiliary or additional tenants are supported for SP with client secret and CLI authentication, not for others. -func newSingleMethodAuthCredential(authConf *authConfiguration) (azcore.TokenCredential, error) { - baseClientOpts := azcore.ClientOptions{ - Cloud: authConf.cloud, - } - +func newSingleMethodAuthCredential(authConf *authConfiguration, baseClientOpts azcore.ClientOptions) (azcore.TokenCredential, error) { if authConf.clientCertPath != "" { logging.V(9).Infof("[auth] Using SP with client certificate credential") + fmtErrorMessage := "A %s must be configured when authenticating as a Service Principal using a Client Certificate." + if authConf.subscriptionId == "" { + return nil, fmt.Errorf(fmtErrorMessage, "Subscription ID") + } + if authConf.tenantId == "" { + return nil, fmt.Errorf(fmtErrorMessage, "Tenant ID") + } + if authConf.clientId == "" { + return nil, fmt.Errorf(fmtErrorMessage, "Client ID") + } certs, key, err := readCertificate(authConf.clientCertPath, authConf.clientCertPassword) if err != nil { - return nil, err + return nil, fmt.Errorf("the Client Certificate Path is not a valid pfx file: %w", err) } options := &azidentity.ClientCertificateCredentialOptions{ AdditionallyAllowedTenants: authConf.auxTenants, // usually empty which is fine @@ -68,6 +133,16 @@ func newSingleMethodAuthCredential(authConf *authConfiguration) (azcore.TokenCre if authConf.clientSecret != "" { logging.V(9).Infof("[auth] Using SP with client secret credential") + fmtErrorMessage := "A %s must be configured when authenticating as a Service Principal using a Client Secret." + if authConf.subscriptionId == "" { + return nil, fmt.Errorf(fmtErrorMessage, "Subscription ID") + } + if authConf.tenantId == "" { + return nil, fmt.Errorf(fmtErrorMessage, "Tenant ID") + } + if authConf.clientId == "" { + return nil, fmt.Errorf(fmtErrorMessage, "Client ID") + } options := &azidentity.ClientSecretCredentialOptions{ AdditionallyAllowedTenants: authConf.auxTenants, // usually empty which is fine ClientOptions: baseClientOpts, @@ -79,13 +154,17 @@ func newSingleMethodAuthCredential(authConf *authConfiguration) (azcore.TokenCre if authConf.useOidc { logging.V(9).Infof("[auth] Using OIDC credential") - return newOidcCredential(authConf) + return newOidcCredential(authConf, baseClientOpts) } else { logging.V(9).Infof("OIDC credential is not enabled, skipping") } if authConf.useMsi { logging.V(9).Infof("[auth] Using Managed Identity (MSI) credential") + fmtErrorMessage := "A %s must be configured when authenticating as a Managed Identity using MSI." + if authConf.subscriptionId == "" { + return nil, fmt.Errorf(fmtErrorMessage, "Subscription ID") + } msiOpts := azidentity.ManagedIdentityCredentialOptions{ ClientOptions: baseClientOpts, } @@ -101,11 +180,10 @@ func newSingleMethodAuthCredential(authConf *authConfiguration) (azcore.TokenCre options := &azidentity.AzureCLICredentialOptions{ AdditionallyAllowedTenants: authConf.auxTenants, // usually empty which is fine } - cli, err := azidentity.NewAzureCLICredential(options) - if err == nil { - return cli, nil + if authConf.subscriptionId != "" { + options.Subscription = authConf.subscriptionId } - return nil, errors.Errorf("Failed to find any valid credentials") + return azidentity.NewAzureCLICredential(options) } // newOidcCredential creates a TokenCredential for OpenID Connect (OIDC) authentication. @@ -115,7 +193,18 @@ func newSingleMethodAuthCredential(authConf *authConfiguration) (azcore.TokenCre // - from a file // - through a token exchange by making a request to a configured endpoint // This function configures the client assertion callback according to the above cases. -func newOidcCredential(authConf *authConfiguration) (azcore.TokenCredential, error) { +func newOidcCredential(authConf *authConfiguration, clientOpts azcore.ClientOptions) (azcore.TokenCredential, error) { + fmtErrorMessage := "A %s must be configured when authenticating with OIDC." + if authConf.subscriptionId == "" { + return nil, fmt.Errorf(fmtErrorMessage, "Subscription ID") + } + if authConf.tenantId == "" { + return nil, fmt.Errorf(fmtErrorMessage, "Tenant ID") + } + if authConf.clientId == "" { + return nil, fmt.Errorf(fmtErrorMessage, "Client ID") + } + // The generic client assertion that simply returns the token it was created with. oidcTokenCredentialCallback := func(token string) (azcore.TokenCredential, error) { return azidentity.NewClientAssertionCredential( @@ -146,13 +235,11 @@ func newOidcCredential(authConf *authConfiguration) (azcore.TokenCredential, err authConf.clientId, getOidcTokenExchangeAssertion(authConf), &azidentity.ClientAssertionCredentialOptions{ - ClientOptions: azcore.ClientOptions{ - Cloud: authConf.cloud, - }, + ClientOptions: clientOpts, }) } - return nil, errors.New("OIDC token or request URL and token are not provided") + return nil, fmt.Errorf(fmtErrorMessage, "OIDC token or request URL and token") } // getOidcTokenExchangeAssertion returns a callback function that implements the OIDC token @@ -233,7 +320,9 @@ func readCertificate(certPath, certPassword string) ([]*x509.Certificate, crypto } type authConfiguration struct { - cloud azcloud.Configuration + cloud *azcloud.Configuration + + subscriptionId string clientId string tenantId string @@ -255,11 +344,18 @@ type authConfiguration struct { // automatically: // https://github.com/Azure/azure-sdk-for-go/blob/sdk/azidentity/v1.8.0/sdk/azidentity/managed_identity_client.go#L143 useMsi bool + + // showSubscription invokes `az account show` and is overridable by tests to fake invoking the az CLI. + showSubscription azSubscriptionProvider } -// getAuthConfig collects auth-related configuration from Pulumi config and environment variables -func (k *azureNativeProvider) readAuthConfig() (*authConfiguration, error) { - auxTenantsString := k.getConfig("auxiliaryTenantIds", "ARM_AUXILIARY_TENANT_IDS") +type configGetter func(configName, envName string) string + +// readAuthConfig collects auth-related configuration from Pulumi config and environment variables +func readAuthConfig(getConfig configGetter) (*authConfiguration, error) { + cloud := getCloud(getConfig) + + auxTenantsString := getConfig("auxiliaryTenantIds", "ARM_AUXILIARY_TENANT_IDS") var auxTenants []string if auxTenantsString != "" { err := json.Unmarshal([]byte(auxTenantsString), &auxTenants) @@ -269,22 +365,40 @@ func (k *azureNativeProvider) readAuthConfig() (*authConfiguration, error) { } return &authConfiguration{ - clientId: k.getConfig("clientId", "ARM_CLIENT_ID"), - tenantId: k.getConfig("tenantId", "ARM_TENANT_ID"), + clientId: getConfig("clientId", "ARM_CLIENT_ID"), + tenantId: getConfig("tenantId", "ARM_TENANT_ID"), auxTenants: auxTenants, - cloud: k.cloud, + cloud: cloud, + + subscriptionId: getConfig("subscriptionId", "ARM_SUBSCRIPTION_ID"), - clientSecret: k.getConfig("clientSecret", "ARM_CLIENT_SECRET"), - clientCertPath: k.getConfig("clientCertificatePath", "ARM_CLIENT_CERTIFICATE_PATH"), - clientCertPassword: k.getConfig("clientCertificatePassword", "ARM_CLIENT_CERTIFICATE_PASSWORD"), + clientSecret: getConfig("clientSecret", "ARM_CLIENT_SECRET"), + clientCertPath: getConfig("clientCertificatePath", "ARM_CLIENT_CERTIFICATE_PATH"), + clientCertPassword: getConfig("clientCertificatePassword", "ARM_CLIENT_CERTIFICATE_PASSWORD"), - useMsi: k.getConfig("useMsi", "ARM_USE_MSI") == "true", + useMsi: getConfig("useMsi", "ARM_USE_MSI") == "true", - useOidc: k.getConfig("useOidc", "ARM_USE_OIDC") == "true", - oidcAudience: k.getConfig("oidcAudience", "ARM_OIDC_AUDIENCE"), - oidcToken: k.getConfig("oidcToken", "ARM_OIDC_TOKEN"), - oidcTokenFilePath: k.getConfig("oidcTokenFilePath", "ARM_OIDC_TOKEN_FILE_PATH"), - oidcTokenRequestToken: k.getConfig("oidcRequestToken", "ACTIONS_ID_TOKEN_REQUEST_TOKEN"), - oidcTokenRequestUrl: k.getConfig("oidcRequestUrl", "ACTIONS_ID_TOKEN_REQUEST_URL"), + useOidc: getConfig("useOidc", "ARM_USE_OIDC") == "true", + oidcAudience: getConfig("oidcAudience", "ARM_OIDC_AUDIENCE"), + oidcToken: getConfig("oidcToken", "ARM_OIDC_TOKEN"), + oidcTokenFilePath: getConfig("oidcTokenFilePath", "ARM_OIDC_TOKEN_FILE_PATH"), + oidcTokenRequestToken: getConfig("oidcRequestToken", "ACTIONS_ID_TOKEN_REQUEST_TOKEN"), + oidcTokenRequestUrl: getConfig("oidcRequestUrl", "ACTIONS_ID_TOKEN_REQUEST_URL"), + + showSubscription: defaultAzSubscriptionProvider, }, nil } + +// getCloud returns the configured Azure cloud (environment). +// Returns nil if not configured, to allow for other detection methods before defaulting to the public cloud. +func getCloud(getConfig configGetter) *azcloud.Configuration { + envName := getConfig("environment", "ARM_ENVIRONMENT") + if envName == "" { + envName = getConfig("environment", "AZURE_ENVIRONMENT") + } + if envName != "" { + cloud := azure.GetCloudByName(envName) + return &cloud + } + return nil +} diff --git a/provider/pkg/provider/auth_azidentity_test.go b/provider/pkg/provider/auth_azidentity_test.go index 838348f45fb5..61d91c398d51 100644 --- a/provider/pkg/provider/auth_azidentity_test.go +++ b/provider/pkg/provider/auth_azidentity_test.go @@ -9,9 +9,10 @@ import ( "net/http/httptest" "os" "path/filepath" + "reflect" "testing" - "github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud" + "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/azidentity" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -20,6 +21,17 @@ import ( //go:embed test.pfx var testPfxCert []byte +type testProvider struct { + config map[string]string +} + +func (k *testProvider) getConfig(configName, envName string) string { + if val, ok := k.config[configName]; ok { + return val + } + return os.Getenv(envName) +} + func TestGetAuthConfig(t *testing.T) { setAuthEnvVariables := func(value, boolValue string) { if value != "" { @@ -41,8 +53,9 @@ func TestGetAuthConfig(t *testing.T) { t.Run("empty", func(t *testing.T) { setAuthEnvVariables("", "") - p := azureNativeProvider{} - c, err := p.readAuthConfig() + p := &testProvider{} + c, err := readAuthConfig(p.getConfig) + require.NoError(t, err) require.NotNil(t, c) require.Empty(t, c.auxTenants) @@ -58,12 +71,14 @@ func TestGetAuthConfig(t *testing.T) { require.Empty(t, c.tenantId) require.False(t, c.useOidc) require.False(t, c.useMsi) + require.Nil(t, c.cloud) }) t.Run("values from config take precedence", func(t *testing.T) { setAuthEnvVariables("env", "false") + t.Setenv("ARM_ENVIRONMENT", "public") - p := azureNativeProvider{ + p := &testProvider{ config: map[string]string{ "auxiliaryTenantIds": `["conf"]`, "clientCertificatePassword": "conf", @@ -80,10 +95,9 @@ func TestGetAuthConfig(t *testing.T) { "useMsi": "true", "useOidc": "true", }, - cloud: cloud.AzureGovernment, } - c, err := p.readAuthConfig() + c, err := readAuthConfig(p.getConfig) require.NoError(t, err) require.NotNil(t, c) require.Equal(t, []string{"conf"}, c.auxTenants) @@ -97,17 +111,18 @@ func TestGetAuthConfig(t *testing.T) { require.Equal(t, "conf", c.oidcTokenRequestToken) require.Equal(t, "conf", c.oidcTokenRequestUrl) require.Equal(t, "conf", c.tenantId) + require.NotNil(t, c.cloud) + require.Equal(t, "https://login.microsoftonline.us/", c.cloud.ActiveDirectoryAuthorityHost) require.True(t, c.useOidc) require.True(t, c.useMsi) }) t.Run("values from env", func(t *testing.T) { - p := azureNativeProvider{ - cloud: cloud.AzureChina, - } + p := &testProvider{} setAuthEnvVariables("env", "true") + t.Setenv("ARM_ENVIRONMENT", "usgovernment") - c, err := p.readAuthConfig() + c, err := readAuthConfig(p.getConfig) require.NoError(t, err) require.NotNil(t, c) require.Equal(t, []string{"env"}, c.auxTenants) @@ -121,6 +136,8 @@ func TestGetAuthConfig(t *testing.T) { require.Equal(t, "env", c.oidcTokenRequestToken) require.Equal(t, "env", c.oidcTokenRequestUrl) require.Equal(t, "env", c.tenantId) + require.NotNil(t, c.cloud) + require.Equal(t, "https://login.microsoftonline.us/", c.cloud.ActiveDirectoryAuthorityHost) require.True(t, c.useOidc) require.True(t, c.useMsi) }) @@ -129,23 +146,39 @@ func TestGetAuthConfig(t *testing.T) { func TestNewCredential(t *testing.T) { t.Run("SP with client secret", func(t *testing.T) { conf := &authConfiguration{ - clientId: "client-id", - clientSecret: "client-secret", - tenantId: "tenant-id", + clientId: "client-id", + clientSecret: "client-secret", + tenantId: "tenant-id", + subscriptionId: "subscription-id", } - cred, err := newSingleMethodAuthCredential(conf) + cred, err := newSingleMethodAuthCredential(conf, azcore.ClientOptions{}) require.NoError(t, err) require.IsType(t, &azidentity.ClientSecretCredential{}, cred) + clientVal := reflect.ValueOf(cred).Elem().FieldByName("client") + require.Equal(t, "client-id", clientVal.Elem().FieldByName("clientID").String()) + require.Equal(t, "tenant-id", clientVal.Elem().FieldByName("tenantID").String()) }) - t.Run("Incomplete SP with client secret conf missing tenant id", func(t *testing.T) { + t.Run("Incomplete SP missing subscription id", func(t *testing.T) { conf := &authConfiguration{ clientId: "client-id", clientSecret: "client-secret", + tenantId: "tenant-id", + } + _, err := newSingleMethodAuthCredential(conf, azcore.ClientOptions{}) + require.Error(t, err) + require.Contains(t, err.Error(), "Subscription") + }) + + t.Run("Incomplete SP with client secret conf missing tenant id", func(t *testing.T) { + conf := &authConfiguration{ + clientId: "client-id", + clientSecret: "client-secret", + subscriptionId: "subscription-id", } - _, err := newSingleMethodAuthCredential(conf) + _, err := newSingleMethodAuthCredential(conf, azcore.ClientOptions{}) require.Error(t, err) - require.Contains(t, err.Error(), "tenant") + require.Contains(t, err.Error(), "Tenant") }) t.Run("SP with client cert", func(t *testing.T) { @@ -157,10 +190,14 @@ func TestNewCredential(t *testing.T) { clientCertPath: certPath, clientCertPassword: "pulumi", tenantId: "tenant-id", + subscriptionId: "subscription-id", } - cred, err := newSingleMethodAuthCredential(conf) + cred, err := newSingleMethodAuthCredential(conf, azcore.ClientOptions{}) require.NoError(t, err) require.IsType(t, &azidentity.ClientCertificateCredential{}, cred) + clientVal := reflect.ValueOf(cred).Elem().FieldByName("client") + require.Equal(t, "client-id", clientVal.Elem().FieldByName("clientID").String()) + require.Equal(t, "tenant-id", clientVal.Elem().FieldByName("tenantID").String()) }) t.Run("SP with invalid client cert", func(t *testing.T) { @@ -171,8 +208,9 @@ func TestNewCredential(t *testing.T) { clientId: "client-id", clientCertPath: certPath, tenantId: "tenant-id", + subscriptionId: "subscription-id", } - _, err := newSingleMethodAuthCredential(conf) + _, err := newSingleMethodAuthCredential(conf, azcore.ClientOptions{}) require.Error(t, err) require.Contains(t, err.Error(), "failed to parse certificate") }) @@ -186,8 +224,9 @@ func TestNewCredential(t *testing.T) { clientCertPath: certPath, clientCertPassword: "wrong", tenantId: "tenant-id", + subscriptionId: "subscription-id", } - _, err := newSingleMethodAuthCredential(conf) + _, err := newSingleMethodAuthCredential(conf, azcore.ClientOptions{}) require.Error(t, err) require.Contains(t, err.Error(), "failed to parse certificate") require.Contains(t, err.Error(), "password incorrect") @@ -195,14 +234,18 @@ func TestNewCredential(t *testing.T) { t.Run("OIDC with token", func(t *testing.T) { conf := &authConfiguration{ - useOidc: true, - oidcToken: "oidc-token", - clientId: "client-id", - tenantId: "tenant-id", + useOidc: true, + oidcToken: "oidc-token", + clientId: "client-id", + tenantId: "tenant-id", + subscriptionId: "subscription-id", } - cred, err := newSingleMethodAuthCredential(conf) + cred, err := newSingleMethodAuthCredential(conf, azcore.ClientOptions{}) require.NoError(t, err) require.IsType(t, &azidentity.ClientAssertionCredential{}, cred) + clientVal := reflect.ValueOf(cred).Elem().FieldByName("client") + require.Equal(t, "client-id", clientVal.Elem().FieldByName("clientID").String()) + require.Equal(t, "tenant-id", clientVal.Elem().FieldByName("tenantID").String()) }) t.Run("OIDC with token file", func(t *testing.T) { @@ -214,8 +257,9 @@ func TestNewCredential(t *testing.T) { oidcTokenFilePath: tokenPath, clientId: "client-id", tenantId: "tenant-id", + subscriptionId: "subscription-id", } - cred, err := newSingleMethodAuthCredential(conf) + cred, err := newSingleMethodAuthCredential(conf, azcore.ClientOptions{}) require.NoError(t, err) require.IsType(t, &azidentity.ClientAssertionCredential{}, cred) }) @@ -226,9 +270,11 @@ func TestNewCredential(t *testing.T) { oidcTokenFilePath: filepath.Join(t.TempDir(), "foo"), clientId: "client-id", tenantId: "tenant-id", + subscriptionId: "subscription-id", } - _, err := newSingleMethodAuthCredential(conf) + _, err := newSingleMethodAuthCredential(conf, azcore.ClientOptions{}) require.Error(t, err) + require.ErrorIs(t, err, os.ErrNotExist) }) t.Run("OIDC with token exchange URL", func(t *testing.T) { @@ -238,8 +284,9 @@ func TestNewCredential(t *testing.T) { oidcTokenRequestUrl: "oidc-token-url", clientId: "client-id", tenantId: "tenant-id", + subscriptionId: "subscription-id", } - cred, err := newSingleMethodAuthCredential(conf) + cred, err := newSingleMethodAuthCredential(conf, azcore.ClientOptions{}) require.NoError(t, err) require.IsType(t, &azidentity.ClientAssertionCredential{}, cred) }) @@ -252,36 +299,46 @@ func TestNewCredential(t *testing.T) { oidcTokenRequestUrl: "oidc-token-url", clientId: "client-id", tenantId: "tenant-id", + subscriptionId: "subscription-id", } - cred, err := newSingleMethodAuthCredential(conf) + cred, err := newSingleMethodAuthCredential(conf, azcore.ClientOptions{}) require.NoError(t, err) require.IsType(t, &azidentity.ClientAssertionCredential{}, cred) }) t.Run("Incomplete OIDC conf", func(t *testing.T) { for _, conf := range []*authConfiguration{ - { - useOidc: true, - oidcToken: "oidc-token", - clientId: "client-id", + { // missing tenantId + useOidc: true, + oidcToken: "oidc-token", + clientId: "client-id", + subscriptionId: "subscription-id", }, - { + { // missing oidcTokenRequestToken useOidc: true, oidcTokenRequestUrl: "oidc-token-url", clientId: "client-id", tenantId: "tenant-id", + subscriptionId: "subscription-id", + }, + { // missing subscriptionId + useOidc: true, + oidcToken: "oidc-token", + clientId: "client-id", + tenantId: "tenant-id", }, } { - _, err := newSingleMethodAuthCredential(conf) + _, err := newSingleMethodAuthCredential(conf, azcore.ClientOptions{}) require.Error(t, err) } }) t.Run("MSI", func(t *testing.T) { conf := &authConfiguration{ - useMsi: true, + useMsi: true, + subscriptionId: "subscription-id", } - cred, err := newSingleMethodAuthCredential(conf) + cred, err := newSingleMethodAuthCredential(conf, azcore.ClientOptions{}) require.NoError(t, err) require.IsType(t, &azidentity.ManagedIdentityCredential{}, cred) }) @@ -289,29 +346,44 @@ func TestNewCredential(t *testing.T) { // Used for user-assigned managed identity t.Run("MSI with client id", func(t *testing.T) { conf := &authConfiguration{ - clientId: "123", - useMsi: true, + useMsi: true, + clientId: "123", + subscriptionId: "subscription-id", } - cred, err := newSingleMethodAuthCredential(conf) + cred, err := newSingleMethodAuthCredential(conf, azcore.ClientOptions{}) require.NoError(t, err) require.IsType(t, &azidentity.ManagedIdentityCredential{}, cred) + // FUTURE: assert cred.client.id = "123" }) t.Run("CLI", func(t *testing.T) { conf := &authConfiguration{} - cred, err := newSingleMethodAuthCredential(conf) + cred, err := newSingleMethodAuthCredential(conf, azcore.ClientOptions{}) require.NoError(t, err) require.IsType(t, &azidentity.AzureCLICredential{}, cred) }) - t.Run("CLI with tenant ids", func(t *testing.T) { + t.Run("CLI with aux tenants", func(t *testing.T) { conf := &authConfiguration{ - tenantId: "tenant-id", auxTenants: []string{"123", "456"}, } - cred, err := newSingleMethodAuthCredential(conf) + cred, err := newSingleMethodAuthCredential(conf, azcore.ClientOptions{}) + require.NoError(t, err) + require.IsType(t, &azidentity.AzureCLICredential{}, cred) + optsVal := reflect.ValueOf(cred).Elem().FieldByName("opts") + require.Equal(t, "123", optsVal.FieldByName("AdditionallyAllowedTenants").Index(0).String()) + require.Equal(t, "456", optsVal.FieldByName("AdditionallyAllowedTenants").Index(1).String()) + }) + + t.Run("CLI with subscription id", func(t *testing.T) { + conf := &authConfiguration{ + subscriptionId: "subscription-id", + } + cred, err := newSingleMethodAuthCredential(conf, azcore.ClientOptions{}) require.NoError(t, err) require.IsType(t, &azidentity.AzureCLICredential{}, cred) + optsVal := reflect.ValueOf(cred).Elem().FieldByName("opts") + require.Equal(t, "subscription-id", optsVal.FieldByName("Subscription").String()) }) } diff --git a/provider/pkg/provider/auth_getclientconfig.go b/provider/pkg/provider/auth_getclientconfig.go new file mode 100644 index 000000000000..5e23e586af77 --- /dev/null +++ b/provider/pkg/provider/auth_getclientconfig.go @@ -0,0 +1,167 @@ +// Copyright 2016-2022, Pulumi Corporation. + +package provider + +import ( + "context" + "fmt" + "strings" + + azcloud "github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud" + azpolicy "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + msgraphsdk "github.com/microsoftgraph/msgraph-sdk-go" + auth "github.com/microsoftgraph/msgraph-sdk-go-core/authentication" + "github.com/microsoftgraph/msgraph-sdk-go/serviceprincipals" + "github.com/pkg/errors" + "github.com/pulumi/pulumi-azure-native/v2/provider/pkg/azure" + "github.com/pulumi/pulumi/sdk/v3/go/common/util/logging" +) + +// ClientConfig represents the provider's Azure client configuration, including the Azure environment, +// client identity, and target subscription. +type ClientConfig struct { + Cloud azcloud.Configuration + + ClientID string + ObjectID string + SubscriptionID string + TenantID string + + AuthenticatedAsAServicePrincipal bool +} + +// GetClientConfig resolves the provider's identity given the auth configuration and a TokenCredential. +// It returns a ClientConfig which contains the client ID, object ID, subscription ID, tenant. +func GetClientConfig(ctx context.Context, config *authConfiguration, cred *azAccount) (*ClientConfig, error) { + + // The original specification for what constitutes the "client configuration" is from here: + // https://github.com/hashicorp/terraform-provider-azurerm/blob/572bb4f37d73f4f0d914737eaca4e5a1fd084c86/internal/clients/auth.go#L33 + + endpoint := getGraphEndpoint(cred.Cloud) + logging.V(9).Infof("MSGraph endpoint: %s", endpoint) + + // Acquire an access token so we can inspect the claims + scope := fmt.Sprintf("https://%s/.default", endpoint) + token, err := cred.GetToken(ctx, azpolicy.TokenRequestOptions{ + Scopes: []string{scope}, + }) + if err != nil { + return nil, fmt.Errorf("could not acquire access token to parse claims: %+v", err) + } + + tokenClaims, err := azure.ParseClaims(token) + if err != nil { + return nil, fmt.Errorf("parsing claims from access token: %+v", err) + } + + authenticatedAsServicePrincipal := true + if strings.Contains(strings.ToLower(tokenClaims.Scopes), "openid") { + authenticatedAsServicePrincipal = false + } + + clientId := tokenClaims.AppId + if clientId == "" { + logging.V(9).Infof("Using user-supplied ClientID because the `appid` claim was missing from the access token") + clientId = config.clientId + } + + objectId := tokenClaims.ObjectId + if objectId == "" { + // Initialize an MS Graph client for the target cloud + authProvider, err := auth.NewAzureIdentityAuthenticationProviderWithScopes(cred, []string{scope}) + if err != nil { + return nil, err + } + adapter, err := msgraphsdk.NewGraphRequestAdapter(authProvider) + if err != nil { + return nil, err + } + adapter.SetBaseUrl(fmt.Sprintf("https://%s/v1.0", endpoint)) + client := msgraphsdk.NewGraphServiceClient(adapter) + + // Lookup the object ID + if authenticatedAsServicePrincipal { + logging.V(9).Infof("Querying Microsoft Graph to discover authenticated service principal object ID because the `oid` claim was missing from the access token") + id, err := servicePrincipalObjectID(ctx, client, clientId) + if err != nil { + return nil, fmt.Errorf("attempting to discover object ID for authenticated service principal with client ID %q: %w", clientId, err) + } + + objectId = *id + } else { + logging.V(9).Infof("Querying Microsoft Graph to discover authenticated user principal object ID because the `oid` claim was missing from the access token") + id, err := userPrincipalObjectID(ctx, client) + if err != nil { + return nil, fmt.Errorf("attempting to discover object ID for authenticated user principal: %w", err) + } + objectId = *id + } + } + + tenantId := tokenClaims.TenantId + if tenantId == "" { + logging.V(9).Infof("Using user-supplied TenantID because the `tid` claim was missing from the access token") + tenantId = config.tenantId + } + + account := &ClientConfig{ + Cloud: cred.Cloud, + + ClientID: clientId, + ObjectID: objectId, + SubscriptionID: cred.SubscriptionId, + TenantID: tenantId, + + AuthenticatedAsAServicePrincipal: authenticatedAsServicePrincipal, + } + + return account, nil +} + +func servicePrincipalObjectID(ctx context.Context, client *msgraphsdk.GraphServiceClient, clientId string) (*string, error) { + filter := fmt.Sprintf("appId eq '%s'", clientId) + response, err := client.ServicePrincipals().Get(ctx, &serviceprincipals.ServicePrincipalsRequestBuilderGetRequestConfiguration{ + QueryParameters: &serviceprincipals.ServicePrincipalsRequestBuilderGetQueryParameters{ + Filter: &filter, + }, + }) + if err != nil { + return nil, fmt.Errorf("executing request: %w", err) + } + + principals := response.GetValue() + if len(principals) != 1 { + return nil, fmt.Errorf("unexpected number of results, expected 1, received %d", len(principals)) + } + + id := principals[0].GetId() + if id == nil { + return nil, errors.New("returned object ID was nil") + } + + return id, nil +} + +func userPrincipalObjectID(ctx context.Context, client *msgraphsdk.GraphServiceClient) (*string, error) { + me, err := client.Me().Get(ctx, nil) + if err != nil { + return nil, fmt.Errorf("executing request: %w", err) + } + + if me.GetId() == nil { + return nil, fmt.Errorf("returned object ID was nil") + } + + return me.GetId(), nil +} + +// from: https://github.com/Azure/go-autorest/blob/autorest/v0.11.29/autorest/azure/environments.go +func getGraphEndpoint(cloud azcloud.Configuration) string { + suffix := "graph.microsoft.com" + if cloud.ActiveDirectoryAuthorityHost == azcloud.AzureChina.ActiveDirectoryAuthorityHost { + suffix = "microsoftgraph.chinacloudapi.cn" + } else if cloud.ActiveDirectoryAuthorityHost == azcloud.AzureGovernment.ActiveDirectoryAuthorityHost { + suffix = "graph.microsoft.us" + } + return suffix +} diff --git a/provider/pkg/provider/auth_test.go b/provider/pkg/provider/auth_test.go index f26aeaf983e4..1eff5110e874 100644 --- a/provider/pkg/provider/auth_test.go +++ b/provider/pkg/provider/auth_test.go @@ -132,8 +132,15 @@ func TestOidcWithTokenFileFromEnv(t *testing.T) { func TestOidcEmptyConfig(t *testing.T) { p := azureNativeProvider{} + t.Setenv("ARM_OIDC_TOKEN", "") + t.Setenv("ARM_OIDC_TOKEN_FILE_PATH", "") + t.Setenv("ARM_OIDC_REQUEST_TOKEN", "") + t.Setenv("ARM_OIDC_REQUEST_URL", "") + t.Setenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "") + t.Setenv("ACTIONS_ID_TOKEN_REQUEST_URL", "") + _, err := p.determineOidcConfig() - assert.NotNil(t, err) + assert.Error(t, err) } func TestOidcUrlTokenPairValidation(t *testing.T) { @@ -141,9 +148,12 @@ func TestOidcUrlTokenPairValidation(t *testing.T) { // With a request token we also need a request URL. t.Setenv("ARM_OIDC_REQUEST_TOKEN", "t1") + t.Setenv("ARM_OIDC_REQUEST_URL", "") + t.Setenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "") + t.Setenv("ACTIONS_ID_TOKEN_REQUEST_URL", "") _, err := p.determineOidcConfig() - assert.NotNil(t, err) + assert.Error(t, err) } func TestOidcPrefersToken(t *testing.T) { diff --git a/provider/pkg/provider/azure_cli.go b/provider/pkg/provider/azure_cli.go new file mode 100644 index 000000000000..df19d9d0d5d3 --- /dev/null +++ b/provider/pkg/provider/azure_cli.go @@ -0,0 +1,104 @@ +// Copyright 2016-2025, Pulumi Corporation. + +package provider + +import ( + "bytes" + "context" + "encoding/json" + "os" + "os/exec" + "runtime" + "strings" + "time" + + "github.com/pkg/errors" +) + +// Subscription represents a Subscription from the Azure CLI +type Subscription struct { + EnvironmentName string `json:"environmentName"` + ID string `json:"id"` + IsDefault bool `json:"isDefault"` + Name string `json:"name"` + State string `json:"state"` + TenantID string `json:"tenantId"` + User *User `json:"user"` +} + +// User represents a User from the Azure CLI +type User struct { + Name string `json:"name"` + Type string `json:"type"` +} + +type subscriptionUnavailableError struct { + message string +} + +func (e *subscriptionUnavailableError) Error() string { + return e.message +} + +func newSubscriptionUnavailableError(message string) error { + return &subscriptionUnavailableError{message} +} + +type azSubscriptionProvider func(ctx context.Context, subscriptionID string) (*Subscription, error) + +// cliTimeout is the default timeout for authentication attempts via CLI tools +const cliTimeout = 10 * time.Second + +// defaultAzSubscriptionProvider invokes the Azure CLI to acquire a subscription. It assumes +// callers have verified that all string arguments are safe to pass to the CLI. +// this code is derived from "CLI token provider" code in the Azure SDK for Go: +// https://github.com/Azure/azure-sdk-for-go/blob/519e8ab1a0e433b755c31ebaa6b177dfc83cb838/sdk/azidentity/azure_cli_credential.go#L117-L172 +var defaultAzSubscriptionProvider = func(ctx context.Context, subscriptionID string) (*Subscription, error) { + // set a default timeout for this authentication iff the application hasn't done so already + var cancel context.CancelFunc + if _, hasDeadline := ctx.Deadline(); !hasDeadline { + ctx, cancel = context.WithTimeout(ctx, cliTimeout) + defer cancel() + } + commandLine := "az account show -o json " + if subscriptionID != "" { + // subscription needs quotes because it may contain spaces + commandLine += ` --subscription "` + subscriptionID + `"` + } + var cliCmd *exec.Cmd + if runtime.GOOS == "windows" { + dir := os.Getenv("SYSTEMROOT") + if dir == "" { + return nil, newSubscriptionUnavailableError("environment variable 'SYSTEMROOT' has no value") + } + cliCmd = exec.CommandContext(ctx, "cmd.exe", "/c", commandLine) + cliCmd.Dir = dir + } else { + cliCmd = exec.CommandContext(ctx, "/bin/sh", "-c", commandLine) + cliCmd.Dir = "/bin" + } + cliCmd.Env = os.Environ() + var stderr bytes.Buffer + cliCmd.Stderr = &stderr + + output, err := cliCmd.Output() + if err != nil { + msg := stderr.String() + var exErr *exec.ExitError + if errors.As(err, &exErr) && exErr.ExitCode() == 127 || strings.HasPrefix(msg, "'az' is not recognized") { + msg = "Azure CLI not found on path" + } + if msg == "" { + msg = err.Error() + } + return nil, newSubscriptionUnavailableError(msg) + } + + s := Subscription{} + err = json.Unmarshal(output, &s) + if err != nil { + return nil, newSubscriptionUnavailableError(err.Error()) + } + + return &s, nil +} diff --git a/provider/pkg/provider/provider.go b/provider/pkg/provider/provider.go index 2e9ebb5147c4..7a820fc6e3c9 100644 --- a/provider/pkg/provider/provider.go +++ b/provider/pkg/provider/provider.go @@ -57,6 +57,9 @@ const ( type azureNativeProvider struct { rpc.UnimplementedResourceProviderServer + authConfig authConfiguration + credential *azAccount + azureClient azure.AzureClient host *provider.HostClient name string @@ -245,9 +248,26 @@ func (k *azureNativeProvider) Configure(ctx context.Context, var credential azcore.TokenCredential if util.EnableAzcoreBackend() { - credential, err = k.newTokenCredential() + logging.V(9).Infof("Using azcore authentication") + + authConfig, err := readAuthConfig(k.getConfig) + if err != nil { + return nil, err + } + k.authConfig = *authConfig + + clientOpts := azcore.ClientOptions{} + credential, err := NewAzCoreIdentity(ctx, authConfig, clientOpts) + if err != nil { + return nil, err + } + k.credential = credential + k.cloud = credential.Cloud + k.subscriptionID = credential.SubscriptionId + + k.azureClient, err = k.newAzureClient(resourceManagerAuth, credential, userAgent) if err != nil { - return nil, fmt.Errorf("creating Pulumi auth credential: %w", err) + return nil, fmt.Errorf("creating Azure client: %w", err) } } else { logging.V(9).Infof("Using legacy authentication") @@ -262,7 +282,7 @@ func (k *azureNativeProvider) Configure(ctx context.Context, // When the provider is parameterized, resources and types that custom resources are built on will probably not be available. if !k.isParameterized() { k.customResources, err = customresources.BuildCustomResources(&k.environment, k.azureClient, k.LookupResource, k.newCrudClient, k.subscriptionID, - resourceManagerBearerAuth, resourceManagerAuth, keyVaultBearerAuth, userAgent, credential) + resourceManagerBearerAuth, resourceManagerAuth, keyVaultBearerAuth, userAgent, k.cloud, credential) if err != nil { return nil, fmt.Errorf("initializing custom resources: %w", err) } @@ -304,33 +324,18 @@ func (k *azureNativeProvider) Invoke(ctx context.Context, req *rpc.InvokeRequest var outputs map[string]interface{} switch req.Tok { case "azure-native:authorization:getClientConfig": - auth, err := k.getAuthConfig() + auth, err := k.getClientConfig(ctx) if err != nil { - return nil, fmt.Errorf("getting auth config: %w", err) - } - objectId := "" - if auth.GetAuthenticatedObjectID != nil { - objectIdPtr, err := auth.GetAuthenticatedObjectID(ctx) - if err != nil { - return nil, fmt.Errorf("getting authenticated object ID: %w", err) - } - if objectIdPtr == nil { - return nil, fmt.Errorf("getting authenticated object ID") - } - objectId = *objectIdPtr + return nil, fmt.Errorf("getting client config: %w", err) } outputs = map[string]interface{}{ "clientId": auth.ClientID, - "objectId": objectId, + "objectId": auth.ObjectID, "subscriptionId": auth.SubscriptionID, "tenantId": auth.TenantID, } case "azure-native:authorization:getClientToken": - auth, err := k.getAuthConfig() - if err != nil { - return nil, fmt.Errorf("getting auth config: %w", err) - } - token, err := k.getClientToken(ctx, auth, args["endpoint"]) + token, err := k.getClientToken(ctx, args["endpoint"]) if err != nil { return nil, err } @@ -398,15 +403,41 @@ func (k *azureNativeProvider) Invoke(ctx context.Context, req *rpc.InvokeRequest return &rpc.InvokeResponse{Return: result}, nil } -func (k *azureNativeProvider) getClientToken(ctx context.Context, authConfig *authConfig, endpointArg resource.PropertyValue) (string, error) { +func (k *azureNativeProvider) getClientConfig(ctx context.Context) (config *ClientConfig, err error) { + if !util.EnableAzcoreBackend() { + auth, err := k.getAuthConfig() + if err != nil { + return nil, fmt.Errorf("getting auth config: %w", err) + } + objectId := "" + if auth.GetAuthenticatedObjectID != nil { + objectIdPtr, err := auth.GetAuthenticatedObjectID(ctx) + if err != nil { + return nil, fmt.Errorf("getting authenticated object ID: %w", err) + } + if objectIdPtr == nil { + return nil, fmt.Errorf("getting authenticated object ID") + } + objectId = *objectIdPtr + } + + return &ClientConfig{ + ClientID: auth.ClientID, + ObjectID: objectId, + SubscriptionID: auth.SubscriptionID, + TenantID: auth.TenantID, + }, nil + } + + return GetClientConfig(ctx, &k.authConfig, k.credential) +} + +func (k *azureNativeProvider) getClientToken(ctx context.Context, endpointArg resource.PropertyValue) (token string, err error) { endpoint := k.tokenEndpoint(endpointArg) + logging.V(9).Infof("getting a token credential for %s", endpoint) if util.EnableAzcoreBackend() { - cred, err := k.newTokenCredential() - if err != nil { - return "", err - } - t, err := cred.GetToken(ctx, tokenRequestOpts(endpoint)) + t, err := k.credential.GetToken(ctx, tokenRequestOpts(endpoint)) if err != nil { return "", err } @@ -414,16 +445,26 @@ func (k *azureNativeProvider) getClientToken(ctx context.Context, authConfig *au } // legacy autorest/go-azure-helpers auth + authConfig, err := k.getAuthConfig() + if err != nil { + return "", fmt.Errorf("getting auth config: %w", err) + } return k.getOAuthToken(ctx, authConfig, endpoint) } -// Returns the Azure endpoint where tokens can be requested. If the argument is not null or empty, -// it will be used verbatim. +// Returns the endpoint to be used as the resource (scope) of the token request. +// If the argument is not null or empty, it will be used verbatim. func (k *azureNativeProvider) tokenEndpoint(endpointArg resource.PropertyValue) string { if endpointArg.HasValue() && endpointArg.IsString() && endpointArg.StringValue() != "" { return endpointArg.StringValue() } - return k.environment.ResourceManagerEndpoint + + // see: https://learn.microsoft.com/en-us/entra/identity-platform/scopes-oidc#trailing-slash-and-default + endpoint := k.cloud.Services[azcloud.ResourceManager].Endpoint + if !strings.HasSuffix(endpoint, "/") { + endpoint += "/" + } + return endpoint } func tokenRequestOpts(endpoint string) policy.TokenRequestOptions { diff --git a/provider/pkg/provider/provider_test.go b/provider/pkg/provider/provider_test.go index 713f208ab0dc..ea8c293bad01 100644 --- a/provider/pkg/provider/provider_test.go +++ b/provider/pkg/provider/provider_test.go @@ -3,12 +3,15 @@ package provider import ( "context" "fmt" + "os" "reflect" "testing" "github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud" "github.com/Azure/azure-sdk-for-go/sdk/azcore/fake" - "github.com/Azure/go-autorest/autorest/azure" + azureEnv "github.com/Azure/go-autorest/autorest/azure" + "github.com/pulumi/pulumi-azure-native/v2/provider/pkg/azure" + "github.com/blang/semver" structpb "github.com/golang/protobuf/ptypes/struct" @@ -473,27 +476,27 @@ func TestReadAfterWrite(t *testing.T) { } func TestUsesCorrectAzureClient(t *testing.T) { - p := azureNativeProvider{} - t.Run("default", func(t *testing.T) { + _, ok := os.LookupEnv("PULUMI_ENABLE_AZCORE_BACKEND") + if ok { + t.Skip("PULUMI_ENABLE_AZCORE_BACKEND is set, cannot test default behavior") + } + assert.True(t, util.EnableAzcoreBackend(), "azcore backend should be enabled by default") + }) + + t.Run("azcore backend disabled explicitly", func(t *testing.T) { t.Setenv("PULUMI_ENABLE_AZCORE_BACKEND", "") - client, err := p.newAzureClient(nil, &fake.TokenCredential{}, "pulumi") - require.NoError(t, err) - assert.Equal(t, "azureClientImpl", reflect.TypeOf(client).Elem().Name()) + assert.False(t, util.EnableAzcoreBackend()) }) - t.Run("Autorest and legacy auth disabled explicitly", func(t *testing.T) { + t.Run("azcore backend disabled explicitly", func(t *testing.T) { t.Setenv("PULUMI_ENABLE_AZCORE_BACKEND", "false") - client, err := p.newAzureClient(nil, &fake.TokenCredential{}, "pulumi") - require.NoError(t, err) - assert.Equal(t, "azureClientImpl", reflect.TypeOf(client).Elem().Name()) + assert.False(t, util.EnableAzcoreBackend()) }) t.Run("Azcore enabled", func(t *testing.T) { t.Setenv("PULUMI_ENABLE_AZCORE_BACKEND", "true") - client, err := p.newAzureClient(nil, &fake.TokenCredential{}, "pulumi") - require.NoError(t, err) - assert.Equal(t, "azCoreClient", reflect.TypeOf(client).Elem().Name()) + assert.True(t, util.EnableAzcoreBackend()) }) } @@ -507,11 +510,7 @@ func TestAzcoreAzureClientUsesCorrectCloud(t *testing.T) { "https://management.chinacloudapi.cn": cloud.AzureChina, "https://management.usgovcloudapi.net": cloud.AzureGovernment, } { - p := azureNativeProvider{ - cloud: cloudInstance, - } - - client, err := p.newAzureClient(nil, &fake.TokenCredential{}, "pulumi") + client, err := azure.NewAzCoreClient(&fake.TokenCredential{}, "", cloudInstance, nil) require.NoError(t, err) require.NotNil(t, client) @@ -525,18 +524,12 @@ func TestAzcoreAzureClientUsesCorrectCloud(t *testing.T) { } func TestAutorestAzureClientUsesCorrectCloud(t *testing.T) { - for expectedEnv, environment := range map[string]azure.Environment{ - azure.PublicCloud.Name: azure.PublicCloud, - azure.ChinaCloud.Name: azure.ChinaCloud, - azure.USGovernmentCloud.Name: azure.USGovernmentCloud, + for expectedEnv, environment := range map[string]azureEnv.Environment{ + azureEnv.PublicCloud.Name: azureEnv.PublicCloud, + azureEnv.ChinaCloud.Name: azureEnv.ChinaCloud, + azureEnv.USGovernmentCloud.Name: azureEnv.USGovernmentCloud, } { - p := azureNativeProvider{ - environment: environment, - } - t.Setenv("PULUMI_ENABLE_AZCORE_BACKEND", "false") - - client, err := p.newAzureClient(nil, nil, "pulumi") - require.NoError(t, err) + client := azure.NewAzureClient(environment, nil, "pulumi") require.NotNil(t, client) // Use reflection to get the value of the private 'environment' field @@ -563,7 +556,7 @@ func TestGetTokenEndpoint(t *testing.T) { t.Run("implicit public", func(t *testing.T) { t.Parallel() p := azureNativeProvider{ - environment: azure.PublicCloud, + cloud: cloud.AzurePublic, } endpoint := p.tokenEndpoint(resource.NewNullProperty()) assert.Equal(t, "https://management.azure.com/", endpoint) @@ -572,7 +565,7 @@ func TestGetTokenEndpoint(t *testing.T) { t.Run("implicit usgov", func(t *testing.T) { t.Parallel() p := azureNativeProvider{ - environment: azure.USGovernmentCloud, + cloud: cloud.AzureGovernment, } endpoint := p.tokenEndpoint(resource.NewNullProperty()) assert.Equal(t, "https://management.usgovcloudapi.net/", endpoint) @@ -581,7 +574,7 @@ func TestGetTokenEndpoint(t *testing.T) { t.Run("implicit with empty string, public", func(t *testing.T) { t.Parallel() p := azureNativeProvider{ - environment: azure.PublicCloud, + cloud: cloud.AzurePublic, } endpoint := p.tokenEndpoint(resource.NewStringProperty("")) assert.Equal(t, "https://management.azure.com/", endpoint) diff --git a/provider/pkg/resources/customresources/custom_keyvault.go b/provider/pkg/resources/customresources/custom_keyvault.go index a3344623de1a..63dd9afac511 100644 --- a/provider/pkg/resources/customresources/custom_keyvault.go +++ b/provider/pkg/resources/customresources/custom_keyvault.go @@ -8,14 +8,29 @@ import ( "net/http" "github.com/Azure/azure-sdk-for-go/sdk/azcore" + azcloud "github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud" "github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys" "github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets" "github.com/pkg/errors" "github.com/pulumi/pulumi/sdk/v3/go/common/resource" ) +// Note: These are "hybrid" resources: schema, create, update, read use default implementation, while DELETE is overridden. +// DELETE is implemented via the KeyVault "data plane" API, because it isn't available via ARM. + +// from: https://github.com/Azure/go-autorest/blob/autorest/v0.11.29/autorest/azure/environments.go +func getKeyVaultSuffix(cloud azcloud.Configuration) (string, error) { + suffix := "vault.azure.net" + if cloud.ActiveDirectoryAuthorityHost == azcloud.AzureChina.ActiveDirectoryAuthorityHost { + suffix = "vault.azure.cn" + } else if cloud.ActiveDirectoryAuthorityHost == azcloud.AzureGovernment.ActiveDirectoryAuthorityHost { + suffix = "vault.usgovcloudapi.net" + } + return suffix, nil +} + // keyVaultSecret creates a custom resource for Azure KeyVault Secret. -func keyVaultSecret(keyVaultDNSSuffix string, tokenCred azcore.TokenCredential) *CustomResource { +func keyVaultSecret(cloud azcloud.Configuration, tokenCred azcore.TokenCredential) *CustomResource { return &CustomResource{ path: "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.KeyVault/vaults/{vaultName}/secrets/{secretName}", Delete: func(ctx context.Context, id string, inputs, state resource.PropertyMap) error { @@ -28,6 +43,10 @@ func keyVaultSecret(keyVaultDNSSuffix string, tokenCred azcore.TokenCredential) return errors.New("secretName not found in resource state") } + keyVaultDNSSuffix, err := getKeyVaultSuffix(cloud) + if err != nil { + return err + } vaultUrl := fmt.Sprintf("https://%s.%s", vaultName.StringValue(), keyVaultDNSSuffix) kvClient, err := azsecrets.NewClient(vaultUrl, tokenCred, nil) if err != nil { @@ -40,7 +59,7 @@ func keyVaultSecret(keyVaultDNSSuffix string, tokenCred azcore.TokenCredential) } // keyVaultKey creates a custom resource for Azure KeyVault Key. -func keyVaultKey(keyVaultDNSSuffix string, tokenCred azcore.TokenCredential) *CustomResource { +func keyVaultKey(cloud azcloud.Configuration, tokenCred azcore.TokenCredential) *CustomResource { return &CustomResource{ path: "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.KeyVault/vaults/{vaultName}/keys/{keyName}", Delete: func(ctx context.Context, id string, inputs, state resource.PropertyMap) error { @@ -53,6 +72,10 @@ func keyVaultKey(keyVaultDNSSuffix string, tokenCred azcore.TokenCredential) *Cu return errors.New("keyName not found in resource state") } + keyVaultDNSSuffix, err := getKeyVaultSuffix(cloud) + if err != nil { + return err + } vaultUrl := fmt.Sprintf("https://%s.%s", vaultName.StringValue(), keyVaultDNSSuffix) kvClient, err := azkeys.NewClient(vaultUrl, tokenCred, nil) if err != nil { diff --git a/provider/pkg/resources/customresources/customresources.go b/provider/pkg/resources/customresources/customresources.go index 6266f837a833..e179b11fe4c0 100644 --- a/provider/pkg/resources/customresources/customresources.go +++ b/provider/pkg/resources/customresources/customresources.go @@ -8,6 +8,7 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" + azcloud "github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault" "github.com/Azure/azure-sdk-for-go/services/keyvault/2016-10-01/keyvault" "github.com/Azure/azure-sdk-for-go/services/storage/mgmt/2019-04-01/storage" @@ -205,10 +206,11 @@ func BuildCustomResources(env *azureEnv.Environment, lookupResource ResourceLookupFunc, crudClientFactory crud.ResourceCrudClientFactory, subscriptionID string, - bearerAuth autorest.Authorizer, - tokenAuth autorest.Authorizer, - kvBearerAuth autorest.Authorizer, - userAgent string, + bearerAuth autorest.Authorizer, // autorest + tokenAuth autorest.Authorizer, // autorest + kvBearerAuth autorest.Authorizer, // autorest + userAgent string, // autorest + cloud azcloud.Configuration, tokenCred azcore.TokenCredential) (map[string]*CustomResource, error) { armKVClient, err := armkeyvault.NewVaultsClient(subscriptionID, tokenCred, &arm.ClientOptions{}) @@ -263,10 +265,9 @@ func BuildCustomResources(env *azureEnv.Environment, // `azCoreTokenCredential` adapter that we use elsewhere to translate legacy token sources to azidentity doesn't // work here because KV needs a different token source for the KV endpoint. if util.EnableAzcoreBackend() { - resources = append(resources, keyVaultSecret(env.KeyVaultDNSSuffix, tokenCred)) - resources = append(resources, keyVaultKey(env.KeyVaultDNSSuffix, tokenCred)) + resources = append(resources, keyVaultSecret(cloud, tokenCred)) + resources = append(resources, keyVaultKey(cloud, tokenCred)) - cloud := azure.GetCloudByName(env.Name) resources = append(resources, storageAccountStaticWebsite_azidentity(cloud, tokenCred)) resources = append(resources, newBlob_azidentity(cloud, tokenCred)) } else { @@ -292,7 +293,7 @@ func BuildCustomResources(env *azureEnv.Environment, } // featureLookup is a map of custom resource to lookup their capabilities. -var featureLookup, _ = BuildCustomResources(&azureEnv.Environment{}, nil, nil, nil, "", nil, nil, nil, "", nil) +var featureLookup, _ = BuildCustomResources(&azureEnv.Environment{}, nil, nil, nil, "", nil, nil, nil, "", azcloud.Configuration{}, nil) // IncludeCustomResource returns isCustom=true if a custom resource is defined for the given path, and include=true if // the given API version should be included. From 6b95d09af878daf2bd5514c9c66567bc32fed107 Mon Sep 17 00:00:00 2001 From: Eron Wright Date: Fri, 22 Aug 2025 12:10:53 -0700 Subject: [PATCH 06/26] slow 7 --- provider/pkg/provider/provider.go | 72 +++++++++++++++++++------------ 1 file changed, 45 insertions(+), 27 deletions(-) diff --git a/provider/pkg/provider/provider.go b/provider/pkg/provider/provider.go index 7a820fc6e3c9..9745b5d42b62 100644 --- a/provider/pkg/provider/provider.go +++ b/provider/pkg/provider/provider.go @@ -204,6 +204,49 @@ func (k *azureNativeProvider) Configure(ctx context.Context, k.setLoggingContext(ctx) + if util.EnableAzcoreBackend() { + logging.V(9).Infof("Using azcore authentication") + + userAgent := k.getUserAgent() + + authConfig, err := readAuthConfig(k.getConfig) + if err != nil { + return nil, err + } + k.authConfig = *authConfig + + clientOpts := azcore.ClientOptions{} + credential, err := NewAzCoreIdentity(ctx, authConfig, clientOpts) + if err != nil { + return nil, err + } + k.credential = credential + k.cloud = credential.Cloud + k.subscriptionID = credential.SubscriptionId + + k.azureClient, err = azure.NewAzCoreClient(credential, userAgent, k.cloud, nil) + if err != nil { + return nil, err + } + + // When the provider is parameterized, resources and types that custom resources are built on will probably not be available. + if !k.isParameterized() { + var err error + k.customResources, err = customresources.BuildCustomResources(nil, k.azureClient, k.LookupResource, k.newCrudClient, k.subscriptionID, + nil, nil, nil, "", k.cloud, credential) + if err != nil { + return nil, fmt.Errorf("initializing custom resources: %w", err) + } + } + + k.skipReadOnUpdate = k.getConfig("skipReadOnUpdate", "ARM_SKIP_READ_ON_UPDATE") == "true" + + return &rpc.ConfigureResponse{ + SupportsPreview: true, + SupportsAutonamingConfiguration: true, + }, nil + } + authConfig, err := k.getAuthConfig() if err != nil { return nil, err @@ -246,33 +289,8 @@ func (k *azureNativeProvider) Configure(ctx context.Context, userAgent := k.getUserAgent() - var credential azcore.TokenCredential - if util.EnableAzcoreBackend() { - logging.V(9).Infof("Using azcore authentication") - - authConfig, err := readAuthConfig(k.getConfig) - if err != nil { - return nil, err - } - k.authConfig = *authConfig - - clientOpts := azcore.ClientOptions{} - credential, err := NewAzCoreIdentity(ctx, authConfig, clientOpts) - if err != nil { - return nil, err - } - k.credential = credential - k.cloud = credential.Cloud - k.subscriptionID = credential.SubscriptionId - - k.azureClient, err = k.newAzureClient(resourceManagerAuth, credential, userAgent) - if err != nil { - return nil, fmt.Errorf("creating Azure client: %w", err) - } - } else { - logging.V(9).Infof("Using legacy authentication") - credential = azCoreTokenCredential{p: k} - } + logging.V(9).Infof("Using legacy authentication") + credential := azCoreTokenCredential{p: k} k.azureClient, err = k.newAzureClient(resourceManagerAuth, credential, userAgent) if err != nil { From 9340a8ddd7b60a4f5ec8938693ff34827b271f96 Mon Sep 17 00:00:00 2001 From: Eron Wright Date: Fri, 22 Aug 2025 12:33:02 -0700 Subject: [PATCH 07/26] step 8 --- provider/pkg/provider/provider.go | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/provider/pkg/provider/provider.go b/provider/pkg/provider/provider.go index 9745b5d42b62..ef19525ce9c4 100644 --- a/provider/pkg/provider/provider.go +++ b/provider/pkg/provider/provider.go @@ -209,6 +209,20 @@ func (k *azureNativeProvider) Configure(ctx context.Context, userAgent := k.getUserAgent() + authConfigLegacy, err := k.getAuthConfig() + if err != nil { + return nil, err + } + + k.environment, err = authConfigLegacy.autorestEnvironment() + if err != nil { + return nil, err + } + + k.cloud = authConfigLegacy.cloud() + + _ = k.autorestEnvToHamiltonEnv() + authConfig, err := readAuthConfig(k.getConfig) if err != nil { return nil, err From c0dd45f3bbf1c113dc3af40e685a4d467be9371c Mon Sep 17 00:00:00 2001 From: Eron Wright Date: Fri, 22 Aug 2025 12:46:54 -0700 Subject: [PATCH 08/26] slow 9 --- provider/pkg/provider/provider.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/provider/pkg/provider/provider.go b/provider/pkg/provider/provider.go index ef19525ce9c4..facad75465c6 100644 --- a/provider/pkg/provider/provider.go +++ b/provider/pkg/provider/provider.go @@ -209,19 +209,19 @@ func (k *azureNativeProvider) Configure(ctx context.Context, userAgent := k.getUserAgent() - authConfigLegacy, err := k.getAuthConfig() + _, err := k.getAuthConfig() if err != nil { return nil, err } - k.environment, err = authConfigLegacy.autorestEnvironment() - if err != nil { - return nil, err - } + // k.environment, err = authConfigLegacy.autorestEnvironment() + // if err != nil { + // return nil, err + // } - k.cloud = authConfigLegacy.cloud() + // k.cloud = authConfigLegacy.cloud() - _ = k.autorestEnvToHamiltonEnv() + // _ = k.autorestEnvToHamiltonEnv() authConfig, err := readAuthConfig(k.getConfig) if err != nil { From b342c3a7769b08b0d75246e3eddf91bae8fccd66 Mon Sep 17 00:00:00 2001 From: Eron Wright Date: Fri, 22 Aug 2025 13:09:09 -0700 Subject: [PATCH 09/26] slow 10 --- provider/pkg/provider/provider.go | 104 ++++++++++++++++-------------- 1 file changed, 57 insertions(+), 47 deletions(-) diff --git a/provider/pkg/provider/provider.go b/provider/pkg/provider/provider.go index facad75465c6..05a7461d9a78 100644 --- a/provider/pkg/provider/provider.go +++ b/provider/pkg/provider/provider.go @@ -11,6 +11,7 @@ import ( "reflect" "regexp" "strings" + "sync" "time" "github.com/blang/semver" @@ -205,56 +206,26 @@ func (k *azureNativeProvider) Configure(ctx context.Context, k.setLoggingContext(ctx) if util.EnableAzcoreBackend() { - logging.V(9).Infof("Using azcore authentication") - - userAgent := k.getUserAgent() - - _, err := k.getAuthConfig() - if err != nil { - return nil, err - } - - // k.environment, err = authConfigLegacy.autorestEnvironment() - // if err != nil { - // return nil, err - // } - - // k.cloud = authConfigLegacy.cloud() - - // _ = k.autorestEnvToHamiltonEnv() - - authConfig, err := readAuthConfig(k.getConfig) - if err != nil { - return nil, err - } - k.authConfig = *authConfig - - clientOpts := azcore.ClientOptions{} - credential, err := NewAzCoreIdentity(ctx, authConfig, clientOpts) - if err != nil { - return nil, err - } - k.credential = credential - k.cloud = credential.Cloud - k.subscriptionID = credential.SubscriptionId - - k.azureClient, err = azure.NewAzCoreClient(credential, userAgent, k.cloud, nil) + var ( + result *rpc.ConfigureResponse + err error + ) + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("panic in configure: %v", r) + } + }() + result, err = k.configure(ctx) + }() + wg.Wait() if err != nil { - return nil, err + return result, err } - // When the provider is parameterized, resources and types that custom resources are built on will probably not be available. - if !k.isParameterized() { - var err error - k.customResources, err = customresources.BuildCustomResources(nil, k.azureClient, k.LookupResource, k.newCrudClient, k.subscriptionID, - nil, nil, nil, "", k.cloud, credential) - if err != nil { - return nil, fmt.Errorf("initializing custom resources: %w", err) - } - } - - k.skipReadOnUpdate = k.getConfig("skipReadOnUpdate", "ARM_SKIP_READ_ON_UPDATE") == "true" - return &rpc.ConfigureResponse{ SupportsPreview: true, SupportsAutonamingConfiguration: true, @@ -328,6 +299,45 @@ func (k *azureNativeProvider) Configure(ctx context.Context, }, nil } +func (k *azureNativeProvider) configure(ctx context.Context) (*rpc.ConfigureResponse, error) { + logging.V(9).Infof("Using azcore authentication") + + userAgent := k.getUserAgent() + + authConfig, err := readAuthConfig(k.getConfig) + if err != nil { + return nil, err + } + k.authConfig = *authConfig + + clientOpts := azcore.ClientOptions{} + credential, err := NewAzCoreIdentity(ctx, authConfig, clientOpts) + if err != nil { + return nil, err + } + k.credential = credential + k.cloud = credential.Cloud + k.subscriptionID = credential.SubscriptionId + + k.azureClient, err = azure.NewAzCoreClient(credential, userAgent, k.cloud, nil) + if err != nil { + return nil, err + } + + // When the provider is parameterized, resources and types that custom resources are built on will probably not be available. + if !k.isParameterized() { + var err error + k.customResources, err = customresources.BuildCustomResources(nil, k.azureClient, k.LookupResource, k.newCrudClient, k.subscriptionID, + nil, nil, nil, "", k.cloud, credential) + if err != nil { + return nil, fmt.Errorf("initializing custom resources: %w", err) + } + } + + k.skipReadOnUpdate = k.getConfig("skipReadOnUpdate", "ARM_SKIP_READ_ON_UPDATE") == "true" + return nil, nil +} + func (k *azureNativeProvider) isParameterized() bool { return strings.HasPrefix(k.name, "azure-native"+parameterizedNameSeparator) } From 977a300ea17f13e3f29d21965be59b75152de6c2 Mon Sep 17 00:00:00 2001 From: Eron Wright Date: Fri, 22 Aug 2025 13:19:44 -0700 Subject: [PATCH 10/26] slow 11 --- provider/pkg/azure/azure.go | 1 + provider/pkg/provider/auth_azidentity.go | 1 + provider/pkg/provider/provider.go | 37 ++++++++++++------------ 3 files changed, 21 insertions(+), 18 deletions(-) diff --git a/provider/pkg/azure/azure.go b/provider/pkg/azure/azure.go index c1da44b51eb2..24af2aace6e9 100644 --- a/provider/pkg/azure/azure.go +++ b/provider/pkg/azure/azure.go @@ -14,6 +14,7 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/azcore" azcloud "github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud" + _ "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" "github.com/Azure/go-autorest/autorest" "github.com/Azure/go-autorest/autorest/azure" "github.com/pulumi/pulumi-azure-native/v2/provider/pkg/util" diff --git a/provider/pkg/provider/auth_azidentity.go b/provider/pkg/provider/auth_azidentity.go index 44fea64413dc..89e18a3d1762 100644 --- a/provider/pkg/provider/auth_azidentity.go +++ b/provider/pkg/provider/auth_azidentity.go @@ -16,6 +16,7 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/azcore" azcloud "github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud" "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + _ "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" "github.com/Azure/azure-sdk-for-go/sdk/azidentity" "github.com/pkg/errors" "github.com/pulumi/pulumi-azure-native/v2/provider/pkg/azure" diff --git a/provider/pkg/provider/provider.go b/provider/pkg/provider/provider.go index 05a7461d9a78..e61d12515e08 100644 --- a/provider/pkg/provider/provider.go +++ b/provider/pkg/provider/provider.go @@ -11,7 +11,6 @@ import ( "reflect" "regexp" "strings" - "sync" "time" "github.com/blang/semver" @@ -206,24 +205,26 @@ func (k *azureNativeProvider) Configure(ctx context.Context, k.setLoggingContext(ctx) if util.EnableAzcoreBackend() { - var ( - result *rpc.ConfigureResponse - err error - ) - var wg sync.WaitGroup - wg.Add(1) - go func() { - defer wg.Done() - defer func() { - if r := recover(); r != nil { - err = fmt.Errorf("panic in configure: %v", r) - } - }() - result, err = k.configure(ctx) - }() - wg.Wait() + // var ( + // result *rpc.ConfigureResponse + // err error + // ) + // var wg sync.WaitGroup + // wg.Add(1) + // go func() { + // defer wg.Done() + // defer func() { + // if r := recover(); r != nil { + // err = fmt.Errorf("panic in configure: %v", r) + // } + // }() + // result, err = k.configure(ctx) + // }() + // wg.Wait() + + _, err := k.configure(ctx) if err != nil { - return result, err + return nil, err } return &rpc.ConfigureResponse{ From 70ada3e7ed3836620be091530500c8830249034d Mon Sep 17 00:00:00 2001 From: Eron Wright Date: Fri, 22 Aug 2025 13:45:34 -0700 Subject: [PATCH 11/26] slow 12 --- provider/pkg/azure/azure.go | 1 + provider/pkg/provider/auth_azidentity.go | 1 + provider/pkg/provider/auth_getclientconfig.go | 1 + provider/pkg/provider/provider.go | 23 +++++-------------- 4 files changed, 9 insertions(+), 17 deletions(-) diff --git a/provider/pkg/azure/azure.go b/provider/pkg/azure/azure.go index 24af2aace6e9..0bdcb800ca34 100644 --- a/provider/pkg/azure/azure.go +++ b/provider/pkg/azure/azure.go @@ -13,6 +13,7 @@ import ( "strings" "github.com/Azure/azure-sdk-for-go/sdk/azcore" + _ "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm/runtime" azcloud "github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud" _ "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" "github.com/Azure/go-autorest/autorest" diff --git a/provider/pkg/provider/auth_azidentity.go b/provider/pkg/provider/auth_azidentity.go index 89e18a3d1762..33bf99be8433 100644 --- a/provider/pkg/provider/auth_azidentity.go +++ b/provider/pkg/provider/auth_azidentity.go @@ -14,6 +14,7 @@ import ( "os" "github.com/Azure/azure-sdk-for-go/sdk/azcore" + _ "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm/runtime" azcloud "github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud" "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" _ "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" diff --git a/provider/pkg/provider/auth_getclientconfig.go b/provider/pkg/provider/auth_getclientconfig.go index 5e23e586af77..6b09c60d6653 100644 --- a/provider/pkg/provider/auth_getclientconfig.go +++ b/provider/pkg/provider/auth_getclientconfig.go @@ -7,6 +7,7 @@ import ( "fmt" "strings" + _ "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm/runtime" azcloud "github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud" azpolicy "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" msgraphsdk "github.com/microsoftgraph/msgraph-sdk-go" diff --git a/provider/pkg/provider/provider.go b/provider/pkg/provider/provider.go index e61d12515e08..417def15326e 100644 --- a/provider/pkg/provider/provider.go +++ b/provider/pkg/provider/provider.go @@ -22,6 +22,7 @@ import ( "google.golang.org/protobuf/types/known/emptypb" "github.com/Azure/azure-sdk-for-go/sdk/azcore" + _ "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm/runtime" azcloud "github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud" "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" "github.com/Azure/go-autorest/autorest" @@ -205,23 +206,6 @@ func (k *azureNativeProvider) Configure(ctx context.Context, k.setLoggingContext(ctx) if util.EnableAzcoreBackend() { - // var ( - // result *rpc.ConfigureResponse - // err error - // ) - // var wg sync.WaitGroup - // wg.Add(1) - // go func() { - // defer wg.Done() - // defer func() { - // if r := recover(); r != nil { - // err = fmt.Errorf("panic in configure: %v", r) - // } - // }() - // result, err = k.configure(ctx) - // }() - // wg.Wait() - _, err := k.configure(ctx) if err != nil { return nil, err @@ -305,6 +289,11 @@ func (k *azureNativeProvider) configure(ctx context.Context) (*rpc.ConfigureResp userAgent := k.getUserAgent() + // _, err := k.getAuthConfig() + // if err != nil { + // return nil, err + // } + authConfig, err := readAuthConfig(k.getConfig) if err != nil { return nil, err From 818bbf92df797b628a72c7a434fff64bb015ba95 Mon Sep 17 00:00:00 2001 From: Eron Wright Date: Fri, 22 Aug 2025 15:03:18 -0700 Subject: [PATCH 12/26] slow 13 --- provider/pkg/provider/provider.go | 12 ++++++++++-- provider/pkg/provider/provider_e2e_test.go | 3 +++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/provider/pkg/provider/provider.go b/provider/pkg/provider/provider.go index 417def15326e..6cb6b5dff8e1 100644 --- a/provider/pkg/provider/provider.go +++ b/provider/pkg/provider/provider.go @@ -206,8 +206,9 @@ func (k *azureNativeProvider) Configure(ctx context.Context, k.setLoggingContext(ctx) if util.EnableAzcoreBackend() { - _, err := k.configure(ctx) + _, err := k.configureAzidentity(ctx) if err != nil { + logging.Errorf("configureAzidentity: %v", err) return nil, err } @@ -284,10 +285,11 @@ func (k *azureNativeProvider) Configure(ctx context.Context, }, nil } -func (k *azureNativeProvider) configure(ctx context.Context) (*rpc.ConfigureResponse, error) { +func (k *azureNativeProvider) configureAzidentity(ctx context.Context) (*rpc.ConfigureResponse, error) { logging.V(9).Infof("Using azcore authentication") userAgent := k.getUserAgent() + logging.V(9).Infof("User agent: %s", userAgent) // _, err := k.getAuthConfig() // if err != nil { @@ -299,20 +301,25 @@ func (k *azureNativeProvider) configure(ctx context.Context) (*rpc.ConfigureResp return nil, err } k.authConfig = *authConfig + logging.V(9).Infof("Auth config: %+v", k.authConfig) clientOpts := azcore.ClientOptions{} credential, err := NewAzCoreIdentity(ctx, authConfig, clientOpts) if err != nil { return nil, err } + logging.V(9).Infof("credential: %+v", credential) + k.credential = credential k.cloud = credential.Cloud k.subscriptionID = credential.SubscriptionId + logging.V(9).Infof("cloud: %+v", k.cloud) k.azureClient, err = azure.NewAzCoreClient(credential, userAgent, k.cloud, nil) if err != nil { return nil, err } + logging.V(9).Infof("client: %+v", k.azureClient) // When the provider is parameterized, resources and types that custom resources are built on will probably not be available. if !k.isParameterized() { @@ -322,6 +329,7 @@ func (k *azureNativeProvider) configure(ctx context.Context) (*rpc.ConfigureResp if err != nil { return nil, fmt.Errorf("initializing custom resources: %w", err) } + logging.V(9).Infof("custom resources initialized") } k.skipReadOnUpdate = k.getConfig("skipReadOnUpdate", "ARM_SKIP_READ_ON_UPDATE") == "true" diff --git a/provider/pkg/provider/provider_e2e_test.go b/provider/pkg/provider/provider_e2e_test.go index 9f9e5deedc09..bf7b3a2a9b85 100644 --- a/provider/pkg/provider/provider_e2e_test.go +++ b/provider/pkg/provider/provider_e2e_test.go @@ -32,6 +32,7 @@ import ( "github.com/pulumi/pulumi/sdk/v3/go/auto/debug" "github.com/pulumi/pulumi/sdk/v3/go/auto/optup" "github.com/pulumi/pulumi/sdk/v3/go/common/apitype" + "github.com/pulumi/pulumi/sdk/v3/go/common/util/logging" pulumirpc "github.com/pulumi/pulumi/sdk/v3/proto/go" ) @@ -49,6 +50,8 @@ func init() { if err != nil { fmt.Printf("failed to read metadata file, run `make schema` before running tests: %v", err) } + + logging.InitLogging(true, 9, true) } func TestStorageBlob(t *testing.T) { From 6897b2f2644b8e852d2d807ccee60449afef318b Mon Sep 17 00:00:00 2001 From: Eron Wright Date: Fri, 22 Aug 2025 15:43:33 -0700 Subject: [PATCH 13/26] slow 14 --- .github/workflows/build-test.yml | 2 +- provider/pkg/provider/provider_e2e_test.go | 3 --- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index 5e0dbbeb963f..4a9e415faa67 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -365,7 +365,7 @@ jobs: PULUMITEST_RETAIN_FILES_ON_FAILURE: true run: | set -euo pipefail - cd provider && go test -test.v -tags all -run ^TestAzidentity$ -coverprofile="coverage.txt" -coverpkg=./... -timeout 1h -parallel 16 ./... 2>&1 | tee /tmp/gotest.log + cd provider && go test -test.v -tags all -run ^TestAzidentity$ -coverprofile="coverage.txt" -coverpkg=./... -timeout 1h -parallel 16 ./... -args -logtostderr -v=9 2>&1 | tee /tmp/gotest.log - name: Upload test log uses: actions/upload-artifact@v4 diff --git a/provider/pkg/provider/provider_e2e_test.go b/provider/pkg/provider/provider_e2e_test.go index bf7b3a2a9b85..9f9e5deedc09 100644 --- a/provider/pkg/provider/provider_e2e_test.go +++ b/provider/pkg/provider/provider_e2e_test.go @@ -32,7 +32,6 @@ import ( "github.com/pulumi/pulumi/sdk/v3/go/auto/debug" "github.com/pulumi/pulumi/sdk/v3/go/auto/optup" "github.com/pulumi/pulumi/sdk/v3/go/common/apitype" - "github.com/pulumi/pulumi/sdk/v3/go/common/util/logging" pulumirpc "github.com/pulumi/pulumi/sdk/v3/proto/go" ) @@ -50,8 +49,6 @@ func init() { if err != nil { fmt.Printf("failed to read metadata file, run `make schema` before running tests: %v", err) } - - logging.InitLogging(true, 9, true) } func TestStorageBlob(t *testing.T) { From d1d34a55aa75180a9ccce9144a20ac494abf2a62 Mon Sep 17 00:00:00 2001 From: Eron Wright Date: Fri, 22 Aug 2025 16:02:55 -0700 Subject: [PATCH 14/26] slow 15 --- provider/pkg/provider/azure_cli.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/provider/pkg/provider/azure_cli.go b/provider/pkg/provider/azure_cli.go index df19d9d0d5d3..1d944a8307d9 100644 --- a/provider/pkg/provider/azure_cli.go +++ b/provider/pkg/provider/azure_cli.go @@ -13,6 +13,7 @@ import ( "time" "github.com/pkg/errors" + "github.com/pulumi/pulumi/sdk/v3/go/common/util/logging" ) // Subscription represents a Subscription from the Azure CLI @@ -37,7 +38,7 @@ type subscriptionUnavailableError struct { } func (e *subscriptionUnavailableError) Error() string { - return e.message + return "az account show: " + e.message } func newSubscriptionUnavailableError(message string) error { @@ -65,6 +66,8 @@ var defaultAzSubscriptionProvider = func(ctx context.Context, subscriptionID str // subscription needs quotes because it may contain spaces commandLine += ` --subscription "` + subscriptionID + `"` } + logging.V(9).Infof("Running command: %s", commandLine) + var cliCmd *exec.Cmd if runtime.GOOS == "windows" { dir := os.Getenv("SYSTEMROOT") @@ -84,6 +87,8 @@ var defaultAzSubscriptionProvider = func(ctx context.Context, subscriptionID str output, err := cliCmd.Output() if err != nil { msg := stderr.String() + logging.Errorf("Command error: %v: %s", err, msg) + var exErr *exec.ExitError if errors.As(err, &exErr) && exErr.ExitCode() == 127 || strings.HasPrefix(msg, "'az' is not recognized") { msg = "Azure CLI not found on path" From 006543e8aa2c67918d5fd372aac91abf53fdb720 Mon Sep 17 00:00:00 2001 From: Eron Wright Date: Fri, 22 Aug 2025 16:35:34 -0700 Subject: [PATCH 15/26] slow 16 --- .github/workflows/build-test.yml | 2 +- provider/pkg/provider/azure_cli.go | 11 +++++++++-- provider/pkg/provider/provider_e2e_test.go | 16 ++++++++++++++++ 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index 4a9e415faa67..9e9f5439c725 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -365,7 +365,7 @@ jobs: PULUMITEST_RETAIN_FILES_ON_FAILURE: true run: | set -euo pipefail - cd provider && go test -test.v -tags all -run ^TestAzidentity$ -coverprofile="coverage.txt" -coverpkg=./... -timeout 1h -parallel 16 ./... -args -logtostderr -v=9 2>&1 | tee /tmp/gotest.log + cd provider && go test -test.v -tags all -run ^TestAzidentity|TestDefaultAzSubscriptionProvider$ -coverprofile="coverage.txt" -coverpkg=./... -timeout 1h -parallel 16 ./... -args -logtostderr -v=9 2>&1 | tee /tmp/gotest.log - name: Upload test log uses: actions/upload-artifact@v4 diff --git a/provider/pkg/provider/azure_cli.go b/provider/pkg/provider/azure_cli.go index 1d944a8307d9..e04e8496443f 100644 --- a/provider/pkg/provider/azure_cli.go +++ b/provider/pkg/provider/azure_cli.go @@ -81,17 +81,22 @@ var defaultAzSubscriptionProvider = func(ctx context.Context, subscriptionID str cliCmd.Dir = "/bin" } cliCmd.Env = os.Environ() - var stderr bytes.Buffer + var stdout, stderr bytes.Buffer cliCmd.Stderr = &stderr + cliCmd.Stdout = &stdout - output, err := cliCmd.Output() + err := cliCmd.Run() if err != nil { msg := stderr.String() + msg += "\n" + stdout.String() + logging.Errorf("Command error: %v: %s", err, msg) var exErr *exec.ExitError if errors.As(err, &exErr) && exErr.ExitCode() == 127 || strings.HasPrefix(msg, "'az' is not recognized") { msg = "Azure CLI not found on path" + } else if errors.As(err, &exErr) { + msg += string(exErr.Stderr) } if msg == "" { msg = err.Error() @@ -99,6 +104,8 @@ var defaultAzSubscriptionProvider = func(ctx context.Context, subscriptionID str return nil, newSubscriptionUnavailableError(msg) } + output := stdout.Bytes() + logging.V(9).Infof("Command output: %s", output) s := Subscription{} err = json.Unmarshal(output, &s) if err != nil { diff --git a/provider/pkg/provider/provider_e2e_test.go b/provider/pkg/provider/provider_e2e_test.go index 9f9e5deedc09..a23e6b4d44e7 100644 --- a/provider/pkg/provider/provider_e2e_test.go +++ b/provider/pkg/provider/provider_e2e_test.go @@ -7,6 +7,7 @@ package provider import ( + "context" "encoding/json" "fmt" "os" @@ -139,6 +140,21 @@ func TestTagging(t *testing.T) { assert.Equal(t, map[string]any{"owner": "tag_2"}, rg2Tags) } +func TestDefaultAzSubscriptionProvider(t *testing.T) { + // AZURE_CONFIG_DIR_FOR_TEST is set by the GH workflow build-test.yml + // to provide an isolated configuration directory for the Azure CLI. + configDir := os.Getenv("AZURE_CONFIG_DIR_FOR_TEST") + if configDir == "" { + t.Skip("Skipping CLI test without AZURE_CONFIG_DIR_FOR_TEST") + } + t.Setenv("AZURE_CONFIG_DIR", configDir) + + ctx := context.Background() + subscription, err := defaultAzSubscriptionProvider(ctx, os.Getenv("ARM_SUBSCRIPTION_ID")) + require.NoError(t, err) + assert.NotNil(t, subscription) +} + func TestAzidentity(t *testing.T) { validate := func(t *testing.T, up auto.UpResult) (map[string]interface{}, jwt.MapClaims) { From db08c75140ac7f7b5906e1993ca69ed927f1e49a Mon Sep 17 00:00:00 2001 From: Eron Wright Date: Fri, 22 Aug 2025 16:51:54 -0700 Subject: [PATCH 16/26] fix: escape regex in go test command for proper execution --- .github/workflows/build-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index 9e9f5439c725..2244e83e5b7f 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -365,7 +365,7 @@ jobs: PULUMITEST_RETAIN_FILES_ON_FAILURE: true run: | set -euo pipefail - cd provider && go test -test.v -tags all -run ^TestAzidentity|TestDefaultAzSubscriptionProvider$ -coverprofile="coverage.txt" -coverpkg=./... -timeout 1h -parallel 16 ./... -args -logtostderr -v=9 2>&1 | tee /tmp/gotest.log + cd provider && go test -test.v -tags all -run "^TestAzidentity|TestDefaultAzSubscriptionProvider$" -coverprofile="coverage.txt" -coverpkg=./... -timeout 1h -parallel 16 ./... -args -logtostderr -v=9 2>&1 | tee /tmp/gotest.log - name: Upload test log uses: actions/upload-artifact@v4 From 4ee01da87c607dabd1c083d36a7601b310232b28 Mon Sep 17 00:00:00 2001 From: Eron Wright Date: Fri, 22 Aug 2025 17:46:26 -0700 Subject: [PATCH 17/26] slow 18 --- provider/pkg/provider/azure_cli.go | 30 +++++++++++++++------- provider/pkg/provider/provider_e2e_test.go | 7 ++++- 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/provider/pkg/provider/azure_cli.go b/provider/pkg/provider/azure_cli.go index e04e8496443f..38b7e196ff1d 100644 --- a/provider/pkg/provider/azure_cli.go +++ b/provider/pkg/provider/azure_cli.go @@ -56,11 +56,13 @@ const cliTimeout = 10 * time.Second // https://github.com/Azure/azure-sdk-for-go/blob/519e8ab1a0e433b755c31ebaa6b177dfc83cb838/sdk/azidentity/azure_cli_credential.go#L117-L172 var defaultAzSubscriptionProvider = func(ctx context.Context, subscriptionID string) (*Subscription, error) { // set a default timeout for this authentication iff the application hasn't done so already - var cancel context.CancelFunc - if _, hasDeadline := ctx.Deadline(); !hasDeadline { - ctx, cancel = context.WithTimeout(ctx, cliTimeout) - defer cancel() - } + // var cancel context.CancelFunc + // if _, hasDeadline := ctx.Deadline(); !hasDeadline { + // ctx, cancel = context.WithTimeout(ctx, cliTimeout) + // defer cancel() + // } + // ctx = context.Background() + commandLine := "az account show -o json " if subscriptionID != "" { // subscription needs quotes because it may contain spaces @@ -84,11 +86,21 @@ var defaultAzSubscriptionProvider = func(ctx context.Context, subscriptionID str var stdout, stderr bytes.Buffer cliCmd.Stderr = &stderr cliCmd.Stdout = &stdout - - err := cliCmd.Run() + cliCmd.WaitDelay = 100 * time.Millisecond + + output, err := func() ([]byte, error) { + err := cliCmd.Run() + stdout := stdout.Bytes() + if errors.Is(err, exec.ErrWaitDelay) && len(stdout) > 0 { + // The child process wrote to stdout and exited without closing it. + // Swallow this error and return stdout because it may contain a token. + return stdout, nil + } + return stdout, err + }() if err != nil { msg := stderr.String() - msg += "\n" + stdout.String() + msg += "\n" + string(output) logging.Errorf("Command error: %v: %s", err, msg) @@ -96,6 +108,7 @@ var defaultAzSubscriptionProvider = func(ctx context.Context, subscriptionID str if errors.As(err, &exErr) && exErr.ExitCode() == 127 || strings.HasPrefix(msg, "'az' is not recognized") { msg = "Azure CLI not found on path" } else if errors.As(err, &exErr) { + logging.Errorf("Exit code: %d %s", exErr.ExitCode(), string(exErr.Stderr)) msg += string(exErr.Stderr) } if msg == "" { @@ -104,7 +117,6 @@ var defaultAzSubscriptionProvider = func(ctx context.Context, subscriptionID str return nil, newSubscriptionUnavailableError(msg) } - output := stdout.Bytes() logging.V(9).Infof("Command output: %s", output) s := Subscription{} err = json.Unmarshal(output, &s) diff --git a/provider/pkg/provider/provider_e2e_test.go b/provider/pkg/provider/provider_e2e_test.go index a23e6b4d44e7..ed358e618d9e 100644 --- a/provider/pkg/provider/provider_e2e_test.go +++ b/provider/pkg/provider/provider_e2e_test.go @@ -151,7 +151,12 @@ func TestDefaultAzSubscriptionProvider(t *testing.T) { ctx := context.Background() subscription, err := defaultAzSubscriptionProvider(ctx, os.Getenv("ARM_SUBSCRIPTION_ID")) - require.NoError(t, err) + assert.NoError(t, err, "first try") + assert.NotNil(t, subscription) + + // try it again + subscription, err = defaultAzSubscriptionProvider(ctx, os.Getenv("ARM_SUBSCRIPTION_ID")) + assert.NoError(t, err, "second try") assert.NotNil(t, subscription) } From 91133fac17ec3c47d6f9592b0ce4b4b6cfe88443 Mon Sep 17 00:00:00 2001 From: Eron Wright Date: Fri, 22 Aug 2025 18:35:41 -0700 Subject: [PATCH 18/26] slow 19 --- .github/workflows/build-test.yml | 2 +- provider/pkg/provider/provider_e2e_test.go | 7 +------ 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index 2244e83e5b7f..e96d9f91e57e 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -365,7 +365,7 @@ jobs: PULUMITEST_RETAIN_FILES_ON_FAILURE: true run: | set -euo pipefail - cd provider && go test -test.v -tags all -run "^TestAzidentity|TestDefaultAzSubscriptionProvider$" -coverprofile="coverage.txt" -coverpkg=./... -timeout 1h -parallel 16 ./... -args -logtostderr -v=9 2>&1 | tee /tmp/gotest.log + cd provider && go test -test.v -tags all -run "^TestAzidentity|TestDefaultAzSubscriptionProvider$" -coverprofile="coverage.txt" -coverpkg=./... -timeout 1h -parallel 16 ./... 2>&1 | tee /tmp/gotest.log - name: Upload test log uses: actions/upload-artifact@v4 diff --git a/provider/pkg/provider/provider_e2e_test.go b/provider/pkg/provider/provider_e2e_test.go index ed358e618d9e..0b7a6c50a477 100644 --- a/provider/pkg/provider/provider_e2e_test.go +++ b/provider/pkg/provider/provider_e2e_test.go @@ -151,12 +151,7 @@ func TestDefaultAzSubscriptionProvider(t *testing.T) { ctx := context.Background() subscription, err := defaultAzSubscriptionProvider(ctx, os.Getenv("ARM_SUBSCRIPTION_ID")) - assert.NoError(t, err, "first try") - assert.NotNil(t, subscription) - - // try it again - subscription, err = defaultAzSubscriptionProvider(ctx, os.Getenv("ARM_SUBSCRIPTION_ID")) - assert.NoError(t, err, "second try") + assert.NoError(t, err) assert.NotNil(t, subscription) } From 77cfc350f390626fea87a2113a0b65550b234250 Mon Sep 17 00:00:00 2001 From: Eron Wright Date: Fri, 22 Aug 2025 21:29:48 -0700 Subject: [PATCH 19/26] slow 20 --- .github/workflows/build-test.yml | 35 +++++-------------- examples/examples_nodejs_keyvault_test.go | 21 ++++------- provider/pkg/azure/azure.go | 1 - provider/pkg/provider/auth_getclientconfig.go | 2 +- provider/pkg/provider/azure_cli.go | 13 ++----- provider/pkg/provider/provider.go | 19 +++------- provider/pkg/provider/provider_e2e_test.go | 15 +------- 7 files changed, 26 insertions(+), 80 deletions(-) diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index e96d9f91e57e..6bbc97d1c016 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -64,7 +64,6 @@ env: jobs: prerequisites: - if: false runs-on: ubuntu-latest name: Build binaries and schema steps: @@ -104,7 +103,6 @@ jobs: status: ${{ job.status }} build_sdks: - if: false needs: [prerequisites,test_provider] # Use big runner for dotnet and nodejs because we need more memory and more compute, respectively runs-on: ${{ (matrix.language == 'dotnet' || matrix.language == 'nodejs' || matrix.language == 'go') && 'pulumi-ubuntu-8core' || 'ubuntu-latest' }} @@ -186,7 +184,6 @@ jobs: status: ${{ job.status }} test_sdks: - if: false needs: build_sdks # Use big runner for dotnet and nodejs because we need more memory and more compute, respectively runs-on: ${{ (matrix.language == 'dotnet' || matrix.language == 'nodejs' || matrix.language == 'go') && 'pulumi-ubuntu-8core' || 'ubuntu-latest' }} @@ -251,7 +248,6 @@ jobs: cd examples && go test -cover -timeout 15m -short -tags=${{ matrix.language }} -skip TestPulumiExamples -parallel 16 . 2>&1 | tee /tmp/gotest.log test_examples: - if: false needs: build_sdks runs-on: ubuntu-latest name: Test pulumi/examples @@ -307,7 +303,7 @@ jobs: test_provider: runs-on: ubuntu-latest name: Test Provider - # needs: prerequisites + needs: prerequisites permissions: id-token: write # required for OIDC auth steps: @@ -324,18 +320,15 @@ jobs: - run: make ensure - - name: Build schema and binaries - run: make codegen schema provider install_provider - - # - name: Prerequisites artifact restore - # uses: ./.github/actions/prerequisites-artifact-restore + - name: Prerequisites artifact restore + uses: ./.github/actions/prerequisites-artifact-restore # This is essentially just copying files from bin to the provider folder - # - name: Prebuild provider prerequisites - # run: | - # make prebuild - # make --touch codegen schema - # make provider_prebuild + - name: Prebuild provider prerequisites + run: | + make prebuild + make --touch codegen schema + make provider_prebuild - name: Write client certificate # The provider wants the cert as a path to a cert file but GH secrets can only be strings. @@ -361,18 +354,9 @@ jobs: env: # specifying this id will cause the OIDC test(s) to run against this AD application OIDC_ARM_CLIENT_ID: ${{ inputs.oidc_arm_client_id }} - PULUMITEST_SKIP_DESTROY_ON_FAILURE: true - PULUMITEST_RETAIN_FILES_ON_FAILURE: true run: | set -euo pipefail - cd provider && go test -test.v -tags all -run "^TestAzidentity|TestDefaultAzSubscriptionProvider$" -coverprofile="coverage.txt" -coverpkg=./... -timeout 1h -parallel 16 ./... 2>&1 | tee /tmp/gotest.log - - - name: Upload test log - uses: actions/upload-artifact@v4 - with: - name: provider-gotest-log - path: /tmp/gotest.log - retention-days: ${{ inputs.retention_days }} + cd provider && go test -coverprofile="coverage.txt" -coverpkg=./... -timeout 1h -parallel 16 ./... 2>&1 | tee /tmp/gotest.log - name: Upload coverage reports to Codecov uses: codecov/codecov-action@v4 @@ -381,7 +365,6 @@ jobs: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} dist: - if: false runs-on: ubuntu-latest name: Provider Dist needs: prerequisites diff --git a/examples/examples_nodejs_keyvault_test.go b/examples/examples_nodejs_keyvault_test.go index fda99535527a..cb13de848dd8 100644 --- a/examples/examples_nodejs_keyvault_test.go +++ b/examples/examples_nodejs_keyvault_test.go @@ -5,12 +5,10 @@ package examples import ( "os" - "os/user" "path/filepath" "testing" "github.com/pulumi/pulumi/pkg/v3/testing/integration" - "github.com/stretchr/testify/require" ) func TestAccKeyVaultTs(t *testing.T) { @@ -94,18 +92,13 @@ func TestAccKeyVaultTs_ClientCert(t *testing.T) { func TestAccKeyVaultTs_CLI(t *testing.T) { skipIfShort(t) - usr, err := user.Current() - require.NoError(t, err) - // .azure.tmp is created by the GH workflow build-test.yml, from the GH secret AZURE_CLI_FOLDER - // which is also documented in the workflow. We rename it to .azure so the `az` CLI can find it. - err = os.Rename(filepath.Join(usr.HomeDir, ".azure.tmp"), filepath.Join(usr.HomeDir, ".azure")) - require.NoError(t, err) - - // Prevent later tests from accidentally picking up the .azure folder because authentication - // falls back to CLI when other methods are misconfigured. - defer func() { - _ = os.Rename(filepath.Join(usr.HomeDir, ".azure"), filepath.Join(usr.HomeDir, ".azure.tmp")) - }() + // AZURE_CONFIG_DIR_FOR_TEST is set by the GH workflow build-test.yml + // to provide an isolated configuration directory for the Azure CLI. + configDir := os.Getenv("AZURE_CONFIG_DIR_FOR_TEST") + if configDir == "" { + t.Skip("Skipping CLI test without AZURE_CONFIG_DIR_FOR_TEST") + } + t.Setenv("AZURE_CONFIG_DIR", configDir) test := getJSBaseOptions(t). With(integration.ProgramTestOptions{ diff --git a/provider/pkg/azure/azure.go b/provider/pkg/azure/azure.go index 0bdcb800ca34..d022bf272460 100644 --- a/provider/pkg/azure/azure.go +++ b/provider/pkg/azure/azure.go @@ -15,7 +15,6 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/azcore" _ "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm/runtime" azcloud "github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud" - _ "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" "github.com/Azure/go-autorest/autorest" "github.com/Azure/go-autorest/autorest/azure" "github.com/pulumi/pulumi-azure-native/v2/provider/pkg/util" diff --git a/provider/pkg/provider/auth_getclientconfig.go b/provider/pkg/provider/auth_getclientconfig.go index 6b09c60d6653..8fb13259b24c 100644 --- a/provider/pkg/provider/auth_getclientconfig.go +++ b/provider/pkg/provider/auth_getclientconfig.go @@ -1,4 +1,4 @@ -// Copyright 2016-2022, Pulumi Corporation. +// Copyright 2016-2025, Pulumi Corporation. package provider diff --git a/provider/pkg/provider/azure_cli.go b/provider/pkg/provider/azure_cli.go index 38b7e196ff1d..a528afc580ec 100644 --- a/provider/pkg/provider/azure_cli.go +++ b/provider/pkg/provider/azure_cli.go @@ -38,7 +38,7 @@ type subscriptionUnavailableError struct { } func (e *subscriptionUnavailableError) Error() string { - return "az account show: " + e.message + return e.message } func newSubscriptionUnavailableError(message string) error { @@ -93,30 +93,23 @@ var defaultAzSubscriptionProvider = func(ctx context.Context, subscriptionID str stdout := stdout.Bytes() if errors.Is(err, exec.ErrWaitDelay) && len(stdout) > 0 { // The child process wrote to stdout and exited without closing it. - // Swallow this error and return stdout because it may contain a token. + // Swallow this error and return stdout because it may contain output. return stdout, nil } return stdout, err }() if err != nil { msg := stderr.String() - msg += "\n" + string(output) - - logging.Errorf("Command error: %v: %s", err, msg) - + logging.Errorf("az command error %v: %s", err, msg) var exErr *exec.ExitError if errors.As(err, &exErr) && exErr.ExitCode() == 127 || strings.HasPrefix(msg, "'az' is not recognized") { msg = "Azure CLI not found on path" - } else if errors.As(err, &exErr) { - logging.Errorf("Exit code: %d %s", exErr.ExitCode(), string(exErr.Stderr)) - msg += string(exErr.Stderr) } if msg == "" { msg = err.Error() } return nil, newSubscriptionUnavailableError(msg) } - logging.V(9).Infof("Command output: %s", output) s := Subscription{} err = json.Unmarshal(output, &s) diff --git a/provider/pkg/provider/provider.go b/provider/pkg/provider/provider.go index 6cb6b5dff8e1..89ac6669e381 100644 --- a/provider/pkg/provider/provider.go +++ b/provider/pkg/provider/provider.go @@ -205,6 +205,8 @@ func (k *azureNativeProvider) Configure(ctx context.Context, k.setLoggingContext(ctx) + k.skipReadOnUpdate = k.getConfig("skipReadOnUpdate", "ARM_SKIP_READ_ON_UPDATE") == "true" + if util.EnableAzcoreBackend() { _, err := k.configureAzidentity(ctx) if err != nil { @@ -277,8 +279,6 @@ func (k *azureNativeProvider) Configure(ctx context.Context, } } - k.skipReadOnUpdate = k.getConfig("skipReadOnUpdate", "ARM_SKIP_READ_ON_UPDATE") == "true" - return &rpc.ConfigureResponse{ SupportsPreview: true, SupportsAutonamingConfiguration: true, @@ -291,35 +291,28 @@ func (k *azureNativeProvider) configureAzidentity(ctx context.Context) (*rpc.Con userAgent := k.getUserAgent() logging.V(9).Infof("User agent: %s", userAgent) - // _, err := k.getAuthConfig() - // if err != nil { - // return nil, err - // } - authConfig, err := readAuthConfig(k.getConfig) if err != nil { return nil, err } k.authConfig = *authConfig - logging.V(9).Infof("Auth config: %+v", k.authConfig) + logging.V(9).Infof("Provider auth configuration: %+v", k.authConfig) clientOpts := azcore.ClientOptions{} credential, err := NewAzCoreIdentity(ctx, authConfig, clientOpts) if err != nil { return nil, err } - logging.V(9).Infof("credential: %+v", credential) - k.credential = credential k.cloud = credential.Cloud k.subscriptionID = credential.SubscriptionId - logging.V(9).Infof("cloud: %+v", k.cloud) + logging.V(9).Infof("Azure cloud: %+v", k.cloud) + logging.V(9).Infof("Azure subscription ID: %s", k.subscriptionID) k.azureClient, err = azure.NewAzCoreClient(credential, userAgent, k.cloud, nil) if err != nil { return nil, err } - logging.V(9).Infof("client: %+v", k.azureClient) // When the provider is parameterized, resources and types that custom resources are built on will probably not be available. if !k.isParameterized() { @@ -329,10 +322,8 @@ func (k *azureNativeProvider) configureAzidentity(ctx context.Context) (*rpc.Con if err != nil { return nil, fmt.Errorf("initializing custom resources: %w", err) } - logging.V(9).Infof("custom resources initialized") } - k.skipReadOnUpdate = k.getConfig("skipReadOnUpdate", "ARM_SKIP_READ_ON_UPDATE") == "true" return nil, nil } diff --git a/provider/pkg/provider/provider_e2e_test.go b/provider/pkg/provider/provider_e2e_test.go index 0b7a6c50a477..1f6d1d28692d 100644 --- a/provider/pkg/provider/provider_e2e_test.go +++ b/provider/pkg/provider/provider_e2e_test.go @@ -256,19 +256,6 @@ func TestAzidentity(t *testing.T) { } t.Setenv("AZURE_CONFIG_DIR", configDir) - // usr, err := user.Current() - // require.NoError(t, err) - // // .azure.tmp is created by the GH workflow build-test.yml, from the GH secret AZURE_CLI_FOLDER - // // which is also documented in the workflow. We rename it to .azure so the `az` CLI can find it. - // err = os.Rename(filepath.Join(usr.HomeDir, ".azure.tmp"), filepath.Join(usr.HomeDir, ".azure")) - // require.NoError(t, err) - - // // Prevent later tests from accidentally picking up the .azure folder because authentication - // // falls back to CLI when other methods are misconfigured. - // defer func() { - // _ = os.Rename(filepath.Join(usr.HomeDir, ".azure"), filepath.Join(usr.HomeDir, ".azure.tmp")) - // }() - // Make sure we test the CLI method t.Setenv("ARM_USE_MSI", "false") t.Setenv("ARM_USE_OIDC", "false") @@ -281,7 +268,7 @@ func TestAzidentity(t *testing.T) { t.Setenv("ACTIONS_ID_TOKEN_REQUEST_URL", "") pt := newPulumiTest(t, "azidentity") - up := pt.Up(t, optup.DebugLogging(debugLogging())) + up := pt.Up(t) clientConfig, clientToken := validate(t, up) assert.Equal(t, "04b07795-8ddb-461a-bbee-02f9e1bf7b46", clientConfig["clientId"]) assert.Equal(t, "04b07795-8ddb-461a-bbee-02f9e1bf7b46", clientToken["appid"]) From 57914e2b15fd02b8c091a8f0cb0638d811695c2c Mon Sep 17 00:00:00 2001 From: Eron Wright Date: Mon, 25 Aug 2025 10:30:43 -0700 Subject: [PATCH 20/26] test: skip missing p-examples --- examples/examples_dotnet_test.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/examples/examples_dotnet_test.go b/examples/examples_dotnet_test.go index a526c01c6e56..d682c5126a86 100644 --- a/examples/examples_dotnet_test.go +++ b/examples/examples_dotnet_test.go @@ -80,6 +80,13 @@ func TestDeletePostgresConfiguration(t *testing.T) { } func TestPulumiExamples(t *testing.T) { + if _, err := os.Stat(pulumiExamplesPath); os.IsNotExist(err) { + if os.Getenv("CI") != "" { + t.Errorf("pulumi examples not found at %q", pulumiExamplesPath) + } + t.Skipf("skipping: pulumi examples not found at %q", pulumiExamplesPath) + } + for _, example := range pexamples.GetTestsByTags(pexamples.AzureNativeProvider, pexamples.CS) { t.Run(example.Dir, func(t *testing.T) { test := getCsharpBaseOptions(t). From 45a2f591c900bf96248f3cb09be2196f37c0799d Mon Sep 17 00:00:00 2001 From: Eron Wright Date: Mon, 25 Aug 2025 10:37:55 -0700 Subject: [PATCH 21/26] test: rename OICD to OIDC --- examples/examples_nodejs_keyvault_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/examples_nodejs_keyvault_test.go b/examples/examples_nodejs_keyvault_test.go index cb13de848dd8..191ae8c19faa 100644 --- a/examples/examples_nodejs_keyvault_test.go +++ b/examples/examples_nodejs_keyvault_test.go @@ -20,7 +20,7 @@ func TestAccKeyVaultTs(t *testing.T) { integration.ProgramTest(t, &test) } -func TestAccKeyVaultTs_OICD(t *testing.T) { +func TestAccKeyVaultTs_OIDC(t *testing.T) { oidcClientId := os.Getenv("OIDC_ARM_CLIENT_ID") if oidcClientId == "" { t.Skip("Skipping OIDC test without OIDC_ARM_CLIENT_ID") @@ -39,9 +39,9 @@ func TestAccKeyVaultTs_OICD(t *testing.T) { integration.ProgramTest(t, &test) } -// This test is almost like TestAccKeyVaultTs_OICD but uses an explicit provider. +// This test is almost like TestAccKeyVaultTs_OIDC but uses an explicit provider. // We want to test configuring the provider via its arguments, not the environment. -func TestAccKeyVaultTs_OICDExplicit(t *testing.T) { +func TestAccKeyVaultTs_OIDCExplicit(t *testing.T) { skipIfShort(t) oidcClientId := os.Getenv("OIDC_ARM_CLIENT_ID") From 1a4c0546c4eec74e5e57fada81a2d61be41efcf0 Mon Sep 17 00:00:00 2001 From: Eron Wright Date: Mon, 25 Aug 2025 10:52:14 -0700 Subject: [PATCH 22/26] cleanup import statements --- provider/pkg/provider/auth_azidentity.go | 3 +-- provider/pkg/provider/auth_getclientconfig.go | 1 - provider/pkg/provider/provider.go | 2 -- 3 files changed, 1 insertion(+), 5 deletions(-) diff --git a/provider/pkg/provider/auth_azidentity.go b/provider/pkg/provider/auth_azidentity.go index 33bf99be8433..0bb8193c97b7 100644 --- a/provider/pkg/provider/auth_azidentity.go +++ b/provider/pkg/provider/auth_azidentity.go @@ -14,10 +14,9 @@ import ( "os" "github.com/Azure/azure-sdk-for-go/sdk/azcore" - _ "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm/runtime" + _ "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm/runtime" // initialize the well-known cloud configurations azcloud "github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud" "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" - _ "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" "github.com/Azure/azure-sdk-for-go/sdk/azidentity" "github.com/pkg/errors" "github.com/pulumi/pulumi-azure-native/v2/provider/pkg/azure" diff --git a/provider/pkg/provider/auth_getclientconfig.go b/provider/pkg/provider/auth_getclientconfig.go index 8fb13259b24c..d048d51a4a36 100644 --- a/provider/pkg/provider/auth_getclientconfig.go +++ b/provider/pkg/provider/auth_getclientconfig.go @@ -7,7 +7,6 @@ import ( "fmt" "strings" - _ "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm/runtime" azcloud "github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud" azpolicy "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" msgraphsdk "github.com/microsoftgraph/msgraph-sdk-go" diff --git a/provider/pkg/provider/provider.go b/provider/pkg/provider/provider.go index 89ac6669e381..79bf5396d4e7 100644 --- a/provider/pkg/provider/provider.go +++ b/provider/pkg/provider/provider.go @@ -22,7 +22,6 @@ import ( "google.golang.org/protobuf/types/known/emptypb" "github.com/Azure/azure-sdk-for-go/sdk/azcore" - _ "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm/runtime" azcloud "github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud" "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" "github.com/Azure/go-autorest/autorest" @@ -210,7 +209,6 @@ func (k *azureNativeProvider) Configure(ctx context.Context, if util.EnableAzcoreBackend() { _, err := k.configureAzidentity(ctx) if err != nil { - logging.Errorf("configureAzidentity: %v", err) return nil, err } From bc935d097de16e2aa1cb682deb8866e51e3efd76 Mon Sep 17 00:00:00 2001 From: Eron Wright Date: Mon, 25 Aug 2025 10:57:58 -0700 Subject: [PATCH 23/26] cleanup commented code --- provider/pkg/provider/azure_cli.go | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/provider/pkg/provider/azure_cli.go b/provider/pkg/provider/azure_cli.go index a528afc580ec..5ba82878ca78 100644 --- a/provider/pkg/provider/azure_cli.go +++ b/provider/pkg/provider/azure_cli.go @@ -47,22 +47,11 @@ func newSubscriptionUnavailableError(message string) error { type azSubscriptionProvider func(ctx context.Context, subscriptionID string) (*Subscription, error) -// cliTimeout is the default timeout for authentication attempts via CLI tools -const cliTimeout = 10 * time.Second - // defaultAzSubscriptionProvider invokes the Azure CLI to acquire a subscription. It assumes // callers have verified that all string arguments are safe to pass to the CLI. // this code is derived from "CLI token provider" code in the Azure SDK for Go: // https://github.com/Azure/azure-sdk-for-go/blob/519e8ab1a0e433b755c31ebaa6b177dfc83cb838/sdk/azidentity/azure_cli_credential.go#L117-L172 var defaultAzSubscriptionProvider = func(ctx context.Context, subscriptionID string) (*Subscription, error) { - // set a default timeout for this authentication iff the application hasn't done so already - // var cancel context.CancelFunc - // if _, hasDeadline := ctx.Deadline(); !hasDeadline { - // ctx, cancel = context.WithTimeout(ctx, cliTimeout) - // defer cancel() - // } - // ctx = context.Background() - commandLine := "az account show -o json " if subscriptionID != "" { // subscription needs quotes because it may contain spaces From e49d13dcd8f97479294fd94d3db4ebe8fecc4eec Mon Sep 17 00:00:00 2001 From: Eron Wright Date: Mon, 25 Aug 2025 11:16:59 -0700 Subject: [PATCH 24/26] test: don't skip in CI --- provider/pkg/provider/provider_e2e_test.go | 24 ++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/provider/pkg/provider/provider_e2e_test.go b/provider/pkg/provider/provider_e2e_test.go index 1f6d1d28692d..f1188807ced7 100644 --- a/provider/pkg/provider/provider_e2e_test.go +++ b/provider/pkg/provider/provider_e2e_test.go @@ -141,10 +141,18 @@ func TestTagging(t *testing.T) { } func TestDefaultAzSubscriptionProvider(t *testing.T) { + if testing.Short() { + t.Skipf("Skipping in testing.Short() mode") + return + } + // AZURE_CONFIG_DIR_FOR_TEST is set by the GH workflow build-test.yml // to provide an isolated configuration directory for the Azure CLI. configDir := os.Getenv("AZURE_CONFIG_DIR_FOR_TEST") if configDir == "" { + if os.Getenv("CI") != "" { + t.Error("CLI test without AZURE_CONFIG_DIR_FOR_TEST") + } t.Skip("Skipping CLI test without AZURE_CONFIG_DIR_FOR_TEST") } t.Setenv("AZURE_CONFIG_DIR", configDir) @@ -156,6 +164,10 @@ func TestDefaultAzSubscriptionProvider(t *testing.T) { } func TestAzidentity(t *testing.T) { + if testing.Short() { + t.Skipf("Skipping in testing.Short() mode") + return + } validate := func(t *testing.T, up auto.UpResult) (map[string]interface{}, jwt.MapClaims) { // validate clientConfig @@ -183,6 +195,9 @@ func TestAzidentity(t *testing.T) { t.Run("OIDC", func(t *testing.T) { oidcClientId := os.Getenv("OIDC_ARM_CLIENT_ID") if oidcClientId == "" { + if os.Getenv("CI") != "" { + t.Error("OIDC test without OIDC_ARM_CLIENT_ID") + } t.Skip("Skipping OIDC test without OIDC_ARM_CLIENT_ID") } @@ -205,6 +220,9 @@ func TestAzidentity(t *testing.T) { t.Run("SP_clientsecret", func(t *testing.T) { clientSecret := os.Getenv("ARM_CLIENT_SECRET") if clientSecret == "" { + if os.Getenv("CI") != "" { + t.Error("SP test without ARM_CLIENT_SECRET") + } t.Skip("Skipping SP test without ARM_CLIENT_SECRET") } @@ -227,6 +245,9 @@ func TestAzidentity(t *testing.T) { t.Run("SP_clientcert", func(t *testing.T) { certPath := os.Getenv("ARM_CLIENT_CERTIFICATE_PATH_FOR_TEST") if certPath == "" { + if os.Getenv("CI") != "" { + t.Error("SP test without ARM_CLIENT_CERTIFICATE_PATH_FOR_TEST") + } t.Skip("Skipping SP test without ARM_CLIENT_CERTIFICATE_PATH_FOR_TEST") } @@ -252,6 +273,9 @@ func TestAzidentity(t *testing.T) { // to provide an isolated configuration directory for the Azure CLI. configDir := os.Getenv("AZURE_CONFIG_DIR_FOR_TEST") if configDir == "" { + if os.Getenv("CI") != "" { + t.Error("CLI test without AZURE_CONFIG_DIR_FOR_TEST") + } t.Skip("Skipping CLI test without AZURE_CONFIG_DIR_FOR_TEST") } t.Setenv("AZURE_CONFIG_DIR", configDir) From 6846380adaf92f9dc985d66554735943c3fcb1c9 Mon Sep 17 00:00:00 2001 From: Eron Wright Date: Mon, 25 Aug 2025 11:22:34 -0700 Subject: [PATCH 25/26] provider: remove newAzureClient --- provider/pkg/provider/provider.go | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/provider/pkg/provider/provider.go b/provider/pkg/provider/provider.go index 79bf5396d4e7..22ecb53e6338 100644 --- a/provider/pkg/provider/provider.go +++ b/provider/pkg/provider/provider.go @@ -24,7 +24,6 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/azcore" azcloud "github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud" "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" - "github.com/Azure/go-autorest/autorest" azureEnv "github.com/Azure/go-autorest/autorest/azure" pbempty "github.com/golang/protobuf/ptypes/empty" "github.com/google/uuid" @@ -263,7 +262,7 @@ func (k *azureNativeProvider) Configure(ctx context.Context, logging.V(9).Infof("Using legacy authentication") credential := azCoreTokenCredential{p: k} - k.azureClient, err = k.newAzureClient(resourceManagerAuth, credential, userAgent) + k.azureClient, err = azure.NewAzureClient(k.environment, resourceManagerAuth, userAgent), nil if err != nil { return nil, fmt.Errorf("creating Azure client: %w", err) } @@ -329,15 +328,6 @@ func (k *azureNativeProvider) isParameterized() bool { return strings.HasPrefix(k.name, "azure-native"+parameterizedNameSeparator) } -func (k *azureNativeProvider) newAzureClient(armAuth autorest.Authorizer, tokenCred azcore.TokenCredential, userAgent string) (azure.AzureClient, error) { - if util.EnableAzcoreBackend() { - logging.V(9).Infof("AzureClient: using azcore and azidentity") - return azure.NewAzCoreClient(tokenCred, userAgent, k.cloud, nil) - } - logging.V(9).Infof("AzureClient: using autorest") - return azure.NewAzureClient(k.environment, armAuth, userAgent), nil -} - // Invoke dynamically executes a built-in function in the provider. func (k *azureNativeProvider) Invoke(ctx context.Context, req *rpc.InvokeRequest) (*rpc.InvokeResponse, error) { label := fmt.Sprintf("%s.Invoke(%s)", k.name, req.Tok) From 779ec3c400898e68dbd0fb6b4cf65beb143070c2 Mon Sep 17 00:00:00 2001 From: Eron Wright Date: Mon, 25 Aug 2025 11:46:16 -0700 Subject: [PATCH 26/26] test: autorest e2e test --- provider/pkg/provider/provider_e2e_test.go | 145 +++++++++++++++++++++ 1 file changed, 145 insertions(+) diff --git a/provider/pkg/provider/provider_e2e_test.go b/provider/pkg/provider/provider_e2e_test.go index f1188807ced7..9eb0a56e567f 100644 --- a/provider/pkg/provider/provider_e2e_test.go +++ b/provider/pkg/provider/provider_e2e_test.go @@ -300,6 +300,151 @@ func TestAzidentity(t *testing.T) { }) } +func TestAutorest(t *testing.T) { + if testing.Short() { + t.Skipf("Skipping in testing.Short() mode") + return + } + + validate := func(t *testing.T, up auto.UpResult) (map[string]interface{}, jwt.MapClaims) { + // validate clientConfig + require.Contains(t, up.Outputs, "clientConfig", "expected clientConfig output") + clientConfig, _ := up.Outputs["clientConfig"].Value.(map[string]interface{}) + clientConfigJSON, _ := json.Marshal(clientConfig) + t.Logf("clientConfig: %s", clientConfigJSON) + + assert.Contains(t, clientConfig, "clientId") + assert.Contains(t, clientConfig, "objectId") + assert.Contains(t, clientConfig, "subscriptionId") + assert.Contains(t, clientConfig, "tenantId") + + // validate clientToken + require.Contains(t, up.Outputs, "clientToken", "expected clientToken output") + clientToken, _ := up.Outputs["clientToken"].Value.(map[string]interface{}) + claims, err := parseJwtUnverified(clientToken["token"].(string)) + require.NoError(t, err) + claimsJSON, _ := json.Marshal(claims) + t.Logf("clientToken: %s", claimsJSON) + + return clientConfig, claims + } + + t.Run("OIDC", func(t *testing.T) { + t.Setenv("PULUMI_ENABLE_AZCORE_BACKEND", "false") + + oidcClientId := os.Getenv("OIDC_ARM_CLIENT_ID") + if oidcClientId == "" { + if os.Getenv("CI") != "" { + t.Error("OIDC test without OIDC_ARM_CLIENT_ID") + } + t.Skip("Skipping OIDC test without OIDC_ARM_CLIENT_ID") + } + + t.Setenv("ARM_USE_OIDC", "true") + t.Setenv("ARM_CLIENT_ID", oidcClientId) + // Make sure we test the OIDC method + t.Setenv("ARM_CLIENT_SECRET", "") + t.Setenv("ARM_CLIENT_CERTIFICATE_PATH", "") + t.Setenv("ARM_CLIENT_CERTIFICATE_PASSWORD", "") + + pt := newPulumiTest(t, "azidentity") + + up := pt.Up(t) + clientConfig, clientToken := validate(t, up) + assert.Equal(t, os.Getenv("ARM_CLIENT_ID"), clientConfig["clientId"]) + assert.Equal(t, os.Getenv("ARM_CLIENT_ID"), clientToken["appid"]) + assert.Equal(t, "app", clientToken["idtyp"]) + }) + + t.Run("SP_clientsecret", func(t *testing.T) { + t.Setenv("PULUMI_ENABLE_AZCORE_BACKEND", "false") + + clientSecret := os.Getenv("ARM_CLIENT_SECRET") + if clientSecret == "" { + if os.Getenv("CI") != "" { + t.Error("SP test without ARM_CLIENT_SECRET") + } + t.Skip("Skipping SP test without ARM_CLIENT_SECRET") + } + + t.Setenv("ARM_CLIENT_ID", os.Getenv("ARM_CLIENT_ID")) + t.Setenv("ARM_CLIENT_SECRET", clientSecret) + // Make sure we test the client secret method + t.Setenv("ARM_CLIENT_CERTIFICATE_PASSWORD", "") + t.Setenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "") + t.Setenv("ACTIONS_ID_TOKEN_REQUEST_URL", "") + + pt := newPulumiTest(t, "azidentity") + + up := pt.Up(t) + clientConfig, clientToken := validate(t, up) + assert.Equal(t, os.Getenv("ARM_CLIENT_ID"), clientConfig["clientId"]) + assert.Equal(t, os.Getenv("ARM_CLIENT_ID"), clientToken["appid"]) + assert.Equal(t, "app", clientToken["idtyp"]) + }) + + t.Run("SP_clientcert", func(t *testing.T) { + t.Setenv("PULUMI_ENABLE_AZCORE_BACKEND", "false") + + certPath := os.Getenv("ARM_CLIENT_CERTIFICATE_PATH_FOR_TEST") + if certPath == "" { + if os.Getenv("CI") != "" { + t.Error("SP test without ARM_CLIENT_CERTIFICATE_PATH_FOR_TEST") + } + t.Skip("Skipping SP test without ARM_CLIENT_CERTIFICATE_PATH_FOR_TEST") + } + + t.Setenv("ARM_CLIENT_ID", os.Getenv("ARM_CLIENT_ID")) + t.Setenv("ARM_CLIENT_CERTIFICATE_PATH", certPath) + t.Setenv("ARM_CLIENT_CERTIFICATE_PASSWORD", os.Getenv("ARM_CLIENT_CERTIFICATE_PASSWORD_FOR_TEST")) + // Make sure we test the client certificate method + t.Setenv("ARM_CLIENT_SECRET", "") + t.Setenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "") + t.Setenv("ACTIONS_ID_TOKEN_REQUEST_URL", "") + + pt := newPulumiTest(t, "azidentity") + + up := pt.Up(t) + clientConfig, clientToken := validate(t, up) + assert.Equal(t, os.Getenv("ARM_CLIENT_ID"), clientConfig["clientId"]) + assert.Equal(t, os.Getenv("ARM_CLIENT_ID"), clientToken["appid"]) + assert.Equal(t, "app", clientToken["idtyp"]) + }) + + t.Run("CLI", func(t *testing.T) { + t.Setenv("PULUMI_ENABLE_AZCORE_BACKEND", "false") + + // AZURE_CONFIG_DIR_FOR_TEST is set by the GH workflow build-test.yml + // to provide an isolated configuration directory for the Azure CLI. + configDir := os.Getenv("AZURE_CONFIG_DIR_FOR_TEST") + if configDir == "" { + if os.Getenv("CI") != "" { + t.Error("CLI test without AZURE_CONFIG_DIR_FOR_TEST") + } + t.Skip("Skipping CLI test without AZURE_CONFIG_DIR_FOR_TEST") + } + t.Setenv("AZURE_CONFIG_DIR", configDir) + + // Make sure we test the CLI method + t.Setenv("ARM_USE_MSI", "false") + t.Setenv("ARM_USE_OIDC", "false") + t.Setenv("ARM_TENANT_ID", "") + t.Setenv("ARM_CLIENT_ID", "") + t.Setenv("ARM_CLIENT_SECRET", "") + t.Setenv("ARM_CLIENT_CERTIFICATE_PATH", "") + t.Setenv("ARM_CLIENT_CERTIFICATE_PASSWORD", "") + t.Setenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "") + t.Setenv("ACTIONS_ID_TOKEN_REQUEST_URL", "") + + pt := newPulumiTest(t, "azidentity") + up := pt.Up(t) + clientConfig, clientToken := validate(t, up) + assert.Equal(t, "04b07795-8ddb-461a-bbee-02f9e1bf7b46", clientConfig["clientId"]) + assert.Equal(t, "04b07795-8ddb-461a-bbee-02f9e1bf7b46", clientToken["appid"]) + assert.Equal(t, "user", clientToken["idtyp"]) + }) +} + func TestUpgradeKeyVault_2_76_0(t *testing.T) { upgradeTest(t, "upgrade-keyvault", "2.76.0") }