diff --git a/go.mod b/go.mod index 84b1119a..daa32a40 100644 --- a/go.mod +++ b/go.mod @@ -5,8 +5,8 @@ go 1.21 require ( github.com/bflad/tfproviderlint v0.30.0 github.com/hashicorp/terraform-plugin-docs v0.19.4 - // v3.5-beta1 - github.com/juju/juju v0.0.0-20240415234708-a7538882134d + // v3.5.1 + github.com/juju/juju v0.0.0-20240524040137-95c267441801 ) @@ -16,8 +16,7 @@ require ( github.com/hashicorp/terraform-plugin-go v0.23.0 github.com/hashicorp/terraform-plugin-log v0.9.0 github.com/hashicorp/terraform-plugin-testing v1.8.0 - github.com/juju/charm/v11 v11.2.0 - github.com/juju/charm/v12 v12.0.1 + github.com/juju/charm/v12 v12.0.2 github.com/juju/clock v1.0.3 github.com/juju/cmd/v3 v3.0.14 github.com/juju/collections v1.0.4 @@ -129,8 +128,8 @@ require ( github.com/juju/lumberjack/v2 v2.0.2 // indirect github.com/juju/mgo/v3 v3.0.4 // indirect github.com/juju/mutex/v2 v2.0.0 // indirect - github.com/juju/os/v2 v2.2.3 // indirect - github.com/juju/packaging/v2 v2.0.1 // indirect + github.com/juju/os/v2 v2.2.5 // indirect + github.com/juju/packaging/v3 v3.0.0 // indirect github.com/juju/persistent-cookiejar v1.0.0 // indirect github.com/juju/proxy v1.0.0 // indirect github.com/juju/pubsub/v2 v2.0.0 // indirect diff --git a/go.sum b/go.sum index 0202311b..5a885330 100644 --- a/go.sum +++ b/go.sum @@ -324,10 +324,8 @@ github.com/juju/ansiterm v1.0.0 h1:gmMvnZRq7JZJx6jkfSq9/+2LMrVEwGwt7UR6G+lmDEg= github.com/juju/ansiterm v1.0.0/go.mod h1:PyXUpnI3olx3bsPcHt98FGPX/KCFZ1Fi+hw1XLI6384= github.com/juju/blobstore/v3 v3.0.2 h1:roZ4YBuZYmWId6y/6ZLQSAMmNlHOclHD8PQAMOQer6E= github.com/juju/blobstore/v3 v3.0.2/go.mod h1:NXEgMhrVH5744/zLfSkzsySlDQUpCgzvcNxjJJhICko= -github.com/juju/charm/v11 v11.2.0 h1:SJ4WI+qvQmMpOE+X6vnZ4NL/BvUJBdjzPN7tscfe6Tk= -github.com/juju/charm/v11 v11.2.0/go.mod h1:Mge5Ko3pPgocmk4v1pQgmBhF8BuBLGTCFu3jq83JvHk= -github.com/juju/charm/v12 v12.0.1 h1:C40Vv7Llfj05y86CnxxbQbSdIXdGzP01rajJR63Ni24= -github.com/juju/charm/v12 v12.0.1/go.mod h1:QRuKxXC5zzfPlAa8ipmxX1tvpbIBliueLVHGbs3T7wU= +github.com/juju/charm/v12 v12.0.2 h1:0UqIAAb4csYF0bGasGcmmIhq/bJUWfGU5+o3XFziWlY= +github.com/juju/charm/v12 v12.0.2/go.mod h1:QRuKxXC5zzfPlAa8ipmxX1tvpbIBliueLVHGbs3T7wU= github.com/juju/clock v1.0.3 h1:yJHIsWXeU8j3QcBdiess09SzfiXRRrsjKPn2whnMeds= github.com/juju/clock v1.0.3/go.mod h1:HIBvJ8kiV/n7UHwKuCkdYL4l/MDECztHR2sAvWDxxf0= github.com/juju/cmd/v3 v3.0.14 h1:KuuamArSH7vQ6SdQKEHYK2scEMkJTEZKLs8abrlW3XE= @@ -356,8 +354,8 @@ github.com/juju/idmclient/v2 v2.0.0 h1:PsGa092JGy6iFNHZCcao+bigVsTyz1C+tHNRdYmKv github.com/juju/idmclient/v2 v2.0.0/go.mod h1:EOiFbPmnkqKvCUS/DHpDRWhL7eKF0AJaTvMjIYlIUak= github.com/juju/jsonschema v1.0.0 h1:2ScR9hhVdHxft+Te3fnclVx61MmlikHNEOirTGi+hV4= github.com/juju/jsonschema v1.0.0/go.mod h1:SlFW+jFtpWX0P4Tb+zTTPR4ufttLrnJIdQPePxVEfkM= -github.com/juju/juju v0.0.0-20240415234708-a7538882134d h1:yK8L5+7fJvzsNkjUkkEps4dTGPa1RUr5rnumV4rKXIc= -github.com/juju/juju v0.0.0-20240415234708-a7538882134d/go.mod h1:8W1iXQ/tGftslRzNF/1U8mVZFjtqc0zZKygwuIcmbPI= +github.com/juju/juju v0.0.0-20240524040137-95c267441801 h1:5aNKt2VkNKsS2JI2QcCjNEvOVfMkNNSYaHX6z5odB30= +github.com/juju/juju v0.0.0-20240524040137-95c267441801/go.mod h1:dxhhMNnbWSXUB7EdQEsaxLhgwKQXo2Ctl4z4AZwWL88= github.com/juju/loggo v1.0.0 h1:Y6ZMQOGR9Aj3BGkiWx7HBbIx6zNwNkxhVNOHU2i1bl0= github.com/juju/loggo v1.0.0/go.mod h1:NIXFioti1SmKAlKNuUwbMenNdef59IF52+ZzuOmHYkg= github.com/juju/lru v1.0.0 h1:FP8mBNF3jBnKwGO5PtsR+8iIegx8DREfhRhpcGpYcn4= @@ -374,10 +372,10 @@ github.com/juju/names/v5 v5.0.0 h1:3IkRTUaniNXsgjy4lNqbJx7dVdsONlzuH6YMYT7uXss= github.com/juju/names/v5 v5.0.0/go.mod h1:PkvHbErUTniKvLu1ejJ5m/AbXOW55MFn1jsGVEbVXk8= github.com/juju/naturalsort v1.0.0 h1:kGmUUy3h8mJ5/SJYaqKOBR3f3owEd5R52Lh+Tjg/dNM= github.com/juju/naturalsort v1.0.0/go.mod h1:Zqa/vGkXr78k47zM6tFmU9phhxKz/PIdqBzpLhJ86zc= -github.com/juju/os/v2 v2.2.3 h1:5SnGWfzFTXcFwu/sd9qEEf/No3UZxivOjJuWmsHI4N4= -github.com/juju/os/v2 v2.2.3/go.mod h1:xGfP9I+Xb/A03NcGBsoJgwr084hPckkQHecaHuV3wBQ= -github.com/juju/packaging/v2 v2.0.1 h1:KeTfqx3Z0c6RcM053GJH7mplroXoRSuh/dK5vqDQLn8= -github.com/juju/packaging/v2 v2.0.1/go.mod h1:JC+FIRTJXGLt9wA+iP3ltkzv+aWVMMojB/R47uIAK0Y= +github.com/juju/os/v2 v2.2.5 h1:Ayw9aC7axKtGgzy3dFRKx84FxasfISMege0iYDsH6io= +github.com/juju/os/v2 v2.2.5/go.mod h1:igGQLjgRSwUery5ZhV/1pZjZkMwnfkAwWCwh5ZfIg+c= +github.com/juju/packaging/v3 v3.0.0 h1:ZzuHhR8Ql9z2oeQ0m73x6k58PW65Cgk5wR9Yc1exoOI= +github.com/juju/packaging/v3 v3.0.0/go.mod h1:WXh/SXqh1du8SFzwb1KC+yZuV4Qc4alWP3MEPqFX9Lw= github.com/juju/persistent-cookiejar v1.0.0 h1:Ag7+QLzqC2m+OYXy2QQnRjb3gTkEBSZagZ6QozwT3EQ= github.com/juju/persistent-cookiejar v1.0.0/go.mod h1:zrbmo4nBKaiP/Ez3F67ewkMbzGYfXyMvRtbOfuAwG0w= github.com/juju/proxy v1.0.0 h1:5XMp0opQJx8K/js3RFG/2EAk+cvKba/zwFJwd5f0AW0= diff --git a/internal/juju/utils.go b/internal/juju/utils.go index 7928e8fe..dd558c83 100644 --- a/internal/juju/utils.go +++ b/internal/juju/utils.go @@ -55,16 +55,11 @@ var singleQuery sync.Once // GetLocalControllerConfig runs the locally installed juju command, // if available, to get the current controller configuration. -func GetLocalControllerConfig() (map[string]string, error) { +func GetLocalControllerConfig() (map[string]string, bool) { // populate the controller controllerConfig information only once singleQuery.Do(populateControllerConfig) - // if empty something went wrong - if localProviderConfig == nil { - return nil, errors.New("the Juju CLI could not be accessed") - } - - return localProviderConfig, nil + return localProviderConfig, localProviderConfig == nil } // populateControllerConfig executes the local juju CLI command diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 75b57086..0743ee01 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -21,6 +21,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/juju/terraform-provider-juju/internal/juju" ) @@ -39,37 +40,50 @@ const ( JujuClientID = "client_id" JujuClientSecret = "client_secret" JujuCACert = "ca_certificate" + + TwoSourcesAuthWarning = "Two sources of identity for controller login" ) -// populateJujuProviderModelLive gets the controller config, -// first from environment variables, then from a live juju -// controller as a fallback. -func populateJujuProviderModelLive() (jujuProviderModel, error) { +// jujuProviderModelEnvVar gets the controller config, +// from environment variables. +func jujuProviderModelEnvVar() jujuProviderModel { + return jujuProviderModel{ + ControllerAddrs: getEnvVar(JujuControllerEnvKey), + CACert: getEnvVar(JujuCACertEnvKey), + ClientID: getEnvVar(JujuClientIDEnvKey), + ClientSecret: getEnvVar(JujuClientSecretEnvKey), + UserName: getEnvVar(JujuUsernameEnvKey), + Password: getEnvVar(JujuPasswordEnvKey), + } +} + +func jujuProviderModelLiveDiscovery() (jujuProviderModel, bool) { data := jujuProviderModel{} controllerConfig, cliNotExist := juju.GetLocalControllerConfig() - data.ControllerAddrs = types.StringValue(getField(JujuControllerEnvKey, controllerConfig)) - data.UserName = types.StringValue(getField(JujuUsernameEnvKey, controllerConfig)) - data.Password = types.StringValue(getField(JujuPasswordEnvKey, controllerConfig)) - data.ClientID = types.StringValue(getField(JujuClientIDEnvKey, controllerConfig)) - data.ClientSecret = types.StringValue(getField(JujuClientSecretEnvKey, controllerConfig)) - data.CACert = types.StringValue(getField(JujuCACertEnvKey, controllerConfig)) - // Only error if a valid controller config could not be fetched - // from the environment variables. - if cliNotExist != nil && !data.valid() { - return data, errors.New("unable to acquire Juju controller config: no working Juju client, and environment variables are not fully set") - } - - return data, nil + + if ctrlAddrs, ok := controllerConfig[JujuControllerEnvKey]; ok && ctrlAddrs != "" { + data.ControllerAddrs = types.StringValue(ctrlAddrs) + } + if caCert, ok := controllerConfig[JujuCACertEnvKey]; ok && caCert != "" { + data.CACert = types.StringValue(caCert) + } + if user, ok := controllerConfig[JujuUsernameEnvKey]; ok && user != "" { + data.UserName = types.StringValue(user) + } + if password, ok := controllerConfig[JujuPasswordEnvKey]; ok && password != "" { + data.Password = types.StringValue(password) + } + return data, cliNotExist } -func getField(field string, config map[string]string) string { - // get the value from the environment variable - controller := os.Getenv(field) - if controller == "" { +func getEnvVar(field string) types.String { + value := types.StringNull() + envVar := os.Getenv(field) + if envVar != "" { // fall back to the live juju data - controller = config[field] + value = types.StringValue(envVar) } - return controller + return value } // Ensure jujuProvider satisfies various provider interfaces. @@ -93,13 +107,62 @@ type jujuProviderModel struct { ClientSecret types.String `tfsdk:"client_secret"` } +func (j jujuProviderModel) loginViaUsername() bool { + return j.UserName.ValueString() != "" && j.Password.ValueString() != "" +} + +func (j jujuProviderModel) loginViaClientCredentials() bool { + return j.ClientID.ValueString() != "" && j.ClientSecret.ValueString() != "" +} + func (j jujuProviderModel) valid() bool { - validUserPass := j.UserName.ValueString() != "" && j.Password.ValueString() != "" - validClientCredentials := j.ClientID.ValueString() != "" && j.ClientSecret.ValueString() != "" + validUserPass := j.loginViaUsername() + validClientCredentials := j.loginViaClientCredentials() return j.ControllerAddrs.ValueString() != "" && j.CACert.ValueString() != "" && - (validUserPass || validClientCredentials) + (validUserPass || validClientCredentials) && + !(validUserPass && validClientCredentials) +} + +// merge 2 providerModels together. The receiver data takes +// precedence. If the model is valid after the client ID and +// client secret are set, return. They take precedence over +// username and password. The two combinations are also +// mutually exclusive. Diagnostic warning are returned if +// the new data contains a username but the current data has +// client ID. +func (j jujuProviderModel) merge(in jujuProviderModel, from string) (jujuProviderModel, diag.Diagnostics) { + diags := diag.Diagnostics{} + mergedModel := j + if mergedModel.ControllerAddrs.ValueString() == "" { + mergedModel.ControllerAddrs = in.ControllerAddrs + } + if mergedModel.CACert.ValueString() == "" { + mergedModel.CACert = in.CACert + } + if mergedModel.ClientID.ValueString() == "" { + mergedModel.ClientID = in.ClientID + } + if mergedModel.ClientSecret.ValueString() == "" { + mergedModel.ClientSecret = in.ClientSecret + } + if mergedModel.valid() { + if in.UserName.ValueString() != "" { + diags.AddWarning(TwoSourcesAuthWarning, + fmt.Sprintf("Ignoring Username value. Username provided via %s,"+ + "however Client ID already available. Only one login type is possible.", from)) + } + + return mergedModel, diags + } + if mergedModel.UserName.ValueString() == "" { + mergedModel.UserName = in.UserName + } + if mergedModel.Password.ValueString() == "" { + mergedModel.Password = in.Password + } + return mergedModel, diags } // Metadata returns the metadata for the provider, such as @@ -215,65 +278,83 @@ func (p *jujuProvider) Configure(ctx context.Context, req provider.ConfigureRequ // the plan being used, then fall back to the JUJU_ environment variables, // lastly check to see if an active juju can supply the data. func getJujuProviderModel(ctx context.Context, req provider.ConfigureRequest) (jujuProviderModel, diag.Diagnostics) { - var data jujuProviderModel + var planData jujuProviderModel var diags diag.Diagnostics - // Read Terraform configuration data into the data model - diags.Append(req.Config.Get(ctx, &data)...) + // Read Terraform configuration data into the juju provider model. + diags.Append(req.Config.Get(ctx, &planData)...) if diags.HasError() { - return data, diags + return planData, diags } - if data.valid() { + if planData.valid() { // The plan contained full controller config, // no need to continue - return data, diags + return planData, diags + } + // If validation failed because we have both username/password + // and client ID/secret combinations in the plan. Exit now. + if planData.UserName.ValueString() != "" && planData.ClientID.ValueString() != "" { + diags.AddError("Only username and password OR client id and "+ + "client secret may be used.", + "Both username and client id are set in the plan. Please remove "+ + "one of the login methods and try again.") + return planData, diags } // Not all controller config contained in the plan, attempt - // to find it. - liveData, err := populateJujuProviderModelLive() - if err != nil { - diags.AddError("Unable to get live controller data", err.Error()) - return data, diags - } - if data.ControllerAddrs.ValueString() == "" { - data.ControllerAddrs = liveData.ControllerAddrs - } - if data.UserName.ValueString() == "" { - data.UserName = liveData.UserName + // to find it via the optional environment variables. + envVarData := jujuProviderModelEnvVar() + planEnvVarDataModel, planEnvVarDataDiags := planData.merge(envVarData, "environment variables") + diags.Append(planEnvVarDataDiags...) + if planEnvVarDataModel.valid() { + return planEnvVarDataModel, diags } - if data.Password.ValueString() == "" { - data.Password = liveData.Password - } - if data.ClientID.ValueString() == "" { - data.ClientID = liveData.ClientID + if planEnvVarDataModel.loginViaClientCredentials() { + if planEnvVarDataModel.ControllerAddrs.ValueString() == "" { + diags.AddError("Controller address required", "The provider must know which juju controller to use. Please add to plan or use the JUJU_CONTROLLER_ADDRESSES environment variable.") + } + if planEnvVarDataModel.CACert.ValueString() == "" { + diags.AddError("Controller CACert required", "For the Juju certificate authority to be trusted by your system. Please add to plan or use the JUJU_CA_CERT environment variable.") + } } - if data.ClientSecret.ValueString() == "" { - data.ClientSecret = liveData.ClientSecret + if diags.HasError() { + return planEnvVarDataModel, diags } - if data.CACert.ValueString() == "" { - data.CACert = liveData.CACert + + // Not all controller config contained in the plan, attempt + // to find it via live discovery. + liveData, cliAlive := jujuProviderModelLiveDiscovery() + errMsgDataModel := planEnvVarDataModel + if cliAlive { + livePlanEnvVarDataModel, livePlanEnvVarDataDiags := planEnvVarDataModel.merge(liveData, "live discovery") + diags.Append(livePlanEnvVarDataDiags...) + if livePlanEnvVarDataModel.valid() { + return livePlanEnvVarDataModel, diags + } + errMsgDataModel = livePlanEnvVarDataModel + } else { + tflog.Debug(ctx, "Live discovery of juju controller failed. The Juju CLI could not be accessed.") } // Validate controller config and return helpful error messages. - validUserPass := (data.UserName.ValueString() != "" && data.Password.ValueString() != "") - validClientCredentials := (data.ClientID.ValueString() != "" && data.ClientSecret.ValueString() != "") - if !validUserPass && !validClientCredentials { + if !errMsgDataModel.loginViaUsername() && !errMsgDataModel.loginViaClientCredentials() { diags.AddError( "Username and password or client id and client secret must be set", - "Currently the provider can authenticate using username and password or client id and client secret, otherwise it will panic", + "Currently the provider can authenticate using username and password or client id and client secret, otherwise it will panic.", ) } - - if data.ControllerAddrs.ValueString() == "" { + if errMsgDataModel.ControllerAddrs.ValueString() == "" { diags.AddError("Controller address required", "The provider must know which juju controller to use.") } - - if data.CACert.ValueString() == "" { - diags.AddError("Controller CACert", "Required for the Juju certificate authority to be trusted by your system") + if errMsgDataModel.CACert.ValueString() == "" { + diags.AddError("Controller CACert required", "For the Juju certificate authority to be trusted by your system.") + } + if diags.HasError() { + tflog.Debug(ctx, "Current login values.", + map[string]interface{}{"jujuProviderModel": planData}) } - return data, diags + return errMsgDataModel, diags } // Resources returns a slice of functions to instantiate each Resource diff --git a/internal/provider/provider_test.go b/internal/provider/provider_test.go index 6c3ff2ce..cda38b44 100644 --- a/internal/provider/provider_test.go +++ b/internal/provider/provider_test.go @@ -150,6 +150,9 @@ func TestProviderConfigurex509InvalidFromEnv(t *testing.T) { } func testAccPreCheck(t *testing.T) { + if TestClient != nil { + return + } if v := os.Getenv(JujuUsernameEnvKey); v == "" { t.Fatalf("%s must be set for acceptance tests", JujuUsernameEnvKey) } @@ -170,7 +173,9 @@ func testAccPreCheck(t *testing.T) { } confResp := configureProvider(t, Provider) assert.Equal(t, confResp.Diagnostics.HasError(), false) - TestClient = confResp.ResourceData.(*juju.Client) + testClient, ok := confResp.ResourceData.(*juju.Client) + assert.Truef(t, ok, "ResourceData, not of type juju client") + TestClient = testClient } func configureProvider(t *testing.T, p provider.Provider) provider.ConfigureResponse { diff --git a/internal/provider/validator_channel.go b/internal/provider/validator_channel.go index b747b9a8..a278b27c 100644 --- a/internal/provider/validator_channel.go +++ b/internal/provider/validator_channel.go @@ -7,7 +7,7 @@ import ( "context" "github.com/hashicorp/terraform-plugin-framework/schema/validator" - "github.com/juju/charm/v11" + "github.com/juju/charm/v12" ) type StringIsChannelValidator struct{}