Skip to content

Commit 9c2bf06

Browse files
coryodanielclaude
andauthored
Add environment fork / deploy / decommission, instance copy/promote (#238)
* Add `environment fork`, `environment deploy`, `instance copy` (alias `promote`) Three thin wrappers around SDK primitives that the preview command already composes: - `mass environment fork <parent> <new-ID>` — exposes Environments.Fork with --copy-environment-defaults, --copy-secrets, --copy-remote-references, and --attributes. Idempotent against the same parent. - `mass environment deploy <env>` — exposes Environments.Deploy; cancels any in-flight environment deployment and schedules a fresh provision wave. - `mass instance copy <source> <destination>` (alias `promote`) — exposes Instances.Copy with --overrides (path to JSON/YAML), --copy-secrets, --copy-remote-references, and --message. Components must match. Helpdocs and generated docs included. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Add --follow to `environment deploy` Reuses the FollowEnvironment helper landed with the preview --follow flag. Behavior is identical: tail every deployment's logs, prefix each line with the instance id, exit once the rollout reaches a quiet steady state. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Make `instance copy` / `promote` take destination via --to `mass instance promote ecomm-staging-db ecomm-production-db` reads ambiguously — easy to land on the wrong side at the end of a long day. Promote (and copy) now take the destination through a required `--to` flag: mass instance promote ecomm-staging-db --to ecomm-production-db Same for copy. Helpdoc and generated docs follow the new shape. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Drop --message flag from `instance copy` / `promote` CopyInstanceInput.Message was removed from the V2 schema — copy is pure config staging, not deployment. SDK v0.2.2 dropped Message from the public CopyInput type. Drop the corresponding CLI flag and update the helpdoc to point users at `mass instance deploy` for the follow-up deployment. * Extract new subcommand builders to satisfy funlen NewCmdEnvironment was 110 lines and NewCmdInstance was 111 lines after the new commands landed (preview/fork/deploy on environment; copy on instance) — over golangci-lint's 100-line funlen cap. Each new command's construction now lives in its own `new*Cmd` builder; the parents just AddCommand them. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Add `mass environment decommission` Wires the new V2 `decommissionEnvironment` mutation (platform PR #3259) through `Environments.Decommission` (SDK v0.2.4) so a fan-out teardown is a single CLI call. Mirrors `environment deploy`: - Reverse-dependency-order tear-down across every instance. - `--follow` streams interleaved per-instance logs. - Async — returns once the wave is enqueued. The environment shell stays put; run `mass environment delete` after to remove the empty environment. Decommissioning is rejected when `decommissionProtection` is on; disable via `updateEnvironment` first. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7d69950 commit 9c2bf06

14 files changed

Lines changed: 771 additions & 15 deletions

cmd/environment.go

Lines changed: 185 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -87,30 +87,80 @@ func NewCmdEnvironment() *cobra.Command {
8787
RunE: runEnvironmentDefault,
8888
}
8989

90-
environmentPreviewCmd := &cobra.Command{
91-
Use: "preview [ID]",
92-
Short: "Converge a preview environment from a YAML config",
93-
Long: helpdocs.MustRender("environment/preview"),
94-
Args: cobra.ExactArgs(1),
95-
RunE: runEnvironmentPreview,
96-
}
97-
environmentPreviewCmd.Flags().StringP("file", "f", "preview.yaml", "Path to the preview config YAML")
98-
environmentPreviewCmd.Flags().StringP("name", "n", "", "Environment name (defaults to ID if not provided)")
99-
environmentPreviewCmd.Flags().StringP("description", "d", "", "Optional environment description")
100-
environmentPreviewCmd.Flags().StringToStringP("attributes", "a", nil, "Custom attributes for ABAC (e.g. -a environment=preview,region=uswest). Overrides `attributes:` in the config file.")
101-
environmentPreviewCmd.Flags().Bool("follow", false, "Stream every deployment's logs to stdout until the rollout completes. Each line is prefixed with the instance id.")
102-
10390
environmentCmd.AddCommand(environmentExportCmd)
10491
environmentCmd.AddCommand(environmentGetCmd)
10592
environmentCmd.AddCommand(environmentListCmd)
10693
environmentCmd.AddCommand(environmentCreateCmd)
10794
environmentCmd.AddCommand(environmentUpdateCmd)
10895
environmentCmd.AddCommand(environmentDefaultCmd)
109-
environmentCmd.AddCommand(environmentPreviewCmd)
96+
environmentCmd.AddCommand(newEnvironmentPreviewCmd())
97+
environmentCmd.AddCommand(newEnvironmentForkCmd())
98+
environmentCmd.AddCommand(newEnvironmentDeployCmd())
99+
environmentCmd.AddCommand(newEnvironmentDecommissionCmd())
110100

111101
return environmentCmd
112102
}
113103

104+
func newEnvironmentPreviewCmd() *cobra.Command {
105+
c := &cobra.Command{
106+
Use: "preview [ID]",
107+
Short: "Converge a preview environment from a YAML config",
108+
Long: helpdocs.MustRender("environment/preview"),
109+
Args: cobra.ExactArgs(1),
110+
RunE: runEnvironmentPreview,
111+
}
112+
c.Flags().StringP("file", "f", "preview.yaml", "Path to the preview config YAML")
113+
c.Flags().StringP("name", "n", "", "Environment name (defaults to ID if not provided)")
114+
c.Flags().StringP("description", "d", "", "Optional environment description")
115+
c.Flags().StringToStringP("attributes", "a", nil, "Custom attributes for ABAC (e.g. -a environment=preview,region=uswest). Overrides `attributes:` in the config file.")
116+
c.Flags().Bool("follow", false, "Stream every deployment's logs to stdout until the rollout completes. Each line is prefixed with the instance id.")
117+
return c
118+
}
119+
120+
func newEnvironmentForkCmd() *cobra.Command {
121+
c := &cobra.Command{
122+
Use: "fork [parent-environment] [new-ID]",
123+
Short: "Fork an existing environment",
124+
Example: `mass environment fork ecomm-production staging`,
125+
Long: helpdocs.MustRender("environment/fork"),
126+
Args: cobra.ExactArgs(2),
127+
RunE: runEnvironmentFork,
128+
}
129+
c.Flags().StringP("name", "n", "", "Environment name (defaults to new-ID if not provided)")
130+
c.Flags().StringP("description", "d", "", "Optional environment description")
131+
c.Flags().StringToStringP("attributes", "a", nil, "Custom attributes for ABAC (e.g. -a region=uswest)")
132+
c.Flags().Bool("copy-environment-defaults", false, "Copy the parent's default resource connections into the fork")
133+
c.Flags().Bool("copy-secrets", false, "Copy every instance's secrets from the parent into the fork")
134+
c.Flags().Bool("copy-remote-references", false, "Copy every instance's remote references from the parent into the fork")
135+
return c
136+
}
137+
138+
func newEnvironmentDeployCmd() *cobra.Command {
139+
c := &cobra.Command{
140+
Use: "deploy [environment]",
141+
Short: "Deploy every instance in an environment, in dependency order",
142+
Example: `mass environment deploy ecomm-staging --follow`,
143+
Long: helpdocs.MustRender("environment/deploy"),
144+
Args: cobra.ExactArgs(1),
145+
RunE: runEnvironmentDeploy,
146+
}
147+
c.Flags().Bool("follow", false, "Stream every deployment's logs to stdout until the rollout completes. Each line is prefixed with the instance id.")
148+
return c
149+
}
150+
151+
func newEnvironmentDecommissionCmd() *cobra.Command {
152+
c := &cobra.Command{
153+
Use: "decommission [environment]",
154+
Short: "Decommission every instance in an environment, in reverse dependency order",
155+
Example: `mass environment decommission ecomm-pr42 --follow`,
156+
Long: helpdocs.MustRender("environment/decommission"),
157+
Args: cobra.ExactArgs(1),
158+
RunE: runEnvironmentDecommission,
159+
}
160+
c.Flags().Bool("follow", false, "Stream every decommission deployment's logs to stdout until the rollout completes. Each line is prefixed with the instance id.")
161+
return c
162+
}
163+
114164
func runEnvironmentExport(cmd *cobra.Command, args []string) error {
115165
ctx := context.Background()
116166

@@ -443,3 +493,124 @@ func runEnvironmentPreview(cmd *cobra.Command, args []string) error {
443493
}
444494
return nil
445495
}
496+
497+
func runEnvironmentFork(cmd *cobra.Command, args []string) error {
498+
ctx := context.Background()
499+
500+
parentID := args[0]
501+
newLocalID := args[1]
502+
name, err := cmd.Flags().GetString("name")
503+
if err != nil {
504+
return err
505+
}
506+
description, err := cmd.Flags().GetString("description")
507+
if err != nil {
508+
return err
509+
}
510+
attrs, err := cmd.Flags().GetStringToString("attributes")
511+
if err != nil {
512+
return err
513+
}
514+
copyDefaults, err := cmd.Flags().GetBool("copy-environment-defaults")
515+
if err != nil {
516+
return err
517+
}
518+
copySecrets, err := cmd.Flags().GetBool("copy-secrets")
519+
if err != nil {
520+
return err
521+
}
522+
copyRefs, err := cmd.Flags().GetBool("copy-remote-references")
523+
if err != nil {
524+
return err
525+
}
526+
527+
if name == "" {
528+
name = newLocalID
529+
}
530+
531+
cmd.SilenceUsage = true
532+
533+
mdClient, mdClientErr := massdriver.NewClient()
534+
if mdClientErr != nil {
535+
return fmt.Errorf("error initializing massdriver client: %w", mdClientErr)
536+
}
537+
538+
input := environments.ForkInput{
539+
ID: newLocalID,
540+
Name: name,
541+
Description: description,
542+
Attributes: cli.AttributesToAnyMap(attrs),
543+
CopyEnvironmentDefaults: copyDefaults,
544+
CopySecrets: copySecrets,
545+
CopyRemoteReferences: copyRefs,
546+
}
547+
548+
env, err := mdClient.Environments.Fork(ctx, parentID, input)
549+
if err != nil {
550+
return err
551+
}
552+
553+
fmt.Printf("✅ Environment `%s` forked from `%s`\n", env.ID, parentID)
554+
fmt.Printf("🔗 %s\n", mdClient.URLs.Helper(ctx).EnvironmentURL(env.ID))
555+
return nil
556+
}
557+
558+
func runEnvironmentDeploy(cmd *cobra.Command, args []string) error {
559+
ctx := context.Background()
560+
561+
environmentID := args[0]
562+
follow, err := cmd.Flags().GetBool("follow")
563+
if err != nil {
564+
return err
565+
}
566+
567+
cmd.SilenceUsage = true
568+
569+
mdClient, mdClientErr := massdriver.NewClient()
570+
if mdClientErr != nil {
571+
return fmt.Errorf("error initializing massdriver client: %w", mdClientErr)
572+
}
573+
574+
env, err := mdClient.Environments.Deploy(ctx, environmentID)
575+
if err != nil {
576+
return err
577+
}
578+
579+
fmt.Printf("🚀 Deploying environment `%s` — instances roll out in dependency order asynchronously\n", env.ID)
580+
fmt.Printf("🔗 %s\n", mdClient.URLs.Helper(ctx).EnvironmentURL(env.ID))
581+
582+
if follow {
583+
return environment.FollowEnvironment(ctx, environment.NewFollowAPI(mdClient), env.ID, os.Stdout)
584+
}
585+
return nil
586+
}
587+
588+
func runEnvironmentDecommission(cmd *cobra.Command, args []string) error {
589+
ctx := context.Background()
590+
591+
environmentID := args[0]
592+
follow, err := cmd.Flags().GetBool("follow")
593+
if err != nil {
594+
return err
595+
}
596+
597+
cmd.SilenceUsage = true
598+
599+
mdClient, mdClientErr := massdriver.NewClient()
600+
if mdClientErr != nil {
601+
return fmt.Errorf("error initializing massdriver client: %w", mdClientErr)
602+
}
603+
604+
env, err := mdClient.Environments.Decommission(ctx, environmentID)
605+
if err != nil {
606+
return err
607+
}
608+
609+
fmt.Printf("🔻 Decommissioning environment `%s` — instances tear down in reverse dependency order asynchronously\n", env.ID)
610+
fmt.Printf("🔗 %s\n", mdClient.URLs.Helper(ctx).EnvironmentURL(env.ID))
611+
612+
if follow {
613+
return environment.FollowEnvironment(ctx, environment.NewFollowAPI(mdClient), env.ID, os.Stdout)
614+
}
615+
return nil
616+
}

cmd/instance.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,10 +124,29 @@ func NewCmdInstance() *cobra.Command {
124124
instanceCmd.AddCommand(instanceVersionCmd)
125125
instanceCmd.AddCommand(instanceDestroyCmd)
126126
instanceCmd.AddCommand(instanceOrphanCmd)
127+
instanceCmd.AddCommand(newInstanceCopyCmd())
127128

128129
return instanceCmd
129130
}
130131

132+
func newInstanceCopyCmd() *cobra.Command {
133+
c := &cobra.Command{
134+
Use: `copy [source] --to [destination]`,
135+
Aliases: []string{"promote"},
136+
Short: "Copy an instance's configuration to another instance of the same component",
137+
Example: `mass instance promote ecomm-staging-db --to ecomm-production-db --copy-secrets`,
138+
Long: helpdocs.MustRender("instance/copy"),
139+
Args: cobra.ExactArgs(1),
140+
RunE: runInstanceCopy,
141+
}
142+
c.Flags().String("to", "", "Destination instance (required). Must be built from the same component as the source.")
143+
c.Flags().StringP("overrides", "o", "", "Path to a JSON or YAML file of param overrides deep-merged onto the source params")
144+
c.Flags().Bool("copy-secrets", false, "Copy secrets from the source instance to the destination")
145+
c.Flags().Bool("copy-remote-references", false, "Copy remote-reference overrides from the source instance to the destination")
146+
_ = c.MarkFlagRequired("to")
147+
return c
148+
}
149+
131150
func runInstanceGet(cmd *cobra.Command, args []string) error {
132151
ctx := context.Background()
133152

@@ -306,6 +325,58 @@ func readParams(path string) (map[string]any, error) {
306325
return params, nil
307326
}
308327

328+
func runInstanceCopy(cmd *cobra.Command, args []string) error {
329+
ctx := context.Background()
330+
331+
sourceID := args[0]
332+
destinationID, err := cmd.Flags().GetString("to")
333+
if err != nil {
334+
return err
335+
}
336+
overridesPath, err := cmd.Flags().GetString("overrides")
337+
if err != nil {
338+
return err
339+
}
340+
copySecrets, err := cmd.Flags().GetBool("copy-secrets")
341+
if err != nil {
342+
return err
343+
}
344+
copyRefs, err := cmd.Flags().GetBool("copy-remote-references")
345+
if err != nil {
346+
return err
347+
}
348+
349+
cmd.SilenceUsage = true
350+
351+
var overrides map[string]any
352+
if overridesPath != "" {
353+
overrides, err = readParams(overridesPath)
354+
if err != nil {
355+
return err
356+
}
357+
}
358+
359+
mdClient, mdClientErr := massdriver.NewClient()
360+
if mdClientErr != nil {
361+
return fmt.Errorf("error initializing massdriver client: %w", mdClientErr)
362+
}
363+
364+
input := instances.CopyInput{
365+
Overrides: overrides,
366+
CopySecrets: copySecrets,
367+
CopyRemoteReferences: copyRefs,
368+
}
369+
370+
inst, err := mdClient.Instances.Copy(ctx, sourceID, destinationID, input)
371+
if err != nil {
372+
return err
373+
}
374+
375+
fmt.Printf("✅ Instance `%s` configuration copied to `%s`\n", sourceID, destinationID)
376+
fmt.Printf("🔗 %s\n", mdClient.URLs.Helper(ctx).InstanceURL(inst.ID))
377+
return nil
378+
}
379+
309380
func runInstanceExport(cmd *cobra.Command, args []string) error {
310381
ctx := context.Background()
311382

docs/generated/mass_environment.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,11 @@ Environments can be modeled by application stage (production, staging, developme
2727

2828
* [mass](/cli/commands/mass) - Massdriver Cloud CLI
2929
* [mass environment create](/cli/commands/mass_environment_create) - Create an environment
30+
* [mass environment decommission](/cli/commands/mass_environment_decommission) - Decommission every instance in an environment, in reverse dependency order
3031
* [mass environment default](/cli/commands/mass_environment_default) - Set an environment default connection
32+
* [mass environment deploy](/cli/commands/mass_environment_deploy) - Deploy every instance in an environment, in dependency order
3133
* [mass environment export](/cli/commands/mass_environment_export) - Export an environment from Massdriver
34+
* [mass environment fork](/cli/commands/mass_environment_fork) - Fork an existing environment
3235
* [mass environment get](/cli/commands/mass_environment_get) - Get an environment from Massdriver
3336
* [mass environment list](/cli/commands/mass_environment_list) - List environments
3437
* [mass environment preview](/cli/commands/mass_environment_preview) - Converge a preview environment from a YAML config

0 commit comments

Comments
 (0)