diff --git a/pkg/cloud/aks/aks.go b/pkg/cloud/aks/aks.go index 477e7924264..ef0cdb85a13 100644 --- a/pkg/cloud/aks/aks.go +++ b/pkg/cloud/aks/aks.go @@ -3,10 +3,13 @@ package aks import ( b64 "encoding/base64" "encoding/json" + "fmt" + "regexp" "strings" "github.com/jenkins-x/jx-logging/pkg/log" "github.com/jenkins-x/jx/v2/pkg/util" + "github.com/pkg/errors" ) // AzureRunner an Azure CLI runner to interact with Azure @@ -28,6 +31,10 @@ type acr struct { Name string `json:"name"` } +type containerExists struct { + Exists bool `json:"exists"` +} + type password struct { Name string `json:"name"` Value string `json:"value"` @@ -46,6 +53,10 @@ type config struct { Auths map[string]*auth `json:"auths,omitempty"` } +var ( + azureContainerURIRegExp = regexp.MustCompile(`https://(?P\w+)\.blob\.core\.windows\.net/(?P\w+)`) +) + // NewAzureRunnerWithCommander specific the command runner for Azure CLI. func NewAzureRunnerWithCommander(runner util.Commander) *AzureRunner { return &AzureRunner{ @@ -242,3 +253,108 @@ func (az *AzureRunner) azureCLI(args ...string) (string, error) { az.Runner.SetArgs(args) return az.Runner.RunWithoutRetry() } + +func parseContainerURL(bucketURL string) (string, string, error) { + match := azureContainerURIRegExp.FindStringSubmatch(bucketURL) + if len(match) == 3 { + return match[1], match[2], nil + } + return "", "", errors.New(fmt.Sprintf("Azure Blob Container Url %s could not be parsed to determine storage account and container name", bucketURL)) +} + +// ContainerExists checks if an Azure Storage Container exists +func (az *AzureRunner) ContainerExists(bucketURL string) (bool, error) { + storageAccount, bucketName, err := parseContainerURL(bucketURL) + if err != nil { + return false, err + } + + accessKey, err := az.GetStorageAccessKey(storageAccount) + if err != nil { + return false, err + } + + bucketExistsArgs := []string{ + "storage", + "container", + "exists", + "-n", + bucketName, + "--account-name", + storageAccount, + "--account-key", + accessKey, + } + + cmdResult, err := az.azureCLI(bucketExistsArgs...) + + if err != nil { + log.Logger().Infof("Error checking bucket exists: %s, %s", cmdResult, err) + return false, err + } + + containerExists := containerExists{} + err = json.Unmarshal([]byte(cmdResult), &containerExists) + if err != nil { + return false, errors.Wrap(err, "unmarshalling Azure container exists command") + } + return containerExists.Exists, nil + +} + +// CreateContainer creates a Blob container within Azure Storage +func (az *AzureRunner) CreateContainer(bucketURL string) error { + storageAccount, bucketName, err := parseContainerURL(bucketURL) + if err != nil { + return err + } + + accessKey, err := az.GetStorageAccessKey(storageAccount) + if err != nil { + return err + } + + createContainerArgs := []string{ + "storage", + "container", + "create", + "-n", + bucketName, + "--account-name", + storageAccount, + "--fail-on-exist", + "--account-key", + accessKey, + } + + cmdResult, err := az.azureCLI(createContainerArgs...) + + if err != nil { + log.Logger().Infof("Error creating bucket: %s, %s", cmdResult, err) + return err + } + + return nil +} + +// GetStorageAccessKey retrieves access keys for an Azure storage account +func (az *AzureRunner) GetStorageAccessKey(storageAccount string) (string, error) { + getStorageAccessKeyArgs := []string{ + "storage", + "account", + "keys", + "list", + "-n", + storageAccount, + "--query", + "[?keyName=='key1'].value | [0]", + } + + cmdResult, err := az.azureCLI(getStorageAccessKeyArgs...) + + if err != nil { + return "", err + } + + return cmdResult, nil +} diff --git a/pkg/cloud/aks/interface.go b/pkg/cloud/aks/interface.go new file mode 100644 index 00000000000..1723241accc --- /dev/null +++ b/pkg/cloud/aks/interface.go @@ -0,0 +1,8 @@ +package aks + +// AzureStorage Interface for Azure Storage commands +type AzureStorage interface { + ContainerExists(bucketURL string) (bool, error) + CreateContainer(bucketURL string) error + GetStorageAccessKey(storageAccount string) (string, error) +} diff --git a/pkg/cloud/aks/mocks/azurestorage.go b/pkg/cloud/aks/mocks/azurestorage.go new file mode 100644 index 00000000000..7edf9868171 --- /dev/null +++ b/pkg/cloud/aks/mocks/azurestorage.go @@ -0,0 +1,197 @@ +// Code generated by pegomock. DO NOT EDIT. +// Source: github.com/jenkins-x/jx/v2/pkg/cloud/aks (interfaces: AzureStorage) + +package aks_test + +import ( + "reflect" + "time" + + pegomock "github.com/petergtz/pegomock" +) + +type MockAzureStorage struct { + fail func(message string, callerSkip ...int) +} + +func NewMockAzureStorage(options ...pegomock.Option) *MockAzureStorage { + mock := &MockAzureStorage{} + for _, option := range options { + option.Apply(mock) + } + return mock +} + +func (mock *MockAzureStorage) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh } +func (mock *MockAzureStorage) FailHandler() pegomock.FailHandler { return mock.fail } + +func (mock *MockAzureStorage) ContainerExists(_param0 string) (bool, error) { + if mock == nil { + panic("mock must not be nil. Use myMock := NewMockAzureStorage().") + } + params := []pegomock.Param{_param0} + result := pegomock.GetGenericMockFrom(mock).Invoke("ContainerExists", params, []reflect.Type{reflect.TypeOf((*bool)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) + var ret0 bool + var ret1 error + if len(result) != 0 { + if result[0] != nil { + ret0 = result[0].(bool) + } + if result[1] != nil { + ret1 = result[1].(error) + } + } + return ret0, ret1 +} + +func (mock *MockAzureStorage) CreateContainer(_param0 string) error { + if mock == nil { + panic("mock must not be nil. Use myMock := NewMockAzureStorage().") + } + params := []pegomock.Param{_param0} + result := pegomock.GetGenericMockFrom(mock).Invoke("CreateContainer", params, []reflect.Type{reflect.TypeOf((*error)(nil)).Elem()}) + var ret0 error + if len(result) != 0 { + if result[0] != nil { + ret0 = result[0].(error) + } + } + return ret0 +} + +func (mock *MockAzureStorage) GetStorageAccessKey(_param0 string) (string, error) { + if mock == nil { + panic("mock must not be nil. Use myMock := NewMockAzureStorage().") + } + params := []pegomock.Param{_param0} + result := pegomock.GetGenericMockFrom(mock).Invoke("GetStorageAccessKey", params, []reflect.Type{reflect.TypeOf((*string)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) + var ret0 string + var ret1 error + if len(result) != 0 { + if result[0] != nil { + ret0 = result[0].(string) + } + if result[1] != nil { + ret1 = result[1].(error) + } + } + return ret0, ret1 +} + +func (mock *MockAzureStorage) VerifyWasCalledOnce() *VerifierMockAzureStorage { + return &VerifierMockAzureStorage{ + mock: mock, + invocationCountMatcher: pegomock.Times(1), + } +} + +func (mock *MockAzureStorage) VerifyWasCalled(invocationCountMatcher pegomock.Matcher) *VerifierMockAzureStorage { + return &VerifierMockAzureStorage{ + mock: mock, + invocationCountMatcher: invocationCountMatcher, + } +} + +func (mock *MockAzureStorage) VerifyWasCalledInOrder(invocationCountMatcher pegomock.Matcher, inOrderContext *pegomock.InOrderContext) *VerifierMockAzureStorage { + return &VerifierMockAzureStorage{ + mock: mock, + invocationCountMatcher: invocationCountMatcher, + inOrderContext: inOrderContext, + } +} + +func (mock *MockAzureStorage) VerifyWasCalledEventually(invocationCountMatcher pegomock.Matcher, timeout time.Duration) *VerifierMockAzureStorage { + return &VerifierMockAzureStorage{ + mock: mock, + invocationCountMatcher: invocationCountMatcher, + timeout: timeout, + } +} + +type VerifierMockAzureStorage struct { + mock *MockAzureStorage + invocationCountMatcher pegomock.Matcher + inOrderContext *pegomock.InOrderContext + timeout time.Duration +} + +func (verifier *VerifierMockAzureStorage) ContainerExists(_param0 string) *MockAzureStorage_ContainerExists_OngoingVerification { + params := []pegomock.Param{_param0} + methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "ContainerExists", params, verifier.timeout) + return &MockAzureStorage_ContainerExists_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} +} + +type MockAzureStorage_ContainerExists_OngoingVerification struct { + mock *MockAzureStorage + methodInvocations []pegomock.MethodInvocation +} + +func (c *MockAzureStorage_ContainerExists_OngoingVerification) GetCapturedArguments() string { + _param0 := c.GetAllCapturedArguments() + return _param0[len(_param0)-1] +} + +func (c *MockAzureStorage_ContainerExists_OngoingVerification) GetAllCapturedArguments() (_param0 []string) { + params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) + if len(params) > 0 { + _param0 = make([]string, len(c.methodInvocations)) + for u, param := range params[0] { + _param0[u] = param.(string) + } + } + return +} + +func (verifier *VerifierMockAzureStorage) CreateContainer(_param0 string) *MockAzureStorage_CreateContainer_OngoingVerification { + params := []pegomock.Param{_param0} + methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "CreateContainer", params, verifier.timeout) + return &MockAzureStorage_CreateContainer_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} +} + +type MockAzureStorage_CreateContainer_OngoingVerification struct { + mock *MockAzureStorage + methodInvocations []pegomock.MethodInvocation +} + +func (c *MockAzureStorage_CreateContainer_OngoingVerification) GetCapturedArguments() string { + _param0 := c.GetAllCapturedArguments() + return _param0[len(_param0)-1] +} + +func (c *MockAzureStorage_CreateContainer_OngoingVerification) GetAllCapturedArguments() (_param0 []string) { + params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) + if len(params) > 0 { + _param0 = make([]string, len(c.methodInvocations)) + for u, param := range params[0] { + _param0[u] = param.(string) + } + } + return +} + +func (verifier *VerifierMockAzureStorage) GetStorageAccessKey(_param0 string) *MockAzureStorage_GetStorageAccessKey_OngoingVerification { + params := []pegomock.Param{_param0} + methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "GetStorageAccessKey", params, verifier.timeout) + return &MockAzureStorage_GetStorageAccessKey_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} +} + +type MockAzureStorage_GetStorageAccessKey_OngoingVerification struct { + mock *MockAzureStorage + methodInvocations []pegomock.MethodInvocation +} + +func (c *MockAzureStorage_GetStorageAccessKey_OngoingVerification) GetCapturedArguments() string { + _param0 := c.GetAllCapturedArguments() + return _param0[len(_param0)-1] +} + +func (c *MockAzureStorage_GetStorageAccessKey_OngoingVerification) GetAllCapturedArguments() (_param0 []string) { + params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) + if len(params) > 0 { + _param0 = make([]string, len(c.methodInvocations)) + for u, param := range params[0] { + _param0[u] = param.(string) + } + } + return +} diff --git a/pkg/cloud/aks/storage/bucket_provider.go b/pkg/cloud/aks/storage/bucket_provider.go new file mode 100644 index 00000000000..e63c12f27ee --- /dev/null +++ b/pkg/cloud/aks/storage/bucket_provider.go @@ -0,0 +1,78 @@ +package storage + +import ( + "fmt" + "io" + "strings" + + "github.com/google/uuid" + "github.com/jenkins-x/jx-logging/pkg/log" + "github.com/jenkins-x/jx/v2/pkg/cloud/aks" + "github.com/jenkins-x/jx/v2/pkg/cloud/buckets" + "github.com/jenkins-x/jx/v2/pkg/config" + "github.com/jenkins-x/jx/v2/pkg/util" + "github.com/pkg/errors" +) + +// AKSBucketProvider the bucket provider for Azure +type AKSBucketProvider struct { + Requirements *config.RequirementsConfig + AzureStorage aks.AzureStorage +} + +// CreateNewBucketForCluster creates a new dynamic bucket +func (b *AKSBucketProvider) CreateNewBucketForCluster(clusterName string, bucketKind string) (string, error) { + uuid := uuid.New() + bucketName := fmt.Sprintf("%s-%s-%s", clusterName, bucketKind, uuid.String()) + + // Max length is 63, https://docs.microsoft.com/en-us/rest/api/storageservices/naming-and-referencing-containers--blobs--and-metadata + if len(bucketName) > 63 { + bucketName = bucketName[:63] + } + bucketName = strings.TrimRight(bucketName, "-") + bucketURL := fmt.Sprintf("https://%s.blob.core.windows.net/%s", b.Requirements.Velero.ServiceAccount, bucketName) + err := b.EnsureBucketIsCreated(bucketURL) + if err != nil { + return bucketURL, errors.Wrapf(err, "failed to create bucket %s", bucketURL) + } + + return bucketURL, nil +} + +// EnsureBucketIsCreated ensures the bucket URL is created +func (b *AKSBucketProvider) EnsureBucketIsCreated(bucketURL string) error { + + exists, err := b.AzureStorage.ContainerExists(bucketURL) + if err != nil { + return errors.Wrap(err, "checking if the provided container exists") + } + if exists { + return nil + } + + log.Logger().Infof("The bucket %s does not exist so lets create it", util.ColorInfo(bucketURL)) + err = b.AzureStorage.CreateContainer(bucketURL) + if err != nil { + return errors.Wrapf(err, "there was a problem creating the bucket with URL %s", + bucketURL) + } + return nil +} + +// UploadFileToBucket is yet to be implemented for this provider +func (b *AKSBucketProvider) UploadFileToBucket(r io.Reader, outputName string, bucketURL string) (string, error) { + return "", nil +} + +// DownloadFileFromBucket is yet to be implemented for this provider +func (b *AKSBucketProvider) DownloadFileFromBucket(bucketURL string) (io.ReadCloser, error) { + return nil, nil +} + +// NewAKSBucketProvider create a new provider for AKS +func NewAKSBucketProvider(requirements *config.RequirementsConfig) buckets.Provider { + return &AKSBucketProvider{ + Requirements: requirements, + AzureStorage: aks.NewAzureRunner(), + } +} diff --git a/pkg/cloud/aks/storage/bucket_provider_test.go b/pkg/cloud/aks/storage/bucket_provider_test.go new file mode 100644 index 00000000000..483a321b4ba --- /dev/null +++ b/pkg/cloud/aks/storage/bucket_provider_test.go @@ -0,0 +1,50 @@ +// +build unit + +package storage + +import ( + "strings" + "testing" + + aks_test "github.com/jenkins-x/jx/v2/pkg/cloud/aks/mocks" + "github.com/jenkins-x/jx/v2/pkg/config" + "github.com/petergtz/pegomock" + "github.com/stretchr/testify/assert" +) + +func TestCreateNewBucketForClusterWithLongClusterNameAndDashAtCharacterSixty(t *testing.T) { + p := AKSBucketProvider{ + AzureStorage: aks_test.NewMockAzureStorage(), + Requirements: &config.RequirementsConfig{ + Velero: config.VeleroConfig{ + ServiceAccount: "teststorage", + }, + }, + } + + pegomock.When(p.AzureStorage.ContainerExists(pegomock.AnyString())).ThenReturn(true, nil) + + bucketName, err := p.CreateNewBucketForCluster("rrehhhhhhhhhhhhhhhhhhhhhhhhhj3j3k2kwkdkjdbiwabduwabduoawbdb-dbwdbwaoud", "logs") + assert.NoError(t, err) + assert.NotNil(t, bucketName, "it should always generate a name") + assert.False(t, strings.HasSuffix(bucketName, "-"), "the bucket can't end with a dash") + assert.Equal(t, "https://teststorage.blob.core.windows.net/rrehhhhhhhhhhhhhhhhhhhhhhhhhj3j3k2kwkdkjdbiwabduwabduoawbdb-dbw", bucketName) +} + +func TestCreateNewBucketForClusterWithSmallClusterName(t *testing.T) { + p := AKSBucketProvider{ + AzureStorage: aks_test.NewMockAzureStorage(), + Requirements: &config.RequirementsConfig{ + Velero: config.VeleroConfig{ + ServiceAccount: "teststorage", + }, + }, + } + + pegomock.When(p.AzureStorage.ContainerExists(pegomock.AnyString())).ThenReturn(true, nil) + + bucketName, err := p.CreateNewBucketForCluster("cluster", "logs") + assert.NoError(t, err) + assert.NotNil(t, bucketName, "it should always generate a name") + assert.False(t, strings.HasSuffix(bucketName, "-"), "the bucket can't end with a dash") +} diff --git a/pkg/cloud/factory/factory.go b/pkg/cloud/factory/factory.go index fbe865a533c..e343568bae6 100644 --- a/pkg/cloud/factory/factory.go +++ b/pkg/cloud/factory/factory.go @@ -4,6 +4,7 @@ import ( v1 "github.com/jenkins-x/jx-api/pkg/apis/jenkins.io/v1" "github.com/jenkins-x/jx-logging/pkg/log" "github.com/jenkins-x/jx/v2/pkg/cloud" + aksStorage "github.com/jenkins-x/jx/v2/pkg/cloud/aks/storage" amazonStorage "github.com/jenkins-x/jx/v2/pkg/cloud/amazon/storage" "github.com/jenkins-x/jx/v2/pkg/cloud/buckets" "github.com/jenkins-x/jx/v2/pkg/cloud/gke/storage" @@ -23,6 +24,8 @@ func NewBucketProvider(requirements *config.RequirementsConfig) buckets.Provider fallthrough case cloud.AWS: return amazonStorage.NewAmazonBucketProvider(requirements) + case cloud.AKS: + return aksStorage.NewAKSBucketProvider(requirements) default: // we have an implementation for GKE / EKS but not for AKS so we should fall back to default // but we don't have every func implemented diff --git a/pkg/config/install_requirements.go b/pkg/config/install_requirements.go index 72af4f5b108..d58c565cac6 100644 --- a/pkg/config/install_requirements.go +++ b/pkg/config/install_requirements.go @@ -489,6 +489,10 @@ type VeleroConfig struct { Schedule string `json:"schedule" envconfig:"JX_REQUIREMENT_VELERO_SCHEDULE"` // TimeToLive period for backups to be retained TimeToLive string `json:"ttl" envconfig:"JX_REQUIREMENT_VELERO_TTL"` + // ResourceGroup for Velero Bucket + ResourceGroup string `json:"resourceGroup"` + // BucketName for Velero Bucket + BucketName string `json:"bucketName"` } // AutoUpdateConfig contains auto update config