diff --git a/docs/resources/application.md b/docs/resources/application.md index 041a6f70..32f8c33a 100644 --- a/docs/resources/application.md +++ b/docs/resources/application.md @@ -25,6 +25,10 @@ resource "juju_application" "this" { series = "trusty" } + resources = { + gosherve-image = "gatici/gosherve:1.0" + } + units = 3 placement = "0,1,2" @@ -55,17 +59,14 @@ resource "juju_application" "this" { - `expose` (Block List) Makes an application publicly available over the network (see [below for nested schema](#nestedblock--expose)) - `name` (String) A custom name for the application deployment. If empty, uses the charm's name. - `placement` (String) Specify the target location for the application's units -- `resources` (Map of Number) Charm resource revisions. Must evaluate to an integer. - - There are a few scenarios that need to be considered: - * If the plan does not specify resource revision and resources are added to the plan, - resources with specified revisions will be attached to the application (equivalent - to juju attach-resource). - * If the plan does specify resource revisions and: - * If the charm revision or channel is updated, then resources get updated to the - latest revision. - * If the charm revision or channel are not updated, then no changes will take - place (juju does not have an "un-attach" command for resources). +- `resources` (Map of String) Charm resources. Must evaluate to a string. A resource could be a resource revision number from CharmHub or a custom OCI image resource. +Specify a resource other than the default for a charm. Note that not all charms have resources. + +Notes: +* A resource can be specified by a revision number or by URL to a OCI image repository. Resources of type 'file' can only be specified by revision number. Resources of type 'oci-image' can be specified by revision number or URL. +* A resource can be added or changed at any time. If the charm has resources and None is specified in the plan, Juju will use the resource defined in the charm's specified channel. +* If a charm is refreshed, by changing the charm revision or channel and if the resource is specified by a revision in the plan, Juju will use the resource defined in the plan. +* Resources specified by URL to an OCI image repository will never be refreshed (upgraded) by juju during a charm refresh unless explicitly changed in the plan. - `storage` (Attributes Set) Storage used by the application. (see [below for nested schema](#nestedatt--storage)) - `storage_directives` (Map of String) Storage directives (constraints) for the juju application. The map key is the label of the storage defined by the charm, the map value is the storage directive in the form ,,. Changing an existing key/value pair will cause the application to be replaced. Adding a new key/value pair will add storage to the application on upgrade. - `trust` (Boolean) Set the trust for the application. diff --git a/examples/resources/juju_application/resource.tf b/examples/resources/juju_application/resource.tf index 3144cff4..8dee92a6 100644 --- a/examples/resources/juju_application/resource.tf +++ b/examples/resources/juju_application/resource.tf @@ -10,6 +10,10 @@ resource "juju_application" "this" { series = "trusty" } + resources = { + gosherve-image = "gatici/gosherve:1.0" + } + units = 3 placement = "0,1,2" diff --git a/internal/juju/applications.go b/internal/juju/applications.go index b01e01f2..d06ea2d1 100644 --- a/internal/juju/applications.go +++ b/internal/juju/applications.go @@ -34,6 +34,7 @@ import ( apispaces "github.com/juju/juju/api/client/spaces" apicommoncharm "github.com/juju/juju/api/common/charm" "github.com/juju/juju/cmd/juju/application/utils" + resourcecmd "github.com/juju/juju/cmd/juju/resource" corebase "github.com/juju/juju/core/base" "github.com/juju/juju/core/constraints" "github.com/juju/juju/core/instance" @@ -110,7 +111,7 @@ func newApplicationClient(sc SharedClient) *applicationsClient { } } -// ConfigEntry is an auxiliar struct to keep information about +// ConfigEntry is an auxiliary struct to keep information about // juju application config entries. Specially, we want to know // if they have the default value. type ConfigEntry struct { @@ -162,14 +163,14 @@ type CreateApplicationInput struct { Placement string Constraints constraints.Value EndpointBindings map[string]string - Resources map[string]int + Resources map[string]string StorageConstraints map[string]jujustorage.Constraints } // validateAndTransform returns transformedCreateApplicationInput which // validated and in the proper format for both the new and legacy deployment // methods. Select input is not transformed due to differences in the -// 2 deployement methods, such as config. +// 2 deployment methods, such as config. func (input CreateApplicationInput) validateAndTransform(conn api.Connection) (parsed transformedCreateApplicationInput, err error) { parsed.charmChannel = input.CharmChannel parsed.charmName = input.CharmName @@ -265,7 +266,7 @@ type transformedCreateApplicationInput struct { units int trust bool endpointBindings map[string]string - resources map[string]int + resources map[string]string storage map[string]jujustorage.Constraints } @@ -293,7 +294,7 @@ type ReadApplicationResponse struct { Placement string EndpointBindings map[string]string Storage map[string]jujustorage.Constraints - Resources map[string]int + Resources map[string]string } type UpdateApplicationInput struct { @@ -313,7 +314,7 @@ type UpdateApplicationInput struct { Constraints *constraints.Value EndpointBindings map[string]string StorageConstraints map[string]jujustorage.Constraints - Resources map[string]int + Resources map[string]string } type DestroyApplicationInput struct { @@ -347,8 +348,15 @@ func (c applicationsClient) CreateApplication(ctx context.Context, input *Create } applicationAPIClient := apiapplication.NewClient(conn) + resourceAPIClient, err := apiresources.NewClient(conn) + if err != nil { + return nil, err + } if applicationAPIClient.BestAPIVersion() >= 19 { - err = c.deployFromRepository(applicationAPIClient, transformedInput) + err := c.deployFromRepository(applicationAPIClient, resourceAPIClient, transformedInput) + if err != nil { + return nil, err + } } else { err = c.legacyDeploy(ctx, conn, applicationAPIClient, transformedInput) err = jujuerrors.Annotate(err, "legacy deploy method") @@ -366,20 +374,14 @@ func (c applicationsClient) CreateApplication(ctx context.Context, input *Create }, err } -func (c applicationsClient) deployFromRepository(applicationAPIClient *apiapplication.Client, transformedInput transformedCreateApplicationInput) error { +func (c applicationsClient) deployFromRepository(applicationAPIClient ApplicationAPIClient, resourceAPIClient ResourceAPIClient, transformedInput transformedCreateApplicationInput) error { settingsForYaml := map[interface{}]interface{}{transformedInput.applicationName: transformedInput.config} configYaml, err := goyaml.Marshal(settingsForYaml) if err != nil { return jujuerrors.Trace(err) } - - resources := make(map[string]string) - for k, v := range transformedInput.resources { - resources[k] = strconv.Itoa(v) - } - c.Tracef("Calling DeployFromRepository") - _, _, errs := applicationAPIClient.DeployFromRepository(apiapplication.DeployFromRepositoryArg{ + deployInfo, localPendingResources, errs := applicationAPIClient.DeployFromRepository(apiapplication.DeployFromRepositoryArg{ CharmName: transformedInput.charmName, ApplicationName: transformedInput.applicationName, Base: &transformedInput.charmBase, @@ -391,14 +393,26 @@ func (c applicationsClient) deployFromRepository(applicationAPIClient *apiapplic Placement: transformedInput.placement, Revision: &transformedInput.charmRevision, Trust: transformedInput.trust, - Resources: resources, + Resources: transformedInput.resources, Storage: transformedInput.storage, }) - return errors.Join(errs...) + + if len(errs) != 0 { + return errors.Join(errs...) + } + + fileSystem := osFilesystem{} + // Upload the provided local resources to Juju + uploadErr := uploadExistingPendingResources(deployInfo.Name, localPendingResources, fileSystem, resourceAPIClient) + + if uploadErr != nil { + return uploadErr + } + return nil } // TODO (hml) 23-Feb-2024 -// Remove the funcationality associated with legacyDeploy +// Remove the functionality associated with legacyDeploy // once the provider no longer supports a version of juju // before 3.3. func (c applicationsClient) legacyDeploy(ctx context.Context, conn api.Connection, applicationAPIClient *apiapplication.Client, transformedInput transformedCreateApplicationInput) error { @@ -669,9 +683,9 @@ func (c applicationsClient) baseToUse(modelconfigAPIClient *apimodelconfig.Clien return supportedBases[0], nil } -// processExpose is a local function that executes an expose request. +// processExpose is a local function that executes an exposed request. // If the exposeConfig argument is nil it simply exits. If not, -// an expose request is done populating the request arguments with +// an exposed request is done populating the request arguments with // the endpoints, spaces, and cidrs contained in the exposeConfig // map. func (c applicationsClient) processExpose(applicationAPIClient ApplicationAPIClient, applicationName string, expose map[string]interface{}) error { @@ -732,14 +746,14 @@ func splitCommaDelimitedList(list string) []string { // processResources is a helper function to process the charm // metadata and request the download of any additional resource. -func (c applicationsClient) processResources(charmsAPIClient *apicharms.Client, conn api.Connection, charmID apiapplication.CharmID, appName string, resources map[string]int) (map[string]string, error) { +func (c applicationsClient) processResources(charmsAPIClient *apicharms.Client, conn api.Connection, charmID apiapplication.CharmID, appName string, resourcesToUse map[string]string) (map[string]string, error) { charmInfo, err := charmsAPIClient.CharmInfo(charmID.URL) if err != nil { return nil, typedError(err) } // check if we have resources to request - if len(charmInfo.Meta.Resources) == 0 && len(resources) == 0 { + if len(charmInfo.Meta.Resources) == 0 && len(resourcesToUse) == 0 { return nil, nil } @@ -748,7 +762,7 @@ func (c applicationsClient) processResources(charmsAPIClient *apicharms.Client, return nil, err } - return addPendingResources(appName, charmInfo.Meta.Resources, resources, charmID, resourcesAPIClient) + return addPendingResources(appName, charmInfo.Meta.Resources, resourcesToUse, charmID, resourcesAPIClient) } // ReadApplicationWithRetryOnNotFound calls ReadApplication until @@ -1072,10 +1086,10 @@ func (c applicationsClient) ReadApplication(input *ReadApplicationInput) (*ReadA if err != nil { return nil, jujuerrors.Annotate(err, "failed to list application resources") } - resourceRevisions := make(map[string]int) + usedResources := make(map[string]string) for _, iResources := range resources { for _, resource := range iResources.Resources { - resourceRevisions[resource.Name] = resource.Revision + usedResources[resource.Name] = strconv.Itoa(resource.Revision) } } @@ -1094,7 +1108,7 @@ func (c applicationsClient) ReadApplication(input *ReadApplicationInput) (*ReadA Placement: placement, EndpointBindings: endpointBindings, Storage: storages, - Resources: resourceRevisions, + Resources: usedResources, } return response, nil @@ -1402,7 +1416,7 @@ func (c applicationsClient) computeSetCharmConfig( } func resolveCharm(charmsAPIClient *apicharms.Client, curl *charm.URL, origin apicommoncharm.Origin) (*charm.URL, apicommoncharm.Origin, []corebase.Base, error) { - // Charm or bundle has been supplied as a URL so we resolve and + // Charm or bundle has been supplied as a URL, so we resolve and // deploy using the store but pass in the origin command line // argument so users can target a specific origin. resolved, err := charmsAPIClient.ResolveCharms([]apicharms.CharmToResolve{{URL: curl, Origin: origin}}) @@ -1420,27 +1434,18 @@ func strPtr(in string) *string { return &in } -func (c applicationsClient) updateResources(appName string, resources map[string]int, charmsAPIClient *apicharms.Client, +func (c applicationsClient) updateResources(appName string, resources map[string]string, charmsAPIClient *apicharms.Client, charmID apiapplication.CharmID, resourcesAPIClient ResourceAPIClient) (map[string]string, error) { meta, err := utils.GetMetaResources(charmID.URL, charmsAPIClient) if err != nil { return nil, err } - - resourceRevisions := make(map[string]string) - for k, v := range resources { - resourceRevisions[k] = strconv.Itoa(v) - } - - // TODO (cderici): Provided resources for GetUpgradeResources are user inputs. - // It's a map[string]string that should come from the plan itself. We currently - // don't have a resources block in the charm. filtered, err := utils.GetUpgradeResources( charmID, charmsAPIClient, resourcesAPIClient, appName, - resourceRevisions, // nil + resources, meta, ) if err != nil { @@ -1453,45 +1458,86 @@ func (c applicationsClient) updateResources(appName string, resources map[string return addPendingResources(appName, filtered, resources, charmID, resourcesAPIClient) } -func addPendingResources(appName string, resourcesToBeAdded map[string]charmresources.Meta, resourceRevisions map[string]int, - charmID apiapplication.CharmID, resourcesAPIClient ResourceAPIClient) (map[string]string, error) { - pendingResources := []charmresources.Resource{} - for _, v := range resourcesToBeAdded { - aux := charmresources.Resource{ - Meta: v, - Origin: charmresources.OriginStore, - Revision: -1, +func addPendingResources(appName string, charmResourcesToAdd map[string]charmresources.Meta, resourcesToUse map[string]string, + charmID apiapplication.CharmID, resourceAPIClient ResourceAPIClient) (map[string]string, error) { + pendingResourcesforAdd := []charmresources.Resource{} + resourceIDs := map[string]string{} + + for _, resourceMeta := range charmResourcesToAdd { + if resourcesToUse == nil { + // If there are no resource revisions, the Charm is deployed with + // default resources according to channel. + resourceFromCharmhub := charmresources.Resource{ + Meta: resourceMeta, + Origin: charmresources.OriginStore, + Revision: -1, + } + pendingResourcesforAdd = append(pendingResourcesforAdd, resourceFromCharmhub) + continue } - if resourceRevisions != nil { - if revision, ok := resourceRevisions[v.Name]; ok { - aux.Revision = revision + + deployValue, ok := resourcesToUse[resourceMeta.Name] + if !ok { + continue + } + if providedRev, err := strconv.Atoi(deployValue); err == nil { + // A resource revision is provided + resourceFromCharmhub := charmresources.Resource{ + Meta: resourceMeta, + Origin: charmresources.OriginStore, + // If the resource is removed, providedRev is -1. Then, Charm + // is deployed with default resources according to channel. + // Otherwise, Charm is deployed with the provided revision. + Revision: providedRev, } + pendingResourcesforAdd = append(pendingResourcesforAdd, resourceFromCharmhub) + continue } - pendingResources = append(pendingResources, aux) + // A new resource to be uploaded by the ResourceApi client. + localResource := charmresources.Resource{ + Meta: resourceMeta, + Origin: charmresources.OriginUpload, + } + fileSystem := osFilesystem{} + t, typeParseErr := charmresources.ParseType(resourceMeta.Type.String()) + if typeParseErr != nil { + return nil, typedError(typeParseErr) + } + r, openResErr := resourcecmd.OpenResource(deployValue, t, fileSystem.Open) + if openResErr != nil { + return nil, typedError(openResErr) + } + toRequestUpload, err := resourceAPIClient.UploadPendingResource(appName, localResource, deployValue, r) + if err != nil { + return nil, typedError(err) + } + // Add the resource name and the corresponding UUID to the resources map. + resourceIDs[resourceMeta.Name] = toRequestUpload } - resourcesReq := apiresources.AddPendingResourcesArgs{ + if len(pendingResourcesforAdd) == 0 { + return resourceIDs, nil + } + + resourcesReqforAdd := apiresources.AddPendingResourcesArgs{ ApplicationID: appName, CharmID: apiresources.CharmID{ URL: charmID.URL, Origin: charmID.Origin, }, - Resources: pendingResources, + Resources: pendingResourcesforAdd, } - - toRequest, err := resourcesAPIClient.AddPendingResources(resourcesReq) + toRequestAdd, err := resourceAPIClient.AddPendingResources(resourcesReqforAdd) if err != nil { return nil, typedError(err) } - - // now build a map with the resource name and the corresponding UUID - toReturn := map[string]string{} - for i, argsResource := range pendingResources { - toReturn[argsResource.Meta.Name] = toRequest[i] + // Add the resource name and the corresponding UUID to the resources map + for i, argsResource := range pendingResourcesforAdd { + resourceIDs[argsResource.Meta.Name] = toRequestAdd[i] } - return toReturn, nil + return resourceIDs, nil } func computeUpdatedBindings(modelDefaultSpace string, currentBindings map[string]string, inputBindings map[string]string, appName string) (params.ApplicationMergeBindingsArgs, error) { diff --git a/internal/juju/applications_test.go b/internal/juju/applications_test.go index 0235cc8f..df51a294 100644 --- a/internal/juju/applications_test.go +++ b/internal/juju/applications_test.go @@ -9,8 +9,12 @@ import ( "fmt" "testing" + charmresources "github.com/juju/charm/v12/resource" "github.com/juju/juju/api" "github.com/juju/juju/api/base" + apiapplication "github.com/juju/juju/api/client/application" + apicharm "github.com/juju/juju/api/common/charm" + corebase "github.com/juju/juju/core/base" "github.com/juju/juju/core/constraints" "github.com/juju/juju/core/model" "github.com/juju/juju/core/resources" @@ -383,6 +387,313 @@ func (s *ApplicationSuite) TestReadApplicationRetryNotFoundStorageNotFoundError( s.Assert().Equal("ubuntu@22.04", resp.Base) } +// TestAddPendingResourceCustomImageResourceProvidedCharmResourcesToAddExistsUploadPendingResourceCalled +// tests the case where charm has one image resources and one custom resource is provided. +// ResourceAPIClient.UploadPendingResource are is called but ResourceAPIClient.AddPendingResource is not called +// One resource ID is returned in the resource list. +func (s *ApplicationSuite) TestAddPendingResourceCustomImageResourceProvidedCharmResourcesToAddExistsUploadPendingResourceCalled() { + defer s.setupMocks(s.T()).Finish() + s.mockSharedClient.EXPECT().ModelType(gomock.Any()).Return(model.IAAS, nil).AnyTimes() + + appName := "testapplication" + deployValue := "ausf-image" + path := "testrepo/udm/1:4" + meta := charmresources.Meta{ + Name: deployValue, + Type: charmresources.TypeContainerImage, + Path: path, + } + ausfResourceID := "1111222" + charmResourcesToAdd := make(map[string]charmresources.Meta) + charmResourcesToAdd["ausf-image"] = meta + resourcesToUse := make(map[string]string) + resourcesToUse["ausf-image"] = "gatici/sdcore-ausf:1.4" + revision := 433 + track := "1.5" + url := "ch:amd64/jammy/sdcore-ausf-k8s-433" + charmOrigin := apicharm.Origin{ + Source: "charm-hub", + ID: "3V9Af7N3QcR4WdGiyF0fvZuJUSF7oMYe", + Hash: "e7b3ff9d328738861b701cd61ea7dd3670e74f5419c3f48c4ac67b10b307b888", + Risk: "edge", + Revision: &revision, + Track: &track, + Architecture: "amd64", + Base: corebase.Base{ + OS: "ubuntu", + Channel: corebase.Channel{ + Track: "22.04", + Risk: "stable", + }, + }, + InstanceKey: "_JsD_6xYr5kYP-gBz6wJ6lt6N1L-zslpIkXAUS-bu4w", + } + charmID := apiapplication.CharmID{ + URL: url, + Origin: charmOrigin, + } + + aExp := s.mockResourceAPIClient.EXPECT() + expectedResourceIDs := map[string]string{"ausf-image": ausfResourceID} + + aExp.UploadPendingResource(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(ausfResourceID, nil) + + resourceIDs, err := addPendingResources(appName, charmResourcesToAdd, resourcesToUse, charmID, s.mockResourceAPIClient) + s.Assert().Equal(resourceIDs, expectedResourceIDs, "Resource IDs does not match.") + s.Assert().Equal(nil, err, "Error is not expected.") +} + +// TestAddPendingResourceCustomImageResourceProvidedNoCharmResourcesToAddEmptyResourceListReturned +// tests the case where charm do not have image resources and one custom resource is provided. +// ResourceAPIClient.AddPendingResource and ResourceAPIClient.UploadPendingResource are not called +// Empty resource list is returned. +func (s *ApplicationSuite) TestAddPendingResourceCustomImageResourceProvidedNoCharmResourcesToAddEmptyResourceListReturned() { + defer s.setupMocks(s.T()).Finish() + s.mockSharedClient.EXPECT().ModelType(gomock.Any()).Return(model.IAAS, nil).AnyTimes() + + appName := "testapplication" + charmResourcesToAdd := make(map[string]charmresources.Meta) + resourcesToUse := make(map[string]string) + resourcesToUse["ausf-image"] = "gatici/sdcore-ausf:1.4" + revision := 433 + track := "1.5" + url := "ch:amd64/jammy/sdcore-ausf-k8s-433" + charmOrigin := apicharm.Origin{ + Source: "charm-hub", + ID: "3V9Af7N3QcR4WdGiyF0fvZuJUSF7oMYe", + Hash: "e7b3ff9d328738861b701cd61ea7dd3670e74f5419c3f48c4ac67b10b307b888", + Risk: "edge", + Revision: &revision, + Track: &track, + Architecture: "amd64", + Base: corebase.Base{ + OS: "ubuntu", + Channel: corebase.Channel{ + Track: "22.04", + Risk: "stable", + }, + }, + InstanceKey: "_JsD_6xYr5kYP-gBz6wJ6lt6N1L-zslpIkXAUS-bu4w", + } + + charmID := apiapplication.CharmID{ + URL: url, + Origin: charmOrigin, + } + + expectedResourceIDs := map[string]string{} + resourceIDs, err := addPendingResources(appName, charmResourcesToAdd, resourcesToUse, charmID, s.mockResourceAPIClient) + s.Assert().Equal(resourceIDs, expectedResourceIDs, "Resource IDs does not match.") + s.Assert().Equal(nil, err, "Error is not expected.") +} + +// TestAddPendingResourceOneCustomResourceOneRevisionProvidedMultipleCharmResourcesToAddUploadPendingResourceAndAddPendingResourceCalled +// tests the case where charm has multiple image resources and one revision number and one custom resource is provided for different charm resources. +// ResourceAPIClient.AddPendingResource and ResourceAPIClient.UploadPendingResource is called. +func (s *ApplicationSuite) TestAddPendingResourceOneCustomResourceOneRevisionProvidedMultipleCharmResourcesToAddUploadPendingResourceAndAddPendingResourceCalled() { + defer s.setupMocks(s.T()).Finish() + s.mockSharedClient.EXPECT().ModelType(gomock.Any()).Return(model.IAAS, nil).AnyTimes() + + appName := "testapplication" + ausfDeployValue := "ausf-image" + udmDeployValue := "udm-image" + pathAusf := "testrepo/ausf/1:4" + pathUdm := "testrepo/udm/1:4" + metaAusf := charmresources.Meta{ + Name: ausfDeployValue, + Type: charmresources.TypeContainerImage, + Path: pathAusf, + } + metaUdm := charmresources.Meta{ + Name: udmDeployValue, + Type: charmresources.TypeContainerImage, + Path: pathUdm, + } + ausfResourceID := "1111222" + udmResourceID := "1111444" + charmResourcesToAdd := make(map[string]charmresources.Meta) + charmResourcesToAdd["ausf-image"] = metaUdm + charmResourcesToAdd["udm-image"] = metaAusf + resourcesToUse := make(map[string]string) + resourcesToUse["ausf-image"] = "gatici/sdcore-ausf:1.4" + resourcesToUse["udm-image"] = "3" + revision := 433 + track := "1.5" + url := "ch:amd64/jammy/sdcore-ausf-k8s-433" + charmOrigin := apicharm.Origin{ + Source: "charm-hub", + ID: "3V9Af7N3QcR4WdGiyF0fvZuJUSF7oMYe", + Hash: "e7b3ff9d328738861b701cd61ea7dd3670e74f5419c3f48c4ac67b10b307b888", + Risk: "edge", + Revision: &revision, + Track: &track, + Architecture: "amd64", + Base: corebase.Base{ + OS: "ubuntu", + Channel: corebase.Channel{ + Track: "22.04", + Risk: "stable", + }, + }, + InstanceKey: "_JsD_6xYr5kYP-gBz6wJ6lt6N1L-zslpIkXAUS-bu4w", + } + charmID := apiapplication.CharmID{ + URL: url, + Origin: charmOrigin, + } + + aExp := s.mockResourceAPIClient.EXPECT() + expectedResourceIDs := map[string]string{"ausf-image": ausfResourceID, "udm-image": udmResourceID} + + aExp.UploadPendingResource(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(ausfResourceID, nil) + aExp.AddPendingResources(gomock.Any()).Return([]string{"1111444"}, nil) + + resourceIDs, err := addPendingResources(appName, charmResourcesToAdd, resourcesToUse, charmID, s.mockResourceAPIClient) + s.Assert().Equal(resourceIDs, expectedResourceIDs, "Resource IDs does not match.") + s.Assert().Equal(nil, err, "Error is not expected.") +} + +// TestAddPendingResourceOneRevisionProvidedMultipleCharmResourcesToAddOnlyAddPendingResourceCalled +// tests the case where charm has multiple image resources and revision number is provided for one resource. +// Only ResourceAPIClient.AddPendingResource called, ResourceAPIClient.UploadPendingResource is not called. +func (s *ApplicationSuite) TestAddPendingResourceOneRevisionProvidedMultipleCharmResourcesToAddOnlyAddPendingResourceCalled() { + defer s.setupMocks(s.T()).Finish() + s.mockSharedClient.EXPECT().ModelType(gomock.Any()).Return(model.IAAS, nil).AnyTimes() + + appName := "testapplication" + ausfDeployValue := "ausf-image" + udmDeployValue := "udm-image" + pathAusf := "testrepo/ausf/1:4" + pathUdm := "testrepo/udm/1:4" + metaAusf := charmresources.Meta{ + Name: ausfDeployValue, + Type: charmresources.TypeContainerImage, + Path: pathAusf, + } + metaUdm := charmresources.Meta{ + Name: udmDeployValue, + Type: charmresources.TypeContainerImage, + Path: pathUdm, + } + udmResourceID := "1111444" + charmResourcesToAdd := make(map[string]charmresources.Meta) + charmResourcesToAdd["ausf-image"] = metaUdm + charmResourcesToAdd["udm-image"] = metaAusf + resourcesToUse := make(map[string]string) + resourcesToUse["udm-image"] = "3" + revision := 433 + track := "1.5" + url := "ch:amd64/jammy/sdcore-ausf-k8s-433" + charmOrigin := apicharm.Origin{ + Source: "charm-hub", + ID: "3V9Af7N3QcR4WdGiyF0fvZuJUSF7oMYe", + Hash: "e7b3ff9d328738861b701cd61ea7dd3670e74f5419c3f48c4ac67b10b307b888", + Risk: "edge", + Revision: &revision, + Track: &track, + Architecture: "amd64", + Base: corebase.Base{ + OS: "ubuntu", + Channel: corebase.Channel{ + Track: "22.04", + Risk: "stable", + }, + }, + InstanceKey: "_JsD_6xYr5kYP-gBz6wJ6lt6N1L-zslpIkXAUS-bu4w", + } + charmID := apiapplication.CharmID{ + URL: url, + Origin: charmOrigin, + } + + aExp := s.mockResourceAPIClient.EXPECT() + expectedResourceIDs := map[string]string{"udm-image": udmResourceID} + aExp.AddPendingResources(gomock.Any()).Return([]string{udmResourceID}, nil) + + resourceIDs, err := addPendingResources(appName, charmResourcesToAdd, resourcesToUse, charmID, s.mockResourceAPIClient) + s.Assert().Equal(resourceIDs, expectedResourceIDs, "Resource IDs does not match.") + s.Assert().Equal(nil, err, "Error is not expected.") +} + +// TestUploadExistingPendingResourcesUploadSuccessful tests the case where ResourceAPIClient.Upload is successful. +// Error is not returned. +func (s *ApplicationSuite) TestUploadExistingPendingResourcesUploadSuccessful() { + defer s.setupMocks(s.T()).Finish() + s.mockSharedClient.EXPECT().ModelType(gomock.Any()).Return(model.IAAS, nil).AnyTimes() + appName := "testapplication" + resource := apiapplication.PendingResourceUpload{ + Name: "custom-image", + Filename: "myimage", + Type: "oci-image", + } + var pendingResources []apiapplication.PendingResourceUpload + pendingResources = append(pendingResources, resource) + fileSystem := osFilesystem{} + aExp := s.mockResourceAPIClient.EXPECT() + aExp.Upload(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil) + + err := uploadExistingPendingResources(appName, pendingResources, fileSystem, s.mockResourceAPIClient) + s.Assert().Equal(nil, err, "Error is not expected.") +} + +// TestUploadExistingPendingResourcesUploadFailedReturnError tests the case where ResourceAPIClient.Upload failed. +// Returns error that upload failed for provided file name. +func (s *ApplicationSuite) TestUploadExistingPendingResourcesUploadFailedReturnError() { + defer s.setupMocks(s.T()).Finish() + s.mockSharedClient.EXPECT().ModelType(gomock.Any()).Return(model.IAAS, nil).AnyTimes() + appName := "testapplication" + fileName := "my-image" + resource := apiapplication.PendingResourceUpload{ + Name: "custom-image", + Filename: fileName, + Type: "oci-image", + } + var pendingResources []apiapplication.PendingResourceUpload + pendingResources = append(pendingResources, resource) + fileSystem := osFilesystem{} + aExp := s.mockResourceAPIClient.EXPECT() + aExp.Upload(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(fmt.Errorf("upload failed for %s", fileName)) + + err := uploadExistingPendingResources(appName, pendingResources, fileSystem, s.mockResourceAPIClient) + s.Assert().Equal("upload failed for my-image", err.Error(), "Error is expected.") +} + +// TestUploadExistingPendingResourcesResourceTypeUnknownReturnError tests the case where resource type is unknown. +// ResourceAPIClient.Upload is not called and returns error that resource type is invalid. +func (s *ApplicationSuite) TestUploadExistingPendingResourcesResourceTypeUnknownReturnError() { + defer s.setupMocks(s.T()).Finish() + s.mockSharedClient.EXPECT().ModelType(gomock.Any()).Return(model.IAAS, nil).AnyTimes() + appName := "testapplication" + var pendingResources []apiapplication.PendingResourceUpload + resource := apiapplication.PendingResourceUpload{ + Name: "custom-image", + Filename: "my-image", + Type: "unknown", + } + pendingResources = append(pendingResources, resource) + fileSystem := osFilesystem{} + err := uploadExistingPendingResources(appName, pendingResources, fileSystem, s.mockResourceAPIClient) + s.Assert().Equal("invalid type unknown for pending resource custom-image: unsupported resource type \"unknown\"", err.Error(), "Error is expected.") +} + +// TestUploadExistingPendingResourcesInvalidFileNameReturnError tests the case where file path is not valid. +// ResourceAPIClient.Upload is not called and returns error that unable to open resource. +func (s *ApplicationSuite) TestUploadExistingPendingResourcesInvalidFileNameReturnError() { + defer s.setupMocks(s.T()).Finish() + s.mockSharedClient.EXPECT().ModelType(gomock.Any()).Return(model.IAAS, nil).AnyTimes() + appName := "testapplication" + var pendingResources []apiapplication.PendingResourceUpload + resource := apiapplication.PendingResourceUpload{ + Name: "custom-image", + Filename: "", + Type: "oci-image", + } + pendingResources = append(pendingResources, resource) + fileSystem := osFilesystem{} + err := uploadExistingPendingResources(appName, pendingResources, fileSystem, s.mockResourceAPIClient) + s.Assert().Equal("unable to open resource custom-image: filepath or registry path: not valid", err.Error(), "Error is expected.") +} + // 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 TestApplicationSuite(t *testing.T) { diff --git a/internal/juju/interfaces.go b/internal/juju/interfaces.go index 7f07ddce..1447f4d3 100644 --- a/internal/juju/interfaces.go +++ b/internal/juju/interfaces.go @@ -4,7 +4,10 @@ package juju import ( + "io" + "github.com/juju/charm/v12" + charmresources "github.com/juju/charm/v12/resource" "github.com/juju/juju/api" apiapplication "github.com/juju/juju/api/client/application" apiclient "github.com/juju/juju/api/client/client" @@ -43,6 +46,7 @@ type ApplicationAPIClient interface { ApplicationsInfo(applications []names.ApplicationTag) ([]params.ApplicationInfoResult, error) Deploy(args apiapplication.DeployArgs) error DestroyUnits(in apiapplication.DestroyUnitsParams) ([]params.DestroyUnitResult, error) + DeployFromRepository(arg apiapplication.DeployFromRepositoryArg) (apiapplication.DeployInfo, []apiapplication.PendingResourceUpload, []error) DestroyApplications(in apiapplication.DestroyApplicationsParams) ([]params.DestroyApplicationResult, error) Expose(application string, exposedEndpoints map[string]params.ExposedEndpoint) error Get(branchName, application string) (*params.ApplicationGetResults, error) @@ -63,6 +67,8 @@ type ModelConfigAPIClient interface { type ResourceAPIClient interface { AddPendingResources(args apiresources.AddPendingResourcesArgs) ([]string, error) ListResources(applications []string) ([]resources.ApplicationResources, error) + Upload(application, name, filename, pendingID string, reader io.ReadSeeker) error + UploadPendingResource(applicationID string, resource charmresources.Resource, filename string, r io.ReadSeeker) (id string, err error) } type SecretAPIClient interface { diff --git a/internal/juju/mock_test.go b/internal/juju/mock_test.go index 506c13ac..e521534a 100644 --- a/internal/juju/mock_test.go +++ b/internal/juju/mock_test.go @@ -10,9 +10,11 @@ package juju import ( + io "io" reflect "reflect" charm "github.com/juju/charm/v12" + resource "github.com/juju/charm/v12/resource" api "github.com/juju/juju/api" application "github.com/juju/juju/api/client/application" client "github.com/juju/juju/api/client/client" @@ -302,6 +304,22 @@ func (mr *MockApplicationAPIClientMockRecorder) Deploy(arg0 any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Deploy", reflect.TypeOf((*MockApplicationAPIClient)(nil).Deploy), arg0) } +// DeployFromRepository mocks base method. +func (m *MockApplicationAPIClient) DeployFromRepository(arg0 application.DeployFromRepositoryArg) (application.DeployInfo, []application.PendingResourceUpload, []error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeployFromRepository", arg0) + ret0, _ := ret[0].(application.DeployInfo) + ret1, _ := ret[1].([]application.PendingResourceUpload) + ret2, _ := ret[2].([]error) + return ret0, ret1, ret2 +} + +// DeployFromRepository indicates an expected call of DeployFromRepository. +func (mr *MockApplicationAPIClientMockRecorder) DeployFromRepository(arg0 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeployFromRepository", reflect.TypeOf((*MockApplicationAPIClient)(nil).DeployFromRepository), arg0) +} + // DestroyApplications mocks base method. func (m *MockApplicationAPIClient) DestroyApplications(arg0 application.DestroyApplicationsParams) ([]params.DestroyApplicationResult, error) { m.ctrl.T.Helper() @@ -572,6 +590,35 @@ func (mr *MockResourceAPIClientMockRecorder) ListResources(arg0 any) *gomock.Cal return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListResources", reflect.TypeOf((*MockResourceAPIClient)(nil).ListResources), arg0) } +// Upload mocks base method. +func (m *MockResourceAPIClient) Upload(arg0, arg1, arg2, arg3 string, arg4 io.ReadSeeker) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Upload", arg0, arg1, arg2, arg3, arg4) + ret0, _ := ret[0].(error) + return ret0 +} + +// Upload indicates an expected call of Upload. +func (mr *MockResourceAPIClientMockRecorder) Upload(arg0, arg1, arg2, arg3, arg4 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Upload", reflect.TypeOf((*MockResourceAPIClient)(nil).Upload), arg0, arg1, arg2, arg3, arg4) +} + +// UploadPendingResource mocks base method. +func (m *MockResourceAPIClient) UploadPendingResource(arg0 string, arg1 resource.Resource, arg2 string, arg3 io.ReadSeeker) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UploadPendingResource", arg0, arg1, arg2, arg3) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UploadPendingResource indicates an expected call of UploadPendingResource. +func (mr *MockResourceAPIClientMockRecorder) UploadPendingResource(arg0, arg1, arg2, arg3 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UploadPendingResource", reflect.TypeOf((*MockResourceAPIClient)(nil).UploadPendingResource), arg0, arg1, arg2, arg3) +} + // MockSecretAPIClient is a mock of SecretAPIClient interface. type MockSecretAPIClient struct { ctrl *gomock.Controller diff --git a/internal/juju/resources.go b/internal/juju/resources.go new file mode 100644 index 00000000..dc8eaaa5 --- /dev/null +++ b/internal/juju/resources.go @@ -0,0 +1,68 @@ +// Copyright 2024 Canonical Ltd. +// Licensed under the Apache License, Version 2.0, see LICENCE file for details. + +package juju + +import ( + "os" + + charmresources "github.com/juju/charm/v12/resource" + jujuerrors "github.com/juju/errors" + apiapplication "github.com/juju/juju/api/client/application" + resourcecmd "github.com/juju/juju/cmd/juju/resource" + "github.com/juju/juju/cmd/modelcmd" +) + +type osFilesystem struct{} + +func (osFilesystem) Create(name string) (*os.File, error) { + return os.Create(name) +} + +func (osFilesystem) RemoveAll(path string) error { + return os.RemoveAll(path) +} + +func (osFilesystem) Open(name string) (modelcmd.ReadSeekCloser, error) { + return os.Open(name) +} + +func (osFilesystem) OpenFile(name string, flag int, perm os.FileMode) (*os.File, error) { + return os.OpenFile(name, flag, perm) +} + +func (osFilesystem) Stat(name string) (os.FileInfo, error) { + return os.Stat(name) +} + +// UploadExistingPendingResources uploads local resources. Used +// after DeployFromRepository, where the resources have been added +// to the controller. +func uploadExistingPendingResources( + appName string, + pendingResources []apiapplication.PendingResourceUpload, + filesystem modelcmd.Filesystem, + resourceAPIClient ResourceAPIClient) error { + if pendingResources == nil { + return nil + } + + for _, pendingResUpload := range pendingResources { + t, typeParseErr := charmresources.ParseType(pendingResUpload.Type) + if typeParseErr != nil { + return jujuerrors.Annotatef(typeParseErr, "invalid type %v for pending resource %v", + pendingResUpload.Type, pendingResUpload.Name) + } + + r, openResErr := resourcecmd.OpenResource(pendingResUpload.Filename, t, filesystem.Open) + if openResErr != nil { + return jujuerrors.Annotatef(openResErr, "unable to open resource %v", pendingResUpload.Name) + } + uploadErr := resourceAPIClient.Upload(appName, pendingResUpload.Name, pendingResUpload.Filename, "", r) + + if uploadErr != nil { + return jujuerrors.Trace(uploadErr) + } + } + return nil +} diff --git a/internal/provider/main_test.go b/internal/provider/main_test.go index f0410cd4..fe93b498 100644 --- a/internal/provider/main_test.go +++ b/internal/provider/main_test.go @@ -37,7 +37,7 @@ func (ct CloudTesting) String() string { // CloudName returns the cloud name as displayed // when using `juju list-clouds`. For example, -// a controller can be bootstrapped with an lxd type. +// a controller can be bootstrapped with an LXD type. // However, that's the controller type, the cloud name // would be localhost func (ct CloudTesting) CloudName() string { diff --git a/internal/provider/resource_application.go b/internal/provider/resource_application.go index cf885ad0..936cd459 100644 --- a/internal/provider/resource_application.go +++ b/internal/provider/resource_application.go @@ -46,17 +46,14 @@ const ( StorageKey = "storage" resourceKeyMarkdownDescription = ` -Charm resource revisions. Must evaluate to an integer. - - There are a few scenarios that need to be considered: - * If the plan does not specify resource revision and resources are added to the plan, - resources with specified revisions will be attached to the application (equivalent - to juju attach-resource). - * If the plan does specify resource revisions and: - * If the charm revision or channel is updated, then resources get updated to the - latest revision. - * If the charm revision or channel are not updated, then no changes will take - place (juju does not have an "un-attach" command for resources). +Charm resources. Must evaluate to a string. A resource could be a resource revision number from CharmHub or a custom OCI image resource. +Specify a resource other than the default for a charm. Note that not all charms have resources. + +Notes: +* A resource can be specified by a revision number or by URL to a OCI image repository. Resources of type 'file' can only be specified by revision number. Resources of type 'oci-image' can be specified by revision number or URL. +* A resource can be added or changed at any time. If the charm has resources and None is specified in the plan, Juju will use the resource defined in the charm's specified channel. +* If a charm is refreshed, by changing the charm revision or channel and if the resource is specified by a revision in the plan, Juju will use the resource defined in the plan. +* Resources specified by URL to an OCI image repository will never be refreshed (upgraded) by juju during a charm refresh unless explicitly changed in the plan. ` ) @@ -260,8 +257,11 @@ func (r *applicationResource) Schema(_ context.Context, _ resource.SchemaRequest }, }, ResourceKey: schema.MapAttribute{ - Optional: true, - ElementType: types.Int64Type, + Optional: true, + ElementType: types.StringType, + Validators: []validator.Map{ + StringIsResourceKeyValidator{}, + }, MarkdownDescription: resourceKeyMarkdownDescription, }, }, @@ -470,7 +470,7 @@ func (r *applicationResource) Create(ctx context.Context, req resource.CreateReq return } - resourceRevisions := make(map[string]int) + resourceRevisions := make(map[string]string) resp.Diagnostics.Append(plan.Resources.ElementsAs(ctx, &resourceRevisions, false)...) if resp.Diagnostics.HasError() { return @@ -795,7 +795,7 @@ func (r *applicationResource) configureConfigData(ctx context.Context, configTyp // Add if the value has changed from the previous state if previousValue, found := previousConfig[k]; found { if !juju.EqualConfigEntries(v, previousValue) { - // remember that this terraform schema type only accepts strings + // remember that this Terraform schema type only accepts strings previousConfig[k] = v.String() changes = true } @@ -827,15 +827,15 @@ func (r *applicationResource) toEndpointBindingsSet(ctx context.Context, endpoin return types.SetValueFrom(ctx, endpointBindingsType, endpointBindingsSlice) } -func (r *applicationResource) configureResourceData(ctx context.Context, resourceType attr.Type, resources types.Map, respResources map[string]int) (types.Map, diag.Diagnostics) { - var previousResources map[string]int +func (r *applicationResource) configureResourceData(ctx context.Context, resourceType attr.Type, resources types.Map, respResources map[string]string) (types.Map, diag.Diagnostics) { + var previousResources map[string]string diagErr := resources.ElementsAs(ctx, &previousResources, false) if diagErr.HasError() { r.trace("configureResourceData exit A") return types.Map{}, diagErr } if previousResources == nil { - previousResources = make(map[string]int) + previousResources = make(map[string]string) } // known previously // update the values from the previous config @@ -844,7 +844,7 @@ func (r *applicationResource) configureResourceData(ctx context.Context, resourc // Add if the value has changed from the previous state if previousValue, found := previousResources[k]; found { if v != previousValue { - // remember that this terraform schema type only accepts strings + // remember that this Terraform schema type only accepts strings previousResources[k] = v changes = true } @@ -912,7 +912,7 @@ func (r *applicationResource) Update(ctx context.Context, req resource.UpdateReq } if !planCharm.Series.Equal(stateCharm.Series) || !planCharm.Base.Equal(stateCharm.Base) { - // This violates terraform's declarative model. We could implement + // This violates Terraform's declarative model. We could implement // `juju set-application-base`, usually used after `upgrade-machine`, // which would change the operating system used for future units of // the application provided the charm supported it, but not change @@ -958,15 +958,15 @@ func (r *applicationResource) Update(ctx context.Context, req resource.UpdateReq // if resources in the plan are equal to resources stored in the state, // we pass on the resources specified in the plan, which tells the provider - // NOT to update resources, because we want revisions fixed to those + // NOT to update resources, because we want resources fixed to those // specified in the plan. if plan.Resources.Equal(state.Resources) { - planResourceMap := make(map[string]int) + planResourceMap := make(map[string]string) resp.Diagnostics.Append(plan.Resources.ElementsAs(ctx, &planResourceMap, false)...) updateApplicationInput.Resources = planResourceMap } else { - planResourceMap := make(map[string]int) - stateResourceMap := make(map[string]int) + planResourceMap := make(map[string]string) + stateResourceMap := make(map[string]string) resp.Diagnostics.Append(plan.Resources.ElementsAs(ctx, &planResourceMap, false)...) resp.Diagnostics.Append(state.Resources.ElementsAs(ctx, &stateResourceMap, false)...) if resp.Diagnostics.HasError() { @@ -974,16 +974,28 @@ func (r *applicationResource) Update(ctx context.Context, req resource.UpdateReq } // what happens when the plan suddenly does not specify resource - // revisions, but state does.. + // revisions, but state does. for k, v := range planResourceMap { if stateResourceMap[k] != v { if updateApplicationInput.Resources == nil { // initialize just in case - updateApplicationInput.Resources = make(map[string]int) + updateApplicationInput.Resources = make(map[string]string) } updateApplicationInput.Resources[k] = v } } + // Resources are removed + // Then, the resources get updated to the latest resource revision according to channel + if len(planResourceMap) == 0 && len(stateResourceMap) != 0 { + for k := range stateResourceMap { + if updateApplicationInput.Resources == nil { + // initialize the resources + updateApplicationInput.Resources = make(map[string]string) + // Set resource revision to zero gets the latest resource revision from CharmHub + updateApplicationInput.Resources[k] = "-1" + } + } + } } if !plan.Constraints.Equal(state.Constraints) { @@ -1116,8 +1128,7 @@ func (r *applicationResource) updateStorage( func (r *applicationResource) computeExposeDeltas(ctx context.Context, stateExpose types.List, planExpose types.List) (map[string]interface{}, []string, diag.Diagnostics) { diags := diag.Diagnostics{} if planExpose.IsNull() { - // if plan is nil we unexpose everything via - // an non empty list. + // if plan is nil we unexpose everything via a non-empty list. return nil, []string{""}, diags } if stateExpose.IsNull() { diff --git a/internal/provider/resource_application_test.go b/internal/provider/resource_application_test.go index 93066573..0c555054 100644 --- a/internal/provider/resource_application_test.go +++ b/internal/provider/resource_application_test.go @@ -172,7 +172,7 @@ func TestAcc_ResourceApplication_UpdatesRevisionConfig(t *testing.T) { ProtoV6ProviderFactories: frameworkProviderFactories, Steps: []resource.TestStep{ { - Config: testAccResourceApplicationWithRevisionAndConfig(modelName, appName, 88, "", "", -1), + Config: testAccResourceApplicationWithRevisionAndConfig(modelName, appName, 88, "", "", ""), Check: resource.ComposeTestCheckFunc( resource.TestCheckResourceAttr("juju_application."+appName, "model", modelName), resource.TestCheckResourceAttr("juju_application."+appName, "charm.#", "1"), @@ -181,7 +181,7 @@ func TestAcc_ResourceApplication_UpdatesRevisionConfig(t *testing.T) { ), }, { - Config: testAccResourceApplicationWithRevisionAndConfig(modelName, appName, 96, configParamName, "", -1), + Config: testAccResourceApplicationWithRevisionAndConfig(modelName, appName, 96, configParamName, "", ""), Check: resource.ComposeTestCheckFunc( resource.TestCheckResourceAttr("juju_application."+appName, "charm.0.revision", "96"), resource.TestCheckResourceAttr("juju_application."+appName, "config."+configParamName, configParamName+"-value"), @@ -233,21 +233,21 @@ func TestAcc_ResourceRevisionUpdatesLXD(t *testing.T) { ProtoV6ProviderFactories: frameworkProviderFactories, Steps: []resource.TestStep{ { - Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "juju-qa-test", 21, "", "foo-file", 4), + Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "juju-qa-test", 21, "", "foo-file", "4"), Check: resource.ComposeTestCheckFunc( resource.TestCheckResourceAttr("juju_application.juju-qa-test", "resources.foo-file", "4"), ), }, { // change resource revision to 3 - Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "juju-qa-test", 21, "", "foo-file", 3), + Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "juju-qa-test", 21, "", "foo-file", "3"), Check: resource.ComposeTestCheckFunc( resource.TestCheckResourceAttr("juju_application.juju-qa-test", "resources.foo-file", "3"), ), }, { // change back to 4 - Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "juju-qa-test", 21, "", "foo-file", 4), + Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "juju-qa-test", 21, "", "foo-file", "4"), Check: resource.ComposeTestCheckFunc( resource.TestCheckResourceAttr("juju_application.juju-qa-test", "resources.foo-file", "4"), ), @@ -267,13 +267,13 @@ func TestAcc_ResourceRevisionAddedToPlanLXD(t *testing.T) { ProtoV6ProviderFactories: frameworkProviderFactories, Steps: []resource.TestStep{ { - Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "juju-qa-test", 20, "", "", -1), + Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "juju-qa-test", 20, "", "", ""), Check: resource.ComposeTestCheckFunc( resource.TestCheckNoResourceAttr("juju_application.juju-qa-test", "resources"), ), }, { - Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "juju-qa-test", 21, "", "foo-file", 4), + Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "juju-qa-test", 21, "", "foo-file", "4"), Check: resource.ComposeTestCheckFunc( resource.TestCheckResourceAttr("juju_application.juju-qa-test", "resources.foo-file", "4"), ), @@ -294,14 +294,14 @@ func TestAcc_ResourceRevisionRemovedFromPlanLXD(t *testing.T) { Steps: []resource.TestStep{ { // we specify the resource revision 4 - Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "juju-qa-test", 20, "", "foo-file", 4), + Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "juju-qa-test", 20, "", "foo-file", "4"), Check: resource.ComposeTestCheckFunc( resource.TestCheckResourceAttr("juju_application.juju-qa-test", "resources.foo-file", "4"), ), }, { // then remove the resource revision and update the charm revision - Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "juju-qa-test", 21, "", "", -1), + Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "juju-qa-test", 21, "", "", ""), Check: resource.ComposeTestCheckFunc( resource.TestCheckNoResourceAttr("juju_application.juju-qa-test", "resources"), ), @@ -321,21 +321,21 @@ func TestAcc_ResourceRevisionUpdatesMicrok8s(t *testing.T) { ProtoV6ProviderFactories: frameworkProviderFactories, Steps: []resource.TestStep{ { - Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "postgresql-k8s", 20, "", "postgresql-image", 152), + Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "postgresql-k8s", 20, "", "postgresql-image", "152"), Check: resource.ComposeTestCheckFunc( resource.TestCheckResourceAttr("juju_application.postgresql-k8s", "resources.postgresql-image", "152"), ), }, { // change resource revision to 151 - Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "postgresql-k8s", 20, "", "postgresql-image", 151), + Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "postgresql-k8s", 20, "", "postgresql-image", "151"), Check: resource.ComposeTestCheckFunc( resource.TestCheckResourceAttr("juju_application.postgresql-k8s", "resources.postgresql-image", "151"), ), }, { // change back to 152 - Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "postgresql-k8s", 20, "", "postgresql-image", 152), + Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "postgresql-k8s", 20, "", "postgresql-image", "152"), Check: resource.ComposeTestCheckFunc( resource.TestCheckResourceAttr("juju_application.postgresql-k8s", "resources.postgresql-image", "152"), ), @@ -344,6 +344,173 @@ func TestAcc_ResourceRevisionUpdatesMicrok8s(t *testing.T) { }) } +func TestAcc_CustomResourcesAddedToPlanMicrok8s(t *testing.T) { + if testingCloud != MicroK8sTesting { + t.Skip(t.Name() + " only runs with Microk8s") + } + agentVersion := os.Getenv(TestJujuAgentVersion) + if agentVersion == "" { + t.Skipf("%s is not set", TestJujuAgentVersion) + } else if internaltesting.CompareVersions(agentVersion, "3.0.3") < 0 { + t.Skipf("%s is not set or is below 3.0.3", TestJujuAgentVersion) + } + modelName := acctest.RandomWithPrefix("tf-test-custom-resource-updates-microk8s") + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: frameworkProviderFactories, + Steps: []resource.TestStep{ + { + // deploy charm without custom resource + Config: testAccResourceApplicationWithoutCustomResources(modelName, "1.0/stable"), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckNoResourceAttr("juju_application.this", "resources"), + ), + }, + { + // Add a custom resource + Config: testAccResourceApplicationWithCustomResources(modelName, "1.0/stable", "grafana-image", "gatici/grafana:10"), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("juju_application.this", "resources.grafana-image", "gatici/grafana:10"), + ), + ExpectNonEmptyPlan: true, + }, + { + // Add another custom resource + Config: testAccResourceApplicationWithCustomResources(modelName, "1.0/stable", "grafana-image", "gatici/grafana:9"), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("juju_application.this", "resources.grafana-image", "gatici/grafana:9"), + ), + ExpectNonEmptyPlan: true, + }, + { + // Add resource revision + Config: testAccResourceApplicationWithCustomResources(modelName, "1.0/stable", "grafana-image", "61"), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("juju_application.this", "resources.grafana-image", "61"), + ), + }, + { + // Remove resource revision + Config: testAccResourceApplicationWithoutCustomResources(modelName, "1.0/stable"), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckNoResourceAttr("juju_application.this", "resources"), + ), + }, + }, + }) +} + +func TestAcc_CustomResourceUpdatesMicrok8s(t *testing.T) { + if testingCloud != MicroK8sTesting { + t.Skip(t.Name() + " only runs with Microk8s") + } + agentVersion := os.Getenv(TestJujuAgentVersion) + if agentVersion == "" { + t.Skipf("%s is not set", TestJujuAgentVersion) + } else if internaltesting.CompareVersions(agentVersion, "3.0.3") < 0 { + t.Skipf("%s is not set or is below 3.0.3", TestJujuAgentVersion) + } + modelName := acctest.RandomWithPrefix("tf-test-custom-resource-updates-microk8s") + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: frameworkProviderFactories, + Steps: []resource.TestStep{ + { + // Deploy charm with a custom resource + Config: testAccResourceApplicationWithCustomResources(modelName, "1.0/edge", "grafana-image", "gatici/grafana:9"), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("juju_application.this", "resources.grafana-image", "gatici/grafana:9"), + ), + ExpectNonEmptyPlan: true, + }, + { + // Keep charm channel and update resource to another custom image + Config: testAccResourceApplicationWithCustomResources(modelName, "1.0/edge", "grafana-image", "gatici/grafana:10"), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("juju_application.this", "resources.grafana-image", "gatici/grafana:10"), + ), + ExpectNonEmptyPlan: true, + }, + { + // Update charm channel and update resource to a revision + Config: testAccResourceApplicationWithCustomResources(modelName, "1.0/stable", "grafana-image", "59"), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("juju_application.this", "resources.grafana-image", "59"), + ), + }, + { + // Update charm channel and keep resource revision + Config: testAccResourceApplicationWithCustomResources(modelName, "1.0/beta", "grafana-image", "59"), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("juju_application.this", "resources.grafana-image", "59"), + ), + }, + { + // Keep charm channel and remove resource revision + Config: testAccResourceApplicationWithoutCustomResources(modelName, "1.0/beta"), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckNoResourceAttr("juju_application.this", "resources"), + ), + }, + }, + }) +} + +func TestAcc_CustomResourcesRemovedFromPlanMicrok8s(t *testing.T) { + if testingCloud != MicroK8sTesting { + t.Skip(t.Name() + " only runs with Microk8s") + } + agentVersion := os.Getenv(TestJujuAgentVersion) + if agentVersion == "" { + t.Skipf("%s is not set", TestJujuAgentVersion) + } else if internaltesting.CompareVersions(agentVersion, "3.0.3") < 0 { + t.Skipf("%s is not set or is below 3.0.3", TestJujuAgentVersion) + } + modelName := acctest.RandomWithPrefix("tf-test-custom-resource-updates-microk8s") + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: frameworkProviderFactories, + Steps: []resource.TestStep{ + { + // Deploy charm with a custom resource + Config: testAccResourceApplicationWithCustomResources(modelName, "1.0/edge", "grafana-image", "gatici/grafana:9"), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("juju_application.this", "resources.grafana-image", "gatici/grafana:9"), + ), + ExpectNonEmptyPlan: true, + }, + { + // Keep charm channel and remove custom resource + Config: testAccResourceApplicationWithoutCustomResources(modelName, "1.0/edge"), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckNoResourceAttr("juju_application.this", "resources"), + ), + }, + { + // Keep charm channel and add resource revision + Config: testAccResourceApplicationWithCustomResources(modelName, "1.0/edge", "grafana-image", "60"), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("juju_application.this", "resources.grafana-image", "60"), + ), + }, + { + // Update charm channel and keep resource revision + Config: testAccResourceApplicationWithCustomResources(modelName, "1.0/stable", "grafana-image", "60"), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("juju_application.this", "resources.grafana-image", "60"), + ), + }, + { + // Update charm channel and remove resource revision + Config: testAccResourceApplicationWithoutCustomResources(modelName, "1.0/beta"), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckNoResourceAttr("juju_application.this", "resources"), + ), + }, + }, + }) +} + func TestAcc_ResourceApplication_Minimal(t *testing.T) { modelName := acctest.RandomWithPrefix("tf-test-application") var charmName string @@ -627,7 +794,7 @@ func testAccResourceApplicationBasic(modelName, appName string) string { } } -func testAccResourceApplicationWithRevisionAndConfig(modelName, appName string, revision int, configParamName string, resourceName string, resourceRevision int) string { +func testAccResourceApplicationWithRevisionAndConfig(modelName, appName string, revision int, configParamName string, resourceName string, resourceRevision string) string { return internaltesting.GetStringFromTemplateWithData( "testAccResourceApplicationWithRevisionAndConfig", ` @@ -669,6 +836,53 @@ resource "juju_application" "{{.AppName}}" { }) } +func testAccResourceApplicationWithCustomResources(modelName, channel string, resourceName string, customResource string) string { + return fmt.Sprintf(` +resource "juju_model" "this" { + name = %q +} + +resource "juju_application" "this" { + model = juju_model.this.name + name = "test-app" + charm { + name = "grafana-k8s" + channel = "%s" + } + trust = true + expose{} + resources = { + "%s" = "%s" + } + config = { + juju-external-hostname="myhostname" + } +} +`, modelName, channel, resourceName, customResource) +} + +func testAccResourceApplicationWithoutCustomResources(modelName, channel string) string { + return fmt.Sprintf(` +resource "juju_model" "this" { + name = %q +} + +resource "juju_application" "this" { + model = juju_model.this.name + name = "test-app" + charm { + name = "grafana-k8s" + channel = "%s" + } + trust = true + expose{} + config = { + juju-external-hostname="myhostname" + } +} +`, modelName, channel) +} + func testAccResourceApplicationUpdates(modelName string, units int, expose bool, hostname string) string { exposeStr := "expose{}" if !expose { diff --git a/internal/provider/validator_resourcekey.go b/internal/provider/validator_resourcekey.go new file mode 100644 index 00000000..a4a9fee7 --- /dev/null +++ b/internal/provider/validator_resourcekey.go @@ -0,0 +1,63 @@ +// Copyright 2024 Canonical Ltd. +// Licensed under the Apache License, Version 2.0, see LICENCE file for details. + +package provider + +import ( + "context" + "fmt" + "regexp" + "strconv" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +type StringIsResourceKeyValidator struct{} + +// Description returns a plain text description of the validator's behavior, suitable for a practitioner to understand its impact. +func (v StringIsResourceKeyValidator) Description(context.Context) string { + return "string must conform to a charm resource: a resource revision number from CharmHub or a custom OCI image resource" +} + +// MarkdownDescription returns a markdown formatted description of the validator's behavior, suitable for a practitioner to understand its impact. +func (v StringIsResourceKeyValidator) MarkdownDescription(context.Context) string { + return resourceKeyMarkdownDescription +} + +// ValidateMap Validate runs the main validation logic of the validator, reading configuration data out of `req` and updating `resp` with diagnostics. +func (v StringIsResourceKeyValidator) ValidateMap(ctx context.Context, req validator.MapRequest, resp *validator.MapResponse) { + // If the value is unknown or null, there is nothing to validate. + if req.ConfigValue.IsUnknown() || req.ConfigValue.IsNull() { + return + } + + var resourceKey map[string]string + resp.Diagnostics.Append(req.ConfigValue.ElementsAs(ctx, &resourceKey, false)...) + if resp.Diagnostics.HasError() { + return + } + for name, value := range resourceKey { + providedRev, err := strconv.Atoi(value) + if err != nil { + imageUrlPattern := `(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]):[\w][\w.-]{0,127}` + urlRegex := regexp.MustCompile(imageUrlPattern) + if urlRegex.MatchString(value) { + continue + } + resp.Diagnostics.AddAttributeError( + req.Path, + "Invalid resource value", + fmt.Sprintf("value of %q should be a valid revision number or image URL.", name), + ) + continue + } + if providedRev <= 0 { + resp.Diagnostics.AddAttributeError( + req.Path, + "Invalid resource value", + fmt.Sprintf("value of %q should be a valid revision number or image URL.", name), + ) + continue + } + } +} diff --git a/internal/provider/validator_resourcekey_test.go b/internal/provider/validator_resourcekey_test.go new file mode 100644 index 00000000..102ef2ae --- /dev/null +++ b/internal/provider/validator_resourcekey_test.go @@ -0,0 +1,67 @@ +// Copyright 2024 Canonical Ltd. +// Licensed under the Apache License, Version 2.0, see LICENCE file for details. + +package provider_test + +import ( + "context" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/juju/terraform-provider-juju/internal/provider" +) + +func TestResourceKeyValidatorValid(t *testing.T) { + validResources := make(map[string]string) + validResources["image1"] = "image/tag:v1.0.0" + validResources["image2"] = "123.123.123.123:123/image/tag:v1.0.0" + validResources["image3"] = "your-domain.com/image/tag:v1.1.1-patch1" + validResources["image4"] = "your_domain/image/tag:patch1" + validResources["image5"] = "your.domain.com/image/tag:1" + validResources["image6"] = "27" + validResources["image7"] = "1" + ctx := context.Background() + + resourceValidator := provider.StringIsResourceKeyValidator{} + resourceValue, _ := types.MapValueFrom(ctx, types.StringType, validResources) + + req := validator.MapRequest{ + ConfigValue: resourceValue, + } + + var resp validator.MapResponse + resourceValidator.ValidateMap(context.Background(), req, &resp) + + if resp.Diagnostics.HasError() { + t.Errorf("errors %v", resp.Diagnostics.Errors()) + } +} + +func TestResourceKeyValidatorInvalidRevision(t *testing.T) { + validResources := make(map[string]string) + validResources["image1"] = "-10" + validResources["image2"] = "0" + validResources["image3"] = "10.5" + validResources["image4"] = "image/tag:" + validResources["image5"] = ":v1.0.0" + validResources["image6"] = "your-domain.com" + ctx := context.Background() + + resourceValidator := provider.StringIsResourceKeyValidator{} + resourceValue, _ := types.MapValueFrom(ctx, types.StringType, validResources) + + req := validator.MapRequest{ + ConfigValue: resourceValue, + } + + var resp validator.MapResponse + resourceValidator.ValidateMap(context.Background(), req, &resp) + err := "Invalid resource value" + if c := resp.Diagnostics.ErrorsCount(); c != 6 { + t.Errorf("expected 6 errors, got %d", c) + } + if deets := resp.Diagnostics.Errors()[0].Summary(); err != deets { + t.Errorf("expected error %q, got %q", err, deets) + } +}