diff --git a/cmd/wodby/migrate/migrate.go b/cmd/wodby/migrate/migrate.go index 5391cdb..54b3f71 100644 --- a/cmd/wodby/migrate/migrate.go +++ b/cmd/wodby/migrate/migrate.go @@ -20,6 +20,7 @@ import ( "github.com/pkg/errors" "github.com/spf13/cobra" + "github.com/spf13/pflag" "github.com/spf13/viper" "github.com/wodby/wodby-cli/pkg/migration/wodby1" "github.com/wodby/wodby-cli/pkg/types" @@ -143,10 +144,13 @@ all components to a different complete snapshot, or --skip-data to omit data. Changes made after the selected backup completed are not migrated. Apply is resumable and stores its plan and state in the system temporary directory. When state exists, the same --apply command preserves the saved plan -and continues completed work. --restart replaces it only when state proves that -no target mutation occurred. --rollback deletes the Wodby 2 resources this -migration created and discards its state; it refuses once --verify has -succeeded, because DNS then points at Wodby 2.`, +and continues completed work. --apply --restart replaces the saved plan. If the +migration already changed Wodby 2, the CLI first shows and confirms removal of +only the resources recorded as created by this migration; pre-existing target +apps and reused resources are preserved. Restart is refused after successful +verification or while any target operation remains ambiguous. --rollback +deletes the Wodby 2 resources this migration created and discards its state; it +refuses once --verify has succeeded, because DNS then points at Wodby 2.`, Example: ` export WODBY1_SOURCE_TOKEN=... export WODBY_API_KEY=... @@ -185,9 +189,12 @@ individual snapshots, or --skip-data to omit data. Changes after each selected backup completed are not migrated. Test the migrated apps before changing DNS, then use --verify. When per-app state exists, --apply preserves the aggregate saved plan and continues completed work; ---restart is allowed only before any target mutation. --rollback deletes the -Wodby 2 resources this migration created and discards its state; it refuses -once --verify has succeeded, because DNS then points at Wodby 2.`, +--apply --restart replaces the saved plan after showing and confirming removal +of only resources recorded as created by these migrations. Pre-existing target +apps and reused resources are preserved. Restart is refused after successful +verification or while any target operation remains ambiguous. --rollback +deletes the Wodby 2 resources this migration created and discards its state; it +refuses once --verify has succeeded, because DNS then points at Wodby 2.`, Example: ` export WODBY1_SOURCE_TOKEN=... export WODBY_API_KEY=... @@ -238,9 +245,13 @@ snapshots, or --skip-data to omit data. Changes after each selected backup completed are not migrated. If a target mutation is ambiguous, inspect Wodby 2 and pass --retry-ambiguous only with the exact operation ID printed by the command. When state exists, --apply preserves the saved plan and continues completed work; ---restart is allowed only before any target mutation. --rollback deletes the -Wodby 2 resources this migration created and discards its state; it refuses -once --verify has succeeded, because DNS then points at Wodby 2.`, +--apply --restart replaces the saved plan. If the migration already changed +Wodby 2, the CLI first shows and confirms removal of only resources recorded as +created by this migration; pre-existing target apps and reused resources are +preserved. Restart is refused after successful verification or while any target +operation remains ambiguous. --rollback deletes the Wodby 2 resources this +migration created and discards its state; it refuses once --verify has +succeeded, because DNS then points at Wodby 2.`, Example: ` export WODBY1_SOURCE_TOKEN=... export WODBY_API_KEY=... @@ -278,7 +289,7 @@ func bindFlags(cmd *cobra.Command, opts *options) { cmd.Flags().StringVar(&opts.sourceToken, "source-token", "", "Wodby 1 API token (defaults to "+sourceTokenEnv+")") cmd.Flags().BoolVar(&opts.apply, "apply", false, "Create the target and import data using the displayed plan") cmd.Flags().BoolVar(&opts.verify, "verify", false, "Verify the applied migration after testing and DNS cutover") - cmd.Flags().BoolVar(&opts.restart, "restart", false, "Start a new applied plan only when saved state proves that no target mutation occurred (requires --apply)") + cmd.Flags().BoolVar(&opts.restart, "restart", false, "Replace the saved plan, safely removing migration-created target resources first when necessary (requires --apply)") cmd.Flags().BoolVar(&opts.rollback, "rollback", false, "Delete the Wodby 2 resources this migration created and discard its resume state") cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false, "Approve a new migration plan without an interactive prompt") @@ -321,6 +332,46 @@ func bindFlags(cmd *cobra.Command, opts *options) { cmd.Flags().StringVarP(&opts.output, "output", "o", "text", "Output format: text or json") } +// restartCommandSuggestion reconstructs the current invocation without secret +// values. It includes every explicitly supplied migration option, replacing +// the run mode with --apply --restart so the suggested command preserves the +// reviewed target and mappings. +func restartCommandSuggestion(cmd *cobra.Command, sourceKind string, sourceID string) string { + args := []string{"wodby", "migrate", "wodby1", sourceKind, sourceID} + cmd.Flags().Visit(func(flag *pflag.Flag) { + switch flag.Name { + case "apply", "verify", "restart", "rollback", "source-token": + return + } + name := "--" + flag.Name + if values, ok := flag.Value.(pflag.SliceValue); ok { + for _, value := range values.GetSlice() { + args = append(args, name, shellCommandArgument(value)) + } + return + } + if flag.Value.Type() == "bool" && flag.Value.String() == "true" { + args = append(args, name) + return + } + args = append(args, name, shellCommandArgument(flag.Value.String())) + }) + args = append(args, "--apply", "--restart") + return strings.Join(args, " ") +} + +func shellCommandArgument(value string) string { + if value != "" && strings.IndexFunc(value, func(r rune) bool { + return !(r >= 'a' && r <= 'z') && + !(r >= 'A' && r <= 'Z') && + !(r >= '0' && r <= '9') && + !strings.ContainsRune("._:/=@,+-", r) + }) == -1 { + return value + } + return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'" +} + func runWodby1App(cmd *cobra.Command, sourceID string, opts *options) (runErr error) { return runWodby1Single(cmd, "app", sourceID, opts) } @@ -377,9 +428,9 @@ func runWodby1Single(cmd *cobra.Command, sourceKind string, sourceID string, opt var reviewedPlan *wodby1.Plan var restartStateIdentity *wodby1.MigrationStateIdentity var restartStateWithMutations *wodby1.MigrationState + restartStateAppName := sourceID var resumeState *wodby1.MigrationState var incompatiblePlan *wodby1.UnsupportedMigrationPlanSchemaError - restartDeletedTargetAppID := 0 allowedTargetAppID := 0 allowTargetAppRecovery := false stateExists, err := artifactExists(statePath) @@ -435,8 +486,8 @@ func runWodby1Single(cmd *cobra.Command, sourceKind string, sourceID string, opt identity := state.Identity() restartStateIdentity = &identity } else { - if state.App.TargetID <= 0 { - return incompatiblePlanUnsafeRestartError(incompatiblePlan, planPath, statePath, state) + if _, err := wodby1.PlanRollback(state); err != nil { + return errors.Wrap(err, "prepare --restart cleanup") } restartStateWithMutations = state } @@ -449,17 +500,14 @@ func runWodby1Single(cmd *cobra.Command, sourceKind string, sourceID string, opt return errors.Wrap(err, "load migration state before target preflight") } if opts.restart { + restartStateAppName = reviewedPlanAppName(reviewed) if state.CanRestartSafely() { identity := state.Identity() restartStateIdentity = &identity } else { - if state.App.TargetID <= 0 { - return unsafeSingleRestartError(state, statePath) + if _, err := wodby1.PlanRollback(state); err != nil { + return errors.Wrap(err, "prepare --restart cleanup") } - // A state with successful or ambiguous mutations normally cannot be - // discarded. After target discovery we make one safe exception: a - // recorded app ID that the original organization definitively says - // no longer exists. restartStateWithMutations = state } } else { @@ -507,33 +555,17 @@ func runWodby1Single(cmd *cobra.Command, sourceKind string, sourceID string, opt return err } if restartStateWithMutations != nil { - if restartStateWithMutations.Target.ExistingApp { - return unsafeExistingAppRestartError(restartStateWithMutations, statePath) - } - if restartStateWithMutations.App.TargetID <= 0 { - return unsafeSingleRestartError(restartStateWithMutations, statePath) - } - if scope.Org.ID != restartStateWithMutations.Target.OrgID { + if scope.Org.ID != restartStateWithMutations.Target.OrgID || + scope.Cluster.ID != restartStateWithMutations.Target.ClusterID { return errors.Errorf( - "cannot verify deletion of saved target app ID %d because the current target resolves to organization ID %d instead of the saved organization ID %d; use the original target credentials and options", - restartStateWithMutations.App.TargetID, + "cannot restart the saved migration because the current target resolves to organization ID %d and cluster ID %d instead of the saved organization ID %d and cluster ID %d; use the original target credentials and options", scope.Org.ID, + scope.Cluster.ID, restartStateWithMutations.Target.OrgID, + restartStateWithMutations.Target.ClusterID, ) } - _, found, err := targetClient.FindAppByID(cmd.Context(), restartStateWithMutations.App.TargetID) - if err != nil { - return errors.Wrap(err, "verify saved target app before restart") - } - if found { - if incompatiblePlan != nil { - return incompatibleTargetAppExistsError(incompatiblePlan, planPath, statePath, restartStateWithMutations) - } - return unsafeSingleRestartError(restartStateWithMutations, statePath) - } - identity := restartStateWithMutations.Identity() - restartStateIdentity = &identity - restartDeletedTargetAppID = restartStateWithMutations.App.TargetID + allowedTargetAppID, allowTargetAppRecovery = stateBackedTargetAppState(restartStateWithMutations) } if resumeState != nil && resumeState.App.TargetID > 0 { if scope.Org.ID != resumeState.Target.OrgID { @@ -733,6 +765,10 @@ func runWodby1Single(cmd *cobra.Command, sourceKind string, sourceID string, opt } preparation.CompleteStep(fmt.Sprintf("Planned %d app(s) and %d instance(s).", plan.Summary.Apps, plan.Summary.Instances)) preparation.StartStep("Inspect target mappings and capabilities") + preflightState := resumeState + if restartStateWithMutations != nil { + preflightState = restartStateWithMutations + } prepared, err := targetClient.PreflightTarget(cmd.Context(), export, &plan, wodby1.TargetPreflightOptions{ SkipCode: opts.skipCode, SkipData: opts.skipData, @@ -741,8 +777,8 @@ func runWodby1Single(cmd *cobra.Command, sourceKind string, sourceID string, opt CodeService: opts.targetCodeService, AllowedTargetAppID: allowedTargetAppID, AllowStateBackedAppRecovery: allowTargetAppRecovery, - AllowedTargetInstanceIDs: stateTargetInstanceIDs(resumeState), - StateBackedInstanceRecovery: stateTargetInstanceRecovery(resumeState), + AllowedTargetInstanceIDs: stateTargetInstanceIDs(preflightState), + StateBackedInstanceRecovery: stateTargetInstanceRecovery(preflightState), AddMissingServices: opts.addMissingServices, Progress: preparation.TargetPreflight, }) @@ -763,7 +799,11 @@ func runWodby1Single(cmd *cobra.Command, sourceKind string, sourceID string, opt } if reviewedPlan != nil { if err := wodby1.RestoreReviewedPlanForResume(&plan, *reviewedPlan); err != nil { - return errors.Wrap(err, "cannot continue from saved plan because the current source or options change its executable actions; the saved plan was not overwritten") + return errors.Wrapf( + err, + "cannot continue from saved plan because the current source or options change its executable actions; the saved plan was not overwritten\nNext step: restart with this command (the source token is intentionally omitted):\n %s\nThe CLI will replace the saved plan and safely remove only resources this migration created", + restartCommandSuggestion(cmd, sourceKind, sourceID), + ) } } if err := ensureArtifactDirectories(planPath, statePath); err != nil { @@ -779,29 +819,49 @@ func runWodby1Single(cmd *cobra.Command, sourceKind string, sourceID string, opt } }() if opts.apply { + var restartCleanup *migrationRestartCleanup if resumeState == nil && opts.restart { - if restartDeletedTargetAppID > 0 { - printDeletedTargetRestartNotice(cmd, planPath, statePath, restartDeletedTargetAppID) - } else { - printRestartNotice(cmd, planPath, statePath) + cleanupAppName := restartStateAppName + if cleanupAppName == "" { + cleanupAppName = sourceID + } + restartCleanup = &migrationRestartCleanup{StatePath: statePath, State: restartStateWithMutations, AppName: cleanupAppName} + if restartStateWithMutations != nil { + restartCleanup.Plan, err = wodby1.PlanRollback(restartStateWithMutations) + if err != nil { + return errors.Wrap(err, "prepare --restart cleanup") + } } } - if err := printApplyReview(cmd, plan, resumeState != nil, prepared); err != nil { + if err := printApplyReview(cmd, plan, resumeState != nil, opts.restart, prepared); err != nil { return err } + if resumeState == nil && opts.restart { + printRestartConflict(cmd, sourceKind, sourceID, []migrationRestartCleanup{*restartCleanup}, restartStateIdentity != nil, true, planPath, statePath) + } if resumeState == nil { - if err := confirmApply(cmd, opts.yes); err != nil { + if opts.restart { + if err := confirmRestart(cmd, opts.yes, restartCleanup.AppName, restartCleanup.Plan.DeletesResources()); err != nil { + return err + } + } else { + if err := confirmApply(cmd, opts.yes); err != nil { + return err + } + } + } + if restartStateWithMutations != nil { + if err := runMigrationRestartCleanupLocked( + cmd, + opts, + targetClient, + *restartCleanup, + ); err != nil { return err } } if restartStateIdentity != nil { - var err error - if restartDeletedTargetAppID > 0 { - err = wodby1.RemoveMigrationStateAfterTargetDeletion(statePath, *restartStateIdentity, restartDeletedTargetAppID) - } else { - err = wodby1.RemoveRestartableMigrationState(statePath, *restartStateIdentity) - } - if err != nil { + if err := wodby1.RemoveRestartableMigrationState(statePath, *restartStateIdentity); err != nil { return errors.Wrap(err, "replace migration state for restart") } } @@ -1013,7 +1073,7 @@ func runWodby1Server(cmd *cobra.Command, sourceID string, opts *options) (runErr var reviewedPlan *wodby1.Plan var restartStateIdentities map[string]wodby1.MigrationStateIdentity - var restartDeletedTargetAppIDs map[string]int + restartCleanupStates := map[string]*wodby1.MigrationState{} var incompatiblePlan *wodby1.UnsupportedMigrationPlanSchemaError var incompatibleServerStates map[string]*wodby1.MigrationState statePaths, err := serverMigrationStatePaths(statePath) @@ -1059,16 +1119,16 @@ func runWodby1Server(cmd *cobra.Command, sourceID string, opts *options) (runErr return incompatiblePlanRestartError(incompatiblePlan, planPath, statePaths, states) } restartStateIdentities = make(map[string]wodby1.MigrationStateIdentity, len(incompatibleServerStates)) - restartDeletedTargetAppIDs = map[string]int{} for _, path := range statePaths { state := incompatibleServerStates[path] - restartStateIdentities[path] = state.Identity() - if !state.CanRestartSafely() { - if state.App.TargetID <= 0 { - return incompatiblePlanUnsafeRestartError(incompatiblePlan, planPath, statePath, state) - } - restartDeletedTargetAppIDs[path] = state.App.TargetID + if state.CanRestartSafely() { + restartStateIdentities[path] = state.Identity() + continue + } + if _, err := wodby1.PlanRollback(state); err != nil { + return errors.Wrapf(err, "prepare --restart cleanup for source app %s", state.Source.ID) } + restartCleanupStates[path] = state } } else { if reviewed.Source.Kind != "server" || reviewed.Source.ID != sourceID { @@ -1082,16 +1142,12 @@ func runWodby1Server(cmd *cobra.Command, sourceID string, opts *options) (runErr ) } if opts.restart { - identities, restartable, err := restartableServerMigrationStates(statePath, reviewed, statePaths) + identities, cleanup, err := serverMigrationStatesForRestart(statePath, reviewed, statePaths) if err != nil { return err } - if !restartable { - return errors.Errorf( - "cannot restart server migration from scratch because at least one resume state records target mutations; continue without --restart to reuse the saved plan and completed work", - ) - } restartStateIdentities = identities + restartCleanupStates = cleanup } else { reviewedPlan = &reviewed if opts.apply { @@ -1126,27 +1182,16 @@ func runWodby1Server(cmd *cobra.Command, sourceID string, opts *options) (runErr if err != nil { return err } - for _, path := range statePaths { - targetAppID := restartDeletedTargetAppIDs[path] - if targetAppID <= 0 { - continue - } - state := incompatibleServerStates[path] - if scope.Org.ID != state.Target.OrgID { + for _, state := range restartCleanupStates { + if scope.Org.ID != state.Target.OrgID || scope.Cluster.ID != state.Target.ClusterID { return errors.Errorf( - "cannot verify deletion of saved target app ID %d because the current target resolves to organization ID %d instead of the saved organization ID %d; use the original target credentials and options", - targetAppID, + "cannot restart the saved server migration because the current target resolves to organization ID %d and cluster ID %d instead of the saved organization ID %d and cluster ID %d; use the original target credentials and options", scope.Org.ID, + scope.Cluster.ID, state.Target.OrgID, + state.Target.ClusterID, ) } - _, found, err := targetClient.FindAppByID(cmd.Context(), targetAppID) - if err != nil { - return errors.Wrap(err, "verify saved target app before restarting incompatible server migration") - } - if found { - return incompatibleTargetAppExistsError(incompatiblePlan, planPath, path, state) - } } preparation.CompleteStep(fmt.Sprintf("Target %s / %s is ready.", scope.Org.Name, scope.Cluster.Name)) @@ -1287,6 +1332,13 @@ func runWodby1Server(cmd *cobra.Command, sourceID string, opts *options) (runErr stateBackedRecovery[app.SourceUUID] = allowRecovery } } + if reviewedPlan == nil && len(restartCleanupStates) != 0 { + for _, state := range restartCleanupStates { + targetID, allowRecovery := stateBackedTargetAppState(state) + allowedTargetAppIDs[state.Source.ID] = targetID + stateBackedRecovery[state.Source.ID] = allowRecovery + } + } preparation.StartStep("Inspect target mappings and capabilities") prepared, err := targetClient.PreflightTarget(cmd.Context(), export, &plan, wodby1.TargetPreflightOptions{ SkipCode: opts.skipCode, @@ -1316,7 +1368,11 @@ func runWodby1Server(cmd *cobra.Command, sourceID string, opts *options) (runErr } if reviewedPlan != nil { if err := wodby1.RestoreReviewedPlanForResume(&plan, *reviewedPlan); err != nil { - return errors.Wrap(err, "cannot continue from saved server plan because the current source or options change its executable actions; the saved plan was not overwritten") + return errors.Wrapf( + err, + "cannot continue from saved server plan because the current source or options change its executable actions; the saved plan was not overwritten\nNext step: restart with this command (the source token is intentionally omitted):\n %s\nThe CLI will replace the saved plan and safely remove only resources these migrations created", + restartCommandSuggestion(cmd, "server", sourceID), + ) } } if err := ensureArtifactDirectories(planPath, statePath); err != nil { @@ -1332,20 +1388,64 @@ func runWodby1Server(cmd *cobra.Command, sourceID string, opts *options) (runErr } }() if opts.apply { + var restartCleanups []migrationRestartCleanup if reviewedPlan == nil && opts.restart { - printRestartNotice(cmd, planPath, statePath) + appNames := make(map[string]string, len(plan.Apps)) + for _, app := range plan.Apps { + appNames[app.SourceUUID] = app.Name + } + for _, path := range statePaths { + state := restartCleanupStates[path] + if state == nil { + continue + } + appName := appNames[state.Source.ID] + if appName == "" { + appName = state.Source.ID + } + rollback, err := wodby1.PlanRollback(state) + if err != nil { + return errors.Wrapf(err, "prepare --restart cleanup for source app %s", state.Source.ID) + } + restartCleanups = append(restartCleanups, migrationRestartCleanup{ + StatePath: path, + State: state, + Plan: rollback, + AppName: appName, + }) + } } - if err := printApplyReview(cmd, plan, reviewedPlan != nil, prepared); err != nil { + if err := printApplyReview(cmd, plan, reviewedPlan != nil, opts.restart, prepared); err != nil { return err } + if reviewedPlan == nil && opts.restart { + printRestartConflict(cmd, "server", sourceID, restartCleanups, len(restartStateIdentities) != 0, false, planPath, statePath) + } if reviewedPlan == nil { - if err := confirmApply(cmd, opts.yes); err != nil { - return err + if opts.restart { + destructive := false + for _, cleanup := range restartCleanups { + destructive = destructive || cleanup.Plan.DeletesResources() + } + if err := confirmRestart(cmd, opts.yes, "restart "+sourceID, destructive); err != nil { + return err + } + } else { + if err := confirmApply(cmd, opts.yes); err != nil { + return err + } + } + } + if len(restartCleanups) != 0 { + for _, cleanup := range restartCleanups { + if err := runMigrationRestartCleanupLocked(cmd, opts, targetClient, cleanup); err != nil { + return errors.Wrapf(err, "restart cleanup for source app %s", cleanup.State.Source.ID) + } } } if len(restartStateIdentities) != 0 { - if err := removeServerMigrationStates(statePath, restartStateIdentities, restartDeletedTargetAppIDs); err != nil { - return errors.Wrap(err, "restart server migration after definitive target rejection") + if err := removeServerMigrationStates(statePath, restartStateIdentities, nil); err != nil { + return errors.Wrap(err, "replace safely restartable server migration states") } } if reviewedPlan == nil { @@ -1891,6 +1991,50 @@ func restartableServerMigrationStates( return identities, len(identities) != 0, nil } +func serverMigrationStatesForRestart( + basePath string, + plan wodby1.Plan, + paths []string, +) (map[string]wodby1.MigrationStateIdentity, map[string]*wodby1.MigrationState, error) { + appIDs := make(map[string]bool, len(plan.Apps)) + for _, app := range plan.Apps { + appIDs[app.SourceUUID] = true + } + safe := make(map[string]wodby1.MigrationStateIdentity, len(paths)) + cleanup := make(map[string]*wodby1.MigrationState, len(paths)) + for _, path := range paths { + state, err := wodby1.InspectMigrationState(path) + if err != nil { + return nil, nil, errors.Wrap(err, "inspect server app migration state") + } + if state.Source.Kind != "app" || !appIDs[state.Source.ID] { + return nil, nil, errors.Errorf("server migration state %s does not belong to an app in the applied plan", path) + } + expectedPath := serverAppStatePath(basePath, state.Source.ID) + same, err := sameArtifactPath(path, expectedPath) + if err != nil { + return nil, nil, err + } + if !same { + return nil, nil, errors.Errorf("server migration state %s does not match its source app identity", path) + } + if state.Target.OrgID != plan.Target.OrgID || + state.Target.ProjectID != plan.Target.ProjectID || + state.Target.ClusterID != plan.Target.ClusterID { + return nil, nil, errors.Errorf("server migration state %s does not match the applied plan target", path) + } + if state.CanRestartSafely() { + safe[path] = state.Identity() + continue + } + if _, err := wodby1.PlanRollback(state); err != nil { + return nil, nil, errors.Wrapf(err, "prepare --restart cleanup for source app %s", state.Source.ID) + } + cleanup[path] = state + } + return safe, cleanup, nil +} + func inspectIncompatibleServerMigrationStates( basePath string, paths []string, @@ -2096,80 +2240,15 @@ func incompatiblePlanRestartError( } } if len(existingTargetIDs) != 0 { - fmt.Fprintf(&message, "\nExisting target app ID(s): %s", strings.Join(existingTargetIDs, ", ")) - fmt.Fprintf(&message, "\nThe CLI will not delete or discard state containing changes to an existing app. Continue with the CLI version that created this migration state.") - return errors.New(message.String()) + fmt.Fprintf(&message, "\nPre-existing target app ID(s), which restart will preserve: %s", strings.Join(existingTargetIDs, ", ")) } - if len(targetIDs) == 0 { - fmt.Fprintf(&message, "\nNext step: rerun the same command with --apply --restart. The CLI will replace the obsolete plan and state files.") - } else { + if len(targetIDs) != 0 { fmt.Fprintf(&message, "\nSaved target app ID(s): %s", strings.Join(targetIDs, ", ")) - fmt.Fprintf(&message, "\nNext step: delete these target apps if they still exist, then rerun the same command with --apply --restart. The CLI will verify their deletion and replace the obsolete plan and state files.") } + fmt.Fprintf(&message, "\nNext step: rerun the same command with --apply --restart. The CLI will show and confirm cleanup of only migration-created resources, replace the obsolete plan and state, and continue with a fresh apply.") return errors.New(message.String()) } -func incompatiblePlanUnsafeRestartError( - plan *wodby1.UnsupportedMigrationPlanSchemaError, - planPath string, - statePath string, - state *wodby1.MigrationState, -) error { - base := incompatiblePlanRestartError(plan, planPath, []string{statePath}, []*wodby1.MigrationState{state}) - return errors.Errorf("%s\nThe saved state records target mutations but does not identify a target app, so the CLI cannot discard it safely.", base) -} - -func incompatibleTargetAppExistsError( - plan *wodby1.UnsupportedMigrationPlanSchemaError, - planPath string, - statePath string, - state *wodby1.MigrationState, -) error { - if state.Target.ExistingApp { - return errors.Errorf( - "the saved migration uses incompatible plan schema %s (supported: %s) and records changes while adding an instance to existing target app ID %d\nPlan: %s\nState: %s\nThe CLI will not ask you to delete an existing app; continue with the CLI version that created this migration state", - plan.Actual, - plan.Supported, - state.Target.AppID, - planPath, - statePath, - ) - } - return errors.Errorf( - "the saved migration uses incompatible plan schema %s (supported: %s) and cannot be resumed\nPlan: %s\nState: %s\nTarget app ID %d still exists. Delete it, then rerun the same command with --apply --restart; the CLI will replace the obsolete plan and state files", - plan.Actual, - plan.Supported, - planPath, - statePath, - state.App.TargetID, - ) -} - -func unsafeSingleRestartError(state *wodby1.MigrationState, statePath string) error { - if state == nil { - return errors.Errorf("cannot restart from scratch because migration state %s is unavailable", statePath) - } - target := "" - if state.App.TargetID > 0 { - target = fmt.Sprintf(" (target app ID %d)", state.App.TargetID) - } - return errors.Errorf( - "cannot restart migration from scratch because state %s records target mutations%s at status=%s phase=%s; continue without --restart to reuse the saved plan and completed work", - statePath, - target, - state.Status, - state.Phase, - ) -} - -func unsafeExistingAppRestartError(state *wodby1.MigrationState, statePath string) error { - return errors.Errorf( - "cannot restart this migration from scratch because state %s records changes made while adding an instance to existing target app ID %d; the CLI will never ask you to delete an existing app. Continue without --restart to reuse completed work", - statePath, - state.Target.AppID, - ) -} - func stateBackedTargetAppState(state *wodby1.MigrationState) (targetID int, allowRecovery bool) { if state.App.TargetID > 0 { return state.App.TargetID, false @@ -2255,13 +2334,15 @@ func printPreview(cmd *cobra.Command, plan wodby1.Plan, prepared ...wodby1.Prepa return nil } -func printApplyReview(cmd *cobra.Command, plan wodby1.Plan, continuing bool, prepared ...wodby1.PreparedMigration) error { +func printApplyReview(cmd *cobra.Command, plan wodby1.Plan, continuing bool, restarting bool, prepared ...wodby1.PreparedMigration) error { if planOutputJSON(cmd) { return nil } wodby1.PrintReview(cmd.OutOrStdout(), plan, prepared...) if continuing { fmt.Fprintln(cmd.OutOrStdout(), "\nContinuing the saved migration plan shown above.") + } else if restarting { + fmt.Fprintln(cmd.OutOrStdout(), "\nFresh migration plan shown above. It has not started.") } else { fmt.Fprintln(cmd.OutOrStdout(), "\nApplying the migration plan shown above.") } @@ -2349,6 +2430,45 @@ func runMigrationRollback( return nil } +type migrationRestartCleanup struct { + StatePath string + State *wodby1.MigrationState + Plan wodby1.RollbackPlan + AppName string +} + +// runMigrationRestartCleanupLocked executes cleanup that was already shown and +// approved as part of the fresh migration plan. The caller holds the migration +// state lock and continues with the new apply after this returns. +func runMigrationRestartCleanupLocked( + cmd *cobra.Command, + opts *options, + targetClient *wodby1.TargetClient, + cleanup migrationRestartCleanup, +) error { + w := cmd.OutOrStdout() + headingColor := cliColorBold + cliColorGreen + if cleanup.Plan.DeletesResources() { + headingColor = cliColorBold + cliColorRed + } + fmt.Fprintln(w, cliColor(w, headingColor, "\nRestart cleanup execution")) + + executor, err := wodby1.NewMigrationExecutor(targetClient, wodby1.MigrationExecutorOptions{ + StatePath: cleanup.StatePath, + PollInterval: opts.pollInterval, + OperationTimeout: opts.waitTimeout, + Progress: migrationProgressReporter(cmd), + }) + if err != nil { + return err + } + if err := executor.Rollback(cmd.Context(), cleanup.State, cleanup.Plan, cleanup.AppName); err != nil { + return errors.Wrap(err, "restart cleanup stopped; inspect Wodby 2 and rerun the same --apply --restart command") + } + fmt.Fprintln(w, cliColor(w, cliColorGreen, "Restart cleanup completed. The saved migration will now be replaced.")) + return nil +} + // confirmRollback requires the app name to be typed back. Rollback destroys // imported data, so a bare y/N is too easy to answer on the wrong terminal. func confirmRollback(cmd *cobra.Command, approved bool, appName string) error { @@ -2400,6 +2520,47 @@ func confirmApply(cmd *cobra.Command, approved bool) error { return nil } +// confirmRestart is the only approval requested for a restart. When cleanup +// deletes target resources, typing a deliberate phrase authorizes both that +// deletion and the fresh plan shown immediately above it. +func confirmRestart(cmd *cobra.Command, approved bool, confirmation string, destructive bool) error { + if approved { + if !planOutputJSON(cmd) { + fmt.Fprintln(cmd.OutOrStdout(), cliColor(cmd.OutOrStdout(), cliColorGreen, "Restart and fresh migration approved with --yes.")) + } + return nil + } + if planOutputJSON(cmd) { + return errors.New("--output json requires --yes when restarting a migration") + } + if !destructive { + fmt.Fprint(cmd.OutOrStdout(), "\nReplace the saved plan and state, then apply this fresh migration? [y/N] ") + line, err := bufio.NewReader(cmd.InOrStdin()).ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + return errors.Wrap(err, "read restart confirmation") + } + answer := strings.ToLower(strings.TrimSpace(line)) + if answer != "y" && answer != "yes" { + return errors.New("restart canceled; the saved plan and state were kept and no Wodby 2 resource was deleted") + } + } else { + fmt.Fprintf( + cmd.OutOrStdout(), + "\nType %q to delete the resources listed above and apply this fresh migration: ", + confirmation, + ) + line, err := bufio.NewReader(cmd.InOrStdin()).ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + return errors.Wrap(err, "read restart confirmation") + } + if strings.TrimSpace(line) != confirmation { + return errors.New("restart canceled; the saved plan and state were kept and no Wodby 2 resource was deleted") + } + } + fmt.Fprintln(cmd.OutOrStdout(), cliColor(cmd.OutOrStdout(), cliColorGreen, "Restart and fresh migration approved.")) + return nil +} + func printSingleResumeNotice( cmd *cobra.Command, planPath string, @@ -2508,24 +2669,63 @@ func migrationPhaseLabel(phase wodby1.MigrationPhase) string { } } -func printRestartNotice(cmd *cobra.Command, planPath string, statePath string) { +func printRestartConflict( + cmd *cobra.Command, + sourceKind string, + sourceID string, + cleanups []migrationRestartCleanup, + hasStateOnly bool, + rollbackAvailable bool, + planPath string, + statePath string, +) { w := cmd.OutOrStdout() if planOutputJSON(cmd) { w = cmd.ErrOrStderr() } - fmt.Fprintln(w, "Starting the migration from scratch as requested by --restart.") - fmt.Fprintf(w, "The safely restartable state will be replaced: %s\n", statePath) - fmt.Fprintf(w, "The applied plan will be regenerated: %s\n", planPath) -} - -func printDeletedTargetRestartNotice(cmd *cobra.Command, planPath string, statePath string, targetAppID int) { - w := cmd.OutOrStdout() - if planOutputJSON(cmd) { - w = cmd.ErrOrStderr() + destructive := false + for _, cleanup := range cleanups { + destructive = destructive || cleanup.Plan.DeletesResources() + } + headingColor := cliColorBold + cliColorOrange + heading := "RESTART PAUSED: confirmation required" + if destructive { + headingColor = cliColorBold + cliColorRed + heading = "RESTART BLOCKED: destructive cleanup approval required" + } + fmt.Fprintln(w, cliColor(w, headingColor, "\n"+heading)) + fmt.Fprintln(w, cliColor(w, cliColorBold, "Existing saved migration found")) + fmt.Fprintf(w, "Source: %s %s\n", sourceKind, sourceID) + fmt.Fprintln(w, "A fresh migration cannot start until the saved migration is either continued or cleaned up.") + cleanupHeading := "\nCleanup required before restart" + if destructive { + fmt.Fprintln(w, cliColor(w, cliColorBold+cliColorRed, cleanupHeading)) + } else { + fmt.Fprintln(w, cliColor(w, cliColorBold, cleanupHeading)) + } + if len(cleanups) == 0 { + fmt.Fprintln(w, "No migration-created Wodby 2 resources need to be deleted.") + } + for index, cleanup := range cleanups { + if len(cleanups) > 1 { + fmt.Fprintf(w, "\nApp %d/%d: %s\n", index+1, len(cleanups), cleanup.AppName) + } + fmt.Fprint(w, cleanup.Plan.DescribeRestart(cleanup.AppName)) + } + if hasStateOnly { + fmt.Fprintln(w, "Saved state without target mutations will be replaced.") + } + fmt.Fprintln(w, "\nChoose how to proceed:") + fmt.Fprintln(w, " Continue saved migration Stop now and rerun the same --apply command without --restart") + if rollbackAvailable { + fmt.Fprintln(w, " Clean up and stop Stop now and rerun the same command with --rollback instead of --apply --restart") + } + fmt.Fprintln(w, " Restart now Confirm below to clean up and immediately apply the fresh plan") + fmt.Fprintln(w, "\nNothing has been deleted and the fresh migration has not started.") + if viper.GetBool("verbose") { + fmt.Fprintf(w, " Plan file: %s\n", planPath) + fmt.Fprintf(w, " Resume-state file: %s\n", statePath) } - fmt.Fprintf(w, "Saved target app ID %d no longer exists; --restart will replace its stale migration state.\n", targetAppID) - fmt.Fprintf(w, "The previous applied plan will be replaced: %s\n", planPath) - fmt.Fprintf(w, "The stale resume state will be replaced: %s\n", statePath) } func rejectBlockedMigrationAction(cmd *cobra.Command, plan wodby1.Plan, action string, prepared ...wodby1.PreparedMigration) error { diff --git a/cmd/wodby/migrate/migrate_test.go b/cmd/wodby/migrate/migrate_test.go index 41d90e0..5cd605f 100644 --- a/cmd/wodby/migrate/migrate_test.go +++ b/cmd/wodby/migrate/migrate_test.go @@ -757,6 +757,108 @@ func TestWodby1ServerRestartReplansAfterDefinitiveRejection(t *testing.T) { } } +func TestWodby1ServerRestartCleansUpMigrationCreatedTargetApp(t *testing.T) { + fixture := newMigrationAPIFixture(t, "admin", "ok", false) + defer fixture.Close() + fixture.setSourceKind("server") + fixture.mu.Lock() + fixture.targetApp101Exists = true + fixture.mu.Unlock() + setMigrationTargetConfig(t, fixture.target.URL+"/v1", "target-key", "") + t.Setenv("TMPDIR", t.TempDir()) + statePath := filepath.Join(t.TempDir(), "server-state.json") + + var preview bytes.Buffer + previewCmd := newWodby1ServerCommand() + previewCmd.SilenceUsage = true + previewCmd.SetOut(&preview) + previewCmd.SetArgs(fixture.serverPlanArgs("", "json")) + if err := previewCmd.Execute(); err != nil { + t.Fatal(err) + } + var plan wodby1.Plan + if err := json.Unmarshal(preview.Bytes(), &plan); err != nil { + t.Fatal(err) + } + planPath, _, err := artifactPaths("server", "server-1", statePath) + if err != nil { + t.Fatal(err) + } + if err := ensureArtifactDirectories(planPath, statePath); err != nil { + t.Fatal(err) + } + if err := writePlanFile(planPath, plan); err != nil { + t.Fatal(err) + } + childStatePath := serverAppStatePath(statePath, "app-1") + saveSuccessfulTargetState(t, childStatePath, wodby1.MigrationStateIdentity{ + Source: wodby1.MigrationStateSourceIdentity{ + Kind: "app", ID: "app-1", ConfigDigest: strings.Repeat("a", 64), + }, + PlanHash: strings.Repeat("b", 64), + Target: wodby1.MigrationStateTarget{ + OrgID: plan.Target.OrgID, ProjectID: plan.Target.ProjectID, ClusterID: plan.Target.ClusterID, + }, + }, "instance-1", 101, 201) + + var output bytes.Buffer + cmd := newWodby1ServerCommand() + cmd.SilenceUsage = true + cmd.SetOut(&output) + cmd.SetIn(strings.NewReader("restart server-1\n")) + cmd.SetArgs(append(withoutCLIArg(fixture.serverPlanArgs("", "text"), "--yes"), + "--state-file", statePath, + "--apply", + "--restart", + )) + applyErr := cmd.Execute() + if applyErr == nil || !strings.Contains(applyErr.Error(), "server migration completed with 2 failed app(s)") { + t.Fatalf("apply error = %v", applyErr) + } + for _, expected := range []string{ + "Existing saved migration found", + `Type "restart server-1" to delete the resources listed above and apply this fresh migration`, + "Restart cleanup execution", + "app \"demo\" (ID 101)", + "Target app ID 101 deleted", + "Restart cleanup completed. The saved migration will now be replaced.", + } { + if !strings.Contains(output.String(), expected) { + t.Fatalf("restart output missing %q:\n%s", expected, output.String()) + } + } + if strings.Count(output.String(), `Type "restart server-1"`) != 1 || + strings.Contains(output.String(), "Proceed with this migration? [y/N]") { + t.Fatalf("server restart requested more than one approval:\n%s", output.String()) + } + planIndex := strings.Index(output.String(), "Wodby 1 to Wodby 2 migration plan") + blockIndex := strings.Index(output.String(), "RESTART BLOCKED: destructive cleanup approval required") + promptIndex := strings.Index(output.String(), `Type "restart server-1"`) + if planIndex < 0 || blockIndex <= planIndex || promptIndex <= blockIndex { + t.Fatalf("restart blocker must follow the plan and remain next to its confirmation:\n%s", output.String()) + } + fixture.mu.Lock() + requests := append([]string(nil), fixture.targetRequests...) + targetExists := fixture.targetApp101Exists + fixture.mu.Unlock() + if targetExists { + t.Fatal("restart preserved the target app created by the prior migration") + } + deleteIndex, laterMutationIndex := -1, -1 + for i, request := range requests { + if request == "DELETE /v1/apps/101" { + deleteIndex = i + } + if deleteIndex >= 0 && i > deleteIndex && !strings.HasPrefix(request, "GET ") { + laterMutationIndex = i + break + } + } + if deleteIndex < 0 || laterMutationIndex < 0 || deleteIndex >= laterMutationIndex { + t.Fatalf("target requests do not clean up before fresh apply: %v", requests) + } +} + func TestWodby1ServerResumeStateRequiresSavedPlan(t *testing.T) { fixture := newMigrationAPIFixture(t, "admin", "ok", false) defer fixture.Close() @@ -1183,9 +1285,12 @@ func TestWodby1AppCommandRestartsIncompatiblePlanAfterTargetDeletion(t *testing. t.Fatalf("restart error = %v", err) } for _, expected := range []string{ - "Saved target app ID 101 no longer exists", - "The previous applied plan will be replaced: " + planPath, - "The stale resume state will be replaced: " + statePath, + "Existing saved migration found", + "RESTART BLOCKED: destructive cleanup approval required", + "Restart cleanup execution", + "Target app ID 101 is already deleted", + "A fresh migration cannot start until the saved migration is either continued or cleaned up", + "The saved migration will now be replaced", } { if !strings.Contains(output.String(), expected) { t.Fatalf("restart output missing %q:\n%s", expected, output.String()) @@ -1207,7 +1312,7 @@ func TestWodby1AppCommandRestartsIncompatiblePlanAfterTargetDeletion(t *testing. } } -func TestWodby1AppCommandRequiresDeletingTargetBeforeRestartingIncompatiblePlan(t *testing.T) { +func TestWodby1AppCommandRestartDeletesRecordedTargetForIncompatiblePlan(t *testing.T) { fixture := newMigrationAPIFixture(t, "admin", "ok", false) defer fixture.Close() fixture.setTargetApp101Exists(true) @@ -1217,27 +1322,48 @@ func TestWodby1AppCommandRequiresDeletingTargetBeforeRestartingIncompatiblePlan( _, planPath, statePath := saveIncompatibleAppliedPlan(t, fixture, 101) cmd := newWodby1AppCommand() cmd.SilenceUsage = true - cmd.SetOut(&bytes.Buffer{}) + var output bytes.Buffer + cmd.SetOut(&output) cmd.SetArgs(append(fixture.planArgs("", "text"), "--apply", "--restart")) err := cmd.Execute() - if err == nil { - t.Fatal("expected existing target app error") + if err == nil || !strings.Contains(err.Error(), "app creation is ambiguous") { + t.Fatalf("restart error = %v", err) } for _, expected := range []string{ - "incompatible plan schema", - "Plan: " + planPath, - "State: " + statePath, - "Target app ID 101 still exists", - "Delete it", - "--apply --restart", + "Existing saved migration found", + "Restart cleanup execution", + `app "app-1" (ID 101)`, + "Target app ID 101 deleted", + "The saved migration will now be replaced", } { - if !strings.Contains(err.Error(), expected) { - t.Fatalf("error missing %q:\n%s", expected, err) + if !strings.Contains(output.String(), expected) { + t.Fatalf("restart output missing %q:\n%s", expected, output.String()) } } + newPlan, loadErr := wodby1.LoadReviewedPlan(planPath) + if loadErr != nil || newPlan.Schema != wodby1.MigrationPlanSchema { + t.Fatalf("replacement plan = %#v, %v", newPlan, loadErr) + } state, loadErr := wodby1.InspectMigrationState(statePath) - if loadErr != nil || state.App.TargetID != 101 { - t.Fatalf("unsafe restart changed state: %#v, %v", state, loadErr) + if loadErr != nil || state.App.TargetID == 101 { + t.Fatalf("replacement state = %#v, %v", state, loadErr) + } + + // Once restart has replaced the old artifacts and begun the fresh apply, + // an ordinary --apply command must resume that new migration. Requiring + // --restart again would repeat cleanup instead of continuing completed work. + var resumeOutput bytes.Buffer + resume := newWodby1AppCommand() + resume.SilenceUsage = true + resume.SetOut(&resumeOutput) + resume.SetArgs(append(fixture.planArgs("", "text"), "--apply")) + resumeErr := resume.Execute() + if resumeErr == nil || !strings.Contains(resumeErr.Error(), "ambiguous") { + t.Fatalf("post-restart resume error = %v", resumeErr) + } + if strings.Contains(resumeErr.Error(), "--restart") || + !strings.Contains(resumeOutput.String(), "Continuing the saved migration plan shown above") { + t.Fatalf("post-restart run did not continue with ordinary --apply:\nerror: %v\n%s", resumeErr, resumeOutput.String()) } } @@ -1385,8 +1511,8 @@ func TestWodby1AppCommandRestartRejectsStateWithTargetMutationRisk(t *testing.T) restart.SetOut(&bytes.Buffer{}) restart.SetArgs(append(args, "--restart")) err := restart.Execute() - if err == nil || !strings.Contains(err.Error(), "records target mutations") || - !strings.Contains(err.Error(), "continue without --restart") { + if err == nil || !strings.Contains(err.Error(), "cannot safely identify every target mutation") || + !strings.Contains(err.Error(), "retry them before restarting") { t.Fatalf("restart error = %v", err) } if fixture.sourceRequestCount() != sourceRequests || len(fixture.targetRequestPaths()) != targetRequests { @@ -1394,6 +1520,95 @@ func TestWodby1AppCommandRestartRejectsStateWithTargetMutationRisk(t *testing.T) } } +func TestWodby1AppCommandRestartCleansUpPreAppStackAndIntegration(t *testing.T) { + fixture := newMigrationAPIFixture(t, "admin", "ok", false) + defer fixture.Close() + setMigrationTargetConfig(t, fixture.target.URL+"/v1", "target-key", "") + t.Setenv("TMPDIR", t.TempDir()) + + var preview bytes.Buffer + previewCmd := newWodby1AppCommand() + previewCmd.SilenceUsage = true + previewCmd.SetOut(&preview) + previewCmd.SetArgs(fixture.planArgs("", "json")) + if err := previewCmd.Execute(); err != nil { + t.Fatal(err) + } + var plan wodby1.Plan + if err := json.Unmarshal(preview.Bytes(), &plan); err != nil { + t.Fatal(err) + } + planPath, statePath, err := artifactPaths("app", "app-1", "") + if err != nil { + t.Fatal(err) + } + if err := ensureArtifactDirectories(planPath, statePath); err != nil { + t.Fatal(err) + } + if err := writePlanFile(planPath, plan); err != nil { + t.Fatal(err) + } + state, err := wodby1.NewMigrationState(migrationStateIdentity(plan), []string{"instance-1"}) + if err != nil { + t.Fatal(err) + } + if err := state.MarkAppOperationIntent("stack_create"); err != nil { + t.Fatal(err) + } + if err := state.MarkAppOperationCreated("stack_create", 700, 0); err != nil { + t.Fatal(err) + } + if err := state.MarkAppOperationIntent("integration_resolve.smtp"); err != nil { + t.Fatal(err) + } + if err := state.MarkAppOperationCreated("integration_resolve.smtp", 610, 0); err != nil { + t.Fatal(err) + } + if err := state.SetStatus(wodby1.MigrationStatusRunning); err != nil { + t.Fatal(err) + } + if err := state.SetPhase(wodby1.MigrationPhasePrepare); err != nil { + t.Fatal(err) + } + if err := wodby1.SaveMigrationState(statePath, state); err != nil { + t.Fatal(err) + } + + var output bytes.Buffer + restart := newWodby1AppCommand() + restart.SilenceUsage = true + restart.SetOut(&output) + restart.SetArgs(append(fixture.planArgs("", "text"), "--apply", "--restart")) + err = restart.Execute() + if err == nil || !strings.Contains(err.Error(), "app creation is ambiguous") { + t.Fatalf("restart error = %v", err) + } + for _, expected := range []string{ + "the stack this migration generated (ID 700)", + "integration ID 610, created by this migration", + "Target stack ID 700 deleted", + "Target integration ID 610 deleted", + "The saved migration will now be replaced", + } { + if !strings.Contains(output.String(), expected) { + t.Fatalf("restart output missing %q:\n%s", expected, output.String()) + } + } + requests := fixture.targetRequestPaths() + stackDelete, integrationDelete := -1, -1 + for index, request := range requests { + if request == "DELETE /v1/stacks/700" { + stackDelete = index + } + if request == "DELETE /v1/integrations/610" { + integrationDelete = index + } + } + if stackDelete < 0 || integrationDelete <= stackDelete { + t.Fatalf("restart cleanup order = %v", requests) + } +} + func TestWodby1AppCommandRestartsAfterSavedTargetWasDeleted(t *testing.T) { fixture := newMigrationAPIFixture(t, "admin", "ok", false) defer fixture.Close() @@ -1459,7 +1674,8 @@ func TestWodby1AppCommandRestartsAfterSavedTargetWasDeleted(t *testing.T) { if err == nil || !strings.Contains(err.Error(), "app creation is ambiguous") { t.Fatalf("restart error = %v", err) } - if !strings.Contains(output.String(), "Saved target app ID 101 no longer exists; --restart will replace its stale migration state.") { + if !strings.Contains(output.String(), "Restart cleanup") || + !strings.Contains(output.String(), "Target app ID 101 is already deleted") { t.Fatalf("restart output is unclear:\n%s", output.String()) } state, err := wodby1.InspectMigrationState(statePath) @@ -1541,7 +1757,10 @@ func TestWodby1AppCommandRestartReplansAfterDefinitiveRejection(t *testing.T) { continueCmd.SetArgs(append(fixture.planArgs("", "text"), "--apply")) continueErr := continueCmd.Execute() if continueErr == nil || !strings.Contains(continueErr.Error(), "cannot continue from saved plan") || - !strings.Contains(continueErr.Error(), "saved plan was not overwritten") { + !strings.Contains(continueErr.Error(), "saved plan was not overwritten") || + !strings.Contains(continueErr.Error(), "wodby migrate wodby1 app app-1") || + !strings.Contains(continueErr.Error(), "--apply --restart") || + strings.Contains(continueErr.Error(), testSourceToken) { t.Fatalf("continue error = %v", continueErr) } preservedPlan, err := wodby1.LoadReviewedPlan(planPath) @@ -1964,6 +2183,16 @@ func (f *migrationAPIFixture) Close() { f.target.Close() } +func withoutCLIArg(args []string, omitted string) []string { + result := make([]string, 0, len(args)) + for _, arg := range args { + if arg != omitted { + result = append(result, arg) + } + } + return result +} + func (f *migrationAPIFixture) planArgs(planPath string, output string) []string { _ = planPath return []string{ @@ -2097,9 +2326,10 @@ func (f *migrationAPIFixture) handleSource(w http.ResponseWriter, r *http.Reques } func (f *migrationAPIFixture) handleTarget(w http.ResponseWriter, r *http.Request) { + isCapacityPreflight := r.Method == http.MethodPost && r.URL.Path == "/v1/orgs/11/actions/preflight-app-service-capacity" f.mu.Lock() f.targetRequests = append(f.targetRequests, r.Method+" "+r.URL.RequestURI()) - if r.Method != http.MethodGet { + if r.Method != http.MethodGet && !isCapacityPreflight { f.mutations++ } stackRevID := f.stackRevID @@ -2108,6 +2338,35 @@ func (f *migrationAPIFixture) handleTarget(w http.ResponseWriter, r *http.Reques targetApp101InstanceName := f.targetApp101InstanceName f.mu.Unlock() + if r.Method == http.MethodDelete && r.URL.Path == "/v1/apps/101" { + if !targetApp101Exists { + http.Error(w, "not found", http.StatusNotFound) + return + } + f.mu.Lock() + f.targetApp101Exists = false + f.mu.Unlock() + writeMigrationJSON(w, wodby1.TargetOperationResult{Success: true}) + return + } + if r.Method == http.MethodDelete && (r.URL.Path == "/v1/stacks/700" || r.URL.Path == "/v1/integrations/610") { + writeMigrationJSON(w, wodby1.TargetOperationResult{Success: true}) + return + } + if isCapacityPreflight { + var input struct { + AdditionalUsage int `json:"additionalUsage"` + } + if err := json.NewDecoder(r.Body).Decode(&input); err != nil || input.AdditionalUsage < 0 { + http.Error(w, "invalid capacity request", http.StatusBadRequest) + return + } + writeMigrationJSON(w, wodby1.TargetAppServiceCapacityPreflight{ + Allowed: true, Enforced: true, UsageIncluded: 10, + ProjectedUsage: float64(input.AdditionalUsage), + }) + return + } if r.Method != http.MethodGet { http.Error(w, "unexpected target mutation", http.StatusInternalServerError) return @@ -2149,8 +2408,8 @@ func (f *migrationAPIFixture) handleTarget(w http.ResponseWriter, r *http.Reques case "/v1/orgs/11": writeMigrationJSON(w, migrationTargetOrgFixture()) case "/v1/user": - writeMigrationJSON(w, wodby1.TargetCurrentUser{ - ID: userID, Email: "customer@example.test", IsAdmin: f.platformAdmin, + writeMigrationJSON(w, map[string]interface{}{ + "id": userID, "email": "customer@example.test", "isAdmin": f.platformAdmin, }) case "/v1/org-memberships": writeMigrationJSON(w, []wodby1.TargetOrgMembership{{ @@ -2232,12 +2491,6 @@ func migrationTargetOrgFixture() wodby1.TargetOrg { return wodby1.TargetOrg{ ID: 11, Name: "acme", Title: "Acme", Capabilities: &wodby1.TargetOrgCapabilities{CustomDomains: true, CronSchedules: true}, - Subscription: &wodby1.TargetOrgSubscription{ - Status: "ACTIVE", - Plan: &wodby1.TargetOrgSubscriptionPlan{ - Name: "team", Title: "Team", Usage: 2, UsageIncluded: 10, - }, - }, } } @@ -2603,3 +2856,46 @@ func TestRollbackJSONOutputRequiresExplicitApproval(t *testing.T) { t.Fatalf("non-interactive rollback returned %v", err) } } + +func TestRestartConfirmationApprovesCleanupAndFreshApplyOnce(t *testing.T) { + cmd := newWodby1InstanceCommand() + var output bytes.Buffer + cmd.SetOut(&output) + cmd.SetIn(strings.NewReader("demo\n")) + + if err := confirmRestart(cmd, false, "demo", true); err != nil { + t.Fatalf("typed restart confirmation returned %v", err) + } + text := output.String() + if strings.Count(text, "Type \"demo\"") != 1 || + !strings.Contains(text, "delete the resources listed above and apply this fresh migration") || + !strings.Contains(text, "Restart and fresh migration approved") { + t.Fatalf("restart confirmation is unclear:\n%s", text) + } +} + +func TestRestartConfirmationKeepsSavedMigrationOnMismatch(t *testing.T) { + cmd := newWodby1InstanceCommand() + cmd.SetOut(&bytes.Buffer{}) + cmd.SetIn(strings.NewReader("y\n")) + + err := confirmRestart(cmd, false, "demo", true) + if err == nil || !strings.Contains(err.Error(), "saved plan and state were kept") || + !strings.Contains(err.Error(), "no Wodby 2 resource was deleted") { + t.Fatalf("mismatched restart confirmation returned %v", err) + } +} + +func TestRestartConfirmationWithoutCleanupUsesYesNo(t *testing.T) { + cmd := newWodby1InstanceCommand() + var output bytes.Buffer + cmd.SetOut(&output) + cmd.SetIn(strings.NewReader("yes\n")) + + if err := confirmRestart(cmd, false, "demo", false); err != nil { + t.Fatalf("non-destructive restart confirmation returned %v", err) + } + if !strings.Contains(output.String(), "Replace the saved plan and state") { + t.Fatalf("non-destructive restart prompt is unclear:\n%s", output.String()) + } +} diff --git a/pkg/migration/wodby1/plan.go b/pkg/migration/wodby1/plan.go index 38875c7..394173a 100644 --- a/pkg/migration/wodby1/plan.go +++ b/pkg/migration/wodby1/plan.go @@ -368,7 +368,6 @@ func BuildPlan(export Export, opts PlanOptions) (Plan, error) { plan.Target.DiscoveryVerified = true plan.Target.Capabilities = &capabilities plan.Target.OrgCapabilities = opts.TargetScope.Org.Capabilities - plan.Target.Subscription = opts.TargetScope.Org.Subscription plan.Target.OrgDefaultTimeZone = opts.TargetScope.Org.DefaultTimeZone if !plan.Target.OrgOwnerOrAdminVerified { plan.addReview(SeverityBlocking, "", "", "target authorization", "target discovery did not verify an active Wodby 2 organization owner or administrator") diff --git a/pkg/migration/wodby1/preflight.go b/pkg/migration/wodby1/preflight.go index 8f6bd63..64e0f78 100644 --- a/pkg/migration/wodby1/preflight.go +++ b/pkg/migration/wodby1/preflight.go @@ -304,6 +304,28 @@ func (c *TargetClient) PreflightTarget( } planApps[item.SourceUUID] = item } + var targetApps []TargetApp + if plan.Target.AppID == 0 { + items, err := c.ListApps(ctx, plan.Target.OrgID) + if err != nil { + return PreparedMigration{}, errors.Wrap(err, "discover existing target apps") + } + targetApps = items + } + var targetInstances []TargetAppInstance + targetInstancesLoaded := false + loadTargetInstances := func() ([]TargetAppInstance, error) { + if targetInstancesLoaded { + return targetInstances, nil + } + items, err := c.ListOrgAppInstances(ctx, plan.Target.OrgID) + if err != nil { + return nil, err + } + targetInstances = items + targetInstancesLoaded = true + return targetInstances, nil + } prepared := PreparedMigration{Apps: []PreparedAppMigration{}} findings := []ReviewItem{} for appIndex, appExport := range appExports { @@ -321,7 +343,7 @@ func (c *TargetClient) PreflightTarget( if plan.Target.AppID > 0 { existingApp, appFound, err = c.FindAppByID(ctx, plan.Target.AppID) } else { - existingApp, appFound, err = c.FindAppExact(ctx, plan.Target.OrgID, appExport.App.Name) + existingApp, appFound, err = findTargetAppExact(targetApps, appExport.App.Name) } if err != nil { return PreparedMigration{}, errors.Wrap(err, "check target app availability") @@ -400,6 +422,29 @@ func (c *TargetClient) PreflightTarget( Subject: "target app name", Message: message, }) + } else if !appFound && allowedTargetAppID == 0 && !allowRecovery && len(targetApps) != 0 { + instances, listErr := loadTargetInstances() + if listErr != nil { + return PreparedMigration{}, errors.Wrap(listErr, "inspect target instances for an earlier migration") + } + matches, matchErr := c.findPriorMigrationAppMatches( + ctx, + plan.Target.OrgID, + appExport.App, + targetApps, + instances, + ) + if matchErr != nil { + return PreparedMigration{}, errors.Wrap(matchErr, "detect an earlier migration of the source app") + } + if len(matches) != 0 { + findings = append(findings, ReviewItem{ + Severity: SeverityBlocking, + App: appExport.App.Name, + Subject: "previous migration", + Message: priorMigrationBlockerMessage(plan.Source.Kind, appExport, matches), + }) + } } repositoryFindings, err := c.resolveRepositoryPlan(ctx, appExport.App, appPlan.Repository, opts.SkipCode) if err != nil { @@ -549,7 +594,11 @@ func (c *TargetClient) PreflightTarget( prepared.StackAdditions = prepared.Apps[0].StackAdditions prepared.Integrations = prepared.Apps[0].Integrations } - findings = append(findings, targetServiceCapacityFindings(plan, prepared, opts)...) + capacityFindings, err := c.targetServiceCapacityFindings(ctx, plan, prepared, opts) + if err != nil { + return PreparedMigration{}, err + } + findings = append(findings, capacityFindings...) if err := plan.AddReviewItems(findings...); err != nil { return PreparedMigration{}, err } @@ -580,36 +629,21 @@ func preparedMigrationUsesLegacyWodby1EnvVars(prepared PreparedMigration) bool { return false } -func targetServiceCapacityFindings( +func (c *TargetClient) targetServiceCapacityFindings( + ctx context.Context, plan *Plan, prepared PreparedMigration, opts TargetPreflightOptions, -) []ReviewItem { - if plan == nil || plan.Target.Subscription == nil || plan.Target.Subscription.Plan == nil { - return []ReviewItem{{ - Severity: SeverityBlocking, - Subject: "target app-service capacity", - Message: "target Wodby 2 API did not return subscription usage and allowance; capacity cannot be verified safely", - }} +) ([]ReviewItem, error) { + if plan == nil { + return nil, errors.New("migration plan is required") } // A resume can contain target services already included in live usage. The // backend repeats its atomic limit check for every remaining app/instance, // while recounting the entire saved plan here would double-count them. if opts.AllowedTargetAppID > 0 || opts.AllowStateBackedAppRecovery || len(opts.AllowedTargetAppIDs) != 0 || len(opts.StateBackedAppRecovery) != 0 { - return nil - } - subscription := plan.Target.Subscription - if !strings.EqualFold(strings.TrimSpace(subscription.Status), "ACTIVE") && - !strings.EqualFold(strings.TrimSpace(subscription.Status), "CANCELING") { - return []ReviewItem{{ - Severity: SeverityBlocking, - Subject: "target subscription", - Message: fmt.Sprintf("target subscription status %q cannot accept new app services", subscription.Status), - }} - } - if !strings.EqualFold(strings.TrimSpace(subscription.Plan.Name), "developer") { - return nil + return nil, nil } additional := 0 for _, app := range prepared.Apps { @@ -621,21 +655,24 @@ func targetServiceCapacityFindings( } } } - projected := subscription.Plan.Usage + float64(additional) - if projected <= subscription.Plan.UsageIncluded { - return nil + decision, err := c.PreflightAppServiceCapacity(ctx, plan.Target.OrgID, additional) + if err != nil { + return nil, err + } + if decision.Allowed { + return nil, nil } return []ReviewItem{{ Severity: SeverityBlocking, Subject: "target app-service capacity", Message: fmt.Sprintf( - "migration needs %d enabled target app service(s), which would raise free-plan usage from %.0f to %.0f; the current allowance is %.0f. Disable or remap optional services, remove other usage, or upgrade the target plan", + "migration needs %d enabled target app service(s), but the backend capacity preflight rejected projected usage from %.0f to %.0f (included usage: %.0f). Disable or remap optional services, remove other usage, adjust the target spending limit, or upgrade the target plan", additional, - subscription.Plan.Usage, - projected, - subscription.Plan.UsageIncluded, + decision.Usage, + decision.ProjectedUsage, + decision.UsageIncluded, ), - }} + }}, nil } // preflightWodbyCIPipelines checks every distinct source ref used by an app, @@ -1639,6 +1676,147 @@ func contextSourceInstance(app AppExport, sourceUUID string) (Instance, bool) { return Instance{}, false } +type priorMigrationAppMatch struct { + App TargetApp + Instances []TargetAppInstance + Stacks []TargetStack +} + +// findPriorMigrationAppMatches detects apps whose active instance stack names +// carry the digest embedded by generatedStackNaming for this Wodby 1 app. This +// works even when the target app itself was renamed after migration. +func (c *TargetClient) findPriorMigrationAppMatches( + ctx context.Context, + orgID int, + source App, + apps []TargetApp, + instances []TargetAppInstance, +) ([]priorMigrationAppMatch, error) { + fingerprint := shortDigest(source.UUID) + if fingerprint == "" || len(apps) == 0 || len(instances) == 0 { + return nil, nil + } + stacks, err := c.listStackRevisionCandidates(ctx, orgID, 0, fingerprint) + if err != nil { + return nil, err + } + matchingStacks := map[int]TargetStack{} + for _, stack := range stacks { + if stack.OrgID != orgID || stack.Public || !strings.Contains(stack.Name, fingerprint) { + continue + } + if err := validateTargetStack(stack); err != nil { + return nil, err + } + matchingStacks[stack.ID] = stack + } + if len(matchingStacks) == 0 { + return nil, nil + } + + appsByID := make(map[int]TargetApp, len(apps)) + for _, app := range apps { + appsByID[app.ID] = app + } + matchesByAppID := map[int]*priorMigrationAppMatch{} + seenStacksByAppID := map[int]map[int]bool{} + for _, instance := range instances { + stack, matched := matchingStacks[instance.StackID] + app, visible := appsByID[instance.AppID] + if !matched || !visible { + continue + } + match := matchesByAppID[app.ID] + if match == nil { + match = &priorMigrationAppMatch{App: app} + matchesByAppID[app.ID] = match + seenStacksByAppID[app.ID] = map[int]bool{} + } + match.Instances = append(match.Instances, instance) + if !seenStacksByAppID[app.ID][stack.ID] { + match.Stacks = append(match.Stacks, stack) + seenStacksByAppID[app.ID][stack.ID] = true + } + } + + matches := make([]priorMigrationAppMatch, 0, len(matchesByAppID)) + for _, match := range matchesByAppID { + sort.Slice(match.Instances, func(i, j int) bool { + if match.Instances[i].Name == match.Instances[j].Name { + return match.Instances[i].ID < match.Instances[j].ID + } + return match.Instances[i].Name < match.Instances[j].Name + }) + sort.Slice(match.Stacks, func(i, j int) bool { return match.Stacks[i].ID < match.Stacks[j].ID }) + matches = append(matches, *match) + } + sort.Slice(matches, func(i, j int) bool { return matches[i].App.ID < matches[j].App.ID }) + return matches, nil +} + +func priorMigrationBlockerMessage(sourceKind string, source AppExport, matches []priorMigrationAppMatch) string { + var b strings.Builder + b.WriteString("generated target stack fingerprints identify an earlier migration of this Wodby 1 app: ") + for index, match := range matches { + if index != 0 { + b.WriteString("; ") + } + fmt.Fprintf(&b, "app %q (ID %d)", match.App.Name, match.App.ID) + if len(match.Stacks) != 0 { + b.WriteString(", stack(s) ") + for stackIndex, stack := range match.Stacks { + if stackIndex != 0 { + b.WriteString(", ") + } + fmt.Fprintf(&b, "%q (ID %d)", stack.Name, stack.ID) + } + } + if len(match.Instances) != 0 { + b.WriteString(", instance(s) ") + for instanceIndex, instance := range match.Instances { + if instanceIndex != 0 { + b.WriteString(", ") + } + fmt.Fprintf(&b, "%q (ID %d)", instance.Name, instance.ID) + } + } + } + b.WriteString(". No resume state at the expected path authorizes these resources, so this run is blocked before it can create a duplicate app. ") + + if sourceKind == "instance" && len(matches) == 1 { + plannedName := "" + if len(source.Instances) == 1 { + plannedName = source.Instances[0].Name + } + for _, instance := range matches[0].Instances { + if plannedName != "" && instance.Name == plannedName { + fmt.Fprintf( + &b, + "Target instance %q already exists there; use the original --state-file to resume or verify that migration. The CLI will not adopt it without its state.", + plannedName, + ) + return b.String() + } + } + fmt.Fprintf( + &b, + "To migrate this not-yet-created instance into the detected app, rerun explicitly with --target-app %d.", + matches[0].App.ID, + ) + return b.String() + } + + b.WriteString("Use the original migration --state-file to resume or verify it.") + if len(matches) == 1 { + fmt.Fprintf( + &b, + " To migrate another instance separately, run migrate wodby1 instance for that source instance with --target-app %d.", + matches[0].App.ID, + ) + } + return b.String() +} + // appCarriesMigrationFingerprint reports whether an existing Wodby 2 app looks // like the work of an earlier migration of this same Wodby 1 app. // diff --git a/pkg/migration/wodby1/preflight_test.go b/pkg/migration/wodby1/preflight_test.go index 488ea35..0c8679e 100644 --- a/pkg/migration/wodby1/preflight_test.go +++ b/pkg/migration/wodby1/preflight_test.go @@ -166,12 +166,64 @@ func TestPreflightTargetResolvesOfficialStackServicesAndRehashesPlan(t *testing. "/v1/service-revisions/102", "/v1/service-revisions/101", "/v1/integrations/44/options/remote-git-repo-file", + "/v1/orgs/8/actions/preflight-app-service-capacity", } if got := api.requestPaths(); !equalPreflightStrings(got, wantPaths) { t.Fatalf("target API paths = %#v, want %#v", got, wantPaths) } } +func TestPreflightTargetUsesBackendAppServiceCapacityDecision(t *testing.T) { + tests := []struct { + name string + decision TargetAppServiceCapacityPreflight + wantBlocking bool + }{ + { + name: "backend rejects projected usage", + decision: TargetAppServiceCapacityPreflight{ + Allowed: false, Enforced: true, Usage: 10, UsageIncluded: 10, ProjectedUsage: 11, + }, + wantBlocking: true, + }, + { + name: "backend exemption allows projected usage", + decision: TargetAppServiceCapacityPreflight{ + Allowed: true, Enforced: false, Usage: 0, UsageIncluded: 10, ProjectedUsage: 1, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + export := preflightFixtureExport(false) + export.Apps[0].Instances[0].Services = []Service{{Name: "php", Enabled: true}} + options := preflightOwnerPlanOptions() + options.SkipCode = true + options.SkipData = true + plan := preflightBuildPlan(t, export, options) + catalog := preflightSingleBuildCatalog("php", false) + catalog.capacityDecision = &test.decision + api := newPreflightTargetAPI(t, catalog) + + if _, err := api.client.PreflightTarget( + context.Background(), + export, + &plan, + TargetPreflightOptions{SkipCode: true, SkipData: true}, + ); err != nil { + t.Fatal(err) + } + if got := api.capacityRequests(); !reflect.DeepEqual(got, []int{1}) { + t.Fatalf("capacity requests = %#v, want [1]", got) + } + if got := preflightHasReview(plan, SeverityBlocking, "target app-service capacity", "backend capacity preflight rejected"); got != test.wantBlocking { + t.Fatalf("capacity blocker = %t, want %t: %#v", got, test.wantBlocking, plan.Review) + } + }) + } +} + func TestPreflightTargetSelectsPublicCatalogStackForManagedAppByDefault(t *testing.T) { export := preflightFixtureExport(false) options := preflightOwnerPlanOptions() @@ -451,6 +503,7 @@ func TestPreflightTargetUsesReviewedRevisionAfterLatestRevisionChanges(t *testin "/v1/service-revisions/103", "/v1/service-revisions/102", "/v1/service-revisions/101", + "/v1/orgs/8/actions/preflight-app-service-capacity", } if got := changedAPI.requestPaths(); !equalPreflightStrings(got, wantPaths) { t.Fatalf("target API paths = %#v, want exact reviewed reads %#v", got, wantPaths) @@ -499,6 +552,74 @@ func TestPreflightTargetBlocksUnrelatedAppNameCollisionButAllowsStateBackedApp(t } } +func TestPreflightTargetBlocksRenamedAppCreatedByPriorMigration(t *testing.T) { + for _, test := range []struct { + name string + targetInstanceName string + want []string + }{ + { + name: "same instance already migrated", + targetInstanceName: "prod", + want: []string{ + `app "renamed-target" (ID 91)`, + `instance(s) "prod" (ID 92)`, + `Target instance "prod" already exists there`, + "original --state-file", + }, + }, + { + name: "another instance can target existing app", + targetInstanceName: "stage", + want: []string{ + `app "renamed-target" (ID 91)`, + `instance(s) "stage" (ID 92)`, + "--target-app 91", + }, + }, + } { + t.Run(test.name, func(t *testing.T) { + export := preflightFixtureExport(false) + export.Source = &ExportSource{Kind: "instance", UUID: "inst-1"} + export.Apps[0].Instances[0].Services = nil + options := preflightOwnerPlanOptions() + options.SourceKind = "instance" + options.SourceID = "inst-1" + plan := preflightBuildPlan(t, export, options) + + catalog := preflightOfficialCatalog() + generated := generatedStackNaming(catalog.stacks["drupal11"], export.Apps[0].App) + catalog.apps = []TargetApp{{ID: 91, Name: "renamed-target", OrgID: 8}} + catalog.appInstances = []TargetAppInstance{{ + ID: 92, AppID: 91, Name: test.targetInstanceName, + ClusterID: 10, EnvID: 11, StackID: 55, StackRevID: 56, + }} + catalog.stacks[generated.Name] = TargetStack{ + ID: 55, Name: generated.Name, Title: generated.Title, Status: "OK", + RevID: 56, LatestRevNumber: 1, OrgID: 8, + } + api := newPreflightTargetAPI(t, catalog) + + if _, err := api.client.PreflightTarget( + context.Background(), + export, + &plan, + TargetPreflightOptions{SkipCode: true, SkipData: true}, + ); err != nil { + t.Fatal(err) + } + if !preflightHasReview(plan, SeverityBlocking, "previous migration", "blocked before it can create a duplicate app") { + t.Fatalf("previous migration blocker = %#v", plan.Review) + } + for _, expected := range test.want { + if !preflightHasReview(plan, SeverityBlocking, "previous migration", expected) { + t.Fatalf("previous migration blocker missing %q: %#v", expected, plan.Review) + } + } + }) + } +} + func TestPreflightTargetPreparesEveryServerAppWithPerAppRecovery(t *testing.T) { export := preflightFixtureExport(false) export.Source = &ExportSource{Kind: "server", UUID: "server-1"} @@ -596,11 +717,10 @@ func TestPreflightTargetRequiresVerifiedOrgOwnerOrAdminPlan(t *testing.T) { requestsAfterOwner := len(api.requestPaths()) memberOptions := preflightOwnerPlanOptions() - memberOptions.TargetScope.User.IsAdmin = true memberOptions.TargetScope.Membership.Role = "member" memberPlan := preflightBuildPlan(t, export, memberOptions) if memberPlan.Target.OrgOwnerOrAdminVerified { - t.Fatalf("platform admin/member was treated as an organization admin: %#v", memberPlan.Target) + t.Fatalf("organization member was treated as an organization admin: %#v", memberPlan.Target) } if !preflightHasReview( memberPlan, @@ -1339,6 +1459,7 @@ func TestNormalizeGitRefType(t *testing.T) { type preflightTargetCatalog struct { apps []TargetApp + appInstances []TargetAppInstance publicStacks []TargetStack remoteGitRepos map[int][]TargetRemoteGitRepo remoteGitRepoFiles map[string]bool @@ -1346,12 +1467,14 @@ type preflightTargetCatalog struct { stackRevisions map[int]TargetStackRevision stackServices map[int][]TargetStackService revisions map[int]TargetServiceRevision + capacityDecision *TargetAppServiceCapacityPreflight } type preflightTargetAPI struct { - client *TargetClient - mu sync.Mutex - paths []string + client *TargetClient + mu sync.Mutex + paths []string + capacityAdditional []int } func newPreflightTargetAPI(t *testing.T, catalog preflightTargetCatalog) *preflightTargetAPI { @@ -1362,6 +1485,27 @@ func newPreflightTargetAPI(t *testing.T, catalog preflightTargetCatalog) *prefli api.paths = append(api.paths, request.URL.Path) api.mu.Unlock() + if request.Method == http.MethodPost && request.URL.Path == "/v1/orgs/8/actions/preflight-app-service-capacity" { + var input struct { + AdditionalUsage int `json:"additionalUsage"` + } + if err := json.NewDecoder(request.Body).Decode(&input); err != nil || input.AdditionalUsage < 0 { + http.Error(w, "invalid capacity request", http.StatusBadRequest) + return + } + api.mu.Lock() + api.capacityAdditional = append(api.capacityAdditional, input.AdditionalUsage) + api.mu.Unlock() + decision := TargetAppServiceCapacityPreflight{ + Allowed: true, Enforced: true, UsageIncluded: 10, + ProjectedUsage: float64(input.AdditionalUsage), + } + if catalog.capacityDecision != nil { + decision = *catalog.capacityDecision + } + preflightWriteJSON(w, decision) + return + } if request.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return @@ -1373,6 +1517,19 @@ func newPreflightTargetAPI(t *testing.T, catalog preflightTargetCatalog) *prefli preflightWriteJSON(w, catalog.publicStacks) case request.URL.Path == "/v1/apps": preflightWriteJSON(w, catalog.apps) + case request.URL.Path == "/v1/app-instances": + if request.URL.Query().Get("orgId") != "8" { + http.Error(w, "invalid app instance scope", http.StatusBadRequest) + return + } + appID := request.URL.Query().Get("appId") + items := []TargetAppInstance{} + for _, item := range catalog.appInstances { + if appID == "" || appID == strconv.Itoa(item.AppID) { + items = append(items, item) + } + } + preflightWriteJSON(w, items) case strings.HasPrefix(request.URL.Path, "/v1/integrations/") && strings.HasSuffix(request.URL.Path, "/options/remote-git-repo-file"): value := strings.TrimSuffix( @@ -1420,12 +1577,29 @@ func newPreflightTargetAPI(t *testing.T, catalog preflightTargetCatalog) *prefli } preflightWriteJSON(w, repositories) case request.URL.Path == "/v1/stacks": - if request.URL.Query().Get("orgId") != "8" || - request.URL.Query().Get("projectIds") != "9" { + if request.URL.Query().Get("orgId") != "8" { http.Error(w, "invalid stack scope", http.StatusBadRequest) return } + projectIDs := request.URL.Query().Get("projectIds") + if projectIDs != "" && projectIDs != "9" { + http.Error(w, "invalid stack project scope", http.StatusBadRequest) + return + } name := request.URL.Query().Get("search") + if projectIDs == "" { + items := []TargetStack{} + for _, stack := range catalog.stacks { + if stack.OrgID == 8 && strings.Contains(stack.Name, name) { + if stack.Status == "" { + stack.Status = "OK" + } + items = append(items, stack) + } + } + preflightWriteJSON(w, TargetStacksResponse{Items: items, TotalCount: len(items)}) + break + } stack, found := catalog.stacks[name] if !found { preflightWriteJSON(w, TargetStacksResponse{Items: []TargetStack{}}) @@ -1536,6 +1710,12 @@ func (a *preflightTargetAPI) requestPaths() []string { return append([]string(nil), a.paths...) } +func (a *preflightTargetAPI) capacityRequests() []int { + a.mu.Lock() + defer a.mu.Unlock() + return append([]int(nil), a.capacityAdditional...) +} + func preflightWriteJSON(w http.ResponseWriter, value interface{}) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(value) @@ -1725,7 +1905,7 @@ func preflightOwnerPlanOptions() PlanOptions { TargetStackID: 7, TargetScope: &TargetScopeDiscovery{ User: TargetCurrentUser{ - ID: userID, Email: "owner@example.test", IsAdmin: false, + ID: userID, Email: "owner@example.test", }, Membership: TargetOrgMembership{ ID: 88, UserID: &userID, OrgID: 8, Role: "owner", Status: "ok", @@ -1733,12 +1913,6 @@ func preflightOwnerPlanOptions() PlanOptions { Org: TargetOrg{ ID: 8, Name: "acme", Title: "Acme", Capabilities: &TargetOrgCapabilities{CustomDomains: true, CronSchedules: true}, - Subscription: &TargetOrgSubscription{ - Status: "ACTIVE", - Plan: &TargetOrgSubscriptionPlan{ - Name: "team", Title: "Team", Usage: 4, UsageIncluded: 10, - }, - }, }, Project: TargetProject{ID: 9, Name: "customer", Title: "Customer", OrgID: 8}, Cluster: TargetCluster{ diff --git a/pkg/migration/wodby1/rollback.go b/pkg/migration/wodby1/rollback.go index 9a0fc18..ea5085a 100644 --- a/pkg/migration/wodby1/rollback.go +++ b/pkg/migration/wodby1/rollback.go @@ -41,6 +41,13 @@ func (p RollbackPlan) empty() bool { return p.AppID == 0 && len(p.InstanceIDs) == 0 && p.StackID == 0 && len(p.IntegrationIDs) == 0 } +// DeletesResources reports whether executing the plan removes anything from +// Wodby 2. A plan that only forgets local resume state does not need a second +// destructive confirmation after the fresh apply plan was approved. +func (p RollbackPlan) DeletesResources() bool { + return !p.empty() +} + // ErrRollbackAfterCutover reports a migration that has already been verified, // which means DNS points at Wodby 2 and the target is serving live traffic. var ErrRollbackAfterCutover = errors.New( @@ -63,6 +70,12 @@ func PlanRollback(state *MigrationState) (RollbackPlan, error) { if state.Status == MigrationStatusComplete || state.Phase == MigrationPhaseVerify { return RollbackPlan{}, ErrRollbackAfterCutover } + if unresolved := unresolvedRollbackOperations(state); len(unresolved) != 0 { + return RollbackPlan{}, errors.Errorf( + "rollback cannot safely identify every target mutation while these operations are unresolved: %s; resume the migration and resolve or retry them before restarting", + strings.Join(unresolved, ", "), + ) + } plan := RollbackPlan{} if state.App.TargetID > 0 && !state.Target.ExistingApp { @@ -107,10 +120,54 @@ func PlanRollback(state *MigrationState) (RollbackPlan, error) { return plan, nil } +func unresolvedRollbackOperations(state *MigrationState) []string { + if state == nil { + return nil + } + result := []string{} + collect := func(scope string, resource MigrationResourceState) { + for name, operation := range resource.Operations { + switch operation.Status { + case MigrationOperationIntent, MigrationOperationAccepted, MigrationOperationAmbiguous: + result = append(result, fmt.Sprintf("%s:%s (%s)", scope, name, operation.Status)) + } + } + } + collect("app", state.App) + for sourceID, instance := range state.Instances { + if instance != nil { + collect("instance:"+sourceID, *instance) + } + } + sort.Strings(result) + return result +} + // Describe renders the plan for the confirmation prompt. func (p RollbackPlan) Describe(appName string) string { + return p.describe(appName, "Rollback", false) +} + +// DescribeRestart renders the resources from a saved migration that must be +// removed before a fresh plan can be applied. It deliberately avoids calling +// this a rollback: restart cleanup is one part of the new apply operation, not +// a separate action the operator has already approved. +func (p RollbackPlan) DescribeRestart(appName string) string { + return p.describe(appName, "Restart", true) +} + +func (p RollbackPlan) describe(appName string, action string, continuing bool) string { var b strings.Builder - b.WriteString("Rollback will delete the following from Wodby 2:\n\n") + if !p.DeletesResources() { + b.WriteString("No migration-created Wodby 2 resources need to be deleted.\n") + if continuing { + b.WriteString("The saved local migration state will be replaced before the fresh migration starts.\n") + } else { + b.WriteString("The saved local migration state will be replaced; Wodby 1 is not touched.\n") + } + return b.String() + } + fmt.Fprintf(&b, "%s will delete the following from Wodby 2:\n\n", action) if p.AppID > 0 { fmt.Fprintf(&b, " app %q (ID %d) and every app instance, service, route, and imported\n", appName, p.AppID) b.WriteString(" database and files under it\n") @@ -135,7 +192,10 @@ func (p RollbackPlan) Describe(appName string) string { len(p.SkippedIntegrations), p.SkippedIntegrations, ) } - b.WriteString("\nThis cannot be undone. Wodby 1 is not touched.\n") + b.WriteString("\nThis deletion cannot be undone. Wodby 1 is not touched.\n") + if continuing { + b.WriteString("After cleanup, the fresh migration plan will be saved and applied.\n") + } return b.String() } @@ -158,7 +218,7 @@ func (e *MigrationExecutor) Rollback( } if plan.empty() { e.reportProgress("Nothing recorded in migration state was created in Wodby 2; nothing to roll back.") - return nil + return RemoveMigrationStateAfterRollback(e.statePath, state.Identity()) } if plan.AppID > 0 { diff --git a/pkg/migration/wodby1/rollback_test.go b/pkg/migration/wodby1/rollback_test.go index 135c2ea..07775ad 100644 --- a/pkg/migration/wodby1/rollback_test.go +++ b/pkg/migration/wodby1/rollback_test.go @@ -156,6 +156,38 @@ func TestPlanRollbackOnAnUntouchedTargetIsANoOp(t *testing.T) { if !plan.empty() { t.Fatalf("plan = %#v", plan) } + if plan.DeletesResources() || !strings.Contains(plan.Describe("demo"), "No migration-created Wodby 2 resources") { + t.Fatalf("empty plan description = %q", plan.Describe("demo")) + } +} + +func TestRestartDescriptionExplainsCleanupWithoutCallingItRollback(t *testing.T) { + plan := RollbackPlan{AppID: 900, ReusedIntegrations: []int{610}} + description := plan.DescribeRestart("demo") + for _, expected := range []string{ + "Restart will delete the following", + `app "demo" (ID 900)`, + "After cleanup, the fresh migration plan will be saved and applied", + } { + if !strings.Contains(description, expected) { + t.Fatalf("restart description missing %q:\n%s", expected, description) + } + } + if strings.Contains(description, "Rollback will delete") { + t.Fatalf("restart description presents cleanup as a separate rollback:\n%s", description) + } +} + +func TestPlanRollbackRejectsUnresolvedTargetOperations(t *testing.T) { + state := rollbackTestState(t, false) + if err := state.MarkAppOperationIntent("stack_create"); err != nil { + t.Fatal(err) + } + if _, err := PlanRollback(state); err == nil || + !strings.Contains(err.Error(), "app:stack_create (intent)") || + !strings.Contains(err.Error(), "retry them before restarting") { + t.Fatalf("PlanRollback() error = %v", err) + } } func TestRollbackDeletesInDependencyOrder(t *testing.T) { @@ -234,3 +266,24 @@ func TestRollbackTreatsAlreadyDeletedResourcesAsDone(t *testing.T) { t.Fatalf("already-deleted resources must not fail a rollback: %v", err) } } + +func TestRollbackRemovesStateWhenNothingWasCreated(t *testing.T) { + state := rollbackTestState(t, false) + statePath := filepath.Join(t.TempDir(), "state.json") + if err := SaveMigrationState(statePath, state); err != nil { + t.Fatal(err) + } + executor, err := NewMigrationExecutor( + mustTargetExecutionClient(t, "http://127.0.0.1"), + MigrationExecutorOptions{StatePath: statePath}, + ) + if err != nil { + t.Fatal(err) + } + if err := executor.Rollback(context.Background(), state, RollbackPlan{}, "demo"); err != nil { + t.Fatal(err) + } + if _, err := InspectMigrationState(statePath); err == nil { + t.Fatal("empty rollback left its obsolete state behind") + } +} diff --git a/pkg/migration/wodby1/target_client.go b/pkg/migration/wodby1/target_client.go index 5c1f6d4..f8df149 100644 --- a/pkg/migration/wodby1/target_client.go +++ b/pkg/migration/wodby1/target_client.go @@ -2,6 +2,8 @@ package wodby1 import ( "context" + "fmt" + "math" "net/url" "strings" @@ -16,16 +18,13 @@ type TargetClient struct { client *rest.Client } -// TargetCurrentUser is the authenticated Wodby 2 account relevant to -// migration authorization. Organization authorization is derived from the -// selected organization's membership, not the account's platform-admin flag. +// TargetCurrentUser is the authenticated Wodby 2 account relevant to migration +// authorization. Organization authorization is derived solely from the +// selected organization's membership. type TargetCurrentUser struct { ID int `json:"id"` Email string `json:"email"` Name string `json:"name"` - // IsAdmin is retained for response compatibility only. Migration - // authorization must never use this platform-level flag. - IsAdmin bool `json:"isAdmin"` } // TargetOrgMembership is the authenticated account's relationship to a @@ -38,6 +37,14 @@ type TargetOrgMembership struct { Status string `json:"status"` } +type TargetAppServiceCapacityPreflight struct { + Allowed bool `json:"allowed"` + Enforced bool `json:"enforced"` + Usage float64 `json:"usage"` + UsageIncluded float64 `json:"usageIncluded"` + ProjectedUsage float64 `json:"projectedUsage"` +} + func NewTargetClient(config types.APIConfig) (*TargetClient, error) { endpoint, err := url.Parse(config.Endpoint) if err != nil { @@ -82,6 +89,47 @@ func (c *TargetClient) ListOrgMemberships(ctx context.Context, orgID int) ([]Tar return items, nil } +// PreflightAppServiceCapacity asks the target backend to evaluate the exact +// app-service increase. Billing exemptions remain private to the backend. +func (c *TargetClient) PreflightAppServiceCapacity(ctx context.Context, orgID int, additionalUsage int) (TargetAppServiceCapacityPreflight, error) { + if orgID <= 0 { + return TargetAppServiceCapacityPreflight{}, errors.New("target organization ID must be positive") + } + if additionalUsage < 0 { + return TargetAppServiceCapacityPreflight{}, errors.New("additional app-service usage must not be negative") + } + + var result TargetAppServiceCapacityPreflight + err := c.client.Post( + ctx, + fmt.Sprintf("/orgs/%d/actions/preflight-app-service-capacity", orgID), + nil, + map[string]int{"additionalUsage": additionalUsage}, + &result, + ) + if err != nil { + return TargetAppServiceCapacityPreflight{}, errors.Wrap(err, "preflight target app-service capacity") + } + values := []float64{result.Usage, result.UsageIncluded, result.ProjectedUsage} + for _, value := range values { + if value < 0 || math.IsNaN(value) || math.IsInf(value, 0) { + return TargetAppServiceCapacityPreflight{}, errors.New("target app-service capacity preflight returned invalid usage values") + } + } + expectedProjected := result.Usage + float64(additionalUsage) + if math.Abs(result.ProjectedUsage-expectedProjected) > 0.000001 { + return TargetAppServiceCapacityPreflight{}, errors.Errorf( + "target app-service capacity preflight returned projected usage %.6g, expected %.6g", + result.ProjectedUsage, + expectedProjected, + ) + } + if !result.Allowed && !result.Enforced { + return TargetAppServiceCapacityPreflight{}, errors.New("target app-service capacity preflight returned a denied but unenforced decision") + } + return result, nil +} + // RequireOrgOwnerOrAdmin rejects credentials that are not an active OWNER or ADMIN // of the selected Wodby 2 organization. func (c *TargetClient) RequireOrgOwnerOrAdmin(ctx context.Context, orgID int) (TargetCurrentUser, TargetOrgMembership, error) { diff --git a/pkg/migration/wodby1/target_discovery.go b/pkg/migration/wodby1/target_discovery.go index d272826..aaffb67 100644 --- a/pkg/migration/wodby1/target_discovery.go +++ b/pkg/migration/wodby1/target_discovery.go @@ -32,7 +32,6 @@ type TargetOrg struct { Domain string `json:"domain,omitempty"` DefaultTimeZone string `json:"defaultTimeZone,omitempty"` Capabilities *TargetOrgCapabilities `json:"capabilities,omitempty"` - Subscription *TargetOrgSubscription `json:"subscription,omitempty"` } type TargetOrgCapabilities struct { diff --git a/pkg/migration/wodby1/target_execution.go b/pkg/migration/wodby1/target_execution.go index d733103..15b74e7 100644 --- a/pkg/migration/wodby1/target_execution.go +++ b/pkg/migration/wodby1/target_execution.go @@ -1732,22 +1732,46 @@ func (c *TargetClient) FindAppByID(ctx context.Context, appID int) (TargetApp, b } func (c *TargetClient) FindAppExact(ctx context.Context, orgID int, name string) (TargetApp, bool, error) { - if err := targetRequirePositiveID("organization", orgID); err != nil { + items, err := c.ListApps(ctx, orgID) + if err != nil { return TargetApp{}, false, err } - if strings.TrimSpace(name) == "" { - return TargetApp{}, false, errors.New("target app name is required") + return findTargetAppExact(items, name) +} + +// ListApps returns every non-infrastructure app visible to the current caller +// in the organization. Migration preflight uses the complete set both for +// exact-name collision checks and for detecting renamed apps created by an +// earlier Wodby 1 migration. +func (c *TargetClient) ListApps(ctx context.Context, orgID int) ([]TargetApp, error) { + if err := targetRequirePositiveID("organization", orgID); err != nil { + return nil, err } query := url.Values{"orgId": []string{strconv.Itoa(orgID)}} items := []TargetApp{} if err := c.client.Get(ctx, "/apps", query, &items); err != nil { - return TargetApp{}, false, errors.Wrap(err, "list target Wodby 2 apps for exact lookup") + return nil, errors.Wrap(err, "list target Wodby 2 apps") } - matches := make([]TargetApp, 0, 1) for _, item := range items { if err := validateTargetApp(item, orgID); err != nil { - return TargetApp{}, false, err + return nil, err + } + } + sort.Slice(items, func(i, j int) bool { + if items[i].Name == items[j].Name { + return items[i].ID < items[j].ID } + return items[i].Name < items[j].Name + }) + return items, nil +} + +func findTargetAppExact(items []TargetApp, name string) (TargetApp, bool, error) { + if strings.TrimSpace(name) == "" { + return TargetApp{}, false, errors.New("target app name is required") + } + matches := make([]TargetApp, 0, 1) + for _, item := range items { if item.Name == name { matches = append(matches, item) } @@ -1843,18 +1867,36 @@ func (c *TargetClient) ListAppInstances(ctx context.Context, orgID, appID int) ( if err := targetRequirePositiveID("app", appID); err != nil { return nil, err } - query := url.Values{ + return c.listAppInstances(ctx, url.Values{ "appId": []string{strconv.Itoa(appID)}, "orgId": []string{strconv.Itoa(orgID)}, + }, appID) +} + +// ListOrgAppInstances returns every app instance visible to the caller in an +// organization. It lets preflight correlate generated migration stacks with +// renamed target apps without issuing one request per app. +func (c *TargetClient) ListOrgAppInstances(ctx context.Context, orgID int) ([]TargetAppInstance, error) { + if err := targetRequirePositiveID("organization", orgID); err != nil { + return nil, err } + return c.listAppInstances(ctx, url.Values{ + "orgId": []string{strconv.Itoa(orgID)}, + }, 0) +} + +func (c *TargetClient) listAppInstances(ctx context.Context, query url.Values, expectedAppID int) ([]TargetAppInstance, error) { items := []TargetAppInstance{} if err := c.client.Get(ctx, "/app-instances", query, &items); err != nil { return nil, errors.Wrap(err, "list target Wodby 2 app instances") } for _, item := range items { - if err := validateTargetAppInstance(item, appID); err != nil { + if err := validateTargetAppInstance(item, expectedAppID); err != nil { return nil, err } + if expectedAppID == 0 && item.AppID <= 0 { + return nil, errors.New("target app instance returned an invalid app ID") + } } sort.Slice(items, func(i, j int) bool { if items[i].Name == items[j].Name { diff --git a/pkg/migration/wodby1/versions_test.go b/pkg/migration/wodby1/versions_test.go index c9f78ff..14f10c1 100644 --- a/pkg/migration/wodby1/versions_test.go +++ b/pkg/migration/wodby1/versions_test.go @@ -106,24 +106,6 @@ func TestCustomStackDefaultEnvironmentRequiresMigration(t *testing.T) { } } -func TestTargetServiceCapacityBlocksFreePlanBeforeMutation(t *testing.T) { - plan := Plan{Target: PlanTarget{Subscription: &TargetOrgSubscription{ - Status: "ACTIVE", - Plan: &TargetOrgSubscriptionPlan{ - Name: "developer", Usage: 8, UsageIncluded: 10, - }, - }}} - prepared := PreparedMigration{Apps: []PreparedAppMigration{{ - Instances: []PreparedInstance{{EffectiveState: map[string]bool{ - "php": true, "nginx": true, "mariadb": true, "mailpit": false, - }}}, - }}} - findings := targetServiceCapacityFindings(&plan, prepared, TargetPreflightOptions{}) - if len(findings) != 1 || findings[0].Severity != SeverityBlocking { - t.Fatalf("expected free-plan capacity blocker, got %#v", findings) - } -} - func versionTestInspection(name string, options []TargetServiceOption) TargetStackServiceInspection { return TargetStackServiceInspection{ StackService: TargetStackService{Name: name},