Repository: ariga/atlas
Pull request #1205 (2022-10-17 17:42:54)

cmd/atlas/internal/cmdapi: make migrate apply logging template based
default
![image](https://user-images.githubusercontent.com/12862103/196374938-61a7913d-71ef-4e58-915c-4ce7e1d42e01.png)

`--log '{{ json . "  " }}`
```json
{
  "Report": null,
  "Driver": "sqlite3",
  "URL": {
    "Scheme": "sqlite",
    "Opaque": "",
    "User": null,
    "Host": "",
    "Path": "",
    "RawPath": "",
    "OmitHost": false,
    "ForceQuery": false,
    "RawQuery": "mode=memory",
    "Fragment": "",
    "RawFragment": "",
    "DSN": "file:sqlite:?mode=memory",
    "Schema": "main"
  },
  "Dir": "../../build/migrations",
  "Pending": [
    {
      "Name": "1_changes.sql",
      "Version": "1",
      "Description": "changes"
    },
    {
      "Name": "2_asd.sql",
      "Version": "2",
      "Description": "asd"
    }
  ],
  "Applied": [
    {
      "Name": "1_changes.sql",
      "Version": "1",
      "Description": "changes",
      "Start": "2022-10-18T10:14:51.207977718+02:00",
      "End": "2022-10-18T10:14:51.208411998+02:00",
      "Stmts": [
        "CREATE TABLE `users` (`id` bigint NOT NULL, `name` varchar(255) NOT NULL, PRIMARY KEY (`id`));"
      ]
    },
    {
      "Name": "2_asd.sql",
      "Version": "2",
      "Description": "asd",
      "Start": "2022-10-18T10:14:51.208414373+02:00",
      "End": "2022-10-18T10:14:51.208533524+02:00",
      "Stmts": [
        "CREATE TABLE `groups` (`id` bigint NOT NULL, `name` varchar(255) NOT NULL, PRIMARY KEY (`id`));",
        "asdasd"
      ],
      "Error": {
        "SQL": "asdasd",
        "Error": "near \"asdasd\": syntax error"
      }
    }
  ],
  "Target": "2",
  "Start": "2022-10-18T10:14:51.207679841+02:00",
  "End": "2022-10-18T10:14:51.208533524+02:00",
  "Error": "sql/migrate: execute: executing statement \"asdasd\" from version \"2\": near \"asdasd\": syntax error"
}
```


Patch:
diff --git a/cmd/atlas/go.mod b/cmd/atlas/go.mod
index fff1ee35..857101c3 100644
--- a/cmd/atlas/go.mod
+++ b/cmd/atlas/go.mod
@@ -19,6 +19,7 @@ require (
 	github.com/spf13/cobra v1.6.0
 	github.com/spf13/pflag v1.0.5
 	github.com/stretchr/testify v1.8.0
+	github.com/zclconf/go-cty v1.8.0
 	golang.org/x/exp v0.0.0-20220428152302-39d4317da171
 	golang.org/x/mod v0.6.0-dev.0.20211013180041-c96bc1413d57
 )
@@ -57,7 +58,6 @@ require (
 	github.com/pmezard/go-difflib v1.0.0 // indirect
 	github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 // indirect
 	github.com/sirupsen/logrus v1.6.0 // indirect
-	github.com/zclconf/go-cty v1.8.0 // indirect
 	go.uber.org/atomic v1.7.0 // indirect
 	go.uber.org/multierr v1.6.0 // indirect
 	go.uber.org/zap v1.18.1 // indirect
diff --git a/cmd/atlas/go.sum b/cmd/atlas/go.sum
index 2eadb2f0..006bd6d1 100644
--- a/cmd/atlas/go.sum
+++ b/cmd/atlas/go.sum
@@ -1,5 +1,3 @@
-ariga.io/atlas v0.7.1-0.20220925103247-db83ea0cd178 h1:/fhqvnyyi4h5epqhIsDQW9NLkklDnQ0GRgQqfUhl9Pw=
-ariga.io/atlas v0.7.1-0.20220925103247-db83ea0cd178/go.mod h1:ft47uSh5hWGDCmQC9DsztZg6Xk+KagM5Ts/mZYKb9JE=
 ariga.io/atlas v0.7.3-0.20221010133632-5e5aca152e8a h1:/5g/0+IO3umGAxt/ROhJ0/BaTjn3rGWjZyJwt+t4UAI=
 ariga.io/atlas v0.7.3-0.20221010133632-5e5aca152e8a/go.mod h1:ft47uSh5hWGDCmQC9DsztZg6Xk+KagM5Ts/mZYKb9JE=
 cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
@@ -21,6 +19,7 @@ github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY
 github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
 github.com/antlr/antlr4/runtime/Go/antlr v0.0.0-20220816024939-bc8df83d7b9d h1:0xIrH2lJbraclvJT3pvTf3u2oCAL60cAqiv4qRpz4EI=
 github.com/antlr/antlr4/runtime/Go/antlr v0.0.0-20220816024939-bc8df83d7b9d/go.mod h1:F7bn7fEU90QkQ3tnmaTx3LTKLEDqnwWODIYppRQ5hnY=
+github.com/apparentlymart/go-dump v0.0.0-20180507223929-23540a00eaa3 h1:ZSTrOEhiM5J5RFxEaFvMZVEAM1KvT1YzbEOwB2EAGjA=
 github.com/apparentlymart/go-textseg/v13 v13.0.0 h1:Y+KvPE1NYz0xl601PVImeQfFyEy6iT90AvPUL1NNfNw=
 github.com/apparentlymart/go-textseg/v13 v13.0.0/go.mod h1:ZK2fH7c4NqDTLtiYLvIkEghdlcqw7yxLeM89kiTRPUo=
 github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
@@ -149,7 +148,6 @@ github.com/hashicorp/hcl/v2 v2.13.0/go.mod h1:e4z5nxYlWNPdDSNYX+ph14EvWYMFm3eP0z
 github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
 github.com/hydrogen18/memlistener v0.0.0-20141126152155-54553eb933fb/go.mod h1:qEIFzExnS6016fRpRfxrExeVn2gbClQA99gQhnIcdhE=
 github.com/imkira/go-interpol v1.1.0/go.mod h1:z0h2/2T3XF8kyEPpRgJ3kmNv+C43p+I/CoI+jC3w2iA=
-github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
 github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
 github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc=
 github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
@@ -264,8 +262,6 @@ github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9
 github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
 github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
 github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU=
-github.com/spf13/cobra v1.5.0 h1:X+jTBEBqF0bHN+9cSMgmfuvv2VHJ9ezmFNf9Y/XstYU=
-github.com/spf13/cobra v1.5.0/go.mod h1:dWXEIy2H428czQCjInthrTRUg7yKbok+2Qi/yBIJoUM=
 github.com/spf13/cobra v1.6.0 h1:42a0n6jwCot1pUmomAp4T7DeMD+20LFv4Q54pxLf2LI=
 github.com/spf13/cobra v1.6.0/go.mod h1:IOw/AERYS7UzyrGinqmz6HLUo219MORXGxhbaJUqzrY=
 github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
@@ -460,8 +456,6 @@ gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU=
 gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
-gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/cmd/atlas/internal/cmdapi/migrate.go b/cmd/atlas/internal/cmdapi/migrate.go
index 0c05ea8b..8bb0db72 100644
--- a/cmd/atlas/internal/cmdapi/migrate.go
+++ b/cmd/atlas/internal/cmdapi/migrate.go
@@ -27,7 +27,6 @@ import (
 	"ariga.io/atlas/sql/sqlcheck"
 	"ariga.io/atlas/sql/sqlclient"
 	"ariga.io/atlas/sql/sqltool"
-	"github.com/fatih/color"
 	"github.com/hashicorp/hcl/v2"
 	"github.com/hashicorp/hcl/v2/hclparse"
 	"github.com/hashicorp/hcl/v2/hclsyntax"
@@ -131,38 +130,38 @@ If run with the "--dry-run" flag, atlas will not execute any SQL.`,
 // migrateApplyCmd represents the 'atlas migrate apply' subcommand.
 func migrateApplyRun(cmd *cobra.Command, args []string, flags migrateApplyFlags) error {
 	var (
-		n   int
-		err error
+		count int
+		err   error
 	)
 	if len(args) > 0 {
-		n, err = strconv.Atoi(args[0])
+		count, err = strconv.Atoi(args[0])
 		if err != nil {
 			return err
 		}
-		if n < 1 {
-			return fmt.Errorf("cannot apply '%d' migration files", n)
+		if count < 1 {
+			return fmt.Errorf("cannot apply '%d' migration files", count)
 		}
 	}
 	// Open and validate the migration directory.
-	dir, err := dir(flags.dirURL, false)
+	migrationDir, err := dir(flags.dirURL, false)
 	if err != nil {
 		return err
 	}
-	if err := migrate.Validate(dir); err != nil {
+	if err := migrate.Validate(migrationDir); err != nil {
 		printChecksumError(cmd)
 		return err
 	}
+	// Open a client to the database.
 	if flags.url == "" {
 		return errors.New(`required flag "url" not set`)
 	}
-	// Open a client to the database.
-	c, err := sqlclient.Open(cmd.Context(), flags.url)
+	client, err := sqlclient.Open(cmd.Context(), flags.url)
 	if err != nil {
 		return err
 	}
-	defer c.Close()
+	defer client.Close()
 	// Acquire a lock.
-	if l, ok := c.Driver.(schema.Locker); ok {
+	if l, ok := client.Driver.(schema.Locker); ok {
 		unlock, err := l.Lock(cmd.Context(), applyLockValue, 0)
 		if err != nil {
 			return fmt.Errorf("acquiring database lock: %w", err)
@@ -170,25 +169,19 @@ func migrateApplyRun(cmd *cobra.Command, args []string, flags migrateApplyFlags)
 		// If unlocking fails notify the user about it.
 		defer cobra.CheckErr(unlock())
 	}
-	if err := checkRevisionSchemaClarity(cmd, c, flags.revisionSchema); err != nil {
-		return err
-	}
-	// Get the correct log format and destination. Currently, only os.Stdout is supported.
-	l, err := logFormat(cmd.OutOrStdout(), flags.logFormat)
-	if err != nil {
+	if err := checkRevisionSchemaClarity(cmd, client, flags.revisionSchema); err != nil {
 		return err
 	}
 	var rrw migrate.RevisionReadWriter
-	rrw, err = entRevisions(cmd.Context(), c, flags.revisionSchema)
+	rrw, err = entRevisions(cmd.Context(), client, flags.revisionSchema)
 	if err != nil {
 		return err
 	}
 	if err := rrw.(*cmdmigrate.EntRevisions).Migrate(cmd.Context()); err != nil {
 		return err
 	}
-	// Determine pending files and lock the database while working.
+	// Determine pending files.
 	opts := []migrate.ExecutorOption{
-		migrate.WithLogger(l),
 		migrate.WithOperatorVersion(operatorVersion()),
 	}
 	if flags.allowDirty {
@@ -200,7 +193,7 @@ func migrateApplyRun(cmd *cobra.Command, args []string, flags migrateApplyFlags)
 	if v := flags.fromVersion; v != "" {
 		opts = append(opts, migrate.WithFromVersion(v))
 	}
-	ex, err := migrate.NewExecutor(c.Driver, dir, rrw, opts...)
+	ex, err := migrate.NewExecutor(client.Driver, migrationDir, rrw, opts...)
 	if err != nil {
 		return err
 	}
@@ -212,18 +205,18 @@ func migrateApplyRun(cmd *cobra.Command, args []string, flags migrateApplyFlags)
 		cmd.Println("No migration files to execute")
 		return nil
 	}
-	if n > 0 {
-		// Cannot apply more than len(pending) files.
-		if n >= len(pending) {
-			n = len(pending)
-		}
-		pending = pending[:n]
+	if l := len(pending); count == 0 || count >= l {
+		// Cannot apply more than len(pending) migration files.
+		count = l
 	}
-	revs, err := rrw.ReadRevisions(cmd.Context())
+	pending = pending[:count]
+	applied, err := rrw.ReadRevisions(cmd.Context())
 	if err != nil {
 		return err
 	}
-	if err := migrate.LogIntro(l, revs, pending); err != nil {
+	report := cmdmigrate.NewApplyReport(client, migrationDir)
+	opts = append(opts, migrate.WithLogger(report))
+	if err := migrate.LogIntro(report, applied, pending); err != nil {
 		return err
 	}
 	var (
@@ -231,7 +224,7 @@ func migrateApplyRun(cmd *cobra.Command, args []string, flags migrateApplyFlags)
 			dryRun: flags.dryRun,
 			mode:   flags.txMode,
 			schema: flags.revisionSchema,
-			c:      c,
+			c:      client,
 			rrw:    rrw,
 		}
 		drv migrate.Driver
@@ -241,22 +234,40 @@ func migrateApplyRun(cmd *cobra.Command, args []string, flags migrateApplyFlags)
 		if err != nil {
 			return err
 		}
-		ex, err := migrate.NewExecutor(drv, dir, rrw, opts...)
+		ex, err = migrate.NewExecutor(drv, migrationDir, rrw, opts...)
 		if err != nil {
 			return err
 		}
-		if err := mux.mayRollback(ex.Execute(cmd.Context(), f)); err != nil {
-			return err
+		if err = mux.mayRollback(ex.Execute(cmd.Context(), f)); err != nil {
+			report.Error = err.Error()
+			break
 		}
-		if err := mux.mayCommit(); err != nil {
-			return err
+		if err = mux.mayCommit(); err != nil {
+			report.Error = err.Error()
+			break
 		}
 	}
-	if err := mux.commit(); err != nil {
-		return err
+	if err == nil {
+		if err = mux.commit(); err != nil {
+			report.Error = err.Error()
+		} else {
+			report.Log(migrate.LogDone{})
+		}
+	}
+	f := cmdmigrate.DefaultApplyTemplate
+	if v := flags.logFormat; v != "" {
+		f, err = template.New("format").Funcs(cmdmigrate.ApplyTemplateFuncs).Parse(v)
+		if err != nil {
+			return fmt.Errorf("parse log format: %w", err)
+		}
+	}
+	if err2 := (&cmdmigrate.TemplateWriter{T: f, W: cmd.OutOrStdout()}).WriteReport(report); err2 != nil {
+		if err != nil {
+			return fmt.Errorf("%w: %v", err2, err)
+		}
+		err = err2
 	}
-	l.Log(migrate.LogDone{})
-	return mux.commit()
+	return err
 }
 
 type migrateDiffFlags struct {
@@ -915,7 +926,7 @@ func migrateStatusRun(cmd *cobra.Command, _ []string, flags migrateStatusFlags)
 	if err := checkRevisionSchemaClarity(cmd, client, flags.revisionSchema); err != nil {
 		return err
 	}
-	var format = cmdmigrate.DefaultTemplate
+	var format = cmdmigrate.DefaultStatusTemplate
 	if f := flags.logFormat; f != "" {
 		format, err = template.New("format").Funcs(cmdmigrate.StatusTemplateFuncs).Parse(f)
 		if err != nil {
@@ -927,7 +938,7 @@ func migrateStatusRun(cmd *cobra.Command, _ []string, flags migrateStatusFlags)
 		Dir:          dir,
 		ReportWriter: &cmdmigrate.TemplateWriter{T: format, W: cmd.OutOrStdout()},
 		Schema:       revisionSchemaName(client, flags.revisionSchema),
-	}).Status(cmd.Context())
+	}).Report(cmd.Context())
 }
 
 type migrateValidateFlags struct {
@@ -1450,92 +1461,6 @@ func formatter(u *url.URL) (migrate.Formatter, error) {
 	}
 }
 
-const logFormatTTY = "tty"
-
-// LogTTY is a migrate.Logger that pretty prints execution progress.
-// If the connected out is not a tty, it will fall back to a non-colorful output.
-type LogTTY struct {
-	out         io.Writer
-	start       time.Time
-	fileStart   time.Time
-	fileCounter int
-	stmtCounter int
-}
-
-var (
-	cyan         = color.CyanString
-	green        = color.HiGreenString
-	red          = color.HiRedString
-	redBgWhiteFg = color.New(color.FgHiWhite, color.BgHiRed).SprintFunc()
-	yellow       = color.YellowString
-	dash         = yellow("--")
-	arr          = cyan("->")
-	indent2      = "  "
-	indent4      = indent2 + indent2
-)
-
-// Log implements the migrate.Logger interface.
-func (l *LogTTY) Log(e migrate.LogEntry) {
-	switch e := e.(type) {
-	case migrate.LogExecution:
-		l.start = time.Now()
-		fmt.Fprintf(l.out, "Migrating to version %v", cyan(e.To))
-		if e.From != "" {
-			fmt.Fprintf(l.out, " from %v", cyan(e.From))
-		}
-		fmt.Fprintf(l.out, " (%d migrations in total):\n", len(e.Files))
-	case migrate.LogFile:
-		l.fileCounter++
-		if !l.fileStart.IsZero() {
-			l.reportFileEnd()
-		}
-		l.fileStart = time.Now()
-		fmt.Fprintf(l.out, "\n%s%v migrating version %v", indent2, dash, cyan(e.Version))
-		if e.Skip > 0 {
-			fmt.Fprintf(l.out, " (partially applied - skipping %s statements)", yellow("%d", e.Skip))
-		}
-		fmt.Fprint(l.out, "\n")
-	case migrate.LogStmt:
-		l.stmtCounter++
-		fmt.Fprintf(l.out, "%s%v %s\n", indent4, arr, e.SQL)
-	case migrate.LogDone:
-		l.reportFileEnd()
-		fmt.Fprintf(l.out, "\n%s%v\n", indent2, cyan(strings.Repeat("-", 25)))
-		fmt.Fprintf(l.out, "%s%v %v\n", indent2, dash, time.Since(l.start))
-		fmt.Fprintf(l.out, "%s%v %v migrations\n", indent2, dash, l.fileCounter)
-		fmt.Fprintf(l.out, "%s%v %v sql statements\n", indent2, dash, l.stmtCounter)
-	case migrate.LogError:
-		fmt.Fprintf(l.out, "%s %s\n", indent4, redBgWhiteFg(e.Error.Error()))
-		fmt.Fprintf(l.out, "\n%s%v\n", indent2, cyan(strings.Repeat("-", 25)))
-		fmt.Fprintf(l.out, "%s%v %v\n", indent2, dash, time.Since(l.start))
-		fmt.Fprintf(l.out, "%s%v %v migrations ok (%s)\n", indent2, dash, zero(l.fileCounter-1), red("1 with errors"))
-		fmt.Fprintf(l.out, "%s%v %v sql statements ok (%s)\n", indent2, dash, zero(l.stmtCounter-1), red("1 with errors"))
-		fmt.Fprintf(l.out, "\n%s\n%v\n\n", red("Error: Execution had errors:"), redBgWhiteFg(e.Error.Error()))
-	default:
-		fmt.Fprintf(l.out, "%v", e)
-	}
-}
-
-func (l *LogTTY) reportFileEnd() {
-	fmt.Fprintf(l.out, "%s%v ok (%v)\n", indent2, dash, yellow("%s", time.Since(l.fileStart)))
-}
-
-func zero(v int) int {
-	if v < 0 {
-		return 0
-	}
-	return v
-}
-
-func logFormat(out io.Writer, format string) (migrate.Logger, error) {
-	switch l := format; l {
-	case "", logFormatTTY:
-		return &LogTTY{out: out}, nil
-	default:
-		return nil, fmt.Errorf("unknown log-format %q", l)
-	}
-}
-
 func migrateFlagsFromEnv(cmd *cobra.Command) error {
 	activeEnv, err := selectEnv(GlobalFlags.SelectedEnv)
 	if err != nil {
diff --git a/cmd/atlas/internal/migrate/report.go b/cmd/atlas/internal/migrate/report.go
index 3cb4735a..520bb972 100644
--- a/cmd/atlas/internal/migrate/report.go
+++ b/cmd/atlas/internal/migrate/report.go
@@ -13,6 +13,7 @@ import (
 	"io"
 	"strings"
 	"text/template"
+	"time"
 
 	"ariga.io/atlas/cmd/atlas/internal/migrate/ent/revision"
 	"ariga.io/atlas/sql/migrate"
@@ -25,10 +26,11 @@ import (
 var (
 	// ColorTemplateFuncs are globally available functions to color strings in a report template.
 	ColorTemplateFuncs = template.FuncMap{
-		"cyan":   color.CyanString,
-		"green":  color.HiGreenString,
-		"red":    color.HiRedString,
-		"yellow": color.YellowString,
+		"cyan":         color.CyanString,
+		"green":        color.HiGreenString,
+		"red":          color.HiRedString,
+		"redBgWhiteFg": color.New(color.FgHiWhite, color.BgHiRed).SprintFunc(),
+		"yellow":       color.YellowString,
 	}
 )
 
@@ -58,6 +60,9 @@ type (
 
 	// Files is a slice of migrate.File. Implements json.Marshaler.
 	Files []migrate.File
+
+	// File wraps migrate.File to implement json.Marshaler.
+	File struct{ migrate.File }
 )
 
 // WriteReport implements ReportWriter.
@@ -66,37 +71,40 @@ func (w *TemplateWriter) WriteReport(r Report) error {
 }
 
 // MarshalJSON implements json.Marshaler.
-func (fs Files) MarshalJSON() ([]byte, error) {
-	type file struct {
+func (f File) MarshalJSON() ([]byte, error) {
+	type local struct {
 		Name        string `json:"Name,omitempty"`
 		Version     string `json:"Version,omitempty"`
 		Description string `json:"Description,omitempty"`
 	}
-	files := make([]file, len(fs))
-	for i, f := range fs {
-		files[i] = file{f.Name(), f.Version(), f.Desc()}
+	return json.Marshal(local{f.Name(), f.Version(), f.Desc()})
+}
+
+// MarshalJSON implements json.Marshaler.
+func (f Files) MarshalJSON() ([]byte, error) {
+	files := make([]File, len(f))
+	for i := range f {
+		files[i] = File{f[i]}
 	}
 	return json.Marshal(files)
 }
 
+// NewEnv returns an initialized Env.
+func NewEnv(c *sqlclient.Client, dir migrate.Dir) Env {
+	e := Env{
+		Driver: c.Name,
+		URL:    c.URL,
+	}
+	if p, ok := dir.(interface{ Path() string }); ok {
+		e.Dir = p.Path()
+	}
+	return e
+}
+
 var (
-	// StatusTemplateFuncs are global functions available in templates.
+	// StatusTemplateFuncs are global functions available in status report templates.
 	StatusTemplateFuncs = merge(template.FuncMap{
-		"json": func(v any, args ...string) (string, error) {
-			var (
-				b   []byte
-				err error
-			)
-			switch len(args) {
-			case 0:
-				b, err = json.Marshal(v)
-			case 1:
-				b, err = json.MarshalIndent(v, "", args[0])
-			default:
-				b, err = json.MarshalIndent(v, args[0], args[1])
-			}
-			return string(b), err
-		},
+		"json":  jsonFn,
 		"table": table,
 		"default": func(report *StatusReport) (string, error) {
 			var buf bytes.Buffer
@@ -121,7 +129,7 @@ Last migration attempt had errors:
 			return buf.String(), err
 		},
 	}, ColorTemplateFuncs)
-	DefaultTemplate = template.Must(template.New("report").Funcs(StatusTemplateFuncs).Parse("{{ default . }}"))
+	DefaultStatusTemplate = template.Must(template.New("report").Funcs(StatusTemplateFuncs).Parse("{{ default . }}"))
 )
 
 type (
@@ -139,16 +147,18 @@ type (
 
 	// StatusReport contains a summary of the migration status of a database.
 	StatusReport struct {
-		Report
-		Env           `json:"Env"`
-		Available     Files               `json:"Available,omitempty"` // Available migration files.
-		Pending       Files               `json:"Pending,omitempty"`   // Pending migration files.
-		Applied       []*migrate.Revision `json:"Applied,omitempty"`   // Applied migration files.
-		Current, Next string              // Current and Next migration version.
-		Count, Total  int                 // Count of Total statements applied of the last revision.
-		Status        string              // Status of migration (OK, PENDING).
-		Error         string              `json:"Error,omitempty"` // Last Error that occurred.
-		SQL           string              `json:"SQL,omitempty"`   // SQL that caused the last Error.
+		Report    `json:"-"`
+		Env       `json:"Env"`
+		Available Files               `json:"Available,omitempty"` // Available migration files
+		Pending   Files               `json:"Pending,omitempty"`   // Pending migration files
+		Applied   []*migrate.Revision `json:"Applied,omitempty"`   // Applied migration files
+		Current   string              `json:"Current,omitempty"`   // Current migration version
+		Next      string              `json:"Next,omitempty"`      // Next migration version
+		Count     int                 `json:"Count,omitempty"`     // Count of applied statements of the last revision
+		Total     int                 `json:"Total,omitempty"`     // Total statements of the last migration
+		Status    string              `json:"Status,omitempty"`    // Status of migration (OK, PENDING)
+		Error     string              `json:"Error,omitempty"`     // Last Error that occurred
+		SQL       string              `json:"SQL,omitempty"`       // SQL that caused the last Error
 	}
 )
 
@@ -158,21 +168,14 @@ func NewStatusReport(c *sqlclient.Client, dir migrate.Dir) (*StatusReport, error
 	if err != nil {
 		return nil, err
 	}
-	sum := &StatusReport{
-		Env: Env{
-			Driver: c.Name,
-			URL:    c.URL,
-		},
+	return &StatusReport{
+		Env:       NewEnv(c, dir),
 		Available: files,
-	}
-	if p, ok := dir.(interface{ Path() string }); ok {
-		sum.Env.Dir = p.Path()
-	}
-	return sum, nil
+	}, nil
 }
 
-// Status creates and writes a StatusReport.
-func (r *StatusReporter) Status(ctx context.Context) error {
+// Report creates and writes a StatusReport.
+func (r *StatusReporter) Report(ctx context.Context) error {
 	rep, err := NewStatusReport(r.Client, r.Dir)
 	if err != nil {
 		return err
@@ -297,6 +300,149 @@ func table(report *StatusReport) (string, error) {
 	return buf.String(), nil
 }
 
+var (
+	// ApplyTemplateFuncs are global functions available in apply report templates.
+	ApplyTemplateFuncs = merge(ColorTemplateFuncs, template.FuncMap{
+		"dec":  dec,
+		"json": jsonFn,
+	})
+	DefaultApplyTemplate = template.Must(template.
+				New("report").
+				Funcs(ApplyTemplateFuncs).
+				Parse(`Migrating to version {{ cyan .Target }}{{ with .Current }} from {{ cyan . }}{{ end }} ({{ len .Pending }} migrations in total):
+{{ range $i, $f := .Applied }}
+  {{ yellow "--" }} migrating version {{ cyan $f.File.Version }}{{ range $f.Applied }}
+    {{ cyan "->" }} {{ . }}{{ end }}
+  {{- with .Error }}
+    {{ redBgWhiteFg .Error }}
+  {{- else }}
+  {{ yellow "--" }} ok ({{ yellow (.End.Sub .Start).String }})
+  {{- end }}
+{{ end }}
+  {{ cyan "-------------------------" }}
+  {{ yellow "--" }} {{ .End.Sub .Start }}
+{{- $files := len .Applied }}
+{{- $stmts := .CountStmts }}
+{{- if .Error }}
+  {{ yellow "--" }} {{ dec $files }} migrations ok (1 with errors)
+  {{ yellow "--" }} {{ dec $stmts }} sql statements ok (1 with errors)
+{{- else }}
+  {{ yellow "--" }} {{ len .Applied }} migrations 
+  {{ yellow "--" }} {{ .CountStmts  }} sql statements
+{{- end }}
+`))
+)
+
+type (
+	// ApplyReport contains a summary of a migration applying attempt on a database.
+	ApplyReport struct {
+		Report `json:"-"`
+		Env
+		Pending Files          `json:"Pending,omitempty"` // Pending migration files
+		Applied []*AppliedFile `json:"Applied,omitempty"` // Applied files
+		Current string         `json:"Current,omitempty"` // Current migration version
+		Target  string         `json:"Target,omitempty"`  // Target migration version
+		Start   time.Time
+		End     time.Time
+		// Error is set even then, if it was not caused by a statement in a migration file,
+		// but by Atlas, e.g. when committing or rolling back a transaction.
+		Error string `json:"Error,omitempty"`
+	}
+
+	// AppliedFile is part of an ApplyReport containing information about an applied file in a migration attempt.
+	AppliedFile struct {
+		migrate.File
+		Start   time.Time
+		End     time.Time
+		Skipped int      // Amount of skipped SQL statements in a partially applied file.
+		Applied []string // SQL statements applied with success
+		Error   *struct {
+			SQL   string // SQL statement that failed.
+			Error string // Error returned by the database.
+		}
+	}
+)
+
+// NewApplyReport returns an ApplyReport.
+func NewApplyReport(client *sqlclient.Client, dir migrate.Dir) *ApplyReport {
+	return &ApplyReport{
+		Env: NewEnv(client, dir),
+	}
+}
+
+// Log implements migrate.Logger.
+func (a *ApplyReport) Log(e migrate.LogEntry) {
+	switch e := e.(type) {
+	case migrate.LogExecution:
+		a.Start = time.Now()
+		a.Current = e.From
+		a.Target = e.To
+		a.Pending = e.Files
+	case migrate.LogFile:
+		if l := len(a.Applied); l > 0 {
+			f := a.Applied[l-1]
+			f.End = time.Now()
+		}
+		a.Applied = append(a.Applied, &AppliedFile{
+			File:    File{e.File},
+			Start:   time.Now(),
+			Skipped: e.Skip,
+		})
+	case migrate.LogStmt:
+		f := a.Applied[len(a.Applied)-1]
+		f.Applied = append(f.Applied, e.SQL)
+	case migrate.LogError:
+		if l := len(a.Applied); l > 0 {
+			f := a.Applied[len(a.Applied)-1]
+			f.End = time.Now()
+			a.End = f.End
+			f.Error = &struct {
+				SQL   string
+				Error string
+			}{e.SQL, e.Error.Error()}
+		}
+	case migrate.LogDone:
+		f := a.Applied[len(a.Applied)-1]
+		f.End = time.Now()
+		a.End = f.End
+	}
+}
+
+// CountStmts returns the amount of applied statements.
+func (a *ApplyReport) CountStmts() (n int) {
+	for _, f := range a.Applied {
+		n += len(f.Applied)
+	}
+	return
+}
+
+// MarshalJSON implements json.Marshaler.
+func (f *AppliedFile) MarshalJSON() ([]byte, error) {
+	type local struct {
+		Name        string    `json:"Name,omitempty"`
+		Version     string    `json:"Version,omitempty"`
+		Description string    `json:"Description,omitempty"`
+		Start       time.Time `json:"Start,omitempty"`
+		End         time.Time `json:"End,omitempty"`
+		Skipped     int       `json:"Skipped,omitempty"`
+		Stmts       []string  `json:"Applied,omitempty"`
+		Error       *struct {
+			SQL   string
+			Error string
+		} `json:"Error,omitempty"`
+	}
+	return json.Marshal(local{
+		Name:        f.Name(),
+		Version:     f.Version(),
+		Description: f.Desc(),
+		Start:       f.Start,
+		End:         f.End,
+		Skipped:     f.Skipped,
+		Stmts:       f.Applied,
+		Error:       f.Error,
+	})
+}
+
 func merge(maps ...template.FuncMap) template.FuncMap {
 	switch len(maps) {
 	case 0:
@@ -313,3 +459,23 @@ func merge(maps ...template.FuncMap) template.FuncMap {
 		return m
 	}
 }
+
+func jsonFn(v any, args ...string) (string, error) {
+	var (
+		b   []byte
+		err error
+	)
+	switch len(args) {
+	case 0:
+		b, err = json.Marshal(v)
+	case 1:
+		b, err = json.MarshalIndent(v, "", args[0])
+	default:
+		b, err = json.MarshalIndent(v, args[0], args[1])
+	}
+	return string(b), err
+}
+
+func dec(i int) int {
+	return i - 1
+}
diff --git a/doc/md/guides/guides.md b/doc/md/guides/guides.md
index 939d9ceb..54f162f4 100644
--- a/doc/md/guides/guides.md
+++ b/doc/md/guides/guides.md
@@ -14,6 +14,8 @@ Welcome to the Atlas Guides.
 
 [Generated Columns](mysql/generated-columns.md)
 
+[Managing MySQL schemas on RDS with Terraform](mysql/terraform.md)
+
 ### PostgreSQL
 
 [Serial Type Columns](postgres/serial-columns.md)
diff --git a/doc/md/guides/mysql/terraform.md b/doc/md/guides/mysql/terraform.md
new file mode 100644
index 00000000..944ba7b2
--- /dev/null
+++ b/doc/md/guides/mysql/terraform.md
@@ -0,0 +1,437 @@
+---
+id: terraform-mysql-rds
+title: Manage MySQL on RDS with Terraform and Atlas
+slug: /guides/mysql/terraform
+tags: [terraform, rds, mysql, aws, iac]
+---
+
+### TL;DR
+
+* Many teams use [Terraform](https://terraform.io/), a popular, open-source, infrastructure-as-code (IaC)
+  tool created by [HashiCorp](https://www.hashicorp.com/) to provision their cloud infrastructure.
+* [RDS](https://aws.amazon.com/rds/) is a fully managed relational database service by AWS.
+* The popular [AWS Terraform Provider](https://registry.terraform.io/providers/hashicorp/aws/latest/docs)
+  can be used to easily provision database instances and manage them.
+* This guide shows how to use the [Atlas Terraform Provider](https://atlasgo.io/integrations/terraform-provider)
+  to manage the schema of an RDS-managed MySQL database using Terraform as part of their IaC pipelines.
+
+### Schema management in IaC
+
+[Terraform](https://terraform.io/) is widely used in the industry to provision and manage resources in AWS. 
+One of the popular use-cases teams use Terraform for is to provision the database
+backing their application on RDS (a fully managed relational database service) using the
+AWS Terraform Provider. The provider supports a resource named
+[`aws_db_instance`](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/db_instance)
+to provision databases using RDS. 
+
+This resource can be used to configure many attributes of the database like the amount of storage, the engine
+and version of the database, backup settings, etc. However, when it comes to provisioning
+the database's schema itself, the AWS provider does not offer any support. As a result, 
+many teams do not manage their database schemas as part of their infrastructure-as-code
+pipeline in Terraform.
+
+In this guide, we will demonstrate how to use Terraform to both provision a MySQL database
+on AWS with RDS _and_ manage its schema in a single pipeline. 
+
+### Prerequisites
+
+* Install Terraform ([guide](https://learn.hashicorp.com/tutorials/terraform/install-cli))
+* An [AWS account](https://aws.amazon.com/free/) (free-tier available) with credentials 
+ [configured for Terraform](https://registry.terraform.io/providers/hashicorp/aws/latest/docs#authentication-and-configuration)
+
+### Source code
+
+The full source code for this guide can be found on the [Atlas Examples](https://github.com/ariga/atlas-examples/tree/master/terraform/rds-mysql-example)
+repository on GitHub.
+
+### Dev database
+To plan a migration from the current to the desired state, Atlas uses a [Dev Database](/concepts/dev-database),
+which is usually provided by a locally running container with an empty database of the type
+you work with (such as MySQL or PostgreSQL).
+
+To spin up a local MySQL database that will be used as a dev-database in our example, run:
+
+```text
+docker run --rm --name atlas-db-dev -d -p 3306:3306 -e MYSQL_DATABASE=example -e MYSQL_ROOT_PASSWORD=pass mysql:8
+```
+
+As reference for the next steps, the URL for the Dev Database will be:
+```text
+mysql://root:pass@localhost:3306/example
+```
+
+### Getting started
+
+Create a new directory for our new Terraform project and in it a file named `main.tf`.
+In this file we will define all the required resources for our project. Start by configuring
+the providers which we will use in our project:
+
+```hcl
+terraform {
+  required_providers {
+    aws = {
+      source  = "hashicorp/aws"
+      version = "~> 4.0"
+    }
+    atlas = {
+      source  = "ariga/atlas"
+      version = "0.3.0-pre.1"
+    }
+  }
+}
+
+# Configure the AWS Provider
+provider "aws" {
+  region = "us-east-1"
+}
+```
+
+### Networking
+Next, our database must reside in a network topology. We will use the AWS-maintained 
+Terraform module `terraform-aws-modules/vpc/aws` for creating a new VPC for our demo.
+
+```hcl
+// Fetch the list of availability zones from the current region.
+data "aws_availability_zones" "available" {
+  state = "available"
+}
+
+// Provision a VPC and subnets in these AZs.
+module "vpc" {
+  source  = "terraform-aws-modules/vpc/aws"
+  version = "3.16.1"
+
+  name                 = "atlas-rds-demo"
+  cidr                 = "10.0.0.0/16"
+  azs                  = data.aws_availability_zones.available.names
+  public_subnets       = ["10.0.4.0/24", "10.0.5.0/24", "10.0.6.0/24"]
+  enable_dns_hostnames = true
+  enable_dns_support   = true
+}
+
+// Create a DB subnet to provision the database. 
+resource "aws_db_subnet_group" "atlas" {
+  name       = "atlas-rds-demo"
+  subnet_ids = module.vpc.public_subnets
+
+  tags = {
+    Name = "Demo"
+  }
+}
+```
+A few things worth noting:
+1. In a more realistic scenario we will not provision a dedicated VPC for our database, rather our
+   database will reside in a more general-purpose VPC which will include compute and other resources.
+2. For the sake of simplicity we are placing our instance in *public* subnets. In a production use case,
+   it is usually recommended to provision specific subnets for databases which should not be reachable
+   from the internet.
+
+### Security groups
+
+Next, we provision the security group which governs access to our database instance. 
+*Notice*: in this demo we are creating a database which is _accessible from the public
+internet_. The reason we need this is that we are running Terraform locally and need it 
+to be able to connect to the database directly in order to manage our schema. In a realistic
+scenario, we will run Terraform from a server which is located in our VPC and can
+access our database.
+
+```hcl
+// Security group which allows *public access* to our database.
+// DO NOT use this in production.
+resource "aws_security_group" "rds" {
+  name   = "atlas-demo"
+  vpc_id = module.vpc.vpc_id
+
+  ingress {
+    from_port   = 3306
+    to_port     = 3306
+    protocol    = "tcp"
+    cidr_blocks = ["0.0.0.0/0"]
+  }
+
+  egress {
+    from_port   = 3306
+    to_port     = 3306
+    protocol    = "tcp"
+    cidr_blocks = ["0.0.0.0/0"]
+  }
+
+  tags = {
+    Name = "atlas"
+  }
+}
+```
+
+### Provision the database
+
+Next, we use the `aws_db_instance` to provision a new database instance:
+
+```hcl
+// Generate a random password for our db user.
+resource "random_password" "password" {
+  length  = 16
+  special = true
+}
+
+// Our RDS-based MySQL 8 instance.
+resource "aws_db_instance" "atlas-demo" {
+  identifier             = "atlas-demo"
+  instance_class         = "db.t3.micro"
+  allocated_storage      = 5
+  engine                 = "mysql"
+  engine_version         = "8.0.28"
+  username               = "atlas"
+  password               = random_password.password.result
+  db_subnet_group_name   = aws_db_subnet_group.atlas.name
+  vpc_security_group_ids = [aws_security_group.rds.id]
+  parameter_group_name   = "default.mysql8.0"
+  publicly_accessible    = true
+  skip_final_snapshot    = true
+}
+```
+
+### Define the desired schema
+
+In a separate file named `schema.hcl` define the desired database schema. To learn more
+about defining SQL resources with the Atlas language, [see the docs](https://atlasgo.io/atlas-schema/sql-resources).
+
+```hcl
+// Create a new database named "hello"
+schema "hello" {}
+
+// Create a table named "users".
+table "users" {
+  schema = schema.hello
+  column "id" {
+    type = int
+  }
+  column "name" {
+    type = varchar(255)
+  }
+}
+```
+
+### Connect everything together
+
+Finally, configure the Atlas Terraform Provider to apply the schema in our `schema.hcl` file
+on the RDS-managed database instance. 
+
+```hcl
+locals {
+  dev_db_url = "mysql://root:pass@localhost:3306/example"
+}
+
+// Load the schema from file and normalize it using the dev database.
+data "atlas_schema" "hello" {
+  dev_db_url = local.dev_db_url
+  src        = file("schema.hcl")
+}
+
+// Apply the normalized schema to the RDS-managed database.
+resource "atlas_schema" "hello" {
+  hcl        = data.atlas_schema.hello.hcl
+  dev_db_url = local.dev_db_url
+  
+  // The connection string will be: mysql://user:pass@endpoint/
+  url = "mysql://${aws_db_instance.atlas-demo.username}:${urlencode(random_password.password.result)}@${aws_db_instance.atlas-demo.endpoint}/"
+}
+
+```
+
+### Time to provision
+
+We're now ready to provision the resources in our AWS account. Start by initializing
+the project:
+
+```shell
+terraform init
+```
+Observe that Terraform downloads the necessary modules and plugins:
+
+```text
+Initializing modules...
+Downloading registry.terraform.io/terraform-aws-modules/vpc/aws 3.16.1 for vpc...
+- vpc in .terraform/modules/vpc
+
+Initializing the backend...
+
+Initializing provider plugins...
+- Finding ariga/atlas versions matching "0.3.0-pre.1"...
+- Finding hashicorp/aws versions matching ">= 3.73.0, ~> 4.0"...
+- Finding latest version of hashicorp/random...
+- Installing ariga/atlas v0.3.0-pre.1...
+- Installed ariga/atlas v0.3.0-pre.1 (self-signed, key ID 45441FCEAAC3770C)
+- Installing hashicorp/aws v4.35.0...
+- Installed hashicorp/aws v4.35.0 (signed by HashiCorp)
+- Installing hashicorp/random v3.4.3...
+- Installed hashicorp/random v3.4.3 (signed by HashiCorp)
+
+Partner and community providers are signed by their developers.
+If you'd like to know more about provider signing, you can read about it here:
+https://www.terraform.io/docs/cli/plugins/signing.html
+
+Terraform has created a lock file .terraform.lock.hcl to record the provider
+selections it made above. Include this file in your version control repository
+so that Terraform can guarantee to make the same selections by default when
+you run "terraform init" in the future.
+
+Terraform has been successfully initialized!
+```
+
+Finally, let's provision our infrastructure. Run:
+
+```text
+terraform apply
+```
+
+Terraform offers a plan similar to:
+
+```text
+Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
+  + create
+
+Terraform will perform the following actions:
+
+  // highlight-start
+  # atlas_schema.hello will be created
+  + resource "atlas_schema" "hello" {
+      + dev_db_url = (sensitive value)
+      + hcl        = <<-EOT
+            table "users" {
+              schema = schema.hello
+              column "id" {
+                null = false
+                type = int
+              }
+              column "name" {
+                null = false
+                type = varchar(255)
+              }
+            }
+            schema "hello" {
+              charset = "latin1"
+              collate = "latin1_swedish_ci"
+            }
+        EOT
+      + id         = (known after apply)
+      + url        = (sensitive value)
+    }
+    // highlight-end
+    
+    // ... A long list of more changes
+
+Plan: 15 to add, 0 to change, 0 to destroy.
+
+Do you want to perform these actions?
+  Terraform will perform the actions described above.
+  Only 'yes' will be accepted to approve.
+
+  Enter a value:
+```
+If the plan looks right to you, write "yes" and hit Enter.
+
+After a few minutes Terraform should finish provisioning everything and print:
+
+```text
+Apply complete! Resources: 15 added, 0 changed, 0 destroyed.
+```
+
+### Evolving the schema
+
+The Atlas provider is useful also in cases where your schema evolves, and
+you wish to change it. Let's see how we can add a new, unique, column to our `users`
+table.
+
+Edit `schema.hcl`:
+
+```hcl
+schema "hello" {}
+
+table "users" {
+  schema = schema.hello
+  column "id" {
+    type = int
+  }
+  column "name" {
+    type = varchar(255)
+  }
+  // highlight-start
+  column "email" {
+    type = varchar(255)
+  }
+  index "idx_email" {
+    columns = [
+      column.email
+    ]
+    unique = true
+  }
+  // highlight-end
+}
+```
+Now, re-apply with Terraform:
+
+```text
+terraform apply
+```
+Terraform understands the diff in the desired schema for our database and produces
+a plan accordingly:
+
+```text
+Terraform will perform the following actions:
+
+  # atlas_schema.hello will be updated in-place
+  ~ resource "atlas_schema" "hello" {
+      ~ hcl        = <<-EOT
+            table "users" {
+              schema = schema.hello
+              column "id" {
+                null = false
+                type = int
+              }
+              column "name" {
+                null = false
+                type = varchar(255)
+              }
+          // highlight-start              
+          +   column "email" {
+          +     null = false
+          +     type = varchar(255)
+          +   }
+          +   index "idx_email" {
+          +     unique  = true
+          +     columns = [column.email]
+          +   }
+          // highlight-end
+            }
+            schema "hello" {
+              charset = "latin1"
+              collate = "latin1_swedish_ci"
+            }
+        EOT
+        id         = "mysql://atlas:7%28%3Ej%24rQ%3Ez%3C%3DPrqU%5D@atlas-demo.cripwukl9y7k.us-east-1.rds.amazonaws.com:3306/"
+        # (2 unchanged attributes hidden)
+    }
+
+Plan: 0 to add, 1 to change, 0 to destroy.
+
+Do you want to perform these actions?
+  Terraform will perform the actions described above.
+  Only 'yes' will be accepted to approve.
+
+  Enter a value:
+```
+After reviewing the plan, write "yes" at the prompt and hit Enter:
+```text
+Apply complete! Resources: 0 added, 1 changed, 0 destroyed.
+```
+
+### Wrapping up
+
+In this guide, we demonstrated how to provision and manage a MySQL instance using Terraform
+and the AWS and Atlas providers. Following the example here, teams can finally integrate schema
+management with their full Infrastructure-as-Code workflows.
+
+## Need More Help?
+
+[Join the Ariga Discord Server](https://discord.gg/zZ6sWVg6NT) for early access to features and the ability to provide
+exclusive feedback that improves your Database Management Tooling.
diff --git a/doc/website/sidebars.js b/doc/website/sidebars.js
index bc274f60..d2fdd3ad 100644
--- a/doc/website/sidebars.js
+++ b/doc/website/sidebars.js
@@ -106,6 +106,11 @@ module.exports = {
                   id: 'guides/mysql/generated-columns',
                   label: 'Generated Columns'
                 },
+                {
+                    type: 'doc',
+                    id: 'guides/mysql/terraform-mysql-rds',
+                    label: 'Managing with Terraform'
+                },
             ],
         },
         {
diff --git a/doc/website/src/pages/index.js b/doc/website/src/pages/index.js
index 4f8914c7..e074a8b4 100644
--- a/doc/website/src/pages/index.js
+++ b/doc/website/src/pages/index.js
@@ -80,7 +80,7 @@ export default function () {
                     <AtlasButton style={{"marginTop": "5%"}} text="Get Started" link="/getting-started" type="primaryButton"/>
                 </div>
                 <div className={index.imageContainer}>
-                    <img src="https://atlasgo.io/uploads/hero2.png" alt="hero"/>
+                    <img src="https://atlasgo.io/uploads/images/atlas-hero-v1.png" alt="hero"/>
                 </div>
             </div>
         </div>
diff --git a/internal/integration/docker-compose.yaml b/internal/integration/docker-compose.yaml
index 4e9934d9..93565c45 100644
--- a/internal/integration/docker-compose.yaml
+++ b/internal/integration/docker-compose.yaml
@@ -2,6 +2,7 @@ version: "3.9"
 
 services:
   mysql56:
+    container_name: atlas-integration-mysql56
     platform: linux/amd64
     image: mysql:5.6.35
     environment:
@@ -13,6 +14,7 @@ services:
       - "3306:3306"
 
   mysql57:
+    container_name: atlas-integration-mysql57
     platform: linux/amd64
     image: mysql:5.7.26
     environment:
@@ -24,6 +26,7 @@ services:
       - "3307:3306"
 
   mysql8:
+    container_name: atlas-integration-mysql8
     platform: linux/amd64
     image: mysql:8.0.19
     environment:
@@ -35,6 +38,7 @@ services:
       - "3308:3306"
 
   postgres10:
+    container_name: atlas-integration-postgres10
     platform: linux/amd64
     image: postgres:10
     environment:
@@ -46,6 +50,7 @@ services:
       - "5430:5432"
 
   postgres11:
+    container_name: atlas-integration-postgres11
     platform: linux/amd64
     image: postgres:11
     environment:
@@ -57,6 +62,7 @@ services:
       - "5431:5432"
 
   postgres12:
+    container_name: atlas-integration-postgres12
     platform: linux/amd64
     image: postgres:12
     environment:
@@ -68,6 +74,7 @@ services:
       - "5432:5432"
 
   postgres13:
+    container_name: atlas-integration-postgres13
     platform: linux/amd64
     image: postgres:13
     environment:
@@ -79,6 +86,7 @@ services:
       - "5433:5432"
 
   postgres14:
+    container_name: atlas-integration-postgres14
     platform: linux/amd64
     image: postgres:14
     environment:
@@ -90,6 +98,7 @@ services:
       - "5434:5432"
 
   mariadb:
+    container_name: atlas-integration-mariadb
     platform: linux/amd64
     image: mariadb
     environment:
@@ -101,6 +110,7 @@ services:
       - "4306:3306"
 
   mariadb102:
+    container_name: atlas-integration-mariadb102
     platform: linux/amd64
     image: mariadb:10.2.32
     environment:
@@ -112,6 +122,7 @@ services:
       - "4307:3306"
 
   mariadb103:
+    container_name: atlas-integration-mariadb103
     platform: linux/amd64
     image: mariadb:10.3.13
     environment:
@@ -124,18 +135,21 @@ services:
 
   # Default DB test, No Password
   tidb5:
+    container_name: atlas-integration-tidb5
     platform: linux/amd64
     image: pingcap/tidb:v5.4.0
     ports:
       - "4309:4000"
 
   tidb6:
+    container_name: atlas-integration-tidb6
     platform: linux/amd64
     image: pingcap/tidb:v6.0.0
     ports:
       - "4310:4000"
   
   cockroach21.2.11:
+    container_name: atlas-integration-cockroach21.2.11
     platform: linux/amd64
     image: cockroachdb/cockroach:v21.2.11
     ports:
diff --git a/sql/migrate/migrate.go b/sql/migrate/migrate.go
index 4956bb8c..3d3dd601 100644
--- a/sql/migrate/migrate.go
+++ b/sql/migrate/migrate.go
@@ -652,11 +652,11 @@ func (e *Executor) Execute(ctx context.Context, m File) (err error) {
 			}
 		}
 	}
-	e.log.Log(LogFile{r.Version, r.Description, r.Applied})
+	e.log.Log(LogFile{m, r.Version, r.Description, r.Applied})
 	for _, stmt := range stmts[r.Applied:] {
 		e.log.Log(LogStmt{stmt})
 		if _, err = e.drv.ExecContext(ctx, stmt); err != nil {
-			e.log.Log(LogError{Error: err})
+			e.log.Log(LogError{SQL: stmt, Error: err})
 			r.done()
 			r.ErrorStmt = stmt
 			r.Error = err.Error()
@@ -876,14 +876,18 @@ type (
 		// To what version.
 		To string
 		// Migration Files to be executed.
-		Files []string
+		Files []File
 	}
 
 	// LogFile is sent if a new migration file is executed.
 	LogFile struct {
+		// The File being executed.
+		File File
 		// Version executed.
+		// Deprecated: Use File.Version() instead.
 		Version string
 		// Desc of migration executed.
+		// Deprecated: Use File.Desc() instead.
 		Desc string
 		// Skip holds the number of stmts of this file that will be skipped.
 		// This happens, if a migration file was only applied partially and will now continue to be applied.
@@ -900,6 +904,7 @@ type (
 
 	// LogError is sent if there is an error while execution.
 	LogError struct {
+		SQL   string // Set, if Error was caused by a SQL statement.
 		Error error
 	}
 
@@ -917,15 +922,11 @@ func (LogError) logEntry()     {}
 // Log implements the Logger interface.
 func (NopLogger) Log(LogEntry) {}
 
-// LogIntro gathers some meta information from the migration files and stored revisions to
-// log some general information prior to actual execution.
+// LogIntro gathers some meta information from the migration files and stored
+// revisions to log some general information prior to actual execution.
 func LogIntro(l Logger, revs []*Revision, files []File) error {
-	names := make([]string, len(files))
-	for i := range files {
-		names[i] = files[i].Name()
-	}
 	last := files[len(files)-1]
-	e := LogExecution{To: last.Version(), Files: names}
+	e := LogExecution{To: last.Version(), Files: files}
 	if len(revs) > 0 {
 		e.From = revs[len(revs)-1].Version
 	}


Test patch:
diff --git a/cmd/atlas/internal/cmdapi/migrate_test.go b/cmd/atlas/internal/cmdapi/migrate_test.go
index 49daf982..c3e7c8fb 100644
--- a/cmd/atlas/internal/cmdapi/migrate_test.go
+++ b/cmd/atlas/internal/cmdapi/migrate_test.go
@@ -202,7 +202,6 @@ func TestMigrate_Apply(t *testing.T) {
 	require.NotContains(t, s, "ALTER TABLE `tbl` ADD `col_4` bigint;") // but not third
 	require.Contains(t, s, "1 migrations ok (1 with errors)")          // logs amount of migrations
 	require.Contains(t, s, "2 sql statements ok (1 with errors)")      // logs amount of statement
-	require.Contains(t, s, "Error: Execution had errors:")             // logs error summary
 	require.Contains(t, s, "near \"asdasd\": syntax error")            // logs error summary
 
 	c, err := sqlclient.Open(ctx, fmt.Sprintf("sqlite://file:%s?cache=shared&_fk=1", filepath.Join(p, "test2.db")))
@@ -238,7 +237,6 @@ func TestMigrate_Apply(t *testing.T) {
 	require.NotContains(t, s, "ALTER TABLE `tbl` ADD `col_4` bigint;")  // but not third
 	require.Contains(t, s, "0 migrations ok (1 with errors)")           // logs amount of migrations
 	require.Contains(t, s, "1 sql statements ok (1 with errors)")       // logs amount of statement
-	require.Contains(t, s, "Error: Execution had errors:")              // logs error summary
 	require.Contains(t, s, "near \"asdasd\": syntax error")             // logs error summary
 
 	// Editing an applied line will raise error.
diff --git a/cmd/atlas/internal/migrate/report_test.go b/cmd/atlas/internal/migrate/report_test.go
index 994dab93..21cff64e 100644
--- a/cmd/atlas/internal/migrate/report_test.go
+++ b/cmd/atlas/internal/migrate/report_test.go
@@ -30,8 +30,8 @@ func TestReporter_Status(t *testing.T) {
 	require.NoError(t, (&StatusReporter{
 		Client:       c,
 		Dir:          dir,
-		ReportWriter: &TemplateWriter{T: DefaultTemplate, W: &buf},
-	}).Status(ctx))
+		ReportWriter: &TemplateWriter{T: DefaultStatusTemplate, W: &buf},
+	}).Report(ctx))
 	require.Equal(t, `Migration Status: PENDING
   -- Current Version: No migration applied yet
   -- Next Version:    1
@@ -50,8 +50,8 @@ func TestReporter_Status(t *testing.T) {
 	require.NoError(t, (&StatusReporter{
 		Client:       c,
 		Dir:          dir,
-		ReportWriter: &TemplateWriter{T: DefaultTemplate, W: &buf},
-	}).Status(ctx))
+		ReportWriter: &TemplateWriter{T: DefaultStatusTemplate, W: &buf},
+	}).Report(ctx))
 	require.Equal(t, `Migration Status: PENDING
   -- Current Version: 1
   -- Next Version:    2
@@ -66,8 +66,8 @@ func TestReporter_Status(t *testing.T) {
 	require.NoError(t, (&StatusReporter{
 		Client:       c,
 		Dir:          dir,
-		ReportWriter: &TemplateWriter{T: DefaultTemplate, W: &buf},
-	}).Status(ctx))
+		ReportWriter: &TemplateWriter{T: DefaultStatusTemplate, W: &buf},
+	}).Report(ctx))
 	require.Equal(t, `Migration Status: PENDING
   -- Current Version: 2
   -- Next Version:    3
@@ -82,8 +82,8 @@ func TestReporter_Status(t *testing.T) {
 	require.NoError(t, (&StatusReporter{
 		Client:       c,
 		Dir:          dir,
-		ReportWriter: &TemplateWriter{T: DefaultTemplate, W: &buf},
-	}).Status(ctx))
+		ReportWriter: &TemplateWriter{T: DefaultStatusTemplate, W: &buf},
+	}).Report(ctx))
 	require.Equal(t, `Migration Status: PENDING
   -- Current Version: 3 (1 statements applied)
   -- Next Version:    3 (1 statements left)
@@ -105,8 +105,8 @@ Last migration attempt had errors:
 	require.NoError(t, (&StatusReporter{
 		Client:       c,
 		Dir:          dir,
-		ReportWriter: &TemplateWriter{T: DefaultTemplate, W: &buf},
-	}).Status(ctx))
+		ReportWriter: &TemplateWriter{T: DefaultStatusTemplate, W: &buf},
+	}).Report(ctx))
 	require.Equal(t, `Migration Status: OK
   -- Current Version: 3
   -- Next Version:    Already at latest version
diff --git a/internal/integration/script_test.go b/internal/integration/script_test.go
index 0d4778df..826a154d 100644
--- a/internal/integration/script_test.go
+++ b/internal/integration/script_test.go
@@ -8,6 +8,7 @@ import (
 	"bytes"
 	"context"
 	"database/sql"
+	"encoding/json"
 	"fmt"
 	"os"
 	"os/exec"
@@ -45,6 +46,7 @@ func TestMySQL_Script(t *testing.T) {
 				"execsql":     t.cmdExec,
 				"atlas":       t.cmdCLI,
 				"clearSchema": t.clearSchema,
+				"validJSON":   validJSON,
 			},
 		})
 	})
@@ -705,3 +707,7 @@ func matchErr(ts *testscript.TestScript, err error, p string) {
 		ts.Fatalf("mismatched errors: %v != %s", err, p)
 	}
 }
+
+func validJSON(ts *testscript.TestScript, _ bool, args []string) {
+	ts.Check(json.Unmarshal([]byte(ts.ReadFile(args[0])), new(map[string]any)))
+}
diff --git a/internal/integration/testdata/mysql/cli-migrate-apply.txt b/internal/integration/testdata/mysql/cli-migrate-apply.txt
index 451d1896..d74015ac 100644
--- a/internal/integration/testdata/mysql/cli-migrate-apply.txt
+++ b/internal/integration/testdata/mysql/cli-migrate-apply.txt
@@ -43,6 +43,19 @@ cmpshow pets pets.sql
 atlas migrate apply --url URL --revisions-schema $db 1
 stdout 'No migration files to execute'
 
+clearSchema
+
+atlas migrate apply --url URL --revisions-schema $db --log '{{ json . }}'
+validJSON stdout
+stdout '"Driver":"mysql"'
+stdout '"Scheme":"mysql"'
+stdout '"Dir":"migrations"'
+stdout '"DSN":"root'
+stdout '"Target":"3"'
+stdout '"Pending":\[{"Name":"1_first.sql","Version":"1","Description":"first"},{"Name":"2_second.sql","Version":"2","Description":"second"},{"Name":"3_third.sql","Version":"3","Description":"third"}\]'
+stdout '"Applied":\["CREATE TABLE `users` \(`id` bigint NOT NULL AUTO_INCREMENT, `age` bigint NOT NULL, `name` varchar\(255\) NOT NULL, PRIMARY KEY \(`id`\)\) CHARSET utf8mb4 COLLATE utf8mb4_bin;"\]'
+stdout '"Start":"\d\d\d\d-\d\d-\d\dT\d\d:\d\d:\d\d.[0-9]'
+
 -- migrations/1_first.sql --
 CREATE TABLE `users` (`id` bigint NOT NULL AUTO_INCREMENT, `age` bigint NOT NULL, `name` varchar(255) NOT NULL, PRIMARY KEY (`id`)) CHARSET utf8mb4 COLLATE utf8mb4_bin;
 
diff --git a/sql/migrate/migrate_test.go b/sql/migrate/migrate_test.go
index 80e0fcf1..f4a247b5 100644
--- a/sql/migrate/migrate_test.go
+++ b/sql/migrate/migrate_test.go
@@ -356,15 +356,18 @@ func TestExecutor(t *testing.T) {
 		"CREATE TABLE t_sub(c int);", "ALTER TABLE t_sub ADD c1 int;", "ALTER TABLE t_sub ADD c2 int;",
 	})
 	requireEqualRevisions(t, []*migrate.Revision{rev1, rev2}, *rrw)
-	require.Equal(t, []migrate.LogEntry{
-		migrate.LogExecution{To: "2.10.x-20", Files: []string{"1.a_sub.up.sql", "2.10.x-20_description.sql"}},
-		migrate.LogFile{Version: "1.a", Desc: "sub.up"},
-		migrate.LogStmt{SQL: "CREATE TABLE t_sub(c int);"},
-		migrate.LogStmt{SQL: "ALTER TABLE t_sub ADD c1 int;"},
-		migrate.LogFile{Version: "2.10.x-20", Desc: "description"},
-		migrate.LogStmt{SQL: "ALTER TABLE t_sub ADD c2 int;"},
-		migrate.LogDone{},
-	}, []migrate.LogEntry(*log))
+	require.Len(t, *log, 7)
+	require.IsType(t, migrate.LogExecution{}, (*log)[0])
+	require.Equal(t, "2.10.x-20", (*log)[0].(migrate.LogExecution).To)
+	require.Len(t, (*log)[0].(migrate.LogExecution).Files, 2)
+	require.Equal(t, "1.a_sub.up.sql", (*log)[0].(migrate.LogExecution).Files[0].Name())
+	require.Equal(t, "2.10.x-20_description.sql", (*log)[0].(migrate.LogExecution).Files[1].Name())
+	require.IsType(t, migrate.LogFile{}, (*log)[1])
+	require.Equal(t, migrate.LogStmt{SQL: "CREATE TABLE t_sub(c int);"}, (*log)[2])
+	require.Equal(t, migrate.LogStmt{SQL: "ALTER TABLE t_sub ADD c1 int;"}, (*log)[3])
+	require.IsType(t, migrate.LogFile{}, (*log)[4])
+	require.Equal(t, migrate.LogStmt{SQL: "ALTER TABLE t_sub ADD c2 int;"}, (*log)[5])
+	require.Equal(t, migrate.LogDone{}, (*log)[6])
 
 	// Partly is pending.
 	p, err := ex.Pending(context.Background())