From 967b43657961c1e4fd496c6525a29dec83f4b26f Mon Sep 17 00:00:00 2001 From: Vitaly Antonenko Date: Wed, 27 Mar 2024 13:10:52 +0300 Subject: [PATCH] Implement methods for user secrets management This commit introduces internal/juju/userSecret and adds method to add user secrets. Implement internal Juju secrets add, update, and remove functionality This commit introduces several changes to the Juju client in the `internal/juju/client.go` file. It includes the implementation of methods for adding, updating, and removing secrets. Additionally, Furthermore, the commit includes changes to the `secret.go` file, introducing new types for managinng secrets. It also includes changes to the `interfaces.go` file, defining new interfaces for the Juju client API. Add secretURI to UpdateSecret Add secretURI to DeleteSecret Add AutoPrunt to UpdateSecret schema Add SecretId to ReadSecret func instead of name. Add lost Asserts. Add secretNotFoundError Extract mocks creation into separate suite. Introduce typedError(err) usage in ClientAPI funcs. Add renaming to UpdateSecret Use struct raather than pointer for Output structures. Introcue NewName in Update input struct. Use pointers in all places in structs where the parameter is not neccessary. Implement schema for user secrets management This commit introduces the ability to add, update, and remove user secrets in the schema. This is done through the `userSecretResource` struct, which has methods for each of these actions. The `Add`, `Update`, and `Remove` methods are currently stubbed out and will need to be implemented in future commits. Add ReadSecret function implementation. Implement Delete secret function Add base64 encoding co Create and change Read fucntion to get value with decode. Add base64 encoding for values in Update --- README.md | 1 + internal/juju/models.go | 2 +- internal/juju/utils.go | 1 + internal/provider/helpers.go | 1 + internal/provider/provider.go | 1 + internal/provider/resource_secret.go | 308 ++++++++++++++++++++++ internal/provider/resource_secret_test.go | 207 +++++++++++++++ internal/testing/versioncomparer.go | 37 +++ 8 files changed, 557 insertions(+), 1 deletion(-) create mode 100644 internal/provider/resource_secret.go create mode 100644 internal/provider/resource_secret_test.go create mode 100644 internal/testing/versioncomparer.go diff --git a/README.md b/README.md index efbe32dd..62158c73 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,7 @@ For example, here they are set using the currently active controller: ```shell export CONTROLLER=$(juju whoami | yq .Controller) +export JUJU_AGENT_VERSION="$(juju show-controller | yq .[$CONTROLLER].details.\"agent-version\"|tr -d '"')" export JUJU_CONTROLLER_ADDRESSES="$(juju show-controller | yq '.[$CONTROLLER]'.details.\"api-endpoints\" | tr -d "[]' "|tr -d '"'|tr -d '\n')" export JUJU_USERNAME="$(cat ~/.local/share/juju/accounts.yaml | yq .controllers.$CONTROLLER.user|tr -d '"')" export JUJU_PASSWORD="$(cat ~/.local/share/juju/accounts.yaml | yq .controllers.$CONTROLLER.password|tr -d '"')" diff --git a/internal/juju/models.go b/internal/juju/models.go index cff08caf..cebeb916 100644 --- a/internal/juju/models.go +++ b/internal/juju/models.go @@ -175,7 +175,7 @@ func (c *modelsClient) CreateModel(input CreateModelInput) (CreateModelResponse, resp.Type = modelInfo.Type.String() resp.UUID = modelInfo.UUID - // Add the model to the client cache of jujuModel + // Add a model object on the client internal to the provider c.AddModel(modelInfo.Name, modelInfo.UUID, modelInfo.Type) // set constraints when required diff --git a/internal/juju/utils.go b/internal/juju/utils.go index ca4c0fed..91a65745 100644 --- a/internal/juju/utils.go +++ b/internal/juju/utils.go @@ -108,6 +108,7 @@ func populateControllerConfig() { } localProviderConfig = map[string]string{} + localProviderConfig["JUJU_AGENT_VERSION"] = controllerConfig.ProviderDetails.AgentVersion localProviderConfig["JUJU_CONTROLLER_ADDRESSES"] = strings.Join(controllerConfig.ProviderDetails.ApiEndpoints, ",") localProviderConfig["JUJU_CA_CERT"] = controllerConfig.ProviderDetails.CACert localProviderConfig["JUJU_USERNAME"] = controllerConfig.Account.User diff --git a/internal/provider/helpers.go b/internal/provider/helpers.go index da099674..71a1facb 100644 --- a/internal/provider/helpers.go +++ b/internal/provider/helpers.go @@ -28,6 +28,7 @@ const ( LogResourceOffer = "resource-offer" LogResourceSSHKey = "resource-sshkey" LogResourceUser = "resource-user" + LogResourceSecret = "resource-secret" ) const LogResourceIntegration = "resource-integration" diff --git a/internal/provider/provider.go b/internal/provider/provider.go index f5f80625..3bdf7a17 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -233,6 +233,7 @@ func (p *jujuProvider) Resources(_ context.Context) []func() resource.Resource { func() resource.Resource { return NewOfferResource() }, func() resource.Resource { return NewSSHKeyResource() }, func() resource.Resource { return NewUserResource() }, + func() resource.Resource { return NewSecretResource() }, } } diff --git a/internal/provider/resource_secret.go b/internal/provider/resource_secret.go new file mode 100644 index 00000000..710cb6cf --- /dev/null +++ b/internal/provider/resource_secret.go @@ -0,0 +1,308 @@ +// Copyright 2024 Canonical Ltd. +// Licensed under the Apache License, Version 2.0, see LICENCE file for details. + +package provider + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/juju/terraform-provider-juju/internal/juju" +) + +// Ensure provider defined types fully satisfy framework interfaces. +var _ resource.Resource = &secretResource{} +var _ resource.ResourceWithConfigure = &secretResource{} +var _ resource.ResourceWithImportState = &secretResource{} + +func NewSecretResource() resource.Resource { + return &secretResource{} +} + +type secretResource struct { + client *juju.Client + + // subCtx is the context created with the new tflog subsystem for applications. + subCtx context.Context +} + +type secretResourceModel struct { + // Model to which the secret belongs. This attribute is required for all actions. + Model types.String `tfsdk:"model"` + // Name of the secret to be updated or removed. This attribute is required for 'update' and 'remove' actions. + Name types.String `tfsdk:"name"` + // Value of the secret to be added or updated. This attribute is required for 'add' and 'update' actions. + // Template: [[#base64]]=[ ...] + Value types.Map `tfsdk:"value"` + // SecretId is the ID of the secret to be updated or removed. This attribute is required for 'update' and 'remove' actions. + SecretId types.String `tfsdk:"secret_id"` + // Info is the description of the secret. This attribute is optional for all actions. + Info types.String `tfsdk:"info"` +} + +func (s *secretResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) +} + +func (s *secretResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_secret" +} + +func (s *secretResource) Schema(_ context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "A resource that represents a Juju secret.", + Attributes: map[string]schema.Attribute{ + "model": schema.StringAttribute{ + Description: "The model in which the secret belongs.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "name": schema.StringAttribute{ + Description: "The name of the secret.", + Optional: true, + }, + "value": schema.MapAttribute{ + Description: "The value map of the secret. There can be more than one key-value pair.", + ElementType: types.StringType, + Required: true, + Sensitive: true, + }, + "secret_id": schema.StringAttribute{ + Description: "The ID of the secret.", + Computed: true, + }, + "info": schema.StringAttribute{ + Description: "The description of the secret.", + Optional: true, + }, + }, + } +} + +// Configure sets up the Juju client for the secret resource. +func (s *secretResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(*juju.Client) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *juju.Client, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + return + } + s.client = client + // Create the local logging subsystem here, using the TF context when creating it. + s.subCtx = tflog.NewSubsystem(ctx, LogResourceSecret) +} + +// Create creates a new secret in the Juju model. +func (s *secretResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + // Prevent panic if the provider has not been configured. + if s.client == nil { + addClientNotConfiguredError(&resp.Diagnostics, "secret", "create") + return + } + + var plan secretResourceModel + + // Read Terraform plan into the model + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + s.trace(fmt.Sprintf("creating secret resource %q", plan.Name.ValueString())) + + secretValue := make(map[string]string) + resp.Diagnostics.Append(plan.Value.ElementsAs(ctx, &secretValue, false)...) + + createSecretOutput, err := s.client.Secrets.CreateSecret(&juju.CreateSecretInput{ + ModelName: plan.Model.ValueString(), + Name: plan.Name.ValueString(), + Value: secretValue, + Info: plan.Info.ValueString(), + }) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to add secret, got error: %s", err)) + return + } + + plan.SecretId = types.StringValue(createSecretOutput.SecretId) + + // Save plan into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) + + s.trace(fmt.Sprintf("created secret resource %q", plan.SecretId)) +} + +// Read reads the details of a secret in the Juju model. +func (s *secretResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + // Prevent panic if the provider has not been configured. + if s.client == nil { + addClientNotConfiguredError(&resp.Diagnostics, "secret", "read") + return + } + + var state secretResourceModel + + // Read Terraform configuration state into the model + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + s.trace(fmt.Sprintf("reading secret resource %q", state.SecretId)) + + readSecretOutput, err := s.client.Secrets.ReadSecret(&juju.ReadSecretInput{ + SecretId: state.SecretId.ValueString(), + ModelName: state.Model.ValueString(), + Name: state.Name.ValueStringPointer(), + Revision: nil, + }) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read secret, got error: %s", err)) + return + } + + // Save the secret details into the Terraform state + if !state.Name.IsNull() { + state.Name = types.StringValue(readSecretOutput.Name) + } + if !state.Info.IsNull() { + state.Info = types.StringValue(readSecretOutput.Info) + } + + secretValue, errDiag := types.MapValueFrom(ctx, types.StringType, readSecretOutput.Value) + resp.Diagnostics.Append(errDiag...) + if resp.Diagnostics.HasError() { + return + } + state.Value = secretValue + + // Save state into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) + + s.trace(fmt.Sprintf("read secret resource %q", state.SecretId)) +} + +// Update updates the details of a secret in the Juju model. +func (s *secretResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + // Prevent panic if the provider has not been configured. + if s.client == nil { + addClientNotConfiguredError(&resp.Diagnostics, "secret", "update") + return + } + + var plan, state secretResourceModel + + // Read Terraform plan and state into the model + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + s.trace(fmt.Sprintf("updating secret resource %q", state.SecretId)) + s.trace(fmt.Sprintf("Update - current state: %v", state)) + s.trace(fmt.Sprintf("Update - proposed plan: %v", plan)) + + var err error + noChange := true + + var updatedSecretInput juju.UpdateSecretInput + + updatedSecretInput.ModelName = state.Model.ValueString() + updatedSecretInput.SecretId = state.SecretId.ValueString() + + // Check if the secret name has changed + if !plan.Name.Equal(state.Name) { + noChange = false + state.Name = plan.Name + updatedSecretInput.Name = plan.Name.ValueStringPointer() + } + + // Check if the secret value has changed + if !plan.Value.Equal(state.Value) { + noChange = false + resp.Diagnostics.Append(plan.Value.ElementsAs(ctx, &state.Value, false)...) + if resp.Diagnostics.HasError() { + return + } + resp.Diagnostics.Append(plan.Value.ElementsAs(ctx, &updatedSecretInput.Value, false)...) + if resp.Diagnostics.HasError() { + return + } + } + + // Check if the secret info has changed + if !plan.Info.Equal(state.Info) { + noChange = false + state.Info = plan.Info + updatedSecretInput.Info = plan.Info.ValueStringPointer() + } + + if noChange { + return + } + + err = s.client.Secrets.UpdateSecret(&updatedSecretInput) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to update secret, got error: %s", err)) + return + } + + // Save updated data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) + + s.trace(fmt.Sprintf("updated secret resource %q", state.SecretId)) +} + +// Delete removes a secret from the Juju model. +func (s *secretResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + // Prevent panic if the provider has not been configured. + if s.client == nil { + addClientNotConfiguredError(&resp.Diagnostics, "secret", "delete") + return + } + + var state secretResourceModel + + // Read Terraform configuration state into the model + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + s.trace(fmt.Sprintf("deleting secret resource %q", state.SecretId)) + + err := s.client.Secrets.DeleteSecret(&juju.DeleteSecretInput{ + ModelName: state.Model.ValueString(), + SecretId: state.SecretId.ValueString(), + }) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to delete secret, got error: %s", err)) + return + } + + s.trace(fmt.Sprintf("deleted secret resource %q", state.SecretId)) +} + +func (s *secretResource) trace(msg string, additionalFields ...map[string]interface{}) { + if s.subCtx == nil { + return + } + tflog.SubsystemTrace(s.subCtx, LogResourceSecret, msg, additionalFields...) +} diff --git a/internal/provider/resource_secret_test.go b/internal/provider/resource_secret_test.go new file mode 100644 index 00000000..6b670dd0 --- /dev/null +++ b/internal/provider/resource_secret_test.go @@ -0,0 +1,207 @@ +// Copyright 2024 Canonical Ltd. +// Licensed under the Apache License, Version 2.0, see LICENCE file for details. + +package provider + +import ( + "os" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + internaltesting "github.com/juju/terraform-provider-juju/internal/testing" +) + +func TestAcc_ResourceSecret_CreateWithoutName(t *testing.T) { + if os.Getenv("JUJU_AGENT_VERSION") == "" || internaltesting.CompareVersions(os.Getenv("JUJU_AGENT_VERSION"), "3.3.0") < 0 { + t.Skip("JUJU_AGENT_VERSION is not set or is below 3.3.0") + } + + modelName := acctest.RandomWithPrefix("tf-test-model") + secretInfo := "test-info" + secretValue := map[string]string{ + "key1": "value1", + "key2": "value2", + } + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: frameworkProviderFactories, + Steps: []resource.TestStep{ + { + Config: testAccResourceSecretWithoutName(modelName, secretValue, secretInfo), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("juju_secret.noname", "model", modelName), + resource.TestCheckResourceAttr("juju_secret.noname", "info", secretInfo), + resource.TestCheckResourceAttr("juju_secret.noname", "value.key1", "value1"), + resource.TestCheckResourceAttr("juju_secret.noname", "value.key2", "value2"), + ), + }, + }, + }) +} + +func TestAcc_ResourceSecret_CreateWithInfo(t *testing.T) { + if os.Getenv("JUJU_AGENT_VERSION") == "" || internaltesting.CompareVersions(os.Getenv("JUJU_AGENT_VERSION"), "3.3.0") < 0 { + t.Skip("JUJU_AGENT_VERSION is not set or is below 3.3.0") + } + + modelName := acctest.RandomWithPrefix("tf-test-model") + secretName := "tf-test-secret" + secretInfo := "test-info" + secretValue := map[string]string{ + "key1": "value1", + "key2": "value2", + } + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: frameworkProviderFactories, + Steps: []resource.TestStep{ + { + Config: testAccResourceSecret(modelName, secretName, secretValue, secretInfo), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("juju_secret."+secretName, "model", modelName), + resource.TestCheckResourceAttr("juju_secret."+secretName, "name", secretName), + resource.TestCheckResourceAttr("juju_secret."+secretName, "info", secretInfo), + resource.TestCheckResourceAttr("juju_secret."+secretName, "value.key1", "value1"), + resource.TestCheckResourceAttr("juju_secret."+secretName, "value.key2", "value2"), + ), + }, + }, + }) +} + +func TestAcc_ResourceSecret_CreateWithNoInfo(t *testing.T) { + if os.Getenv("JUJU_AGENT_VERSION") == "" || internaltesting.CompareVersions(os.Getenv("JUJU_AGENT_VERSION"), "3.3.0") < 0 { + t.Skip("JUJU_AGENT_VERSION is not set or is below 3.3.0") + } + + modelName := acctest.RandomWithPrefix("tf-test-model") + secretName := "tf-test-secret" + secretValue := map[string]string{ + "key1": "value1", + "key2": "value2", + } + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: frameworkProviderFactories, + Steps: []resource.TestStep{ + { + Config: testAccResourceSecret(modelName, secretName, secretValue, ""), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("juju_secret."+secretName, "model", modelName), + resource.TestCheckResourceAttr("juju_secret."+secretName, "name", secretName), + resource.TestCheckResourceAttr("juju_secret."+secretName, "value.key1", "value1"), + resource.TestCheckResourceAttr("juju_secret."+secretName, "value.key2", "value2"), + ), + }, + }, + }) +} + +func TestAcc_ResourceSecret_Update(t *testing.T) { + if os.Getenv("JUJU_AGENT_VERSION") == "" || internaltesting.CompareVersions(os.Getenv("JUJU_AGENT_VERSION"), "3.3.0") < 0 { + t.Skip("JUJU_AGENT_VERSION is not set or is below 3.3.0") + } + + modelName := acctest.RandomWithPrefix("tf-test-model") + secretName := "tf-test-secret" + secretInfo := "test-info" + + updatedSecretInfo := "updated-test-info" + + secretValue := map[string]string{ + "key1": "value1", + "key2": "value2", + } + + secretValueUpdated := map[string]string{ + "key1": "value1", + "key2": "newValue2", + "key3": "value3", + } + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: frameworkProviderFactories, + Steps: []resource.TestStep{ + { + Config: testAccResourceSecret(modelName, secretName, secretValue, secretInfo), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("juju_secret."+secretName, "model", modelName), + resource.TestCheckResourceAttr("juju_secret."+secretName, "name", secretName), + resource.TestCheckResourceAttr("juju_secret."+secretName, "info", secretInfo), + resource.TestCheckResourceAttr("juju_secret."+secretName, "value.key1", "value1"), + resource.TestCheckResourceAttr("juju_secret."+secretName, "value.key2", "value2"), + ), + }, + { + Config: testAccResourceSecret(modelName, secretName, secretValueUpdated, updatedSecretInfo), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("juju_secret."+secretName, "model", modelName), + resource.TestCheckResourceAttr("juju_secret."+secretName, "name", secretName), + resource.TestCheckResourceAttr("juju_secret."+secretName, "info", updatedSecretInfo), + resource.TestCheckResourceAttr("juju_secret."+secretName, "value.key1", "value1"), + resource.TestCheckResourceAttr("juju_secret."+secretName, "value.key2", "newValue2"), + resource.TestCheckResourceAttr("juju_secret."+secretName, "value.key3", "value3"), + ), + }, + }, + }) +} + +func testAccResourceSecret(modelName, secretName string, secretValue map[string]string, secretInfo string) string { + return internaltesting.GetStringFromTemplateWithData( + "testAccResourceSecret", + ` +resource "juju_model" "{{.ModelName}}" { + name = "{{.ModelName}}" +} + +resource "juju_secret" "{{.SecretName}}" { + model = juju_model.{{.ModelName}}.name + name = "{{.SecretName}}" + value = { + {{- range $key, $value := .SecretValue }} + "{{$key}}" = "{{$value}}" + {{- end }} + } + {{- if ne .SecretInfo "" }} + info = "{{.SecretInfo}}" + {{- end }} +} +`, internaltesting.TemplateData{ + "ModelName": modelName, + "SecretName": secretName, + "SecretValue": secretValue, + "SecretInfo": secretInfo, + }) +} + +func testAccResourceSecretWithoutName(modelName string, secretValue map[string]string, secretInfo string) string { + return internaltesting.GetStringFromTemplateWithData( + "testAccResourceSecret", + ` +resource "juju_model" "{{.ModelName}}" { + name = "{{.ModelName}}" +} + +resource "juju_secret" "noname" { + model = juju_model.{{.ModelName}}.name + value = { + {{- range $key, $value := .SecretValue }} + "{{$key}}" = "{{$value}}" + {{- end }} + } + {{- if ne .SecretInfo "" }} + info = "{{.SecretInfo}}" + {{- end }} +} +`, internaltesting.TemplateData{ + "ModelName": modelName, + "SecretValue": secretValue, + "SecretInfo": secretInfo, + }) +} diff --git a/internal/testing/versioncomparer.go b/internal/testing/versioncomparer.go new file mode 100644 index 00000000..f9cff6f5 --- /dev/null +++ b/internal/testing/versioncomparer.go @@ -0,0 +1,37 @@ +// Copyright 2024 Canonical Ltd. +// Licensed under the Apache License, Version 2.0, see LICENCE file for details. + +package testsing + +import ( + "strconv" + "strings" +) + +// CompareVersions compares two versions in the format "X.Y.Z" and returns: +// -1 if version1 is less than version2 +// 0 if version1 is equal to version2 +// 1 if version1 is greater than version2 +func CompareVersions(version1, version2 string) int { + v1Parts := strings.Split(version1, ".") + v2Parts := strings.Split(version2, ".") + + for i := 0; i < 3; i++ { + v1, err := strconv.Atoi(v1Parts[i]) + if err != nil { + panic(err) + } + v2, err := strconv.Atoi(v2Parts[i]) + if err != nil { + panic(err) + } + + if v1 < v2 { + return -1 + } else if v1 > v2 { + return 1 + } + } + + return 0 +}