Skip to content

Commit

Permalink
feat: support versioned migration (#35)
Browse files Browse the repository at this point in the history
* workflows/ci: install atlas-cli for testing

Signed-off-by: Giau. Tran Minh <[email protected]>

* atlas: create the client for atlas-cli

Signed-off-by: Giau. Tran Minh <[email protected]>

* fix: update test helpers function to support URLs

Signed-off-by: Giau. Tran Minh <[email protected]>

* feat: added new DS for read migration status

Signed-off-by: Giau. Tran Minh <[email protected]>

* feat: added resource for apply migrations

Signed-off-by: Giau. Tran Minh <[email protected]>

* fix: update provider

Signed-off-by: Giau. Tran Minh <[email protected]>

* chore: update docs

Signed-off-by: Giau. Tran Minh <[email protected]>

* r/migration: allow create resource with latest version

Signed-off-by: Giau. Tran Minh <[email protected]>

* fix: using require package to test values

Signed-off-by: Giau. Tran Minh <[email protected]>

* fix: remove unused error

Signed-off-by: Giau. Tran Minh <[email protected]>

* chore: rename MigrateClient to Client

Signed-off-by: Giau. Tran Minh <[email protected]>

* chore: added examples

Signed-off-by: Giau. Tran Minh <[email protected]>

Signed-off-by: Giau. Tran Minh <[email protected]>
  • Loading branch information
giautm authored Nov 3, 2022
1 parent e0c20fb commit 767bf99
Show file tree
Hide file tree
Showing 24 changed files with 1,335 additions and 13 deletions.
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,11 @@ jobs:
with:
terraform_version: ${{ matrix.terraform-version }}
terraform_wrapper: false
- name: Install Atlas-CLI
run: |
curl -s -A "Terraform-Provider-CI" -o /usr/local/bin/atlas \
https://release.ariga.io/atlas/atlas-linux-amd64-latest?test=1
chmod +x /usr/local/bin/atlas
- run: go test -v -cover ./...
env:
TF_ACC: '1'
42 changes: 42 additions & 0 deletions docs/data-sources/migration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
---
# generated by https://github.com/hashicorp/terraform-plugin-docs
page_title: "atlas_migration Data Source - terraform-provider-atlas"
subcategory: ""
description: |-
Data source returns the information about the current migration.
---

# atlas_migration (Data Source)

Data source returns the information about the current migration.

## Example Usage

```terraform
data "atlas_migration" "hello" {
dir = "migrations?format=atlas"
url = "mysql://root:pass@localhost:3307/hello"
}
```

<!-- schema generated by tfplugindocs -->
## Schema

### Required

- `dir` (String) Select migration directory using URL format
- `url` (String, Sensitive) [driver://username:password@address/dbname?param=value] select a resource using the URL format

### Optional

- `revisions_schema` (String) The name of the schema the revisions table resides in

### Read-Only

- `current` (String) Current migration version
- `id` (String) The ID of the migration
- `latest` (String) The latest version of the migration is in the migration directory
- `next` (String) Next migration version
- `status` (String) The Status of migration (OK, PENDING)


56 changes: 56 additions & 0 deletions docs/resources/migration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
---
# generated by https://github.com/hashicorp/terraform-plugin-docs
page_title: "atlas_migration Resource - terraform-provider-atlas"
subcategory: ""
description: |-
The resource applies pending migration files on the connected database.See https://atlasgo.io/
---

# atlas_migration (Resource)

The resource applies pending migration files on the connected database.See https://atlasgo.io/

## Example Usage

```terraform
data "atlas_migration" "hello" {
dir = "migrations?format=atlas"
url = "mysql://root:pass@localhost:3307/hello"
}
resource "atlas_migration" "hello" {
dir = "migrations?format=atlas"
version = data.atlas_migration.hello.latest # Use latest to run all migrations
url = data.atlas_migration.hello.url
}
```

<!-- schema generated by tfplugindocs -->
## Schema

### Required

- `dir` (String) the URL of the migration directory, by default it is file://migrations, e.g a directory named migrations in the current working directory.
- `url` (String, Sensitive) The url of the database see https://atlasgo.io/cli/url

### Optional

- `revisions_schema` (String) The name of the schema the revisions table resides in
- `version` (String) The version of the migration to apply, if not specified the latest version will be applied

### Read-Only

- `id` (String) The ID of this resource
- `status` (Object) The status of the migration (see [below for nested schema](#nestedatt--status))

<a id="nestedatt--status"></a>
### Nested Schema for `status`

Read-Only:

- `current` (String)
- `latest` (String)
- `next` (String)
- `status` (String)


4 changes: 4 additions & 0 deletions examples/data-sources/atlas_migration/data-source.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
data "atlas_migration" "hello" {
dir = "migrations?format=atlas"
url = "mysql://root:pass@localhost:3307/hello"
}
10 changes: 10 additions & 0 deletions examples/resources/atlas_migration/resource.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
data "atlas_migration" "hello" {
dir = "migrations?format=atlas"
url = "mysql://root:pass@localhost:3307/hello"
}

resource "atlas_migration" "hello" {
dir = "migrations?format=atlas"
version = data.atlas_migration.hello.latest # Use latest to run all migrations
url = data.atlas_migration.hello.url
}
226 changes: 226 additions & 0 deletions internal/atlas/atlas.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
package atlas

import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path"
"runtime"
"strings"

"github.com/hashicorp/terraform-plugin-framework/diag"
)

type (
// Client is a client for the Atlas CLI.
Client struct {
path string
}
// ApplyParams are the parameters for the `migrate apply` command.
ApplyParams struct {
DirURL string
URL string
RevisionsSchema string
BaselineVersion string
TxMode string
Amount uint
}
// StatusParams are the parameters for the `migrate status` command.
StatusParams struct {
DirURL string
URL string
RevisionsSchema string
}
)

// NewClient returns a new Atlas client.
// The client will try to find the Atlas CLI in the current directory,
// and in the PATH.
func NewClient(name string) (*Client, error) {
path, err := execPath(name)
if err != nil {
return nil, err
}
return NewClientWithPath(path), nil
}

// NewClientWithPath returns a new Atlas client with the given atlas-cli path.
func NewClientWithPath(path string) *Client {
return &Client{path: path}
}

// Apply runs the `migrate apply` command.
func (c *Client) Apply(ctx context.Context, data *ApplyParams) (*ApplyReport, error) {
args := []string{
"migrate", "apply", "--log", "{{ json . }}",
"--url", data.URL,
"--dir", fmt.Sprintf("file://%s", data.DirURL),
}
if data.RevisionsSchema != "" {
args = append(args, "--revisions-schema", data.RevisionsSchema)
}
if data.BaselineVersion != "" {
args = append(args, "--baseline", data.BaselineVersion)
}
if data.TxMode != "" {
args = append(args, "--tx-mode", data.TxMode)
}
if data.Amount > 0 {
args = append(args, fmt.Sprintf("%d", data.Amount))
}
var report ApplyReport
if err := c.runCommand(ctx, args, &report); err != nil {
return nil, err
}
return &report, nil
}

// Status runs the `migrate status` command.
func (c *Client) Status(ctx context.Context, data *StatusParams) (*StatusReport, error) {
args := []string{
"migrate", "status", "--log", "{{ json . }}",
"--url", data.URL,
"--dir", fmt.Sprintf("file://%s", data.DirURL),
}
if data.RevisionsSchema != "" {
args = append(args, "--revisions-schema", data.RevisionsSchema)
}
var report StatusReport
if err := c.runCommand(ctx, args, &report); err != nil {
return nil, err
}
return &report, nil
}

// runCommand runs the given command and unmarshals the output into the given
// interface.
func (c *Client) runCommand(ctx context.Context, args []string, report interface{}) error {
cmd := exec.CommandContext(ctx, c.path, args...)
cmd.Env = append(cmd.Env, "ATLAS_NO_UPDATE_NOTIFIER=1")
output, err := cmd.Output()
if err != nil {
exitErr, ok := err.(*exec.ExitError)
if !ok {
return err
}
if exitErr.Stderr != nil && len(exitErr.Stderr) > 0 {
return &cliError{
summary: string(exitErr.Stderr),
detail: string(output),
}
}
if exitErr.ExitCode() != 1 || !json.Valid(output) {
return &cliError{
summary: "Atlas CLI",
detail: string(output),
}
}
// When the exit code is 1, it means that the command
// was executed successfully, and the output is a JSON
}
if err := json.Unmarshal(output, report); err != nil {
return fmt.Errorf("atlas: unable to decode the report %w", err)
}
return nil
}

// LatestVersion returns the latest version of the migrations directory.
func (r StatusReport) LatestVersion() string {
if l := len(r.Available); l > 0 {
return r.Available[l-1].Version
}
return ""
}

// Amount returns the number of migrations need to apply
// for the given version.
//
// The second return value is true if the version is found
// and the database is up-to-date.
//
// If the version is not found, it returns 0 and the second
// return value is false.
func (r StatusReport) Amount(version string) (amount uint, ok bool) {
if version == "" {
amount := uint(len(r.Pending))
return amount, amount == 0
}
if r.Current == version {
return amount, true
}
for idx, v := range r.Pending {
if v.Version == version {
amount = uint(idx + 1)
break
}
}
return amount, false
}

func execPath(name string) (string, error) {
if runtime.GOOS == "windows" {
name += ".exe"
}
wd, err := os.Getwd()
if err != nil {
return "", err
}
p := path.Join(wd, name)
if _, err = os.Stat(p); os.IsExist(err) {
return p, nil
}
// If the binary is not in the current directory,
// try to find it in the PATH.
return exec.LookPath(name)
}

type cliError struct {
summary string
detail string
}

// Error implements the error interface.
func (e cliError) Error() string {
return e.summary
}

// Severity implements the diag.Diagnostic interface.
func (e cliError) Severity() diag.Severity {
return diag.SeverityError
}

// Summary implements the diag.Diagnostic interface.
func (e cliError) Summary() string {
if strings.HasPrefix(e.summary, "Error: ") {
return e.summary[7:]
}
return e.summary
}

// Detail implements the diag.Diagnostic interface.
func (e cliError) Detail() string {
return strings.TrimSpace(e.detail)
}

// Equal implements the diag.Diagnostic interface.
func (e cliError) Equal(other diag.Diagnostic) bool {
if other == nil {
return false
}
if o, ok := other.(*cliError); ok && o != nil {
return e.summary == o.summary && e.detail == o.detail
}
return false
}

// ErrorDiagnostic checks if the given error is a diagnostic.
// If it is, it returns the diagnostic error.
// Otherwise, it returns a new diagnostic error with the given error as the detail.
func ErrorDiagnostic(err error, summary string) diag.Diagnostic {
if diag, ok := err.(diag.Diagnostic); ok {
return diag
}
return diag.NewErrorDiagnostic(summary, err.Error())
}
Loading

0 comments on commit 767bf99

Please sign in to comment.