From 606da195038d7d8a77c721b2d3c30948c0d180af Mon Sep 17 00:00:00 2001 From: FaureAlexis Date: Tue, 11 Nov 2025 15:27:37 +0100 Subject: [PATCH 1/5] feat: add StringOrNil helper function for optional string fields Add StringOrNil function that returns nil for null, unknown, or empty strings. This is useful for API fields that should be omitted when empty rather than sending empty strings. --- internal/expand/string.go | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/internal/expand/string.go b/internal/expand/string.go index 7b89d28..2158e9a 100644 --- a/internal/expand/string.go +++ b/internal/expand/string.go @@ -10,6 +10,21 @@ func String(value types.String) *string { return value.ValueStringPointer() } +// StringOrNil returns nil if the string is null, unknown, or empty. +// This is useful for fields that should not be sent to the API if empty. +func StringOrNil(value types.String) *string { + if value.IsNull() || value.IsUnknown() { + return nil + } + + str := value.ValueString() + if str == "" { + return nil + } + + return &str +} + func RequiredString(value types.String) string { if value.IsNull() || value.IsUnknown() { return "" From 42a527b97e3b67b1e7b353067e1d9f33910bb174 Mon Sep 17 00:00:00 2001 From: FaureAlexis Date: Tue, 11 Nov 2025 15:27:37 +0100 Subject: [PATCH 2/5] fix: handle empty destination UUID in service resources Use StringOrNil instead of ValueStringPointer for DestinationUuid to prevent sending empty strings to the API when the field is not set. --- internal/service/service/models.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/service/service/models.go b/internal/service/service/models.go index f9610e3..4f28391 100644 --- a/internal/service/service/models.go +++ b/internal/service/service/models.go @@ -11,6 +11,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/types" "terraform-provider-coolify/internal/api" + "terraform-provider-coolify/internal/expand" "terraform-provider-coolify/internal/flatten" sutil "terraform-provider-coolify/internal/service/util" ) @@ -103,7 +104,7 @@ func (m ServiceModel) ToAPICreate() api.CreateServiceJSONRequestBody { return api.CreateServiceJSONRequestBody{ Name: m.Name.ValueStringPointer(), Description: m.Description.ValueStringPointer(), - DestinationUuid: m.DestinationUuid.ValueStringPointer(), + DestinationUuid: expand.StringOrNil(m.DestinationUuid), EnvironmentName: m.EnvironmentName.ValueString(), EnvironmentUuid: m.EnvironmentUuid.ValueString(), ProjectUuid: m.ProjectUuid.ValueString(), @@ -116,7 +117,7 @@ func (m ServiceModel) ToAPIUpdate() api.UpdateServiceByUuidJSONRequestBody { return api.UpdateServiceByUuidJSONRequestBody{ Name: m.Name.ValueStringPointer(), Description: m.Description.ValueStringPointer(), - DestinationUuid: m.DestinationUuid.ValueStringPointer(), + DestinationUuid: expand.StringOrNil(m.DestinationUuid), EnvironmentName: m.EnvironmentName.ValueString(), EnvironmentUuid: m.EnvironmentUuid.ValueString(), ProjectUuid: m.ProjectUuid.ValueString(), From ba04ac0959ac24bc12620b3401cd15d3660ca789 Mon Sep 17 00:00:00 2001 From: FaureAlexis Date: Tue, 11 Nov 2025 15:27:37 +0100 Subject: [PATCH 3/5] fix: update application envs creation to match API requirements The Coolify API only accepts key, value, is_preview, and is_literal during creation. Other fields (is_build_time, is_multiline, is_shown_once) must be updated via a bulk update after creation. --- internal/service/application_envs_resource.go | 47 +++++++++++++++++-- 1 file changed, 42 insertions(+), 5 deletions(-) diff --git a/internal/service/application_envs_resource.go b/internal/service/application_envs_resource.go index 58201f8..fa9479f 100644 --- a/internal/service/application_envs_resource.go +++ b/internal/service/application_envs_resource.go @@ -101,12 +101,13 @@ func (r *applicationEnvsResource) Create(ctx context.Context, req resource.Creat uuid := plan.Uuid.ValueString() for i, env := range plan.Env { + // Note: L'API Coolify n'accepte que key, value, is_preview, is_literal lors de la création + // Les autres champs (is_build_time, is_multiline, is_shown_once) doivent être mis à jour après createResp, err := r.client.CreateEnvByApplicationUuidWithResponse(ctx, uuid, api.CreateEnvByApplicationUuidJSONRequestBody{ - IsBuildTime: env.IsBuildTime.ValueBoolPointer(), - IsLiteral: env.IsLiteral.ValueBoolPointer(), - IsPreview: env.IsPreview.ValueBoolPointer(), - Key: env.Key.ValueStringPointer(), - Value: env.Value.ValueStringPointer(), + IsLiteral: env.IsLiteral.ValueBoolPointer(), + IsPreview: env.IsPreview.ValueBoolPointer(), + Key: env.Key.ValueStringPointer(), + Value: env.Value.ValueStringPointer(), }) if err != nil { @@ -129,6 +130,42 @@ func (r *applicationEnvsResource) Create(ctx context.Context, req resource.Creat plan.Env[i].Uuid = types.StringPointerValue(createResp.JSON201.Uuid) } + // Après la création, faire un bulk update pour les champs non-supportés en création + // (is_build_time, is_multiline, is_shown_once) + var bulkUpdateEnvs = []updateEnvsByApplicationUuidJSONRequestBodyItem{} + for _, env := range plan.Env { + bulkUpdateEnvs = append(bulkUpdateEnvs, updateEnvsByApplicationUuidJSONRequestBodyItem{ + IsBuildTime: env.IsBuildTime.ValueBoolPointer(), + IsLiteral: env.IsLiteral.ValueBoolPointer(), + IsMultiline: env.IsMultiline.ValueBoolPointer(), + IsPreview: env.IsPreview.ValueBoolPointer(), + IsShownOnce: env.IsShownOnce.ValueBoolPointer(), + Key: env.Key.ValueStringPointer(), + Value: env.Value.ValueStringPointer(), + }) + } + + if len(bulkUpdateEnvs) > 0 { + updateResp, err := r.client.UpdateEnvsByApplicationUuidWithResponse(ctx, uuid, api.UpdateEnvsByApplicationUuidJSONRequestBody{ + Data: bulkUpdateEnvs, + }) + + if err != nil { + resp.Diagnostics.AddError( + fmt.Sprintf("Error updating application envs after creation: uuid=%s", uuid), + err.Error(), + ) + return + } + + if updateResp.StatusCode() != http.StatusCreated { + resp.Diagnostics.AddError( + "Unexpected HTTP status code updating application envs after creation", + fmt.Sprintf("Received %s updating application envs: uuid=%s. Details: %s", updateResp.Status(), uuid, updateResp.Body)) + return + } + } + data, _ := r.readFromAPI(ctx, &resp.Diagnostics, uuid) data.Env = r.filterRelevantEnvs(plan.Env, data.Env) From a5a9ae6c14ed1e109a809b40ef58c9b0c0583f00 Mon Sep 17 00:00:00 2001 From: FaureAlexis Date: Tue, 11 Nov 2025 15:27:37 +0100 Subject: [PATCH 4/5] feat: add application resource with full CRUD operations Implement coolify_application resource supporting multiple application types: - Public Git repositories - Private Git repositories with deploy keys - GitHub App deployments - Dockerfile-based deployments - Docker Image deployments - Docker Compose deployments Includes comprehensive model mapping, plan modifiers, unit tests, acceptance tests, and example Terraform configurations for each application type. --- .../coolify_application/deploy_key.tf | 19 + .../coolify_application/dockercompose.tf | 25 + .../coolify_application/dockerfile.tf | 23 + .../coolify_application/dockerimage.tf | 17 + .../coolify_application/github_app.tf | 19 + .../resources/coolify_application/import.sh | 13 + .../resources/coolify_application/public.tf | 18 + internal/provider/provider.go | 1 + internal/service/application_model.go | 943 ++++++++++++++++++ internal/service/application_planmodifier.go | 113 +++ internal/service/application_resource.go | 485 +++++++++ .../application_resource_acceptance_test.go | 157 +++ internal/service/application_resource_test.go | 62 ++ tools/tfplugingen-openapi.yml | 13 + 14 files changed, 1908 insertions(+) create mode 100644 examples/resources/coolify_application/deploy_key.tf create mode 100644 examples/resources/coolify_application/dockercompose.tf create mode 100644 examples/resources/coolify_application/dockerfile.tf create mode 100644 examples/resources/coolify_application/dockerimage.tf create mode 100644 examples/resources/coolify_application/github_app.tf create mode 100644 examples/resources/coolify_application/import.sh create mode 100644 examples/resources/coolify_application/public.tf create mode 100644 internal/service/application_model.go create mode 100644 internal/service/application_planmodifier.go create mode 100644 internal/service/application_resource.go create mode 100644 internal/service/application_resource_acceptance_test.go create mode 100644 internal/service/application_resource_test.go diff --git a/examples/resources/coolify_application/deploy_key.tf b/examples/resources/coolify_application/deploy_key.tf new file mode 100644 index 0000000..6f0cf82 --- /dev/null +++ b/examples/resources/coolify_application/deploy_key.tf @@ -0,0 +1,19 @@ +# Example: Private Deploy Key Application + +resource "coolify_application" "deploy_key_example" { + source_type = "private-deploy-key" + + project_uuid = "your-project-uuid" + server_uuid = "your-server-uuid" + environment_name = "production" + + private_key_uuid = "your-private-key-uuid" + git_repository = "git@github.com:your-org/your-private-repo.git" + git_branch = "main" + build_pack = "nixpacks" + ports_exposes = "80" + + name = "Deploy Key Example" + description = "Application from private repository via deploy key" +} + diff --git a/examples/resources/coolify_application/dockercompose.tf b/examples/resources/coolify_application/dockercompose.tf new file mode 100644 index 0000000..08226fe --- /dev/null +++ b/examples/resources/coolify_application/dockercompose.tf @@ -0,0 +1,25 @@ +# Example: Docker Compose Application + +resource "coolify_application" "dockercompose_example" { + source_type = "dockercompose" + + project_uuid = "your-project-uuid" + server_uuid = "your-server-uuid" + environment_name = "production" + + docker_compose_raw = < +# Example: ./import.sh rg8ks8c + +if [ -z "$1" ]; then + echo "Usage: $0 " + exit 1 +fi + +terraform import coolify_application.example "$1" + diff --git a/examples/resources/coolify_application/public.tf b/examples/resources/coolify_application/public.tf new file mode 100644 index 0000000..233f1b7 --- /dev/null +++ b/examples/resources/coolify_application/public.tf @@ -0,0 +1,18 @@ +# Example: Public Git Repository Application + +resource "coolify_application" "public_example" { + source_type = "public" + + project_uuid = "your-project-uuid" + server_uuid = "your-server-uuid" + environment_name = "production" + + git_repository = "https://github.com/coollabsio/coolify" + git_branch = "main" + build_pack = "nixpacks" + ports_exposes = "80" + + name = "Public App Example" + description = "Application from public Git repository" +} + diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 1c66320..3ff052a 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -215,6 +215,7 @@ func (p *CoolifyProvider) Resources(ctx context.Context) []func() resource.Resou private_key.NewPrivateKeyResource, service.NewServerResource, service.NewProjectResource, + service.NewApplicationResource, service.NewApplicationEnvsResource, service.NewServiceEnvsResource, service.NewPostgresqlDatabaseResource, diff --git a/internal/service/application_model.go b/internal/service/application_model.go new file mode 100644 index 0000000..d7688fc --- /dev/null +++ b/internal/service/application_model.go @@ -0,0 +1,943 @@ +package service + +import ( + "context" + "fmt" + "strings" + + "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/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" + + "terraform-provider-coolify/internal/api" + "terraform-provider-coolify/internal/expand" + "terraform-provider-coolify/internal/flatten" +) + +type ApplicationSourceType string + +const ( + ApplicationSourceTypePublic ApplicationSourceType = "public" + ApplicationSourceTypePrivateGithubApp ApplicationSourceType = "private-github-app" + ApplicationSourceTypePrivateDeployKey ApplicationSourceType = "private-deploy-key" + ApplicationSourceTypeDockerfile ApplicationSourceType = "dockerfile" + ApplicationSourceTypeDockerimage ApplicationSourceType = "dockerimage" + ApplicationSourceTypeDockercompose ApplicationSourceType = "dockercompose" +) + +type ApplicationModel struct { + Uuid types.String `tfsdk:"uuid"` + + // Source type discriminant + SourceType types.String `tfsdk:"source_type"` + + // Common fields + ProjectUuid types.String `tfsdk:"project_uuid"` + ServerUuid types.String `tfsdk:"server_uuid"` + EnvironmentName types.String `tfsdk:"environment_name"` + EnvironmentUuid types.String `tfsdk:"environment_uuid"` + DestinationUuid types.String `tfsdk:"destination_uuid"` + Name types.String `tfsdk:"name"` + Description types.String `tfsdk:"description"` + Domains types.String `tfsdk:"domains"` + InstantDeploy types.Bool `tfsdk:"instant_deploy"` + + // Git-based source fields (public, private-github-app, private-deploy-key) + GitRepository types.String `tfsdk:"git_repository"` + GitBranch types.String `tfsdk:"git_branch"` + BuildPack types.String `tfsdk:"build_pack"` + PortsExposes types.String `tfsdk:"ports_exposes"` + + // Private GitHub App specific + GithubAppUuid types.String `tfsdk:"github_app_uuid"` + + // Private Deploy Key specific + PrivateKeyUuid types.String `tfsdk:"private_key_uuid"` + + // Dockerfile specific + Dockerfile types.String `tfsdk:"dockerfile"` + + // Docker Image specific + DockerRegistryImageName types.String `tfsdk:"docker_registry_image_name"` + DockerRegistryImageTag types.String `tfsdk:"docker_registry_image_tag"` + + // Docker Compose specific + DockerComposeRaw types.String `tfsdk:"docker_compose_raw"` + + // Optional common fields + BaseDirectory types.String `tfsdk:"base_directory"` + BuildCommand types.String `tfsdk:"build_command"` + StartCommand types.String `tfsdk:"start_command"` + InstallCommand types.String `tfsdk:"install_command"` + PublishDirectory types.String `tfsdk:"publish_directory"` + PortsMappings types.String `tfsdk:"ports_mappings"` + GitCommitSha types.String `tfsdk:"git_commit_sha"` + IsStatic types.Bool `tfsdk:"is_static"` + StaticImage types.String `tfsdk:"static_image"` + HealthCheckEnabled types.Bool `tfsdk:"health_check_enabled"` + HealthCheckPath types.String `tfsdk:"health_check_path"` + HealthCheckPort types.String `tfsdk:"health_check_port"` + HealthCheckHost types.String `tfsdk:"health_check_host"` + HealthCheckMethod types.String `tfsdk:"health_check_method"` + HealthCheckReturnCode types.Int64 `tfsdk:"health_check_return_code"` + HealthCheckScheme types.String `tfsdk:"health_check_scheme"` + HealthCheckResponseText types.String `tfsdk:"health_check_response_text"` + HealthCheckInterval types.Int64 `tfsdk:"health_check_interval"` + HealthCheckTimeout types.Int64 `tfsdk:"health_check_timeout"` + HealthCheckRetries types.Int64 `tfsdk:"health_check_retries"` + HealthCheckStartPeriod types.Int64 `tfsdk:"health_check_start_period"` + LimitsMemory types.String `tfsdk:"limits_memory"` + LimitsMemorySwap types.String `tfsdk:"limits_memory_swap"` + LimitsMemorySwappiness types.Int64 `tfsdk:"limits_memory_swappiness"` + LimitsMemoryReservation types.String `tfsdk:"limits_memory_reservation"` + LimitsCpus types.String `tfsdk:"limits_cpus"` + LimitsCpuset types.String `tfsdk:"limits_cpuset"` + LimitsCpuShares types.Int64 `tfsdk:"limits_cpu_shares"` + CustomLabels types.String `tfsdk:"custom_labels"` + CustomDockerRunOptions types.String `tfsdk:"custom_docker_run_options"` + PostDeploymentCommand types.String `tfsdk:"post_deployment_command"` + PostDeploymentCommandContainer types.String `tfsdk:"post_deployment_command_container"` + PreDeploymentCommand types.String `tfsdk:"pre_deployment_command"` + PreDeploymentCommandContainer types.String `tfsdk:"pre_deployment_command_container"` + ManualWebhookSecretGithub types.String `tfsdk:"manual_webhook_secret_github"` + ManualWebhookSecretGitlab types.String `tfsdk:"manual_webhook_secret_gitlab"` + ManualWebhookSecretBitbucket types.String `tfsdk:"manual_webhook_secret_bitbucket"` + ManualWebhookSecretGitea types.String `tfsdk:"manual_webhook_secret_gitea"` + Redirect types.String `tfsdk:"redirect"` + UseBuildServer types.Bool `tfsdk:"use_build_server"` + IsHttpBasicAuthEnabled types.Bool `tfsdk:"is_http_basic_auth_enabled"` + HttpBasicAuthUsername types.String `tfsdk:"http_basic_auth_username"` + HttpBasicAuthPassword types.String `tfsdk:"http_basic_auth_password"` + DockerComposeLocation types.String `tfsdk:"docker_compose_location"` + DockerComposeCustomStartCommand types.String `tfsdk:"docker_compose_custom_start_command"` + DockerComposeCustomBuildCommand types.String `tfsdk:"docker_compose_custom_build_command"` + WatchPaths types.String `tfsdk:"watch_paths"` +} + +func (m ApplicationModel) Schema(ctx context.Context) schema.Schema { + return schema.Schema{ + Description: "Create, read, update, and delete a Coolify application resource.", + Attributes: map[string]schema.Attribute{ + "uuid": schema.StringAttribute{ + Computed: true, + Description: "UUID of the application.", + PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}, + }, + "source_type": schema.StringAttribute{ + Required: true, + Description: "Type of application source. One of: public, private-github-app, private-deploy-key, dockerfile, dockerimage, dockercompose", + }, + "project_uuid": schema.StringAttribute{ + Required: true, + Description: "UUID of the project.", + }, + "server_uuid": schema.StringAttribute{ + Required: true, + Description: "UUID of the server.", + }, + "environment_name": schema.StringAttribute{ + Required: true, + Description: "Name of the environment.", + }, + "environment_uuid": schema.StringAttribute{ + Optional: true, + Description: "UUID of the environment. Will replace environment_name in future.", + }, + "destination_uuid": schema.StringAttribute{ + Optional: true, + Computed: true, + Description: "UUID of the destination if the server has multiple destinations.", + }, + "name": schema.StringAttribute{ + Optional: true, + Computed: true, + Description: "Name of the application.", + }, + "description": schema.StringAttribute{ + Optional: true, + Computed: true, + Description: "Description of the application.", + }, + "domains": schema.StringAttribute{ + Optional: true, + Computed: true, + Description: "Application domains.", + }, + "instant_deploy": schema.BoolAttribute{ + Optional: true, + Computed: true, + Description: "Instant deploy the application.", + Default: booldefault.StaticBool(false), + }, + // Git-based fields + "git_repository": schema.StringAttribute{ + Optional: true, + Computed: true, + Description: "Git repository URL. Required for public, private-github-app, and private-deploy-key source types.", + PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}, + }, + "git_branch": schema.StringAttribute{ + Optional: true, + Description: "Git branch. Required for public, private-github-app, and private-deploy-key source types.", + }, + "build_pack": schema.StringAttribute{ + Optional: true, + Description: "Build pack type (nixpacks, static, dockerfile, dockercompose). Required for public, private-github-app, and private-deploy-key source types.", + }, + "ports_exposes": schema.StringAttribute{ + Optional: true, + Description: "Ports to expose. Required for public, private-github-app, private-deploy-key, and dockerimage source types.", + }, + // Private GitHub App specific + "github_app_uuid": schema.StringAttribute{ + Optional: true, + Description: "GitHub App UUID. Required for private-github-app source type.", + }, + // Private Deploy Key specific + "private_key_uuid": schema.StringAttribute{ + Optional: true, + Description: "Private key UUID. Required for private-deploy-key source type.", + }, + // Dockerfile specific + "dockerfile": schema.StringAttribute{ + Optional: true, + Description: "Dockerfile content. Required for dockerfile source type.", + }, + // Docker Image specific + "docker_registry_image_name": schema.StringAttribute{ + Optional: true, + Description: "Docker registry image name. Required for dockerimage source type.", + }, + "docker_registry_image_tag": schema.StringAttribute{ + Optional: true, + Description: "Docker registry image tag.", + }, + // Docker Compose specific + "docker_compose_raw": schema.StringAttribute{ + Optional: true, + Description: "Docker Compose raw content. Required for dockercompose source type.", + }, + // Optional common fields + "base_directory": schema.StringAttribute{ + Optional: true, + Computed: true, + Description: "Base directory for all commands.", + PlanModifiers: []planmodifier.String{UseStateForUnknownUnlessNullString()}, + }, + "build_command": schema.StringAttribute{Optional: true, Description: "Build command."}, + "start_command": schema.StringAttribute{Optional: true, Description: "Start command."}, + "install_command": schema.StringAttribute{Optional: true, Description: "Install command."}, + "publish_directory": schema.StringAttribute{Optional: true, Description: "Publish directory."}, + "ports_mappings": schema.StringAttribute{Optional: true, Description: "Ports mappings."}, + "git_commit_sha": schema.StringAttribute{ + Optional: true, + Computed: true, + Description: "Git commit SHA.", + PlanModifiers: []planmodifier.String{UseStateForUnknownUnlessNullString()}, + }, + "is_static": schema.BoolAttribute{Optional: true, Description: "Flag to indicate if the application is static."}, + "static_image": schema.StringAttribute{ + Optional: true, + Computed: true, + Description: "Static image (e.g., nginx:alpine).", + PlanModifiers: []planmodifier.String{UseStateForUnknownUnlessNullString()}, + }, + "health_check_enabled": schema.BoolAttribute{Optional: true, Description: "Health check enabled."}, + "health_check_path": schema.StringAttribute{ + Optional: true, + Computed: true, + Description: "Health check path.", + PlanModifiers: []planmodifier.String{UseStateForUnknownUnlessNullString()}, + }, + "health_check_port": schema.StringAttribute{Optional: true, Description: "Health check port."}, + "health_check_host": schema.StringAttribute{Optional: true, Description: "Health check host."}, + "health_check_method": schema.StringAttribute{ + Optional: true, + Computed: true, + Description: "Health check method.", + PlanModifiers: []planmodifier.String{UseStateForUnknownUnlessNullString()}, + }, + "health_check_return_code": schema.Int64Attribute{ + Optional: true, + Computed: true, + Description: "Health check return code.", + PlanModifiers: []planmodifier.Int64{UseStateForUnknownUnlessNullInt64()}, + }, + "health_check_scheme": schema.StringAttribute{ + Optional: true, + Computed: true, + Description: "Health check scheme.", + PlanModifiers: []planmodifier.String{UseStateForUnknownUnlessNullString()}, + }, + "health_check_response_text": schema.StringAttribute{Optional: true, Description: "Health check response text."}, + "health_check_interval": schema.Int64Attribute{ + Optional: true, + Computed: true, + Description: "Health check interval in seconds.", + PlanModifiers: []planmodifier.Int64{UseStateForUnknownUnlessNullInt64()}, + }, + "health_check_timeout": schema.Int64Attribute{ + Optional: true, + Computed: true, + Description: "Health check timeout in seconds.", + PlanModifiers: []planmodifier.Int64{UseStateForUnknownUnlessNullInt64()}, + }, + "health_check_retries": schema.Int64Attribute{ + Optional: true, + Computed: true, + Description: "Health check retries count.", + PlanModifiers: []planmodifier.Int64{UseStateForUnknownUnlessNullInt64()}, + }, + "health_check_start_period": schema.Int64Attribute{ + Optional: true, + Computed: true, + Description: "Health check start period in seconds.", + PlanModifiers: []planmodifier.Int64{UseStateForUnknownUnlessNullInt64()}, + }, + "limits_memory": schema.StringAttribute{Optional: true, Description: "Memory limit."}, + "limits_memory_swap": schema.StringAttribute{ + Optional: true, + Computed: true, + Description: "Memory swap limit.", + PlanModifiers: []planmodifier.String{UseStateForUnknownUnlessNullString()}, + }, + "limits_memory_swappiness": schema.Int64Attribute{ + Optional: true, + Computed: true, + Description: "Memory swappiness.", + PlanModifiers: []planmodifier.Int64{UseStateForUnknownUnlessNullInt64()}, + }, + "limits_memory_reservation": schema.StringAttribute{ + Optional: true, + Computed: true, + Description: "Memory reservation.", + PlanModifiers: []planmodifier.String{UseStateForUnknownUnlessNullString()}, + }, + "limits_cpus": schema.StringAttribute{Optional: true, Description: "CPU limit."}, + "limits_cpuset": schema.StringAttribute{Optional: true, Description: "CPU set."}, + "limits_cpu_shares": schema.Int64Attribute{ + Optional: true, + Computed: true, + Description: "CPU shares.", + PlanModifiers: []planmodifier.Int64{UseStateForUnknownUnlessNullInt64()}, + }, + "custom_labels": schema.StringAttribute{ + Optional: true, + Computed: true, + Description: "Custom labels.", + PlanModifiers: []planmodifier.String{UseStateForUnknownUnlessNullString()}, + }, + "custom_docker_run_options": schema.StringAttribute{Optional: true, Description: "Custom docker run options."}, + "post_deployment_command": schema.StringAttribute{Optional: true, Description: "Post deployment command."}, + "post_deployment_command_container": schema.StringAttribute{Optional: true, Description: "Post deployment command container."}, + "pre_deployment_command": schema.StringAttribute{Optional: true, Description: "Pre deployment command."}, + "pre_deployment_command_container": schema.StringAttribute{Optional: true, Description: "Pre deployment command container."}, + "manual_webhook_secret_github": schema.StringAttribute{Optional: true, Sensitive: true, Description: "Manual webhook secret for Github."}, + "manual_webhook_secret_gitlab": schema.StringAttribute{Optional: true, Sensitive: true, Description: "Manual webhook secret for Gitlab."}, + "manual_webhook_secret_bitbucket": schema.StringAttribute{Optional: true, Sensitive: true, Description: "Manual webhook secret for Bitbucket."}, + "manual_webhook_secret_gitea": schema.StringAttribute{Optional: true, Sensitive: true, Description: "Manual webhook secret for Gitea."}, + "redirect": schema.StringAttribute{Optional: true, Description: "How to set redirect with Traefik / Caddy. www<->non-www."}, + "use_build_server": schema.BoolAttribute{Optional: true, Description: "Use build server."}, + "is_http_basic_auth_enabled": schema.BoolAttribute{ + Optional: true, + Computed: true, + Description: "HTTP Basic Authentication enabled.", + PlanModifiers: []planmodifier.Bool{UseStateForUnknownUnlessNullBool()}, + }, + "http_basic_auth_username": schema.StringAttribute{Optional: true, Description: "Username for HTTP Basic Authentication"}, + "http_basic_auth_password": schema.StringAttribute{Optional: true, Sensitive: true, Description: "Password for HTTP Basic Authentication"}, + "docker_compose_location": schema.StringAttribute{ + Optional: true, + Computed: true, + Description: "Docker Compose location.", + PlanModifiers: []planmodifier.String{UseStateForUnknownUnlessNullString()}, + }, + "docker_compose_custom_start_command": schema.StringAttribute{Optional: true, Description: "Docker Compose custom start command."}, + "docker_compose_custom_build_command": schema.StringAttribute{Optional: true, Description: "Docker Compose custom build command."}, + "watch_paths": schema.StringAttribute{Optional: true, Description: "Watch paths."}, + }, + } +} + +// preserveGitRepository preserves the state git_repository if it exists and is different from API +// This handles the case where API returns "user/repo" but user configured "https://github.com/user/repo" +func preserveGitRepository(stateVal types.String, apiVal types.String) types.String { + // If state is null/unknown, return API value + if stateVal.IsNull() || stateVal.IsUnknown() { + return apiVal + } + // If state has a full URL and API has short format, preserve state + stateStr := stateVal.ValueString() + apiStr := apiVal.ValueString() + if stateStr != "" && apiStr != "" && stateStr != apiStr { + // Check if state is a full URL and API is short format + if (strings.HasPrefix(stateStr, "http://") || strings.HasPrefix(stateStr, "https://") || strings.HasPrefix(stateStr, "git@")) && + !strings.Contains(apiStr, "://") && !strings.HasPrefix(apiStr, "git@") { + // State has full URL, API has short format - preserve state + return stateVal + } + // If both are different but neither is clearly a URL format issue, preserve state + // This handles edge cases where formats differ + return stateVal + } + // If values match or state is empty, return API value + return apiVal +} + +func (m ApplicationModel) FromAPI(app *api.Application, state ApplicationModel) ApplicationModel { + // Simplification: Return API values as-is + // For fields not returned by API or that should be preserved from state, we keep the state value + + // Special handling for git_repository: API may return shortened format + apiGitRepo := flatten.String(app.GitRepository) + preservedGitRepo := preserveGitRepository(state.GitRepository, apiGitRepo) + + return ApplicationModel{ + Uuid: flatten.String(app.Uuid), + SourceType: state.SourceType, // Not returned by API + ProjectUuid: state.ProjectUuid, // Not returned by API + ServerUuid: state.ServerUuid, // Not returned by API + EnvironmentName: state.EnvironmentName, // Not returned by API + EnvironmentUuid: state.EnvironmentUuid, // Not returned by API + DestinationUuid: state.DestinationUuid, // Not returned by API + Name: flatten.String(app.Name), + Description: flatten.String(app.Description), + Domains: flatten.String(app.Fqdn), // API returns "fqdn", map to "domains" + InstantDeploy: state.InstantDeploy, // Not returned by API + GitRepository: preservedGitRepo, // Special handling for URL format + GitBranch: flatten.String(app.GitBranch), + BuildPack: flatten.String((*string)(app.BuildPack)), + PortsExposes: flatten.String(app.PortsExposes), + GithubAppUuid: state.GithubAppUuid, // Not returned by API + PrivateKeyUuid: state.PrivateKeyUuid, // Not returned by API + Dockerfile: flatten.String(app.Dockerfile), + DockerRegistryImageName: flatten.String(app.DockerRegistryImageName), + DockerRegistryImageTag: flatten.String(app.DockerRegistryImageTag), + DockerComposeRaw: flatten.String(app.DockerComposeRaw), + BaseDirectory: flatten.String(app.BaseDirectory), + BuildCommand: flatten.String(app.BuildCommand), + StartCommand: flatten.String(app.StartCommand), + InstallCommand: flatten.String(app.InstallCommand), + PublishDirectory: flatten.String(app.PublishDirectory), + PortsMappings: flatten.String(app.PortsMappings), + GitCommitSha: flatten.String(app.GitCommitSha), + IsStatic: state.IsStatic, // Not returned by API + StaticImage: flatten.String(app.StaticImage), + HealthCheckEnabled: flatten.Bool(app.HealthCheckEnabled), + HealthCheckPath: flatten.String(app.HealthCheckPath), + HealthCheckPort: flatten.String(app.HealthCheckPort), + HealthCheckHost: flatten.String(app.HealthCheckHost), + HealthCheckMethod: flatten.String(app.HealthCheckMethod), + HealthCheckReturnCode: flatten.Int64(app.HealthCheckReturnCode), + HealthCheckScheme: flatten.String(app.HealthCheckScheme), + HealthCheckResponseText: flatten.String(app.HealthCheckResponseText), + HealthCheckInterval: flatten.Int64(app.HealthCheckInterval), + HealthCheckTimeout: flatten.Int64(app.HealthCheckTimeout), + HealthCheckRetries: flatten.Int64(app.HealthCheckRetries), + HealthCheckStartPeriod: flatten.Int64(app.HealthCheckStartPeriod), + LimitsMemory: flatten.String(app.LimitsMemory), + LimitsMemorySwap: flatten.String(app.LimitsMemorySwap), + LimitsMemorySwappiness: flatten.Int64(app.LimitsMemorySwappiness), + LimitsMemoryReservation: flatten.String(app.LimitsMemoryReservation), + LimitsCpus: flatten.String(app.LimitsCpus), + LimitsCpuset: flatten.String(app.LimitsCpuset), + LimitsCpuShares: flatten.Int64(app.LimitsCpuShares), + CustomLabels: flatten.String(app.CustomLabels), + CustomDockerRunOptions: flatten.String(app.CustomDockerRunOptions), + PostDeploymentCommand: flatten.String(app.PostDeploymentCommand), + PostDeploymentCommandContainer: flatten.String(app.PostDeploymentCommandContainer), + PreDeploymentCommand: flatten.String(app.PreDeploymentCommand), + PreDeploymentCommandContainer: flatten.String(app.PreDeploymentCommandContainer), + ManualWebhookSecretGithub: state.ManualWebhookSecretGithub, // Sensitive, not returned by API + ManualWebhookSecretGitlab: state.ManualWebhookSecretGitlab, // Sensitive, not returned by API + ManualWebhookSecretBitbucket: state.ManualWebhookSecretBitbucket, // Sensitive, not returned by API + ManualWebhookSecretGitea: state.ManualWebhookSecretGitea, // Sensitive, not returned by API + Redirect: flatten.String((*string)(app.Redirect)), + UseBuildServer: state.UseBuildServer, // Not returned by API + IsHttpBasicAuthEnabled: flatten.Bool(app.IsHttpBasicAuthEnabled), + HttpBasicAuthUsername: flatten.String(app.HttpBasicAuthUsername), + HttpBasicAuthPassword: state.HttpBasicAuthPassword, // Sensitive, not returned by API + DockerComposeLocation: flatten.String(app.DockerComposeLocation), + DockerComposeCustomStartCommand: flatten.String(app.DockerComposeCustomStartCommand), + DockerComposeCustomBuildCommand: flatten.String(app.DockerComposeCustomBuildCommand), + WatchPaths: flatten.String(app.WatchPaths), + } +} + +// ToAPICreate routes to the appropriate API creation method based on source_type +func (m ApplicationModel) ToAPICreate() (interface{}, error) { + sourceType := ApplicationSourceType(m.SourceType.ValueString()) + + switch sourceType { + case ApplicationSourceTypePublic: + return m.toCreatePublicApplication(), nil + case ApplicationSourceTypePrivateGithubApp: + return m.toCreatePrivateGithubAppApplication(), nil + case ApplicationSourceTypePrivateDeployKey: + return m.toCreatePrivateDeployKeyApplication(), nil + case ApplicationSourceTypeDockerfile: + return m.toCreateDockerfileApplication(), nil + case ApplicationSourceTypeDockerimage: + return m.toCreateDockerimageApplication(), nil + case ApplicationSourceTypeDockercompose: + return m.toCreateDockercomposeApplication(), nil + default: + return nil, fmt.Errorf("unsupported source_type: %s", sourceType) + } +} + +// validateRedirect validates and converts a redirect string to the appropriate enum type +func validateRedirect[T ~string](redirect *string) *T { + if redirect == nil || *redirect == "" { + return nil + } + validRedirects := map[string]bool{"both": true, "non-www": true, "www": true} + if !validRedirects[*redirect] { + return nil // Invalid redirect value, return nil to omit from request + } + enumVal := T(*redirect) + return &enumVal +} + +func (m ApplicationModel) toCreatePublicApplication() api.CreatePublicApplicationJSONRequestBody { + buildPack := api.CreatePublicApplicationJSONBodyBuildPack(m.BuildPack.ValueString()) + redirect := expand.StringOrNil(m.Redirect) + redirectEnum := validateRedirect[api.CreatePublicApplicationJSONBodyRedirect](redirect) + staticImage := expand.String(m.StaticImage) + var staticImageEnum *api.CreatePublicApplicationJSONBodyStaticImage + if staticImage != nil { + staticImageEnumVal := api.CreatePublicApplicationJSONBodyStaticImage(*staticImage) + staticImageEnum = &staticImageEnumVal + } + + return api.CreatePublicApplicationJSONRequestBody{ + ProjectUuid: m.ProjectUuid.ValueString(), + ServerUuid: m.ServerUuid.ValueString(), + EnvironmentName: m.EnvironmentName.ValueString(), + EnvironmentUuid: m.EnvironmentUuid.ValueString(), + GitRepository: m.GitRepository.ValueString(), + GitBranch: m.GitBranch.ValueString(), + BuildPack: buildPack, + PortsExposes: m.PortsExposes.ValueString(), + DestinationUuid: expand.StringOrNil(m.DestinationUuid), + Name: expand.String(m.Name), + Description: expand.String(m.Description), + Domains: expand.String(m.Domains), + GitCommitSha: expand.String(m.GitCommitSha), + DockerRegistryImageName: expand.String(m.DockerRegistryImageName), + DockerRegistryImageTag: expand.String(m.DockerRegistryImageTag), + IsStatic: expand.Bool(m.IsStatic), + StaticImage: staticImageEnum, + InstallCommand: expand.String(m.InstallCommand), + BuildCommand: expand.String(m.BuildCommand), + StartCommand: expand.String(m.StartCommand), + PortsMappings: expand.String(m.PortsMappings), + BaseDirectory: expand.String(m.BaseDirectory), + PublishDirectory: expand.String(m.PublishDirectory), + HealthCheckEnabled: expand.Bool(m.HealthCheckEnabled), + HealthCheckPath: expand.String(m.HealthCheckPath), + HealthCheckPort: expand.String(m.HealthCheckPort), + HealthCheckHost: expand.StringOrNil(m.HealthCheckHost), + HealthCheckMethod: expand.String(m.HealthCheckMethod), + HealthCheckReturnCode: expand.Int64(m.HealthCheckReturnCode), + HealthCheckScheme: expand.String(m.HealthCheckScheme), + HealthCheckResponseText: expand.String(m.HealthCheckResponseText), + HealthCheckInterval: expand.Int64(m.HealthCheckInterval), + HealthCheckTimeout: expand.Int64(m.HealthCheckTimeout), + HealthCheckRetries: expand.Int64(m.HealthCheckRetries), + HealthCheckStartPeriod: expand.Int64(m.HealthCheckStartPeriod), + LimitsMemory: expand.String(m.LimitsMemory), + LimitsMemorySwap: expand.String(m.LimitsMemorySwap), + LimitsMemorySwappiness: expand.Int64(m.LimitsMemorySwappiness), + LimitsMemoryReservation: expand.String(m.LimitsMemoryReservation), + LimitsCpus: expand.String(m.LimitsCpus), + LimitsCpuset: expand.String(m.LimitsCpuset), + LimitsCpuShares: expand.Int64(m.LimitsCpuShares), + CustomLabels: expand.String(m.CustomLabels), + CustomDockerRunOptions: expand.String(m.CustomDockerRunOptions), + PostDeploymentCommand: expand.String(m.PostDeploymentCommand), + PostDeploymentCommandContainer: expand.String(m.PostDeploymentCommandContainer), + PreDeploymentCommand: expand.String(m.PreDeploymentCommand), + PreDeploymentCommandContainer: expand.String(m.PreDeploymentCommandContainer), + ManualWebhookSecretGithub: expand.String(m.ManualWebhookSecretGithub), + ManualWebhookSecretGitlab: expand.String(m.ManualWebhookSecretGitlab), + ManualWebhookSecretBitbucket: expand.String(m.ManualWebhookSecretBitbucket), + ManualWebhookSecretGitea: expand.String(m.ManualWebhookSecretGitea), + Redirect: redirectEnum, + InstantDeploy: expand.Bool(m.InstantDeploy), + Dockerfile: expand.String(m.Dockerfile), + DockerComposeLocation: expand.String(m.DockerComposeLocation), + DockerComposeRaw: expand.String(m.DockerComposeRaw), + DockerComposeCustomStartCommand: expand.String(m.DockerComposeCustomStartCommand), + DockerComposeCustomBuildCommand: expand.String(m.DockerComposeCustomBuildCommand), + WatchPaths: expand.String(m.WatchPaths), + UseBuildServer: expand.Bool(m.UseBuildServer), + IsHttpBasicAuthEnabled: expand.Bool(m.IsHttpBasicAuthEnabled), + HttpBasicAuthUsername: expand.String(m.HttpBasicAuthUsername), + HttpBasicAuthPassword: expand.String(m.HttpBasicAuthPassword), + } +} + +func (m ApplicationModel) toCreatePrivateGithubAppApplication() api.CreatePrivateGithubAppApplicationJSONRequestBody { + buildPack := api.CreatePrivateGithubAppApplicationJSONBodyBuildPack(m.BuildPack.ValueString()) + redirect := expand.StringOrNil(m.Redirect) + redirectEnum := validateRedirect[api.CreatePrivateGithubAppApplicationJSONBodyRedirect](redirect) + staticImage := expand.String(m.StaticImage) + var staticImageEnum *api.CreatePrivateGithubAppApplicationJSONBodyStaticImage + if staticImage != nil { + staticImageEnumVal := api.CreatePrivateGithubAppApplicationJSONBodyStaticImage(*staticImage) + staticImageEnum = &staticImageEnumVal + } + + return api.CreatePrivateGithubAppApplicationJSONRequestBody{ + ProjectUuid: m.ProjectUuid.ValueString(), + ServerUuid: m.ServerUuid.ValueString(), + EnvironmentName: m.EnvironmentName.ValueString(), + EnvironmentUuid: m.EnvironmentUuid.ValueString(), + GithubAppUuid: m.GithubAppUuid.ValueString(), + GitRepository: m.GitRepository.ValueString(), + GitBranch: m.GitBranch.ValueString(), + BuildPack: buildPack, + PortsExposes: m.PortsExposes.ValueString(), + DestinationUuid: expand.StringOrNil(m.DestinationUuid), + Name: expand.String(m.Name), + Description: expand.String(m.Description), + Domains: expand.String(m.Domains), + GitCommitSha: expand.String(m.GitCommitSha), + DockerRegistryImageName: expand.String(m.DockerRegistryImageName), + DockerRegistryImageTag: expand.String(m.DockerRegistryImageTag), + IsStatic: expand.Bool(m.IsStatic), + StaticImage: staticImageEnum, + InstallCommand: expand.String(m.InstallCommand), + BuildCommand: expand.String(m.BuildCommand), + StartCommand: expand.String(m.StartCommand), + PortsMappings: expand.String(m.PortsMappings), + BaseDirectory: expand.String(m.BaseDirectory), + PublishDirectory: expand.String(m.PublishDirectory), + HealthCheckEnabled: expand.Bool(m.HealthCheckEnabled), + HealthCheckPath: expand.String(m.HealthCheckPath), + HealthCheckPort: expand.String(m.HealthCheckPort), + HealthCheckHost: expand.StringOrNil(m.HealthCheckHost), + HealthCheckMethod: expand.String(m.HealthCheckMethod), + HealthCheckReturnCode: expand.Int64(m.HealthCheckReturnCode), + HealthCheckScheme: expand.String(m.HealthCheckScheme), + HealthCheckResponseText: expand.String(m.HealthCheckResponseText), + HealthCheckInterval: expand.Int64(m.HealthCheckInterval), + HealthCheckTimeout: expand.Int64(m.HealthCheckTimeout), + HealthCheckRetries: expand.Int64(m.HealthCheckRetries), + HealthCheckStartPeriod: expand.Int64(m.HealthCheckStartPeriod), + LimitsMemory: expand.String(m.LimitsMemory), + LimitsMemorySwap: expand.String(m.LimitsMemorySwap), + LimitsMemorySwappiness: expand.Int64(m.LimitsMemorySwappiness), + LimitsMemoryReservation: expand.String(m.LimitsMemoryReservation), + LimitsCpus: expand.String(m.LimitsCpus), + LimitsCpuset: expand.String(m.LimitsCpuset), + LimitsCpuShares: expand.Int64(m.LimitsCpuShares), + CustomLabels: expand.String(m.CustomLabels), + CustomDockerRunOptions: expand.String(m.CustomDockerRunOptions), + PostDeploymentCommand: expand.String(m.PostDeploymentCommand), + PostDeploymentCommandContainer: expand.String(m.PostDeploymentCommandContainer), + PreDeploymentCommand: expand.String(m.PreDeploymentCommand), + PreDeploymentCommandContainer: expand.String(m.PreDeploymentCommandContainer), + ManualWebhookSecretGithub: expand.String(m.ManualWebhookSecretGithub), + ManualWebhookSecretGitlab: expand.String(m.ManualWebhookSecretGitlab), + ManualWebhookSecretBitbucket: expand.String(m.ManualWebhookSecretBitbucket), + ManualWebhookSecretGitea: expand.String(m.ManualWebhookSecretGitea), + Redirect: redirectEnum, + InstantDeploy: expand.Bool(m.InstantDeploy), + Dockerfile: expand.String(m.Dockerfile), + DockerComposeLocation: expand.String(m.DockerComposeLocation), + DockerComposeRaw: expand.String(m.DockerComposeRaw), + DockerComposeCustomStartCommand: expand.String(m.DockerComposeCustomStartCommand), + DockerComposeCustomBuildCommand: expand.String(m.DockerComposeCustomBuildCommand), + WatchPaths: expand.String(m.WatchPaths), + UseBuildServer: expand.Bool(m.UseBuildServer), + IsHttpBasicAuthEnabled: expand.Bool(m.IsHttpBasicAuthEnabled), + HttpBasicAuthUsername: expand.String(m.HttpBasicAuthUsername), + HttpBasicAuthPassword: expand.String(m.HttpBasicAuthPassword), + } +} + +func (m ApplicationModel) toCreatePrivateDeployKeyApplication() api.CreatePrivateDeployKeyApplicationJSONRequestBody { + buildPack := api.CreatePrivateDeployKeyApplicationJSONBodyBuildPack(m.BuildPack.ValueString()) + redirect := expand.StringOrNil(m.Redirect) + redirectEnum := validateRedirect[api.CreatePrivateDeployKeyApplicationJSONBodyRedirect](redirect) + staticImage := expand.String(m.StaticImage) + var staticImageEnum *api.CreatePrivateDeployKeyApplicationJSONBodyStaticImage + if staticImage != nil { + staticImageEnumVal := api.CreatePrivateDeployKeyApplicationJSONBodyStaticImage(*staticImage) + staticImageEnum = &staticImageEnumVal + } + + return api.CreatePrivateDeployKeyApplicationJSONRequestBody{ + ProjectUuid: m.ProjectUuid.ValueString(), + ServerUuid: m.ServerUuid.ValueString(), + EnvironmentName: m.EnvironmentName.ValueString(), + EnvironmentUuid: m.EnvironmentUuid.ValueString(), + PrivateKeyUuid: m.PrivateKeyUuid.ValueString(), + GitRepository: m.GitRepository.ValueString(), + GitBranch: m.GitBranch.ValueString(), + BuildPack: buildPack, + PortsExposes: m.PortsExposes.ValueString(), + DestinationUuid: expand.StringOrNil(m.DestinationUuid), + Name: expand.String(m.Name), + Description: expand.String(m.Description), + Domains: expand.String(m.Domains), + GitCommitSha: expand.String(m.GitCommitSha), + DockerRegistryImageName: expand.String(m.DockerRegistryImageName), + DockerRegistryImageTag: expand.String(m.DockerRegistryImageTag), + IsStatic: expand.Bool(m.IsStatic), + StaticImage: staticImageEnum, + InstallCommand: expand.String(m.InstallCommand), + BuildCommand: expand.String(m.BuildCommand), + StartCommand: expand.String(m.StartCommand), + PortsMappings: expand.String(m.PortsMappings), + BaseDirectory: expand.String(m.BaseDirectory), + PublishDirectory: expand.String(m.PublishDirectory), + HealthCheckEnabled: expand.Bool(m.HealthCheckEnabled), + HealthCheckPath: expand.String(m.HealthCheckPath), + HealthCheckPort: expand.String(m.HealthCheckPort), + HealthCheckHost: expand.StringOrNil(m.HealthCheckHost), + HealthCheckMethod: expand.String(m.HealthCheckMethod), + HealthCheckReturnCode: expand.Int64(m.HealthCheckReturnCode), + HealthCheckScheme: expand.String(m.HealthCheckScheme), + HealthCheckResponseText: expand.String(m.HealthCheckResponseText), + HealthCheckInterval: expand.Int64(m.HealthCheckInterval), + HealthCheckTimeout: expand.Int64(m.HealthCheckTimeout), + HealthCheckRetries: expand.Int64(m.HealthCheckRetries), + HealthCheckStartPeriod: expand.Int64(m.HealthCheckStartPeriod), + LimitsMemory: expand.String(m.LimitsMemory), + LimitsMemorySwap: expand.String(m.LimitsMemorySwap), + LimitsMemorySwappiness: expand.Int64(m.LimitsMemorySwappiness), + LimitsMemoryReservation: expand.String(m.LimitsMemoryReservation), + LimitsCpus: expand.String(m.LimitsCpus), + LimitsCpuset: expand.String(m.LimitsCpuset), + LimitsCpuShares: expand.Int64(m.LimitsCpuShares), + CustomLabels: expand.String(m.CustomLabels), + CustomDockerRunOptions: expand.String(m.CustomDockerRunOptions), + PostDeploymentCommand: expand.String(m.PostDeploymentCommand), + PostDeploymentCommandContainer: expand.String(m.PostDeploymentCommandContainer), + PreDeploymentCommand: expand.String(m.PreDeploymentCommand), + PreDeploymentCommandContainer: expand.String(m.PreDeploymentCommandContainer), + ManualWebhookSecretGithub: expand.String(m.ManualWebhookSecretGithub), + ManualWebhookSecretGitlab: expand.String(m.ManualWebhookSecretGitlab), + ManualWebhookSecretBitbucket: expand.String(m.ManualWebhookSecretBitbucket), + ManualWebhookSecretGitea: expand.String(m.ManualWebhookSecretGitea), + Redirect: redirectEnum, + InstantDeploy: expand.Bool(m.InstantDeploy), + Dockerfile: expand.String(m.Dockerfile), + DockerComposeLocation: expand.String(m.DockerComposeLocation), + DockerComposeRaw: expand.String(m.DockerComposeRaw), + DockerComposeCustomStartCommand: expand.String(m.DockerComposeCustomStartCommand), + DockerComposeCustomBuildCommand: expand.String(m.DockerComposeCustomBuildCommand), + WatchPaths: expand.String(m.WatchPaths), + UseBuildServer: expand.Bool(m.UseBuildServer), + IsHttpBasicAuthEnabled: expand.Bool(m.IsHttpBasicAuthEnabled), + HttpBasicAuthUsername: expand.String(m.HttpBasicAuthUsername), + HttpBasicAuthPassword: expand.String(m.HttpBasicAuthPassword), + } +} + +func (m ApplicationModel) toCreateDockerfileApplication() api.CreateDockerfileApplicationJSONRequestBody { + buildPack := expand.String(m.BuildPack) + var buildPackEnum *api.CreateDockerfileApplicationJSONBodyBuildPack + if buildPack != nil { + buildPackEnumVal := api.CreateDockerfileApplicationJSONBodyBuildPack(*buildPack) + buildPackEnum = &buildPackEnumVal + } + redirect := expand.StringOrNil(m.Redirect) + redirectEnum := validateRedirect[api.CreateDockerfileApplicationJSONBodyRedirect](redirect) + + return api.CreateDockerfileApplicationJSONRequestBody{ + ProjectUuid: m.ProjectUuid.ValueString(), + ServerUuid: m.ServerUuid.ValueString(), + EnvironmentName: m.EnvironmentName.ValueString(), + EnvironmentUuid: m.EnvironmentUuid.ValueString(), + Dockerfile: m.Dockerfile.ValueString(), + BuildPack: buildPackEnum, + PortsExposes: expand.String(m.PortsExposes), + DestinationUuid: expand.StringOrNil(m.DestinationUuid), + Name: expand.String(m.Name), + Description: expand.String(m.Description), + Domains: expand.String(m.Domains), + DockerRegistryImageName: expand.String(m.DockerRegistryImageName), + DockerRegistryImageTag: expand.String(m.DockerRegistryImageTag), + PortsMappings: expand.String(m.PortsMappings), + BaseDirectory: expand.String(m.BaseDirectory), + HealthCheckEnabled: expand.Bool(m.HealthCheckEnabled), + HealthCheckPath: expand.String(m.HealthCheckPath), + HealthCheckPort: expand.String(m.HealthCheckPort), + HealthCheckHost: expand.StringOrNil(m.HealthCheckHost), + HealthCheckMethod: expand.String(m.HealthCheckMethod), + HealthCheckReturnCode: expand.Int64(m.HealthCheckReturnCode), + HealthCheckScheme: expand.String(m.HealthCheckScheme), + HealthCheckResponseText: expand.String(m.HealthCheckResponseText), + HealthCheckInterval: expand.Int64(m.HealthCheckInterval), + HealthCheckTimeout: expand.Int64(m.HealthCheckTimeout), + HealthCheckRetries: expand.Int64(m.HealthCheckRetries), + HealthCheckStartPeriod: expand.Int64(m.HealthCheckStartPeriod), + LimitsMemory: expand.String(m.LimitsMemory), + LimitsMemorySwap: expand.String(m.LimitsMemorySwap), + LimitsMemorySwappiness: expand.Int64(m.LimitsMemorySwappiness), + LimitsMemoryReservation: expand.String(m.LimitsMemoryReservation), + LimitsCpus: expand.String(m.LimitsCpus), + LimitsCpuset: expand.String(m.LimitsCpuset), + LimitsCpuShares: expand.Int64(m.LimitsCpuShares), + CustomLabels: expand.String(m.CustomLabels), + CustomDockerRunOptions: expand.String(m.CustomDockerRunOptions), + PostDeploymentCommand: expand.String(m.PostDeploymentCommand), + PostDeploymentCommandContainer: expand.String(m.PostDeploymentCommandContainer), + PreDeploymentCommand: expand.String(m.PreDeploymentCommand), + PreDeploymentCommandContainer: expand.String(m.PreDeploymentCommandContainer), + ManualWebhookSecretGithub: expand.String(m.ManualWebhookSecretGithub), + ManualWebhookSecretGitlab: expand.String(m.ManualWebhookSecretGitlab), + ManualWebhookSecretBitbucket: expand.String(m.ManualWebhookSecretBitbucket), + ManualWebhookSecretGitea: expand.String(m.ManualWebhookSecretGitea), + Redirect: redirectEnum, + InstantDeploy: expand.Bool(m.InstantDeploy), + UseBuildServer: expand.Bool(m.UseBuildServer), + } +} + +func (m ApplicationModel) toCreateDockerimageApplication() api.CreateDockerimageApplicationJSONRequestBody { + redirect := expand.StringOrNil(m.Redirect) + redirectEnum := validateRedirect[api.CreateDockerimageApplicationJSONBodyRedirect](redirect) + + return api.CreateDockerimageApplicationJSONRequestBody{ + ProjectUuid: m.ProjectUuid.ValueString(), + ServerUuid: m.ServerUuid.ValueString(), + EnvironmentName: m.EnvironmentName.ValueString(), + EnvironmentUuid: m.EnvironmentUuid.ValueString(), + DockerRegistryImageName: m.DockerRegistryImageName.ValueString(), + DockerRegistryImageTag: expand.String(m.DockerRegistryImageTag), + PortsExposes: m.PortsExposes.ValueString(), + DestinationUuid: expand.StringOrNil(m.DestinationUuid), + Name: expand.String(m.Name), + Description: expand.String(m.Description), + Domains: expand.String(m.Domains), + PortsMappings: expand.String(m.PortsMappings), + HealthCheckEnabled: expand.Bool(m.HealthCheckEnabled), + HealthCheckPath: expand.String(m.HealthCheckPath), + HealthCheckPort: expand.String(m.HealthCheckPort), + HealthCheckHost: expand.StringOrNil(m.HealthCheckHost), + HealthCheckMethod: expand.String(m.HealthCheckMethod), + HealthCheckReturnCode: expand.Int64(m.HealthCheckReturnCode), + HealthCheckScheme: expand.String(m.HealthCheckScheme), + HealthCheckResponseText: expand.String(m.HealthCheckResponseText), + HealthCheckInterval: expand.Int64(m.HealthCheckInterval), + HealthCheckTimeout: expand.Int64(m.HealthCheckTimeout), + HealthCheckRetries: expand.Int64(m.HealthCheckRetries), + HealthCheckStartPeriod: expand.Int64(m.HealthCheckStartPeriod), + LimitsMemory: expand.String(m.LimitsMemory), + LimitsMemorySwap: expand.String(m.LimitsMemorySwap), + LimitsMemorySwappiness: expand.Int64(m.LimitsMemorySwappiness), + LimitsMemoryReservation: expand.String(m.LimitsMemoryReservation), + LimitsCpus: expand.String(m.LimitsCpus), + LimitsCpuset: expand.String(m.LimitsCpuset), + LimitsCpuShares: expand.Int64(m.LimitsCpuShares), + CustomLabels: expand.String(m.CustomLabels), + CustomDockerRunOptions: expand.String(m.CustomDockerRunOptions), + PostDeploymentCommand: expand.String(m.PostDeploymentCommand), + PostDeploymentCommandContainer: expand.String(m.PostDeploymentCommandContainer), + PreDeploymentCommand: expand.String(m.PreDeploymentCommand), + PreDeploymentCommandContainer: expand.String(m.PreDeploymentCommandContainer), + ManualWebhookSecretGithub: expand.String(m.ManualWebhookSecretGithub), + ManualWebhookSecretGitlab: expand.String(m.ManualWebhookSecretGitlab), + ManualWebhookSecretBitbucket: expand.String(m.ManualWebhookSecretBitbucket), + ManualWebhookSecretGitea: expand.String(m.ManualWebhookSecretGitea), + Redirect: redirectEnum, + InstantDeploy: expand.Bool(m.InstantDeploy), + } +} + +func (m ApplicationModel) toCreateDockercomposeApplication() api.CreateDockercomposeApplicationJSONRequestBody { + return api.CreateDockercomposeApplicationJSONRequestBody{ + ProjectUuid: m.ProjectUuid.ValueString(), + ServerUuid: m.ServerUuid.ValueString(), + EnvironmentName: m.EnvironmentName.ValueString(), + EnvironmentUuid: m.EnvironmentUuid.ValueString(), + DockerComposeRaw: m.DockerComposeRaw.ValueString(), + DestinationUuid: expand.StringOrNil(m.DestinationUuid), + Name: expand.String(m.Name), + Description: expand.String(m.Description), + InstantDeploy: expand.Bool(m.InstantDeploy), + UseBuildServer: expand.Bool(m.UseBuildServer), + } +} + +func (m ApplicationModel) ToAPIUpdate() api.UpdateApplicationByUuidJSONRequestBody { + buildPack := expand.String(m.BuildPack) + var buildPackEnum *api.UpdateApplicationByUuidJSONBodyBuildPack + if buildPack != nil { + buildPackEnumVal := api.UpdateApplicationByUuidJSONBodyBuildPack(*buildPack) + buildPackEnum = &buildPackEnumVal + } + redirect := expand.StringOrNil(m.Redirect) + redirectEnum := validateRedirect[api.UpdateApplicationByUuidJSONBodyRedirect](redirect) + + return api.UpdateApplicationByUuidJSONRequestBody{ + Description: expand.String(m.Description), + DestinationUuid: expand.StringOrNil(m.DestinationUuid), + Domains: expand.String(m.Domains), + Name: expand.String(m.Name), + ProjectUuid: expand.String(m.ProjectUuid), + ServerUuid: expand.String(m.ServerUuid), + EnvironmentName: expand.String(m.EnvironmentName), + BuildPack: buildPackEnum, + BaseDirectory: expand.String(m.BaseDirectory), + BuildCommand: expand.String(m.BuildCommand), + StartCommand: expand.String(m.StartCommand), + InstallCommand: expand.String(m.InstallCommand), + PublishDirectory: expand.String(m.PublishDirectory), + PortsMappings: expand.String(m.PortsMappings), + PortsExposes: expand.String(m.PortsExposes), + GitCommitSha: expand.String(m.GitCommitSha), + GitBranch: expand.String(m.GitBranch), + GitRepository: expand.String(m.GitRepository), + GithubAppUuid: expand.String(m.GithubAppUuid), + HealthCheckEnabled: expand.Bool(m.HealthCheckEnabled), + HealthCheckPath: expand.String(m.HealthCheckPath), + HealthCheckPort: expand.String(m.HealthCheckPort), + HealthCheckHost: expand.StringOrNil(m.HealthCheckHost), + HealthCheckMethod: expand.String(m.HealthCheckMethod), + HealthCheckReturnCode: expand.Int64(m.HealthCheckReturnCode), + HealthCheckScheme: expand.String(m.HealthCheckScheme), + HealthCheckResponseText: expand.String(m.HealthCheckResponseText), + HealthCheckInterval: expand.Int64(m.HealthCheckInterval), + HealthCheckTimeout: expand.Int64(m.HealthCheckTimeout), + HealthCheckRetries: expand.Int64(m.HealthCheckRetries), + HealthCheckStartPeriod: expand.Int64(m.HealthCheckStartPeriod), + LimitsMemory: expand.String(m.LimitsMemory), + LimitsMemorySwap: expand.String(m.LimitsMemorySwap), + LimitsMemorySwappiness: expand.Int64(m.LimitsMemorySwappiness), + LimitsMemoryReservation: expand.String(m.LimitsMemoryReservation), + LimitsCpus: expand.String(m.LimitsCpus), + LimitsCpuset: expand.String(m.LimitsCpuset), + LimitsCpuShares: expand.Int64(m.LimitsCpuShares), + CustomLabels: expand.String(m.CustomLabels), + CustomDockerRunOptions: expand.String(m.CustomDockerRunOptions), + PostDeploymentCommand: expand.String(m.PostDeploymentCommand), + PostDeploymentCommandContainer: expand.String(m.PostDeploymentCommandContainer), + PreDeploymentCommand: expand.String(m.PreDeploymentCommand), + PreDeploymentCommandContainer: expand.String(m.PreDeploymentCommandContainer), + ManualWebhookSecretGithub: expand.String(m.ManualWebhookSecretGithub), + ManualWebhookSecretGitlab: expand.String(m.ManualWebhookSecretGitlab), + ManualWebhookSecretBitbucket: expand.String(m.ManualWebhookSecretBitbucket), + ManualWebhookSecretGitea: expand.String(m.ManualWebhookSecretGitea), + Redirect: redirectEnum, + InstantDeploy: expand.Bool(m.InstantDeploy), + Dockerfile: expand.String(m.Dockerfile), + DockerComposeLocation: expand.String(m.DockerComposeLocation), + DockerComposeRaw: expand.String(m.DockerComposeRaw), + DockerComposeCustomStartCommand: expand.String(m.DockerComposeCustomStartCommand), + DockerComposeCustomBuildCommand: expand.String(m.DockerComposeCustomBuildCommand), + DockerComposeDomains: nil, // TODO: handle array + WatchPaths: expand.String(m.WatchPaths), + UseBuildServer: expand.Bool(m.UseBuildServer), + DockerRegistryImageName: expand.String(m.DockerRegistryImageName), + DockerRegistryImageTag: expand.String(m.DockerRegistryImageTag), + } +} + diff --git a/internal/service/application_planmodifier.go b/internal/service/application_planmodifier.go new file mode 100644 index 0000000..a0dab36 --- /dev/null +++ b/internal/service/application_planmodifier.go @@ -0,0 +1,113 @@ +package service + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// useStateForUnknownUnlessNull is a plan modifier for Optional+Computed fields +// For CREATE: if config is null, mark as Unknown (to accept API defaults) +// For UPDATE: if config is null and state has value, keep state value +type useStateForUnknownUnlessNull struct{} + +func (m useStateForUnknownUnlessNull) Description(ctx context.Context) string { + return "Handles Optional+Computed fields: marks as Unknown on create when null, preserves state on update" +} + +func (m useStateForUnknownUnlessNull) MarkdownDescription(ctx context.Context) string { + return "Handles Optional+Computed fields: marks as Unknown on create when null, preserves state on update" +} + +func (m useStateForUnknownUnlessNull) PlanModifyString(ctx context.Context, req planmodifier.StringRequest, resp *planmodifier.StringResponse) { + // If config has explicit value, use it + if !req.ConfigValue.IsNull() { + return + } + + // If plan value is not null (user set it), keep it + if !req.PlanValue.IsNull() { + return + } + + // Config is null (not set by user) + + // During CREATE (no prior state): mark as Unknown so API can provide default + if req.StateValue.IsNull() || req.StateValue.IsUnknown() { + resp.PlanValue = types.StringUnknown() + return + } + + // During UPDATE (has prior state): keep the prior state value + resp.PlanValue = req.StateValue +} + +func UseStateForUnknownUnlessNullString() planmodifier.String { + return useStateForUnknownUnlessNull{} +} + +// Same for Int64 +type useStateForUnknownUnlessNullInt64 struct{} + +func (m useStateForUnknownUnlessNullInt64) Description(ctx context.Context) string { + return "Handles Optional+Computed fields: marks as Unknown on create when null, preserves state on update" +} + +func (m useStateForUnknownUnlessNullInt64) MarkdownDescription(ctx context.Context) string { + return "Handles Optional+Computed fields: marks as Unknown on create when null, preserves state on update" +} + +func (m useStateForUnknownUnlessNullInt64) PlanModifyInt64(ctx context.Context, req planmodifier.Int64Request, resp *planmodifier.Int64Response) { + if !req.ConfigValue.IsNull() { + return + } + + if !req.PlanValue.IsNull() { + return + } + + if req.StateValue.IsNull() || req.StateValue.IsUnknown() { + resp.PlanValue = types.Int64Unknown() + return + } + + resp.PlanValue = req.StateValue +} + +func UseStateForUnknownUnlessNullInt64() planmodifier.Int64 { + return useStateForUnknownUnlessNullInt64{} +} + +// Same for Bool +type useStateForUnknownUnlessNullBool struct{} + +func (m useStateForUnknownUnlessNullBool) Description(ctx context.Context) string { + return "Handles Optional+Computed fields: marks as Unknown on create when null, preserves state on update" +} + +func (m useStateForUnknownUnlessNullBool) MarkdownDescription(ctx context.Context) string { + return "Handles Optional+Computed fields: marks as Unknown on create when null, preserves state on update" +} + +func (m useStateForUnknownUnlessNullBool) PlanModifyBool(ctx context.Context, req planmodifier.BoolRequest, resp *planmodifier.BoolResponse) { + if !req.ConfigValue.IsNull() { + return + } + + if !req.PlanValue.IsNull() { + return + } + + if req.StateValue.IsNull() || req.StateValue.IsUnknown() { + resp.PlanValue = types.BoolUnknown() + return + } + + resp.PlanValue = req.StateValue +} + +func UseStateForUnknownUnlessNullBool() planmodifier.Bool { + return useStateForUnknownUnlessNullBool{} +} + diff --git a/internal/service/application_resource.go b/internal/service/application_resource.go new file mode 100644 index 0000000..1d5d192 --- /dev/null +++ b/internal/service/application_resource.go @@ -0,0 +1,485 @@ +package service + +import ( + "context" + "fmt" + "net/http" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-log/tflog" + + "terraform-provider-coolify/internal/api" + "terraform-provider-coolify/internal/provider/util" +) + +var ( + _ resource.Resource = &applicationResource{} + _ resource.ResourceWithConfigure = &applicationResource{} + _ resource.ResourceWithImportState = &applicationResource{} +) + +func NewApplicationResource() resource.Resource { + return &applicationResource{} +} + +type applicationResource struct { + client *api.ClientWithResponses +} + +func (r *applicationResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_application" +} + +func (r *applicationResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = ApplicationModel{}.Schema(ctx) + resp.Schema.Description = "Create, read, update, and delete a Coolify application resource." + + // Mark sensitive attributes + sensitiveAttrs := []string{ + "manual_webhook_secret_bitbucket", + "manual_webhook_secret_gitea", + "manual_webhook_secret_github", + "manual_webhook_secret_gitlab", + "http_basic_auth_password", + } + for _, attr := range sensitiveAttrs { + if err := makeResourceAttributeSensitive(resp.Schema.Attributes, attr); err != nil { + tflog.Warn(ctx, fmt.Sprintf("Failed to mark attribute as sensitive: %s", attr)) + } + } +} + +func (r *applicationResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + util.ProviderDataFromResourceConfigureRequest(req, &r.client, resp) +} + +func (r *applicationResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan ApplicationModel + + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + // Validate required fields based on source_type + if diags := r.validateCreatePlan(ctx, plan); diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + + sourceType := ApplicationSourceType(plan.SourceType.ValueString()) + tflog.Debug(ctx, "Creating application", map[string]interface{}{ + "source_type": sourceType, + "name": plan.Name.ValueString(), + }) + + createBody, err := plan.ToAPICreate() + if err != nil { + resp.Diagnostics.AddError( + "Error preparing application creation", + err.Error(), + ) + return + } + + var uuid string + switch sourceType { + case ApplicationSourceTypePublic: + body := createBody.(api.CreatePublicApplicationJSONRequestBody) + apiResp, err := r.client.CreatePublicApplicationWithResponse(ctx, body) + if err != nil { + resp.Diagnostics.AddError("Error creating application", err.Error()) + return + } + if apiResp.StatusCode() != http.StatusCreated { + resp.Diagnostics.AddError( + "Unexpected HTTP status code creating application", + fmt.Sprintf("Received %d creating application. Details: %s", apiResp.StatusCode(), string(apiResp.Body)), + ) + return + } + if apiResp.JSON201 == nil || apiResp.JSON201.Uuid == nil { + resp.Diagnostics.AddError( + "Invalid response creating application", + "Response did not contain application UUID", + ) + return + } + uuid = *apiResp.JSON201.Uuid + case ApplicationSourceTypePrivateGithubApp: + body := createBody.(api.CreatePrivateGithubAppApplicationJSONRequestBody) + apiResp, err := r.client.CreatePrivateGithubAppApplicationWithResponse(ctx, body) + if err != nil { + resp.Diagnostics.AddError("Error creating application", err.Error()) + return + } + if apiResp.StatusCode() != http.StatusCreated { + resp.Diagnostics.AddError( + "Unexpected HTTP status code creating application", + fmt.Sprintf("Received %d creating application. Details: %s", apiResp.StatusCode(), string(apiResp.Body)), + ) + return + } + if apiResp.JSON201 == nil || apiResp.JSON201.Uuid == nil { + resp.Diagnostics.AddError( + "Invalid response creating application", + "Response did not contain application UUID", + ) + return + } + uuid = *apiResp.JSON201.Uuid + case ApplicationSourceTypePrivateDeployKey: + body := createBody.(api.CreatePrivateDeployKeyApplicationJSONRequestBody) + apiResp, err := r.client.CreatePrivateDeployKeyApplicationWithResponse(ctx, body) + if err != nil { + resp.Diagnostics.AddError("Error creating application", err.Error()) + return + } + if apiResp.StatusCode() != http.StatusCreated { + resp.Diagnostics.AddError( + "Unexpected HTTP status code creating application", + fmt.Sprintf("Received %d creating application. Details: %s", apiResp.StatusCode(), string(apiResp.Body)), + ) + return + } + if apiResp.JSON201 == nil || apiResp.JSON201.Uuid == nil { + resp.Diagnostics.AddError( + "Invalid response creating application", + "Response did not contain application UUID", + ) + return + } + uuid = *apiResp.JSON201.Uuid + case ApplicationSourceTypeDockerfile: + body := createBody.(api.CreateDockerfileApplicationJSONRequestBody) + apiResp, err := r.client.CreateDockerfileApplicationWithResponse(ctx, body) + if err != nil { + resp.Diagnostics.AddError("Error creating application", err.Error()) + return + } + if apiResp.StatusCode() != http.StatusCreated { + resp.Diagnostics.AddError( + "Unexpected HTTP status code creating application", + fmt.Sprintf("Received %d creating application. Details: %s", apiResp.StatusCode(), string(apiResp.Body)), + ) + return + } + if apiResp.JSON201 == nil || apiResp.JSON201.Uuid == nil { + resp.Diagnostics.AddError( + "Invalid response creating application", + "Response did not contain application UUID", + ) + return + } + uuid = *apiResp.JSON201.Uuid + case ApplicationSourceTypeDockerimage: + body := createBody.(api.CreateDockerimageApplicationJSONRequestBody) + apiResp, err := r.client.CreateDockerimageApplicationWithResponse(ctx, body) + if err != nil { + resp.Diagnostics.AddError("Error creating application", err.Error()) + return + } + if apiResp.StatusCode() != http.StatusCreated { + resp.Diagnostics.AddError( + "Unexpected HTTP status code creating application", + fmt.Sprintf("Received %d creating application. Details: %s", apiResp.StatusCode(), string(apiResp.Body)), + ) + return + } + if apiResp.JSON201 == nil || apiResp.JSON201.Uuid == nil { + resp.Diagnostics.AddError( + "Invalid response creating application", + "Response did not contain application UUID", + ) + return + } + uuid = *apiResp.JSON201.Uuid + case ApplicationSourceTypeDockercompose: + body := createBody.(api.CreateDockercomposeApplicationJSONRequestBody) + apiResp, err := r.client.CreateDockercomposeApplicationWithResponse(ctx, body) + if err != nil { + resp.Diagnostics.AddError("Error creating application", err.Error()) + return + } + if apiResp.StatusCode() != http.StatusCreated { + resp.Diagnostics.AddError( + "Unexpected HTTP status code creating application", + fmt.Sprintf("Received %d creating application. Details: %s", apiResp.StatusCode(), string(apiResp.Body)), + ) + return + } + if apiResp.JSON201 == nil || apiResp.JSON201.Uuid == nil { + resp.Diagnostics.AddError( + "Invalid response creating application", + "Response did not contain application UUID", + ) + return + } + uuid = *apiResp.JSON201.Uuid + default: + resp.Diagnostics.AddError( + "Unsupported source_type", + fmt.Sprintf("source_type %s is not supported", sourceType), + ) + return + } + + data, ok := r.ReadFromAPI(ctx, &resp.Diagnostics, uuid, plan) + if !ok { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *applicationResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state ApplicationModel + + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + tflog.Debug(ctx, "Reading application", map[string]interface{}{ + "uuid": state.Uuid.ValueString(), + }) + + if state.Uuid.ValueString() == "" { + resp.Diagnostics.AddError("Invalid State", "No UUID found in state") + return + } + + data, ok := r.ReadFromAPI(ctx, &resp.Diagnostics, state.Uuid.ValueString(), state) + if !ok { + resp.State.RemoveResource(ctx) + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *applicationResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan ApplicationModel + var state ApplicationModel + + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + uuid := state.Uuid.ValueString() + if uuid == "" { + resp.Diagnostics.AddError("Invalid State", "No UUID found in state") + return + } + + tflog.Debug(ctx, "Updating application", map[string]interface{}{ + "uuid": uuid, + }) + + updateResp, err := r.client.UpdateApplicationByUuidWithResponse(ctx, uuid, plan.ToAPIUpdate()) + if err != nil { + resp.Diagnostics.AddError( + fmt.Sprintf("Error updating application: uuid=%s", uuid), + err.Error(), + ) + return + } + + if updateResp.StatusCode() != http.StatusOK { + resp.Diagnostics.AddError( + "Unexpected HTTP status code updating application", + fmt.Sprintf("Received %d updating application: uuid=%s. Details: %s", updateResp.StatusCode(), uuid, string(updateResp.Body)), + ) + return + } + + data, ok := r.ReadFromAPI(ctx, &resp.Diagnostics, uuid, plan) + if !ok { + resp.State.RemoveResource(ctx) + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *applicationResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state ApplicationModel + + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + uuid := state.Uuid.ValueString() + if uuid == "" { + resp.Diagnostics.AddError("Invalid State", "No UUID found in state") + return + } + + tflog.Debug(ctx, "Deleting application", map[string]interface{}{ + "uuid": uuid, + }) + + deleteTrue := true + deleteFalse := false + deleteResp, err := r.client.DeleteApplicationByUuidWithResponse(ctx, uuid, &api.DeleteApplicationByUuidParams{ + DeleteConfigurations: &deleteTrue, + DeleteVolumes: &deleteTrue, + DockerCleanup: &deleteTrue, + DeleteConnectedNetworks: &deleteFalse, + }) + + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to delete application, got error: %s", err)) + return + } + + if deleteResp.StatusCode() != http.StatusOK && deleteResp.StatusCode() != http.StatusNoContent { + resp.Diagnostics.AddError( + "Unexpected HTTP status code deleting application", + fmt.Sprintf("Received %d deleting application: %s. Details: %s", deleteResp.StatusCode(), uuid, string(deleteResp.Body)), + ) + return + } +} + +func (r *applicationResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("uuid"), req, resp) +} + + +// MARK: Helper Functions + +func (r *applicationResource) ReadFromAPI( + ctx context.Context, + diags *diag.Diagnostics, + uuid string, + state ApplicationModel, +) (ApplicationModel, bool) { + res, err := r.client.GetApplicationByUuidWithResponse(ctx, uuid) + if err != nil { + diags.AddError( + fmt.Sprintf("Error reading application: uuid=%s", uuid), + err.Error(), + ) + return ApplicationModel{}, false + } + + if res.StatusCode() == http.StatusNotFound { + return ApplicationModel{}, false + } + + if res.StatusCode() != http.StatusOK { + diags.AddError( + "Unexpected HTTP status code reading application", + fmt.Sprintf("Received %d for application: uuid=%s. Details: %s", res.StatusCode(), uuid, string(res.Body)), + ) + return ApplicationModel{}, false + } + + result := ApplicationModel{}.FromAPI(res.JSON200, state) + return result, true +} + +func (r *applicationResource) validateCreatePlan(ctx context.Context, plan ApplicationModel) diag.Diagnostics { + var diags diag.Diagnostics + sourceType := ApplicationSourceType(plan.SourceType.ValueString()) + + switch sourceType { + case ApplicationSourceTypePublic, ApplicationSourceTypePrivateGithubApp, ApplicationSourceTypePrivateDeployKey: + if plan.GitRepository.IsNull() || plan.GitRepository.ValueString() == "" { + diags.AddAttributeError( + path.Root("git_repository"), + "Missing required field", + fmt.Sprintf("git_repository is required for source_type %s", sourceType), + ) + } + if plan.GitBranch.IsNull() || plan.GitBranch.ValueString() == "" { + diags.AddAttributeError( + path.Root("git_branch"), + "Missing required field", + fmt.Sprintf("git_branch is required for source_type %s", sourceType), + ) + } + if plan.BuildPack.IsNull() || plan.BuildPack.ValueString() == "" { + diags.AddAttributeError( + path.Root("build_pack"), + "Missing required field", + fmt.Sprintf("build_pack is required for source_type %s", sourceType), + ) + } + if plan.PortsExposes.IsNull() || plan.PortsExposes.ValueString() == "" { + diags.AddAttributeError( + path.Root("ports_exposes"), + "Missing required field", + fmt.Sprintf("ports_exposes is required for source_type %s", sourceType), + ) + } + if sourceType == ApplicationSourceTypePrivateGithubApp { + if plan.GithubAppUuid.IsNull() || plan.GithubAppUuid.ValueString() == "" { + diags.AddAttributeError( + path.Root("github_app_uuid"), + "Missing required field", + "github_app_uuid is required for source_type private-github-app", + ) + } + } + if sourceType == ApplicationSourceTypePrivateDeployKey { + if plan.PrivateKeyUuid.IsNull() || plan.PrivateKeyUuid.ValueString() == "" { + diags.AddAttributeError( + path.Root("private_key_uuid"), + "Missing required field", + "private_key_uuid is required for source_type private-deploy-key", + ) + } + } + case ApplicationSourceTypeDockerfile: + if plan.Dockerfile.IsNull() || plan.Dockerfile.ValueString() == "" { + diags.AddAttributeError( + path.Root("dockerfile"), + "Missing required field", + "dockerfile is required for source_type dockerfile", + ) + } + case ApplicationSourceTypeDockerimage: + if plan.DockerRegistryImageName.IsNull() || plan.DockerRegistryImageName.ValueString() == "" { + diags.AddAttributeError( + path.Root("docker_registry_image_name"), + "Missing required field", + "docker_registry_image_name is required for source_type dockerimage", + ) + } + if plan.PortsExposes.IsNull() || plan.PortsExposes.ValueString() == "" { + diags.AddAttributeError( + path.Root("ports_exposes"), + "Missing required field", + "ports_exposes is required for source_type dockerimage", + ) + } + case ApplicationSourceTypeDockercompose: + if plan.DockerComposeRaw.IsNull() || plan.DockerComposeRaw.ValueString() == "" { + diags.AddAttributeError( + path.Root("docker_compose_raw"), + "Missing required field", + "docker_compose_raw is required for source_type dockercompose", + ) + } + default: + diags.AddAttributeError( + path.Root("source_type"), + "Invalid source_type", + fmt.Sprintf("source_type %s is not supported. Valid values: public, private-github-app, private-deploy-key, dockerfile, dockerimage, dockercompose", sourceType), + ) + } + + return diags +} + diff --git a/internal/service/application_resource_acceptance_test.go b/internal/service/application_resource_acceptance_test.go new file mode 100644 index 0000000..7721c60 --- /dev/null +++ b/internal/service/application_resource_acceptance_test.go @@ -0,0 +1,157 @@ +package service_test + +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/knownvalue" + "github.com/hashicorp/terraform-plugin-testing/plancheck" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" + + "terraform-provider-coolify/internal/acctest" +) + +func TestAccApplicationResource_Public(t *testing.T) { + resName := "coolify_application.test" + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.TestAccPreCheck(t) }, + ProtoV6ProviderFactories: acctest.TestAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { // Create and Read testing + Config: ` + resource "coolify_application" "test" { + source_type = "public" + project_uuid = "` + acctest.ProjectUUID + `" + server_uuid = "` + acctest.ServerUUID + `" + environment_name = "` + acctest.EnvironmentName + `" + git_repository = "https://github.com/coollabsio/coolify" + git_branch = "main" + build_pack = "nixpacks" + ports_exposes = "80" + name = "TerraformAccTest Public App" + description = "Terraform acceptance testing" + } + `, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr(resName, "source_type", "public"), + resource.TestCheckResourceAttr(resName, "name", "TerraformAccTest Public App"), + resource.TestCheckResourceAttr(resName, "description", "Terraform acceptance testing"), + resource.TestCheckResourceAttr(resName, "git_repository", "https://github.com/coollabsio/coolify"), + resource.TestCheckResourceAttr(resName, "git_branch", "main"), + resource.TestCheckResourceAttr(resName, "build_pack", "nixpacks"), + resource.TestCheckResourceAttr(resName, "ports_exposes", "80"), + // Verify dynamic values + resource.TestCheckResourceAttrSet(resName, "uuid"), + resource.TestCheckResourceAttrSet(resName, "id"), + ), + }, + { // ImportState testing + ResourceName: resName, + ImportState: true, + ImportStateVerify: true, + }, + { // Update and Read testing + Config: ` + resource "coolify_application" "test" { + source_type = "public" + project_uuid = "` + acctest.ProjectUUID + `" + server_uuid = "` + acctest.ServerUUID + `" + environment_name = "` + acctest.EnvironmentName + `" + git_repository = "https://github.com/coollabsio/coolify" + git_branch = "main" + build_pack = "nixpacks" + ports_exposes = "80" + name = "TerraformAccTest Public App Updated" + description = "Terraform acceptance testing updated" + } + `, + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction(resName, plancheck.ResourceActionUpdate), + plancheck.ExpectKnownValue(resName, tfjsonpath.New("name"), knownvalue.StringExact("TerraformAccTest Public App Updated")), + }, + PostApplyPostRefresh: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction(resName, plancheck.ResourceActionNoop), + }, + }, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet(resName, "uuid"), + resource.TestCheckResourceAttr(resName, "name", "TerraformAccTest Public App Updated"), + resource.TestCheckResourceAttr(resName, "description", "Terraform acceptance testing updated"), + ), + }, + }, + }) +} + +func TestAccApplicationResource_Dockerfile(t *testing.T) { + resName := "coolify_application.test" + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.TestAccPreCheck(t) }, + ProtoV6ProviderFactories: acctest.TestAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { // Create and Read testing + Config: ` + resource "coolify_application" "test" { + source_type = "dockerfile" + project_uuid = "` + acctest.ProjectUUID + `" + server_uuid = "` + acctest.ServerUUID + `" + environment_name = "` + acctest.EnvironmentName + `" + dockerfile = < Date: Tue, 11 Nov 2025 15:27:37 +0100 Subject: [PATCH 5/5] chore: add local provider installation script Add utility script for installing the provider locally for development and testing purposes. --- internal/service/application_envs_resource.go | 12 - internal/service/application_model.go | 244 ++++++++++-------- internal/service/application_planmodifier.go | 11 - internal/service/application_resource.go | 5 - scripts/install-local-provider.sh | 76 ++++++ 5 files changed, 210 insertions(+), 138 deletions(-) create mode 100755 scripts/install-local-provider.sh diff --git a/internal/service/application_envs_resource.go b/internal/service/application_envs_resource.go index fa9479f..33c6849 100644 --- a/internal/service/application_envs_resource.go +++ b/internal/service/application_envs_resource.go @@ -101,8 +101,6 @@ func (r *applicationEnvsResource) Create(ctx context.Context, req resource.Creat uuid := plan.Uuid.ValueString() for i, env := range plan.Env { - // Note: L'API Coolify n'accepte que key, value, is_preview, is_literal lors de la création - // Les autres champs (is_build_time, is_multiline, is_shown_once) doivent être mis à jour après createResp, err := r.client.CreateEnvByApplicationUuidWithResponse(ctx, uuid, api.CreateEnvByApplicationUuidJSONRequestBody{ IsLiteral: env.IsLiteral.ValueBoolPointer(), IsPreview: env.IsPreview.ValueBoolPointer(), @@ -126,12 +124,9 @@ func (r *applicationEnvsResource) Create(ctx context.Context, req resource.Creat return } - // Set the UUID from the response plan.Env[i].Uuid = types.StringPointerValue(createResp.JSON201.Uuid) } - // Après la création, faire un bulk update pour les champs non-supportés en création - // (is_build_time, is_multiline, is_shown_once) var bulkUpdateEnvs = []updateEnvsByApplicationUuidJSONRequestBodyItem{} for _, env := range plan.Env { bulkUpdateEnvs = append(bulkUpdateEnvs, updateEnvsByApplicationUuidJSONRequestBodyItem{ @@ -215,26 +210,22 @@ func (r *applicationEnvsResource) Update(ctx context.Context, req resource.Updat uuid := plan.Uuid.ValueString() - // Update API call logic tflog.Debug(ctx, "Updating application envs", map[string]interface{}{ "uuid": uuid, }) - // Create a map of current state envs for fast lookup stateEnvs := make(map[string]resource_application_envs.ApplicationEnvsModel) for _, env := range state.Env { key := fmt.Sprintf("%s-%t", env.Key.ValueString(), env.IsPreview.ValueBool()) stateEnvs[key] = env } - // Create a map of plan envs for fast lookup planEnvs := make(map[string]resource_application_envs.ApplicationEnvsModel) for _, env := range plan.Env { key := fmt.Sprintf("%s-%t", env.Key.ValueString(), env.IsPreview.ValueBool()) planEnvs[key] = env } - // Delete envs that are in state but not in plan for key, env := range stateEnvs { if _, exists := planEnvs[key]; !exists { _, err := r.client.DeleteEnvByApplicationUuidWithResponse(ctx, uuid, env.Uuid.ValueString()) @@ -294,7 +285,6 @@ func (r *applicationEnvsResource) Update(ctx context.Context, req resource.Updat func (r *applicationEnvsResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { var state applicationEnvsResourceModel - // Read Terraform prior state data into the model resp.Diagnostics.Append(req.State.Get(ctx, &state)...) if resp.Diagnostics.HasError() { return @@ -313,8 +303,6 @@ func (r *applicationEnvsResource) ImportState(ctx context.Context, req resource. resource.ImportStatePassthroughID(ctx, path.Root("uuid"), req, resp) } -// MARK: Helper Functions - func (r *applicationEnvsResource) filterRelevantEnvs( stateEnvs []resource_application_envs.ApplicationEnvsModel, apiEnvs []resource_application_envs.ApplicationEnvsModel, diff --git a/internal/service/application_model.go b/internal/service/application_model.go index d7688fc..e55e605 100644 --- a/internal/service/application_model.go +++ b/internal/service/application_model.go @@ -173,10 +173,12 @@ func (m ApplicationModel) Schema(ctx context.Context) schema.Schema { }, // Git-based fields "git_repository": schema.StringAttribute{ - Optional: true, - Computed: true, - Description: "Git repository URL. Required for public, private-github-app, and private-deploy-key source types.", - PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}, + Optional: true, + Computed: true, + Description: "Git repository URL. Required for public, private-github-app, and private-deploy-key source types.", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, }, "git_branch": schema.StringAttribute{ Optional: true, @@ -221,10 +223,12 @@ func (m ApplicationModel) Schema(ctx context.Context) schema.Schema { }, // Optional common fields "base_directory": schema.StringAttribute{ - Optional: true, - Computed: true, - Description: "Base directory for all commands.", - PlanModifiers: []planmodifier.String{UseStateForUnknownUnlessNullString()}, + Optional: true, + Computed: true, + Description: "Base directory for all commands.", + PlanModifiers: []planmodifier.String{ + UseStateForUnknownUnlessNullString(), + }, }, "build_command": schema.StringAttribute{Optional: true, Description: "Build command."}, "start_command": schema.StringAttribute{Optional: true, Description: "Start command."}, @@ -232,102 +236,132 @@ func (m ApplicationModel) Schema(ctx context.Context) schema.Schema { "publish_directory": schema.StringAttribute{Optional: true, Description: "Publish directory."}, "ports_mappings": schema.StringAttribute{Optional: true, Description: "Ports mappings."}, "git_commit_sha": schema.StringAttribute{ - Optional: true, - Computed: true, - Description: "Git commit SHA.", - PlanModifiers: []planmodifier.String{UseStateForUnknownUnlessNullString()}, + Optional: true, + Computed: true, + Description: "Git commit SHA.", + PlanModifiers: []planmodifier.String{ + UseStateForUnknownUnlessNullString(), + }, }, "is_static": schema.BoolAttribute{Optional: true, Description: "Flag to indicate if the application is static."}, "static_image": schema.StringAttribute{ - Optional: true, - Computed: true, - Description: "Static image (e.g., nginx:alpine).", - PlanModifiers: []planmodifier.String{UseStateForUnknownUnlessNullString()}, + Optional: true, + Computed: true, + Description: "Static image (e.g., nginx:alpine).", + PlanModifiers: []planmodifier.String{ + UseStateForUnknownUnlessNullString(), + }, }, "health_check_enabled": schema.BoolAttribute{Optional: true, Description: "Health check enabled."}, "health_check_path": schema.StringAttribute{ - Optional: true, - Computed: true, - Description: "Health check path.", - PlanModifiers: []planmodifier.String{UseStateForUnknownUnlessNullString()}, + Optional: true, + Computed: true, + Description: "Health check path.", + PlanModifiers: []planmodifier.String{ + UseStateForUnknownUnlessNullString(), + }, }, "health_check_port": schema.StringAttribute{Optional: true, Description: "Health check port."}, "health_check_host": schema.StringAttribute{Optional: true, Description: "Health check host."}, "health_check_method": schema.StringAttribute{ - Optional: true, - Computed: true, - Description: "Health check method.", - PlanModifiers: []planmodifier.String{UseStateForUnknownUnlessNullString()}, + Optional: true, + Computed: true, + Description: "Health check method.", + PlanModifiers: []planmodifier.String{ + UseStateForUnknownUnlessNullString(), + }, }, "health_check_return_code": schema.Int64Attribute{ - Optional: true, - Computed: true, - Description: "Health check return code.", - PlanModifiers: []planmodifier.Int64{UseStateForUnknownUnlessNullInt64()}, + Optional: true, + Computed: true, + Description: "Health check return code.", + PlanModifiers: []planmodifier.Int64{ + UseStateForUnknownUnlessNullInt64(), + }, }, "health_check_scheme": schema.StringAttribute{ - Optional: true, - Computed: true, - Description: "Health check scheme.", - PlanModifiers: []planmodifier.String{UseStateForUnknownUnlessNullString()}, + Optional: true, + Computed: true, + Description: "Health check scheme.", + PlanModifiers: []planmodifier.String{ + UseStateForUnknownUnlessNullString(), + }, }, "health_check_response_text": schema.StringAttribute{Optional: true, Description: "Health check response text."}, "health_check_interval": schema.Int64Attribute{ - Optional: true, - Computed: true, - Description: "Health check interval in seconds.", - PlanModifiers: []planmodifier.Int64{UseStateForUnknownUnlessNullInt64()}, + Optional: true, + Computed: true, + Description: "Health check interval in seconds.", + PlanModifiers: []planmodifier.Int64{ + UseStateForUnknownUnlessNullInt64(), + }, }, "health_check_timeout": schema.Int64Attribute{ - Optional: true, - Computed: true, - Description: "Health check timeout in seconds.", - PlanModifiers: []planmodifier.Int64{UseStateForUnknownUnlessNullInt64()}, + Optional: true, + Computed: true, + Description: "Health check timeout in seconds.", + PlanModifiers: []planmodifier.Int64{ + UseStateForUnknownUnlessNullInt64(), + }, }, "health_check_retries": schema.Int64Attribute{ - Optional: true, - Computed: true, - Description: "Health check retries count.", - PlanModifiers: []planmodifier.Int64{UseStateForUnknownUnlessNullInt64()}, + Optional: true, + Computed: true, + Description: "Health check retries count.", + PlanModifiers: []planmodifier.Int64{ + UseStateForUnknownUnlessNullInt64(), + }, }, "health_check_start_period": schema.Int64Attribute{ - Optional: true, - Computed: true, - Description: "Health check start period in seconds.", - PlanModifiers: []planmodifier.Int64{UseStateForUnknownUnlessNullInt64()}, + Optional: true, + Computed: true, + Description: "Health check start period in seconds.", + PlanModifiers: []planmodifier.Int64{ + UseStateForUnknownUnlessNullInt64(), + }, }, "limits_memory": schema.StringAttribute{Optional: true, Description: "Memory limit."}, "limits_memory_swap": schema.StringAttribute{ - Optional: true, - Computed: true, - Description: "Memory swap limit.", - PlanModifiers: []planmodifier.String{UseStateForUnknownUnlessNullString()}, + Optional: true, + Computed: true, + Description: "Memory swap limit.", + PlanModifiers: []planmodifier.String{ + UseStateForUnknownUnlessNullString(), + }, }, "limits_memory_swappiness": schema.Int64Attribute{ - Optional: true, - Computed: true, - Description: "Memory swappiness.", - PlanModifiers: []planmodifier.Int64{UseStateForUnknownUnlessNullInt64()}, + Optional: true, + Computed: true, + Description: "Memory swappiness.", + PlanModifiers: []planmodifier.Int64{ + UseStateForUnknownUnlessNullInt64(), + }, }, "limits_memory_reservation": schema.StringAttribute{ - Optional: true, - Computed: true, - Description: "Memory reservation.", - PlanModifiers: []planmodifier.String{UseStateForUnknownUnlessNullString()}, + Optional: true, + Computed: true, + Description: "Memory reservation.", + PlanModifiers: []planmodifier.String{ + UseStateForUnknownUnlessNullString(), + }, }, "limits_cpus": schema.StringAttribute{Optional: true, Description: "CPU limit."}, "limits_cpuset": schema.StringAttribute{Optional: true, Description: "CPU set."}, "limits_cpu_shares": schema.Int64Attribute{ - Optional: true, - Computed: true, - Description: "CPU shares.", - PlanModifiers: []planmodifier.Int64{UseStateForUnknownUnlessNullInt64()}, + Optional: true, + Computed: true, + Description: "CPU shares.", + PlanModifiers: []planmodifier.Int64{ + UseStateForUnknownUnlessNullInt64(), + }, }, "custom_labels": schema.StringAttribute{ - Optional: true, - Computed: true, - Description: "Custom labels.", - PlanModifiers: []planmodifier.String{UseStateForUnknownUnlessNullString()}, + Optional: true, + Computed: true, + Description: "Custom labels.", + PlanModifiers: []planmodifier.String{ + UseStateForUnknownUnlessNullString(), + }, }, "custom_docker_run_options": schema.StringAttribute{Optional: true, Description: "Custom docker run options."}, "post_deployment_command": schema.StringAttribute{Optional: true, Description: "Post deployment command."}, @@ -341,18 +375,22 @@ func (m ApplicationModel) Schema(ctx context.Context) schema.Schema { "redirect": schema.StringAttribute{Optional: true, Description: "How to set redirect with Traefik / Caddy. www<->non-www."}, "use_build_server": schema.BoolAttribute{Optional: true, Description: "Use build server."}, "is_http_basic_auth_enabled": schema.BoolAttribute{ - Optional: true, - Computed: true, - Description: "HTTP Basic Authentication enabled.", - PlanModifiers: []planmodifier.Bool{UseStateForUnknownUnlessNullBool()}, + Optional: true, + Computed: true, + Description: "HTTP Basic Authentication enabled.", + PlanModifiers: []planmodifier.Bool{ + UseStateForUnknownUnlessNullBool(), + }, }, "http_basic_auth_username": schema.StringAttribute{Optional: true, Description: "Username for HTTP Basic Authentication"}, "http_basic_auth_password": schema.StringAttribute{Optional: true, Sensitive: true, Description: "Password for HTTP Basic Authentication"}, "docker_compose_location": schema.StringAttribute{ - Optional: true, - Computed: true, - Description: "Docker Compose location.", - PlanModifiers: []planmodifier.String{UseStateForUnknownUnlessNullString()}, + Optional: true, + Computed: true, + Description: "Docker Compose location.", + PlanModifiers: []planmodifier.String{ + UseStateForUnknownUnlessNullString(), + }, }, "docker_compose_custom_start_command": schema.StringAttribute{Optional: true, Description: "Docker Compose custom start command."}, "docker_compose_custom_build_command": schema.StringAttribute{Optional: true, Description: "Docker Compose custom build command."}, @@ -361,57 +399,44 @@ func (m ApplicationModel) Schema(ctx context.Context) schema.Schema { } } -// preserveGitRepository preserves the state git_repository if it exists and is different from API -// This handles the case where API returns "user/repo" but user configured "https://github.com/user/repo" func preserveGitRepository(stateVal types.String, apiVal types.String) types.String { - // If state is null/unknown, return API value if stateVal.IsNull() || stateVal.IsUnknown() { return apiVal } - // If state has a full URL and API has short format, preserve state stateStr := stateVal.ValueString() apiStr := apiVal.ValueString() if stateStr != "" && apiStr != "" && stateStr != apiStr { - // Check if state is a full URL and API is short format if (strings.HasPrefix(stateStr, "http://") || strings.HasPrefix(stateStr, "https://") || strings.HasPrefix(stateStr, "git@")) && !strings.Contains(apiStr, "://") && !strings.HasPrefix(apiStr, "git@") { - // State has full URL, API has short format - preserve state return stateVal } - // If both are different but neither is clearly a URL format issue, preserve state - // This handles edge cases where formats differ return stateVal } - // If values match or state is empty, return API value return apiVal } func (m ApplicationModel) FromAPI(app *api.Application, state ApplicationModel) ApplicationModel { - // Simplification: Return API values as-is - // For fields not returned by API or that should be preserved from state, we keep the state value - - // Special handling for git_repository: API may return shortened format apiGitRepo := flatten.String(app.GitRepository) preservedGitRepo := preserveGitRepository(state.GitRepository, apiGitRepo) return ApplicationModel{ Uuid: flatten.String(app.Uuid), - SourceType: state.SourceType, // Not returned by API - ProjectUuid: state.ProjectUuid, // Not returned by API - ServerUuid: state.ServerUuid, // Not returned by API - EnvironmentName: state.EnvironmentName, // Not returned by API - EnvironmentUuid: state.EnvironmentUuid, // Not returned by API - DestinationUuid: state.DestinationUuid, // Not returned by API + SourceType: state.SourceType, + ProjectUuid: state.ProjectUuid, + ServerUuid: state.ServerUuid, + EnvironmentName: state.EnvironmentName, + EnvironmentUuid: state.EnvironmentUuid, + DestinationUuid: state.DestinationUuid, Name: flatten.String(app.Name), Description: flatten.String(app.Description), - Domains: flatten.String(app.Fqdn), // API returns "fqdn", map to "domains" - InstantDeploy: state.InstantDeploy, // Not returned by API - GitRepository: preservedGitRepo, // Special handling for URL format + Domains: flatten.String(app.Fqdn), + InstantDeploy: state.InstantDeploy, + GitRepository: preservedGitRepo, GitBranch: flatten.String(app.GitBranch), BuildPack: flatten.String((*string)(app.BuildPack)), PortsExposes: flatten.String(app.PortsExposes), - GithubAppUuid: state.GithubAppUuid, // Not returned by API - PrivateKeyUuid: state.PrivateKeyUuid, // Not returned by API + GithubAppUuid: state.GithubAppUuid, + PrivateKeyUuid: state.PrivateKeyUuid, Dockerfile: flatten.String(app.Dockerfile), DockerRegistryImageName: flatten.String(app.DockerRegistryImageName), DockerRegistryImageTag: flatten.String(app.DockerRegistryImageTag), @@ -423,7 +448,7 @@ func (m ApplicationModel) FromAPI(app *api.Application, state ApplicationModel) PublishDirectory: flatten.String(app.PublishDirectory), PortsMappings: flatten.String(app.PortsMappings), GitCommitSha: flatten.String(app.GitCommitSha), - IsStatic: state.IsStatic, // Not returned by API + IsStatic: state.IsStatic, StaticImage: flatten.String(app.StaticImage), HealthCheckEnabled: flatten.Bool(app.HealthCheckEnabled), HealthCheckPath: flatten.String(app.HealthCheckPath), @@ -450,15 +475,15 @@ func (m ApplicationModel) FromAPI(app *api.Application, state ApplicationModel) PostDeploymentCommandContainer: flatten.String(app.PostDeploymentCommandContainer), PreDeploymentCommand: flatten.String(app.PreDeploymentCommand), PreDeploymentCommandContainer: flatten.String(app.PreDeploymentCommandContainer), - ManualWebhookSecretGithub: state.ManualWebhookSecretGithub, // Sensitive, not returned by API - ManualWebhookSecretGitlab: state.ManualWebhookSecretGitlab, // Sensitive, not returned by API - ManualWebhookSecretBitbucket: state.ManualWebhookSecretBitbucket, // Sensitive, not returned by API - ManualWebhookSecretGitea: state.ManualWebhookSecretGitea, // Sensitive, not returned by API + ManualWebhookSecretGithub: state.ManualWebhookSecretGithub, + ManualWebhookSecretGitlab: state.ManualWebhookSecretGitlab, + ManualWebhookSecretBitbucket: state.ManualWebhookSecretBitbucket, + ManualWebhookSecretGitea: state.ManualWebhookSecretGitea, Redirect: flatten.String((*string)(app.Redirect)), - UseBuildServer: state.UseBuildServer, // Not returned by API + UseBuildServer: state.UseBuildServer, IsHttpBasicAuthEnabled: flatten.Bool(app.IsHttpBasicAuthEnabled), HttpBasicAuthUsername: flatten.String(app.HttpBasicAuthUsername), - HttpBasicAuthPassword: state.HttpBasicAuthPassword, // Sensitive, not returned by API + HttpBasicAuthPassword: state.HttpBasicAuthPassword, DockerComposeLocation: flatten.String(app.DockerComposeLocation), DockerComposeCustomStartCommand: flatten.String(app.DockerComposeCustomStartCommand), DockerComposeCustomBuildCommand: flatten.String(app.DockerComposeCustomBuildCommand), @@ -488,14 +513,13 @@ func (m ApplicationModel) ToAPICreate() (interface{}, error) { } } -// validateRedirect validates and converts a redirect string to the appropriate enum type func validateRedirect[T ~string](redirect *string) *T { if redirect == nil || *redirect == "" { return nil } validRedirects := map[string]bool{"both": true, "non-www": true, "www": true} if !validRedirects[*redirect] { - return nil // Invalid redirect value, return nil to omit from request + return nil } enumVal := T(*redirect) return &enumVal @@ -933,7 +957,7 @@ func (m ApplicationModel) ToAPIUpdate() api.UpdateApplicationByUuidJSONRequestBo DockerComposeRaw: expand.String(m.DockerComposeRaw), DockerComposeCustomStartCommand: expand.String(m.DockerComposeCustomStartCommand), DockerComposeCustomBuildCommand: expand.String(m.DockerComposeCustomBuildCommand), - DockerComposeDomains: nil, // TODO: handle array + DockerComposeDomains: nil, WatchPaths: expand.String(m.WatchPaths), UseBuildServer: expand.Bool(m.UseBuildServer), DockerRegistryImageName: expand.String(m.DockerRegistryImageName), diff --git a/internal/service/application_planmodifier.go b/internal/service/application_planmodifier.go index a0dab36..8e82502 100644 --- a/internal/service/application_planmodifier.go +++ b/internal/service/application_planmodifier.go @@ -7,9 +7,6 @@ import ( "github.com/hashicorp/terraform-plugin-framework/types" ) -// useStateForUnknownUnlessNull is a plan modifier for Optional+Computed fields -// For CREATE: if config is null, mark as Unknown (to accept API defaults) -// For UPDATE: if config is null and state has value, keep state value type useStateForUnknownUnlessNull struct{} func (m useStateForUnknownUnlessNull) Description(ctx context.Context) string { @@ -21,25 +18,19 @@ func (m useStateForUnknownUnlessNull) MarkdownDescription(ctx context.Context) s } func (m useStateForUnknownUnlessNull) PlanModifyString(ctx context.Context, req planmodifier.StringRequest, resp *planmodifier.StringResponse) { - // If config has explicit value, use it if !req.ConfigValue.IsNull() { return } - // If plan value is not null (user set it), keep it if !req.PlanValue.IsNull() { return } - // Config is null (not set by user) - - // During CREATE (no prior state): mark as Unknown so API can provide default if req.StateValue.IsNull() || req.StateValue.IsUnknown() { resp.PlanValue = types.StringUnknown() return } - // During UPDATE (has prior state): keep the prior state value resp.PlanValue = req.StateValue } @@ -47,7 +38,6 @@ func UseStateForUnknownUnlessNullString() planmodifier.String { return useStateForUnknownUnlessNull{} } -// Same for Int64 type useStateForUnknownUnlessNullInt64 struct{} func (m useStateForUnknownUnlessNullInt64) Description(ctx context.Context) string { @@ -79,7 +69,6 @@ func UseStateForUnknownUnlessNullInt64() planmodifier.Int64 { return useStateForUnknownUnlessNullInt64{} } -// Same for Bool type useStateForUnknownUnlessNullBool struct{} func (m useStateForUnknownUnlessNullBool) Description(ctx context.Context) string { diff --git a/internal/service/application_resource.go b/internal/service/application_resource.go index 1d5d192..167c833 100644 --- a/internal/service/application_resource.go +++ b/internal/service/application_resource.go @@ -36,7 +36,6 @@ func (r *applicationResource) Schema(ctx context.Context, req resource.SchemaReq resp.Schema = ApplicationModel{}.Schema(ctx) resp.Schema.Description = "Create, read, update, and delete a Coolify application resource." - // Mark sensitive attributes sensitiveAttrs := []string{ "manual_webhook_secret_bitbucket", "manual_webhook_secret_gitea", @@ -63,7 +62,6 @@ func (r *applicationResource) Create(ctx context.Context, req resource.CreateReq return } - // Validate required fields based on source_type if diags := r.validateCreatePlan(ctx, plan); diags.HasError() { resp.Diagnostics.Append(diags...) return @@ -355,9 +353,6 @@ func (r *applicationResource) ImportState(ctx context.Context, req resource.Impo resource.ImportStatePassthroughID(ctx, path.Root("uuid"), req, resp) } - -// MARK: Helper Functions - func (r *applicationResource) ReadFromAPI( ctx context.Context, diags *diag.Diagnostics, diff --git a/scripts/install-local-provider.sh b/scripts/install-local-provider.sh new file mode 100755 index 0000000..1d4e7d1 --- /dev/null +++ b/scripts/install-local-provider.sh @@ -0,0 +1,76 @@ +#!/bin/bash + +set -e + +GREEN='\033[0;32m' +BLUE='\033[0;34m' +YELLOW='\033[1;33m' +NC='\033[0m' + +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +PROVIDER_DIR="$( cd "$SCRIPT_DIR/.." && pwd )" + +if ! command -v go &> /dev/null; then + echo "❌ Go is not installed. Please install Go first." + exit 1 +fi + +OS=$(go env GOOS) +ARCH=$(go env GOARCH) +PLUGIN_DIR="$HOME/.terraform.d/plugins/registry.terraform.io/sierrajc/coolify/dev/${OS}_${ARCH}" + +echo -e "${BLUE}🔨 Building Terraform Coolify provider...${NC}" +echo " Directory: $PROVIDER_DIR" + +if [ -f "$PROVIDER_DIR/tools/tfplugingen-openapi.yml" ]; then + echo "📝 Generating code from OpenAPI..." + (cd "$PROVIDER_DIR" && make generate) || echo "⚠️ Generation failed, continuing..." +fi + +echo "📦 Installing provider to $PLUGIN_DIR..." +mkdir -p "$PLUGIN_DIR" + +BINARY_NAME="terraform-provider-coolify_dev_${OS}_${ARCH}" +BINARY_PATH="$PLUGIN_DIR/$BINARY_NAME" + +echo "🔨 Building provider..." +(cd "$PROVIDER_DIR" && go build -o "$BINARY_PATH" .) + +if [ ! -f "$BINARY_PATH" ]; then + echo "❌ Build failed" + exit 1 +fi + +chmod +x "$PLUGIN_DIR/$BINARY_NAME" + +echo "" +echo -e "${GREEN}✅ Provider installed successfully!${NC}" +echo "" +echo -e "${BLUE}Location:${NC} $PLUGIN_DIR/$BINARY_NAME" +echo "" +echo -e "${YELLOW}📋 Next steps:${NC}" +echo "1. In your Terraform project, create a file with:" +echo "" +echo " terraform {" +echo " required_providers {" +echo " coolify = {" +echo " source = \"registry.terraform.io/sierrajc/coolify\"" +echo " version = \"dev\"" +echo " }" +echo " }" +echo " }" +echo "" +echo "2. Configure the provider:" +echo "" +echo " provider \"coolify\" {" +echo " # token will be read from COOLIFY_TOKEN" +echo " }" +echo "" +echo "3. Initialize Terraform:" +echo "" +echo " terraform init" +echo "" +echo -e "${YELLOW}💡 Tip:${NC} Export your API token:" +echo " export COOLIFY_TOKEN=\"your-api-token\"" +echo " export COOLIFY_ENDPOINT=\"https://app.coolify.io/api/v1\" # Optional" +