Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM golang:1.22 AS build
FROM golang:1.24 AS build

WORKDIR /app

Expand Down
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
133 changes: 133 additions & 0 deletions commands/env/detached.go
Original file line number Diff line number Diff line change
@@ -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/<uuid>/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
}
1 change: 1 addition & 0 deletions commands/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
76 changes: 76 additions & 0 deletions tests/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
36 changes: 36 additions & 0 deletions tests/server/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
1 change: 1 addition & 0 deletions tests/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
Loading