Repository: github/gh-ost

Issue:
Set `postpone-cut-over-flag-file` via an interactive command
Thank you for this great OSS and to all the maintainers.
In my use case, I needed to specify the `postpone-cut-over-flag-file` after the process had already started.
I'm not sure if this would be useful for others, but I'll go ahead and create a PR.

Discussion:

Solution patch:
diff --git a/doc/interactive-commands.md b/doc/interactive-commands.md
index 44413d5..af37935 100644
--- a/doc/interactive-commands.md
+++ b/doc/interactive-commands.md
@@ -41,6 +41,7 @@ Both interfaces may serve at the same time. Both respond to simple text command,
 - `throttle-control-replicas='replica1,replica2'`: change list of throttle-control replicas, these are replicas `gh-ost` will check. This takes a comma separated list of replica's to check and replaces the previous list.
 - `throttle`: force migration suspend
 - `no-throttle`: cancel forced suspension (though other throttling reasons may still apply)
+- `postpone-cut-over-flag-file=<path>`: Postpone the [cut-over](cut-over.md) phase, writing a cut over flag file to the given path
 - `unpostpone`: at a time where `gh-ost` is postponing the [cut-over](cut-over.md) phase, instruct `gh-ost` to stop postponing and proceed immediately to cut-over.
 - `panic`: immediately panic and abort operation
 
diff --git a/go/logic/server.go b/go/logic/server.go
index 4e41fd2..b5d05b7 100644
--- a/go/logic/server.go
+++ b/go/logic/server.go
@@ -99,6 +99,16 @@ func (this *Server) runCPUProfile(args string) (io.Reader, error) {
 	return &buf, nil
 }
 
+func (this *Server) createPostponeCutOverFlagFile(filePath string) (err error) {
+	if !base.FileExists(filePath) {
+		if err := base.TouchFile(filePath); err != nil {
+			return fmt.Errorf("Failed to create postpone cut-over flag file %s: %w", filePath, err)
+		}
+		this.migrationContext.Log.Infof("Created postpone-cut-over-flag-file: %s", filePath)
+	}
+	return nil
+}
+
 func (this *Server) BindSocketFile() (err error) {
 	if this.migrationContext.ServeSocketFile == "" {
 		return nil
@@ -222,6 +232,7 @@ throttle-http=<URL>                  # Set a new throttle URL
 throttle-control-replicas=<replicas> # Set a new comma delimited list of throttle control replicas
 throttle                             # Force throttling
 no-throttle                          # End forced throttling (other throttling may still apply)
+postpone-cut-over-flag-file=<path>   # Postpone the cut-over phase, writing a cut over flag file to the given path
 unpostpone                           # Bail out a cut-over postpone; proceed to cut-over
 panic                                # panic and quit without cleanup
 help                                 # This message
@@ -395,6 +406,19 @@ help                                 # This message
 			atomic.StoreInt64(&this.migrationContext.ThrottleCommandedByUser, 0)
 			return ForcePrintStatusAndHintRule, nil
 		}
+	case "postpone-cut-over-flag-file":
+		{
+			if arg == "" {
+				err := fmt.Errorf("User commanded 'postpone-cut-over-flag-file' without specifying file path")
+				return NoPrintStatusRule, err
+			}
+			if err := this.createPostponeCutOverFlagFile(arg); err != nil {
+				return NoPrintStatusRule, err
+			}
+			this.migrationContext.PostponeCutOverFlagFile = arg
+			fmt.Fprintf(writer, "Postponed\n")
+			return ForcePrintStatusAndHintRule, nil
+		}
 	case "unpostpone", "no-postpone", "cut-over":
 		{
 			if arg == "" && this.migrationContext.ForceNamedCutOverCommand {



Test patch:
diff --git a/go/logic/server_test.go b/go/logic/server_test.go
index 78f0fd6..52624a2 100644
--- a/go/logic/server_test.go
+++ b/go/logic/server_test.go
@@ -1,6 +1,8 @@
 package logic
 
 import (
+	"os"
+	"path"
 	"testing"
 	"time"
 
@@ -66,3 +68,38 @@ func TestServerRunCPUProfile(t *testing.T) {
 		require.Equal(t, int64(0), s.isCPUProfiling)
 	})
 }
+
+func TestServerCreatePostponeCutOverFlagFile(t *testing.T) {
+	t.Parallel()
+
+	t.Run("success", func(t *testing.T) {
+		s := &Server{
+			migrationContext: base.NewMigrationContext(),
+		}
+		dir, err := os.MkdirTemp("", "gh-ost-test-")
+		require.NoError(t, err)
+		defer os.RemoveAll(dir)
+
+		filePath := path.Join(dir, "postpone-cut-over.flag")
+
+		err = s.createPostponeCutOverFlagFile(filePath)
+		require.NoError(t, err)
+		require.FileExists(t, filePath)
+	})
+
+	t.Run("file already exists", func(t *testing.T) {
+		s := &Server{
+			migrationContext: base.NewMigrationContext(),
+		}
+		dir, err := os.MkdirTemp("", "gh-ost-test-")
+		require.NoError(t, err)
+
+		filePath := path.Join(dir, "postpone-cut-over.flag")
+		err = base.TouchFile(filePath)
+		require.NoError(t, err)
+
+		err = s.createPostponeCutOverFlagFile(filePath)
+		require.NoError(t, err)
+		require.FileExists(t, filePath)
+	})
+}