Repository: ariga/atlas
Pull request #779 (2022-05-08 19:42:56)

internal/integration: testscript for cli tests


Patch:
diff --git a/cmd/atlascmd/migrate.go b/cmd/atlascmd/migrate.go
index 56b1cbca..f1ff0b0a 100644
--- a/cmd/atlascmd/migrate.go
+++ b/cmd/atlascmd/migrate.go
@@ -9,19 +9,19 @@ import (
 	"fmt"
 	"io"
 	"io/ioutil"
-	"os"
 	"strings"
 
 	"ariga.io/atlas/sql/migrate"
 	"ariga.io/atlas/sql/schema"
 	"ariga.io/atlas/sql/sqlclient"
 	"ariga.io/atlas/sql/sqltool"
+	"github.com/spf13/pflag"
 
 	"github.com/spf13/cobra"
 )
 
 const (
-	migrateDiffFlagDevURL  = "dev-url"
+	migrateFlagDevURL      = "dev-url"
 	migrateFlagDir         = "dir"
 	migrateFlagForce       = "force"
 	migrateFlagFormat      = "format"
@@ -54,15 +54,17 @@ var (
 					return err
 				}
 				if err := migrate.Validate(dir); err != nil {
-					fmt.Fprintf(
-						cmd.ErrOrStderr(),
-						"Error: %s\n\nYou have a checksum error in your migration directory.\n"+
-							"This happens if you manually create or edit a migration file.\n"+
-							"Please check your migration files and run\n\n"+
-							"'atlas migrate hash --force'\n\nto re-hash the contents and resolve the error.\n\n",
-						err,
-					)
-					os.Exit(1)
+					fmt.Fprintf(cmd.OutOrStderr(), `You have a checksum error in your migration directory.
+This happens if you manually create or edit a migration file.
+Please check your migration files and run
+
+'atlas migrate hash --force'
+
+to re-hash the contents and resolve the error
+
+`)
+					cmd.SilenceUsage = true
+					return err
 				}
 			}
 			return nil
@@ -106,7 +108,7 @@ This command should be used whenever a manual change in the migration directory
 the atlas.sum file. If there is a mismatch it will be reported.`,
 		Example: `  atlas migrate validate
   atlas migrate validate --dir /path/to/migration/directory`,
-		Run: func(*cobra.Command, []string) {},
+		RunE: CmdMigrateValidateRun,
 	}
 )
 
@@ -117,19 +119,25 @@ func init() {
 	MigrateCmd.AddCommand(MigrateHashCmd)
 	MigrateCmd.AddCommand(MigrateNewCmd)
 	MigrateCmd.AddCommand(MigrateValidateCmd)
+	// Reusable flags.
+	devURL := func(set *pflag.FlagSet) {
+		set.StringVarP(&MigrateFlags.DevURL, migrateFlagDevURL, "", "", "[driver://username:password@address/dbname?param=value] select a data source using the URL format")
+	}
 	// Global flags.
-	MigrateCmd.PersistentFlags().StringVarP(&MigrateFlags.DirURL, migrateFlagDir, "", "file://migrations", "select migration directory using DSN format")
+	MigrateCmd.PersistentFlags().StringVarP(&MigrateFlags.DirURL, migrateFlagDir, "", "file://migrations", "select migration directory using URL format")
 	MigrateCmd.PersistentFlags().StringSliceVarP(&MigrateFlags.Schemas, migrateFlagSchema, "", nil, "set schema names")
 	MigrateCmd.PersistentFlags().StringVarP(&MigrateFlags.Format, migrateFlagFormat, "", formatAtlas, "set migration file format")
 	MigrateCmd.PersistentFlags().BoolVarP(&MigrateFlags.Force, migrateFlagForce, "", false, "force a command to run on a broken migration directory state")
 	MigrateCmd.PersistentFlags().SortFlags = false
 	// Diff flags.
-	MigrateDiffCmd.Flags().StringVarP(&MigrateFlags.DevURL, migrateDiffFlagDevURL, "", "", "[driver://username:password@address/dbname?param=value] select a data source using the DSN format")
-	MigrateDiffCmd.Flags().StringVarP(&MigrateFlags.ToURL, migrateDiffFlagTo, "", "", "[driver://username:password@address/dbname?param=value] select a data source using the DSN format")
+	devURL(MigrateDiffCmd.Flags())
+	MigrateDiffCmd.Flags().StringVarP(&MigrateFlags.ToURL, migrateDiffFlagTo, "", "", "[driver://username:password@address/dbname?param=value] select a data source using the URL format")
 	MigrateDiffCmd.Flags().BoolVarP(&MigrateFlags.Verbose, migrateDiffFlagVerbose, "", false, "enable verbose logging")
 	MigrateDiffCmd.Flags().SortFlags = false
-	cobra.CheckErr(MigrateDiffCmd.MarkFlagRequired(migrateDiffFlagDevURL))
+	cobra.CheckErr(MigrateDiffCmd.MarkFlagRequired(migrateFlagDevURL))
 	cobra.CheckErr(MigrateDiffCmd.MarkFlagRequired(migrateDiffFlagTo))
+	// Validate flags.
+	devURL(MigrateValidateCmd.Flags())
 }
 
 // CmdMigrateDiffRun is the command executed when running the CLI with 'migrate diff' args.
@@ -180,6 +188,19 @@ func CmdMigrateDiffRun(cmd *cobra.Command, args []string) error {
 	return pl.WritePlan(plan)
 }
 
+// CmdMigrateHashRun is the command executed when running the CLI with 'migrate hash' args.
+func CmdMigrateHashRun(*cobra.Command, []string) error {
+	dir, err := dir()
+	if err != nil {
+		return err
+	}
+	sum, err := migrate.HashSum(dir)
+	if err != nil {
+		return err
+	}
+	return migrate.WriteSumFile(dir, sum)
+}
+
 // CmdMigrateNewRun is the command executed when running the CLI with 'migrate new' args.
 func CmdMigrateNewRun(_ *cobra.Command, args []string) error {
 	dir, err := dir()
@@ -197,17 +218,28 @@ func CmdMigrateNewRun(_ *cobra.Command, args []string) error {
 	return migrate.NewPlanner(nil, dir, migrate.WithFormatter(f)).WritePlan(&migrate.Plan{Name: name})
 }
 
-// CmdMigrateHashRun is the command executed when running the CLI with 'migrate hash' args.
-func CmdMigrateHashRun(*cobra.Command, []string) error {
-	dir, err := dir()
+// CmdMigrateValidateRun is the command executed when running the CLI with 'migrate validate' args.
+func CmdMigrateValidateRun(cmd *cobra.Command, _ []string) error {
+	// Validating the integrity is done by the PersistentPreRun already.
+	if MigrateFlags.DevURL == "" {
+		// If there is no --dev-url given do not attempt to replay the migration directory.
+		return nil
+	}
+	// Open a client for the dev-db.
+	dev, err := sqlclient.Open(cmd.Context(), MigrateFlags.DevURL)
 	if err != nil {
 		return err
 	}
-	sum, err := migrate.HashSum(dir)
+	defer dev.Close()
+	// We are using a migrate.GlobStateReader to replay the migration directory.
+	dir, err := dir()
 	if err != nil {
 		return err
 	}
-	return migrate.WriteSumFile(dir, sum)
+	if _, err := migrate.GlobStateReader(dir, dev.Driver, "*.sql").ReadState(cmd.Context()); err != nil {
+		return fmt.Errorf("replaying the migration directory: %w", err)
+	}
+	return nil
 }
 
 // dir returns a migrate.Dir to use as migration directory. For now only local directories are supported.
diff --git a/doc/md/cli/reference.md b/doc/md/cli/reference.md
index e0640c88..1df406bd 100644
--- a/doc/md/cli/reference.md
+++ b/doc/md/cli/reference.md
@@ -60,7 +60,7 @@ atlas migrate
 
 #### Flags
 ```
-      --dir string       select migration directory using DSN format (default "file://migrations")
+      --dir string       select migration directory using URL format (default "file://migrations")
       --force            force a command to run on a broken migration directory state
       --format string    set migration file format (default "atlas")
       --schema strings   set schema names
@@ -90,8 +90,8 @@ the migration directory state to the desired schema. The desired state can be an
 ```
 #### Flags
 ```
-      --dev-url string   [driver://username:password@address/dbname?param=value] select a data source using the DSN format
-      --to string        [driver://username:password@address/dbname?param=value] select a data source using the DSN format
+      --dev-url string   [driver://username:password@address/dbname?param=value] select a data source using the URL format
+      --to string        [driver://username:password@address/dbname?param=value] select a data source using the URL format
       --verbose          enable verbose logging
 
 ```
@@ -140,7 +140,7 @@ Validates the migration directories checksum.
 
 #### Usage
 ```
-atlas migrate validate
+atlas migrate validate [flags]
 ```
 
 #### Details
@@ -153,6 +153,12 @@ the atlas.sum file. If there is a mismatch it will be reported.
   atlas migrate validate
   atlas migrate validate --dir /path/to/migration/directory
 ```
+#### Flags
+```
+      --dev-url string   [driver://username:password@address/dbname?param=value] select a data source using the URL format
+
+```
+
 
 ## atlas schema
 
diff --git a/doc/md/ddl/sql.mdx b/doc/md/ddl/sql.mdx
index 91eb387e..fbd9dcd7 100644
--- a/doc/md/ddl/sql.mdx
+++ b/doc/md/ddl/sql.mdx
@@ -400,6 +400,12 @@ index "idx_name" {
   type = GIN
   columns = [column.data]
 }
+
+index "idx_range" {
+  type = BRIN
+  columns = [column.range]
+  page_per_range = 128
+}
 ```
 
 #### Properties
@@ -410,6 +416,7 @@ index "idx_name" {
 | type      | attribute | IndexType (enum)        | Defines the index type. e.g. `HASH`, `GIN`, `FULLTEXT`.        |
 | columns   | attribute | reference (list)        | The columns that comprise the index.                           |
 | on        | resource  | schema.IndexPart (list) | The index parts that comprise the index.                       |
+| options   | attribute | schema.Attr             | Additional driver specific attributes. e.g. `page_per_range`   |
 
 ### Index Expressions
 
diff --git a/doc/md/getting-started/01-introduction.mdx b/doc/md/getting-started/01-introduction.mdx
index cb423657..4ff86567 100644
--- a/doc/md/getting-started/01-introduction.mdx
+++ b/doc/md/getting-started/01-introduction.mdx
@@ -83,10 +83,9 @@ move the atlas binary to a file location on your system PATH.
 Latest release is updated twice a day and is based on the most recent tagged release.
 :::
 
-Starting with release [v0.3.0](https://github.com/ariga/atlas/releases/tag/v0.3.0),
-ֿthe distributed binaries include code for a [Management UI](../ui/intro.md) wrapping the
+The distributed binaries include code for a [Management UI](../ui/intro.md) wrapping the
 core Atlas engine that is not currently released publicly. The binaries
-themselves are still released under the same [Apache License 2.0](https://github.com/ariga/atlas/blob/master/LICENSE).
+themselves are released under the [Ariga End User License](https://ariga.io/legal/atlas/eula).
 If you would like to build Atlas from source without the UI follow the instructions [here](../cli/reference.md#building-from-source).
 
 To verify that the CLI is installed correctly run:
diff --git a/doc/md/terraform.md b/doc/md/terraform.md
new file mode 100644
index 00000000..e62a2f88
--- /dev/null
+++ b/doc/md/terraform.md
@@ -0,0 +1,67 @@
+---
+title: Terraform Provider
+id: terraform-provider
+slug: /terraform-provider
+---
+## Introduction
+
+The official [Atlas Terraform provider](https://registry.terraform.io/providers/ariga/atlas/latest) 
+allows you to use Atlas with Terraform to manage your database schemas as part of you Infrastructure-as-Code (Iac)
+workflow . Read about the release announcement [here](https://atlasgo.io/blog/2022-05-03-announcing-terraform-provider).
+* [Documentation](https://registry.terraform.io/providers/ariga/atlas/latest/docs)
+* [GitHub Repository](https://github.com/ariga/terraform-provider-atlas)
+
+## Installation
+Add Atlas to your [required providers](https://www.terraform.io/language/providers/requirements#requiring-providers):
+```hcl
+terraform {
+  required_providers {
+    atlas = {
+      source = "ariga/atlas"
+      version = "~> 0.1.0"
+    }
+  }
+}
+```
+
+## Basic example
+
+Currently, the Atlas Terraform provider uses an [HCL file](/ddl/sql) to describe the
+desired state of the database, and performs migrations according to the state difference 
+between the HCL file and the target database.
+
+To use the Terraform provider, you will need such a file. To quickly get started, either:
+
+1. Use the Atlas CLI to [inspect](cli/getting-started/inspection#inspecting-our-database) an existing database  
+2. Start with an empty, basic schema:
+ 
+```hcl
+schema "example" {
+  charset = "utf8mb4"
+  collate = "utf8mb4_0900_ai_ci"
+}
+```
+
+### Configure Terraform
+
+Use the following configuration to apply the HCL file `schema.hcl` onto a target MySQL 
+database (but you can specify any of the [supported databases](https://github.com/ariga/atlas#supported-databases)):
+
+```hcl title="main.tf"
+provider "atlas" {}
+
+// Load (and normalize) the desired schema from an HCL file.
+data "atlas_schema" "market" {
+  dev_db_url = "mysql://root:pass@localhost:3307/market"
+  src = file("${path.module}/schema.hcl")
+}
+
+// Sync the state of the target database with the hcl file.
+resource "atlas_schema" "market" {
+  hcl = data.atlas_schema.market.hcl
+  url = "mysql://root:pass@localhost:3306/market"  
+  dev_db_url = "mysql://root:pass@localhost:3307/market"
+}
+```
+
+For the full documentation and examples of the provider visit the [registry page](https://registry.terraform.io/providers/ariga/atlas/latest/docs).
diff --git a/doc/website/blog/2022-05-04-announcing-terraform-provider.md b/doc/website/blog/2022-05-04-announcing-terraform-provider.md
new file mode 100644
index 00000000..7251b35d
--- /dev/null
+++ b/doc/website/blog/2022-05-04-announcing-terraform-provider.md
@@ -0,0 +1,131 @@
+---
+title: "Terraform for databases: Announcing the official Terraform provider for Atlas"
+authors: Hedwigz
+tags: [terraform, announcement]
+image: https://blog.ariga.io/uploads/images/posts/terraform-provider/terraform-provider.png
+---
+
+Today we are glad to announce the release of the official [Atlas Terraform Provider](https://registry.terraform.io/providers/ariga/atlas).
+
+### What is Terraform
+[Terraform](https://terraform.io/) is a popular open-source tool created by [HashiCorp](https://www.hashicorp.com/), used to greatly simplify the task of provisioning and managing resources in the cloud. With Terraform, organizations can describe the desired state of their infrastructure in a simple configuration language and let Terraform plan and apply these changes in an automated way. This way, Terraform allows teams to truly deliver infrastructure-as-code (IaC), which completely change how teams and organizations manage their cloud infrastructure. 
+
+### Infrastructure-as-Code and database management
+Most cloud-native applications are backed by a database. The database is often the most critical part of many software systems, so making changes to its schema (structure and layout of the data inside) is a very risky business. However, schemas must evolve: as functionality changes over time, the backing tables are added, columns are dropped, indexes are created for performance reasons, and more.  
+  
+Therefore it is surprising that there is no established way of integrating the management of schema changes (commonly called schema "migrations") into popular Infrastructure-as-Code workflows. For this reason, many organizations are running migrations from within the application code or using solutions outside the ecosystem of Terraform, meaning that management of the production environment is fragmented and hard to synchronize. Atlas aims to change that.  
+  
+The Atlas Terraform provider allows you to synchronize your database with your desired schema, in a safe and stateful manner. By using Atlas’s core migration engine and embedding it in a Terraform provider, we are enabling teams to manage their database schemas as part of their full IaC workflow. This way, teams can use existing providers (such as AWS or GCP) to provision the database instance and use the Atlas provider to keep the schema in sync. Integrating Atlas with Terraform is especially useful because it couples the state of the infrastructure with the state of the database. It is also extremely neat when using a [dev database](https://atlasgo.io/dev-database), which is a feature that combines infrastructure and DB management to provide safety and correctness.
+
+### Demo
+#### Prerequisites
+Make sure you have installed:
+* [Docker](https://docs.docker.com/get-docker/)
+* [Terraform](https://www.terraform.io/downloads)
+
+Let’s see an example of the provider in action. First, spin a database using docker:
+```bash
+docker run -p 3306:3306 --name iloveatlas -e MYSQL_ROOT_PASSWORD=pass -e MYSQL_DATABASE=market -d mysql:8
+```
+Great! Now we have an instance of MySQL database running.
+
+As an extra measure of safety, we will run another identical database which will serve as a [Dev Database](https://atlasgo.io/cli/dev-database). In short, the dev-db helps to catch errors that can only be detected when applying the schema. It is also useful to format the schema in a correct and predictable way. Read more about it [here](https://atlasgo.io/cli/dev-database).  
+Run a second instance of MySQL on another port, to serve as a dev-db:
+
+```shell
+docker run -p 3307:3306 --name devdb-greatness -e MYSQL_ROOT_PASSWORD=pass -e MYSQL_DATABASE=market -d mysql:8
+```
+
+Next, we need an HCL file describing the desired state of our database. You can use [atlas cli](https://atlasgo.io/cli/getting-started/setting-up) to [inspect](https://atlasgo.io/cli/getting-started/inspection) the state of another database or you can use the following basic schema:
+```hcl title="schema.hcl"
+table "orders" {
+  schema = schema.market
+  column "id" {
+    null           = false
+    type           = int
+    auto_increment = true
+  }
+  column "name" {
+    null = false
+    type = varchar(20)
+  }
+  primary_key {
+    columns = [column.id]
+  }
+}
+
+schema "market" {
+  charset = "utf8mb4"
+  collate = "utf8mb4_0900_ai_ci"
+}
+``` 
+Save the schema file locally in a file named `schema.hcl`.
+
+Now that we have our database schema we can use terraform to apply that schema to our database.
+Create a file named `main.tf` and copy the following snippet:
+```hcl title="main.tf"
+terraform {
+  required_providers {
+    atlas = {
+      version = "~> 0.1.0"
+      source  = "ariga/atlas"
+    }
+  }
+}
+
+provider "atlas" {}
+
+data "atlas_schema" "market" {
+  dev_db_url = "mysql://root:pass@localhost:3307/market"
+  src = file("${path.module}/schema.hcl")
+}
+
+resource "atlas_schema" "market" {
+  hcl = data.atlas_schema.market.hcl
+  url = "mysql://root:pass@localhost:3306/market"  
+  dev_db_url = "mysql://root:pass@localhost:3307/market"
+}
+```
+Finally, init terraform:
+```shell
+terraform init
+```
+And apply the schema to the database by executing:
+```shell
+terraform apply --auto-approve
+```
+
+Awesome! Now your database should have a table named `orders`. To verify that we can connect to the database:
+```shell
+$ docker exec -it iloveatlas mysql -ppass --database=market
+
+mysql> show tables;
++------------------+
+| Tables_in_market |
++------------------+
+| orders           |
++------------------+
+1 row in set (0.00 sec)
+
+mysql> show create table orders;
++--------+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
+| Table  | Create Table                                                                                                                                                                       |
++--------+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
+| orders | CREATE TABLE `orders` (
+  `id` int NOT NULL AUTO_INCREMENT,
+  `name` varchar(20) NOT NULL,
+  PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci |
++--------+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
+1 row in set (0.00 sec)
+```
+
+For more examples and documentation visit the official [GitHub repository](https://github.com/ariga/terraform-provider-atlas) or the [provider page](https://registry.terraform.io/providers/ariga/atlas/latest) on Terraform registry.
+
+### What's next
+
+In this post, we presented the [Atlas Terraform Provider](https://registry.terraform.io/providers/ariga/atlas/latest). The provider currently supports the basic, declarative migration workflow that is available in the Atlas engine. In upcoming versions, we will add support for an additional kind of workflow that is supported by the engine and is called [versioned migration authoring](https://entgo.io/blog/2022/03/14/announcing-versioned-migrations/). In addition, more advanced safety checks (such as simulation on database snapshots) and migration strategies are also being worked on.  
+
+While the Terraform provider has just been released, the [core engine](https://github.com/ariga/atlas) that it is driving, is well tested and widely used (especially as the migration engine backing the popular [Ent](https://github.com/ent/ent) framework.) If you, like me, have always wanted to manage your database schema as part of your team's infrastructure-as-code workflow, give the Atlas Terraform provider a try!
+
+Have questions? Feedback? Find our team [on our Discord server](https://discord.gg/zZ6sWVg6NT).
diff --git a/doc/website/blog/authors.yml b/doc/website/blog/authors.yml
new file mode 100644
index 00000000..9b9fd9fe
--- /dev/null
+++ b/doc/website/blog/authors.yml
@@ -0,0 +1,6 @@
+Hedwigz:
+  name: Amit Shani
+  title: Software Engineer at Ariga
+  url: "https://github.com/hedwigz"
+  image_url: "https://avatars.githubusercontent.com/u/8277210?v=4"
+  twitter: itsamitush
\ No newline at end of file
diff --git a/doc/website/docusaurus.config.js b/doc/website/docusaurus.config.js
index 26197f39..0f471a81 100644
--- a/doc/website/docusaurus.config.js
+++ b/doc/website/docusaurus.config.js
@@ -75,6 +75,7 @@ module.exports = {
           "items": [
             {"label": "Privacy Policy", "to": "https://ariga.io/legal/privacy"},
             {"label": "Terms of Service", "to": "https://ariga.io/legal/tos"},
+            {"label": "End User License", "to": "https://ariga.io/legal/atlas/eula"},
           ]
         }
       ],
@@ -87,6 +88,11 @@ module.exports = {
       <br/>
       `,
     },
+    announcementBar: {
+      id: 'announcementBar-1', // Increment on change
+      content: `️🚀 Sign up for a user testing session and receive exclusive Atlas swag, register <a target="_blank" rel="noopener noreferrer" href="https://calendly.com/ariga-user-testing/atlas-user-testing">here!</a>`,
+      isCloseable: true,
+    },
   },
   plugins: [
     [
@@ -100,6 +106,12 @@ module.exports = {
         ],
       },
     ],
+    [
+      require.resolve('docusaurus-gtm-plugin'),
+      {
+        id: 'GTM-T9GX8BR', // GTM Container ID
+      }
+    ],
   ],
   presets: [
     [
diff --git a/doc/website/package-lock.json b/doc/website/package-lock.json
index bd427c39..ec689ec9 100644
--- a/doc/website/package-lock.json
+++ b/doc/website/package-lock.json
@@ -14,6 +14,7 @@
         "@mdx-js/react": "^1.6.21",
         "@svgr/webpack": "^5.5.0",
         "clsx": "^1.1.1",
+        "docusaurus-gtm-plugin": "^0.0.2",
         "file-loader": "^6.2.0",
         "react": "^17.0.1",
         "react-dom": "^17.0.1",
@@ -6145,6 +6146,11 @@
         "buffer-indexof": "^1.0.0"
       }
     },
+    "node_modules/docusaurus-gtm-plugin": {
+      "version": "0.0.2",
+      "resolved": "https://registry.npmjs.org/docusaurus-gtm-plugin/-/docusaurus-gtm-plugin-0.0.2.tgz",
+      "integrity": "sha512-Xx/df0Ppd5SultlzUj9qlQk2lX9mNVfTb41juyBUPZ1Nc/5dNx+uN0VuLyF4JEObkDRrUY1EFo9fEUDo8I6QOQ=="
+    },
     "node_modules/dom-converter": {
       "version": "0.2.0",
       "resolved": "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz",
@@ -17691,6 +17697,11 @@
         "buffer-indexof": "^1.0.0"
       }
     },
+    "docusaurus-gtm-plugin": {
+      "version": "0.0.2",
+      "resolved": "https://registry.npmjs.org/docusaurus-gtm-plugin/-/docusaurus-gtm-plugin-0.0.2.tgz",
+      "integrity": "sha512-Xx/df0Ppd5SultlzUj9qlQk2lX9mNVfTb41juyBUPZ1Nc/5dNx+uN0VuLyF4JEObkDRrUY1EFo9fEUDo8I6QOQ=="
+    },
     "dom-converter": {
       "version": "0.2.0",
       "resolved": "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz",
diff --git a/doc/website/package.json b/doc/website/package.json
index 4242f06d..66112f47 100644
--- a/doc/website/package.json
+++ b/doc/website/package.json
@@ -22,6 +22,7 @@
     "@mdx-js/react": "^1.6.21",
     "@svgr/webpack": "^5.5.0",
     "clsx": "^1.1.1",
+    "docusaurus-gtm-plugin": "^0.0.2",
     "file-loader": "^6.2.0",
     "react": "^17.0.1",
     "react-dom": "^17.0.1",
diff --git a/doc/website/sidebars.js b/doc/website/sidebars.js
index 475c1c67..852a2a02 100644
--- a/doc/website/sidebars.js
+++ b/doc/website/sidebars.js
@@ -54,6 +54,10 @@ module.exports = {
             type: 'doc',
             id: 'deployment/deployment',
         },
+        {
+            type: 'doc',
+            id: 'terraform-provider',
+        },
         {
             type: 'category',
             label: 'Go API',
diff --git a/doc/website/src/css/custom.css b/doc/website/src/css/custom.css
index 66af53ed..5ef830e8 100644
--- a/doc/website/src/css/custom.css
+++ b/doc/website/src/css/custom.css
@@ -132,4 +132,32 @@ span.token.keyword {
 
 span.token.punctuation {
   color: rgb(191, 199, 213)!important;
+}
+
+div.language-hcl * span.token.property {
+  color: #9CDCFE;
+}
+
+:root {
+  --site-primary-hue-saturation: 217, 73%, 78%;
+  --ifm-footer-title-color: white;
+}
+
+div[class^='announcementBar_'] {
+  --site-announcement-bar-stripe-color1: hsl(
+          var(--site-primary-hue-saturation),
+          30%
+  );
+  --site-announcement-bar-stripe-color2: hsl(
+          var(--site-primary-hue-saturation),
+          55%
+  );
+  background: repeating-linear-gradient(
+          35deg,
+          var(--site-announcement-bar-stripe-color1),
+          var(--site-announcement-bar-stripe-color1) 20px,
+          var(--site-announcement-bar-stripe-color2) 10px,
+          var(--site-announcement-bar-stripe-color2) 40px
+  );
+  font-weight: bold;
 }
\ No newline at end of file
diff --git a/doc/website/src/pages/index.module.css b/doc/website/src/pages/index.module.css
index 4589ff4a..7003d466 100644
--- a/doc/website/src/pages/index.module.css
+++ b/doc/website/src/pages/index.module.css
@@ -593,4 +593,28 @@ html[data-theme='dark'] .subtitle2 {
 }
 html[data-theme='dark'] .subtitleMargin {
   color: white;
+}
+
+:root {
+  --site-primary-hue-saturation: 217, 73%, 78%;
+  --ifm-footer-title-color: white;
+}
+
+div[class^='announcementBar_'] {
+  --site-announcement-bar-stripe-color1: hsl(
+          var(--site-primary-hue-saturation),
+          30%
+  );
+  --site-announcement-bar-stripe-color2: hsl(
+          var(--site-primary-hue-saturation),
+          55%
+  );
+  background: repeating-linear-gradient(
+          35deg,
+          var(--site-announcement-bar-stripe-color1),
+          var(--site-announcement-bar-stripe-color1) 20px,
+          var(--site-announcement-bar-stripe-color2) 10px,
+          var(--site-announcement-bar-stripe-color2) 40px
+  );
+  font-weight: bold;
 }
\ No newline at end of file
diff --git a/sql/postgres/diff.go b/sql/postgres/diff.go
index 382e6aa9..d61c0a59 100644
--- a/sql/postgres/diff.go
+++ b/sql/postgres/diff.go
@@ -161,7 +161,12 @@ func (*diff) IndexAttrChanged(from, to []schema.Attr) bool {
 		return true
 	}
 	var p1, p2 IndexPredicate
-	return sqlx.Has(from, &p1) != sqlx.Has(to, &p2) || (p1.P != p2.P && p1.P != sqlx.MayWrap(p2.P))
+	if sqlx.Has(from, &p1) != sqlx.Has(to, &p2) || (p1.P != p2.P && p1.P != sqlx.MayWrap(p2.P)) {
+		return true
+	}
+	s1, ok1 := indexStorageParams(from)
+	s2, ok2 := indexStorageParams(to)
+	return ok1 != ok2 || ok1 && *s1 != *s2
 }
 
 // IndexPartAttrChanged reports if the index-part attributes were changed.
@@ -407,6 +412,19 @@ func formatPartition(p Partition) (string, error) {
 	return b.String(), nil
 }
 
+// indexStorageParams returns the index storage parameters from the attributes
+// in case it is there, and it is not the default.
+func indexStorageParams(attrs []schema.Attr) (*IndexStorageParams, bool) {
+	s := &IndexStorageParams{}
+	if !sqlx.Has(attrs, s) {
+		return nil, false
+	}
+	if !s.AutoSummarize && (s.PagesPerRange == 0 || s.PagesPerRange == defaultPagePerRange) {
+		return nil, false
+	}
+	return s, true
+}
+
 func trimCast(s string) string {
 	i := strings.LastIndex(s, "::")
 	if i == -1 {
diff --git a/sql/postgres/driver.go b/sql/postgres/driver.go
index be523d5b..06b21979 100644
--- a/sql/postgres/driver.go
+++ b/sql/postgres/driver.go
@@ -231,10 +231,12 @@ const (
 
 // List of supported index types.
 const (
-	IndexTypeBTree = "BTREE"
-	IndexTypeHash  = "HASH"
-	IndexTypeGIN   = "GIN"
-	IndexTypeGiST  = "GIST"
+	IndexTypeBTree      = "BTREE"
+	IndexTypeHash       = "HASH"
+	IndexTypeGIN        = "GIN"
+	IndexTypeGiST       = "GIST"
+	IndexTypeBRIN       = "BRIN"
+	defaultPagePerRange = 128
 )
 
 // List of "GENERATED" types.
diff --git a/sql/postgres/inspect.go b/sql/postgres/inspect.go
index 912190f8..e88eccc5 100644
--- a/sql/postgres/inspect.go
+++ b/sql/postgres/inspect.go
@@ -349,12 +349,12 @@ func (i *inspect) addIndexes(s *schema.Schema, rows *sql.Rows) error {
 	names := make(map[string]*schema.Index)
 	for rows.Next() {
 		var (
-			uniq, primary                        bool
-			table, name, typ                     string
-			desc, nullsfirst, nullslast          sql.NullBool
-			column, contype, pred, expr, comment sql.NullString
+			uniq, primary                                 bool
+			table, name, typ                              string
+			desc, nullsfirst, nullslast                   sql.NullBool
+			column, contype, pred, expr, comment, options sql.NullString
 		)
-		if err := rows.Scan(&table, &name, &typ, &column, &primary, &uniq, &contype, &pred, &expr, &desc, &nullsfirst, &nullslast, &comment); err != nil {
+		if err := rows.Scan(&table, &name, &typ, &column, &primary, &uniq, &contype, &pred, &expr, &desc, &nullsfirst, &nullslast, &comment, &options); err != nil {
 			return fmt.Errorf("postgres: scanning indexes for schema %q: %w", s.Name, err)
 		}
 		t, ok := s.Table(table)
@@ -380,6 +380,13 @@ func (i *inspect) addIndexes(s *schema.Schema, rows *sql.Rows) error {
 			if sqlx.ValidString(pred) {
 				idx.Attrs = append(idx.Attrs, &IndexPredicate{P: pred.String})
 			}
+			if sqlx.ValidString(options) {
+				p, err := newIndexStorage(options.String)
+				if err != nil {
+					return err
+				}
+				idx.Attrs = append(idx.Attrs, p)
+			}
 			names[name] = idx
 			if primary {
 				t.PrimaryKey = idx
@@ -736,6 +743,17 @@ type (
 		NullsLast bool
 	}
 
+	// IndexStorageParams describes index storage parameters add with the WITH clause.
+	// https://www.postgresql.org/docs/current/sql-createindex.html#SQL-CREATEINDEX-STORAGE-PARAMETERS
+	IndexStorageParams struct {
+		schema.Attr
+		// AutoSummarize defines the authsummarize storage parameter.
+		AutoSummarize bool
+		// PagesPerRange defines pages_per_range storage
+		// parameter for BRIN indexes. Defaults to 128.
+		PagesPerRange int64
+	}
+
 	// NoInherit attribute defines the NO INHERIT flag for CHECK constraint.
 	// https://www.postgresql.org/docs/current/catalog-pg-constraint.html
 	NoInherit struct {
@@ -773,6 +791,32 @@ type (
 	}
 )
 
+// newIndexStorage parses and returns the index storage parameters.
+func newIndexStorage(opts string) (*IndexStorageParams, error) {
+	params := &IndexStorageParams{}
+	for _, p := range strings.Split(strings.Trim(opts, "{}"), ",") {
+		kv := strings.Split(p, "=")
+		if len(kv) != 2 {
+			return nil, fmt.Errorf("invalid index storage parameter: %s", p)
+		}
+		switch kv[0] {
+		case "autosummarize":
+			b, err := strconv.ParseBool(kv[1])
+			if err != nil {
+				return nil, fmt.Errorf("failed parsing autosummarize %q: %w", kv[1], err)
+			}
+			params.AutoSummarize = b
+		case "pages_per_range":
+			i, err := strconv.ParseInt(kv[1], 10, 64)
+			if err != nil {
+				return nil, fmt.Errorf("failed parsing pages_per_range %q: %w", kv[1], err)
+			}
+			params.PagesPerRange = i
+		}
+	}
+	return params, nil
+}
+
 const (
 	// Query to list runtime parameters.
 	paramsQuery = `SELECT setting FROM pg_settings WHERE name IN ('lc_collate', 'lc_ctype', 'server_version_num') ORDER BY name`
@@ -869,7 +913,8 @@ SELECT
 	pg_index_column_has_property(idx.indexrelid, a.attnum, 'desc') AS desc,
 	pg_index_column_has_property(idx.indexrelid, a.attnum, 'nulls_first') AS nulls_first,
 	pg_index_column_has_property(idx.indexrelid, a.attnum, 'nulls_last') AS nulls_last,
-	obj_description(to_regclass($1 || i.relname)::oid) AS comment
+	obj_description(to_regclass($1 || i.relname)::oid) AS comment,
+	i.reloptions AS options
 FROM
 	(
 		select
diff --git a/sql/postgres/migrate.go b/sql/postgres/migrate.go
index f419b982..58110911 100644
--- a/sql/postgres/migrate.go
+++ b/sql/postgres/migrate.go
@@ -729,9 +729,22 @@ func (s *state) index(b *sqlx.Builder, idx *schema.Index) {
 	if p := (IndexPredicate{}); sqlx.Has(idx.Attrs, &p) {
 		b.P("WHERE").P(p.P)
 	}
+	if p, ok := indexStorageParams(idx.Attrs); ok {
+		b.P("WITH")
+		b.Wrap(func(b *sqlx.Builder) {
+			var parts []string
+			if p.AutoSummarize {
+				parts = append(parts, "autosummarize = true")
+			}
+			if p.PagesPerRange != 0 && p.PagesPerRange != defaultPagePerRange {
+				parts = append(parts, fmt.Sprintf("pages_per_range = %d", p.PagesPerRange))
+			}
+			b.WriteString(strings.Join(parts, ", "))
+		})
+	}
 	for _, attr := range idx.Attrs {
 		switch attr.(type) {
-		case *schema.Comment, *ConType, *IndexType, *IndexPredicate:
+		case *schema.Comment, *ConType, *IndexType, *IndexPredicate, *IndexStorageParams:
 		default:
 			panic(fmt.Sprintf("unexpected index attribute: %T", attr))
 		}
diff --git a/sql/postgres/sqlspec.go b/sql/postgres/sqlspec.go
index e7e574b6..0d75e3d5 100644
--- a/sql/postgres/sqlspec.go
+++ b/sql/postgres/sqlspec.go
@@ -105,7 +105,7 @@ func MarshalSpec(v interface{}, marshaler schemaspec.Marshaler) ([]byte, error)
 var (
 	hclState = schemahcl.New(
 		schemahcl.WithTypes(TypeRegistry.Specs()),
-		schemahcl.WithScopedEnums("table.index.type", IndexTypeBTree, IndexTypeHash, IndexTypeGIN, IndexTypeGiST),
+		schemahcl.WithScopedEnums("table.index.type", IndexTypeBTree, IndexTypeHash, IndexTypeGIN, IndexTypeGiST, IndexTypeBRIN),
 		schemahcl.WithScopedEnums("table.partition.type", PartitionTypeRange, PartitionTypeList, PartitionTypeHash),
 		schemahcl.WithScopedEnums("table.column.identity.generated", GeneratedTypeAlways, GeneratedTypeByDefault),
 		schemahcl.WithScopedEnums("table.column.as.type", "STORED"),
@@ -307,6 +307,13 @@ func convertIndex(spec *sqlspec.Index, t *schema.Table) (*schema.Index, error) {
 		}
 		idx.Attrs = append(idx.Attrs, &IndexPredicate{P: p})
 	}
+	if attr, ok := spec.Attr("page_per_range"); ok {
+		p, err := attr.Int64()
+		if err != nil {
+			return nil, err
+		}
+		idx.Attrs = append(idx.Attrs, &IndexStorageParams{PagesPerRange: p})
+	}
 	return idx, nil
 }
 
@@ -445,6 +452,9 @@ func indexSpec(idx *schema.Index) (*sqlspec.Index, error) {
 	if i := (IndexPredicate{}); sqlx.Has(idx.Attrs, &i) && i.P != "" {
 		spec.Extra.Attrs = append(spec.Extra.Attrs, specutil.VarAttr("where", strconv.Quote(i.P)))
 	}
+	if p, ok := indexStorageParams(idx.Attrs); ok {
+		spec.Extra.Attrs = append(spec.Extra.Attrs, specutil.Int64Attr("page_per_range", p.PagesPerRange))
+	}
 	return spec, nil
 }
 


Test patch:
diff --git a/cmd/atlascmd/migrate_test.go b/cmd/atlascmd/migrate_test.go
index 7dba8efe..891872e9 100644
--- a/cmd/atlascmd/migrate_test.go
+++ b/cmd/atlascmd/migrate_test.go
@@ -12,7 +12,6 @@ import (
 	"io/ioutil"
 	"net/url"
 	"os"
-	"os/exec"
 	"path/filepath"
 	"strings"
 	"testing"
@@ -127,46 +126,47 @@ func TestMigrate_New(t *testing.T) {
 	require.NoError(t, err)
 	require.FileExists(t, filepath.Join(p, v+"_liquibase.sql"))
 	require.Equal(t, 9, countFiles(t, p))
-}
 
-func TestMigrate_NewError(t *testing.T) {
-	if os.Getenv("DO_NEW") == "1" {
-		runCmd(Root, "migrate", "new", "--dir", "file://testdata")
-		return
-	}
 	f := filepath.Join("testdata", "new.sql")
 	require.NoError(t, os.WriteFile(f, []byte("contents"), 0600))
-	defer os.Remove(f)
-	cmd := exec.Command(os.Args[0], "-test.run=TestMigrate_NewError") //nolint:gosec
-	cmd.Env = append(os.Environ(), "DO_NEW=1")
-	err := cmd.Run()
-	if err, ok := err.(*exec.ExitError); ok && !err.Success() {
-		return
-	}
-	t.Fatalf("process ran with err %v, want exit status 1", err)
+	t.Cleanup(func() { os.Remove(f) })
+	s, err = runCmd(Root, "migrate", "new", "--dir", "file://testdata")
+	require.NotZero(t, s)
+	require.Error(t, err)
 }
 
 func TestMigrate_Validate(t *testing.T) {
+	// Without re-playing.
+	MigrateFlags.DevURL = "" // global flags are set from other tests ...
 	s, err := runCmd(Root, "migrate", "validate", "--dir", "file://testdata")
 	require.Zero(t, s)
 	require.NoError(t, err)
-}
 
-func TestMigrate_ValidateError(t *testing.T) {
-	if os.Getenv("DO_VALIDATE") == "1" {
-		runCmd(Root, "migrate", "validate", "--dir", "file://testdata")
-		return
-	}
 	f := filepath.Join("testdata", "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()
-	if err, ok := err.(*exec.ExitError); ok && !err.Success() {
-		return
-	}
-	t.Fatalf("process ran with err %v, want exit status 1", err)
+	t.Cleanup(func() { os.Remove(f) })
+	s, err = runCmd(Root, "migrate", "validate", "--dir", "file://testdata")
+	require.NotZero(t, s)
+	require.Error(t, err)
+	require.NoError(t, os.Remove(f))
+
+	// Replay migration files if a dev-url is given.
+	p := t.TempDir()
+	require.NoError(t, os.WriteFile(filepath.Join(p, "1_initial.sql"), []byte("create table t1 (c1 int)"), 0644))
+	require.NoError(t, os.WriteFile(filepath.Join(p, "2_second.sql"), []byte("create table t2 (c2 int)"), 0644))
+	_, err = runCmd(Root, "migrate", "hash", "--force", "--dir", "file://"+p)
+	require.NoError(t, err)
+	s, err = runCmd(
+		Root, "migrate", "validate",
+		"--dir", "file://"+p,
+		"--dev-url", openSQLite(t, ""),
+	)
+	require.Zero(t, s)
+	require.NoError(t, err)
+
+	// Should fail since the files are not compatible with SQLite.
+	_, err = runCmd(Root, "migrate", "validate", "--dir", "file://testdata", "--dev-url", openSQLite(t, ""))
+	require.Error(t, err)
 }
 
 func TestMigrate_Hash(t *testing.T) {
@@ -191,23 +191,15 @@ func TestMigrate_Hash(t *testing.T) {
 	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(Root, "migrate", "hash", "--dir", "file://"+os.Getenv("MIGRATION_DIR"))
-		return
-	}
-	p := t.TempDir()
-	err := copyFile(filepath.Join("testdata", "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 exit status 1", err)
+	p = t.TempDir()
+	require.NoError(t, copyFile(
+		filepath.Join("testdata", "20220318104614_initial.sql"),
+		filepath.Join(p, "20220318104614_initial.sql"),
+	))
+	s, err = runCmd(Root, "migrate", "hash", "--dir", "file://"+os.Getenv("MIGRATION_DIR"))
+	require.NotZero(t, s)
+	require.Error(t, err)
 }
 
 const hcl = `
diff --git a/internal/integration/integration_test.go b/internal/integration/integration_test.go
index ba3e0c10..c2193621 100644
--- a/internal/integration/integration_test.go
+++ b/internal/integration/integration_test.go
@@ -18,7 +18,6 @@ import (
 	"ariga.io/atlas/schema/schemaspec"
 	"ariga.io/atlas/sql/migrate"
 	"ariga.io/atlas/sql/schema"
-
 	entsql "entgo.io/ent/dialect/sql"
 	entschema "entgo.io/ent/dialect/sql/schema"
 	"entgo.io/ent/entc/integration/ent"
@@ -542,3 +541,11 @@ func (r *rrw) ReadRevisions(ctx context.Context) (migrate.Revisions, error) {
 func (r *rrw) clean() {
 	*r = rrw(migrate.Revisions{})
 }
+
+func buildCmd(t *testing.T) (string, error) {
+	td := t.TempDir()
+	if err := exec.Command("go", "build", "-o", td, "ariga.io/atlas/cmd/atlas").Run(); err != nil {
+		return "", err
+	}
+	return filepath.Join(td, "atlas"), nil
+}
diff --git a/internal/integration/script_test.go b/internal/integration/script_test.go
index 1af395a6..bdc00627 100644
--- a/internal/integration/script_test.go
+++ b/internal/integration/script_test.go
@@ -73,6 +73,7 @@ func TestSQLite_Script(t *testing.T) {
 			"synced":  tt.cmdSynced,
 			"cmpshow": tt.cmdCmpShow,
 			"execsql": tt.cmdExec,
+			"atlas":   tt.cmdCLI,
 		},
 	})
 }
@@ -122,8 +123,12 @@ var (
 	keyDrv *sqlite.Driver
 )
 
+const cliPathKey = "cli"
+
 func (t *liteTest) setupScript(env *testscript.Env) error {
-	db, err := sql.Open("sqlite3", fmt.Sprintf("file:%s?mode=memory&cache=shared&_fk=1", filepath.Base(env.WorkDir)))
+
+	db, err := sql.Open("sqlite3", fmt.Sprintf("file:%s?cache=shared&_fk=1",
+		filepath.Join(env.WorkDir, "atlas.sqlite")))
 	require.NoError(t, err)
 	env.Defer(func() {
 		require.NoError(t, db.Close())
@@ -135,6 +140,11 @@ func (t *liteTest) setupScript(env *testscript.Env) error {
 	// environment as tests run in parallel.
 	env.Values[keyDB] = db
 	env.Values[keyDrv] = drv
+	cliPath, err := buildCmd(t.T)
+	if err != nil {
+		return err
+	}
+	env.Setenv(cliPathKey, cliPath)
 	return nil
 }
 
@@ -310,6 +320,27 @@ func (t *liteTest) cmdExec(ts *testscript.TestScript, _ bool, args []string) {
 	cmdExec(ts, args, ts.Value(keyDB).(*sql.DB))
 }
 
+func (t *liteTest) cmdCLI(ts *testscript.TestScript, _ bool, args []string) {
+	wd := ts.Getenv("WORK")
+	for i, arg := range args {
+		u := fmt.Sprintf("sqlite://file:%s/atlas.sqlite?cache=shared&_fk=1", wd)
+		args[i] = strings.ReplaceAll(arg, "URL", u)
+	}
+	switch l := len(args); {
+	// If command was run with a unix redirect-like suffix.
+	case l > 1 && args[l-2] == ">":
+		path := filepath.Join(wd, args[l-1])
+		out, err := os.Create(path)
+		ts.Check(err)
+		defer out.Close()
+		cmd := exec.Command(ts.Getenv(cliPathKey), args[0:l-2]...)
+		cmd.Stdout = out
+		ts.Check(cmd.Run())
+	default:
+		ts.Check(ts.Exec(ts.Getenv(cliPathKey), args...))
+	}
+}
+
 func cmdExec(ts *testscript.TestScript, args []string, db *sql.DB) {
 	if len(args) == 0 {
 		ts.Fatalf("missing statements for 'execsql'")
diff --git a/internal/integration/testdata/postgres/index-type-brin.txt b/internal/integration/testdata/postgres/index-type-brin.txt
new file mode 100644
index 00000000..7f972bfd
--- /dev/null
+++ b/internal/integration/testdata/postgres/index-type-brin.txt
@@ -0,0 +1,82 @@
+apply 1.hcl
+cmpshow users 1.sql
+
+# Add the "page_per_range" storage parameter.
+apply 2.hcl
+cmpshow users 2.sql
+
+# Change the "page_per_range" storage parameter.
+apply 3.hcl
+cmpshow users 3.sql
+
+-- 1.hcl --
+schema "$db" {}
+
+table "users" {
+  schema = schema.$db
+  column "c" {
+    null = false
+    type = int
+  }
+  index "users_c" {
+    type = BRIN
+    columns = [column.c]
+  }
+}
+
+-- 1.sql --
+       Table "script_index_type_brin.users"
+ Column |  Type   | Collation | Nullable | Default
+--------+---------+-----------+----------+---------
+ c      | integer |           | not null |
+Indexes:
+    "users_c" brin (c)
+
+-- 2.hcl --
+schema "$db" {}
+
+table "users" {
+  schema = schema.$db
+  column "c" {
+    null = false
+    type = int
+  }
+  index "users_c" {
+    type = BRIN
+    columns = [column.c]
+    page_per_range = 2
+  }
+}
+
+-- 2.sql --
+       Table "script_index_type_brin.users"
+ Column |  Type   | Collation | Nullable | Default
+--------+---------+-----------+----------+---------
+ c      | integer |           | not null |
+Indexes:
+    "users_c" brin (c) WITH (pages_per_range='2')
+
+
+-- 3.hcl --
+schema "$db" {}
+
+table "users" {
+  schema = schema.$db
+  column "c" {
+    null = false
+    type = int
+  }
+  index "users_c" {
+    type = BRIN
+    columns = [column.c]
+    page_per_range = 3
+  }
+}
+
+-- 3.sql --
+       Table "script_index_type_brin.users"
+ Column |  Type   | Collation | Nullable | Default
+--------+---------+-----------+----------+---------
+ c      | integer |           | not null |
+Indexes:
+    "users_c" brin (c) WITH (pages_per_range='3')
\ No newline at end of file
diff --git a/internal/integration/testdata/sqlite/cli-inspect.txt b/internal/integration/testdata/sqlite/cli-inspect.txt
new file mode 100644
index 00000000..5ca194fe
--- /dev/null
+++ b/internal/integration/testdata/sqlite/cli-inspect.txt
@@ -0,0 +1,14 @@
+apply 1.hcl
+atlas schema inspect -u URL > inspected.hcl
+cmp inspected.hcl 1.hcl
+
+-- 1.hcl --
+table "users" {
+  schema = schema.main
+  column "id" {
+    null = false
+    type = int
+  }
+}
+schema "main" {
+}
\ No newline at end of file
diff --git a/sql/postgres/diff_test.go b/sql/postgres/diff_test.go
index 17a27e79..530e2ff7 100644
--- a/sql/postgres/diff_test.go
+++ b/sql/postgres/diff_test.go
@@ -279,12 +279,14 @@ func TestDiff_TableDiff(t *testing.T) {
 				{Name: "c2_unique", Unique: true, Table: from, Parts: []*schema.IndexPart{{SeqNo: 1, C: from.Columns[1]}}},
 				{Name: "c3_predicate", Table: from, Parts: []*schema.IndexPart{{SeqNo: 1, C: from.Columns[1]}}},
 				{Name: "c4_predicate", Table: from, Parts: []*schema.IndexPart{{SeqNo: 1, C: from.Columns[1]}}, Attrs: []schema.Attr{&IndexPredicate{P: "(c4 <> NULL)"}}},
+				{Name: "c4_storage_params", Table: from, Parts: []*schema.IndexPart{{SeqNo: 1, C: from.Columns[1]}}, Attrs: []schema.Attr{&IndexStorageParams{PagesPerRange: 4}}},
 			}
 			to.Indexes = []*schema.Index{
 				{Name: "c1_index", Table: from, Parts: []*schema.IndexPart{{SeqNo: 1, C: from.Columns[0]}}},
 				{Name: "c3_unique", Unique: true, Table: from, Parts: []*schema.IndexPart{{SeqNo: 1, C: to.Columns[1]}}},
 				{Name: "c3_predicate", Table: from, Parts: []*schema.IndexPart{{SeqNo: 1, C: from.Columns[1]}}, Attrs: []schema.Attr{&IndexPredicate{P: "c3 <> NULL"}}},
 				{Name: "c4_predicate", Table: from, Parts: []*schema.IndexPart{{SeqNo: 1, C: from.Columns[1]}}, Attrs: []schema.Attr{&IndexPredicate{P: "c4 <> NULL"}}},
+				{Name: "c4_storage_params", Table: from, Parts: []*schema.IndexPart{{SeqNo: 1, C: from.Columns[1]}}, Attrs: []schema.Attr{&IndexStorageParams{PagesPerRange: 2}}},
 			}
 			return testcase{
 				name: "indexes",
@@ -294,6 +296,7 @@ func TestDiff_TableDiff(t *testing.T) {
 					&schema.ModifyIndex{From: from.Indexes[0], To: to.Indexes[0], Change: schema.ChangeUnique},
 					&schema.DropIndex{I: from.Indexes[1]},
 					&schema.ModifyIndex{From: from.Indexes[2], To: to.Indexes[2], Change: schema.ChangeAttr},
+					&schema.ModifyIndex{From: from.Indexes[4], To: to.Indexes[4], Change: schema.ChangeAttr},
 					&schema.AddIndex{I: to.Indexes[1]},
 				},
 			}
diff --git a/sql/postgres/inspect_test.go b/sql/postgres/inspect_test.go
index 371b106b..0b04e68a 100644
--- a/sql/postgres/inspect_test.go
+++ b/sql/postgres/inspect_test.go
@@ -137,16 +137,17 @@ users      | parent_id   | bigint              | YES         |
 				m.ExpectQuery(queryIndexes).
 					WithArgs("public", "users").
 					WillReturnRows(sqltest.Rows(`
-   table_name   |    index_name   | index_type  | column_name | primary | unique | constraint_type | predicate             |   expression              | desc | nulls_first | nulls_last | comment
-----------------+-----------------+-------------+-------------+---------+--------+-----------------+-----------------------+---------------------------+------+-------------+------------+-----------
-users           | idx             | hash        |             | f       | f      |                 |                       | "left"((c11)::text, 100)  | t    | t           | f          | boring
-users           | idx1            | btree       |             | f       | f      |                 | (id <> NULL::integer) | "left"((c11)::text, 100)  | t    | t           | f          |
-users           | t1_c1_key       | btree       | c1          | f       | t      | u               |                       | c1                        | t    | t           | f          |
-users           | t1_pkey         | btree       | id          | t       | t      | p               |                       | id                        | t    | f           | f          |
-users           | idx4            | btree       | c1          | f       | t      |                 |                       | c1                        | f    | f           | f          |
-users           | idx4            | btree       | id          | f       | t      |                 |                       | id                        | f    | f           | t          |
-users           | idx5            | btree       | c1          | f       | t      |                 |                       | c1                        | f    | f           | f          |
-users           | idx5            | btree       |             | f       | t      |                 |                       | coalesce(parent_id, 0)    | f    | f           | f          |
+   table_name   |    index_name   | index_type  | column_name | primary | unique | constraint_type | predicate             |   expression              | desc | nulls_first | nulls_last | comment   | options
+----------------+-----------------+-------------+-------------+---------+--------+-----------------+-----------------------+---------------------------+------+-------------+------------+-----------+-----------
+users           | idx             | hash        |             | f       | f      |                 |                       | "left"((c11)::text, 100)  | t    | t           | f          | boring    |
+users           | idx1            | btree       |             | f       | f      |                 | (id <> NULL::integer) | "left"((c11)::text, 100)  | t    | t           | f          |           |
+users           | t1_c1_key       | btree       | c1          | f       | t      | u               |                       | c1                        | t    | t           | f          |           |
+users           | t1_pkey         | btree       | id          | t       | t      | p               |                       | id                        | t    | f           | f          |           |
+users           | idx4            | btree       | c1          | f       | t      |                 |                       | c1                        | f    | f           | f          |           |
+users           | idx4            | btree       | id          | f       | t      |                 |                       | id                        | f    | f           | t          |           |
+users           | idx5            | btree       | c1          | f       | t      |                 |                       | c1                        | f    | f           | f          |           |
+users           | idx5            | btree       |             | f       | t      |                 |                       | coalesce(parent_id, 0)    | f    | f           | f          |           |
+users           | idx6            | brin        | c1          | f       | t      |                 |                       |                           | f    | f           | f          |           | {autosummarize=true,pages_per_range=2}
 `))
 				m.noFKs()
 				m.noChecks()
@@ -165,6 +166,7 @@ users           | idx5            | btree       |             | f       | t
 					{Name: "t1_c1_key", Unique: true, Table: t, Attrs: []schema.Attr{&IndexType{T: "btree"}, &ConType{T: "u"}}, Parts: []*schema.IndexPart{{SeqNo: 1, C: columns[1], Desc: true, Attrs: []schema.Attr{&IndexColumnProperty{NullsFirst: true}}}}},
 					{Name: "idx4", Unique: true, Table: t, Attrs: []schema.Attr{&IndexType{T: "btree"}}, Parts: []*schema.IndexPart{{SeqNo: 1, C: columns[1]}, {SeqNo: 2, C: columns[0], Attrs: []schema.Attr{&IndexColumnProperty{NullsLast: true}}}}},
 					{Name: "idx5", Unique: true, Table: t, Attrs: []schema.Attr{&IndexType{T: "btree"}}, Parts: []*schema.IndexPart{{SeqNo: 1, C: columns[1]}, {SeqNo: 2, X: &schema.RawExpr{X: `coalesce(parent_id, 0)`}}}},
+					{Name: "idx6", Unique: true, Table: t, Attrs: []schema.Attr{&IndexType{T: "brin"}, &IndexStorageParams{AutoSummarize: true, PagesPerRange: 2}}, Parts: []*schema.IndexPart{{SeqNo: 1, C: columns[1]}}},
 				}
 				pk := &schema.Index{
 					Name:   "t1_pkey",
@@ -592,7 +594,7 @@ func (m mock) tableExists(schema, table string, exists bool) {
 
 func (m mock) noIndexes() {
 	m.ExpectQuery(queryIndexes).
-		WillReturnRows(sqlmock.NewRows([]string{"table_name", "index_name", "column_name", "primary", "unique", "constraint_type", "predicate", "expression"}))
+		WillReturnRows(sqlmock.NewRows([]string{"table_name", "index_name", "column_name", "primary", "unique", "constraint_type", "predicate", "expression", "options"}))
 }
 
 func (m mock) noFKs() {
diff --git a/sql/postgres/migrate_test.go b/sql/postgres/migrate_test.go
index cc39e46b..a042c605 100644
--- a/sql/postgres/migrate_test.go
+++ b/sql/postgres/migrate_test.go
@@ -322,6 +322,18 @@ func TestPlanChanges(t *testing.T) {
 									},
 								},
 							},
+							&schema.AddIndex{
+								I: &schema.Index{
+									Name: "id_brin",
+									Parts: []*schema.IndexPart{
+										{C: users.Columns[0], Desc: true},
+									},
+									Attrs: []schema.Attr{
+										&IndexType{T: IndexTypeBRIN},
+										&IndexStorageParams{PagesPerRange: 2},
+									},
+								},
+							},
 							&schema.AddCheck{
 								C: &schema.Check{Name: "name_not_empty", Expr: `("name" <> '')`},
 							},
@@ -348,6 +360,10 @@ func TestPlanChanges(t *testing.T) {
 						Cmd:     `CREATE INDEX "id_key" ON "users" ("id" DESC) WHERE success`,
 						Reverse: `DROP INDEX "id_key"`,
 					},
+					{
+						Cmd:     `CREATE INDEX "id_brin" ON "users" USING BRIN ("id" DESC) WITH (pages_per_range = 2)`,
+						Reverse: `DROP INDEX "id_brin"`,
+					},
 					{
 						Cmd:     `COMMENT ON COLUMN "users" ."name" IS 'foo'`,
 						Reverse: `COMMENT ON COLUMN "users" ."name" IS ''`,
diff --git a/sql/postgres/sqlspec_test.go b/sql/postgres/sqlspec_test.go
index 56a1fbbb..f8b752a4 100644
--- a/sql/postgres/sqlspec_test.go
+++ b/sql/postgres/sqlspec_test.go
@@ -285,6 +285,29 @@ table "t" {
 	})
 }
 
+func TestUnmarshalSpec_BRINIndex(t *testing.T) {
+	f := `
+schema "s" {}
+table "t" {
+	schema = schema.s
+	column "c" {
+		type = int
+	}
+	index "i" {
+		type = BRIN
+		columns = [column.c]
+		page_per_range = 2
+	}
+}
+`
+	var s schema.Schema
+	err := UnmarshalHCL([]byte(f), &s)
+	require.NoError(t, err)
+	idx := s.Tables[0].Indexes[0]
+	require.Equal(t, IndexTypeBRIN, idx.Attrs[0].(*IndexType).T)
+	require.EqualValues(t, 2, idx.Attrs[1].(*IndexStorageParams).PagesPerRange)
+}
+
 func TestUnmarshalSpec_Partitioned(t *testing.T) {
 	t.Run("Columns", func(t *testing.T) {
 		var (
@@ -485,6 +508,58 @@ schema "test" {
 	require.EqualValues(t, expected, string(buf))
 }
 
+func TestMarshalSpec_BRINIndex(t *testing.T) {
+	s := &schema.Schema{
+		Name: "test",
+		Tables: []*schema.Table{
+			{
+				Name: "users",
+				Columns: []*schema.Column{
+					{
+						Name: "id",
+						Type: &schema.ColumnType{Type: &schema.IntegerType{T: "int"}},
+					},
+				},
+			},
+		},
+	}
+	s.Tables[0].Schema = s
+	s.Tables[0].Schema = s
+	s.Tables[0].Indexes = []*schema.Index{
+		{
+			Name:   "index",
+			Table:  s.Tables[0],
+			Unique: true,
+			Parts: []*schema.IndexPart{
+				{SeqNo: 0, C: s.Tables[0].Columns[0]},
+			},
+			Attrs: []schema.Attr{
+				&IndexType{T: IndexTypeBRIN},
+				&IndexStorageParams{PagesPerRange: 2},
+			},
+		},
+	}
+	buf, err := MarshalSpec(s, hclState)
+	require.NoError(t, err)
+	const expected = `table "users" {
+  schema = schema.test
+  column "id" {
+    null = false
+    type = int
+  }
+  index "index" {
+    unique         = true
+    columns        = [column.id]
+    type           = BRIN
+    page_per_range = 2
+  }
+}
+schema "test" {
+}
+`
+	require.EqualValues(t, expected, string(buf))
+}
+
 func TestUnmarshalSpec_Identity(t *testing.T) {
 	f := `
 schema "s" {}
diff --git a/sql/sqltool/tool_test.go b/sql/sqltool/tool_test.go
index c8c8fefc..4f5bfedb 100644
--- a/sql/sqltool/tool_test.go
+++ b/sql/sqltool/tool_test.go
@@ -25,7 +25,7 @@ var plan = &migrate.Plan{
 }
 
 func TestFormatters(t *testing.T) {
-	v := time.Now().Format("20060102150405")
+	v := time.Now().UTC().Format("20060102150405")
 	for _, tt := range []struct {
 		name     string
 		fmt      migrate.Formatter