From 0daeea349061deb84af23e7b714537e3357ac0f0 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 14 Sep 2026 11:04:04 +0200 Subject: [PATCH] feat(application): add command to update preview domains Add `coolify app previews update` so operators can replace regular or Docker Compose preview domains via PATCH, including optional internal ports in URLs and `--force-domain-override`. --- README.md | 2 + cmd/application/application.go | 1 + cmd/application/application_test.go | 18 ++++++ cmd/application/previews/update.go | 79 +++++++++++++++++++++++++ cmd/application/previews/update_test.go | 48 +++++++++++++++ internal/models/application.go | 14 +++++ internal/models/application_test.go | 27 +++++++++ internal/service/application.go | 11 ++++ internal/service/application_test.go | 29 +++++++++ llms-full.txt | 18 +++++- 10 files changed, 246 insertions(+), 1 deletion(-) create mode 100644 cmd/application/previews/update.go create mode 100644 cmd/application/previews/update_test.go diff --git a/README.md b/README.md index 039fe98..d52c71a 100644 --- a/README.md +++ b/README.md @@ -211,6 +211,8 @@ Commands can use `server` or `servers` interchangeably. - `--environment-name ` - Environment name (or use `--environment-uuid `) #### Application Previews +- `coolify app previews update --domains ` - Update regular preview domains +- `coolify app previews update --compose-domain ` - Update Docker Compose preview domains - `coolify app previews delete ` - Delete a preview deployment - `--force` - Skip confirmation prompt diff --git a/cmd/application/application.go b/cmd/application/application.go index a09e926..057bc89 100644 --- a/cmd/application/application.go +++ b/cmd/application/application.go @@ -79,6 +79,7 @@ func NewAppCommand() *cobra.Command { Long: `Manage preview deployments created from pull requests. Requires the application UUID.`, } previewsCmd.AddCommand(previews.NewDeletePreviewCommand()) + previewsCmd.AddCommand(previews.NewUpdatePreviewCommand()) cmd.AddCommand(previewsCmd) return cmd diff --git a/cmd/application/application_test.go b/cmd/application/application_test.go index 051d680..d3ecb0b 100644 --- a/cmd/application/application_test.go +++ b/cmd/application/application_test.go @@ -31,6 +31,24 @@ func TestNewApplicationCommands_ExposeParityFlags(t *testing.T) { assert.NotNil(t, NewLogsCommand().Flags().Lookup("service")) assert.NotNil(t, NewMoveCommand().Flags().Lookup("environment-uuid")) assert.NotNil(t, NewUpdateCommand().Flags().Lookup("compose-domain")) + previews := NewAppCommand().Commands() + var previewsCommand *cobra.Command + for _, command := range previews { + if command.Name() == "previews" { + previewsCommand = command + } + } + require.NotNil(t, previewsCommand) + var updatePreviewCommand *cobra.Command + for _, command := range previewsCommand.Commands() { + if command.Name() == "update" { + updatePreviewCommand = command + } + } + require.NotNil(t, updatePreviewCommand) + assert.NotNil(t, updatePreviewCommand.Flags().Lookup("domains")) + assert.NotNil(t, updatePreviewCommand.Flags().Lookup("compose-domain")) + assert.NotNil(t, updatePreviewCommand.Flags().Lookup("force-domain-override")) for _, flag := range []string{ "disable-build-cache", "docker-images-to-keep", "include-source-commit-in-build", diff --git a/cmd/application/previews/update.go b/cmd/application/previews/update.go new file mode 100644 index 0000000..b218f8c --- /dev/null +++ b/cmd/application/previews/update.go @@ -0,0 +1,79 @@ +package previews + +import ( + "fmt" + "strconv" + + "github.com/spf13/cobra" + + "github.com/coollabsio/coolify-cli/cmd/application/appflags" + "github.com/coollabsio/coolify-cli/internal/cli" + "github.com/coollabsio/coolify-cli/internal/models" + "github.com/coollabsio/coolify-cli/internal/output" + "github.com/coollabsio/coolify-cli/internal/service" +) + +func NewUpdatePreviewCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "update ", + Short: "Update preview deployment domains", + Long: `Replace the domains for an application preview deployment. + +Use --domains for regular applications. Use repeatable --compose-domain flags for Docker Compose applications. +Include an internal port in a URL when needed; Coolify stores the public domain without the port. + +Examples: + coolify app previews update 42 --domains "https://preview.example.com:3000" + coolify app previews update 42 --compose-domain "web=https://web.example.com:8080" --compose-domain "api=https://api.example.com:3000"`, + Args: cli.ExactArgs(2, " "), + RunE: func(cmd *cobra.Command, args []string) error { + prID := args[1] + parsedPRID, err := strconv.Atoi(prID) + if err != nil || parsedPRID <= 0 { + return fmt.Errorf("invalid pr_id: must be a positive integer") + } + + req := models.ApplicationPreviewUpdateRequest{} + if cmd.Flags().Changed("domains") { + domains, _ := cmd.Flags().GetString("domains") + req.Domains = &domains + } + composeDomainsChanged, err := appflags.ApplyComposeDomainsFlag(cmd, &req.DockerComposeDomains) + if err != nil { + return err + } + if req.Domains == nil && !composeDomainsChanged { + return fmt.Errorf("one of --domains or --compose-domain is required") + } + req.ForceDomainOverride, _ = cmd.Flags().GetBool("force-domain-override") + + client, err := cli.GetAPIClient(cmd) + if err != nil { + return fmt.Errorf("failed to get API client: %w", err) + } + if err := cli.CheckMinimumVersion(cmd.Context(), client, "4.0.0-beta.474"); err != nil { + return err + } + + preview, err := service.NewApplicationService(client).UpdatePreview(cmd.Context(), args[0], prID, req) + if err != nil { + return fmt.Errorf("failed to update preview deployment: %w", err) + } + + format, _ := cmd.Flags().GetString("format") + formatter, err := output.NewFormatter(format, output.Options{}) + if err != nil { + return err + } + + return formatter.Format(preview) + }, + } + + cmd.Flags().String("domains", "", "Domains for a regular application preview (comma-separated)") + appflags.BindComposeDomainsFlag(cmd) + cmd.Flags().Bool("force-domain-override", false, "Allow domains already used by another resource") + cmd.MarkFlagsMutuallyExclusive("domains", "compose-domain") + + return cmd +} diff --git a/cmd/application/previews/update_test.go b/cmd/application/previews/update_test.go new file mode 100644 index 0000000..5b5c97e --- /dev/null +++ b/cmd/application/previews/update_test.go @@ -0,0 +1,48 @@ +package previews + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestUpdatePreviewCommand_RequiresDomains(t *testing.T) { + cmd := NewUpdatePreviewCommand() + cmd.SilenceErrors = true + cmd.SilenceUsage = true + cmd.SetArgs([]string{"app-uuid", "42"}) + + err := cmd.Execute() + + require.Error(t, err) + assert.Contains(t, err.Error(), "one of --domains or --compose-domain is required") +} + +func TestUpdatePreviewCommand_RejectsInvalidPullRequestID(t *testing.T) { + cmd := NewUpdatePreviewCommand() + cmd.SilenceErrors = true + cmd.SilenceUsage = true + cmd.SetArgs([]string{"app-uuid", "1.9", "--domains", "https://preview.example.com"}) + + err := cmd.Execute() + + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid pr_id") +} + +func TestUpdatePreviewCommand_RejectsDomainsWithComposeDomain(t *testing.T) { + cmd := NewUpdatePreviewCommand() + cmd.SilenceErrors = true + cmd.SilenceUsage = true + cmd.SetArgs([]string{ + "app-uuid", "42", + "--domains", "https://preview.example.com", + "--compose-domain", "web=https://web.example.com", + }) + + err := cmd.Execute() + + require.Error(t, err) + assert.Contains(t, err.Error(), "group [domains compose-domain]") +} diff --git a/internal/models/application.go b/internal/models/application.go index b6959fd..31232e8 100644 --- a/internal/models/application.go +++ b/internal/models/application.go @@ -248,6 +248,20 @@ type DockerComposeDomain struct { Domain string `json:"domain"` } +type ApplicationPreviewUpdateRequest struct { + Domains *string `json:"domains,omitempty"` + DockerComposeDomains []DockerComposeDomain `json:"docker_compose_domains,omitempty"` + ForceDomainOverride bool `json:"force_domain_override,omitempty"` +} + +type ApplicationPreviewUpdateResponse struct { + UUID string `json:"uuid"` + PullRequestID int `json:"pull_request_id"` + Domains *string `json:"domains"` + DockerComposeDomains []DockerComposeDomain `json:"docker_compose_domains"` + DomainPortOverrides map[string]int `json:"domain_port_overrides"` +} + // ApplicationLifecycleResponse represents the response from lifecycle operations type ApplicationLifecycleResponse struct { Message string `json:"message"` diff --git a/internal/models/application_test.go b/internal/models/application_test.go index ade4611..9db0d4f 100644 --- a/internal/models/application_test.go +++ b/internal/models/application_test.go @@ -123,6 +123,33 @@ func TestDockerComposeDomains_OmittedWhenUnset(t *testing.T) { assert.NotContains(t, string(data), "docker_compose_domains") } +func TestApplicationPreviewUpdateRequest_MarshalComposeDomains(t *testing.T) { + req := ApplicationPreviewUpdateRequest{ + DockerComposeDomains: []DockerComposeDomain{{Name: "web", Domain: "https://web.example.com:8080"}}, + } + + data, err := json.Marshal(req) + require.NoError(t, err) + + var body map[string]any + require.NoError(t, json.Unmarshal(data, &body)) + assert.NotContains(t, body, "domains") + assert.Equal(t, []any{map[string]any{ + "name": "web", "domain": "https://web.example.com:8080", + }}, body["docker_compose_domains"]) +} + +func TestApplicationUpdateRequest_MarshalsExplicitlyEmptyPortsExposes(t *testing.T) { + empty := "" + data, err := json.Marshal(ApplicationUpdateRequest{PortsExposes: &empty}) + require.NoError(t, err) + + var body map[string]any + require.NoError(t, json.Unmarshal(data, &body)) + assert.Contains(t, body, "ports_exposes") + assert.Equal(t, "", body["ports_exposes"]) +} + func TestEnvironmentVariableRequests_MarshalBuildtimeField(t *testing.T) { buildTime := false runtime := true diff --git a/internal/service/application.go b/internal/service/application.go index 2795674..7247205 100644 --- a/internal/service/application.go +++ b/internal/service/application.go @@ -70,6 +70,17 @@ func (s *ApplicationService) DeletePreview(ctx context.Context, appUUID, prID st return nil } +// UpdatePreview replaces the domains for an application preview deployment. +func (s *ApplicationService) UpdatePreview(ctx context.Context, appUUID, prID string, req models.ApplicationPreviewUpdateRequest) (*models.ApplicationPreviewUpdateResponse, error) { + var preview models.ApplicationPreviewUpdateResponse + err := s.client.Patch(ctx, fmt.Sprintf("applications/%s/previews/%s", appUUID, prID), req, &preview) + if err != nil { + return nil, fmt.Errorf("failed to update preview %s for application %s: %w", prID, appUUID, err) + } + + return &preview, nil +} + // Start starts an application (initiates deployment) func (s *ApplicationService) Start(ctx context.Context, uuid string, force bool, instantDeploy bool) (*models.ApplicationLifecycleResponse, error) { var resp models.ApplicationLifecycleResponse diff --git a/internal/service/application_test.go b/internal/service/application_test.go index 0ad7279..9fedf4b 100644 --- a/internal/service/application_test.go +++ b/internal/service/application_test.go @@ -420,6 +420,35 @@ func TestApplicationService_DeletePreview_Success(t *testing.T) { require.NoError(t, err) } +func TestApplicationService_UpdatePreview_Success(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v1/applications/app-uuid-123/previews/42", r.URL.Path) + assert.Equal(t, "PATCH", r.Method) + + var body models.ApplicationPreviewUpdateRequest + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + require.NotNil(t, body.Domains) + assert.Equal(t, "https://preview.example.com:3000", *body.Domains) + assert.True(t, body.ForceDomainOverride) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"uuid":"preview-uuid","pull_request_id":42,"domains":"https://preview.example.com","docker_compose_domains":null,"domain_port_overrides":{"https://preview.example.com":3000}}`)) + })) + defer server.Close() + + client := api.NewClient(server.URL, "test-token") + svc := NewApplicationService(client) + domains := "https://preview.example.com:3000" + result, err := svc.UpdatePreview(context.Background(), "app-uuid-123", "42", models.ApplicationPreviewUpdateRequest{ + Domains: &domains, + ForceDomainOverride: true, + }) + + require.NoError(t, err) + assert.Equal(t, "preview-uuid", result.UUID) + assert.Equal(t, 3000, result.DomainPortOverrides["https://preview.example.com"]) +} + func TestApplicationService_DeletePreview_NotFound(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) diff --git a/llms-full.txt b/llms-full.txt index 0d34f48..197a3e6 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -1585,6 +1585,23 @@ Parameters: required: false default: false +Command: coolify app previews update +Description: Replace regular or Docker Compose preview domains, optionally assigning an internal port in each URL. +Parameters: + - name: --domains + type: string + description: Comma-separated domains for a regular application preview + required: false + - name: --compose-domain + type: stringArray + description: Docker Compose domain in service=url format (repeatable) + required: false + - name: --force-domain-override + type: boolean + description: Allow domains already used by another resource + required: false + default: false + Command: coolify app restart Description: Restart a running application. Parameters: (None) @@ -5033,4 +5050,3 @@ Parameters: (None) Command: coolify version Description: Current Coolify CLI version Parameters: (None) -