From 9b653b7fb93a8d93a01bdb625d84f5e37bed8dd7 Mon Sep 17 00:00:00 2001 From: Danielle Date: Mon, 8 Jun 2026 10:29:08 -0300 Subject: [PATCH 1/2] feat: add `detached deploy` command for detached environments (sc-20335) Add `shipyard detached deploy `, mirroring the external API's detached-environment create endpoint (POST /api/v1/application-build//detached-app-build). Flags map 1:1 to the API body: - --name -> display_name - --branch repo=branch -> project_branch_overrides (repeatable) - --build-on-commit -> build_on_commit (global string) - --build-on-commit-for -> build_on_commit (per-repo object, repeatable) Validates build_on_commit values (always|inherit|never) and the mutual exclusion of the global vs per-repo forms client-side; --org flows into the ?org= query param. Includes a mock-server handler and TestDetachedDeploy cases (success, overrides, 404, unknown org, and both validation errors), plus README docs. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 19 ++++++ commands/env/detached.go | 133 +++++++++++++++++++++++++++++++++++++++ commands/root.go | 1 + tests/cli_test.go | 76 ++++++++++++++++++++++ tests/server/handlers.go | 36 +++++++++++ tests/server/server.go | 1 + 6 files changed, 266 insertions(+) create mode 100644 commands/env/detached.go diff --git a/README.md b/README.md index 800f1ab..fb27455 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,25 @@ shipyard rebuild environment {environment_uuid} shipyard revive environment {environment_uuid} ``` +### Deploy a detached environment + +Create a new, independent ("detached") environment by cloning an existing application build. +Requires detached environments to be enabled for your org. + +```bash +shipyard detached deploy {application_build_uuid} --name my-detached-env +``` + +Override branches per-repo and control whether the detached environment rebuilds on new commits: + +```bash +# Override the branch for a repo, and never rebuild on new commits +shipyard detached deploy {application_build_uuid} --name my-detached-env --branch web=feature-x --build-on-commit never + +# Per-repo build-on-commit settings (always | inherit | never) +shipyard detached deploy {application_build_uuid} --build-on-commit-for web=always --build-on-commit-for api=never +``` + ### Get all services and exposed ports for an environment ```bash diff --git a/commands/env/detached.go b/commands/env/detached.go new file mode 100644 index 0000000..6b06964 --- /dev/null +++ b/commands/env/detached.go @@ -0,0 +1,133 @@ +package env + +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/shipyard/shipyard-cli/constants" + "github.com/shipyard/shipyard-cli/pkg/client" + "github.com/shipyard/shipyard-cli/pkg/display" + "github.com/shipyard/shipyard-cli/pkg/requests/uri" + "github.com/spf13/cobra" +) + +// validBuildOnCommit are the accepted values for the --build-on-commit flag and the +// values of the --build-on-commit-for map, mirroring the external API. +var validBuildOnCommit = map[string]bool{"always": true, "inherit": true, "never": true} + +// detachedDeployResponse models the JSON returned by the external +// POST /api/v1/application-build//detached-app-build endpoint. +type detachedDeployResponse struct { + Data struct { + Message string `json:"message"` + ApplicationUUID string `json:"application_uuid"` + ApplicationBuildUUID string `json:"application_build_uuid"` + DisplayName string `json:"display_name"` + } `json:"data"` +} + +func NewDetachedCmd(c client.Client) *cobra.Command { + cmd := &cobra.Command{ + Use: "detached", + GroupID: constants.GroupEnvironments, + Short: "Manage detached environments", + Long: `Create and manage detached environments, which are independent clones of an existing environment.`, + } + + cmd.AddCommand(newDetachedDeployCmd(c)) + + return cmd +} + +func newDetachedDeployCmd(c client.Client) *cobra.Command { + cmd := &cobra.Command{ + Use: "deploy [application build ID]", + Short: "Deploy a detached environment from a source application build", + Long: `Create a new, independent ("detached") environment by cloning an existing application build. + +The new environment copies the source environment's configuration (secrets, build args, +env vars) and then runs on its own, with no link back to the source.`, + Example: ` # Deploy a detached environment from application build 1a2b3c + shipyard detached deploy 1a2b3c --name pr-preview + + # Override branches for specific repos and never rebuild on new commits + shipyard detached deploy 1a2b3c --name pr-preview --branch web=feature-x --build-on-commit never + + # Per-repo build-on-commit settings + shipyard detached deploy 1a2b3c --build-on-commit-for web=always --build-on-commit-for api=never`, + Args: cobra.ExactArgs(1), + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + return deployDetached(cmd, c, args[0]) + }, + } + + cmd.Flags().String("name", "", "Display name for the new detached environment") + cmd.Flags().StringToString("branch", nil, "Per-repo branch override, as repo=branch (repeatable)") + cmd.Flags().String("build-on-commit", "", "Rebuild on new commits for all repos: always, inherit, or never (default never)") + cmd.Flags().StringToString("build-on-commit-for", nil, "Per-repo build-on-commit setting, as repo=setting (repeatable)") + + return cmd +} + +func deployDetached(cmd *cobra.Command, c client.Client, appBuildID string) error { + name, _ := cmd.Flags().GetString("name") + branches, _ := cmd.Flags().GetStringToString("branch") + buildOnCommit, _ := cmd.Flags().GetString("build-on-commit") + buildOnCommitFor, _ := cmd.Flags().GetStringToString("build-on-commit-for") + + // build_on_commit can be a global string or a per-repo object, but not both. + if buildOnCommit != "" && len(buildOnCommitFor) > 0 { + return fmt.Errorf("--build-on-commit and --build-on-commit-for are mutually exclusive") + } + if buildOnCommit != "" && !validBuildOnCommit[buildOnCommit] { + return fmt.Errorf("invalid --build-on-commit %q: must be always, inherit, or never", buildOnCommit) + } + for repo, setting := range buildOnCommitFor { + if !validBuildOnCommit[setting] { + return fmt.Errorf("invalid --build-on-commit-for %s=%s: setting must be always, inherit, or never", repo, setting) + } + } + + // Assemble the request body, omitting empty fields so the API applies its defaults. + payload := make(map[string]any) + if name != "" { + payload["display_name"] = name + } + if len(branches) > 0 { + payload["project_branch_overrides"] = branches + } + switch { + case len(buildOnCommitFor) > 0: + payload["build_on_commit"] = buildOnCommitFor + case buildOnCommit != "": + payload["build_on_commit"] = buildOnCommit + } + + params := make(map[string]string) + if org := c.OrgLookupFn(); org != "" { + params["org"] = org + } + + body, err := c.Requester.Do( + http.MethodPost, + uri.CreateResourceURI("", "application-build", appBuildID, "detached-app-build", params), + "application/json", + payload, + ) + if err != nil { + return err + } + + var resp detachedDeployResponse + if err := json.Unmarshal(body, &resp); err != nil { + return fmt.Errorf("could not parse response: %w", err) + } + + display.Println(fmt.Sprintf( + "Detached environment %q deployed (application UUID: %s).", + resp.Data.DisplayName, resp.Data.ApplicationUUID, + )) + return nil +} diff --git a/commands/root.go b/commands/root.go index 9d26bc1..4f3f1cd 100644 --- a/commands/root.go +++ b/commands/root.go @@ -90,6 +90,7 @@ func setupCommands() { rootCmd.AddGroup(&cobra.Group{ID: constants.GroupEnvironments, Title: "Environments"}) rootCmd.AddCommand(env.NewCancelCmd(c)) + rootCmd.AddCommand(env.NewDetachedCmd(c)) rootCmd.AddCommand(env.NewRebuildCmd(c)) rootCmd.AddCommand(env.NewRestartCmd(c)) rootCmd.AddCommand(env.NewReviveCmd(c)) diff --git a/tests/cli_test.go b/tests/cli_test.go index ed1c566..c19fdb4 100644 --- a/tests/cli_test.go +++ b/tests/cli_test.go @@ -232,6 +232,82 @@ func TestRebuildEnvironment(t *testing.T) { } } +func TestDetachedDeploy(t *testing.T) { + t.Parallel() + tests := []struct { + name string + args []string + output string + isErr bool + }{ + { + name: "default org success", + args: []string{"detached", "deploy", "default-1", "--name", "qa-detached"}, + output: "Detached environment \"qa-detached\" deployed (application UUID: new-app-uuid).\n", + }, + { + name: "non default org success", + args: []string{"detached", "deploy", "pug-1", "--org", "pugs", "--name", "pug-detached"}, + output: "Detached environment \"pug-detached\" deployed (application UUID: new-app-uuid).\n", + }, + { + name: "with branch and build-on-commit overrides", + args: []string{"detached", "deploy", "default-1", "--name", "ovr", "--branch", "web=feature-x", "--build-on-commit", "never"}, + output: "Detached environment \"ovr\" deployed (application UUID: new-app-uuid).\n", + }, + { + name: "missing build returns 404", + args: []string{"detached", "deploy", "missing-build", "--name", "x"}, + output: "Command error: application build not found\n", + isErr: true, + }, + { + name: "non existent org", + args: []string{"detached", "deploy", "default-1", "--org", "cats"}, + output: "Command error: user org not found\n", + isErr: true, + }, + { + name: "invalid build-on-commit value (client-side)", + args: []string{"detached", "deploy", "default-1", "--build-on-commit", "sometimes"}, + output: "Command error: invalid --build-on-commit \"sometimes\": must be always, inherit, or never\n", + isErr: true, + }, + { + name: "mutually exclusive build-on-commit flags (client-side)", + args: []string{"detached", "deploy", "default-1", "--build-on-commit", "never", "--build-on-commit-for", "web=always"}, + output: "Command error: --build-on-commit and --build-on-commit-for are mutually exclusive\n", + isErr: true, + }, + } + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + c := newCmd(test.args) + err := c.cmd.Run() + if test.isErr { + if err == nil { + t.Errorf("expected error %q but command succeeded", test.output) + return + } + if diff := cmp.Diff(c.stdErr.String(), test.output); diff != "" { + t.Error(diff) + } + return + } + if err != nil { + t.Logf("Detached deploy failed: %v", err) + t.Logf("Stderr: %q", c.stdErr.String()) + t.Fatalf("unexpected error") + } + if diff := cmp.Diff(c.stdOut.String(), test.output); diff != "" { + t.Error(diff) + } + }) + } +} + // nolint:gosec // Bad arguments can't be passed in. func newCmd(args []string) *cmdWrapper { c := cmdWrapper{ diff --git a/tests/server/handlers.go b/tests/server/handlers.go index acd156c..e090fac 100644 --- a/tests/server/handlers.go +++ b/tests/server/handlers.go @@ -40,6 +40,42 @@ func (handler) rebuildEnvironment(w http.ResponseWriter, r *http.Request) { } } +func (handler) deployDetached(w http.ResponseWriter, r *http.Request) { + org := r.URL.Query().Get("org") + if _, ok := store[org]; !ok { + orgNotFound(w) + return + } + if r.PathValue("id") == "missing-build" { + w.WriteHeader(http.StatusNotFound) + _, _ = fmt.Fprint(w, "application build not found") + return + } + + var req struct { + DisplayName string `json:"display_name"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + name := req.DisplayName + if name == "" { + name = "detached" + } + + w.WriteHeader(http.StatusCreated) + resp := map[string]any{ + "data": map[string]any{ + "message": fmt.Sprintf("Detached environment '%s' deployed successfully", name), + "application_uuid": "new-app-uuid", + "application_build_uuid": "new-app-build-uuid", + "display_name": name, + }, + } + if err := json.NewEncoder(w).Encode(resp); err != nil { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(err.Error())) + } +} + func findEnvByID(w http.ResponseWriter, r *http.Request) *types.Environment { org := r.URL.Query().Get("org") envs, ok := store[org] diff --git a/tests/server/server.go b/tests/server/server.go index 4cb13d3..a189c5f 100644 --- a/tests/server/server.go +++ b/tests/server/server.go @@ -8,6 +8,7 @@ func NewHandler() http.Handler { mux.HandleFunc("GET /environment", h.getAllEnvironments) mux.HandleFunc("GET /environment/{id}", h.getEnvironmentByID) mux.HandleFunc("POST /environment/{id}/rebuild", h.rebuildEnvironment) + mux.HandleFunc("POST /application-build/{id}/detached-app-build", h.deployDetached) return mux } From fcba61e3a72eae33f76b8e6c04c2b2b58fbd83e1 Mon Sep 17 00:00:00 2001 From: Danielle Date: Mon, 8 Jun 2026 11:31:50 -0300 Subject: [PATCH 2/2] fix: bump Docker build image to golang:1.24 to match go.mod go.mod requires `go 1.24`, but the Dockerfile built on golang:1.22 with GOTOOLCHAIN=local, so `go mod download` failed with "go.mod requires go >= 1.24". Bump the builder base image to golang:1.24. Co-Authored-By: Claude Opus 4.8 (1M context) --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 76e6035..f9c9e56 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.22 AS build +FROM golang:1.24 AS build WORKDIR /app