diff --git a/pkg/registry/registry.go b/pkg/registry/registry.go index 7e981d9..7feae44 100644 --- a/pkg/registry/registry.go +++ b/pkg/registry/registry.go @@ -6,6 +6,7 @@ import ( "go.clever-cloud.com/terraform-provider/pkg/resources/addon" "go.clever-cloud.com/terraform-provider/pkg/resources/cellar" "go.clever-cloud.com/terraform-provider/pkg/resources/cellar/bucket" + "go.clever-cloud.com/terraform-provider/pkg/resources/docker" "go.clever-cloud.com/terraform-provider/pkg/resources/java" "go.clever-cloud.com/terraform-provider/pkg/resources/materiakv" "go.clever-cloud.com/terraform-provider/pkg/resources/mongodb" @@ -32,4 +33,5 @@ var Resources = []func() resource.Resource{ python.NewResourcePython, scala.NewResourceScala(), static.NewResourceStatic(), + docker.NewResourceDocker(), } diff --git a/pkg/resources/docker/resource_docker.go b/pkg/resources/docker/resource_docker.go new file mode 100644 index 0000000..864ad4e --- /dev/null +++ b/pkg/resources/docker/resource_docker.go @@ -0,0 +1,23 @@ +package docker + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource" + "go.clever-cloud.dev/client" +) + +type ResourceDocker struct { + cc *client.Client + org string +} + +func NewResourceDocker() func() resource.Resource { + return func() resource.Resource { + return &ResourceDocker{} + } +} + +func (r *ResourceDocker) Metadata(ctx context.Context, req resource.MetadataRequest, res *resource.MetadataResponse) { + res.TypeName = req.ProviderTypeName + "_docker" +} diff --git a/pkg/resources/docker/resource_docker.md b/pkg/resources/docker/resource_docker.md new file mode 100644 index 0000000..d2bafba --- /dev/null +++ b/pkg/resources/docker/resource_docker.md @@ -0,0 +1,3 @@ +Manage [Docker](https://) applications. + +See [Docker product](https://www.clever-cloud.com/doc/getting-started/by-language/docker/) specification. diff --git a/pkg/resources/docker/resource_docker_crud.go b/pkg/resources/docker/resource_docker_crud.go new file mode 100644 index 0000000..9ee8d48 --- /dev/null +++ b/pkg/resources/docker/resource_docker_crud.go @@ -0,0 +1,198 @@ +package docker + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + "go.clever-cloud.com/terraform-provider/pkg" + "go.clever-cloud.com/terraform-provider/pkg/application" + "go.clever-cloud.com/terraform-provider/pkg/provider" + "go.clever-cloud.com/terraform-provider/pkg/tmp" +) + +// Weird behaviour, but TF can ask for a Resource without having configured a Provider (maybe for Meta and Schema) +// So we need to handle the case there is no ProviderData +func (r *ResourceDocker) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + tflog.Debug(ctx, "ResourceDocker.Configure()") + + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + provider, ok := req.ProviderData.(provider.Provider) + if ok { + r.cc = provider.Client() + r.org = provider.Organization() + } + + tflog.Debug(ctx, "AFTER CONFIGURED", map[string]interface{}{"cc": r.cc == nil, "org": r.org}) +} + +// Create a new resource +func (r *ResourceDocker) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + plan := Docker{} + + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + instance := application.LookupInstance(ctx, r.cc, "docker", "Docker", resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + vhosts := []string{} + resp.Diagnostics.Append(plan.AdditionalVHosts.ElementsAs(ctx, &vhosts, false)...) + if resp.Diagnostics.HasError() { + return + } + + environment := plan.toEnv(ctx, resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + createAppReq := application.CreateReq{ + Client: r.cc, + Organization: r.org, + Application: tmp.CreateAppRequest{ + Name: plan.Name.ValueString(), + Deploy: "git", + Description: plan.Description.ValueString(), + InstanceType: instance.Type, + InstanceVariant: instance.Variant.ID, + InstanceVersion: instance.Version, + BuildFlavor: plan.BuildFlavor.ValueString(), + MinFlavor: plan.SmallestFlavor.ValueString(), + MaxFlavor: plan.BiggestFlavor.ValueString(), + MinInstances: plan.MinInstanceCount.ValueInt64(), + MaxInstances: plan.MaxInstanceCount.ValueInt64(), + StickySessions: plan.StickySessions.ValueBool(), + ForceHttps: application.FromForceHTTPS(plan.RedirectHTTPS.ValueBool()), + Zone: plan.Region.ValueString(), + CancelOnPush: false, + }, + Environment: environment, + VHosts: vhosts, + Deployment: plan.toDeployment(), + } + + createAppRes, diags := application.CreateApp(ctx, createAppReq) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + tflog.Debug(ctx, "BUILD FLAVOR RES"+createAppRes.Application.BuildFlavor.Name, map[string]interface{}{}) + plan.ID = pkg.FromStr(createAppRes.Application.ID) + plan.DeployURL = pkg.FromStr(createAppRes.Application.DeployURL) + plan.VHost = pkg.FromStr(createAppRes.Application.Vhosts[0].Fqdn) + + resp.Diagnostics.Append(resp.State.Set(ctx, plan)...) + if resp.Diagnostics.HasError() { + return + } +} + +// Read resource information +func (r *ResourceDocker) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state Docker + + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + appPHP, diags := application.ReadApp(ctx, r.cc, r.org, state.ID.ValueString()) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + if appPHP.AppIsDeleted { + resp.State.RemoveResource(ctx) + return + } + + state.Name = pkg.FromStr(appPHP.App.Name) + state.Description = pkg.FromStr(appPHP.App.Description) + state.MinInstanceCount = pkg.FromI(int64(appPHP.App.Instance.MinInstances)) + state.MaxInstanceCount = pkg.FromI(int64(appPHP.App.Instance.MaxInstances)) + state.SmallestFlavor = pkg.FromStr(appPHP.App.Instance.MinFlavor.Name) + state.BiggestFlavor = pkg.FromStr(appPHP.App.Instance.MaxFlavor.Name) + state.Region = pkg.FromStr(appPHP.App.Zone) + state.DeployURL = pkg.FromStr(appPHP.App.DeployURL) + + if appPHP.App.SeparateBuild { + state.BuildFlavor = pkg.FromStr(appPHP.App.BuildFlavor.Name) + } else { + state.BuildFlavor = types.StringNull() + } + + vhosts := pkg.Map(appPHP.App.Vhosts, func(vhost tmp.Vhost) string { + return vhost.Fqdn + }) + hasDefaultVHost := pkg.HasSome(vhosts, func(vhost string) bool { + return pkg.VhostCleverAppsRegExp.MatchString(vhost) + }) + if hasDefaultVHost { + cleverapps := *pkg.First(vhosts, func(vhost string) bool { + return pkg.VhostCleverAppsRegExp.MatchString(vhost) + }) + state.VHost = pkg.FromStr(cleverapps) + } else { + state.VHost = types.StringNull() + } + + vhostsWithoutDefault := pkg.Filter(vhosts, func(vhost string) bool { + ok := pkg.VhostCleverAppsRegExp.MatchString(vhost) + return !ok + }) + if len(vhostsWithoutDefault) > 0 { + state.AdditionalVHosts = pkg.FromListString(vhostsWithoutDefault) + } else { + state.AdditionalVHosts = types.ListNull(types.StringType) + } + + resp.Diagnostics.Append(resp.State.Set(ctx, state)...) +} + +// Update resource +func (r *ResourceDocker) Update(ctx context.Context, req resource.UpdateRequest, res *resource.UpdateResponse) { + // TODO +} + +// Delete resource +func (r *ResourceDocker) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state Docker + + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + tflog.Debug(ctx, "DOCKER DELETE", map[string]interface{}{"state": state}) + + res := tmp.DeleteApp(ctx, r.cc, r.org, state.ID.ValueString()) + if res.IsNotFoundError() { + resp.State.RemoveResource(ctx) + return + } + if res.HasError() { + resp.Diagnostics.AddError("failed to delete app", res.Error().Error()) + return + } + + resp.State.RemoveResource(ctx) +} + +// Import resource +func (r *ResourceDocker) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + // Save the import identifier in the id attribute + // and call Read() to fill fields + attr := path.Root("id") + resource.ImportStatePassthroughID(ctx, attr, req, resp) +} diff --git a/pkg/resources/docker/resource_docker_schema.go b/pkg/resources/docker/resource_docker_schema.go new file mode 100644 index 0000000..7591234 --- /dev/null +++ b/pkg/resources/docker/resource_docker_schema.go @@ -0,0 +1,162 @@ +package docker + +import ( + "context" + _ "embed" + "fmt" + "strconv" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64default" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" + "github.com/hashicorp/terraform-plugin-framework/types" + "go.clever-cloud.com/terraform-provider/pkg" + "go.clever-cloud.com/terraform-provider/pkg/application" + "go.clever-cloud.com/terraform-provider/pkg/attributes" +) + +type Docker struct { + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + Description types.String `tfsdk:"description"` + MinInstanceCount types.Int64 `tfsdk:"min_instance_count"` + MaxInstanceCount types.Int64 `tfsdk:"max_instance_count"` + SmallestFlavor types.String `tfsdk:"smallest_flavor"` + BiggestFlavor types.String `tfsdk:"biggest_flavor"` + BuildFlavor types.String `tfsdk:"build_flavor"` + Region types.String `tfsdk:"region"` + StickySessions types.Bool `tfsdk:"sticky_sessions"` + RedirectHTTPS types.Bool `tfsdk:"redirect_https"` + VHost types.String `tfsdk:"vhost"` + AdditionalVHosts types.List `tfsdk:"additional_vhosts"` + DeployURL types.String `tfsdk:"deploy_url"` + Deployment *attributes.Deployment `tfsdk:"deployment"` + Hooks *attributes.Hooks `tfsdk:"hooks"` + Dependencies types.Set `tfsdk:"dependencies"` + + // Env + AppFolder types.String `tfsdk:"app_folder"` + Environment types.Map `tfsdk:"environment"` + + // Docker related + Dockerfile types.String `tfsdk:"dockerfile"` + ContainerPort types.Int64 `tfsdk:"container_port"` + ContainerPortTCP types.Int64 `tfsdk:"container_port_tcp"` + EnableIPv6 types.Bool `tfsdk:"enable_ipv6"` + RegistryURL types.String `tfsdk:"registry_url"` + RegistryUser types.String `tfsdk:"registry_user"` + RegistryPassword types.String `tfsdk:"registry_password"` + DaemonSocketMount types.Bool `tfsdk:"daemon_socket_mount"` +} + +//go:embed resource_docker.md +var phpDoc string + +func (r ResourceDocker) Schema(ctx context.Context, req resource.SchemaRequest, res *resource.SchemaResponse) { + res.Schema = schema.Schema{ + Version: 0, + MarkdownDescription: phpDoc, + Attributes: attributes.WithRuntimeCommons(map[string]schema.Attribute{ + "dockerfile": schema.StringAttribute{ + Optional: true, + Computed: true, + Default: stringdefault.StaticString("Dockerfile"), + MarkdownDescription: "The name of the Dockerfile to build", + }, + "container_port": schema.Int64Attribute{ + Optional: true, + Computed: true, + Default: int64default.StaticInt64(8080), + MarkdownDescription: "Set to custom HTTP port if your Docker container runs on custom port", + }, + "container_port_tcp": schema.Int64Attribute{ + Optional: true, + Computed: true, + Default: int64default.StaticInt64(4040), + MarkdownDescription: "Set to custom TCP port if your Docker container runs on custom port.", + }, + "enable_ipv6": schema.BoolAttribute{ + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + MarkdownDescription: "Activate the support of IPv6 with an IPv6 subnet int the docker daemon", + }, + "registry_url": schema.StringAttribute{ + Optional: true, + MarkdownDescription: "The server of your private registry (optional). Docker’s public registry", + }, + "registry_user": schema.StringAttribute{ + Optional: true, + MarkdownDescription: "The username to login to a private registry", + }, + "registry_password": schema.StringAttribute{ + Optional: true, + MarkdownDescription: "The password of your username", + }, + "daemon_socket_mount": schema.BoolAttribute{ + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + MarkdownDescription: "Set to true to access the host Docker socket from inside your container", + }, + }), + Blocks: attributes.WithBlockRuntimeCommons(map[string]schema.Block{}), + } +} + +// https://developer.hashicorp.com/terraform/plugin/framework/resources/state-upgrade#implementing-state-upgrade-support +func (p *Docker) UpgradeState(ctx context.Context) map[int64]resource.StateUpgrader { + return map[int64]resource.StateUpgrader{} +} + +func (p *Docker) toEnv(ctx context.Context, diags diag.Diagnostics) map[string]string { + env := map[string]string{} + + // do not use the real map since ElementAs can nullish it + // https://github.com/hashicorp/terraform-plugin-framework/issues/698 + customEnv := map[string]string{} + diags.Append(p.Environment.ElementsAs(ctx, &customEnv, false)...) + if diags.HasError() { + return env + } + env = pkg.Merge(env, customEnv) + + pkg.IfIsSet(p.AppFolder, func(s string) { env["APP_FOLDER"] = s }) + + /* + CC_DOCKERFILE . Dockerfile + CC_DOCKER_EXPOSED_HTTP_PORT Set to custom HTTP port if your Docker container runs on custom port. 8080 + CC_DOCKER_EXPOSED_TCP_PORT Set to custom TCP port if your Docker container runs on custom port. 4040 + CC_DOCKER_FIXED_CIDR_V6 Activate the support of IPv6 with an IPv6 subnet int the docker daemon. + CC_DOCKER_LOGIN_PASSWORD The password of your username. + CC_DOCKER_LOGIN_SERVER The server of your private registry (optional). Docker’s public registry + CC_DOCKER_LOGIN_USERNAME The username to login to a private registry. + CC_MOUNT_DOCKER_SOCKET Set to true to access the host Docker socket from inside your container. false + */ + pkg.IfIsSet(p.Dockerfile, func(s string) { env["CC_DOCKERFILE"] = s }) + pkg.IfIsSetI(p.ContainerPort, func(i int64) { env["CC_DOCKER_EXPOSED_HTTP_PORT"] = fmt.Sprintf("%d", i) }) + pkg.IfIsSetI(p.ContainerPortTCP, func(i int64) { env["CC_DOCKER_EXPOSED_TCP_PORT"] = fmt.Sprintf("%d", i) }) + pkg.IfIsSetB(p.EnableIPv6, func(e bool) { env["CC_DOCKER_FIXED_CIDR_V6"] = strconv.FormatBool(e) }) + pkg.IfIsSet(p.RegistryURL, func(s string) { env["CC_DOCKER_LOGIN_SERVER"] = s }) + pkg.IfIsSet(p.RegistryUser, func(s string) { env["CC_DOCKER_LOGIN_USERNAME"] = s }) + pkg.IfIsSet(p.RegistryPassword, func(s string) { env["CC_DOCKER_LOGIN_PASSWORD"] = s }) + pkg.IfIsSetB(p.DaemonSocketMount, func(e bool) { env["CC_MOUNT_DOCKER_SOCKET"] = strconv.FormatBool(e) }) + + env = pkg.Merge(env, p.Hooks.ToEnv()) + + return env +} + +func (p *Docker) toDeployment() *application.Deployment { + if p.Deployment == nil || p.Deployment.Repository.IsNull() { + return nil + } + + return &application.Deployment{ + Repository: p.Deployment.Repository.ValueString(), + Commit: p.Deployment.Commit.ValueStringPointer(), + } +} diff --git a/pkg/resources/docker/resource_docker_test.go b/pkg/resources/docker/resource_docker_test.go new file mode 100644 index 0000000..71685c5 --- /dev/null +++ b/pkg/resources/docker/resource_docker_test.go @@ -0,0 +1,81 @@ +package docker_test + +import ( + "context" + _ "embed" + "fmt" + "os" + "regexp" + "testing" + "time" + + "github.com/hashicorp/terraform-plugin-framework/providerserver" + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" + "go.clever-cloud.com/terraform-provider/pkg/helper" + "go.clever-cloud.com/terraform-provider/pkg/provider/impl" + "go.clever-cloud.com/terraform-provider/pkg/tmp" + "go.clever-cloud.dev/client" +) + +var protoV6Provider = map[string]func() (tfprotov6.ProviderServer, error){ + "clevercloud": providerserver.NewProtocol6WithError(impl.New("test")()), +} + +func TestAccDocker_basic(t *testing.T) { + ctx := context.Background() + rName := fmt.Sprintf("tf-test-docker-%d", time.Now().UnixMilli()) + fullName := fmt.Sprintf("clevercloud_docker.%s", rName) + cc := client.New(client.WithAutoOauthConfig()) + org := os.Getenv("ORGANISATION") + providerBlock := helper.NewProvider("clevercloud").SetOrganisation(org).String() + dockerBlock := helper.NewRessource( + "clevercloud_docker", + rName, + helper.SetKeyValues(map[string]any{ + "name": rName, + "region": "par", + "min_instance_count": 1, + "max_instance_count": 2, + "smallest_flavor": "XS", + "biggest_flavor": "M", + "additional_vhosts": [1]string{"toto-tf5283457829345.com"}, + })).String() + + resource.Test(t, resource.TestCase{ + PreCheck: func() { + if org == "" { + t.Fatalf("missing ORGANISATION env var") + } + }, + ProtoV6ProviderFactories: protoV6Provider, + Steps: []resource.TestStep{{ + Destroy: false, + ResourceName: rName, + Config: providerBlock + dockerBlock, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestMatchResourceAttr(fullName, "id", regexp.MustCompile(`^app_.*$`)), + resource.TestMatchResourceAttr(fullName, "deploy_url", regexp.MustCompile(`^git\+ssh.*\.git$`)), + resource.TestCheckResourceAttr(fullName, "region", "par"), + ), + }}, + CheckDestroy: func(state *terraform.State) error { + for _, resource := range state.RootModule().Resources { + res := tmp.GetApp(ctx, cc, org, resource.Primary.ID) + if res.IsNotFoundError() { + continue + } + if res.HasError() { + return fmt.Errorf("unexpectd error: %s", res.Error().Error()) + } + if res.Payload().State == "TO_DELETE" { + continue + } + + return fmt.Errorf("expect resource '%s' to be deleted state: '%s'", resource.Primary.ID, res.Payload().State) + } + return nil + }, + }) +} diff --git a/pkg/resources/materiakv/resource_materiakv_crud.go b/pkg/resources/materiakv/resource_materiakv_crud.go index 5dbb6c3..6f35d7b 100644 --- a/pkg/resources/materiakv/resource_materiakv_crud.go +++ b/pkg/resources/materiakv/resource_materiakv_crud.go @@ -125,7 +125,7 @@ func (r *ResourceMateriaKV) Read(ctx context.Context, req resource.ReadRequest, addonKV := addonKVRes.Payload() - if addonKV.Status.Status == "TO_DELETE" { + if addonKV.Status == "TO_DELETE" { resp.State.RemoveResource(ctx) return } diff --git a/pkg/resources/materiakv/resource_materiakv_test.go b/pkg/resources/materiakv/resource_materiakv_test.go index ca815f6..4adc960 100644 --- a/pkg/resources/materiakv/resource_materiakv_test.go +++ b/pkg/resources/materiakv/resource_materiakv_test.go @@ -48,11 +48,11 @@ func TestAccMateriaKV_basic(t *testing.T) { if res.HasError() { return fmt.Errorf("unexpectd error: %s", res.Error().Error()) } - if res.Payload().Status.Status == "TO_DELETE" { + if res.Payload().Status == "TO_DELETE" { continue } - return fmt.Errorf("expect resource '%s' to be deleted", resource.Primary.ID) + return fmt.Errorf("expect resource '%s' to be deleted: %+v", resource.Primary.ID, res.Payload()) } return nil }, diff --git a/pkg/tmp/addon.go b/pkg/tmp/addon.go index c146343..b2ed122 100644 --- a/pkg/tmp/addon.go +++ b/pkg/tmp/addon.go @@ -76,25 +76,19 @@ func GetPostgreSQL(ctx context.Context, cc *client.Client, postgresqlID string) } type MateriaKV struct { - ID string `json:"id"` - ClusterID string `json:"clusterId"` - OrganisationID string `json:"ownerId"` - Kind string `json:"kind"` - Plan string `json:"plan"` - Host string `json:"host"` - Port int64 `json:"port"` - Token string `json:"token"` - TokenID string `json:"tokenId"` - Status MateriaKVStatus `json:"status"` + ID string `json:"id"` + ClusterID string `json:"clusterId"` + OrganisationID string `json:"ownerId"` + Kind string `json:"kind"` + Plan string `json:"plan"` + Host string `json:"host"` + Port int64 `json:"port"` + Token string `json:"token"` + TokenID string `json:"tokenId"` + Status string `json:"status"` // ccapiUrl "https://api.clever-cloud.com/v2/vendor/apps/addon_dbf12716-9353-41ef-aabf-e4b7fce1ba5e" } -type MateriaKVStatus struct { - ID string `json:"id"` - ServiceID string `json:"serviceId"` - Status string `json:"status"` -} - func GetMateriaKV(ctx context.Context, cc *client.Client, organisationID, postgresqlID string) client.Response[MateriaKV] { path := fmt.Sprintf("/v4/materia/organisations/%s/materia/databases/%s", organisationID, postgresqlID) return client.Get[MateriaKV](ctx, cc, path) diff --git a/pkg/types.go b/pkg/types.go index 2c23908..1416f99 100644 --- a/pkg/types.go +++ b/pkg/types.go @@ -15,3 +15,9 @@ func IfIsSetB(v types.Bool, fn func(s bool)) { fn(v.ValueBool()) } } + +func IfIsSetI(v types.Int64, fn func(i int64)) { + if !v.IsNull() && !v.IsUnknown() { + fn(v.ValueInt64()) + } +}