diff --git a/atlasaction/action.go b/atlasaction/action.go index 9f85898..8ff1c8f 100644 --- a/atlasaction/action.go +++ b/atlasaction/action.go @@ -152,10 +152,11 @@ const ( CmdMigrateDown = "migrate/down" CmdMigrateTest = "migrate/test" // Declarative workflow Commands - CmdSchemaPush = "schema/push" - CmdSchemaTest = "schema/test" - CmdSchemaPlan = "schema/plan" - CmdSchemaApply = "schema/apply" + CmdSchemaPush = "schema/push" + CmdSchemaTest = "schema/test" + CmdSchemaPlan = "schema/plan" + CmdSchemaPlanApprove = "schema/plan/approve" + CmdSchemaApply = "schema/apply" ) // Run runs the action based on the command name. @@ -183,6 +184,8 @@ func (a *Actions) Run(ctx context.Context, act string) error { return a.SchemaTest(ctx) case CmdSchemaPlan: return a.SchemaPlan(ctx) + case CmdSchemaPlanApprove: + return a.SchemaPlanApprove(ctx) case CmdSchemaApply: return a.SchemaApply(ctx) default: @@ -492,8 +495,11 @@ func (a *Actions) SchemaTest(ctx context.Context) error { // SchemaPlan runs the GitHub Action for "ariga/atlas-action/schema/plan" func (a *Actions) SchemaPlan(ctx context.Context) error { tc, err := a.GetTriggerContext() - if err != nil { + switch { + case err != nil: return fmt.Errorf("unable to get the trigger context: %w", err) + case tc.PullRequest == nil: + return fmt.Errorf("the action should be run in a pull request context") } var plan *atlasexec.SchemaPlan params := &atlasexec.SchemaPlanListParams{ @@ -510,36 +516,7 @@ func (a *Actions) SchemaPlan(ctx context.Context) error { switch planFiles, err := a.Atlas.SchemaPlanList(ctx, params); { case err != nil: return fmt.Errorf("failed to list schema plans: %w", err) - // Multiple existing plans. - case len(planFiles) > 1: - for _, f := range planFiles { - a.Infof("Found schema plan: %s", f.URL) - } - // There are existing plans. - return fmt.Errorf("found multiple schema plans, please approve or delete the existing plans") - // Branch context with no plan - case tc.PullRequest == nil && len(planFiles) == 0: - a.Infof("No schema plan found") - return nil - // Branch context with pending plan - case tc.PullRequest == nil && len(planFiles) == 1: - result, err := a.Atlas.SchemaPlanApprove(ctx, &atlasexec.SchemaPlanApproveParams{ - ConfigURL: params.ConfigURL, - Env: params.Env, - Vars: params.Vars, - URL: planFiles[0].URL, - }) - if err != nil { - return fmt.Errorf("failed to approve the schema plan: %w", err) - } - // Successfully approved the plan. - a.Infof("Schema plan approved successfully: %s", result.Link) - a.SetOutput("link", result.Link) - a.SetOutput("plan", result.URL) - a.SetOutput("status", result.Status) - return nil - // PR context and existing plan - case tc.PullRequest != nil && len(planFiles) == 1: + case len(planFiles) == 1: a.Infof("Schema plan already exists, linting the plan %q", planFiles[0].Name) plan, err = a.Atlas.SchemaPlanLint(ctx, &atlasexec.SchemaPlanLintParams{ ConfigURL: params.ConfigURL, @@ -555,12 +532,7 @@ func (a *Actions) SchemaPlan(ctx context.Context) error { if err != nil { return fmt.Errorf("failed to get the schema plan: %w", err) } - // FIXME(giautm): Remove this workaround after fixing the schema URL issue. - plan.File.URL = planFiles[0].URL - plan.File.Link = planFiles[0].Link - plan.File.Status = "PENDING" - // PR context and no existing plan - case tc.PullRequest != nil && len(planFiles) == 0: + case len(planFiles) == 0: name := a.GetInput("name") runPlan: // Dry run if the name is not provided. @@ -594,7 +566,10 @@ func (a *Actions) SchemaPlan(ctx context.Context) error { goto runPlan } default: - return fmt.Errorf("unexpected context, please contact the atlas team") + for _, f := range planFiles { + a.Infof("Found schema plan: %s", f.URL) + } + return fmt.Errorf("found multiple schema plans, please approve or delete the existing plans") } // Set the output values from the schema plan. a.SetOutput("link", plan.File.Link) @@ -621,6 +596,60 @@ func (a *Actions) SchemaPlan(ctx context.Context) error { return nil } +// SchemaPlanApprove runs the GitHub Action for "ariga/atlas-action/schema/plan/approve" +func (a *Actions) SchemaPlanApprove(ctx context.Context) error { + tc, err := a.GetTriggerContext() + switch { + case err != nil: + return fmt.Errorf("unable to get the trigger context: %w", err) + case tc.PullRequest != nil: + return fmt.Errorf("the action should be run in a branch context") + } + params := &atlasexec.SchemaPlanApproveParams{ + ConfigURL: a.GetInput("config"), + Env: a.GetInput("env"), + Vars: a.GetVarsInput("vars"), + URL: a.GetInput("plan"), + } + if params.URL == "" { + a.Infof("No plan URL provided, searching for the pending plan") + switch planFiles, err := a.Atlas.SchemaPlanList(ctx, &atlasexec.SchemaPlanListParams{ + ConfigURL: params.ConfigURL, + Env: params.Env, + Vars: params.Vars, + Context: a.GetRunContext(ctx, tc), + Repo: a.GetInput("schema-name"), + DevURL: a.GetInput("dev-url"), + From: a.GetArrayInput("from"), + To: a.GetArrayInput("to"), + Pending: true, + }); { + case err != nil: + return fmt.Errorf("failed to list schema plans: %w", err) + case len(planFiles) == 1: + params.URL = planFiles[0].URL + case len(planFiles) == 0: + a.Infof("No schema plan found") + return nil + default: + for _, f := range planFiles { + a.Infof("Found schema plan: %s", f.URL) + } + return fmt.Errorf("found multiple schema plans, please approve or delete the existing plans") + } + } + result, err := a.Atlas.SchemaPlanApprove(ctx, params) + if err != nil { + return fmt.Errorf("failed to approve the schema plan: %w", err) + } + // Successfully approved the plan. + a.Infof("Schema plan approved successfully: %s", result.Link) + a.SetOutput("link", result.Link) + a.SetOutput("plan", result.URL) + a.SetOutput("status", result.Status) + return nil +} + // SchemaApply runs the GitHub Action for "ariga/atlas-action/schema/apply" func (a *Actions) SchemaApply(ctx context.Context) error { params := &atlasexec.SchemaApplyParams{ diff --git a/atlasaction/action_test.go b/atlasaction/action_test.go index 4b58ad8..c0895da 100644 --- a/atlasaction/action_test.go +++ b/atlasaction/action_test.go @@ -2061,14 +2061,88 @@ func TestSchemaPlan(t *testing.T) { "link": "https://gh.atlasgo.cloud/plan/pr-1-Rl4lBdMk", }, act.output, "expected output with plan URL") + // Check all logs output + require.Equal(t, `time=NOW level=INFO msg="Found schema plan: atlas://atlas-action/plans/pr-1-Rl4lBdMk" +time=NOW level=INFO msg="Found schema plan: atlas://atlas-action/plans/pr-1-Rl4lBdMk" +time=NOW level=INFO msg="The current state is synced with the desired state, no changes to be made" +time=NOW level=INFO msg="Schema plan does not exist, creating a new one with name \"pr-1-ufnTS7Nr\"" +time=NOW level=INFO msg="Schema plan already exists, linting the plan \"pr-1-Rl4lBdMk\"" +`, out.String()) +} + +func TestSchemaPlanApprove(t *testing.T) { + h := http.NewServeMux() + h.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL) + }) + srv := httptest.NewServer(h) + t.Cleanup(srv.Close) + planFile := &atlasexec.SchemaPlanFile{ + Name: "pr-1-Rl4lBdMk", + FromHash: "ufnTS7NrAgkvQlxbpnSxj119MAPGNqVj0i3Eelv+iLc=", // Used as comment marker + ToHash: "Rl4lBdMkvFoGQ4xu+3sYCeogTVnamJ7bmDoq9pMXcjw=", + URL: "atlas://atlas-action/plans/pr-1-Rl4lBdMk", + Link: "https://gh.atlasgo.cloud/plan/pr-1-Rl4lBdMk", + Status: "PENDING", + } + var planFiles []atlasexec.SchemaPlanFile + var approveErr error + m := &mockAtlas{ + schemaPlanList: func(_ context.Context, p *atlasexec.SchemaPlanListParams) ([]atlasexec.SchemaPlanFile, error) { + return planFiles, nil + }, + schemaPlanApprove: func(_ context.Context, p *atlasexec.SchemaPlanApproveParams) (*atlasexec.SchemaPlanApprove, error) { + require.Equal(t, "file://atlas.hcl", p.ConfigURL) + require.Equal(t, "atlas://atlas-action/plans/pr-1-Rl4lBdMk", p.URL) + if approveErr != nil { + return nil, approveErr + } + return &atlasexec.SchemaPlanApprove{ + URL: "atlas://atlas-action/plans/pr-1-Rl4lBdMk", + Link: "https://gh.atlasgo.cloud/plan/pr-1-Rl4lBdMk", + Status: "APPROVED", + }, nil + }, + } + t.Setenv("GITHUB_TOKEN", "token") + out := &bytes.Buffer{} + act := &mockAction{ + inputs: map[string]string{ + // "schema-name": "atlas://atlas-action", + "from": "sqlite://file?_fk=1&mode=memory", + "config": "file://atlas.hcl", + "env": "test", + }, + logger: slog.New(slog.NewTextHandler(out, &slog.HandlerOptions{ + ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr { + if a.Key == slog.TimeKey { + return slog.String(slog.TimeKey, "NOW") // Fake time + } + return a + }, + })), + trigger: &atlasaction.TriggerContext{ + SCM: atlasaction.SCM{Type: atlasexec.SCMTypeGithub, APIURL: srv.URL}, + Repo: "ariga/atlas-action", + RepoURL: "https://github.com/ariga/atlas-action", + Branch: "g/feature-1", + Commit: "commit-id", + }, + } + ctx := context.Background() + // Multiple plans will fail with an error + planFiles = []atlasexec.SchemaPlanFile{*planFile, *planFile} + act.resetOutputs() + require.ErrorContains(t, (&atlasaction.Actions{Action: act, Atlas: m}).SchemaPlanApprove(ctx), "found multiple schema plans, please approve or delete the existing plans") + require.Len(t, act.summary, 0, "Expected 1 summary") + // Trigger with no pull request, master branch + planFiles = []atlasexec.SchemaPlanFile{*planFile} act.trigger.PullRequest = nil act.trigger.Branch = "master" act.resetOutputs() - require.NoError(t, (&atlasaction.Actions{Action: act, Atlas: m}).SchemaPlan(ctx)) - require.Len(t, act.summary, 2, "No more summaries generated") - require.Equal(t, 1, commentCounter, "No more comments generated") - require.Equal(t, 1, commentEdited, "No comment should be edited") + require.NoError(t, (&atlasaction.Actions{Action: act, Atlas: m}).SchemaPlanApprove(ctx)) + require.Len(t, act.summary, 0, "No more summaries generated") require.EqualValues(t, map[string]string{ "plan": "atlas://atlas-action/plans/pr-1-Rl4lBdMk", "status": "APPROVED", @@ -2078,19 +2152,17 @@ func TestSchemaPlan(t *testing.T) { // No pending plan planFiles = nil act.resetOutputs() - require.NoError(t, (&atlasaction.Actions{Action: act, Atlas: m}).SchemaPlan(ctx)) - require.Len(t, act.summary, 2, "No more summaries generated") - require.Equal(t, 1, commentCounter, "No more comments generated") - require.Equal(t, 1, commentEdited, "No comment should be edited") + require.NoError(t, (&atlasaction.Actions{Action: act, Atlas: m}).SchemaPlanApprove(ctx)) + require.Len(t, act.summary, 0, "No more summaries generated") require.EqualValues(t, map[string]string{}, act.output, "expected output with plan URL") // Check all logs output - require.Equal(t, `time=NOW level=INFO msg="Found schema plan: atlas://atlas-action/plans/pr-1-Rl4lBdMk" + require.Equal(t, `time=NOW level=INFO msg="No plan URL provided, searching for the pending plan" time=NOW level=INFO msg="Found schema plan: atlas://atlas-action/plans/pr-1-Rl4lBdMk" -time=NOW level=INFO msg="The current state is synced with the desired state, no changes to be made" -time=NOW level=INFO msg="Schema plan does not exist, creating a new one with name \"pr-1-ufnTS7Nr\"" -time=NOW level=INFO msg="Schema plan already exists, linting the plan \"pr-1-Rl4lBdMk\"" +time=NOW level=INFO msg="Found schema plan: atlas://atlas-action/plans/pr-1-Rl4lBdMk" +time=NOW level=INFO msg="No plan URL provided, searching for the pending plan" time=NOW level=INFO msg="Schema plan approved successfully: https://gh.atlasgo.cloud/plan/pr-1-Rl4lBdMk" +time=NOW level=INFO msg="No plan URL provided, searching for the pending plan" time=NOW level=INFO msg="No schema plan found" `, out.String()) } diff --git a/schema/plan/approve/action.yml b/schema/plan/approve/action.yml new file mode 100644 index 0000000..352efec --- /dev/null +++ b/schema/plan/approve/action.yml @@ -0,0 +1,53 @@ +name: 'Schema Plan Approve' +description: 'Approve a migration plan by its URL' +branding: + icon: database +author: 'Ariga' +inputs: + working-directory: + description: Atlas working directory, default is project root + required: false + config: + description: | + The URL of the Atlas configuration file. By default, Atlas will look for a file + named `atlas.hcl` in the current directory. For example, `file://config/atlas.hcl`. + Learn more about [Atlas configuration files](https://atlasgo.io/atlas-schema/projects). + required: false + env: + description: The environment to use from the Atlas configuration file. For example, `dev`. + required: false + vars: + description: | + A JSON object containing variables to be used in the Atlas configuration file. + For example, `{"var1": "value1", "var2": "value2"}`. + required: false + dev-url: + description: | + The URL of the dev-database to use for analysis. For example: `mysql://root:pass@localhost:3306/dev`. + Read more about [dev-databases](https://atlasgo.io/concepts/dev-database). + required: false + plan: + description: | + The URL of the plan to be approved. For example, `atlas:///plans/`. + required: false + schema-name: + description: The name (slug) of the project in Atlas Cloud. + required: false + from: + description: | + URL(s) of the current schema state. + required: false + to: + description: | + URL(s) of the desired schema state. + required: false +outputs: + plan: + description: The plan to be applied or generated. (ig. `atlas:///plans/`) + link: # id of the output + description: Link to the schema plan on Atlas. + status: + description: The status of the plan. (ig. `PENDING`, `APPROVED`) +runs: + using: node20 + main: index.js diff --git a/schema/plan/approve/index.js b/schema/plan/approve/index.js new file mode 100644 index 0000000..1f6504e --- /dev/null +++ b/schema/plan/approve/index.js @@ -0,0 +1 @@ +require('../../../shim/dist')('schema/plan/approve')