diff --git a/.github/workflows/test_integration.yml b/.github/workflows/test_integration.yml index aa763f9c..317397b2 100644 --- a/.github/workflows/test_integration.yml +++ b/.github/workflows/test_integration.yml @@ -69,6 +69,15 @@ jobs: channel: ${{ matrix.action-operator.cloud-channel }} juju-channel: ${{ matrix.action-operator.juju }} lxd-channel: ${{ matrix.action-operator.lxd-channel }} + - name: In case of LXD setup also microk8s + if: ${{ matrix.action-operator.cloud == 'lxd' }} + run: | + sudo snap install microk8s --channel=1.28-strict/stable + sudo usermod -a -G snap_microk8s $USER + sudo chown -R $USER ~/.kube + sudo microk8s.enable dns storage + sudo microk8s.enable dns local-storage + sudo -g snap_microk8s -E microk8s status --wait-ready --timeout=600 - name: Create additional networks when testing with LXD if: ${{ matrix.action-operator.cloud == 'lxd' }} run: | @@ -88,6 +97,11 @@ jobs: echo "EOF" >> $GITHUB_ENV echo "TEST_MANAGEMENT_BR=10.150.40.0/24" >> $GITHUB_ENV echo "TEST_PUBLIC_BR=10.170.80.0/24" >> $GITHUB_ENV + - name: "Set additional environment for LXD" + if: ${{ matrix.action-operator.cloud == 'lxd' }} + # language=bash + run: | + sudo microk8s.config > /home/$USER/microk8s-config.yaml - run: go mod download - env: TF_ACC: "1" diff --git a/.github/workflows/test_integration_jaas.yaml b/.github/workflows/test_integration_jaas.yaml index 09baad43..6209005c 100644 --- a/.github/workflows/test_integration_jaas.yaml +++ b/.github/workflows/test_integration_jaas.yaml @@ -63,6 +63,17 @@ jobs: jimm-version: v3.1.10 juju-channel: 3/stable ghcr-pat: ${{ secrets.GITHUB_TOKEN }} + - name: Setup microk8s for juju_kubernetes_cloud test + run: | + sudo snap install microk8s --channel=1.28-strict/stable + sudo usermod -a -G snap_microk8s $USER + sudo chown -R $USER ~/.kube + sudo microk8s.enable dns storage + sudo microk8s.enable dns local-storage + sudo -g snap_microk8s -E microk8s status --wait-ready --timeout=600 + echo "MICROK8S_CONFIG<> $GITHUB_ENV + sudo microk8s.config view >> $GITHUB_ENV + echo "EOF" >> $GITHUB_ENV - name: Create additional networks when testing with LXD run: | sudo lxc network create management-br ipv4.address=10.150.40.1/24 ipv4.nat=true ipv6.address=none ipv6.nat=false diff --git a/docs/resources/kubernetes_cloud.md b/docs/resources/kubernetes_cloud.md new file mode 100644 index 00000000..1ec088c6 --- /dev/null +++ b/docs/resources/kubernetes_cloud.md @@ -0,0 +1,46 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "juju_kubernetes_cloud Resource - terraform-provider-juju" +subcategory: "" +description: |- + A resource that represent a Juju Cloud for existing controller. +--- + +# juju_kubernetes_cloud (Resource) + +A resource that represent a Juju Cloud for existing controller. + +## Example Usage + +```terraform +resource "juju_kubernetes_cloud" "my-k8s-cloud" { + name = "my-k8s-cloud" + kubernetes_config = file(".yaml") +} + +resource "juju_model" "my-model" { + name = "my-model" + credential = juju_kubernetes_cloud.my-k8s-cloud.credential + cloud { + name = juju_kubernetes_cloud.my-k8s-cloud.name + } +} +``` + + +## Schema + +### Required + +- `name` (String) The name of the cloud. Changing this value will cause the cloud to be destroyed and recreated by terraform. + +### Optional + +- `kubernetes_config` (String, Sensitive) The kubernetes config file path for the cloud. Cloud credentials will be added to the Juju controller for you. +- `parent_cloud_name` (String) The parent cloud name in case adding k8s cluster from existed cloud. Changing this value will cause the cloud to be destroyed and recreated by terraform. +- `parent_cloud_region` (String) The parent cloud region name in case adding k8s cluster from existed cloud. Changing this value will cause the cloud to be destroyed and recreated by terraform. + +### Read-Only + +- `credential` (String) The name of the credential created for this cloud. +- `id` (String) The ID of this resource. diff --git a/examples/resources/juju_kubernetes_cloud/resource.tf b/examples/resources/juju_kubernetes_cloud/resource.tf new file mode 100644 index 00000000..cb2479a4 --- /dev/null +++ b/examples/resources/juju_kubernetes_cloud/resource.tf @@ -0,0 +1,12 @@ +resource "juju_kubernetes_cloud" "my-k8s-cloud" { + name = "my-k8s-cloud" + kubernetes_config = file(".yaml") +} + +resource "juju_model" "my-model" { + name = "my-model" + credential = juju_kubernetes_cloud.my-k8s-cloud.credential + cloud { + name = juju_kubernetes_cloud.my-k8s-cloud.name + } +} \ No newline at end of file diff --git a/go.mod b/go.mod index 11add663..3b42818b 100644 --- a/go.mod +++ b/go.mod @@ -35,6 +35,7 @@ require ( gopkg.in/httprequest.v1 v1.2.1 gopkg.in/macaroon.v2 v2.1.0 gopkg.in/yaml.v2 v2.4.0 + k8s.io/client-go v0.29.0 ) require ( @@ -224,7 +225,6 @@ require ( k8s.io/api v0.29.0 // indirect k8s.io/apiextensions-apiserver v0.29.0 // indirect k8s.io/apimachinery v0.29.0 // indirect - k8s.io/client-go v0.29.0 // indirect k8s.io/klog/v2 v2.110.1 // indirect k8s.io/kube-openapi v0.0.0-20231010175941-2dd684a91f00 // indirect k8s.io/utils v0.0.0-20231127182322-b307cd553661 // indirect diff --git a/internal/juju/interfaces.go b/internal/juju/interfaces.go index a5cbcf33..e2cd924d 100644 --- a/internal/juju/interfaces.go +++ b/internal/juju/interfaces.go @@ -15,6 +15,7 @@ import ( apiresources "github.com/juju/juju/api/client/resources" apisecrets "github.com/juju/juju/api/client/secrets" apicommoncharm "github.com/juju/juju/api/common/charm" + jujucloud "github.com/juju/juju/cloud" "github.com/juju/juju/core/constraints" "github.com/juju/juju/core/model" "github.com/juju/juju/core/resources" @@ -94,3 +95,13 @@ type JaasAPIClient interface { RenameGroup(req *jaasparams.RenameGroupRequest) error RemoveGroup(req *jaasparams.RemoveGroupRequest) error } + +// KubernetesCloudAPIClient defines the set of methods that the Kubernetes cloud API provides. +type KubernetesCloudAPIClient interface { + AddCloud(cloud jujucloud.Cloud, force bool) error + Cloud(tag names.CloudTag) (jujucloud.Cloud, error) + UpdateCloud(cloud jujucloud.Cloud) error + RemoveCloud(cloud string) error + AddCredential(cloud string, credential jujucloud.Credential) error + UserCredentials(user names.UserTag, cloud names.CloudTag) ([]names.CloudCredentialTag, error) +} diff --git a/internal/juju/kubernetesClouds.go b/internal/juju/kubernetesClouds.go new file mode 100644 index 00000000..8cb21501 --- /dev/null +++ b/internal/juju/kubernetesClouds.go @@ -0,0 +1,279 @@ +// Copyright 2024 Canonical Ltd. +// Licensed under the Apache License, Version 2.0, see LICENCE file for details. + +package juju + +import ( + "crypto/rand" + "encoding/hex" + "strings" + + jujuclock "github.com/juju/clock" + "github.com/juju/errors" + "github.com/juju/juju/api" + "github.com/juju/juju/api/client/cloud" + k8s "github.com/juju/juju/caas/kubernetes" + "github.com/juju/juju/caas/kubernetes/clientconfig" + k8scloud "github.com/juju/juju/caas/kubernetes/cloud" + "github.com/juju/names/v5" + "k8s.io/client-go/tools/clientcmd" +) + +type kubernetesCloudsClient struct { + SharedClient + + getKubernetesCloudAPIClient func(connection api.Connection) KubernetesCloudAPIClient +} + +type CreateKubernetesCloudInput struct { + Name string + KubernetesContextName string + KubernetesConfig string + ParentCloudName string + ParentCloudRegion string +} + +type ReadKubernetesCloudInput struct { + Name string +} + +type ReadKubernetesCloudOutput struct { + Name string + CredentialName string + ParentCloudName string + ParentCloudRegion string +} + +type UpdateKubernetesCloudInput struct { + Name string + KubernetesContextName string + KubernetesConfig string + ParentCloudName string + ParentCloudRegion string +} + +type DestroyKubernetesCloudInput struct { + Name string +} + +func newKubernetesCloudsClient(sc SharedClient) *kubernetesCloudsClient { + return &kubernetesCloudsClient{ + SharedClient: sc, + getKubernetesCloudAPIClient: func(connection api.Connection) KubernetesCloudAPIClient { + return cloud.NewClient(connection) + }, + } +} + +func getNewCredentialUID() (string, error) { + b := make([]byte, 4) + if _, err := rand.Read(b); err != nil { + return "", errors.Trace(err) + } + return hex.EncodeToString(b), nil +} + +// CreateKubernetesCloud creates a new Kubernetes cloud with juju cloud facade. +func (c *kubernetesCloudsClient) CreateKubernetesCloud(input *CreateKubernetesCloudInput) (string, error) { + conn, err := c.GetConnection(nil) + if err != nil { + return "", err + } + defer func() { _ = conn.Close() }() + + kubernetesAPIClient := c.getKubernetesCloudAPIClient(conn) + + credentialUID, err := getNewCredentialUID() + if err != nil { + return "", errors.Annotate(err, "generating new credential UID") + } + + conf, err := clientcmd.NewClientConfigFromBytes([]byte(input.KubernetesConfig)) + if err != nil { + return "", errors.Annotate(err, "parsing kubernetes configuration data") + } + + k8sConf, err := conf.RawConfig() + if err != nil { + return "", errors.Annotate(err, "fetching kubernetes configuration") + } + + var k8sContextName string + if input.KubernetesContextName == "" { + k8sContextName = k8sConf.CurrentContext + } else { + k8sContextName = input.KubernetesContextName + } + + var hostCloudRegion string + if input.ParentCloudName != "" || input.ParentCloudRegion != "" { + hostCloudRegion = input.ParentCloudName + "/" + input.ParentCloudRegion + } else { + hostCloudRegion = k8s.K8sCloudOther + } + + credResolver := clientconfig.GetJujuAdminServiceAccountResolver(jujuclock.WallClock) + + k8sConfWithCreds, err := credResolver(credentialUID, &k8sConf, k8sContextName) + if err != nil { + return "", errors.Annotate(err, "resolving k8s credential") + } + + newCloud, err := k8scloud.CloudFromKubeConfigContext( + k8sContextName, + k8sConfWithCreds, + k8scloud.CloudParamaters{ + Name: input.Name, + HostCloudRegion: hostCloudRegion, + }, + ) + if err != nil { + return "", errors.Trace(err) + } + + newCredential, err := k8scloud.CredentialFromKubeConfigContext(k8sContextName, k8sConfWithCreds) + if err != nil { + return "", errors.Trace(err) + } + + err = kubernetesAPIClient.AddCloud(newCloud, false) + if err != nil { + return "", errors.Annotate(err, "adding kubernetes cloud") + } + + credentialName := input.Name + cloudName := input.Name + + currentUser := getCurrentJujuUser(conn) + + cloudCredTag, err := GetCloudCredentialTag(cloudName, currentUser, credentialName) + if err != nil { + return "", errors.Annotate(err, "getting cloud credential tag") + } + + err = kubernetesAPIClient.AddCredential(cloudCredTag.String(), newCredential) + if err != nil { + return "", errors.Annotate(err, "adding kubernetes cloud credential") + } + + return credentialName, nil +} + +// ReadKubernetesCloud reads a Kubernetes cloud with juju cloud facade. +func (c *kubernetesCloudsClient) ReadKubernetesCloud(input ReadKubernetesCloudInput) (*ReadKubernetesCloudOutput, error) { + conn, err := c.GetConnection(nil) + if err != nil { + return nil, err + } + defer func() { _ = conn.Close() }() + + kubernetesAPIClient := c.getKubernetesCloudAPIClient(conn) + + cld, err := kubernetesAPIClient.Cloud(names.NewCloudTag(input.Name)) + if err != nil { + return nil, errors.Annotate(err, "getting clouds") + } + + userName := getCurrentJujuUser(conn) + + cloudCredentialTags, err := kubernetesAPIClient.UserCredentials(names.NewUserTag(userName), names.NewCloudTag(input.Name)) + if err != nil { + return nil, errors.Annotate(err, "getting user credentials") + } + if len(cloudCredentialTags) == 0 { + return nil, errors.NotFoundf("cloud credentials for user %q", userName) + } + + credentialName := cloudCredentialTags[0].Name() + + parentCloudName, parentCloudRegion := getParentCloudNameAndRegion(cld.HostCloudRegion) + return &ReadKubernetesCloudOutput{ + Name: input.Name, + CredentialName: credentialName, + ParentCloudName: parentCloudName, + ParentCloudRegion: parentCloudRegion, + }, nil +} + +// getParentCloudNameAndRegion returns the parent cloud name +// and region from the host cloud region. HostCloudRegion represents the k8s +// host cloud. The format is /. +func getParentCloudNameAndRegion(hostCloudRegion string) (string, string) { + parts := strings.Split(hostCloudRegion, "/") + if len(parts) != 2 { + return "", "" + } + return parts[0], parts[1] +} + +// UpdateKubernetesCloud updates a Kubernetes cloud with juju cloud facade. +func (c *kubernetesCloudsClient) UpdateKubernetesCloud(input UpdateKubernetesCloudInput) error { + conn, err := c.GetConnection(nil) + if err != nil { + return err + } + defer func() { _ = conn.Close() }() + + kubernetesAPIClient := c.getKubernetesCloudAPIClient(conn) + + conf, err := clientcmd.NewClientConfigFromBytes([]byte(input.KubernetesConfig)) + if err != nil { + return errors.Annotate(err, "parsing kubernetes configuration data") + } + + apiConf, err := conf.RawConfig() + if err != nil { + return errors.Annotate(err, "fetching kubernetes configuration") + } + + var k8sContextName string + if input.KubernetesContextName == "" { + k8sContextName = apiConf.CurrentContext + } else { + k8sContextName = input.KubernetesContextName + } + + var hostCloudRegion string + if input.ParentCloudName != "" || input.ParentCloudRegion != "" { + hostCloudRegion = input.ParentCloudName + "/" + input.ParentCloudRegion + } else { + hostCloudRegion = k8s.K8sCloudOther + } + + newCloud, err := k8scloud.CloudFromKubeConfigContext( + k8sContextName, + &apiConf, + k8scloud.CloudParamaters{ + Name: input.Name, + HostCloudRegion: hostCloudRegion, + }, + ) + if err != nil { + return errors.Trace(err) + } + + err = kubernetesAPIClient.UpdateCloud(newCloud) + if err != nil { + return errors.Annotate(err, "updating kubernetes cloud") + } + + return nil +} + +// RemoveKubernetesCloud removes a Kubernetes cloud with juju cloud facade. +func (c *kubernetesCloudsClient) RemoveKubernetesCloud(input DestroyKubernetesCloudInput) error { + conn, err := c.GetConnection(nil) + if err != nil { + return err + } + defer func() { _ = conn.Close() }() + + kubernetesAPIClient := c.getKubernetesCloudAPIClient(conn) + + err = kubernetesAPIClient.RemoveCloud(input.Name) + if err != nil { + return errors.Annotate(err, "removing kubernetes cloud") + } + + return nil +} diff --git a/internal/juju/kubernetesClouds_test.go b/internal/juju/kubernetesClouds_test.go new file mode 100644 index 00000000..a00ff9f9 --- /dev/null +++ b/internal/juju/kubernetesClouds_test.go @@ -0,0 +1,137 @@ +// Copyright 2024 Canonical Ltd. +// Licensed under the Apache License, Version 2.0, see LICENCE file for details. + +package juju + +import ( + "testing" + + k8s "github.com/juju/juju/caas/kubernetes" + k8scloud "github.com/juju/juju/caas/kubernetes/cloud" + "github.com/stretchr/testify/suite" + "go.uber.org/mock/gomock" + "k8s.io/client-go/tools/clientcmd" +) + +type KubernetesCloudSuite struct { + suite.Suite + JujuSuite + + mockKubernetesCloudClient *MockKubernetesCloudAPIClient +} + +func (s *KubernetesCloudSuite) SetupSuite() { + s.testModelName = strPtr("test-kubernetes-cloud-model") +} + +func (s *KubernetesCloudSuite) setupMocks(t *testing.T) *gomock.Controller { + ctlr := s.JujuSuite.setupMocks(t) + s.mockKubernetesCloudClient = NewMockKubernetesCloudAPIClient(ctlr) + + return ctlr +} + +func getFakeCloudConfig() string { + return ` +apiVersion: v1 +clusters: +- cluster: + certificate-authority-data: ZmFrZS1jZXJ0aWZpY2F0ZS1hdXRob3JpdHktZGF0YQ== + server: https://10.172.195.202:16443 + name: microk8s-cluster +contexts: +- context: + cluster: microk8s-cluster + user: admin + name: fake-cloud-context +current-context: fake-cloud-context +kind: Config +preferences: {} +users: +- name: admin + user: + client-certificate-data: ZmFrZS1jbGllbnQtY2VydGlmaWNhdGUtZGF0YQ== + client-key-data: ZmFrZS1jbGllbnQta2V5LWRhdGE= +` +} + +func (s *KubernetesCloudSuite) TestCreateKubernetesCloud() { + ctlr := s.setupMocks(s.T()) + defer ctlr.Finish() + + s.mockKubernetesCloudClient.EXPECT().AddCloud(gomock.Any(), gomock.Any()).Return(nil).AnyTimes() + s.mockKubernetesCloudClient.EXPECT().AddCredential(gomock.Any(), gomock.Any()).Return(nil).AnyTimes() + + fakeCloudConfig, err := clientcmd.NewClientConfigFromBytes([]byte(getFakeCloudConfig())) + s.Require().NoError(err) + + fakeApiConfig, err := fakeCloudConfig.RawConfig() + s.Require().NoError(err) + + fakeContextName := "fake-cloud-context" + + fakeCloudRegion := k8s.K8sCloudOther + + fakeCloud, err := k8scloud.CloudFromKubeConfigContext( + fakeContextName, + &fakeApiConfig, + k8scloud.CloudParamaters{ + Name: "fake-cloud", + HostCloudRegion: fakeCloudRegion, + }, + ) + s.Require().NoError(err) + + err = s.mockKubernetesCloudClient.AddCloud(fakeCloud, false) + s.Require().NoError(err) + + fakeCredential, err := k8scloud.CredentialFromKubeConfigContext(fakeContextName, &fakeApiConfig) + s.Require().NoError(err) + + fakeCloudCredTag := "fake-cloud-cred" + + err = s.mockKubernetesCloudClient.AddCredential(fakeCloudCredTag, fakeCredential) + s.Require().NoError(err) +} + +func (s *KubernetesCloudSuite) TestUpdateKubernetesCloud() { + ctlr := s.setupMocks(s.T()) + defer ctlr.Finish() + + s.mockKubernetesCloudClient.EXPECT().UpdateCloud(gomock.Any()).Return(nil).AnyTimes() + + fakeCloudConfig, err := clientcmd.NewClientConfigFromBytes([]byte(getFakeCloudConfig())) + s.Require().NoError(err) + + fakeApiConfig, err := fakeCloudConfig.RawConfig() + s.Require().NoError(err) + + fakeCloud, err := k8scloud.CloudFromKubeConfigContext( + "fake-cloud-context", + &fakeApiConfig, + k8scloud.CloudParamaters{ + Name: "fake-cloud", + HostCloudRegion: k8s.K8sCloudOther, + }, + ) + s.Require().NoError(err) + + err = s.mockKubernetesCloudClient.UpdateCloud(fakeCloud) + s.Require().NoError(err) +} + +func (s *KubernetesCloudSuite) TestRemoveKubernetesCloud() { + ctlr := s.setupMocks(s.T()) + defer ctlr.Finish() + + s.mockKubernetesCloudClient.EXPECT().RemoveCloud(gomock.Any()).Return(nil).AnyTimes() + + err := s.mockKubernetesCloudClient.RemoveCloud("fake-cloud") + s.Require().NoError(err) +} + +// In order for 'go test' to run this suite, we need to create +// a normal test function and pass our suite to suite.Run +func TestKubernetesCloudSuite(t *testing.T) { + suite.Run(t, new(KubernetesCloudSuite)) +} diff --git a/internal/juju/kubernetes_clouds.go b/internal/juju/kubernetes_clouds.go deleted file mode 100644 index 7ceff8d2..00000000 --- a/internal/juju/kubernetes_clouds.go +++ /dev/null @@ -1,52 +0,0 @@ -// Copyright 2024 Canonical Ltd. -// Licensed under the Apache License, Version 2.0, see LICENCE file for details. - -package juju - -type kubernetesCloudsClient struct { - SharedClient -} - -type CreateKubernetesCloudInput struct { -} - -type CreateKubernetesCloudOutput struct { -} - -type ReadKubernetesCloudInput struct { -} - -type ReadKubernetesCloudOutput struct { -} - -type UpdateKubernetesCloudInput struct { -} - -type DestroyKubernetesCloudInput struct { -} - -func newKubernetesCloudsClient(sc SharedClient) *kubernetesCloudsClient { - return &kubernetesCloudsClient{ - SharedClient: sc, - } -} - -// CreateKubernetesCloud creates a new Kubernetes cloud with juju cloud facade. -func (c *kubernetesCloudsClient) CreateKubernetesCloud(input *CreateKubernetesCloudInput) (*CreateKubernetesCloudOutput, error) { - return nil, nil -} - -// ReadKubernetesCloud reads a Kubernetes cloud with juju cloud facade. -func (c *kubernetesCloudsClient) ReadKubernetesCloud(input *ReadKubernetesCloudInput) (*ReadKubernetesCloudOutput, error) { - return nil, nil -} - -// UpdateKubernetesCloud updates a Kubernetes cloud with juju cloud facade. -func (c *kubernetesCloudsClient) UpdateKubernetesCloud(input *UpdateKubernetesCloudInput) error { - return nil -} - -// DestroyKubernetesCloud destroys a Kubernetes cloud with juju cloud facade. -func (c *kubernetesCloudsClient) DestroyKubernetesCloud(input *DestroyKubernetesCloudInput) error { - return nil -} diff --git a/internal/juju/mock_test.go b/internal/juju/mock_test.go index 6e69f6c0..3f02bb4e 100644 --- a/internal/juju/mock_test.go +++ b/internal/juju/mock_test.go @@ -1,9 +1,9 @@ // Code generated by MockGen. DO NOT EDIT. -// Source: github.com/juju/terraform-provider-juju/internal/juju (interfaces: SharedClient,ClientAPIClient,ApplicationAPIClient,ModelConfigAPIClient,ResourceAPIClient,SecretAPIClient,JaasAPIClient) +// Source: github.com/juju/terraform-provider-juju/internal/juju (interfaces: SharedClient,ClientAPIClient,ApplicationAPIClient,ModelConfigAPIClient,ResourceAPIClient,SecretAPIClient,JaasAPIClient,KubernetesCloudAPIClient) // // Generated by this command: // -// mockgen -package juju -destination mock_test.go github.com/juju/terraform-provider-juju/internal/juju SharedClient,ClientAPIClient,ApplicationAPIClient,ModelConfigAPIClient,ResourceAPIClient,SecretAPIClient,JaasAPIClient +// mockgen -package juju -destination mock_test.go github.com/juju/terraform-provider-juju/internal/juju SharedClient,ClientAPIClient,ApplicationAPIClient,ModelConfigAPIClient,ResourceAPIClient,SecretAPIClient,JaasAPIClient,KubernetesCloudAPIClient // // Package juju is a generated GoMock package. @@ -22,6 +22,7 @@ import ( resources "github.com/juju/juju/api/client/resources" secrets "github.com/juju/juju/api/client/secrets" charm0 "github.com/juju/juju/api/common/charm" + cloud "github.com/juju/juju/cloud" constraints "github.com/juju/juju/core/constraints" model "github.com/juju/juju/core/model" resources0 "github.com/juju/juju/core/resources" @@ -854,3 +855,112 @@ func (mr *MockJaasAPIClientMockRecorder) RenameGroup(arg0 any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RenameGroup", reflect.TypeOf((*MockJaasAPIClient)(nil).RenameGroup), arg0) } + +// MockKubernetesCloudAPIClient is a mock of KubernetesCloudAPIClient interface. +type MockKubernetesCloudAPIClient struct { + ctrl *gomock.Controller + recorder *MockKubernetesCloudAPIClientMockRecorder +} + +// MockKubernetesCloudAPIClientMockRecorder is the mock recorder for MockKubernetesCloudAPIClient. +type MockKubernetesCloudAPIClientMockRecorder struct { + mock *MockKubernetesCloudAPIClient +} + +// NewMockKubernetesCloudAPIClient creates a new mock instance. +func NewMockKubernetesCloudAPIClient(ctrl *gomock.Controller) *MockKubernetesCloudAPIClient { + mock := &MockKubernetesCloudAPIClient{ctrl: ctrl} + mock.recorder = &MockKubernetesCloudAPIClientMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockKubernetesCloudAPIClient) EXPECT() *MockKubernetesCloudAPIClientMockRecorder { + return m.recorder +} + +// AddCloud mocks base method. +func (m *MockKubernetesCloudAPIClient) AddCloud(arg0 cloud.Cloud, arg1 bool) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AddCloud", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// AddCloud indicates an expected call of AddCloud. +func (mr *MockKubernetesCloudAPIClientMockRecorder) AddCloud(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddCloud", reflect.TypeOf((*MockKubernetesCloudAPIClient)(nil).AddCloud), arg0, arg1) +} + +// AddCredential mocks base method. +func (m *MockKubernetesCloudAPIClient) AddCredential(arg0 string, arg1 cloud.Credential) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AddCredential", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// AddCredential indicates an expected call of AddCredential. +func (mr *MockKubernetesCloudAPIClientMockRecorder) AddCredential(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddCredential", reflect.TypeOf((*MockKubernetesCloudAPIClient)(nil).AddCredential), arg0, arg1) +} + +// Cloud mocks base method. +func (m *MockKubernetesCloudAPIClient) Cloud(arg0 names.CloudTag) (cloud.Cloud, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Cloud", arg0) + ret0, _ := ret[0].(cloud.Cloud) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Cloud indicates an expected call of Cloud. +func (mr *MockKubernetesCloudAPIClientMockRecorder) Cloud(arg0 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Cloud", reflect.TypeOf((*MockKubernetesCloudAPIClient)(nil).Cloud), arg0) +} + +// RemoveCloud mocks base method. +func (m *MockKubernetesCloudAPIClient) RemoveCloud(arg0 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RemoveCloud", arg0) + ret0, _ := ret[0].(error) + return ret0 +} + +// RemoveCloud indicates an expected call of RemoveCloud. +func (mr *MockKubernetesCloudAPIClientMockRecorder) RemoveCloud(arg0 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemoveCloud", reflect.TypeOf((*MockKubernetesCloudAPIClient)(nil).RemoveCloud), arg0) +} + +// UpdateCloud mocks base method. +func (m *MockKubernetesCloudAPIClient) UpdateCloud(arg0 cloud.Cloud) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateCloud", arg0) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateCloud indicates an expected call of UpdateCloud. +func (mr *MockKubernetesCloudAPIClientMockRecorder) UpdateCloud(arg0 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateCloud", reflect.TypeOf((*MockKubernetesCloudAPIClient)(nil).UpdateCloud), arg0) +} + +// UserCredentials mocks base method. +func (m *MockKubernetesCloudAPIClient) UserCredentials(arg0 names.UserTag, arg1 names.CloudTag) ([]names.CloudCredentialTag, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UserCredentials", arg0, arg1) + ret0, _ := ret[0].([]names.CloudCredentialTag) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UserCredentials indicates an expected call of UserCredentials. +func (mr *MockKubernetesCloudAPIClientMockRecorder) UserCredentials(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UserCredentials", reflect.TypeOf((*MockKubernetesCloudAPIClient)(nil).UserCredentials), arg0, arg1) +} diff --git a/internal/juju/package_test.go b/internal/juju/package_test.go index 4754e0d1..d64520c0 100644 --- a/internal/juju/package_test.go +++ b/internal/juju/package_test.go @@ -3,5 +3,5 @@ package juju_test -//go:generate go run go.uber.org/mock/mockgen -package juju -destination mock_test.go github.com/juju/terraform-provider-juju/internal/juju SharedClient,ClientAPIClient,ApplicationAPIClient,ModelConfigAPIClient,ResourceAPIClient,SecretAPIClient,JaasAPIClient +//go:generate go run go.uber.org/mock/mockgen -package juju -destination mock_test.go github.com/juju/terraform-provider-juju/internal/juju SharedClient,ClientAPIClient,ApplicationAPIClient,ModelConfigAPIClient,ResourceAPIClient,SecretAPIClient,JaasAPIClient,KubernetesCloudAPIClient //go:generate go run go.uber.org/mock/mockgen -package juju -destination jujuapi_mock_test.go github.com/juju/juju/api Connection diff --git a/internal/provider/helpers.go b/internal/provider/helpers.go index 3b6154bb..1da27011 100644 --- a/internal/provider/helpers.go +++ b/internal/provider/helpers.go @@ -21,16 +21,17 @@ const ( LogDataSourceOffer = "datasource-offer" LogDataSourceSecret = "datasource-secret" - LogResourceApplication = "resource-application" - LogResourceAccessModel = "resource-access-model" - LogResourceCredential = "resource-credential" - LogResourceMachine = "resource-machine" - LogResourceModel = "resource-model" - LogResourceOffer = "resource-offer" - LogResourceSSHKey = "resource-sshkey" - LogResourceUser = "resource-user" - LogResourceSecret = "resource-secret" - LogResourceAccessSecret = "resource-access-secret" + LogResourceApplication = "resource-application" + LogResourceAccessModel = "resource-access-model" + LogResourceCredential = "resource-credential" + LogResourceKubernetesCloud = "resource-kubernetes-cloud" + LogResourceMachine = "resource-machine" + LogResourceModel = "resource-model" + LogResourceOffer = "resource-offer" + LogResourceSSHKey = "resource-sshkey" + LogResourceUser = "resource-user" + LogResourceSecret = "resource-secret" + LogResourceAccessSecret = "resource-access-secret" LogResourceJAASAccessModel = "resource-jaas-access-model" LogResourceJAASAccessCloud = "resource-jaas-access-cloud" diff --git a/internal/provider/provider.go b/internal/provider/provider.go index d810fe04..6b0a3d56 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -364,6 +364,7 @@ func (p *jujuProvider) Resources(_ context.Context) []func() resource.Resource { func() resource.Resource { return NewApplicationResource() }, func() resource.Resource { return NewCredentialResource() }, func() resource.Resource { return NewIntegrationResource() }, + func() resource.Resource { return NewKubernetesCloudResource() }, func() resource.Resource { return NewMachineResource() }, func() resource.Resource { return NewModelResource() }, func() resource.Resource { return NewOfferResource() }, diff --git a/internal/provider/resource_kubernetes_cloud.go b/internal/provider/resource_kubernetes_cloud.go index d6bae37d..9cea4edd 100644 --- a/internal/provider/resource_kubernetes_cloud.go +++ b/internal/provider/resource_kubernetes_cloud.go @@ -5,43 +5,68 @@ package provider import ( "context" - "github.com/hashicorp/terraform-plugin-framework/path" - + "fmt" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/juju/terraform-provider-juju/internal/juju" ) // Ensure provider defined types fully satisfy framework interfaces. var _ resource.Resource = &kubernetesCloudResource{} var _ resource.ResourceWithConfigure = &kubernetesCloudResource{} -var _ resource.ResourceWithImportState = &kubernetesCloudResource{} func NewKubernetesCloudResource() resource.Resource { return &kubernetesCloudResource{} } type kubernetesCloudResource struct { - *juju.Client + client *juju.Client // subCtx is the context created with the new tflog subsystem for applications. - context.Context + subCtx context.Context } -func (o *kubernetesCloudResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { +type kubernetesCloudResourceModel struct { + CloudName types.String `tfsdk:"name"` + CloudCredential types.String `tfsdk:"credential"` + KubernetesConfig types.String `tfsdk:"kubernetes_config"` + ParentCloudName types.String `tfsdk:"parent_cloud_name"` + ParentCloudRegion types.String `tfsdk:"parent_cloud_region"` + // ID required by the testing framework + ID types.String `tfsdk:"id"` } -func (o *kubernetesCloudResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { - resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) +// Configure is used to configure the kubernetes cloud resource. +func (r *kubernetesCloudResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(*juju.Client) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *juju.Client, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + return + } + r.client = client + // Create the local logging subsystem here, using the TF context when creating it. + r.subCtx = tflog.NewSubsystem(ctx, LogResourceKubernetesCloud) } -func (o *kubernetesCloudResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { +// Metadata returns the metadata for the kubernetes cloud resource. +func (r *kubernetesCloudResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { resp.TypeName = req.ProviderTypeName + "_kubernetes_cloud" } -func (o *kubernetesCloudResource) Schema(_ context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { +// Schema returns the schema for the kubernetes cloud resource. +func (r *kubernetesCloudResource) Schema(_ context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { resp.Schema = schema.Schema{ Description: "A resource that represent a Juju Cloud for existing controller.", Attributes: map[string]schema.Attribute{ @@ -52,24 +77,25 @@ func (o *kubernetesCloudResource) Schema(_ context.Context, req resource.SchemaR stringplanmodifier.RequiresReplace(), }, }, - "kubeconfig": schema.StringAttribute{ - Description: "The kubeconfig file path for the cloud.", + "credential": schema.StringAttribute{ + Description: "The name of the credential created for this cloud.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "kubernetes_config": schema.StringAttribute{ + Description: "The kubernetes config file path for the cloud. Cloud credentials will be added to the Juju controller for you.", Optional: true, Sensitive: true, }, - "parentcloudname": schema.StringAttribute{ + "parent_cloud_name": schema.StringAttribute{ Description: "The parent cloud name in case adding k8s cluster from existed cloud. Changing this value will cause the cloud to be destroyed and recreated by terraform.", Optional: true, - PlanModifiers: []planmodifier.String{ - stringplanmodifier.RequiresReplace(), - }, }, - "parentcloudregion": schema.StringAttribute{ + "parent_cloud_region": schema.StringAttribute{ Description: "The parent cloud region name in case adding k8s cluster from existed cloud. Changing this value will cause the cloud to be destroyed and recreated by terraform.", Optional: true, - PlanModifiers: []planmodifier.String{ - stringplanmodifier.RequiresReplace(), - }, }, "id": schema.StringAttribute{ Computed: true, @@ -82,17 +108,146 @@ func (o *kubernetesCloudResource) Schema(_ context.Context, req resource.SchemaR } // Create adds a new kubernetes cloud to controllers used now by Terraform provider. -func (o *kubernetesCloudResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { +func (r *kubernetesCloudResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + // Prevent panic if the provider has not been configured. + if r.client == nil { + addClientNotConfiguredError(&resp.Diagnostics, "kubernetes_cloud", "create") + return + } + + var plan kubernetesCloudResourceModel + + // Read Terraform configuration from the request into the model + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + // Create the kubernetes cloud. + cloudCredentialName, err := r.client.Clouds.CreateKubernetesCloud( + &juju.CreateKubernetesCloudInput{ + Name: plan.CloudName.ValueString(), + KubernetesConfig: plan.KubernetesConfig.ValueString(), + }, + ) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create kubernetes cloud, got error %s", err)) + return + } + + plan.CloudCredential = types.StringValue(cloudCredentialName) + plan.ID = types.StringValue(newKubernetesCloudID(plan.CloudName.ValueString(), cloudCredentialName)) + + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) + + r.trace(fmt.Sprintf("Created kubernetes cloud %s", plan.CloudName.ValueString())) } // Read reads the current state of the kubernetes cloud. -func (o *kubernetesCloudResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { +func (r *kubernetesCloudResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + // Prevent panic if the provider has not been configured. + if r.client == nil { + addClientNotConfiguredError(&resp.Diagnostics, "kubernetes_cloud", "read") + return + } + + var state kubernetesCloudResourceModel + + // Read Terraform configuration from the request into the model + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + // Read the kubernetes readKubernetesCloudOutput. + readKubernetesCloudOutput, err := r.client.Clouds.ReadKubernetesCloud( + juju.ReadKubernetesCloudInput{ + Name: state.CloudName.ValueString(), + }, + ) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read kubernetes readKubernetesCloudOutput, got error %s", err)) + return + } + + state.CloudName = types.StringValue(readKubernetesCloudOutput.Name) + state.CloudCredential = types.StringValue(readKubernetesCloudOutput.CredentialName) + state.ID = types.StringValue(newKubernetesCloudID(readKubernetesCloudOutput.Name, readKubernetesCloudOutput.CredentialName)) + + r.trace(fmt.Sprintf("Read kubernetes cloud %s", state.CloudName)) + + // Set the state onto the Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) } // Update updates the kubernetes cloud on the controller used by Terraform provider. -func (o *kubernetesCloudResource) Update(context.Context, resource.UpdateRequest, *resource.UpdateResponse) { +func (r *kubernetesCloudResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + // Prevent panic if the provider has not been configured. + if r.client == nil { + addClientNotConfiguredError(&resp.Diagnostics, "kubernetes_cloud", "update") + return + } + + var plan kubernetesCloudResourceModel + + // Read Terraform configuration from the request into the model + resp.Diagnostics.Append(req.State.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + // Update the kubernetes cloud. + err := r.client.Clouds.UpdateKubernetesCloud( + juju.UpdateKubernetesCloudInput{ + Name: plan.CloudName.ValueString(), + KubernetesConfig: plan.KubernetesConfig.ValueString(), + }, + ) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to update kubernetes cloud, got error %s", err)) + return + } + + r.trace(fmt.Sprintf("Updated kubernetes cloud %s", plan.CloudName.ValueString())) } // Delete removes the kubernetes cloud from the controller used by Terraform provider. -func (o *kubernetesCloudResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { +func (r *kubernetesCloudResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + // Prevent panic if the provider has not been configured. + if r.client == nil { + addClientNotConfiguredError(&resp.Diagnostics, "kubernetes_cloud", "delete") + return + } + + var plan kubernetesCloudResourceModel + + // Read Terraform configuration from the request into the model + resp.Diagnostics.Append(req.State.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + // Remove the kubernetes cloud. + err := r.client.Clouds.RemoveKubernetesCloud( + juju.DestroyKubernetesCloudInput{ + Name: plan.CloudName.ValueString(), + }, + ) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to remove kubernetes cloud, got error %s", err)) + return + } + + r.trace(fmt.Sprintf("Removed kubernetes cloud %s", plan.CloudName.ValueString())) +} + +func (r *kubernetesCloudResource) trace(msg string, additionalFields ...map[string]interface{}) { + if r.subCtx == nil { + return + } + tflog.SubsystemTrace(r.subCtx, LogResourceKubernetesCloud, msg, additionalFields...) +} + +func newKubernetesCloudID(kubernetesCloudName string, cloudCredentialName string) string { + return fmt.Sprintf("%s:%s", kubernetesCloudName, cloudCredentialName) } diff --git a/internal/provider/resource_kubernetes_cloud_test.go b/internal/provider/resource_kubernetes_cloud_test.go new file mode 100644 index 00000000..c9e76f31 --- /dev/null +++ b/internal/provider/resource_kubernetes_cloud_test.go @@ -0,0 +1,65 @@ +// Copyright 2024 Canonical Ltd. +// Licensed under the Apache License, Version 2.0, see LICENCE file for details. + +package provider + +import ( + internaltesting "github.com/juju/terraform-provider-juju/internal/testing" + "os" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" +) + +func TestAcc_ResourceKubernetesCloud(t *testing.T) { + // TODO: Skip this ACC test until we have a way to run correctly with kubernetes_config + // attribute set to a correct k8s config in github action environment + t.Skip(t.Name() + " is skipped until we have a way to run correctly with kubernetes_config attribute set to a correct k8s config in github action environment") + + if testingCloud != LXDCloudTesting { + t.Skip(t.Name() + " only runs with LXD") + } + cloudName := acctest.RandomWithPrefix("tf-test-k8scloud") + modelName := acctest.RandomWithPrefix("tf-test-model") + cloudConfig := os.Getenv("MICROK8S_CONFIG") + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: frameworkProviderFactories, + Steps: []resource.TestStep{ + { + Config: testAccResourceKubernetesCloud(cloudName, modelName, cloudConfig), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("juju_kubernetes_cloud."+cloudName, "name", cloudName), + resource.TestCheckResourceAttr("juju_kubernetes_cloud."+cloudName, "model", modelName), + ), + }, + }, + }) +} + +func testAccResourceKubernetesCloud(cloudName string, modelName string, config string) string { + return internaltesting.GetStringFromTemplateWithData( + "testAccResourceSecret", + ` + + +resource "juju_kubernetes_cloud" "tf-test-k8scloud" { + name = "{{.CloudName}}" + kubernetes_config = file("~/microk8s-config.yaml") +} + +resource "juju_model" {{.ModelName}} { + name = "{{.ModelName}}" + credential = juju_kubernetes_cloud.tf-test-k8scloud.credential + cloud { + name = juju_kubernetes_cloud.tf-test-k8scloud.name + } +} +`, internaltesting.TemplateData{ + "CloudName": cloudName, + "ModelName": modelName, + "Config": config, + }) +}