cli: refactor iam create command (#1034)

* AB#2788 refactor iam create

* AB#2788 go mod tidy

* AB#2788 encode b64 at runtime

* AB#2788 rename receiver
This commit is contained in:
Moritz Sanft 2023-02-01 11:32:01 +01:00 committed by GitHub
parent 39b8d4e396
commit 6166b52f5d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 1241 additions and 1313 deletions

View File

@ -26,7 +26,7 @@ type cloudCreator interface {
) (clusterid.File, error)
}
type iamCreator interface {
type cloudIAMCreator interface {
Create(
ctx context.Context,
provider cloudprovider.Provider,

View File

@ -6,9 +6,32 @@ SPDX-License-Identifier: AGPL-3.0-only
package cmd
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"regexp"
"strings"
"github.com/edgelesssys/constellation/v2/cli/internal/cloudcmd"
"github.com/edgelesssys/constellation/v2/cli/internal/iamid"
"github.com/edgelesssys/constellation/v2/internal/cloud/cloudprovider"
"github.com/edgelesssys/constellation/v2/internal/config"
"github.com/edgelesssys/constellation/v2/internal/constants"
"github.com/edgelesssys/constellation/v2/internal/file"
"github.com/spf13/afero"
"github.com/spf13/cobra"
)
var (
// GCP-specific validation regexes
// Source: https://cloud.google.com/resource-manager/reference/rest/v1/projects.
zoneRegex = regexp.MustCompile(`^\w+-\w+-[abc]$`)
regionRegex = regexp.MustCompile(`^\w+-\w+[0-9]$`)
projectIDRegex = regexp.MustCompile(`^[a-z][-a-z0-9]{4,28}[a-z0-9]{1}$`)
serviceAccIDRegex = regexp.MustCompile(`^[a-z](?:[-a-z0-9]{4,28}[a-z0-9])$`)
)
// NewIAMCmd returns a new cobra.Command for the iam parent command. It needs another verb and does nothing on its own.
func NewIAMCmd() *cobra.Command {
cmd := &cobra.Command{
@ -33,6 +56,7 @@ func newIAMCreateCmd() *cobra.Command {
}
cmd.PersistentFlags().Bool("generate-config", false, "automatically generate a configuration file and fill in the required fields")
cmd.PersistentFlags().Bool("yes", false, "create the IAM configuration without further confirmation")
cmd.AddCommand(newIAMCreateAWSCmd())
cmd.AddCommand(newIAMCreateAzureCmd())
@ -40,3 +64,466 @@ func newIAMCreateCmd() *cobra.Command {
return cmd
}
// newIAMCreateAWSCmd returns a new cobra.Command for the iam create aws command.
func newIAMCreateAWSCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "aws",
Short: "Create IAM configuration on AWS for your Constellation cluster",
Long: "Create IAM configuration on AWS for your Constellation cluster.",
Args: cobra.ExactArgs(0),
RunE: createRunIAMFunc(cloudprovider.AWS),
}
cmd.Flags().String("prefix", "", "name prefix for all resources (required)")
must(cobra.MarkFlagRequired(cmd.Flags(), "prefix"))
cmd.Flags().String("zone", "", "AWS availability zone the resources will be created in, e.g. us-east-2a (required)\n"+
"Find available zones here: https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/using-regions-availability-zones.html#concepts-availability-zones. "+
"Note that we do not support every zone / region. You can find a list of all supported regions in our docs.")
must(cobra.MarkFlagRequired(cmd.Flags(), "zone"))
return cmd
}
// newIAMCreateAzureCmd returns a new cobra.Command for the iam create azure command.
func newIAMCreateAzureCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "azure",
Short: "Create IAM configuration on Microsoft Azure for your Constellation cluster",
Long: "Create IAM configuration on Microsoft Azure for your Constellation cluster.",
Args: cobra.ExactArgs(0),
RunE: createRunIAMFunc(cloudprovider.Azure),
}
cmd.Flags().String("resourceGroup", "", "name prefix of the two resource groups your cluster / IAM resources will be created in (required)")
must(cobra.MarkFlagRequired(cmd.Flags(), "resourceGroup"))
cmd.Flags().String("region", "", "region the resources will be created in, e.g. westus (required)")
must(cobra.MarkFlagRequired(cmd.Flags(), "region"))
cmd.Flags().String("servicePrincipal", "", "name of the service principal that will be created (required)")
must(cobra.MarkFlagRequired(cmd.Flags(), "servicePrincipal"))
return cmd
}
// NewIAMCreateGCPCmd returns a new cobra.Command for the iam create gcp command.
func newIAMCreateGCPCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "gcp",
Short: "Create IAM configuration on GCP for your Constellation cluster",
Long: "Create IAM configuration on GCP for your Constellation cluster.",
Args: cobra.ExactArgs(0),
RunE: createRunIAMFunc(cloudprovider.GCP),
}
cmd.Flags().String("zone", "", "GCP zone the cluster will be deployed in (required)\n"+
"Find a list of available zones here: https://cloud.google.com/compute/docs/regions-zones#available.")
must(cobra.MarkFlagRequired(cmd.Flags(), "zone"))
cmd.Flags().String("serviceAccountID", "", "ID for the service account that will be created (required)\n"+
"Must match ^[a-z](?:[-a-z0-9]{4,28}[a-z0-9])$.")
must(cobra.MarkFlagRequired(cmd.Flags(), "serviceAccountID"))
cmd.Flags().String("projectID", "", "ID of the GCP project the configuration will be created in (required)\n"+
"Find it on the welcome screen of your project: https://console.cloud.google.com/welcome.")
must(cobra.MarkFlagRequired(cmd.Flags(), "projectID"))
return cmd
}
// createRunIAMFunc is the entrypoint for the iam create command. It sets up the iamCreator
// and starts IAM creation for the specific cloud provider.
func createRunIAMFunc(provider cloudprovider.Provider) func(cmd *cobra.Command, args []string) error {
var providerCreator providerIAMCreator
switch provider {
case cloudprovider.AWS:
providerCreator = &awsIAMCreator{}
case cloudprovider.Azure:
providerCreator = &azureIAMCreator{}
case cloudprovider.GCP:
providerCreator = &gcpIAMCreator{}
default:
return func(cmd *cobra.Command, args []string) error {
return fmt.Errorf("unknown provider %s", provider)
}
}
return func(cmd *cobra.Command, args []string) error {
iamCreator := newIAMCreator(cmd)
defer iamCreator.spinner.Stop()
iamCreator.provider = provider
iamCreator.providerCreator = providerCreator
return iamCreator.create(cmd.Context())
}
}
// newIAMCreator creates a new iamiamCreator.
func newIAMCreator(cmd *cobra.Command) *iamCreator {
spinner := newSpinner(cmd.ErrOrStderr())
return &iamCreator{
cmd: cmd,
spinner: spinner,
creator: cloudcmd.NewIAMCreator(spinner),
fileHandler: file.NewHandler(afero.NewOsFs()),
iamConfig: &cloudcmd.IAMConfig{},
}
}
// iamCreator is the iamCreator for the iam create command.
type iamCreator struct {
cmd *cobra.Command
spinner spinnerInterf
creator cloudIAMCreator
fileHandler file.Handler
provider cloudprovider.Provider
providerCreator providerIAMCreator
iamConfig *cloudcmd.IAMConfig
}
// create IAM configuration on the iamCreator's cloud provider.
func (c *iamCreator) create(ctx context.Context) error {
flags, err := c.parseFlagsAndSetupConfig()
if err != nil {
return err
}
if !flags.yesFlag {
c.cmd.Printf("The following IAM configuration will be created:\n\n")
c.providerCreator.printConfirmValues(c.cmd, flags)
if flags.generateConfig {
c.cmd.Printf("The configuration file %s will be automatically generated and populated with the IAM values.\n", flags.configPath)
}
ok, err := askToConfirm(c.cmd, "Do you want to create the configuration?")
if err != nil {
return err
}
if !ok {
c.cmd.Println("The creation of the configuration was aborted.")
return nil
}
}
c.spinner.Start("Creating", false)
conf := createConfig(c.provider)
iamFile, err := c.creator.Create(ctx, c.provider, c.iamConfig)
c.spinner.Stop()
if err != nil {
return err
}
c.cmd.Println() // Print empty line to separate after spinner ended.
err = c.providerCreator.parseAndWriteIDFile(iamFile, c.fileHandler)
if err != nil {
return err
}
if flags.generateConfig {
c.providerCreator.writeOutputValuesToConfig(conf, flags, iamFile)
if err := c.fileHandler.WriteYAML(flags.configPath, conf, file.OptMkdirAll); err != nil {
return err
}
c.cmd.Printf("Your IAM configuration was created and filled into %s successfully.\n", flags.configPath)
return nil
}
c.providerCreator.printOutputValues(c.cmd, flags, iamFile)
c.cmd.Println("Your IAM configuration was created successfully. Please fill the above values into your configuration file.")
return nil
}
// parseFlagsAndSetupConfig parses the flags of the iam create command and fills the values into the IAM config (output values of the command).
func (c *iamCreator) parseFlagsAndSetupConfig() (iamFlags, error) {
configPath, err := c.cmd.Flags().GetString("config")
if err != nil {
return iamFlags{}, fmt.Errorf("parsing config string: %w", err)
}
generateConfig, err := c.cmd.Flags().GetBool("generate-config")
if err != nil {
return iamFlags{}, fmt.Errorf("parsing generate-config bool: %w", err)
}
yesFlag, err := c.cmd.Flags().GetBool("yes")
if err != nil {
return iamFlags{}, fmt.Errorf("parsing yes bool: %w", err)
}
flags := iamFlags{
generateConfig: generateConfig,
configPath: configPath,
yesFlag: yesFlag,
}
flags, err = c.providerCreator.parseFlagsAndSetupConfig(c.cmd, flags, c.iamConfig)
if err != nil {
return iamFlags{}, fmt.Errorf("parsing provider-specific value: %w", err)
}
return flags, nil
}
// iamFlags contains the parsed flags of the iam create command, including the parsed flags of the selected cloud provider.
type iamFlags struct {
aws awsFlags
azure azureFlags
gcp gcpFlags
generateConfig bool
configPath string
yesFlag bool
}
// awsFlags contains the parsed flags of the iam create aws command.
type awsFlags struct {
prefix string
region string
zone string
}
// azureFlags contains the parsed flags of the iam create azure command.
type azureFlags struct {
region string
resourceGroup string
servicePrincipal string
}
// gcpFlags contains the parsed flags of the iam create gcp command.
type gcpFlags struct {
serviceAccountID string
zone string
region string
projectID string
}
// providerIAMCreator is an interface for the IAM actions of different cloud providers.
type providerIAMCreator interface {
// printConfirmValues prints the values that will be created on the cloud provider and need to be confirmed by the user.
printConfirmValues(cmd *cobra.Command, flags iamFlags)
// printOutputValues prints the values that were created on the cloud provider.
printOutputValues(cmd *cobra.Command, flags iamFlags, iamFile iamid.File)
// writeOutputValuesToConfig writes the output values of the IAM creation to the constellation config file.
writeOutputValuesToConfig(conf *config.Config, flags iamFlags, iamFile iamid.File)
// parseFlagsAndSetupConfig parses the provider-specific flags and fills the values into the IAM config (output values of the command).
parseFlagsAndSetupConfig(cmd *cobra.Command, flags iamFlags, iamConfig *cloudcmd.IAMConfig) (iamFlags, error)
// parseAndWriteIDFile parses the GCP service account key and writes it to a keyfile. It is only implemented for GCP.
parseAndWriteIDFile(iamFile iamid.File, fileHandler file.Handler) error
}
// awsIAMCreator implements the providerIAMCreator interface for AWS.
type awsIAMCreator struct{}
func (c *awsIAMCreator) parseFlagsAndSetupConfig(cmd *cobra.Command, flags iamFlags, iamConfig *cloudcmd.IAMConfig) (iamFlags, error) {
prefix, err := cmd.Flags().GetString("prefix")
if err != nil {
return iamFlags{}, fmt.Errorf("parsing prefix string: %w", err)
}
zone, err := cmd.Flags().GetString("zone")
if err != nil {
return iamFlags{}, fmt.Errorf("parsing zone string: %w", err)
}
flags.aws = awsFlags{
prefix: prefix,
zone: zone,
}
if strings.HasPrefix(zone, "eu-central-1") {
flags.aws.region = "eu-central-1"
} else if strings.HasPrefix(zone, "us-east-2") {
flags.aws.region = "us-east-2"
} else if strings.HasPrefix(zone, "ap-south-1") {
flags.aws.region = "ap-south-1"
} else {
return iamFlags{}, fmt.Errorf("invalid AWS region, to find a correct region please refer to our docs and https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/using-regions-availability-zones.html#concepts-availability-zones")
}
// Setup IAM config.
iamConfig.AWS = cloudcmd.AWSIAMConfig{
Region: flags.aws.region,
Prefix: flags.aws.prefix,
}
return flags, nil
}
func (c *awsIAMCreator) printConfirmValues(cmd *cobra.Command, flags iamFlags) {
cmd.Printf("Region:\t\t%s\n", flags.aws.region)
cmd.Printf("Name Prefix:\t%s\n\n", flags.aws.prefix)
}
func (c *awsIAMCreator) printOutputValues(cmd *cobra.Command, flags iamFlags, iamFile iamid.File) {
cmd.Printf("region:\t\t\t%s\n", flags.aws.region)
cmd.Printf("zone:\t\t\t%s\n", flags.aws.zone)
cmd.Printf("iamProfileControlPlane:\t%s\n", iamFile.AWSOutput.ControlPlaneInstanceProfile)
cmd.Printf("iamProfileWorkerNodes:\t%s\n\n", iamFile.AWSOutput.WorkerNodeInstanceProfile)
}
func (c *awsIAMCreator) writeOutputValuesToConfig(conf *config.Config, flags iamFlags, iamFile iamid.File) {
conf.Provider.AWS.Region = flags.aws.region
conf.Provider.AWS.Zone = flags.aws.zone
conf.Provider.AWS.IAMProfileControlPlane = iamFile.AWSOutput.ControlPlaneInstanceProfile
conf.Provider.AWS.IAMProfileWorkerNodes = iamFile.AWSOutput.WorkerNodeInstanceProfile
}
func (c *awsIAMCreator) parseAndWriteIDFile(iamFile iamid.File, fileHandler file.Handler) error {
return nil
}
// azureIAMCreator implements the providerIAMCreator interface for Azure.
type azureIAMCreator struct{}
func (c *azureIAMCreator) parseFlagsAndSetupConfig(cmd *cobra.Command, flags iamFlags, iamConfig *cloudcmd.IAMConfig) (iamFlags, error) {
region, err := cmd.Flags().GetString("region")
if err != nil {
return iamFlags{}, fmt.Errorf("parsing region string: %w", err)
}
resourceGroup, err := cmd.Flags().GetString("resourceGroup")
if err != nil {
return iamFlags{}, fmt.Errorf("parsing resourceGroup string: %w", err)
}
servicePrincipal, err := cmd.Flags().GetString("servicePrincipal")
if err != nil {
return iamFlags{}, fmt.Errorf("parsing servicePrincipal string: %w", err)
}
flags.azure = azureFlags{
region: region,
resourceGroup: resourceGroup,
servicePrincipal: servicePrincipal,
}
// Setup IAM config.
iamConfig.Azure = cloudcmd.AzureIAMConfig{
Region: flags.azure.region,
ResourceGroup: flags.azure.resourceGroup,
ServicePrincipal: flags.azure.servicePrincipal,
}
return flags, nil
}
func (c *azureIAMCreator) printConfirmValues(cmd *cobra.Command, flags iamFlags) {
cmd.Printf("Region:\t\t\t%s\n", flags.azure.region)
cmd.Printf("Resource Group:\t\t%s\n", flags.azure.resourceGroup)
cmd.Printf("Service Principal:\t%s\n\n", flags.azure.servicePrincipal)
}
func (c *azureIAMCreator) printOutputValues(cmd *cobra.Command, flags iamFlags, iamFile iamid.File) {
cmd.Printf("subscription:\t\t%s\n", iamFile.AzureOutput.SubscriptionID)
cmd.Printf("tenant:\t\t\t%s\n", iamFile.AzureOutput.TenantID)
cmd.Printf("location:\t\t%s\n", flags.azure.region)
cmd.Printf("resourceGroup:\t\t%s\n", flags.azure.resourceGroup)
cmd.Printf("userAssignedIdentity:\t%s\n", iamFile.AzureOutput.UAMIID)
cmd.Printf("appClientID:\t\t%s\n", iamFile.AzureOutput.ApplicationID)
cmd.Printf("clientSecretValue:\t%s\n\n", iamFile.AzureOutput.ApplicationClientSecretValue)
cmd.Println("Your IAM configuration was created successfully. Please fill the above values into your configuration file.")
}
func (c *azureIAMCreator) writeOutputValuesToConfig(conf *config.Config, flags iamFlags, iamFile iamid.File) {
conf.Provider.Azure.SubscriptionID = iamFile.AzureOutput.SubscriptionID
conf.Provider.Azure.TenantID = iamFile.AzureOutput.TenantID
conf.Provider.Azure.Location = flags.azure.region
conf.Provider.Azure.ResourceGroup = flags.azure.resourceGroup
conf.Provider.Azure.UserAssignedIdentity = iamFile.AzureOutput.UAMIID
conf.Provider.Azure.AppClientID = iamFile.AzureOutput.ApplicationID
conf.Provider.Azure.ClientSecretValue = iamFile.AzureOutput.ApplicationClientSecretValue
}
func (c *azureIAMCreator) parseAndWriteIDFile(iamFile iamid.File, fileHandler file.Handler) error {
return nil
}
// gcpIAMCreator implements the providerIAMCreator interface for GCP.
type gcpIAMCreator struct{}
func (c *gcpIAMCreator) parseFlagsAndSetupConfig(cmd *cobra.Command, flags iamFlags, iamConfig *cloudcmd.IAMConfig) (iamFlags, error) {
zone, err := cmd.Flags().GetString("zone")
if err != nil {
return iamFlags{}, fmt.Errorf("parsing zone string: %w", err)
}
if !zoneRegex.MatchString(zone) {
return iamFlags{}, fmt.Errorf("invalid zone string: %s", zone)
}
// Infer region from zone.
zoneParts := strings.Split(zone, "-")
region := fmt.Sprintf("%s-%s", zoneParts[0], zoneParts[1])
if !regionRegex.MatchString(region) {
return iamFlags{}, fmt.Errorf("invalid region string: %s", region)
}
projectID, err := cmd.Flags().GetString("projectID")
if err != nil {
return iamFlags{}, fmt.Errorf("parsing projectID string: %w", err)
}
if !projectIDRegex.MatchString(projectID) {
return iamFlags{}, fmt.Errorf("invalid projectID string: %s", projectID)
}
serviceAccID, err := cmd.Flags().GetString("serviceAccountID")
if err != nil {
return iamFlags{}, fmt.Errorf("parsing serviceAccountID string: %w", err)
}
if !serviceAccIDRegex.MatchString(serviceAccID) {
return iamFlags{}, fmt.Errorf("invalid serviceAccountID string: %s", serviceAccID)
}
flags.gcp = gcpFlags{
zone: zone,
region: region,
projectID: projectID,
serviceAccountID: serviceAccID,
}
// Setup IAM config.
iamConfig.GCP = cloudcmd.GCPIAMConfig{
Zone: flags.gcp.zone,
Region: flags.gcp.region,
ProjectID: flags.gcp.projectID,
ServiceAccountID: flags.gcp.serviceAccountID,
}
return flags, nil
}
func (c *gcpIAMCreator) printConfirmValues(cmd *cobra.Command, flags iamFlags) {
cmd.Printf("Project ID:\t\t%s\n", flags.gcp.projectID)
cmd.Printf("Service Account ID:\t%s\n", flags.gcp.serviceAccountID)
cmd.Printf("Region:\t\t\t%s\n", flags.gcp.region)
cmd.Printf("Zone:\t\t\t%s\n\n", flags.gcp.zone)
}
func (c *gcpIAMCreator) printOutputValues(cmd *cobra.Command, flags iamFlags, iamFile iamid.File) {
cmd.Println(fmt.Sprintf("serviceAccountKeyPath:\t%s\n", constants.GCPServiceAccountKeyFile))
}
func (c *gcpIAMCreator) writeOutputValuesToConfig(conf *config.Config, flags iamFlags, iamFile iamid.File) {
conf.Provider.GCP.ServiceAccountKeyPath = constants.GCPServiceAccountKeyFile
}
func (c *gcpIAMCreator) parseAndWriteIDFile(iamFile iamid.File, fileHandler file.Handler) error {
// GCP needs to write the service account key to a file.
tmpOut, err := parseIDFile(iamFile.GCPOutput.ServiceAccountKey)
if err != nil {
return err
}
if err := fileHandler.WriteJSON(constants.GCPServiceAccountKeyFile, tmpOut, file.OptNone); err != nil {
return err
}
return nil
}
// parseIDFile parses the given base64 encoded JSON string of the GCP service account key and returns a map.
func parseIDFile(serviceAccountKeyBase64 string) (map[string]string, error) {
dec, err := base64.StdEncoding.DecodeString(serviceAccountKeyBase64)
if err != nil {
return nil, err
}
out := make(map[string]string)
if err = json.Unmarshal(dec, &out); err != nil {
return nil, err
}
return out, nil
}

View File

@ -0,0 +1,753 @@
/*
Copyright (c) Edgeless Systems GmbH
SPDX-License-Identifier: AGPL-3.0-only
*/
package cmd
import (
"bytes"
"encoding/base64"
"strings"
"testing"
"github.com/edgelesssys/constellation/v2/cli/internal/cloudcmd"
"github.com/edgelesssys/constellation/v2/cli/internal/iamid"
"github.com/edgelesssys/constellation/v2/internal/cloud/cloudprovider"
"github.com/edgelesssys/constellation/v2/internal/config"
"github.com/edgelesssys/constellation/v2/internal/constants"
"github.com/edgelesssys/constellation/v2/internal/file"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseIDFile(t *testing.T) {
validIAMIDFile := iamid.File{
CloudProvider: cloudprovider.GCP,
GCPOutput: iamid.GCPFile{
ServiceAccountKey: base64.RawStdEncoding.EncodeToString([]byte(`{"private_key_id":"not_a_secret"}`)),
},
}
invalidIAMIDFile := iamid.File{
CloudProvider: cloudprovider.GCP,
GCPOutput: iamid.GCPFile{
ServiceAccountKey: "ey_Jwcml2YXRlX2tleV9pZCI6Im5vdF9hX3NlY3JldCJ9Cg==", // invalid b64
},
}
testCases := map[string]struct {
idFile iamid.File
wantPrivateKeyID string
wantErr bool
}{
"valid base64": {
idFile: validIAMIDFile,
wantPrivateKeyID: "not_a_secret",
},
"invalid base64": {
idFile: invalidIAMIDFile,
wantErr: true,
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
outMap, err := parseIDFile(tc.idFile.GCPOutput.ServiceAccountKey)
if tc.wantErr {
assert.Error(err)
} else {
assert.NoError(err)
assert.Equal(tc.wantPrivateKeyID, outMap["private_key_id"])
}
})
}
}
func TestIAMCreateAWS(t *testing.T) {
defaultFs := func(require *require.Assertions, provider cloudprovider.Provider, existingFiles []string) afero.Fs {
fs := afero.NewMemMapFs()
fileHandler := file.NewHandler(fs)
for _, f := range existingFiles {
require.NoError(fileHandler.Write(f, []byte{1, 2, 3}, file.OptNone))
}
return fs
}
readOnlyFs := func(require *require.Assertions, provider cloudprovider.Provider, existingFiles []string) afero.Fs {
fs := afero.NewReadOnlyFs(afero.NewMemMapFs())
return fs
}
validIAMIDFile := iamid.File{
CloudProvider: cloudprovider.AWS,
AWSOutput: iamid.AWSFile{
ControlPlaneInstanceProfile: "test_control_plane_instance_profile",
WorkerNodeInstanceProfile: "test_worker_nodes_instance_profile",
},
}
testCases := map[string]struct {
setupFs func(require *require.Assertions, provider cloudprovider.Provider, existingFiles []string) afero.Fs
creator *stubIAMCreator
provider cloudprovider.Provider
zoneFlag string
prefixFlag string
yesFlag bool
generateConfigFlag bool
configFlag string
existingFiles []string
stdin string
wantAbort bool
wantErr bool
}{
"iam create aws": {
setupFs: defaultFs,
creator: &stubIAMCreator{id: validIAMIDFile},
provider: cloudprovider.AWS,
zoneFlag: "us-east-2a",
prefixFlag: "test",
yesFlag: true,
},
"iam create aws generate config": {
setupFs: defaultFs,
creator: &stubIAMCreator{id: validIAMIDFile},
provider: cloudprovider.AWS,
zoneFlag: "us-east-2a",
prefixFlag: "test",
yesFlag: true,
configFlag: constants.ConfigFilename,
generateConfigFlag: true,
},
"iam create aws generate config custom path": {
setupFs: defaultFs,
creator: &stubIAMCreator{id: validIAMIDFile},
provider: cloudprovider.AWS,
zoneFlag: "us-east-2a",
prefixFlag: "test",
yesFlag: true,
generateConfigFlag: true,
configFlag: "custom-config.yaml",
},
"iam create aws generate config path already exists": {
setupFs: defaultFs,
creator: &stubIAMCreator{id: validIAMIDFile},
provider: cloudprovider.AWS,
zoneFlag: "us-east-2a",
prefixFlag: "test",
yesFlag: true,
generateConfigFlag: true,
wantErr: true,
configFlag: constants.ConfigFilename,
existingFiles: []string{constants.ConfigFilename},
},
"iam create aws generate config custom path already exists": {
setupFs: defaultFs,
creator: &stubIAMCreator{id: validIAMIDFile},
provider: cloudprovider.AWS,
zoneFlag: "us-east-2a",
prefixFlag: "test",
yesFlag: true,
generateConfigFlag: true,
wantErr: true,
configFlag: "custom-config.yaml",
existingFiles: []string{"custom-config.yaml"},
},
"interactive": {
setupFs: defaultFs,
creator: &stubIAMCreator{id: validIAMIDFile},
provider: cloudprovider.AWS,
zoneFlag: "us-east-2a",
prefixFlag: "test",
stdin: "yes\n",
},
"interactive generate config": {
setupFs: defaultFs,
creator: &stubIAMCreator{id: validIAMIDFile},
provider: cloudprovider.AWS,
zoneFlag: "us-east-2a",
prefixFlag: "test",
stdin: "yes\n",
configFlag: constants.ConfigFilename,
generateConfigFlag: true,
},
"interactive abort": {
setupFs: defaultFs,
creator: &stubIAMCreator{id: validIAMIDFile},
provider: cloudprovider.AWS,
zoneFlag: "us-east-2a",
prefixFlag: "test",
stdin: "no\n",
wantAbort: true,
},
"interactive generate config abort": {
setupFs: defaultFs,
creator: &stubIAMCreator{id: validIAMIDFile},
provider: cloudprovider.AWS,
zoneFlag: "us-east-2a",
prefixFlag: "test",
stdin: "no\n",
generateConfigFlag: true,
configFlag: constants.ConfigFilename,
wantAbort: true,
},
"invalid zone": {
setupFs: defaultFs,
creator: &stubIAMCreator{id: validIAMIDFile},
provider: cloudprovider.AWS,
zoneFlag: "us-west-5b",
prefixFlag: "test",
yesFlag: true,
wantErr: true,
},
"unwritable fs": {
setupFs: readOnlyFs,
creator: &stubIAMCreator{id: validIAMIDFile},
provider: cloudprovider.AWS,
zoneFlag: "us-east-2a",
prefixFlag: "test",
yesFlag: true,
generateConfigFlag: true,
wantErr: true,
configFlag: constants.ConfigFilename,
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
require := require.New(t)
cmd := newIAMCreateAWSCmd()
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})
cmd.SetIn(bytes.NewBufferString(tc.stdin))
cmd.Flags().String("config", constants.ConfigFilename, "") // register persistent flag manually
cmd.Flags().Bool("generate-config", false, "") // register persistent flag manually
cmd.Flags().Bool("yes", false, "") // register persistent flag manually
if tc.zoneFlag != "" {
require.NoError(cmd.Flags().Set("zone", tc.zoneFlag))
}
if tc.prefixFlag != "" {
require.NoError(cmd.Flags().Set("prefix", tc.prefixFlag))
}
if tc.yesFlag {
require.NoError(cmd.Flags().Set("yes", "true"))
}
if tc.generateConfigFlag {
require.NoError(cmd.Flags().Set("generate-config", "true"))
}
if tc.configFlag != "" {
require.NoError(cmd.Flags().Set("config", tc.configFlag))
}
fileHandler := file.NewHandler(tc.setupFs(require, tc.provider, tc.existingFiles))
iamCreator := &iamCreator{
cmd: cmd,
spinner: &nopSpinner{},
creator: tc.creator,
fileHandler: fileHandler,
iamConfig: &cloudcmd.IAMConfig{},
provider: tc.provider,
providerCreator: &awsIAMCreator{},
}
err := iamCreator.create(cmd.Context())
if tc.wantErr {
assert.Error(err)
return
}
if tc.wantAbort {
assert.False(tc.creator.createCalled)
return
}
if tc.generateConfigFlag {
readConfig := &config.Config{}
readErr := fileHandler.ReadYAML(tc.configFlag, readConfig)
require.NoError(readErr)
assert.Equal(tc.creator.id.AWSOutput.ControlPlaneInstanceProfile, readConfig.Provider.AWS.IAMProfileControlPlane)
assert.Equal(tc.creator.id.AWSOutput.WorkerNodeInstanceProfile, readConfig.Provider.AWS.IAMProfileWorkerNodes)
assert.Equal(tc.zoneFlag, readConfig.Provider.AWS.Zone)
assert.True(strings.HasPrefix(readConfig.Provider.AWS.Zone, readConfig.Provider.AWS.Region))
}
require.NoError(err)
assert.True(tc.creator.createCalled)
assert.Equal(tc.creator.id.AWSOutput, validIAMIDFile.AWSOutput)
})
}
}
func TestIAMCreateAzure(t *testing.T) {
defaultFs := func(require *require.Assertions, provider cloudprovider.Provider, existingFiles []string) afero.Fs {
fs := afero.NewMemMapFs()
fileHandler := file.NewHandler(fs)
for _, f := range existingFiles {
require.NoError(fileHandler.Write(f, []byte{1, 2, 3}, file.OptNone))
}
return fs
}
readOnlyFs := func(require *require.Assertions, provider cloudprovider.Provider, existingFiles []string) afero.Fs {
fs := afero.NewReadOnlyFs(afero.NewMemMapFs())
return fs
}
validIAMIDFile := iamid.File{
CloudProvider: cloudprovider.Azure,
AzureOutput: iamid.AzureFile{
SubscriptionID: "test_subscription_id",
TenantID: "test_tenant_id",
ApplicationID: "test_application_id",
ApplicationClientSecretValue: "test_application_client_secret_value",
UAMIID: "test_uami_id",
},
}
testCases := map[string]struct {
setupFs func(require *require.Assertions, provider cloudprovider.Provider, existingFiles []string) afero.Fs
creator *stubIAMCreator
provider cloudprovider.Provider
regionFlag string
servicePrincipalFlag string
resourceGroupFlag string
yesFlag bool
generateConfigFlag bool
configFlag string
existingFiles []string
stdin string
wantAbort bool
wantErr bool
}{
"iam create azure": {
setupFs: defaultFs,
creator: &stubIAMCreator{id: validIAMIDFile},
provider: cloudprovider.Azure,
regionFlag: "westus",
servicePrincipalFlag: "constell-test-sp",
resourceGroupFlag: "constell-test-rg",
yesFlag: true,
},
"iam create azure generate config": {
setupFs: defaultFs,
creator: &stubIAMCreator{id: validIAMIDFile},
provider: cloudprovider.Azure,
regionFlag: "westus",
servicePrincipalFlag: "constell-test-sp",
resourceGroupFlag: "constell-test-rg",
generateConfigFlag: true,
configFlag: constants.ConfigFilename,
yesFlag: true,
},
"iam create azure generate config custom path": {
setupFs: defaultFs,
creator: &stubIAMCreator{id: validIAMIDFile},
provider: cloudprovider.Azure,
regionFlag: "westus",
servicePrincipalFlag: "constell-test-sp",
resourceGroupFlag: "constell-test-rg",
generateConfigFlag: true,
configFlag: "custom-config.yaml",
yesFlag: true,
},
"iam create azure generate config custom path already exists": {
setupFs: defaultFs,
creator: &stubIAMCreator{id: validIAMIDFile},
provider: cloudprovider.Azure,
regionFlag: "westus",
servicePrincipalFlag: "constell-test-sp",
resourceGroupFlag: "constell-test-rg",
generateConfigFlag: true,
yesFlag: true,
wantErr: true,
configFlag: "custom-config.yaml",
existingFiles: []string{"custom-config.yaml"},
},
"iam create generate config path already exists": {
setupFs: defaultFs,
creator: &stubIAMCreator{id: validIAMIDFile},
provider: cloudprovider.Azure,
regionFlag: "westus",
servicePrincipalFlag: "constell-test-sp",
resourceGroupFlag: "constell-test-rg",
generateConfigFlag: true,
configFlag: constants.ConfigFilename,
existingFiles: []string{constants.ConfigFilename},
yesFlag: true,
wantErr: true,
},
"interactive": {
setupFs: defaultFs,
creator: &stubIAMCreator{id: validIAMIDFile},
provider: cloudprovider.Azure,
regionFlag: "westus",
servicePrincipalFlag: "constell-test-sp",
resourceGroupFlag: "constell-test-rg",
stdin: "yes\n",
},
"interactive generate config": {
setupFs: defaultFs,
creator: &stubIAMCreator{id: validIAMIDFile},
provider: cloudprovider.Azure,
regionFlag: "westus",
servicePrincipalFlag: "constell-test-sp",
resourceGroupFlag: "constell-test-rg",
stdin: "yes\n",
generateConfigFlag: true,
configFlag: constants.ConfigFilename,
},
"interactive abort": {
setupFs: defaultFs,
creator: &stubIAMCreator{id: validIAMIDFile},
provider: cloudprovider.Azure,
regionFlag: "westus",
servicePrincipalFlag: "constell-test-sp",
resourceGroupFlag: "constell-test-rg",
stdin: "no\n",
wantAbort: true,
},
"interactive generate config abort": {
setupFs: defaultFs,
creator: &stubIAMCreator{id: validIAMIDFile},
provider: cloudprovider.Azure,
regionFlag: "westus",
servicePrincipalFlag: "constell-test-sp",
resourceGroupFlag: "constell-test-rg",
stdin: "no\n",
generateConfigFlag: true,
wantAbort: true,
},
"unwritable fs": {
setupFs: readOnlyFs,
creator: &stubIAMCreator{id: validIAMIDFile},
provider: cloudprovider.Azure,
regionFlag: "westus",
servicePrincipalFlag: "constell-test-sp",
resourceGroupFlag: "constell-test-rg",
yesFlag: true,
generateConfigFlag: true,
configFlag: constants.ConfigFilename,
wantErr: true,
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
require := require.New(t)
cmd := newIAMCreateAzureCmd()
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})
cmd.SetIn(bytes.NewBufferString(tc.stdin))
cmd.Flags().String("config", constants.ConfigFilename, "") // register persistent flag manually
cmd.Flags().Bool("generate-config", false, "") // register persistent flag manually
cmd.Flags().Bool("yes", false, "") // register persistent flag
if tc.regionFlag != "" {
require.NoError(cmd.Flags().Set("region", tc.regionFlag))
}
if tc.resourceGroupFlag != "" {
require.NoError(cmd.Flags().Set("resourceGroup", tc.resourceGroupFlag))
}
if tc.servicePrincipalFlag != "" {
require.NoError(cmd.Flags().Set("servicePrincipal", tc.servicePrincipalFlag))
}
if tc.yesFlag {
require.NoError(cmd.Flags().Set("yes", "true"))
}
if tc.generateConfigFlag {
require.NoError(cmd.Flags().Set("generate-config", "true"))
}
if tc.configFlag != "" {
require.NoError(cmd.Flags().Set("config", tc.configFlag))
}
fileHandler := file.NewHandler(tc.setupFs(require, tc.provider, tc.existingFiles))
iamCreator := &iamCreator{
cmd: cmd,
spinner: &nopSpinner{},
creator: tc.creator,
fileHandler: fileHandler,
iamConfig: &cloudcmd.IAMConfig{},
provider: tc.provider,
providerCreator: &azureIAMCreator{},
}
err := iamCreator.create(cmd.Context())
if tc.wantErr {
assert.Error(err)
return
}
if tc.wantAbort {
assert.False(tc.creator.createCalled)
return
}
if tc.generateConfigFlag {
readConfig := &config.Config{}
readErr := fileHandler.ReadYAML(tc.configFlag, readConfig)
require.NoError(readErr)
assert.Equal(tc.creator.id.AzureOutput.SubscriptionID, readConfig.Provider.Azure.SubscriptionID)
assert.Equal(tc.creator.id.AzureOutput.TenantID, readConfig.Provider.Azure.TenantID)
assert.Equal(tc.creator.id.AzureOutput.ApplicationID, readConfig.Provider.Azure.AppClientID)
assert.Equal(tc.creator.id.AzureOutput.ApplicationClientSecretValue, readConfig.Provider.Azure.ClientSecretValue)
assert.Equal(tc.creator.id.AzureOutput.UAMIID, readConfig.Provider.Azure.UserAssignedIdentity)
assert.Equal(tc.regionFlag, readConfig.Provider.Azure.Location)
assert.Equal(tc.resourceGroupFlag, readConfig.Provider.Azure.ResourceGroup)
}
require.NoError(err)
assert.True(tc.creator.createCalled)
assert.Equal(tc.creator.id.AzureOutput, validIAMIDFile.AzureOutput)
})
}
}
func TestIAMCreateGCP(t *testing.T) {
defaultFs := func(require *require.Assertions, provider cloudprovider.Provider, existingFiles []string) afero.Fs {
fs := afero.NewMemMapFs()
fileHandler := file.NewHandler(fs)
for _, f := range existingFiles {
require.NoError(fileHandler.Write(f, []byte{1, 2, 3}, file.OptNone))
}
return fs
}
readOnlyFs := func(require *require.Assertions, provider cloudprovider.Provider, existingFiles []string) afero.Fs {
fs := afero.NewReadOnlyFs(afero.NewMemMapFs())
return fs
}
validIAMIDFile := iamid.File{
CloudProvider: cloudprovider.GCP,
GCPOutput: iamid.GCPFile{
ServiceAccountKey: "eyJwcml2YXRlX2tleV9pZCI6Im5vdF9hX3NlY3JldCJ9Cg==", // {"private_key_id":"not_a_secret"}
},
}
invalidIAMIDFile := iamid.File{
CloudProvider: cloudprovider.GCP,
GCPOutput: iamid.GCPFile{
ServiceAccountKey: "ey_Jwcml2YXRlX2tleV9pZCI6Im5vdF9hX3NlY3JldCJ9Cg==", // invalid b64
},
}
testCases := map[string]struct {
setupFs func(require *require.Assertions, provider cloudprovider.Provider, existingFiles []string) afero.Fs
creator *stubIAMCreator
provider cloudprovider.Provider
zoneFlag string
serviceAccountIDFlag string
projectIDFlag string
yesFlag bool
generateConfigFlag bool
configFlag string
existingFiles []string
stdin string
wantAbort bool
wantErr bool
}{
"iam create gcp": {
setupFs: defaultFs,
creator: &stubIAMCreator{id: validIAMIDFile},
provider: cloudprovider.GCP,
zoneFlag: "europe-west1-a",
serviceAccountIDFlag: "constell-test",
projectIDFlag: "constell-1234",
yesFlag: true,
},
"iam create gcp generate config": {
setupFs: defaultFs,
creator: &stubIAMCreator{id: validIAMIDFile},
provider: cloudprovider.GCP,
zoneFlag: "europe-west1-a",
serviceAccountIDFlag: "constell-test",
projectIDFlag: "constell-1234",
generateConfigFlag: true,
configFlag: constants.ConfigFilename,
yesFlag: true,
},
"iam create gcp generate config custom path": {
setupFs: defaultFs,
creator: &stubIAMCreator{id: validIAMIDFile},
provider: cloudprovider.GCP,
zoneFlag: "europe-west1-a",
serviceAccountIDFlag: "constell-test",
projectIDFlag: "constell-1234",
generateConfigFlag: true,
configFlag: "custom-config.yaml",
yesFlag: true,
},
"iam create gcp generate config path already exists": {
setupFs: defaultFs,
creator: &stubIAMCreator{id: validIAMIDFile},
provider: cloudprovider.GCP,
zoneFlag: "europe-west1-a",
serviceAccountIDFlag: "constell-test",
projectIDFlag: "constell-1234",
generateConfigFlag: true,
configFlag: constants.ConfigFilename,
existingFiles: []string{constants.ConfigFilename},
yesFlag: true,
wantErr: true,
},
"iam create gcp generate config custom path already exists": {
setupFs: defaultFs,
creator: &stubIAMCreator{id: validIAMIDFile},
provider: cloudprovider.GCP,
zoneFlag: "europe-west1-a",
serviceAccountIDFlag: "constell-test",
projectIDFlag: "constell-1234",
generateConfigFlag: true,
configFlag: "custom-config.yaml",
existingFiles: []string{"custom-config.yaml"},
yesFlag: true,
wantErr: true,
},
"iam create gcp invalid flags": {
setupFs: defaultFs,
creator: &stubIAMCreator{id: validIAMIDFile},
provider: cloudprovider.GCP,
zoneFlag: "-a",
yesFlag: true,
wantErr: true,
},
"iam create gcp invalid b64": {
setupFs: defaultFs,
creator: &stubIAMCreator{id: invalidIAMIDFile},
provider: cloudprovider.GCP,
zoneFlag: "europe-west1-a",
serviceAccountIDFlag: "constell-test",
projectIDFlag: "constell-1234",
yesFlag: true,
wantErr: true,
},
"interactive": {
setupFs: defaultFs,
creator: &stubIAMCreator{id: validIAMIDFile},
provider: cloudprovider.GCP,
zoneFlag: "europe-west1-a",
serviceAccountIDFlag: "constell-test",
projectIDFlag: "constell-1234",
stdin: "yes\n",
},
"interactive generate config": {
setupFs: defaultFs,
creator: &stubIAMCreator{id: validIAMIDFile},
provider: cloudprovider.GCP,
zoneFlag: "europe-west1-a",
serviceAccountIDFlag: "constell-test",
projectIDFlag: "constell-1234",
stdin: "yes\n",
configFlag: constants.ConfigFilename,
generateConfigFlag: true,
},
"interactive abort": {
setupFs: defaultFs,
creator: &stubIAMCreator{id: validIAMIDFile},
provider: cloudprovider.GCP,
zoneFlag: "europe-west1-a",
serviceAccountIDFlag: "constell-test",
projectIDFlag: "constell-1234",
stdin: "no\n",
wantAbort: true,
},
"interactive abort generate config": {
setupFs: defaultFs,
creator: &stubIAMCreator{id: validIAMIDFile},
provider: cloudprovider.GCP,
zoneFlag: "europe-west1-a",
serviceAccountIDFlag: "constell-test",
projectIDFlag: "constell-1234",
stdin: "no\n",
wantAbort: true,
configFlag: constants.ConfigFilename,
generateConfigFlag: true,
},
"unwritable fs": {
setupFs: readOnlyFs,
creator: &stubIAMCreator{id: validIAMIDFile},
provider: cloudprovider.GCP,
zoneFlag: "europe-west1-a",
serviceAccountIDFlag: "constell-test",
projectIDFlag: "constell-1234",
yesFlag: true,
generateConfigFlag: true,
configFlag: constants.ConfigFilename,
wantErr: true,
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
require := require.New(t)
cmd := newIAMCreateGCPCmd()
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})
cmd.SetIn(bytes.NewBufferString(tc.stdin))
cmd.Flags().String("config", constants.ConfigFilename, "") // register persistent flag manually
cmd.Flags().Bool("generate-config", false, "") // register persistent flag manually
cmd.Flags().Bool("yes", false, "") // register persistent flag
if tc.zoneFlag != "" {
require.NoError(cmd.Flags().Set("zone", tc.zoneFlag))
}
if tc.serviceAccountIDFlag != "" {
require.NoError(cmd.Flags().Set("serviceAccountID", tc.serviceAccountIDFlag))
}
if tc.projectIDFlag != "" {
require.NoError(cmd.Flags().Set("projectID", tc.projectIDFlag))
}
if tc.yesFlag {
require.NoError(cmd.Flags().Set("yes", "true"))
}
if tc.generateConfigFlag {
require.NoError(cmd.Flags().Set("generate-config", "true"))
}
if tc.configFlag != "" {
require.NoError(cmd.Flags().Set("config", tc.configFlag))
}
fileHandler := file.NewHandler(tc.setupFs(require, tc.provider, tc.existingFiles))
iamCreator := &iamCreator{
cmd: cmd,
spinner: &nopSpinner{},
creator: tc.creator,
fileHandler: fileHandler,
iamConfig: &cloudcmd.IAMConfig{},
provider: tc.provider,
providerCreator: &gcpIAMCreator{},
}
err := iamCreator.create(cmd.Context())
if tc.wantErr {
assert.Error(err)
return
}
if tc.wantAbort {
assert.False(tc.creator.createCalled)
return
}
if tc.generateConfigFlag {
readConfig := &config.Config{}
readErr := fileHandler.ReadYAML(tc.configFlag, readConfig)
require.NoError(readErr)
assert.Equal(constants.GCPServiceAccountKeyFile, readConfig.Provider.GCP.ServiceAccountKeyPath)
}
require.NoError(err)
assert.True(tc.creator.createCalled)
assert.Equal(tc.creator.id.GCPOutput, validIAMIDFile.GCPOutput)
readServiceAccountKey := &map[string]string{}
readErr := fileHandler.ReadJSON(constants.GCPServiceAccountKeyFile, readServiceAccountKey)
require.NoError(readErr)
assert.Equal("not_a_secret", (*readServiceAccountKey)["private_key_id"])
})
}
}

View File

@ -1,165 +0,0 @@
/*
Copyright (c) Edgeless Systems GmbH
SPDX-License-Identifier: AGPL-3.0-only
*/
package cmd
import (
"fmt"
"strings"
"github.com/edgelesssys/constellation/v2/cli/internal/cloudcmd"
"github.com/edgelesssys/constellation/v2/internal/cloud/cloudprovider"
"github.com/edgelesssys/constellation/v2/internal/file"
"github.com/spf13/afero"
"github.com/spf13/cobra"
)
// newIAMCreateAWSCmd returns a new cobra.Command for the iam create aws command.
func newIAMCreateAWSCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "aws",
Short: "Create IAM configuration on AWS for your Constellation cluster",
Long: "Create IAM configuration on AWS for your Constellation cluster.",
Args: cobra.ExactArgs(0),
RunE: runIAMCreateAWS,
}
cmd.Flags().String("prefix", "", "name prefix for all resources (required)")
must(cobra.MarkFlagRequired(cmd.Flags(), "prefix"))
cmd.Flags().String("zone", "", "AWS availability zone the resources will be created in, e.g. us-east-2a (required)\n"+
"Find available zones here: https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/using-regions-availability-zones.html#concepts-availability-zones. "+
"Note that we do not support every zone / region. You can find a list of all supported regions in our docs.")
must(cobra.MarkFlagRequired(cmd.Flags(), "zone"))
cmd.Flags().Bool("yes", false, "create the IAM configuration without further confirmation")
return cmd
}
func runIAMCreateAWS(cmd *cobra.Command, args []string) error {
spinner := newSpinner(cmd.ErrOrStderr())
defer spinner.Stop()
fileHandler := file.NewHandler(afero.NewOsFs())
creator := cloudcmd.NewIAMCreator(spinner)
return iamCreateAWS(cmd, spinner, creator, fileHandler)
}
func iamCreateAWS(cmd *cobra.Command, spinner spinnerInterf, creator iamCreator, fileHandler file.Handler) error {
// Get input variables.
awsFlags, err := parseAWSFlags(cmd)
if err != nil {
return err
}
// Confirmation.
if !awsFlags.yesFlag {
cmd.Printf("The following IAM configuration will be created:\n\n")
cmd.Printf("Region:\t\t%s\n", awsFlags.region)
cmd.Printf("Name Prefix:\t%s\n\n", awsFlags.prefix)
if awsFlags.generateConfig {
cmd.Printf("The configuration file %s will be automatically generated and populated with the IAM values.\n", awsFlags.configPath)
}
ok, err := askToConfirm(cmd, "Do you want to create the configuration?")
if err != nil {
return err
}
if !ok {
cmd.Println("The creation of the configuration was aborted.")
return nil
}
}
// Creation.
spinner.Start("Creating", false)
conf := createConfig(cloudprovider.AWS)
iamFile, err := creator.Create(cmd.Context(), cloudprovider.AWS, &cloudcmd.IAMConfig{
AWS: cloudcmd.AWSIAMConfig{
Region: awsFlags.region,
Prefix: awsFlags.prefix,
},
})
spinner.Stop()
if err != nil {
return err
}
cmd.Println() // Print empty line to separate after spinner ended.
if awsFlags.generateConfig {
conf.Provider.AWS.Region = awsFlags.region
conf.Provider.AWS.Zone = awsFlags.zone
conf.Provider.AWS.IAMProfileControlPlane = iamFile.AWSOutput.ControlPlaneInstanceProfile
conf.Provider.AWS.IAMProfileWorkerNodes = iamFile.AWSOutput.WorkerNodeInstanceProfile
if err := fileHandler.WriteYAML(awsFlags.configPath, conf, file.OptMkdirAll); err != nil {
return err
}
cmd.Printf("Your IAM configuration was created and filled into %s successfully.\n", awsFlags.configPath)
return nil
}
cmd.Printf("region:\t\t\t%s\n", awsFlags.region)
cmd.Printf("zone:\t\t\t%s\n", awsFlags.zone)
cmd.Printf("iamProfileControlPlane:\t%s\n", iamFile.AWSOutput.ControlPlaneInstanceProfile)
cmd.Printf("iamProfileWorkerNodes:\t%s\n\n", iamFile.AWSOutput.WorkerNodeInstanceProfile)
cmd.Println("Your IAM configuration was created successfully. Please fill the above values into your configuration file.")
return nil
}
// parseAWSFlags parses and validates the flags of the iam create aws command.
func parseAWSFlags(cmd *cobra.Command) (awsFlags, error) {
var region string
prefix, err := cmd.Flags().GetString("prefix")
if err != nil {
return awsFlags{}, fmt.Errorf("parsing prefix string: %w", err)
}
zone, err := cmd.Flags().GetString("zone")
if err != nil {
return awsFlags{}, fmt.Errorf("parsing zone string: %w", err)
}
if strings.HasPrefix(zone, "eu-central-1") {
region = "eu-central-1"
} else if strings.HasPrefix(zone, "us-east-2") {
region = "us-east-2"
} else if strings.HasPrefix(zone, "ap-south-1") {
region = "ap-south-1"
} else {
return awsFlags{}, fmt.Errorf("invalid AWS region, to find a correct region please refer to our docs and https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/using-regions-availability-zones.html#concepts-availability-zones")
}
configPath, err := cmd.Flags().GetString("config")
if err != nil {
return awsFlags{}, fmt.Errorf("parsing config string: %w", err)
}
generateConfig, err := cmd.Flags().GetBool("generate-config")
if err != nil {
return awsFlags{}, fmt.Errorf("parsing generate-config bool: %w", err)
}
yesFlag, err := cmd.Flags().GetBool("yes")
if err != nil {
return awsFlags{}, fmt.Errorf("parsing yes bool: %w", err)
}
return awsFlags{
zone: zone,
prefix: prefix,
region: region,
generateConfig: generateConfig,
configPath: configPath,
yesFlag: yesFlag,
}, nil
}
// awsFlags contains the parsed flags of the iam create aws command.
type awsFlags struct {
prefix string
region string
zone string
generateConfig bool
configPath string
yesFlag bool
}

View File

@ -1,227 +0,0 @@
/*
Copyright (c) Edgeless Systems GmbH
SPDX-License-Identifier: AGPL-3.0-only
*/
package cmd
import (
"bytes"
"strings"
"testing"
"github.com/edgelesssys/constellation/v2/cli/internal/iamid"
"github.com/edgelesssys/constellation/v2/internal/cloud/cloudprovider"
"github.com/edgelesssys/constellation/v2/internal/config"
"github.com/edgelesssys/constellation/v2/internal/constants"
"github.com/edgelesssys/constellation/v2/internal/file"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestIAMCreateAWS(t *testing.T) {
defaultFs := func(require *require.Assertions, provider cloudprovider.Provider, existingFiles []string) afero.Fs {
fs := afero.NewMemMapFs()
fileHandler := file.NewHandler(fs)
for _, f := range existingFiles {
require.NoError(fileHandler.Write(f, []byte{1, 2, 3}, file.OptNone))
}
return fs
}
readOnlyFs := func(require *require.Assertions, provider cloudprovider.Provider, existingFiles []string) afero.Fs {
fs := afero.NewReadOnlyFs(afero.NewMemMapFs())
return fs
}
validIAMIDFile := iamid.File{
CloudProvider: cloudprovider.AWS,
AWSOutput: iamid.AWSFile{
ControlPlaneInstanceProfile: "test_control_plane_instance_profile",
WorkerNodeInstanceProfile: "test_worker_nodes_instance_profile",
},
}
testCases := map[string]struct {
setupFs func(require *require.Assertions, provider cloudprovider.Provider, existingFiles []string) afero.Fs
creator *stubIAMCreator
provider cloudprovider.Provider
zoneFlag string
prefixFlag string
yesFlag bool
generateConfigFlag bool
configFlag string
existingFiles []string
stdin string
wantAbort bool
wantErr bool
}{
"iam create aws": {
setupFs: defaultFs,
creator: &stubIAMCreator{id: validIAMIDFile},
provider: cloudprovider.AWS,
zoneFlag: "us-east-2a",
prefixFlag: "test",
yesFlag: true,
},
"iam create aws generate config": {
setupFs: defaultFs,
creator: &stubIAMCreator{id: validIAMIDFile},
provider: cloudprovider.AWS,
zoneFlag: "us-east-2a",
prefixFlag: "test",
yesFlag: true,
configFlag: constants.ConfigFilename,
generateConfigFlag: true,
},
"iam create aws generate config custom path": {
setupFs: defaultFs,
creator: &stubIAMCreator{id: validIAMIDFile},
provider: cloudprovider.AWS,
zoneFlag: "us-east-2a",
prefixFlag: "test",
yesFlag: true,
generateConfigFlag: true,
configFlag: "custom-config.yaml",
},
"iam create aws generate config path already exists": {
setupFs: defaultFs,
creator: &stubIAMCreator{id: validIAMIDFile},
provider: cloudprovider.AWS,
zoneFlag: "us-east-2a",
prefixFlag: "test",
yesFlag: true,
generateConfigFlag: true,
wantErr: true,
configFlag: constants.ConfigFilename,
existingFiles: []string{constants.ConfigFilename},
},
"iam create aws generate config custom path already exists": {
setupFs: defaultFs,
creator: &stubIAMCreator{id: validIAMIDFile},
provider: cloudprovider.AWS,
zoneFlag: "us-east-2a",
prefixFlag: "test",
yesFlag: true,
generateConfigFlag: true,
wantErr: true,
configFlag: "custom-config.yaml",
existingFiles: []string{"custom-config.yaml"},
},
"interactive": {
setupFs: defaultFs,
creator: &stubIAMCreator{id: validIAMIDFile},
provider: cloudprovider.AWS,
zoneFlag: "us-east-2a",
prefixFlag: "test",
stdin: "yes\n",
},
"interactive generate config": {
setupFs: defaultFs,
creator: &stubIAMCreator{id: validIAMIDFile},
provider: cloudprovider.AWS,
zoneFlag: "us-east-2a",
prefixFlag: "test",
stdin: "yes\n",
configFlag: constants.ConfigFilename,
generateConfigFlag: true,
},
"interactive abort": {
setupFs: defaultFs,
creator: &stubIAMCreator{id: validIAMIDFile},
provider: cloudprovider.AWS,
zoneFlag: "us-east-2a",
prefixFlag: "test",
stdin: "no\n",
wantAbort: true,
},
"interactive generate config abort": {
setupFs: defaultFs,
creator: &stubIAMCreator{id: validIAMIDFile},
provider: cloudprovider.AWS,
zoneFlag: "us-east-2a",
prefixFlag: "test",
stdin: "no\n",
generateConfigFlag: true,
configFlag: constants.ConfigFilename,
wantAbort: true,
},
"invalid zone": {
setupFs: defaultFs,
creator: &stubIAMCreator{id: validIAMIDFile},
provider: cloudprovider.AWS,
zoneFlag: "us-west-5b",
prefixFlag: "test",
yesFlag: true,
wantErr: true,
},
"unwritable fs": {
setupFs: readOnlyFs,
creator: &stubIAMCreator{id: validIAMIDFile},
provider: cloudprovider.AWS,
zoneFlag: "us-east-2a",
prefixFlag: "test",
yesFlag: true,
generateConfigFlag: true,
wantErr: true,
configFlag: constants.ConfigFilename,
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
require := require.New(t)
cmd := newIAMCreateAWSCmd()
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})
cmd.SetIn(bytes.NewBufferString(tc.stdin))
cmd.Flags().String("config", constants.ConfigFilename, "") // register persistent flag manually
cmd.Flags().Bool("generate-config", false, "") // register persistent flag manually
if tc.zoneFlag != "" {
require.NoError(cmd.Flags().Set("zone", tc.zoneFlag))
}
if tc.prefixFlag != "" {
require.NoError(cmd.Flags().Set("prefix", tc.prefixFlag))
}
if tc.yesFlag {
require.NoError(cmd.Flags().Set("yes", "true"))
}
if tc.generateConfigFlag {
require.NoError(cmd.Flags().Set("generate-config", "true"))
}
if tc.configFlag != "" {
require.NoError(cmd.Flags().Set("config", tc.configFlag))
}
fileHandler := file.NewHandler(tc.setupFs(require, tc.provider, tc.existingFiles))
err := iamCreateAWS(cmd, &nopSpinner{}, tc.creator, fileHandler)
if tc.wantErr {
assert.Error(err)
return
}
if tc.wantAbort {
assert.False(tc.creator.createCalled)
return
}
if tc.generateConfigFlag {
readConfig := &config.Config{}
readErr := fileHandler.ReadYAML(tc.configFlag, readConfig)
require.NoError(readErr)
assert.Equal(tc.creator.id.AWSOutput.ControlPlaneInstanceProfile, readConfig.Provider.AWS.IAMProfileControlPlane)
assert.Equal(tc.creator.id.AWSOutput.WorkerNodeInstanceProfile, readConfig.Provider.AWS.IAMProfileWorkerNodes)
assert.Equal(tc.zoneFlag, readConfig.Provider.AWS.Zone)
assert.True(strings.HasPrefix(readConfig.Provider.AWS.Zone, readConfig.Provider.AWS.Region))
}
require.NoError(err)
assert.True(tc.creator.createCalled)
assert.Equal(tc.creator.id.AWSOutput, validIAMIDFile.AWSOutput)
})
}
}

View File

@ -1,171 +0,0 @@
/*
Copyright (c) Edgeless Systems GmbH
SPDX-License-Identifier: AGPL-3.0-only
*/
package cmd
import (
"fmt"
"github.com/edgelesssys/constellation/v2/cli/internal/cloudcmd"
"github.com/edgelesssys/constellation/v2/internal/cloud/cloudprovider"
"github.com/edgelesssys/constellation/v2/internal/file"
"github.com/spf13/afero"
"github.com/spf13/cobra"
)
// newIAMCreateAzureCmd returns a new cobra.Command for the iam create azure command.
func newIAMCreateAzureCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "azure",
Short: "Create IAM configuration on Microsoft Azure for your Constellation cluster",
Long: "Create IAM configuration on Microsoft Azure for your Constellation cluster.",
Args: cobra.ExactArgs(0),
RunE: runIAMCreateAzure,
}
cmd.Flags().String("resourceGroup", "", "name prefix of the two resource groups your cluster / IAM resources will be created in (required)")
must(cobra.MarkFlagRequired(cmd.Flags(), "resourceGroup"))
cmd.Flags().String("region", "", "region the resources will be created in, e.g. westus (required)")
must(cobra.MarkFlagRequired(cmd.Flags(), "region"))
cmd.Flags().String("servicePrincipal", "", "name of the service principal that will be created (required)")
must(cobra.MarkFlagRequired(cmd.Flags(), "servicePrincipal"))
cmd.Flags().Bool("yes", false, "create the IAM configuration without further confirmation")
return cmd
}
func runIAMCreateAzure(cmd *cobra.Command, args []string) error {
spinner := newSpinner(cmd.ErrOrStderr())
defer spinner.Stop()
fileHandler := file.NewHandler(afero.NewOsFs())
creator := cloudcmd.NewIAMCreator(spinner)
return iamCreateAzure(cmd, spinner, creator, fileHandler)
}
func iamCreateAzure(cmd *cobra.Command, spinner spinnerInterf, creator iamCreator, fileHandler file.Handler) error {
// Get input variables.
azureFlags, err := parseAzureFlags(cmd)
if err != nil {
return err
}
// Confirmation.
if !azureFlags.yesFlag {
cmd.Printf("The following IAM configuration will be created:\n\n")
cmd.Printf("Region:\t\t\t%s\n", azureFlags.region)
cmd.Printf("Resource Group:\t\t%s\n", azureFlags.resourceGroup)
cmd.Printf("Service Principal:\t%s\n\n", azureFlags.servicePrincipal)
if azureFlags.generateConfig {
cmd.Printf("The configuration file %s will be automatically generated and populated with the IAM values.\n", azureFlags.configPath)
}
ok, err := askToConfirm(cmd, "Do you want to create the configuration?")
if err != nil {
return err
}
if !ok {
cmd.Println("The creation of the configuration was aborted.")
return nil
}
}
// Creation.
spinner.Start("Creating", false)
conf := createConfig(cloudprovider.Azure)
iamFile, err := creator.Create(cmd.Context(), cloudprovider.Azure, &cloudcmd.IAMConfig{
Azure: cloudcmd.AzureIAMConfig{
Region: azureFlags.region,
ServicePrincipal: azureFlags.servicePrincipal,
ResourceGroup: azureFlags.resourceGroup,
},
})
spinner.Stop()
if err != nil {
return err
}
cmd.Println() // Print empty line to separate after spinner ended.
if azureFlags.generateConfig {
conf.Provider.Azure.SubscriptionID = iamFile.AzureOutput.SubscriptionID
conf.Provider.Azure.TenantID = iamFile.AzureOutput.TenantID
conf.Provider.Azure.Location = azureFlags.region
conf.Provider.Azure.ResourceGroup = azureFlags.resourceGroup
conf.Provider.Azure.UserAssignedIdentity = iamFile.AzureOutput.UAMIID
conf.Provider.Azure.AppClientID = iamFile.AzureOutput.ApplicationID
conf.Provider.Azure.ClientSecretValue = iamFile.AzureOutput.ApplicationClientSecretValue
if err := fileHandler.WriteYAML(azureFlags.configPath, conf, file.OptMkdirAll); err != nil {
return err
}
cmd.Printf("Your IAM configuration was created and filled into %s successfully.\n", azureFlags.configPath)
return nil
}
cmd.Printf("subscription:\t\t%s\n", iamFile.AzureOutput.SubscriptionID)
cmd.Printf("tenant:\t\t\t%s\n", iamFile.AzureOutput.TenantID)
cmd.Printf("location:\t\t%s\n", azureFlags.region)
cmd.Printf("resourceGroup:\t\t%s\n", azureFlags.resourceGroup)
cmd.Printf("userAssignedIdentity:\t%s\n", iamFile.AzureOutput.UAMIID)
cmd.Printf("appClientID:\t\t%s\n", iamFile.AzureOutput.ApplicationID)
cmd.Printf("appClientSecretValue:\t%s\n\n", iamFile.AzureOutput.ApplicationClientSecretValue)
cmd.Println("Your IAM configuration was created successfully. Please fill the above values into your configuration file.")
return nil
}
// parseAzureFlags parses and validates the flags of the iam create azure command.
func parseAzureFlags(cmd *cobra.Command) (azureFlags, error) {
region, err := cmd.Flags().GetString("region")
if err != nil {
return azureFlags{}, fmt.Errorf("parsing region string: %w", err)
}
resourceGroup, err := cmd.Flags().GetString("resourceGroup")
if err != nil {
return azureFlags{}, fmt.Errorf("parsing resourceGroup string: %w", err)
}
servicePrincipal, err := cmd.Flags().GetString("servicePrincipal")
if err != nil {
return azureFlags{}, fmt.Errorf("parsing servicePrincipal string: %w", err)
}
configPath, err := cmd.Flags().GetString("config")
if err != nil {
return azureFlags{}, fmt.Errorf("parsing config string: %w", err)
}
generateConfig, err := cmd.Flags().GetBool("generate-config")
if err != nil {
return azureFlags{}, fmt.Errorf("parsing generate-config bool: %w", err)
}
yesFlag, err := cmd.Flags().GetBool("yes")
if err != nil {
return azureFlags{}, fmt.Errorf("parsing yes bool: %w", err)
}
return azureFlags{
servicePrincipal: servicePrincipal,
resourceGroup: resourceGroup,
region: region,
generateConfig: generateConfig,
configPath: configPath,
yesFlag: yesFlag,
}, nil
}
// azureFlags contains the parsed flags of the iam create azure command.
type azureFlags struct {
region string
resourceGroup string
servicePrincipal string
generateConfig bool
configPath string
yesFlag bool
}

View File

@ -1,236 +0,0 @@
/*
Copyright (c) Edgeless Systems GmbH
SPDX-License-Identifier: AGPL-3.0-only
*/
package cmd
import (
"bytes"
"testing"
"github.com/edgelesssys/constellation/v2/cli/internal/iamid"
"github.com/edgelesssys/constellation/v2/internal/cloud/cloudprovider"
"github.com/edgelesssys/constellation/v2/internal/config"
"github.com/edgelesssys/constellation/v2/internal/constants"
"github.com/edgelesssys/constellation/v2/internal/file"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestIAMCreateAzure(t *testing.T) {
defaultFs := func(require *require.Assertions, provider cloudprovider.Provider, existingFiles []string) afero.Fs {
fs := afero.NewMemMapFs()
fileHandler := file.NewHandler(fs)
for _, f := range existingFiles {
require.NoError(fileHandler.Write(f, []byte{1, 2, 3}, file.OptNone))
}
return fs
}
readOnlyFs := func(require *require.Assertions, provider cloudprovider.Provider, existingFiles []string) afero.Fs {
fs := afero.NewReadOnlyFs(afero.NewMemMapFs())
return fs
}
validIAMIDFile := iamid.File{
CloudProvider: cloudprovider.Azure,
AzureOutput: iamid.AzureFile{
SubscriptionID: "test_subscription_id",
TenantID: "test_tenant_id",
ApplicationID: "test_application_id",
ApplicationClientSecretValue: "test_application_client_secret_value",
UAMIID: "test_uami_id",
},
}
testCases := map[string]struct {
setupFs func(require *require.Assertions, provider cloudprovider.Provider, existingFiles []string) afero.Fs
creator *stubIAMCreator
provider cloudprovider.Provider
regionFlag string
servicePrincipalFlag string
resourceGroupFlag string
yesFlag bool
generateConfigFlag bool
configFlag string
existingFiles []string
stdin string
wantAbort bool
wantErr bool
}{
"iam create azure": {
setupFs: defaultFs,
creator: &stubIAMCreator{id: validIAMIDFile},
provider: cloudprovider.Azure,
regionFlag: "westus",
servicePrincipalFlag: "constell-test-sp",
resourceGroupFlag: "constell-test-rg",
yesFlag: true,
},
"iam create azure generate config": {
setupFs: defaultFs,
creator: &stubIAMCreator{id: validIAMIDFile},
provider: cloudprovider.Azure,
regionFlag: "westus",
servicePrincipalFlag: "constell-test-sp",
resourceGroupFlag: "constell-test-rg",
generateConfigFlag: true,
configFlag: constants.ConfigFilename,
yesFlag: true,
},
"iam create azure generate config custom path": {
setupFs: defaultFs,
creator: &stubIAMCreator{id: validIAMIDFile},
provider: cloudprovider.Azure,
regionFlag: "westus",
servicePrincipalFlag: "constell-test-sp",
resourceGroupFlag: "constell-test-rg",
generateConfigFlag: true,
configFlag: "custom-config.yaml",
yesFlag: true,
},
"iam create azure generate config custom path already exists": {
setupFs: defaultFs,
creator: &stubIAMCreator{id: validIAMIDFile},
provider: cloudprovider.Azure,
regionFlag: "westus",
servicePrincipalFlag: "constell-test-sp",
resourceGroupFlag: "constell-test-rg",
generateConfigFlag: true,
yesFlag: true,
wantErr: true,
configFlag: "custom-config.yaml",
existingFiles: []string{"custom-config.yaml"},
},
"iam create generate config path already exists": {
setupFs: defaultFs,
creator: &stubIAMCreator{id: validIAMIDFile},
provider: cloudprovider.Azure,
regionFlag: "westus",
servicePrincipalFlag: "constell-test-sp",
resourceGroupFlag: "constell-test-rg",
generateConfigFlag: true,
configFlag: constants.ConfigFilename,
existingFiles: []string{constants.ConfigFilename},
yesFlag: true,
wantErr: true,
},
"interactive": {
setupFs: defaultFs,
creator: &stubIAMCreator{id: validIAMIDFile},
provider: cloudprovider.Azure,
regionFlag: "westus",
servicePrincipalFlag: "constell-test-sp",
resourceGroupFlag: "constell-test-rg",
stdin: "yes\n",
},
"interactive generate config": {
setupFs: defaultFs,
creator: &stubIAMCreator{id: validIAMIDFile},
provider: cloudprovider.Azure,
regionFlag: "westus",
servicePrincipalFlag: "constell-test-sp",
resourceGroupFlag: "constell-test-rg",
stdin: "yes\n",
generateConfigFlag: true,
configFlag: constants.ConfigFilename,
},
"interactive abort": {
setupFs: defaultFs,
creator: &stubIAMCreator{id: validIAMIDFile},
provider: cloudprovider.Azure,
regionFlag: "westus",
servicePrincipalFlag: "constell-test-sp",
resourceGroupFlag: "constell-test-rg",
stdin: "no\n",
wantAbort: true,
},
"interactive generate config abort": {
setupFs: defaultFs,
creator: &stubIAMCreator{id: validIAMIDFile},
provider: cloudprovider.Azure,
regionFlag: "westus",
servicePrincipalFlag: "constell-test-sp",
resourceGroupFlag: "constell-test-rg",
stdin: "no\n",
generateConfigFlag: true,
wantAbort: true,
},
"unwritable fs": {
setupFs: readOnlyFs,
creator: &stubIAMCreator{id: validIAMIDFile},
provider: cloudprovider.Azure,
regionFlag: "westus",
servicePrincipalFlag: "constell-test-sp",
resourceGroupFlag: "constell-test-rg",
yesFlag: true,
generateConfigFlag: true,
configFlag: constants.ConfigFilename,
wantErr: true,
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
require := require.New(t)
cmd := newIAMCreateAzureCmd()
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})
cmd.SetIn(bytes.NewBufferString(tc.stdin))
cmd.Flags().String("config", constants.ConfigFilename, "") // register persistent flag manually
cmd.Flags().Bool("generate-config", false, "") // register persistent flag manually
if tc.regionFlag != "" {
require.NoError(cmd.Flags().Set("region", tc.regionFlag))
}
if tc.resourceGroupFlag != "" {
require.NoError(cmd.Flags().Set("resourceGroup", tc.resourceGroupFlag))
}
if tc.servicePrincipalFlag != "" {
require.NoError(cmd.Flags().Set("servicePrincipal", tc.servicePrincipalFlag))
}
if tc.yesFlag {
require.NoError(cmd.Flags().Set("yes", "true"))
}
if tc.generateConfigFlag {
require.NoError(cmd.Flags().Set("generate-config", "true"))
}
if tc.configFlag != "" {
require.NoError(cmd.Flags().Set("config", tc.configFlag))
}
fileHandler := file.NewHandler(tc.setupFs(require, tc.provider, tc.existingFiles))
err := iamCreateAzure(cmd, &nopSpinner{}, tc.creator, fileHandler)
if tc.wantErr {
assert.Error(err)
return
}
if tc.wantAbort {
assert.False(tc.creator.createCalled)
return
}
if tc.generateConfigFlag {
readConfig := &config.Config{}
readErr := fileHandler.ReadYAML(tc.configFlag, readConfig)
require.NoError(readErr)
assert.Equal(tc.creator.id.AzureOutput.SubscriptionID, readConfig.Provider.Azure.SubscriptionID)
assert.Equal(tc.creator.id.AzureOutput.TenantID, readConfig.Provider.Azure.TenantID)
assert.Equal(tc.creator.id.AzureOutput.ApplicationID, readConfig.Provider.Azure.AppClientID)
assert.Equal(tc.creator.id.AzureOutput.ApplicationClientSecretValue, readConfig.Provider.Azure.ClientSecretValue)
assert.Equal(tc.creator.id.AzureOutput.UAMIID, readConfig.Provider.Azure.UserAssignedIdentity)
assert.Equal(tc.regionFlag, readConfig.Provider.Azure.Location)
assert.Equal(tc.resourceGroupFlag, readConfig.Provider.Azure.ResourceGroup)
}
require.NoError(err)
assert.True(tc.creator.createCalled)
assert.Equal(tc.creator.id.AzureOutput, validIAMIDFile.AzureOutput)
})
}
}

View File

@ -1,215 +0,0 @@
/*
Copyright (c) Edgeless Systems GmbH
SPDX-License-Identifier: AGPL-3.0-only
*/
package cmd
import (
"encoding/base64"
"encoding/json"
"fmt"
"regexp"
"strings"
"github.com/edgelesssys/constellation/v2/cli/internal/cloudcmd"
"github.com/edgelesssys/constellation/v2/internal/cloud/cloudprovider"
"github.com/edgelesssys/constellation/v2/internal/constants"
"github.com/edgelesssys/constellation/v2/internal/file"
"github.com/spf13/afero"
"github.com/spf13/cobra"
)
var (
zoneRegex = regexp.MustCompile(`^\w+-\w+-[abc]$`)
regionRegex = regexp.MustCompile(`^\w+-\w+[0-9]$`)
projectIDRegex = regexp.MustCompile(`^[a-z][-a-z0-9]{4,28}[a-z0-9]{1}$`)
serviceAccIDRegex = regexp.MustCompile(`^[a-z](?:[-a-z0-9]{4,28}[a-z0-9])$`)
)
// NewIAMCreateGCPCmd returns a new cobra.Command for the iam create gcp command.
func newIAMCreateGCPCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "gcp",
Short: "Create IAM configuration on GCP for your Constellation cluster",
Long: "Create IAM configuration on GCP for your Constellation cluster.",
Args: cobra.ExactArgs(0),
RunE: runIAMCreateGCP,
}
cmd.Flags().String("zone", "", "GCP zone the cluster will be deployed in (required)\n"+
"Find a list of available zones here: https://cloud.google.com/compute/docs/regions-zones#available.")
must(cobra.MarkFlagRequired(cmd.Flags(), "zone"))
cmd.Flags().String("serviceAccountID", "", "ID for the service account that will be created (required)\n"+
"Must match ^[a-z](?:[-a-z0-9]{4,28}[a-z0-9])$.")
must(cobra.MarkFlagRequired(cmd.Flags(), "serviceAccountID"))
cmd.Flags().String("projectID", "", "ID of the GCP project the configuration will be created in (required)\n"+
"Find it on the welcome screen of your project: https://console.cloud.google.com/welcome.")
must(cobra.MarkFlagRequired(cmd.Flags(), "projectID"))
cmd.Flags().Bool("yes", false, "create the IAM configuration without further confirmation")
return cmd
}
func runIAMCreateGCP(cmd *cobra.Command, args []string) error {
fileHandler := file.NewHandler(afero.NewOsFs())
spinner := newSpinner(cmd.ErrOrStderr())
defer spinner.Stop()
creator := cloudcmd.NewIAMCreator(spinner)
return iamCreateGCP(cmd, spinner, creator, fileHandler)
}
func iamCreateGCP(cmd *cobra.Command, spinner spinnerInterf, creator iamCreator, fileHandler file.Handler) error {
// Get input variables.
gcpFlags, err := parseGCPFlags(cmd)
if err != nil {
return err
}
// Confirmation.
if !gcpFlags.yesFlag {
cmd.Printf("The following IAM configuration will be created:\n\n")
cmd.Printf("Project ID:\t\t%s\n", gcpFlags.projectID)
cmd.Printf("Service Account ID:\t%s\n", gcpFlags.serviceAccountID)
cmd.Printf("Region:\t\t\t%s\n", gcpFlags.region)
cmd.Printf("Zone:\t\t\t%s\n\n", gcpFlags.zone)
if gcpFlags.generateConfig {
cmd.Printf("The configuration file %s will be automatically generated and populated with the IAM values.\n", gcpFlags.configPath)
}
ok, err := askToConfirm(cmd, "Do you want to create the configuration?")
if err != nil {
return err
}
if !ok {
cmd.Println("The creation of the configuration was aborted.")
return nil
}
}
// Creation.
spinner.Start("Creating", false)
conf := createConfig(cloudprovider.GCP)
iamFile, err := creator.Create(cmd.Context(), cloudprovider.GCP, &cloudcmd.IAMConfig{
GCP: cloudcmd.GCPIAMConfig{
ServiceAccountID: gcpFlags.serviceAccountID,
Region: gcpFlags.region,
Zone: gcpFlags.zone,
ProjectID: gcpFlags.projectID,
},
})
spinner.Stop()
if err != nil {
return err
}
cmd.Println() // Print empty line to separate after spinner ended.
// Write back values.
tmpOut, err := parseIDFile(iamFile.GCPOutput.ServiceAccountKey)
if err != nil {
return err
}
if err := fileHandler.WriteJSON(constants.GCPServiceAccountKeyFile, tmpOut, file.OptNone); err != nil {
return err
}
if gcpFlags.generateConfig {
conf.Provider.GCP.ServiceAccountKeyPath = constants.GCPServiceAccountKeyFile
if err := fileHandler.WriteYAML(gcpFlags.configPath, conf, file.OptMkdirAll); err != nil {
return err
}
cmd.Printf("Your IAM configuration was created and filled into %s successfully.\n", gcpFlags.configPath)
return nil
}
cmd.Println(fmt.Sprintf("serviceAccountKeyPath:\t%s\n", constants.GCPServiceAccountKeyFile))
cmd.Println("Your IAM configuration was created successfully. Please fill the above values into your configuration file.")
return nil
}
func parseIDFile(serviceAccountKeyBase64 string) (map[string]string, error) {
dec, err := base64.StdEncoding.DecodeString(serviceAccountKeyBase64)
if err != nil {
return nil, err
}
out := make(map[string]string)
if err = json.Unmarshal(dec, &out); err != nil {
return nil, err
}
return out, nil
}
// parseGCPFlags parses and validates the flags of the iam create gcp command.
func parseGCPFlags(cmd *cobra.Command) (gcpFlags, error) {
zone, err := cmd.Flags().GetString("zone")
if err != nil {
return gcpFlags{}, fmt.Errorf("parsing zone string: %w", err)
}
if !zoneRegex.MatchString(zone) {
return gcpFlags{}, fmt.Errorf("invalid zone string: %s", zone)
}
// Infer region from zone.
zoneParts := strings.Split(zone, "-")
region := fmt.Sprintf("%s-%s", zoneParts[0], zoneParts[1])
if !regionRegex.MatchString(region) {
return gcpFlags{}, fmt.Errorf("invalid region string: %s", region)
}
projectID, err := cmd.Flags().GetString("projectID")
if err != nil {
return gcpFlags{}, fmt.Errorf("parsing projectID string: %w", err)
}
// Source for regex: https://cloud.google.com/resource-manager/reference/rest/v1/projects.
if !projectIDRegex.MatchString(projectID) {
return gcpFlags{}, fmt.Errorf("invalid projectID string: %s", projectID)
}
serviceAccID, err := cmd.Flags().GetString("serviceAccountID")
if err != nil {
return gcpFlags{}, fmt.Errorf("parsing serviceAccountID string: %w", err)
}
if !serviceAccIDRegex.MatchString(serviceAccID) {
return gcpFlags{}, fmt.Errorf("invalid serviceAccountID string: %s", serviceAccID)
}
configPath, err := cmd.Flags().GetString("config")
if err != nil {
return gcpFlags{}, fmt.Errorf("parsing config string: %w", err)
}
generateConfig, err := cmd.Flags().GetBool("generate-config")
if err != nil {
return gcpFlags{}, fmt.Errorf("parsing generate-config bool: %w", err)
}
yesFlag, err := cmd.Flags().GetBool("yes")
if err != nil {
return gcpFlags{}, fmt.Errorf("parsing yes bool: %w", err)
}
return gcpFlags{
serviceAccountID: serviceAccID,
zone: zone,
region: region,
projectID: projectID,
generateConfig: generateConfig,
configPath: configPath,
yesFlag: yesFlag,
}, nil
}
// gcpFlags contains the parsed flags of the iam create gcp command.
type gcpFlags struct {
serviceAccountID string
zone string
region string
projectID string
generateConfig bool
configPath string
yesFlag bool
}

View File

@ -1,298 +0,0 @@
/*
Copyright (c) Edgeless Systems GmbH
SPDX-License-Identifier: AGPL-3.0-only
*/
package cmd
import (
"bytes"
"testing"
"github.com/edgelesssys/constellation/v2/cli/internal/iamid"
"github.com/edgelesssys/constellation/v2/internal/cloud/cloudprovider"
"github.com/edgelesssys/constellation/v2/internal/config"
"github.com/edgelesssys/constellation/v2/internal/constants"
"github.com/edgelesssys/constellation/v2/internal/file"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestIAMCreateGCP(t *testing.T) {
defaultFs := func(require *require.Assertions, provider cloudprovider.Provider, existingFiles []string) afero.Fs {
fs := afero.NewMemMapFs()
fileHandler := file.NewHandler(fs)
for _, f := range existingFiles {
require.NoError(fileHandler.Write(f, []byte{1, 2, 3}, file.OptNone))
}
return fs
}
readOnlyFs := func(require *require.Assertions, provider cloudprovider.Provider, existingFiles []string) afero.Fs {
fs := afero.NewReadOnlyFs(afero.NewMemMapFs())
return fs
}
validIAMIDFile := iamid.File{
CloudProvider: cloudprovider.GCP,
GCPOutput: iamid.GCPFile{
ServiceAccountKey: "eyJwcml2YXRlX2tleV9pZCI6Im5vdF9hX3NlY3JldCJ9Cg==", // {"private_key_id":"not_a_secret"}
},
}
invalidIAMIDFile := iamid.File{
CloudProvider: cloudprovider.GCP,
GCPOutput: iamid.GCPFile{
ServiceAccountKey: "ey_Jwcml2YXRlX2tleV9pZCI6Im5vdF9hX3NlY3JldCJ9Cg==", // invalid b64
},
}
testCases := map[string]struct {
setupFs func(require *require.Assertions, provider cloudprovider.Provider, existingFiles []string) afero.Fs
creator *stubIAMCreator
provider cloudprovider.Provider
zoneFlag string
serviceAccountIDFlag string
projectIDFlag string
yesFlag bool
generateConfigFlag bool
configFlag string
existingFiles []string
stdin string
wantAbort bool
wantErr bool
}{
"iam create gcp": {
setupFs: defaultFs,
creator: &stubIAMCreator{id: validIAMIDFile},
provider: cloudprovider.GCP,
zoneFlag: "europe-west1-a",
serviceAccountIDFlag: "constell-test",
projectIDFlag: "constell-1234",
yesFlag: true,
},
"iam create gcp generate config": {
setupFs: defaultFs,
creator: &stubIAMCreator{id: validIAMIDFile},
provider: cloudprovider.GCP,
zoneFlag: "europe-west1-a",
serviceAccountIDFlag: "constell-test",
projectIDFlag: "constell-1234",
generateConfigFlag: true,
configFlag: constants.ConfigFilename,
yesFlag: true,
},
"iam create gcp generate config custom path": {
setupFs: defaultFs,
creator: &stubIAMCreator{id: validIAMIDFile},
provider: cloudprovider.GCP,
zoneFlag: "europe-west1-a",
serviceAccountIDFlag: "constell-test",
projectIDFlag: "constell-1234",
generateConfigFlag: true,
configFlag: "custom-config.yaml",
yesFlag: true,
},
"iam create gcp generate config path already exists": {
setupFs: defaultFs,
creator: &stubIAMCreator{id: validIAMIDFile},
provider: cloudprovider.GCP,
zoneFlag: "europe-west1-a",
serviceAccountIDFlag: "constell-test",
projectIDFlag: "constell-1234",
generateConfigFlag: true,
configFlag: constants.ConfigFilename,
existingFiles: []string{constants.ConfigFilename},
yesFlag: true,
wantErr: true,
},
"iam create gcp generate config custom path already exists": {
setupFs: defaultFs,
creator: &stubIAMCreator{id: validIAMIDFile},
provider: cloudprovider.GCP,
zoneFlag: "europe-west1-a",
serviceAccountIDFlag: "constell-test",
projectIDFlag: "constell-1234",
generateConfigFlag: true,
configFlag: "custom-config.yaml",
existingFiles: []string{"custom-config.yaml"},
yesFlag: true,
wantErr: true,
},
"iam create gcp invalid flags": {
setupFs: defaultFs,
creator: &stubIAMCreator{id: validIAMIDFile},
provider: cloudprovider.GCP,
zoneFlag: "-a",
yesFlag: true,
wantErr: true,
},
"iam create gcp invalid b64": {
setupFs: defaultFs,
creator: &stubIAMCreator{id: invalidIAMIDFile},
provider: cloudprovider.GCP,
zoneFlag: "europe-west1-a",
serviceAccountIDFlag: "constell-test",
projectIDFlag: "constell-1234",
yesFlag: true,
wantErr: true,
},
"interactive": {
setupFs: defaultFs,
creator: &stubIAMCreator{id: validIAMIDFile},
provider: cloudprovider.GCP,
zoneFlag: "europe-west1-a",
serviceAccountIDFlag: "constell-test",
projectIDFlag: "constell-1234",
stdin: "yes\n",
},
"interactive generate config": {
setupFs: defaultFs,
creator: &stubIAMCreator{id: validIAMIDFile},
provider: cloudprovider.GCP,
zoneFlag: "europe-west1-a",
serviceAccountIDFlag: "constell-test",
projectIDFlag: "constell-1234",
stdin: "yes\n",
configFlag: constants.ConfigFilename,
generateConfigFlag: true,
},
"interactive abort": {
setupFs: defaultFs,
creator: &stubIAMCreator{id: validIAMIDFile},
provider: cloudprovider.GCP,
zoneFlag: "europe-west1-a",
serviceAccountIDFlag: "constell-test",
projectIDFlag: "constell-1234",
stdin: "no\n",
wantAbort: true,
},
"interactive abort generate config": {
setupFs: defaultFs,
creator: &stubIAMCreator{id: validIAMIDFile},
provider: cloudprovider.GCP,
zoneFlag: "europe-west1-a",
serviceAccountIDFlag: "constell-test",
projectIDFlag: "constell-1234",
stdin: "no\n",
wantAbort: true,
configFlag: constants.ConfigFilename,
generateConfigFlag: true,
},
"unwritable fs": {
setupFs: readOnlyFs,
creator: &stubIAMCreator{id: validIAMIDFile},
provider: cloudprovider.GCP,
zoneFlag: "europe-west1-a",
serviceAccountIDFlag: "constell-test",
projectIDFlag: "constell-1234",
yesFlag: true,
generateConfigFlag: true,
configFlag: constants.ConfigFilename,
wantErr: true,
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
require := require.New(t)
cmd := newIAMCreateGCPCmd()
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})
cmd.SetIn(bytes.NewBufferString(tc.stdin))
cmd.Flags().String("config", constants.ConfigFilename, "") // register persistent flag manually
cmd.Flags().Bool("generate-config", false, "") // register persistent flag manually
if tc.zoneFlag != "" {
require.NoError(cmd.Flags().Set("zone", tc.zoneFlag))
}
if tc.serviceAccountIDFlag != "" {
require.NoError(cmd.Flags().Set("serviceAccountID", tc.serviceAccountIDFlag))
}
if tc.projectIDFlag != "" {
require.NoError(cmd.Flags().Set("projectID", tc.projectIDFlag))
}
if tc.yesFlag {
require.NoError(cmd.Flags().Set("yes", "true"))
}
if tc.generateConfigFlag {
require.NoError(cmd.Flags().Set("generate-config", "true"))
}
if tc.configFlag != "" {
require.NoError(cmd.Flags().Set("config", tc.configFlag))
}
fileHandler := file.NewHandler(tc.setupFs(require, tc.provider, tc.existingFiles))
err := iamCreateGCP(cmd, &nopSpinner{}, tc.creator, fileHandler)
if tc.wantErr {
assert.Error(err)
return
}
if tc.wantAbort {
assert.False(tc.creator.createCalled)
return
}
if tc.generateConfigFlag {
readConfig := &config.Config{}
readErr := fileHandler.ReadYAML(tc.configFlag, readConfig)
require.NoError(readErr)
assert.Equal(constants.GCPServiceAccountKeyFile, readConfig.Provider.GCP.ServiceAccountKeyPath)
}
require.NoError(err)
assert.True(tc.creator.createCalled)
assert.Equal(tc.creator.id.GCPOutput, validIAMIDFile.GCPOutput)
readServiceAccountKey := &map[string]string{}
readErr := fileHandler.ReadJSON(constants.GCPServiceAccountKeyFile, readServiceAccountKey)
require.NoError(readErr)
assert.Equal("not_a_secret", (*readServiceAccountKey)["private_key_id"])
})
}
}
func TestParseIDFile(t *testing.T) {
validIAMIDFile := iamid.File{
CloudProvider: cloudprovider.GCP,
GCPOutput: iamid.GCPFile{
ServiceAccountKey: "eyJwcml2YXRlX2tleV9pZCI6Im5vdF9hX3NlY3JldCJ9Cg==", // {"private_key_id":"not_a_secret"}
},
}
invalidIAMIDFile := iamid.File{
CloudProvider: cloudprovider.GCP,
GCPOutput: iamid.GCPFile{
ServiceAccountKey: "ey_Jwcml2YXRlX2tleV9pZCI6Im5vdF9hX3NlY3JldCJ9Cg==", // invalid b64
},
}
testCases := map[string]struct {
idFile iamid.File
wantPrivateKeyID string
wantErr bool
}{
"valid base64": {
idFile: validIAMIDFile,
wantPrivateKeyID: "not_a_secret",
},
"invalid base64": {
idFile: invalidIAMIDFile,
wantErr: true,
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
outMap, err := parseIDFile(tc.idFile.GCPOutput.ServiceAccountKey)
if tc.wantErr {
assert.Error(err)
} else {
assert.NoError(err)
assert.Equal(tc.wantPrivateKeyID, outMap["private_key_id"])
}
})
}
}