Skip to content

Commit c1e9afc

Browse files
committed
feat(update-app-base): add support to update base for application charms
This PR adds support to update the base in application charms by requiring a replace in case of a machine charm, and perform the upgrade in case of a k8s charm. We add the model_type computed field on the application schema, and we use it to make a decision in the planmodifier RequiresReplaceIf. re #635
1 parent fbd1ccc commit c1e9afc

File tree

5 files changed

+160
-20
lines changed

5 files changed

+160
-20
lines changed

docs/resources/application.md

+2-1
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ Notes:
7474
### Read-Only
7575

7676
- `id` (String) The ID of this resource.
77+
- `model_type` (String) The type of the model where the application is to be deployed. It is a computed field and is needed to determine if the application should be replaced or updated in case of base updates.
7778
- `principal` (Boolean, Deprecated) Whether this is a Principal application
7879

7980
<a id="nestedblock--charm"></a>
@@ -85,7 +86,7 @@ Required:
8586

8687
Optional:
8788

88-
- `base` (String) The operating system on which to deploy. E.g. ubuntu@22.04.
89+
- `base` (String) The operating system on which to deploy. E.g. ubuntu@22.04. Changing this value for machine charms will trigger a replace by terraform.
8990
- `channel` (String) The channel to use when deploying a charm. Specified as \<track>/\<risk>/\<branch>.
9091
- `revision` (Number) The revision of the charm to deploy. During the update phase, the charm revision should be update before config update, to avoid issues with config parameters parsing.
9192
- `series` (String, Deprecated) The series on which to deploy.

internal/juju/applications.go

+33-13
Original file line numberDiff line numberDiff line change
@@ -271,7 +271,8 @@ type transformedCreateApplicationInput struct {
271271
}
272272

273273
type CreateApplicationResponse struct {
274-
AppName string
274+
AppName string
275+
ModelType string
275276
}
276277

277278
type ReadApplicationInput struct {
@@ -284,6 +285,7 @@ type ReadApplicationResponse struct {
284285
Channel string
285286
Revision int
286287
Base string
288+
ModelType string
287289
Series string
288290
Units int
289291
Trust bool
@@ -307,9 +309,9 @@ type UpdateApplicationInput struct {
307309
Trust *bool
308310
Expose map[string]interface{}
309311
// Unexpose indicates what endpoints to unexpose
310-
Unexpose []string
311-
Config map[string]string
312-
//Series string // Unsupported today
312+
Unexpose []string
313+
Config map[string]string
314+
Base string
313315
Placement map[string]interface{}
314316
Constraints *constraints.Value
315317
EndpointBindings map[string]string
@@ -368,9 +370,16 @@ func (c applicationsClient) CreateApplication(ctx context.Context, input *Create
368370
// If we have managed to deploy something, now we have
369371
// to check if we have to expose something
370372
err = c.processExpose(applicationAPIClient, transformedInput.applicationName, transformedInput.expose)
371-
373+
if err != nil {
374+
return nil, err
375+
}
376+
modelType, err := c.ModelType(input.ModelName)
377+
if err != nil {
378+
return nil, err
379+
}
372380
return &CreateApplicationResponse{
373-
AppName: transformedInput.applicationName,
381+
AppName: transformedInput.applicationName,
382+
ModelType: modelType.String(),
374383
}, err
375384
}
376385

@@ -1114,6 +1123,7 @@ func (c applicationsClient) ReadApplication(input *ReadApplicationInput) (*ReadA
11141123
Channel: appInfo.Channel,
11151124
Revision: charmURL.Revision,
11161125
Base: fmt.Sprintf("%s@%s", appInfo.Base.Name, baseChannel.Track),
1126+
ModelType: modelType.String(),
11171127
Series: seriesString,
11181128
Units: unitCount,
11191129
Trust: trustValue,
@@ -1194,7 +1204,7 @@ func (c applicationsClient) UpdateApplication(input *UpdateApplicationInput) err
11941204
// before the operations with config. Because the config params
11951205
// can be changed from one revision to another. So "Revision-Config"
11961206
// ordering will help to prevent issues with the configuration parsing.
1197-
if input.Revision != nil || input.Channel != "" || len(input.Resources) != 0 {
1207+
if input.Revision != nil || input.Channel != "" || len(input.Resources) != 0 || input.Base != "" {
11981208
setCharmConfig, err := c.computeSetCharmConfig(input, applicationAPIClient, charmsAPIClient, resourcesAPIClient)
11991209
if err != nil {
12001210
return err
@@ -1379,22 +1389,24 @@ func (c applicationsClient) computeSetCharmConfig(
13791389
newOrigin.Branch = strPtr(parsedChannel.Branch)
13801390
}
13811391
}
1392+
if input.Base != "" {
1393+
base, err := corebase.ParseBaseFromString(input.Base)
1394+
if err != nil {
1395+
return nil, err
1396+
}
1397+
newOrigin.Base = base
1398+
}
13821399

13831400
resolvedURL, resolvedOrigin, supportedBases, err := resolveCharm(charmsAPIClient, newURL, newOrigin)
13841401
if err != nil {
13851402
return nil, err
13861403
}
13871404

1388-
// Ensure that the new charm supports the architecture and
1389-
// operating system currently used by the deployed application.
1405+
// Ensure that the new charm supports the architecture used by the deployed application.
13901406
if oldOrigin.Architecture != resolvedOrigin.Architecture {
13911407
msg := fmt.Sprintf("the new charm does not support the current architecture %q", oldOrigin.Architecture)
13921408
return nil, errors.New(msg)
13931409
}
1394-
if !basesContain(oldOrigin.Base, supportedBases) {
1395-
msg := fmt.Sprintf("the new charm does not support the current operating system %q", oldOrigin.Base.String())
1396-
return nil, errors.New(msg)
1397-
}
13981410

13991411
// Ensure the new revision or channel is contained
14001412
// in the origin to be saved by juju when AddCharm
@@ -1406,6 +1418,14 @@ func (c applicationsClient) computeSetCharmConfig(
14061418
oldOrigin.Risk = newOrigin.Risk
14071419
oldOrigin.Branch = newOrigin.Branch
14081420
}
1421+
if input.Base != "" {
1422+
oldOrigin.Base = newOrigin.Base
1423+
}
1424+
1425+
if !basesContain(oldOrigin.Base, supportedBases) {
1426+
msg := fmt.Sprintf("the new charm does not support the current operating system %q", oldOrigin.Base.String())
1427+
return nil, errors.New(msg)
1428+
}
14091429

14101430
resultOrigin, err := charmsAPIClient.AddCharm(resolvedURL, oldOrigin, false)
14111431
if err != nil {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// Copyright 2025 Canonical Ltd.
2+
// Licensed under the AGPLv3, see LICENCE file for details.
3+
4+
package provider
5+
6+
import (
7+
"context"
8+
9+
"github.com/hashicorp/terraform-plugin-framework/path"
10+
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
11+
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
12+
"github.com/hashicorp/terraform-plugin-framework/types"
13+
"github.com/juju/juju/core/model"
14+
)
15+
16+
// baseApplicationRequiresReplaceIf is a plan modifier that sets the RequiresReplace field if the
17+
// model type is IAAS. The reason is that with CAAS the application can be updated in place.
18+
// With IAAS the application needs to be replaced. To make this decision the model type is needed.
19+
// Since you can't access the juju client in the plan modifiers we've added a computed field `model_type`.
20+
// This is set in the state by means of the `stringplanmodifier.UseStateForUnknown()`, so when we update the base
21+
// is always guaranteed to be set.
22+
func baseApplicationRequiresReplaceIf(ctx context.Context, req planmodifier.StringRequest, resp *stringplanmodifier.RequiresReplaceIfFuncResponse) {
23+
if req.State.Raw.IsKnown() {
24+
var modelType types.String
25+
diags := req.State.GetAttribute(ctx, path.Root("model_type"), &modelType)
26+
if diags.HasError() {
27+
resp.Diagnostics.Append(diags...)
28+
return
29+
}
30+
resp.RequiresReplace = modelType.ValueString() == model.IAAS.String()
31+
}
32+
}

internal/provider/resource_application.go

+27-6
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ type applicationResourceModel struct {
8282
Constraints types.String `tfsdk:"constraints"`
8383
Expose types.List `tfsdk:"expose"`
8484
ModelName types.String `tfsdk:"model"`
85+
ModelType types.String `tfsdk:"model_type"`
8586
Placement types.String `tfsdk:"placement"`
8687
EndpointBindings types.Set `tfsdk:"endpoint_bindings"`
8788
Resources types.Map `tfsdk:"resources"`
@@ -147,6 +148,14 @@ func (r *applicationResource) Schema(_ context.Context, _ resource.SchemaRequest
147148
stringplanmodifier.RequiresReplaceIfConfigured(),
148149
},
149150
},
151+
"model_type": schema.StringAttribute{
152+
Description: "The type of the model where the application is to be deployed. It is a computed field and " +
153+
"is needed to determine if the application should be replaced or updated in case of base updates.",
154+
Computed: true,
155+
PlanModifiers: []planmodifier.String{
156+
stringplanmodifier.UseStateForUnknown(),
157+
},
158+
},
150159
"units": schema.Int64Attribute{
151160
Description: "The number of application units to deploy for the charm.",
152161
Optional: true,
@@ -316,11 +325,12 @@ func (r *applicationResource) Schema(_ context.Context, _ resource.SchemaRequest
316325
DeprecationMessage: "Configure base instead. This attribute will be removed in the next major version of the provider.",
317326
},
318327
BaseKey: schema.StringAttribute{
319-
Description: "The operating system on which to deploy. E.g. ubuntu@22.04.",
328+
Description: "The operating system on which to deploy. E.g. ubuntu@22.04. Changing this value for machine charms will trigger a replace by terraform.",
320329
Optional: true,
321330
Computed: true,
322331
PlanModifiers: []planmodifier.String{
323332
stringplanmodifier.UseStateForUnknown(),
333+
stringplanmodifier.RequiresReplaceIf(baseApplicationRequiresReplaceIf, "", ""),
324334
},
325335
Validators: []validator.String{
326336
stringvalidator.ConflictsWith(path.Expressions{
@@ -581,7 +591,6 @@ func (r *applicationResource) Create(ctx context.Context, req resource.CreateReq
581591
return
582592
}
583593
r.trace(fmt.Sprintf("read application resource %q", createResp.AppName))
584-
585594
// Save plan into Terraform state
586595

587596
// Constraints do not apply to subordinate applications. If the application
@@ -590,6 +599,7 @@ func (r *applicationResource) Create(ctx context.Context, req resource.CreateReq
590599
plan.Placement = types.StringValue(readResp.Placement)
591600
plan.Principal = types.BoolNull()
592601
plan.ApplicationName = types.StringValue(createResp.AppName)
602+
plan.ModelType = types.StringValue(readResp.ModelType)
593603
planCharm.Revision = types.Int64Value(int64(readResp.Revision))
594604
planCharm.Base = types.StringValue(readResp.Base)
595605
planCharm.Series = types.StringValue(readResp.Series)
@@ -692,6 +702,12 @@ func (r *applicationResource) Read(ctx context.Context, req resource.ReadRequest
692702
}
693703
r.trace("read application", map[string]interface{}{"resource": appName, "response": response})
694704

705+
modelType, err := r.client.Applications.ModelType(modelName)
706+
if err != nil {
707+
resp.Diagnostics.Append(handleApplicationNotFoundError(ctx, err, &resp.State)...)
708+
return
709+
}
710+
695711
state.ApplicationName = types.StringValue(appName)
696712
state.ModelName = types.StringValue(modelName)
697713

@@ -700,6 +716,7 @@ func (r *applicationResource) Read(ctx context.Context, req resource.ReadRequest
700716
state.Placement = types.StringValue(response.Placement)
701717
state.Principal = types.BoolNull()
702718
state.UnitCount = types.Int64Value(int64(response.Units))
719+
state.ModelType = types.StringValue(modelType.String())
703720
state.Trust = types.BoolValue(response.Trust)
704721

705722
// state requiring transformation
@@ -919,16 +936,18 @@ func (r *applicationResource) Update(ctx context.Context, req resource.UpdateReq
919936
} else if !planCharm.Revision.Equal(stateCharm.Revision) {
920937
updateApplicationInput.Revision = intPtr(planCharm.Revision)
921938
}
922-
923-
if !planCharm.Series.Equal(stateCharm.Series) || !planCharm.Base.Equal(stateCharm.Base) {
939+
if !planCharm.Base.Equal(stateCharm.Base) {
940+
updateApplicationInput.Base = planCharm.Base.ValueString()
941+
}
942+
if !planCharm.Series.Equal(stateCharm.Series) {
924943
// This violates Terraform's declarative model. We could implement
925944
// `juju set-application-base`, usually used after `upgrade-machine`,
926945
// which would change the operating system used for future units of
927946
// the application provided the charm supported it, but not change
928947
// the current. This provider does not implement an equivalent to
929948
// `upgrade-machine`. There is also a question of how to handle a
930949
// change to series, revision and channel at the same time.
931-
resp.Diagnostics.AddWarning("Not Supported", "Changing an application's operating system after deploy.")
950+
resp.Diagnostics.AddWarning("Not Supported", "Changing operating system's series after deploy.")
932951
}
933952
}
934953

@@ -1051,7 +1070,8 @@ func (r *applicationResource) Update(ctx context.Context, req resource.UpdateReq
10511070
if updateApplicationInput.Channel != "" ||
10521071
updateApplicationInput.Revision != nil ||
10531072
updateApplicationInput.Placement != nil ||
1054-
updateApplicationInput.Units != nil {
1073+
updateApplicationInput.Units != nil ||
1074+
updateApplicationInput.Base != "" {
10551075
readResp, err := r.client.Applications.ReadApplicationWithRetryOnNotFound(ctx, &juju.ReadApplicationInput{
10561076
ModelName: updateApplicationInput.ModelName,
10571077
AppName: updateApplicationInput.AppName,
@@ -1090,6 +1110,7 @@ func (r *applicationResource) Update(ctx context.Context, req resource.UpdateReq
10901110
}
10911111
}
10921112

1113+
plan.ModelType = state.ModelType
10931114
plan.ID = types.StringValue(newAppID(plan.ModelName.ValueString(), plan.ApplicationName.ValueString()))
10941115
plan.Principal = types.BoolNull()
10951116
r.trace("Updated", applicationResourceModelForLogging(ctx, &plan))

internal/provider/resource_application_test.go

+66
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,37 @@ func TestAcc_CharmUpdates(t *testing.T) {
276276
})
277277
}
278278

279+
func TestAcc_CharmUpdateBase(t *testing.T) {
280+
modelName := acctest.RandomWithPrefix("tf-test-charmbaseupdates")
281+
282+
resource.ParallelTest(t, resource.TestCase{
283+
PreCheck: func() { testAccPreCheck(t) },
284+
ProtoV6ProviderFactories: frameworkProviderFactories,
285+
Steps: []resource.TestStep{
286+
{
287+
Config: testAccApplicationUpdateBaseCharm(modelName, "ubuntu@22.04"),
288+
Check: resource.ComposeTestCheckFunc(
289+
resource.TestCheckResourceAttr("juju_application.this", "charm.0.base", "ubuntu@22.04"),
290+
),
291+
},
292+
{
293+
// move to base ubuntu 20.04
294+
Config: testAccApplicationUpdateBaseCharm(modelName, "ubuntu@20.04"),
295+
Check: resource.ComposeTestCheckFunc(
296+
resource.TestCheckResourceAttr("juju_application.this", "charm.0.base", "ubuntu@20.04"),
297+
),
298+
},
299+
{
300+
// move back to ubuntu 22.04
301+
Config: testAccApplicationUpdateBaseCharm(modelName, "ubuntu@22.04"),
302+
Check: resource.ComposeTestCheckFunc(
303+
resource.TestCheckResourceAttr("juju_application.this", "charm.0.base", "ubuntu@22.04"),
304+
),
305+
},
306+
},
307+
})
308+
}
309+
279310
func TestAcc_ResourceRevisionUpdatesLXD(t *testing.T) {
280311
if testingCloud != LXDCloudTesting {
281312
t.Skip(t.Name() + " only runs with LXD")
@@ -1021,6 +1052,41 @@ func testAccResourceApplicationUpdatesCharm(modelName string, channel string) st
10211052
}
10221053
}
10231054

1055+
func testAccApplicationUpdateBaseCharm(modelName string, base string) string {
1056+
if testingCloud == LXDCloudTesting {
1057+
return fmt.Sprintf(`
1058+
resource "juju_model" "this" {
1059+
name = %q
1060+
}
1061+
1062+
resource "juju_application" "this" {
1063+
model = juju_model.this.name
1064+
name = "test-app"
1065+
charm {
1066+
name = "ubuntu"
1067+
base = %q
1068+
}
1069+
}
1070+
`, modelName, base)
1071+
} else {
1072+
return fmt.Sprintf(`
1073+
resource "juju_model" "this" {
1074+
name = %q
1075+
}
1076+
1077+
resource "juju_application" "this" {
1078+
model = juju_model.this.name
1079+
name = "test-app"
1080+
charm {
1081+
name = "coredns"
1082+
channel = "1.25/stable"
1083+
base = %q
1084+
}
1085+
}
1086+
`, modelName, base)
1087+
}
1088+
}
1089+
10241090
// testAccResourceApplicationConstraints will return two set for constraint
10251091
// applications. The version to be used in K8s sets the juju-external-hostname
10261092
// because we set the expose parameter.

0 commit comments

Comments
 (0)