Skip to content

Commit

Permalink
schema/apply: handle error for multiple targets (#92)
Browse files Browse the repository at this point in the history
* atlasexec: update Atlas mock

* atlasexec: decode the stdout when got error

* atlasexec: add tests for multiple targets
  • Loading branch information
giautm committed Sep 12, 2024
1 parent 26d462e commit 8fc188c
Show file tree
Hide file tree
Showing 5 changed files with 93 additions and 37 deletions.
65 changes: 35 additions & 30 deletions atlasexec/atlas.go
Original file line number Diff line number Diff line change
Expand Up @@ -248,38 +248,21 @@ func (c *Client) runCommand(ctx context.Context, args []string) (io.Reader, erro
cmd.Stderr = &stderr
cmd.Stdout = &stdout
if err := cmd.Run(); err != nil {
cerr := &Error{
return nil, &Error{
err: err,
Stderr: strings.TrimSpace(stderr.String()),
Stdout: strings.TrimSpace(stdout.String()),
}
if exitErr := (&exec.ExitError{}); errors.As(err, &exitErr) {
cerr.err = exitErr
}
return nil, cerr
}
return &stdout, nil
}

// TempFile creates a temporary file with the given content and extension.
func TempFile(content, ext string) (string, func() error, error) {
f, err := os.CreateTemp("", "atlasexec-*."+ext)
if err != nil {
return "", nil, err
}
defer f.Close()
_, err = f.WriteString(content)
if err != nil {
return "", nil, err
}
return fmt.Sprintf("file://%s", f.Name()), func() error {
return os.Remove(f.Name())
}, nil
}

// Error is an error returned by the atlasexec package,
// when it executes the atlas-cli command.
type Error struct {
err *exec.ExitError
Stdout string
Stderr string
err error // The underlying error.
Stdout string // Stdout of the command.
Stderr string // Stderr of the command.
}

// Error implements the error interface.
Expand All @@ -291,17 +274,38 @@ func (e *Error) Error() string {
}

// ExitCode returns the exit code of the command.
// If the error is not an exec.ExitError, it returns 1.
func (e *Error) ExitCode() int {
if e.err == nil {
return new(exec.ExitError).ExitCode()
var exitErr *exec.ExitError
if errors.As(e.err, &exitErr) {
return exitErr.ExitCode()
}
return e.err.ExitCode()
// Not an exec.ExitError or nil.
// Return the system default exit code.
return new(exec.ExitError).ExitCode()
}

// Unwrap returns the underlying error.
func (e *Error) Unwrap() error {
return e.err
}

// TempFile creates a temporary file with the given content and extension.
func TempFile(content, ext string) (string, func() error, error) {
f, err := os.CreateTemp("", "atlasexec-*."+ext)
if err != nil {
return "", nil, err
}
defer f.Close()
_, err = f.WriteString(content)
if err != nil {
return "", nil, err
}
return fmt.Sprintf("file://%s", f.Name()), func() error {
return os.Remove(f.Name())
}, nil
}

// AsArgs returns the variables as arguments.
func (v Vars2) AsArgs() []string {
keys := make([]string, 0, len(v))
Expand Down Expand Up @@ -363,19 +367,20 @@ func jsonDecode[T any](r io.Reader, err error) ([]*T, error) {
dst = append(dst, &m)
default:
return nil, &Error{
err: fmt.Errorf("decoding JSON from stdout: %w", err),
Stdout: string(buf),
}
}
}
}

func jsonDecodeErr[T any](fn func([]*T) error) func(io.Reader, error) ([]*T, error) {
func jsonDecodeErr[T any](fn func([]*T, string) error) func(io.Reader, error) ([]*T, error) {
return func(r io.Reader, err error) ([]*T, error) {
if err != nil {
if cliErr := (&Error{}); errors.As(err, &cliErr) && cliErr.Stderr == "" {
if cliErr := (&Error{}); errors.As(err, &cliErr) && cliErr.Stdout != "" {
d, err := jsonDecode[T](strings.NewReader(cliErr.Stdout), nil)
if err == nil {
return nil, fn(d)
return nil, fn(d, cliErr.Stderr)
}
// If the error is not a JSON, return the original error.
}
Expand Down
12 changes: 9 additions & 3 deletions atlasexec/atlas_migrate.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ type (
// during a migration applying attempt.
MigrateApplyError struct {
Result []*MigrateApply
Stderr string
}
// MigrateExecOrder define how Atlas computes and executes pending migration files to the database.
// See: https://atlasgo.io/versioned/apply#execution-order
Expand Down Expand Up @@ -569,12 +570,17 @@ func (r MigrateStatus) Amount(version string) (amount uint64, ok bool) {
return amount, false
}

func newMigrateApplyError(r []*MigrateApply) error {
return &MigrateApplyError{Result: r}
func newMigrateApplyError(r []*MigrateApply, stderr string) error {
return &MigrateApplyError{Result: r, Stderr: stderr}
}

// Error implements the error interface.
func (e *MigrateApplyError) Error() string { return last(e.Result).Error }
func (e *MigrateApplyError) Error() string {
if e.Stderr != "" {
return e.Stderr
}
return last(e.Result).Error
}

func plural(n int) (s string) {
if n > 1 {
Expand Down
12 changes: 9 additions & 3 deletions atlasexec/atlas_schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ type (
// during a schema applying attempt.
SchemaApplyError struct {
Result []*SchemaApply
Stderr string
}
// SchemaInspectParams are the parameters for the `schema inspect` command.
SchemaInspectParams struct {
Expand Down Expand Up @@ -629,9 +630,14 @@ type InvalidParamsError struct {
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}
func newSchemaApplyError(r []*SchemaApply, stderr string) error {
return &SchemaApplyError{Result: r, Stderr: stderr}
}

// Error implements the error interface.
func (e *SchemaApplyError) Error() string { return last(e.Result).Error }
func (e *SchemaApplyError) Error() string {
if e.Stderr != "" {
return e.Stderr
}
return last(e.Result).Error
}
32 changes: 32 additions & 0 deletions atlasexec/atlas_schema_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -625,3 +625,35 @@ func TestSchema_Apply(t *testing.T) {
})
}
}

func TestSchema_ApplyEnvs(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)
require.NoError(t, c.SetEnv(map[string]string{
"TEST_ARGS": "schema apply --format {{ json . }} --env test",
"TEST_STDOUT": `{"Driver":"sqlite3","URL":{"Scheme":"sqlite","Host":"local-su.db"}}
{"Driver":"sqlite3","URL":{"Scheme":"sqlite","Host":"local-pi.db"}}
{"Driver":"sqlite3","URL":{"Scheme":"sqlite","Host":"local-bu.db"}}`,
"TEST_STDERR": `Abort: The plan "From" hash does not match the current state hash (passed with --from):
- iHZMQ1EoarAXt/KU0KQbBljbbGs8gVqX2ZBXefePSGE= (plan value)
+ Cp8xCVYilZuwULkggsfJLqIQHaxYcg/IpU+kgjVUBA4= (current hash)
`,
}))
result, err := c.SchemaApply(context.Background(), &atlasexec.SchemaApplyParams{
Env: "test",
})
require.ErrorContains(t, err, `The plan "From" hash does not match the current state hash`)
require.Nil(t, result)

err2, ok := err.(*atlasexec.SchemaApplyError)
require.True(t, ok, "should be a SchemaApplyError, got %T", err)
require.Contains(t, err2.Stderr, `Abort: The plan "From" hash does not match the current state hash (passed with --from)`)
require.Len(t, err2.Result, 3, "should returns succeed results")
require.Equal(t, "sqlite://local-su.db", err2.Result[0].URL.String())
require.Equal(t, "sqlite://local-pi.db", err2.Result[1].URL.String())
require.Equal(t, "sqlite://local-bu.db", err2.Result[2].URL.String())
}
9 changes: 8 additions & 1 deletion atlasexec/mock-atlas.sh
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,14 @@ fi

if [[ "$TEST_STDOUT" != "" ]]; then
echo -n $TEST_STDOUT
exit 0
if [[ "$TEST_STDERR" == "" ]]; then
exit 0 # No stderr
fi
# In some cases, Atlas will write the error in stderr
# when if the command is partially successful.
# eg. Run the apply commands with multiple environments.
>&2 echo -n $TEST_STDERR
exit 1
fi

TEST_STDERR="${TEST_STDERR:-Missing stderr either stdout input for the test}"
Expand Down

0 comments on commit 8fc188c

Please sign in to comment.