diff --git a/Makefile b/Makefile index b2709fa2..ffe1d6e4 100644 --- a/Makefile +++ b/Makefile @@ -2,6 +2,8 @@ INSTALL_PATH ?= ~/bin GIT_SHA := $(shell git log -1 --pretty=format:"%H") LD_FLAGS := "-X github.com/massdriver-cloud/mass/internal/version.version=dev -X github.com/massdriver-cloud/mass/internal/version.gitSHA=local-dev-${GIT_SHA}" +MAKEFLAGS += --no-print-directory + .DEFAULT_GOAL := install .PHONY: check @@ -13,7 +15,18 @@ clean: .PHONY: docs docs: build - ./mass docs + @OS="$$(uname -s)"; \ + if [ "$$OS" = "Darwin" ]; then \ + ./bin/mass-darwin-arm64 docs; \ + elif [ "$$OS" = "Linux" ]; then \ + ./bin/mass-linux-amd64 docs; \ + elif echo "$$OS" | grep -q -E "^(MINGW|MSYS|Windows)"; then \ + ./bin/mass-windows-amd64.exe docs; \ + else \ + echo "Unsupported OS: $$OS"; \ + exit 1; \ + fi + @echo "docs generated" .PHONY: test test: @@ -41,15 +54,15 @@ build: .PHONY: build.macos build.macos: bin - GOOS=darwin GOARCH=arm64 go build -o bin/mass-darwin-arm64 -ldflags=${LD_FLAGS} + @GOOS=darwin GOARCH=arm64 go build -o bin/mass-darwin-arm64 -ldflags=${LD_FLAGS} .PHONY: build.linux build.linux: bin - GOOS=linux GOARCH=amd64 go build -o bin/mass-linux-amd64 -ldflags=${LD_FLAGS} + @GOOS=linux GOARCH=amd64 go build -o bin/mass-linux-amd64 -ldflags=${LD_FLAGS} .PHONY: build.windows build.windows: bin - GOOS=windows GOARCH=amd64 go build -o bin/mass-windows-amd64.exe -ldflags=${LD_FLAGS} + @GOOS=windows GOARCH=amd64 go build -o bin/mass-windows-amd64.exe -ldflags=${LD_FLAGS} .PHONY: install.macos install.macos: build.macos diff --git a/cmd/deployment.go b/cmd/deployment.go index 9097f2cd..0a87c534 100644 --- a/cmd/deployment.go +++ b/cmd/deployment.go @@ -1,6 +1,7 @@ package cmd import ( + "bufio" "bytes" "context" "embed" @@ -9,12 +10,14 @@ import ( "fmt" "os" "os/signal" + "strings" "syscall" "text/template" "github.com/charmbracelet/glamour" "github.com/massdriver-cloud/mass/docs/helpdocs" "github.com/massdriver-cloud/mass/internal/cli" + "github.com/massdriver-cloud/mass/internal/prettylogs" "github.com/massdriver-cloud/massdriver-sdk-go/massdriver" "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/platform/deployments" @@ -64,9 +67,20 @@ func NewCmdDeployment() *cobra.Command { RunE: runDeploymentLogs, } + deploymentAbortCmd := &cobra.Command{ + Use: "abort ", + Short: "Abort a pending, approved, or running deployment", + Example: `mass deployment abort 12345678-1234-1234-1234-123456789012 --force`, + Long: helpdocs.MustRender("deployment/abort"), + Args: cobra.ExactArgs(1), + RunE: runDeploymentAbort, + } + deploymentAbortCmd.Flags().BoolP("force", "f", false, "Skip confirmation prompt") + deploymentCmd.AddCommand(deploymentGetCmd) deploymentCmd.AddCommand(deploymentListCmd) deploymentCmd.AddCommand(deploymentLogsCmd) + deploymentCmd.AddCommand(deploymentAbortCmd) return deploymentCmd } @@ -184,6 +198,41 @@ func signalContext(parent context.Context) (context.Context, context.CancelFunc) return signal.NotifyContext(parent, syscall.SIGINT, syscall.SIGTERM) } +func runDeploymentAbort(cmd *cobra.Command, args []string) error { + ctx := context.Background() + deploymentID := args[0] + + force, err := cmd.Flags().GetBool("force") + if err != nil { + return err + } + cmd.SilenceUsage = true + + if !force { + fmt.Printf("%s: This will abort deployment %s. A running deployment aborted mid-flight leaves any partial infrastructure changes in place, and does not halt execution of the deployment if it is already running.\n", prettylogs.Orange("WARNING"), deploymentID) + fmt.Printf("Type '%s' to confirm abort: ", deploymentID) + reader := bufio.NewReader(os.Stdin) + answer, _ := reader.ReadString('\n') + if strings.TrimSpace(answer) != deploymentID { + fmt.Println("Abort cancelled.") + return nil + } + } + + mdClient, err := massdriver.NewClient() + if err != nil { + return fmt.Errorf("error initializing massdriver client: %w", err) + } + + aborted, err := mdClient.Deployments.Abort(ctx, deploymentID) + if err != nil { + return err + } + + fmt.Printf("✅ Deployment `%s` aborted (status: %s)\n", aborted.ID, aborted.Status) + return nil +} + //nolint:dupl // parallel template-render shape with renderInstance; consolidating would couple unrelated commands func renderDeployment(deployment *types.Deployment) error { tmplBytes, err := deploymentTemplates.ReadFile("templates/deployment.get.md.tmpl") diff --git a/cmd/instance.go b/cmd/instance.go index 5d67c170..f5d63afd 100644 --- a/cmd/instance.go +++ b/cmd/instance.go @@ -15,6 +15,7 @@ import ( "github.com/massdriver-cloud/mass/internal/cli" "github.com/massdriver-cloud/mass/internal/commands/instance" "github.com/massdriver-cloud/mass/internal/files" + "github.com/massdriver-cloud/mass/internal/prettylogs" "github.com/massdriver-cloud/massdriver-sdk-go/massdriver" "github.com/charmbracelet/glamour" @@ -105,12 +106,24 @@ func NewCmdInstance() *cobra.Command { RunE: runInstanceList, } + instanceOrphanCmd := &cobra.Command{ + Use: `orphan --`, + Short: "Orphan an instance (reset to INITIALIZED, optionally clearing state locks)", + Example: `mass instance orphan api-prod-db --force`, + Long: helpdocs.MustRender("instance/orphan"), + Args: cobra.ExactArgs(1), + RunE: runInstanceOrphan, + } + instanceOrphanCmd.Flags().BoolP("force", "f", false, "Skip confirmation prompt") + instanceOrphanCmd.Flags().Bool("delete-state", false, "Also delete the remote Terraform/OpenTofu state files (irreversible)") + instanceCmd.AddCommand(instanceDeployCmd) instanceCmd.AddCommand(instanceExportCmd) instanceCmd.AddCommand(instanceGetCmd) instanceCmd.AddCommand(instanceListCmd) instanceCmd.AddCommand(instanceVersionCmd) instanceCmd.AddCommand(instanceDestroyCmd) + instanceCmd.AddCommand(instanceOrphanCmd) return instanceCmd } @@ -342,6 +355,50 @@ func runInstanceVersion(cmd *cobra.Command, args []string) error { return nil } +func runInstanceOrphan(cmd *cobra.Command, args []string) error { + ctx := context.Background() + name := args[0] + + force, err := cmd.Flags().GetBool("force") + if err != nil { + return err + } + deleteState, err := cmd.Flags().GetBool("delete-state") + if err != nil { + return err + } + cmd.SilenceUsage = true + + if !force { + if deleteState { + fmt.Printf("%s: This will orphan instance %s, resetting it to INITIALIZED and permanently deleting its Terraform/OpenTofu state files. The next deployment will provision from scratch and may duplicate any resources tracked by the prior state. This is irreversible.\n", prettylogs.Orange("WARNING"), name) + } else { + fmt.Printf("%s: This will orphan instance %s, resetting it to INITIALIZED and clearing all of its Terraform/OpenTofu state locks.\n", prettylogs.Orange("WARNING"), name) + } + fmt.Printf("Type '%s' to confirm orphan: ", name) + reader := bufio.NewReader(os.Stdin) + answer, _ := reader.ReadString('\n') + if strings.TrimSpace(answer) != name { + fmt.Println("Orphan cancelled.") + return nil + } + } + + mdClient, err := massdriver.NewClient() + if err != nil { + return fmt.Errorf("error initializing massdriver client: %w", err) + } + + orphaned, err := mdClient.Instances.Orphan(ctx, name, instances.OrphanInput{DeleteState: deleteState}) + if err != nil { + return err + } + + fmt.Printf("✅ Instance `%s` orphaned (status: %s)\n", orphaned.ID, orphaned.Status) + fmt.Printf("🔗 %s\n", mdClient.URLs.Helper(ctx).InstanceURL(orphaned.ID)) + return nil +} + func runInstanceList(cmd *cobra.Command, args []string) error { ctx := context.Background() diff --git a/docs/generated/mass_deployment.md b/docs/generated/mass_deployment.md index e4a201e4..2cd2730f 100644 --- a/docs/generated/mass_deployment.md +++ b/docs/generated/mass_deployment.md @@ -19,6 +19,7 @@ Use these commands to inspect deployment history and logs: - `mass deployment list ` — list recent deployments for an instance - `mass deployment get ` — show details for a single deployment - `mass deployment logs ` — print log output from a deployment +- `mass deployment abort ` — abort a pending, approved, or running deployment ### Options @@ -30,6 +31,7 @@ Use these commands to inspect deployment history and logs: ### SEE ALSO * [mass](/cli/commands/mass) - Massdriver Cloud CLI +* [mass deployment abort](/cli/commands/mass_deployment_abort) - Abort a pending, approved, or running deployment * [mass deployment get](/cli/commands/mass_deployment_get) - Get a deployment by ID * [mass deployment list](/cli/commands/mass_deployment_list) - List deployments for an instance (most recent first) * [mass deployment logs](/cli/commands/mass_deployment_logs) - Stream the log output from a deployment diff --git a/docs/generated/mass_deployment_abort.md b/docs/generated/mass_deployment_abort.md new file mode 100644 index 00000000..b9bdf6b7 --- /dev/null +++ b/docs/generated/mass_deployment_abort.md @@ -0,0 +1,62 @@ +--- +id: mass_deployment_abort.md +slug: /cli/commands/mass_deployment_abort +title: Mass Deployment Abort +sidebar_label: Mass Deployment Abort +--- +## mass deployment abort + +Abort a pending, approved, or running deployment + +### Synopsis + +# Abort a Deployment + +Cancels a `PENDING`, `APPROVED`, or `RUNNING` deployment. The deployment transitions to `ABORTED`. + +A running deployment aborted mid-flight will not cancel or halt the +running provisioner. It only transitions the state of the Massdriver +deployment to `ABORTED`. Any partial infrastructure changes the +provisioner had applied will remain in place — the instance's state is +left as it was at the moment of abort. + +To discard a `PROPOSED` deployment instead, use the `reject` flow. + +## Usage + +```shell +mass deployment abort [--force] +``` + +## Flags + +- `--force, -f`: Skip the confirmation prompt. + +## Examples + +```shell +mass deployment abort 12345678-1234-1234-1234-123456789012 +mass deployment abort 12345678-1234-1234-1234-123456789012 --force +``` + + +``` +mass deployment abort [flags] +``` + +### Examples + +``` +mass deployment abort 12345678-1234-1234-1234-123456789012 --force +``` + +### Options + +``` + -f, --force Skip confirmation prompt + -h, --help help for abort +``` + +### SEE ALSO + +* [mass deployment](/cli/commands/mass_deployment) - Manage deployments diff --git a/docs/generated/mass_instance.md b/docs/generated/mass_instance.md index b0b004df..6eb699b9 100644 --- a/docs/generated/mass_instance.md +++ b/docs/generated/mass_instance.md @@ -35,4 +35,5 @@ Instances are used to: * [mass instance export](/cli/commands/mass_instance_export) - Export instances * [mass instance get](/cli/commands/mass_instance_get) - Get an instance * [mass instance list](/cli/commands/mass_instance_list) - List instances in an environment +* [mass instance orphan](/cli/commands/mass_instance_orphan) - Orphan an instance (reset to INITIALIZED, optionally clearing state locks) * [mass instance version](/cli/commands/mass_instance_version) - Set instance version diff --git a/docs/generated/mass_instance_orphan.md b/docs/generated/mass_instance_orphan.md new file mode 100644 index 00000000..4275e16c --- /dev/null +++ b/docs/generated/mass_instance_orphan.md @@ -0,0 +1,60 @@ +--- +id: mass_instance_orphan.md +slug: /cli/commands/mass_instance_orphan +title: Mass Instance Orphan +sidebar_label: Mass Instance Orphan +--- +## mass instance orphan + +Orphan an instance (reset to INITIALIZED, optionally clearing state locks) + +### Synopsis + +# Orphan an Instance + +Resets an instance to `INITIALIZED`, clearing all of its Terraform/OpenTofu state locks. This is a break-glass operation for instances that are permanently stuck, such as instances in a `FAILED` state that cannot be successfully +provisioned or decommissioned. Active `RUNNING`, `PENDING`, and `APPROVED` deployments are bulk-aborted so a late worker will not retry deployments. + +By default, the remote state files are preserved so the next deployment can re-attach to existing infrastructure. Pass `--delete-state` to also permanently delete the state files. + +## Usage + +```shell +mass instance orphan -- [--delete-state] [--force] +``` + +## Flags + +- `--force, -f`: Skip the confirmation prompt. +- `--delete-state`: Also permanently delete the instance's Terraform/OpenTofu state files. The next deployment will provision from scratch and may duplicate any resources tracked by the prior state. **Irreversible.** + +## Examples + +```shell +mass instance orphan api-prod-db +mass instance orphan api-prod-db --force +mass instance orphan api-prod-db --delete-state +``` + + +``` +mass instance orphan -- [flags] +``` + +### Examples + +``` +mass instance orphan api-prod-db --force +``` + +### Options + +``` + --delete-state Also delete the remote Terraform/OpenTofu state files (irreversible) + -f, --force Skip confirmation prompt + -h, --help help for orphan +``` + +### SEE ALSO + +* [mass instance](/cli/commands/mass_instance) - Manage instances of IaC deployed in environments. diff --git a/docs/helpdocs/deployment.md b/docs/helpdocs/deployment.md index 12773658..156f67c1 100644 --- a/docs/helpdocs/deployment.md +++ b/docs/helpdocs/deployment.md @@ -7,3 +7,4 @@ Use these commands to inspect deployment history and logs: - `mass deployment list ` — list recent deployments for an instance - `mass deployment get ` — show details for a single deployment - `mass deployment logs ` — print log output from a deployment +- `mass deployment abort ` — abort a pending, approved, or running deployment diff --git a/docs/helpdocs/deployment/abort.md b/docs/helpdocs/deployment/abort.md new file mode 100644 index 00000000..91bceb50 --- /dev/null +++ b/docs/helpdocs/deployment/abort.md @@ -0,0 +1,28 @@ +# Abort a Deployment + +Cancels a `PENDING`, `APPROVED`, or `RUNNING` deployment. The deployment transitions to `ABORTED`. + +A running deployment aborted mid-flight will not cancel or halt the +running provisioner. It only transitions the state of the Massdriver +deployment to `ABORTED`. Any partial infrastructure changes the +provisioner had applied will remain in place — the instance's state is +left as it was at the moment of abort. + +To discard a `PROPOSED` deployment instead, use the `reject` flow. + +## Usage + +```shell +mass deployment abort [--force] +``` + +## Flags + +- `--force, -f`: Skip the confirmation prompt. + +## Examples + +```shell +mass deployment abort 12345678-1234-1234-1234-123456789012 +mass deployment abort 12345678-1234-1234-1234-123456789012 --force +``` diff --git a/docs/helpdocs/instance/orphan.md b/docs/helpdocs/instance/orphan.md new file mode 100644 index 00000000..4dd83de4 --- /dev/null +++ b/docs/helpdocs/instance/orphan.md @@ -0,0 +1,25 @@ +# Orphan an Instance + +Resets an instance to `INITIALIZED`, clearing all of its Terraform/OpenTofu state locks. This is a break-glass operation for instances that are permanently stuck, such as instances in a `FAILED` state that cannot be successfully +provisioned or decommissioned. Active `RUNNING`, `PENDING`, and `APPROVED` deployments are bulk-aborted so a late worker will not retry deployments. + +By default, the remote state files are preserved so the next deployment can re-attach to existing infrastructure. Pass `--delete-state` to also permanently delete the state files. + +## Usage + +```shell +mass instance orphan -- [--delete-state] [--force] +``` + +## Flags + +- `--force, -f`: Skip the confirmation prompt. +- `--delete-state`: Also permanently delete the instance's Terraform/OpenTofu state files. The next deployment will provision from scratch and may duplicate any resources tracked by the prior state. **Irreversible.** + +## Examples + +```shell +mass instance orphan api-prod-db +mass instance orphan api-prod-db --force +mass instance orphan api-prod-db --delete-state +``` diff --git a/go.mod b/go.mod index ed8d9a78..55e54152 100644 --- a/go.mod +++ b/go.mod @@ -15,7 +15,7 @@ require ( github.com/itchyny/gojq v0.12.16 github.com/manifoldco/promptui v0.9.0 github.com/massdriver-cloud/airlock v0.0.9 - github.com/massdriver-cloud/massdriver-sdk-go v0.2.0 + github.com/massdriver-cloud/massdriver-sdk-go v0.2.1 github.com/mattn/go-runewidth v0.0.16 github.com/opencontainers/image-spec v1.1.1 github.com/osteele/liquid v1.7.0 diff --git a/go.sum b/go.sum index 9ea7226e..e18a80e5 100644 --- a/go.sum +++ b/go.sum @@ -125,8 +125,10 @@ github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYt github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= github.com/massdriver-cloud/airlock v0.0.9 h1:t+jTY6nZEiPZNTKx0wEgQTPztIxL4u0RFvVWXn2/RMc= github.com/massdriver-cloud/airlock v0.0.9/go.mod h1:igJm33JvINiUtbyEspUeKUWyWewG+jYyxO1UDHqLp9Q= -github.com/massdriver-cloud/massdriver-sdk-go v0.2.0 h1:NNRe6ZB93fnlDRb2j/619Zw9PapBDYUazcMitMtnGjA= -github.com/massdriver-cloud/massdriver-sdk-go v0.2.0/go.mod h1:6NrSP+wfGQvUOAggsz10/Wkln8CKmk3VBnD+OJzZgFY= +github.com/massdriver-cloud/massdriver-sdk-go v0.2.1-0.20260515043345-6ce3d1195ebf h1:s3IugUvi+bbcvDsSy2TDDGkEz7+8k3le7MS4v+ZFKic= +github.com/massdriver-cloud/massdriver-sdk-go v0.2.1-0.20260515043345-6ce3d1195ebf/go.mod h1:6NrSP+wfGQvUOAggsz10/Wkln8CKmk3VBnD+OJzZgFY= +github.com/massdriver-cloud/massdriver-sdk-go v0.2.1 h1:KjvNc2P7Wa+P3lam65tVzUI7SAhW0A9Osm/9mOxRIqQ= +github.com/massdriver-cloud/massdriver-sdk-go v0.2.1/go.mod h1:6NrSP+wfGQvUOAggsz10/Wkln8CKmk3VBnD+OJzZgFY= github.com/massdriver-cloud/terraform-config-inspect v0.0.1 h1:eLtKFRaklHIxcPvUtZmNacl28n4QIHr29pJzw/u/FKU= github.com/massdriver-cloud/terraform-config-inspect v0.0.1/go.mod h1:3AbDpWxIRMdMAg7FDmTJuVBhCGNwdm49cBIOmUHjqRg= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=