From 99782f709476ffda2bd30099bdf53d6ad03766b6 Mon Sep 17 00:00:00 2001 From: gatici Date: Wed, 29 May 2024 19:38:54 +0300 Subject: [PATCH 01/27] feat(application): deploy applications with custom resources - This commit defines ResourceHttpClient to upload custom OCI resources to Juju controller. - `addPendingResources` function is modified to upload custom resources. - Application is deployed using custom resources. docs(application): Application markdown file is modified to update `resources` description of application definition in the TF plan. IMPORTANT: Application input resources are changed from map[string]int to resources map[string]string. Signed-off-by: gatici --- docs/resources/application.md | 2 +- internal/juju/applications.go | 325 +++++++++++++++++++--- internal/provider/resource_application.go | 24 +- 3 files changed, 292 insertions(+), 59 deletions(-) diff --git a/docs/resources/application.md b/docs/resources/application.md index 041a6f70..d9d02e57 100644 --- a/docs/resources/application.md +++ b/docs/resources/application.md @@ -55,7 +55,7 @@ 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. +- `resources` (Map of String) Charm resource revisions. Must evaluate to a string. A resource could be a resource revision number or a custom resource. 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, diff --git a/internal/juju/applications.go b/internal/juju/applications.go index b01e01f2..cda3f909 100644 --- a/internal/juju/applications.go +++ b/internal/juju/applications.go @@ -12,12 +12,17 @@ import ( "context" "errors" "fmt" + "io" "math" + "mime" + "net/http" + "os" "reflect" "sort" "strconv" "strings" "time" + "unicode" "github.com/juju/charm/v12" charmresources "github.com/juju/charm/v12/resource" @@ -33,7 +38,10 @@ import ( apiresources "github.com/juju/juju/api/client/resources" apispaces "github.com/juju/juju/api/client/spaces" apicommoncharm "github.com/juju/juju/api/common/charm" + jujuhttp "github.com/juju/juju/api/http" "github.com/juju/juju/cmd/juju/application/utils" + resourcecmd "github.com/juju/juju/cmd/juju/resource" + "github.com/juju/juju/cmd/modelcmd" corebase "github.com/juju/juju/core/base" "github.com/juju/juju/core/constraints" "github.com/juju/juju/core/instance" @@ -49,13 +57,99 @@ import ( goyaml "gopkg.in/yaml.v2" ) -var ApplicationNotFoundError = &applicationNotFoundError{} +const ( + // ContentTypeRaw is the HTTP content-type value used for raw, unformatted content. + ContentTypeRaw = "application/octet-stream" +) +const ( + // MediaTypeFormData is the media type for file uploads (see + // mime.FormatMediaType). + MediaTypeFormData = "form-data" + // QueryParamPendingID is the query parameter we use to send up the pending id. + QueryParamPendingID = "pendingid" +) + +const ( + // HeaderContentType is the header name for the type of a file upload. + HeaderContentType = "Content-Type" + // HeaderContentSha384 is the header name for the sha hash of a file upload. + HeaderContentSha384 = "Content-Sha384" + // HeaderContentLength is the header name for the length of a file upload. + HeaderContentLength = "Content-Length" + // HeaderContentDisposition is the header name for value that holds the filename. + // The params are formatted according to RFC 2045 and RFC 2616 (see + // mime.ParseMediaType and mime.FormatMediaType). + HeaderContentDisposition = "Content-Disposition" +) + +const ( + // HTTPEndpointPath is the URL path, with substitutions, for + // a resource request. + HTTPEndpointPath = "/applications/%s/resources/%s" +) + +const FilenameParamForContentDispositionHeader = "filename" + +// UploadRequest defines a single upload request. +type UploadRequest struct { + // Application is the application ID. + Application string + + // Name is the resource name. + Name string + + // Filename is the name of the file as it exists on disk. + Filename string + + // Size is the size of the uploaded data, in bytes. + Size int64 + + // Fingerprint is the fingerprint of the uploaded data. + Fingerprint charmresources.Fingerprint + + // PendingID is the pending ID to associate with this upload, if any. + PendingID string + + // Content is the content to upload. + Content io.ReadSeeker +} + +type HttpRequestClient struct { + base.ClientFacade + facade base.FacadeCaller + + httpClient jujuhttp.HTTPDoer +} + +type osFilesystem struct{} // ApplicationNotFoundError type applicationNotFoundError struct { appName string } +var ApplicationNotFoundError = &applicationNotFoundError{} + +// newEndpointPath returns the API URL path for the identified resource. +func newEndpointPath(application, name string) string { + return fmt.Sprintf(HTTPEndpointPath, application, name) +} + +// ResourceHttpClient returns a new Client for the given raw API caller. +func ResourceHttpClient(apiCaller base.APICallCloser) *HttpRequestClient { + frontend, backend := base.NewClientFacade(apiCaller, "Resources") + + httpClient, err := apiCaller.HTTPClient() + if err != nil { + return nil + } + return &HttpRequestClient{ + ClientFacade: frontend, + facade: backend, + httpClient: httpClient, + } +} + func (ae *applicationNotFoundError) Error() string { return fmt.Sprintf("application %s not found", ae.appName) } @@ -110,7 +204,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 +256,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 +359,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 +387,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 +407,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 { @@ -321,6 +415,37 @@ type DestroyApplicationInput struct { ModelName string } +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) +} + +// Checks if strings consists from digits +// Used to detect resources which are given with revision number +func isInt(s string) bool { + for _, c := range s { + if !unicode.IsDigit(c) { + return false + } + } + return true +} + func resolveCharmURL(charmName string) (*charm.URL, error) { path, err := charm.EnsureSchema(charmName, charm.CharmHub) if err != nil { @@ -347,10 +472,11 @@ func (c applicationsClient) CreateApplication(ctx context.Context, input *Create } applicationAPIClient := apiapplication.NewClient(conn) + resourceHttpClient := ResourceHttpClient(conn) if applicationAPIClient.BestAPIVersion() >= 19 { - err = c.deployFromRepository(applicationAPIClient, transformedInput) + err = c.deployFromRepository(applicationAPIClient, resourceHttpClient, transformedInput) } else { - err = c.legacyDeploy(ctx, conn, applicationAPIClient, transformedInput) + err = c.legacyDeploy(ctx, conn, applicationAPIClient, transformedInput, resourceHttpClient) err = jujuerrors.Annotate(err, "legacy deploy method") } if err != nil { @@ -366,20 +492,15 @@ 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 *apiapplication.Client, resourceHttpClient *HttpRequestClient, 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,17 +512,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{} + uploadErr := uploadExistingPendingResources(deployInfo.Name, localPendingResources, fileSystem, resourceHttpClient) + + if uploadErr != nil { + return uploadErr + } + return nil } // TODO (hml) 23-Feb-2024 // Remove the funcationality 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 { +func (c applicationsClient) legacyDeploy(ctx context.Context, conn api.Connection, applicationAPIClient *apiapplication.Client, transformedInput transformedCreateApplicationInput, resourceHttpClient *HttpRequestClient) error { // Version needed for operating system selection. c.controllerVersion, _ = conn.ServerVersion() @@ -520,7 +650,7 @@ func (c applicationsClient) legacyDeploy(ctx context.Context, conn api.Connectio Origin: resultOrigin, } - resources, err := c.processResources(charmsAPIClient, conn, charmID, transformedInput.applicationName, transformedInput.resources) + resources, err := c.processResources(charmsAPIClient, conn, charmID, transformedInput.applicationName, transformedInput.resources, resourceHttpClient) if err != nil && !jujuerrors.Is(err, jujuerrors.AlreadyExists) { return err } @@ -732,7 +862,7 @@ 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, resources map[string]string, resourceHttpClient *HttpRequestClient) (map[string]string, error) { charmInfo, err := charmsAPIClient.CharmInfo(charmID.URL) if err != nil { return nil, typedError(err) @@ -748,7 +878,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, resources, charmID, resourcesAPIClient, resourceHttpClient) } // ReadApplicationWithRetryOnNotFound calls ReadApplication until @@ -1072,10 +1202,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) + resourceRevisions := make(map[string]string) for _, iResources := range resources { for _, resource := range iResources.Resources { - resourceRevisions[resource.Name] = resource.Revision + resourceRevisions[resource.Name] = strconv.Itoa(resource.Revision) } } @@ -1124,7 +1254,7 @@ func (c applicationsClient) UpdateApplication(input *UpdateApplicationInput) err charmsAPIClient := apicharms.NewClient(conn) clientAPIClient := c.getClientAPIClient(conn) modelconfigAPIClient := c.getModelConfigAPIClient(conn) - + resourceHttpClient := ResourceHttpClient(conn) resourcesAPIClient, err := c.getResourceAPIClient(conn) if err != nil { return err @@ -1165,7 +1295,7 @@ func (c applicationsClient) UpdateApplication(input *UpdateApplicationInput) err // can be changed from one revision to another. So "Revision-Config" // ordering will help to prevent issues with the configuration parsing. if input.Revision != nil || input.Channel != "" || len(input.Resources) != 0 { - setCharmConfig, err := c.computeSetCharmConfig(input, applicationAPIClient, charmsAPIClient, resourcesAPIClient) + setCharmConfig, err := c.computeSetCharmConfig(input, applicationAPIClient, charmsAPIClient, resourcesAPIClient, resourceHttpClient) if err != nil { return err } @@ -1313,6 +1443,7 @@ func (c applicationsClient) computeSetCharmConfig( applicationAPIClient ApplicationAPIClient, charmsAPIClient *apicharms.Client, resourcesAPIClient ResourceAPIClient, + resourceHttpClient *HttpRequestClient, ) (*apiapplication.SetCharmConfig, error) { oldURL, oldOrigin, err := applicationAPIClient.GetCharmURLOrigin("", input.AppName) if err != nil { @@ -1387,7 +1518,7 @@ func (c applicationsClient) computeSetCharmConfig( Origin: resultOrigin, } - resourceIDs, err := c.updateResources(input.AppName, input.Resources, charmsAPIClient, apiCharmID, resourcesAPIClient) + resourceIDs, err := c.updateResources(input.AppName, input.Resources, charmsAPIClient, apiCharmID, resourcesAPIClient, resourceHttpClient) if err != nil { return nil, err } @@ -1420,27 +1551,19 @@ func strPtr(in string) *string { return &in } -func (c applicationsClient) updateResources(appName string, resources map[string]int, charmsAPIClient *apicharms.Client, - charmID apiapplication.CharmID, resourcesAPIClient ResourceAPIClient) (map[string]string, error) { +func (c applicationsClient) updateResources(appName string, resources map[string]string, charmsAPIClient *apicharms.Client, + charmID apiapplication.CharmID, resourcesAPIClient ResourceAPIClient, resourceHttpClient *HttpRequestClient) (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 { @@ -1450,21 +1573,34 @@ func (c applicationsClient) updateResources(appName string, resources map[string return nil, nil } - return addPendingResources(appName, filtered, resources, charmID, resourcesAPIClient) + return addPendingResources(appName, filtered, resources, charmID, resourcesAPIClient, resourceHttpClient) } -func addPendingResources(appName string, resourcesToBeAdded map[string]charmresources.Meta, resourceRevisions map[string]int, - charmID apiapplication.CharmID, resourcesAPIClient ResourceAPIClient) (map[string]string, error) { +func addPendingResources(appName string, resourcesToBeAdded map[string]charmresources.Meta, resourcesRevisions map[string]string, + charmID apiapplication.CharmID, resourcesAPIClient ResourceAPIClient, resourceHttpClient *HttpRequestClient) (map[string]string, error) { pendingResources := []charmresources.Resource{} - for _, v := range resourcesToBeAdded { + pendingResourceUploads := []apiapplication.PendingResourceUpload{} + + for _, resourceMeta := range resourcesToBeAdded { aux := charmresources.Resource{ - Meta: v, + Meta: resourceMeta, Origin: charmresources.OriginStore, Revision: -1, } - if resourceRevisions != nil { - if revision, ok := resourceRevisions[v.Name]; ok { - aux.Revision = revision + if resourcesRevisions != nil { + if revision, ok := resourcesRevisions[resourceMeta.Name]; ok { + if isInt(revision) { + iRevision, err := strconv.Atoi(revision) + if err != nil { + return nil, typedError(err) + } + aux.Revision = iRevision + } + pendingResourceUploads = append(pendingResourceUploads, apiapplication.PendingResourceUpload{ + Name: resourceMeta.Name, + Filename: resourcesRevisions[resourceMeta.Name], + Type: resourceMeta.Type.String(), + }) } } @@ -1485,6 +1621,13 @@ func addPendingResources(appName string, resourcesToBeAdded map[string]charmreso return nil, typedError(err) } + fileSystem := osFilesystem{} + uploadErr := uploadExistingPendingResources(appName, pendingResourceUploads, fileSystem, resourceHttpClient) + + if uploadErr != nil { + return nil, uploadErr + } + // now build a map with the resource name and the corresponding UUID toReturn := map[string]string{} for i, argsResource := range pendingResources { @@ -1494,6 +1637,96 @@ func addPendingResources(appName string, resourcesToBeAdded map[string]charmreso return toReturn, nil } +// Upload sends the provided resource blob up to Juju. +func upload(appName, name, filename, pendingID string, reader io.ReadSeeker, resourceHttpClient *HttpRequestClient) error { + uReq, err := apiresources.NewUploadRequest(appName, name, filename, reader) + if err != nil { + return jujuerrors.Trace(err) + } + if pendingID != "" { + uReq.PendingID = pendingID + } + req, err := uReq.HTTPRequest() + if err != nil { + return jujuerrors.Trace(err) + } + var response params.UploadResult // ignored + if err := resourceHttpClient.httpClient.Do(resourceHttpClient.facade.RawAPICaller().Context(), req, &response); err != nil { + return jujuerrors.Trace(err) + } + + return nil +} + +// setFilename sets a name to the file. +func setFilename(filename string, req *http.Request) { + filename = mime.BEncoding.Encode("utf-8", filename) + + disp := mime.FormatMediaType( + MediaTypeFormData, + map[string]string{FilenameParamForContentDispositionHeader: filename}, + ) + + req.Header.Set(HeaderContentDisposition, disp) +} + +// HTTPRequest generates a new HTTP request. +func (ur UploadRequest) HTTPRequest() (*http.Request, error) { + urlStr := newEndpointPath(ur.Application, ur.Name) + + req, err := http.NewRequest(http.MethodPut, urlStr, ur.Content) + if err != nil { + return nil, jujuerrors.Trace(err) + } + + req.Header.Set(HeaderContentType, ContentTypeRaw) + req.Header.Set(HeaderContentSha384, ur.Fingerprint.String()) + req.Header.Set(HeaderContentLength, fmt.Sprint(ur.Size)) + setFilename(ur.Filename, req) + + req.ContentLength = ur.Size + + if ur.PendingID != "" { + query := req.URL.Query() + query.Set(QueryParamPendingID, ur.PendingID) + req.URL.RawQuery = query.Encode() + } + + return req, nil +} + +// 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, + resourceHttpClient *HttpRequestClient) 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 := upload(appName, pendingResUpload.Name, pendingResUpload.Filename, "", r, resourceHttpClient) + + if uploadErr != nil { + return jujuerrors.Trace(uploadErr) + } + } + return nil +} + func computeUpdatedBindings(modelDefaultSpace string, currentBindings map[string]string, inputBindings map[string]string, appName string) (params.ApplicationMergeBindingsArgs, error) { var defaultSpace string oldDefault := currentBindings[""] diff --git a/internal/provider/resource_application.go b/internal/provider/resource_application.go index cf885ad0..14fcb736 100644 --- a/internal/provider/resource_application.go +++ b/internal/provider/resource_application.go @@ -46,7 +46,7 @@ const ( StorageKey = "storage" resourceKeyMarkdownDescription = ` -Charm resource revisions. Must evaluate to an integer. +Charm resource revisions. Must evaluate to a string. A resource could be a resource revision number or a custom resource. 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, @@ -261,7 +261,7 @@ func (r *applicationResource) Schema(_ context.Context, _ resource.SchemaRequest }, ResourceKey: schema.MapAttribute{ Optional: true, - ElementType: types.Int64Type, + ElementType: types.StringType, 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 @@ -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 } @@ -961,12 +961,12 @@ func (r *applicationResource) Update(ctx context.Context, req resource.UpdateReq // NOT to update resources, because we want revisions 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,12 +974,12 @@ 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 } From 9c156dca393ab395ffa65bea4ddbbdcaa11295f0 Mon Sep 17 00:00:00 2001 From: gatici Date: Sun, 2 Jun 2024 09:43:58 +0300 Subject: [PATCH 02/27] feat(application): update custom images when application is updated This commit updates the application resources after the application is deployed using a custom OCI resource when TF plan resources are updated. tests(resource): UploadPendingResource mock base method is generated. Signed-off-by: gatici --- internal/juju/applications.go | 116 ++++++++++++++++++---------------- internal/juju/interfaces.go | 3 + internal/juju/mock_test.go | 17 +++++ 3 files changed, 83 insertions(+), 53 deletions(-) diff --git a/internal/juju/applications.go b/internal/juju/applications.go index cda3f909..01a4c351 100644 --- a/internal/juju/applications.go +++ b/internal/juju/applications.go @@ -70,7 +70,7 @@ const ( ) const ( - // HeaderContentType is the header name for the type of a file upload. + // HeaderContentType is the header name for the type of file upload. HeaderContentType = "Content-Type" // HeaderContentSha384 is the header name for the sha hash of a file upload. HeaderContentSha384 = "Content-Sha384" @@ -476,7 +476,7 @@ func (c applicationsClient) CreateApplication(ctx context.Context, input *Create if applicationAPIClient.BestAPIVersion() >= 19 { err = c.deployFromRepository(applicationAPIClient, resourceHttpClient, transformedInput) } else { - err = c.legacyDeploy(ctx, conn, applicationAPIClient, transformedInput, resourceHttpClient) + err = c.legacyDeploy(ctx, conn, applicationAPIClient, transformedInput) err = jujuerrors.Annotate(err, "legacy deploy method") } if err != nil { @@ -531,7 +531,7 @@ func (c applicationsClient) deployFromRepository(applicationAPIClient *apiapplic // Remove the funcationality 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, resourceHttpClient *HttpRequestClient) error { +func (c applicationsClient) legacyDeploy(ctx context.Context, conn api.Connection, applicationAPIClient *apiapplication.Client, transformedInput transformedCreateApplicationInput) error { // Version needed for operating system selection. c.controllerVersion, _ = conn.ServerVersion() @@ -650,7 +650,7 @@ func (c applicationsClient) legacyDeploy(ctx context.Context, conn api.Connectio Origin: resultOrigin, } - resources, err := c.processResources(charmsAPIClient, conn, charmID, transformedInput.applicationName, transformedInput.resources, resourceHttpClient) + resources, err := c.processResources(charmsAPIClient, conn, charmID, transformedInput.applicationName, transformedInput.resources) if err != nil && !jujuerrors.Is(err, jujuerrors.AlreadyExists) { return err } @@ -862,7 +862,7 @@ 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]string, resourceHttpClient *HttpRequestClient) (map[string]string, error) { +func (c applicationsClient) processResources(charmsAPIClient *apicharms.Client, conn api.Connection, charmID apiapplication.CharmID, appName string, resources map[string]string) (map[string]string, error) { charmInfo, err := charmsAPIClient.CharmInfo(charmID.URL) if err != nil { return nil, typedError(err) @@ -878,7 +878,7 @@ func (c applicationsClient) processResources(charmsAPIClient *apicharms.Client, return nil, err } - return addPendingResources(appName, charmInfo.Meta.Resources, resources, charmID, resourcesAPIClient, resourceHttpClient) + return addPendingResources(appName, charmInfo.Meta.Resources, resources, charmID, resourcesAPIClient) } // ReadApplicationWithRetryOnNotFound calls ReadApplication until @@ -1573,65 +1573,74 @@ func (c applicationsClient) updateResources(appName string, resources map[string return nil, nil } - return addPendingResources(appName, filtered, resources, charmID, resourcesAPIClient, resourceHttpClient) + return addPendingResources(appName, filtered, resources, charmID, resourcesAPIClient) } func addPendingResources(appName string, resourcesToBeAdded map[string]charmresources.Meta, resourcesRevisions map[string]string, - charmID apiapplication.CharmID, resourcesAPIClient ResourceAPIClient, resourceHttpClient *HttpRequestClient) (map[string]string, error) { - pendingResources := []charmresources.Resource{} - pendingResourceUploads := []apiapplication.PendingResourceUpload{} + charmID apiapplication.CharmID, resourcesAPIClient ResourceAPIClient) (map[string]string, error) { + pendingResourcesforAdd := []charmresources.Resource{} + toReturn := map[string]string{} for _, resourceMeta := range resourcesToBeAdded { - aux := charmresources.Resource{ - Meta: resourceMeta, - Origin: charmresources.OriginStore, - Revision: -1, - } if resourcesRevisions != nil { - if revision, ok := resourcesRevisions[resourceMeta.Name]; ok { - if isInt(revision) { - iRevision, err := strconv.Atoi(revision) + if deployValue, ok := resourcesRevisions[resourceMeta.Name]; ok { + if isInt(deployValue) { + // A resource revision is provided + providedRev, err := strconv.Atoi(deployValue) + if err != nil { + return nil, typedError(err) + } + aux := charmresources.Resource{ + Meta: resourceMeta, + Origin: charmresources.OriginStore, + Revision: -1, + } + aux.Revision = providedRev + pendingResourcesforAdd = append(pendingResourcesforAdd, aux) + } else { + // A new resource to be uploaded by the client + uux := 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 := resourcesAPIClient.UploadPendingResource(appName, uux, deployValue, r) if err != nil { return nil, typedError(err) } - aux.Revision = iRevision + // Add the resource name and the corresponding UUID to the resources map + toReturn[resourceMeta.Name] = toRequestUpload } - pendingResourceUploads = append(pendingResourceUploads, apiapplication.PendingResourceUpload{ - Name: resourceMeta.Name, - Filename: resourcesRevisions[resourceMeta.Name], - Type: resourceMeta.Type.String(), - }) } } - - pendingResources = append(pendingResources, aux) - } - - resourcesReq := apiresources.AddPendingResourcesArgs{ - ApplicationID: appName, - CharmID: apiresources.CharmID{ - URL: charmID.URL, - Origin: charmID.Origin, - }, - Resources: pendingResources, } - toRequest, err := resourcesAPIClient.AddPendingResources(resourcesReq) - if err != nil { - return nil, typedError(err) - } - - fileSystem := osFilesystem{} - uploadErr := uploadExistingPendingResources(appName, pendingResourceUploads, fileSystem, resourceHttpClient) - - if uploadErr != nil { - return nil, uploadErr - } - - // 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] + if len(pendingResourcesforAdd) != 0 { + resourcesReqforAdd := apiresources.AddPendingResourcesArgs{ + ApplicationID: appName, + CharmID: apiresources.CharmID{ + URL: charmID.URL, + Origin: charmID.Origin, + }, + Resources: pendingResourcesforAdd, + } + toRequestAdd, err := resourcesAPIClient.AddPendingResources(resourcesReqforAdd) + if err != nil { + return nil, typedError(err) + } + // Add the resource name and the corresponding UUID to the resources map + for i, argsResource := range pendingResourcesforAdd { + toReturn[argsResource.Meta.Name] = toRequestAdd[i] + } } return toReturn, nil @@ -1650,7 +1659,7 @@ func upload(appName, name, filename, pendingID string, reader io.ReadSeeker, res if err != nil { return jujuerrors.Trace(err) } - var response params.UploadResult // ignored + var response params.UploadResult if err := resourceHttpClient.httpClient.Do(resourceHttpClient.facade.RawAPICaller().Context(), req, &response); err != nil { return jujuerrors.Trace(err) } @@ -1706,6 +1715,7 @@ func uploadExistingPendingResources( if pendingResources == nil { return nil } + pendingID := "" for _, pendingResUpload := range pendingResources { t, typeParseErr := charmresources.ParseType(pendingResUpload.Type) @@ -1718,7 +1728,7 @@ func uploadExistingPendingResources( if openResErr != nil { return jujuerrors.Annotatef(openResErr, "unable to open resource %v", pendingResUpload.Name) } - uploadErr := upload(appName, pendingResUpload.Name, pendingResUpload.Filename, "", r, resourceHttpClient) + uploadErr := upload(appName, pendingResUpload.Name, pendingResUpload.Filename, pendingID, r, resourceHttpClient) if uploadErr != nil { return jujuerrors.Trace(uploadErr) diff --git a/internal/juju/interfaces.go b/internal/juju/interfaces.go index 7f07ddce..921fb959 100644 --- a/internal/juju/interfaces.go +++ b/internal/juju/interfaces.go @@ -5,6 +5,7 @@ package juju import ( "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" @@ -17,6 +18,7 @@ import ( "github.com/juju/juju/core/secrets" "github.com/juju/juju/rpc/params" "github.com/juju/names/v5" + "io" ) type SharedClient interface { @@ -63,6 +65,7 @@ type ModelConfigAPIClient interface { type ResourceAPIClient interface { AddPendingResources(args apiresources.AddPendingResourcesArgs) ([]string, error) ListResources(applications []string) ([]resources.ApplicationResources, 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..47212f2e 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" @@ -572,6 +574,21 @@ func (mr *MockResourceAPIClientMockRecorder) ListResources(arg0 any) *gomock.Cal return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListResources", reflect.TypeOf((*MockResourceAPIClient)(nil).ListResources), arg0) } +// 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 From cd09044b1ffeb3b057831bb67fe0546a23d1b776 Mon Sep 17 00:00:00 2001 From: gatici Date: Thu, 6 Jun 2024 14:55:43 +0300 Subject: [PATCH 03/27] feat(application): remove resorces when the plan does not specify resource If the application is deployed with specified resources such as revision numbers from CharmHub or an OCI image, then the resources are removed from the TF plan, the default resources from the CharmHub is used. test(resources): Adding tests for new functions that added for custom resource upload docs(application): Updating application resources map description in the application markdown file Signed-off-by: gatici --- docs/resources/application.md | 20 +- .../resources/juju_application/resource.tf | 18 ++ go.sum | 26 ++ internal/juju/applications.go | 39 +-- internal/juju/applications_test.go | 17 ++ internal/juju/package_test.go | 2 +- internal/provider/main_test.go | 2 +- internal/provider/resource_application.go | 41 ++- .../provider/resource_application_test.go | 238 ++++++++++++++++-- .../provider/resource_files/ausf-image.json | 3 + .../provider/resource_files/ausf-image.yaml | 1 + .../resource_files/custom-foo-file.txt | 1 + .../resource_files/special-foo-file.txt | 1 + 13 files changed, 357 insertions(+), 52 deletions(-) create mode 100644 internal/provider/resource_files/ausf-image.json create mode 100644 internal/provider/resource_files/ausf-image.yaml create mode 100644 internal/provider/resource_files/custom-foo-file.txt create mode 100644 internal/provider/resource_files/special-foo-file.txt diff --git a/docs/resources/application.md b/docs/resources/application.md index d9d02e57..87d87211 100644 --- a/docs/resources/application.md +++ b/docs/resources/application.md @@ -37,6 +37,24 @@ resource "juju_application" "this" { external-hostname = "..." } } + +resource "juju_application" "custom_resources_example" { + name = "placement-example" + model = juju_model.development.name + charm { + name = "hello-kubecon" + channel = "edge" + revision = 14 + series = "trusty" + } + + resources = { + gosherve-image = "gatici/gosherve:1.0" + } + + units = 3 + placement = "0,1,2" +} ``` @@ -55,7 +73,7 @@ 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 String) Charm resource revisions. Must evaluate to a string. A resource could be a resource revision number or a custom resource. +- `resources` (Map of String) Charm resource revisions. Must evaluate to a string. A resource could be a resource revision number from CharmHub or a custom OCI image resource. 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, diff --git a/examples/resources/juju_application/resource.tf b/examples/resources/juju_application/resource.tf index 3144cff4..2de0ddf5 100644 --- a/examples/resources/juju_application/resource.tf +++ b/examples/resources/juju_application/resource.tf @@ -21,4 +21,22 @@ resource "juju_application" "this" { config = { external-hostname = "..." } +} + +resource "juju_application" "custom_resources_example" { + name = "placement-example" + model = juju_model.development.name + charm { + name = "hello-kubecon" + channel = "edge" + revision = 14 + series = "trusty" + } + + resources = { + gosherve-image = "gatici/gosherve:1.0" + } + + units = 3 + placement = "0,1,2" } \ No newline at end of file diff --git a/go.sum b/go.sum index 77b3ab2b..f4c64e7b 100644 --- a/go.sum +++ b/go.sum @@ -42,8 +42,11 @@ github.com/ProtonMail/go-crypto v1.1.0-alpha.2 h1:bkyFVUP+ROOARdgCiJzNQo2V2kiB97 github.com/ProtonMail/go-crypto v1.1.0-alpha.2/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= github.com/Rican7/retry v0.3.1 h1:scY4IbO8swckzoA/11HgBwaZRJEyY9vaNJshcdhp1Mc= github.com/Rican7/retry v0.3.1/go.mod h1:CxSDrhAyXmTMeEuRAnArMu1FHu48vtfjLREWqVl7Vw0= +github.com/agext/levenshtein v1.2.1/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= github.com/agext/levenshtein v1.2.2 h1:0S/Yg6LYmFJ5stwQeRp6EeOcCbj7xiqQSdNelsXvaqE= github.com/agext/levenshtein v1.2.2/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= +github.com/apparentlymart/go-dump v0.0.0-20180507223929-23540a00eaa3/go.mod h1:oL81AME2rN47vu18xqj1S1jPIPuN7afo62yKTNn3XMM= +github.com/apparentlymart/go-textseg v1.0.0/go.mod h1:z96Txxhf3xSFMPmb5X/1W05FF/Nj9VFpLOpjS5yuumk= github.com/apparentlymart/go-textseg/v12 v12.0.0/go.mod h1:S/4uRK2UtaQttw1GenVJEynmyUenKwP++x/+DdGV/Ec= github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= @@ -93,6 +96,7 @@ github.com/bmatcuk/doublestar/v4 v4.6.1 h1:FH9SifrbvJhnlQpztAx++wlkk70QBf0iBWDwN github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/bmizerany/pat v0.0.0-20210406213842-e4b6760bdd6f h1:gOO/tNZMjjvTKZWpY7YnXC72ULNLErRtp94LountVE8= github.com/bmizerany/pat v0.0.0-20210406213842-e4b6760bdd6f/go.mod h1:8rLXio+WjiTceGBHIoTvn60HIbs7Hm7bcHjyrSqYB9c= +github.com/bsm/go-vlq v0.0.0-20150828105119-ec6e8d4f5f4e/go.mod h1:N+BjUcTjSxc2mtRGSCPsat1kze3CUtvJN3/jTXlp29k= github.com/bufbuild/protocompile v0.4.0 h1:LbFKd2XowZvQ/kajzguUp2DC9UEIQhIq77fZZlaQsNA= github.com/bufbuild/protocompile v0.4.0/go.mod h1:3v93+mbWn/v3xzN+31nwkJfrEpAUwp+BagBSZWx+TP8= github.com/canonical/go-dqlite v1.21.0 h1:4gLDdV2GF+vg0yv9Ff+mfZZNQ1JGhnQ3GnS2GeZPHfA= @@ -146,6 +150,7 @@ github.com/frankban/quicktest v1.7.2/go.mod h1:jaStnuzAqU1AJdCO0l53JDCJrVDKcS03D github.com/frankban/quicktest v1.10.0/go.mod h1:ui7WezCLWMWxVWr1GETZY3smRy0G4KWq9vcPtJmFl7Y= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= github.com/go-git/go-billy/v5 v5.5.0 h1:yEY4yhzCDuMGSv83oGxiBotRzhwhNr8VZyphhiu+mTU= @@ -234,6 +239,7 @@ github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/ github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= github.com/hashicorp/cli v1.1.6 h1:CMOV+/LJfL1tXCOKrgAX0uRKnzjj/mpmqNXloRSy2K8= github.com/hashicorp/cli v1.1.6/go.mod h1:MPon5QYlgjjo0BSoAiN0ESeT5fRzDjVRp+uioJ0piz4= +github.com/hashicorp/errwrap v0.0.0-20180715044906-d6c0cd880357/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -246,6 +252,7 @@ github.com/hashicorp/go-cty v1.4.1-0.20200414143053-d3edf31b6320 h1:1/D3zfFHttUK github.com/hashicorp/go-cty v1.4.1-0.20200414143053-d3edf31b6320/go.mod h1:EiZBMaudVLy8fmjf9Npq1dq9RalhveqZG5w/yz3mHWs= github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-multierror v0.0.0-20180717150148-3d5d8f294aa0/go.mod h1:JMRHfdO9jKNzS/+BTlxCjKNQHg/jZAft8U7LloJvN7I= github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= @@ -300,6 +307,7 @@ github.com/hashicorp/vault/api v1.10.0 h1:/US7sIjWN6Imp4o/Rj1Ce2Nr5bki/AXi9vAW3p github.com/hashicorp/vault/api v1.10.0/go.mod h1:jo5Y/ET+hNyz+JnKDt8XLAdKs+AM0G5W0Vp1IrFI8N8= github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE= github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/huandu/xstrings v1.3.3 h1:/Gcsuc1x8JVbJ9/rlye4xZnVAbEkGauT8lbebqcQws4= github.com/huandu/xstrings v1.3.3/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/im7mortal/kmutex v1.0.1 h1:zAACzjwD+OEknDqnLdvRa/BhzFM872EBwKijviGLc9Q= @@ -309,6 +317,7 @@ github.com/imdario/mergo v0.3.15 h1:M8XP7IuFNsqUx6VPK2P9OSmsYsI/YFaGil0uD21V3dM= github.com/imdario/mergo v0.3.15/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= +github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jhump/protoreflect v1.15.1 h1:HUMERORf3I3ZdX05WaQ6MIpd/NJ434hTp5YiKgfCL6c= github.com/jhump/protoreflect v1.15.1/go.mod h1:jD/2GMKKE6OqX8qTjhADU1e6DShO+gavG9e0Q693nKo= github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= @@ -433,6 +442,7 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348/go.mod h1:B69LEHPfb2qLo0BaaOLcbitczOKLWTsrBG9LczfCD4k= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lestrrat-go/blackmagic v1.0.2 h1:Cg2gVSc9h7sz9NOByczrbUvLopQmXrfFx//N+AkAr5k= @@ -510,6 +520,7 @@ github.com/mitchellh/go-linereader v0.0.0-20190213213312-1b945b3263eb h1:GRiLv4r github.com/mitchellh/go-linereader v0.0.0-20190213213312-1b945b3263eb/go.mod h1:OaY7UOoTkkrX3wRwjpYRKafIkkyeD0UtweSHAWWiqQM= github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU= github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8= +github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= github.com/mitchellh/go-wordwrap v1.0.0 h1:6GlHJ/LTGMrIJbwgdqdl2eEH8o+Exx/0m8ir9Gns0u4= github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= @@ -538,8 +549,11 @@ github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= github.com/oklog/run v1.0.0 h1:Ru7dDtJNOyC66gQ5dQmaCa0qIsAUFY3sFpK1Xk8igrw= github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo/v2 v2.13.0 h1:0jY9lJquiL8fcf3M4LAXN5aMlS/b2BV86HFFPCPMgE4= github.com/onsi/ginkgo/v2 v2.13.0/go.mod h1:TE309ZR8s5FsKKpuB1YAQYBzCaAfUgatB/xlT/ETL/o= +github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/onsi/gomega v1.29.0 h1:KIA/t2t5UBzoirT4H9tsML45GEbo3ouUnBHsCfD2tVg= github.com/onsi/gomega v1.29.0/go.mod h1:9sxs+SwGrKI0+PWe4Fxa9tFQQBG5xSsSbMXOI8PPpoQ= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= @@ -589,6 +603,7 @@ github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkB github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= @@ -601,6 +616,7 @@ github.com/skeema/knownhosts v1.2.2/go.mod h1:xYbVRSPxqBZFrdmDyMmsOs+uX1UZC3nTN3 github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/pflag v1.0.2/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/std-uritemplate/std-uritemplate/go v0.0.47 h1:erzz/DR4sOzWr0ca2MgSTkMckpLEsDySaTZwVFQq9zw= @@ -648,6 +664,7 @@ github.com/yuin/goldmark v1.7.1 h1:3bajkSilaCbjdKVsKdZjZCLBNPL9pYzrCakKaf4U49U= github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= github.com/yuin/goldmark-meta v1.1.0 h1:pWw+JLHGZe8Rk0EGsMVssiNb/AaPMHfSRszZeUeiOUc= github.com/yuin/goldmark-meta v1.1.0/go.mod h1:U4spWENafuA7Zyg+Lj5RqK/MF+ovMYtBvXi1lBb2VP0= +github.com/zclconf/go-cty v1.0.0/go.mod h1:xnAOWiHeOqg2nWS62VtQ7pbOu17FtxJNW8RLEih+O3s= github.com/zclconf/go-cty v1.14.4 h1:uXXczd9QDGsgu0i/QFR/hzI5NYCHLf6NQw/atrbnhq8= github.com/zclconf/go-cty v1.14.4/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo= @@ -667,6 +684,7 @@ go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc= golang.org/x/crypto v0.0.0-20180723164146-c126467f60eb/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190404164418-38d8ce5564a5/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE= +golang.org/x/crypto v0.0.0-20190426145343-a29dc8fdc734/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= @@ -686,10 +704,13 @@ golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91 golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180811021610-c39426892332/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190502183928-7f726cade0ab/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -712,9 +733,11 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190502175342-a43fa875dd82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -801,6 +824,7 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV gopkg.in/errgo.v1 v1.0.0/go.mod h1:CxwszS/Xz1C49Ucd2i6Zil5UToP1EmyrFhKaMVbg1mk= gopkg.in/errgo.v1 v1.0.1 h1:oQFRXzZ7CkBGdm1XZm/EbQYaYNNEElNBOd09M6cqNso= gopkg.in/errgo.v1 v1.0.1/go.mod h1:3NjfXwocQRYAPTq4/fzX+CwUhPRcR/azYRhj8G+LqMo= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/gobwas/glob.v0 v0.2.3 h1:uLMy+ys6BqRCutdUNyWLlmEnd7VULqh1nsxxV1kj0qQ= gopkg.in/gobwas/glob.v0 v0.2.3/go.mod h1:JgYsZg6HmXzPbMVcSQwXigfIbVWt5ysj8n78j6LiwQY= gopkg.in/httprequest.v1 v1.2.1 h1:pEPLMdF/gjWHnKxLpuCYaHFjc8vAB2wrYjXrqDVC16E= @@ -827,6 +851,7 @@ gopkg.in/tomb.v2 v2.0.0-20161208151619-d5d1b5820637 h1:yiW+nvdHb9LVqSHQBXfZCieqV gopkg.in/tomb.v2 v2.0.0-20161208151619-d5d1b5820637/go.mod h1:BHsqpu/nsuzkT5BpiH1EMZPLyqSMM8JbIavyFACoFNk= gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= @@ -838,6 +863,7 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +howett.net/plist v0.0.0-20181124034731-591f970eefbb/go.mod h1:vMygbs4qMhSZSc4lCUl2OEE+rDiIIJAIdR4m7MiMcm0= k8s.io/api v0.29.0 h1:NiCdQMY1QOp1H8lfRyeEf8eOwV6+0xA6XEE44ohDX2A= k8s.io/api v0.29.0/go.mod h1:sdVmXoz2Bo/cb77Pxi71IPTSErEW32xa4aXwKH7gfBA= k8s.io/apiextensions-apiserver v0.29.0 h1:0VuspFG7Hj+SxyF/Z/2T0uFbI5gb5LRgEyUVE3Q4lV0= diff --git a/internal/juju/applications.go b/internal/juju/applications.go index 01a4c351..5311a2e8 100644 --- a/internal/juju/applications.go +++ b/internal/juju/applications.go @@ -62,8 +62,7 @@ const ( ContentTypeRaw = "application/octet-stream" ) const ( - // MediaTypeFormData is the media type for file uploads (see - // mime.FormatMediaType). + // MediaTypeFormData is the media type for file uploads (see mime.FormatMediaType). MediaTypeFormData = "form-data" // QueryParamPendingID is the query parameter we use to send up the pending id. QueryParamPendingID = "pendingid" @@ -77,14 +76,12 @@ const ( // HeaderContentLength is the header name for the length of a file upload. HeaderContentLength = "Content-Length" // HeaderContentDisposition is the header name for value that holds the filename. - // The params are formatted according to RFC 2045 and RFC 2616 (see - // mime.ParseMediaType and mime.FormatMediaType). + // See mime.ParseMediaType and mime.FormatMediaType. HeaderContentDisposition = "Content-Disposition" ) const ( - // HTTPEndpointPath is the URL path, with substitutions, for - // a resource request. + // HTTPEndpointPath is the URL path, with substitutions, for a resource request. HTTPEndpointPath = "/applications/%s/resources/%s" ) @@ -116,8 +113,7 @@ type UploadRequest struct { type HttpRequestClient struct { base.ClientFacade - facade base.FacadeCaller - + facade base.FacadeCaller httpClient jujuhttp.HTTPDoer } @@ -131,7 +127,7 @@ type applicationNotFoundError struct { var ApplicationNotFoundError = &applicationNotFoundError{} // newEndpointPath returns the API URL path for the identified resource. -func newEndpointPath(application, name string) string { +func newEndpointPath(application string, name string) string { return fmt.Sprintf(HTTPEndpointPath, application, name) } @@ -435,7 +431,7 @@ func (osFilesystem) Stat(name string) (os.FileInfo, error) { return os.Stat(name) } -// Checks if strings consists from digits +// isInt checks if strings consists from digits // Used to detect resources which are given with revision number func isInt(s string) bool { for _, c := range s { @@ -515,10 +511,13 @@ func (c applicationsClient) deployFromRepository(applicationAPIClient *apiapplic Resources: transformedInput.resources, Storage: transformedInput.storage, }) + if len(errs) != 0 { return errors.Join(errs...) } + fileSystem := osFilesystem{} + // Upload the provided local resources to Juju uploadErr := uploadExistingPendingResources(deployInfo.Name, localPendingResources, fileSystem, resourceHttpClient) if uploadErr != nil { @@ -528,7 +527,7 @@ func (c applicationsClient) deployFromRepository(applicationAPIClient *apiapplic } // 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 { @@ -1518,7 +1517,7 @@ func (c applicationsClient) computeSetCharmConfig( Origin: resultOrigin, } - resourceIDs, err := c.updateResources(input.AppName, input.Resources, charmsAPIClient, apiCharmID, resourcesAPIClient, resourceHttpClient) + resourceIDs, err := c.updateResources(input.AppName, input.Resources, charmsAPIClient, apiCharmID, resourcesAPIClient) if err != nil { return nil, err } @@ -1552,7 +1551,7 @@ func strPtr(in string) *string { } func (c applicationsClient) updateResources(appName string, resources map[string]string, charmsAPIClient *apicharms.Client, - charmID apiapplication.CharmID, resourcesAPIClient ResourceAPIClient, resourceHttpClient *HttpRequestClient) (map[string]string, error) { + charmID apiapplication.CharmID, resourcesAPIClient ResourceAPIClient) (map[string]string, error) { meta, err := utils.GetMetaResources(charmID.URL, charmsAPIClient) if err != nil { return nil, err @@ -1590,16 +1589,20 @@ func addPendingResources(appName string, resourcesToBeAdded map[string]charmreso if err != nil { return nil, typedError(err) } - aux := charmresources.Resource{ + resourceFromCharmhub := charmresources.Resource{ Meta: resourceMeta, Origin: charmresources.OriginStore, Revision: -1, } - aux.Revision = providedRev - pendingResourcesforAdd = append(pendingResourcesforAdd, aux) + // If the resource is removed, revision does not exist + // Charm is deployed with default resources according to charm revision or channel + if providedRev != 0 { + resourceFromCharmhub.Revision = providedRev + } + pendingResourcesforAdd = append(pendingResourcesforAdd, resourceFromCharmhub) } else { // A new resource to be uploaded by the client - uux := charmresources.Resource{ + localResource := charmresources.Resource{ Meta: resourceMeta, Origin: charmresources.OriginUpload, } @@ -1613,7 +1616,7 @@ func addPendingResources(appName string, resourcesToBeAdded map[string]charmreso if openResErr != nil { return nil, typedError(openResErr) } - toRequestUpload, err := resourcesAPIClient.UploadPendingResource(appName, uux, deployValue, r) + toRequestUpload, err := resourcesAPIClient.UploadPendingResource(appName, localResource, deployValue, r) if err != nil { return nil, typedError(err) } diff --git a/internal/juju/applications_test.go b/internal/juju/applications_test.go index 0235cc8f..341a69f4 100644 --- a/internal/juju/applications_test.go +++ b/internal/juju/applications_test.go @@ -19,6 +19,7 @@ import ( "github.com/juju/names/v4" "github.com/juju/utils/v3" "github.com/juju/version/v2" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" "go.uber.org/mock/gomock" ) @@ -388,3 +389,19 @@ func (s *ApplicationSuite) TestReadApplicationRetryNotFoundStorageNotFoundError( func TestApplicationSuite(t *testing.T) { suite.Run(t, new(ApplicationSuite)) } + +func TestNewEndpointPath(t *testing.T) { + application := "ausf" + name := "sdcore-ausf-k8s" + want := "/applications/ausf/resources/sdcore-ausf-k8s" + got := newEndpointPath(application, name) + assert.Equal(t, got, want) +} + +func TestNewEndpointPathEmptyInputs(t *testing.T) { + application := "" + name := "" + want := "/applications//resources/" + got := newEndpointPath(application, name) + assert.Equal(t, got, want) +} diff --git a/internal/juju/package_test.go b/internal/juju/package_test.go index 8a3f724f..1f041758 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 +//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,HttpRequestClient,UploadRequest,ResourceHttpClient,osFilesystem,apicharms.Client,utils.GetUpgradeResources,charmresources.ParseType,resourcecmd.Opensource,typedError,io.ReadSeeker,http.Request,mime.Encoding.Encode,Mime.FormatMediaType,http.NewRequest //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/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 14fcb736..bca3d035 100644 --- a/internal/provider/resource_application.go +++ b/internal/provider/resource_application.go @@ -47,16 +47,26 @@ const ( resourceKeyMarkdownDescription = ` Charm resource revisions. Must evaluate to a string. A resource could be a resource revision number or a custom resource. - - 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). +There are a few scenarios that need to be considered: + * If the plan does not specify a resource and resources are added to the plan (as a revision number or a custom resource), specified resources are 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, existing resources are kept. (Resources are not detached) + * If the plan does specify resource revisions and resources are removed from the plan: + - If charm revision/channel is updated, the resources associated with the updated charm revision or channel is attached. + - If the charm revision/channel are not updated then the resources associated with the existing charm revision/channel are attached. + * Charm could be deployed without resource, then resource could be added later. + * Resources could be provided in the following formats: + - A custom repository URL + - A files ends with .txt, .json or .yaml + - A resource revision from CharmHub + * Charm could be deployed with resources. + * If the provided resource revision does not exist during initial deployment or update, Client does not start deployment with an error that resource was not found in the store. + * If the provided custom resource does not exist during initial deployment or update, Client start deployment and charm could not be deployed properly and charm will be in error state. + * If the Provided resource type is not correct then Client fails with incorrect resource error for below scenarios: + - An image is expected but file does not include image URL + - A plain text file is expected but a .json file is provided. + * If the provided resource does not exist then Client fails with path is not valid error. + * If provided resource value is changed from an int to a file then the image resource from the file is attached. + * If provided resource value is changed from a file to an int then image revision is attached. ` ) @@ -984,6 +994,17 @@ func (r *applicationResource) Update(ctx context.Context, req resource.UpdateReq updateApplicationInput.Resources[k] = v } } + // Resources are removed + 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 + updateApplicationInput.Resources[k] = "0" + } + } + } } if !plan.Constraints.Equal(state.Constraints) { diff --git a/internal/provider/resource_application_test.go b/internal/provider/resource_application_test.go index 93066573..6cec8629 100644 --- a/internal/provider/resource_application_test.go +++ b/internal/provider/resource_application_test.go @@ -24,6 +24,9 @@ import ( internaltesting "github.com/juju/terraform-provider-juju/internal/testing" ) +var specialResourceFile = "resource_files/special-foo-file.txt" +var customResourceFile = "resource_files/custom-foo-file.txt" + func TestAcc_ResourceApplication(t *testing.T) { modelName := acctest.RandomWithPrefix("tf-test-application") appName := "test-app" @@ -172,7 +175,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 +184,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,23 +236,52 @@ func TestAcc_ResourceRevisionUpdatesLXD(t *testing.T) { ProtoV6ProviderFactories: frameworkProviderFactories, Steps: []resource.TestStep{ { - Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "juju-qa-test", 21, "", "foo-file", 4), + // deploy with a resource revision + 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), + // update charm revision and update resource to another revision number + Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "juju-qa-test", 20, "", "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), + // update charm revision and update resource to custom resource + Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "juju-qa-test", 22, "", "foo-file", customResourceFile), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("juju_application.juju-qa-test", "resources.foo-file", "4"), + resource.TestCheckResourceAttr("juju_application.juju-qa-test", "resources.foo-file", customResourceFile), + ), + }, + { + // keep charm revision and update resource to another custom resource + Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "juju-qa-test", 22, "", "foo-file", specialResourceFile), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("juju_application.juju-qa-test", "resources.foo-file", specialResourceFile), + ), + }, + { + // update charm revision and update resource to resource revision + Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "juju-qa-test", 21, "", "foo-file", "2"), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("juju_application.juju-qa-test", "resources.foo-file", "2"), + ), + }, + { + // keep charm revision and update resource to another resource revision + Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "juju-qa-test", 21, "", "foo-file", "3"), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("juju_application.juju-qa-test", "resources.foo-file", "2"), + ), + }, + { + // update charm revision and remove resource + Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "juju-qa-test", 21, "", "", ""), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckNoResourceAttr("juju_application.juju-qa-test", "resources"), ), }, }, @@ -267,17 +299,54 @@ func TestAcc_ResourceRevisionAddedToPlanLXD(t *testing.T) { ProtoV6ProviderFactories: frameworkProviderFactories, Steps: []resource.TestStep{ { - Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "juju-qa-test", 20, "", "", -1), + // deploy with no resource + 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), + // update charm revision and resource to a revision number + Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "juju-qa-test", 21, "", "foo-file", "4"), Check: resource.ComposeTestCheckFunc( resource.TestCheckResourceAttr("juju_application.juju-qa-test", "resources.foo-file", "4"), ), }, + { + // keep charm revision and resource to custom resource + Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "juju-qa-test", 21, "", "foo-file", customResourceFile), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("juju_application.juju-qa-test", "resources.foo-file", customResourceFile), + ), + }, + { + // update charm revision and update resource to another custom resource + Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "juju-qa-test", 22, "", "foo-file", specialResourceFile), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("juju_application.juju-qa-test", "resources.foo-file", specialResourceFile), + ), + }, + { + // update charm revision and do not update resource + Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "juju-qa-test", 20, "", "foo-file", specialResourceFile), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("juju_application.juju-qa-test", "resources.foo-file", specialResourceFile), + ), + }, + { + // update charm revision and resource to a revision again + Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "juju-qa-test", 22, "", "foo-file", "3"), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("juju_application.juju-qa-test", "resources.foo-file", "3"), + ), + }, + { + // update charm revision and remove resource + Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "juju-qa-test", 20, "", "", ""), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckNoResourceAttr("juju_application.juju-qa-test", "resources"), + ), + }, }, }) } @@ -293,15 +362,84 @@ func TestAcc_ResourceRevisionRemovedFromPlanLXD(t *testing.T) { ProtoV6ProviderFactories: frameworkProviderFactories, Steps: []resource.TestStep{ { - // we specify the resource revision 4 - Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "juju-qa-test", 20, "", "foo-file", 4), + // deploy with a resource revision + 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), + // update the charm revision and update the resource to a custom resource + Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "juju-qa-test", 24, "", "foo-file", specialResourceFile), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("juju_application.juju-qa-test", "resources.foo-file", specialResourceFile), + ), + }, + { + // update the charm revision and update the resource to another custom resource + Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "juju-qa-test", 22, "", "foo-file", customResourceFile), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("juju_application.juju-qa-test", "resources.foo-file", customResourceFile), + ), + }, + { + // keep the charm revision and update the resource to a resource revision + Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "juju-qa-test", 22, "", "foo-file", "3"), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("juju_application.juju-qa-test", "resources.foo-file", "3"), + ), + }, + { + // keep the charm revision and remove resource revision + Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "juju-qa-test", 22, "", "", ""), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckNoResourceAttr("juju_application.juju-qa-test", "resources"), + ), + }, + }, + }) +} + +func TestAcc_CustomResourceRemovedFromPlanLXD(t *testing.T) { + if testingCloud != LXDCloudTesting { + t.Skip(t.Name() + " only runs with LXD") + } + modelName := acctest.RandomWithPrefix("tf-test-resource-revision-updates-lxd") + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: frameworkProviderFactories, + Steps: []resource.TestStep{ + { + // deploy with a custom resource + Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "juju-qa-test", 20, "", "foo-file", specialResourceFile), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("juju_application.juju-qa-test", "resources.foo-file", specialResourceFile), + ), + }, + { + // update the charm revision and update the resource revision to another custom resource + Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "juju-qa-test", 22, "", "foo-file", customResourceFile), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("juju_application.juju-qa-test", "resources.foo-file", customResourceFile), + ), + }, + { + // keep the charm revision and update resource to a revision number + Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "juju-qa-test", 22, "", "foo-file", "3"), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("juju_application.juju-qa-test", "resources.foo-file", "3"), + ), + }, + { + // update charm revision and update resource to a custom resource again + Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "juju-qa-test", 22, "", "foo-file", specialResourceFile), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("juju_application.juju-qa-test", "resources.foo-file", specialResourceFile), + ), + }, + { + // keep the charm revision and remove resource + Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "juju-qa-test", 22, "", "", ""), Check: resource.ComposeTestCheckFunc( resource.TestCheckNoResourceAttr("juju_application.juju-qa-test", "resources"), ), @@ -321,23 +459,81 @@ func TestAcc_ResourceRevisionUpdatesMicrok8s(t *testing.T) { ProtoV6ProviderFactories: frameworkProviderFactories, Steps: []resource.TestStep{ { - Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "postgresql-k8s", 20, "", "postgresql-image", 152), + // deploy charm with a resource revision + 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), + // update charm revision and update resource revision + Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "postgresql-k8s", 21, "", "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), + // keep charm revision and update resource to resource revision again + Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "postgresql-k8s", 20, "", "postgresql-image", "150"), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("juju_application.postgresql-k8s", "resources.postgresql-image", "152"), + resource.TestCheckResourceAttr("juju_application.postgresql-k8s", "resources.postgresql-image", "150"), + ), + }, + { + // keep charm revision and remove resource revision + Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "postgresql-k8s", 20, "", "", ""), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckNoResourceAttr("juju_application.postgresql-k8s", "resources.postgresql-image"), + ), + }, + }, + }) +} + +func TestAcc_CustomResourceUpdatesMicrok8s(t *testing.T) { + if testingCloud != MicroK8sTesting { + t.Skip(t.Name() + " only runs with Microk8s") + } + modelName := acctest.RandomWithPrefix("tf-test-resource-revision-updates-microk8s") + ausfResourceJsonFile := "resource_files/ausf-image.json" + ausfResourceYamlFile := "resource_files/ausf-image.yaml" + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: frameworkProviderFactories, + Steps: []resource.TestStep{ + { + // deploy charm with a custom resource + Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "sdcore-ausf-k8s", 297, "", "ausf-image", "gatici/sdcore-ausf"), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("juju_application.sdcore-ausf-k8s", "resources.ausf-image", "gatici/sdcore-ausf"), + ), + }, + { + // keep charm revision and update resource to a revision + Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "sdcore-ausf-k8s", 20, "", "ausf-image", "30"), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("juju_application.sdcore-ausf-k8s", "resources.ausf-image", "150"), + ), + }, + { + // update charm revision and update resource using a json file + Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "sdcore-ausf-k8s", 20, "", "ausf-image", ausfResourceJsonFile), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("juju_application.sdcore-ausf-k8s", "resources.ausf-image", "gatici/sdcore-ausf:1.4.0"), + ), + }, + { + // keep charm revision and update resource using a yaml file + Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "sdcore-ausf-k8s", 20, "", "ausf-image", ausfResourceYamlFile), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("juju_application.sdcore-ausf-k8s", "resources.ausf-image", "gatici/sdcore-ausf:1.4"), + ), + }, + { + // keep charm revision and remove resource revision + Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "sdcore-ausf-k8s", 20, "", "", ""), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckNoResourceAttr("juju_application.sdcore-ausf-k8ss", "resources.ausf-image"), ), }, }, @@ -627,7 +823,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", ` diff --git a/internal/provider/resource_files/ausf-image.json b/internal/provider/resource_files/ausf-image.json new file mode 100644 index 00000000..98993cb9 --- /dev/null +++ b/internal/provider/resource_files/ausf-image.json @@ -0,0 +1,3 @@ +{ + "ImageName": "gatici/sdcore-ausf:1.4.0" +} \ No newline at end of file diff --git a/internal/provider/resource_files/ausf-image.yaml b/internal/provider/resource_files/ausf-image.yaml new file mode 100644 index 00000000..3be75d1c --- /dev/null +++ b/internal/provider/resource_files/ausf-image.yaml @@ -0,0 +1 @@ +registrypath: gatici/sdcore-ausf:1.4 diff --git a/internal/provider/resource_files/custom-foo-file.txt b/internal/provider/resource_files/custom-foo-file.txt new file mode 100644 index 00000000..126d4818 --- /dev/null +++ b/internal/provider/resource_files/custom-foo-file.txt @@ -0,0 +1 @@ +testing custom foo file resource. \ No newline at end of file diff --git a/internal/provider/resource_files/special-foo-file.txt b/internal/provider/resource_files/special-foo-file.txt new file mode 100644 index 00000000..221ce876 --- /dev/null +++ b/internal/provider/resource_files/special-foo-file.txt @@ -0,0 +1 @@ +testing special foo file resource. \ No newline at end of file From 30095db2e898996924f704b2fbbc951f06660a9e Mon Sep 17 00:00:00 2001 From: gatici Date: Tue, 2 Jul 2024 10:48:02 +0300 Subject: [PATCH 04/27] feat(resources): address review comments to update to latest resource revision upon channel updates If the charm channel is updated and revision or custom image is not specified in the TF plan, a resource revision should be attached according the updated channel. Formerly, this functionality is not implemented properly. This commit implements this. revert: Unncessary resource files added for testing are removed. chore: Some libraries are updated in go.mod and go.sum. Linting issues are fixed. docs(resources): Documentation is updated for `resources` key to include added functionality. Signed-off-by: gatici --- docs/resources/application.md | 33 +- .../resources/juju_application/resource.tf | 2 +- go.sum | 26 -- internal/juju/applications.go | 25 +- internal/juju/interfaces.go | 3 +- internal/juju/package_test.go | 2 +- internal/provider/resource_application.go | 77 ++-- .../provider/resource_application_test.go | 344 +++++++++++------- .../provider/resource_files/ausf-image.yaml | 2 +- .../resource_files/custom-foo-file.txt | 1 - .../resource_files/special-foo-file.txt | 1 - internal/provider/validator_base.go | 1 - 12 files changed, 299 insertions(+), 218 deletions(-) delete mode 100644 internal/provider/resource_files/custom-foo-file.txt delete mode 100644 internal/provider/resource_files/special-foo-file.txt diff --git a/docs/resources/application.md b/docs/resources/application.md index 87d87211..ae394e0a 100644 --- a/docs/resources/application.md +++ b/docs/resources/application.md @@ -39,7 +39,7 @@ resource "juju_application" "this" { } resource "juju_application" "custom_resources_example" { - name = "placement-example" + name = "custom-resource-example" model = juju_model.development.name charm { name = "hello-kubecon" @@ -73,17 +73,26 @@ resource "juju_application" "custom_resources_example" { - `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 String) Charm resource revisions. Must evaluate to a string. A resource could be a resource revision number from CharmHub or a custom OCI image resource. - - 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. + +There are a few scenarios that need to be considered: +* Charms could be deployed with specifying the resources (from CharmHub or an OCI image repository). If the resources are not specified, the resources which are associated with the Charm in the CharmHub are used. + Resource inputs are provided in a string format. + - Resource revision number from CharmHub (string) + - OCI image information as a URL (string) + - A path of json or yaml file which includes OCI image repository information (string) +* Changing resource input from a revision to a custom OCI resource is processed and updated smoothly according to the provided input. +* Resources could be added to the Terraform plan after deployment. + - If the resources are added to the plan (as a revision number or a custom OCI image resource), specified resources are attached to the application (equivalent to juju attach-resource). +* Charm which includes resources could be updated. + If the plan does specify resource revisions from CharmHub: + - if the charm channel is updated, resources get updated to the latest revision associated with the updated channel. + If the plan does specify custom OCI image resources: + - if the charm channel is updated, existing resources are kept. (Resources are not detached) +* Resources could be removed from the Terraform plan. + If the plan does specify resource revisions from CharmHub or custom OCI images, then resources are removed from the plan: + - If the charm channel is updated, resources get updated to the latest revision associated with the updated charm channel. + - If the charm channel is not updated then the resources get updated to the latest revision associated with the existing charm channel. - `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 2de0ddf5..e9491880 100644 --- a/examples/resources/juju_application/resource.tf +++ b/examples/resources/juju_application/resource.tf @@ -24,7 +24,7 @@ resource "juju_application" "this" { } resource "juju_application" "custom_resources_example" { - name = "placement-example" + name = "custom-resource-example" model = juju_model.development.name charm { name = "hello-kubecon" diff --git a/go.sum b/go.sum index f4c64e7b..77b3ab2b 100644 --- a/go.sum +++ b/go.sum @@ -42,11 +42,8 @@ github.com/ProtonMail/go-crypto v1.1.0-alpha.2 h1:bkyFVUP+ROOARdgCiJzNQo2V2kiB97 github.com/ProtonMail/go-crypto v1.1.0-alpha.2/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= github.com/Rican7/retry v0.3.1 h1:scY4IbO8swckzoA/11HgBwaZRJEyY9vaNJshcdhp1Mc= github.com/Rican7/retry v0.3.1/go.mod h1:CxSDrhAyXmTMeEuRAnArMu1FHu48vtfjLREWqVl7Vw0= -github.com/agext/levenshtein v1.2.1/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= github.com/agext/levenshtein v1.2.2 h1:0S/Yg6LYmFJ5stwQeRp6EeOcCbj7xiqQSdNelsXvaqE= github.com/agext/levenshtein v1.2.2/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= -github.com/apparentlymart/go-dump v0.0.0-20180507223929-23540a00eaa3/go.mod h1:oL81AME2rN47vu18xqj1S1jPIPuN7afo62yKTNn3XMM= -github.com/apparentlymart/go-textseg v1.0.0/go.mod h1:z96Txxhf3xSFMPmb5X/1W05FF/Nj9VFpLOpjS5yuumk= github.com/apparentlymart/go-textseg/v12 v12.0.0/go.mod h1:S/4uRK2UtaQttw1GenVJEynmyUenKwP++x/+DdGV/Ec= github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= @@ -96,7 +93,6 @@ github.com/bmatcuk/doublestar/v4 v4.6.1 h1:FH9SifrbvJhnlQpztAx++wlkk70QBf0iBWDwN github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/bmizerany/pat v0.0.0-20210406213842-e4b6760bdd6f h1:gOO/tNZMjjvTKZWpY7YnXC72ULNLErRtp94LountVE8= github.com/bmizerany/pat v0.0.0-20210406213842-e4b6760bdd6f/go.mod h1:8rLXio+WjiTceGBHIoTvn60HIbs7Hm7bcHjyrSqYB9c= -github.com/bsm/go-vlq v0.0.0-20150828105119-ec6e8d4f5f4e/go.mod h1:N+BjUcTjSxc2mtRGSCPsat1kze3CUtvJN3/jTXlp29k= github.com/bufbuild/protocompile v0.4.0 h1:LbFKd2XowZvQ/kajzguUp2DC9UEIQhIq77fZZlaQsNA= github.com/bufbuild/protocompile v0.4.0/go.mod h1:3v93+mbWn/v3xzN+31nwkJfrEpAUwp+BagBSZWx+TP8= github.com/canonical/go-dqlite v1.21.0 h1:4gLDdV2GF+vg0yv9Ff+mfZZNQ1JGhnQ3GnS2GeZPHfA= @@ -150,7 +146,6 @@ github.com/frankban/quicktest v1.7.2/go.mod h1:jaStnuzAqU1AJdCO0l53JDCJrVDKcS03D github.com/frankban/quicktest v1.10.0/go.mod h1:ui7WezCLWMWxVWr1GETZY3smRy0G4KWq9vcPtJmFl7Y= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= github.com/go-git/go-billy/v5 v5.5.0 h1:yEY4yhzCDuMGSv83oGxiBotRzhwhNr8VZyphhiu+mTU= @@ -239,7 +234,6 @@ github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/ github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= github.com/hashicorp/cli v1.1.6 h1:CMOV+/LJfL1tXCOKrgAX0uRKnzjj/mpmqNXloRSy2K8= github.com/hashicorp/cli v1.1.6/go.mod h1:MPon5QYlgjjo0BSoAiN0ESeT5fRzDjVRp+uioJ0piz4= -github.com/hashicorp/errwrap v0.0.0-20180715044906-d6c0cd880357/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -252,7 +246,6 @@ github.com/hashicorp/go-cty v1.4.1-0.20200414143053-d3edf31b6320 h1:1/D3zfFHttUK github.com/hashicorp/go-cty v1.4.1-0.20200414143053-d3edf31b6320/go.mod h1:EiZBMaudVLy8fmjf9Npq1dq9RalhveqZG5w/yz3mHWs= github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= -github.com/hashicorp/go-multierror v0.0.0-20180717150148-3d5d8f294aa0/go.mod h1:JMRHfdO9jKNzS/+BTlxCjKNQHg/jZAft8U7LloJvN7I= github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= @@ -307,7 +300,6 @@ github.com/hashicorp/vault/api v1.10.0 h1:/US7sIjWN6Imp4o/Rj1Ce2Nr5bki/AXi9vAW3p github.com/hashicorp/vault/api v1.10.0/go.mod h1:jo5Y/ET+hNyz+JnKDt8XLAdKs+AM0G5W0Vp1IrFI8N8= github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE= github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ= -github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/huandu/xstrings v1.3.3 h1:/Gcsuc1x8JVbJ9/rlye4xZnVAbEkGauT8lbebqcQws4= github.com/huandu/xstrings v1.3.3/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/im7mortal/kmutex v1.0.1 h1:zAACzjwD+OEknDqnLdvRa/BhzFM872EBwKijviGLc9Q= @@ -317,7 +309,6 @@ github.com/imdario/mergo v0.3.15 h1:M8XP7IuFNsqUx6VPK2P9OSmsYsI/YFaGil0uD21V3dM= github.com/imdario/mergo v0.3.15/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= -github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jhump/protoreflect v1.15.1 h1:HUMERORf3I3ZdX05WaQ6MIpd/NJ434hTp5YiKgfCL6c= github.com/jhump/protoreflect v1.15.1/go.mod h1:jD/2GMKKE6OqX8qTjhADU1e6DShO+gavG9e0Q693nKo= github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= @@ -442,7 +433,6 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348/go.mod h1:B69LEHPfb2qLo0BaaOLcbitczOKLWTsrBG9LczfCD4k= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lestrrat-go/blackmagic v1.0.2 h1:Cg2gVSc9h7sz9NOByczrbUvLopQmXrfFx//N+AkAr5k= @@ -520,7 +510,6 @@ github.com/mitchellh/go-linereader v0.0.0-20190213213312-1b945b3263eb h1:GRiLv4r github.com/mitchellh/go-linereader v0.0.0-20190213213312-1b945b3263eb/go.mod h1:OaY7UOoTkkrX3wRwjpYRKafIkkyeD0UtweSHAWWiqQM= github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU= github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8= -github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= github.com/mitchellh/go-wordwrap v1.0.0 h1:6GlHJ/LTGMrIJbwgdqdl2eEH8o+Exx/0m8ir9Gns0u4= github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= @@ -549,11 +538,8 @@ github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= github.com/oklog/run v1.0.0 h1:Ru7dDtJNOyC66gQ5dQmaCa0qIsAUFY3sFpK1Xk8igrw= github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA= -github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo/v2 v2.13.0 h1:0jY9lJquiL8fcf3M4LAXN5aMlS/b2BV86HFFPCPMgE4= github.com/onsi/ginkgo/v2 v2.13.0/go.mod h1:TE309ZR8s5FsKKpuB1YAQYBzCaAfUgatB/xlT/ETL/o= -github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/onsi/gomega v1.29.0 h1:KIA/t2t5UBzoirT4H9tsML45GEbo3ouUnBHsCfD2tVg= github.com/onsi/gomega v1.29.0/go.mod h1:9sxs+SwGrKI0+PWe4Fxa9tFQQBG5xSsSbMXOI8PPpoQ= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= @@ -603,7 +589,6 @@ github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkB github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= -github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= @@ -616,7 +601,6 @@ github.com/skeema/knownhosts v1.2.2/go.mod h1:xYbVRSPxqBZFrdmDyMmsOs+uX1UZC3nTN3 github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= -github.com/spf13/pflag v1.0.2/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/std-uritemplate/std-uritemplate/go v0.0.47 h1:erzz/DR4sOzWr0ca2MgSTkMckpLEsDySaTZwVFQq9zw= @@ -664,7 +648,6 @@ github.com/yuin/goldmark v1.7.1 h1:3bajkSilaCbjdKVsKdZjZCLBNPL9pYzrCakKaf4U49U= github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= github.com/yuin/goldmark-meta v1.1.0 h1:pWw+JLHGZe8Rk0EGsMVssiNb/AaPMHfSRszZeUeiOUc= github.com/yuin/goldmark-meta v1.1.0/go.mod h1:U4spWENafuA7Zyg+Lj5RqK/MF+ovMYtBvXi1lBb2VP0= -github.com/zclconf/go-cty v1.0.0/go.mod h1:xnAOWiHeOqg2nWS62VtQ7pbOu17FtxJNW8RLEih+O3s= github.com/zclconf/go-cty v1.14.4 h1:uXXczd9QDGsgu0i/QFR/hzI5NYCHLf6NQw/atrbnhq8= github.com/zclconf/go-cty v1.14.4/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo= @@ -684,7 +667,6 @@ go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc= golang.org/x/crypto v0.0.0-20180723164146-c126467f60eb/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190404164418-38d8ce5564a5/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE= -golang.org/x/crypto v0.0.0-20190426145343-a29dc8fdc734/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= @@ -704,13 +686,10 @@ golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91 golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180811021610-c39426892332/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190502183928-7f726cade0ab/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -733,11 +712,9 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190502175342-a43fa875dd82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -824,7 +801,6 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV gopkg.in/errgo.v1 v1.0.0/go.mod h1:CxwszS/Xz1C49Ucd2i6Zil5UToP1EmyrFhKaMVbg1mk= gopkg.in/errgo.v1 v1.0.1 h1:oQFRXzZ7CkBGdm1XZm/EbQYaYNNEElNBOd09M6cqNso= gopkg.in/errgo.v1 v1.0.1/go.mod h1:3NjfXwocQRYAPTq4/fzX+CwUhPRcR/azYRhj8G+LqMo= -gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/gobwas/glob.v0 v0.2.3 h1:uLMy+ys6BqRCutdUNyWLlmEnd7VULqh1nsxxV1kj0qQ= gopkg.in/gobwas/glob.v0 v0.2.3/go.mod h1:JgYsZg6HmXzPbMVcSQwXigfIbVWt5ysj8n78j6LiwQY= gopkg.in/httprequest.v1 v1.2.1 h1:pEPLMdF/gjWHnKxLpuCYaHFjc8vAB2wrYjXrqDVC16E= @@ -851,7 +827,6 @@ gopkg.in/tomb.v2 v2.0.0-20161208151619-d5d1b5820637 h1:yiW+nvdHb9LVqSHQBXfZCieqV gopkg.in/tomb.v2 v2.0.0-20161208151619-d5d1b5820637/go.mod h1:BHsqpu/nsuzkT5BpiH1EMZPLyqSMM8JbIavyFACoFNk= gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= -gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= @@ -863,7 +838,6 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -howett.net/plist v0.0.0-20181124034731-591f970eefbb/go.mod h1:vMygbs4qMhSZSc4lCUl2OEE+rDiIIJAIdR4m7MiMcm0= k8s.io/api v0.29.0 h1:NiCdQMY1QOp1H8lfRyeEf8eOwV6+0xA6XEE44ohDX2A= k8s.io/api v0.29.0/go.mod h1:sdVmXoz2Bo/cb77Pxi71IPTSErEW32xa4aXwKH7gfBA= k8s.io/apiextensions-apiserver v0.29.0 h1:0VuspFG7Hj+SxyF/Z/2T0uFbI5gb5LRgEyUVE3Q4lV0= diff --git a/internal/juju/applications.go b/internal/juju/applications.go index 5311a2e8..ced3dedc 100644 --- a/internal/juju/applications.go +++ b/internal/juju/applications.go @@ -64,7 +64,7 @@ const ( const ( // MediaTypeFormData is the media type for file uploads (see mime.FormatMediaType). MediaTypeFormData = "form-data" - // QueryParamPendingID is the query parameter we use to send up the pending id. + // QueryParamPendingID is the query parameter we use to send up the pending ID. QueryParamPendingID = "pendingid" ) @@ -76,7 +76,6 @@ const ( // HeaderContentLength is the header name for the length of a file upload. HeaderContentLength = "Content-Length" // HeaderContentDisposition is the header name for value that holds the filename. - // See mime.ParseMediaType and mime.FormatMediaType. HeaderContentDisposition = "Content-Disposition" ) @@ -1253,7 +1252,6 @@ func (c applicationsClient) UpdateApplication(input *UpdateApplicationInput) err charmsAPIClient := apicharms.NewClient(conn) clientAPIClient := c.getClientAPIClient(conn) modelconfigAPIClient := c.getModelConfigAPIClient(conn) - resourceHttpClient := ResourceHttpClient(conn) resourcesAPIClient, err := c.getResourceAPIClient(conn) if err != nil { return err @@ -1294,7 +1292,7 @@ func (c applicationsClient) UpdateApplication(input *UpdateApplicationInput) err // can be changed from one revision to another. So "Revision-Config" // ordering will help to prevent issues with the configuration parsing. if input.Revision != nil || input.Channel != "" || len(input.Resources) != 0 { - setCharmConfig, err := c.computeSetCharmConfig(input, applicationAPIClient, charmsAPIClient, resourcesAPIClient, resourceHttpClient) + setCharmConfig, err := c.computeSetCharmConfig(input, applicationAPIClient, charmsAPIClient, resourcesAPIClient) if err != nil { return err } @@ -1442,7 +1440,6 @@ func (c applicationsClient) computeSetCharmConfig( applicationAPIClient ApplicationAPIClient, charmsAPIClient *apicharms.Client, resourcesAPIClient ResourceAPIClient, - resourceHttpClient *HttpRequestClient, ) (*apiapplication.SetCharmConfig, error) { oldURL, oldOrigin, err := applicationAPIClient.GetCharmURLOrigin("", input.AppName) if err != nil { @@ -1532,7 +1529,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}}) @@ -1556,7 +1553,6 @@ func (c applicationsClient) updateResources(appName string, resources map[string if err != nil { return nil, err } - filtered, err := utils.GetUpgradeResources( charmID, charmsAPIClient, @@ -1594,8 +1590,9 @@ func addPendingResources(appName string, resourcesToBeAdded map[string]charmreso Origin: charmresources.OriginStore, Revision: -1, } - // If the resource is removed, revision does not exist - // Charm is deployed with default resources according to charm revision or channel + // if the resource is removed, providedRev is 0 + // Then, Charm is deployed with default resources according to channel + // Otherwise, Charm is deployed with the provided revision if providedRev != 0 { resourceFromCharmhub.Revision = providedRev } @@ -1624,9 +1621,16 @@ func addPendingResources(appName string, resourcesToBeAdded map[string]charmreso toReturn[resourceMeta.Name] = toRequestUpload } } + } else { + // If there is no resource revisions, Charm is deployed with default resources according to channel + resourceFromCharmhub := charmresources.Resource{ + Meta: resourceMeta, + Origin: charmresources.OriginStore, + Revision: -1, + } + pendingResourcesforAdd = append(pendingResourcesforAdd, resourceFromCharmhub) } } - if len(pendingResourcesforAdd) != 0 { resourcesReqforAdd := apiresources.AddPendingResourcesArgs{ ApplicationID: appName, @@ -1645,7 +1649,6 @@ func addPendingResources(appName string, resourcesToBeAdded map[string]charmreso toReturn[argsResource.Meta.Name] = toRequestAdd[i] } } - return toReturn, nil } diff --git a/internal/juju/interfaces.go b/internal/juju/interfaces.go index 921fb959..c7ea4bd3 100644 --- a/internal/juju/interfaces.go +++ b/internal/juju/interfaces.go @@ -4,6 +4,8 @@ package juju import ( + "io" + "github.com/juju/charm/v12" charmresources "github.com/juju/charm/v12/resource" "github.com/juju/juju/api" @@ -18,7 +20,6 @@ import ( "github.com/juju/juju/core/secrets" "github.com/juju/juju/rpc/params" "github.com/juju/names/v5" - "io" ) type SharedClient interface { diff --git a/internal/juju/package_test.go b/internal/juju/package_test.go index 1f041758..8a3f724f 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,HttpRequestClient,UploadRequest,ResourceHttpClient,osFilesystem,apicharms.Client,utils.GetUpgradeResources,charmresources.ParseType,resourcecmd.Opensource,typedError,io.ReadSeeker,http.Request,mime.Encoding.Encode,Mime.FormatMediaType,http.NewRequest +//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 //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/resource_application.go b/internal/provider/resource_application.go index bca3d035..ad0cface 100644 --- a/internal/provider/resource_application.go +++ b/internal/provider/resource_application.go @@ -7,6 +7,7 @@ import ( "context" "fmt" "strings" + "unicode" "github.com/dustin/go-humanize" "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" @@ -46,27 +47,25 @@ const ( StorageKey = "storage" resourceKeyMarkdownDescription = ` -Charm resource revisions. Must evaluate to a string. A resource could be a resource revision number or a custom resource. +Charm resources. Must evaluate to a string. A resource could be a resource revision number from CharmHub or a custom OCI image resource. There are a few scenarios that need to be considered: - * If the plan does not specify a resource and resources are added to the plan (as a revision number or a custom resource), specified resources are 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, existing resources are kept. (Resources are not detached) - * If the plan does specify resource revisions and resources are removed from the plan: - - If charm revision/channel is updated, the resources associated with the updated charm revision or channel is attached. - - If the charm revision/channel are not updated then the resources associated with the existing charm revision/channel are attached. - * Charm could be deployed without resource, then resource could be added later. - * Resources could be provided in the following formats: - - A custom repository URL - - A files ends with .txt, .json or .yaml - - A resource revision from CharmHub - * Charm could be deployed with resources. - * If the provided resource revision does not exist during initial deployment or update, Client does not start deployment with an error that resource was not found in the store. - * If the provided custom resource does not exist during initial deployment or update, Client start deployment and charm could not be deployed properly and charm will be in error state. - * If the Provided resource type is not correct then Client fails with incorrect resource error for below scenarios: - - An image is expected but file does not include image URL - - A plain text file is expected but a .json file is provided. - * If the provided resource does not exist then Client fails with path is not valid error. - * If provided resource value is changed from an int to a file then the image resource from the file is attached. - * If provided resource value is changed from a file to an int then image revision is attached. +* Charms could be deployed with specifying the resources (from CharmHub or an OCI image repository). If the resources are not specified, the resources which are associated with the Charm in the CharmHub are used. + Resource inputs are provided in a string format. + - Resource revision number from CharmHub (string) + - OCI image information as a URL (string) + - A path of json or yaml file which includes OCI image repository information (string) +* Changing resource input from a revision to a custom OCI resource is processed and updated smoothly according to the provided input. +* Resources could be added to the Terraform plan after deployment. + - If the resources are added to the plan (as a revision number or a custom OCI image resource), specified resources are attached to the application (equivalent to juju attach-resource). +* Charm which includes resources could be updated. + If the plan does specify resource revisions from CharmHub: + - if the charm channel is updated, resources get updated to the latest revision associated with the updated channel. + If the plan does specify custom OCI image resources: + - if the charm channel is updated, existing resources are kept. (Resources are not detached) +* Resources could be removed from the Terraform plan. + If the plan does specify resource revisions from CharmHub or custom OCI images, then resources are removed from the plan: + - If the charm channel is updated, resources get updated to the latest revision associated with the updated charm channel. + - If the charm channel is not updated then the resources get updated to the latest revision associated with the existing charm channel. ` ) @@ -110,6 +109,17 @@ type applicationResourceModel struct { ID types.String `tfsdk:"id"` } +// isInt checks if strings consists from digits +// Used to detect resources which are given with revision number +func isInt(s string) bool { + for _, c := range s { + if !unicode.IsDigit(c) { + return false + } + } + return true +} + func (r *applicationResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { resp.TypeName = req.ProviderTypeName + "_application" } @@ -974,6 +984,16 @@ func (r *applicationResource) Update(ctx context.Context, req resource.UpdateReq planResourceMap := make(map[string]string) resp.Diagnostics.Append(plan.Resources.ElementsAs(ctx, &planResourceMap, false)...) updateApplicationInput.Resources = planResourceMap + // Resource revisions exists in the plan but channel is updated + // Then, the resources get updated to the latest resource revision according to charm channel + if len(updateApplicationInput.Resources) != 0 && updateApplicationInput.Channel != "" { + for k, v := range updateApplicationInput.Resources { + if isInt(v) { + // Set resource revision to zero gets the latest resource revision from CharmHub + updateApplicationInput.Resources[k] = "0" + } + } + } } else { planResourceMap := make(map[string]string) stateResourceMap := make(map[string]string) @@ -995,12 +1015,27 @@ func (r *applicationResource) Update(ctx context.Context, req resource.UpdateReq } } // 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 + // Set resource revision to zero gets the latest resource revision from CharmHub + updateApplicationInput.Resources[k] = "0" + } + } + } + // Resource revisions exists in the plan but channel is updated + // Then, the resources get updated to the latest resource revision according to channel + if len(planResourceMap) != 0 && updateApplicationInput.Channel != "" { + for k, v := range planResourceMap { + if isInt(v) { + if updateApplicationInput.Resources == nil { + // initialize just in case + updateApplicationInput.Resources = make(map[string]string) + } + // Set resource revision to zero gets the latest resource revision from CharmHub updateApplicationInput.Resources[k] = "0" } } diff --git a/internal/provider/resource_application_test.go b/internal/provider/resource_application_test.go index 6cec8629..bf2397ca 100644 --- a/internal/provider/resource_application_test.go +++ b/internal/provider/resource_application_test.go @@ -13,20 +13,15 @@ import ( "github.com/hashicorp/terraform-plugin-testing/helper/acctest" "github.com/hashicorp/terraform-plugin-testing/helper/resource" "github.com/hashicorp/terraform-plugin-testing/terraform" - apiapplication "github.com/juju/juju/api/client/application" apiclient "github.com/juju/juju/api/client/client" apispaces "github.com/juju/juju/api/client/spaces" "github.com/juju/juju/rpc/params" "github.com/juju/names/v5" - internaljuju "github.com/juju/terraform-provider-juju/internal/juju" internaltesting "github.com/juju/terraform-provider-juju/internal/testing" ) -var specialResourceFile = "resource_files/special-foo-file.txt" -var customResourceFile = "resource_files/custom-foo-file.txt" - func TestAcc_ResourceApplication(t *testing.T) { modelName := acctest.RandomWithPrefix("tf-test-application") appName := "test-app" @@ -236,52 +231,23 @@ func TestAcc_ResourceRevisionUpdatesLXD(t *testing.T) { ProtoV6ProviderFactories: frameworkProviderFactories, Steps: []resource.TestStep{ { - // deploy with a resource revision Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "juju-qa-test", 21, "", "foo-file", "4"), Check: resource.ComposeTestCheckFunc( resource.TestCheckResourceAttr("juju_application.juju-qa-test", "resources.foo-file", "4"), ), }, { - // update charm revision and update resource to another revision number - Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "juju-qa-test", 20, "", "foo-file", "3"), - Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("juju_application.juju-qa-test", "resources.foo-file", "3"), - ), - }, - { - // update charm revision and update resource to custom resource - Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "juju-qa-test", 22, "", "foo-file", customResourceFile), - Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("juju_application.juju-qa-test", "resources.foo-file", customResourceFile), - ), - }, - { - // keep charm revision and update resource to another custom resource - Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "juju-qa-test", 22, "", "foo-file", specialResourceFile), - Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("juju_application.juju-qa-test", "resources.foo-file", specialResourceFile), - ), - }, - { - // update charm revision and update resource to resource revision - Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "juju-qa-test", 21, "", "foo-file", "2"), - Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("juju_application.juju-qa-test", "resources.foo-file", "2"), - ), - }, - { - // keep charm revision and update resource to another resource revision + // change resource revision to 3 Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "juju-qa-test", 21, "", "foo-file", "3"), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("juju_application.juju-qa-test", "resources.foo-file", "2"), + resource.TestCheckResourceAttr("juju_application.juju-qa-test", "resources.foo-file", "3"), ), }, { - // update charm revision and remove resource - Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "juju-qa-test", 21, "", "", ""), + // change back to 4 + Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "juju-qa-test", 21, "", "foo-file", "4"), Check: resource.ComposeTestCheckFunc( - resource.TestCheckNoResourceAttr("juju_application.juju-qa-test", "resources"), + resource.TestCheckResourceAttr("juju_application.juju-qa-test", "resources.foo-file", "4"), ), }, }, @@ -299,241 +265,290 @@ func TestAcc_ResourceRevisionAddedToPlanLXD(t *testing.T) { ProtoV6ProviderFactories: frameworkProviderFactories, Steps: []resource.TestStep{ { - // deploy with no resource Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "juju-qa-test", 20, "", "", ""), Check: resource.ComposeTestCheckFunc( resource.TestCheckNoResourceAttr("juju_application.juju-qa-test", "resources"), ), }, { - // update charm revision and resource to a revision number Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "juju-qa-test", 21, "", "foo-file", "4"), Check: resource.ComposeTestCheckFunc( resource.TestCheckResourceAttr("juju_application.juju-qa-test", "resources.foo-file", "4"), ), }, + }, + }) +} + +func TestAcc_ResourceRevisionRemovedFromPlanLXD(t *testing.T) { + if testingCloud != LXDCloudTesting { + t.Skip(t.Name() + " only runs with LXD") + } + modelName := acctest.RandomWithPrefix("tf-test-resource-revision-updates-lxd") + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: frameworkProviderFactories, + Steps: []resource.TestStep{ { - // keep charm revision and resource to custom resource - Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "juju-qa-test", 21, "", "foo-file", customResourceFile), + // we specify the resource revision 4 + Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "juju-qa-test", 20, "", "foo-file", "4"), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("juju_application.juju-qa-test", "resources.foo-file", customResourceFile), + resource.TestCheckResourceAttr("juju_application.juju-qa-test", "resources.foo-file", "4"), ), }, { - // update charm revision and update resource to another custom resource - Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "juju-qa-test", 22, "", "foo-file", specialResourceFile), + // then remove the resource revision and update the charm revision + Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "juju-qa-test", 21, "", "", ""), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("juju_application.juju-qa-test", "resources.foo-file", specialResourceFile), + resource.TestCheckNoResourceAttr("juju_application.juju-qa-test", "resources"), ), }, + }, + }) +} + +func TestAcc_ResourceRevisionUpdatesMicrok8s(t *testing.T) { + if testingCloud != MicroK8sTesting { + t.Skip(t.Name() + " only runs with Microk8s") + } + modelName := acctest.RandomWithPrefix("tf-test-resource-revision-updates-microk8s") + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: frameworkProviderFactories, + Steps: []resource.TestStep{ { - // update charm revision and do not update resource - Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "juju-qa-test", 20, "", "foo-file", specialResourceFile), + Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "postgresql-k8s", 20, "", "postgresql-image", "152"), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("juju_application.juju-qa-test", "resources.foo-file", specialResourceFile), + resource.TestCheckResourceAttr("juju_application.postgresql-k8s", "resources.postgresql-image", "152"), ), }, { - // update charm revision and resource to a revision again - Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "juju-qa-test", 22, "", "foo-file", "3"), + // change resource revision to 151 + Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "postgresql-k8s", 20, "", "postgresql-image", "151"), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("juju_application.juju-qa-test", "resources.foo-file", "3"), + resource.TestCheckResourceAttr("juju_application.postgresql-k8s", "resources.postgresql-image", "151"), ), }, { - // update charm revision and remove resource - Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "juju-qa-test", 20, "", "", ""), + // change back to 152 + Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "postgresql-k8s", 20, "", "postgresql-image", "152"), Check: resource.ComposeTestCheckFunc( - resource.TestCheckNoResourceAttr("juju_application.juju-qa-test", "resources"), + resource.TestCheckResourceAttr("juju_application.postgresql-k8s", "resources.postgresql-image", "152"), ), }, }, }) } -func TestAcc_ResourceRevisionRemovedFromPlanLXD(t *testing.T) { - if testingCloud != LXDCloudTesting { - t.Skip(t.Name() + " only runs with LXD") +func TestAcc_CustomResourcesAddedToPlanMicrok8s(t *testing.T) { + if testingCloud != MicroK8sTesting { + t.Skip(t.Name() + " only runs with Microk8s") } - modelName := acctest.RandomWithPrefix("tf-test-resource-revision-updates-lxd") - - resource.ParallelTest(t, resource.TestCase{ + modelName := acctest.RandomWithPrefix("tf-test-custom-resource-updates-microk8s") + resource.Test(t, resource.TestCase{ PreCheck: func() { testAccPreCheck(t) }, ProtoV6ProviderFactories: frameworkProviderFactories, Steps: []resource.TestStep{ { - // deploy with a resource revision - Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "juju-qa-test", 20, "", "foo-file", "4"), + // deploy charm without custom resource + Config: testAccResourceApplicationWithoutCustomResources(modelName, "1.3/edge"), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("juju_application.juju-qa-test", "resources.foo-file", "4"), + resource.TestCheckNoResourceAttr("juju_application.this", "resources"), ), }, { - // update the charm revision and update the resource to a custom resource - Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "juju-qa-test", 24, "", "foo-file", specialResourceFile), + // Add a custom resource + Config: testAccResourceApplicationWithCustomResources(modelName, "1.3/edge", "ausf-image", "gatici/sdcore-ausf:1.4"), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("juju_application.juju-qa-test", "resources.foo-file", specialResourceFile), + resource.TestCheckResourceAttr("juju_application.this", "resources.ausf-image", "gatici/sdcore-ausf:1.4"), ), + ExpectNonEmptyPlan: true, }, { - // update the charm revision and update the resource to another custom resource - Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "juju-qa-test", 22, "", "foo-file", customResourceFile), + // Add another custom resource + Config: testAccResourceApplicationWithCustomResources(modelName, "1.3/edge", "ausf-image", "gatici/sdcore-ausf:latest"), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("juju_application.juju-qa-test", "resources.foo-file", customResourceFile), + resource.TestCheckResourceAttr("juju_application.this", "resources.ausf-image", "gatici/sdcore-ausf:latest"), ), + ExpectNonEmptyPlan: true, }, { - // keep the charm revision and update the resource to a resource revision - Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "juju-qa-test", 22, "", "foo-file", "3"), + // Add resource revision + Config: testAccResourceApplicationWithCustomResources(modelName, "1.3/edge", "ausf-image", "30"), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("juju_application.juju-qa-test", "resources.foo-file", "3"), + resource.TestCheckResourceAttr("juju_application.this", "resources.ausf-image", "30"), ), }, { - // keep the charm revision and remove resource revision - Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "juju-qa-test", 22, "", "", ""), + // Remove resource revision + Config: testAccResourceApplicationWithoutCustomResources(modelName, "1.3/edge"), Check: resource.ComposeTestCheckFunc( - resource.TestCheckNoResourceAttr("juju_application.juju-qa-test", "resources"), + resource.TestCheckNoResourceAttr("juju_application.this", "resources"), ), }, }, }) } -func TestAcc_CustomResourceRemovedFromPlanLXD(t *testing.T) { - if testingCloud != LXDCloudTesting { - t.Skip(t.Name() + " only runs with LXD") +func TestAcc_CustomResourceUpdatesMicrok8s(t *testing.T) { + if testingCloud != MicroK8sTesting { + t.Skip(t.Name() + " only runs with Microk8s") } - modelName := acctest.RandomWithPrefix("tf-test-resource-revision-updates-lxd") + modelName := acctest.RandomWithPrefix("tf-test-custom-resource-updates-microk8s") resource.Test(t, resource.TestCase{ PreCheck: func() { testAccPreCheck(t) }, ProtoV6ProviderFactories: frameworkProviderFactories, Steps: []resource.TestStep{ { - // deploy with a custom resource - Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "juju-qa-test", 20, "", "foo-file", specialResourceFile), + // Deploy charm with a custom resource + Config: testAccResourceApplicationWithCustomResources(modelName, "1.3/beta", "ausf-image", "gatici/sdcore-ausf:latest"), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("juju_application.juju-qa-test", "resources.foo-file", specialResourceFile), + resource.TestCheckResourceAttr("juju_application.this", "resources.ausf-image", "gatici/sdcore-ausf:latest"), ), + ExpectNonEmptyPlan: true, }, { - // update the charm revision and update the resource revision to another custom resource - Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "juju-qa-test", 22, "", "foo-file", customResourceFile), + // Keep charm channel and update resource to another custom image + Config: testAccResourceApplicationWithCustomResources(modelName, "1.3/beta", "ausf-image", "gatici/sdcore-ausf:1.4"), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("juju_application.juju-qa-test", "resources.foo-file", customResourceFile), + resource.TestCheckResourceAttr("juju_application.this", "resources.ausf-image", "gatici/sdcore-ausf:1.4"), ), + ExpectNonEmptyPlan: true, }, { - // keep the charm revision and update resource to a revision number - Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "juju-qa-test", 22, "", "foo-file", "3"), + // Update charm channel and update resource to a revision + Config: testAccResourceApplicationWithCustomResources(modelName, "1.3/edge", "ausf-image", "10"), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("juju_application.juju-qa-test", "resources.foo-file", "3"), + resource.TestCheckResourceAttr("juju_application.this", "resources.ausf-image", "10"), ), + ExpectNonEmptyPlan: true, }, { - // update charm revision and update resource to a custom resource again - Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "juju-qa-test", 22, "", "foo-file", specialResourceFile), + // Update charm channel and keep resource revision + Config: testAccResourceApplicationWithCustomResources(modelName, "1.3/beta", "ausf-image", "10"), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("juju_application.juju-qa-test", "resources.foo-file", specialResourceFile), + resource.TestCheckResourceAttr("juju_application.this", "resources.ausf-image", "10"), ), + ExpectNonEmptyPlan: true, }, { - // keep the charm revision and remove resource - Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "juju-qa-test", 22, "", "", ""), + // Keep charm channel and update resource which is given in a json file + Config: testAccResourceApplicationWithCustomResources(modelName, "1.3/beta", "ausf-image", "resource_files/ausf-image.json"), Check: resource.ComposeTestCheckFunc( - resource.TestCheckNoResourceAttr("juju_application.juju-qa-test", "resources"), + resource.TestCheckResourceAttr("juju_application.this", "resources.ausf-image", "resource_files/ausf-image.json"), ), + ExpectNonEmptyPlan: true, }, - }, - }) -} - -func TestAcc_ResourceRevisionUpdatesMicrok8s(t *testing.T) { - if testingCloud != MicroK8sTesting { - t.Skip(t.Name() + " only runs with Microk8s") - } - modelName := acctest.RandomWithPrefix("tf-test-resource-revision-updates-microk8s") - - resource.ParallelTest(t, resource.TestCase{ - PreCheck: func() { testAccPreCheck(t) }, - ProtoV6ProviderFactories: frameworkProviderFactories, - Steps: []resource.TestStep{ { - // deploy charm with a resource revision - Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "postgresql-k8s", 20, "", "postgresql-image", "152"), + // Update charm channel and keep resource which is given in a json file + Config: testAccResourceApplicationWithCustomResources(modelName, "1.3/edge", "ausf-image", "resource_files/ausf-image.json"), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("juju_application.postgresql-k8s", "resources.postgresql-image", "152"), + resource.TestCheckResourceAttr("juju_application.this", "resources.ausf-image", "resource_files/ausf-image.json"), ), + ExpectNonEmptyPlan: true, }, { - // update charm revision and update resource revision - Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "postgresql-k8s", 21, "", "postgresql-image", "151"), + // Keep charm channel and update resource which is given in a yaml file + Config: testAccResourceApplicationWithCustomResources(modelName, "1.3/edge", "ausf-image", "resource_files/ausf-image.yaml"), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("juju_application.postgresql-k8s", "resources.postgresql-image", "151"), + resource.TestCheckResourceAttr("juju_application.this", "resources.ausf-image", "resource_files/ausf-image.yaml"), ), + ExpectNonEmptyPlan: true, }, { - // keep charm revision and update resource to resource revision again - Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "postgresql-k8s", 20, "", "postgresql-image", "150"), + // Update charm channel and keep resource which is given in a yaml file + Config: testAccResourceApplicationWithCustomResources(modelName, "1.3/beta", "ausf-image", "resource_files/ausf-image.yaml"), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("juju_application.postgresql-k8s", "resources.postgresql-image", "150"), + resource.TestCheckResourceAttr("juju_application.this", "resources.ausf-image", "resource_files/ausf-image.yaml"), ), + ExpectNonEmptyPlan: true, }, { - // keep charm revision and remove resource revision - Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "postgresql-k8s", 20, "", "", ""), + // Keep charm channel and remove resource revision + Config: testAccResourceApplicationWithoutCustomResources(modelName, "1.3/beta"), Check: resource.ComposeTestCheckFunc( - resource.TestCheckNoResourceAttr("juju_application.postgresql-k8s", "resources.postgresql-image"), + resource.TestCheckNoResourceAttr("juju_application.this", "resources"), ), }, }, }) } -func TestAcc_CustomResourceUpdatesMicrok8s(t *testing.T) { +func TestAcc_CustomResourcesRemovedFromPlanMicrok8s(t *testing.T) { if testingCloud != MicroK8sTesting { t.Skip(t.Name() + " only runs with Microk8s") } - modelName := acctest.RandomWithPrefix("tf-test-resource-revision-updates-microk8s") - ausfResourceJsonFile := "resource_files/ausf-image.json" - ausfResourceYamlFile := "resource_files/ausf-image.yaml" + 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: testAccResourceApplicationWithRevisionAndConfig(modelName, "sdcore-ausf-k8s", 297, "", "ausf-image", "gatici/sdcore-ausf"), + // Deploy charm with a custom resource + Config: testAccResourceApplicationWithCustomResources(modelName, "1.3/edge", "ausf-image", "gatici/sdcore-ausf:latest"), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("juju_application.this", "resources.ausf-image", "gatici/sdcore-ausf:latest"), + ), + ExpectNonEmptyPlan: true, + }, + { + // Keep charm channel and remove custom resource + Config: testAccResourceApplicationWithoutCustomResources(modelName, "1.3/edge"), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckNoResourceAttr("juju_application.this", "resources"), + ), + }, + { + // Keep charm channel and add resource revision + Config: testAccResourceApplicationWithCustomResources(modelName, "1.3/edge", "ausf-image", "30"), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("juju_application.this", "resources.ausf-image", "30"), + ), + }, + { + // Update charm channel and keep resource revision + Config: testAccResourceApplicationWithCustomResources(modelName, "1.3/beta", "ausf-image", "30"), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("juju_application.sdcore-ausf-k8s", "resources.ausf-image", "gatici/sdcore-ausf"), + resource.TestCheckResourceAttr("juju_application.this", "resources.ausf-image", "30"), ), }, { - // keep charm revision and update resource to a revision - Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "sdcore-ausf-k8s", 20, "", "ausf-image", "30"), + // Update charm channel and remove resource revision + Config: testAccResourceApplicationWithoutCustomResources(modelName, "1.3/edge"), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("juju_application.sdcore-ausf-k8s", "resources.ausf-image", "150"), + resource.TestCheckNoResourceAttr("juju_application.this", "resources"), ), }, { - // update charm revision and update resource using a json file - Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "sdcore-ausf-k8s", 20, "", "ausf-image", ausfResourceJsonFile), + // Keep charm channel and update resource which is given in a json file + Config: testAccResourceApplicationWithCustomResources(modelName, "1.3/edge", "ausf-image", "resource_files/ausf-image.json"), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("juju_application.sdcore-ausf-k8s", "resources.ausf-image", "gatici/sdcore-ausf:1.4.0"), + resource.TestCheckResourceAttr("juju_application.this", "resources.ausf-image", "resource_files/ausf-image.json"), ), + ExpectNonEmptyPlan: true, }, { - // keep charm revision and update resource using a yaml file - Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "sdcore-ausf-k8s", 20, "", "ausf-image", ausfResourceYamlFile), + // Update charm channel and remove image resource which is given in a json file + Config: testAccResourceApplicationWithoutCustomResources(modelName, "1.3/beta"), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("juju_application.sdcore-ausf-k8s", "resources.ausf-image", "gatici/sdcore-ausf:1.4"), + resource.TestCheckNoResourceAttr("juju_application.this", "resources"), ), }, { - // keep charm revision and remove resource revision - Config: testAccResourceApplicationWithRevisionAndConfig(modelName, "sdcore-ausf-k8s", 20, "", "", ""), + // Keep charm channel and add resource which is given in a yaml file + Config: testAccResourceApplicationWithCustomResources(modelName, "1.3/beta", "ausf-image", "resource_files/ausf-image.yaml"), Check: resource.ComposeTestCheckFunc( - resource.TestCheckNoResourceAttr("juju_application.sdcore-ausf-k8ss", "resources.ausf-image"), + resource.TestCheckResourceAttr("juju_application.this", "resources.ausf-image", "resource_files/ausf-image.yaml"), + ), + ExpectNonEmptyPlan: true, + }, + { + // Update charm channel and remove resource + Config: testAccResourceApplicationWithoutCustomResources(modelName, "1.3/edge"), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckNoResourceAttr("juju_application.this", "resources"), ), }, }, @@ -865,6 +880,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 = "sdcore-ausf-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 = "sdcore-ausf-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/resource_files/ausf-image.yaml b/internal/provider/resource_files/ausf-image.yaml index 3be75d1c..46d3bc59 100644 --- a/internal/provider/resource_files/ausf-image.yaml +++ b/internal/provider/resource_files/ausf-image.yaml @@ -1 +1 @@ -registrypath: gatici/sdcore-ausf:1.4 +registrypath: gatici/sdcore-ausf:1.5 diff --git a/internal/provider/resource_files/custom-foo-file.txt b/internal/provider/resource_files/custom-foo-file.txt deleted file mode 100644 index 126d4818..00000000 --- a/internal/provider/resource_files/custom-foo-file.txt +++ /dev/null @@ -1 +0,0 @@ -testing custom foo file resource. \ No newline at end of file diff --git a/internal/provider/resource_files/special-foo-file.txt b/internal/provider/resource_files/special-foo-file.txt deleted file mode 100644 index 221ce876..00000000 --- a/internal/provider/resource_files/special-foo-file.txt +++ /dev/null @@ -1 +0,0 @@ -testing special foo file resource. \ No newline at end of file diff --git a/internal/provider/validator_base.go b/internal/provider/validator_base.go index d0b263a5..c5942eb5 100644 --- a/internal/provider/validator_base.go +++ b/internal/provider/validator_base.go @@ -7,7 +7,6 @@ import ( "context" "github.com/hashicorp/terraform-plugin-framework/schema/validator" - "github.com/juju/juju/core/base" ) From e607e7f6256d3b4c09aa424765806b31c64f925e Mon Sep 17 00:00:00 2001 From: gatici Date: Mon, 8 Jul 2024 12:01:38 +0300 Subject: [PATCH 05/27] revert(docs): removing additional example from resource.tf In the previous commits, a new sample TF plan was added to show the usage of custom resources. This commits reverts this change by adding combining the placement and resource usage example in the same existing TF plan. Signed-off-by: gatici --- docs/resources/application.md | 12 ++++++++---- examples/resources/juju_application/resource.tf | 12 ++++++++++++ 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/docs/resources/application.md b/docs/resources/application.md index ae394e0a..dfdcc942 100644 --- a/docs/resources/application.md +++ b/docs/resources/application.md @@ -26,20 +26,20 @@ resource "juju_application" "this" { } units = 3 - + placement = "0,1,2" storage_directives = { files = "101M" } - + config = { external-hostname = "..." } } -resource "juju_application" "custom_resources_example" { - name = "custom-resource-example" +resource "juju_application" "placement_and_custom_resource_example" { + name = "placement-example" model = juju_model.development.name charm { name = "hello-kubecon" @@ -54,6 +54,10 @@ resource "juju_application" "custom_resources_example" { units = 3 placement = "0,1,2" + + config = { + external-hostname = "..." + } } ``` diff --git a/examples/resources/juju_application/resource.tf b/examples/resources/juju_application/resource.tf index e9491880..5b4fe1b4 100644 --- a/examples/resources/juju_application/resource.tf +++ b/examples/resources/juju_application/resource.tf @@ -12,19 +12,27 @@ resource "juju_application" "this" { units = 3 +<<<<<<< HEAD placement = "0,1,2" storage_directives = { files = "101M" } +======= +>>>>>>> 17accba (Removing additional example from resource.tf) config = { external-hostname = "..." } } +<<<<<<< HEAD resource "juju_application" "custom_resources_example" { name = "custom-resource-example" +======= +resource "juju_application" "placement_example" { + name = "placement-example" +>>>>>>> 17accba (Removing additional example from resource.tf) model = juju_model.development.name charm { name = "hello-kubecon" @@ -39,4 +47,8 @@ resource "juju_application" "custom_resources_example" { units = 3 placement = "0,1,2" + + config = { + external-hostname = "..." + } } \ No newline at end of file From 38538dd4a9196cf672cf5c35795aaade35615a05 Mon Sep 17 00:00:00 2001 From: gatici Date: Mon, 8 Jul 2024 12:03:03 +0300 Subject: [PATCH 06/27] revert(test): use the existing ProviderStableVersion ProviderStableVersion is changed while performing some local tests mistakenly. This change is reverted with this commit. Signed-off-by: gatici --- internal/provider/provider_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/provider/provider_test.go b/internal/provider/provider_test.go index 6027c911..cda38b44 100644 --- a/internal/provider/provider_test.go +++ b/internal/provider/provider_test.go @@ -21,7 +21,7 @@ import ( "github.com/juju/terraform-provider-juju/internal/juju" ) -const TestProviderStableVersion = "0.12.0" +const TestProviderStableVersion = "0.10.1" // providerFactories are used to instantiate the Framework provider during // acceptance testing. From 1e00a30982b8f21164dccedf5fe4745df788f42c Mon Sep 17 00:00:00 2001 From: gatici Date: Mon, 8 Jul 2024 12:03:48 +0300 Subject: [PATCH 07/27] chore: add necessary lines for imports Empty lines are required between different types of imports and these are added within this commit. Signed-off-by: gatici --- internal/provider/resource_application_test.go | 2 ++ internal/provider/validator_base.go | 1 + 2 files changed, 3 insertions(+) diff --git a/internal/provider/resource_application_test.go b/internal/provider/resource_application_test.go index bf2397ca..ef0b1370 100644 --- a/internal/provider/resource_application_test.go +++ b/internal/provider/resource_application_test.go @@ -13,11 +13,13 @@ import ( "github.com/hashicorp/terraform-plugin-testing/helper/acctest" "github.com/hashicorp/terraform-plugin-testing/helper/resource" "github.com/hashicorp/terraform-plugin-testing/terraform" + apiapplication "github.com/juju/juju/api/client/application" apiclient "github.com/juju/juju/api/client/client" apispaces "github.com/juju/juju/api/client/spaces" "github.com/juju/juju/rpc/params" "github.com/juju/names/v5" + internaljuju "github.com/juju/terraform-provider-juju/internal/juju" internaltesting "github.com/juju/terraform-provider-juju/internal/testing" ) diff --git a/internal/provider/validator_base.go b/internal/provider/validator_base.go index c5942eb5..d0b263a5 100644 --- a/internal/provider/validator_base.go +++ b/internal/provider/validator_base.go @@ -7,6 +7,7 @@ import ( "context" "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/juju/juju/core/base" ) From 1bc1e60f8da2a95e4e0114ab2e8b911846ce7e84 Mon Sep 17 00:00:00 2001 From: gatici Date: Mon, 8 Jul 2024 12:32:49 +0300 Subject: [PATCH 08/27] chore: resolve merge conflicts Resolving the merge conflicts with main branch as another merged PRs created some conflicts. Signed-off-by: gatici --- .../resources/juju_application/resource.tf | 34 +++---------------- internal/provider/resource_application.go | 1 + 2 files changed, 5 insertions(+), 30 deletions(-) diff --git a/examples/resources/juju_application/resource.tf b/examples/resources/juju_application/resource.tf index 5b4fe1b4..8dee92a6 100644 --- a/examples/resources/juju_application/resource.tf +++ b/examples/resources/juju_application/resource.tf @@ -10,44 +10,18 @@ resource "juju_application" "this" { series = "trusty" } + resources = { + gosherve-image = "gatici/gosherve:1.0" + } + units = 3 -<<<<<<< HEAD placement = "0,1,2" storage_directives = { files = "101M" } -======= ->>>>>>> 17accba (Removing additional example from resource.tf) - config = { - external-hostname = "..." - } -} - -<<<<<<< HEAD -resource "juju_application" "custom_resources_example" { - name = "custom-resource-example" -======= -resource "juju_application" "placement_example" { - name = "placement-example" ->>>>>>> 17accba (Removing additional example from resource.tf) - model = juju_model.development.name - charm { - name = "hello-kubecon" - channel = "edge" - revision = 14 - series = "trusty" - } - - resources = { - gosherve-image = "gatici/gosherve:1.0" - } - - units = 3 - placement = "0,1,2" - config = { external-hostname = "..." } diff --git a/internal/provider/resource_application.go b/internal/provider/resource_application.go index ad0cface..3724595b 100644 --- a/internal/provider/resource_application.go +++ b/internal/provider/resource_application.go @@ -30,6 +30,7 @@ import ( "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/juju/errors" "github.com/juju/juju/core/constraints" + jujustorage "github.com/juju/juju/storage" "github.com/juju/terraform-provider-juju/internal/juju" From 967f59ed76a21f44b3c65aa62095aad1eff8a93e Mon Sep 17 00:00:00 2001 From: gatici Date: Mon, 8 Jul 2024 13:16:39 +0300 Subject: [PATCH 09/27] docs(resource): simplify the resourceKeyMarkdownDescription This commit reorganizes the resourceKeyMarkdownDescription by simplifying the explanation. Signed-off-by: gatici --- internal/provider/resource_application.go | 25 ++++++++--------------- 1 file changed, 8 insertions(+), 17 deletions(-) diff --git a/internal/provider/resource_application.go b/internal/provider/resource_application.go index 3724595b..6fc30af8 100644 --- a/internal/provider/resource_application.go +++ b/internal/provider/resource_application.go @@ -50,23 +50,14 @@ const ( resourceKeyMarkdownDescription = ` Charm resources. Must evaluate to a string. A resource could be a resource revision number from CharmHub or a custom OCI image resource. There are a few scenarios that need to be considered: -* Charms could be deployed with specifying the resources (from CharmHub or an OCI image repository). If the resources are not specified, the resources which are associated with the Charm in the CharmHub are used. - Resource inputs are provided in a string format. - - Resource revision number from CharmHub (string) - - OCI image information as a URL (string) - - A path of json or yaml file which includes OCI image repository information (string) -* Changing resource input from a revision to a custom OCI resource is processed and updated smoothly according to the provided input. -* Resources could be added to the Terraform plan after deployment. - - If the resources are added to the plan (as a revision number or a custom OCI image resource), specified resources are attached to the application (equivalent to juju attach-resource). -* Charm which includes resources could be updated. - If the plan does specify resource revisions from CharmHub: - - if the charm channel is updated, resources get updated to the latest revision associated with the updated channel. - If the plan does specify custom OCI image resources: - - if the charm channel is updated, existing resources are kept. (Resources are not detached) -* Resources could be removed from the Terraform plan. - If the plan does specify resource revisions from CharmHub or custom OCI images, then resources are removed from the plan: - - If the charm channel is updated, resources get updated to the latest revision associated with the updated charm channel. - - If the charm channel is not updated then the resources get updated to the latest revision associated with the existing charm channel. + +* Specify a resource other than the default for a charm. Note that not all charms have resources. 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 are specified in the plan, Juju will use the resource defined in the charm's specified channel. Juju does not allow resources to be removed from an application. + +* If a charm is refreshed, by changing the charm revision or channel, the resource is also refreshed to the current defined channel listed for the charm if the resource is specified by revision. This is normal behavior for juju but not typical behavior for terraform. + +* 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. ` ) From facf25911e8871fb746f5ec3e873bf837996e477 Mon Sep 17 00:00:00 2001 From: gatici Date: Tue, 23 Jul 2024 11:49:46 +0300 Subject: [PATCH 10/27] style: adding resources.go file This PR adds new functions to upload OCI images and handle upload requests. In the initial commit, all these functions were added to the `applications.go` file which reduces the readability of code. This commit puts all the functions which are used to process OCI image resources in a separate file named `resources.go' to reduce the complexity and increase the readilibity. docs(application): Application documentation is updated by adding custom resource usage in the existing sample TF plan. Application optional TF plan parameters are updated by changing the description of `resources` map. test(resources): A few tests are added for the functions in the resources.go. This tests will be expanded in the next commits. Signed-off-by: gatici --- docs/resources/application.md | 54 ++----- internal/juju/applications.go | 212 +-------------------------- internal/juju/applications_test.go | 17 --- internal/juju/package_test.go | 4 +- internal/juju/resources.go | 223 +++++++++++++++++++++++++++++ internal/juju/resources_test.go | 23 +++ main.go | 2 +- 7 files changed, 263 insertions(+), 272 deletions(-) create mode 100644 internal/juju/resources.go create mode 100644 internal/juju/resources_test.go diff --git a/docs/resources/application.md b/docs/resources/application.md index dfdcc942..15d0ed6c 100644 --- a/docs/resources/application.md +++ b/docs/resources/application.md @@ -25,35 +25,17 @@ resource "juju_application" "this" { series = "trusty" } + resources = { + gosherve-image = "gatici/gosherve:1.0" + } + units = 3 - + placement = "0,1,2" storage_directives = { files = "101M" } - - config = { - external-hostname = "..." - } -} - -resource "juju_application" "placement_and_custom_resource_example" { - name = "placement-example" - model = juju_model.development.name - charm { - name = "hello-kubecon" - channel = "edge" - revision = 14 - series = "trusty" - } - - resources = { - gosherve-image = "gatici/gosherve:1.0" - } - - units = 3 - placement = "0,1,2" config = { external-hostname = "..." @@ -78,25 +60,15 @@ resource "juju_application" "placement_and_custom_resource_example" { - `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 String) Charm resources. Must evaluate to a string. A resource could be a resource revision number from CharmHub or a custom OCI image resource. - There are a few scenarios that need to be considered: -* Charms could be deployed with specifying the resources (from CharmHub or an OCI image repository). If the resources are not specified, the resources which are associated with the Charm in the CharmHub are used. - Resource inputs are provided in a string format. - - Resource revision number from CharmHub (string) - - OCI image information as a URL (string) - - A path of json or yaml file which includes OCI image repository information (string) -* Changing resource input from a revision to a custom OCI resource is processed and updated smoothly according to the provided input. -* Resources could be added to the Terraform plan after deployment. - - If the resources are added to the plan (as a revision number or a custom OCI image resource), specified resources are attached to the application (equivalent to juju attach-resource). -* Charm which includes resources could be updated. - If the plan does specify resource revisions from CharmHub: - - if the charm channel is updated, resources get updated to the latest revision associated with the updated channel. - If the plan does specify custom OCI image resources: - - if the charm channel is updated, existing resources are kept. (Resources are not detached) -* Resources could be removed from the Terraform plan. - If the plan does specify resource revisions from CharmHub or custom OCI images, then resources are removed from the plan: - - If the charm channel is updated, resources get updated to the latest revision associated with the updated charm channel. - - If the charm channel is not updated then the resources get updated to the latest revision associated with the existing charm channel. + +* Specify a resource other than the default for a charm. Note that not all charms have resources. 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 are specified in the plan, Juju will use the resource defined in the charm's specified channel. Juju does not allow resources to be removed from an application. + +* If a charm is refreshed, by changing the charm revision or channel, the resource is also refreshed to the current defined channel listed for the charm if the resource is specified by revision. This is normal behavior for juju but not typical behavior for terraform. + +* 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/internal/juju/applications.go b/internal/juju/applications.go index ced3dedc..bc4e93dd 100644 --- a/internal/juju/applications.go +++ b/internal/juju/applications.go @@ -12,17 +12,12 @@ import ( "context" "errors" "fmt" - "io" "math" - "mime" - "net/http" - "os" "reflect" "sort" "strconv" "strings" "time" - "unicode" "github.com/juju/charm/v12" charmresources "github.com/juju/charm/v12/resource" @@ -38,10 +33,8 @@ import ( apiresources "github.com/juju/juju/api/client/resources" apispaces "github.com/juju/juju/api/client/spaces" apicommoncharm "github.com/juju/juju/api/common/charm" - jujuhttp "github.com/juju/juju/api/http" "github.com/juju/juju/cmd/juju/application/utils" resourcecmd "github.com/juju/juju/cmd/juju/resource" - "github.com/juju/juju/cmd/modelcmd" corebase "github.com/juju/juju/core/base" "github.com/juju/juju/core/constraints" "github.com/juju/juju/core/instance" @@ -57,94 +50,13 @@ import ( goyaml "gopkg.in/yaml.v2" ) -const ( - // ContentTypeRaw is the HTTP content-type value used for raw, unformatted content. - ContentTypeRaw = "application/octet-stream" -) -const ( - // MediaTypeFormData is the media type for file uploads (see mime.FormatMediaType). - MediaTypeFormData = "form-data" - // QueryParamPendingID is the query parameter we use to send up the pending ID. - QueryParamPendingID = "pendingid" -) - -const ( - // HeaderContentType is the header name for the type of file upload. - HeaderContentType = "Content-Type" - // HeaderContentSha384 is the header name for the sha hash of a file upload. - HeaderContentSha384 = "Content-Sha384" - // HeaderContentLength is the header name for the length of a file upload. - HeaderContentLength = "Content-Length" - // HeaderContentDisposition is the header name for value that holds the filename. - HeaderContentDisposition = "Content-Disposition" -) - -const ( - // HTTPEndpointPath is the URL path, with substitutions, for a resource request. - HTTPEndpointPath = "/applications/%s/resources/%s" -) - -const FilenameParamForContentDispositionHeader = "filename" - -// UploadRequest defines a single upload request. -type UploadRequest struct { - // Application is the application ID. - Application string - - // Name is the resource name. - Name string - - // Filename is the name of the file as it exists on disk. - Filename string - - // Size is the size of the uploaded data, in bytes. - Size int64 - - // Fingerprint is the fingerprint of the uploaded data. - Fingerprint charmresources.Fingerprint - - // PendingID is the pending ID to associate with this upload, if any. - PendingID string - - // Content is the content to upload. - Content io.ReadSeeker -} - -type HttpRequestClient struct { - base.ClientFacade - facade base.FacadeCaller - httpClient jujuhttp.HTTPDoer -} - -type osFilesystem struct{} +var ApplicationNotFoundError = &applicationNotFoundError{} // ApplicationNotFoundError type applicationNotFoundError struct { appName string } -var ApplicationNotFoundError = &applicationNotFoundError{} - -// newEndpointPath returns the API URL path for the identified resource. -func newEndpointPath(application string, name string) string { - return fmt.Sprintf(HTTPEndpointPath, application, name) -} - -// ResourceHttpClient returns a new Client for the given raw API caller. -func ResourceHttpClient(apiCaller base.APICallCloser) *HttpRequestClient { - frontend, backend := base.NewClientFacade(apiCaller, "Resources") - - httpClient, err := apiCaller.HTTPClient() - if err != nil { - return nil - } - return &HttpRequestClient{ - ClientFacade: frontend, - facade: backend, - httpClient: httpClient, - } -} - func (ae *applicationNotFoundError) Error() string { return fmt.Sprintf("application %s not found", ae.appName) } @@ -410,37 +322,6 @@ type DestroyApplicationInput struct { ModelName string } -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) -} - -// isInt checks if strings consists from digits -// Used to detect resources which are given with revision number -func isInt(s string) bool { - for _, c := range s { - if !unicode.IsDigit(c) { - return false - } - } - return true -} - func resolveCharmURL(charmName string) (*charm.URL, error) { path, err := charm.EnsureSchema(charmName, charm.CharmHub) if err != nil { @@ -1652,97 +1533,6 @@ func addPendingResources(appName string, resourcesToBeAdded map[string]charmreso return toReturn, nil } -// Upload sends the provided resource blob up to Juju. -func upload(appName, name, filename, pendingID string, reader io.ReadSeeker, resourceHttpClient *HttpRequestClient) error { - uReq, err := apiresources.NewUploadRequest(appName, name, filename, reader) - if err != nil { - return jujuerrors.Trace(err) - } - if pendingID != "" { - uReq.PendingID = pendingID - } - req, err := uReq.HTTPRequest() - if err != nil { - return jujuerrors.Trace(err) - } - var response params.UploadResult - if err := resourceHttpClient.httpClient.Do(resourceHttpClient.facade.RawAPICaller().Context(), req, &response); err != nil { - return jujuerrors.Trace(err) - } - - return nil -} - -// setFilename sets a name to the file. -func setFilename(filename string, req *http.Request) { - filename = mime.BEncoding.Encode("utf-8", filename) - - disp := mime.FormatMediaType( - MediaTypeFormData, - map[string]string{FilenameParamForContentDispositionHeader: filename}, - ) - - req.Header.Set(HeaderContentDisposition, disp) -} - -// HTTPRequest generates a new HTTP request. -func (ur UploadRequest) HTTPRequest() (*http.Request, error) { - urlStr := newEndpointPath(ur.Application, ur.Name) - - req, err := http.NewRequest(http.MethodPut, urlStr, ur.Content) - if err != nil { - return nil, jujuerrors.Trace(err) - } - - req.Header.Set(HeaderContentType, ContentTypeRaw) - req.Header.Set(HeaderContentSha384, ur.Fingerprint.String()) - req.Header.Set(HeaderContentLength, fmt.Sprint(ur.Size)) - setFilename(ur.Filename, req) - - req.ContentLength = ur.Size - - if ur.PendingID != "" { - query := req.URL.Query() - query.Set(QueryParamPendingID, ur.PendingID) - req.URL.RawQuery = query.Encode() - } - - return req, nil -} - -// 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, - resourceHttpClient *HttpRequestClient) error { - if pendingResources == nil { - return nil - } - pendingID := "" - - 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 := upload(appName, pendingResUpload.Name, pendingResUpload.Filename, pendingID, r, resourceHttpClient) - - if uploadErr != nil { - return jujuerrors.Trace(uploadErr) - } - } - return nil -} - func computeUpdatedBindings(modelDefaultSpace string, currentBindings map[string]string, inputBindings map[string]string, appName string) (params.ApplicationMergeBindingsArgs, error) { var defaultSpace string oldDefault := currentBindings[""] diff --git a/internal/juju/applications_test.go b/internal/juju/applications_test.go index 341a69f4..0235cc8f 100644 --- a/internal/juju/applications_test.go +++ b/internal/juju/applications_test.go @@ -19,7 +19,6 @@ import ( "github.com/juju/names/v4" "github.com/juju/utils/v3" "github.com/juju/version/v2" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" "go.uber.org/mock/gomock" ) @@ -389,19 +388,3 @@ func (s *ApplicationSuite) TestReadApplicationRetryNotFoundStorageNotFoundError( func TestApplicationSuite(t *testing.T) { suite.Run(t, new(ApplicationSuite)) } - -func TestNewEndpointPath(t *testing.T) { - application := "ausf" - name := "sdcore-ausf-k8s" - want := "/applications/ausf/resources/sdcore-ausf-k8s" - got := newEndpointPath(application, name) - assert.Equal(t, got, want) -} - -func TestNewEndpointPathEmptyInputs(t *testing.T) { - application := "" - name := "" - want := "/applications//resources/" - got := newEndpointPath(application, name) - assert.Equal(t, got, want) -} diff --git a/internal/juju/package_test.go b/internal/juju/package_test.go index 8a3f724f..a2317490 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 -//go:generate go run go.uber.org/mock/mockgen -package juju -destination jujuapi_mock_test.go github.com/juju/juju/api Connection +////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 +////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/juju/resources.go b/internal/juju/resources.go new file mode 100644 index 00000000..383f8c9b --- /dev/null +++ b/internal/juju/resources.go @@ -0,0 +1,223 @@ +package juju + +import ( + "fmt" + "io" + "mime" + "net/http" + "os" + "unicode" + + charmresources "github.com/juju/charm/v12/resource" + jujuerrors "github.com/juju/errors" + "github.com/juju/juju/api/base" + apiapplication "github.com/juju/juju/api/client/application" + apiresources "github.com/juju/juju/api/client/resources" + jujuhttp "github.com/juju/juju/api/http" + resourcecmd "github.com/juju/juju/cmd/juju/resource" + "github.com/juju/juju/cmd/modelcmd" + "github.com/juju/juju/rpc/params" +) + +// newEndpointPath returns the API URL path for the identified resource. +func newEndpointPath(application string, name string) string { + return fmt.Sprintf(HTTPEndpointPath, application, name) +} + +// ResourceHttpClient returns a new Client for the given raw API caller. +func ResourceHttpClient(apiCaller base.APICallCloser) *HttpRequestClient { + frontend, backend := base.NewClientFacade(apiCaller, "Resources") + + httpClient, err := apiCaller.HTTPClient() + if err != nil { + return nil + } + return &HttpRequestClient{ + ClientFacade: frontend, + facade: backend, + httpClient: httpClient, + } +} + +const ( + // ContentTypeRaw is the HTTP content-type value used for raw, unformatted content. + ContentTypeRaw = "application/octet-stream" +) +const ( + // MediaTypeFormData is the media type for file uploads (see mime.FormatMediaType). + MediaTypeFormData = "form-data" + // QueryParamPendingID is the query parameter we use to send up the pending ID. + QueryParamPendingID = "pendingid" +) + +const ( + // HeaderContentType is the header name for the type of file upload. + HeaderContentType = "Content-Type" + // HeaderContentSha384 is the header name for the sha hash of a file upload. + HeaderContentSha384 = "Content-Sha384" + // HeaderContentLength is the header name for the length of a file upload. + HeaderContentLength = "Content-Length" + // HeaderContentDisposition is the header name for value that holds the filename. + HeaderContentDisposition = "Content-Disposition" +) + +const ( + // HTTPEndpointPath is the URL path, with substitutions, for a resource request. + HTTPEndpointPath = "/applications/%s/resources/%s" +) + +const FilenameParamForContentDispositionHeader = "filename" + +// UploadRequest defines a single upload request. +type UploadRequest struct { + // Application is the application ID. + Application string + + // Name is the resource name. + Name string + + // Filename is the name of the file as it exists on disk. + Filename string + + // Size is the size of the uploaded data, in bytes. + Size int64 + + // Fingerprint is the fingerprint of the uploaded data. + Fingerprint charmresources.Fingerprint + + // PendingID is the pending ID to associate with this upload, if any. + PendingID string + + // Content is the content to upload. + Content io.ReadSeeker +} + +type HttpRequestClient struct { + base.ClientFacade + facade base.FacadeCaller + httpClient jujuhttp.HTTPDoer +} + +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) +} + +// isInt checks if strings consists from digits +// Used to detect resources which are given with revision number +func isInt(s string) bool { + for _, c := range s { + if !unicode.IsDigit(c) { + return false + } + } + return true +} + +// Upload sends the provided resource blob up to Juju. +func upload(appName, name, filename, pendingID string, reader io.ReadSeeker, resourceHttpClient *HttpRequestClient) error { + uReq, err := apiresources.NewUploadRequest(appName, name, filename, reader) + if err != nil { + return jujuerrors.Trace(err) + } + if pendingID != "" { + uReq.PendingID = pendingID + } + req, err := uReq.HTTPRequest() + if err != nil { + return jujuerrors.Trace(err) + } + var response params.UploadResult + if err := resourceHttpClient.httpClient.Do(resourceHttpClient.facade.RawAPICaller().Context(), req, &response); err != nil { + return jujuerrors.Trace(err) + } + + return nil +} + +// setFilename sets a name to the file. +func setFilename(filename string, req *http.Request) { + filename = mime.BEncoding.Encode("utf-8", filename) + + disp := mime.FormatMediaType( + MediaTypeFormData, + map[string]string{FilenameParamForContentDispositionHeader: filename}, + ) + + req.Header.Set(HeaderContentDisposition, disp) +} + +// HTTPRequest generates a new HTTP request. +func (ur UploadRequest) HTTPRequest() (*http.Request, error) { + urlStr := newEndpointPath(ur.Application, ur.Name) + + req, err := http.NewRequest(http.MethodPut, urlStr, ur.Content) + if err != nil { + return nil, jujuerrors.Trace(err) + } + + req.Header.Set(HeaderContentType, ContentTypeRaw) + req.Header.Set(HeaderContentSha384, ur.Fingerprint.String()) + req.Header.Set(HeaderContentLength, fmt.Sprint(ur.Size)) + setFilename(ur.Filename, req) + + req.ContentLength = ur.Size + + if ur.PendingID != "" { + query := req.URL.Query() + query.Set(QueryParamPendingID, ur.PendingID) + req.URL.RawQuery = query.Encode() + } + + return req, nil +} + +// 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, + resourceHttpClient *HttpRequestClient) error { + if pendingResources == nil { + return nil + } + pendingID := "" + + 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 := upload(appName, pendingResUpload.Name, pendingResUpload.Filename, pendingID, r, resourceHttpClient) + + if uploadErr != nil { + return jujuerrors.Trace(uploadErr) + } + } + return nil +} diff --git a/internal/juju/resources_test.go b/internal/juju/resources_test.go new file mode 100644 index 00000000..17f21925 --- /dev/null +++ b/internal/juju/resources_test.go @@ -0,0 +1,23 @@ +package juju + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewEndpointPath(t *testing.T) { + application := "ausf" + name := "sdcore-ausf-k8s" + want := "/applications/ausf/resources/sdcore-ausf-k8s" + got := newEndpointPath(application, name) + assert.Equal(t, got, want) +} + +func TestNewEndpointPathEmptyInputs(t *testing.T) { + application := "" + name := "" + want := "/applications//resources/" + got := newEndpointPath(application, name) + assert.Equal(t, got, want) +} diff --git a/main.go b/main.go index 0462bc9d..9ed824bd 100644 --- a/main.go +++ b/main.go @@ -36,7 +36,7 @@ var ( func main() { var debugMode bool - flag.BoolVar(&debugMode, "debug", false, "set to true to run the provider with support for debuggers like delve") + flag.BoolVar(&debugMode, "debug", true, "set to true to run the provider with support for debuggers like delve") flag.Parse() var serveOpts []tf6server.ServeOpt From 00dd25164885f41f485495319134dd725fe3eff1 Mon Sep 17 00:00:00 2001 From: gatici Date: Tue, 23 Jul 2024 18:32:12 +0300 Subject: [PATCH 11/27] fix(application): use -1 to indicate that revision is not provided We would like to indicate that default revision number from the CharmHub should be used by providing an invalid revision number. Formerly "0" was used but this is now fixed by replacing it with "-1" in this commit. Signed-off-by: gatici --- internal/juju/applications.go | 4 ++-- internal/juju/resources.go | 9 ++++----- internal/provider/resource_application.go | 6 +++--- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/internal/juju/applications.go b/internal/juju/applications.go index bc4e93dd..788beb04 100644 --- a/internal/juju/applications.go +++ b/internal/juju/applications.go @@ -1471,10 +1471,10 @@ func addPendingResources(appName string, resourcesToBeAdded map[string]charmreso Origin: charmresources.OriginStore, Revision: -1, } - // if the resource is removed, providedRev is 0 + // 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 - if providedRev != 0 { + if providedRev != -1 { resourceFromCharmhub.Revision = providedRev } pendingResourcesforAdd = append(pendingResourcesforAdd, resourceFromCharmhub) diff --git a/internal/juju/resources.go b/internal/juju/resources.go index 383f8c9b..4147a9c0 100644 --- a/internal/juju/resources.go +++ b/internal/juju/resources.go @@ -6,7 +6,7 @@ import ( "mime" "net/http" "os" - "unicode" + "strconv" charmresources "github.com/juju/charm/v12/resource" jujuerrors "github.com/juju/errors" @@ -123,10 +123,9 @@ func (osFilesystem) Stat(name string) (os.FileInfo, error) { // isInt checks if strings consists from digits // Used to detect resources which are given with revision number func isInt(s string) bool { - for _, c := range s { - if !unicode.IsDigit(c) { - return false - } + _, err := strconv.Atoi(s) + if err != nil { + return false } return true } diff --git a/internal/provider/resource_application.go b/internal/provider/resource_application.go index 6fc30af8..0bb7094a 100644 --- a/internal/provider/resource_application.go +++ b/internal/provider/resource_application.go @@ -982,7 +982,7 @@ func (r *applicationResource) Update(ctx context.Context, req resource.UpdateReq for k, v := range updateApplicationInput.Resources { if isInt(v) { // Set resource revision to zero gets the latest resource revision from CharmHub - updateApplicationInput.Resources[k] = "0" + updateApplicationInput.Resources[k] = "-1" } } } @@ -1014,7 +1014,7 @@ func (r *applicationResource) Update(ctx context.Context, req resource.UpdateReq // initialize the resources updateApplicationInput.Resources = make(map[string]string) // Set resource revision to zero gets the latest resource revision from CharmHub - updateApplicationInput.Resources[k] = "0" + updateApplicationInput.Resources[k] = "-1" } } } @@ -1028,7 +1028,7 @@ func (r *applicationResource) Update(ctx context.Context, req resource.UpdateReq updateApplicationInput.Resources = make(map[string]string) } // Set resource revision to zero gets the latest resource revision from CharmHub - updateApplicationInput.Resources[k] = "0" + updateApplicationInput.Resources[k] = "-1" } } } From bd7b47d267b42b0c87b1232aefa15034f0539c74 Mon Sep 17 00:00:00 2001 From: gatici Date: Wed, 24 Jul 2024 15:32:20 +0300 Subject: [PATCH 12/27] feat(application): adding custom plan validator for ResourceKey ResourceKey schema is validated using a custom PlanValidator named StringIsResourceKeyValidator to to ensure that the string is an int or OCI image information as a URL. This will allow for failure during the planning phase if the values are not as expected. Signed-off-by: gatici --- internal/juju/resources.go | 5 +- internal/provider/resource_application.go | 9 ++-- internal/provider/validator_resourcekey.go | 50 +++++++++++++++++++ .../provider/validator_resourcekey_test.go | 12 +++++ 4 files changed, 69 insertions(+), 7 deletions(-) create mode 100644 internal/provider/validator_resourcekey.go create mode 100644 internal/provider/validator_resourcekey_test.go diff --git a/internal/juju/resources.go b/internal/juju/resources.go index 4147a9c0..f367eeac 100644 --- a/internal/juju/resources.go +++ b/internal/juju/resources.go @@ -124,10 +124,7 @@ func (osFilesystem) Stat(name string) (os.FileInfo, error) { // Used to detect resources which are given with revision number func isInt(s string) bool { _, err := strconv.Atoi(s) - if err != nil { - return false - } - return true + return err == nil } // Upload sends the provided resource blob up to Juju. diff --git a/internal/provider/resource_application.go b/internal/provider/resource_application.go index 0bb7094a..1d4987d1 100644 --- a/internal/provider/resource_application.go +++ b/internal/provider/resource_application.go @@ -55,7 +55,7 @@ There are a few scenarios that need to be considered: * A resource can be added or changed at any time. If the charm has resources and none are specified in the plan, Juju will use the resource defined in the charm's specified channel. Juju does not allow resources to be removed from an application. -* If a charm is refreshed, by changing the charm revision or channel, the resource is also refreshed to the current defined channel listed for the charm if the resource is specified by revision. This is normal behavior for juju but not typical behavior for terraform. +* If a charm is refreshed, by changing the charm revision or channel, the resource is also refreshed to the current defined channel listed for the charm if the resource is specified by revision. Please note that this is normal behavior for Juju but not typical behavior for Terraform. * 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. ` @@ -272,8 +272,11 @@ func (r *applicationResource) Schema(_ context.Context, _ resource.SchemaRequest }, }, ResourceKey: schema.MapAttribute{ - Optional: true, - ElementType: types.StringType, + Optional: true, + ElementType: types.StringType, + Validators: []validator.Map{ + StringIsResourceKeyValidator{}, + }, MarkdownDescription: resourceKeyMarkdownDescription, }, }, diff --git a/internal/provider/validator_resourcekey.go b/internal/provider/validator_resourcekey.go new file mode 100644 index 00000000..996933e4 --- /dev/null +++ b/internal/provider/validator_resourcekey.go @@ -0,0 +1,50 @@ +// Copyright 2024 Canonical Ltd. +// Licensed under the AGPLv3, see LICENCE file for details. + +package provider + +import ( + "context" + "fmt" + "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 { + if isInt(value) { + providedRev, err := strconv.Atoi(value) + if err != nil || providedRev <= 0 { + resp.Diagnostics.AddAttributeError( + req.Path, + "Invalid Resource Revision", + fmt.Sprintf("value of %q is expected to be a valid revision number: %s", name, err), + ) + } + } + } +} diff --git a/internal/provider/validator_resourcekey_test.go b/internal/provider/validator_resourcekey_test.go new file mode 100644 index 00000000..c21739da --- /dev/null +++ b/internal/provider/validator_resourcekey_test.go @@ -0,0 +1,12 @@ +// Copyright 2024 Canonical Ltd. +// Licensed under the AGPLv3, see LICENCE file for details. + +package provider_test + +import ( + "testing" +) + +func TestResourceKeyValidatorValid(t *testing.T) { + +} From 84fb47de31d25ac2767d89760232da152e930a85 Mon Sep 17 00:00:00 2001 From: gatici Date: Wed, 24 Jul 2024 15:49:26 +0300 Subject: [PATCH 13/27] refactor(application): rename argument names in addPendingResources and processResources functions This commit renames the arguments in two functions as existing arguments does not identify the correct resource types withing this change.: - func processResources: - resources -> resourcesToUse Argument "resources" are too generic and it is renamed to resourcesToUse. - In addPendingResources: - resourcesToBeAdded -> charmResources resourcesToBeAdded indicates the resources which are available in the charm.Hence charmResources are used to indicate this map. - resourcesRevisions -> resourcesToUse resourceRevisions does not cover the OCI resources, however this map could include resource revisions from CharmHub and custom OCI images. Signed-off-by: gatici --- internal/juju/applications.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/internal/juju/applications.go b/internal/juju/applications.go index 788beb04..338bad13 100644 --- a/internal/juju/applications.go +++ b/internal/juju/applications.go @@ -741,14 +741,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]string) (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 } @@ -757,7 +757,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 @@ -1452,14 +1452,14 @@ 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, resourcesRevisions map[string]string, +func addPendingResources(appName string, charmResources map[string]charmresources.Meta, resourcesToUse map[string]string, charmID apiapplication.CharmID, resourcesAPIClient ResourceAPIClient) (map[string]string, error) { pendingResourcesforAdd := []charmresources.Resource{} toReturn := map[string]string{} - for _, resourceMeta := range resourcesToBeAdded { - if resourcesRevisions != nil { - if deployValue, ok := resourcesRevisions[resourceMeta.Name]; ok { + for _, resourceMeta := range charmResources { + if resourcesToUse != nil { + if deployValue, ok := resourcesToUse[resourceMeta.Name]; ok { if isInt(deployValue) { // A resource revision is provided providedRev, err := strconv.Atoi(deployValue) From 90e5094569071bd83e7921490a97e58be08cdc51 Mon Sep 17 00:00:00 2001 From: gatici Date: Fri, 26 Jul 2024 15:00:22 +0300 Subject: [PATCH 14/27] refactor(application): rename variables in the methods to increase readibility In ReadApplication method, rename resourceRevisions to usedResources chore: Delete unnecassary configuration files which are planned to used provider testing Signed-off-by: gatici --- internal/juju/applications.go | 13 +++++++------ internal/provider/resource_files/ausf-image.json | 3 --- internal/provider/resource_files/ausf-image.yaml | 1 - 3 files changed, 7 insertions(+), 10 deletions(-) delete mode 100644 internal/provider/resource_files/ausf-image.json delete mode 100644 internal/provider/resource_files/ausf-image.yaml diff --git a/internal/juju/applications.go b/internal/juju/applications.go index 338bad13..9a5d7a3b 100644 --- a/internal/juju/applications.go +++ b/internal/juju/applications.go @@ -1081,10 +1081,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]string) + usedResources := make(map[string]string) for _, iResources := range resources { for _, resource := range iResources.Resources { - resourceRevisions[resource.Name] = strconv.Itoa(resource.Revision) + usedResources[resource.Name] = strconv.Itoa(resource.Revision) } } @@ -1103,7 +1103,7 @@ func (c applicationsClient) ReadApplication(input *ReadApplicationInput) (*ReadA Placement: placement, EndpointBindings: endpointBindings, Storage: storages, - Resources: resourceRevisions, + Resources: usedResources, } return response, nil @@ -1133,6 +1133,7 @@ func (c applicationsClient) UpdateApplication(input *UpdateApplicationInput) err charmsAPIClient := apicharms.NewClient(conn) clientAPIClient := c.getClientAPIClient(conn) modelconfigAPIClient := c.getModelConfigAPIClient(conn) + resourcesAPIClient, err := c.getResourceAPIClient(conn) if err != nil { return err @@ -1452,12 +1453,12 @@ func (c applicationsClient) updateResources(appName string, resources map[string return addPendingResources(appName, filtered, resources, charmID, resourcesAPIClient) } -func addPendingResources(appName string, charmResources map[string]charmresources.Meta, resourcesToUse map[string]string, +func addPendingResources(appName string, charmResourcesToAdd map[string]charmresources.Meta, resourcesToUse map[string]string, charmID apiapplication.CharmID, resourcesAPIClient ResourceAPIClient) (map[string]string, error) { pendingResourcesforAdd := []charmresources.Resource{} toReturn := map[string]string{} - for _, resourceMeta := range charmResources { + for _, resourceMeta := range charmResourcesToAdd { if resourcesToUse != nil { if deployValue, ok := resourcesToUse[resourceMeta.Name]; ok { if isInt(deployValue) { @@ -1479,7 +1480,7 @@ func addPendingResources(appName string, charmResources map[string]charmresource } pendingResourcesforAdd = append(pendingResourcesforAdd, resourceFromCharmhub) } else { - // A new resource to be uploaded by the client + // A new resource to be uploaded by the ResourceApi client localResource := charmresources.Resource{ Meta: resourceMeta, Origin: charmresources.OriginUpload, diff --git a/internal/provider/resource_files/ausf-image.json b/internal/provider/resource_files/ausf-image.json deleted file mode 100644 index 98993cb9..00000000 --- a/internal/provider/resource_files/ausf-image.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "ImageName": "gatici/sdcore-ausf:1.4.0" -} \ No newline at end of file diff --git a/internal/provider/resource_files/ausf-image.yaml b/internal/provider/resource_files/ausf-image.yaml deleted file mode 100644 index 46d3bc59..00000000 --- a/internal/provider/resource_files/ausf-image.yaml +++ /dev/null @@ -1 +0,0 @@ -registrypath: gatici/sdcore-ausf:1.5 From bf88c01ac3b9f1de2634030a003aeeab37b2974f Mon Sep 17 00:00:00 2001 From: gatici Date: Fri, 26 Jul 2024 15:08:06 +0300 Subject: [PATCH 15/27] revert: activate the disabled lines which are used mock generation The mistalenly disabled lines are enabled. Signed-off-by: gatici --- internal/juju/package_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/juju/package_test.go b/internal/juju/package_test.go index a2317490..8a3f724f 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 -////go:generate go run go.uber.org/mock/mockgen -package juju -destination jujuapi_mock_test.go github.com/juju/juju/api Connection +//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 +//go:generate go run go.uber.org/mock/mockgen -package juju -destination jujuapi_mock_test.go github.com/juju/juju/api Connection From 0dc83a50d0638500fc6cb23bca488c0dd1064bf0 Mon Sep 17 00:00:00 2001 From: gatici Date: Fri, 26 Jul 2024 15:10:08 +0300 Subject: [PATCH 16/27] docs: update the docstring of isInt method definition Signed-off-by: gatici --- internal/juju/resources.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/juju/resources.go b/internal/juju/resources.go index f367eeac..8a060cec 100644 --- a/internal/juju/resources.go +++ b/internal/juju/resources.go @@ -120,7 +120,7 @@ func (osFilesystem) Stat(name string) (os.FileInfo, error) { return os.Stat(name) } -// isInt checks if strings consists from digits +// isInt checks if strings could be converted to an integer // Used to detect resources which are given with revision number func isInt(s string) bool { _, err := strconv.Atoi(s) From 070fc13ce8588b70273564588aa8b9fb1a703d75 Mon Sep 17 00:00:00 2001 From: gatici Date: Fri, 26 Jul 2024 15:11:34 +0300 Subject: [PATCH 17/27] chore: use the correct ProviderStableVersion The PR was parked for a long time and provider stable version is updated in the main branch during this time period. We need to use the same provider version. Signed-off-by: gatici --- internal/provider/provider_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/provider/provider_test.go b/internal/provider/provider_test.go index cda38b44..6027c911 100644 --- a/internal/provider/provider_test.go +++ b/internal/provider/provider_test.go @@ -21,7 +21,7 @@ import ( "github.com/juju/terraform-provider-juju/internal/juju" ) -const TestProviderStableVersion = "0.10.1" +const TestProviderStableVersion = "0.12.0" // providerFactories are used to instantiate the Framework provider during // acceptance testing. From da0f3d667b1b8dc1f51deb140944e7d4a304beff Mon Sep 17 00:00:00 2001 From: gatici Date: Fri, 26 Jul 2024 15:18:10 +0300 Subject: [PATCH 18/27] fix(application): remove the manipulation to use resource version from Charmhub if a resource revision is provided If a revision is provided and application channel is updated, provided revision number is used in the application. docs(resources): Update the ResourceKey description in the Markdown file chore: Improve the implementation of isInt method Signed-off-by: gatici --- docs/resources/application.md | 4 +-- internal/provider/resource_application.go | 40 ++++------------------- 2 files changed, 8 insertions(+), 36 deletions(-) diff --git a/docs/resources/application.md b/docs/resources/application.md index 15d0ed6c..23174b9b 100644 --- a/docs/resources/application.md +++ b/docs/resources/application.md @@ -64,9 +64,9 @@ There are a few scenarios that need to be considered: * Specify a resource other than the default for a charm. Note that not all charms have resources. 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 are specified in the plan, Juju will use the resource defined in the charm's specified channel. Juju does not allow resources to be removed from an application. +* 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, the resource is also refreshed to the current defined channel listed for the charm if the resource is specified by revision. This is normal behavior for juju but not typical behavior for terraform. +* If a charm is refreshed, by changing the charm revision or channel, the resource is also refreshed to the current defined channel listed for the charm if the resource is specified by revision. * 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)) diff --git a/internal/provider/resource_application.go b/internal/provider/resource_application.go index 1d4987d1..da9ee059 100644 --- a/internal/provider/resource_application.go +++ b/internal/provider/resource_application.go @@ -6,8 +6,8 @@ package provider import ( "context" "fmt" + "strconv" "strings" - "unicode" "github.com/dustin/go-humanize" "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" @@ -53,9 +53,9 @@ There are a few scenarios that need to be considered: * Specify a resource other than the default for a charm. Note that not all charms have resources. 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 are specified in the plan, Juju will use the resource defined in the charm's specified channel. Juju does not allow resources to be removed from an application. +* 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, the resource is also refreshed to the current defined channel listed for the charm if the resource is specified by revision. Please note that this is normal behavior for Juju but not typical behavior for Terraform. +* If a charm is refreshed, by changing the charm revision or channel, the resource is also refreshed to the current defined channel listed for the charm if the resource is specified by revision. * 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. ` @@ -101,15 +101,11 @@ type applicationResourceModel struct { ID types.String `tfsdk:"id"` } -// isInt checks if strings consists from digits +// isInt checks if strings could be converted to an integer // Used to detect resources which are given with revision number func isInt(s string) bool { - for _, c := range s { - if !unicode.IsDigit(c) { - return false - } - } - return true + _, err := strconv.Atoi(s) + return err == nil } func (r *applicationResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { @@ -979,16 +975,6 @@ func (r *applicationResource) Update(ctx context.Context, req resource.UpdateReq planResourceMap := make(map[string]string) resp.Diagnostics.Append(plan.Resources.ElementsAs(ctx, &planResourceMap, false)...) updateApplicationInput.Resources = planResourceMap - // Resource revisions exists in the plan but channel is updated - // Then, the resources get updated to the latest resource revision according to charm channel - if len(updateApplicationInput.Resources) != 0 && updateApplicationInput.Channel != "" { - for k, v := range updateApplicationInput.Resources { - if isInt(v) { - // Set resource revision to zero gets the latest resource revision from CharmHub - updateApplicationInput.Resources[k] = "-1" - } - } - } } else { planResourceMap := make(map[string]string) stateResourceMap := make(map[string]string) @@ -1021,20 +1007,6 @@ func (r *applicationResource) Update(ctx context.Context, req resource.UpdateReq } } } - // Resource revisions exists in the plan but channel is updated - // Then, the resources get updated to the latest resource revision according to channel - if len(planResourceMap) != 0 && updateApplicationInput.Channel != "" { - for k, v := range planResourceMap { - if isInt(v) { - if updateApplicationInput.Resources == nil { - // initialize just in case - 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) { From da99a6cb0d0fc507b7c5d67d0eab8299ed437dfd Mon Sep 17 00:00:00 2001 From: gatici Date: Fri, 26 Jul 2024 16:29:28 +0300 Subject: [PATCH 19/27] feat(resources): extend the custom plan validator to validate image repository URL format Images are provided in allowed URL pattern and it is validated using regex format. Juju APIs can not resolve the image provided in digest format so this format is not allowed. Signed-off-by: gatici --- internal/provider/validator_resourcekey.go | 27 +++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/internal/provider/validator_resourcekey.go b/internal/provider/validator_resourcekey.go index 996933e4..f99fbfca 100644 --- a/internal/provider/validator_resourcekey.go +++ b/internal/provider/validator_resourcekey.go @@ -6,6 +6,7 @@ package provider import ( "context" "fmt" + "regexp" "strconv" "github.com/hashicorp/terraform-plugin-framework/schema/validator" @@ -38,13 +39,33 @@ func (v StringIsResourceKeyValidator) ValidateMap(ctx context.Context, req valid for name, value := range resourceKey { if isInt(value) { providedRev, err := strconv.Atoi(value) - if err != nil || providedRev <= 0 { + if err != nil { resp.Diagnostics.AddAttributeError( req.Path, - "Invalid Resource Revision", - fmt.Sprintf("value of %q is expected to be a valid revision number: %s", name, err), + "Invalid Resource revision", + fmt.Sprintf("value of %q should be a valid revision number or image URL: %s", name, err), ) } + if providedRev <= 0 { + resp.Diagnostics.AddAttributeError( + req.Path, + "Invalid Resource revision", + fmt.Sprintf("value of %q should be a valid revision number or image URL: %s", name, "Negative revision number is invalid."), + ) + } + } else { + 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) { + return + } else { + resp.Diagnostics.AddAttributeError( + req.Path, + "Invalid image URL", + fmt.Sprintf("value of %q should be a valid revision number or image URL: %s", name, "The value format is invalid as a revision number or for an image URL."), + ) + return + } } } } From 3344f5a0eac638bb1c3acca69db3a0d1ea50be3b Mon Sep 17 00:00:00 2001 From: gatici Date: Fri, 26 Jul 2024 16:40:07 +0300 Subject: [PATCH 20/27] revert: set the debug mode to false Debug mode is set to True during development and mistakenly committed. This change is reverted. Signed-off-by: gatici --- main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.go b/main.go index 9ed824bd..0462bc9d 100644 --- a/main.go +++ b/main.go @@ -36,7 +36,7 @@ var ( func main() { var debugMode bool - flag.BoolVar(&debugMode, "debug", true, "set to true to run the provider with support for debuggers like delve") + flag.BoolVar(&debugMode, "debug", false, "set to true to run the provider with support for debuggers like delve") flag.Parse() var serveOpts []tf6server.ServeOpt From d176fd060a8bff30dd9f5dc6ef1ea74e30b15a91 Mon Sep 17 00:00:00 2001 From: gatici Date: Sat, 3 Aug 2024 01:59:20 +0300 Subject: [PATCH 21/27] fix: addressing review comments docs: Fixing typos and wrong content in method docstrings chore: Updating and adding license header files feat(resource): Remove new added ResourceHttpClient and use existing Juju resourcesAPIClient. To use Juju resourcesAPIClient some additional Charm info required and helper methods are added to under resources.go. chore: Unnecessary spaces are removed. fix: Comments are addressed in validator resourcekey file to increase the readability. Signed-off-by: gatici --- internal/juju/applications.go | 120 ++++---- internal/juju/resources.go | 290 +++++++----------- internal/juju/resources_test.go | 23 -- internal/provider/resource_application.go | 3 +- internal/provider/validator_resourcekey.go | 35 +-- .../provider/validator_resourcekey_test.go | 2 +- 6 files changed, 183 insertions(+), 290 deletions(-) delete mode 100644 internal/juju/resources_test.go diff --git a/internal/juju/applications.go b/internal/juju/applications.go index 9a5d7a3b..24feb383 100644 --- a/internal/juju/applications.go +++ b/internal/juju/applications.go @@ -13,6 +13,7 @@ import ( "errors" "fmt" "math" + "os" "reflect" "sort" "strconv" @@ -35,6 +36,7 @@ import ( apicommoncharm "github.com/juju/juju/api/common/charm" "github.com/juju/juju/cmd/juju/application/utils" resourcecmd "github.com/juju/juju/cmd/juju/resource" + "github.com/juju/juju/cmd/modelcmd" corebase "github.com/juju/juju/core/base" "github.com/juju/juju/core/constraints" "github.com/juju/juju/core/instance" @@ -322,6 +324,12 @@ type DestroyApplicationInput struct { ModelName string } +type osFilesystem struct{} + +func (osFilesystem) Open(name string) (modelcmd.ReadSeekCloser, error) { + return os.Open(name) +} + func resolveCharmURL(charmName string) (*charm.URL, error) { path, err := charm.EnsureSchema(charmName, charm.CharmHub) if err != nil { @@ -348,9 +356,24 @@ func (c applicationsClient) CreateApplication(ctx context.Context, input *Create } applicationAPIClient := apiapplication.NewClient(conn) - resourceHttpClient := ResourceHttpClient(conn) if applicationAPIClient.BestAPIVersion() >= 19 { - err = c.deployFromRepository(applicationAPIClient, resourceHttpClient, transformedInput) + resourceIDs, apiCharmID, err := c.deployFromRepository(applicationAPIClient, transformedInput, conn) + if err != nil { + return nil, err + } + if len(resourceIDs) != 0 { + toReturn := apiapplication.SetCharmConfig{ + ApplicationName: transformedInput.applicationName, + CharmID: apiCharmID, + ResourceIDs: resourceIDs, + } + setCharmConfig := &toReturn + + err = applicationAPIClient.SetCharm(model.GenerationMaster, *setCharmConfig) + if err != nil { + return nil, err + } + } } else { err = c.legacyDeploy(ctx, conn, applicationAPIClient, transformedInput) err = jujuerrors.Annotate(err, "legacy deploy method") @@ -368,15 +391,16 @@ func (c applicationsClient) CreateApplication(ctx context.Context, input *Create }, err } -func (c applicationsClient) deployFromRepository(applicationAPIClient *apiapplication.Client, resourceHttpClient *HttpRequestClient, transformedInput transformedCreateApplicationInput) error { +func (c applicationsClient) deployFromRepository(applicationAPIClient *apiapplication.Client, transformedInput transformedCreateApplicationInput, conn api.Connection) (map[string]string, apiapplication.CharmID, error) { settingsForYaml := map[interface{}]interface{}{transformedInput.applicationName: transformedInput.config} configYaml, err := goyaml.Marshal(settingsForYaml) + resourceIDs := map[string]string{} + apiCharmID := apiapplication.CharmID{} if err != nil { - return jujuerrors.Trace(err) + return resourceIDs, apiapplication.CharmID{}, jujuerrors.Trace(err) } - c.Tracef("Calling DeployFromRepository") - deployInfo, localPendingResources, errs := applicationAPIClient.DeployFromRepository(apiapplication.DeployFromRepositoryArg{ + deployInfo, pendingResources, errs := applicationAPIClient.DeployFromRepository(apiapplication.DeployFromRepositoryArg{ CharmName: transformedInput.charmName, ApplicationName: transformedInput.applicationName, Base: &transformedInput.charmBase, @@ -391,19 +415,30 @@ func (c applicationsClient) deployFromRepository(applicationAPIClient *apiapplic Resources: transformedInput.resources, Storage: transformedInput.storage, }) - - if len(errs) != 0 { - return errors.Join(errs...) + if errs != nil { + return resourceIDs, apiCharmID, errors.Join(errs...) } - fileSystem := osFilesystem{} - // Upload the provided local resources to Juju - uploadErr := uploadExistingPendingResources(deployInfo.Name, localPendingResources, fileSystem, resourceHttpClient) + charmsAPIClient := apicharms.NewClient(conn) + resolvedURL, resolvedOrigin, _, err := getCharmResolvedUrlAndOrigin(conn, transformedInput) + if err != nil { + return resourceIDs, apiCharmID, err + } + resultOrigin, err := charmsAPIClient.AddCharm(resolvedURL, resolvedOrigin, false) + if err != nil { + return resourceIDs, apiCharmID, err + } + resourceIDs, err = c.getResourceIDs(transformedInput, conn, deployInfo, pendingResources) + if err != nil { + return resourceIDs, apiCharmID, err + } - if uploadErr != nil { - return uploadErr + apiCharmID = apiapplication.CharmID{ + URL: resolvedURL.String(), + Origin: resultOrigin, } - return nil + + return resourceIDs, apiCharmID, nil } // TODO (hml) 23-Feb-2024 @@ -417,54 +452,7 @@ func (c applicationsClient) legacyDeploy(ctx context.Context, conn api.Connectio charmsAPIClient := apicharms.NewClient(conn) modelconfigAPIClient := apimodelconfig.NewClient(conn) - channel, err := charm.ParseChannel(transformedInput.charmChannel) - if err != nil { - return err - } - - charmURL, err := resolveCharmURL(transformedInput.charmName) - if err != nil { - return err - } - - if charmURL.Revision != UnspecifiedRevision { - return fmt.Errorf("cannot specify revision in a charm name") - } - if transformedInput.charmRevision != UnspecifiedRevision && channel.Empty() { - return fmt.Errorf("specifying a revision requires a channel for future upgrades") - } - - userSuppliedBase := transformedInput.charmBase - platformCons, err := modelconfigAPIClient.GetModelConstraints() - if err != nil { - return err - } - platform := utils.MakePlatform(transformedInput.constraints, userSuppliedBase, platformCons) - - urlForOrigin := charmURL - if transformedInput.charmRevision != UnspecifiedRevision { - urlForOrigin = urlForOrigin.WithRevision(transformedInput.charmRevision) - } - - // Juju 2.9 cares that the series is in the origin. Juju 3.3 does not. - // We are supporting both now. - if !userSuppliedBase.Empty() { - userSuppliedSeries, err := corebase.GetSeriesFromBase(userSuppliedBase) - if err != nil { - return err - } - urlForOrigin = urlForOrigin.WithSeries(userSuppliedSeries) - } - - origin, err := utils.MakeOrigin(charm.Schema(urlForOrigin.Schema), transformedInput.charmRevision, channel, platform) - if err != nil { - return err - } - - // 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. - resolvedURL, resolvedOrigin, supportedBases, err := resolveCharm(charmsAPIClient, charmURL, origin) + resolvedURL, resolvedOrigin, supportedBases, err := getCharmResolvedUrlAndOrigin(conn, transformedInput) if err != nil { return err } @@ -472,7 +460,7 @@ func (c applicationsClient) legacyDeploy(ctx context.Context, conn api.Connectio return jujuerrors.NotSupportedf("deploying bundles") } c.Tracef("resolveCharm returned", map[string]interface{}{"resolvedURL": resolvedURL, "resolvedOrigin": resolvedOrigin, "supportedBases": supportedBases}) - + userSuppliedBase := transformedInput.charmBase baseToUse, err := c.baseToUse(modelconfigAPIClient, userSuppliedBase, resolvedOrigin.Base, supportedBases) if err != nil { c.Warnf("failed to get a suggested operating system from resolved charm response", map[string]interface{}{"err": err}) @@ -678,9 +666,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 { diff --git a/internal/juju/resources.go b/internal/juju/resources.go index 8a060cec..2b0e758d 100644 --- a/internal/juju/resources.go +++ b/internal/juju/resources.go @@ -1,125 +1,24 @@ +// Copyright 2024 Canonical Ltd. +// Licensed under the Apache License, Version 2.0, see LICENCE file for details. + package juju import ( "fmt" - "io" - "mime" - "net/http" - "os" "strconv" + "github.com/juju/charm/v12" charmresources "github.com/juju/charm/v12/resource" - jujuerrors "github.com/juju/errors" - "github.com/juju/juju/api/base" + "github.com/juju/juju/api" apiapplication "github.com/juju/juju/api/client/application" - apiresources "github.com/juju/juju/api/client/resources" - jujuhttp "github.com/juju/juju/api/http" + apicharms "github.com/juju/juju/api/client/charms" + apimodelconfig "github.com/juju/juju/api/client/modelconfig" + apicommoncharm "github.com/juju/juju/api/common/charm" + "github.com/juju/juju/cmd/juju/application/utils" resourcecmd "github.com/juju/juju/cmd/juju/resource" - "github.com/juju/juju/cmd/modelcmd" - "github.com/juju/juju/rpc/params" + corebase "github.com/juju/juju/core/base" ) -// newEndpointPath returns the API URL path for the identified resource. -func newEndpointPath(application string, name string) string { - return fmt.Sprintf(HTTPEndpointPath, application, name) -} - -// ResourceHttpClient returns a new Client for the given raw API caller. -func ResourceHttpClient(apiCaller base.APICallCloser) *HttpRequestClient { - frontend, backend := base.NewClientFacade(apiCaller, "Resources") - - httpClient, err := apiCaller.HTTPClient() - if err != nil { - return nil - } - return &HttpRequestClient{ - ClientFacade: frontend, - facade: backend, - httpClient: httpClient, - } -} - -const ( - // ContentTypeRaw is the HTTP content-type value used for raw, unformatted content. - ContentTypeRaw = "application/octet-stream" -) -const ( - // MediaTypeFormData is the media type for file uploads (see mime.FormatMediaType). - MediaTypeFormData = "form-data" - // QueryParamPendingID is the query parameter we use to send up the pending ID. - QueryParamPendingID = "pendingid" -) - -const ( - // HeaderContentType is the header name for the type of file upload. - HeaderContentType = "Content-Type" - // HeaderContentSha384 is the header name for the sha hash of a file upload. - HeaderContentSha384 = "Content-Sha384" - // HeaderContentLength is the header name for the length of a file upload. - HeaderContentLength = "Content-Length" - // HeaderContentDisposition is the header name for value that holds the filename. - HeaderContentDisposition = "Content-Disposition" -) - -const ( - // HTTPEndpointPath is the URL path, with substitutions, for a resource request. - HTTPEndpointPath = "/applications/%s/resources/%s" -) - -const FilenameParamForContentDispositionHeader = "filename" - -// UploadRequest defines a single upload request. -type UploadRequest struct { - // Application is the application ID. - Application string - - // Name is the resource name. - Name string - - // Filename is the name of the file as it exists on disk. - Filename string - - // Size is the size of the uploaded data, in bytes. - Size int64 - - // Fingerprint is the fingerprint of the uploaded data. - Fingerprint charmresources.Fingerprint - - // PendingID is the pending ID to associate with this upload, if any. - PendingID string - - // Content is the content to upload. - Content io.ReadSeeker -} - -type HttpRequestClient struct { - base.ClientFacade - facade base.FacadeCaller - httpClient jujuhttp.HTTPDoer -} - -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) -} - // isInt checks if strings could be converted to an integer // Used to detect resources which are given with revision number func isInt(s string) bool { @@ -127,93 +26,132 @@ func isInt(s string) bool { return err == nil } -// Upload sends the provided resource blob up to Juju. -func upload(appName, name, filename, pendingID string, reader io.ReadSeeker, resourceHttpClient *HttpRequestClient) error { - uReq, err := apiresources.NewUploadRequest(appName, name, filename, reader) +func (c applicationsClient) getResourceIDs(transformedInput transformedCreateApplicationInput, conn api.Connection, deployInfo apiapplication.DeployInfo, pendingResources []apiapplication.PendingResourceUpload) (map[string]string, error) { + resourceIDs := map[string]string{} + charmsAPIClient := apicharms.NewClient(conn) + modelconfigAPIClient := apimodelconfig.NewClient(conn) + resourcesAPIClient, err := c.getResourceAPIClient(conn) if err != nil { - return jujuerrors.Trace(err) - } - if pendingID != "" { - uReq.PendingID = pendingID + return resourceIDs, err } - req, err := uReq.HTTPRequest() + resolvedURL, resolvedOrigin, supportedBases, err := getCharmResolvedUrlAndOrigin(conn, transformedInput) if err != nil { - return jujuerrors.Trace(err) + return resourceIDs, err } - var response params.UploadResult - if err := resourceHttpClient.httpClient.Do(resourceHttpClient.facade.RawAPICaller().Context(), req, &response); err != nil { - return jujuerrors.Trace(err) + userSuppliedBase := transformedInput.charmBase + baseToUse, err := c.baseToUse(modelconfigAPIClient, userSuppliedBase, resolvedOrigin.Base, supportedBases) + if err != nil { + return resourceIDs, err } - return nil -} + resolvedOrigin.Base = baseToUse -// setFilename sets a name to the file. -func setFilename(filename string, req *http.Request) { - filename = mime.BEncoding.Encode("utf-8", filename) + // 3.3 version of ResolveCharm does not always include the series + // in the url. However, juju 2.9 requires it. + series, err := corebase.GetSeriesFromBase(baseToUse) + if err != nil { + return resourceIDs, err + } + resolvedURL = resolvedURL.WithSeries(series) + + resultOrigin, err := charmsAPIClient.AddCharm(resolvedURL, resolvedOrigin, false) + if err != nil { + return resourceIDs, err + } + charmID := apiapplication.CharmID{ + URL: resolvedURL.String(), + Origin: resultOrigin, + } - disp := mime.FormatMediaType( - MediaTypeFormData, - map[string]string{FilenameParamForContentDispositionHeader: filename}, - ) + charmInfo, err := charmsAPIClient.CharmInfo(charmID.URL) + if err != nil { + return resourceIDs, err + } - req.Header.Set(HeaderContentDisposition, disp) + for _, resourceMeta := range charmInfo.Meta.Resources { + for _, pendingResource := range pendingResources { + if pendingResource.Name == resourceMeta.Name { + fileSystem := osFilesystem{} + localResource := charmresources.Resource{ + Meta: resourceMeta, + Origin: charmresources.OriginStore, + } + t, typeParseErr := charmresources.ParseType(resourceMeta.Type.String()) + if typeParseErr != nil { + return resourceIDs, typeParseErr + } + r, openResErr := resourcecmd.OpenResource(pendingResource.Filename, t, fileSystem.Open) + if openResErr != nil { + return resourceIDs, openResErr + } + toRequestUpload, err := resourcesAPIClient.UploadPendingResource(deployInfo.Name, localResource, pendingResource.Filename, r) + if err != nil { + return resourceIDs, err + } + resourceIDs[resourceMeta.Name] = toRequestUpload + } + } + } + return resourceIDs, nil } -// HTTPRequest generates a new HTTP request. -func (ur UploadRequest) HTTPRequest() (*http.Request, error) { - urlStr := newEndpointPath(ur.Application, ur.Name) +func getCharmResolvedUrlAndOrigin(conn api.Connection, transformedInput transformedCreateApplicationInput) (*charm.URL, apicommoncharm.Origin, []corebase.Base, error) { + charmsAPIClient := apicharms.NewClient(conn) + modelconfigAPIClient := apimodelconfig.NewClient(conn) - req, err := http.NewRequest(http.MethodPut, urlStr, ur.Content) + channel, err := charm.ParseChannel(transformedInput.charmChannel) if err != nil { - return nil, jujuerrors.Trace(err) + return nil, apicommoncharm.Origin{}, []corebase.Base{}, err } - req.Header.Set(HeaderContentType, ContentTypeRaw) - req.Header.Set(HeaderContentSha384, ur.Fingerprint.String()) - req.Header.Set(HeaderContentLength, fmt.Sprint(ur.Size)) - setFilename(ur.Filename, req) + charmURL, err := resolveCharmURL(transformedInput.charmName) + if err != nil { + return nil, apicommoncharm.Origin{}, []corebase.Base{}, err + } - req.ContentLength = ur.Size + if charmURL.Revision != UnspecifiedRevision { + err := fmt.Errorf("cannot specify revision in a charm name") + return nil, apicommoncharm.Origin{}, []corebase.Base{}, err + } + if transformedInput.charmRevision != UnspecifiedRevision && channel.Empty() { + err = fmt.Errorf("specifying a revision requires a channel for future upgrades") + return nil, apicommoncharm.Origin{}, []corebase.Base{}, err + } - if ur.PendingID != "" { - query := req.URL.Query() - query.Set(QueryParamPendingID, ur.PendingID) - req.URL.RawQuery = query.Encode() + userSuppliedBase := transformedInput.charmBase + platformCons, err := modelconfigAPIClient.GetModelConstraints() + if err != nil { + return nil, apicommoncharm.Origin{}, []corebase.Base{}, err } + platform := utils.MakePlatform(transformedInput.constraints, userSuppliedBase, platformCons) - return req, nil -} + urlForOrigin := charmURL + if transformedInput.charmRevision != UnspecifiedRevision { + urlForOrigin = urlForOrigin.WithRevision(transformedInput.charmRevision) + } -// 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, - resourceHttpClient *HttpRequestClient) error { - if pendingResources == nil { - return nil - } - pendingID := "" - - 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) + // Juju 2.9 cares that the series is in the origin. Juju 3.3 does not. + // We are supporting both now. + if !userSuppliedBase.Empty() { + userSuppliedSeries, err := corebase.GetSeriesFromBase(userSuppliedBase) + if err != nil { + return nil, apicommoncharm.Origin{}, []corebase.Base{}, err } + urlForOrigin = urlForOrigin.WithSeries(userSuppliedSeries) + } - r, openResErr := resourcecmd.OpenResource(pendingResUpload.Filename, t, filesystem.Open) - if openResErr != nil { - return jujuerrors.Annotatef(openResErr, "unable to open resource %v", pendingResUpload.Name) - } - uploadErr := upload(appName, pendingResUpload.Name, pendingResUpload.Filename, pendingID, r, resourceHttpClient) + origin, err := utils.MakeOrigin(charm.Schema(urlForOrigin.Schema), transformedInput.charmRevision, channel, platform) + if err != nil { + return nil, apicommoncharm.Origin{}, []corebase.Base{}, err + } - if uploadErr != nil { - return jujuerrors.Trace(uploadErr) - } + // 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. + resolvedURL, resolvedOrigin, supportedBases, err := resolveCharm(charmsAPIClient, charmURL, origin) + if err != nil { + return nil, apicommoncharm.Origin{}, []corebase.Base{}, err } - return nil + + return resolvedURL, resolvedOrigin, supportedBases, nil } diff --git a/internal/juju/resources_test.go b/internal/juju/resources_test.go deleted file mode 100644 index 17f21925..00000000 --- a/internal/juju/resources_test.go +++ /dev/null @@ -1,23 +0,0 @@ -package juju - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestNewEndpointPath(t *testing.T) { - application := "ausf" - name := "sdcore-ausf-k8s" - want := "/applications/ausf/resources/sdcore-ausf-k8s" - got := newEndpointPath(application, name) - assert.Equal(t, got, want) -} - -func TestNewEndpointPathEmptyInputs(t *testing.T) { - application := "" - name := "" - want := "/applications//resources/" - got := newEndpointPath(application, name) - assert.Equal(t, got, want) -} diff --git a/internal/provider/resource_application.go b/internal/provider/resource_application.go index da9ee059..a0a96472 100644 --- a/internal/provider/resource_application.go +++ b/internal/provider/resource_application.go @@ -30,7 +30,6 @@ import ( "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/juju/errors" "github.com/juju/juju/core/constraints" - jujustorage "github.com/juju/juju/storage" "github.com/juju/terraform-provider-juju/internal/juju" @@ -969,7 +968,7 @@ 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]string) diff --git a/internal/provider/validator_resourcekey.go b/internal/provider/validator_resourcekey.go index f99fbfca..79db32a5 100644 --- a/internal/provider/validator_resourcekey.go +++ b/internal/provider/validator_resourcekey.go @@ -1,5 +1,5 @@ // Copyright 2024 Canonical Ltd. -// Licensed under the AGPLv3, see LICENCE file for details. +// Licensed under the Apache License, Version 2.0, see LICENCE file for details. package provider @@ -38,7 +38,7 @@ func (v StringIsResourceKeyValidator) ValidateMap(ctx context.Context, req valid } for name, value := range resourceKey { if isInt(value) { - providedRev, err := strconv.Atoi(value) + _, err := strconv.Atoi(value) if err != nil { resp.Diagnostics.AddAttributeError( req.Path, @@ -46,26 +46,17 @@ func (v StringIsResourceKeyValidator) ValidateMap(ctx context.Context, req valid fmt.Sprintf("value of %q should be a valid revision number or image URL: %s", name, err), ) } - if providedRev <= 0 { - resp.Diagnostics.AddAttributeError( - req.Path, - "Invalid Resource revision", - fmt.Sprintf("value of %q should be a valid revision number or image URL: %s", name, "Negative revision number is invalid."), - ) - } - } else { - 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) { - return - } else { - resp.Diagnostics.AddAttributeError( - req.Path, - "Invalid image URL", - fmt.Sprintf("value of %q should be a valid revision number or image URL: %s", name, "The value format is invalid as a revision number or for an image URL."), - ) - return - } + continue + } + 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 image URL", + fmt.Sprintf("value of %q should be a valid revision number or image URL.", name), + ) } } diff --git a/internal/provider/validator_resourcekey_test.go b/internal/provider/validator_resourcekey_test.go index c21739da..a9800b8d 100644 --- a/internal/provider/validator_resourcekey_test.go +++ b/internal/provider/validator_resourcekey_test.go @@ -1,5 +1,5 @@ // Copyright 2024 Canonical Ltd. -// Licensed under the AGPLv3, see LICENCE file for details. +// Licensed under the Apache License, Version 2.0, see LICENCE file for details. package provider_test From 74f48bdd06310462e2ae0c93ed7fc00cc0792d4e Mon Sep 17 00:00:00 2001 From: gatici Date: Tue, 6 Aug 2024 15:50:23 +0300 Subject: [PATCH 22/27] refactor(application): refactoring the addPendingResources function to increase readibility Signed-off-by: gatici --- internal/juju/applications.go | 121 +++++++++++++++++----------------- 1 file changed, 61 insertions(+), 60 deletions(-) diff --git a/internal/juju/applications.go b/internal/juju/applications.go index 24feb383..10e80fe0 100644 --- a/internal/juju/applications.go +++ b/internal/juju/applications.go @@ -1447,78 +1447,79 @@ func addPendingResources(appName string, charmResourcesToAdd map[string]charmres toReturn := map[string]string{} for _, resourceMeta := range charmResourcesToAdd { - if resourcesToUse != nil { - if deployValue, ok := resourcesToUse[resourceMeta.Name]; ok { - if isInt(deployValue) { - // A resource revision is provided - providedRev, err := strconv.Atoi(deployValue) - if err != nil { - return nil, typedError(err) - } - resourceFromCharmhub := charmresources.Resource{ - Meta: resourceMeta, - Origin: charmresources.OriginStore, - Revision: -1, - } - // 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 - if providedRev != -1 { - resourceFromCharmhub.Revision = providedRev - } - pendingResourcesforAdd = append(pendingResourcesforAdd, resourceFromCharmhub) - } else { - // 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 := resourcesAPIClient.UploadPendingResource(appName, localResource, deployValue, r) - if err != nil { - return nil, typedError(err) - } - // Add the resource name and the corresponding UUID to the resources map - toReturn[resourceMeta.Name] = toRequestUpload - } - } - } else { - // If there is no resource revisions, Charm is deployed with default resources according to channel + 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 len(pendingResourcesforAdd) != 0 { - resourcesReqforAdd := apiresources.AddPendingResourcesArgs{ - ApplicationID: appName, - CharmID: apiresources.CharmID{ - URL: charmID.URL, - Origin: charmID.Origin, - }, - Resources: pendingResourcesforAdd, + + 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 + } + + // A new resource to be uploaded by the ResourceApi client. + localResource := charmresources.Resource{ + Meta: resourceMeta, + Origin: charmresources.OriginUpload, } - toRequestAdd, err := resourcesAPIClient.AddPendingResources(resourcesReqforAdd) + 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 := resourcesAPIClient.UploadPendingResource(appName, localResource, deployValue, r) if err != nil { return nil, typedError(err) } - // Add the resource name and the corresponding UUID to the resources map - for i, argsResource := range pendingResourcesforAdd { - toReturn[argsResource.Meta.Name] = toRequestAdd[i] - } + // Add the resource name and the corresponding UUID to the resources map. + toReturn[resourceMeta.Name] = toRequestUpload + } + + if len(pendingResourcesforAdd) == 0 { + return toReturn, nil } + + resourcesReqforAdd := apiresources.AddPendingResourcesArgs{ + ApplicationID: appName, + CharmID: apiresources.CharmID{ + URL: charmID.URL, + Origin: charmID.Origin, + }, + Resources: pendingResourcesforAdd, + } + toRequestAdd, err := resourcesAPIClient.AddPendingResources(resourcesReqforAdd) + if err != nil { + return nil, typedError(err) + } + // Add the resource name and the corresponding UUID to the resources map + for i, argsResource := range pendingResourcesforAdd { + toReturn[argsResource.Meta.Name] = toRequestAdd[i] + } + return toReturn, nil } From 8d206e141d0deb3d90c63a0e75e816a370565ba9 Mon Sep 17 00:00:00 2001 From: gatici Date: Tue, 6 Aug 2024 17:57:41 +0300 Subject: [PATCH 23/27] docs(resources): arrange the resourceKeyMarkdownDescription Format the definition to fix the awkwardly reading and make it compatible with Spec: https://docs.google.com/document/d/1i236ntmw-qXyqifmEtk_rcszWXcXSUhXkeJ6ByKXIeE/edit. Signed-off-by: gatici --- docs/resources/application.md | 10 ++++------ internal/provider/resource_application.go | 10 ++++------ 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/docs/resources/application.md b/docs/resources/application.md index 23174b9b..32f8c33a 100644 --- a/docs/resources/application.md +++ b/docs/resources/application.md @@ -60,14 +60,12 @@ resource "juju_application" "this" { - `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 String) Charm resources. Must evaluate to a string. A resource could be a resource revision number from CharmHub or a custom OCI image resource. -There are a few scenarios that need to be considered: - -* Specify a resource other than the default for a charm. Note that not all charms have resources. 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. +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, the resource is also refreshed to the current defined channel listed for the charm if the resource is specified by revision. - +* 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. diff --git a/internal/provider/resource_application.go b/internal/provider/resource_application.go index a0a96472..cf67fb9e 100644 --- a/internal/provider/resource_application.go +++ b/internal/provider/resource_application.go @@ -48,14 +48,12 @@ const ( resourceKeyMarkdownDescription = ` Charm resources. Must evaluate to a string. A resource could be a resource revision number from CharmHub or a custom OCI image resource. -There are a few scenarios that need to be considered: - -* Specify a resource other than the default for a charm. Note that not all charms have resources. 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. +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, the resource is also refreshed to the current defined channel listed for the charm if the resource is specified by revision. - +* 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. ` ) From e40a30918dfe5be5fc3ec0b83e35ea9abcdfd619 Mon Sep 17 00:00:00 2001 From: gatici Date: Wed, 7 Aug 2024 00:31:23 +0300 Subject: [PATCH 24/27] test(resources): add tests for resourcekey validator refactor: StringIsResourceKeyValidator ValidateMap function is refactored to increase the readibility. Signed-off-by: gatici --- internal/provider/validator_resourcekey.go | 33 +++++------ .../provider/validator_resourcekey_test.go | 55 +++++++++++++++++++ 2 files changed, 72 insertions(+), 16 deletions(-) diff --git a/internal/provider/validator_resourcekey.go b/internal/provider/validator_resourcekey.go index 79db32a5..a4a9fee7 100644 --- a/internal/provider/validator_resourcekey.go +++ b/internal/provider/validator_resourcekey.go @@ -37,26 +37,27 @@ func (v StringIsResourceKeyValidator) ValidateMap(ctx context.Context, req valid return } for name, value := range resourceKey { - if isInt(value) { - _, err := strconv.Atoi(value) - if err != nil { - resp.Diagnostics.AddAttributeError( - req.Path, - "Invalid Resource revision", - fmt.Sprintf("value of %q should be a valid revision number or image URL: %s", name, err), - ) + 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 } - 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) { + 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 } - resp.Diagnostics.AddAttributeError( - req.Path, - "Invalid image URL", - fmt.Sprintf("value of %q should be a valid revision number or image URL.", name), - ) } } diff --git a/internal/provider/validator_resourcekey_test.go b/internal/provider/validator_resourcekey_test.go index a9800b8d..102ef2ae 100644 --- a/internal/provider/validator_resourcekey_test.go +++ b/internal/provider/validator_resourcekey_test.go @@ -4,9 +4,64 @@ 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) + } } From bdd6fef1f37eb411e3edd1efa184da72603d0d37 Mon Sep 17 00:00:00 2001 From: gatici Date: Wed, 7 Aug 2024 23:26:25 +0300 Subject: [PATCH 25/27] fix(tests): fixing the integration tests fix(resources): Fix the helper methods to get the base or series if none of them explicitly provided. Signed-off-by: gatici --- internal/juju/applications.go | 29 ++++++++--- internal/juju/resources.go | 18 +++---- .../provider/resource_application_test.go | 50 ------------------- 3 files changed, 31 insertions(+), 66 deletions(-) diff --git a/internal/juju/applications.go b/internal/juju/applications.go index 10e80fe0..48b9d12d 100644 --- a/internal/juju/applications.go +++ b/internal/juju/applications.go @@ -420,10 +420,27 @@ func (c applicationsClient) deployFromRepository(applicationAPIClient *apiapplic } charmsAPIClient := apicharms.NewClient(conn) - resolvedURL, resolvedOrigin, _, err := getCharmResolvedUrlAndOrigin(conn, transformedInput) + modelconfigAPIClient := apimodelconfig.NewClient(conn) + resolvedURL, resolvedOrigin, supportedBases, err := getCharmResolvedUrlAndOrigin(conn, transformedInput) + if err != nil { + return resourceIDs, apiCharmID, err + } + userSuppliedBase := transformedInput.charmBase + baseToUse, err := c.baseToUse(modelconfigAPIClient, userSuppliedBase, resolvedOrigin.Base, supportedBases) + if err != nil { + return resourceIDs, apiCharmID, err + } + if !userSuppliedBase.Empty() && !userSuppliedBase.IsCompatible(baseToUse) { + return resourceIDs, apiCharmID, err + } + resolvedOrigin.Base = baseToUse + series, err := corebase.GetSeriesFromBase(baseToUse) if err != nil { return resourceIDs, apiCharmID, err } + resolvedURL = resolvedURL.WithSeries(series) + + // Add charm expects base or series, one of them should exist. resultOrigin, err := charmsAPIClient.AddCharm(resolvedURL, resolvedOrigin, false) if err != nil { return resourceIDs, apiCharmID, err @@ -1444,7 +1461,7 @@ func (c applicationsClient) updateResources(appName string, resources map[string func addPendingResources(appName string, charmResourcesToAdd map[string]charmresources.Meta, resourcesToUse map[string]string, charmID apiapplication.CharmID, resourcesAPIClient ResourceAPIClient) (map[string]string, error) { pendingResourcesforAdd := []charmresources.Resource{} - toReturn := map[string]string{} + resourceIDs := map[string]string{} for _, resourceMeta := range charmResourcesToAdd { if resourcesToUse == nil { @@ -1496,11 +1513,11 @@ func addPendingResources(appName string, charmResourcesToAdd map[string]charmres return nil, typedError(err) } // Add the resource name and the corresponding UUID to the resources map. - toReturn[resourceMeta.Name] = toRequestUpload + resourceIDs[resourceMeta.Name] = toRequestUpload } if len(pendingResourcesforAdd) == 0 { - return toReturn, nil + return resourceIDs, nil } resourcesReqforAdd := apiresources.AddPendingResourcesArgs{ @@ -1517,10 +1534,10 @@ func addPendingResources(appName string, charmResourcesToAdd map[string]charmres } // Add the resource name and the corresponding UUID to the resources map for i, argsResource := range pendingResourcesforAdd { - toReturn[argsResource.Meta.Name] = toRequestAdd[i] + 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/resources.go b/internal/juju/resources.go index 2b0e758d..550f58d0 100644 --- a/internal/juju/resources.go +++ b/internal/juju/resources.go @@ -5,7 +5,6 @@ package juju import ( "fmt" - "strconv" "github.com/juju/charm/v12" charmresources "github.com/juju/charm/v12/resource" @@ -19,13 +18,8 @@ import ( corebase "github.com/juju/juju/core/base" ) -// isInt checks if strings could be converted to an integer -// Used to detect resources which are given with revision number -func isInt(s string) bool { - _, err := strconv.Atoi(s) - return err == nil -} - +// getResourceIDs uploads pending resources and +// returns the resource IDs of uploaded resources func (c applicationsClient) getResourceIDs(transformedInput transformedCreateApplicationInput, conn api.Connection, deployInfo apiapplication.DeployInfo, pendingResources []apiapplication.PendingResourceUpload) (map[string]string, error) { resourceIDs := map[string]string{} charmsAPIClient := apicharms.NewClient(conn) @@ -38,14 +32,13 @@ func (c applicationsClient) getResourceIDs(transformedInput transformedCreateApp if err != nil { return resourceIDs, err } + userSuppliedBase := transformedInput.charmBase baseToUse, err := c.baseToUse(modelconfigAPIClient, userSuppliedBase, resolvedOrigin.Base, supportedBases) if err != nil { return resourceIDs, err } - resolvedOrigin.Base = baseToUse - // 3.3 version of ResolveCharm does not always include the series // in the url. However, juju 2.9 requires it. series, err := corebase.GetSeriesFromBase(baseToUse) @@ -95,6 +88,8 @@ func (c applicationsClient) getResourceIDs(transformedInput transformedCreateApp return resourceIDs, nil } +// getResourceIDs uploads pending resources and +// returns the resource IDs of uploaded resources func getCharmResolvedUrlAndOrigin(conn api.Connection, transformedInput transformedCreateApplicationInput) (*charm.URL, apicommoncharm.Origin, []corebase.Base, error) { charmsAPIClient := apicharms.NewClient(conn) modelconfigAPIClient := apimodelconfig.NewClient(conn) @@ -119,6 +114,9 @@ func getCharmResolvedUrlAndOrigin(conn api.Connection, transformedInput transfor } userSuppliedBase := transformedInput.charmBase + if err != nil { + return nil, apicommoncharm.Origin{}, []corebase.Base{}, err + } platformCons, err := modelconfigAPIClient.GetModelConstraints() if err != nil { return nil, apicommoncharm.Origin{}, []corebase.Base{}, err diff --git a/internal/provider/resource_application_test.go b/internal/provider/resource_application_test.go index ef0b1370..664c3619 100644 --- a/internal/provider/resource_application_test.go +++ b/internal/provider/resource_application_test.go @@ -425,7 +425,6 @@ func TestAcc_CustomResourceUpdatesMicrok8s(t *testing.T) { Check: resource.ComposeTestCheckFunc( resource.TestCheckResourceAttr("juju_application.this", "resources.ausf-image", "10"), ), - ExpectNonEmptyPlan: true, }, { // Update charm channel and keep resource revision @@ -433,39 +432,6 @@ func TestAcc_CustomResourceUpdatesMicrok8s(t *testing.T) { Check: resource.ComposeTestCheckFunc( resource.TestCheckResourceAttr("juju_application.this", "resources.ausf-image", "10"), ), - ExpectNonEmptyPlan: true, - }, - { - // Keep charm channel and update resource which is given in a json file - Config: testAccResourceApplicationWithCustomResources(modelName, "1.3/beta", "ausf-image", "resource_files/ausf-image.json"), - Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("juju_application.this", "resources.ausf-image", "resource_files/ausf-image.json"), - ), - ExpectNonEmptyPlan: true, - }, - { - // Update charm channel and keep resource which is given in a json file - Config: testAccResourceApplicationWithCustomResources(modelName, "1.3/edge", "ausf-image", "resource_files/ausf-image.json"), - Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("juju_application.this", "resources.ausf-image", "resource_files/ausf-image.json"), - ), - ExpectNonEmptyPlan: true, - }, - { - // Keep charm channel and update resource which is given in a yaml file - Config: testAccResourceApplicationWithCustomResources(modelName, "1.3/edge", "ausf-image", "resource_files/ausf-image.yaml"), - Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("juju_application.this", "resources.ausf-image", "resource_files/ausf-image.yaml"), - ), - ExpectNonEmptyPlan: true, - }, - { - // Update charm channel and keep resource which is given in a yaml file - Config: testAccResourceApplicationWithCustomResources(modelName, "1.3/beta", "ausf-image", "resource_files/ausf-image.yaml"), - Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("juju_application.this", "resources.ausf-image", "resource_files/ausf-image.yaml"), - ), - ExpectNonEmptyPlan: true, }, { // Keep charm channel and remove resource revision @@ -523,14 +489,6 @@ func TestAcc_CustomResourcesRemovedFromPlanMicrok8s(t *testing.T) { resource.TestCheckNoResourceAttr("juju_application.this", "resources"), ), }, - { - // Keep charm channel and update resource which is given in a json file - Config: testAccResourceApplicationWithCustomResources(modelName, "1.3/edge", "ausf-image", "resource_files/ausf-image.json"), - Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("juju_application.this", "resources.ausf-image", "resource_files/ausf-image.json"), - ), - ExpectNonEmptyPlan: true, - }, { // Update charm channel and remove image resource which is given in a json file Config: testAccResourceApplicationWithoutCustomResources(modelName, "1.3/beta"), @@ -538,14 +496,6 @@ func TestAcc_CustomResourcesRemovedFromPlanMicrok8s(t *testing.T) { resource.TestCheckNoResourceAttr("juju_application.this", "resources"), ), }, - { - // Keep charm channel and add resource which is given in a yaml file - Config: testAccResourceApplicationWithCustomResources(modelName, "1.3/beta", "ausf-image", "resource_files/ausf-image.yaml"), - Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("juju_application.this", "resources.ausf-image", "resource_files/ausf-image.yaml"), - ), - ExpectNonEmptyPlan: true, - }, { // Update charm channel and remove resource Config: testAccResourceApplicationWithoutCustomResources(modelName, "1.3/edge"), From daedc8ba69266e992a3ce50d4ca8c5cb78b01ac3 Mon Sep 17 00:00:00 2001 From: gatici Date: Thu, 15 Aug 2024 20:53:18 +0300 Subject: [PATCH 26/27] revert: revert the changes which are done in deployFromRepository and legacyDeploy methods feat: Use ResourceAPIClient.Upload to upload the custom resources for the applications which are deployed with deployFromRepository method tests: Use grafana-k8s charm to validate the custom resource usage in the integration tests Signed-off-by: gatici --- internal/juju/applications.go | 135 +++++++------- internal/juju/interfaces.go | 2 + internal/juju/mock_test.go | 30 +++ internal/juju/resources.go | 175 +++++------------- internal/provider/resource_application.go | 15 +- .../provider/resource_application_test.go | 84 +++++---- 6 files changed, 191 insertions(+), 250 deletions(-) diff --git a/internal/juju/applications.go b/internal/juju/applications.go index 48b9d12d..87a78a28 100644 --- a/internal/juju/applications.go +++ b/internal/juju/applications.go @@ -13,7 +13,6 @@ import ( "errors" "fmt" "math" - "os" "reflect" "sort" "strconv" @@ -36,7 +35,6 @@ import ( apicommoncharm "github.com/juju/juju/api/common/charm" "github.com/juju/juju/cmd/juju/application/utils" resourcecmd "github.com/juju/juju/cmd/juju/resource" - "github.com/juju/juju/cmd/modelcmd" corebase "github.com/juju/juju/core/base" "github.com/juju/juju/core/constraints" "github.com/juju/juju/core/instance" @@ -324,12 +322,6 @@ type DestroyApplicationInput struct { ModelName string } -type osFilesystem struct{} - -func (osFilesystem) Open(name string) (modelcmd.ReadSeekCloser, error) { - return os.Open(name) -} - func resolveCharmURL(charmName string) (*charm.URL, error) { path, err := charm.EnsureSchema(charmName, charm.CharmHub) if err != nil { @@ -356,24 +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 { - resourceIDs, apiCharmID, err := c.deployFromRepository(applicationAPIClient, transformedInput, conn) + err := c.deployFromRepository(applicationAPIClient, resourceAPIClient, transformedInput) if err != nil { return nil, err } - if len(resourceIDs) != 0 { - toReturn := apiapplication.SetCharmConfig{ - ApplicationName: transformedInput.applicationName, - CharmID: apiCharmID, - ResourceIDs: resourceIDs, - } - setCharmConfig := &toReturn - - err = applicationAPIClient.SetCharm(model.GenerationMaster, *setCharmConfig) - if err != nil { - return nil, err - } - } } else { err = c.legacyDeploy(ctx, conn, applicationAPIClient, transformedInput) err = jujuerrors.Annotate(err, "legacy deploy method") @@ -391,16 +374,14 @@ func (c applicationsClient) CreateApplication(ctx context.Context, input *Create }, err } -func (c applicationsClient) deployFromRepository(applicationAPIClient *apiapplication.Client, transformedInput transformedCreateApplicationInput, conn api.Connection) (map[string]string, apiapplication.CharmID, 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) - resourceIDs := map[string]string{} - apiCharmID := apiapplication.CharmID{} if err != nil { - return resourceIDs, apiapplication.CharmID{}, jujuerrors.Trace(err) + return jujuerrors.Trace(err) } c.Tracef("Calling DeployFromRepository") - deployInfo, pendingResources, errs := applicationAPIClient.DeployFromRepository(apiapplication.DeployFromRepositoryArg{ + deployInfo, localPendingResources, errs := applicationAPIClient.DeployFromRepository(apiapplication.DeployFromRepositoryArg{ CharmName: transformedInput.charmName, ApplicationName: transformedInput.applicationName, Base: &transformedInput.charmBase, @@ -415,47 +396,20 @@ func (c applicationsClient) deployFromRepository(applicationAPIClient *apiapplic Resources: transformedInput.resources, Storage: transformedInput.storage, }) - if errs != nil { - return resourceIDs, apiCharmID, errors.Join(errs...) - } - charmsAPIClient := apicharms.NewClient(conn) - modelconfigAPIClient := apimodelconfig.NewClient(conn) - resolvedURL, resolvedOrigin, supportedBases, err := getCharmResolvedUrlAndOrigin(conn, transformedInput) - if err != nil { - return resourceIDs, apiCharmID, err + if len(errs) != 0 { + return errors.Join(errs...) } - userSuppliedBase := transformedInput.charmBase - baseToUse, err := c.baseToUse(modelconfigAPIClient, userSuppliedBase, resolvedOrigin.Base, supportedBases) - if err != nil { - return resourceIDs, apiCharmID, err - } - if !userSuppliedBase.Empty() && !userSuppliedBase.IsCompatible(baseToUse) { - return resourceIDs, apiCharmID, err - } - resolvedOrigin.Base = baseToUse - series, err := corebase.GetSeriesFromBase(baseToUse) - if err != nil { - return resourceIDs, apiCharmID, err - } - resolvedURL = resolvedURL.WithSeries(series) - // Add charm expects base or series, one of them should exist. - resultOrigin, err := charmsAPIClient.AddCharm(resolvedURL, resolvedOrigin, false) - if err != nil { - return resourceIDs, apiCharmID, err - } - resourceIDs, err = c.getResourceIDs(transformedInput, conn, deployInfo, pendingResources) - if err != nil { - return resourceIDs, apiCharmID, err - } + fileSystem := osFilesystem{} + // Upload the provided local resources to Juju + uploadErr := uploadExistingPendingResources(deployInfo.Name, localPendingResources, fileSystem, resourceAPIClient) - apiCharmID = apiapplication.CharmID{ - URL: resolvedURL.String(), - Origin: resultOrigin, + if uploadErr != nil { + return uploadErr } + return nil - return resourceIDs, apiCharmID, nil } // TODO (hml) 23-Feb-2024 @@ -469,7 +423,54 @@ func (c applicationsClient) legacyDeploy(ctx context.Context, conn api.Connectio charmsAPIClient := apicharms.NewClient(conn) modelconfigAPIClient := apimodelconfig.NewClient(conn) - resolvedURL, resolvedOrigin, supportedBases, err := getCharmResolvedUrlAndOrigin(conn, transformedInput) + channel, err := charm.ParseChannel(transformedInput.charmChannel) + if err != nil { + return err + } + + charmURL, err := resolveCharmURL(transformedInput.charmName) + if err != nil { + return err + } + + if charmURL.Revision != UnspecifiedRevision { + return fmt.Errorf("cannot specify revision in a charm name") + } + if transformedInput.charmRevision != UnspecifiedRevision && channel.Empty() { + return fmt.Errorf("specifying a revision requires a channel for future upgrades") + } + + userSuppliedBase := transformedInput.charmBase + platformCons, err := modelconfigAPIClient.GetModelConstraints() + if err != nil { + return err + } + platform := utils.MakePlatform(transformedInput.constraints, userSuppliedBase, platformCons) + + urlForOrigin := charmURL + if transformedInput.charmRevision != UnspecifiedRevision { + urlForOrigin = urlForOrigin.WithRevision(transformedInput.charmRevision) + } + + // Juju 2.9 cares that the series is in the origin. Juju 3.3 does not. + // We are supporting both now. + if !userSuppliedBase.Empty() { + userSuppliedSeries, err := corebase.GetSeriesFromBase(userSuppliedBase) + if err != nil { + return err + } + urlForOrigin = urlForOrigin.WithSeries(userSuppliedSeries) + } + + origin, err := utils.MakeOrigin(charm.Schema(urlForOrigin.Schema), transformedInput.charmRevision, channel, platform) + if err != nil { + return err + } + + // 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. + resolvedURL, resolvedOrigin, supportedBases, err := resolveCharm(charmsAPIClient, charmURL, origin) if err != nil { return err } @@ -477,7 +478,7 @@ func (c applicationsClient) legacyDeploy(ctx context.Context, conn api.Connectio return jujuerrors.NotSupportedf("deploying bundles") } c.Tracef("resolveCharm returned", map[string]interface{}{"resolvedURL": resolvedURL, "resolvedOrigin": resolvedOrigin, "supportedBases": supportedBases}) - userSuppliedBase := transformedInput.charmBase + baseToUse, err := c.baseToUse(modelconfigAPIClient, userSuppliedBase, resolvedOrigin.Base, supportedBases) if err != nil { c.Warnf("failed to get a suggested operating system from resolved charm response", map[string]interface{}{"err": err}) @@ -1459,7 +1460,7 @@ func (c applicationsClient) updateResources(appName string, resources map[string } func addPendingResources(appName string, charmResourcesToAdd map[string]charmresources.Meta, resourcesToUse map[string]string, - charmID apiapplication.CharmID, resourcesAPIClient ResourceAPIClient) (map[string]string, error) { + charmID apiapplication.CharmID, resourceAPIClient ResourceAPIClient) (map[string]string, error) { pendingResourcesforAdd := []charmresources.Resource{} resourceIDs := map[string]string{} @@ -1508,7 +1509,7 @@ func addPendingResources(appName string, charmResourcesToAdd map[string]charmres if openResErr != nil { return nil, typedError(openResErr) } - toRequestUpload, err := resourcesAPIClient.UploadPendingResource(appName, localResource, deployValue, r) + toRequestUpload, err := resourceAPIClient.UploadPendingResource(appName, localResource, deployValue, r) if err != nil { return nil, typedError(err) } @@ -1528,7 +1529,7 @@ func addPendingResources(appName string, charmResourcesToAdd map[string]charmres }, Resources: pendingResourcesforAdd, } - toRequestAdd, err := resourcesAPIClient.AddPendingResources(resourcesReqforAdd) + toRequestAdd, err := resourceAPIClient.AddPendingResources(resourcesReqforAdd) if err != nil { return nil, typedError(err) } diff --git a/internal/juju/interfaces.go b/internal/juju/interfaces.go index c7ea4bd3..1447f4d3 100644 --- a/internal/juju/interfaces.go +++ b/internal/juju/interfaces.go @@ -46,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) @@ -66,6 +67,7 @@ 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) } diff --git a/internal/juju/mock_test.go b/internal/juju/mock_test.go index 47212f2e..e521534a 100644 --- a/internal/juju/mock_test.go +++ b/internal/juju/mock_test.go @@ -304,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() @@ -574,6 +590,20 @@ 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() diff --git a/internal/juju/resources.go b/internal/juju/resources.go index 550f58d0..dc8eaaa5 100644 --- a/internal/juju/resources.go +++ b/internal/juju/resources.go @@ -4,152 +4,65 @@ package juju import ( - "fmt" + "os" - "github.com/juju/charm/v12" charmresources "github.com/juju/charm/v12/resource" - "github.com/juju/juju/api" + jujuerrors "github.com/juju/errors" apiapplication "github.com/juju/juju/api/client/application" - apicharms "github.com/juju/juju/api/client/charms" - apimodelconfig "github.com/juju/juju/api/client/modelconfig" - 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/cmd/modelcmd" ) -// getResourceIDs uploads pending resources and -// returns the resource IDs of uploaded resources -func (c applicationsClient) getResourceIDs(transformedInput transformedCreateApplicationInput, conn api.Connection, deployInfo apiapplication.DeployInfo, pendingResources []apiapplication.PendingResourceUpload) (map[string]string, error) { - resourceIDs := map[string]string{} - charmsAPIClient := apicharms.NewClient(conn) - modelconfigAPIClient := apimodelconfig.NewClient(conn) - resourcesAPIClient, err := c.getResourceAPIClient(conn) - if err != nil { - return resourceIDs, err - } - resolvedURL, resolvedOrigin, supportedBases, err := getCharmResolvedUrlAndOrigin(conn, transformedInput) - if err != nil { - return resourceIDs, err - } - - userSuppliedBase := transformedInput.charmBase - baseToUse, err := c.baseToUse(modelconfigAPIClient, userSuppliedBase, resolvedOrigin.Base, supportedBases) - if err != nil { - return resourceIDs, err - } - resolvedOrigin.Base = baseToUse - // 3.3 version of ResolveCharm does not always include the series - // in the url. However, juju 2.9 requires it. - series, err := corebase.GetSeriesFromBase(baseToUse) - if err != nil { - return resourceIDs, err - } - resolvedURL = resolvedURL.WithSeries(series) - - resultOrigin, err := charmsAPIClient.AddCharm(resolvedURL, resolvedOrigin, false) - if err != nil { - return resourceIDs, err - } - charmID := apiapplication.CharmID{ - URL: resolvedURL.String(), - Origin: resultOrigin, - } +type osFilesystem struct{} - charmInfo, err := charmsAPIClient.CharmInfo(charmID.URL) - if err != nil { - return resourceIDs, err - } - - for _, resourceMeta := range charmInfo.Meta.Resources { - for _, pendingResource := range pendingResources { - if pendingResource.Name == resourceMeta.Name { - fileSystem := osFilesystem{} - localResource := charmresources.Resource{ - Meta: resourceMeta, - Origin: charmresources.OriginStore, - } - t, typeParseErr := charmresources.ParseType(resourceMeta.Type.String()) - if typeParseErr != nil { - return resourceIDs, typeParseErr - } - r, openResErr := resourcecmd.OpenResource(pendingResource.Filename, t, fileSystem.Open) - if openResErr != nil { - return resourceIDs, openResErr - } - toRequestUpload, err := resourcesAPIClient.UploadPendingResource(deployInfo.Name, localResource, pendingResource.Filename, r) - if err != nil { - return resourceIDs, err - } - resourceIDs[resourceMeta.Name] = toRequestUpload - } - } - } - return resourceIDs, nil +func (osFilesystem) Create(name string) (*os.File, error) { + return os.Create(name) } -// getResourceIDs uploads pending resources and -// returns the resource IDs of uploaded resources -func getCharmResolvedUrlAndOrigin(conn api.Connection, transformedInput transformedCreateApplicationInput) (*charm.URL, apicommoncharm.Origin, []corebase.Base, error) { - charmsAPIClient := apicharms.NewClient(conn) - modelconfigAPIClient := apimodelconfig.NewClient(conn) - - channel, err := charm.ParseChannel(transformedInput.charmChannel) - if err != nil { - return nil, apicommoncharm.Origin{}, []corebase.Base{}, err - } - - charmURL, err := resolveCharmURL(transformedInput.charmName) - if err != nil { - return nil, apicommoncharm.Origin{}, []corebase.Base{}, err - } +func (osFilesystem) RemoveAll(path string) error { + return os.RemoveAll(path) +} - if charmURL.Revision != UnspecifiedRevision { - err := fmt.Errorf("cannot specify revision in a charm name") - return nil, apicommoncharm.Origin{}, []corebase.Base{}, err - } - if transformedInput.charmRevision != UnspecifiedRevision && channel.Empty() { - err = fmt.Errorf("specifying a revision requires a channel for future upgrades") - return nil, apicommoncharm.Origin{}, []corebase.Base{}, err - } +func (osFilesystem) Open(name string) (modelcmd.ReadSeekCloser, error) { + return os.Open(name) +} - userSuppliedBase := transformedInput.charmBase - if err != nil { - return nil, apicommoncharm.Origin{}, []corebase.Base{}, err - } - platformCons, err := modelconfigAPIClient.GetModelConstraints() - if err != nil { - return nil, apicommoncharm.Origin{}, []corebase.Base{}, err - } - platform := utils.MakePlatform(transformedInput.constraints, userSuppliedBase, platformCons) +func (osFilesystem) OpenFile(name string, flag int, perm os.FileMode) (*os.File, error) { + return os.OpenFile(name, flag, perm) +} - urlForOrigin := charmURL - if transformedInput.charmRevision != UnspecifiedRevision { - urlForOrigin = urlForOrigin.WithRevision(transformedInput.charmRevision) - } +func (osFilesystem) Stat(name string) (os.FileInfo, error) { + return os.Stat(name) +} - // Juju 2.9 cares that the series is in the origin. Juju 3.3 does not. - // We are supporting both now. - if !userSuppliedBase.Empty() { - userSuppliedSeries, err := corebase.GetSeriesFromBase(userSuppliedBase) - if err != nil { - return nil, apicommoncharm.Origin{}, []corebase.Base{}, err +// 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) } - urlForOrigin = urlForOrigin.WithSeries(userSuppliedSeries) - } - origin, err := utils.MakeOrigin(charm.Schema(urlForOrigin.Schema), transformedInput.charmRevision, channel, platform) - if err != nil { - return nil, apicommoncharm.Origin{}, []corebase.Base{}, err - } + 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) - // 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. - resolvedURL, resolvedOrigin, supportedBases, err := resolveCharm(charmsAPIClient, charmURL, origin) - if err != nil { - return nil, apicommoncharm.Origin{}, []corebase.Base{}, err + if uploadErr != nil { + return jujuerrors.Trace(uploadErr) + } } - - return resolvedURL, resolvedOrigin, supportedBases, nil + return nil } diff --git a/internal/provider/resource_application.go b/internal/provider/resource_application.go index cf67fb9e..936cd459 100644 --- a/internal/provider/resource_application.go +++ b/internal/provider/resource_application.go @@ -6,7 +6,6 @@ package provider import ( "context" "fmt" - "strconv" "strings" "github.com/dustin/go-humanize" @@ -98,13 +97,6 @@ type applicationResourceModel struct { ID types.String `tfsdk:"id"` } -// isInt checks if strings could be converted to an integer -// Used to detect resources which are given with revision number -func isInt(s string) bool { - _, err := strconv.Atoi(s) - return err == nil -} - func (r *applicationResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { resp.TypeName = req.ProviderTypeName + "_application" } @@ -803,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 } @@ -920,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 @@ -1136,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 664c3619..a1b8fe4a 100644 --- a/internal/provider/resource_application_test.go +++ b/internal/provider/resource_application_test.go @@ -348,6 +348,12 @@ 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) }, @@ -355,37 +361,37 @@ func TestAcc_CustomResourcesAddedToPlanMicrok8s(t *testing.T) { Steps: []resource.TestStep{ { // deploy charm without custom resource - Config: testAccResourceApplicationWithoutCustomResources(modelName, "1.3/edge"), + Config: testAccResourceApplicationWithoutCustomResources(modelName, "1.0/stable"), Check: resource.ComposeTestCheckFunc( resource.TestCheckNoResourceAttr("juju_application.this", "resources"), ), }, { // Add a custom resource - Config: testAccResourceApplicationWithCustomResources(modelName, "1.3/edge", "ausf-image", "gatici/sdcore-ausf:1.4"), + Config: testAccResourceApplicationWithCustomResources(modelName, "1.0/stable", "grafana-image", "gatici/grafana:10"), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("juju_application.this", "resources.ausf-image", "gatici/sdcore-ausf:1.4"), + resource.TestCheckResourceAttr("juju_application.this", "resources.grafana-image", "gatici/grafana:10"), ), ExpectNonEmptyPlan: true, }, { // Add another custom resource - Config: testAccResourceApplicationWithCustomResources(modelName, "1.3/edge", "ausf-image", "gatici/sdcore-ausf:latest"), + Config: testAccResourceApplicationWithCustomResources(modelName, "1.0/stable", "grafana-image", "gatici/grafana:9"), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("juju_application.this", "resources.ausf-image", "gatici/sdcore-ausf:latest"), + resource.TestCheckResourceAttr("juju_application.this", "resources.grafana-image", "gatici/grafana:9"), ), ExpectNonEmptyPlan: true, }, { // Add resource revision - Config: testAccResourceApplicationWithCustomResources(modelName, "1.3/edge", "ausf-image", "30"), + Config: testAccResourceApplicationWithCustomResources(modelName, "1.0/stable", "grafana-image", "61"), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("juju_application.this", "resources.ausf-image", "30"), + resource.TestCheckResourceAttr("juju_application.this", "resources.grafana-image", "61"), ), }, { // Remove resource revision - Config: testAccResourceApplicationWithoutCustomResources(modelName, "1.3/edge"), + Config: testAccResourceApplicationWithoutCustomResources(modelName, "1.0/stable"), Check: resource.ComposeTestCheckFunc( resource.TestCheckNoResourceAttr("juju_application.this", "resources"), ), @@ -398,6 +404,12 @@ 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) }, @@ -405,37 +417,37 @@ func TestAcc_CustomResourceUpdatesMicrok8s(t *testing.T) { Steps: []resource.TestStep{ { // Deploy charm with a custom resource - Config: testAccResourceApplicationWithCustomResources(modelName, "1.3/beta", "ausf-image", "gatici/sdcore-ausf:latest"), + Config: testAccResourceApplicationWithCustomResources(modelName, "1.0/edge", "grafana-image", "gatici/grafana:9"), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("juju_application.this", "resources.ausf-image", "gatici/sdcore-ausf:latest"), + 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.3/beta", "ausf-image", "gatici/sdcore-ausf:1.4"), + Config: testAccResourceApplicationWithCustomResources(modelName, "1.0/edge", "grafana-image", "gatici/grafana:10"), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("juju_application.this", "resources.ausf-image", "gatici/sdcore-ausf:1.4"), + 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.3/edge", "ausf-image", "10"), + Config: testAccResourceApplicationWithCustomResources(modelName, "1.0/stable", "grafana-image", "59"), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("juju_application.this", "resources.ausf-image", "10"), + resource.TestCheckResourceAttr("juju_application.this", "resources.grafana-image", "59"), ), }, { // Update charm channel and keep resource revision - Config: testAccResourceApplicationWithCustomResources(modelName, "1.3/beta", "ausf-image", "10"), + Config: testAccResourceApplicationWithCustomResources(modelName, "1.0/beta", "grafana-image", "59"), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("juju_application.this", "resources.ausf-image", "10"), + resource.TestCheckResourceAttr("juju_application.this", "resources.grafana-image", "59"), ), }, { // Keep charm channel and remove resource revision - Config: testAccResourceApplicationWithoutCustomResources(modelName, "1.3/beta"), + Config: testAccResourceApplicationWithoutCustomResources(modelName, "1.0/beta"), Check: resource.ComposeTestCheckFunc( resource.TestCheckNoResourceAttr("juju_application.this", "resources"), ), @@ -448,6 +460,12 @@ 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) }, @@ -455,50 +473,36 @@ func TestAcc_CustomResourcesRemovedFromPlanMicrok8s(t *testing.T) { Steps: []resource.TestStep{ { // Deploy charm with a custom resource - Config: testAccResourceApplicationWithCustomResources(modelName, "1.3/edge", "ausf-image", "gatici/sdcore-ausf:latest"), + Config: testAccResourceApplicationWithCustomResources(modelName, "1.0/edge", "grafana-image", "gatici/grafana:9"), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("juju_application.this", "resources.ausf-image", "gatici/sdcore-ausf:latest"), + resource.TestCheckResourceAttr("juju_application.this", "resources.grafana-image", "gatici/grafana:9"), ), ExpectNonEmptyPlan: true, }, { // Keep charm channel and remove custom resource - Config: testAccResourceApplicationWithoutCustomResources(modelName, "1.3/edge"), + 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.3/edge", "ausf-image", "30"), + Config: testAccResourceApplicationWithCustomResources(modelName, "1.0/edge", "grafana-image", "60"), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("juju_application.this", "resources.ausf-image", "30"), + resource.TestCheckResourceAttr("juju_application.this", "resources.grafana-image", "60"), ), }, { // Update charm channel and keep resource revision - Config: testAccResourceApplicationWithCustomResources(modelName, "1.3/beta", "ausf-image", "30"), + Config: testAccResourceApplicationWithCustomResources(modelName, "1.0/stable", "grafana-image", "60"), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("juju_application.this", "resources.ausf-image", "30"), + resource.TestCheckResourceAttr("juju_application.this", "resources.grafana-image", "60"), ), }, { // Update charm channel and remove resource revision - Config: testAccResourceApplicationWithoutCustomResources(modelName, "1.3/edge"), - Check: resource.ComposeTestCheckFunc( - resource.TestCheckNoResourceAttr("juju_application.this", "resources"), - ), - }, - { - // Update charm channel and remove image resource which is given in a json file - Config: testAccResourceApplicationWithoutCustomResources(modelName, "1.3/beta"), - Check: resource.ComposeTestCheckFunc( - resource.TestCheckNoResourceAttr("juju_application.this", "resources"), - ), - }, - { - // Update charm channel and remove resource - Config: testAccResourceApplicationWithoutCustomResources(modelName, "1.3/edge"), + Config: testAccResourceApplicationWithoutCustomResources(modelName, "1.0/beta"), Check: resource.ComposeTestCheckFunc( resource.TestCheckNoResourceAttr("juju_application.this", "resources"), ), @@ -867,7 +871,7 @@ resource "juju_application" "this" { model = juju_model.this.name name = "test-app" charm { - name = "sdcore-ausf-k8s" + name = "grafana-k8s" channel = "%s" } trust = true From a8acb4cb63458a83715d812299e44a69f110d6c5 Mon Sep 17 00:00:00 2001 From: gatici Date: Tue, 27 Aug 2024 14:19:43 +0300 Subject: [PATCH 27/27] chore: fix integration tests, linting issues add more tests tests: add more unit tests for addPendingResource and UploadPendingResource methods fix: solve inregratation test and linting issues Signed-off-by: gatici --- internal/juju/applications.go | 1 - internal/juju/applications_test.go | 311 ++++++++++++++++++ .../provider/resource_application_test.go | 2 +- 3 files changed, 312 insertions(+), 2 deletions(-) diff --git a/internal/juju/applications.go b/internal/juju/applications.go index 87a78a28..d06ea2d1 100644 --- a/internal/juju/applications.go +++ b/internal/juju/applications.go @@ -409,7 +409,6 @@ func (c applicationsClient) deployFromRepository(applicationAPIClient Applicatio return uploadErr } return nil - } // TODO (hml) 23-Feb-2024 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/provider/resource_application_test.go b/internal/provider/resource_application_test.go index a1b8fe4a..0c555054 100644 --- a/internal/provider/resource_application_test.go +++ b/internal/provider/resource_application_test.go @@ -846,7 +846,7 @@ resource "juju_application" "this" { model = juju_model.this.name name = "test-app" charm { - name = "sdcore-ausf-k8s" + name = "grafana-k8s" channel = "%s" } trust = true