Repository: ariga/atlas Branch: master Commit: 9138a9fe419e Files: 530 Total size: 4.0 MB Directory structure: gitextract_z_8o4h8f/ ├── .github/ │ ├── ops/ │ │ └── mysql/ │ │ └── Dockerfile │ └── workflows/ │ ├── cd-docker-push-cockroach_oss.yaml │ ├── cd-docker-push-mysql_oss.yaml │ ├── ci-dialect_oss.yaml │ ├── ci-go_oss.yaml │ ├── ci-revisions_oss.yaml │ └── ci-sdk.yml ├── .golangci.yml ├── LICENSE ├── README.md ├── atlasexec/ │ ├── README.md │ ├── atlas.go │ ├── atlas_internal_test.go │ ├── atlas_migrate.go │ ├── atlas_migrate_example_test.go │ ├── atlas_migrate_test.go │ ├── atlas_models.go │ ├── atlas_schema.go │ ├── atlas_schema_test.go │ ├── atlas_test.go │ ├── copilot.go │ ├── copilot_test.go │ ├── internal/ │ │ └── e2e/ │ │ ├── sqlite_test.go │ │ ├── testdata/ │ │ │ ├── multi-tenants/ │ │ │ │ ├── atlas.hcl │ │ │ │ └── migrations/ │ │ │ │ ├── 20240112070806.sql │ │ │ │ ├── 20240116003831.sql │ │ │ │ └── atlas.sum │ │ │ ├── schema-plan/ │ │ │ │ ├── schema-1.lt.hcl │ │ │ │ └── schema-2.lt.hcl │ │ │ └── versioned-basic/ │ │ │ ├── atlas.hcl │ │ │ └── migrations/ │ │ │ ├── 20240112070806.sql │ │ │ └── atlas.sum │ │ └── util_e2e.go │ ├── mock-atlas.sh │ ├── testdata/ │ │ ├── broken/ │ │ │ ├── 20231029112426.sql │ │ │ └── atlas.sum │ │ └── migrations/ │ │ ├── 20230727105553_init.sql │ │ ├── 20230727105615_t2.sql │ │ ├── 20230926085734_destructive-change.sql │ │ └── atlas.sum │ ├── working_dir.go │ └── working_dir_test.go ├── cmd/ │ └── atlas/ │ ├── go.mod │ ├── go.sum │ ├── internal/ │ │ ├── cloudapi/ │ │ │ ├── client.go │ │ │ ├── client_oss.go │ │ │ └── client_test.go │ │ ├── cmdapi/ │ │ │ ├── cmdapi.go │ │ │ ├── cmdapi_oss.go │ │ │ ├── cmdapi_test.go │ │ │ ├── migrate.go │ │ │ ├── migrate_oss.go │ │ │ ├── migrate_test.go │ │ │ ├── project.go │ │ │ ├── project_test.go │ │ │ ├── schema.go │ │ │ ├── schema_test.go │ │ │ ├── testdata/ │ │ │ │ ├── baseline1/ │ │ │ │ │ ├── 1_baseline.sql │ │ │ │ │ └── atlas.sum │ │ │ │ ├── baseline2/ │ │ │ │ │ ├── 1_baseline.sql │ │ │ │ │ ├── 20220318104614_initial.sql │ │ │ │ │ ├── 20220318104615_second.sql │ │ │ │ │ └── atlas.sum │ │ │ │ ├── import/ │ │ │ │ │ ├── dbmate/ │ │ │ │ │ │ ├── 1_initial.sql │ │ │ │ │ │ └── 2_second_migration.sql │ │ │ │ │ ├── dbmate_gold/ │ │ │ │ │ │ ├── 1_initial.sql │ │ │ │ │ │ └── 2_second_migration.sql │ │ │ │ │ ├── flyway/ │ │ │ │ │ │ ├── B2__baseline.sql │ │ │ │ │ │ ├── R__views.sql │ │ │ │ │ │ ├── U1__initial.sql │ │ │ │ │ │ ├── V1__initial.sql │ │ │ │ │ │ ├── V2__second_migration.sql │ │ │ │ │ │ └── V3__third_migration.sql │ │ │ │ │ ├── flyway_gold/ │ │ │ │ │ │ ├── 2_baseline.sql │ │ │ │ │ │ ├── 3R_views.sql │ │ │ │ │ │ └── 3_third_migration.sql │ │ │ │ │ ├── golang-migrate/ │ │ │ │ │ │ ├── 1_initial.down.sql │ │ │ │ │ │ ├── 1_initial.up.sql │ │ │ │ │ │ ├── 2_second_migration.down.sql │ │ │ │ │ │ └── 2_second_migration.up.sql │ │ │ │ │ ├── golang-migrate_gold/ │ │ │ │ │ │ ├── 1_initial.sql │ │ │ │ │ │ └── 2_second_migration.sql │ │ │ │ │ ├── goose/ │ │ │ │ │ │ ├── 1_initial.sql │ │ │ │ │ │ └── 2_second_migration.sql │ │ │ │ │ ├── goose_gold/ │ │ │ │ │ │ ├── 1_initial.sql │ │ │ │ │ │ └── 2_second_migration.sql │ │ │ │ │ ├── liquibase/ │ │ │ │ │ │ ├── 1_initial.sql │ │ │ │ │ │ └── 2_second_migration.sql │ │ │ │ │ └── liquibase_gold/ │ │ │ │ │ ├── 1_initial.sql │ │ │ │ │ └── 2_second_migration.sql │ │ │ │ ├── mysql/ │ │ │ │ │ ├── 20220318104614_initial.sql │ │ │ │ │ ├── 20220420213403_second.sql │ │ │ │ │ └── atlas.sum │ │ │ │ ├── sqlite/ │ │ │ │ │ ├── 20220318104614_initial.sql │ │ │ │ │ ├── 20220318104615_second.sql │ │ │ │ │ └── atlas.sum │ │ │ │ ├── sqlite2/ │ │ │ │ │ ├── 20220318104614_initial.sql │ │ │ │ │ ├── 20220318104615_second.sql │ │ │ │ │ └── atlas.sum │ │ │ │ ├── sqlitetx/ │ │ │ │ │ ├── 20220925092817_initial.sql │ │ │ │ │ ├── 20220925094021_second.sql │ │ │ │ │ ├── 20220925094437_third.sql │ │ │ │ │ └── atlas.sum │ │ │ │ ├── sqlitetx2/ │ │ │ │ │ ├── 20220925092817_initial.sql │ │ │ │ │ ├── 20220925094021_second.sql │ │ │ │ │ ├── 20220925094437_third.sql │ │ │ │ │ └── atlas.sum │ │ │ │ ├── sqlitetx3/ │ │ │ │ │ ├── 20220925092817_initial.sql │ │ │ │ │ ├── 20220925094021_second.sql │ │ │ │ │ └── atlas.sum │ │ │ │ ├── sqlitetx4/ │ │ │ │ │ ├── 20220925092817_initial.sql │ │ │ │ │ ├── 20220925094021_second.sql │ │ │ │ │ └── atlas.sum │ │ │ │ └── templatedir/ │ │ │ │ ├── 1.sql │ │ │ │ ├── 2.sql │ │ │ │ ├── atlas.sum │ │ │ │ └── shared/ │ │ │ │ └── users.sql │ │ │ ├── vercheck/ │ │ │ │ ├── notification.tmpl │ │ │ │ ├── req_oss.go │ │ │ │ ├── vercheck.go │ │ │ │ └── vercheck_test.go │ │ │ ├── version_oss.go │ │ │ └── version_oss_test.go │ │ ├── cmdext/ │ │ │ ├── cmdext.go │ │ │ ├── cmdext_oss.go │ │ │ ├── cmdext_test.go │ │ │ ├── reader.go │ │ │ └── reader_test.go │ │ ├── cmdlog/ │ │ │ ├── cmdlog.go │ │ │ ├── cmdlog_oss.go │ │ │ └── cmdlog_test.go │ │ ├── cmdstate/ │ │ │ ├── cmdstate.go │ │ │ └── cmdstate_test.go │ │ ├── docker/ │ │ │ ├── docker.go │ │ │ └── docker_test.go │ │ ├── migrate/ │ │ │ ├── ent/ │ │ │ │ ├── client.go │ │ │ │ ├── convert.go │ │ │ │ ├── ent.go │ │ │ │ ├── entc.go │ │ │ │ ├── enttest/ │ │ │ │ │ └── enttest.go │ │ │ │ ├── generate.go │ │ │ │ ├── hook/ │ │ │ │ │ └── hook.go │ │ │ │ ├── internal/ │ │ │ │ │ └── schemaconfig.go │ │ │ │ ├── migrate/ │ │ │ │ │ ├── migrate.go │ │ │ │ │ └── schema.go │ │ │ │ ├── mutation.go │ │ │ │ ├── predicate/ │ │ │ │ │ └── predicate.go │ │ │ │ ├── revision/ │ │ │ │ │ ├── revision.go │ │ │ │ │ └── where.go │ │ │ │ ├── revision.go │ │ │ │ ├── revision_create.go │ │ │ │ ├── revision_delete.go │ │ │ │ ├── revision_query.go │ │ │ │ ├── revision_update.go │ │ │ │ ├── runtime/ │ │ │ │ │ └── runtime.go │ │ │ │ ├── runtime.go │ │ │ │ ├── schema/ │ │ │ │ │ └── revision.go │ │ │ │ ├── template/ │ │ │ │ │ └── convert.tmpl │ │ │ │ └── tx.go │ │ │ ├── migrate.go │ │ │ ├── migrate_oss.go │ │ │ ├── migrate_test.go │ │ │ └── testdata/ │ │ │ ├── broken/ │ │ │ │ ├── 1.sql │ │ │ │ ├── 2.sql │ │ │ │ ├── 3.sql │ │ │ │ └── atlas.sum │ │ │ └── fixed/ │ │ │ ├── 1.sql │ │ │ ├── 2.sql │ │ │ ├── 3.sql │ │ │ └── atlas.sum │ │ ├── migratelint/ │ │ │ ├── lint.go │ │ │ ├── lint_oss.go │ │ │ └── lint_test.go │ │ └── sqlparse/ │ │ ├── myparse/ │ │ │ └── myparse_oss.go │ │ ├── parseutil/ │ │ │ └── parseutil.go │ │ ├── pgparse/ │ │ │ └── pgparse_oss.go │ │ ├── sqliteparse/ │ │ │ ├── Lexer.g4 │ │ │ ├── Parser.g4 │ │ │ ├── README.md │ │ │ ├── lexer.go │ │ │ ├── parser.go │ │ │ ├── parser_base_listener.go │ │ │ ├── parser_base_visitor.go │ │ │ ├── parser_listener.go │ │ │ ├── parser_visitor.go │ │ │ └── sqliteparse_oss.go │ │ └── sqlparse.go │ ├── main.go │ └── main_oss.go ├── go.mod ├── go.sum ├── internal/ │ ├── ci/ │ │ ├── ci_dialect.tmpl │ │ ├── ci_go.tmpl │ │ ├── ci_revisions.tmpl │ │ ├── cockroach/ │ │ │ ├── Dockerfile.tmpl │ │ │ └── main.go │ │ ├── jobs_oss.go │ │ └── main.go │ └── integration/ │ ├── README.md │ ├── cockroach_test.go │ ├── docker-compose.yaml │ ├── go.mod │ ├── go.sum │ ├── hclsqlspec/ │ │ └── hclsqlspec_test.go │ ├── integration_test.go │ ├── mysql_test.go │ ├── postgres_test.go │ ├── script_test.go │ ├── sqlite_test.go │ ├── testdata/ │ │ ├── migrations/ │ │ │ ├── mysql/ │ │ │ │ ├── 1_initial.sql │ │ │ │ └── atlas.sum │ │ │ ├── mysqlock/ │ │ │ │ ├── 1.sql │ │ │ │ ├── 2.sql │ │ │ │ ├── 3.sql │ │ │ │ └── atlas.sum │ │ │ └── postgres/ │ │ │ ├── 1_initial.sql │ │ │ └── atlas.sum │ │ ├── mysql/ │ │ │ ├── autoincrement.txtar │ │ │ ├── check-maria.txtar │ │ │ ├── check.txtar │ │ │ ├── cli-inspect-file.txtar │ │ │ ├── cli-migrate-apply-datasrc.txtar │ │ │ ├── cli-migrate-apply.txtar │ │ │ ├── cli-migrate-diff-format.txtar │ │ │ ├── cli-migrate-diff-mode-normalized.txtar │ │ │ ├── cli-migrate-diff.txtar │ │ │ ├── cli-project-schemas.txtar │ │ │ ├── cli-project-url-escape.txtar │ │ │ ├── cli-schema-apply-datasrc.txtar │ │ │ ├── column-add-drop.txtar │ │ │ ├── column-bit.txtar │ │ │ ├── column-bool.txtar │ │ │ ├── column-charset.txtar │ │ │ ├── column-default-expr.txtar │ │ │ ├── column-generated-inspect.txtar │ │ │ ├── column-generated.txtar │ │ │ ├── column-json.txtar │ │ │ ├── column-time-precision-maria.txtar │ │ │ ├── column-time-precision-mysql.txtar │ │ │ ├── foreign-key-add.txtar │ │ │ ├── foreign-key-modify-action.txtar │ │ │ ├── foreign-key.txtar │ │ │ ├── index-add-drop.txtar │ │ │ ├── index-desc.txtar │ │ │ ├── index-expr.txtar │ │ │ ├── index-prefix.txtar │ │ │ ├── index-type.txtar │ │ │ ├── index-unique.txtar │ │ │ ├── primary-key-parts.txtar │ │ │ ├── primary-key.txtar │ │ │ └── table-engine.txtar │ │ ├── postgres/ │ │ │ ├── cli-inspect-file.txtar │ │ │ ├── cli-inspect.txtar │ │ │ ├── cli-migrate-apply-datasrc.txtar │ │ │ ├── cli-migrate-apply.txtar │ │ │ ├── cli-migrate-diff-unsupported.txtar │ │ │ ├── cli-migrate-diff.txtar │ │ │ ├── cli-migrate-status.txtar │ │ │ ├── column-array.txtar │ │ │ ├── column-bit.txtar │ │ │ ├── column-comment.txtar │ │ │ ├── column-default.txtar │ │ │ ├── column-domain.txtar │ │ │ ├── column-enum-array.txtar │ │ │ ├── column-enum.txtar │ │ │ ├── column-float.txtar │ │ │ ├── column-generated-inspect.txtar │ │ │ ├── column-identity.txtar │ │ │ ├── column-interval.txtar │ │ │ ├── column-numeric.txtar │ │ │ ├── column-range.txtar │ │ │ ├── column-serial.txtar │ │ │ ├── column-textsearch.txtar │ │ │ ├── column-time-precision.txtar │ │ │ ├── foreign-key-action.txtar │ │ │ ├── foreign-key.txtar │ │ │ ├── index-desc.txtar │ │ │ ├── index-expr.txtar │ │ │ ├── index-include.txtar │ │ │ ├── index-issue-557.txtar │ │ │ ├── index-nulls-distinct.txtar │ │ │ ├── index-operator-class.txtar │ │ │ ├── index-partial.txtar │ │ │ ├── index-type-brin.txtar │ │ │ ├── index-type.txtar │ │ │ ├── index-unique-constraint.txtar │ │ │ ├── primary-key.txtar │ │ │ ├── table-checks.txtar │ │ │ └── table-partition.txtar │ │ └── sqlite/ │ │ ├── autoincrement.txtar │ │ ├── cli-apply-multifile.txtar │ │ ├── cli-apply-project-multifile.txtar │ │ ├── cli-apply-vars.txtar │ │ ├── cli-inspect.txtar │ │ ├── cli-migrate-apply.txtar │ │ ├── cli-migrate-diff-datasrc-hcl-paths.txtar │ │ ├── cli-migrate-diff-datasrc-hcl.txtar │ │ ├── cli-migrate-diff-minimal-env.txtar │ │ ├── cli-migrate-diff-multifile.txtar │ │ ├── cli-migrate-diff-sql.txtar │ │ ├── cli-migrate-diff.txtar │ │ ├── cli-migrate-lint-add-notnull.txtar │ │ ├── cli-migrate-lint-destructive.txtar │ │ ├── cli-migrate-lint-ignore.txtar │ │ ├── cli-migrate-lint-minimal-env.txtar │ │ ├── cli-migrate-lint-project.txtar │ │ ├── cli-migrate-project-multifile.txtar │ │ ├── cli-migrate-project.txtar │ │ ├── cli-migrate-set.txtar │ │ ├── cli-project-vars.txtar │ │ ├── cli-schema-project-file.txtar │ │ ├── column-default.txtar │ │ ├── column-generated.txtar │ │ ├── column-user-defined.txtar │ │ ├── index-desc.txtar │ │ ├── index-expr.txtar │ │ ├── index-partial.txtar │ │ └── table-options.txtar │ ├── tidb_test.go │ └── tools.go ├── schemahcl/ │ ├── context.go │ ├── context_test.go │ ├── extension.go │ ├── extension_test.go │ ├── schemahcl.go │ ├── schemahcl_test.go │ ├── spec.go │ ├── spec_test.go │ ├── stdlib.go │ ├── stdlib_test.go │ ├── testdata/ │ │ ├── a.hcl │ │ ├── b.hcl │ │ ├── nested/ │ │ │ └── c.hcl │ │ └── variables.hcl │ ├── types.go │ └── types_test.go ├── sdk/ │ ├── recordriver/ │ │ ├── driver.go │ │ └── driver_test.go │ └── tmplrun/ │ ├── testdata/ │ │ ├── app.tmpl │ │ └── foo.go │ ├── tmplrun.go │ └── tmplrun_test.go └── sql/ ├── internal/ │ ├── spectest/ │ │ └── spectest.go │ ├── specutil/ │ │ ├── convert.go │ │ ├── convert_test.go │ │ └── spec.go │ ├── sqltest/ │ │ └── sqltest.go │ └── sqlx/ │ ├── dev.go │ ├── dev_test.go │ ├── diff.go │ ├── plan.go │ ├── plan_test.go │ ├── sqlx.go │ ├── sqlx_oss.go │ └── sqlx_test.go ├── migrate/ │ ├── dir.go │ ├── dir_test.go │ ├── lex.go │ ├── lex_test.go │ ├── migrate.go │ ├── migrate_oss.go │ ├── migrate_test.go │ └── testdata/ │ ├── golang-migrate/ │ │ └── 1_base.up.sql │ ├── lex/ │ │ ├── 1.sql │ │ ├── 1.sql.golden │ │ ├── 10_delimiter_comment.sql │ │ ├── 10_delimiter_comment.sql.golden │ │ ├── 11_delimiter_mysql_command.sql │ │ ├── 11_delimiter_mysql_command.sql.golden │ │ ├── 12_delimiter_mysql_command.sql │ │ ├── 12_delimiter_mysql_command.sql.golden │ │ ├── 13_delimiter_mysql_command.sql │ │ ├── 13_delimiter_mysql_command.sql.golden │ │ ├── 14_delimiter_mysql_command.sql │ │ ├── 14_delimiter_mysql_command.sql.golden │ │ ├── 15_dollar_quote.sql │ │ ├── 15_dollar_quote.sql.golden │ │ ├── 16_begin_atomic.sql │ │ ├── 16_begin_atomic.sql.golden │ │ ├── 17_paren.sql │ │ ├── 17_paren.sql.golden │ │ ├── 18_pg_expr.sql │ │ ├── 18_pg_expr.sql.golden │ │ ├── 19_ms_gocmd.sql │ │ ├── 19_ms_gocmd.sql.golden │ │ ├── 20_ms_go-delim.sql │ │ ├── 20_ms_go-delim.sql.golden │ │ ├── 2_mysql.sql │ │ ├── 2_mysql.sql.golden │ │ ├── 3_delimiter.sql │ │ ├── 3_delimiter.sql.golden │ │ ├── 4_delimiter.sql │ │ ├── 4_delimiter.sql.golden │ │ ├── 5_delimiter.sql │ │ ├── 5_delimiter.sql.golden │ │ ├── 6_skip_comment.sql │ │ ├── 6_skip_comment.sql.golden │ │ ├── 7_delimiter_2n.sql │ │ ├── 7_delimiter_2n.sql.golden │ │ ├── 8_delimiter_3n.sql │ │ ├── 8_delimiter_3n.sql.golden │ │ ├── 9_delimiter_3n.sql │ │ └── 9_delimiter_3n.sql.golden │ ├── lexbegintry/ │ │ ├── 1.sql │ │ └── 1.sql.golden │ ├── lexescaped/ │ │ ├── 1.my.sql │ │ ├── 1.my.sql.golden │ │ ├── 2.pg.sql │ │ └── 2.pg.sql.golden │ ├── lexgroup/ │ │ ├── 1_trigger.sql │ │ ├── 1_trigger.sql.golden │ │ ├── 2_function.sql │ │ ├── 2_function.sql.golden │ │ ├── 3_delimiter.sql │ │ └── 3_delimiter.sql.golden │ ├── migrate/ │ │ ├── 1_initial.down.sql │ │ ├── 1_initial.up.sql │ │ ├── atlas.sum │ │ └── sub/ │ │ ├── 1.a_sub.up.sql │ │ ├── 2.10.x-20_description.sql │ │ ├── 3_partly.sql │ │ └── atlas.sum │ ├── partial-checkpoint/ │ │ ├── 1_first.sql │ │ ├── 2_second.sql │ │ ├── 3_checkpoint.sql │ │ ├── 4_fourth.sql │ │ ├── 5_checkpoint.sql │ │ ├── 6_sixth.sql │ │ └── atlas.sum │ └── sqlserver/ │ ├── 1_return_table.sql │ ├── 1_return_table.sql.golden │ ├── 2_function.sql │ └── 2_function.sql.golden ├── mysql/ │ ├── convert.go │ ├── diff_oss.go │ ├── diff_oss_test.go │ ├── driver_oss.go │ ├── driver_oss_test.go │ ├── inspect_oss.go │ ├── inspect_oss_test.go │ ├── internal/ │ │ └── mysqlversion/ │ │ ├── is/ │ │ │ ├── .README.md │ │ │ ├── charset2collate │ │ │ ├── charset2collate.maria │ │ │ ├── collate2charset │ │ │ └── collate2charset.maria │ │ ├── mysqlversion.go │ │ └── mysqlversion_test.go │ ├── migrate_oss.go │ ├── migrate_oss_test.go │ ├── mysqlcheck/ │ │ ├── mysqlcheck.go │ │ ├── mysqlcheck_oss.go │ │ └── mysqlcheck_test.go │ ├── sqlspec_oss.go │ ├── sqlspec_oss_test.go │ └── tidb.go ├── postgres/ │ ├── convert.go │ ├── crdb_oss.go │ ├── diff_oss.go │ ├── diff_oss_test.go │ ├── driver_oss.go │ ├── driver_oss_test.go │ ├── inspect_oss.go │ ├── inspect_oss_test.go │ ├── internal/ │ │ └── postgresop/ │ │ └── postgresop.go │ ├── migrate_oss.go │ ├── migrate_oss_test.go │ ├── postgrescheck/ │ │ ├── postgrescheck.go │ │ ├── postgrescheck_oss.go │ │ └── postgrescheck_test.go │ ├── sqlspec_oss.go │ └── sqlspec_oss_test.go ├── schema/ │ ├── changekind_string.go │ ├── dsl.go │ ├── dsl_test.go │ ├── exclude_oss.go │ ├── inspect.go │ ├── migrate.go │ ├── migrate_test.go │ └── schema.go ├── sqlcheck/ │ ├── condrop/ │ │ ├── condrop.go │ │ └── condrop_test.go │ ├── datadepend/ │ │ ├── datadepend.go │ │ └── datadepend_test.go │ ├── destructive/ │ │ ├── destructive.go │ │ ├── destructive_oss.go │ │ └── destructive_test.go │ ├── incompatible/ │ │ ├── incompatible.go │ │ └── incompatible_test.go │ └── sqlcheck.go ├── sqlclient/ │ ├── client.go │ └── client_test.go ├── sqlite/ │ ├── convert.go │ ├── diff.go │ ├── diff_test.go │ ├── driver.go │ ├── driver_oss.go │ ├── driver_test.go │ ├── inspect.go │ ├── inspect_test.go │ ├── migrate.go │ ├── migrate_test.go │ ├── sqlitecheck/ │ │ ├── sqlitecheck.go │ │ ├── sqlitecheck_oss.go │ │ └── sqlitecheck_test.go │ ├── sqlspec.go │ └── sqlspec_test.go ├── sqlspec/ │ ├── sqlspec.go │ └── sqlspec_test.go └── sqltool/ ├── doc.go ├── hidden.go ├── hidden_windows.go ├── testdata/ │ ├── dbmate/ │ │ ├── 1_initial.sql │ │ └── 2_second_migration.sql │ ├── flyway/ │ │ ├── B2__baseline.sql │ │ ├── R__views.sql │ │ ├── U1__initial.sql │ │ ├── V1__initial.sql │ │ ├── V2__second_migration.sql │ │ ├── V3__third_migration.sql │ │ └── v3/ │ │ └── V3_1__fourth_migration.sql │ ├── golang-migrate/ │ │ ├── 1_initial.down.sql │ │ ├── 1_initial.up.sql │ │ ├── 2_second_migration.down.sql │ │ └── 2_second_migration.up.sql │ ├── goose/ │ │ ├── 1_initial.sql │ │ └── 2_second_migration.sql │ └── liquibase/ │ ├── 1_initial.sql │ └── 2_second_migration.sql ├── tool.go └── tool_test.go ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/ops/mysql/Dockerfile ================================================ ARG DIALECT=mysql:8.0 FROM $DIALECT as builder ARG SERVER=mysqld ENV MYSQL_ROOT_PASSWORD=pass # Remove the last line of the entry point script, leaving the initialization code but omitting actually starting the db. RUN sed -i 's/exec "$@"/echo "not running $@"/' /usr/local/bin/docker-entrypoint.sh RUN /usr/local/bin/docker-entrypoint.sh ${SERVER} FROM $DIALECT COPY --from=builder /var/lib/mysql /var/lib/mysql ================================================ FILE: .github/workflows/cd-docker-push-cockroach_oss.yaml ================================================ name: CD - Build Docker - Cockroach - Community Edition on: pull_request: push: branches: - master env: CRDB_VERSIONS: v21.2.11 v22.1.0 jobs: build-services: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version-file: cmd/atlas/go.mod - name: Log in to registry run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u $ --password-stdin - name: "build cockroach image" run: | VER="${{ env.CRDB_VERSIONS }}" for i in $VER do : if ! docker manifest inspect ghcr.io/ariga/cockroachdb-single-node:$i; then go run internal/ci/cockroach/main.go $i > internal/ci/cockroach/Dockerfile docker build -t ghcr.io/ariga/cockroachdb-single-node:$i internal/ci/cockroach/ docker push ghcr.io/ariga/cockroachdb-single-node:$i else echo image already exists fi done ================================================ FILE: .github/workflows/cd-docker-push-mysql_oss.yaml ================================================ name: CD - Build Docker - MySQL Quick Boot - Community Edition on: push: paths: - .github/ops/mysql/** - .github/workflows/cd-docker-push-mysql_oss.yaml branches: - master schedule: # Runs at 00:00 on Sunday - cron: '0 0 * * 0' workflow_dispatch: jobs: push-docker: strategy: fail-fast: false matrix: include: - dialect: mysql:latest - dialect: mysql:5.6 platforms: linux/amd64 - dialect: mysql:5.6.35 platforms: linux/amd64 - dialect: mysql:5.7 platforms: linux/amd64 - dialect: mysql:5.7.26 platforms: linux/amd64 - dialect: mysql:8 - dialect: mysql:8.0.40 - dialect: mysql:8.4 - dialect: mysql:8.4.0 - dialect: mysql:8.3 - dialect: mysql:8.3.0 - dialect: mariadb:latest build-args: SERVER=mariadbd - dialect: mariadb:10.2 - dialect: mariadb:10.2.32 - dialect: mariadb:10.3 - dialect: mariadb:10.3.13 platforms: linux/amd64 - dialect: mariadb:10.7 runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Login to Docker Hub uses: docker/login-action@v1 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Set up QEMU uses: docker/setup-qemu-action@v2 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v2 - name: Build & Push ${{ matrix.dialect }} Docker Image uses: docker/build-push-action@v2 with: context: . file: ./.github/ops/mysql/Dockerfile push: true tags: arigaio/${{ matrix.dialect }} platforms: ${{ matrix.platforms || 'linux/amd64,linux/arm64' }} build-args: | DIALECT=${{ matrix.dialect }} ${{ matrix.build-args }} ================================================ FILE: .github/workflows/ci-dialect_oss.yaml ================================================ # # # # # # # # # # # # # # # # # CODE GENERATED - DO NOT EDIT # # # # # # # # # # # # # # # # name: CI - Dialect Tests - Community Edition on: workflow_call: concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}-dialect cancel-in-progress: true env: ATLAS_NO_UPGRADE_SUGGESTIONS: 1 jobs: integration-mysql56: runs-on: ubuntu-latest services: mysql56: image: mysql:5.6.35 env: MYSQL_DATABASE: test MYSQL_ROOT_PASSWORD: pass ports: - 3306:3306 options: >- --health-cmd "mysqladmin ping -ppass" --health-interval 10s --health-start-period 10s --health-timeout 5s --health-retries 10 steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version-file: cmd/atlas/go.mod - name: Run integration tests for mysql56 working-directory: internal/integration run: go test -race -count=2 -v -run="MySQL" -version="mysql56" -timeout 15m ./... env: MYSQL_DATABASE: test MYSQL_ROOT_PASSWORD: pass integration-mysql57: runs-on: ubuntu-latest services: mysql57: image: mysql:5.7.26 env: MYSQL_DATABASE: test MYSQL_ROOT_PASSWORD: pass ports: - 3307:3306 options: >- --health-cmd "mysqladmin ping -ppass" --health-interval 10s --health-start-period 10s --health-timeout 5s --health-retries 10 steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version-file: cmd/atlas/go.mod - name: Run integration tests for mysql57 working-directory: internal/integration run: go test -race -count=2 -v -run="MySQL" -version="mysql57" -timeout 15m ./... env: MYSQL_DATABASE: test MYSQL_ROOT_PASSWORD: pass integration-mysql8: runs-on: ubuntu-latest services: mysql8: image: mysql:8 env: MYSQL_DATABASE: test MYSQL_ROOT_PASSWORD: pass ports: - 3308:3306 options: >- --health-cmd "mysqladmin ping -ppass" --health-interval 10s --health-start-period 10s --health-timeout 5s --health-retries 10 steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version-file: cmd/atlas/go.mod - name: Run integration tests for mysql8 working-directory: internal/integration run: go test -race -count=2 -v -run="MySQL" -version="mysql8" -timeout 15m ./... env: MYSQL_DATABASE: test MYSQL_ROOT_PASSWORD: pass integration-maria107: runs-on: ubuntu-latest services: maria107: image: mariadb:10.7 env: MYSQL_DATABASE: test MYSQL_ROOT_PASSWORD: pass ports: - 4306:3306 options: >- --health-cmd "mysqladmin ping -ppass" --health-interval 10s --health-start-period 10s --health-timeout 5s --health-retries 10 steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version-file: cmd/atlas/go.mod - name: Run integration tests for maria107 working-directory: internal/integration run: go test -race -count=2 -v -run="MySQL" -version="maria107" -timeout 15m ./... env: MYSQL_DATABASE: test MYSQL_ROOT_PASSWORD: pass integration-maria102: runs-on: ubuntu-latest services: maria102: image: mariadb:10.2.32 env: MYSQL_DATABASE: test MYSQL_ROOT_PASSWORD: pass ports: - 4307:3306 options: >- --health-cmd "mysqladmin ping -ppass" --health-interval 10s --health-start-period 10s --health-timeout 5s --health-retries 10 steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version-file: cmd/atlas/go.mod - name: Run integration tests for maria102 working-directory: internal/integration run: go test -race -count=2 -v -run="MySQL" -version="maria102" -timeout 15m ./... env: MYSQL_DATABASE: test MYSQL_ROOT_PASSWORD: pass integration-maria103: runs-on: ubuntu-latest services: maria103: image: mariadb:10.3.13 env: MYSQL_DATABASE: test MYSQL_ROOT_PASSWORD: pass ports: - 4308:3306 options: >- --health-cmd "mysqladmin ping -ppass" --health-interval 10s --health-start-period 10s --health-timeout 5s --health-retries 10 steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version-file: cmd/atlas/go.mod - name: Run integration tests for maria103 working-directory: internal/integration run: go test -race -count=2 -v -run="MySQL" -version="maria103" -timeout 15m ./... env: MYSQL_DATABASE: test MYSQL_ROOT_PASSWORD: pass integration-postgres-ext-postgis: runs-on: ubuntu-latest services: postgres-ext-postgis: image: postgis/postgis:latest env: POSTGRES_DB: test POSTGRES_PASSWORD: pass ports: - 5429:5432 options: >- --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version-file: cmd/atlas/go.mod - name: Run integration tests for postgres-ext-postgis working-directory: internal/integration run: go test -race -count=2 -v -run="Postgres" -version="postgres-ext-postgis" -timeout 15m ./... env: POSTGRES_DB: test POSTGRES_PASSWORD: pass integration-postgres10: runs-on: ubuntu-latest services: postgres10: image: postgres:10 env: POSTGRES_DB: test POSTGRES_PASSWORD: pass ports: - 5430:5432 options: >- --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version-file: cmd/atlas/go.mod - name: Run integration tests for postgres10 working-directory: internal/integration run: go test -race -count=2 -v -run="Postgres" -version="postgres10" -timeout 15m ./... env: POSTGRES_DB: test POSTGRES_PASSWORD: pass integration-postgres11: runs-on: ubuntu-latest services: postgres11: image: postgres:11 env: POSTGRES_DB: test POSTGRES_PASSWORD: pass ports: - 5431:5432 options: >- --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version-file: cmd/atlas/go.mod - name: Run integration tests for postgres11 working-directory: internal/integration run: go test -race -count=2 -v -run="Postgres" -version="postgres11" -timeout 15m ./... env: POSTGRES_DB: test POSTGRES_PASSWORD: pass integration-postgres12: runs-on: ubuntu-latest services: postgres12: image: postgres:12.3 env: POSTGRES_DB: test POSTGRES_PASSWORD: pass ports: - 5432:5432 options: >- --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version-file: cmd/atlas/go.mod - name: Run integration tests for postgres12 working-directory: internal/integration run: go test -race -count=2 -v -run="Postgres" -version="postgres12" -timeout 15m ./... env: POSTGRES_DB: test POSTGRES_PASSWORD: pass integration-postgres13: runs-on: ubuntu-latest services: postgres13: image: postgres:13.1 env: POSTGRES_DB: test POSTGRES_PASSWORD: pass ports: - 5433:5432 options: >- --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version-file: cmd/atlas/go.mod - name: Run integration tests for postgres13 working-directory: internal/integration run: go test -race -count=2 -v -run="Postgres" -version="postgres13" -timeout 15m ./... env: POSTGRES_DB: test POSTGRES_PASSWORD: pass integration-postgres14: runs-on: ubuntu-latest services: postgres14: image: postgres:14 env: POSTGRES_DB: test POSTGRES_PASSWORD: pass ports: - 5434:5432 options: >- --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version-file: cmd/atlas/go.mod - name: Run integration tests for postgres14 working-directory: internal/integration run: go test -race -count=2 -v -run="Postgres" -version="postgres14" -timeout 15m ./... env: POSTGRES_DB: test POSTGRES_PASSWORD: pass integration-postgres15: runs-on: ubuntu-latest services: postgres15: image: postgres:15 env: POSTGRES_DB: test POSTGRES_PASSWORD: pass ports: - 5435:5432 options: >- --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version-file: cmd/atlas/go.mod - name: Run integration tests for postgres15 working-directory: internal/integration run: go test -race -count=2 -v -run="Postgres" -version="postgres15" -timeout 15m ./... env: POSTGRES_DB: test POSTGRES_PASSWORD: pass integration-sqlite: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version-file: cmd/atlas/go.mod - name: Run integration tests for sqlite working-directory: internal/integration run: go test -race -count=2 -v -run="SQLite.*" -version="sqlite" -timeout 15m ./... integration-tidb5: runs-on: ubuntu-latest services: tidb5: image: pingcap/tidb:v5.4.0 ports: - 4309:4000 steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version-file: cmd/atlas/go.mod - name: Run integration tests for tidb5 working-directory: internal/integration run: go test -race -count=2 -v -run="TiDB" -version="tidb5" -timeout 15m ./... integration-tidb6: runs-on: ubuntu-latest services: tidb6: image: pingcap/tidb:v6.0.0 ports: - 4310:4000 steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version-file: cmd/atlas/go.mod - name: Run integration tests for tidb6 working-directory: internal/integration run: go test -race -count=2 -v -run="TiDB" -version="tidb6" -timeout 15m ./... integration-cockroach: runs-on: ubuntu-latest services: cockroach: image: ghcr.io/ariga/cockroachdb-single-node:v21.2.11 ports: - 26257:26257 steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version-file: cmd/atlas/go.mod - name: Run integration tests for cockroach working-directory: internal/integration run: go test -race -count=2 -v -run="Cockroach" -version="cockroach" -timeout 15m ./... ================================================ FILE: .github/workflows/ci-go_oss.yaml ================================================ # # # # # # # # # # # # # # # # # CODE GENERATED - DO NOT EDIT # # # # # # # # # # # # # # # # name: CI - General - Community Edition on: pull_request: paths-ignore: - 'doc/**' - 'ops/**' push: branches: - master paths-ignore: - 'doc/**' - 'ops/**' concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} cancel-in-progress: true env: ATLAS_NO_UPGRADE_SUGGESTIONS: 1 jobs: lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version-file: cmd/atlas/go.mod - name: Run linters uses: golangci/golangci-lint-action@v6 with: args: --verbose generate-cmp: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version-file: cmd/atlas/go.mod - name: Install stringer run: go install golang.org/x/tools/cmd/stringer@latest - name: run "go generate ./..." run: go generate ./... - name: go generate cmd/atlas working-directory: cmd/atlas run: go generate ./... - name: Verify generated files are checked-in properly run: | status=$(git status --porcelain | grep -v "go.\(sum\|mod\)" | cat) if [ -n "$status" ]; then echo "you need to run 'go generate ./...' and commit the changes" echo "$status" exit 1 fi unit: runs-on: ubuntu-latest strategy: matrix: go: [ "1.22" ] steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version: ${{ matrix.go }} - name: Run sql tests run: go test -race ./... working-directory: sql - name: Run schemahcl tests run: go test -race ./... working-directory: schemahcl cli: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version-file: cmd/atlas/go.mod - name: Run cli tests run: go test -race ./... working-directory: cmd/atlas integration: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version-file: cmd/atlas/go.mod - name: Run integration tests for HCL working-directory: internal/integration/hclsqlspec run: go test -race -count=2 -v ./... dialect-integration: needs: [lint, generate-cmp, unit, cli, integration] uses: ./.github/workflows/ci-dialect_oss.yaml secrets: inherit ================================================ FILE: .github/workflows/ci-revisions_oss.yaml ================================================ # # # # # # # # # # # # # # # # # CODE GENERATED - DO NOT EDIT # # # # # # # # # # # # # # # # name: CI - Revisions - Community Edition on: pull_request: paths: - 'cmd/atlas/internal/migrate/ent/**' push: branches: - master paths: - 'cmd/atlas/internal/migrate/ent/**' concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} cancel-in-progress: true jobs: revisions: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - uses: actions/setup-go@v5 with: go-version-file: cmd/atlas/go.mod - name: Checkout origin/master run: git checkout origin/master - name: Create revisions from master run: go run . migrate apply --dir file://internal/cmdapi/testdata/sqlite --url sqlite://db?_fk=1 working-directory: cmd/atlas - name: Checkout previous HEAD run: git checkout - - name: Migrate revisions table to HEAD run: go run . migrate apply --dir file://internal/cmdapi/testdata/sqlite --url sqlite://db?_fk=1 working-directory: cmd/atlas ================================================ FILE: .github/workflows/ci-sdk.yml ================================================ name: Go SDK CI on: push: branches: - master pull_request: jobs: golangci-lint: runs-on: ubuntu-latest strategy: matrix: module: ["atlasexec", "sdk"] steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version-file: ./go.mod - name: Run Go linters uses: golangci/golangci-lint-action@v6 with: working-directory: ${{ matrix.module }} args: --verbose --timeout 15m unit-tests: runs-on: ubuntu-latest strategy: matrix: module: ["atlasexec", "sdk"] steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version-file: ./go.mod - uses: ariga/setup-atlas@v0 with: cloud-token: ${{ secrets.ATLAS_TOKEN }} env: ATLAS_DEBUG: "true" - name: Run tests run: go test -race ./... working-directory: ${{ matrix.module }} e2e-tests: runs-on: ubuntu-latest services: postgres: image: postgres env: POSTGRES_PASSWORD: pass ports: - 5432:5432 options: >- --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version-file: ./go.mod - uses: ariga/setup-atlas@v0 with: cloud-token: ${{ secrets.ATLAS_TOKEN }} env: ATLAS_DEBUG: "true" - name: Run e2e tests run: go test ./internal/e2e working-directory: atlasexec env: ATLASEXEC_E2ETEST: "1" ATLASEXEC_E2ETEST_ATLAS_PATH: atlas ATLASEXEC_E2ETEST_POSTGRES_URL: "postgres://postgres:pass@localhost:5432/postgres?search_path=public&sslmode=disable" ================================================ FILE: .golangci.yml ================================================ run: timeout: 3m issues: include: - EXC0012 exclude: - G601 - G404 - redefines-builtin-id exclude-rules: - path: _test\.go linters: - gosec - path: sql/migrate/dir.go linters: - gosec - path: sql/mysql/inspect_oss.go linters: - gosec - path: sql/migrate/lex.go linters: - revive - path: sql/internal/sqlx/diff.go linters: - revive linters-settings: goheader: template: |- Copyright 2021-present The Atlas Authors. All rights reserved. This source code is licensed under the Apache 2.0 license found in the LICENSE file in the root directory of this source tree. linters: disable-all: true enable: - gosec - revive - goheader ================================================ FILE: LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: README.md ================================================ # Atlas - Manage Your Database Schema as Code [![Twitter](https://img.shields.io/twitter/url.svg?label=Follow%20%40atlasgo_io&style=social&url=https%3A%2F%2Ftwitter.com%2Fatlasgo_io)](https://twitter.com/atlasgo_io) [![Discord](https://img.shields.io/discord/930720389120794674?label=discord&logo=discord&style=flat-square&logoColor=white)](https://discord.com/invite/zZ6sWVg6NT)

Atlas banner

Atlas is a language-agnostic tool for managing and migrating database schemas using modern DevOps principles. It offers two workflows: - **Declarative**: Similar to Terraform, Atlas compares the current state of the database to the desired state, as defined in an [HCL], [SQL], or [ORM] schema. Based on this comparison, it generates and executes a migration plan to transition the database to its desired state. - **Versioned**: Unlike other tools, Atlas automatically plans schema migrations for you. Users can describe their desired database schema in [HCL], [SQL], or their chosen [ORM], and by utilizing Atlas, they can plan, lint, and apply the necessary migrations to the database. ## Supported Databases [PostgreSQL](https://atlasgo.io/guides/postgres) · [MySQL](https://atlasgo.io/guides/mysql) · [MariaDB](https://atlasgo.io/guides/mysql) · [SQL Server](https://atlasgo.io/guides/mssql) · [SQLite](https://atlasgo.io/guides/sqlite) · [ClickHouse](https://atlasgo.io/guides/clickhouse) · [Redshift](https://atlasgo.io/guides/redshift) · [Oracle](https://atlasgo.io/guides/oracle) · [Snowflake](https://atlasgo.io/guides/snowflake) · [CockroachDB](https://atlasgo.io/guides/cockroachdb) · [TiDB](https://atlasgo.io/guides/mysql) · [Databricks](https://atlasgo.io/guides/databricks) · [Spanner](https://atlasgo.io/guides/spanner) · [Aurora DSQL](https://atlasgo.io/guides/dsql) · [Azure Fabric](https://atlasgo.io/guides/azure-fabric) ## Installation **macOS + Linux:** ```bash curl -sSf https://atlasgo.sh | sh ``` **Homebrew:** ```bash brew install ariga/tap/atlas ``` **Docker:** ```bash docker pull arigaio/atlas ``` **NPM:** ```bash npx @ariga/atlas ``` See [installation docs](https://atlasgo.io/getting-started#installation) for all platforms. ## Key Features - **[Declarative schema migrations](https://atlasgo.io/declarative/apply)**: The `atlas schema` command offers various options for [inspecting](https://atlasgo.io/inspect), diffing, comparing, [planning](https://atlasgo.io/declarative/plan) and applying migrations using standard Terraform-like workflows. - **[Versioned migrations](https://atlasgo.io/versioned/intro)**: The `atlas migrate` command provides a state-of-the-art experience for [planning](https://atlasgo.io/versioned/diff), [linting](https://atlasgo.io/lint/analyzers), and [applying](https://atlasgo.io/versioned/apply) migrations. - **[Schema as Code](https://atlasgo.io/atlas-schema)**: Define your desired database schema using [SQL], [HCL], or your chosen [ORM]. Atlas supports [16 ORM loaders](https://atlasgo.io/orms) across 6 languages. - **[Security-as-Code](https://atlasgo.io/guides/postgres/security-declarative)**: Manage roles, permissions, and [row-level security](https://atlasgo.io/guides/postgres/row-level-security) policies as version-controlled code. - **[Data management](https://atlasgo.io/atlas-schema/sql)**: Manage seed and lookup data declaratively alongside your schema. - **[Cloud-native CI/CD](https://atlasgo.io/integrations)**: [Kubernetes operator](https://atlasgo.io/integrations/kubernetes), [Terraform provider](https://atlasgo.io/integrations/terraform), [GitHub Actions](https://atlasgo.io/integrations/github-actions), [GitLab CI](https://atlasgo.io/integrations/gitlab), [ArgoCD](https://atlasgo.io/integrations/kubernetes/argocd), and more. - **[Testing framework](https://atlasgo.io/testing/schema)**: Unit test schema logic (functions, views, triggers, procedures) and [migration behavior](https://atlasgo.io/testing/migrate). - **[50+ safety analyzers](https://atlasgo.io/lint/analyzers)**: Database-aware migration linting that detects destructive changes, data-dependent modifications, table locks, backward-incompatible changes, and more. - **[Multi-tenancy](https://atlasgo.io/guides/multi-tenancy)**: Built-in support for multi-tenant database migrations. - **[Drift detection](https://atlasgo.io/monitoring)**: Monitoring as Code with automatic schema drift detection and remediation. - **[Cloud integration](https://atlasgo.io/guides/deploying/secrets)**: IAM-based authentication for [AWS RDS](https://atlasgo.io/guides/deploying/secrets#aws-rds-iam-authentication) and [GCP Cloud SQL](https://atlasgo.io/guides/deploying/secrets#gcp-cloudsql-iam-authentication), secrets management via [AWS Secrets Manager](https://atlasgo.io/guides/deploying/secrets#aws-secrets-manager), [GCP Secret Manager](https://atlasgo.io/guides/deploying/secrets#gcp-secret-manager), [HashiCorp Vault](https://atlasgo.io/guides/deploying/secrets#hashicorp-vault), and more. ## Getting Started Get started with Atlas by following the [Getting Started](https://atlasgo.io/getting-started/) docs. Inspect an existing database schema: ```shell atlas schema inspect -u "postgres://localhost:5432/mydb" ``` Apply your desired schema to the database: ```shell atlas schema apply \ --url "postgres://localhost:5432/mydb" \ --to file://schema.hcl \ --dev-url "docker://postgres/16/dev" ``` 📖 [Getting Started docs](https://atlasgo.io/getting-started/) ## Migration Linting Atlas ships with 50+ built-in [analyzers](https://atlasgo.io/lint/analyzers) that review your migration files and catch issues before they reach production. Analyzers detect [destructive changes](https://atlasgo.io/lint/analyzers#destructive-changes) like dropped tables or columns, [data-dependent modifications](https://atlasgo.io/lint/analyzers#data-dependent-changes) such as adding non-nullable columns without defaults, and database-specific risks like table locks and table rewrites that can cause downtime on busy tables. You can also define your own [custom policy rules](https://atlasgo.io/lint/rules). ```bash atlas migrate lint --dev-url "docker://postgres/16/dev" ``` ## Schema Testing [Test](https://atlasgo.io/testing/schema) database logic (functions, views, triggers, procedures) and [data migrations](https://atlasgo.io/testing/migrate) with `.test.hcl` files: ```hcl test "schema" "postal" { # Valid postal codes pass exec { sql = "SELECT '12345'::us_postal_code" } # Invalid postal codes fail catch { sql = "SELECT 'hello'::us_postal_code" } } test "schema" "seed" { for_each = [ {input: "hello", expected: "HELLO"}, {input: "world", expected: "WORLD"}, ] exec { sql = "SELECT upper('${each.value.input}')" output = each.value.expected } } ``` ```bash atlas schema test --dev-url "docker://postgres/16/dev" ``` 📖 [Testing docs](https://atlasgo.io/testing/schema) ## Security-as-Code Manage database [roles, permissions](https://atlasgo.io/guides/postgres/security-declarative), and [row-level security](https://atlasgo.io/guides/postgres/row-level-security) as version-controlled code: ```hcl role "app_readonly" { comment = "Read-only access for reporting" } role "app_writer" { comment = "Read-write access for the application" member_of = [role.app_readonly] } user "api_user" { password = var.api_password conn_limit = 20 comment = "Application API service account" member_of = [role.app_writer] } permission { for_each = [table.orders, table.products, table.users] for = each.value to = role.app_readonly privileges = [SELECT] } policy "tenant_isolation" { on = table.orders for = ALL to = ["app_writer"] using = "(tenant_id = current_setting('app.current_tenant')::integer)" check = "(tenant_id = current_setting('app.current_tenant')::integer)" } ``` 📖 [Security-as-Code docs](https://atlasgo.io/guides/postgres/security-declarative) ## Data Management Manage seed and lookup data declaratively alongside your schema: ```sql CREATE TABLE countries ( id INT PRIMARY KEY, code VARCHAR(2) NOT NULL, name VARCHAR(100) NOT NULL ); INSERT INTO countries (id, code, name) VALUES (1, 'US', 'United States'), (2, 'IL', 'Israel'), (3, 'DE', 'Germany'); ``` 📖 [Data management docs](https://atlasgo.io/atlas-schema/sql) ## ORM Support Define your schema in any of the 16 supported ORMs. Atlas reads your models and generates migrations: | Language | ORMs | |----------|------| | Go | [GORM](https://atlasgo.io/guides/orms/gorm), [Ent](https://atlasgo.io/guides/orms/ent), [Bun](https://atlasgo.io/guides/orms/bun), [Beego](https://atlasgo.io/guides/orms/beego), [sqlc](https://atlasgo.io/guides/frameworks/sqlc-versioned) | | TypeScript | [Prisma](https://atlasgo.io/guides/orms/prisma), [Drizzle](https://atlasgo.io/guides/orms/drizzle), [TypeORM](https://atlasgo.io/guides/orms/typeorm), [Sequelize](https://atlasgo.io/guides/orms/sequelize) | | Python | [Django](https://atlasgo.io/guides/orms/django), [SQLAlchemy](https://atlasgo.io/guides/orms/sqlalchemy) | | Java | [Hibernate](https://atlasgo.io/guides/orms/hibernate) | | .NET | [EF Core](https://atlasgo.io/guides/orms/efcore) | | PHP | [Doctrine](https://atlasgo.io/guides/orms/doctrine) | 📖 [ORM integration docs](https://atlasgo.io/orms) ## Integrations Lint, test, and apply migrations automatically in your CI/CD pipeline or infrastructure-as-code workflow: | Integration | Docs | |-------------|------| | GitHub Actions | [Versioned guide](https://atlasgo.io/guides/ci-platforms/github-versioned) · [Declarative guide](https://atlasgo.io/guides/ci-platforms/github-declarative) | | GitLab CI | [Versioned guide](https://atlasgo.io/guides/ci-platforms/gitlab-versioned) · [Declarative guide](https://atlasgo.io/guides/ci-platforms/gitlab-declarative) | | CircleCI | [Versioned guide](https://atlasgo.io/guides/ci-platforms/circleci-versioned) · [Declarative guide](https://atlasgo.io/guides/ci-platforms/circleci-declarative) | | Bitbucket Pipes | [Versioned guide](https://atlasgo.io/guides/ci-platforms/bitbucket-versioned) · [Declarative guide](https://atlasgo.io/guides/ci-platforms/bitbucket-declarative) | | Azure DevOps | [GitHub repos](https://atlasgo.io/guides/ci-platforms/azure-devops-github) · [Azure repos](https://atlasgo.io/guides/ci-platforms/azure-devops-repos) | | Terraform Provider | [atlasgo.io/integrations/terraform-provider](https://atlasgo.io/integrations/terraform-provider) | | Kubernetes Operator | [atlasgo.io/integrations/kubernetes](https://atlasgo.io/integrations/kubernetes) | | ArgoCD | [atlasgo.io/guides/deploying/k8s-argo](https://atlasgo.io/guides/deploying/k8s-argo) | | Flux | [atlasgo.io/guides/deploying/k8s-flux](https://atlasgo.io/guides/deploying/k8s-flux) | | Crossplane | [atlasgo.io/guides/deploying/crossplane](https://atlasgo.io/guides/deploying/crossplane) | | Go SDK | [pkg.go.dev/ariga.io/atlas-go-sdk/atlasexec](https://pkg.go.dev/ariga.io/atlas-go-sdk/atlasexec) | ### AI Agent Integration Atlas provides [Agent Skills](https://atlasgo.io/guides/ai-tools/agent-skills), an open standard for packaging migration expertise for AI coding assistants: [Claude Code](https://atlasgo.io/guides/ai-tools/claude-code-instructions), [GitHub Copilot](https://atlasgo.io/guides/ai-tools/github-copilot-instructions), [Cursor](https://atlasgo.io/guides/ai-tools/cursor-rules), [OpenAI Codex](https://atlasgo.io/guides/ai-tools/codex-instructions). Learn more at [AI tools docs](https://atlasgo.io/guides/ai-tools). ## CLI Usage ### `schema inspect` _**Easily inspect your database schema by providing a database URL and convert it to HCL, JSON, SQL, ERD, or other formats.**_ Inspect a specific MySQL schema and get its representation in Atlas DDL syntax: ```shell atlas schema inspect -u "mysql://root:pass@localhost:3306/example" > schema.hcl ```
Result ```hcl table "users" { schema = schema.example column "id" { null = false type = int } ... } ```
Inspect the entire MySQL database and get its JSON representation: ```shell atlas schema inspect \ --url "mysql://root:pass@localhost:3306/" \ --format '{{ json . }}' | jq ```
Result ```json { "schemas": [ { "name": "example", "tables": [ { "name": "users", "columns": [ ... ] } ] } ] } ```
Inspect a specific PostgreSQL schema and get its ERD representation in Mermaid syntax: ```shell atlas schema inspect \ --url "postgres://root:pass@:5432/test?search_path=public&sslmode=disable" \ --format '{{ mermaid . }}' ``` ```mermaid erDiagram users { int id PK varchar name } blog_posts { int id PK varchar title text body int author_id FK } blog_posts }o--o| users : author_fk ``` Use the [split format](https://atlasgo.io/inspect/database-to-code) for one-file-per-object output: ```bash atlas schema inspect -u '' --format '{{ sql . | split | write }}' ``` ``` ├── schemas │ └── public │ ├── public.sql │ ├── tables │ │ ├── profiles.sql │ │ └── users.sql │ ├── functions │ └── types └── main.sql ``` 📖 [Schema inspection docs](https://atlasgo.io/inspect) ### `schema diff` _**Compare two schema states and get a migration plan to transform one into the other. A state can be specified using a database URL, HCL, SQL, or ORM schema, or a migration directory.**_ ```shell atlas schema diff \ --from "postgres://postgres:pass@:5432/test?search_path=public&sslmode=disable" \ --to file://schema.hcl \ --dev-url "docker://postgres/15/test" ``` 📖 [Declarative workflow docs](https://atlasgo.io/declarative/apply) ### `schema apply` _**Generate a migration plan and apply it to the database to bring it to the desired state. The desired state can be specified using a database URL, HCL, SQL, or ORM schema, or a migration directory.**_ ```shell atlas schema apply \ --url mysql://root:pass@:3306/db1 \ --to file://schema.hcl \ --dev-url docker://mysql/8/db1 ```
Result ```shell -- Planned Changes: -- Modify "users" table ALTER TABLE `db1`.`users` DROP COLUMN `d`, ADD COLUMN `c` int NOT NULL; Use the arrow keys to navigate: ↓ ↑ → ← ? Are you sure?: ▸ Apply Abort ```
📖 [Declarative workflow docs](https://atlasgo.io/declarative/apply) ### `migrate diff` _**Write a new migration file to the migration directory that brings it to the desired state. The desired state can be specified using a database URL, HCL, SQL, or ORM schema, or a migration directory.**_ ```shell atlas migrate diff add_blog_posts \ --dir file://migrations \ --to file://schema.hcl \ --dev-url docker://mysql/8/test ``` 📖 [Versioned workflow docs](https://atlasgo.io/versioned/diff) ### `migrate apply` _**Apply all or part of pending migration files in the migration directory on the database.**_ ```shell atlas migrate apply \ --url mysql://root:pass@:3306/db1 \ --dir file://migrations ``` 📖 [Versioned workflow docs](https://atlasgo.io/versioned/apply) ## Supported Version Policy To ensure the best performance, security and compatibility with the Atlas Cloud service, the Atlas team will only support the two most recent minor versions of the CLI. For example, if the latest version is `v0.25`, the supported versions will be `v0.24` and `v0.25` (in addition to any patch releases and the "canary" release which is built twice a day). ## Community [Documentation](https://atlasgo.io/getting-started) · [Discord](https://discord.com/invite/zZ6sWVg6NT) · [Twitter](https://twitter.com/atlasgo_io) [HCL]: https://atlasgo.io/atlas-schema/hcl [SQL]: https://atlasgo.io/atlas-schema/sql [ORM]: https://atlasgo.io/orms ================================================ FILE: atlasexec/README.md ================================================ # Atlas SDK for Go [![Go Reference](https://pkg.go.dev/badge/ariga.io/atlas-go-sdk/atlasexec.svg)](https://pkg.go.dev/ariga.io/atlas@master/atlasexec) An SDK for building ariga/atlas providers in Go. ## Installation ```bash go get -u ariga.io/atlas@master ``` ## How to use To use the SDK, you need to create a new client with your `migrations` folder and the `atlas` binary path. ```go package main import ( ... "ariga.io/atlas/atlasexec" ) func main() { // Create a new client client, err := atlasexec.NewClient("my-migration-folder", "my-atlas-cli-path") if err != nil { log.Fatalf("failed to create client: %v", err) } } ``` ## APIs For more information, refer to the documentation available at [GoDoc](https://pkg.go.dev/ariga.io/atlas@master/atlasexec#Client) ================================================ FILE: atlasexec/atlas.go ================================================ // Copyright 2021-present The Atlas Authors. All rights reserved. // This source code is licensed under the Apache 2.0 license found // in the LICENSE file in the root directory of this source tree. package atlasexec import ( "bufio" "bytes" "context" "encoding/json" "errors" "fmt" "io" "maps" "os" "os/exec" "reflect" "regexp" "slices" "strings" "sync" ) type ( // Client is a client for the Atlas CLI. Client struct { execPath string workingDir string env Environ stdout io.Writer stderr io.Writer } // LoginParams are the parameters for the `login` command. LoginParams struct { Token string GrantOnly bool // If true, runs `atlas login --grant-only` for offline token flow. } // WhoAmIParams are the parameters for the `whoami` command WhoAmIParams struct { ConfigURL string Env string Vars VarArgs } // WhoAmI contains the result of an 'atlas whoami' run. WhoAmI struct { Org string `json:"Org,omitempty"` } // Version contains the result of an 'atlas version' run. Version struct { Version string `json:"Version"` SHA string `json:"SHA,omitempty"` Canary bool `json:"Canary,omitempty"` } // VarArgs is a map of variables for the command. VarArgs interface { // AsArgs returns the variables as arguments. AsArgs() []string } // Vars2 is a map of variables for the command. // It supports multiple values for the same key (list). Vars2 map[string]any // Environ is a map of environment variables. Environ map[string]string // RunContext is an input type for describing the context of where the // command is triggered from. For example, a GitHub Action on the master branch. RunContext struct { Repo string `json:"repo,omitempty"` Path string `json:"path,omitempty"` Branch string `json:"branch,omitempty"` Commit string `json:"commit,omitempty"` URL string `json:"url,omitempty"` Username string `json:"username,omitempty"` // The username that triggered the event that initiated the command. UserID string `json:"userID,omitempty"` // The user ID that triggered the event that initiated the command. SCMType SCMType `json:"scmType,omitempty"` // Source control management system type. } // SCMType is a type for the "scm_type" enum field. SCMType string // DeployRunContext is an input type for describing the context in which // `migrate-apply` and `migrate down` were used. For example, a GitHub Action with version v1.2.3 DeployRunContext struct { TriggerType TriggerType `json:"triggerType,omitempty"` TriggerVersion string `json:"triggerVersion,omitempty"` } // TriggerType defines the type for the "trigger_type" enum field. TriggerType string // Vars is a map of variables for the command. // // Deprecated: Use Vars2 instead. Vars map[string]string ) // TriggerType values. const ( TriggerTypeCLI TriggerType = "CLI" TriggerTypeKubernetes TriggerType = "KUBERNETES" TriggerTypeTerraform TriggerType = "TERRAFORM" TriggerTypeGithubAction TriggerType = "GITHUB_ACTION" TriggerTypeCircleCIOrb TriggerType = "CIRCLECI_ORB" TriggerTypeGitlab TriggerType = "GITLAB" TriggerTypeBitbucket TriggerType = "BITBUCKET" TriggerTypeAzureDevOps TriggerType = "AZURE_DEVOPS" ) // SCMType values. const ( SCMTypeGithub SCMType = "GITHUB" SCMTypeGitlab SCMType = "GITLAB" SCMTypeBitbucket SCMType = "BITBUCKET" SCMTypeAzureDevOps SCMType = "AZURE_DEVOPS" ) // ExecutionOrder values. const ( ExecOrderLinear MigrateExecOrder = "linear" // Default ExecOrderLinearSkip MigrateExecOrder = "linear-skip" ExecOrderNonLinear MigrateExecOrder = "non-linear" ) // NewClient returns a new Atlas client with the given atlas-cli path. func NewClient(workingDir, execPath string) (_ *Client, err error) { if execPath == "" { return nil, fmt.Errorf("execPath cannot be empty") } else if execPath, err = exec.LookPath(execPath); err != nil { return nil, fmt.Errorf("looking up atlas-cli: %w", err) } if workingDir != "" { _, err := os.Stat(workingDir) if err != nil { return nil, fmt.Errorf("initializing Atlas with working dir %q: %w", workingDir, err) } } return &Client{ execPath: execPath, workingDir: workingDir, }, nil } // WithWorkDir creates a new client with the given working directory. // It is useful to run multiple commands in the multiple directories. // // Example: // // client := atlasexec.NewClient("", "atlas") // err := client.WithWorkDir("dir1", func(c *atlasexec.Client) error { // _, err := c.MigrateApply(ctx, &atlasexec.MigrateApplyParams{ // }) // return err // }) func (c *Client) WithWorkDir(dir string, fn func(*Client) error) error { wd := c.workingDir defer func() { c.workingDir = wd }() c.workingDir = dir return fn(c) } // SetEnv allows we override the environment variables for the atlas-cli. // To append new environment variables to environment from OS, use NewOSEnviron() then add new variables. func (c *Client) SetEnv(env map[string]string) error { for k := range env { if _, ok := defaultEnvs[k]; ok { return fmt.Errorf("atlasexec: cannot override the default environment variable %q", k) } } c.env = env return nil } // SetStdout specifies a writer to stream stdout to for every command. func (c *Client) SetStdout(w io.Writer) { c.stdout = w } // SetStderr specifies a writer to stream stderr to for every command. func (c *Client) SetStderr(w io.Writer) { c.stderr = w } // Login runs the 'login' command. func (c *Client) Login(ctx context.Context, params *LoginParams) error { if params.Token == "" { return errors.New("token cannot be empty") } args := []string{"login", "--token", params.Token} if params.GrantOnly { args = append(args, "--grant-only") } _, err := c.runCommand(ctx, args) return err } // Logout runs the 'logout' command. func (c *Client) Logout(ctx context.Context) error { _, err := c.runCommand(ctx, []string{"logout"}) return err } // WhoAmI runs the 'whoami' command. func (c *Client) WhoAmI(ctx context.Context, params *WhoAmIParams) (*WhoAmI, error) { args := []string{"whoami", "--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()...) } return firstResult(jsonDecode[WhoAmI](c.runCommand(ctx, args))) } var reVersion = regexp.MustCompile(`^atlas version v(\d+\.\d+.\d+)-?([a-z0-9]*)?`) // Version runs the 'version' command. func (c *Client) Version(ctx context.Context) (*Version, error) { r, err := c.runCommand(ctx, []string{"version"}) if err != nil { return nil, err } out, err := io.ReadAll(r) if err != nil { return nil, err } v := reVersion.FindSubmatch(out) if v == nil { return nil, errors.New("unexpected output format") } var sha string if len(v) > 2 { sha = string(v[2]) } return &Version{ Version: string(v[1]), SHA: sha, Canary: strings.Contains(string(out), "canary"), }, nil } // var reVersion = regexp.MustCompile(`^atlas version v(\d+\.\d+.\d+)-?([a-z0-9]*)?`) func (v Version) String() string { var b strings.Builder fmt.Fprintf(&b, "atlas version v%s", v.Version) if v.SHA != "" { fmt.Fprintf(&b, "-%s", v.SHA) } if v.Canary { b.WriteString("-canary") } return b.String() } // NewOSEnviron returns the current environment variables from the OS. func NewOSEnviron() Environ { env := map[string]string{} for _, ev := range os.Environ() { parts := strings.SplitN(ev, "=", 2) if len(parts) == 0 { continue } k := parts[0] v := "" if len(parts) == 2 { v = parts[1] } env[k] = v } return env } // ToSlice converts the environment variables to a slice. func (e Environ) ToSlice() []string { env := make([]string, 0, len(e)) for k, v := range e { env = append(env, k+"="+v) } // Ensure the order of the envs. slices.Sort(env) return env } var defaultEnvs = map[string]string{ // Disable the update notifier and upgrade suggestions. "ATLAS_NO_UPDATE_NOTIFIER": "1", "ATLAS_NO_UPGRADE_SUGGESTIONS": "1", } // ErrRequireLogin is returned when a command requires the user to be logged in. // It exists here to be shared between the different packages that require login. var ErrRequireLogin = errors.New("command requires 'atlas login'") // runCommand runs the given command and returns its output. func (c *Client) runCommand(ctx context.Context, args []string) (io.Reader, error) { var stdout, stderr bytes.Buffer cmd := c.cmd(ctx, args) cmd.Stdout = mergeWriters(&stdout, c.stdout) cmd.Stderr = mergeWriters(&stderr, c.stderr) if err := c.runErr(cmd.Run(), &stdout, &stderr); err != nil { return nil, err } return &stdout, nil } // Stream is an interface for reading a stream of items. type Stream[T any] interface { // Next reads the next item from the stream, making it available by calling Current. // It returns false if there are no more items and the stream is closed. Next() bool // Current returns the current item from the stream. Current() (T, error) // Err returns the error, if any, that occurred while reading the stream. Err() error } // runCommandStream runs the given command streams its output split by new-lines. func (c *Client) runCommandStream(ctx context.Context, args []string) (Stream[string], error) { cmd := c.cmd(ctx, args) var stderr bytes.Buffer cmd.Stderr = mergeWriters(&stderr, c.stderr) out, err := cmd.StdoutPipe() if err != nil { return nil, fmt.Errorf("creating stdout pipe: %w", err) } if err = cmd.Start(); err != nil { return nil, fmt.Errorf("starting command: %w", err) } var ( scan = bufio.NewScanner(out) buf = strings.Builder{} ch = make(chan string) s = &stream{ch: ch} stdout = mergeWriters(&buf, c.stdout) ) go func() { defer close(ch) for scan.Scan() { stdout.Write(scan.Bytes()) ch <- scan.Text() } s.lock.Lock() defer s.lock.Unlock() s.err = c.runErr(cmd.Wait(), &buf, &stderr) }() return s, nil } func (c *Client) cmd(ctx context.Context, args []string) *exec.Cmd { cmd := exec.CommandContext(ctx, c.execPath, args...) //nolint:gosec cmd.Dir = c.workingDir var env Environ if c.env == nil { // Initialize the environment variables from the OS. env = NewOSEnviron() } else { env = maps.Clone(c.env) } maps.Copy(env, defaultEnvs) cmd.Env = env.ToSlice() return cmd } func (c *Client) runErr(err error, stdout, stderr interface{ String() string }) error { if err == nil { return nil } e := strings.TrimSpace(stderr.String()) // Explicit check the stderr for the login error. if e == "Error: command requires 'atlas login'" { return ErrRequireLogin } return &Error{ err: err, Stderr: strings.TrimSpace(stderr.String()), Stdout: strings.TrimSpace(stdout.String()), } } type stream struct { ch chan string cur string err error lock sync.RWMutex } // Next advances the stream to the next item. func (s *stream) Next() bool { s.lock.RLock() if s.err != nil || s.ch == nil { s.lock.RUnlock() return false } s.lock.RUnlock() r, ok := <-s.ch if !ok { return false } s.cur = r return true } // Current returns the current item from the stream. func (s *stream) Current() (string, error) { s.lock.RLock() defer s.lock.RUnlock() if s.err != nil { return "", s.err } return s.cur, nil } // Err returns the error, if any, that occurred while reading the stream. func (s *stream) Err() error { s.lock.RLock() defer s.lock.RUnlock() return s.err } var _ Stream[string] = (*stream)(nil) func mergeWriters(writers ...io.Writer) io.Writer { var compact []io.Writer for _, w := range writers { if w != nil { compact = append(compact, w) } } switch len(compact) { case 1: return compact[0] case 0: return io.Discard default: return io.MultiWriter(compact...) } } // Error is an error returned by the atlasexec package, // when it executes the atlas-cli command. type Error struct { err error // The underlying error. Stdout string // Stdout of the command. Stderr string // Stderr of the command. } // Error implements the error interface. func (e *Error) Error() string { if e.Stderr != "" { return e.Stderr } return e.Stdout } // ExitCode returns the exit code of the command. // If the error is not an exec.ExitError, it returns 1. func (e *Error) ExitCode() int { var exitErr *exec.ExitError if errors.As(e.err, &exitErr) { return exitErr.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)) for k := range v { keys = append(keys, k) } slices.Sort(keys) var args []string for _, k := range keys { switch reflect.TypeOf(v[k]).Kind() { case reflect.Slice, reflect.Array: ev := reflect.ValueOf(v[k]) for i := range ev.Len() { args = append(args, "--var", fmt.Sprintf("%s=%v", k, ev.Index(i))) } default: args = append(args, "--var", fmt.Sprintf("%s=%v", k, v[k])) } } return args } // AsArgs returns the variables as arguments. func (v Vars) AsArgs() []string { var args []string for k, v := range v { args = append(args, "--var", fmt.Sprintf("%s=%s", k, v)) } return args } func stringVal(r io.Reader, err error) (string, error) { if err != nil { return "", err } s, err := io.ReadAll(r) if err != nil { return "", err } return string(s), nil } func jsonDecode[T any](r io.Reader, err error) ([]*T, error) { if err != nil { return nil, err } buf, err := io.ReadAll(r) if err != nil { return nil, err } var dst []*T dec := json.NewDecoder(bytes.NewReader(buf)) for { var m T switch err := dec.Decode(&m); err { case io.EOF: return dst, nil case nil: 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, 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.Stdout != "" { d, err := jsonDecode[T](strings.NewReader(cliErr.Stdout), nil) if err == nil { return nil, fn(d, cliErr.Stderr) } // If the error is not a JSON, return the original error. } return nil, err } return jsonDecode[T](r, err) } } // repeatFlag repeats the flag for each value. func repeatFlag(flag string, values []string) []string { if len(values) == 0 { return nil } out := make([]string, 0, len(values)*2) for _, v := range values { out = append(out, flag, v) } return out } func listString(args []string) string { return strings.Join(args, ",") } func firstResult[T ~[]E, E any](r T, err error) (e E, _ error) { switch { case err != nil: return e, err case len(r) == 1: return r[0], nil default: return e, errors.New("The command returned more than one result, use Slice function instead") } } func last[A ~[]E, E any](a A) (_ E) { if l := len(a); l > 0 { return a[l-1] } return } ================================================ FILE: atlasexec/atlas_internal_test.go ================================================ // Copyright 2021-present The Atlas Authors. All rights reserved. // This source code is licensed under the Apache 2.0 license found // in the LICENSE file in the root directory of this source tree. package atlasexec import ( "context" "io" "testing" "github.com/stretchr/testify/require" ) func TestEnv(t *testing.T) { // printenv is a simple command that prints all environment variables c, err := NewClient(t.TempDir(), "printenv") require.NoError(t, err) // Should not be able to override the default environment variable require.ErrorContains(t, c.SetEnv(map[string]string{ "FOO": "bar", "ATLAS_NO_UPDATE_NOTIFIER": "0", }), "cannot override the default environment variable") // Should be able to set new environment variables require.NoError(t, c.SetEnv(map[string]string{ "FOO": "bar", "BAZ": "qux", })) // Invoke the command and check the environment variables v, err := c.runCommand(context.Background(), nil) require.NoError(t, err) raw, err := io.ReadAll(v) require.NoError(t, err) require.Equal(t, `ATLAS_NO_UPDATE_NOTIFIER=1 ATLAS_NO_UPGRADE_SUGGESTIONS=1 BAZ=qux FOO=bar `, string(raw)) } ================================================ FILE: atlasexec/atlas_migrate.go ================================================ // Copyright 2021-present The Atlas Authors. All rights reserved. // This source code is licensed under the Apache 2.0 license found // in the LICENSE file in the root directory of this source tree. package atlasexec import ( "context" "encoding/json" "errors" "fmt" "io" "strconv" "strings" "time" ) type ( // MigrateApplyParams are the parameters for the `migrate apply` command. MigrateApplyParams struct { ConfigURL string Env string Vars VarArgs Context *DeployRunContext DirURL string URL string RevisionsSchema string BaselineVersion string TxMode string ExecOrder MigrateExecOrder Amount uint64 ToVersion string AllowDirty bool DryRun bool LockName string } // MigrateApply contains a summary of a migration applying attempt on a database. MigrateApply struct { Env Pending []File `json:"Pending,omitempty"` // Pending migration files Applied []*AppliedFile `json:"Applied,omitempty"` // Applied files Current string `json:"Current,omitempty"` // Current migration version Target string `json:"Target,omitempty"` // Target migration version Start time.Time End time.Time // Error is set even then, if it was not caused by a statement in a migration file, // but by Atlas, e.g. when committing or rolling back a transaction. Error string `json:"Error,omitempty"` } // MigrateApplyError is returned when an error occurred // 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 MigrateExecOrder string // MigrateDownParams are the parameters for the `migrate down` command. MigrateDownParams struct { ConfigURL string Env string Vars VarArgs Context *DeployRunContext DevURL string DirURL string URL string RevisionsSchema string Amount uint64 ToVersion string ToTag string // Not yet supported // DryRun bool // TxMode string } // MigrateDown contains a summary of a migration down attempt on a database. MigrateDown struct { Planned []File `json:"Planned,omitempty"` // Planned migration files Reverted []*RevertedFile `json:"Reverted,omitempty"` // Reverted files Current string `json:"Current,omitempty"` // Current migration version Target string `json:"Target,omitempty"` // Target migration version Total int `json:"Total,omitempty"` // Total number of migrations to revert Start time.Time End time.Time // URL and Status are set only when the migration is planned or executed in the cloud. URL string `json:"URL,omitempty"` Status string `json:"Status,omitempty"` // Error is set even then, if it was not caused by a statement in a migration file, // but by Atlas, e.g. when committing or rolling back a transaction. Error string `json:"Error,omitempty"` } // MigratePushParams are the parameters for the `migrate push` command. MigratePushParams struct { ConfigURL string Env string Vars VarArgs Context *RunContext DevURL string Name string Tag string DirURL string DirFormat string LockTimeout string } // MigrateLintParams are the parameters for the `migrate lint` command. MigrateLintParams struct { ConfigURL string Env string Vars VarArgs Context *RunContext Format string DevURL string GitBase string GitDir string DirURL string Latest uint64 Writer io.Writer Base string Web bool } // MigrateHashParams are the parameters for the `migrate hash` command. MigrateHashParams struct { ConfigURL string Env string Vars VarArgs DirURL string DirFormat string } // MigrateRebaseParams are the parameters for the `migrate rebase` command. MigrateRebaseParams struct { ConfigURL string Env string Vars VarArgs DirURL string Files []string } // MigrateTestParams are the parameters for the `migrate test` command. MigrateTestParams struct { ConfigURL string Env string Vars VarArgs Context *RunContext DevURL string DirURL string DirFormat string Run string RevisionsSchema string Paths []string } // MigrateStatusParams are the parameters for the `migrate status` command. MigrateStatusParams struct { ConfigURL string Env string Vars VarArgs DirURL string URL string RevisionsSchema string } // MigrateLsParams are the parameters for the `migrate ls` command. MigrateLsParams struct { ConfigURL string Env string Vars VarArgs DirURL string Short bool // -s: print only migration version (omit description and .sql suffix) Latest bool // -l: print only the latest migration file } // MigrateSetParams are the parameters for the `migrate set` command. MigrateSetParams struct { ConfigURL string Env string Vars VarArgs DirURL string URL string RevisionsSchema string Version string } // MigrateDiffParams are the parameters for the `migrate diff` command. MigrateDiffParams struct { ConfigURL string Env string Vars VarArgs Name string ToURL string DevURL string DirURL string DirFormat string Schema []string LockTimeout string Format string Qualifier string } // MigrateDiff contains the result of the `migrate diff` command. MigrateDiff struct { Files []File `json:"Files,omitempty"` // Generated migration files Dir string `json:"Dir,omitempty"` // Path to migration directory } // MigrateStatus contains a summary of the migration status of a database. MigrateStatus struct { Env Env `json:"Env,omitempty"` // Environment info. Available []File `json:"Available,omitempty"` // Available migration files Pending []File `json:"Pending,omitempty"` // Pending migration files Applied []*Revision `json:"Applied,omitempty"` // Applied migration files Current string `json:"Current,omitempty"` // Current migration version Next string `json:"Next,omitempty"` // Next migration version Count int `json:"Count,omitempty"` // Count of applied statements of the last revision Total int `json:"Total,omitempty"` // Total statements of the last migration Status string `json:"Status,omitempty"` // Status of migration (OK, PENDING) Error string `json:"Error,omitempty"` // Last Error that occurred SQL string `json:"SQL,omitempty"` // SQL that caused the last Error } ) // MigratePush runs the 'migrate push' command. func (c *Client) MigratePush(ctx context.Context, params *MigratePushParams) (string, error) { args := []string{"migrate", "push"} if params.DevURL != "" { args = append(args, "--dev-url", params.DevURL) } if params.DirURL != "" { args = append(args, "--dir", params.DirURL) } if params.DirFormat != "" { args = append(args, "--dir-format", params.DirFormat) } if params.LockTimeout != "" { args = append(args, "--lock-timeout", params.LockTimeout) } if params.Context != nil { buf, err := json.Marshal(params.Context) if err != nil { return "", err } args = append(args, "--context", string(buf)) } 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()...) } if params.Name == "" { return "", errors.New("directory name cannot be empty") } if params.Tag != "" { args = append(args, fmt.Sprintf("%s:%s", params.Name, params.Tag)) } else { args = append(args, params.Name) } resp, err := stringVal(c.runCommand(ctx, args)) return strings.TrimSpace(resp), err } // MigrateApply runs the 'migrate apply' command. func (c *Client) MigrateApply(ctx context.Context, params *MigrateApplyParams) (*MigrateApply, error) { return firstResult(c.MigrateApplySlice(ctx, params)) } // MigrateApplySlice runs the 'migrate apply' command for multiple targets. func (c *Client) MigrateApplySlice(ctx context.Context, params *MigrateApplyParams) ([]*MigrateApply, error) { args := []string{"migrate", "apply", "--format", "{{ json . }}"} if params.Env != "" { args = append(args, "--env", params.Env) } if params.ConfigURL != "" { args = append(args, "--config", params.ConfigURL) } if params.Context != nil { buf, err := json.Marshal(params.Context) if err != nil { return nil, err } args = append(args, "--context", string(buf)) } if params.URL != "" { args = append(args, "--url", params.URL) } if params.DirURL != "" { args = append(args, "--dir", params.DirURL) } if params.AllowDirty { args = append(args, "--allow-dirty") } if params.DryRun { args = append(args, "--dry-run") } if params.RevisionsSchema != "" { args = append(args, "--revisions-schema", params.RevisionsSchema) } if params.BaselineVersion != "" { args = append(args, "--baseline", params.BaselineVersion) } if params.TxMode != "" { args = append(args, "--tx-mode", params.TxMode) } if params.ExecOrder != "" { args = append(args, "--exec-order", string(params.ExecOrder)) } if params.ToVersion != "" { args = append(args, "--to-version", params.ToVersion) } if params.Amount > 0 { args = append(args, strconv.FormatUint(params.Amount, 10)) } if params.LockName != "" { args = append(args, "--lock-name", params.LockName) } if params.Vars != nil { args = append(args, params.Vars.AsArgs()...) } return jsonDecodeErr(newMigrateApplyError)(c.runCommand(ctx, args)) } // MigrateDown runs the 'migrate down' command. func (c *Client) MigrateDown(ctx context.Context, params *MigrateDownParams) (*MigrateDown, error) { args := []string{"migrate", "down", "--format", "{{ json . }}"} if params.Env != "" { args = append(args, "--env", params.Env) } if params.ConfigURL != "" { args = append(args, "--config", params.ConfigURL) } if params.DevURL != "" { args = append(args, "--dev-url", params.DevURL) } if params.Context != nil { buf, err := json.Marshal(params.Context) if err != nil { return nil, err } args = append(args, "--context", string(buf)) } if params.URL != "" { args = append(args, "--url", params.URL) } if params.DirURL != "" { args = append(args, "--dir", params.DirURL) } if params.RevisionsSchema != "" { args = append(args, "--revisions-schema", params.RevisionsSchema) } if params.ToVersion != "" { args = append(args, "--to-version", params.ToVersion) } if params.ToTag != "" { args = append(args, "--to-tag", params.ToTag) } if params.Amount > 0 { args = append(args, strconv.FormatUint(params.Amount, 10)) } if params.Vars != nil { args = append(args, params.Vars.AsArgs()...) } r, err := c.runCommand(ctx, args) if cliErr := (&Error{}); errors.As(err, &cliErr) && cliErr.Stderr == "" { r = strings.NewReader(cliErr.Stdout) err = nil } // NOTE: This command only support one result. return firstResult(jsonDecode[MigrateDown](r, err)) } // MigrateTest runs the 'migrate test' command. func (c *Client) MigrateTest(ctx context.Context, params *MigrateTestParams) (string, error) { args := []string{"migrate", "test"} if params.Env != "" { args = append(args, "--env", params.Env) } if params.ConfigURL != "" { args = append(args, "--config", params.ConfigURL) } if params.DirURL != "" { args = append(args, "--dir", params.DirURL) } if params.DirFormat != "" { args = append(args, "--dir-format", params.DirFormat) } if params.DevURL != "" { args = append(args, "--dev-url", params.DevURL) } if params.Context != nil { buf, err := json.Marshal(params.Context) if err != nil { return "", err } args = append(args, "--context", string(buf)) } if params.RevisionsSchema != "" { args = append(args, "--revisions-schema", params.RevisionsSchema) } if params.Run != "" { args = append(args, "--run", params.Run) } if params.Vars != nil { args = append(args, params.Vars.AsArgs()...) } if len(params.Paths) > 0 { args = append(args, params.Paths...) } return stringVal(c.runCommand(ctx, args)) } // MigrateStatus runs the 'migrate status' command. func (c *Client) MigrateStatus(ctx context.Context, params *MigrateStatusParams) (*MigrateStatus, error) { args := []string{"migrate", "status", "--format", "{{ json . }}"} if params.Env != "" { args = append(args, "--env", params.Env) } if params.ConfigURL != "" { args = append(args, "--config", params.ConfigURL) } if params.URL != "" { args = append(args, "--url", params.URL) } if params.DirURL != "" { args = append(args, "--dir", params.DirURL) } if params.RevisionsSchema != "" { args = append(args, "--revisions-schema", params.RevisionsSchema) } if params.Vars != nil { args = append(args, params.Vars.AsArgs()...) } // NOTE: This command only support one result. return firstResult(jsonDecode[MigrateStatus](c.runCommand(ctx, args))) } // MigrateLs runs the 'migrate ls' command and returns the listed migration file names (or versions when Short is true), one per line. func (c *Client) MigrateLs(ctx context.Context, params *MigrateLsParams) (string, error) { args := []string{"migrate", "ls"} if params.ConfigURL != "" { args = append(args, "--config", params.ConfigURL) } if params.Env != "" { args = append(args, "--env", params.Env) } if params.DirURL != "" { args = append(args, "--dir", params.DirURL) } if params.Vars != nil { args = append(args, params.Vars.AsArgs()...) } if params.Short { args = append(args, "--short") } if params.Latest { args = append(args, "--latest") } return stringVal(c.runCommand(ctx, args)) } // MigrateSet runs the 'migrate set' command. func (c *Client) MigrateSet(ctx context.Context, params *MigrateSetParams) error { args := []string{"migrate", "set"} if params.Env != "" { args = append(args, "--env", params.Env) } if params.ConfigURL != "" { args = append(args, "--config", params.ConfigURL) } if params.URL != "" { args = append(args, "--url", params.URL) } if params.DirURL != "" { args = append(args, "--dir", params.DirURL) } if params.RevisionsSchema != "" { args = append(args, "--revisions-schema", params.RevisionsSchema) } if params.Vars != nil { args = append(args, params.Vars.AsArgs()...) } if params.Version != "" { args = append(args, params.Version) } _, err := c.runCommand(ctx, args) return err } // MigrateDiff runs the 'migrate diff --dry-run' command and returns the generated migration files without changing the filesystem. // Requires atlas CLI to be logged in to the cloud. func (c *Client) MigrateDiff(ctx context.Context, params *MigrateDiffParams) (*MigrateDiff, error) { args := []string{"migrate", "diff", "--dry-run"} if params.Env != "" { args = append(args, "--env", params.Env) } if params.ConfigURL != "" { args = append(args, "--config", params.ConfigURL) } if params.ToURL != "" { args = append(args, "--to", params.ToURL) } if params.DevURL != "" { args = append(args, "--dev-url", params.DevURL) } if params.DirURL != "" { args = append(args, "--dir", params.DirURL) } if params.DirFormat != "" { args = append(args, "--dir-format", params.DirFormat) } if params.LockTimeout != "" { args = append(args, "--lock-timeout", params.LockTimeout) } if params.Qualifier != "" { args = append(args, "--qualifier", params.Qualifier) } if len(params.Schema) > 0 { args = append(args, "--schema", strings.Join(params.Schema, ",")) } if params.Format != "" { args = append(args, "--format", params.Format) } if params.Vars != nil { args = append(args, params.Vars.AsArgs()...) } if params.Name != "" { args = append(args, params.Name) } v, err := jsonDecode[MigrateDiff](c.runCommand(ctx, args)) var e *Error switch { // if jsonDecode returns an error, and stderr is empty, it means the migration is synced with the desired state. case errors.As(err, &e) && e.Stderr == "": return &MigrateDiff{}, nil case err != nil: return nil, err } return firstResult(v, nil) } // MigrateLint runs the 'migrate lint' command. func (c *Client) MigrateLint(ctx context.Context, params *MigrateLintParams) (*SummaryReport, error) { if params.Writer != nil || params.Web { return nil, errors.New("atlasexec: Writer or Web reporting are not supported with MigrateLint, use MigrateLintError") } args, err := params.AsArgs() if err != nil { return nil, err } r, err := c.runCommand(ctx, args) if cliErr := (&Error{}); errors.As(err, &cliErr) && cliErr.Stderr == "" { r = strings.NewReader(cliErr.Stdout) err = nil } // NOTE: This command only support one result. return firstResult(jsonDecode[SummaryReport](r, err)) } // MigrateHash runs the 'migrate hash' command. func (c *Client) MigrateHash(ctx context.Context, params *MigrateHashParams) error { args := []string{"migrate", "hash"} if params.Env != "" { args = append(args, "--env", params.Env) } if params.ConfigURL != "" { args = append(args, "--config", params.ConfigURL) } if params.Vars != nil { args = append(args, params.Vars.AsArgs()...) } if params.DirURL != "" { args = append(args, "--dir", params.DirURL) } if params.DirFormat != "" { args = append(args, "--dir-format", params.DirFormat) } _, err := c.runCommand(ctx, args) return err } // MigrateRebase runs the 'migrate rebase' command. func (c *Client) MigrateRebase(ctx context.Context, params *MigrateRebaseParams) error { args := []string{"migrate", "rebase"} if params.Env != "" { args = append(args, "--env", params.Env) } if params.ConfigURL != "" { args = append(args, "--config", params.ConfigURL) } if params.Vars != nil { args = append(args, params.Vars.AsArgs()...) } if params.DirURL != "" { args = append(args, "--dir", params.DirURL) } args = append(args, params.Files...) _, err := c.runCommand(ctx, args) return err } // MigrateLintError runs the 'migrate lint' command, the output is written to params.Writer and reports // if an error occurred. If the error is a setup error, a Error is returned. If the error is a lint error, // LintErr is returned. func (c *Client) MigrateLintError(ctx context.Context, params *MigrateLintParams) error { args, err := params.AsArgs() if err != nil { return err } r, err := c.runCommand(ctx, args) var ( cliErr *Error isCLI = errors.As(err, &cliErr) ) // Setup errors. if isCLI && cliErr.Stderr != "" { return cliErr } // Lint errors. if isCLI && cliErr.Stdout != "" { err = ErrLint r = strings.NewReader(cliErr.Stdout) } // Unknown errors. if err != nil && !isCLI { return err } if params.Writer != nil && r != nil { if _, ioErr := io.Copy(params.Writer, r); ioErr != nil { err = errors.Join(err, ioErr) } } return err } // AsArgs returns the parameters as arguments. func (p *MigrateLintParams) AsArgs() ([]string, error) { args := []string{"migrate", "lint"} if p.Web { args = append(args, "-w") } if p.Context != nil { buf, err := json.Marshal(p.Context) if err != nil { return nil, err } args = append(args, "--context", string(buf)) } if p.Env != "" { args = append(args, "--env", p.Env) } if p.ConfigURL != "" { args = append(args, "--config", p.ConfigURL) } if p.DevURL != "" { args = append(args, "--dev-url", p.DevURL) } if p.DirURL != "" { args = append(args, "--dir", p.DirURL) } if p.Base != "" { args = append(args, "--base", p.Base) } if p.Latest > 0 { args = append(args, "--latest", strconv.FormatUint(p.Latest, 10)) } if p.GitBase != "" { args = append(args, "--git-base", p.GitBase) } if p.GitDir != "" { args = append(args, "--git-dir", p.GitDir) } if p.Vars != nil { args = append(args, p.Vars.AsArgs()...) } format := "{{ json . }}" if p.Format != "" { format = p.Format } args = append(args, "--format", format) return args, nil } // Summary of the migration attempt. func (a *MigrateApply) Summary(ident string) string { var ( passedC, failedC int passedS, failedS int passedF, failedF int lines = make([]string, 0, 3) ) for _, f := range a.Applied { // For each check file, count the // number of failed assertions. for _, cf := range f.Checks { for _, s := range cf.Stmts { if s.Error != nil { failedC++ } else { passedC++ } } } passedS += len(f.Applied) if f.Error != nil { failedF++ // Last statement failed (not an assertion). if len(f.Checks) == 0 || f.Checks[len(f.Checks)-1].Error == nil { passedS-- failedS++ } } else { passedF++ } } // Execution time. lines = append(lines, a.End.Sub(a.Start).String()) // Executed files. switch { case passedF > 0 && failedF > 0: lines = append(lines, fmt.Sprintf("%d migration%s ok, %d with errors", passedF, plural(passedF), failedF)) case passedF > 0: lines = append(lines, fmt.Sprintf("%d migration%s", passedF, plural(passedF))) case failedF > 0: lines = append(lines, fmt.Sprintf("%d migration%s with errors", failedF, plural(failedF))) } // Executed checks. switch { case passedC > 0 && failedC > 0: lines = append(lines, fmt.Sprintf("%d check%s ok, %d failure%s", passedC, plural(passedC), failedC, plural(failedC))) case passedC > 0: lines = append(lines, fmt.Sprintf("%d check%s", passedC, plural(passedC))) case failedC > 0: lines = append(lines, fmt.Sprintf("%d check error%s", failedC, plural(failedC))) } // Executed statements. switch { case passedS > 0 && failedS > 0: lines = append(lines, fmt.Sprintf("%d sql statement%s ok, %d with errors", passedS, plural(passedS), failedS)) case passedS > 0: lines = append(lines, fmt.Sprintf("%d sql statement%s", passedS, plural(passedS))) case failedS > 0: lines = append(lines, fmt.Sprintf("%d sql statement%s with errors", failedS, plural(failedS))) } var b strings.Builder for i, l := range lines { b.WriteString("-") b.WriteByte(' ') b.WriteString(fmt.Sprintf("**%s**", l)) if i < len(lines)-1 { b.WriteByte('\n') b.WriteString(ident) } } return b.String() } var ( // ErrLint is returned when the 'migrate lint' finds a diagnostic that is configured to // be reported as an error, such as destructive changes by default. ErrLint = errors.New("lint error") // Deprecated: Use ErrLint instead. LintErr = ErrLint ) // LatestVersion returns the latest version of the migration directory. func (r MigrateStatus) 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 MigrateStatus) Amount(version string) (amount uint64, ok bool) { if version == "" { amount := uint64(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 = uint64(idx + 1) //nolint:gosec //G115: Safe conversion as idx is from range break } } return amount, false } func newMigrateApplyError(r []*MigrateApply, stderr string) error { return &MigrateApplyError{Result: r, Stderr: stderr} } // Error implements the error interface. func (e *MigrateApplyError) Error() string { var errs []string for _, r := range e.Result { if r.Error != "" { errs = append(errs, r.Error) } } if e.Stderr != "" { errs = append(errs, e.Stderr) } return strings.Join(errs, "\n") } func plural(n int) (s string) { if n > 1 { s += "s" } return } ================================================ FILE: atlasexec/atlas_migrate_example_test.go ================================================ // Copyright 2021-present The Atlas Authors. All rights reserved. // This source code is licensed under the Apache 2.0 license found // in the LICENSE file in the root directory of this source tree. package atlasexec_test import ( "context" "fmt" "log" "os" "ariga.io/atlas/atlasexec" ) func ExampleClient_MigrateApply() { // Define the execution context, supplying a migration directory // and potentially an `atlas.hcl` configuration file using `atlasexec.WithHCL`. workdir, err := atlasexec.NewWorkingDir( atlasexec.WithMigrations( os.DirFS("./migrations"), ), ) if err != nil { log.Fatalf("failed to load working directory: %v", err) } // atlasexec works on a temporary directory, so we need to close it defer workdir.Close() // Initialize the client. client, err := atlasexec.NewClient(workdir.Path(), "atlas") if err != nil { log.Fatalf("failed to initialize client: %v", err) } // Run `atlas migrate apply` on a SQLite database under /tmp. res, err := client.MigrateApply(context.Background(), &atlasexec.MigrateApplyParams{ URL: "sqlite:///tmp/demo.db?_fk=1&cache=shared", }) if err != nil { log.Fatalf("failed to apply migrations: %v", err) } fmt.Printf("Applied %d migrations\n", len(res.Applied)) } func ExampleClient_MigrateSet() { // Define the execution context, supplying a migration directory // and potentially an `atlas.hcl` configuration file using `atlasexec.WithHCL`. workdir, err := atlasexec.NewWorkingDir( atlasexec.WithMigrations( os.DirFS("./migrations"), ), ) if err != nil { log.Fatalf("failed to load working directory: %v", err) } // atlasexec works on a temporary directory, so we need to close it defer workdir.Close() // Initialize the client. client, err := atlasexec.NewClient(workdir.Path(), "atlas") if err != nil { log.Fatalf("failed to initialize client: %v", err) } // Run `atlas migrate set` to mark migrations as applied up to version "3". err = client.MigrateSet(context.Background(), &atlasexec.MigrateSetParams{ URL: "sqlite:///tmp/demo.db?_fk=1&cache=shared", Version: "3", }) if err != nil { log.Fatalf("failed to set migrations: %v", err) } fmt.Println("Migration version set successfully") } ================================================ FILE: atlasexec/atlas_migrate_test.go ================================================ // Copyright 2021-present The Atlas Authors. All rights reserved. // This source code is licensed under the Apache 2.0 license found // in the LICENSE file in the root directory of this source tree. package atlasexec_test import ( "bytes" "context" "database/sql" "encoding/base64" "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "os" "os/exec" "path/filepath" "strings" "testing" "ariga.io/atlas/atlasexec" "ariga.io/atlas/sql/migrate" "ariga.io/atlas/sql/sqlcheck" "github.com/stretchr/testify/require" ) func TestMigrate_Status(t *testing.T) { type args struct { ctx context.Context data *atlasexec.MigrateStatusParams } tests := []struct { name string args args wantCurrent string wantNext string wantErr bool }{ { args: args{ ctx: context.Background(), data: &atlasexec.MigrateStatusParams{ DirURL: "file://testdata/migrations", }, }, wantCurrent: "No migration applied yet", wantNext: "20230727105553", }, } wd, err := os.Getwd() require.NoError(t, err) c, err := atlasexec.NewClient(wd, "atlas") require.NoError(t, err) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { tt.args.data.URL = sqlitedb(t, "") got, err := c.MigrateStatus(tt.args.ctx, tt.args.data) if (err != nil) != tt.wantErr { t.Errorf("migrateStatus() error = %v, wantErr %v", err, tt.wantErr) return } require.Equal(t, tt.wantCurrent, got.Current) require.Equal(t, tt.wantNext, got.Next) }) } } func TestMigrate_Apply(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) for _, tt := range []struct { name string params *atlasexec.MigrateApplyParams args string stdout string }{ { name: "no params", params: &atlasexec.MigrateApplyParams{}, args: "migrate apply --format {{ json . }}", stdout: `{"Driver":"mysql"}`, }, { name: "with env", params: &atlasexec.MigrateApplyParams{ Env: "test", }, args: "migrate apply --format {{ json . }} --env test", stdout: `{"Driver":"mysql"}`, }, { name: "with url", params: &atlasexec.MigrateApplyParams{ URL: "sqlite://file?_fk=1&cache=shared&mode=memory", }, args: "migrate apply --format {{ json . }} --url sqlite://file?_fk=1&cache=shared&mode=memory", stdout: `{"Driver":"mysql"}`, }, { name: "with exec order", params: &atlasexec.MigrateApplyParams{ ExecOrder: atlasexec.ExecOrderLinear, }, args: "migrate apply --format {{ json . }} --exec-order linear", stdout: `{"Driver":"mysql"}`, }, { name: "with lock name", params: &atlasexec.MigrateApplyParams{ LockName: "custom_lock", }, args: "migrate apply --format {{ json . }} --lock-name custom_lock", stdout: `{"Driver":"mysql"}`, }, } { t.Run(tt.name, func(t *testing.T) { t.Setenv("TEST_ARGS", tt.args) t.Setenv("TEST_STDOUT", tt.stdout) result, err := c.MigrateApply(context.Background(), tt.params) require.NoError(t, err) require.NotNil(t, result) require.Equal(t, "mysql", result.Driver) }) } } func TestMigrate_Ls(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) for _, tt := range []struct { name string params *atlasexec.MigrateLsParams args string stdout string }{ { name: "no params", params: &atlasexec.MigrateLsParams{}, args: "migrate ls", stdout: "\n", }, { name: "with dir", params: &atlasexec.MigrateLsParams{ DirURL: "file://migrations", }, args: "migrate ls --dir file://migrations", stdout: "\n", }, { name: "with short", params: &atlasexec.MigrateLsParams{ Short: true, }, args: "migrate ls --short", stdout: "\n", }, { name: "with latest", params: &atlasexec.MigrateLsParams{ Latest: true, }, args: "migrate ls --latest", stdout: "\n", }, { name: "with short and latest", params: &atlasexec.MigrateLsParams{ DirURL: "file://migrations", Short: true, Latest: true, }, args: "migrate ls --dir file://migrations --short --latest", stdout: "20230727105615\n", }, { name: "with config and env", params: &atlasexec.MigrateLsParams{ ConfigURL: "file://atlas.hcl", Env: "dev", }, args: "migrate ls --config file://atlas.hcl --env dev", stdout: "\n", }, } { t.Run(tt.name, func(t *testing.T) { t.Setenv("TEST_ARGS", tt.args) t.Setenv("TEST_STDOUT", tt.stdout) got, err := c.MigrateLs(context.Background(), tt.params) require.NoError(t, err) require.Equal(t, tt.stdout, got) }) } } func TestMigrate_Ls_Integration(t *testing.T) { wd, err := os.Getwd() require.NoError(t, err) c, err := atlasexec.NewClient(wd, "atlas") require.NoError(t, err) dirURL := "file://testdata/migrations" out, err := c.MigrateLs(context.Background(), &atlasexec.MigrateLsParams{DirURL: dirURL}) if err != nil { if strings.Contains(err.Error(), "unknown") || strings.Contains(err.Error(), "unknown command") { t.Skip("atlas binary does not support 'migrate ls' (e.g. OSS build)") } require.NoError(t, err) } lines := strings.Split(strings.TrimSpace(out), "\n") require.GreaterOrEqual(t, len(lines), 2) require.Contains(t, out, "20230727105553_init.sql") require.Contains(t, out, "20230727105615_t2.sql") outShort, err := c.MigrateLs(context.Background(), &atlasexec.MigrateLsParams{DirURL: dirURL, Short: true}) if err != nil && strings.Contains(err.Error(), "unknown flag") { t.Skip("atlas binary does not support --short/--latest (use enterprise or newer build)") } require.NoError(t, err) require.Contains(t, outShort, "20230727105553") require.Contains(t, outShort, "20230727105615") require.NotContains(t, outShort, ".sql") outLatest, err := c.MigrateLs(context.Background(), &atlasexec.MigrateLsParams{DirURL: dirURL, Latest: true}) require.NoError(t, err) require.Equal(t, 1, len(strings.Split(strings.TrimSpace(outLatest), "\n"))) require.Contains(t, outLatest, "20230926085734_destructive-change.sql") } func TestMigrate_Set(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) for _, tt := range []struct { name string params *atlasexec.MigrateSetParams args string }{ { name: "no params", params: &atlasexec.MigrateSetParams{}, args: "migrate set", }, { name: "with env", params: &atlasexec.MigrateSetParams{ Env: "test", }, args: "migrate set --env test", }, { name: "with url", params: &atlasexec.MigrateSetParams{ URL: "sqlite://file?_fk=1&cache=shared&mode=memory", }, args: "migrate set --url sqlite://file?_fk=1&cache=shared&mode=memory", }, { name: "with dir", params: &atlasexec.MigrateSetParams{ DirURL: "file://migrations", }, args: "migrate set --dir file://migrations", }, { name: "with revisions-schema", params: &atlasexec.MigrateSetParams{ RevisionsSchema: "my_revisions", }, args: "migrate set --revisions-schema my_revisions", }, { name: "with version", params: &atlasexec.MigrateSetParams{ Version: "3", }, args: "migrate set 3", }, { name: "with all params", params: &atlasexec.MigrateSetParams{ URL: "sqlite://file?_fk=1&cache=shared&mode=memory", DirURL: "file://migrations", RevisionsSchema: "my_revisions", Version: "1.2.4", }, args: "migrate set --url sqlite://file?_fk=1&cache=shared&mode=memory --dir file://migrations --revisions-schema my_revisions 1.2.4", }, } { t.Run(tt.name, func(t *testing.T) { t.Setenv("TEST_ARGS", tt.args) t.Setenv("TEST_STDOUT", "ok") err := c.MigrateSet(context.Background(), tt.params) require.NoError(t, err) }) } } func TestMigrate_Down(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) for _, tt := range []struct { name string params *atlasexec.MigrateDownParams args string stdout string }{ { name: "no params", params: &atlasexec.MigrateDownParams{}, args: "migrate down --format {{ json . }}", stdout: `{"Status":"Pending"}`, }, { name: "with env", params: &atlasexec.MigrateDownParams{ Env: "test", }, args: "migrate down --format {{ json . }} --env test", stdout: `{"Status":"Pending"}`, }, { name: "with url", params: &atlasexec.MigrateDownParams{ URL: "sqlite://file?_fk=1&cache=shared&mode=memory", }, args: "migrate down --format {{ json . }} --url sqlite://file?_fk=1&cache=shared&mode=memory", stdout: `{"Status":"Pending"}`, }, { name: "with target version", params: &atlasexec.MigrateDownParams{ ToVersion: "12345", }, args: "migrate down --format {{ json . }} --to-version 12345", stdout: `{"Status":"Pending"}`, }, { name: "with tag version", params: &atlasexec.MigrateDownParams{ ToTag: "12345", }, args: "migrate down --format {{ json . }} --to-tag 12345", stdout: `{"Status":"Pending"}`, }, { name: "with amount", params: &atlasexec.MigrateDownParams{ Amount: 10, }, args: "migrate down --format {{ json . }} 10", stdout: `{"Status":"Pending"}`, }, { name: "dev-url", params: &atlasexec.MigrateDownParams{ DevURL: "url", }, args: "migrate down --format {{ json . }} --dev-url url", stdout: `{"Status":"Pending"}`, }, } { t.Run(tt.name, func(t *testing.T) { t.Setenv("TEST_ARGS", tt.args) t.Setenv("TEST_STDOUT", tt.stdout) result, err := c.MigrateDown(context.Background(), tt.params) require.NoError(t, err) require.NotNil(t, result) require.Equal(t, "Pending", result.Status) }) } } func TestMigrate_Test(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) for _, tt := range []struct { name string params *atlasexec.MigrateTestParams args string stdout string }{ { name: "no params", params: &atlasexec.MigrateTestParams{}, args: "migrate test", stdout: "test result", }, { name: "with env", params: &atlasexec.MigrateTestParams{ Env: "test", }, args: "migrate test --env test", stdout: "test result", }, { name: "with config", params: &atlasexec.MigrateTestParams{ ConfigURL: "file://config.hcl", }, args: "migrate test --config file://config.hcl", stdout: "test result", }, { name: "with dev-url", params: &atlasexec.MigrateTestParams{ DevURL: "sqlite://file?_fk=1&cache=shared&mode=memory", }, args: "migrate test --dev-url sqlite://file?_fk=1&cache=shared&mode=memory", stdout: "test result", }, { name: "with run", params: &atlasexec.MigrateTestParams{ Run: "example", }, args: "migrate test --run example", stdout: "test result", }, { name: "with run and paths", params: &atlasexec.MigrateTestParams{ Run: "example", Paths: []string{"./foo", "./bar"}, }, args: "migrate test --run example ./foo ./bar", stdout: "test result", }, { name: "with revisions-schema", params: &atlasexec.MigrateTestParams{ RevisionsSchema: "schema", }, args: "migrate test --revisions-schema schema", stdout: "test result", }, { name: "with run context", params: &atlasexec.MigrateTestParams{ Context: &atlasexec.RunContext{ Repo: "testing-repo", }, }, args: "migrate test --context {\"repo\":\"testing-repo\"}", stdout: "test result", }, } { t.Run(tt.name, func(t *testing.T) { t.Setenv("TEST_ARGS", tt.args) t.Setenv("TEST_STDOUT", tt.stdout) result, err := c.MigrateTest(context.Background(), tt.params) require.NoError(t, err) require.Equal(t, tt.stdout, result) }) } } func TestAtlasMigrate_ApplyBroken(t *testing.T) { c, err := atlasexec.NewClient(".", "atlas") require.NoError(t, err) got, err := c.MigrateApply(context.Background(), &atlasexec.MigrateApplyParams{ URL: "sqlite://?mode=memory", DirURL: "file://testdata/broken", }) require.ErrorContains(t, err, `sql/migrate: executing statement "broken;" from version "20231029112426": near "broken": syntax error`) require.Nil(t, got) report, ok := err.(*atlasexec.MigrateApplyError) require.True(t, ok) require.Equal(t, "20231029112426", report.Result[0].Target) require.Equal(t, "sql/migrate: executing statement \"broken;\" from version \"20231029112426\": near \"broken\": syntax error", report.Error()) require.Len(t, report.Result[0].Applied, 1) require.Equal(t, &struct { Stmt, Text string }{ Stmt: "broken;", Text: "near \"broken\": syntax error", }, report.Result[0].Applied[0].Error) } func TestMigrateApplyError_Error(t *testing.T) { t.Run("single result error only", func(t *testing.T) { e := &atlasexec.MigrateApplyError{ Result: []*atlasexec.MigrateApply{ {Error: "sql/migrate: execution failed"}, }, } require.Equal(t, "sql/migrate: execution failed", e.Error()) }) t.Run("stderr only", func(t *testing.T) { e := &atlasexec.MigrateApplyError{ Stderr: "Error: unable to acquire lock", } require.Equal(t, "Error: unable to acquire lock", e.Error()) }) t.Run("single result error and stderr", func(t *testing.T) { e := &atlasexec.MigrateApplyError{ Result: []*atlasexec.MigrateApply{ {Error: "sql/migrate: execution failed"}, }, Stderr: "Error: unable to acquire lock", } require.Equal(t, "sql/migrate: execution failed\nError: unable to acquire lock", e.Error()) }) t.Run("multiple result errors and stderr", func(t *testing.T) { e := &atlasexec.MigrateApplyError{ Result: []*atlasexec.MigrateApply{ {Error: "error on target 1"}, {Error: "error on target 2"}, }, Stderr: "Error: unable to acquire lock", } require.Equal(t, "error on target 1\nerror on target 2\nError: unable to acquire lock", e.Error()) }) t.Run("multiple results with some having no error", func(t *testing.T) { e := &atlasexec.MigrateApplyError{ Result: []*atlasexec.MigrateApply{ {Error: ""}, {Error: "error on target 2"}, {Error: ""}, }, Stderr: "Error: unable to acquire lock", } require.Equal(t, "error on target 2\nError: unable to acquire lock", e.Error()) }) t.Run("no errors at all", func(t *testing.T) { e := &atlasexec.MigrateApplyError{ Result: []*atlasexec.MigrateApply{ {Error: ""}, }, } require.Equal(t, "", e.Error()) }) t.Run("nil result with stderr", func(t *testing.T) { e := &atlasexec.MigrateApplyError{ Stderr: "Error: connection refused", } require.Equal(t, "Error: connection refused", e.Error()) }) } func TestAtlasMigrate_Apply(t *testing.T) { ec, err := atlasexec.NewWorkingDir( atlasexec.WithMigrations(os.DirFS(filepath.Join("testdata", "migrations"))), atlasexec.WithAtlasHCL(func(w io.Writer) error { _, err := w.Write([]byte(` variable "url" { type = string default = getenv("DB_URL") } env { name = atlas.env url = var.url migration { dir = "file://migrations" } }`)) return err }), ) require.NoError(t, err) t.Cleanup(func() { require.NoError(t, ec.Close()) }) c, err := atlasexec.NewClient(ec.Path(), "atlas") require.NoError(t, err) got, err := c.MigrateApply(context.Background(), &atlasexec.MigrateApplyParams{ Env: "test", }) require.ErrorContains(t, err, `required flag "url" not set`) require.Nil(t, got) var exerr *exec.ExitError require.ErrorAs(t, err, &exerr) // Set the env var and try again os.Setenv("DB_URL", "sqlite://file?_fk=1&cache=shared&mode=memory") got, err = c.MigrateApply(context.Background(), &atlasexec.MigrateApplyParams{ Env: "test", }) require.NoError(t, err) require.Equal(t, "sqlite", got.Env.Driver) require.Equal(t, "file://migrations", got.Env.Dir) require.Equal(t, "sqlite://file?_fk=1&cache=shared&mode=memory", got.Env.URL.String()) require.Equal(t, "20230926085734", got.Target) // Add dirty changes and try again os.Setenv("DB_URL", "sqlite://test.db?_fk=1&cache=shared&mode=memory") drv, err := sql.Open("sqlite3", "test.db") require.NoError(t, err) defer os.Remove("test.db") _, err = drv.ExecContext(context.Background(), "create table atlas_schema_revisions(version varchar(255) not null primary key);") require.NoError(t, err) got, err = c.MigrateApply(context.Background(), &atlasexec.MigrateApplyParams{ Env: "test", AllowDirty: true, }) require.NoError(t, err) require.EqualValues(t, "20230926085734", got.Target) } func TestAtlasMigrate_ApplyWithRemote(t *testing.T) { type ( ContextInput struct { TriggerType string `json:"triggerType,omitempty"` TriggerVersion string `json:"triggerVersion,omitempty"` } graphQLQuery struct { Query string `json:"query"` Variables json.RawMessage `json:"variables"` MigrateApplyReport struct { Input struct { Context *ContextInput `json:"context,omitempty"` } `json:"input"` } } ) token := "123456789" handler := func(payloads *[]graphQLQuery) http.HandlerFunc { return func(_ http.ResponseWriter, r *http.Request) { require.Equal(t, "Bearer "+token, r.Header.Get("Authorization")) var query graphQLQuery require.NoError(t, json.NewDecoder(r.Body).Decode(&query)) *payloads = append(*payloads, query) } } var payloads []graphQLQuery srv := httptest.NewServer(handler(&payloads)) t.Cleanup(srv.Close) ec, err := atlasexec.NewWorkingDir( atlasexec.WithMigrations(os.DirFS(filepath.Join("testdata", "migrations"))), atlasexec.WithAtlasHCL(func(w io.Writer) error { _, err := fmt.Fprintf(w, ` env { name = atlas.env url = "sqlite://file?_fk=1&cache=shared&mode=memory" migration { dir = "atlas://test_dir" } } atlas { cloud { token = %q url = %q } }`, token, srv.URL) return err }), ) require.NoError(t, err) t.Cleanup(func() { require.NoError(t, ec.Close()) }) c, err := atlasexec.NewClient(ec.Path(), "atlas") require.NoError(t, err) got, err := c.MigrateApply(context.Background(), &atlasexec.MigrateApplyParams{ Env: "test", }) require.NoError(t, err) require.NotNil(t, got) require.Len(t, payloads, 3) reportPayload := payloads[2] require.Regexp(t, "mutation ReportMigration", reportPayload.Query) err = json.Unmarshal(reportPayload.Variables, &reportPayload.MigrateApplyReport) require.NoError(t, err) require.Nil(t, reportPayload.MigrateApplyReport.Input.Context) got, err = c.MigrateApply(context.Background(), &atlasexec.MigrateApplyParams{ Env: "test", Context: &atlasexec.DeployRunContext{TriggerVersion: "1.2.3", TriggerType: atlasexec.TriggerTypeGithubAction}, }) require.NoError(t, err) require.NotNil(t, got) require.Len(t, payloads, 6) reportPayload = payloads[5] require.Regexp(t, "mutation ReportMigration", reportPayload.Query) err = json.Unmarshal(reportPayload.Variables, &reportPayload.MigrateApplyReport) require.NoError(t, err) require.NotNil(t, reportPayload.MigrateApplyReport.Input.Context) require.Equal(t, "GITHUB_ACTION", reportPayload.MigrateApplyReport.Input.Context.TriggerType) require.Equal(t, "1.2.3", reportPayload.MigrateApplyReport.Input.Context.TriggerVersion) } func TestAtlasMigrate_Push(t *testing.T) { type ( graphQLQuery struct { Query string `json:"query"` Variables json.RawMessage `json:"variables"` PushDir *struct { Input struct { Slug string `json:"slug"` Tag string `json:"tag"` Driver string `json:"driver"` Dir string `json:"dir"` } `json:"input"` } DiffSyncDir *struct { Input struct { Slug string `json:"slug"` Driver string `json:"driver"` Dir string `json:"dir"` Add string `json:"add"` Delete []string `json:"delete"` Context *atlasexec.RunContext `json:"context"` } `json:"input"` } } httpTest struct { payloads []graphQLQuery srv *httptest.Server } ) token := "123456789" newHTTPTest := func() (*httpTest, string) { tt := &httpTest{} handler := func() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { require.Equal(t, "Bearer "+token, r.Header.Get("Authorization")) var query graphQLQuery require.NoError(t, json.NewDecoder(r.Body).Decode(&query)) if strings.Contains(query.Query, "pushDir") { err := json.Unmarshal(query.Variables, &query.PushDir) require.NoError(t, err) fmt.Fprint(w, `{"data":{"pushDir":{"url":"https://some-org.atlasgo.cloud/dirs/314159/tags/12345"}}}`) } if strings.Contains(query.Query, "diffSyncDir") { err := json.Unmarshal(query.Variables, &query.DiffSyncDir) require.NoError(t, err) fmt.Fprint(w, `{"data":{"diffSyncDir":{"url":"https://some-org.atlasgo.cloud/dirs/314159/tags/12345"}}}`) } tt.payloads = append(tt.payloads, query) } } tt.srv = httptest.NewServer(handler()) t.Cleanup(tt.srv.Close) return tt, generateHCL(t, token, tt.srv) } c, err := atlasexec.NewClient(".", "atlas") require.NoError(t, err) inputContext := &atlasexec.RunContext{ Repo: "testing-repo", Path: "path/to/dir", Branch: "testing-branch", Commit: "sha123", URL: "this://is/a/url", UserID: "test-user-id", Username: "test-user", SCMType: atlasexec.SCMTypeGithub, } t.Run("sync", func(t *testing.T) { params := &atlasexec.MigratePushParams{ DevURL: "sqlite://file?mode=memory", DirURL: "file://testdata/migrations", Name: "test-dir-slug", Env: "test", } t.Run("with context", func(t *testing.T) { tt, atlasConfigURL := newHTTPTest() params.ConfigURL = atlasConfigURL got, err := c.MigratePush(context.Background(), params) require.NoError(t, err) require.Len(t, tt.payloads, 3) require.Equal(t, `https://some-org.atlasgo.cloud/dirs/314159/tags/12345`, got) p := &tt.payloads[2] require.Contains(t, p.Query, "diffSyncDir") require.Equal(t, "test-dir-slug", p.DiffSyncDir.Input.Slug) require.Equal(t, "SQLITE", p.DiffSyncDir.Input.Driver) require.NotEmpty(t, p.DiffSyncDir.Input.Dir) }) t.Run("without context", func(t *testing.T) { tt, atlasConfigURL := newHTTPTest() params.ConfigURL = atlasConfigURL params.Context = inputContext got, err := c.MigratePush(context.Background(), params) require.NoError(t, err) require.Equal(t, `https://some-org.atlasgo.cloud/dirs/314159/tags/12345`, got) require.Len(t, tt.payloads, 3) p := &tt.payloads[2] require.Contains(t, p.Query, "diffSyncDir") err = json.Unmarshal(p.Variables, &p.DiffSyncDir) require.NoError(t, err) require.Equal(t, inputContext, p.DiffSyncDir.Input.Context) }) }) t.Run("push", func(t *testing.T) { tt, atlasConfigURL := newHTTPTest() params := &atlasexec.MigratePushParams{ ConfigURL: atlasConfigURL, DevURL: "sqlite://file?mode=memory", DirURL: "file://testdata/migrations", Name: "test-dir-slug", Context: inputContext, Env: "test", Tag: "this-is-my-tag", } got, err := c.MigratePush(context.Background(), params) require.NoError(t, err) require.Equal(t, `https://some-org.atlasgo.cloud/dirs/314159/tags/12345`, got) require.Len(t, tt.payloads, 2) p := &tt.payloads[1] require.Contains(t, p.Query, "pushDir") require.Equal(t, "test-dir-slug", p.PushDir.Input.Slug) require.Equal(t, "SQLITE", p.PushDir.Input.Driver) require.Equal(t, "this-is-my-tag", p.PushDir.Input.Tag) require.NotEmpty(t, p.PushDir.Input.Dir) }) } func TestMigrateHash(t *testing.T) { td := t.TempDir() require.NoError(t, os.Mkdir(fmt.Sprintf("%s/migrations", td), 0777)) require.NoError(t, os.WriteFile(fmt.Sprintf("%s/migrations/1.sql", td), []byte(`create table t (c int not null)`), 0666)) c, err := atlasexec.NewClient(td, "atlas") require.NoError(t, err) inspect := func() error { _, err = c.SchemaInspect(context.Background(), &atlasexec.SchemaInspectParams{ DevURL: "sqlite://file?mode=memory", URL: fmt.Sprintf("file://%s/migrations", td), }) return err } require.ErrorContains(t, inspect(), "checksum file not found") require.NoError(t, c.MigrateHash(context.Background(), &atlasexec.MigrateHashParams{})) require.FileExists(t, fmt.Sprintf("%s/migrations/atlas.sum", td)) require.NoError(t, inspect()) } func TestMigrateRebase(t *testing.T) { td := t.TempDir() require.NoError(t, os.Mkdir(fmt.Sprintf("%s/migrations", td), 0777)) // create initial migrations dir state require.NoError(t, os.WriteFile(fmt.Sprintf("%s/migrations/2024030709.sql", td), []byte(`create table t (c int not null)`), 0666)) require.NoError(t, os.WriteFile(fmt.Sprintf("%s/migrations/2024030711.sql", td), []byte("alter table `t` add column `c3` text not null;"), 0666)) c, err := atlasexec.NewClient(td, "atlas") require.NoError(t, err) require.NoError(t, c.MigrateHash(context.Background(), &atlasexec.MigrateHashParams{})) require.FileExists(t, fmt.Sprintf("%s/migrations/atlas.sum", td)) // Print atlas.sum before adding a new migration before, err := os.ReadFile(fmt.Sprintf("%s/migrations/atlas.sum", td)) require.NoError(t, err) // add a new migration require.NoError(t, os.WriteFile(fmt.Sprintf("%s/migrations/2024030710.sql", td), []byte("alter table `t` add column `c2` text not null;"), 0666)) require.NoError(t, c.MigrateHash(context.Background(), &atlasexec.MigrateHashParams{})) require.FileExists(t, fmt.Sprintf("%s/migrations/atlas.sum", td)) require.NoError(t, c.MigrateRebase(context.Background(), &atlasexec.MigrateRebaseParams{ Files: []string{ "2024030711.sql", }, DirURL: fmt.Sprintf("file://%s/migrations", td), })) inspect := func() error { _, err = c.SchemaInspect(context.Background(), &atlasexec.SchemaInspectParams{ DevURL: "sqlite://file?mode=memory", URL: fmt.Sprintf("file://%s/migrations", td), }) return err } // ensure sum file changes after rebase after, err := os.ReadFile(fmt.Sprintf("%s/migrations/atlas.sum", td)) require.NotEqual(t, before, after) require.NoError(t, inspect()) } func TestAtlasMigrate_Lint(t *testing.T) { t.Run("with broken config", func(t *testing.T) { c, err := atlasexec.NewClient(".", "atlas") require.NoError(t, err) got, err := c.MigrateLint(context.Background(), &atlasexec.MigrateLintParams{ ConfigURL: "file://config-broken.hcl", }) require.ErrorContains(t, err, `file "config-broken.hcl" was not found`) require.Nil(t, got) }) t.Run("with broken dev-url", func(t *testing.T) { c, err := atlasexec.NewClient(".", "atlas") require.NoError(t, err) got, err := c.MigrateLint(context.Background(), &atlasexec.MigrateLintParams{ DirURL: "file://atlasexec/testdata/migrations", }) require.ErrorContains(t, err, `required flag(s) "dev-url" not set`) require.Nil(t, got) }) t.Run("broken dir", func(t *testing.T) { c, err := atlasexec.NewClient(".", "atlas") require.NoError(t, err) got, err := c.MigrateLint(context.Background(), &atlasexec.MigrateLintParams{ DevURL: "sqlite://file?mode=memory", DirURL: "file://atlasexec/testdata/doesnotexist", }) require.ErrorContains(t, err, `stat atlasexec/testdata/doesnotexist: no such file or directory`) require.Nil(t, got) }) t.Run("lint error parsing", func(t *testing.T) { c, err := atlasexec.NewClient(".", "atlas") require.NoError(t, err) got, err := c.MigrateLint(context.Background(), &atlasexec.MigrateLintParams{ DevURL: "sqlite://file?mode=memory", DirURL: "file://testdata/migrations", Latest: 1, }) require.NoError(t, err) require.GreaterOrEqual(t, 4, len(got.Steps)) require.Equal(t, "sqlite", got.Env.Driver) require.Equal(t, "testdata/migrations", got.Env.Dir) require.Equal(t, "sqlite://file?mode=memory", got.Env.URL.String()) require.Equal(t, 1, len(got.Files)) expectedReport := &atlasexec.FileReport{ Name: "20230926085734_destructive-change.sql", Text: "DROP TABLE t2;\n", Reports: []sqlcheck.Report{{ Text: "destructive changes detected", Diagnostics: []sqlcheck.Diagnostic{{ Pos: 0, Text: `Dropping table "t2"`, Code: "DS102", SuggestedFixes: []sqlcheck.SuggestedFix{{ Message: "Add a pre-migration check to ensure table \"t2\" is empty before dropping it", TextEdit: &sqlcheck.TextEdit{ Line: 1, End: 1, NewText: "-- atlas:txtar\n\n-- checks/destructive.sql --\n-- atlas:assert DS102\nSELECT NOT EXISTS (SELECT 1 FROM `t2`) AS `is_empty`;\n\n-- migration.sql --\nDROP TABLE t2;", }, }}, }}, }}, Error: "destructive changes detected", } require.EqualValues(t, expectedReport, got.Files[0]) }) t.Run("lint with manually parsing output", func(t *testing.T) { c, err := atlasexec.NewClient(".", "atlas") require.NoError(t, err) var buf bytes.Buffer err = c.MigrateLintError(context.Background(), &atlasexec.MigrateLintParams{ DevURL: "sqlite://file?mode=memory", DirURL: "file://testdata/migrations", Latest: 1, Writer: &buf, }) require.Equal(t, atlasexec.ErrLint, err) var raw json.RawMessage require.NoError(t, json.NewDecoder(&buf).Decode(&raw)) require.Contains(t, string(raw), "destructive changes detected") }) t.Run("lint uses --base and --latest", func(t *testing.T) { c, err := atlasexec.NewClient(".", "atlas") require.NoError(t, err) summary, err := c.MigrateLint(context.Background(), &atlasexec.MigrateLintParams{ DevURL: "sqlite://file?mode=memory", DirURL: "file://testdata/migrations", Latest: 1, Base: "atlas://test-dir", }) require.ErrorContains(t, err, "--latest, --git-base, and --base are mutually exclusive") require.Nil(t, summary) }) } func TestAtlasMigrate_LintWithLogin(t *testing.T) { type ( migrateLintReport struct { Context *atlasexec.RunContext `json:"context"` } graphQLQuery struct { Query string `json:"query"` Variables json.RawMessage `json:"variables"` MigrateLintReport struct { migrateLintReport `json:"input"` } } Dir struct { Name string `json:"name"` Content string `json:"content"` Slug string `json:"slug"` } dirsQueryResponse struct { Data struct { Dirs []Dir `json:"dirs"` } `json:"data"` } ) token := "123456789" handler := func(payloads *[]graphQLQuery) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { require.Equal(t, "Bearer "+token, r.Header.Get("Authorization")) var query graphQLQuery require.NoError(t, json.NewDecoder(r.Body).Decode(&query)) *payloads = append(*payloads, query) switch { case strings.Contains(query.Query, "mutation reportMigrationLint"): _, err := fmt.Fprintf(w, `{ "data": { "reportMigrationLint": { "url": "https://migration-lint-report-url" } } }`) require.NoError(t, err) case strings.Contains(query.Query, "query dirs"): dir, err := migrate.NewLocalDir("./testdata/migrations") require.NoError(t, err) ad, err := migrate.ArchiveDir(dir) require.NoError(t, err) var resp dirsQueryResponse resp.Data.Dirs = []Dir{{ Name: "test-dir-name", Slug: "test-dir-slug", Content: base64.StdEncoding.EncodeToString(ad), }} st2bytes, err := json.Marshal(resp) require.NoError(t, err) _, err = fmt.Fprint(w, string(st2bytes)) require.NoError(t, err) } } } t.Run("Web and Writer params produces an error", func(t *testing.T) { var payloads []graphQLQuery srv := httptest.NewServer(handler(&payloads)) t.Cleanup(srv.Close) atlasConfigURL := generateHCL(t, token, srv) c, err := atlasexec.NewClient(".", "atlas") require.NoError(t, err) params := &atlasexec.MigrateLintParams{ ConfigURL: atlasConfigURL, DevURL: "sqlite://file?mode=memory", DirURL: "file://testdata/migrations", Latest: 1, Web: true, } got, err := c.MigrateLint(context.Background(), params) require.ErrorContains(t, err, "Writer or Web reporting are not supported") require.Nil(t, got) params.Web = false params.Writer = &bytes.Buffer{} got, err = c.MigrateLint(context.Background(), params) require.ErrorContains(t, err, "Writer or Web reporting are not supported") require.Nil(t, got) }) t.Run("lint parse web output - no error - custom format", func(t *testing.T) { var payloads []graphQLQuery srv := httptest.NewServer(handler(&payloads)) t.Cleanup(srv.Close) atlasConfigURL := generateHCL(t, token, srv) c, err := atlasexec.NewClient(".", "atlas") require.NoError(t, err) var buf bytes.Buffer err = c.MigrateLintError(context.Background(), &atlasexec.MigrateLintParams{ DevURL: "sqlite://file?mode=memory", DirURL: "file://testdata/migrations", ConfigURL: atlasConfigURL, Latest: 1, Writer: &buf, Format: "{{ .URL }}", Web: true, }) require.Equal(t, err, atlasexec.ErrLint) require.Equal(t, strings.TrimSpace(buf.String()), "https://migration-lint-report-url") }) t.Run("lint parse web output - no error - default format", func(t *testing.T) { var payloads []graphQLQuery srv := httptest.NewServer(handler(&payloads)) t.Cleanup(srv.Close) atlasConfigURL := generateHCL(t, token, srv) c, err := atlasexec.NewClient(".", "atlas") require.NoError(t, err) var buf bytes.Buffer err = c.MigrateLintError(context.Background(), &atlasexec.MigrateLintParams{ DevURL: "sqlite://file?mode=memory", DirURL: "file://testdata/migrations", ConfigURL: atlasConfigURL, Latest: 1, Writer: &buf, Web: true, }) require.Equal(t, atlasexec.ErrLint, err) var sr atlasexec.SummaryReport require.NoError(t, json.NewDecoder(&buf).Decode(&sr)) require.Equal(t, "https://migration-lint-report-url", sr.URL) }) t.Run("lint uses --base", func(t *testing.T) { var payloads []graphQLQuery srv := httptest.NewServer(handler(&payloads)) t.Cleanup(srv.Close) atlasConfigURL := generateHCL(t, token, srv) c, err := atlasexec.NewClient(".", "atlas") require.NoError(t, err) summary, err := c.MigrateLint(context.Background(), &atlasexec.MigrateLintParams{ DevURL: "sqlite://file?mode=memory", DirURL: "file://testdata/migrations", ConfigURL: atlasConfigURL, Base: "atlas://test-dir-slug", }) require.NoError(t, err) require.NotNil(t, summary) }) t.Run("lint uses --context has error", func(t *testing.T) { var payloads []graphQLQuery srv := httptest.NewServer(handler(&payloads)) t.Cleanup(srv.Close) c, err := atlasexec.NewClient(".", "atlas") require.NoError(t, err) var ( buf bytes.Buffer atlasConfigURL = generateHCL(t, token, srv) runContext = &atlasexec.RunContext{ Repo: "testing-repo", Path: "path/to/dir", Branch: "testing-branch", Commit: "sha123", URL: "this://is/a/url", Username: "test-user", UserID: "test-user-id", SCMType: atlasexec.SCMTypeGithub, } ) err = c.MigrateLintError(context.Background(), &atlasexec.MigrateLintParams{ DevURL: "sqlite://file?mode=memory", DirURL: "file://testdata/migrations", ConfigURL: atlasConfigURL, Base: "atlas://test-dir-slug", Context: runContext, Writer: &buf, Web: true, }) require.Equal(t, atlasexec.ErrLint, err) var sr atlasexec.SummaryReport require.NoError(t, json.NewDecoder(&buf).Decode(&sr)) require.Equal(t, "https://migration-lint-report-url", sr.URL) found := false for _, query := range payloads { if !strings.Contains(query.Query, "mutation reportMigrationLint") { continue } found = true require.NoError(t, json.Unmarshal(query.Variables, &query.MigrateLintReport)) require.Equal(t, runContext, query.MigrateLintReport.Context) } require.True(t, found) }) } func TestMigrate_Diff(t *testing.T) { c, err := atlasexec.NewClient(".", "atlas") require.NoError(t, err) td := t.TempDir() require.NoError(t, os.WriteFile(fmt.Sprintf("%s/schema.sql", td), []byte(`create table t (c int not null)`), 0666)) params := &atlasexec.MigrateDiffParams{ ToURL: fmt.Sprintf("file://%s/schema.sql", td), DevURL: "sqlite://file?mode=memory", DirURL: "file://testdata/migrations", Name: "test-diff", } output, err := c.MigrateDiff(context.Background(), params) require.NoError(t, err) require.Len(t, output.Files, 1) require.Contains(t, output.Files[0].Name, "test-diff.sql") require.Equal(t, output.Files[0].Content, "-- Disable the enforcement of foreign-keys constraints\nPRAGMA foreign_keys = off;\n-- Drop \"t1\" table\nDROP TABLE `t1`;\n-- Create \"t\" table\nCREATE TABLE `t` (\n `c` int NOT NULL\n);\n-- Enable back the enforcement of foreign-keys constraints\nPRAGMA foreign_keys = on;\n") require.Equal(t, output.Dir, "file://testdata/migrations?format=atlas") // No diff params = &atlasexec.MigrateDiffParams{ ToURL: "file://testdata/migrations", DevURL: "sqlite://file?mode=memory", DirURL: "file://testdata/migrations", Name: "test-diff", } output, err = c.MigrateDiff(context.Background(), params) require.NoError(t, err) require.Len(t, output.Files, 0) } ================================================ FILE: atlasexec/atlas_models.go ================================================ // Copyright 2021-present The Atlas Authors. All rights reserved. // This source code is licensed under the Apache 2.0 license found // in the LICENSE file in the root directory of this source tree. package atlasexec import ( "errors" "time" "ariga.io/atlas/sql/schema" "ariga.io/atlas/sql/sqlcheck" "ariga.io/atlas/sql/sqlclient" ) type ( // File wraps migrate.File to implement json.Marshaler. File struct { Name string `json:"Name,omitempty"` Version string `json:"Version,omitempty"` Description string `json:"Description,omitempty"` Content string `json:"Content,omitempty"` } // AppliedFile is part of a MigrateApply containing information about an applied file in a migration attempt. AppliedFile struct { File Start time.Time End time.Time Skipped int // Amount of skipped SQL statements in a partially applied file. Applied []string // SQL statements applied with success Checks []*FileChecks // Assertion checks Error *struct { Stmt string // SQL statement that failed. Text string // Error returned by the database. } } // RevertedFile is part of a MigrateDown containing information about a reverted file in a downgrade attempt. RevertedFile struct { File Start time.Time End time.Time Skipped int // Amount of skipped SQL statements in a partially applied file. Applied []string // SQL statements applied with success Scope string // Scope of the revert. e.g., statement, versions, etc. Error *struct { Stmt string // SQL statement that failed. Text string // Error returned by the database. } } // A SummaryReport contains a summary of the analysis of all files. // It is used as an input to templates to report the CI results. SummaryReport struct { URL string `json:"URL,omitempty"` // URL of the report, if exists. // Env holds the environment information. Env struct { Driver string `json:"Driver,omitempty"` // Driver name. URL *sqlclient.URL `json:"URL,omitempty"` // URL to dev database. Dir string `json:"Dir,omitempty"` // Path to migration directory. } // Schema versions found by the runner. Schema struct { Current string `json:"Current,omitempty"` // Current schema. Desired string `json:"Desired,omitempty"` // Desired schema. } // Steps of the analysis. Added in verbose mode. Steps []*StepReport `json:"Steps,omitempty"` // Files reports. Non-empty in case there are findings. Files []*FileReport `json:"Files,omitempty"` } // StepReport contains a summary of the analysis of a single step. StepReport struct { Name string `json:"Name,omitempty"` // Step name. Text string `json:"Text,omitempty"` // Step description. Error string `json:"Error,omitempty"` // Error that cause the execution to halt. Result *FileReport `json:"Result,omitempty"` // Result of the step. For example, a diagnostic. } // FileReport contains a summary of the analysis of a single file. FileReport struct { Name string `json:"Name,omitempty"` // Name of the file. Text string `json:"Text,omitempty"` // Contents of the file. Reports []sqlcheck.Report `json:"Reports,omitempty"` // List of reports. Error string `json:"Error,omitempty"` // File specific error. } // FileChecks represents a set of checks to run before applying a file. FileChecks struct { Name string `json:"Name,omitempty"` // File/group name. Stmts []*Check `json:"Stmts,omitempty"` // Checks statements executed. Error *StmtError `json:"Error,omitempty"` // Assertion error. Start time.Time `json:"Start,omitempty"` // Start assertion time. End time.Time `json:"End,omitempty"` // End assertion time. } // Check represents an assertion and its status. Check struct { Stmt string `json:"Stmt,omitempty"` // Assertion statement. Error *string `json:"Error,omitempty"` // Assertion error, if any. } // StmtError groups a statement with its execution error. StmtError struct { Stmt string `json:"Stmt,omitempty"` // SQL statement that failed. Text string `json:"Text,omitempty"` // Error message as returned by the database. } // Env holds the environment information. Env struct { Driver string `json:"Driver,omitempty"` // Driver name. URL *sqlclient.URL `json:"URL,omitempty"` // URL to dev database. Dir string `json:"Dir,omitempty"` // Path to migration directory. } // Changes represents a list of changes that are pending or applied. Changes struct { Applied []string `json:"Applied,omitempty"` // SQL changes applied with success Pending []string `json:"Pending,omitempty"` // SQL changes that were not applied Error *StmtError `json:"Error,omitempty"` // Error that occurred during applying } // A Revision denotes an applied migration in a deployment. Used to track migration executions state of a database. Revision struct { Version string `json:"Version"` // Version of the migration. Description string `json:"Description"` // Description of this migration. Type string `json:"Type"` // Type of the migration. Applied int `json:"Applied"` // Applied amount of statements in the migration. Total int `json:"Total"` // Total amount of statements in the migration. ExecutedAt time.Time `json:"ExecutedAt"` // ExecutedAt is the starting point of execution. ExecutionTime time.Duration `json:"ExecutionTime"` // ExecutionTime of the migration. Error string `json:"Error,omitempty"` // Error of the migration, if any occurred. ErrorStmt string `json:"ErrorStmt,omitempty"` // ErrorStmt is the statement that raised Error. OperatorVersion string `json:"OperatorVersion"` // OperatorVersion that executed this migration. } // A Report describes a schema analysis report with an optional specific diagnostic. Report struct { Text string `json:"Text"` // Report text. Desc string `json:"Desc,omitempty"` // Optional description (secondary text). Error bool `json:"Error,omitempty"` // Report is an error report. Diagnostics []Diagnostic `json:"Diagnostics,omitempty"` // Report diagnostics. } // A Diagnostic is a text associated with a specific position of a definition/element in a file. Diagnostic struct { Pos *schema.Pos `json:"Pos,omitempty"` // Element position. Text string `json:"Text"` // Diagnostic text. Code string `json:"Code,omitempty"` // Code describes the check (optional). } // TableSizeMetric represents a table size metric from schema stats TableSizeMetric struct { Schema string `json:"schema"` Table string `json:"table"` Value float64 `json:"value"` } ) // MetricTableSizeBytes is the name of the table size metric in bytes. const MetricTableSizeBytes = "atlas_table_size_bytes" // DiagnosticsCount returns the total number of diagnostics in the report. func (r *SummaryReport) DiagnosticsCount() int { var n int for _, f := range r.Files { for _, r := range f.Reports { n += len(r.Diagnostics) } } return n } // Errors returns the errors in the summary report, if exists. func (r *SummaryReport) Errors() []error { var errs []error for _, f := range r.Files { if f.Error != "" { errs = append(errs, errors.New(f.Error)) } } return errs } ================================================ FILE: atlasexec/atlas_schema.go ================================================ // Copyright 2021-present The Atlas Authors. All rights reserved. // This source code is licensed under the Apache 2.0 license found // in the LICENSE file in the root directory of this source tree. package atlasexec import ( "context" "encoding/json" "fmt" "strconv" "strings" "time" "ariga.io/atlas/sql/migrate" ) type ( // SchemaPushParams are the parameters for the `schema push` command. SchemaPushParams struct { ConfigURL string Env string Vars VarArgs Context *RunContext DevURL string URL []string // Desired schema URL(s) to push Schema []string // If set, only the specified schemas are pushed. Name string // Name of the schema (repo) to push to. Tag string // Tag to push the schema with Version string // Version of the schema to push. Defaults to the current timestamp. Description string // Description of the schema changes. } // SchemaPush represents the result of a 'schema push' command. SchemaPush struct { Link string Slug string URL string } // SchemaApplyParams are the parameters for the `schema apply` command. SchemaApplyParams struct { ConfigURL string Env string Vars VarArgs DevURL string URL string To string // TODO: change to []string TxMode string Exclude []string Include []string Schema []string DryRun bool // If true, --dry-run is set. AutoApprove bool // If true, --auto-approve is set. PlanURL string // URL of the plan in Atlas format (atlas:///plans/). (optional) LockName string } // SchemaApply represents the result of a 'schema apply' command. SchemaApply struct { Env // Changes holds the changes applied to the database. // Exists for backward compatibility with the old schema // apply structure as old SDK versions rely on it. Changes Changes `json:"Changes,omitempty"` Error string `json:"Error,omitempty"` // Any error that occurred during execution. Start time.Time `json:"Start,omitempty"` // When apply (including plan) started. End time.Time `json:"End,omitempty"` // When apply ended. Applied *AppliedFile `json:"Applied,omitempty"` // Applied migration file (pre-planned or computed). // Plan information might be partially filled. For example, if lint is done // during plan-stage, the linting report is available in the Plan field. If // the migration is pre-planned migration, the File.URL is set, etc. Plan *SchemaPlan `json:"Plan,omitempty"` } // SchemaApplyError is returned when an error occurred // during a schema applying attempt. SchemaApplyError struct { Result []*SchemaApply Stderr string } // SchemaInspectParams are the parameters for the `schema inspect` command. SchemaInspectParams struct { ConfigURL string Env string Vars VarArgs Format string DevURL string URL string Exclude []string Include []string Schema []string } // SchemaTestParams are the parameters for the `schema test` command. SchemaTestParams struct { ConfigURL string Env string Vars VarArgs DevURL string URL string Run string Paths []string } // SchemaPlanParams are the parameters for the `schema plan` command. SchemaPlanParams struct { ConfigURL string Env string Vars VarArgs Context *RunContext DevURL string Exclude []string Include []string Schema []string From, To []string Repo string Name string Directives []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. Pending bool Push, Save bool } // SchemaPlanListParams are the parameters for the `schema plan list` command. SchemaPlanListParams struct { ConfigURL string Env string Vars VarArgs Context *RunContext DevURL string Schema []string Exclude []string Include []string From, To []string Repo string Pending bool // If true, only pending plans are listed. } // SchemaPlanPushParams are the parameters for the `schema plan push` command. SchemaPlanPushParams struct { ConfigURL string Env string Vars VarArgs Context *RunContext DevURL string Schema []string Exclude []string Include []string From, To []string Repo string Pending bool // Push plan in pending state. File string // File to push. (optional) } // SchemaPlanPullParams are the parameters for the `schema plan pull` command. SchemaPlanPullParams struct { ConfigURL string Env string Vars VarArgs URL string // URL to the plan in Atlas format. (required) } // SchemaPlanLintParams are the parameters for the `schema plan lint` command. SchemaPlanLintParams struct { ConfigURL string Env string Vars VarArgs Context *RunContext DevURL string Schema []string Exclude []string Include []string From, To []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 Schema []string Exclude []string Include []string From, To []string Repo string Name 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. } // SchemaPlanApprove is the result of a 'schema plan approve' command. SchemaPlanApprove struct { 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. } // 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. FromDesc string `json:"FromDesc,omitempty"` // Optional description of the 'from' state. ToHash string `json:"ToHash,omitempty"` // Hash of the 'to' realm. ToDesc string `json:"ToDesc,omitempty"` // Optional description of the 'to' state. Migration string `json:"Migration,omitempty"` // Migration SQL. Stmts []*migrate.Stmt `json:"Stmts,omitempty"` // Statements in the migration (available only in the JSON output). // 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. } // SchemaCleanParams are the parameters for the `schema clean` command. SchemaCleanParams struct { ConfigURL string Env string Vars VarArgs URL string // URL of the schema to clean. (required) DryRun bool // If true, --dry-run is set. AutoApprove bool // If true, --auto-approve is set. } // SchemaClean represents the result of a 'schema clean' command. SchemaClean struct { Env Start time.Time `json:"Start,omitempty"` // When clean started. End time.Time `json:"End,omitempty"` // When clean ended. Applied *AppliedFile `json:"Applied,omitempty"` // Applied migration file. Error string `json:"Error,omitempty"` // Any error that occurred during execution. } // SchemaLintParams are the parameters for the `schema lint` command. SchemaLintParams struct { ConfigURL string Env string Vars VarArgs URL []string // Schema URL(s) to lint Schema []string // If set, only the specified schemas are linted. Format string DevURL string } // SchemaLintReport holds the results of a schema lint operation SchemaLintReport struct { Steps []Report `json:"Steps,omitempty"` } // SchemaStatsInspectParams are the parameters for the `schema stat inspect` command. SchemaStatsInspectParams struct { ConfigURL string Env string Vars VarArgs URL string Exclude []string Include []string Schema []string } ) // SchemaPush runs the 'schema push' command. func (c *Client) SchemaPush(ctx context.Context, params *SchemaPushParams) (*SchemaPush, error) { args := []string{"schema", "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 nil, err } args = append(args, "--context", string(buf)) } // Flags of the 'schema push' sub-commands args = append(args, repeatFlag("--url", params.URL)...) if params.DevURL != "" { args = append(args, "--dev-url", params.DevURL) } if len(params.Schema) > 0 { args = append(args, "--schema", listString(params.Schema)) } if params.Tag != "" { args = append(args, "--tag", params.Tag) } if params.Version != "" { args = append(args, "--version", params.Version) } if params.Description != "" { args = append(args, "--desc", params.Description) } if params.Name != "" { args = append(args, params.Name) } return firstResult(jsonDecode[SchemaPush](c.runCommand(ctx, args))) } // SchemaApply runs the 'schema apply' command. func (c *Client) SchemaApply(ctx context.Context, params *SchemaApplyParams) (*SchemaApply, error) { return firstResult(c.SchemaApplySlice(ctx, params)) } // SchemaApplySlice runs the 'schema apply' command for multiple targets. func (c *Client) SchemaApplySlice(ctx context.Context, params *SchemaApplyParams) ([]*SchemaApply, error) { args := []string{"schema", "apply", "--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 apply' sub-commands if params.URL != "" { args = append(args, "--url", params.URL) } if params.To != "" { args = append(args, "--to", params.To) } if params.TxMode != "" { args = append(args, "--tx-mode", params.TxMode) } if params.DevURL != "" { args = append(args, "--dev-url", params.DevURL) } if len(params.Schema) > 0 { args = append(args, "--schema", listString(params.Schema)) } if len(params.Exclude) > 0 { args = append(args, "--exclude", listString(params.Exclude)) } if len(params.Include) > 0 { args = append(args, "--include", listString(params.Include)) } if params.PlanURL != "" { args = append(args, "--plan", params.PlanURL) } if params.LockName != "" { args = append(args, "--lock-name", params.LockName) } switch { case params.DryRun: args = append(args, "--dry-run") case params.AutoApprove: args = append(args, "--auto-approve") } return jsonDecodeErr(newSchemaApplyError)(c.runCommand(ctx, args)) } // SchemaInspect runs the 'schema inspect' command. func (c *Client) SchemaInspect(ctx context.Context, params *SchemaInspectParams) (string, error) { args := []string{"schema", "inspect"} if params.Env != "" { args = append(args, "--env", params.Env) } if params.ConfigURL != "" { args = append(args, "--config", params.ConfigURL) } if params.URL != "" { args = append(args, "--url", params.URL) } if params.DevURL != "" { args = append(args, "--dev-url", params.DevURL) } switch { case params.Format == "sql": args = append(args, "--format", "{{ sql . }}") case params.Format != "": args = append(args, "--format", params.Format) } if len(params.Schema) > 0 { args = append(args, "--schema", listString(params.Schema)) } if len(params.Exclude) > 0 { args = append(args, "--exclude", listString(params.Exclude)) } if len(params.Include) > 0 { args = append(args, "--include", listString(params.Include)) } if params.Vars != nil { args = append(args, params.Vars.AsArgs()...) } return stringVal(c.runCommand(ctx, args)) } // SchemaTest runs the 'schema test' command. func (c *Client) SchemaTest(ctx context.Context, params *SchemaTestParams) (string, error) { args := []string{"schema", "test"} if params.Env != "" { args = append(args, "--env", params.Env) } if params.ConfigURL != "" { args = append(args, "--config", params.ConfigURL) } if params.URL != "" { args = append(args, "--url", params.URL) } if params.DevURL != "" { args = append(args, "--dev-url", params.DevURL) } if params.Run != "" { args = append(args, "--run", params.Run) } if params.Vars != nil { args = append(args, params.Vars.AsArgs()...) } if len(params.Paths) > 0 { args = append(args, params.Paths...) } 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.Schema) > 0 { args = append(args, "--schema", listString(params.Schema)) } if len(params.Exclude) > 0 { args = append(args, "--exclude", listString(params.Exclude)) } if len(params.Include) > 0 { args = append(args, "--include", listString(params.Include)) } 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) } if params.Save { args = append(args, "--save") } if params.Push { args = append(args, "--push") } if params.Pending { args = append(args, "--pending") } if params.DryRun { args = append(args, "--dry-run") } else { args = append(args, "--auto-approve") } for _, d := range params.Directives { args = append(args, "--directive", strconv.Quote(d)) } // NOTE: This command only support one result. return firstResult(jsonDecode[SchemaPlan](c.runCommand(ctx, args))) } // SchemaPlanList runs the `schema plan list` command. func (c *Client) SchemaPlanList(ctx context.Context, params *SchemaPlanListParams) ([]SchemaPlanFile, error) { args := []string{"schema", "plan", "list", "--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.Schema) > 0 { args = append(args, "--schema", listString(params.Schema)) } if len(params.Exclude) > 0 { args = append(args, "--exclude", listString(params.Exclude)) } if len(params.Include) > 0 { args = append(args, "--include", listString(params.Include)) } 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.Repo != "" { args = append(args, "--repo", params.Repo) } if params.Pending { args = append(args, "--pending") } args = append(args, "--auto-approve") // NOTE: This command only support one result. v, err := firstResult(jsonDecode[[]SchemaPlanFile](c.runCommand(ctx, args))) if err != nil { return nil, err } return *v, nil } // 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.Schema) > 0 { args = append(args, "--schema", listString(params.Schema)) } if len(params.Exclude) > 0 { args = append(args, "--exclude", listString(params.Exclude)) } if len(params.Include) > 0 { args = append(args, "--include", listString(params.Include)) } 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.Schema) > 0 { args = append(args, "--schema", listString(params.Schema)) } if len(params.Exclude) > 0 { args = append(args, "--exclude", listString(params.Exclude)) } if len(params.Include) > 0 { args = append(args, "--include", listString(params.Include)) } 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.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.Schema) > 0 { args = append(args, "--schema", listString(params.Schema)) } if len(params.Exclude) > 0 { args = append(args, "--exclude", listString(params.Exclude)) } if len(params.Include) > 0 { args = append(args, "--include", listString(params.Include)) } 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) (*SchemaPlanApprove, 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[SchemaPlanApprove](c.runCommand(ctx, args))) } // SchemaClean runs the `schema clean` command. func (c *Client) SchemaClean(ctx context.Context, params *SchemaCleanParams) (*SchemaClean, error) { args := []string{"schema", "clean", "--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 clean' sub-commands if params.URL != "" { args = append(args, "--url", params.URL) } switch { case params.DryRun: args = append(args, "--dry-run") case params.AutoApprove: args = append(args, "--auto-approve") } return firstResult(jsonDecode[SchemaClean](c.runCommand(ctx, args))) } // SchemaLint runs the 'schema lint' command. func (c *Client) SchemaLint(ctx context.Context, params *SchemaLintParams) (*SchemaLintReport, error) { args, err := params.AsArgs() if err != nil { return nil, err } return firstResult(jsonDecode[SchemaLintReport](c.runCommand(ctx, args))) } // SchemaStatsInspect runs the 'schema stats inspect' command. func (c *Client) SchemaStatsInspect(ctx context.Context, params *SchemaStatsInspectParams) (string, error) { args := []string{"schema", "stats", "inspect", "--format", "{{ json .Realm }}"} if params.Env != "" { args = append(args, "--env", params.Env) } if params.ConfigURL != "" { args = append(args, "--config", params.ConfigURL) } if params.URL != "" { args = append(args, "--url", params.URL) } if len(params.Schema) > 0 { args = append(args, "--schema", listString(params.Schema)) } if len(params.Exclude) > 0 { args = append(args, "--exclude", listString(params.Exclude)) } if len(params.Include) > 0 { args = append(args, "--include", listString(params.Include)) } if params.Vars != nil { args = append(args, params.Vars.AsArgs()...) } return stringVal(c.runCommand(ctx, args)) } // AsArgs returns the parameters as arguments. func (p *SchemaLintParams) AsArgs() ([]string, error) { args := []string{"schema", "lint", "--format", "{{ json . }}"} if p.Env != "" { args = append(args, "--env", p.Env) } if p.ConfigURL != "" { args = append(args, "--config", p.ConfigURL) } if p.DevURL != "" { args = append(args, "--dev-url", p.DevURL) } args = append(args, repeatFlag("--url", p.URL)...) if len(p.Schema) > 0 { args = append(args, "--schema", listString(p.Schema)) } if p.Vars != nil { args = append(args, p.Vars.AsArgs()...) } return args, nil } // 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, stderr string) error { return &SchemaApplyError{Result: r, Stderr: stderr} } // Error implements the error interface. func (e *SchemaApplyError) Error() string { var errs []string for _, r := range e.Result { if r.Error != "" { errs = append(errs, r.Error) } } if e.Stderr != "" { errs = append(errs, e.Stderr) } return strings.Join(errs, "\n") } ================================================ FILE: atlasexec/atlas_schema_test.go ================================================ // Copyright 2021-present The Atlas Authors. All rights reserved. // This source code is licensed under the Apache 2.0 license found // in the LICENSE file in the root directory of this source tree. package atlasexec_test import ( "context" "fmt" "net/http" "net/http/httptest" "os" "path/filepath" "testing" "ariga.io/atlas/atlasexec" "github.com/stretchr/testify/require" ) func TestSchema_Test(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) for _, tt := range []struct { name string params *atlasexec.SchemaTestParams args string stdout string }{ { name: "no params", params: &atlasexec.SchemaTestParams{}, args: "schema test", stdout: "test result", }, { name: "with env", params: &atlasexec.SchemaTestParams{ Env: "test", }, args: "schema test --env test", stdout: "test result", }, { name: "with config", params: &atlasexec.SchemaTestParams{ ConfigURL: "file://config.hcl", }, args: "schema test --config file://config.hcl", stdout: "test result", }, { name: "with dev-url", params: &atlasexec.SchemaTestParams{ DevURL: "sqlite://file?_fk=1&cache=shared&mode=memory", }, args: "schema test --dev-url sqlite://file?_fk=1&cache=shared&mode=memory", stdout: "test result", }, { name: "with run", params: &atlasexec.SchemaTestParams{ Run: "example", }, args: "schema test --run example", stdout: "test result", }, { name: "with run and paths", params: &atlasexec.SchemaTestParams{ Run: "example", Paths: []string{"./foo", "./bar"}, }, args: "schema test --run example ./foo ./bar", stdout: "test result", }, } { t.Run(tt.name, func(t *testing.T) { t.Setenv("TEST_ARGS", tt.args) t.Setenv("TEST_STDOUT", tt.stdout) result, err := c.SchemaTest(context.Background(), tt.params) require.NoError(t, err) require.Equal(t, tt.stdout, result) }) } } func TestSchema_Inspect(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) for _, tt := range []struct { name string params *atlasexec.SchemaInspectParams args string stdout string }{ { name: "no params", params: &atlasexec.SchemaInspectParams{}, args: "schema inspect", stdout: `schema "public" {}`, }, { name: "with env", params: &atlasexec.SchemaInspectParams{ Env: "test", }, args: "schema inspect --env test", stdout: `schema "public" {}`, }, { name: "with config", params: &atlasexec.SchemaInspectParams{ ConfigURL: "file://config.hcl", Env: "test", }, args: "schema inspect --env test --config file://config.hcl", stdout: `schema "public" {}`, }, } { t.Run(tt.name, func(t *testing.T) { t.Setenv("TEST_ARGS", tt.args) t.Setenv("TEST_STDOUT", tt.stdout) result, err := c.SchemaInspect(context.Background(), tt.params) require.NoError(t, err) require.Equal(t, tt.stdout, result) }) } } func TestAtlasSchema_Apply(t *testing.T) { ce, err := atlasexec.NewWorkingDir() require.NoError(t, err) t.Cleanup(func() { require.NoError(t, ce.Close()) }) f, err := os.CreateTemp("", "sqlite-test") require.NoError(t, err) defer os.Remove(f.Name()) u := fmt.Sprintf("sqlite://%s?_fk=1", f.Name()) c, err := atlasexec.NewClient(ce.Path(), "atlas") require.NoError(t, err) s1 := ` -- create table "users CREATE TABLE users( id int NOT NULL, name varchar(100) NULL, PRIMARY KEY(id) );` path, err := ce.WriteFile("schema.sql", []byte(s1)) to := fmt.Sprintf("file://%s", path) require.NoError(t, err) _, err = c.SchemaApply(context.Background(), &atlasexec.SchemaApplyParams{ URL: u, To: to, DevURL: "sqlite://file?_fk=1&cache=shared&mode=memory", AutoApprove: true, }) require.NoError(t, err) _, err = ce.WriteFile("schema.sql", []byte(s1+` -- create table "blog_posts" CREATE TABLE blog_posts( id int NOT NULL, title varchar(100) NULL, body text NULL, author_id int NULL, PRIMARY KEY(id), CONSTRAINT author_fk FOREIGN KEY(author_id) REFERENCES users(id) );`)) require.NoError(t, err) _, err = c.SchemaApply(context.Background(), &atlasexec.SchemaApplyParams{ URL: u, To: to, DevURL: "sqlite://file?_fk=1&cache=shared&mode=memory", AutoApprove: true, }) require.NoError(t, err) s, err := c.SchemaInspect(context.Background(), &atlasexec.SchemaInspectParams{ URL: u, }) require.NoError(t, err) require.Equal(t, `table "users" { schema = schema.main column "id" { null = false type = int } column "name" { null = true type = varchar } primary_key { columns = [column.id] } } table "blog_posts" { schema = schema.main column "id" { null = false type = int } column "title" { null = true type = varchar } column "body" { null = true type = text } column "author_id" { null = true type = int } primary_key { columns = [column.id] } foreign_key "author_fk" { columns = [column.author_id] ref_columns = [table.users.column.id] on_update = NO_ACTION on_delete = NO_ACTION } } 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 from to and schema", params: &atlasexec.SchemaPlanParams{ From: []string{"1", "2"}, To: []string{"2", "3"}, Schema: []string{"public", "bupisu"}, }, args: `schema plan --format {{ json . }} --schema public,bupisu --from 1,2 --to 2,3 --auto-approve`, }, { name: "with from to and directives", params: &atlasexec.SchemaPlanParams{ From: []string{"1", "2"}, To: []string{"2", "3"}, Directives: []string{"atlas:nolint", "atlas:txmode none"}, }, args: `schema plan --format {{ json . }} --from 1,2 --to 2,3 --auto-approve --directive "atlas:nolint" --directive "atlas:txmode none"`, }, { 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", }, { name: "with include", params: &atlasexec.SchemaPlanParams{ Include: []string{"public", "bupisu"}, }, args: "schema plan --format {{ json . }} --include public,bupisu --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 auto-approve and schema", params: &atlasexec.SchemaPlanPushParams{ Repo: "testing-repo", File: "file://plan.hcl", Schema: []string{"public", "bupisu"}, }, args: "schema plan push --format {{ json . }} --schema public,bupisu --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", }, { name: "with file and schema", params: &atlasexec.SchemaPlanLintParams{ File: "file://plan.hcl", Schema: []string{"public", "bupisu"}, }, args: "schema plan lint --format {{ json . }} --schema public,bupisu --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", }, { name: "with file and schema", params: &atlasexec.SchemaPlanValidateParams{ File: "file://plan.hcl", Schema: []string{"public", "bupisu"}, }, args: "schema plan validate --schema public,bupisu --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", `{"URL":"atlas://app1/plans/foo-plan", "Link":"some-link", "Status":"APPROVED"}`) result, err := c.SchemaPlanApprove(context.Background(), tt.params) require.NoError(t, err) require.Equal(t, "atlas://app1/plans/foo-plan", result.URL) }) } } 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) }) } } func TestSchema_PlanList(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.SchemaPlanListParams args string }{ { name: "no params", params: &atlasexec.SchemaPlanListParams{}, args: "schema plan list --format {{ json . }} --auto-approve", }, { name: "with repo", params: &atlasexec.SchemaPlanListParams{ Repo: "atlas://testing-repo", From: []string{"env://url"}, }, args: "schema plan list --format {{ json . }} --from env://url --repo atlas://testing-repo --auto-approve", }, { name: "with repo and schema", params: &atlasexec.SchemaPlanListParams{ Repo: "atlas://testing-repo", From: []string{"env://url"}, Schema: []string{"public", "bupisu"}, }, args: "schema plan list --format {{ json . }} --schema public,bupisu --from env://url --repo atlas://testing-repo --auto-approve", }, { name: "with repo and pending", params: &atlasexec.SchemaPlanListParams{ Repo: "atlas://testing-repo", Pending: true, }, args: "schema plan list --format {{ json . }} --repo atlas://testing-repo --pending --auto-approve", }, } for _, tt := range testCases { t.Run(tt.name, func(t *testing.T) { t.Setenv("TEST_ARGS", tt.args) t.Setenv("TEST_STDOUT", `[{"Name":"pr-2-ufnTS7Nr"}]`) result, err := c.SchemaPlanList(context.Background(), tt.params) require.NoError(t, err) require.Equal(t, "pr-2-ufnTS7Nr", result[0].Name) }) } } func TestSchema_Push(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.SchemaPushParams args string }{ { name: "no params", params: &atlasexec.SchemaPushParams{}, args: "schema push --format {{ json . }}", }, { name: "push with 1 URL", params: &atlasexec.SchemaPushParams{ URL: []string{"file://foo.hcl"}, }, args: "schema push --format {{ json . }} --url file://foo.hcl", }, { name: "push with 2 URLs", params: &atlasexec.SchemaPushParams{ URL: []string{"file://foo.hcl", "file://bupisu.hcl"}, }, args: "schema push --format {{ json . }} --url file://foo.hcl --url file://bupisu.hcl", }, { name: "with repo", params: &atlasexec.SchemaPushParams{ Name: "atlas-action", }, args: "schema push --format {{ json . }} atlas-action", }, { name: "with repo and schemas", params: &atlasexec.SchemaPushParams{ Name: "atlas-action", Schema: []string{"public", "bupisu"}, }, args: "schema push --format {{ json . }} --schema public,bupisu atlas-action", }, { name: "with repo and tag", params: &atlasexec.SchemaPushParams{ Name: "atlas-action", Tag: "v1.0.0", }, args: "schema push --format {{ json . }} --tag v1.0.0 atlas-action", }, { name: "with repo and tag and description", params: &atlasexec.SchemaPushParams{ Name: "atlas-action", Tag: "v1.0.0", Description: "release-v1", }, args: "schema push --format {{ json . }} --tag v1.0.0 --desc release-v1 atlas-action", }, { name: "with repo and tag, version and description", params: &atlasexec.SchemaPushParams{ Name: "atlas-action", Tag: "v1.0.0", Version: "20240829100417", Description: "release-v1", }, args: "schema push --format {{ json . }} --tag v1.0.0 --version 20240829100417 --desc release-v1 atlas-action", }, } for _, tt := range testCases { t.Run(tt.name, func(t *testing.T) { t.Setenv("TEST_ARGS", tt.args) t.Setenv("TEST_STDOUT", `{"Link":"https://gh.atlasgo.cloud/schemas/141733920810","Slug":"awesome-app","URL":"atlas://awesome-app?tag=latest"}`) result, err := c.SchemaPush(context.Background(), tt.params) require.NoError(t, err) require.Equal(t, "https://gh.atlasgo.cloud/schemas/141733920810", result.Link) require.Equal(t, "atlas://awesome-app?tag=latest", result.URL) require.Equal(t, "awesome-app", result.Slug) }) } } func TestSchema_Apply(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.SchemaApplyParams args string }{ { name: "no params", params: &atlasexec.SchemaApplyParams{}, args: "schema apply --format {{ json . }}", }, { name: "with plan", params: &atlasexec.SchemaApplyParams{ PlanURL: "atlas://app1/plans/foo-plan", }, args: "schema apply --format {{ json . }} --plan atlas://app1/plans/foo-plan", }, { name: "with auto-approve", params: &atlasexec.SchemaApplyParams{ AutoApprove: true, }, args: "schema apply --format {{ json . }} --auto-approve", }, { name: "with lock name", params: &atlasexec.SchemaApplyParams{ LockName: "custom_schema_lock", }, args: "schema apply --format {{ json . }} --lock-name custom_schema_lock", }, } for _, tt := range testCases { t.Run(tt.name, func(t *testing.T) { t.Setenv("TEST_ARGS", tt.args) t.Setenv("TEST_STDOUT", `{"Driver":"sqlite"}`) result, err := c.SchemaApply(context.Background(), tt.params) require.NoError(t, err) require.Equal(t, "sqlite", result.Driver) }) } } func TestSchema_Clean(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.SchemaCleanParams args string }{ { name: "with env and dry-run", params: &atlasexec.SchemaCleanParams{ Env: "test", URL: "sqlite://app1.db", DryRun: true, }, args: "schema clean --format {{ json . }} --env test --url sqlite://app1.db --dry-run", }, { name: "with auto-approve", params: &atlasexec.SchemaCleanParams{ URL: "sqlite://app1.db", AutoApprove: true, }, args: "schema clean --format {{ json . }} --url sqlite://app1.db --auto-approve", }, } for _, tt := range testCases { t.Run(tt.name, func(t *testing.T) { t.Setenv("TEST_ARGS", tt.args) t.Setenv("TEST_STDOUT", "{\"Start\":\"2024-09-20T14:51:40.439499+07:00\",\"End\":\"2024-09-20T14:51:40.439533+07:00\",\"Applied\":{\"Name\":\"20240920075140.sql\",\"Version\":\"20240920075140\",\"Start\":\"2024-09-20T14:51:40.43952+07:00\",\"End\":\"2024-09-20T14:51:40.439533+07:00\",\"Applied\":[\"PRAGMA foreign_keys = off;\",\"DROP TABLE `t1`;\", \"PRAGMA foreign_keys = on;\"]}}") result, err := c.SchemaClean(context.Background(), tt.params) require.NoError(t, err) require.Equal(t, "20240920075140.sql", result.Applied.Name) }) } } 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":"sqlite","URL":{"Scheme":"sqlite","Host":"local-su.db"}} {"Driver":"sqlite","URL":{"Scheme":"sqlite","Host":"local-pi.db"}} {"Driver":"sqlite","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()) } func TestSchemaApplyError_Error(t *testing.T) { t.Run("single result error only", func(t *testing.T) { e := &atlasexec.SchemaApplyError{ Result: []*atlasexec.SchemaApply{ {Error: "schema apply failed"}, }, } require.Equal(t, "schema apply failed", e.Error()) }) t.Run("stderr only", func(t *testing.T) { e := &atlasexec.SchemaApplyError{ Stderr: "Error: unable to acquire lock", } require.Equal(t, "Error: unable to acquire lock", e.Error()) }) t.Run("single result error and stderr", func(t *testing.T) { e := &atlasexec.SchemaApplyError{ Result: []*atlasexec.SchemaApply{ {Error: "schema apply failed"}, }, Stderr: "Error: unable to acquire lock", } require.Equal(t, "schema apply failed\nError: unable to acquire lock", e.Error()) }) t.Run("multiple result errors and stderr", func(t *testing.T) { e := &atlasexec.SchemaApplyError{ Result: []*atlasexec.SchemaApply{ {Error: "error on target 1"}, {Error: "error on target 2"}, }, Stderr: "Error: unable to acquire lock", } require.Equal(t, "error on target 1\nerror on target 2\nError: unable to acquire lock", e.Error()) }) t.Run("multiple results with some having no error", func(t *testing.T) { e := &atlasexec.SchemaApplyError{ Result: []*atlasexec.SchemaApply{ {Error: ""}, {Error: "error on target 2"}, {Error: ""}, }, Stderr: "Error: unable to acquire lock", } require.Equal(t, "error on target 2\nError: unable to acquire lock", e.Error()) }) t.Run("no errors at all", func(t *testing.T) { e := &atlasexec.SchemaApplyError{ Result: []*atlasexec.SchemaApply{ {Error: ""}, }, } require.Equal(t, "", e.Error()) }) t.Run("nil result with stderr", func(t *testing.T) { e := &atlasexec.SchemaApplyError{ Stderr: "Error: connection refused", } require.Equal(t, "Error: connection refused", e.Error()) }) } func TestAtlasSchema_Lint(t *testing.T) { t.Run("with broken config", func(t *testing.T) { c, err := atlasexec.NewClient(".", "atlas") require.NoError(t, err) _, err = c.SchemaLint(context.Background(), &atlasexec.SchemaLintParams{ ConfigURL: "file://config-broken.hcl", }) require.ErrorContains(t, err, `file "config-broken.hcl" was not found`) }) t.Run("with missing dev-url", func(t *testing.T) { c, err := atlasexec.NewClient(".", "atlas") require.NoError(t, err) _, err = c.SchemaLint(context.Background(), &atlasexec.SchemaLintParams{ URL: []string{"file://testdata/schema.hcl"}, }) require.ErrorContains(t, err, `required flag(s) "dev-url" not set`) }) var ( atlashcl = filepath.Join(t.TempDir(), "atlas.hcl") srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { fmt.Fprintln(w, `{"data": {"me":{ "name": "p", "org": "life"}}}`) })) ) t.Cleanup(srv.Close) require.NoError(t, os.WriteFile(atlashcl, []byte(fmt.Sprintf(` atlas { cloud { token = "aci_token" url = %q org = "life" } } lint { naming { table { match = "^[a-z_]+$" } } }`, srv.URL)), 0600)) t.Run("with non-existent schema file", func(t *testing.T) { c, err := atlasexec.NewClient(".", "atlas") require.NoError(t, err) _, err = c.SchemaLint(context.Background(), &atlasexec.SchemaLintParams{ ConfigURL: "file://" + atlashcl, DevURL: "sqlite://file?mode=memory", URL: []string{"file://testdata/doesnotexist.hcl"}, }) require.ErrorContains(t, err, "Error: stat testdata/doesnotexist.hcl: no such file or directory") }) t.Run("with schema containing problems", func(t *testing.T) { c, err := atlasexec.NewClient(".", "atlas") require.NoError(t, err) report, err := c.SchemaLint(context.Background(), &atlasexec.SchemaLintParams{ ConfigURL: "file://" + atlashcl, DevURL: "sqlite://file?mode=memory", URL: []string{sqlitedb(t, "create table T1(id int);")}, }) require.NoError(t, err) require.NotNil(t, report) require.NotEmpty(t, report.Steps) require.Len(t, report.Steps, 1) require.Len(t, report.Steps[0].Diagnostics, 1) require.Equal(t, "Table \"main.T1\" violates the naming policy", report.Steps[0].Diagnostics[0].Text) require.Equal(t, "NM102", report.Steps[0].Diagnostics[0].Code) }) } func TestSchema_Lint(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.SchemaLintParams args string }{ { name: "with dev-url and url", params: &atlasexec.SchemaLintParams{ URL: []string{"file://testdata/schema.hcl"}, DevURL: "sqlite://file?mode=memory", }, args: "schema lint --format {{ json . }} --dev-url sqlite://file?mode=memory --url file://testdata/schema.hcl", }, { name: "with dev-url and url and schema", params: &atlasexec.SchemaLintParams{ URL: []string{"file://testdata/schema.hcl"}, DevURL: "sqlite://file?mode=memory", Schema: []string{"main", "bupisu"}, }, args: "schema lint --format {{ json . }} --dev-url sqlite://file?mode=memory --url file://testdata/schema.hcl --schema main,bupisu", }, } for _, tt := range testCases { t.Run(tt.name, func(t *testing.T) { t.Setenv("TEST_ARGS", tt.args) t.Setenv("TEST_STDOUT", `{"Steps":[{"Diagnostics":[{"Text":"Table \"main.T1\" violates the naming policy","Code":"NM102"}]}]}`) result, err := c.SchemaLint(context.Background(), tt.params) require.NoError(t, err) require.NotNil(t, result) require.NotEmpty(t, result.Steps) require.Len(t, result.Steps, 1) require.Len(t, result.Steps[0].Diagnostics, 1) require.Equal(t, "Table \"main.T1\" violates the naming policy", result.Steps[0].Diagnostics[0].Text) }) } } ================================================ FILE: atlasexec/atlas_test.go ================================================ // Copyright 2021-present The Atlas Authors. All rights reserved. // This source code is licensed under the Apache 2.0 license found // in the LICENSE file in the root directory of this source tree. package atlasexec_test import ( "context" "database/sql" "fmt" "net/http/httptest" "os" "os/exec" "path/filepath" "testing" "ariga.io/atlas/atlasexec" _ "github.com/mattn/go-sqlite3" "github.com/stretchr/testify/require" ) func TestError(t *testing.T) { err := atlasexec.Error{} require.NotPanics(t, func() { err.ExitCode() }) } func TestNewClient(t *testing.T) { execPath, err := exec.LookPath("atlas") require.NoError(t, err) // Test that we can create a client with a custom exec path. _, err = atlasexec.NewClient(t.TempDir(), execPath) require.NoError(t, err) // Atlas-CLI is installed in the PATH. _, err = atlasexec.NewClient(t.TempDir(), "atlas") require.NoError(t, err) // Atlas-CLI is not found for the given exec path. _, err = atlasexec.NewClient(t.TempDir(), "/foo/atlas") require.ErrorContains(t, err, `no such file or directory`) } func TestVersion(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) for _, tt := range []struct { env string expect *atlasexec.Version }{ { env: "v1.2.3", expect: &atlasexec.Version{Version: "1.2.3"}, }, { env: "v0.14.1-abcdef-canary", expect: &atlasexec.Version{ Version: "0.14.1", SHA: "abcdef", Canary: true, }, }, { env: "v11.22.33-sha", expect: &atlasexec.Version{ Version: "11.22.33", SHA: "sha", }, }, } { t.Run(tt.env, func(t *testing.T) { t.Setenv("TEST_ARGS", "version") t.Setenv("TEST_STDOUT", fmt.Sprintf("atlas version %s", tt.env)) v, err := c.Version(context.Background()) require.NoError(t, err) require.Equal(t, tt.expect, v) if tt.env != "" { require.Equal(t, "atlas version "+tt.env, v.String()) } }) } } func TestLogin(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) // Empty token returns error without invoking the binary. err = c.Login(context.Background(), &atlasexec.LoginParams{Token: ""}) require.Error(t, err) require.Contains(t, err.Error(), "token cannot be empty") // Login with token only: must pass "login --token ". t.Run("token_only", func(t *testing.T) { t.Setenv("TEST_ARGS", "login --token my-token") t.Setenv("TEST_STDOUT", "ok") t.Setenv("TEST_STDERR", "") err := c.Login(context.Background(), &atlasexec.LoginParams{Token: "my-token"}) require.NoError(t, err) }) // Login with token and GrantOnly: must pass "login --token --grant-only". t.Run("grant_only", func(t *testing.T) { t.Setenv("TEST_ARGS", "login --token my-token --grant-only") t.Setenv("TEST_STDOUT", "ok") t.Setenv("TEST_STDERR", "") err := c.Login(context.Background(), &atlasexec.LoginParams{Token: "my-token", GrantOnly: true}) require.NoError(t, err) }) } func TestWhoAmI(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) t.Setenv("TEST_ARGS", "whoami --format {{ json . }}") // Test success. t.Setenv("TEST_STDOUT", `{"Org":"boring"}`) v, err := c.WhoAmI(context.Background(), &atlasexec.WhoAmIParams{}) require.NoError(t, err) require.NotNil(t, v) require.Equal(t, "boring", v.Org) // Test error. t.Setenv("TEST_STDOUT", "") t.Setenv("TEST_STDERR", `Error: command requires 'atlas login'`) _, err = c.WhoAmI(context.Background(), &atlasexec.WhoAmIParams{}) require.EqualError(t, err, "command requires 'atlas login'") require.ErrorIs(t, err, atlasexec.ErrRequireLogin) // Test config url t.Setenv("TEST_ARGS", "whoami --format {{ json . }} --config file://config.hcl --env local --var foo=bar") t.Setenv("TEST_STDOUT", `{"Org":"boring"}`) t.Setenv("TEST_STDERR", "") v, err = c.WhoAmI(context.Background(), &atlasexec.WhoAmIParams{ ConfigURL: "file://config.hcl", Env: "local", Vars: atlasexec.Vars{"foo": "bar"}, }) require.NoError(t, err) require.NotNil(t, v) require.Equal(t, "boring", v.Org) } func TestVars2(t *testing.T) { var vars = atlasexec.Vars2{ "key1": "value1", "key2": "value2", "key3": []string{"value3", "value4"}, "key4": 100, "key5": []int{1, 2, 3}, "key6": []stringer{{}, {}}, } require.Equal(t, []string{ "--var", "key1=value1", "--var", "key2=value2", "--var", "key3=value3", "--var", "key3=value4", "--var", "key4=100", "--var", "key5=1", "--var", "key5=2", "--var", "key5=3", "--var", "key6=foo", "--var", "key6=foo", }, vars.AsArgs()) } func generateHCL(t *testing.T, token string, srv *httptest.Server) string { st := fmt.Sprintf( `atlas { cloud { token = %q url = %q } } env "test" {} `, token, srv.URL) atlasConfigURL, clean, err := atlasexec.TempFile(st, "hcl") require.NoError(t, err) t.Cleanup(func() { require.NoError(t, clean()) }) return atlasConfigURL } func sqlitedb(t *testing.T, seed string) string { td := t.TempDir() dsn := fmt.Sprintf("file:%s?cache=shared&_fk=1", filepath.Join(td, "file.db")) db, err := sql.Open("sqlite3", dsn) require.NoError(t, err) if seed != "" { _, err = db.ExecContext(context.Background(), seed) require.NoError(t, err) } return fmt.Sprintf("sqlite://%s", dsn) } type stringer struct{} func (s stringer) String() string { return "foo" } ================================================ FILE: atlasexec/copilot.go ================================================ // Copyright 2021-present The Atlas Authors. All rights reserved. // This source code is licensed under the Apache 2.0 license found // in the LICENSE file in the root directory of this source tree. package atlasexec import ( "context" "encoding/json" "strings" ) // CopilotTypeMessage, CopilotTypeToolCall, and CopilotTypeToolOutput are the // types of messages that can be emitted by the Copilot execution. const ( CopilotTypeMessage = "message" CopilotTypeToolCall = "tool_call" CopilotTypeToolOutput = "tool_output" ) type ( // CopilotParams are the parameters for the Copilot execution. CopilotParams struct { Prompt, Session string // FSWrite and FSDelete glob patterns to specify file permissions. FSWrite, FSDelete string } // Copilot is the result of a Copilot execution. Copilot []*CopilotMessage // CopilotMessage is the JSON message emitted by the Copilot OneShot execution. CopilotMessage struct { // Session ID for the Copilot session. SessionID string `json:"sessionID,omitempty"` // Type of the message. Can be "message", "tool_call", or "tool_output". Type string `json:"type"` // Content, ToolCall and ToolOutput are mutually exclusive. Content string `json:"content,omitempty"` ToolCall *ToolCallMessage `json:"toolCall,omitempty"` ToolOutput *ToolOutputMessage `json:"toolOutput,omitempty"` } // ToolCallMessage is the input to a tool call. ToolCallMessage struct { CallID string `json:"callID"` Function string `json:"function"` Arguments string `json:"arguments"` } // ToolOutputMessage is the output of a tool call. ToolOutputMessage struct { CallID string `json:"callID"` Content string `json:"content"` } ) // Copilot executes a one-shot Copilot session with the provided options. func (c *Client) Copilot(ctx context.Context, params *CopilotParams) (Copilot, error) { args := []string{"copilot", "-q", params.Prompt} if params.Session != "" { args = append(args, "-r", params.Session) } if params.FSWrite != "" { args = append(args, "-p", "fs.write="+params.FSWrite) } if params.FSDelete != "" { args = append(args, "-p", "fs.delete="+params.FSDelete) } return jsonDecode[CopilotMessage](c.runCommand(ctx, args)) } type copilotStream struct { s Stream[string] cur *CopilotMessage err error } // Next advances the stream to the next CopilotMessage. func (s *copilotStream) Next() bool { s.cur = nil s.err = nil return s.s.Next() } // Current returns the current CopilotMessage from the stream. func (s *copilotStream) Current() (*CopilotMessage, error) { if s.err != nil { return nil, s.err } if s.cur == nil { cur, err := s.s.Current() if err != nil { s.err = err return nil, err } var m CopilotMessage if s.err = json.Unmarshal([]byte(cur), &m); s.err != nil { return nil, s.err } s.cur = &m } return s.cur, nil } // Err returns the error encountered during the stream processing. func (s *copilotStream) Err() error { if s.err != nil { return s.err } return s.s.Err() } var _ Stream[*CopilotMessage] = (*copilotStream)(nil) // CopilotStream executes a one-shot Copilot session, streaming the result. func (c *Client) CopilotStream(ctx context.Context, params *CopilotParams) (Stream[*CopilotMessage], error) { args := []string{"copilot", "-q", params.Prompt} if params.Session != "" { args = append(args, "-r", params.Session) } if params.FSWrite != "" { args = append(args, "-p", "fs.write="+params.FSWrite) } if params.FSDelete != "" { args = append(args, "-p", "fs.delete="+params.FSDelete) } s, err := c.runCommandStream(ctx, args) if err != nil { return nil, err } return &copilotStream{s: s}, nil } func (c Copilot) String() string { var buf strings.Builder for _, msg := range c { if msg.Type == CopilotTypeMessage { buf.WriteString(msg.Content) } } return buf.String() } ================================================ FILE: atlasexec/copilot_test.go ================================================ // Copyright 2021-present The Atlas Authors. All rights reserved. // This source code is licensed under the Apache 2.0 license found // in the LICENSE file in the root directory of this source tree. package atlasexec_test import ( "context" "fmt" "os" "path/filepath" "testing" "ariga.io/atlas/atlasexec" "github.com/stretchr/testify/require" ) func TestCopilot(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) p := &atlasexec.CopilotParams{Prompt: "What is the capital of France?"} t.Setenv("TEST_ARGS", "copilot -q "+p.Prompt) t.Setenv("TEST_STDOUT", `{"sessionID":"id","type":"message","content":"The capital of"} {"sessionID":"id","type":"tool_call","toolCall":{"callID":"1","function":"get_capital","arguments":"France"}} {"sessionID":"id","type":"tool_output","toolOutput":{"callID":"1","content":"Paris"}} {"sessionID":"id","type":"message","content":" France is Paris."}`) copilot, err := c.Copilot(context.Background(), p) require.NoError(t, err) require.Equal(t, "The capital of France is Paris.", copilot.String()) p = &atlasexec.CopilotParams{Prompt: "And Germany?", Session: "id"} t.Setenv("TEST_ARGS", fmt.Sprintf("copilot -q %s -r %s", p.Prompt, p.Session)) t.Setenv("TEST_STDOUT", `{"sessionID":"id","type":"message","content":"Berlin."}`) copilot, err = c.Copilot(context.Background(), p) require.NoError(t, err) require.Equal(t, "Berlin.", copilot.String()) p = &atlasexec.CopilotParams{Prompt: "And Israel?", Session: "id", FSWrite: "*", FSDelete: "**"} t.Setenv("TEST_ARGS", fmt.Sprintf("copilot -q %s -r %s -p fs.write=%s -p fs.delete=%s", p.Prompt, p.Session, p.FSWrite, p.FSDelete)) t.Setenv("TEST_STDOUT", `{"sessionID":"id","type":"message","content":"Jerusalem."}`) copilot, err = c.Copilot(context.Background(), p) require.NoError(t, err) require.Equal(t, "Jerusalem.", copilot.String()) msgs := []string{ "Those are of course the Atlas founders.", " CEO is Ariel Mashraki,", " who's ability to craft clean", " , efficient, and elegant code is legendary.", " CTO is Rotem Tamir, also known", " as 'THE coding and wording wizard'.", } var out string for _, msg := range msgs { out += fmt.Sprintf(`{"sessionID":"id","type":"message","content":"%s"}`+"\n", msg) } p = &atlasexec.CopilotParams{Prompt: "Who are the coolest people in the world?"} t.Setenv("TEST_ARGS", "copilot -q "+p.Prompt) t.Setenv("TEST_STDOUT", out) s, err := c.CopilotStream(context.Background(), p) require.NoError(t, err) var ( m *atlasexec.CopilotMessage i int ) for s.Next() { m, err = s.Current() require.NoError(t, err) require.Equal(t, &atlasexec.CopilotMessage{ SessionID: "id", Type: "message", Content: msgs[i], }, m) i++ } require.NoError(t, s.Err()) } ================================================ FILE: atlasexec/internal/e2e/sqlite_test.go ================================================ // Copyright 2021-present The Atlas Authors. All rights reserved. // This source code is licensed under the Apache 2.0 license found // in the LICENSE file in the root directory of this source tree. package e2etest import ( "context" "database/sql" "fmt" "log" "os" "testing" "ariga.io/atlas/atlasexec" "github.com/stretchr/testify/require" _ "github.com/mattn/go-sqlite3" ) func Test_SQLite(t *testing.T) { runTestWithVersions(t, []string{"latest"}, "versioned-basic", func(t *testing.T, _ *atlasexec.Version, _ *atlasexec.WorkingDir, c *atlasexec.Client) { url := "sqlite://file.db?_fk=1" ctx := context.Background() s, err := c.MigrateStatus(ctx, &atlasexec.MigrateStatusParams{ URL: url, Env: "local", }) require.NoError(t, err) require.Equal(t, 1, len(s.Pending)) require.Equal(t, "20240112070806", s.Pending[0].Version) r, err := c.MigrateApply(ctx, &atlasexec.MigrateApplyParams{ URL: url, Env: "local", }) require.NoError(t, err) require.Equal(t, 1, len(r.Applied), "Should be one migration applied") require.Equal(t, "20240112070806", r.Applied[0].Version, "Should be the correct migration applied") // Apply again, should be a no-op. r, err = c.MigrateApply(ctx, &atlasexec.MigrateApplyParams{ URL: url, Env: "local", }) require.NoError(t, err, "Should be no error") require.Equal(t, 0, len(r.Applied), "Should be no migrations applied") }) } func Test_PostgreSQL(t *testing.T) { u := os.Getenv("ATLASEXEC_E2ETEST_POSTGRES_URL") if u == "" { t.Skip("ATLASEXEC_E2ETEST_POSTGRES_URL not set") } runTestWithVersions(t, []string{"latest"}, "versioned-basic", func(t *testing.T, _ *atlasexec.Version, _ *atlasexec.WorkingDir, c *atlasexec.Client) { url := u ctx := context.Background() s, err := c.MigrateStatus(ctx, &atlasexec.MigrateStatusParams{ URL: url, Env: "local", }) require.NoError(t, err) require.Equal(t, 1, len(s.Pending)) require.Equal(t, "20240112070806", s.Pending[0].Version) r, err := c.MigrateApply(ctx, &atlasexec.MigrateApplyParams{ URL: url, Env: "local", }) require.NoError(t, err) require.Equal(t, 1, len(r.Applied), "Should be one migration applied") require.Equal(t, "20240112070806", r.Applied[0].Version, "Should be the correct migration applied") // Apply again, should be a no-op. r, err = c.MigrateApply(ctx, &atlasexec.MigrateApplyParams{ URL: url, Env: "local", }) require.NoError(t, err, "Should be no error") require.Equal(t, 0, len(r.Applied), "Should be no migrations applied") }) } func Test_MultiTenants(t *testing.T) { t.Setenv("ATLASEXEC_E2ETEST_ATLAS_PATH", "atlas") runTestWithVersions(t, []string{"latest"}, "multi-tenants", func(t *testing.T, _ *atlasexec.Version, wd *atlasexec.WorkingDir, c *atlasexec.Client) { ctx := context.Background() r, err := c.MigrateApplySlice(ctx, &atlasexec.MigrateApplyParams{ Env: "local", Amount: 1, // Only apply one migration. }) require.NoError(t, err) require.Len(t, r, 2, "Should be two tenants") require.Equal(t, 1, len(r[0].Applied), "Should be one migration applied") require.Equal(t, "20240112070806", r[0].Applied[0].Version, "Should be the correct migration applied") // Insert some data to the second tenant to make the migration fail. db, err := sql.Open("sqlite3", fmt.Sprintf("file:%s?_fk=1", wd.Path("foo.db"))) if err != nil { log.Fatalf("failed opening db: %s", err) } _, err = db.Exec("INSERT INTO t1(c1) VALUES (1),(1),(1)") require.NoError(t, err) // Apply again, should be one successful and one failed migration. _, err = c.MigrateApplySlice(ctx, &atlasexec.MigrateApplyParams{ Env: "local", }) require.ErrorContains(t, err, "UNIQUE constraint failed", "Should be error") mae, ok := err.(*atlasexec.MigrateApplyError) require.True(t, ok, "Should be a MigrateApplyError") require.Len(t, mae.Result, 2, "Should be two reports") require.Equal(t, 1, len(mae.Result[0].Applied), "Should be one migration applied") require.Equal(t, "20240116003831", mae.Result[0].Applied[0].Version, "Should be the correct migration applied") require.Equal(t, 1, len(mae.Result[1].Applied), "Should be one migration applied") require.Contains(t, mae.Result[1].Error, "UNIQUE constraint failed", "Should be the correct error") // Apply again, should be one successful and one failed migration. _, err = c.MigrateApplySlice(ctx, &atlasexec.MigrateApplyParams{ Env: "local", }) require.ErrorContains(t, err, "UNIQUE constraint failed", "Should be error") mae, ok = err.(*atlasexec.MigrateApplyError) require.True(t, ok, "Should be a MigrateApplyError") require.Len(t, mae.Result, 2, "Should be two reports") require.Equal(t, 0, len(mae.Result[0].Applied), "Should be no migrations applied") require.Equal(t, 1, len(mae.Result[1].Applied), "Should be one tried to apply") 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, _ *atlasexec.Version, _ *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, _ *atlasexec.Version, _ *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") }) } ================================================ FILE: atlasexec/internal/e2e/testdata/multi-tenants/atlas.hcl ================================================ env { for_each = toset([ "sqlite://bar.db?_fk=1", "sqlite://foo.db?_fk=1", ]) name = atlas.env url = each.value migration { dir = "file://migrations" } } ================================================ FILE: atlasexec/internal/e2e/testdata/multi-tenants/migrations/20240112070806.sql ================================================ CREATE TABLE t1(c1 int); ================================================ FILE: atlasexec/internal/e2e/testdata/multi-tenants/migrations/20240116003831.sql ================================================ CREATE UNIQUE INDEX c1_unique ON t1(c1); ================================================ FILE: atlasexec/internal/e2e/testdata/multi-tenants/migrations/atlas.sum ================================================ h1:S0UEXIKYA1mHhjFJbnzZh7bzeb42+5KM4HLzVlGuE4Q= 20240112070806.sql h1:nhoPxDs1H3UH6aEpy1qJ6Bj6zbFRt61sB4ndi0sx7zw= 20240116003831.sql h1:X3xnvEuBDK23s+re/ZYMdx/Ian+WhvzLgeJBN/2TJrA= ================================================ FILE: atlasexec/internal/e2e/testdata/schema-plan/schema-1.lt.hcl ================================================ schema "public" { comment = "This is a test schema" } table "t1" { schema = schema.public column "c1" { type = bigint } } ================================================ FILE: atlasexec/internal/e2e/testdata/schema-plan/schema-2.lt.hcl ================================================ schema "public" { comment = "This is a test schema" } table "t1" { schema = schema.public column "c1" { type = bigint } column "c2" { type = text } } ================================================ FILE: atlasexec/internal/e2e/testdata/versioned-basic/atlas.hcl ================================================ env "local" { migration { dir = "file://migrations" } } ================================================ FILE: atlasexec/internal/e2e/testdata/versioned-basic/migrations/20240112070806.sql ================================================ CREATE TABLE t1(c1 int); ================================================ FILE: atlasexec/internal/e2e/testdata/versioned-basic/migrations/atlas.sum ================================================ h1:vefBQWShy7/4OI7C1NqFH9y2PtGtOUS5zFQ1492XitE= 20240112070806.sql h1:nhoPxDs1H3UH6aEpy1qJ6Bj6zbFRt61sB4ndi0sx7zw= ================================================ FILE: atlasexec/internal/e2e/util_e2e.go ================================================ // Copyright 2021-present The Atlas Authors. All rights reserved. // This source code is licensed under the Apache 2.0 license found // in the LICENSE file in the root directory of this source tree. package e2etest import ( "context" "fmt" "io" "net/http" "os" "os/exec" "path/filepath" "testing" "ariga.io/atlas/atlasexec" ) const testFixtureDir = "testdata" func runTestWithVersions(t *testing.T, versions []string, fixtureName string, cb func(t *testing.T, ver *atlasexec.Version, wd *atlasexec.WorkingDir, tf *atlasexec.Client)) { if os.Getenv("ATLASEXEC_E2ETEST") == "" { t.Skip("ATLASEXEC_E2ETEST not set") } t.Helper() alreadyRunVersions := map[string]bool{} for _, av := range versions { t.Run(fmt.Sprintf("%s-%s", fixtureName, av), func(t *testing.T) { if alreadyRunVersions[av] { t.Skipf("already run version %q", av) } alreadyRunVersions[av] = true var execPath string if localBinPath := os.Getenv("ATLASEXEC_E2ETEST_ATLAS_PATH"); localBinPath != "" { 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 { t.Fatal(err) } // TODO: Check that the version is the same as the one we expect. runningVersion, err := c.Version(context.Background()) if err != nil { t.Fatalf("unable to determine running version (expected %q): %s", av, err) } wd, err := atlasexec.NewWorkingDir() if err != nil { t.Fatal(err) } defer wd.Close() if fixtureName != "" { err := wd.CopyFS("", os.DirFS(filepath.Join(testFixtureDir, fixtureName))) if err != nil { t.Fatalf("error copying config file into test dir: %s", err) } } err = c.WithWorkDir(wd.Path(), func(c *atlasexec.Client) (err error) { defer func() { if r := recover(); r != nil { var ok bool if err, ok = r.(error); !ok { err = fmt.Errorf("run test failure: %v", r) } } }() cb(t, runningVersion, wd, c) return nil }) if err != nil { t.Fatal(err) } }) } } func downloadAtlas(t *testing.T, version string) string { t.Helper() c := http.DefaultClient req, err := http.NewRequest(http.MethodGet, "https://atlasgo.sh?test=1", nil) if err != nil { t.Fatal(err) } req.Header.Set("User-Agent", "AtlasExec/Integration-Test") res, err := c.Do(req) if err != nil { t.Fatal(err) } defer res.Body.Close() if res.StatusCode != http.StatusOK { t.Fatalf("unexpected status code: %d", res.StatusCode) } path := filepath.Join(t.TempDir(), "installer.sh") f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0777) if err != nil { t.Fatal(err) } if _, err = io.Copy(f, res.Body); err != nil { f.Close() t.Fatal(err) } else if err = f.Close(); err != nil { t.Fatal(err) } atlasBin := filepath.Join(t.TempDir(), "atlas") cmd := exec.Command(path, "--user-agent", "AtlasExec/Integration-Test", "--output", atlasBin, "--no-install", ) cmd.Env = append(os.Environ(), fmt.Sprintf("ATLAS_VERSION=%s", version)) if testing.Verbose() { cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr } if err = cmd.Run(); err != nil { t.Fatal(err) } return atlasBin } ================================================ FILE: atlasexec/mock-atlas.sh ================================================ #!/bin/bash # TEST_BATCH provide the directory contains all # outputs for multiple runs. The path should be absolute # or related to current working directory. if [[ "$TEST_BATCH" != "" ]]; then COUNTER_FILE=$TEST_BATCH/counter COUNTER=$(cat $COUNTER_FILE 2>/dev/null) COUNTER=$((COUNTER+1)) DIR_CUR="$TEST_BATCH/$COUNTER" if [ ! -d "$DIR_CUR" ]; then >&2 echo -n "$DIR_CUR does not exist, quitting..." exit 1 fi # Save counter for the next runs echo -n $COUNTER > $COUNTER_FILE if [ -f "$DIR_CUR/args" ]; then TEST_ARGS=$(cat $DIR_CUR/args) fi if [ -f "$DIR_CUR/stderr" ]; then TEST_STDERR=$(cat $DIR_CUR/stderr) fi if [ -f "$DIR_CUR/stdout" ]; then TEST_STDOUT=$(cat $DIR_CUR/stdout) fi fi if [[ "$TEST_ARGS" != "$@" ]]; then >&2 echo "Receive unexpected args: $@" exit 1 fi if [[ "$TEST_STDOUT" != "" ]]; then printf "%s" "$TEST_STDOUT" if [[ "$TEST_STDERR" == "" ]]; then # `migrate down` and `migrate lint` commands print result to stdout # but the error code is set to 1. exit ${TEST_EXIT_CODE:-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}" >&2 echo -n $TEST_STDERR exit 1 ================================================ FILE: atlasexec/testdata/broken/20231029112426.sql ================================================ broken; ================================================ FILE: atlasexec/testdata/broken/atlas.sum ================================================ h1:Enr95HgKxQs2iSsOANpqDUOaHc6eZeQ+ak0ZF2wjmZE= 20231029112426.sql h1:lHLnIyWaiYac90Ad0I1SOsPxvQng3tGlq++/8RkpJaI= ================================================ FILE: atlasexec/testdata/migrations/20230727105553_init.sql ================================================ CREATE TABLE t1 ( c1 int ); ================================================ FILE: atlasexec/testdata/migrations/20230727105615_t2.sql ================================================ CREATE TABLE t2 ( c1 int, c2 text ); ================================================ FILE: atlasexec/testdata/migrations/20230926085734_destructive-change.sql ================================================ DROP TABLE t2; ================================================ FILE: atlasexec/testdata/migrations/atlas.sum ================================================ h1:hnQZfRcN6sV+y+0YePtkLazMy+Ty3lhyGv69ixeYoXc= 20230727105553_init.sql h1:jxgvnWO6tZD3lSPpH1ao5E/6VjapP7iwvBCUJ6aez58= 20230727105615_t2.sql h1:UvzeoFxe90Y/7b21ziwg6pPzWJQSV7LeYwJl8J63lMU= 20230926085734_destructive-change.sql h1:Gf/bSvUkfqHr/MEXKCxdGu2YvG8zwe4ER5TW8T/laA0= ================================================ FILE: atlasexec/working_dir.go ================================================ // Copyright 2021-present The Atlas Authors. All rights reserved. // This source code is licensed under the Apache 2.0 license found // in the LICENSE file in the root directory of this source tree. package atlasexec import ( "errors" "io" "io/fs" "os" "os/exec" "path/filepath" "ariga.io/atlas/sql/migrate" ) type ( // WorkingDir is a temporary directory with a copy of the files from dir. // It can be used to run commands in the temporary directory. // The temporary directory is removed when Close is called. WorkingDir struct { dir string } // Option is a function that modifies a ContextExecer. Option func(ce *WorkingDir) error ) // WithAtlasHCLString creates the atlas.hcl file with the given string. func WithAtlasHCLString(s string) Option { return WithAtlasHCL(func(w io.Writer) error { _, err := w.Write([]byte(s)) return err }) } // WithAtlasHCLPath creates the atlas.hcl file by copying the file at the given path. func WithAtlasHCLPath(path string) Option { return WithAtlasHCL(func(w io.Writer) error { f, err := os.Open(path) if err != nil { return err } defer f.Close() _, err = io.Copy(w, f) return err }) } // WithAtlasHCL accept a function to create the atlas.hcl file. func WithAtlasHCL(fn func(w io.Writer) error) Option { return func(ce *WorkingDir) error { return ce.CreateFile("atlas.hcl", fn) } } // WithMigrations copies all files from dir to the migrations directory. // If dir is nil, no files are copied. func WithMigrations(dir fs.FS) Option { return func(ce *WorkingDir) error { if dir == nil { return nil } return ce.CopyFS("migrations", dir) } } // NewWorkingDir creates a new ContextExecer. // It creates a temporary directory and copies all files from dir to the temporary directory. // The atlasHCL function is called with a writer to create the atlas.hcl file. // If atlasHCL is nil, no atlas.hcl file is created. func NewWorkingDir(opts ...Option) (*WorkingDir, error) { tmpDir, err := os.MkdirTemp("", "atlasexec-*") if err != nil { if err2 := os.RemoveAll(tmpDir); err2 != nil { err = errors.Join(err, err2) } return nil, err } c := &WorkingDir{dir: tmpDir} for _, opt := range opts { if err := opt(c); err != nil { return nil, err } } return c, nil } // Close removes the temporary directory. func (ce *WorkingDir) Close() error { return os.RemoveAll(ce.dir) } // DirFS returns a fs.FS for the temporary directory. func (ce *WorkingDir) DirFS() fs.FS { return os.DirFS(ce.dir) } // Dir returns the path to the temporary directory. func (ce *WorkingDir) Path(elem ...string) string { if len(elem) == 0 { return ce.dir } return filepath.Join(append([]string{ce.dir}, elem...)...) } // RunCommand runs the command in the temporary directory. func (ce *WorkingDir) RunCommand(cmd *exec.Cmd) error { // Restore the current directory after the command is run. defer func(d string) { cmd.Dir = d }(cmd.Dir) cmd.Dir = ce.dir return cmd.Run() } // WriteFile writes the file to the temporary directory. func (ce *WorkingDir) WriteFile(name string, data []byte) (string, error) { err := ce.CreateFile(name, func(w io.Writer) (err error) { _, err = w.Write(data) return err }) if err != nil { return "", err } return ce.Path(name), err } // CreateFile creates the file in the temporary directory. func (ce *WorkingDir) CreateFile(name string, fn func(w io.Writer) error) error { f, err := os.Create(ce.Path(name)) if err != nil { return err } if err := fn(f); err != nil { if err2 := f.Close(); err2 != nil { err = errors.Join(err, err2) } return err } return f.Close() } // CopyFS copies all files from source FileSystem to the destination directory // in the temporary directory. // If source is nil, an error is returned. func (ce *WorkingDir) CopyFS(name string, src fs.FS) error { dst := ce.Path(name) // Ensure destination directory exists. if err := os.MkdirAll(dst, 0700); err != nil { return err } switch dir := src.(type) { case nil: return errors.New("atlasexec: source is nil") case migrate.Dir: // The migrate.MemDir doesn't 100% compatible with fs.FS. // It returns fs.ErrNotExist error when open "." directory. // So, we need to handle it separately using the Files method. files, err := dir.Files() if err != nil { return err } for _, f := range files { name := filepath.Join(dst, f.Name()) if err := os.WriteFile(name, f.Bytes(), 0644); err != nil { //nolint:gosec return err } } // If the atlas.sum file exists, copy it to the destination directory. if hf, err := dir.Open(migrate.HashFileName); err == nil { data, err := io.ReadAll(hf) if err != nil { return err } name := filepath.Join(dst, migrate.HashFileName) if err := os.WriteFile(name, data, 0644); err != nil { //nolint:gosec return err } } return nil default: return fs.WalkDir(dir, ".", func(path string, d fs.DirEntry, err error) error { if err != nil || path == "." { return err } name := filepath.Join(dst, path) if d.IsDir() { return os.Mkdir(name, 0700) } data, err := fs.ReadFile(dir, path) if err != nil { return err } return os.WriteFile(name, data, 0644) //nolint:gosec }) } } ================================================ FILE: atlasexec/working_dir_test.go ================================================ // Copyright 2021-present The Atlas Authors. All rights reserved. // This source code is licensed under the Apache 2.0 license found // in the LICENSE file in the root directory of this source tree. package atlasexec import ( "bytes" "io" "os" "os/exec" "path/filepath" "testing" "testing/fstest" "text/template" "ariga.io/atlas/sql/migrate" "github.com/stretchr/testify/require" ) func TestContextExecer(t *testing.T) { src := fstest.MapFS{ "bar": &fstest.MapFile{Data: []byte("bar-content")}, } ce, err := NewWorkingDir() checkFileContent := func(t *testing.T, name, expected string) { t.Helper() full := filepath.Join(ce.dir, name) require.FileExists(t, full, "The file %q should exist", name) actual, err := os.ReadFile(full) require.NoError(t, err) require.Equal(t, expected, string(actual), "The file %q should have the expected content", name) } require.NoError(t, err) require.DirExists(t, ce.dir, "The temporary directory should exist") require.NoFileExists(t, filepath.Join(ce.dir, "atlas.hcl"), "The file atlas.hcl should not exist") require.NoError(t, ce.Close()) // Test WithMigrations. ce, err = NewWorkingDir(WithMigrations(src)) require.NoError(t, err) checkFileContent(t, filepath.Join("migrations", "bar"), "bar-content") require.NoError(t, ce.Close()) // Test WithMigrations - MemDir. dir := &migrate.MemDir{} require.NoError(t, dir.WriteFile("1.sql", []byte("-- only .sql files are copied\nmem-content"))) require.NoError(t, dir.WriteFile(migrate.HashFileName, []byte("-- And the atlas.sum"))) ce, err = NewWorkingDir(WithMigrations(dir)) require.NoError(t, err) checkFileContent(t, filepath.Join("migrations", "1.sql"), "-- only .sql files are copied\nmem-content") checkFileContent(t, filepath.Join("migrations", migrate.HashFileName), "-- And the atlas.sum") require.NoError(t, ce.Close()) // Test WithAtlasHCL. ce, err = NewWorkingDir( WithAtlasHCL(func(w io.Writer) error { return template.Must(template.New("").Parse(`{{ .foo }} & {{ .bar }}`)). Execute(w, map[string]any{ "foo": "foo", "bar": "bar", }) }), WithMigrations(src), ) require.NoError(t, err) require.DirExists(t, ce.dir, "tmpDir") checkFileContent(t, filepath.Join("migrations", "bar"), "bar-content") checkFileContent(t, "atlas.hcl", "foo & bar") // Test WriteFile. _, err = ce.WriteFile(filepath.Join("migrations", "foo"), []byte("foo-content")) require.NoError(t, err) checkFileContent(t, filepath.Join("migrations", "foo"), "foo-content") // Test RunCommand. buf := &bytes.Buffer{} cmd := exec.Command("ls") cmd.Dir = "fake-dir" cmd.Stdout = buf require.NoError(t, ce.RunCommand(cmd)) require.Equal(t, "fake-dir", cmd.Dir) require.Equal(t, "atlas.hcl\nmigrations\n", buf.String()) require.NoError(t, ce.Close()) } func TestMaintainOriginalWorkingDir(t *testing.T) { dir := t.TempDir() c, err := NewClient(dir, "atlas") require.NoError(t, err) require.Equal(t, dir, c.workingDir) require.NoError(t, c.WithWorkDir("bar", func(c *Client) error { require.Equal(t, "bar", c.workingDir) return nil })) require.Equal(t, dir, c.workingDir, "The working directory should not be changed") } ================================================ FILE: cmd/atlas/go.mod ================================================ module ariga.io/atlas/cmd/atlas go 1.24.13 replace ariga.io/atlas => ../.. require ( ariga.io/atlas v0.32.1-0.20250325101103-175b25e1c1b9 entgo.io/ent v0.14.5-0.20250523082027-21ecfa0872d4 github.com/1lann/promptui v0.8.1-0.20220708222609-81fad96dd5e1 github.com/antlr4-go/antlr/v4 v4.13.0 github.com/aws/aws-sdk-go-v2/config v1.29.17 github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.2.16 github.com/chzyer/readline v1.5.1 github.com/fatih/color v1.16.0 github.com/go-sql-driver/mysql v1.9.3 github.com/google/uuid v1.6.0 github.com/hashicorp/go-retryablehttp v0.7.7 github.com/hashicorp/hcl/v2 v2.18.1 github.com/lib/pq v1.10.9 github.com/mattn/go-isatty v0.0.20 github.com/mattn/go-sqlite3 v1.14.28 github.com/mitchellh/go-homedir v1.1.0 github.com/spf13/cobra v1.8.0 github.com/spf13/pflag v1.0.5 github.com/stretchr/testify v1.11.1 github.com/tursodatabase/libsql-client-go v0.0.0-20240902231107-85af5b9d094d github.com/vektah/gqlparser/v2 v2.5.16 github.com/zclconf/go-cty v1.14.4 gocloud.dev v0.43.0 golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 golang.org/x/mod v0.26.0 golang.org/x/oauth2 v0.30.0 google.golang.org/api v0.242.0 ) require ( cloud.google.com/go/auth v0.16.3 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.7.0 // indirect cloud.google.com/go/iam v1.5.2 // indirect cloud.google.com/go/longrunning v0.6.7 // indirect cloud.google.com/go/secretmanager v1.15.0 // indirect filippo.io/edwards25519 v1.1.0 // indirect github.com/agext/levenshtein v1.2.3 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect github.com/aws/aws-sdk-go v1.55.7 // indirect github.com/aws/aws-sdk-go-v2 v1.36.5 // indirect github.com/aws/aws-sdk-go-v2/credentials v1.17.70 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.32 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.36 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.36 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.4 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.17 // indirect github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.35.7 // indirect github.com/aws/aws-sdk-go-v2/service/ssm v1.60.1 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.25.5 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.3 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.34.0 // indirect github.com/aws/smithy-go v1.22.4 // indirect github.com/bmatcuk/doublestar v1.3.4 // indirect github.com/coder/websocket v1.8.12 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/inflect v0.19.0 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/google/wire v0.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect github.com/googleapis/gax-go/v2 v2.15.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mitchellh/go-wordwrap v1.0.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/zclconf/go-cty-yaml v1.1.0 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.62.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect go.opentelemetry.io/otel v1.37.0 // indirect go.opentelemetry.io/otel/metric v1.37.0 // indirect go.opentelemetry.io/otel/sdk v1.37.0 // indirect go.opentelemetry.io/otel/sdk/metric v1.37.0 // indirect go.opentelemetry.io/otel/trace v1.37.0 // indirect golang.org/x/crypto v0.40.0 // indirect golang.org/x/net v0.42.0 // indirect golang.org/x/sync v0.16.0 // indirect golang.org/x/sys v0.34.0 // indirect golang.org/x/text v0.28.0 // indirect golang.org/x/time v0.12.0 // indirect golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect google.golang.org/genproto v0.0.0-20250715232539-7130f93afb79 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250715232539-7130f93afb79 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250715232539-7130f93afb79 // indirect google.golang.org/grpc v1.73.0 // indirect google.golang.org/protobuf v1.36.8 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) ================================================ FILE: cmd/atlas/go.sum ================================================ cloud.google.com/go v0.121.4 h1:cVvUiY0sX0xwyxPwdSU2KsF9knOVmtRyAMt8xou0iTs= cloud.google.com/go v0.121.4/go.mod h1:XEBchUiHFJbz4lKBZwYBDHV/rSyfFktk737TLDU089s= cloud.google.com/go/auth v0.16.3 h1:kabzoQ9/bobUmnseYnBO6qQG7q4a/CffFRlJSxv2wCc= cloud.google.com/go/auth v0.16.3/go.mod h1:NucRGjaXfzP1ltpcQ7On/VTZ0H4kWB5Jy+Y9Dnm76fA= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/compute/metadata v0.7.0 h1:PBWF+iiAerVNe8UCHxdOt6eHLVc3ydFeOCw78U8ytSU= cloud.google.com/go/compute/metadata v0.7.0/go.mod h1:j5MvL9PprKL39t166CoB1uVHfQMs4tFQZZcKwksXUjo= cloud.google.com/go/iam v1.5.2 h1:qgFRAGEmd8z6dJ/qyEchAuL9jpswyODjA2lS+w234g8= cloud.google.com/go/iam v1.5.2/go.mod h1:SE1vg0N81zQqLzQEwxL2WI6yhetBdbNQuTvIKCSkUHE= cloud.google.com/go/kms v1.22.0 h1:dBRIj7+GDeeEvatJeTB19oYZNV0aj6wEqSIT/7gLqtk= cloud.google.com/go/kms v1.22.0/go.mod h1:U7mf8Sva5jpOb4bxYZdtw/9zsbIjrklYwPcvMk34AL8= cloud.google.com/go/longrunning v0.6.7 h1:IGtfDWHhQCgCjwQjV9iiLnUta9LBCo8R9QmAFsS/PrE= cloud.google.com/go/longrunning v0.6.7/go.mod h1:EAFV3IZAKmM56TyiE6VAP3VoTzhZzySwI/YI1s/nRsY= cloud.google.com/go/secretmanager v1.15.0 h1:RtkCMgTpaBMbzozcRUGfZe46jb9a3qh5EdEtVRUATF8= cloud.google.com/go/secretmanager v1.15.0/go.mod h1:1hQSAhKK7FldiYw//wbR/XPfPc08eQ81oBsnRUHEvUc= entgo.io/ent v0.14.5-0.20250523082027-21ecfa0872d4 h1:d7UZAvQCnOp1PyiHAWkPCXBEPW3tVjraiK/RZlsW0XY= entgo.io/ent v0.14.5-0.20250523082027-21ecfa0872d4/go.mod h1:zTzLmWtPvGpmSwtkaayM2cm5m819NdM7z7tYPq3vN0U= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/1lann/promptui v0.8.1-0.20220708222609-81fad96dd5e1 h1:LejjvYg4tCW5HO7q/1nzPrprh47oUD9OUySQ29pDp5c= github.com/1lann/promptui v0.8.1-0.20220708222609-81fad96dd5e1/go.mod h1:cnC/60IoLiDM0GhdKYJ6oO7AwpZe1IQfPnSKlAURgHw= github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60= github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo= github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= github.com/aws/aws-sdk-go v1.55.7 h1:UJrkFq7es5CShfBwlWAC8DA077vp8PyVbQd3lqLiztE= github.com/aws/aws-sdk-go v1.55.7/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= github.com/aws/aws-sdk-go-v2 v1.20.1/go.mod h1:NU06lETsFm8fUC6ZjhgDpVBcGZTFQ6XM+LZWZxMI4ac= github.com/aws/aws-sdk-go-v2 v1.36.5 h1:0OF9RiEMEdDdZEMqF9MRjevyxAQcf6gY+E7vwBILFj0= github.com/aws/aws-sdk-go-v2 v1.36.5/go.mod h1:EYrzvCCN9CMUTa5+6lf6MM4tq3Zjp8UhSGR/cBsjai0= github.com/aws/aws-sdk-go-v2/config v1.29.17 h1:jSuiQ5jEe4SAMH6lLRMY9OVC+TqJLP5655pBGjmnjr0= github.com/aws/aws-sdk-go-v2/config v1.29.17/go.mod h1:9P4wwACpbeXs9Pm9w1QTh6BwWwJjwYvJ1iCt5QbCXh8= github.com/aws/aws-sdk-go-v2/credentials v1.17.70 h1:ONnH5CM16RTXRkS8Z1qg7/s2eDOhHhaXVd72mmyv4/0= github.com/aws/aws-sdk-go-v2/credentials v1.17.70/go.mod h1:M+lWhhmomVGgtuPOhO85u4pEa3SmssPTdcYpP/5J/xc= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.32 h1:KAXP9JSHO1vKGCr5f4O6WmlVKLFFXgWYAGoJosorxzU= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.32/go.mod h1:h4Sg6FQdexC1yYG9RDnOvLbW1a/P986++/Y/a+GyEM8= github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.2.16 h1:j+YO7Khxpk73ESxUpheUSw91qT42+LqNZiEjul1Dmnk= github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.2.16/go.mod h1:laSv+AlZPuT/bpJQ2Xspq/oDKhB/XZLohISGTKU7DOg= github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.36 h1:SsytQyTMHMDPspp+spo7XwXTP44aJZZAC7fBV2C5+5s= github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.36/go.mod h1:Q1lnJArKRXkenyog6+Y+zr7WDpk4e6XlR6gs20bbeNo= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.36 h1:i2vNHQiXUvKhs3quBR6aqlgJaiaexz/aNvdCktW/kAM= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.36/go.mod h1:UdyGa7Q91id/sdyHPwth+043HhmP6yP9MBHgbZM0xo8= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.4 h1:CXV68E2dNqhuynZJPB80bhPQwAKqBWVer887figW6Jc= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.4/go.mod h1:/xFi9KtvBXP97ppCz1TAEvU1Uf66qvid89rbem3wCzQ= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.17 h1:t0E6FzREdtCsiLIoLCWsYliNsRBgyGD/MCK571qk4MI= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.17/go.mod h1:ygpklyoaypuyDvOM5ujWGrYWpAK3h7ugnmKCU/76Ys4= github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.35.7 h1:d+mnMa4JbJlooSbYQfrJpit/YINaB30JEVgrhtjZneA= github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.35.7/go.mod h1:1X1NotbcGHH7PCQJ98PsExSxsJj/VWzz8MfFz43+02M= github.com/aws/aws-sdk-go-v2/service/ssm v1.60.1 h1:OwMzNDe5VVTXD4kGmeK/FtqAITiV8Mw4TCa8IyNO0as= github.com/aws/aws-sdk-go-v2/service/ssm v1.60.1/go.mod h1:IyVabkWrs8SNdOEZLyFFcW9bUltV4G6OQS0s6H20PHg= github.com/aws/aws-sdk-go-v2/service/sso v1.25.5 h1:AIRJ3lfb2w/1/8wOOSqYb9fUKGwQbtysJ2H1MofRUPg= github.com/aws/aws-sdk-go-v2/service/sso v1.25.5/go.mod h1:b7SiVprpU+iGazDUqvRSLf5XmCdn+JtT1on7uNL6Ipc= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.3 h1:BpOxT3yhLwSJ77qIY3DoHAQjZsc4HEGfMCE4NGy3uFg= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.3/go.mod h1:vq/GQR1gOFLquZMSrxUK/cpvKCNVYibNyJ1m7JrU88E= github.com/aws/aws-sdk-go-v2/service/sts v1.34.0 h1:NFOJ/NXEGV4Rq//71Hs1jC/NvPs1ezajK+yQmkwnPV0= github.com/aws/aws-sdk-go-v2/service/sts v1.34.0/go.mod h1:7ph2tGpfQvwzgistp2+zga9f+bCjlQJPkPUmMgDSD7w= github.com/aws/smithy-go v1.14.1/go.mod h1:Tg+OJXh4MB2R/uN61Ko2f6hTZwB/ZYGOtib8J3gBHzA= github.com/aws/smithy-go v1.22.4 h1:uqXzVZNuNexwc/xrh6Tb56u89WDlJY6HS+KC0S4QSjw= github.com/aws/smithy-go v1.22.4/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= github.com/bmatcuk/doublestar v1.3.4 h1:gPypJ5xD31uhX6Tf54sDPUOBXTqKH4c9aPY66CyQrS0= github.com/bmatcuk/doublestar v1.3.4/go.mod h1:wiQtGV+rzVYxB7WIlirSN++5HPtPlXEo9MEoZQC/PmE= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM= github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI= github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04= github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo= github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-openapi/inflect v0.19.0 h1:9jCH9scKIbHeV9m12SmPilScz6krDxKRasNNSNPXu/4= github.com/go-openapi/inflect v0.19.0/go.mod h1:lHpZVlpIQqLyKwJ4N+YSc9hchQy/i12fJykb83CRBH4= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68= github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-replayers/grpcreplay v1.3.0 h1:1Keyy0m1sIpqstQmgz307zhiJ1pV4uIlFds5weTmxbo= github.com/google/go-replayers/grpcreplay v1.3.0/go.mod h1:v6NgKtkijC0d3e3RW8il6Sy5sqRVUwoQa4mHOGEy8DI= github.com/google/go-replayers/httpreplay v1.2.0 h1:VM1wEyyjaoU53BwrOnaf9VhAyQQEEioJvFYxYcLRKzk= github.com/google/go-replayers/httpreplay v1.2.0/go.mod h1:WahEFFZZ7a1P4VM1qEeHy+tME4bwyqPcwWbNlUI1Mcg= github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc= github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/wire v0.6.0 h1:HBkoIh4BdSxoyo9PveV8giw7ZsaBOvzWKfcg/6MrVwI= github.com/google/wire v0.6.0/go.mod h1:F4QhpQ9EDIdJ1Mbop/NZBRB+5yrR6qg3BnctaoUk6NA= github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4= github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo= github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= github.com/hashicorp/hcl/v2 v2.18.1 h1:6nxnOJFku1EuSawSD81fuviYUV8DxFr3fp2dUi3ZYSo= github.com/hashicorp/hcl/v2 v2.18.1/go.mod h1:ThLC89FV4p9MPW804KVbe/cEXoQ8NZEh+JtMeeGErHE= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A= github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tursodatabase/libsql-client-go v0.0.0-20240902231107-85af5b9d094d h1:dOMI4+zEbDI37KGb0TI44GUAwxHF9cMsIoDTJ7UmgfU= github.com/tursodatabase/libsql-client-go v0.0.0-20240902231107-85af5b9d094d/go.mod h1:l8xTsYB90uaVdMHXMCxKKLSgw5wLYBwBKKefNIUnm9s= github.com/vektah/gqlparser/v2 v2.5.16 h1:1gcmLTvs3JLKXckwCwlUagVn/IlV2bwqle0vJ0vy5p8= github.com/vektah/gqlparser/v2 v2.5.16/go.mod h1:1lz1OeCqgQbQepsGxPVywrjdBHW2T08PUS3pJqepRww= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/zclconf/go-cty v1.14.4 h1:uXXczd9QDGsgu0i/QFR/hzI5NYCHLf6NQw/atrbnhq8= github.com/zclconf/go-cty v1.14.4/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= github.com/zclconf/go-cty-yaml v1.1.0 h1:nP+jp0qPHv2IhUVqmQSzjvqAWcObN0KBkUl2rWBdig0= github.com/zclconf/go-cty-yaml v1.1.0/go.mod h1:9YLUH4g7lOhVWqUbctnVlZ5KLpg7JAprQNgxSZ1Gyxs= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.62.0 h1:rbRJ8BBoVMsQShESYZ0FkvcITu8X8QNwJogcLUmDNNw= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.62.0/go.mod h1:ru6KHrNtNHxM4nD/vd6QrLVWgKhxPYgblq4VAtNawTQ= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0/go.mod h1:NfchwuyNoMcZ5MLHwPrODwUF1HWCXWrL31s8gSAdIKY= go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc= go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= gocloud.dev v0.43.0 h1:aW3eq4RMyehbJ54PMsh4hsp7iX8cO/98ZRzJJOzN/5M= gocloud.dev v0.43.0/go.mod h1:eD8rkg7LhKUHrzkEdLTZ+Ty/vgPHPCd+yMQdfelQVu4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 h1:aAcj0Da7eBAtrTp03QXWvm88pSyOt+UgdZw2BFZ+lEw= golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8/go.mod h1:CQ1k9gNrJ50XIzaKCRR2hssIjF07kZFEiieALBM/ARQ= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg= golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= google.golang.org/api v0.242.0 h1:7Lnb1nfnpvbkCiZek6IXKdJ0MFuAZNAJKQfA1ws62xg= google.golang.org/api v0.242.0/go.mod h1:cOVEm2TpdAGHL2z+UwyS+kmlGr3bVWQQ6sYEqkKje50= google.golang.org/genproto v0.0.0-20250715232539-7130f93afb79 h1:Nt6z9UHqSlIdIGJdz6KhTIs2VRx/iOsA5iE8bmQNcxs= google.golang.org/genproto v0.0.0-20250715232539-7130f93afb79/go.mod h1:kTmlBHMPqR5uCZPBvwa2B18mvubkjyY3CRLI0c6fj0s= google.golang.org/genproto/googleapis/api v0.0.0-20250715232539-7130f93afb79 h1:iOye66xuaAK0WnkPuhQPUFy8eJcmwUXqGGP3om6IxX8= google.golang.org/genproto/googleapis/api v0.0.0-20250715232539-7130f93afb79/go.mod h1:HKJDgKsFUnv5VAGeQjz8kxcgDP0HoE0iZNp0OdZNlhE= google.golang.org/genproto/googleapis/rpc v0.0.0-20250715232539-7130f93afb79 h1:1ZwqphdOdWYXsUHgMpU/101nCtf/kSp9hOrcvFsnl10= google.golang.org/genproto/googleapis/rpc v0.0.0-20250715232539-7130f93afb79/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok= google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc= google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= ================================================ FILE: cmd/atlas/internal/cloudapi/client.go ================================================ // Copyright 2021-present The Atlas Authors. All rights reserved. // This source code is licensed under the Apache 2.0 license found // in the LICENSE file in the root directory of this source tree. package cloudapi import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "runtime" "slices" "strings" "testing" "time" "ariga.io/atlas/sql/migrate" "ariga.io/atlas/sql/sqlclient" "github.com/hashicorp/go-retryablehttp" "github.com/vektah/gqlparser/v2/gqlerror" ) const ( // defaultURL for Atlas Cloud. defaultURL = "https://api.atlasgo.cloud/query" // DefaultProjectName is the default name for projects. DefaultProjectName = "default" // DefaultDirName is the default directory for reporting // if no directory was specified by the user. DefaultDirName = ".atlas" ) // Client is a client for the Atlas Cloud API. type Client struct { client *retryablehttp.Client endpoint string } // New creates a new Client for the Atlas Cloud API. func New(endpoint, token string) *Client { if endpoint == "" { endpoint = defaultURL } var ( client = retryablehttp.NewClient() transport = client.HTTPClient.Transport ) client.HTTPClient.Timeout = time.Second * 30 client.ErrorHandler = func(res *http.Response, err error, _ int) (*http.Response, error) { return res, err // Let Client.post handle the error. } client.HTTPClient.Transport = &roundTripper{ token: token, base: transport, extraHeaders: make(map[string]string), } // Disable logging until "ATLAS_DEBUG" option will be added. client.Logger = nil // Keep retry short for unit/integration tests. if testing.Testing() || testingURL(endpoint) { client.HTTPClient.Timeout = 0 client.RetryWaitMin, client.RetryWaitMax = 0, time.Microsecond } return &Client{ endpoint: endpoint, client: client, } } type clientCtxKey struct{} // NewContext returns a new context with the given Client attached. func NewContext(parent context.Context, c *Client) context.Context { return context.WithValue(parent, clientCtxKey{}, c) } // FromContext returns a Client stored inside a context, or nil if there isn't one. func FromContext(ctx context.Context) *Client { c, _ := ctx.Value(clientCtxKey{}).(*Client) return c } // DirInput is the input type for retrieving a single directory. type DirInput struct { Slug string `json:"slug,omitempty"` Name string `json:"name,omitempty"` Tag string `json:"tag,omitempty"` } // Dir retrieves a directory from the Atlas Cloud API. func (c *Client) Dir(ctx context.Context, input DirInput) (migrate.Dir, error) { var ( payload struct { Dir struct { Content []byte `json:"content"` } `json:"dirState"` } query = ` query dirState($input: DirStateInput!) { dirState(input: $input) { content } }` vars = struct { Input DirInput `json:"input"` }{ Input: input, } ) if err := c.post(ctx, query, vars, &payload); err != nil { return nil, err } return migrate.UnarchiveDir(payload.Dir.Content) } type ( // DeployContextInput is an input type for describing the context in which // `migrate-apply` was used. For example, a GitHub Action with version v1.2.3 DeployContextInput struct { TriggerType string `json:"triggerType,omitempty"` TriggerVersion string `json:"triggerVersion,omitempty"` } // ReportMigrationSetInput represents the input type for reporting a set of migration deployments. ReportMigrationSetInput struct { ID string `json:"id"` Planned int `json:"planned"` StartTime time.Time `json:"startTime"` EndTime time.Time `json:"endTime"` Error *string `json:"error,omitempty"` Log []ReportStep `json:"log,omitempty"` Completed []ReportMigrationInput `json:"completed,omitempty"` Context *DeployContextInput `json:"context,omitempty"` } // ReportMigrationInput represents an input type for reporting a migration deployments. ReportMigrationInput struct { ProjectName string `json:"projectName"` EnvName string `json:"envName"` DirName string `json:"dirName"` AtlasVersion string `json:"atlasVersion"` Target DeployedTargetInput `json:"target"` StartTime time.Time `json:"startTime"` EndTime time.Time `json:"endTime"` FromVersion string `json:"fromVersion"` ToVersion string `json:"toVersion"` CurrentVersion string `json:"currentVersion"` Error *string `json:"error,omitempty"` Files []DeployedFileInput `json:"files"` Log string `json:"log"` Context *DeployContextInput `json:"context,omitempty"` DryRun bool `json:"dryRun,omitempty"` } // DeployedTargetInput represents the input type for a deployed target. DeployedTargetInput struct { ID string `json:"id"` Schema string `json:"schema"` URL string `json:"url"` // URL string without userinfo. } // DeployedFileInput represents the input type for a deployed file. DeployedFileInput struct { Name string `json:"name"` Content string `json:"content"` StartTime time.Time `json:"startTime"` EndTime time.Time `json:"endTime"` Skipped int `json:"skipped"` Applied int `json:"applied"` Checks []FileChecksInput `json:"checks"` Error *StmtErrorInput `json:"error,omitempty"` } // FileChecksInput represents the input type for a file checks. FileChecksInput struct { Name string `json:"name"` Start time.Time `json:"start"` End time.Time `json:"end"` Checks []CheckStmtInput `json:"checks"` Error *StmtErrorInput `json:"error,omitempty"` } // CheckStmtInput represents the input type for a statement check. CheckStmtInput struct { Stmt string `json:"stmt"` Error *string `json:"error,omitempty"` } // StmtErrorInput represents the input type for a statement error. StmtErrorInput struct { Stmt string `json:"stmt"` Text string `json:"text"` } // ReportStep is top-level step in a report. ReportStep struct { Text string `json:"text"` StartTime time.Time `json:"startTime"` EndTime time.Time `json:"endTime"` Error bool `json:"error,omitempty"` Log []ReportStepLog `json:"log,omitempty"` } // ReportStepLog is a log entry in a step. ReportStepLog struct { Text string `json:"text,omitempty"` Children []ReportStepLog `json:"children,omitempty"` } ) // ReportMigrationSet reports a set of migration deployments to the Atlas Cloud API. func (c *Client) ReportMigrationSet(ctx context.Context, input ReportMigrationSetInput) (string, error) { var ( payload struct { ReportMigrationSet struct { URL string `json:"url"` } `json:"reportMigrationSet"` } query = ` mutation ReportMigrationSet($input: ReportMigrationSetInput!) { reportMigrationSet(input: $input) { url } }` vars = struct { Input ReportMigrationSetInput `json:"input"` }{ Input: input, } ) if err := c.post(ctx, query, vars, &payload); err != nil { return "", err } return payload.ReportMigrationSet.URL, nil } // ReportMigration reports a migration deployment to the Atlas Cloud API. func (c *Client) ReportMigration(ctx context.Context, input ReportMigrationInput) (string, error) { var ( payload struct { ReportMigration struct { URL string `json:"url"` } `json:"reportMigration"` } query = ` mutation ReportMigration($input: ReportMigrationInput!) { reportMigration(input: $input) { url } }` vars = struct { Input ReportMigrationInput `json:"input"` }{ Input: input, } ) if err := c.post(ctx, query, vars, &payload); err != nil { return "", err } return payload.ReportMigration.URL, nil } // ErrUnauthorized is returned when the server returns a 401 status code. var ErrUnauthorized = errors.New(http.StatusText(http.StatusUnauthorized)) func (c *Client) post(ctx context.Context, query string, vars, data any) error { body, err := json.Marshal(struct { Query string `json:"query"` Variables any `json:"variables,omitempty"` }{ Query: query, Variables: vars, }) if err != nil { return err } req, err := retryablehttp.NewRequestWithContext(ctx, http.MethodPost, c.endpoint, bytes.NewReader(body)) if err != nil { return err } req.Header.Set("Content-Type", "application/json") res, err := c.client.Do(req) if err != nil { return err } defer res.Body.Close() switch { case res.StatusCode == http.StatusUnauthorized: return ErrUnauthorized case res.StatusCode != http.StatusOK: buf, err := io.ReadAll(io.LimitReader(res.Body, 1<<20)) if err != nil { return &HTTPError{StatusCode: res.StatusCode, Message: err.Error()} } var v struct { Errors errlist `json:"errors,omitempty"` } if err := json.Unmarshal(buf, &v); err != nil || len(v.Errors) == 0 { // If the error is not a GraphQL error, return the message as is. return &HTTPError{StatusCode: res.StatusCode, Message: string(bytes.TrimSpace(buf))} } return v.Errors } var scan = struct { Data any `json:"data"` Errors errlist `json:"errors,omitempty"` }{ Data: data, } if err := json.NewDecoder(res.Body).Decode(&scan); err != nil && !errors.Is(err, io.EOF) { return fmt.Errorf("decoding response: %w", err) } if len(scan.Errors) > 0 { return scan.Errors } return nil } // AddHeader adds a header to the client requests. func (c *Client) AddHeader(key, value string) { rt, ok := c.client.HTTPClient.Transport.(*roundTripper) if !ok { return } rt.extraHeaders[key] = value } type ( // errlist wraps the gqlerror.List to print errors without // extra newlines and prefix info added. errlist gqlerror.List // roundTripper is a http.RoundTripper that adds the Authorization header. roundTripper struct { token string extraHeaders map[string]string base http.RoundTripper } ) func (e errlist) Error() string { s := strings.TrimPrefix(gqlerror.List(e).Error(), "input:") return strings.TrimSpace(s) } // RoundTrip implements http.RoundTripper. func (r *roundTripper) RoundTrip(req *http.Request) (*http.Response, error) { SetHeader(req, r.token) for k, v := range r.extraHeaders { req.Header.Set(k, v) } return r.base.RoundTrip(req) } // HTTPError represents a generic HTTP error. Hence, non 2xx status codes. type HTTPError struct { StatusCode int Message string } func (e *HTTPError) Error() string { return fmt.Sprintf("unexpected error code %d: %s", e.StatusCode, e.Message) } // RedactedURL returns a URL string with the userinfo redacted. func RedactedURL(s string) (string, error) { u, err := sqlclient.ParseURL(s) if err != nil { return "", err } return u.Redacted(), nil } // version of the CLI set by cmdapi. var version = "development" // SetVersion allow cmdapi to set the version // of the CLI provided at build time. func SetVersion(v, flavor string) { version = v if flavor != "" { version += "-" + flavor } } // SetHeader sets header fields for cloud requests. func SetHeader(req *http.Request, token string) { for k, v := range header(token) { req.Header.Set(k, v[0]) } } func header(token string) http.Header { h := make(http.Header) h.Set("Authorization", "Bearer "+token) h.Set("User-Agent", UserAgent()) h.Set("Content-Type", "application/json") return h } // UserAgent is the value the CLI uses in the User-Agent HTTP header. func UserAgent(systems ...string) string { sysInfo := runtime.GOOS + "/" + runtime.GOARCH if len(systems) > 0 { systems = slices.DeleteFunc(systems, func(s string) bool { return strings.TrimSpace(s) == "" }) sysInfo = strings.Join(slices.Insert(systems, 0, sysInfo), "; ") } return fmt.Sprintf("Atlas/%s (%s)", version, sysInfo) } ================================================ FILE: cmd/atlas/internal/cloudapi/client_oss.go ================================================ // Copyright 2021-present The Atlas Authors. All rights reserved. // This source code is licensed under the Apache 2.0 license found // in the LICENSE file in the root directory of this source tree. //go:build !ent package cloudapi import ( "net/url" ) func testingURL(endpoint string) bool { u, err := url.Parse(endpoint) if err != nil { return false } host := u.Hostname() return host == "localhost" || host == "127.0.0.1" } ================================================ FILE: cmd/atlas/internal/cloudapi/client_test.go ================================================ // Copyright 2021-present The Atlas Authors. All rights reserved. // This source code is licensed under the Apache 2.0 license found // in the LICENSE file in the root directory of this source tree. package cloudapi import ( "context" "encoding/base64" "encoding/json" "fmt" "net/http" "net/http/httptest" "runtime" "strings" "testing" "ariga.io/atlas/sql/migrate" "github.com/stretchr/testify/require" ) func TestClient_Dir(t *testing.T) { var dir migrate.MemDir require.NoError(t, dir.WriteFile("1.sql", []byte("create table foo (id int)"))) ad, err := migrate.ArchiveDir(&dir) require.NoError(t, err) SetVersion("v0.13.0", "") srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var input struct { Variables struct { DirInput DirInput `json:"input"` } `json:"variables"` } err := json.NewDecoder(r.Body).Decode(&input) require.NoError(t, err) require.Equal(t, "foo", input.Variables.DirInput.Name) require.Equal(t, "x", input.Variables.DirInput.Tag) require.Equal(t, "Bearer atlas", r.Header.Get("Authorization")) expUA := fmt.Sprintf("Atlas/v0.13.0 (%s/%s)", runtime.GOOS, runtime.GOARCH) require.Equal(t, expUA, r.Header.Get("User-Agent")) fmt.Fprintf(w, `{"data":{"dirState":{"content":%q}}}`, base64.StdEncoding.EncodeToString(ad)) })) client := New(srv.URL, "atlas") defer srv.Close() gd, err := client.Dir(context.Background(), DirInput{ Name: "foo", Tag: "x", }) require.NoError(t, err) gcheck, err := gd.Checksum() require.NoError(t, err) dcheck, err := dir.Checksum() require.NoError(t, err) require.Equal(t, dcheck.Sum(), gcheck.Sum()) } func TestClient_GraphQLError(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusUnprocessableEntity) _, err := w.Write([]byte(`{"errors":[{"message":"error\n","path":["variable","input","driver"],"extensions":{}}],"data":null}`)) require.NoError(t, err) })) client := New(srv.URL, "atlas") defer srv.Close() link, err := client.ReportMigration(context.Background(), ReportMigrationInput{ EnvName: "foo", ProjectName: "bar", }) require.EqualError(t, err, "variable.input.driver error", "error is trimmed") require.Empty(t, link) } func TestClient_HTTPError(t *testing.T) { var ( body string code = http.StatusInternalServerError ) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, body, code) })) client := New(srv.URL, "atlas") defer srv.Close() body = "internal error" _, err := client.ReportMigration(context.Background(), ReportMigrationInput{ EnvName: "foo", ProjectName: "bar", }) require.EqualError(t, err, `unexpected error code 500: internal error`) // Error should be limited to 1MB. body = fmt.Sprintf("%s!", strings.Repeat("a", 1<<20)) _, err = client.ReportMigration(context.Background(), ReportMigrationInput{ EnvName: "foo", ProjectName: "bar", }) require.ErrorContains(t, err, "unexpected error code 500: a") require.NotContains(t, err.Error(), "!") // Unauthorized error. body = "unauthorized" code = http.StatusUnauthorized _, err = client.ReportMigration(context.Background(), ReportMigrationInput{ EnvName: "foo", ProjectName: "bar", }) require.ErrorIs(t, err, ErrUnauthorized) code = http.StatusForbidden body = "Forbidden" _, err = client.ReportMigration(context.Background(), ReportMigrationInput{ EnvName: "foo", ProjectName: "bar", }) require.EqualError(t, err, "unexpected error code 403: Forbidden") code = http.StatusConflict body = `{"errors":[{"message":"conflict\n","path":["variable","input","driver"],"extensions":{}}],"data":null}` _, err = client.ReportMigration(context.Background(), ReportMigrationInput{ EnvName: "foo", ProjectName: "bar", }) require.EqualError(t, err, "variable.input.driver conflict", "GraphQL error") } func TestClient_ReportMigration(t *testing.T) { const project, env = "atlas", "dev" srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var input struct { Variables struct { Input ReportMigrationInput `json:"input"` } `json:"variables"` } err := json.NewDecoder(r.Body).Decode(&input) require.NoError(t, err) require.Equal(t, env, input.Variables.Input.EnvName) require.Equal(t, project, input.Variables.Input.ProjectName) fmt.Fprintf(w, `{"data":{"reportMigration":{"url":"https://atlas.com"}}}`) })) client := New(srv.URL, "atlas") defer srv.Close() link, err := client.ReportMigration(context.Background(), ReportMigrationInput{ EnvName: env, ProjectName: project, }) require.NoError(t, err) require.NotEmpty(t, link) } func TestClient_ReportMigrationSet(t *testing.T) { const ( planned = 2 id, log, project, env = "deployment-set-1", "started deployment", "atlas", "dev" ) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var input struct { Variables struct { Input ReportMigrationSetInput `json:"input"` } `json:"variables"` } err := json.NewDecoder(r.Body).Decode(&input) require.NoError(t, err) require.Equal(t, id, input.Variables.Input.ID) require.Equal(t, []ReportStep{{Text: log}}, input.Variables.Input.Log) require.Equal(t, planned, input.Variables.Input.Planned) require.Equal(t, env, input.Variables.Input.Completed[0].EnvName) require.Equal(t, project, input.Variables.Input.Completed[0].ProjectName) require.Equal(t, "dir-1", input.Variables.Input.Completed[0].DirName) require.Equal(t, env, input.Variables.Input.Completed[1].EnvName) require.Equal(t, project, input.Variables.Input.Completed[1].ProjectName) require.Equal(t, "dir-2", input.Variables.Input.Completed[1].DirName) fmt.Fprintf(w, `{"data":{"reportMigrationSet":{"url":"https://atlas.com"}}}`) })) client := New(srv.URL, "atlas") defer srv.Close() link, err := client.ReportMigrationSet(context.Background(), ReportMigrationSetInput{ ID: id, Planned: planned, Log: []ReportStep{{Text: log}}, Completed: []ReportMigrationInput{ { EnvName: env, ProjectName: project, DirName: "dir-1", }, { EnvName: env, ProjectName: project, DirName: "dir-2", }, }, }) require.NoError(t, err) require.NotEmpty(t, link) } func TestRedactedURL(t *testing.T) { u, err := RedactedURL("mysql://user:pass@:3306/db") require.NoError(t, err) require.Equal(t, "mysql://user:xxxxx@:3306/db", u) u, err = RedactedURL("\\n mysql://user:pass@:3306/db") require.EqualError(t, err, `first path segment in URL cannot contain colon`) require.Empty(t, u) } func TestUserAgent(t *testing.T) { platform := runtime.GOOS + "/" + runtime.GOARCH require.Equal(t, fmt.Sprintf("Atlas/%s (%s)", version, platform), UserAgent()) require.Equal(t, fmt.Sprintf("Atlas/%s (%s; foo/bar; bar/baz)", version, platform), UserAgent("foo/bar", "bar/baz")) require.Equal(t, fmt.Sprintf("Atlas/%s (%s; bar/baz)", version, platform), UserAgent(" ", "", "bar/baz")) } func TestClient_AddHeader(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { require.Equal(t, "val", r.Header.Get("key")) })) client := New(srv.URL, "atlas") defer srv.Close() client.AddHeader("key", "val") _, err := client.ReportMigration(context.Background(), ReportMigrationInput{ EnvName: "foo", ProjectName: "bar", }) require.NoError(t, err) } func TestClient_Retry(t *testing.T) { var ( calls = []int{http.StatusInternalServerError, http.StatusInternalServerError, http.StatusOK} srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { require.Equal(t, "val", r.Header.Get("key")) require.Equal(t, "Bearer atlas", r.Header.Get("Authorization")) w.WriteHeader(calls[0]) calls = calls[1:] })) client = New(srv.URL, "atlas") ) defer srv.Close() client.AddHeader("key", "val") _, err := client.ReportMigration(context.Background(), ReportMigrationInput{ EnvName: "foo", }) require.NoError(t, err) require.Empty(t, calls) } ================================================ FILE: cmd/atlas/internal/cmdapi/cmdapi.go ================================================ // Copyright 2021-present The Atlas Authors. All rights reserved. // This source code is licensed under the Apache 2.0 license found // in the LICENSE file in the root directory of this source tree. // Package cmdapi holds the atlas commands used to build an atlas distribution. package cmdapi import ( "context" "encoding/csv" "errors" "fmt" "net/url" "sort" "strings" "time" "ariga.io/atlas/cmd/atlas/internal/cmdext" "ariga.io/atlas/sql/migrate" "ariga.io/atlas/sql/schema" "ariga.io/atlas/sql/sqlclient" "github.com/spf13/cobra" "github.com/spf13/pflag" "github.com/zclconf/go-cty/cty" "golang.org/x/exp/maps" "golang.org/x/mod/semver" ) var ( // Root represents the root command when called without any subcommands. Root = &cobra.Command{ Use: "atlas", Short: "Manage your database schema as code", SilenceUsage: true, } // GlobalFlags contains flags common to many Atlas sub-commands. GlobalFlags struct { // Config defines the path to the Atlas project/config file. ConfigURL string // SelectedEnv contains the environment selected from the active project via the --env flag. SelectedEnv string // Vars contains the input variables passed from the CLI to Atlas DDL or project files. Vars Vars } // flavor holds Atlas flavor. Custom flavors (like the community build) should set this by build flag // "-X 'ariga.io/atlas/cmd/atlas/internal/cmdapi.flavor=community'" flavor string // version holds Atlas version. When built with cloud packages should be set by build flag, e.g. // "-X 'ariga.io/atlas/cmd/atlas/internal/cmdapi.version=v0.1.2'" version string // versionCmd represents the subcommand 'atlas version'. versionCmd = &cobra.Command{ Use: "version", Short: "Prints this Atlas CLI version information.", Run: func(cmd *cobra.Command, _ []string) { var ( f = versionFmt args []any ) if flavor != "" { f += "%s " args = append(args, flavor) } f += "version %s\n%s\n%s" v, u := parseV(version) args = append(args, v, u, versionInfo) cmd.Printf(f, args...) }, } // license holds Atlas license. When built with cloud packages should be set by build flag // "-X 'ariga.io/atlas/cmd/atlas/internal/cmdapi.license=${license}'" license = `LICENSE Atlas is licensed under Apache 2.0 as found in https://github.com/ariga/atlas/blob/master/LICENSE.` // licenseCmd represents the subcommand 'atlas license'. licenseCmd = &cobra.Command{ Use: "license", Short: "Display license information", Run: func(cmd *cobra.Command, _ []string) { cmd.Println(license) }, } ) type ( // ErrorFormatter implemented by the errors below to // allow them format command output on error. ErrorFormatter interface { FormatError(*cobra.Command) } // FormattedError is an error that format the command output when returned. FormattedError struct { Err error Prefix string // Prefix to use on error. Silent bool // Silent errors are not printed. } // AbortError returns a command error that is formatted as "Abort: ..." when // the execution is aborted by the user. AbortError struct { Err error } // Aborter allows errors to signal if the error is an abort error. Aborter interface { error IsAbort() } ) func (e *FormattedError) Error() string { return e.Err.Error() } func (e *FormattedError) FormatError(cmd *cobra.Command) { cmd.SilenceErrors = e.Silent if e.Prefix != "" { cmd.SetErrPrefix(e.Prefix) } } // AbortErrorf is like fmt.Errorf for creating AbortError. func AbortErrorf(format string, a ...any) error { return &AbortError{Err: fmt.Errorf(format, a...)} } func (e *AbortError) Error() string { return e.Err.Error() } func (e *AbortError) FormatError(cmd *cobra.Command) { cmd.SetErrPrefix("Abort:") } func (e *AbortError) Unwrap() error { return e.Err } // RunE wraps the command cobra.Command.RunE function with additional postrun logic. func RunE(f func(*cobra.Command, []string) error) func(*cobra.Command, []string) error { return func(cmd *cobra.Command, args []string) (err error) { if err = f(cmd, args); err != nil { if err1 := (Aborter)(nil); errors.As(err, &err1) { err = &AbortError{Err: err} } if ef, ok := err.(ErrorFormatter); ok { ef.FormatError(cmd) } } return err } } func init() { Root.AddCommand(versionCmd) Root.AddCommand(licenseCmd) // Register a global function to clean up the global // flags regardless if the command passed or failed. cobra.OnFinalize(func() { GlobalFlags.ConfigURL = "" GlobalFlags.Vars = nil GlobalFlags.SelectedEnv = "" }) } // parseV returns a user facing version and release notes url func parseV(version string) (string, string) { u := "https://github.com/ariga/atlas/releases/latest" if ok := semver.IsValid(version); !ok { return "- development", u } s := strings.Split(version, "-") if len(s) != 0 && s[len(s)-1] != "canary" { u = fmt.Sprintf("https://github.com/ariga/atlas/releases/tag/%s", version) } return version, u } // Version returns the current Atlas binary version. func Version() string { return version } // Vars implements pflag.Value. type Vars map[string]cty.Value // String implements pflag.Value.String. func (v Vars) String() string { var ( b strings.Builder ks = maps.Keys(v) ) sort.Strings(ks) for _, k := range ks { if b.Len() > 0 { b.WriteString(", ") } b.WriteString(k) b.WriteString(":") switch v1 := v[k]; v1.Type() { case cty.String: b.WriteString(v1.AsString()) case cty.List(cty.String): b.WriteString("[") for i, v2 := range v1.AsValueSlice() { if i > 0 { b.WriteString(", ") } b.WriteString(v2.AsString()) } b.WriteString("]") default: b.WriteString(v1.GoString()) } } return "[" + b.String() + "]" } // Copy returns a copy of the current variables. func (v Vars) Copy() Vars { vc := make(Vars) for k := range v { vc[k] = v[k] } return vc } // Replace overrides the variables. func (v *Vars) Replace(vc Vars) { *v = vc } // Set implements pflag.Value.Set. func (v *Vars) Set(s string) error { if *v == nil { *v = make(Vars) } kvs, err := csv.NewReader(strings.NewReader(s)).Read() if err != nil { return err } for i := range kvs { kv := strings.SplitN(kvs[i], "=", 2) if len(kv) != 2 { return fmt.Errorf("variables must be format as key=value, got: %q", kvs[i]) } v1 := cty.StringVal(kv[1]) switch v0, ok := (*v)[kv[0]]; { case ok && v0.Type().IsListType(): (*v)[kv[0]] = cty.ListVal(append(v0.AsValueSlice(), v1)) case ok: (*v)[kv[0]] = cty.ListVal([]cty.Value{v0, v1}) default: (*v)[kv[0]] = v1 } } return nil } // Type implements pflag.Value.Type. func (v *Vars) Type() string { return "=" } const ( flagAllowDirty = "allow-dirty" flagEdit = "edit" flagAutoApprove = "auto-approve" flagBaseline = "baseline" flagConfig = "config" flagContext = "context" flagDevURL = "dev-url" flagDirURL = "dir" flagDirFormat = "dir-format" flagDryRun = "dry-run" flagEnv = "env" flagExclude = "exclude" flagInclude = "include" flagFile = "file" flagFrom = "from" flagFromShort = "f" flagFormat = "format" flagGitBase = "git-base" flagGitDir = "git-dir" flagLatest = "latest" flagLockTimeout = "lock-timeout" flagLog = "log" flagPlan = "plan" flagRevisionSchema = "revisions-schema" flagSchema = "schema" flagSchemaShort = "s" flagTo = "to" flagTxMode = "tx-mode" flagExecOrder = "exec-order" flagURL = "url" flagURLShort = "u" flagVar = "var" flagQualifier = "qualifier" ) func addGlobalFlags(set *pflag.FlagSet) { set.StringVar(&GlobalFlags.SelectedEnv, flagEnv, "", "set which env from the config file to use") set.Var(&GlobalFlags.Vars, flagVar, "input variables") set.StringVarP(&GlobalFlags.ConfigURL, flagConfig, "c", defaultConfigPath, "select config (project) file using URL format") } func addFlagAutoApprove(set *pflag.FlagSet, target *bool) { set.BoolVar(target, flagAutoApprove, false, "apply changes without prompting for approval") } func addFlagDirFormat(set *pflag.FlagSet, target *string) { set.StringVar(target, flagDirFormat, "atlas", "select migration file format") } func addFlagLockTimeout(set *pflag.FlagSet, target *time.Duration) { set.DurationVar(target, flagLockTimeout, 10*time.Second, "set how long to wait for the database lock") } // addFlagURL adds a URL flag. If given, args[0] override the name, args[1] the shorthand, args[2] the default value. func addFlagDirURL(set *pflag.FlagSet, target *string, args ...string) { name, short, val := flagDirURL, "", "file://migrations" switch len(args) { case 3: val = args[2] fallthrough case 2: short = args[1] fallthrough case 1: name = args[0] } set.StringVarP(target, name, short, val, "select migration directory using URL format") } func addFlagDevURL(set *pflag.FlagSet, target *string) { set.StringVar( target, flagDevURL, "", "[driver://username:password@address/dbname?param=value] select a dev database using the URL format", ) } func addFlagDryRun(set *pflag.FlagSet, target *bool) { set.BoolVar(target, flagDryRun, false, "print SQL without executing it") } func addFlagExclude(set *pflag.FlagSet, target *[]string) { set.StringSliceVar( target, flagExclude, nil, "list of glob patterns used to filter resources from applying", ) } func addFlagInclude(set *pflag.FlagSet, target *[]string) { set.StringSliceVar( target, flagInclude, nil, "list of glob patterns used to select which resources to keep in inspection", ) } func addFlagLog(set *pflag.FlagSet, target *string) { set.StringVar(target, flagLog, "", "Go template to use to format the output") // Use MarkHidden instead of MarkDeprecated to avoid // spam users' system logs with deprecation warnings. cobra.CheckErr(set.MarkHidden(flagLog)) } func addFlagFormat(set *pflag.FlagSet, target *string) { set.StringVar(target, flagFormat, "", "Go template to use to format the output") } func addFlagRevisionSchema(set *pflag.FlagSet, target *string) { set.StringVar(target, flagRevisionSchema, "", "name of the schema the revisions table resides in") } func addFlagSchemas(set *pflag.FlagSet, target *[]string) { set.StringSliceVarP( target, flagSchema, flagSchemaShort, nil, "set schema names", ) } // addFlagURL adds a URL flag. If given, args[0] override the name, args[1] the shorthand. func addFlagURL(set *pflag.FlagSet, target *string, args ...string) { name, short := flagURL, flagURLShort switch len(args) { case 2: short = args[1] fallthrough case 1: name = args[0] } set.StringVarP( target, name, short, "", "[driver://username:password@address/dbname?param=value] select a resource using the URL format", ) } func addFlagURLs(set *pflag.FlagSet, target *[]string, args ...string) { name, short := flagURL, flagURLShort switch len(args) { case 2: short = args[1] fallthrough case 1: name = args[0] } set.StringSliceVarP( target, name, short, nil, "[driver://username:password@address/dbname?param=value] select a resource using the URL format", ) } func addFlagToURLs(set *pflag.FlagSet, target *[]string) { set.StringSliceVarP(target, flagTo, "", nil, "[driver://username:password@address/dbname?param=value] select a desired state using the URL format") } // maySetFlag sets the flag with the provided name to envVal if such a flag exists // on the cmd, it was not set by the user via the command line and if envVal is not // an empty string. func maySetFlag(cmd *cobra.Command, name, envVal string) error { if f := cmd.Flag(name); f == nil || f.Changed || envVal == "" { return nil } return cmd.Flags().Set(name, envVal) } // resetFromEnv traverses the command flags, records what flags // were not set by the user and returns a callback to clear them // after it was set by the current environment. func resetFromEnv(cmd *cobra.Command) func() { mayReset := make(map[string]func() error) cmd.Flags().VisitAll(func(f *pflag.Flag) { if f.Changed { return } vs := f.Value.String() r := func() error { return f.Value.Set(vs) } if v, ok := f.Value.(*Vars); ok { vs := v.Copy() r = func() error { v.Replace(vs.Copy()) return nil } } else if v, ok := f.Value.(pflag.SliceValue); ok { vs := v.GetSlice() r = func() error { return v.Replace(vs) } } mayReset[f.Name] = r }) return func() { for name, reset := range mayReset { if f := cmd.Flag(name); f != nil && f.Changed { f.Changed = false // Unexpected error, because this flag was set before. cobra.CheckErr(reset()) } } } } // stateReaderConfig is given to stateReader. type stateReaderConfig struct { urls []string // urls to create a migrate.StateReader from client, dev *sqlclient.Client // database connections, while dev is considered a dev database, client is not schemas []string // schemas to work on exclude []string // exclude flag values include []string // include flag values withPos bool // indicate if schema.Pos should be loaded. vars Vars } // Exported is a temporary method to convert the stateReaderConfig to cmdext.StateReaderConfig. func (c *stateReaderConfig) Exported() (*cmdext.StateReaderConfig, error) { var ( err error parsed = make([]*url.URL, len(c.urls)) ) for i, u := range c.urls { if parsed[i], err = sqlclient.ParseURL(u); err != nil { return nil, err } } return &cmdext.StateReaderConfig{ URLs: parsed, Client: c.client, Dev: c.dev, Schemas: c.schemas, Exclude: c.exclude, Include: c.include, WithPos: c.withPos, Vars: c.vars, }, nil } // readerUseDev reports if any of the URL uses the dev-database. func readerUseDev(env *Env, urls ...string) (bool, error) { s, err := selectScheme(urls) if err != nil { return false, err } switch { case s == envAttrScheme && env != nil && len(urls) == 1: u, err := env.VarFromURL(urls[0]) if err != nil { return false, err } // No circular reference possible with env:// variable. return readerUseDev(env, u) case s == cmdext.SchemaTypeFile, s == cmdext.SchemaTypeAtlas: return true, nil default: return cmdext.States.HasLoader(s), nil } } // stateReader returns a migrate.StateReader that reads the state from the given urls. func stateReader(ctx context.Context, env *Env, config *stateReaderConfig) (*cmdext.StateReadCloser, error) { excfg, err := config.Exported() if err != nil { return nil, err } scheme, err := selectScheme(config.urls) if err != nil { return nil, err } switch scheme { // "file" scheme is valid for both migration directory and HCL paths. case cmdext.SchemaTypeFile: switch ext, err := cmdext.FilesExt(excfg.URLs); { case err != nil: return nil, err case ext == cmdext.FileTypeHCL: return cmdext.StateReaderHCL(ctx, excfg) case ext == cmdext.FileTypeSQL: return cmdext.StateReaderSQL(ctx, excfg) default: panic("unreachable") // checked by filesExt. } // "atlas" scheme represents an Atlas Cloud schema. case cmdext.SchemaTypeAtlas: return cmdext.StateReaderAtlas(ctx, excfg) // "env" scheme represents an attribute defined // on the selected environment. case envAttrScheme: switch { case GlobalFlags.SelectedEnv == "": return nil, errors.New("cannot use env:// variables without selecting an environment") case len(config.urls) != 1: return nil, errors.New("cannot use multiple env:// variables in a single flag") default: u, err := env.VarFromURL(config.urls[0]) if err != nil { return nil, err } cfg := *config cfg.urls = []string{u} return stateReader(ctx, env, &cfg) } default: // In case there is an external state-loader registered with this scheme. if l, ok := cmdext.States.Loader(scheme); ok { rc, err := l.LoadState(ctx, excfg) if err != nil { return nil, err } return rc, nil } // All other schemes are database (or docker) connections. c, err := env.openClient(ctx, config.urls[0]) // call to selectScheme already checks for len > 0 if err != nil { return nil, err } var sr migrate.StateReader switch c.URL.Schema { case "": sr = migrate.RealmConn(c.Driver, &schema.InspectRealmOption{ Schemas: config.schemas, Exclude: config.exclude, Include: config.include, }) default: sr = migrate.SchemaConn(c.Driver, c.URL.Schema, &schema.InspectOptions{ Exclude: config.exclude, Include: config.include, }) } return &cmdext.StateReadCloser{ StateReader: sr, Closer: c, Schema: c.URL.Schema, }, nil } } const localStateFile = "local-community.json" // LocalState keeps track of local state to enhance developer experience. type LocalState struct { UpgradeSuggested time.Time `json:"v1.us"` } ================================================ FILE: cmd/atlas/internal/cmdapi/cmdapi_oss.go ================================================ // Copyright 2021-present The Atlas Authors. All rights reserved. // This source code is licensed under the Apache 2.0 license found // in the LICENSE file in the root directory of this source tree. //go:build !ent package cmdapi import ( "context" "errors" "fmt" "net/url" "os" "testing" "text/template" "time" "ariga.io/atlas/cmd/atlas/internal/cmdext" "ariga.io/atlas/cmd/atlas/internal/cmdlog" "ariga.io/atlas/cmd/atlas/internal/cmdstate" cmdmigrate "ariga.io/atlas/cmd/atlas/internal/migrate" "ariga.io/atlas/cmd/atlas/internal/migratelint" "ariga.io/atlas/schemahcl" "ariga.io/atlas/sql/migrate" "ariga.io/atlas/sql/schema" "ariga.io/atlas/sql/sqlcheck" "ariga.io/atlas/sql/sqlclient" "github.com/spf13/cobra" ) func init() { schemaCmd := schemaCmd() schemaCmd.AddCommand( schemaApplyCmd(), schemaCleanCmd(), schemaDiffCmd(), schemaFmtCmd(), schemaInspectCmd(), unsupportedCommand("schema", "test"), unsupportedCommand("schema", "plan"), unsupportedCommand("schema", "push"), ) Root.AddCommand(schemaCmd) migrateCmd := migrateCmd() migrateCmd.AddCommand( migrateApplyCmd(), migrateDiffCmd(), migrateHashCmd(), migrateImportCmd(), migrateLintCmd(), migrateNewCmd(), migrateSetCmd(), migrateStatusCmd(), migrateValidateCmd(), unsupportedCommand("migrate", "checkpoint"), unsupportedCommand("migrate", "down"), unsupportedCommand("migrate", "rebase"), unsupportedCommand("migrate", "rm"), unsupportedCommand("migrate", "edit"), unsupportedCommand("migrate", "push"), unsupportedCommand("migrate", "test"), ) Root.AddCommand(migrateCmd) } // unsupportedCommand create a stub command that reports // the command is not supported by this build. func unsupportedCommand(cmd, sub string) *cobra.Command { s := unsupportedMessage(cmd, sub) c := &cobra.Command{ Hidden: true, Use: fmt.Sprintf("%s is not supported by this build", sub), Short: s, Long: s, RunE: RunE(func(*cobra.Command, []string) error { return AbortErrorf("%s", s) }), } c.SetHelpTemplate(s + "\n") return c } // unsupportedMessage returns a message informing the user that the command // or one of its options are not supported. For example: // // unsupportedMessage("migrate", "checkpoint") // unsupportedMessage("schema", "apply --plan") func unsupportedMessage(cmd, sub string) string { return fmt.Sprintf( `'atlas %s %s' is not supported by the community version. To install the non-community version of Atlas, use the following command: curl -sSf https://atlasgo.sh | sh Or, visit the website to see all installation options: https://atlasgo.io/docs#installation `, cmd, sub, ) } type ( // Project represents an atlas.hcl project config file. Project struct { Envs []*Env `spec:"env"` // List of environments Lint *Lint `spec:"lint"` // Optional global lint policy Diff *Diff `spec:"diff"` // Optional global diff policy Test *Test `spec:"test"` // Optional test configuration cloud *cmdext.AtlasConfig } ) const ( envSkipUpgradeSuggestions = "ATLAS_NO_UPGRADE_SUGGESTIONS" oneWeek = 7 * 24 * time.Hour ) // maySuggestUpgrade informs the user about the limitations of the community edition to stderr // at most once a week. The user can disable this message by setting the ATLAS_NO_UPGRADE_SUGGESTIONS // environment variable. func maySuggestUpgrade(cmd *cobra.Command) { if os.Getenv(envSkipUpgradeSuggestions) != "" || testing.Testing() { return } state := cmdstate.File[LocalState]{Name: localStateFile} prev, err := state.Read() if err != nil { return } if time.Since(prev.UpgradeSuggested) < oneWeek { return } s := `Notice: This Atlas edition lacks support for features such as checkpoints, testing, down migrations, and more. Additionally, advanced database objects such as views, triggers, and stored procedures are not supported. To read more: https://atlasgo.io/community-edition To install the non-community version of Atlas, use the following command: curl -sSf https://atlasgo.sh | sh Or, visit the website to see all installation options: https://atlasgo.io/docs#installation ` _ = cmdlog.WarnOnce(cmd.ErrOrStderr(), cmdlog.ColorCyan(s)) prev.UpgradeSuggested = time.Now() _ = state.Write(prev) } // migrateLintSetFlags allows setting extra flags for the 'migrate lint' command. func migrateLintSetFlags(*cobra.Command, *migrateLintFlags) {} // migrateLintRun is the run command for 'migrate lint'. func migrateLintRun(cmd *cobra.Command, _ []string, flags migrateLintFlags, env *Env) error { dev, err := sqlclient.Open(cmd.Context(), flags.devURL) if err != nil { return err } defer dev.Close() dir, err := cmdmigrate.Dir(cmd.Context(), flags.dirURL, false) if err != nil { return err } var detect migratelint.ChangeDetector switch { case flags.latest == 0 && flags.gitBase == "": return fmt.Errorf("--%s or --%s is required", flagLatest, flagGitBase) case flags.latest > 0 && flags.gitBase != "": return fmt.Errorf("--%s and --%s are mutually exclusive", flagLatest, flagGitBase) case flags.latest > 0: detect = migratelint.LatestChanges(dir, int(flags.latest)) case flags.gitBase != "": detect, err = migratelint.NewGitChangeDetector( dir, migratelint.WithWorkDir(flags.gitDir), migratelint.WithBase(flags.gitBase), migratelint.WithMigrationsPath(dir.(interface{ Path() string }).Path()), ) if err != nil { return err } } format := migratelint.DefaultTemplate if f := flags.logFormat; f != "" { format, err = template.New("format").Funcs(migratelint.TemplateFuncs).Parse(f) if err != nil { return fmt.Errorf("parse format: %w", err) } } az, err := sqlcheck.AnalyzerFor(dev.Name, env.Lint.Remain()) if err != nil { return err } r := &migratelint.Runner{ Dev: dev, Dir: dir, ChangeDetector: detect, ReportWriter: &migratelint.TemplateWriter{ T: format, W: cmd.OutOrStdout(), }, Analyzers: az, } err = r.Run(cmd.Context()) // Print the error in case it was not printed before. cmd.SilenceErrors = errors.As(err, &migratelint.SilentError{}) cmd.SilenceUsage = cmd.SilenceErrors return err } func migrateDiffRun(cmd *cobra.Command, args []string, flags migrateDiffFlags, env *Env) error { if flags.dryRun { return errors.New("'--dry-run' is not supported in the community version") } ctx := cmd.Context() dev, err := sqlclient.Open(ctx, flags.devURL) if err != nil { return err } defer dev.Close() // Acquire a lock. unlock, err := dev.Lock(ctx, "atlas_migrate_diff", flags.lockTimeout) if err != nil { return fmt.Errorf("acquiring database lock: %w", err) } // If unlocking fails notify the user about it. defer func() { cobra.CheckErr(unlock()) }() // Open the migration directory. u, err := url.Parse(flags.dirURL) if err != nil { return err } dir, err := cmdmigrate.DirURL(ctx, u, false) if err != nil { return err } if flags.edit { l, ok := dir.(*migrate.LocalDir) if !ok { return fmt.Errorf("--edit flag supports only atlas directories, but got: %T", dir) } dir = &editDir{l} } var name, indent string if len(args) > 0 { name = args[0] } f, err := cmdmigrate.Formatter(u) if err != nil { return err } if f, indent, err = mayIndent(u, f, flags.format); err != nil { return err } diffOpts := diffOptions(cmd, env) // If there is a state-loader that requires a custom // 'migrate diff' handling, offload it the work. if d, ok := cmdext.States.Differ(flags.desiredURLs); ok { err := d.MigrateDiff(ctx, &cmdext.MigrateDiffOptions{ To: flags.desiredURLs, Name: name, Indent: indent, Dir: dir, Dev: dev, Options: diffOpts, }) return maskNoPlan(cmd, err) } // Get a state reader for the desired state. desired, err := stateReader(ctx, env, &stateReaderConfig{ urls: flags.desiredURLs, dev: dev, client: dev, schemas: flags.schemas, vars: env.Vars(), }) if err != nil { return err } defer desired.Close() opts := []migrate.PlannerOption{ migrate.PlanFormat(f), migrate.PlanWithIndent(indent), migrate.PlanWithDiffOptions(diffOpts...), } if dev.URL.Schema != "" { // Disable tables qualifier in schema-mode. opts = append(opts, migrate.PlanWithSchemaQualifier(flags.qualifier)) } // Plan the changes and create a new migration file. pl := migrate.NewPlanner(dev.Driver, dir, opts...) plan, err := func() (*migrate.Plan, error) { if dev.URL.Schema != "" { return pl.PlanSchema(ctx, name, desired.StateReader) } return pl.Plan(ctx, name, desired.StateReader) }() var cerr *migrate.NotCleanError switch { case errors.As(err, &cerr) && dev.URL.Schema == "" && desired.Schema != "": return fmt.Errorf("dev database is not clean (%s). Add a schema to the URL to limit the scope of the connection", cerr.Reason) case err != nil: return maskNoPlan(cmd, err) default: return pl.WritePlan(plan) } } // schemaApplyRunE is the community version of the 'atlas schema apply' command. func schemaApplyRunE(cmd *cobra.Command, _ []string, flags *schemaApplyFlags) error { switch { case flags.edit: return AbortErrorf("%s", unsupportedMessage("schema", "apply --edit")) case flags.planURL != "": return AbortErrorf("%s", unsupportedMessage("schema", "apply --plan")) case len(flags.include) > 0: return AbortErrorf("%s", unsupportedMessage("schema", "apply --include")) case GlobalFlags.SelectedEnv == "": env, err := selectEnv(cmd) if err != nil { return err } return schemaApplyRun(cmd, *flags, env) default: _, envs, err := EnvByName(cmd, GlobalFlags.SelectedEnv, GlobalFlags.Vars) if err != nil { return err } if len(envs) != 1 { return fmt.Errorf("multi-environment %q is not supported", GlobalFlags.SelectedEnv) } if err := setSchemaEnvFlags(cmd, envs[0]); err != nil { return err } return schemaApplyRun(cmd, *flags, envs[0]) } } func schemaApplyRun(cmd *cobra.Command, flags schemaApplyFlags, env *Env) error { var ( err error ctx = cmd.Context() dev *sqlclient.Client format = cmdlog.SchemaPlanTemplate ) if err = flags.check(env); err != nil { return err } if v := flags.logFormat; v != "" { if !flags.dryRun && !flags.autoApprove { return errors.New(`--log and --format can only be used with --dry-run or --auto-approve`) } if format, err = template.New("format").Funcs(cmdlog.ApplyTemplateFuncs).Parse(v); err != nil { return fmt.Errorf("parse log format: %w", err) } } if flags.devURL != "" { if dev, err = sqlclient.Open(ctx, flags.devURL); err != nil { return err } defer dev.Close() } from, err := stateReader(ctx, env, &stateReaderConfig{ urls: []string{flags.url}, schemas: flags.schemas, exclude: flags.exclude, }) if err != nil { return err } defer from.Close() client, ok := from.Closer.(*sqlclient.Client) if !ok { return errors.New("--url must be a database connection") } to, err := stateReader(ctx, env, &stateReaderConfig{ urls: flags.toURLs, dev: dev, client: client, schemas: flags.schemas, exclude: flags.exclude, vars: env.Vars(), }) if err != nil { return err } defer to.Close() diff, err := computeDiff(ctx, client, from, to, diffOptions(cmd, env)...) if err != nil { return err } maySuggestUpgrade(cmd) // Returning at this stage should // not trigger the help message. cmd.SilenceUsage = true switch changes := diff.changes; { case len(changes) == 0: return format.Execute(cmd.OutOrStdout(), &cmdlog.SchemaApply{}) case flags.logFormat != "" && flags.autoApprove: var ( applied int plan *migrate.Plan cause *cmdlog.StmtError out = cmd.OutOrStdout() ) if plan, err = client.PlanChanges(ctx, "", changes, planOptions(client)...); err != nil { return err } if err = applyChanges(ctx, client, changes, flags.txMode); err == nil { applied = len(plan.Changes) } else if i, ok := err.(interface{ Applied() int }); ok && i.Applied() < len(plan.Changes) { applied, cause = i.Applied(), &cmdlog.StmtError{Stmt: plan.Changes[i.Applied()].Cmd, Text: err.Error()} } else { cause = &cmdlog.StmtError{Text: err.Error()} } err1 := format.Execute(out, cmdlog.NewSchemaApply(ctx, cmdlog.NewEnv(client, nil), plan.Changes[:applied], plan.Changes[applied:], cause)) return errors.Join(err, err1) default: switch err := summary(cmd, client, changes, format); { case err != nil: return err case flags.dryRun: return nil case flags.autoApprove: return applyChanges(ctx, client, changes, flags.txMode) default: return promptApply(cmd, flags, diff, client, dev) } } } // applySchemaClean is the community-version of the 'atlas schema clean' handler. func applySchemaClean(cmd *cobra.Command, client *sqlclient.Client, drop []schema.Change, flags schemaCleanFlags) error { if flags.dryRun { return AbortErrorf("%s", unsupportedMessage("schema", "clean --dry-run")) } if flags.logFormat != "" { return AbortErrorf("%s", unsupportedMessage("schema", "clean --format")) } if len(drop) == 0 { cmd.Println("Nothing to drop") return nil } if err := summary(cmd, client, drop, cmdlog.SchemaPlanTemplate); err != nil { return err } if flags.autoApprove || promptUser(cmd) { if err := client.ApplyChanges(cmd.Context(), drop); err != nil { return err } } return nil } func schemaDiffRun(cmd *cobra.Command, _ []string, flags schemaDiffFlags, env *Env) error { var ( ctx = cmd.Context() c *sqlclient.Client ) if len(flags.include) > 0 { return AbortErrorf("%s", unsupportedMessage("schema", "diff --include")) } // We need a driver for diffing and planning. If given, dev database has precedence. if flags.devURL != "" { var err error c, err = sqlclient.Open(ctx, flags.devURL) if err != nil { return err } defer c.Close() } from, err := stateReader(ctx, env, &stateReaderConfig{ urls: flags.fromURL, dev: c, vars: env.Vars(), schemas: flags.schemas, exclude: flags.exclude, }) if err != nil { return err } defer from.Close() to, err := stateReader(ctx, env, &stateReaderConfig{ urls: flags.toURL, dev: c, vars: env.Vars(), schemas: flags.schemas, exclude: flags.exclude, }) if err != nil { return err } defer to.Close() if c == nil { // If not both states are provided by a database connection, the call to state-reader would have returned // an error already. If we land in this case, we can assume both states are database connections. c = to.Closer.(*sqlclient.Client) } format := cmdlog.SchemaDiffTemplate if v := flags.format; v != "" { if format, err = template.New("format").Funcs(cmdlog.SchemaDiffFuncs).Parse(v); err != nil { return fmt.Errorf("parse log format: %w", err) } } diff, err := computeDiff(ctx, c, from, to, diffOptions(cmd, env)...) if err != nil { return err } maySuggestUpgrade(cmd) return format.Execute(cmd.OutOrStdout(), cmdlog.NewSchemaDiff(ctx, c, diff.from, diff.to, diff.changes), ) } func summary(cmd *cobra.Command, c *sqlclient.Client, changes []schema.Change, t *template.Template) error { p, err := c.PlanChanges(cmd.Context(), "", changes, planOptions(c)...) if err != nil { return err } return t.Execute( cmd.OutOrStdout(), cmdlog.NewSchemaPlan(cmd.Context(), cmdlog.NewEnv(c, nil), p.Changes, nil), ) } func promptApply(cmd *cobra.Command, flags schemaApplyFlags, diff *diff, client, _ *sqlclient.Client) error { if !flags.dryRun && (flags.autoApprove || promptUser(cmd)) { return applyChanges(cmd.Context(), client, diff.changes, flags.txMode) } return nil } func maySetLoginContext(*cobra.Command, *Project) error { return nil } func setEnvs(context.Context, []*Env) {} // specOptions are the options for the schema spec. var specOptions []schemahcl.Option // diffOptions returns environment-aware diff options. func diffOptions(_ *cobra.Command, env *Env) []schema.DiffOption { return append(env.DiffOptions(), schema.DiffNormalized()) } // openClient allows opening environment-aware clients. func (*Env) openClient(ctx context.Context, u string) (*sqlclient.Client, error) { return sqlclient.Open(ctx, u) } type schemaInspectFlags struct { url string // URL of resource to inspect. devURL string // URL of the dev database. logFormat string // Format of the log output. schemas []string // Schemas to take into account when diffing. exclude []string // List of glob patterns used to filter resources from applying (see schema.InspectOptions). } // schemaInspectCmd represents the 'atlas schema inspect' subcommand. func schemaInspectCmd() *cobra.Command { cmd, _ := schemaInspectCmdWithFlags() return cmd } func schemaInspectCmdWithFlags() (*cobra.Command, *schemaInspectFlags) { var ( env *Env flags schemaInspectFlags cmd = &cobra.Command{ Use: "inspect", Short: "Inspect a database and print its schema in Atlas DDL syntax.", Long: `'atlas schema inspect' connects to the given database and inspects its schema. It then prints to the screen the schema of that database in Atlas DDL syntax. This output can be saved to a file, commonly by redirecting the output to a file named with a ".hcl" suffix: atlas schema inspect -u "mysql://user:pass@localhost:3306/dbname" > schema.hcl This file can then be edited and used with the` + " `atlas schema apply` " + `command to plan and execute schema migrations against the given database. In cases where users wish to inspect all multiple schemas in a given database (for instance a MySQL server may contain multiple named databases), omit the relevant part from the url, e.g. "mysql://user:pass@localhost:3306/". To select specific schemas from the databases, users may use the "--schema" (or "-s" shorthand) flag. `, Example: ` atlas schema inspect -u "mysql://user:pass@localhost:3306/dbname" atlas schema inspect -u "mariadb://user:pass@localhost:3306/" --schema=schemaA,schemaB -s schemaC atlas schema inspect --url "postgres://user:pass@host:port/dbname?sslmode=disable" atlas schema inspect -u "sqlite://file:ex1.db?_fk=1"`, PreRunE: RunE(func(cmd *cobra.Command, args []string) (err error) { if env, err = selectEnv(cmd); err != nil { return err } return setSchemaEnvFlags(cmd, env) }), RunE: RunE(func(cmd *cobra.Command, args []string) error { return schemaInspectRun(cmd, args, flags, env) }), } ) cmd.Flags().SortFlags = false addFlagURL(cmd.Flags(), &flags.url) addFlagDevURL(cmd.Flags(), &flags.devURL) addFlagSchemas(cmd.Flags(), &flags.schemas) addFlagExclude(cmd.Flags(), &flags.exclude) addFlagLog(cmd.Flags(), &flags.logFormat) addFlagFormat(cmd.Flags(), &flags.logFormat) cobra.CheckErr(cmd.MarkFlagRequired(flagURL)) cmd.MarkFlagsMutuallyExclusive(flagLog, flagFormat) return cmd, &flags } func schemaInspectRun(cmd *cobra.Command, _ []string, flags schemaInspectFlags, env *Env) error { var ( ctx = cmd.Context() dev *sqlclient.Client ) useDev, err := readerUseDev(env, flags.url) if err != nil { return err } if flags.devURL != "" && useDev { if dev, err = sqlclient.Open(ctx, flags.devURL); err != nil { return err } defer dev.Close() } r, err := stateReader(ctx, env, &stateReaderConfig{ urls: []string{flags.url}, dev: dev, vars: env.Vars(), schemas: flags.schemas, exclude: flags.exclude, }) if err != nil { return err } defer r.Close() client, ok := r.Closer.(*sqlclient.Client) if !ok && dev != nil { client = dev } format := cmdlog.SchemaInspectTemplate if v := flags.logFormat; v != "" { if format, err = template.New("format").Funcs(cmdlog.InspectTemplateFuncs).Parse(v); err != nil { return fmt.Errorf("parse log format: %w", err) } } s, err := r.ReadState(ctx) if err != nil { return err } maySuggestUpgrade(cmd) i := cmdlog.NewSchemaInspect(ctx, client, s) i.URL = flags.url return format.Execute(cmd.OutOrStdout(), i) } ================================================ FILE: cmd/atlas/internal/cmdapi/cmdapi_test.go ================================================ // Copyright 2021-present The Atlas Authors. All rights reserved. // This source code is licensed under the Apache 2.0 license found // in the LICENSE file in the root directory of this source tree. package cmdapi import ( "bytes" "context" "database/sql" "fmt" "os" "testing" "ariga.io/atlas/sql/sqlite" "github.com/spf13/cobra" "github.com/stretchr/testify/require" ) func TestVars_String(t *testing.T) { var vs Vars require.Equal(t, "[]", vs.String()) require.NoError(t, vs.Set("a=b")) require.Equal(t, "[a:b]", vs.String()) require.NoError(t, vs.Set("b=c")) require.Equal(t, "[a:b, b:c]", vs.String()) require.NoError(t, vs.Set("a=d")) require.Equal(t, "[a:[b, d], b:c]", vs.String(), "multiple values of the same key: --var url= --var url=") } func runCmd(cmd *cobra.Command, args ...string) (string, error) { return runCmdContext(context.Background(), cmd, args...) } func runCmdContext(ctx context.Context, cmd *cobra.Command, args ...string) (string, error) { var out bytes.Buffer cmd.SetOut(&out) cmd.SetErr(&out) // Cobra checks for the args to equal nil and if so uses os.Args[1:]. // In tests, this leads to go tooling arguments being part of the command arguments. if args == nil { args = []string{} } cmd.SetArgs(args) err := cmd.ExecuteContext(ctx) return out.String(), err } // openSQLite creates a sqlite db, seeds it with the seed query and returns the url to it. func openSQLite(t *testing.T, seed string) string { f, err := os.CreateTemp("", "sqlite.db") require.NoError(t, err) t.Cleanup(func() { require.NoError(t, os.Remove(f.Name())) }) dsn := fmt.Sprintf("file:%s?cache=shared&_fk=1", f.Name()) db, err := sql.Open("sqlite3", dsn) require.NoError(t, err) t.Cleanup(func() { require.NoError(t, db.Close()) }) drv, err := sqlite.Open(db) require.NoError(t, err) if seed != "" { _, err := drv.ExecContext(context.Background(), seed) require.NoError(t, err) } return fmt.Sprintf("sqlite://%s", dsn) } ================================================ FILE: cmd/atlas/internal/cmdapi/migrate.go ================================================ // Copyright 2021-present The Atlas Authors. All rights reserved. // This source code is licensed under the Apache 2.0 license found // in the LICENSE file in the root directory of this source tree. package cmdapi import ( "bytes" "context" "database/sql" "encoding/json" "errors" "fmt" "io" "net/url" "os" "os/exec" "path" "path/filepath" "strconv" "strings" "text/template" "text/template/parse" "time" "ariga.io/atlas/cmd/atlas/internal/cloudapi" "ariga.io/atlas/cmd/atlas/internal/cmdext" "ariga.io/atlas/cmd/atlas/internal/cmdlog" cmdmigrate "ariga.io/atlas/cmd/atlas/internal/migrate" "ariga.io/atlas/cmd/atlas/internal/migrate/ent/revision" "ariga.io/atlas/sql/migrate" "ariga.io/atlas/sql/schema" "ariga.io/atlas/sql/sqlclient" "ariga.io/atlas/sql/sqltool" "github.com/google/uuid" "github.com/spf13/cobra" ) // migrateCmd represents the subcommand 'atlas migrate'. func migrateCmd() *cobra.Command { cmd := &cobra.Command{ Use: "migrate", Short: "Manage versioned migration files", Long: "'atlas migrate' wraps several sub-commands for migration management.", } addGlobalFlags(cmd.PersistentFlags()) return cmd } type migrateApplyFlags struct { url string dirURL string revisionSchema string dryRun bool logFormat string lockTimeout time.Duration allowDirty bool // allow working on a database that already has resources baselineVersion string // apply with this version as baseline txMode string // (none, file, all) execOrder string // (linear, linear-skip, non-linear) context string // Run context. See cloudapi.DeployContextInput. } func (f *migrateApplyFlags) migrateOptions() ([]migrate.ExecutorOption, error) { var opts []migrate.ExecutorOption if f.allowDirty { opts = append(opts, migrate.WithAllowDirty(true)) } if v := f.baselineVersion; v != "" { opts = append(opts, migrate.WithBaselineVersion(v)) } if v := f.execOrder; v != "" && v != execOrderLinear { switch v { case execOrderLinearSkip: opts = append(opts, migrate.WithExecOrder(migrate.ExecOrderLinearSkip)) case execOrderNonLinear: opts = append(opts, migrate.WithExecOrder(migrate.ExecOrderNonLinear)) default: return nil, fmt.Errorf("unknown execution order: %q", v) } } return opts, nil } func migrateApplyCmd() *cobra.Command { var ( flags migrateApplyFlags cmd = &cobra.Command{ Use: "apply [flags] [amount]", Short: "Applies pending migration files on the connected database.", Long: `'atlas migrate apply' reads the migration state of the connected database and computes what migrations are pending. It then attempts to apply the pending migration files in the correct order onto the database. The first argument denotes the maximum number of migration files to apply. As a safety measure 'atlas migrate apply' will abort with an error, if: - the migration directory is not in sync with the 'atlas.sum' file - the migration and database history do not match each other If run with the "--dry-run" flag, atlas will not execute any SQL.`, Example: ` atlas migrate apply -u "mysql://user:pass@localhost:3306/dbname" atlas migrate apply --dir "file:///path/to/migration/directory" --url "mysql://user:pass@localhost:3306/dbname" 1 atlas migrate apply --env dev 1 atlas migrate apply --dry-run --env dev 1`, Args: cobra.MaximumNArgs(1), RunE: RunE(func(cmd *cobra.Command, args []string) (cmdErr error) { switch { case GlobalFlags.SelectedEnv == "": // Env not selected, but the // -c flag might be set. env, err := selectEnv(cmd) if err != nil { return err } if err := setMigrateEnvFlags(cmd, env); err != nil { return err } return migrateApplyRun(cmd, args, flags, env, &MigrateReport{}) // nop reporter default: project, envs, err := EnvByName(cmd, GlobalFlags.SelectedEnv, GlobalFlags.Vars) if err != nil { return err } set, err := NewReportProvider(cmd.Context(), project, envs, &flags) if err != nil { return err } var hasRemote bool defer func() { if hasRemote { set.Flush(cmd, cmdErr) } }() return cmdEnvsRun(envs, setMigrateEnvFlags, cmd, func(env *Env) error { // Report deployments only if one of the migration directories is a cloud directory. if u, err := url.Parse(flags.dirURL); err == nil && u.Scheme == cmdmigrate.DirTypeAtlas { hasRemote = true } return migrateApplyRun(cmd, args, flags, env, set.ReportFor(flags, env)) }) } }), } ) cmd.Flags().SortFlags = false addFlagURL(cmd.Flags(), &flags.url) addFlagDirURL(cmd.Flags(), &flags.dirURL) addFlagLog(cmd.Flags(), &flags.logFormat) addFlagFormat(cmd.Flags(), &flags.logFormat) addFlagRevisionSchema(cmd.Flags(), &flags.revisionSchema) addFlagDryRun(cmd.Flags(), &flags.dryRun) addFlagLockTimeout(cmd.Flags(), &flags.lockTimeout) cmd.Flags().StringVarP(&flags.baselineVersion, flagBaseline, "", "", "start the first migration after the given baseline version") cmd.Flags().StringVarP(&flags.txMode, flagTxMode, "", txModeFile, "set transaction mode [none, file, all]") cmd.Flags().StringVarP(&flags.execOrder, flagExecOrder, "", execOrderLinear, "set file execution order [linear, linear-skip, non-linear]") cmd.Flags().StringVar(&flags.context, flagContext, "", "describes what triggered this command (e.g., GitHub Action)") cobra.CheckErr(cmd.Flags().MarkHidden(flagContext)) cmd.Flags().BoolVarP(&flags.allowDirty, flagAllowDirty, "", false, "allow start working on a non-clean database") cmd.MarkFlagsMutuallyExclusive(flagLog, flagFormat) return cmd } type ( // MigrateReport responsible for reporting 'migrate apply' reports. MigrateReport struct { id string // target id env *Env // nil, if no env set client *sqlclient.Client log *cmdlog.MigrateApply rrw cmdmigrate.RevisionReadWriter done func(*cloudapi.ReportMigrationInput) } // MigrateReportSet is a set of reports. MigrateReportSet struct { cloudapi.ReportMigrationSetInput client *cloudapi.Client done int // number of done migrations } ) // NewReportProvider returns a new ReporterProvider. func NewReportProvider(ctx context.Context, p *Project, envs []*Env, flags *migrateApplyFlags) (*MigrateReportSet, error) { c := cloudapi.FromContext(ctx) if p.cloud.Client != nil { c = p.cloud.Client } s := &MigrateReportSet{ client: c, ReportMigrationSetInput: cloudapi.ReportMigrationSetInput{ ID: uuid.NewString(), StartTime: time.Now(), Planned: len(envs), }, } if flags.context != "" { if err := json.Unmarshal([]byte(flags.context), &s.Context); err != nil { return nil, fmt.Errorf("invalid --context: %w", err) } } s.Step("Start migration for %d targets", len(envs)) for _, e := range envs { s.StepLog(s.RedactedURL(e.URL)) } return s, nil } // RedactedURL returns the redacted URL of the given environment at index i. func (*MigrateReportSet) RedactedURL(u string) string { u, err := cloudapi.RedactedURL(u) if err != nil { return fmt.Sprintf("Error: redacting URL: %v", err) } return u } // Step starts a new reporting step. func (s *MigrateReportSet) Step(format string, args ...interface{}) { if len(s.Log) > 0 && s.Log[len(s.Log)-1].EndTime.IsZero() { s.Log[len(s.Log)-1].EndTime = time.Now() } s.Log = append(s.Log, cloudapi.ReportStep{ StartTime: time.Now(), Text: fmt.Sprintf(format, args...), }) } // StepLog logs a line to the current reporting step. func (s *MigrateReportSet) StepLog(text string) { if len(s.Log) == 0 { s.Step("Unnamed step") // Unexpected. } s.Log[len(s.Log)-1].Log = append(s.Log[len(s.Log)-1].Log, cloudapi.ReportStepLog{ Text: text, }) } // StepLogf logs a line to the current reporting step with formatting. func (s *MigrateReportSet) StepLogf(format string, args ...interface{}) { s.StepLog(fmt.Sprintf(format, args...)) } // StepLogError logs a line to the current reporting step. func (s *MigrateReportSet) StepLogError(text string) { if !strings.HasPrefix(text, "Error") { text = "Error: " + text } s.StepLog(text) s.Error = &text s.Log[len(s.Log)-1].Error = true } // ReportFor returns a new MigrateReport for the given environment. func (s *MigrateReportSet) ReportFor(flags migrateApplyFlags, e *Env) *MigrateReport { s.Step("Run migration: %d", s.done+1) s.StepLogf("Target URL: %s", s.RedactedURL(e.URL)) s.StepLogf("Migration directory: %s", s.RedactedURL(flags.dirURL)) return &MigrateReport{ env: e, done: func(r *cloudapi.ReportMigrationInput) { s.done++ r.DryRun = flags.dryRun s.Log[len(s.Log)-1].EndTime = time.Now() if r.Error != nil && *r.Error != "" { s.StepLogError(*r.Error) } s.Completed = append(s.Completed, *r) }, } } // Flush report the migration deployment to the cloud. // The current implementation is simplistic and sends each // report separately without marking them as part of a group. // // Note that reporting errors are logged, but not cause Atlas to fail. func (s *MigrateReportSet) Flush(cmd *cobra.Command, cmdErr error) { if cmdErr != nil && s.Error == nil { var uerr *url.Error if errors.As(cmdErr, &uerr) { uerr.URL = "" cmdErr = uerr } s.StepLogError(cmdErr.Error()) } var ( err error link string ) switch { // Skip reporting if set is empty, // or there is no cloud connectivity. case s.Planned == 0, s.client == nil: return // Single migration that was completed. case s.Planned == 1 && len(s.Completed) == 1: s.Completed[0].Context = s.Context link, err = s.client.ReportMigration(cmd.Context(), s.Completed[0]) // Single migration that failed to start. case s.Planned == 1 && len(s.Completed) == 0: s.EndTime = time.Now() link, err = s.client.ReportMigrationSet(cmd.Context(), s.ReportMigrationSetInput) // Multi environment migration (e.g., multi-tenancy). case s.Planned > 1: s.EndTime = time.Now() link, err = s.client.ReportMigrationSet(cmd.Context(), s.ReportMigrationSetInput) } switch { case err != nil: txt := fmt.Sprintf("Error: %s", strings.TrimRight(err.Error(), "\n")) // Ensure errors are printed in new lines. if cmd.Flags().Changed(flagFormat) { txt = "\n" + txt } cmd.PrintErrln(txt) // Unlike errors that are printed to stderr, links are printed to stdout. // We do it only if the format was not customized by the user (e.g., JSON). case link != "" && !cmd.Flags().Changed(flagFormat): cmd.Println(link) } } // Init the report if the necessary dependencies. func (r *MigrateReport) Init(c *sqlclient.Client, l *cmdlog.MigrateApply, rrw cmdmigrate.RevisionReadWriter) { r.client, r.log, r.rrw = c, l, rrw } // RecordTargetID asks the revisions-table to allow or provide // the target identifier if cloud reporting is enabled. func (r *MigrateReport) RecordTargetID(ctx context.Context) error { if r.CloudEnabled(ctx) { id, err := r.rrw.ID(ctx, operatorVersion()) if err != nil { return err } r.id = id } return nil } // RecordPlanError records any errors that occurred during the planning phase. i.e., when calling to ex.Pending. func (r *MigrateReport) RecordPlanError(cmd *cobra.Command, flags migrateApplyFlags, planerr string) { if !r.CloudEnabled(cmd.Context()) { return } var ver string if rev, err := r.rrw.CurrentRevision(cmd.Context()); err == nil { ver = rev.Version } r.done(&cloudapi.ReportMigrationInput{ ProjectName: r.env.config.cloud.Project, EnvName: r.env.Name, DirName: r.DirName(flags), AtlasVersion: operatorVersion(), Target: cloudapi.DeployedTargetInput{ ID: r.id, Schema: r.client.URL.Schema, URL: r.client.URL.Redacted(), }, StartTime: r.log.Start, EndTime: r.log.End, FromVersion: r.log.Current, ToVersion: r.log.Target, CurrentVersion: ver, Error: &planerr, Log: planerr, }) } // Done closes and flushes this report. func (r *MigrateReport) Done(cmd *cobra.Command, flags migrateApplyFlags) error { if !r.CloudEnabled(cmd.Context()) { return logApply(cmd, cmd.OutOrStdout(), flags, r.log) } var ( ver string clog bytes.Buffer err = logApply(cmd, io.MultiWriter(cmd.OutOrStdout(), &clog), flags, r.log) ) switch rev, err1 := r.rrw.CurrentRevision(cmd.Context()); { case errors.Is(err1, migrate.ErrRevisionNotExist): case err1 != nil: return errors.Join(err, err1) default: ver = rev.Version } r.done(&cloudapi.ReportMigrationInput{ ProjectName: r.env.config.cloud.Project, EnvName: r.env.Name, DirName: r.DirName(flags), AtlasVersion: operatorVersion(), Target: cloudapi.DeployedTargetInput{ ID: r.id, Schema: r.client.URL.Schema, URL: r.client.URL.Redacted(), }, StartTime: r.log.Start, EndTime: r.log.End, FromVersion: r.log.Current, ToVersion: r.log.Target, CurrentVersion: ver, Error: func() *string { if r.log.Error != "" { return &r.log.Error } return nil }(), Files: func() []cloudapi.DeployedFileInput { files := make([]cloudapi.DeployedFileInput, len(r.log.Applied)) for i, f := range r.log.Applied { f1 := cloudapi.DeployedFileInput{ Name: f.Name(), Content: string(f.Bytes()), StartTime: f.Start, EndTime: f.End, Skipped: f.Skipped, Applied: len(f.Applied), Error: (*cloudapi.StmtErrorInput)(f.Error), Checks: make([]cloudapi.FileChecksInput, 0, len(f.Checks)), } for _, c := range f.Checks { stmts := make([]cloudapi.CheckStmtInput, 0, len(c.Stmts)) for _, s := range c.Stmts { stmts = append(stmts, cloudapi.CheckStmtInput{ Stmt: s.Stmt, Error: s.Error, }) } f1.Checks = append(f1.Checks, cloudapi.FileChecksInput{ Name: c.Name, Start: c.Start, End: c.End, Checks: stmts, Error: (*cloudapi.StmtErrorInput)(c.Error), }) } files[i] = f1 } return files }(), Log: clog.String(), }) return err } // DirName returns the directory name for the report. func (r *MigrateReport) DirName(flags migrateApplyFlags) string { dirName := flags.dirURL switch u, err := url.Parse(flags.dirURL); { case err != nil: // Local directories are reported as (dangling) // deployments without a directory. case u.Scheme == cmdmigrate.DirTypeFile: dirName = cloudapi.DefaultDirName // Directory slug. default: dirName = path.Join(u.Host, u.Path) } return dirName } // CloudEnabled reports if cloud reporting is enabled. func (r *MigrateReport) CloudEnabled(ctx context.Context) bool { if r.env == nil || r.env.cloud == nil { return false // The --env was not set. } cloud := r.env.cloud // Cloud reporting is enabled only if there is a cloud connection. return cloud.Project != "" && (cloud.Client != nil || cloudapi.FromContext(ctx) != nil) } func logApply(cmd *cobra.Command, w io.Writer, flags migrateApplyFlags, r *cmdlog.MigrateApply) error { var ( err error f = cmdlog.MigrateApplyTemplate ) if v := flags.logFormat; v != "" { f, err = template.New("format").Funcs(cmdlog.ApplyTemplateFuncs).Parse(v) if err != nil { return fmt.Errorf("parse format: %w", err) } } if err = f.Execute(w, r); err != nil { return fmt.Errorf("execute log template: %w", err) } // In case a custom logging was configured, avoid reporting errors twice. // For example, printing error lines may break parsing the JSON output. cmd.SilenceErrors = flags.logFormat != "" return nil } type migrateDiffFlags struct { edit bool desiredURLs []string dirURL, dirFormat string devURL string schemas []string lockTimeout time.Duration format string qualifier string // optional table qualifier dryRun bool } // migrateDiffCmd represents the 'atlas migrate diff' subcommand. func migrateDiffCmd() *cobra.Command { var ( flags migrateDiffFlags cmd = &cobra.Command{ Use: "diff [flags] [name]", Short: "Compute the diff between the migration directory and a desired state and create a new migration file.", Long: `The 'atlas migrate diff' command uses the dev-database to calculate the current state of the migration directory by executing its files. It then compares its state to the desired state and create a new migration file containing SQL statements for moving from the current to the desired state. The desired state can be another another database, an HCL, SQL, or ORM schema. See: https://atlasgo.io/versioned/diff`, Example: ` atlas migrate diff --dev-url "docker://mysql/8/dev" --to "file://schema.hcl" atlas migrate diff --dev-url "docker://postgres/15/dev?search_path=public" --to "file://atlas.hcl" add_users_table atlas migrate diff --dev-url "mysql://user:pass@localhost:3306/dev" --to "mysql://user:pass@localhost:3306/dbname" atlas migrate diff --env dev --format '{{ sql . " " }}'`, Args: cobra.MaximumNArgs(1), PreRunE: func(cmd *cobra.Command, args []string) error { if err := migrateFlagsFromConfig(cmd); err != nil { return err } if err := dirFormatBC(flags.dirFormat, &flags.dirURL); err != nil { return err } return checkDir(cmd, flags.dirURL, true) }, RunE: RunE(func(cmd *cobra.Command, args []string) error { env, err := selectEnv(cmd) if err != nil { return err } return migrateDiffRun(cmd, args, flags, env) }), } ) cmd.Flags().SortFlags = false addFlagToURLs(cmd.Flags(), &flags.desiredURLs) addFlagDevURL(cmd.Flags(), &flags.devURL) addFlagDirURL(cmd.Flags(), &flags.dirURL) addFlagDirFormat(cmd.Flags(), &flags.dirFormat) addFlagSchemas(cmd.Flags(), &flags.schemas) addFlagLockTimeout(cmd.Flags(), &flags.lockTimeout) addFlagFormat(cmd.Flags(), &flags.format) cmd.Flags().StringVar(&flags.qualifier, flagQualifier, "", "qualify tables with custom qualifier when working on a single schema") cmd.Flags().BoolVarP(&flags.edit, flagEdit, "", false, "edit the generated migration file(s)") cmd.Flags().BoolVar(&flags.dryRun, flagDryRun, false, "print the generated file to stdout instead of writing it to the migration directory") cobra.CheckErr(cmd.Flags().MarkHidden(flagDryRun)) cmd.MarkFlagsMutuallyExclusive(flagEdit, flagDryRun) cobra.CheckErr(cmd.MarkFlagRequired(flagTo)) cobra.CheckErr(cmd.MarkFlagRequired(flagDevURL)) return cmd } func mayIndent(dir *url.URL, f migrate.Formatter, format string) (migrate.Formatter, string, error) { if format == "" { return f, "", nil } reject := errors.New(`'sql' can only be used to indent statements`) t, err := template.New("format"). // The "sql" is a dummy function to detect if the // template was used to indent the SQL statements. Funcs(template.FuncMap{"sql": func(...any) (string, error) { return "", reject }}). Parse(format) if err != nil { return nil, "", fmt.Errorf("parse format: %w", err) } indent, ok := func() (string, bool) { if len(t.Tree.Root.Nodes) != 1 { return "", false } n, ok := t.Tree.Root.Nodes[0].(*parse.ActionNode) if !ok || len(n.Pipe.Cmds) != 1 || len(n.Pipe.Cmds[0].Args) < 2 || len(n.Pipe.Cmds[0].Args) > 3 { return "", false } args := n.Pipe.Cmds[0].Args if args[0].String() != "sql" || args[1].String() != "." && args[1].String() != "$" { return "", false } d := `""` // empty string as arg. if len(args) == 3 { d = args[2].String() } return d, true }() if ok { if indent, err = strconv.Unquote(indent); err != nil { return nil, "", fmt.Errorf("parse indent: %w", err) } return f, indent, nil } // If the template is not an indent, it cannot contain the "sql" function. if err := t.Execute(io.Discard, &migrate.Plan{}); err != nil && errors.Is(err, reject) { return nil, "", fmt.Errorf("%v. got: %v", reject, t.Root.String()) } tfs := f.(migrate.TemplateFormatter) if len(tfs) != 1 { return nil, "", fmt.Errorf("cannot use format with: %q", dir.Query().Get("format")) } return migrate.TemplateFormatter{{N: tfs[0].N, C: t}}, "", nil } // maskNoPlan masks ErrNoPlan errors. func maskNoPlan(cmd *cobra.Command, err error) error { if errors.Is(err, migrate.ErrNoPlan) { cmd.Println("The migration directory is synced with the desired state, no changes to be made") return nil } return err } type migrateHashFlags struct{ dirURL, dirFormat string } // migrateHashCmd represents the 'atlas migrate hash' subcommand. func migrateHashCmd() *cobra.Command { var ( flags migrateHashFlags cmd = &cobra.Command{ Use: "hash [flags]", Short: "Hash (re-)creates an integrity hash file for the migration directory.", Long: `'atlas migrate hash' computes the integrity hash sum of the migration directory and stores it in the atlas.sum file. This command should be used whenever a manual change in the migration directory was made.`, Example: ` atlas migrate hash`, PreRunE: func(cmd *cobra.Command, args []string) error { if err := migrateFlagsFromConfig(cmd); err != nil { return err } return dirFormatBC(flags.dirFormat, &flags.dirURL) }, RunE: RunE(func(cmd *cobra.Command, args []string) error { dir, err := cmdmigrate.Dir(cmd.Context(), flags.dirURL, false) if err != nil { return err } sum, err := dir.Checksum() if err != nil { return err } return migrate.WriteSumFile(dir, sum) }), } ) addFlagDirURL(cmd.Flags(), &flags.dirURL) addFlagDirFormat(cmd.Flags(), &flags.dirFormat) cmd.Flags().Bool("force", false, "") cobra.CheckErr(cmd.Flags().MarkDeprecated("force", "you can safely omit it.")) return cmd } type migrateImportFlags struct{ fromURL, toURL, dirFormat string } // migrateImportCmd represents the 'atlas migrate import' subcommand. func migrateImportCmd() *cobra.Command { var ( flags migrateImportFlags cmd = &cobra.Command{ Use: "import [flags]", Short: "Import a migration directory from another migration management tool to the Atlas format.", Example: ` atlas migrate import --from "file:///path/to/source/directory?format=liquibase" --to "file:///path/to/migration/directory"`, // Validate the source directory. Consider a directory with no sum file // valid, since it might be an import from an existing project. PreRunE: func(cmd *cobra.Command, _ []string) error { if err := migrateFlagsFromConfig(cmd); err != nil { return err } if err := dirFormatBC(flags.dirFormat, &flags.fromURL); err != nil { return err } d, err := cmdmigrate.Dir(cmd.Context(), flags.fromURL, false) if err != nil { return err } if err = migrate.Validate(d); err != nil && !errors.Is(err, migrate.ErrChecksumNotFound) { printChecksumError(cmd, err) return err } return nil }, RunE: RunE(func(cmd *cobra.Command, args []string) error { return migrateImportRun(cmd, args, flags) }), } ) cmd.Flags().SortFlags = false addFlagDirURL(cmd.Flags(), &flags.fromURL, flagFrom) addFlagDirURL(cmd.Flags(), &flags.toURL, flagTo) addFlagDirFormat(cmd.Flags(), &flags.dirFormat) return cmd } func migrateImportRun(cmd *cobra.Command, _ []string, flags migrateImportFlags) error { p, err := url.Parse(flags.fromURL) if err != nil { return err } if f := p.Query().Get("format"); f == "" || f == cmdmigrate.FormatAtlas { return fmt.Errorf("cannot import a migration directory already in %q format", cmdmigrate.FormatAtlas) } src, err := cmdmigrate.Dir(cmd.Context(), flags.fromURL, false) if err != nil { return err } trgt, err := cmdmigrate.Dir(cmd.Context(), flags.toURL, true) if err != nil { return err } // Target must be empty. ff, err := trgt.Files() switch { case err != nil: return err case len(ff) != 0: return errors.New("target migration directory must be empty") } ff, err = src.Files() switch { case err != nil: return err case len(ff) == 0: fmt.Fprint(cmd.OutOrStderr(), "nothing to import") cmd.SilenceUsage = true return nil } // Fix version numbers for Flyway repeatable migrations. if _, ok := src.(*sqltool.FlywayDir); ok { sqltool.SetRepeatableVersion(ff) } // Extract the statements for each of the migration files, // add them to a plan to format with the DefaultFormatter. for _, f := range ff { stmts, err := f.StmtDecls() // Not driver aware. if err != nil { return err } plan := &migrate.Plan{ Version: f.Version(), Name: f.Desc(), Changes: make([]*migrate.Change, len(stmts)), } var buf strings.Builder for i, s := range stmts { for _, c := range s.Comments { buf.WriteString(c) if !strings.HasSuffix(c, "\n") { buf.WriteString("\n") } } buf.WriteString(strings.TrimSuffix(s.Text, ";")) plan.Changes[i] = &migrate.Change{Cmd: buf.String()} buf.Reset() } files, err := migrate.DefaultFormatter.Format(plan) if err != nil { return err } for _, f := range files { if err := trgt.WriteFile(f.Name(), f.Bytes()); err != nil { return err } } } sum, err := trgt.Checksum() if err != nil { return err } return migrate.WriteSumFile(trgt, sum) } type migrateLintFlags struct { dirURL, dirFormat string devURL string logFormat string latest uint // --latest 1 gitBase, gitDir string // --git-base master --git-dir /path/to/git/repo // Not enabled by default. dirBase string // --base atlas://myapp web bool // Open the web browser context string // Run context. See cloudapi.ContextInput. } // migrateLintCmd represents the 'atlas migrate lint' subcommand. func migrateLintCmd() *cobra.Command { var ( env *Env flags migrateLintFlags cmd = &cobra.Command{ Use: "lint [flags]", Short: "Run analysis on the migration directory", Example: ` atlas migrate lint --env dev atlas migrate lint --dir "file:///path/to/migrations" --dev-url "docker://mysql/8/dev" --latest 1 atlas migrate lint --dir "file:///path/to/migrations" --dev-url "mysql://root:pass@localhost:3306" --git-base master atlas migrate lint --dir "file:///path/to/migrations" --dev-url "mysql://root:pass@localhost:3306" --format '{{ json .Files }}'`, PreRunE: func(cmd *cobra.Command, args []string) (err error) { if env, err = selectEnv(cmd); err != nil { return err } if err := setMigrateEnvFlags(cmd, env); err != nil { return err } return dirFormatBC(flags.dirFormat, &flags.dirURL) }, RunE: RunE(func(cmd *cobra.Command, args []string) error { return migrateLintRun(cmd, args, flags, env) }), } ) cmd.Flags().SortFlags = false addFlagDevURL(cmd.Flags(), &flags.devURL) addFlagDirURL(cmd.Flags(), &flags.dirURL) addFlagDirFormat(cmd.Flags(), &flags.dirFormat) addFlagLog(cmd.Flags(), &flags.logFormat) addFlagFormat(cmd.Flags(), &flags.logFormat) cmd.Flags().UintVarP(&flags.latest, flagLatest, "", 0, "run analysis on the latest N migration files") cmd.Flags().StringVarP(&flags.gitBase, flagGitBase, "", "", "run analysis against the base Git branch") cmd.Flags().StringVarP(&flags.gitDir, flagGitDir, "", ".", "path to the repository working directory") cobra.CheckErr(cmd.MarkFlagRequired(flagDevURL)) cmd.MarkFlagsMutuallyExclusive(flagLog, flagFormat) migrateLintSetFlags(cmd, &flags) return cmd } type migrateNewFlags struct { edit bool dirURL string dirFormat string } // migrateNewCmd represents the 'atlas migrate new' subcommand. func migrateNewCmd() *cobra.Command { var ( flags migrateNewFlags cmd = &cobra.Command{ Use: "new [flags] [name]", Short: "Creates a new empty migration file in the migration directory.", Long: `'atlas migrate new' creates a new migration according to the configured formatter without any statements in it.`, Example: ` atlas migrate new my-new-migration`, Args: cobra.MaximumNArgs(1), PreRunE: func(cmd *cobra.Command, _ []string) error { if err := migrateFlagsFromConfig(cmd); err != nil { return err } if err := dirFormatBC(flags.dirFormat, &flags.dirURL); err != nil { return err } return checkDir(cmd, flags.dirURL, true) }, RunE: RunE(func(cmd *cobra.Command, args []string) error { return migrateNewRun(cmd, args, flags) }), } ) cmd.Flags().SortFlags = false addFlagDirURL(cmd.Flags(), &flags.dirURL) addFlagDirFormat(cmd.Flags(), &flags.dirFormat) cmd.Flags().BoolVarP(&flags.edit, flagEdit, "", false, "edit the created migration file(s)") return cmd } func migrateNewRun(cmd *cobra.Command, args []string, flags migrateNewFlags) error { u, err := url.Parse(flags.dirURL) if err != nil { return err } dir, err := cmdmigrate.DirURL(cmd.Context(), u, true) if err != nil { return err } if flags.edit { l, ok := dir.(*migrate.LocalDir) if !ok { return fmt.Errorf("--edit flag supports only atlas directories, but got: %T", dir) } dir = &editDir{l} } f, err := cmdmigrate.Formatter(u) if err != nil { return err } var name string if len(args) > 0 { name = args[0] } return migrate.NewPlanner(nil, dir, migrate.PlanFormat(f)).WritePlan(&migrate.Plan{Name: name}) } type migrateSetFlags struct { url string dirURL, dirFormat string revisionSchema string } // migrateSetCmd represents the 'atlas migrate set' subcommand. func migrateSetCmd() *cobra.Command { var ( flags migrateSetFlags cmd = &cobra.Command{ Use: "set [flags] [version]", Short: "Set the current version of the migration history table.", Long: `'atlas migrate set' edits the revision table to consider all migrations up to and including the given version to be applied. This command is usually used after manually making changes to the managed database.`, Example: ` atlas migrate set 3 --url "mysql://user:pass@localhost:3306/" atlas migrate set --env local atlas migrate set 1.2.4 --url "mysql://user:pass@localhost:3306/my_db" --revision-schema my_revisions`, PreRunE: func(cmd *cobra.Command, _ []string) error { if err := migrateFlagsFromConfig(cmd); err != nil { return err } if err := dirFormatBC(flags.dirFormat, &flags.dirURL); err != nil { return err } return checkDir(cmd, flags.dirURL, false) }, RunE: RunE(func(cmd *cobra.Command, args []string) error { return migrateSetRun(cmd, args, flags) }), } ) cmd.Flags().SortFlags = false addFlagURL(cmd.Flags(), &flags.url) addFlagDirURL(cmd.Flags(), &flags.dirURL) addFlagDirFormat(cmd.Flags(), &flags.dirFormat) addFlagRevisionSchema(cmd.Flags(), &flags.revisionSchema) return cmd } func migrateSetRun(cmd *cobra.Command, args []string, flags migrateSetFlags) (rerr error) { ctx := cmd.Context() dir, err := cmdmigrate.Dir(ctx, flags.dirURL, false) if err != nil { return err } client, err := sqlclient.Open(ctx, flags.url) if err != nil { return err } defer client.Close() // Acquire a lock. unlock, err := client.Driver.Lock(ctx, applyLockValue, 0) if err != nil { return fmt.Errorf("acquiring database lock: %w", err) } // If unlocking fails notify the user about it. defer func() { cobra.CheckErr(unlock()) }() if err := checkRevisionSchemaClarity(cmd, client, flags.revisionSchema); err != nil { return err } // Ensure revision table exists. rrw, err := entRevisions(ctx, client, flags.revisionSchema) if err != nil { return err } if err := rrw.Migrate(ctx); err != nil { return err } // Wrap manipulation in a transaction. tx, err := client.Tx(ctx, nil) if err != nil { return err } defer func() { if rerr == nil { rerr = tx.Commit() } else if err2 := tx.Rollback(); err2 != nil { rerr = fmt.Errorf("%v: %w", err2, err) } }() rrw, err = entRevisions(ctx, tx.Client, flags.revisionSchema) if err != nil { return err } revs, err := rrw.ReadRevisions(ctx) if err != nil { return err } files, err := dir.Files() if err != nil { return err } var version string switch n := len(args); { // Prevent the case where 'migrate set' is called without a version on // a clean database. i.e., we allow only removing or syncing revisions. case n == 0 && len(revs) > 0: // Calling set without a version and an empty // migration directory purges the revision table. if len(files) > 0 { version = files[len(files)-1].Version() } case n == 1: // Check if the target version does exist in the migration directory. if idx := migrate.FilesLastIndex(files, func(f migrate.File) bool { return f.Version() == args[0] }); idx == -1 { return fmt.Errorf("migration with version %q not found", args[0]) } version = args[0] default: return fmt.Errorf("accepts 1 arg(s), received %d", n) } log := cmdlog.NewMigrateSet(ctx) for _, r := range revs { // Check all existing revisions and ensure they precede the given version. If we encounter a partially // applied revision, or one with errors, mark them "fixed". switch { // remove revision to keep linear history case r.Version > version: log.Removed(r) if err := rrw.DeleteRevision(ctx, r.Version); err != nil { return err } // keep, but if with error mark "fixed" case r.Version == version && (r.Error != "" || r.Total != r.Applied): log.Set(r) r.Type = migrate.RevisionTypeExecute | migrate.RevisionTypeResolved if err := rrw.WriteRevision(ctx, r); err != nil { return err } } } revs, err = rrw.ReadRevisions(ctx) if err != nil { return err } // If the target version succeeds the last revision, mark // migrations applied, until we reach the target version. var pending []migrate.File switch { case len(revs) == 0: // Take every file until we reach target version. for _, f := range files { if f.Version() > version { break } pending = append(pending, f) } case version > revs[len(revs)-1].Version: loop: // Take every file succeeding the last revision until we reach target version. for _, f := range files { switch { case f.Version() <= revs[len(revs)-1].Version: // Migration precedes last revision. case f.Version() > version: // Migration succeeds target revision. break loop default: // between last revision and target pending = append(pending, f) } } } // Mark every pending file as applied. sum, err := dir.Checksum() if err != nil { return err } for _, f := range pending { h, err := sum.SumByName(f.Name()) if err != nil { return err } rev := &migrate.Revision{ Version: f.Version(), Description: f.Desc(), Type: migrate.RevisionTypeResolved, ExecutedAt: time.Now(), Hash: h, OperatorVersion: operatorVersion(), } log.Set(rev) if err := rrw.WriteRevision(ctx, rev); err != nil { return err } } if log.Current, err = rrw.CurrentRevision(ctx); err != nil && !errors.Is(err, migrate.ErrRevisionNotExist) { return err } return cmdlog.MigrateSetTemplate.Execute(cmd.OutOrStdout(), log) } type migrateStatusFlags struct { url string dirURL, dirFormat string revisionSchema string logFormat string } // migrateStatusCmd represents the 'atlas migrate status' subcommand. func migrateStatusCmd() *cobra.Command { var ( flags migrateStatusFlags cmd = &cobra.Command{ Use: "status [flags]", Short: "Get information about the current migration status.", Long: `'atlas migrate status' reports information about the current status of a connected database compared to the migration directory.`, Example: ` atlas migrate status --url "mysql://user:pass@localhost:3306/" atlas migrate status --url "mysql://user:pass@localhost:3306/" --dir "file:///path/to/migration/directory"`, PreRunE: func(cmd *cobra.Command, _ []string) error { if err := migrateFlagsFromConfig(cmd); err != nil { return err } if err := dirFormatBC(flags.dirFormat, &flags.dirURL); err != nil { return err } return checkDir(cmd, flags.dirURL, false) }, RunE: RunE(func(cmd *cobra.Command, args []string) error { return migrateStatusRun(cmd, args, flags) }), } ) cmd.Flags().SortFlags = false addFlagURL(cmd.Flags(), &flags.url) addFlagLog(cmd.Flags(), &flags.logFormat) addFlagFormat(cmd.Flags(), &flags.logFormat) addFlagDirURL(cmd.Flags(), &flags.dirURL) addFlagDirFormat(cmd.Flags(), &flags.dirFormat) addFlagRevisionSchema(cmd.Flags(), &flags.revisionSchema) cmd.MarkFlagsMutuallyExclusive(flagLog, flagFormat) return cmd } func migrateStatusRun(cmd *cobra.Command, _ []string, flags migrateStatusFlags) error { ctx := cmd.Context() dirURL, err := url.Parse(flags.dirURL) if err != nil { return fmt.Errorf("parse dir-url: %w", err) } dir, err := cmdmigrate.DirURL(ctx, dirURL, false) if err != nil { return err } client, err := sqlclient.Open(ctx, flags.url) if err != nil { return err } defer client.Close() if err := checkRevisionSchemaClarity(cmd, client, flags.revisionSchema); err != nil { return err } report, err := (&cmdlog.StatusReporter{ Client: client, Dir: dir, DirURL: dirURL, Schema: revisionSchemaName(client, flags.revisionSchema), }).Report(ctx) if err != nil { return err } format := cmdlog.MigrateStatusTemplate if f := flags.logFormat; f != "" { if format, err = template.New("format").Funcs(cmdlog.StatusTemplateFuncs).Parse(f); err != nil { return fmt.Errorf("parse format: %w", err) } } return format.Execute(cmd.OutOrStdout(), report) } type migrateValidateFlags struct { devURL string dirURL, dirFormat string } // migrateValidateCmd represents the 'atlas migrate validate' subcommand. func migrateValidateCmd() *cobra.Command { var ( flags migrateValidateFlags cmd = &cobra.Command{ Use: "validate [flags]", Short: "Validates the migration directories checksum and SQL statements.", Long: `'atlas migrate validate' computes the integrity hash sum of the migration directory and compares it to the atlas.sum file. If there is a mismatch it will be reported. If the --dev-url flag is given, the migration files are executed on the connected database in order to validate SQL semantics.`, Example: ` atlas migrate validate atlas migrate validate --dir "file:///path/to/migration/directory" atlas migrate validate --dir "file:///path/to/migration/directory" --dev-url "docker://mysql/8/dev" atlas migrate validate --env dev --dev-url "docker://postgres/15/dev?search_path=public"`, PreRunE: func(cmd *cobra.Command, _ []string) error { if err := migrateFlagsFromConfig(cmd); err != nil { return err } if err := dirFormatBC(flags.dirFormat, &flags.dirURL); err != nil { return err } err := checkDir(cmd, flags.dirURL, false) return err }, RunE: RunE(func(cmd *cobra.Command, args []string) error { return migrateValidateRun(cmd, args, flags) }), } ) cmd.Flags().SortFlags = false addFlagDevURL(cmd.Flags(), &flags.devURL) addFlagDirURL(cmd.Flags(), &flags.dirURL) addFlagDirFormat(cmd.Flags(), &flags.dirFormat) return cmd } func migrateValidateRun(cmd *cobra.Command, _ []string, flags migrateValidateFlags) error { // Validating the integrity is done by the PersistentPreRun already. if flags.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(), flags.devURL) if err != nil { return err } defer dev.Close() // Currently, only our own migration file format is supported. dir, err := cmdmigrate.Dir(cmd.Context(), flags.dirURL, false) if err != nil { return err } ex, err := migrate.NewExecutor(dev.Driver, dir, migrate.NopRevisionReadWriter{}) if err != nil { return err } if _, err := ex.Replay(cmd.Context(), func() migrate.StateReader { if dev.URL.Schema != "" { return migrate.SchemaConn(dev, "", nil) } return migrate.RealmConn(dev, nil) }()); err != nil && !errors.Is(err, migrate.ErrNoPendingFiles) { return fmt.Errorf("replaying the migration directory: %w", err) } return nil } const applyLockValue = "atlas_migrate_execute" func checkRevisionSchemaClarity(cmd *cobra.Command, c *sqlclient.Client, revisionSchemaFlag string) error { // The "old" default behavior for the revision schema location was to store the revision table in its own schema. // Now, the table is saved in the connected schema, if any. To keep the backwards compatability, we now require // for schema bound connections to have the schema-revision flag present if there is no revision table in the schema // but the old default schema does have one. if c.URL.Schema != "" && revisionSchemaFlag == "" { // If the schema does not contain a revision table, but we can find a table in the previous default schema, // abort and tell the user to specify the intention. opts := &schema.InspectOptions{Tables: []string{revision.Table}, Mode: schema.InspectTables} s, err := c.InspectSchema(cmd.Context(), "", opts) var ok bool switch { case schema.IsNotExistError(err): // If the schema does not exist, the table does not as well. case err != nil: return err default: // Connected schema does exist, check if the table does. _, ok = s.Table(revision.Table) } if !ok { // Either schema or table does not exist. // Check for the old default schema. If it does not exist, we have no problem. s, err := c.InspectSchema(cmd.Context(), defaultRevisionSchema, opts) switch { case schema.IsNotExistError(err): // Schema does not exist, we can proceed. case err != nil: return err default: if _, ok := s.Table(revision.Table); ok { fmt.Fprintf(cmd.OutOrStderr(), `We couldn't find a revision table in the connected schema but found one in the schema 'atlas_schema_revisions' and cannot determine the desired behavior. As a safety guard, we require you to specify whether to use the existing table in 'atlas_schema_revisions' or create a new one in the connected schema by providing the '--revisions-schema' flag or deleting the 'atlas_schema_revisions' schema if it is unused. `) cmd.SilenceUsage = true cmd.SilenceErrors = true return errors.New("ambiguous revision table") } } } } return nil } func entRevisions(ctx context.Context, c *sqlclient.Client, flag string) (cmdmigrate.RevisionReadWriter, error) { return cmdmigrate.RevisionsForClient(ctx, c, revisionSchemaName(c, flag)) } // defaultRevisionSchema is the default schema for storing revisions table. const defaultRevisionSchema = "atlas_schema_revisions" func revisionSchemaName(c *sqlclient.Client, flag string) string { switch { case flag != "": return flag case c.URL.Schema != "": return c.URL.Schema default: return defaultRevisionSchema } } const ( txModeNone = "none" txModeAll = "all" txModeFile = "file" txModeDirective = "txmode" execOrderLinear = "linear" execOrderLinearSkip = "linear-skip" execOrderNonLinear = "non-linear" ) // tx handles wrapping migration execution in transactions. type tx struct { dryRun bool mode, schema string c *sqlclient.Client rrw migrate.RevisionReadWriter // current transaction context. tx *sqlclient.TxClient txrrw migrate.RevisionReadWriter } // driverFor returns the migrate.Driver to use to execute migration statements. func (tx *tx) driverFor(ctx context.Context, f migrate.File) (migrate.Driver, migrate.RevisionReadWriter, error) { if tx.dryRun { // If the --dry-run flag is given we don't want to execute any statements on the database. return &dryRunDriver{tx.c.Driver}, &dryRunRevisions{tx.rrw}, nil } mode, err := tx.modeFor(f) if err != nil { return nil, nil, err } switch mode { case txModeNone: return tx.c.Driver, tx.rrw, nil case txModeFile: // In file-mode, this function is called each time a new file is executed. Open a transaction. if tx.tx != nil { return nil, nil, errors.New("unexpected active transaction") } var err error tx.tx, err = tx.c.Tx(ctx, nil) if err != nil { return nil, nil, err } if tx.txrrw, err = entRevisions(ctx, tx.tx.Client, tx.schema); err != nil { return nil, nil, err } return tx.tx.Driver, tx.txrrw, nil case txModeAll: // In file-mode, this function is called each time a new file is executed. Since we wrap all files into one // huge transaction, if there already is an opened one, use that. if tx.tx == nil { var err error tx.tx, err = tx.c.Tx(ctx, nil) if err != nil { return nil, nil, err } if tx.txrrw, err = entRevisions(ctx, tx.tx.Client, tx.schema); err != nil { return nil, nil, err } } return tx.tx.Driver, tx.txrrw, nil default: return nil, nil, fmt.Errorf("unknown tx-mode %q", mode) } } // mayRollback may roll back a transaction depending on the given transaction mode. func (tx *tx) mayRollback(err error) error { if tx.tx != nil && err != nil { if err2 := tx.tx.Rollback(); err2 != nil { err = fmt.Errorf("%v: %w", err2, err) } } return err } // mayCommit may commit a transaction depending on the given transaction mode. func (tx *tx) mayCommit() error { // Only commit if each file is wrapped in a transaction. if tx.tx != nil && !tx.dryRun && tx.mode == txModeFile { return tx.commit() } return nil } // commit the transaction, if one is active. func (tx *tx) commit() error { if tx.tx == nil { return nil } defer func() { tx.tx, tx.txrrw = nil, nil }() return tx.tx.Commit() } func (tx *tx) modeFor(f migrate.File) (string, error) { l, ok := f.(*migrate.LocalFile) if !ok { return tx.mode, nil } switch m, err := txmodeFor(l); { case err != nil: return "", err case m == "", m == tx.mode: return tx.mode, nil default: // m == txModeNone, m == txModeFile if tx.mode == txModeAll { return "", fmt.Errorf("cannot set txmode directive to %q in %q when txmode %q is set globally", m, l.Name(), txModeAll) } return m, nil } } // txmodeFor returns the transaction mode for the given file. func txmodeFor(f *migrate.LocalFile) (string, error) { switch ds := f.Directive(txModeDirective); { case len(ds) == 0: return "", nil case len(ds) > 1: return "", fmt.Errorf("multiple txmode values found in file %q: %q", f.Name(), ds) case ds[0] == txModeAll: return "", fmt.Errorf("txmode %q is not allowed in file directive %q. Use %q instead", txModeAll, f.Name(), txModeFile) case ds[0] == txModeNone, ds[0] == txModeFile: return ds[0], nil default: return "", fmt.Errorf("unknown txmode %q found in file directive %q", ds[0], f.Name()) } } func operatorVersion() string { v, _ := parseV(version) return "Atlas CLI " + v } // dirFormatBC ensures the soon-to-be deprecated --dir-format flag gets set on all migration directory URLs. func dirFormatBC(flag string, urls ...*string) error { for _, s := range urls { u, err := url.Parse(*s) if err != nil { return err } if !u.Query().Has("format") && flag != "" { q := u.Query() q.Set("format", flag) u.RawQuery = q.Encode() *s = u.String() } } return nil } func checkDir(cmd *cobra.Command, url string, create bool) error { d, err := cmdmigrate.Dir(cmd.Context(), url, create) if err != nil { return err } if err = migrate.Validate(d); err != nil { printChecksumError(cmd, err) return err } return nil } func printChecksumError(cmd *cobra.Command, err error) { cmd.SilenceUsage = true out := cmd.OutOrStderr() fmt.Fprintln(out, "You have a checksum error in your migration directory.") if csErr := (&migrate.ChecksumError{}); errors.As(err, &csErr) { fmt.Fprintf(out, "\n\tL%d: %s was %s\n\n", csErr.Line, csErr.File, csErr.Reason) } fmt.Fprintf( out, "Please check your migration files and run %v to re-hash the contents\n\n", cmdlog.ColorCyan("'atlas migrate hash'"), ) } // selectScheme validates the scheme of the provided to urls and returns the selected // url scheme. Currently, all URLs must be of the same scheme, and only multiple // "file://" URLs are allowed. func selectScheme(urls []string) (string, error) { var scheme string if len(urls) == 0 { return "", errors.New("at least one url is required") } for _, u := range urls { parts := strings.SplitN(u, "://", 2) switch current := parts[0]; { case len(parts) == 1: ex := filepath.Ext(u) switch f, err := os.Stat(u); { case err != nil: case f.IsDir(), ex == cmdext.FileTypeSQL, ex == cmdext.FileTypeHCL: return "", fmt.Errorf("missing scheme. Did you mean file://%s?", u) } return "", errors.New("missing scheme. See: https://atlasgo.io/url") case scheme == "": scheme = current case scheme != current: return "", fmt.Errorf("got mixed --to url schemes: %q and %q, the desired state must be provided from a single kind of source", scheme, current) case current != cmdext.SchemaTypeFile: return "", fmt.Errorf("got multiple --to urls of scheme %q, only multiple 'file://' urls are supported", current) } } return scheme, nil } func migrateFlagsFromConfig(cmd *cobra.Command) error { env, err := selectEnv(cmd) if err != nil { return err } return setMigrateEnvFlags(cmd, env) } func setMigrateEnvFlags(cmd *cobra.Command, env *Env) error { if err := maySetFlag(cmd, flagURL, env.URL); err != nil { return err } if err := maySetFlag(cmd, flagDevURL, env.DevURL); err != nil { return err } if err := maySetFlag(cmd, flagDirURL, env.Migration.Dir); err != nil { return err } if err := maySetFlag(cmd, flagDirFormat, env.Migration.Format); err != nil { return err } if err := maySetFlag(cmd, flagBaseline, env.Migration.Baseline); err != nil { return err } if err := maySetFlag(cmd, flagRevisionSchema, env.Migration.RevisionsSchema); err != nil { return err } switch cmd.Name() { case "apply": if err := maySetFlag(cmd, flagFormat, env.Format.Migrate.Apply); err != nil { return err } if err := maySetFlag(cmd, flagLockTimeout, env.Migration.LockTimeout); err != nil { return err } if err := maySetFlag(cmd, flagExecOrder, strings.ReplaceAll(strings.ToLower(env.Migration.ExecOrder), "_", "-")); err != nil { return err } case "down": if err := maySetFlag(cmd, flagFormat, env.Format.Migrate.Down); err != nil { return err } if err := maySetFlag(cmd, flagLockTimeout, env.Migration.LockTimeout); err != nil { return err } case "diff", "checkpoint": if err := maySetFlag(cmd, flagLockTimeout, env.Migration.LockTimeout); err != nil { return err } if err := maySetFlag(cmd, flagFormat, env.Format.Migrate.Diff); err != nil { return err } case "lint": if err := maySetFlag(cmd, flagFormat, env.Format.Migrate.Lint); err != nil { return err } if err := maySetFlag(cmd, flagFormat, env.Lint.Format); err != nil { return err } if err := maySetFlag(cmd, flagLatest, strconv.Itoa(env.Lint.Latest)); err != nil { return err } if err := maySetFlag(cmd, flagGitDir, env.Lint.Git.Dir); err != nil { return err } if err := maySetFlag(cmd, flagGitBase, env.Lint.Git.Base); err != nil { return err } case "status": if err := maySetFlag(cmd, flagFormat, env.Format.Migrate.Status); err != nil { return err } } // Transform "src" to a URL. srcs, err := env.Sources() if err != nil { return err } for i, s := range srcs { if isURL(s) { continue } if s, err = filepath.Abs(s); err != nil { return fmt.Errorf("finding abs path to source: %q: %w", s, err) } srcs[i] = "file://" + s } if err := maySetFlag(cmd, flagTo, strings.Join(srcs, ",")); err != nil { return err } if err := maySetFlag(cmd, flagSchema, strings.Join(env.Schemas, ",")); err != nil { return err } return nil } // isURL returns true if the given string // is an Atlas URL with a scheme. func isURL(s string) bool { u, err := url.Parse(s) return err == nil && u.Scheme != "" } // cmdEnvsRun executes a given command on each of the configured environment. func cmdEnvsRun( envs []*Env, setFlags func(*cobra.Command, *Env) error, cmd *cobra.Command, runCmd func(*Env) error, ) error { var ( w bytes.Buffer out = cmd.OutOrStdout() reset = resetFromEnv(cmd) ) cmd.SetOut(io.MultiWriter(out, &w)) defer cmd.SetOut(out) for i, e := range envs { if err := setFlags(cmd, e); err != nil { return err } if err := runCmd(e); err != nil { return err } b := bytes.TrimLeft(w.Bytes(), " \t\r") // In case a custom logging was configured, ensure there is // a newline separator between the different environments. if cmd.Flags().Changed(flagFormat) && bytes.LastIndexByte(b, '\n') != len(b)-1 && i != len(envs)-1 { cmd.Println() } reset() w.Reset() } return nil } type editDir struct{ *migrate.LocalDir } // WriteFile implements the migrate.Dir.WriteFile method. func (d *editDir) WriteFile(name string, b []byte) (err error) { if name != migrate.HashFileName { if b, err = edit(name, b); err != nil { return err } } return d.LocalDir.WriteFile(name, b) } // edit allows editing the file content using editor. func edit(name string, src []byte) ([]byte, error) { p := filepath.Join(os.TempDir(), name) if err := os.WriteFile(p, src, 0644); err != nil { return nil, fmt.Errorf("write source content to temp file: %w", err) } defer os.Remove(p) editor := "vi" if e := os.Getenv("EDITOR"); e != "" { editor = e } cmd := exec.Command("sh", "-c", editor+" "+p) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { return nil, fmt.Errorf("exec edit: %w", err) } b, err := os.ReadFile(p) if err != nil { return nil, fmt.Errorf("read edited temp file: %w", err) } return b, nil } type ( // dryRunDriver wraps a migrate.Driver without executing any SQL statements. dryRunDriver struct{ migrate.Driver } // dryRunRevisions wraps a migrate.RevisionReadWriter without executing any SQL statements. dryRunRevisions struct{ migrate.RevisionReadWriter } ) // ExecContext overrides the wrapped schema.ExecQuerier to not execute any SQL. func (dryRunDriver) ExecContext(context.Context, string, ...any) (sql.Result, error) { return nil, nil } // Lock implements the schema.Locker interface. func (dryRunDriver) Lock(context.Context, string, time.Duration) (schema.UnlockFunc, error) { // We dry-run, we don't execute anything. Locking is not required. return func() error { return nil }, nil } // CheckClean implements the migrate.CleanChecker interface. func (dryRunDriver) CheckClean(context.Context, *migrate.TableIdent) error { return nil } // Snapshot implements the migrate.Snapshoter interface. func (dryRunDriver) Snapshot(context.Context) (migrate.RestoreFunc, error) { // We dry-run, we don't execute anything. Snapshotting not required. return func(context.Context) error { return nil }, nil } // WriteRevision overrides the wrapped migrate.RevisionReadWriter to not saved any changes to revisions. func (dryRunRevisions) WriteRevision(context.Context, *migrate.Revision) error { return nil } ================================================ FILE: cmd/atlas/internal/cmdapi/migrate_oss.go ================================================ // Copyright 2021-present The Atlas Authors. All rights reserved. // This source code is licensed under the Apache 2.0 license found // in the LICENSE file in the root directory of this source tree. //go:build !ent package cmdapi import ( "errors" "fmt" "net/url" "strconv" "ariga.io/atlas/cmd/atlas/internal/cmdlog" cmdmigrate "ariga.io/atlas/cmd/atlas/internal/migrate" "ariga.io/atlas/sql/migrate" "github.com/spf13/cobra" ) // migrateApplyRun represents the 'atlas migrate apply' subcommand. func migrateApplyRun(cmd *cobra.Command, args []string, flags migrateApplyFlags, env *Env, mr *MigrateReport) (err error) { var ( count int ctx = cmd.Context() ) if len(args) > 0 { if count, err = strconv.Atoi(args[0]); err != nil { if nerr := (&strconv.NumError{}); errors.As(err, &nerr) && nerr.Err != nil { err = nerr.Err } return fmt.Errorf("invalid amount argument %q (%w). Omit the argument or pass a valid integer instead", args[0], err) } if count < 1 { return fmt.Errorf("cannot apply '%d' migration files", count) } } dirURL, err := url.Parse(flags.dirURL) if err != nil { return fmt.Errorf("parse dir-url: %w", err) } // Open and validate the migration directory. dir, err := cmdmigrate.DirURL(ctx, dirURL, false) if err != nil { return err } if err := migrate.Validate(dir); err != nil { printChecksumError(cmd, err) return err } // Open a client to the database. if flags.url == "" { return errors.New(`required flag "url" not set`) } client, err := env.openClient(ctx, flags.url) if err != nil { return err } defer client.Close() // Prevent usage printing after input validation. cmd.SilenceUsage = true // Acquire a lock. unlock, err := client.Driver.Lock(ctx, applyLockValue, flags.lockTimeout) if err != nil { return fmt.Errorf("acquiring database lock: %w", err) } // If unlocking fails notify the user about it. defer func() { cobra.CheckErr(unlock()) }() if err := checkRevisionSchemaClarity(cmd, client, flags.revisionSchema); err != nil { return err } var rrw migrate.RevisionReadWriter if rrw, err = entRevisions(ctx, client, flags.revisionSchema); err != nil { return err } mrrw, ok := rrw.(cmdmigrate.RevisionReadWriter) if !ok { return fmt.Errorf("unexpected revision read-writer type: %T", rrw) } if err := mrrw.Migrate(ctx); err != nil { return err } // Setup reporting info. report := cmdlog.NewMigrateApply(ctx, client, dirURL) mr.Init(client, report, mrrw) // If cloud reporting is enabled, and we cannot obtain the current // target identifier, abort and report it to the user. if err := mr.RecordTargetID(cmd.Context()); err != nil { return err } // Determine pending files. opts, err := flags.migrateOptions() if err != nil { return err } opts = append(opts, migrate.WithOperatorVersion(operatorVersion()), migrate.WithLogger(report)) ex, err := migrate.NewExecutor(client.Driver, dir, rrw, opts...) if err != nil { return err } pending, err := ex.Pending(ctx) if err != nil && !errors.Is(err, migrate.ErrNoPendingFiles) { mr.RecordPlanError(cmd, flags, err.Error()) return err } noPending := errors.Is(err, migrate.ErrNoPendingFiles) // Get the pending files before obtaining applied revisions, // as the Executor may write a baseline revision in the table. applied, err := rrw.ReadRevisions(ctx) if err != nil { return err } if noPending { migrate.LogNoPendingFiles(report, applied) return mr.Done(cmd, flags) } if l := len(pending); count == 0 || count >= l { // Cannot apply more than len(pending) migration files. count = l } pending = pending[:count] migrate.LogIntro(report, applied, pending) var ( mux = tx{ dryRun: flags.dryRun, mode: flags.txMode, schema: flags.revisionSchema, c: client, rrw: rrw, } drv migrate.Driver ) for _, f := range pending { if drv, rrw, err = mux.driverFor(ctx, f); err != nil { break } if ex, err = migrate.NewExecutor(drv, dir, rrw, opts...); err != nil { return fmt.Errorf("unexpected executor creation error: %w", err) } if err = mux.mayRollback(ex.Execute(ctx, f)); err != nil { break } if err = mux.mayCommit(); err != nil { break } } if err == nil { if err = mux.commit(); err == nil { report.Log(migrate.LogDone{}) } } if err != nil { report.Error = err.Error() } return errors.Join(err, mr.Done(cmd, flags)) } ================================================ FILE: cmd/atlas/internal/cmdapi/migrate_test.go ================================================ // Copyright 2021-present The Atlas Authors. All rights reserved. // This source code is licensed under the Apache 2.0 license found // in the LICENSE file in the root directory of this source tree. package cmdapi import ( "context" "database/sql" "encoding/json" "errors" "fmt" "io" "net/url" "os" "os/exec" "path/filepath" "runtime" "strings" "testing" "time" "ariga.io/atlas/cmd/atlas/internal/cmdlog" migrate2 "ariga.io/atlas/cmd/atlas/internal/migrate" "ariga.io/atlas/sql/migrate" "ariga.io/atlas/sql/schema" "ariga.io/atlas/sql/sqlclient" "ariga.io/atlas/sql/sqlite" _ "ariga.io/atlas/sql/sqlite" _ "ariga.io/atlas/sql/sqlite/sqlitecheck" "github.com/fatih/color" "github.com/google/uuid" _ "github.com/mattn/go-sqlite3" "github.com/stretchr/testify/require" ) func TestMigrate(t *testing.T) { _, err := runCmd(migrateCmd()) require.NoError(t, err) } func TestMigrate_Import(t *testing.T) { for _, tool := range []string{"dbmate", "flyway", "golang-migrate", "goose", "liquibase"} { p := t.TempDir() t.Run(tool, func(t *testing.T) { // remove this once --dir-format is removed. Test is kept to ensure BC. path := filepath.FromSlash("testdata/import/" + tool) out, err := runCmd( migrateImportCmd(), "--from", "file://"+path, "--to", "file://"+p, "--dir-format", tool, ) require.NoError(t, err) require.Zero(t, out) path += "_gold" ex, err := os.ReadDir(path) require.NoError(t, err) ac, err := os.ReadDir(p) require.NoError(t, err) require.Equal(t, len(ex)+1, len(ac)) // sum file for i := range ex { e, err := os.ReadFile(filepath.Join(path, ex[i].Name())) require.NoError(t, err) a, err := os.ReadFile(filepath.Join(p, ex[i].Name())) require.NoError(t, err) require.Equal(t, string(e), string(a)) } }) p = t.TempDir() t.Run(tool, func(t *testing.T) { path := filepath.FromSlash("testdata/import/" + tool) out, err := runCmd( migrateImportCmd(), "--from", fmt.Sprintf("file://%s?format=%s", path, tool), "--to", "file://"+p, ) require.NoError(t, err) require.Zero(t, out) path += "_gold" ex, err := os.ReadDir(path) require.NoError(t, err) ac, err := os.ReadDir(p) require.NoError(t, err) require.Equal(t, len(ex)+1, len(ac)) // sum file for i := range ex { e, err := os.ReadFile(filepath.Join(path, ex[i].Name())) require.NoError(t, err) a, err := os.ReadFile(filepath.Join(p, ex[i].Name())) require.NoError(t, err) require.Equal(t, string(e), string(a)) } }) } } func TestMigrate_Apply(t *testing.T) { var ( p = t.TempDir() ctx = context.Background() ) // Disable text coloring in testing // to assert on string matching. color.NoColor = true // Fails on empty directory. s, err := runCmd( migrateApplyCmd(), "--dir", "file://"+p, "-u", openSQLite(t, ""), ) require.NoError(t, err) require.Equal(t, "No migration files to execute\n", s) // Fails on directory without sum file. require.NoError(t, os.Rename( filepath.FromSlash("testdata/sqlite/atlas.sum"), filepath.FromSlash("testdata/sqlite/atlas.sum.bak"), )) t.Cleanup(func() { os.Rename(filepath.FromSlash("testdata/sqlite/atlas.sum.bak"), filepath.FromSlash("testdata/sqlite/atlas.sum")) }) _, err = runCmd( migrateApplyCmd(), "--dir", "file://testdata/sqlite", "--url", openSQLite(t, ""), ) require.ErrorIs(t, err, migrate.ErrChecksumNotFound) require.NoError(t, os.Rename( filepath.FromSlash("testdata/sqlite/atlas.sum.bak"), filepath.FromSlash("testdata/sqlite/atlas.sum"), )) // A lock will prevent execution. sqlclient.Register( "sqlitelockapply", sqlclient.OpenerFunc(func(ctx context.Context, u *url.URL) (*sqlclient.Client, error) { client, err := sqlclient.Open(ctx, strings.Replace(u.String(), u.Scheme, "sqlite", 1)) if err != nil { return nil, err } client.Driver = &sqliteLockerDriver{client.Driver} return client, nil }), sqlclient.RegisterDriverOpener(func(db schema.ExecQuerier) (migrate.Driver, error) { drv, err := sqlite.Open(db) if err != nil { return nil, err } return &sqliteLockerDriver{drv}, nil }), ) f, err := os.Create(filepath.Join(p, "test.db")) require.NoError(t, err) require.NoError(t, f.Close()) s, err = runCmd( migrateApplyCmd(), "--dir", "file://testdata/sqlite", "--url", fmt.Sprintf("sqlitelockapply://file:%s?cache=shared&_fk=1", filepath.Join(p, "test.db")), ) require.ErrorIs(t, err, errLock) require.True(t, strings.HasPrefix(s, "Error: acquiring database lock: "+errLock.Error())) // Apply zero throws error. for _, n := range []string{"-1", "0"} { _, err = runCmd( migrateApplyCmd(), "--dir", "file://testdata/sqlite", "--url", fmt.Sprintf("sqlite://file:%s?cache=shared&_fk=1", filepath.Join(p, "test.db")), "--", n, ) require.EqualError(t, err, fmt.Sprintf("cannot apply '%s' migration files", n)) } // Will work and print stuff to the console. s, err = runCmd( migrateApplyCmd(), "--dir", "file://testdata/sqlite", "--url", fmt.Sprintf("sqlite://file:%s?cache=shared&_fk=1", filepath.Join(p, "test.db")), "1", ) require.NoError(t, err) require.Contains(t, s, "20220318104614") // log to version require.Contains(t, s, "CREATE TABLE tbl (`col` int NOT NULL);") // logs statement require.NotContains(t, s, "ALTER TABLE `tbl` ADD `col_2` bigint;") // does not execute second file require.Contains(t, s, "1 migration") // logs amount of migrations require.Contains(t, s, "1 sql statement") // Transactions will be wrapped per file. If the second file has an error, first still is applied. s, err = runCmd( migrateApplyCmd(), "--dir", "file://testdata/sqlite2", "--url", fmt.Sprintf("sqlite://file:%s?cache=shared&_fk=1", filepath.Join(p, "test2.db")), ) require.Error(t, err) require.Contains(t, s, "20220318104614") // log to version require.Contains(t, s, "CREATE TABLE tbl (`col` int NOT NULL);") // logs statement require.Contains(t, s, "ALTER TABLE `tbl` ADD `col_2` bigint;") // does execute first stmt first second file require.Contains(t, s, "ALTER TABLE `tbl` ADD `col_3` bigint;") // does execute second stmt first second file require.NotContains(t, s, "ALTER TABLE `tbl` ADD `col_4` bigint;") // but not third require.Contains(t, s, "-- 1 migration ok, 1 with errors") // logs amount of migrations require.Contains(t, s, "-- 2 sql statements ok, 1 with errors") // logs amount of statement require.Contains(t, s, "near \"asdasd\": syntax error") // logs error summary c, err := sqlclient.Open(ctx, fmt.Sprintf("sqlite://file:%s?cache=shared&_fk=1", filepath.Join(p, "test2.db"))) require.NoError(t, err) t.Cleanup(func() { require.NoError(t, c.Close()) }) sch, err := c.InspectSchema(ctx, "", nil) tbl, ok := sch.Table("tbl") require.True(t, ok) _, ok = tbl.Column("col_2") require.False(t, ok) _, ok = tbl.Column("col_3") require.False(t, ok) rrw, err := migrate2.NewEntRevisions(ctx, c) require.NoError(t, err) revs, err := rrw.ReadRevisions(ctx) require.NoError(t, err) require.Len(t, revs, 1) // Running again will pick up the failed statement and try it again. s, err = runCmd( migrateApplyCmd(), "--dir", "file://testdata/sqlite2", "--url", fmt.Sprintf("sqlite://file:%s?cache=shared&_fk=1", filepath.Join(p, "test2.db")), ) require.Error(t, err) require.Contains(t, s, "20220318104614") // currently applied version require.Contains(t, s, "20220318104615") // retry second (partially applied) require.NotContains(t, s, "CREATE TABLE tbl (`col` int NOT NULL);") // will not attempt stmts from first file require.Contains(t, s, "ALTER TABLE `tbl` ADD `col_2` bigint;") // picks up first statement require.Contains(t, s, "ALTER TABLE `tbl` ADD `col_3` bigint;") // does execute second stmt first second file require.NotContains(t, s, "ALTER TABLE `tbl` ADD `col_4` bigint;") // but not third require.Contains(t, s, "-- 1 migration with errors") // logs amount of migrations require.Contains(t, s, "-- 1 sql statement ok, 1 with errors") // logs amount of statement require.Contains(t, s, "near \"asdasd\": syntax error") // logs error summary // Editing an applied line will raise error. s, err = runCmd( migrateApplyCmd(), "--dir", "file://testdata/sqlite2", "--url", fmt.Sprintf("sqlite://file:%s?cache=shared&_fk=1", filepath.Join(p, "test2.db")), "--tx-mode", "none", ) t.Cleanup(func() { _ = os.RemoveAll("testdata/sqlite3") }) require.NoError(t, exec.Command("cp", "-r", "testdata/sqlite2", "testdata/sqlite3").Run()) sed(t, "s/col_2/col_5/g", "testdata/sqlite3/20220318104615_second.sql") _, err = runCmd(migrateHashCmd(), "--dir", "file://testdata/sqlite3") require.NoError(t, err) s, err = runCmd( migrateApplyCmd(), "--dir", "file://testdata/sqlite3", "--url", fmt.Sprintf("sqlite://file:%s?cache=shared&_fk=1", filepath.Join(p, "test2.db")), ) require.ErrorAs(t, err, &migrate.HistoryChangedError{}) // Fixing the migration file will finish without errors. sed(t, "s/col_5/col_2/g", "testdata/sqlite3/20220318104615_second.sql") sed(t, "s/asdasd //g", "testdata/sqlite3/20220318104615_second.sql") _, err = runCmd(migrateHashCmd(), "--dir", "file://testdata/sqlite3") require.NoError(t, err) s, err = runCmd( migrateApplyCmd(), "--dir", "file://testdata/sqlite3", "--url", fmt.Sprintf("sqlite://file:%s?cache=shared&_fk=1", filepath.Join(p, "test2.db")), ) require.NoError(t, err) require.Contains(t, s, "20220318104615") // retry second (partially applied) require.Contains(t, s, "ALTER TABLE `tbl` ADD `col_3` bigint;") // does execute second stmt first second file require.Contains(t, s, "ALTER TABLE `tbl` ADD `col_4` bigint;") // does execute second stmt first second file require.Contains(t, s, "1 migrations") // logs amount of migrations require.Contains(t, s, "2") // logs amount of statement require.NotContains(t, s, "Error: Execution had errors:") // logs error summary require.NotContains(t, s, "near \"asdasd\": syntax error") // logs error summary // Running again will report database being in clean state. s, err = runCmd( migrateApplyCmd(), "--dir", "file://testdata/sqlite3", "--url", fmt.Sprintf("sqlite://file:%s?cache=shared&_fk=1", filepath.Join(p, "test2.db")), ) require.NoError(t, err) require.Equal(t, "No migration files to execute\n", s) // Dry run will print the statements in second migration file without executing them. // No changes to the revisions will be done. s, err = runCmd( migrateApplyCmd(), "--dir", "file://testdata/sqlite", "--url", fmt.Sprintf("sqlite://file:%s?cache=shared&_fk=1", filepath.Join(p, "test.db")), "--dry-run", "1", ) require.NoError(t, err) require.Contains(t, s, "20220318104615") // log to version require.Contains(t, s, "ALTER TABLE `tbl` ADD `col_2` bigint;") // logs statement c1, err := sqlclient.Open(ctx, fmt.Sprintf("sqlite://file:%s?cache=shared&_fk=1", filepath.Join(p, "test.db"))) require.NoError(t, err) t.Cleanup(func() { require.NoError(t, c1.Close()) }) sch, err = c1.InspectSchema(ctx, "", nil) tbl, ok = sch.Table("tbl") require.True(t, ok) _, ok = tbl.Column("col_2") require.False(t, ok) rrw, err = migrate2.NewEntRevisions(ctx, c1) require.NoError(t, err) revs, err = rrw.ReadRevisions(ctx) require.NoError(t, err) require.Len(t, revs, 1) // Prerequisites for testing missing migration behavior. c1, err = sqlclient.Open(ctx, fmt.Sprintf("sqlite://file:%s?cache=shared&_fk=1", filepath.Join(p, "test3.db"))) require.NoError(t, err) t.Cleanup(func() { require.NoError(t, c1.Close()) }) require.NoError(t, os.Rename( "testdata/sqlite3/20220318104615_second.sql", "testdata/sqlite3/20220318104616_second.sql", )) _, err = runCmd(migrateHashCmd(), "--dir", "file://testdata/sqlite3") require.NoError(t, err) rrw, err = migrate2.NewEntRevisions(ctx, c1) require.NoError(t, err) require.NoError(t, rrw.Migrate(ctx)) // No changes if the last revision has a greater version than the last migration. require.NoError(t, rrw.WriteRevision(ctx, &migrate.Revision{Version: "zzz"})) s, err = runCmd( migrateApplyCmd(), "--dir", "file://testdata/sqlite3", "--url", fmt.Sprintf("sqlite://file:%s?cache=shared&_fk=1", filepath.Join(p, "test3.db")), ) require.NoError(t, err) require.Equal(t, "No migration files to execute\n", s) // If the revision is before the last but after the first migration, only the last one is pending. _, err = c1.ExecContext(ctx, "DROP table `atlas_schema_revisions`") require.NoError(t, err) s, err = runCmd( migrateApplyCmd(), "1", "--dir", "file://testdata/sqlite3", "--url", fmt.Sprintf("sqlite://file:%s?cache=shared&_fk=1", filepath.Join(p, "test3.db")), ) require.NoError(t, rrw.WriteRevision(ctx, &migrate.Revision{Version: "20220318104615"})) require.NoError(t, err) s, err = runCmd( migrateApplyCmd(), "--dir", "file://testdata/sqlite3", "--url", fmt.Sprintf("sqlite://file:%s?cache=shared&_fk=1", filepath.Join(p, "test3.db")), ) require.NoError(t, err) require.NotContains(t, s, "20220318104614") // log to version require.Contains(t, s, "20220318104616") // log to version require.Contains(t, s, "ALTER TABLE `tbl` ADD `col_2` bigint;") // logs statement // If the revision is before every migration file, every file is pending. _, err = c1.ExecContext(ctx, "DROP table `atlas_schema_revisions`; DROP table `tbl`;") require.NoError(t, err) require.NoError(t, rrw.Migrate(ctx)) require.NoError(t, rrw.WriteRevision(ctx, &migrate.Revision{Version: "1"})) require.NoError(t, err) s, err = runCmd( migrateApplyCmd(), "--dir", "file://testdata/sqlite3", "--url", fmt.Sprintf("sqlite://file:%s?cache=shared&_fk=1", filepath.Join(p, "test3.db")), ) require.NoError(t, err) require.Contains(t, s, "20220318104614") // log to version require.Contains(t, s, "20220318104616") // log to version require.Contains(t, s, "CREATE TABLE tbl (`col` int NOT NULL);") // logs statement require.Contains(t, s, "ALTER TABLE `tbl` ADD `col_2` bigint;") // logs statement // If the revision is partially applied, error out. require.NoError(t, rrw.WriteRevision(ctx, &migrate.Revision{Version: "z", Description: "z", Total: 1})) require.NoError(t, err) _, err = runCmd( migrateApplyCmd(), "--dir", "file://testdata/sqlite3", "--url", fmt.Sprintf("sqlite://file:%s?cache=shared&_fk=1", filepath.Join(p, "test3.db")), ) require.EqualError(t, err, migrate.MissingMigrationError{Version: "z", Description: "z"}.Error()) } func TestMigrate_ApplyMultiEnv(t *testing.T) { t.Run("FromVars", func(t *testing.T) { p := t.TempDir() h := ` variable "urls" { type = list(string) } env "local" { for_each = toset(var.urls) url = each.value dev = "sqlite://ci?mode=memory&cache=shared&_fk=1" migration { dir = "file://testdata/sqlite" } } ` path := filepath.Join(p, "atlas.hcl") err := os.WriteFile(path, []byte(h), 0600) require.NoError(t, err) cmd := migrateCmd() cmd.AddCommand(migrateApplyCmd()) s, err := runCmd( cmd, "apply", "-c", "file://"+path, "--env", "local", "--var", fmt.Sprintf("urls=sqlite://file:%s?cache=shared&_fk=1", filepath.Join(p, "test1.db")), "--var", fmt.Sprintf("urls=sqlite://file:%s?cache=shared&_fk=1", filepath.Join(p, "test2.db")), ) require.NoError(t, err) require.Equal(t, 2, strings.Count(s, "Migrating to version 20220318104615 (2 migrations in total)"), "execution per environment") _, err = os.Stat(filepath.Join(p, "test1.db")) require.NoError(t, err) _, err = os.Stat(filepath.Join(p, "test2.db")) require.NoError(t, err) }) t.Run("FromDataSrc", func(t *testing.T) { var ( h = ` variable "url" { type = string } locals { string = "%test" bool = true int = 1 } data "sql" "tenants" { url = var.url query = <") _, err = db.Exec("DELETE FROM `tenants`") require.NoError(t, err) s, err = runCmd( cmd, "apply", "-c", "file://"+path, "--env", "local", "--var", fmt.Sprintf("url=sqlite://file:%s?cache=shared&_fk=1", filepath.Join(p, "tenants.db")), // Inject fake variable to enforce re-evaluation of the data source (skip cache). "--var", fmt.Sprintf("cache=%s", uuid.NewString()), ) // Empty list is expanded to zero blocks. require.EqualError(t, err, `env "local" not defined in config file`) }) t.Run("TemplateDir", func(t *testing.T) { var ( h = ` variable "path" { type = string } data "template_dir" "migrations" { path = var.path vars = { Env = atlas.env } } env "dev" { url = "sqlite://${atlas.env}?mode=memory&_fk=1" migration { dir = data.template_dir.migrations.url } } env "prod" { url = "sqlite://${atlas.env}?mode=memory&_fk=1" migration { dir = data.template_dir.migrations.url } } ` p = t.TempDir() path = filepath.Join(p, "atlas.hcl") ) err := os.WriteFile(path, []byte(h), 0600) require.NoError(t, err) for _, e := range []string{"dev", "prod"} { cmd := migrateCmd() cmd.AddCommand(migrateApplyCmd()) s, err := runCmd( cmd, "apply", "-c", "file://"+path, "--env", e, "--var", "path=testdata/templatedir", ) require.NoError(t, err) require.Contains(t, s, "Migrating to version 2 (2 migrations in total):") require.Contains(t, s, fmt.Sprintf("create table %s1 (c text);", e)) require.Contains(t, s, fmt.Sprintf("create table %s2 (c text);", e)) require.Contains(t, s, fmt.Sprintf("create table users_%s2 (c text);", e)) } }) } func TestMigrate_ApplyTxMode(t *testing.T) { for _, mode := range []string{"none", "file", "all"} { t.Run(mode, func(t *testing.T) { p := t.TempDir() // Apply the first 2 migrations. s, err := runCmd( migrateApplyCmd(), "--dir", "file://testdata/sqlitetx", "--url", fmt.Sprintf("sqlite://file:%s?cache=shared&_fk=1", filepath.Join(p, "test.db")), "--tx-mode", mode, "2", ) require.NoError(t, err) require.NotEmpty(t, s) db, err := sql.Open("sqlite3", fmt.Sprintf("file:%s?cache=shared&_fk=1", filepath.Join(p, "test.db"))) require.NoError(t, err) var n int require.NoError(t, db.QueryRow("SELECT COUNT(*) FROM `friendships`").Scan(&n)) require.Equal(t, 2, n) // Apply the rest. s, err = runCmd( migrateApplyCmd(), "--dir", "file://testdata/sqlitetx", "--url", fmt.Sprintf("sqlite://file:%s?cache=shared&_fk=1", filepath.Join(p, "test.db")), "--tx-mode", mode, ) require.NoError(t, err) require.NoError(t, db.QueryRow("SELECT COUNT(*) FROM `friendships`").Scan(&n)) require.Equal(t, 2, n) // For transactions check that the foreign keys are checked before the transaction is committed. if mode != "none" { // Apply the first 2 migrations for the faulty one. s, err = runCmd( migrateApplyCmd(), "--dir", "file://testdata/sqlitetx2", "--url", fmt.Sprintf("sqlite://file:%s?cache=shared&_fk=1", filepath.Join(p, "test_2.db")), "--tx-mode", mode, "2", ) require.NoError(t, err) require.NotEmpty(t, s) db, err = sql.Open("sqlite3", fmt.Sprintf("file:%s?cache=shared&_fk=1", filepath.Join(p, "test_2.db"))) require.NoError(t, err) require.NoError(t, db.QueryRow("SELECT COUNT(*) FROM `friendships`").Scan(&n)) require.Equal(t, 2, n) // Add an existing constraint. c, err := sqlclient.Open(context.Background(), fmt.Sprintf("sqlite://file:%s?cache=shared&_fk=1", filepath.Join(p, "test_2.db"))) require.NoError(t, err) _, err = c.ExecContext(context.Background(), "PRAGMA foreign_keys = off; INSERT INTO `friendships` (`user_id`, `friend_id`) VALUES (3,3);PRAGMA foreign_keys = on;") require.NoError(t, err) require.NoError(t, db.QueryRow("SELECT COUNT(*) FROM `friendships`").Scan(&n)) require.Equal(t, 3, n) // Apply the rest, expect it to fail due to constraint error, but only the new one is reported. s, err = runCmd( migrateApplyCmd(), "--dir", "file://testdata/sqlitetx2", "--url", fmt.Sprintf("sqlite://file:%s?cache=shared&_fk=1", filepath.Join(p, "test_2.db")), "--tx-mode", mode, ) require.EqualError(t, err, "sql/sqlite: foreign key mismatch: [{tbl:friendships ref:users row:4 index:1}]") require.NoError(t, db.QueryRow("SELECT COUNT(*) FROM `friendships`").Scan(&n)) require.Equal(t, 3, n) // was rolled back } }) } } func TestMigrate_ApplyTxModeDirective(t *testing.T) { for _, mode := range []string{txModeNone, txModeFile} { u := openSQLite(t, "") _, err := runCmd( migrateApplyCmd(), "--dir", "file://testdata/sqlitetx3", "--url", u, "--tx-mode", mode, ) require.EqualError(t, err, `sql/migrate: executing statement "INSERT INTO t1 VALUES (1), (1);" from version "20220925094021": UNIQUE constraint failed: t1.a`) db, err := sql.Open("sqlite3", strings.TrimPrefix(u, "sqlite://")) require.NoError(t, err) var n int require.NoError(t, db.QueryRow("SELECT COUNT(*) FROM sqlite_master WHERE name IN ('atlas_schema_revisions', 'users', 't1')").Scan(&n)) require.Equal(t, 3, n) require.NoError(t, db.Close()) } _, err := runCmd( migrateApplyCmd(), "--dir", "file://testdata/sqlitetx3", "--url", "sqlite://txmode?mode=memory&_fk=1", "--tx-mode", txModeAll, ) require.EqualError(t, err, `cannot set txmode directive to "none" in "20220925094021_second.sql" when txmode "all" is set globally`) s, err := runCmd( migrateApplyCmd(), "--dir", "file://testdata/sqlitetx4", "--url", "sqlite://txmode?mode=memory&_fk=1", "--tx-mode", txModeAll, "--log", "{{ .Error }}", ) require.EqualError(t, err, `unknown txmode "unknown" found in file directive "20220925094021_second.sql"`) // Errors should be attached to the report. require.Equal(t, s, `unknown txmode "unknown" found in file directive "20220925094021_second.sql"`) } func TestMigrate_ApplyExecOrder(t *testing.T) { p := t.TempDir() db := fmt.Sprintf("sqlite://file:%s?cache=shared&_fk=1", filepath.Join(p, "test.db")) require.NoError(t, os.Mkdir(filepath.Join(p, "migrations"), 0700)) dir, err := migrate.NewLocalDir(filepath.Join(p, "migrations")) require.NoError(t, err) write := func(n, b string) { require.NoError(t, dir.WriteFile(n, []byte(b))) hash, err := dir.Checksum() require.NoError(t, err) require.NoError(t, migrate.WriteSumFile(dir, hash)) } write("1.sql", "create table t1(c int);") write("3.sql", "create table t3(c int);") // First run. s, err := runCmd( migrateApplyCmd(), "--dir", "file://"+dir.Path(), "--url", db, "--format", "{{ len .Applied }}", ) require.NoError(t, err) require.Equal(t, "2", s) // File was added out of order. write("2.sql", "create table t2(c int);") _, err = runCmd( migrateApplyCmd(), "--dir", "file://"+dir.Path(), "--url", db, ) require.EqualError(t, err, "migration file 2.sql was added out of order. See: https://atlasgo.io/versioned/apply#non-linear-error") // The "linear" option is the default execution order. _, err = runCmd( migrateApplyCmd(), "--dir", "file://"+dir.Path(), "--url", db, "--exec-order", "linear", ) require.EqualError(t, err, "migration file 2.sql was added out of order. See: https://atlasgo.io/versioned/apply#non-linear-error") // Keep linear order and skip files that were added out of order. s, err = runCmd( migrateApplyCmd(), "--dir", "file://"+dir.Path(), "--url", db, "--exec-order", "linear-skip", ) require.NoError(t, err) require.Equal(t, "No migration files to execute\n", s) // Allow non-linear order. s, err = runCmd( migrateApplyCmd(), "--dir", "file://"+dir.Path(), "--url", db, "--exec-order", "non-linear", "--format", "{{ range .Applied }}{{ .Version }}{{ end }}", ) require.NoError(t, err) require.Equal(t, "2", s) write("2.5.sql", "create table t25(c int);") write("4.sql", "create table t4(c int);") s, err = runCmd( migrateApplyCmd(), "--dir", "file://"+dir.Path(), "--url", db, "--exec-order", "non-linear", "--format", "{{ range .Applied }}{{ println .Version }}{{ end }}", ) require.NoError(t, err) require.Equal(t, "2.5\n4\n", s) // There are no pending migrations, in all execution orders. for _, o := range []string{"linear", "linear-skip", "non-linear"} { s, err = runCmd( migrateApplyCmd(), "--dir", "file://"+dir.Path(), "--url", db, "--exec-order", o, ) require.NoError(t, err) require.Equal(t, "No migration files to execute\n", s) } } func TestMigrate_ApplyBaseline(t *testing.T) { t.Run("FromFlags", func(t *testing.T) { p := t.TempDir() // Run migration with baseline should store this revision in the database. s, err := runCmd( migrateApplyCmd(), "--dir", "file://testdata/baseline1", "--baseline", "1", "--url", fmt.Sprintf("sqlite://file:%s?cache=shared&_fk=1", filepath.Join(p, "test1.db")), ) require.NoError(t, err) require.Contains(t, s, "No migration files to execute") // Next run without baseline should run the migration from the baseline. s, err = runCmd( migrateApplyCmd(), "--dir", "file://testdata/baseline1", "--url", fmt.Sprintf("sqlite://file:%s?cache=shared&_fk=1", filepath.Join(p, "test1.db")), ) require.NoError(t, err) require.Contains(t, s, "No migration files to execute") // Multiple migration files with baseline. s, err = runCmd( migrateApplyCmd(), "--dir", "file://testdata/baseline2", "--baseline", "1", "--url", fmt.Sprintf("sqlite://file:%s?cache=shared&_fk=1", filepath.Join(p, "test2.db")), ) require.NoError(t, err) require.Contains(t, s, "Migrating to version 20220318104615 from 1 (2 migrations in total)") // Run all migration files and skip baseline. s, err = runCmd( migrateApplyCmd(), "--dir", "file://testdata/baseline2", "--url", fmt.Sprintf("sqlite://file:%s?cache=shared&_fk=1", filepath.Join(p, "test3.db")), ) require.NoError(t, err) require.Contains(t, s, "Migrating to version 20220318104615 (3 migrations in total)") }) t.Run("FromConfig", func(t *testing.T) { const h = ` env "local" { migration { baseline = "1" } }` p := t.TempDir() path := filepath.Join(p, "atlas.hcl") err := os.WriteFile(path, []byte(h), 0600) require.NoError(t, err) cmd := migrateCmd() cmd.AddCommand(migrateApplyCmd()) s, err := runCmd( cmd, "apply", "-c", "file://"+path, "--env", "local", "--dir", "file://testdata/baseline1", "--url", fmt.Sprintf("sqlite://file:%s?cache=shared&_fk=1", filepath.Join(p, "test1.db")), ) require.NoError(t, err) require.Contains(t, s, "No migration files to execute") cmd = migrateCmd() cmd.AddCommand(migrateApplyCmd()) s, err = runCmd( cmd, "apply", "-c", "file://"+path, "--env", "local", "--dir", "file://testdata/baseline2", "--url", fmt.Sprintf("sqlite://file:%s?cache=shared&_fk=1", filepath.Join(p, "test2.db")), ) require.NoError(t, err) require.Contains(t, s, "Migrating to version 20220318104615 from 1 (2 migrations in total)") }) } func TestMigrate_Diff(t *testing.T) { p := t.TempDir() to := hclURL(t) // Will create migration directory if not existing. _, err := runCmd( migrateDiffCmd(), "name", "--dir", "file://"+filepath.Join(p, "migrations"), "--dev-url", openSQLite(t, ""), "--to", to, ) require.NoError(t, err) require.FileExists(t, filepath.Join(p, "migrations", fmt.Sprintf("%s_name.sql", time.Now().UTC().Format("20060102150405")))) // Expect no clean dev error. p = t.TempDir() s, err := runCmd( migrateDiffCmd(), "name", "--dir", "file://"+p, "--dev-url", openSQLite(t, "create table t (c int);"), "--to", to, ) require.ErrorAs(t, err, new(*migrate.NotCleanError)) require.ErrorContains(t, err, "found table \"t\"") // Works (on empty directory). s, err = runCmd( migrateDiffCmd(), "name", "--dir", "file://"+p, "--dev-url", openSQLite(t, ""), "--to", to, ) require.NoError(t, err) require.Zero(t, s) require.FileExists(t, filepath.Join(p, fmt.Sprintf("%s_name.sql", time.Now().UTC().Format("20060102150405")))) require.FileExists(t, filepath.Join(p, "atlas.sum")) // A lock will prevent diffing. sqlclient.Register("sqlitelockdiff", sqlclient.OpenerFunc(func(ctx context.Context, u *url.URL) (*sqlclient.Client, error) { u.Scheme = "sqlite" client, err := sqlclient.OpenURL(ctx, u) if err != nil { return nil, err } client.Driver = &sqliteLockerDriver{Driver: client.Driver} return client, nil })) f, err := os.Create(filepath.Join(p, "test.db")) require.NoError(t, err) require.NoError(t, f.Close()) s, err = runCmd( migrateDiffCmd(), "name", "--dir", "file://"+t.TempDir(), "--dev-url", fmt.Sprintf("sqlitelockdiff://file:%s?cache=shared&_fk=1", filepath.Join(p, "test.db")), "--to", to, ) require.True(t, strings.HasPrefix(s, "Error: acquiring database lock: "+errLock.Error())) require.ErrorIs(t, err, errLock) t.Run("Edit", func(t *testing.T) { p := t.TempDir() t.Setenv("EDITOR", "echo '-- Comment' >>") args := []string{ "--edit", "--dir", "file://" + p, "--dev-url", openSQLite(t, ""), "--to", to, } _, err := runCmd(migrateDiffCmd(), args...) files, err := os.ReadDir(p) require.NoError(t, err) require.Len(t, files, 2) b, err := os.ReadFile(filepath.Join(p, files[0].Name())) require.NoError(t, err) require.Contains(t, string(b), "CREATE") require.True(t, strings.HasSuffix(string(b), "-- Comment\n")) require.Equal(t, "atlas.sum", files[1].Name()) // Second run will have no effect. _, err = runCmd(migrateDiffCmd(), args...) require.NoError(t, err) files, err = os.ReadDir(p) require.NoError(t, err) require.Len(t, files, 2) }) t.Run("Format", func(t *testing.T) { for f, out := range map[string]string{ "{{sql .}}": "CREATE TABLE `t` (`c` int NULL);", `{{- sql . " " -}}`: "CREATE TABLE `t` (\n `c` int NULL\n);", "{{ sql . \"\t\" }}": "CREATE TABLE `t` (\n\t`c` int NULL\n);", "{{sql $ \" \t \"}}": "CREATE TABLE `t` (\n \t `c` int NULL\n);", } { p := t.TempDir() d, err := migrate.NewLocalDir(p) require.NoError(t, err) // Works with indentation. s, err = runCmd( migrateDiffCmd(), "name", "--dir", "file://"+p, "--dev-url", openSQLite(t, ""), "--to", openSQLite(t, "create table t (c int);"), "--format", f, ) require.NoError(t, err) require.Zero(t, s) files, err := d.Files() require.NoError(t, err) require.Len(t, files, 1) require.Equal(t, "-- Create \"t\" table\n"+out+"\n", string(files[0].Bytes())) } // Invalid use of sql. s, err = runCmd( migrateDiffCmd(), "name", "--dir", "file://"+p, "--dev-url", openSQLite(t, ""), "--to", openSQLite(t, "create table t (c int);"), "--format", `{{ if . }}{{ sql . " " }}{{ end }}`, ) require.EqualError(t, err, `'sql' can only be used to indent statements. got: {{if .}}{{sql . " "}}{{end}}`) // Valid template. p := t.TempDir() d, err := migrate.NewLocalDir(p) require.NoError(t, err) s, err = runCmd( migrateDiffCmd(), "name", "--dir", "file://"+p, "--dev-url", openSQLite(t, ""), "--to", openSQLite(t, "create table t (c int);"), "--format", `{{ range .Changes }}{{ .Cmd }}{{ end }}`, ) require.NoError(t, err) files, err := d.Files() require.NoError(t, err) require.Len(t, files, 1) require.Equal(t, "CREATE TABLE `t` (`c` int NULL)", string(files[0].Bytes())) }) t.Run("ProjectFile", func(t *testing.T) { p := t.TempDir() h := ` variable "schema" { type = string } variable "dir" { type = string } variable "destructive" { type = bool default = false } env "local" { src = "file://${var.schema}" dev = "sqlite://ci?mode=memory&_fk=1" migration { dir = "file://${var.dir}" } diff { skip { drop_column = !var.destructive } } } ` pathC := filepath.Join(p, "atlas.hcl") require.NoError(t, os.WriteFile(pathC, []byte(h), 0600)) pathS := filepath.Join(p, "schema.sql") require.NoError(t, os.WriteFile(pathS, []byte(`CREATE TABLE t(c1 int, c2 int);`), 0600)) pathD := t.TempDir() cmd := migrateCmd() cmd.AddCommand(migrateDiffCmd()) s, err := runCmd( cmd, "diff", "initial", "-c", "file://"+pathC, "--env", "local", "--var", "schema="+pathS, "--var", "dir="+pathD, ) require.NoError(t, err) require.Empty(t, s) d, err := migrate.NewLocalDir(pathD) require.NoError(t, err) files, err := d.Files() require.NoError(t, err) require.Len(t, files, 1) require.Equal(t, "-- Create \"t\" table\nCREATE TABLE `t` (`c1` int NULL, `c2` int NULL);\n", string(files[0].Bytes())) // Drop column should be skipped. require.NoError(t, os.WriteFile(pathS, []byte(`CREATE TABLE t(c1 int);`), 0600)) cmd = migrateCmd() cmd.AddCommand(migrateDiffCmd()) s, err = runCmd( cmd, "diff", "no_change", "-c", "file://"+pathC, "--env", "local", "--var", "schema="+pathS, "--var", "dir="+pathD, ) require.NoError(t, err) require.Equal(t, "The migration directory is synced with the desired state, no changes to be made\n", s) files, err = d.Files() require.NoError(t, err) require.Len(t, files, 1) // Column is dropped when destructive is true. cmd = migrateCmd() cmd.AddCommand(migrateDiffCmd()) s, err = runCmd( cmd, "diff", "second", "-c", "file://"+pathC, "--env", "local", "--var", "schema="+pathS, "--var", "dir="+pathD, "--var", "destructive=true", ) require.NoError(t, err) require.Empty(t, s) files, err = d.Files() require.NoError(t, err) require.Len(t, files, 2) }) t.Run("TemplateDir", func(t *testing.T) { var ( h = ` variable "default" { type = string default = "Default" } variable "schema_path" { type = string } variable "migrations_path" { type = string } data "hcl_schema" "app" { path = var.schema_path vars = { default = var.default } } data "template_dir" "app" { path = var.migrations_path vars = { default = var.default } } env "local" { src = data.hcl_schema.app.url dev = "sqlite://file?mode=memory&_fk=1" migration { dir = data.template_dir.app.url } } ` p, md = t.TempDir(), t.TempDir() cfg, state = filepath.Join(p, "atlas.hcl"), filepath.Join(p, "schema.hcl") ) err := os.WriteFile(cfg, []byte(h), 0600) require.NoError(t, err) err = os.WriteFile(state, []byte(`variable "default" { type = string } schema "main" {} table "users" { schema = schema.main column "c" { type = text default = var.default } } `), 0600) require.NoError(t, err) dir, err := migrate.NewLocalDir(md) require.NoError(t, err) err = dir.WriteFile("1.sql", []byte(`create table users (c text default "{{ .default }}" NOT NULL);`)) require.NoError(t, err) cmd := migrateCmd() cmd.AddCommand(migrateHashCmd()) _, err = runCmd( cmd, "hash", "--dir", "file://"+md, ) require.NoError(t, err) f, err := dir.Open(migrate.HashFileName) require.NoError(t, err) b, err := io.ReadAll(f) require.NoError(t, err) require.NotEmpty(t, b) cmd = migrateCmd() cmd.AddCommand(migrateDiffCmd()) s, err := runCmd( cmd, "diff", "-c", "file://"+cfg, "--env", "local", "--var", "migrations_path="+md, "--var", "schema_path="+state, ) // Desired state and migration directory are in sync. require.NoError(t, err) require.Equal(t, "The migration directory is synced with the desired state, no changes to be made\n", s) f, err = dir.Open(migrate.HashFileName) require.NoError(t, err) nb, err := io.ReadAll(f) require.NoError(t, err) require.Equal(t, b, nb, "hash file should not be updated") // Update the desired state and run diff again. err = os.WriteFile(state, []byte(`variable "default" { type = string } schema "main" {} table "users" { schema = schema.main column "c" { type = text default = var.default } column "d" { type = text } } `), 0600) require.NoError(t, err) _, err = runCmd( cmd, "diff", "-c", "file://"+cfg, "--env", "local", "--var", "migrations_path="+md, "--var", "schema_path="+state, ) require.NoError(t, err) // Check files. files, err := dir.Files() require.NoError(t, err) require.Len(t, files, 2) require.Equal(t, "1.sql", files[0].Name()) require.Equal(t, `create table users (c text default "{{ .default }}" NOT NULL);`, string(files[0].Bytes()), "should not update template files") require.Equal(t, "-- Add column \"d\" to table: \"users\"\nALTER TABLE `users` ADD COLUMN `d` text NOT NULL;\n", string(files[1].Bytes())) // Ensure the sum file is consistent. f, err = dir.Open(migrate.HashFileName) require.NoError(t, err) before, err := io.ReadAll(f) require.NoError(t, err) cmd = migrateCmd() cmd.AddCommand(migrateHashCmd()) _, err = runCmd( cmd, "hash", "--dir", "file://"+md, ) require.NoError(t, err) f, err = dir.Open(migrate.HashFileName) require.NoError(t, err) after, err := io.ReadAll(f) require.NoError(t, err) require.Equal(t, before, after) }) } func TestMigrate_StatusJSON(t *testing.T) { p := t.TempDir() s, err := runCmd( migrateStatusCmd(), "--dir", "file://"+p, "-u", openSQLite(t, ""), "--format", "{{ json .Env.Driver }}", ) require.NoError(t, err) require.Equal(t, `"sqlite"`, s) } func TestMigrate_Set(t *testing.T) { u := fmt.Sprintf("sqlite://file:%s?_fk=1", filepath.Join(t.TempDir(), "test.db")) _, err := runCmd( migrateApplyCmd(), "--dir", "file://testdata/sqlite", "--url", u, ) require.NoError(t, err) s, err := runCmd( migrateSetCmd(), "--dir", "file://testdata/sqlite", "-u", u, "20220318104614", ) require.NoError(t, err) require.Equal(t, `Current version is 20220318104614 (1 removed): - 20220318104615 (second) `, s) s, err = runCmd( migrateSetCmd(), "--dir", "file://testdata/sqlite", "-u", u, "20220318104615", ) require.NoError(t, err) require.Equal(t, `Current version is 20220318104615 (1 set): + 20220318104615 (second) `, s) s, err = runCmd( migrateSetCmd(), "--dir", "file://testdata/baseline1", "-u", u, ) require.NoError(t, err) require.Equal(t, `Current version is 1 (1 set, 2 removed): + 1 (baseline) - 20220318104614 (initial) - 20220318104615 (second) `, s) s, err = runCmd( migrateSetCmd(), "--dir", filepath.Join("file://", t.TempDir()), // empty dir. "-u", u, ) require.NoError(t, err) require.Equal(t, `All revisions deleted (1 in total): - 1 (baseline) `, s) // Empty database. u = fmt.Sprintf("sqlite://file:%s?_fk=1", filepath.Join(t.TempDir(), "test.db")) _, err = runCmd( migrateSetCmd(), "--dir", "file://testdata/sqlite", "-u", u, ) require.EqualError(t, err, "accepts 1 arg(s), received 0") s, err = runCmd( migrateSetCmd(), "--dir", "file://testdata/sqlite", "-u", u, "20220318104614", ) require.NoError(t, err) require.Equal(t, `Current version is 20220318104614 (1 set): + 20220318104614 (initial) `, s) } func TestMigrate_New(t *testing.T) { var ( p = t.TempDir() v = time.Now().UTC().Format("20060102150405") ) s, err := runCmd(migrateNewCmd(), "--dir", "file://"+p) require.Zero(t, s) require.NoError(t, err) require.FileExists(t, filepath.Join(p, v+".sql")) require.FileExists(t, filepath.Join(p, "atlas.sum")) require.Equal(t, 2, countFiles(t, p)) s, err = runCmd(migrateNewCmd(), "my-migration-file", "--dir", "file://"+p) require.Zero(t, s) require.NoError(t, err) require.FileExists(t, filepath.Join(p, v+"_my-migration-file.sql")) require.FileExists(t, filepath.Join(p, "atlas.sum")) require.Equal(t, 3, countFiles(t, p)) p = t.TempDir() s, err = runCmd(migrateNewCmd(), "golang-migrate", "--dir", "file://"+p, "--dir-format", migrate2.FormatGolangMigrate) require.Zero(t, s) require.NoError(t, err) require.FileExists(t, filepath.Join(p, v+"_golang-migrate.up.sql")) require.FileExists(t, filepath.Join(p, v+"_golang-migrate.down.sql")) require.Equal(t, 3, countFiles(t, p)) p = t.TempDir() s, err = runCmd(migrateNewCmd(), "goose", "--dir", "file://"+p+"?format="+migrate2.FormatGoose) require.Zero(t, s) require.NoError(t, err) require.FileExists(t, filepath.Join(p, v+"_goose.sql")) require.Equal(t, 2, countFiles(t, p)) p = t.TempDir() s, err = runCmd(migrateNewCmd(), "flyway", "--dir", "file://"+p+"?format="+migrate2.FormatFlyway) require.Zero(t, s) require.NoError(t, err) require.FileExists(t, filepath.Join(p, fmt.Sprintf("V%s__%s.sql", v, migrate2.FormatFlyway))) require.FileExists(t, filepath.Join(p, fmt.Sprintf("U%s__%s.sql", v, migrate2.FormatFlyway))) require.Equal(t, 3, countFiles(t, p)) p = t.TempDir() s, err = runCmd(migrateNewCmd(), "liquibase", "--dir", "file://"+p+"?format="+migrate2.FormatLiquibase) require.Zero(t, s) require.NoError(t, err) require.FileExists(t, filepath.Join(p, v+"_liquibase.sql")) require.Equal(t, 2, countFiles(t, p)) p = t.TempDir() s, err = runCmd(migrateNewCmd(), "dbmate", "--dir", "file://"+p+"?format="+migrate2.FormatDBMate) require.Zero(t, s) require.NoError(t, err) require.FileExists(t, filepath.Join(p, v+"_dbmate.sql")) require.Equal(t, 2, countFiles(t, p)) f := filepath.Join("testdata", "mysql", "new.sql") require.NoError(t, os.WriteFile(f, []byte("contents"), 0600)) t.Cleanup(func() { os.Remove(f) }) s, err = runCmd(migrateNewCmd(), "--dir", "file://testdata/mysql") require.NotZero(t, s) require.Error(t, err) t.Run("Edit", func(t *testing.T) { p := t.TempDir() require.NoError(t, os.Setenv("EDITOR", "echo 'contents' >")) t.Cleanup(func() { require.NoError(t, os.Unsetenv("EDITOR")) }) s, err = runCmd(migrateNewCmd(), "--dir", "file://"+p, "--edit") files, err := os.ReadDir(p) require.NoError(t, err) require.Len(t, files, 2) b, err := os.ReadFile(filepath.Join(p, files[0].Name())) require.NoError(t, err) require.Equal(t, "contents\n", string(b)) require.Equal(t, "atlas.sum", files[1].Name()) }) } func TestMigrate_Validate(t *testing.T) { // Without re-playing. s, err := runCmd(migrateValidateCmd(), "--dir", "file://testdata/mysql") require.Zero(t, s) require.NoError(t, err) f := filepath.Join("testdata", "mysql", "new.sql") require.NoError(t, os.WriteFile(f, []byte("contents"), 0600)) t.Cleanup(func() { os.Remove(f) }) s, err = runCmd(migrateValidateCmd(), "--dir", "file://testdata/mysql") 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(migrateHashCmd(), "--dir", "file://"+p) require.NoError(t, err) s, err = runCmd( migrateValidateCmd(), "--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(migrateValidateCmd(), "--dir", "file://testdata/mysql", "--dev-url", openSQLite(t, "")) require.Error(t, err) // Will report detailed information when there is a checksum mismatch. p = t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(p, "1_initial.sql"), []byte("create table t1 (c1 int)"), 0644)) s, err = runCmd(migrateValidateCmd(), "--dir", "file://"+p) require.ErrorIs(t, err, migrate.ErrChecksumNotFound) require.Contains(t, s, "You have a checksum error") _, err = runCmd(migrateHashCmd(), "--dir", "file://"+p) require.NoError(t, err) require.NoError(t, os.WriteFile(filepath.Join(p, "2_second.sql"), []byte("create table t2 (c2 int)"), 0644)) s, err = runCmd(migrateValidateCmd(), "--dir", "file://"+p) csErr := &migrate.ChecksumError{} require.ErrorAs(t, err, &csErr) require.Equal(t, 3, csErr.Line) require.Equal(t, "2_second.sql", csErr.File) require.Equal(t, migrate.ReasonAdded, csErr.Reason) require.Contains(t, s, "You have a checksum error") require.Contains(t, s, "L3: 2_second.sql was added") } func TestMigrate_Hash(t *testing.T) { s, err := runCmd(migrateHashCmd(), "--dir", "file://testdata/mysql") require.Zero(t, s) require.NoError(t, err) // Prints a warning if --force flag is still used. s, err = runCmd(migrateHashCmd(), "--dir", "file://testdata/mysql", "--force") require.NoError(t, err) require.Equal(t, "Flag --force has been deprecated, you can safely omit it.\n", s) p := t.TempDir() err = copyFile(filepath.Join("testdata", "mysql", "20220318104614_initial.sql"), filepath.Join(p, "20220318104614_initial.sql")) require.NoError(t, err) s, err = runCmd(migrateHashCmd(), "--dir", "file://"+p) require.Zero(t, s) require.NoError(t, err) require.FileExists(t, filepath.Join(p, "atlas.sum")) d, err := os.ReadFile(filepath.Join(p, "atlas.sum")) require.NoError(t, err) dir, err := migrate.NewLocalDir(p) require.NoError(t, err) sum, err := dir.Checksum() require.NoError(t, err) b, err := sum.MarshalText() require.NoError(t, err) require.Equal(t, d, b) p = t.TempDir() require.NoError(t, copyFile( filepath.Join("testdata", "mysql", "20220318104614_initial.sql"), filepath.Join(p, "20220318104614_initial.sql"), )) s, err = runCmd(migrateHashCmd(), "--dir", "file://"+os.Getenv("MIGRATION_DIR")) require.NotZero(t, s) require.Error(t, err) } func TestMigrate_Lint(t *testing.T) { p := t.TempDir() s, err := runCmd( migrateLintCmd(), "--dir", "file://"+p, "--dev-url", openSQLite(t, ""), "--latest", "1", ) require.NoError(t, err) require.Empty(t, s) err = os.WriteFile(filepath.Join(p, "1.sql"), []byte("CREATE TABLE t(c int);"), 0600) require.NoError(t, err) err = os.WriteFile(filepath.Join(p, "2.sql"), []byte("DROP TABLE t;"), 0600) require.NoError(t, err) s, err = runCmd( migrateLintCmd(), "--dir", "file://"+p, "--dev-url", openSQLite(t, ""), "--latest", "1", ) require.Error(t, err) require.Regexp(t, `Analyzing changes from version 1 to 2 \(1 migration in total\): -- analyzing version 2 -- destructive changes detected: -- L1: Dropping table "t" https://atlasgo.io/lint/analyzers#DS102 -- suggested fix: -> Add a pre-migration check to ensure table "t" is empty before dropping it -- ok \(.+\) ------------------------- -- .+ -- 1 version with errors -- 1 schema change -- 1 diagnostic `, s) s, err = runCmd( migrateLintCmd(), "--dir", "file://"+p, "--dev-url", openSQLite(t, ""), "--latest", "1", "--log", "{{ range .Files }}{{ .Name }}{{ end }}", // Backward compatibility with old flag name. ) require.Error(t, err) require.Equal(t, "2.sql", s) t.Run("FromConfig", func(t *testing.T) { cfg := filepath.Join(p, "atlas.hcl") err := os.WriteFile(cfg, []byte(` variable "error" { type = bool default = false } lint { latest = 1 destructive { error = var.error } } `), 0600) require.NoError(t, err) cmd := migrateCmd() cmd.AddCommand(migrateLintCmd()) s, err := runCmd( cmd, "lint", "--dir", "file://"+p, "--dev-url", openSQLite(t, ""), "-c", "file://"+cfg, ) require.NoError(t, err) require.Regexp(t, `Analyzing changes from version 1 to 2 \(1 migration in total\): -- analyzing version 2 -- destructive changes detected: -- L1: Dropping table "t" https://atlasgo.io/lint/analyzers#DS102 -- suggested fix: -> Add a pre-migration check to ensure table "t" is empty before dropping it -- ok \(.+\) ------------------------- -- .+ -- 1 version with warnings -- 1 schema change -- 1 diagnostic `, s) cmd = migrateCmd() cmd.AddCommand(migrateLintCmd()) s, err = runCmd( cmd, "lint", "--dir", "file://"+p, "--dev-url", openSQLite(t, ""), "-c", "file://"+cfg, "--var", "error=true", ) require.Error(t, err) require.Regexp(t, `Analyzing changes from version 1 to 2 \(1 migration in total\): -- analyzing version 2 -- destructive changes detected: -- L1: Dropping table "t" https://atlasgo.io/lint/analyzers#DS102 -- suggested fix: -> Add a pre-migration check to ensure table "t" is empty before dropping it -- ok (.+) ------------------------- -- .+ -- 1 version with errors -- 1 schema change -- 1 diagnostic `, s) }) // Change files to golang-migrate format. require.NoError(t, os.Rename(filepath.Join(p, "1.sql"), filepath.Join(p, "1.up.sql"))) require.NoError(t, os.Rename(filepath.Join(p, "2.sql"), filepath.Join(p, "1.down.sql"))) s, err = runCmd( migrateLintCmd(), "--dir", "file://"+p+"?format="+migrate2.FormatGolangMigrate, "--dev-url", openSQLite(t, ""), "--latest", "2", "--format", "{{ range .Files }}{{ .Name }}:{{ len .Reports }}{{ end }}", ) require.NoError(t, err) require.Equal(t, "1.up.sql:0", s) s, err = runCmd( migrateLintCmd(), "--dir", "file://"+p+"?format="+migrate2.FormatGolangMigrate, "--dev-url", openSQLite(t, ""), "--latest", "2", "--format", "{{ range .Files }}{{ .Name }}:{{ len .Reports }}{{ end }}", "--dir-format", migrate2.FormatGolangMigrate, ) require.NoError(t, err) require.Equal(t, "1.up.sql:0", s) // Invalid files. err = os.WriteFile(filepath.Join(p, "2.up.sql"), []byte("BORING"), 0600) require.NoError(t, err) s, err = runCmd( migrateLintCmd(), "--dir", "file://"+p+"?format="+migrate2.FormatGolangMigrate, "--dev-url", openSQLite(t, ""), "--latest", "1", ) require.Error(t, err) require.Regexp(t, `Analyzing changes from version 1.up to 2.up \(1 migration in total\): Error: executing statement: BORING: near "BORING": syntax error ------------------------- -- .+ -- 1 version with errors `, s) } const testSchema = ` schema "main" { } table "table" { schema = schema.main column "col" { type = int comment = "column comment" } column "age" { type = int } column "price1" { type = int } column "price2" { type = int } column "account_name" { type = varchar(32) null = true } column "created_at" { type = datetime default = sql("current_timestamp") } primary_key { columns = [table.table.column.col] } index "index" { unique = true columns = [ table.table.column.col, table.table.column.age, ] } foreign_key "accounts" { columns = [ table.table.column.account_name, ] ref_columns = [ table.accounts.column.name, ] on_delete = SET_NULL on_update = "NO_ACTION" } check "positive price" { expr = "price1 > 0" } check { expr = "price1 <> price2" } check { expr = "price2 <> price1" } comment = "table comment" } table "accounts" { schema = schema.main column "name" { type = varchar(32) } column "unsigned_float" { type = float(10) unsigned = true } column "unsigned_decimal" { type = decimal(10, 2) unsigned = true } primary_key { columns = [table.accounts.column.name] } }` func hclURL(t *testing.T) string { p := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(p, "schema.hcl"), []byte(testSchema), 0600)) return "file://" + filepath.Join(p, "schema.hcl") } func copyFile(src, dst string) error { sf, err := os.Open(src) if err != nil { return err } defer sf.Close() df, err := os.Create(dst) if err != nil { return err } defer df.Close() _, err = io.Copy(df, sf) return err } type sqliteLockerDriver struct{ migrate.Driver } var errLock = errors.New("lockErr") func (d *sqliteLockerDriver) Lock(context.Context, string, time.Duration) (schema.UnlockFunc, error) { return func() error { return nil }, errLock } func countFiles(t *testing.T, p string) int { files, err := os.ReadDir(p) require.NoError(t, err) return len(files) } func sed(t *testing.T, r, p string) { args := []string{"-i"} if runtime.GOOS == "darwin" { args = append(args, ".bk") } buf, err := exec.Command("sed", append(args, r, p)...).CombinedOutput() require.NoError(t, err, string(buf)) } func lines(f migrate.File) []string { return strings.Split(strings.TrimSpace(string(f.Bytes())), "\n") } ================================================ FILE: cmd/atlas/internal/cmdapi/project.go ================================================ // Copyright 2021-present The Atlas Authors. All rights reserved. // This source code is licensed under the Apache 2.0 license found // in the LICENSE file in the root directory of this source tree. package cmdapi import ( "context" "fmt" "net/url" "os" "path/filepath" "reflect" "strings" "sync" "ariga.io/atlas/cmd/atlas/internal/cloudapi" "ariga.io/atlas/cmd/atlas/internal/cmdext" cmdmigrate "ariga.io/atlas/cmd/atlas/internal/migrate" "ariga.io/atlas/schemahcl" "ariga.io/atlas/sql/schema" "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/hclparse" "github.com/hashicorp/hcl/v2/hclsyntax" "github.com/spf13/cobra" "github.com/zclconf/go-cty/cty" "github.com/zclconf/go-cty/cty/function" ) type ( // Env represents an Atlas environment. Env struct { // Name for this environment. Name string `spec:"name,name"` // URL of the database. URL string `spec:"url"` // URL of the dev-database for this environment. // See: https://atlasgo.io/dev-database DevURL string `spec:"dev"` // List of schemas in this database that are managed by Atlas. Schemas []string `spec:"schemas"` // Exclude defines a list of glob patterns used to filter // resources on inspection. Exclude []string `spec:"exclude"` // Include defines a list of glob patterns used to keep // resources on inspection. Include []string `spec:"include"` // Schema containing the schema configuration of the env. Schema *Schema `spec:"schema"` // Migration containing the migration configuration of the env. Migration *Migration `spec:"migration"` // Diff policy of the environment. Diff *Diff `spec:"diff"` // Lint policy of the environment. Lint *Lint `spec:"lint"` // Format of the environment. Format Format `spec:"format"` // Test configuration of the environment. Test *Test `spec:"test"` schemahcl.DefaultExtension cloud *cmdext.AtlasConfig config *Project } // Migration represents the migration directory for the Env. Migration struct { Dir string `spec:"dir"` Exclude []string `spec:"exclude"` Format string `spec:"format"` Baseline string `spec:"baseline"` ExecOrder string `spec:"exec_order"` LockTimeout string `spec:"lock_timeout"` RevisionsSchema string `spec:"revisions_schema"` Repo *Repo `spec:"repo"` } // Schema represents a schema in the registry. Schema struct { // The extension holds the "src" attribute. // It can be a string, or a list of strings. schemahcl.DefaultExtension Repo *Repo `spec:"repo"` } // Repo represents a repository in the schema registry // for a schema or migrations directory. Repo struct { Name string `spec:"name"` // Name of the repository. } // Lint represents the configuration of migration linting. Lint struct { // Format configures the --format option. Format string `spec:"log"` // Latest configures the --latest option. Latest int `spec:"latest"` Git struct { // Dir configures the --git-dir option. Dir string `spec:"dir"` // Base configures the --git-base option. Base string `spec:"base"` } `spec:"git"` // Review defines when Atlas will ask the user to review and approve the changes. Review string `spec:"review"` schemahcl.DefaultExtension } // Diff represents the schema diffing policy. Diff struct { // SkipChanges configures the skip changes policy. SkipChanges *SkipChanges `spec:"skip"` schemahcl.DefaultExtension } // Test represents the test configuration of a project or environment. Test struct { // Schema represents the 'schema test' configuration. Schema struct { Src []string `spec:"src"` Vars Vars `spec:"vars"` } `spec:"schema"` // Migrate represents the 'migrate test' configuration. Migrate struct { Src []string `spec:"src"` Vars Vars `spec:"vars"` } `spec:"migrate"` } // SkipChanges represents the skip changes policy. SkipChanges struct { AddSchema bool `spec:"add_schema"` DropSchema bool `spec:"drop_schema"` ModifySchema bool `spec:"modify_schema"` AddTable bool `spec:"add_table"` DropTable bool `spec:"drop_table"` ModifyTable bool `spec:"modify_table"` RenameTable bool `spec:"rename_table"` AddColumn bool `spec:"add_column"` DropColumn bool `spec:"drop_column"` ModifyColumn bool `spec:"modify_column"` AddIndex bool `spec:"add_index"` DropIndex bool `spec:"drop_index"` ModifyIndex bool `spec:"modify_index"` AddForeignKey bool `spec:"add_foreign_key"` DropForeignKey bool `spec:"drop_foreign_key"` ModifyForeignKey bool `spec:"modify_foreign_key"` AddView bool `spec:"add_view"` DropView bool `spec:"drop_view"` ModifyView bool `spec:"modify_view"` RenameView bool `spec:"rename_view"` AddFunc bool `spec:"add_func"` DropFunc bool `spec:"drop_func"` ModifyFunc bool `spec:"modify_func"` RenameFunc bool `spec:"rename_func"` AddProc bool `spec:"add_proc"` DropProc bool `spec:"drop_proc"` ModifyProc bool `spec:"modify_proc"` RenameProc bool `spec:"rename_proc"` AddTrigger bool `spec:"add_trigger"` DropTrigger bool `spec:"drop_trigger"` ModifyTrigger bool `spec:"modify_trigger"` RenameTrigger bool `spec:"rename_trigger"` RenameConstraint bool `spec:"rename_constraint"` schemahcl.DefaultExtension } // Format represents the output formatting configuration of an environment. Format struct { Migrate struct { // Apply configures the formatting for 'migrate apply'. Apply string `spec:"apply"` // Down configures the formatting for 'migrate down'. Down string `spec:"down"` // Lint configures the formatting for 'migrate lint'. Lint string `spec:"lint"` // Status configures the formatting for 'migrate status'. Status string `spec:"status"` // Apply configures the formatting for 'migrate diff'. Diff string `spec:"diff"` } `spec:"migrate"` Schema struct { // Clean configures the formatting for 'schema clean'. Clean string `spec:"clean"` // Inspect configures the formatting for 'schema inspect'. Inspect string `spec:"inspect"` // Apply configures the formatting for 'schema apply'. Apply string `spec:"apply"` // Apply configures the formatting for 'schema diff'. Diff string `spec:"diff"` // Push configures the formatting for 'schema push'. Push string `spec:"push"` } `spec:"schema"` schemahcl.DefaultExtension } ) // envScheme defines the scheme that can be used to reference env attributes. const envAttrScheme = "env" // MigrationRepo returns the migration repository name, if set. func (e *Env) MigrationRepo() (s string) { if e != nil && e.Migration != nil && e.Migration.Repo != nil { s = e.Migration.Repo.Name } return } // MigrationExclude returns the exclusion patterns of the migration directory. func (e *Env) MigrationExclude() []string { if e != nil && e.Migration != nil { return e.Migration.Exclude } return nil } // SchemaRepo returns the desired schema repository name, if set. func (e *Env) SchemaRepo() (s string) { if e != nil && e.Schema != nil && e.Schema.Repo != nil { s = e.Schema.Repo.Name } return } // LintReview returns the review mode for the lint command. func (e *Env) LintReview() string { if e != nil && e.Lint != nil && e.Lint.Review != "" { return e.Lint.Review } return ReviewAlways } // VarFromURL returns the string variable (env attribute) from the URL. func (e *Env) VarFromURL(s string) (string, error) { u, err := url.Parse(s) if err != nil { return "", err } if u.Host == "" || u.Path != "" || u.RawQuery != "" { return "", fmt.Errorf("invalid env:// variable %q", s) } var sv string switch u.Host { case "url": sv = e.URL case "dev": sv = e.DevURL case "src", "schema.src": var ( ok bool attr *schemahcl.Attr ) switch { case u.Host == "src": attr, ok = e.Attr("src") case e.Schema != nil: attr, ok = e.Schema.Attr("src") } if !ok { return "", fmt.Errorf("env://%s: no src attribute defined in env %q", u.Host, e.Name) } switch attr.V.Type() { case cty.String: s, err := attr.String() if err != nil { return "", fmt.Errorf("env://%s: %w", u.Host, err) } return s, nil case cty.List(cty.String): vs, err := attr.Strings() if err != nil { return "", fmt.Errorf("env://%s: %w", u.Host, err) } if len(vs) != 0 { return "", fmt.Errorf("env://%s: expect one schema in env %q, got %d", u.Host, e.Name, len(vs)) } return vs[0], nil default: return "", fmt.Errorf("env://%s: src attribute must be a string or list of strings, got %s", u.Host, attr.V.Type().FriendlyName()) } case "migration.dir": if e.Migration == nil || e.Migration.Dir == "" { return "", fmt.Errorf("env://%s: no migration dir defined in env %q", u.Host, e.Name) } sv = e.Migration.Dir default: attr, ok := e.Attr(u.Host) if !ok { return "", fmt.Errorf("env://%s (attribute) not found in env.%s", u.Host, e.Name) } if sv, err = attr.String(); err != nil { return "", fmt.Errorf("env://%s: %w", u.Host, err) } } if strings.HasPrefix(sv, envAttrScheme+"://") { return "", fmt.Errorf("env://%s (attribute) cannot reference another env://", s) } return sv, nil } // support backward compatibility with the 'log' attribute. func (e *Env) remainedLog() error { r, ok := e.Remain().Resource("log") if ok { return r.As(&e.Format) } return nil } // Extend allows extending environment blocks with // a global one. For example: // // lint { // log = < 0 { opts = append(opts, schema.DiffSkipChanges(changes...)) } return opts } // DiffOptions returns the diff options configured for the environment, // or nil if no environment or diff policy were set. func (e *Env) DiffOptions() []schema.DiffOption { if e == nil || e.Diff == nil { return nil } return e.Diff.Options() } // Sources returns the paths containing the Atlas desired schema. // The "src" attribute predates the "schema" block. If the "schema" // is defined, it takes precedence over the "src" attribute. func (e *Env) Sources() ([]string, error) { var ( ok bool attr *schemahcl.Attr ) if attr, ok = e.Attr("src"); !ok && e.Schema != nil { attr, ok = e.Schema.Attr("src") } if !ok { return nil, nil } switch attr.V.Type() { case cty.String: s, err := attr.String() if err != nil { return nil, err } return []string{s}, nil case cty.List(cty.String): return attr.Strings() default: return nil, fmt.Errorf("expected src to be either a string or strings, got: %s", attr.V.Type().FriendlyName()) } } // Vars returns the extra attributes stored in the Env as a map[string]cty.Value. func (e *Env) Vars() map[string]cty.Value { m := make(map[string]cty.Value, len(e.Extra.Attrs)) for _, attr := range e.Extra.Attrs { if attr.K == "src" { continue } m[attr.K] = attr.V } // For backward compatibility, we append the GlobalFlags as variables. for k, v := range GlobalFlags.Vars { if _, ok := m[k]; !ok { m[k] = v } } return m } // Extend allows extending environment blocks with // a global one. For example: // // test { // schema { // src = [...] // } // } // // env "local" { // ... // test { // schema { // src = [...] // } // } // } func (t *Test) Extend(global *Test) *Test { if t == nil { return global } return t } // EnvByName parses and returns the project configuration with selected environments. func EnvByName(cmd *cobra.Command, name string, vars map[string]cty.Value) (*Project, []*Env, error) { envs := make(map[string][]*Env) defer func() { setEnvs(cmd.Context(), envs[name]) }() if p, e, ok := envsCache.load(GlobalFlags.ConfigURL, name, vars); ok { return p, e, maySetLoginContext(cmd, p) } u, err := url.Parse(GlobalFlags.ConfigURL) if err != nil { return nil, nil, err } switch { case u.Scheme == "": return nil, nil, fmt.Errorf("missing scheme for config file. Did you mean file://%s?", u) case u.Scheme != "file": return nil, nil, fmt.Errorf("unsupported config file driver %q", u.Scheme) } path := filepath.Join(u.Host, u.Path) if _, err := os.Stat(path); err != nil { if os.IsNotExist(err) { err = fmt.Errorf("config file %q was not found: %w", path, err) } return nil, nil, err } project, err := parseConfig(cmd.Context(), path, name, vars) if err != nil { return nil, nil, err } // The atlas.hcl token predates 'atlas login' command. If exists, // attach it to the context to indicate the user is authenticated. if err := maySetLoginContext(cmd, project); err != nil { return nil, nil, err } if err := project.Lint.remainedLog(); err != nil { return nil, nil, err } for _, e := range project.Envs { if e.Name == "" { return nil, nil, fmt.Errorf("all envs must have names on file %q", path) } if _, err := e.Sources(); err != nil { return nil, nil, err } if e.Migration == nil { e.Migration = &Migration{} } if err := e.remainedLog(); err != nil { return nil, nil, err } e.Diff = e.Diff.Extend(project.Diff) e.Lint = e.Lint.Extend(project.Lint) if err := e.Lint.remainedLog(); err != nil { return nil, nil, err } e.Test = e.Test.Extend(project.Test) envs[e.Name] = append(envs[e.Name], e) } envsCache.store(GlobalFlags.ConfigURL, name, vars, project, envs[name]) switch { case name == "": // If no env was selected, // return only the project. return project, nil, nil case len(envs[name]) == 0: return nil, nil, fmt.Errorf("env %q not defined in config file", name) default: return project, envs[name], nil } } type ( envCacheK struct { path, env, vars string } envCacheV struct { p *Project e []*Env } envCache struct { sync.RWMutex m map[envCacheK]envCacheV } ) var envsCache = &envCache{m: make(map[envCacheK]envCacheV)} func (c *envCache) load(path, env string, vars Vars) (*Project, []*Env, bool) { c.RLock() v, ok := c.m[envCacheK{path: path, env: env, vars: vars.String()}] c.RUnlock() return v.p, v.e, ok } func (c *envCache) store(path, env string, vars Vars, p *Project, e []*Env) { c.Lock() c.m[envCacheK{path: path, env: env, vars: vars.String()}] = envCacheV{p: p, e: e} c.Unlock() } const ( blockEnv = "env" refAtlas = "atlas" defaultConfigPath = "file://atlas.hcl" ) func parseConfig(ctx context.Context, path, env string, vars map[string]cty.Value) (*Project, error) { pr, err := partialParse(path, env) if err != nil { return nil, err } base, err := filepath.Abs(filepath.Dir(path)) if err != nil { return nil, err } cloud := &cmdext.AtlasConfig{ Project: cloudapi.DefaultProjectName, } state := schemahcl.New( append( append(cmdext.SpecOptions, specOptions...), cloud.InitBlock(), schemahcl.WithContext(ctx), schemahcl.WithScopedEnums("env.migration.format", cmdmigrate.Formats...), schemahcl.WithScopedEnums("env.migration.exec_order", "LINEAR", "LINEAR_SKIP", "NON_LINEAR"), schemahcl.WithScopedEnums("env.lint.review", ReviewModes...), schemahcl.WithScopedEnums("lint.review", ReviewModes...), schemahcl.WithVariables(map[string]cty.Value{ refAtlas: cty.ObjectVal(map[string]cty.Value{ blockEnv: cty.StringVal(env), }), }), schemahcl.WithFunctions(map[string]function.Function{ "file": schemahcl.MakeFileFunc(base), "glob": schemahcl.MakeGlobFunc(base), "fileset": schemahcl.MakeFileSetFunc(base), "getenv": getEnvFunc, }), )..., ) p := &Project{Lint: &Lint{}, Diff: &Diff{}, cloud: cloud} if err := state.Eval(pr, p, vars); err != nil { return nil, err } for _, e := range p.Envs { e.config, e.cloud = p, cloud } return p, nil } func init() { cloudapi.SetVersion(version, flavor) schemahcl.Register(blockEnv, &Env{}) } func partialParse(path, env string) (*hclparse.Parser, error) { parser := hclparse.NewParser() fi, err := parser.ParseHCLFile(path) if err != nil { return nil, err } var labeled, nonlabeled, used []*hclsyntax.Block for _, b := range fi.Body.(*hclsyntax.Body).Blocks { switch b.Type { case blockEnv: switch n := len(b.Labels); { // No env was selected. case env == "" && n == 0: // Exact env was selected. case n == 1 && b.Labels[0] == env: labeled = append(labeled, b) // Dynamic env selection. case n == 0 && b.Body != nil && b.Body.Attributes[schemahcl.AttrName] != nil: x := b.Body.Attributes[schemahcl.AttrName].Expr if x != nil && schemahcl.UseTraversal(x, hcl.Traversal{ hcl.TraverseRoot{Name: refAtlas}, hcl.TraverseAttr{Name: blockEnv}, }) { nonlabeled = append(nonlabeled, b) } } default: used = append(used, b) } } // Labeled blocks take precedence // over non-labeled env blocks. switch { case len(labeled) > 0: used = append(used, labeled...) case len(nonlabeled) > 0: used = append(used, nonlabeled...) } fi.Body = &hclsyntax.Body{ Blocks: used, Attributes: fi.Body.(*hclsyntax.Body).Attributes, } return parser, nil } // Review modes for 'schema apply'. const ( ReviewAlways = "ALWAYS" // Always review changes. The default mode. ReviewWarning = "WARNING" // Review changes only if there are any diagnostics (including warnings). ReviewError = "ERROR" // Review changes only if there are severe diagnostics (error level). ) var ReviewModes = []string{ReviewAlways, ReviewWarning, ReviewError} // getEnvFunc is a custom HCL function that returns // the value of an environment variable. var getEnvFunc = function.New(&function.Spec{ Params: []function.Parameter{ { Name: "key", Type: cty.String, }, }, Type: function.StaticReturnType(cty.String), Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { return cty.StringVal(os.Getenv(args[0].AsString())), nil }, }) ================================================ FILE: cmd/atlas/internal/cmdapi/project_test.go ================================================ // Copyright 2021-present The Atlas Authors. All rights reserved. // This source code is licensed under the Apache 2.0 license found // in the LICENSE file in the root directory of this source tree. package cmdapi import ( "context" "fmt" "os" "path/filepath" "sort" "testing" "ariga.io/atlas/cmd/atlas/internal/cloudapi" "ariga.io/atlas/cmd/atlas/internal/cmdext" cmdmigrate "ariga.io/atlas/cmd/atlas/internal/migrate" "ariga.io/atlas/schemahcl" "ariga.io/atlas/sql/schema" "github.com/spf13/cobra" "github.com/stretchr/testify/require" "github.com/zclconf/go-cty/cty" ) func TestEnvByName(t *testing.T) { d := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(d, "local.txt"), []byte("text"), 0600)) h := ` variable "name" { type = string default = "hello" } locals { envName = atlas.env emptyEnv = getenv("NOT_SET") opened = file("local.txt") } lint { review = ERROR destructive { error = true } // Backwards compatibility with old attribute. log = <