diff --git a/.gitignore b/.gitignore index 2cc346b..3df9ef0 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ bin .cnab /build/git_askpass.sh az +!pkg/az +!.gitignore diff --git a/README.md b/README.md index 795dd76..6575c58 100644 --- a/README.md +++ b/README.md @@ -219,18 +219,37 @@ mixins: ### Authenticate +The az mixin supports several authentication methods. All are provided with custom `login` command: + ```yaml -az: - description: "Azure CLI login" - arguments: - - login - flags: - service-principal: - username: ${ bundle.credentials.AZURE_SP_CLIENT_ID } - password: ${ bundle.credentials.AZURE_SP_PASSWORD } - tenant: ${ bundle.credentials.AZURE_TENANT } +install: + - az: + login: ``` +### Existing Azure CLI Authentication + +If you have already authenticated using `az login`, the mixin will use your +existing credentials. This requires the following files to exist in your +`.azure` directory: +- `azureProfile.json`: Contains your Azure profile information. +- `msal_token_cache.json`: Contains the cached authentication tokens. + +### Service Principal Authentication + +To authenticate using a service principal, set the following environment +variables: +- `AZURE_CLIENT_ID` +- `AZURE_CLIENT_SECRET` +- `AZURE_TENANT_ID` + +### Managed Identity Authentication + +When running in Azure, you can authenticate using managed identity. By default, +the system-assigned managed identity is used. To use a user-assigned managed +identity, set the `AZURE_CLIENT_ID` environment variable to the client ID of +the managed identity. + ### Provision a VM Create a VM, ignoring the error if it already exists. diff --git a/pkg/az/action.go b/pkg/az/action.go index e108278..2065433 100644 --- a/pkg/az/action.go +++ b/pkg/az/action.go @@ -88,6 +88,8 @@ func (s *TypedStep) UnmarshalYAML(unmarshal func(interface{}) error) error { continue case "group": cmd = &GroupCommand{} + case "login": + cmd = &LoginCommand{} default: // It's a custom user command customCmd := &UserCommand{} b, err := yaml.Marshal(step) diff --git a/pkg/az/login.go b/pkg/az/login.go new file mode 100644 index 0000000..0a8eacf --- /dev/null +++ b/pkg/az/login.go @@ -0,0 +1,83 @@ +package az + +import ( + "context" + "os" + "path/filepath" + + "get.porter.sh/porter/pkg/exec/builder" +) + +var ( + _ TypedCommand = &LoginCommand{} + _ builder.HasErrorHandling = &LoginCommand{} +) + +// LoginCommand handles logging into Azure +type LoginCommand struct { + action string + Description string `yaml:"description"` +} + +func (c *LoginCommand) HandleError(ctx context.Context, err builder.ExitError, stdout string, stderr string) error { + // Handle specific login errors if necessary + return err +} + +func (c *LoginCommand) GetWorkingDir() string { + return "" +} + +func (c *LoginCommand) SetAction(action string) { + c.action = action +} + +func (c *LoginCommand) GetCommand() string { + if c.azureDirExists() { + // Use a no-op command since we don't have to log in. + return "true" + } + + return "az" +} + +func (c *LoginCommand) GetArguments() []string { + if c.azureDirExists() { + return []string{} + } + return []string{"login"} +} + +func (c *LoginCommand) GetFlags() builder.Flags { + flags := builder.Flags{} + + if c.azureDirExists() { + return flags + } + + if os.Getenv("AZURE_CLIENT_ID") != "" && os.Getenv("AZURE_CLIENT_SECRET") != "" && os.Getenv("AZURE_TENANT_ID") != "" { + // Add flags for service principal authentication + flags = append(flags, builder.NewFlag("service-principal", "")) + flags = append(flags, builder.NewFlag("username", os.Getenv("AZURE_CLIENT_ID"))) + flags = append(flags, builder.NewFlag("password", os.Getenv("AZURE_CLIENT_SECRET"))) + flags = append(flags, builder.NewFlag("tenant", os.Getenv("AZURE_TENANT_ID"))) + } else if os.Getenv("AZURE_CLIENT_ID") != "" { + // Add flag for user-assigned managed identity + flags = append(flags, builder.NewFlag("identity", "")) + flags = append(flags, builder.NewFlag("username", os.Getenv("AZURE_CLIENT_ID"))) + } else { + // Add flag for system-assigned managed identity + flags = append(flags, builder.NewFlag("identity", "")) + } + + return flags +} + +func (c *LoginCommand) SuppressesOutput() bool { + return false +} + +func (c *LoginCommand) azureDirExists() bool { + _, err := os.Stat(filepath.Join(os.Getenv("HOME"), ".azure")) + return err == nil +} diff --git a/pkg/az/login_test.go b/pkg/az/login_test.go new file mode 100644 index 0000000..d18212f --- /dev/null +++ b/pkg/az/login_test.go @@ -0,0 +1,125 @@ +package az + +import ( + "os" + "path/filepath" + "testing" + + "get.porter.sh/porter/pkg/exec/builder" + "github.com/stretchr/testify/assert" +) + +func TestLoginCommand_GetArguments_ServicePrincipal(t *testing.T) { + tempHome := t.TempDir() + os.Setenv("HOME", tempHome) + os.Setenv("AZURE_CLIENT_ID", "test-client-id") + os.Setenv("AZURE_CLIENT_SECRET", "test-client-secret") + os.Setenv("AZURE_TENANT_ID", "test-tenant-id") + defer os.Unsetenv("AZURE_CLIENT_ID") + defer os.Unsetenv("AZURE_CLIENT_SECRET") + defer os.Unsetenv("AZURE_TENANT_ID") + + cmd := &LoginCommand{} + args := cmd.GetArguments() + + expectedArgs := []string{"login"} + assert.Equal(t, expectedArgs, args) +} + +func TestLoginCommand_GetCommandAndGetArguments_ExistingAzureDirectory(t *testing.T) { + tempHome := t.TempDir() + os.Setenv("HOME", tempHome) + homeDir := os.Getenv("HOME") + if err := os.MkdirAll(filepath.Join(homeDir, ".azure"), 0755); err != nil { + t.Fatal("failed to create .azure directory:", err) + } + defer os.RemoveAll(filepath.Join(homeDir, ".azure")) + + cmd := &LoginCommand{} + args := cmd.GetArguments() + + expectedArgs := []string{} + assert.Equal(t, "true", cmd.GetCommand()) + assert.Equal(t, expectedArgs, args) +} + +func TestLoginCommand_GetArguments_ManagedIdentity(t *testing.T) { + tempHome := t.TempDir() + os.Setenv("HOME", tempHome) + os.Unsetenv("AZURE_CLIENT_ID") + os.Unsetenv("AZURE_CLIENT_SECRET") + os.Unsetenv("AZURE_TENANT_ID") + + cmd := &LoginCommand{} + args := cmd.GetArguments() + + expectedArgs := []string{"login"} + assert.Equal(t, expectedArgs, args) +} + +func TestLoginCommand_GetFlags_ServicePrincipal(t *testing.T) { + tempHome := t.TempDir() + os.Setenv("HOME", tempHome) + os.Setenv("AZURE_CLIENT_ID", "test-client-id") + os.Setenv("AZURE_CLIENT_SECRET", "test-client-secret") + os.Setenv("AZURE_TENANT_ID", "test-tenant-id") + defer os.Unsetenv("AZURE_CLIENT_ID") + defer os.Unsetenv("AZURE_CLIENT_SECRET") + defer os.Unsetenv("AZURE_TENANT_ID") + + cmd := &LoginCommand{} + flags := cmd.GetFlags() + + expectedFlags := builder.Flags{ + builder.NewFlag("service-principal", ""), + builder.NewFlag("username", "test-client-id"), + builder.NewFlag("password", "test-client-secret"), + builder.NewFlag("tenant", "test-tenant-id"), + } + assert.Equal(t, expectedFlags, flags) +} + +func TestLoginCommand_GetFlags_UserAssignedManagedIdentity(t *testing.T) { + tempHome := t.TempDir() + os.Setenv("HOME", tempHome) + os.Setenv("AZURE_CLIENT_ID", "test-client-id") + defer os.Unsetenv("AZURE_CLIENT_ID") + + cmd := &LoginCommand{} + flags := cmd.GetFlags() + + expectedFlags := builder.Flags{ + builder.NewFlag("identity", ""), + builder.NewFlag("username", "test-client-id"), + } + assert.Equal(t, expectedFlags, flags) +} + +func TestLoginCommand_GetFlags_SystemManagedIdentity(t *testing.T) { + tempHome := t.TempDir() + os.Setenv("HOME", tempHome) + + cmd := &LoginCommand{} + flags := cmd.GetFlags() + + expectedFlags := builder.Flags{ + builder.NewFlag("identity", ""), + } + assert.Equal(t, expectedFlags, flags) +} + +func TestLoginCommand_GetFlags_ExistingAzureDirectory(t *testing.T) { + tempHome := t.TempDir() + os.Setenv("HOME", tempHome) + homeDir := os.Getenv("HOME") + if err := os.MkdirAll(filepath.Join(homeDir, ".azure"), 0755); err != nil { + t.Fatal("failed to create .azure directory:", err) + } + defer os.RemoveAll(filepath.Join(homeDir, ".azure")) + + cmd := &LoginCommand{} + flags := cmd.GetFlags() + + expectedFlags := builder.Flags{} + assert.Equal(t, expectedFlags, flags) +} diff --git a/pkg/az/schema/schema.json b/pkg/az/schema/schema.json index 04f6f3f..94ee7ad 100644 --- a/pkg/az/schema/schema.json +++ b/pkg/az/schema/schema.json @@ -31,7 +31,7 @@ } }, "installBicep": { - "description": "Indicates if Bicep should be install", + "description": "Indicates if Bicep should be installed", "type": "boolean" } }, @@ -174,7 +174,8 @@ }, "additionalProperties": false }, - "group": {"$ref": "#/definitions/group"} + "group": {"$ref": "#/definitions/group"}, + "login": {"$ref": "#/definitions/login"} }, "additionalProperties": false }, @@ -192,6 +193,11 @@ } }, "additionalProperties": false + }, + "login": { + "description": "Login to Azure", + "type": "object", + "additionalProperties": false } }, "type": "object",