diff --git a/atlasexec/atlas_schema.go b/atlasexec/atlas_schema.go index 88fa945..3248fc9 100644 --- a/atlasexec/atlas_schema.go +++ b/atlasexec/atlas_schema.go @@ -2,6 +2,8 @@ package atlasexec import ( "context" + "encoding/json" + "fmt" ) type ( @@ -54,6 +56,95 @@ type ( URL string Run string } + // SchemaPlanParams are the parameters for the `schema plan` command. + SchemaPlanParams struct { + ConfigURL string + Env string + Vars VarArgs + Context *RunContext + DevURL string + + From, To []string + Name string + Repo string + // The below are mutually exclusive and can be replaced + // with the 'schema plan' sub-commands instead. + DryRun bool // If false, --auto-approve is set. + Push, Save bool + } + // SchemaPlanPushParams are the parameters for the `schema plan push` command. + SchemaPlanPushParams struct { + ConfigURL string + Env string + Vars VarArgs + Context *RunContext + DevURL string + + From, To []string + File string + Repo string + Pending bool + } + // SchemaPlanPullParams are the parameters for the `schema plan pull` command. + SchemaPlanPullParams struct { + ConfigURL string + Env string + Vars VarArgs + URL string + } + // SchemaPlanLintParams are the parameters for the `schema plan lint` command. + SchemaPlanLintParams struct { + ConfigURL string + Env string + Vars VarArgs + Context *RunContext + DevURL string + + From, To []string + Name string + Repo string + File string + } + // SchemaPlanValidateParams are the parameters for the `schema plan validate` command. + SchemaPlanValidateParams struct { + ConfigURL string + Env string + Vars VarArgs + Context *RunContext + DevURL string + + From, To []string + Name string + Repo string + File string + } + // SchemaPlanApproveParams are the parameters for the `schema plan approve` command. + SchemaPlanApproveParams struct { + ConfigURL string + Env string + Vars VarArgs + + URL string + } + // SchemaPlan is the result of a 'schema plan' command. + SchemaPlan struct { + Env Env `json:"Env,omitempty"` // Environment info. + Repo string `json:"Repo,omitempty"` // Repository name. + Lint *SummaryReport `json:"Lint,omitempty"` // Lint report. + File *SchemaPlanFile `json:"File,omitempty"` // Plan file. + Error string `json:"Error,omitempty"` // Any error occurred during planning. + } + // SchemaPlanFile is a JSON representation of a schema plan file. + SchemaPlanFile struct { + Name string `json:"Name,omitempty"` // Name of the plan. + FromHash string `json:"FromHash,omitempty"` // Hash of the 'from' realm. + ToHash string `json:"ToHash,omitempty"` // Hash of the 'to' realm. + Migration string `json:"Migration,omitempty"` // Migration SQL. + // registry only fields. + URL string `json:"URL,omitempty"` // URL of the plan in Atlas format. + Link string `json:"Link,omitempty"` // Link to the plan in the registry. + Status string `json:"Status,omitempty"` // Status of the plan in the registry. + } ) // SchemaApply runs the 'schema apply' command. @@ -156,6 +247,262 @@ func (c *Client) SchemaTest(ctx context.Context, params *SchemaTestParams) (stri return stringVal(c.runCommand(ctx, args)) } +// SchemaPlan runs the `schema plan` command. +func (c *Client) SchemaPlan(ctx context.Context, params *SchemaPlanParams) (*SchemaPlan, error) { + args := []string{"schema", "plan", "--format", "{{ json . }}"} + // Global flags + if params.ConfigURL != "" { + args = append(args, "--config", params.ConfigURL) + } + if params.Env != "" { + args = append(args, "--env", params.Env) + } + if params.Vars != nil { + args = append(args, params.Vars.AsArgs()...) + } + // Hidden flags + if params.Context != nil { + buf, err := json.Marshal(params.Context) + if err != nil { + return nil, err + } + args = append(args, "--context", string(buf)) + } + // Flags of the 'schema plan' sub-commands + if params.DevURL != "" { + args = append(args, "--dev-url", params.DevURL) + } + if len(params.From) > 0 { + args = append(args, "--from", listString(params.From)) + } + if len(params.To) > 0 { + args = append(args, "--to", listString(params.To)) + } + if params.Name != "" { + args = append(args, "--name", params.Name) + } + if params.Repo != "" { + args = append(args, "--repo", params.Repo) + } + switch { + case params.Save: + if params.Push || params.DryRun { + return nil, &InvalidParamsError{"schema plan", "can not use --save with --push or --dry-run"} + } + args = append(args, "--save", "--auto-approve") + case params.Push: + if params.Save || params.DryRun { + return nil, &InvalidParamsError{"schema plan", "can not use --push with --save or --dry-run"} + } + args = append(args, "--push", "--auto-approve") + case params.DryRun: + if params.Save || params.Push { + return nil, &InvalidParamsError{"schema plan", "can not use --dry-run with --save or --push"} + } + args = append(args, "--dry-run") + default: + args = append(args, "--auto-approve") + } + // NOTE: This command only support one result. + return firstResult(jsonDecode[SchemaPlan](c.runCommand(ctx, args))) +} + +// SchemaPlanPush runs the `schema plan push` command. +func (c *Client) SchemaPlanPush(ctx context.Context, params *SchemaPlanPushParams) (string, error) { + args := []string{"schema", "plan", "push", "--format", "{{ json . }}"} + // Global flags + if params.ConfigURL != "" { + args = append(args, "--config", params.ConfigURL) + } + if params.Env != "" { + args = append(args, "--env", params.Env) + } + if params.Vars != nil { + args = append(args, params.Vars.AsArgs()...) + } + // Hidden flags + if params.Context != nil { + buf, err := json.Marshal(params.Context) + if err != nil { + return "", err + } + args = append(args, "--context", string(buf)) + } + // Flags of the 'schema plan push' sub-commands + if params.DevURL != "" { + args = append(args, "--dev-url", params.DevURL) + } + if len(params.From) > 0 { + args = append(args, "--from", listString(params.From)) + } + if len(params.To) > 0 { + args = append(args, "--to", listString(params.To)) + } + if params.File != "" { + args = append(args, "--file", params.File) + } else { + return "", &InvalidParamsError{"schema plan push", "missing required flag --file"} + } + if params.Repo != "" { + args = append(args, "--repo", params.Repo) + } + if params.Pending { + args = append(args, "--pending") + } else { + args = append(args, "--auto-approve") + } + return stringVal(c.runCommand(ctx, args)) +} + +// SchemaPlanPush runs the `schema plan pull` command. +func (c *Client) SchemaPlanPull(ctx context.Context, params *SchemaPlanPullParams) (string, error) { + args := []string{"schema", "plan", "pull"} + // Global flags + if params.ConfigURL != "" { + args = append(args, "--config", params.ConfigURL) + } + if params.Env != "" { + args = append(args, "--env", params.Env) + } + if params.Vars != nil { + args = append(args, params.Vars.AsArgs()...) + } + // Flags of the 'schema plan pull' sub-commands + if params.URL != "" { + args = append(args, "--url", params.URL) + } else { + return "", &InvalidParamsError{"schema plan pull", "missing required flag --url"} + } + return stringVal(c.runCommand(ctx, args)) +} + +// SchemaPlanLint runs the `schema plan lint` command. +func (c *Client) SchemaPlanLint(ctx context.Context, params *SchemaPlanLintParams) (*SchemaPlan, error) { + args := []string{"schema", "plan", "lint", "--format", "{{ json . }}"} + // Global flags + if params.ConfigURL != "" { + args = append(args, "--config", params.ConfigURL) + } + if params.Env != "" { + args = append(args, "--env", params.Env) + } + if params.Vars != nil { + args = append(args, params.Vars.AsArgs()...) + } + // Hidden flags + if params.Context != nil { + buf, err := json.Marshal(params.Context) + if err != nil { + return nil, err + } + args = append(args, "--context", string(buf)) + } + // Flags of the 'schema plan lint' sub-commands + if params.DevURL != "" { + args = append(args, "--dev-url", params.DevURL) + } + if len(params.From) > 0 { + args = append(args, "--from", listString(params.From)) + } + if len(params.To) > 0 { + args = append(args, "--to", listString(params.To)) + } + if params.File != "" { + args = append(args, "--file", params.File) + } else { + return nil, &InvalidParamsError{"schema plan lint", "missing required flag --file"} + } + if params.Name != "" { + args = append(args, "--name", params.Name) + } + if params.Repo != "" { + args = append(args, "--repo", params.Repo) + } + args = append(args, "--auto-approve") + // NOTE: This command only support one result. + return firstResult(jsonDecode[SchemaPlan](c.runCommand(ctx, args))) +} + +// SchemaPlanValidate runs the `schema plan validate` command. +func (c *Client) SchemaPlanValidate(ctx context.Context, params *SchemaPlanValidateParams) error { + args := []string{"schema", "plan", "validate"} + // Global flags + if params.ConfigURL != "" { + args = append(args, "--config", params.ConfigURL) + } + if params.Env != "" { + args = append(args, "--env", params.Env) + } + if params.Vars != nil { + args = append(args, params.Vars.AsArgs()...) + } + // Hidden flags + if params.Context != nil { + buf, err := json.Marshal(params.Context) + if err != nil { + return err + } + args = append(args, "--context", string(buf)) + } + // Flags of the 'schema plan validate' sub-commands + if params.DevURL != "" { + args = append(args, "--dev-url", params.DevURL) + } + if len(params.From) > 0 { + args = append(args, "--from", listString(params.From)) + } + if len(params.To) > 0 { + args = append(args, "--to", listString(params.To)) + } + if params.File != "" { + args = append(args, "--file", params.File) + } else { + return &InvalidParamsError{"schema plan validate", "missing required flag --file"} + } + if params.Name != "" { + args = append(args, "--name", params.Name) + } + if params.Repo != "" { + args = append(args, "--repo", params.Repo) + } + args = append(args, "--auto-approve") + _, err := stringVal(c.runCommand(ctx, args)) + return err +} + +// SchemaPlanApprove runs the `schema plan approve` command. +func (c *Client) SchemaPlanApprove(ctx context.Context, params *SchemaPlanApproveParams) (*SchemaPlan, error) { + args := []string{"schema", "plan", "approve", "--format", "{{ json . }}"} + // Global flags + if params.ConfigURL != "" { + args = append(args, "--config", params.ConfigURL) + } + if params.Env != "" { + args = append(args, "--env", params.Env) + } + if params.Vars != nil { + args = append(args, params.Vars.AsArgs()...) + } + // Flags of the 'schema plan approve' sub-commands + if params.URL != "" { + args = append(args, "--url", params.URL) + } else { + return nil, &InvalidParamsError{"schema plan approve", "missing required flag --url"} + } + // NOTE: This command only support one result. + return firstResult(jsonDecode[SchemaPlan](c.runCommand(ctx, args))) +} + +// InvalidParamsError is an error type for invalid parameters. +type InvalidParamsError struct { + cmd string + msg string +} + +// Error returns the error message. +func (e *InvalidParamsError) Error() string { + return fmt.Sprintf("atlasexec: command %q has invalid parameters: %v", e.cmd, e.msg) +} func newSchemaApplyError(r []*SchemaApply) error { return &SchemaApplyError{Result: r} } diff --git a/atlasexec/atlas_schema_test.go b/atlasexec/atlas_schema_test.go index 45268ed..1c74d4c 100644 --- a/atlasexec/atlas_schema_test.go +++ b/atlasexec/atlas_schema_test.go @@ -215,3 +215,263 @@ schema "main" { } `, s) } + +func TestSchema_Plan(t *testing.T) { + wd, err := os.Getwd() + require.NoError(t, err) + c, err := atlasexec.NewClient(t.TempDir(), filepath.Join(wd, "./mock-atlas.sh")) + require.NoError(t, err) + + testCases := []struct { + name string + params *atlasexec.SchemaPlanParams + args string + }{ + { + name: "no params", + params: &atlasexec.SchemaPlanParams{}, + args: "schema plan --format {{ json . }} --auto-approve", + }, + { + name: "with env", + params: &atlasexec.SchemaPlanParams{ + Env: "test", + }, + args: "schema plan --format {{ json . }} --env test --auto-approve", + }, + { + name: "with from to", + params: &atlasexec.SchemaPlanParams{ + From: []string{"1", "2"}, + To: []string{"2", "3"}, + }, + args: `schema plan --format {{ json . }} --from 1,2 --to 2,3 --auto-approve`, + }, + { + name: "with config", + params: &atlasexec.SchemaPlanParams{ + ConfigURL: "file://config.hcl", + }, + args: "schema plan --format {{ json . }} --config file://config.hcl --auto-approve", + }, + { + name: "with dev-url", + params: &atlasexec.SchemaPlanParams{ + DevURL: "sqlite://file?_fk=1&cache=shared&mode=memory", + }, + args: "schema plan --format {{ json . }} --dev-url sqlite://file?_fk=1&cache=shared&mode=memory --auto-approve", + }, + { + name: "with name", + params: &atlasexec.SchemaPlanParams{ + Name: "example", + }, + args: "schema plan --format {{ json . }} --name example --auto-approve", + }, + { + name: "with dry-run", + params: &atlasexec.SchemaPlanParams{ + DryRun: true, + }, + args: "schema plan --format {{ json . }} --dry-run", + }, + { + name: "with save", + params: &atlasexec.SchemaPlanParams{ + Save: true, + }, + args: "schema plan --format {{ json . }} --save --auto-approve", + }, + { + name: "with push", + params: &atlasexec.SchemaPlanParams{ + Repo: "testing-repo", + Push: true, + }, + args: "schema plan --format {{ json . }} --repo testing-repo --push --auto-approve", + }, + } + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("TEST_ARGS", tt.args) + t.Setenv("TEST_STDOUT", `{"Repo":"foo"}`) + result, err := c.SchemaPlan(context.Background(), tt.params) + require.NoError(t, err) + require.Equal(t, "foo", result.Repo) + }) + } +} + +func TestSchema_PlanPush(t *testing.T) { + wd, err := os.Getwd() + require.NoError(t, err) + c, err := atlasexec.NewClient(t.TempDir(), filepath.Join(wd, "./mock-atlas.sh")) + require.NoError(t, err) + + testCases := []struct { + name string + params *atlasexec.SchemaPlanPushParams + args string + }{ + { + name: "with auto-approve", + params: &atlasexec.SchemaPlanPushParams{ + Repo: "testing-repo", + File: "file://plan.hcl", + }, + args: "schema plan push --format {{ json . }} --file file://plan.hcl --repo testing-repo --auto-approve", + }, + { + name: "with pending status", + params: &atlasexec.SchemaPlanPushParams{ + Pending: true, + File: "file://plan.hcl", + }, + args: "schema plan push --format {{ json . }} --file file://plan.hcl --pending", + }, + } + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("TEST_ARGS", tt.args) + t.Setenv("TEST_STDOUT", `{"Repo":"foo"}`) + result, err := c.SchemaPlanPush(context.Background(), tt.params) + require.NoError(t, err) + require.Equal(t, `{"Repo":"foo"}`, result) + }) + } +} + +func TestSchema_PlanLint(t *testing.T) { + wd, err := os.Getwd() + require.NoError(t, err) + c, err := atlasexec.NewClient(t.TempDir(), filepath.Join(wd, "./mock-atlas.sh")) + require.NoError(t, err) + + testCases := []struct { + name string + params *atlasexec.SchemaPlanLintParams + args string + }{ + { + name: "with repo", + params: &atlasexec.SchemaPlanLintParams{ + Repo: "testing-repo", + File: "file://plan.hcl", + }, + args: "schema plan lint --format {{ json . }} --file file://plan.hcl --repo testing-repo --auto-approve", + }, + { + name: "with file only", + params: &atlasexec.SchemaPlanLintParams{ + File: "file://plan.hcl", + }, + args: "schema plan lint --format {{ json . }} --file file://plan.hcl --auto-approve", + }, + } + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("TEST_ARGS", tt.args) + t.Setenv("TEST_STDOUT", `{"Repo":"foo"}`) + result, err := c.SchemaPlanLint(context.Background(), tt.params) + require.NoError(t, err) + require.Equal(t, "foo", result.Repo) + }) + } +} + +func TestSchema_PlanValidate(t *testing.T) { + wd, err := os.Getwd() + require.NoError(t, err) + c, err := atlasexec.NewClient(t.TempDir(), filepath.Join(wd, "./mock-atlas.sh")) + require.NoError(t, err) + + testCases := []struct { + name string + params *atlasexec.SchemaPlanValidateParams + args string + }{ + { + name: "with repo", + params: &atlasexec.SchemaPlanValidateParams{ + Repo: "testing-repo", + File: "file://plan.hcl", + }, + args: "schema plan validate --file file://plan.hcl --repo testing-repo --auto-approve", + }, + { + name: "with file only", + params: &atlasexec.SchemaPlanValidateParams{ + File: "file://plan.hcl", + }, + args: "schema plan validate --file file://plan.hcl --auto-approve", + }, + } + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("TEST_ARGS", tt.args) + t.Setenv("TEST_STDOUT", `{"Repo":"foo"}`) + err := c.SchemaPlanValidate(context.Background(), tt.params) + require.NoError(t, err) + }) + } +} + +func TestSchema_PlanApprove(t *testing.T) { + wd, err := os.Getwd() + require.NoError(t, err) + c, err := atlasexec.NewClient(t.TempDir(), filepath.Join(wd, "./mock-atlas.sh")) + require.NoError(t, err) + + testCases := []struct { + name string + params *atlasexec.SchemaPlanApproveParams + args string + }{ + { + name: "with url", + params: &atlasexec.SchemaPlanApproveParams{ + URL: "atlas://app1/plans/foo-plan", + }, + args: "schema plan approve --format {{ json . }} --url atlas://app1/plans/foo-plan", + }, + } + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("TEST_ARGS", tt.args) + t.Setenv("TEST_STDOUT", `{"Repo":"foo"}`) + result, err := c.SchemaPlanApprove(context.Background(), tt.params) + require.NoError(t, err) + require.Equal(t, "foo", result.Repo) + }) + } +} + +func TestSchema_PlanPull(t *testing.T) { + wd, err := os.Getwd() + require.NoError(t, err) + c, err := atlasexec.NewClient(t.TempDir(), filepath.Join(wd, "./mock-atlas.sh")) + require.NoError(t, err) + + testCases := []struct { + name string + params *atlasexec.SchemaPlanPullParams + args string + }{ + { + name: "with url", + params: &atlasexec.SchemaPlanPullParams{ + URL: "atlas://app1/plans/foo-plan", + }, + args: "schema plan pull --url atlas://app1/plans/foo-plan", + }, + } + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("TEST_ARGS", tt.args) + t.Setenv("TEST_STDOUT", "excited-plan") + result, err := c.SchemaPlanPull(context.Background(), tt.params) + require.NoError(t, err) + require.Equal(t, "excited-plan", result) + }) + } +} diff --git a/atlasexec/internal/e2e/sqlite_test.go b/atlasexec/internal/e2e/sqlite_test.go index 4150e9f..9356272 100644 --- a/atlasexec/internal/e2e/sqlite_test.go +++ b/atlasexec/internal/e2e/sqlite_test.go @@ -127,3 +127,34 @@ func Test_MultiTenants(t *testing.T) { require.Contains(t, mae.Result[1].Error, "UNIQUE constraint failed", "Should be the correct error") }) } + +func Test_SchemaPlan(t *testing.T) { + runTestWithVersions(t, []string{"latest"}, "schema-plan", func(t *testing.T, ver *atlasexec.Version, wd *atlasexec.WorkingDir, c *atlasexec.Client) { + ctx := context.Background() + plan, err := c.SchemaPlan(ctx, &atlasexec.SchemaPlanParams{ + From: []string{"file://schema-1.lt.hcl"}, + To: []string{"file://schema-2.lt.hcl"}, + DevURL: "sqlite://:memory:?_fk=1", + DryRun: true, + }) + require.NoError(t, err) + f := plan.File + require.NotNil(t, f, "Should have a file") + require.Equal(t, "-- Add column \"c2\" to table: \"t1\"\nALTER TABLE `t1` ADD COLUMN `c2` text NOT NULL;\n", f.Migration, "Should be the correct migration") + require.Empty(t, f.URL, "Should be no URL") + }) + runTestWithVersions(t, []string{"latest"}, "schema-plan", func(t *testing.T, ver *atlasexec.Version, wd *atlasexec.WorkingDir, c *atlasexec.Client) { + ctx := context.Background() + plan, err := c.SchemaPlan(ctx, &atlasexec.SchemaPlanParams{ + From: []string{"file://schema-1.lt.hcl"}, + To: []string{"file://schema-2.lt.hcl"}, + DevURL: "sqlite://:memory:?_fk=1", + Save: true, + }) + require.NoError(t, err) + f := plan.File + require.NotNil(t, f, "Should have a file") + require.Equal(t, "-- Add column \"c2\" to table: \"t1\"\nALTER TABLE `t1` ADD COLUMN `c2` text NOT NULL;\n", f.Migration, "Should be the correct migration") + require.Regexp(t, "^file://\\d{14}\\.plan\\.hcl$", f.URL, "Should be an URL to a file") + }) +} diff --git a/atlasexec/internal/e2e/testdata/schema-plan/schema-1.lt.hcl b/atlasexec/internal/e2e/testdata/schema-plan/schema-1.lt.hcl new file mode 100644 index 0000000..1bf6ce9 --- /dev/null +++ b/atlasexec/internal/e2e/testdata/schema-plan/schema-1.lt.hcl @@ -0,0 +1,10 @@ +schema "public" { + comment = "This is a test schema" +} + +table "t1" { + schema = schema.public + column "c1" { + type = bigint + } +} diff --git a/atlasexec/internal/e2e/testdata/schema-plan/schema-2.lt.hcl b/atlasexec/internal/e2e/testdata/schema-plan/schema-2.lt.hcl new file mode 100644 index 0000000..73060d0 --- /dev/null +++ b/atlasexec/internal/e2e/testdata/schema-plan/schema-2.lt.hcl @@ -0,0 +1,13 @@ +schema "public" { + comment = "This is a test schema" +} + +table "t1" { + schema = schema.public + column "c1" { + type = bigint + } + column "c2" { + type = text + } +} diff --git a/atlasexec/internal/e2e/util_e2e.go b/atlasexec/internal/e2e/util_e2e.go index 3024dc2..83114c2 100644 --- a/atlasexec/internal/e2e/util_e2e.go +++ b/atlasexec/internal/e2e/util_e2e.go @@ -33,6 +33,9 @@ func runTestWithVersions(t *testing.T, versions []string, fixtureName string, cb execPath = localBinPath } else { execPath = downloadAtlas(t, av) + if err := os.Chmod(execPath, 0755); err != nil { + t.Fatalf("unable to make atlas executable: %s", err) + } } c, err := atlasexec.NewClient("", execPath) if err != nil {