Repository: ariga/atlas
Pull request #662 (2022-03-18 15:29:39)

cmd/action: add atlas migrate hash command to solve checksum mismatches
This PR adds the `atlas migrate hash` command that can be used to re-hash the migration directory. This is needed for when the user did any manual change to the migration directory to create a correct `atlas.sum` file.


Patch:
diff --git a/cmd/action/migrate.go b/cmd/action/migrate.go
index c11f6a68..4d605b05 100644
--- a/cmd/action/migrate.go
+++ b/cmd/action/migrate.go
@@ -77,6 +77,15 @@ the migration directory state to the desired schema. The desired state can be an
 		Args: cobra.MaximumNArgs(1),
 		RunE: CmdMigrateDiffRun,
 	}
+	// MigrateHashCmd represents the migrate hash command.
+	MigrateHashCmd = &cobra.Command{
+		Use:   "hash",
+		Short: "Hash creates an integrity hash file for the migration directories.",
+		Long: `'atlas migrate hash' computes the integrity hash sum of the migration directory and stores it in the atlas.sum file.
+This command should be used whenever a manual change in the migration directory was made.`,
+		Example: `  atlas migrate hash --force`,
+		RunE:    CmdMigrateHashRun,
+	}
 	// MigrateValidateCmd represents the migrate validate command.
 	MigrateValidateCmd = &cobra.Command{
 		Use:   "validate",
@@ -94,6 +103,7 @@ func init() {
 	RootCmd.AddCommand(MigrateCmd)
 	MigrateCmd.AddCommand(MigrateDiffCmd)
 	MigrateCmd.AddCommand(MigrateValidateCmd)
+	MigrateCmd.AddCommand(MigrateHashCmd)
 	// Global flags.
 	MigrateCmd.PersistentFlags().StringVarP(&MigrateFlags.DirURL, migrateFlagDir, "", "file://migrations", "select migration directory using DSN format")
 	MigrateCmd.PersistentFlags().StringSliceVarP(&MigrateFlags.Schemas, migrateFlagSchema, "", nil, "set schema names")
@@ -118,7 +128,7 @@ func CmdMigrateDiffRun(cmd *cobra.Command, args []string) error {
 	if err := checkClean(cmd.Context(), dev); err != nil {
 		return err
 	}
-	// Open the migration directory. For now only local directories are supported.
+	// Open the migration directory.
 	dir, err := dir()
 	if err != nil {
 		return err
@@ -151,6 +161,20 @@ func CmdMigrateDiffRun(cmd *cobra.Command, args []string) error {
 	// TODO(masseelch): clean up dev after reading the state from migration dir.
 }
 
+// CmdMigrateHashRun is the command executed when running the CLI with 'migrate hash' args.
+func CmdMigrateHashRun(*cobra.Command, []string) error {
+	// Open the migration directory.
+	dir, err := dir()
+	if err != nil {
+		return err
+	}
+	sum, err := migrate.HashSum(dir)
+	if err != nil {
+		return err
+	}
+	return migrate.WriteSumFile(dir, sum)
+}
+
 // dir returns a migrate.Dir to use as migration directory. For now only local directories are supported.
 func dir() (migrate.Dir, error) {
 	parts := strings.SplitN(MigrateFlags.DirURL, "://", 2)
diff --git a/doc/md/cli/reference.md b/doc/md/cli/reference.md
index d126198d..7718e417 100644
--- a/doc/md/cli/reference.md
+++ b/doc/md/cli/reference.md
@@ -95,6 +95,25 @@ the migration directory state to the desired schema. The desired state can be an
 ```
 
 
+### atlas migrate hash
+
+Hash creates an integrity hash file for the migration directories.
+
+#### Usage
+```
+atlas migrate hash
+```
+
+#### Details
+'atlas migrate hash' computes the integrity hash sum of the migration directory and stores it in the atlas.sum file.
+This command should be used whenever a manual change in the migration directory was made.
+
+#### Example
+
+```
+  atlas migrate hash --force
+```
+
 ### atlas migrate validate
 
 Validates the migration directories checksum.
diff --git a/sql/migrate/migrate.go b/sql/migrate/migrate.go
index 640030f5..def3b642 100644
--- a/sql/migrate/migrate.go
+++ b/sql/migrate/migrate.go
@@ -286,11 +286,7 @@ func (p *Planner) WritePlan(plan *Plan) error {
 		if err != nil {
 			return err
 		}
-		b, err := sum.MarshalText()
-		if err != nil {
-			return err
-		}
-		return p.dir.WriteFile(hashFile, b)
+		return WriteSumFile(p.dir, sum)
 	}
 	return nil
 }
@@ -492,6 +488,15 @@ func Validate(dir Dir) error {
 	return nil
 }
 
+// WriteSumFile writes the given HashFile to the Dir. If the file does not exist, it is created.
+func WriteSumFile(dir Dir, sum HashFile) error {
+	b, err := sum.MarshalText()
+	if err != nil {
+		return err
+	}
+	return dir.WriteFile(hashFile, b)
+}
+
 // Sum returns the checksum of the represented hash file.
 func (s HashFile) Sum() string {
 	sha := sha256.New()


Test patch:
diff --git a/cmd/action/migrate_test.go b/cmd/action/migrate_test.go
index b11b09dc..0a59b923 100644
--- a/cmd/action/migrate_test.go
+++ b/cmd/action/migrate_test.go
@@ -6,6 +6,8 @@ package action
 
 import (
 	"fmt"
+	"io"
+	"io/ioutil"
 	"os"
 	"os/exec"
 	"path/filepath"
@@ -13,6 +15,7 @@ import (
 	"testing"
 	"time"
 
+	"ariga.io/atlas/sql/migrate"
 	"github.com/stretchr/testify/require"
 )
 
@@ -60,8 +63,9 @@ func TestMigrate_ValidateError(t *testing.T) {
 		runCmd(RootCmd, "migrate", "validate", "--dir", "file://migrations")
 		return
 	}
-	require.NoError(t, os.WriteFile("migrations/new.sql", []byte("contents"), 0600))
-	defer os.Remove("migrations/new.sql")
+	f := filepath.Join("migrations", "new.sql")
+	require.NoError(t, os.WriteFile(f, []byte("contents"), 0600))
+	defer os.Remove(f)
 	cmd := exec.Command(os.Args[0], "-test.run=TestMigrate_ValidateError") //nolint:gosec
 	cmd.Env = append(os.Environ(), "DO_VALIDATE=1")
 	err := cmd.Run()
@@ -71,6 +75,47 @@ func TestMigrate_ValidateError(t *testing.T) {
 	t.Fatalf("process ran with err %v, want exist status 1", err)
 }
 
+func TestMigrate_Hash(t *testing.T) {
+	s, err := runCmd(RootCmd, "migrate", "hash", "--dir", "file://migrations")
+	require.Zero(t, s)
+	require.NoError(t, err)
+
+	p := t.TempDir()
+	err = copyFile(filepath.Join("migrations", "20220318104614_initial.sql"), filepath.Join(p, "20220318104614_initial.sql"))
+	require.NoError(t, err)
+
+	s, err = runCmd(RootCmd, "migrate", "hash", "--dir", "file://"+p, "--force")
+	require.Zero(t, s)
+	require.NoError(t, err)
+	require.FileExists(t, filepath.Join(p, "atlas.sum"))
+	d, err := ioutil.ReadFile(filepath.Join(p, "atlas.sum"))
+	require.NoError(t, err)
+	dir, err := migrate.NewLocalDir(p)
+	require.NoError(t, err)
+	sum, err := migrate.HashSum(dir)
+	require.NoError(t, err)
+	b, err := sum.MarshalText()
+	require.NoError(t, err)
+	require.Equal(t, d, b)
+}
+
+func TestMigrate_HashError(t *testing.T) {
+	if os.Getenv("DO_HASH") == "1" {
+		runCmd(RootCmd, "migrate", "hash", "--dir", "file://"+os.Getenv("MIGRATION_DIR"))
+		return
+	}
+	p := t.TempDir()
+	err := copyFile(filepath.Join("migrations", "20220318104614_initial.sql"), filepath.Join(p, "20220318104614_initial.sql"))
+	require.NoError(t, err)
+	cmd := exec.Command(os.Args[0], "-test.run=TestMigrate_HashError") //nolint:gosec
+	cmd.Env = append(os.Environ(), "DO_HASH=1", "MIGRATION_DIR="+p)
+	err = cmd.Run()
+	if err, ok := err.(*exec.ExitError); ok && !err.Success() {
+		return
+	}
+	t.Fatalf("process ran with err %v, want exist status 1", err)
+}
+
 const hcl = `
 schema "main" {
 }
@@ -156,3 +201,18 @@ func hclURL(t *testing.T) string {
 	require.NoError(t, os.WriteFile(filepath.Join(p, "atlas.hcl"), []byte(hcl), 0600))
 	return "file://" + filepath.Join(p, "atlas.hcl")
 }
+
+func copyFile(src, dst string) error {
+	sf, err := os.Open(src)
+	if err != nil {
+		return err
+	}
+	defer sf.Close()
+	df, err := os.Create(dst)
+	if err != nil {
+		return err
+	}
+	defer df.Close()
+	_, err = io.Copy(df, sf)
+	return err
+}
diff --git a/sql/migrate/migrate_test.go b/sql/migrate/migrate_test.go
index 66443379..57c8faf7 100644
--- a/sql/migrate/migrate_test.go
+++ b/sql/migrate/migrate_test.go
@@ -87,7 +87,7 @@ func TestPlanner_Plan(t *testing.T) {
 	require.Equal(t, drv.plan, plan)
 }
 
-func TestHash(t *testing.T) {
+func TestHashSum(t *testing.T) {
 	p := t.TempDir()
 	d, err := migrate.NewLocalDir(p)
 	require.NoError(t, err)