diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1e285be..56a35e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,3 +35,69 @@ jobs: - run: go build ./... - run: go test ./... -v -race -count=1 - run: go vet ./... + + strict-contracts: + name: Validate strict plugin contracts + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + - name: Validate strict plugin contracts + run: go run github.com/GoCodeAlone/workflow/cmd/wfctl@v0.20.1 plugin validate --file plugin.json --strict-contracts + - name: Build plugin binaries + run: | + set -euo pipefail + mkdir -p dist + go build -o dist/workflow-plugin-migrations ./cmd/workflow-plugin-migrations + go build -o dist/workflow-plugin-atlas-migrate ./cmd/workflow-plugin-atlas-migrate + - name: Build packaged plugin artifacts + run: | + set -euo pipefail + + main_package="$(mktemp -d)" + cp plugin.json plugin.contracts.json LICENSE "$main_package" + cp dist/workflow-plugin-migrations "$main_package/" + sed -i.bak \ + -e 's/"version": ".*"/"version": "0.3.6-SNAPSHOT-ci"/' \ + -e 's|/releases/download/v[0-9][^/]*/|/releases/download/v0.3.6-SNAPSHOT-ci/|g' \ + "$main_package/plugin.json" + rm -f "$main_package/plugin.json.bak" + tar -C "$main_package" -czf dist/workflow-plugin-migrations-linux-amd64.tar.gz workflow-plugin-migrations plugin.json plugin.contracts.json LICENSE + + atlas_package="$(mktemp -d)" + cp plugin.atlas.json "$atlas_package/plugin.json" + cp plugin.atlas.contracts.json "$atlas_package/plugin.contracts.json" + cp dist/workflow-plugin-atlas-migrate "$atlas_package/" + cp LICENSE "$atlas_package" + sed -i.bak \ + -e 's/"version": ".*"/"version": "0.3.6-SNAPSHOT-ci"/' \ + -e 's|/releases/download/v[0-9][^/]*/|/releases/download/v0.3.6-SNAPSHOT-ci/|g' \ + "$atlas_package/plugin.json" + rm -f "$atlas_package/plugin.json.bak" + tar -C "$atlas_package" -czf dist/workflow-plugin-atlas-migrate-linux-amd64.tar.gz workflow-plugin-atlas-migrate plugin.json plugin.contracts.json LICENSE + - name: Validate packaged plugin artifact + run: | + set -euo pipefail + main_archive="$(find dist -name 'workflow-plugin-migrations-linux-amd64.tar.gz' -print -quit)" + test -n "$main_archive" + tmp="$(mktemp -d)" + tar -xzf "$main_archive" -C "$tmp" + test -f "$tmp/workflow-plugin-migrations" + test -f "$tmp/plugin.json" + test -f "$tmp/plugin.contracts.json" + grep -q '/releases/download/v0.3.6-SNAPSHOT-ci/' "$tmp/plugin.json" + go run github.com/GoCodeAlone/workflow/cmd/wfctl@v0.20.1 plugin validate --file "$tmp/plugin.json" --strict-contracts + + atlas_archive="$(find dist -name 'workflow-plugin-atlas-migrate-linux-amd64.tar.gz' -print -quit)" + test -n "$atlas_archive" + atlas_tmp="$(mktemp -d)" + tar -xzf "$atlas_archive" -C "$atlas_tmp" + test -f "$atlas_tmp/workflow-plugin-atlas-migrate" + test -f "$atlas_tmp/plugin.json" + test -f "$atlas_tmp/plugin.contracts.json" + grep -q '/releases/download/v0.3.6-SNAPSHOT-ci/' "$atlas_tmp/plugin.json" + go run github.com/GoCodeAlone/workflow/cmd/wfctl@v0.20.1 plugin validate --file "$atlas_tmp/plugin.json" --strict-contracts diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 5803656..7a4e4ee 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -3,7 +3,7 @@ version: 2 before: hooks: - go mod tidy - - "sed -i.bak 's/\"version\": \".*\"/\"version\": \"{{ .Version }}\"/' plugin.json && rm -f plugin.json.bak" + - "for manifest in plugin.json plugin.atlas.json; do sed -i.bak -e 's/\"version\": \".*\"/\"version\": \"{{ .Version }}\"/' -e 's|/releases/download/v[0-9][^/]*/|/releases/download/v{{ .Version }}/|g' \"$manifest\" && rm -f \"$manifest.bak\"; done" builds: # Main plugin binary: golang-migrate + goose + wfctl CLI @@ -63,6 +63,7 @@ archives: name_template: "workflow-plugin-migrations-{{ .Os }}-{{ .Arch }}" files: - plugin.json + - plugin.contracts.json - LICENSE - id: workflow-plugin-atlas-migrate @@ -70,7 +71,10 @@ archives: formats: [tar.gz] name_template: "workflow-plugin-atlas-migrate-{{ .Os }}-{{ .Arch }}" files: - - plugin.json + - src: plugin.atlas.json + dst: plugin.json + - src: plugin.atlas.contracts.json + dst: plugin.contracts.json - LICENSE - id: workflow-migrate diff --git a/go.mod b/go.mod index 9905d85..cd3780d 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/jackc/pgx/v5 v5.9.2 github.com/pressly/goose/v3 v3.27.0 github.com/spf13/cobra v1.10.2 + gopkg.in/yaml.v3 v3.0.1 ) require ( @@ -205,7 +206,6 @@ require ( google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d // indirect google.golang.org/grpc v1.80.0 // indirect google.golang.org/protobuf v1.36.11 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect modernc.org/libc v1.70.0 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/internal/atlasplugin/modules.go b/internal/atlasplugin/modules.go index 574c1db..d1b9fb2 100644 --- a/internal/atlasplugin/modules.go +++ b/internal/atlasplugin/modules.go @@ -82,7 +82,7 @@ func (m *atlasMigrationsModule) InvokeMethod(method string, args map[string]any) if err != nil { return nil, err } - return map[string]any{"applied": result.Applied, "duration_ms": result.DurationMs}, nil + return map[string]any{"applied": result.Applied, "skipped": result.Skipped, "duration_ms": result.DurationMs}, nil case "down": steps := 1 if v, ok := args["steps"].(int); ok { @@ -93,7 +93,7 @@ func (m *atlasMigrationsModule) InvokeMethod(method string, args map[string]any) if err != nil { return nil, err } - return map[string]any{"applied": result.Applied, "duration_ms": result.DurationMs}, nil + return map[string]any{"applied": result.Applied, "skipped": result.Skipped, "duration_ms": result.DurationMs}, nil case "status": st, err := d.Status(ctx, req) if err != nil { @@ -109,7 +109,7 @@ func (m *atlasMigrationsModule) InvokeMethod(method string, args map[string]any) if err != nil { return nil, err } - return map[string]any{"applied": result.Applied, "duration_ms": result.DurationMs}, nil + return map[string]any{"applied": result.Applied, "skipped": result.Skipped, "duration_ms": result.DurationMs}, nil default: return nil, fmt.Errorf("database.migrations %q: unknown method %q", m.name, method) } @@ -125,7 +125,7 @@ func (m *driverModule) Start(_ context.Context) error { return nil } func (m *driverModule) Stop(_ context.Context) error { return nil } func (m *driverModule) InvokeMethod(method string, _ map[string]any) (map[string]any, error) { if method == "driver_name" { - return map[string]any{"name": m.driver.Name()}, nil + return map[string]any{"driver": m.driver.Name()}, nil } return nil, fmt.Errorf("database.migration_driver: unknown method %q", method) } diff --git a/internal/contracts.go b/internal/contracts.go new file mode 100644 index 0000000..3fe47c8 --- /dev/null +++ b/internal/contracts.go @@ -0,0 +1,386 @@ +package internal + +import ( + "github.com/GoCodeAlone/workflow/dynamic" + sdk "github.com/GoCodeAlone/workflow/plugin/external/sdk" + "github.com/GoCodeAlone/workflow/schema" +) + +// Proto message names for the wfctl strict-contract descriptors. +// These are the canonical fully-qualified proto type names referenced by +// plugin.contracts.json and plugin.atlas.contracts.json. Keeping them here +// as exported constants gives the drift tests a single source of truth so that +// renaming a message requires updating contracts.go, the JSON file, and—if the +// constant is not updated—the test will flag the mismatch immediately. +const ( + ProtoMigrationsModuleConfig = "workflow.plugins.migrations.MigrationsModuleConfig" + ProtoMigrationDriverConfig = "workflow.plugins.migrations.MigrationDriverConfig" + ProtoMigrateUpInput = "workflow.plugins.migrations.MigrateUpInput" + ProtoMigrateUpOutput = "workflow.plugins.migrations.MigrateUpOutput" + ProtoMigrateDownInput = "workflow.plugins.migrations.MigrateDownInput" + ProtoMigrateDownOutput = "workflow.plugins.migrations.MigrateDownOutput" + ProtoMigrateStatusInput = "workflow.plugins.migrations.MigrateStatusInput" + ProtoMigrateStatusOutput = "workflow.plugins.migrations.MigrateStatusOutput" + ProtoMigrateToInput = "workflow.plugins.migrations.MigrateToInput" + ProtoMigrateToOutput = "workflow.plugins.migrations.MigrateToOutput" +) + +// moduleContracts returns field contracts for each module type provided by this plugin. +// A contract describes the required/optional config inputs and outputs for a module type +// so that the Workflow engine and audit tooling can validate configurations statically. +func moduleContracts() map[string]*dynamic.FieldContract { + return map[string]*dynamic.FieldContract{ + "database.migrations": migrationsModuleContract(), + "database.migration_driver": migrationDriverModuleContract(), + } +} + +// stepContracts returns field contracts for each step type provided by this plugin. +func stepContracts() map[string]*dynamic.FieldContract { + return map[string]*dynamic.FieldContract{ + "step.migrate_up": migrateUpContract(), + "step.migrate_down": migrateDownContract(), + "step.migrate_status": migrateStatusContract(), + "step.migrate_to": migrateToContract(), + } +} + +// migrationsModuleContract returns the field contract for the database.migrations module. +func migrationsModuleContract() *dynamic.FieldContract { + c := dynamic.NewFieldContract() + c.RequiredInputs["source_dir"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeString, + Description: "Directory containing migration files", + } + c.OptionalInputs["driver"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeString, + Description: "Migration driver: golang-migrate (default) or goose", + Default: "golang-migrate", + } + c.OptionalInputs["driver_ref"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeString, + Description: "Literal driver name fallback (module-ref lookup is not yet implemented; treated as a driver name string)", + } + c.OptionalInputs["dsn_env"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeString, + Description: "Environment variable name holding the database DSN", + Default: "DATABASE_URL", + } + c.OptionalInputs["history_table"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeString, + Description: "Migration history table name (reserved for future driver support; currently has no effect at runtime)", + } + c.OptionalInputs["timeout"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeString, + Description: "Operation timeout as a Go duration string (e.g. 5m)", + Default: "5m", + } + return c +} + +// migrationDriverModuleContract returns the field contract for the database.migration_driver module. +func migrationDriverModuleContract() *dynamic.FieldContract { + c := dynamic.NewFieldContract() + c.RequiredInputs["driver"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeString, + Description: "Driver name: golang-migrate, goose, or atlas", + } + c.Outputs["driver"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeString, + Description: "Resolved driver name (returned by the driver_name method)", + } + return c +} + +// migrateUpContract returns the field contract for step.migrate_up. +func migrateUpContract() *dynamic.FieldContract { + c := dynamic.NewFieldContract() + c.RequiredInputs["source_dir"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeString, + Description: "Directory containing migration files", + } + c.OptionalInputs["driver"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeString, + Description: "Migration driver: golang-migrate (default) or goose", + Default: "golang-migrate", + } + c.OptionalInputs["dsn_env"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeString, + Description: "Environment variable name holding the database DSN", + Default: "DATABASE_URL", + } + c.OptionalInputs["dsn"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeString, + Description: "Literal database DSN (overrides dsn_env)", + } + c.OptionalInputs["timeout"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeString, + Description: "Operation timeout as a Go duration string (e.g. 5m)", + Default: "5m", + } + c.Outputs["applied"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeInt, + Description: "Number of migrations applied", + } + c.Outputs["skipped"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeInt, + Description: "Number of migrations skipped (already applied)", + } + c.Outputs["duration_ms"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeInt, + Description: "Total execution time in milliseconds", + } + return c +} + +// migrateDownContract returns the field contract for step.migrate_down. +func migrateDownContract() *dynamic.FieldContract { + c := dynamic.NewFieldContract() + c.RequiredInputs["source_dir"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeString, + Description: "Directory containing migration files", + } + c.OptionalInputs["driver"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeString, + Description: "Migration driver: golang-migrate (default) or goose", + Default: "golang-migrate", + } + c.OptionalInputs["dsn_env"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeString, + Description: "Environment variable name holding the database DSN", + Default: "DATABASE_URL", + } + c.OptionalInputs["dsn"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeString, + Description: "Literal database DSN (overrides dsn_env)", + } + c.OptionalInputs["timeout"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeString, + Description: "Operation timeout as a Go duration string (e.g. 5m)", + Default: "5m", + } + c.OptionalInputs["steps"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeInt, + Description: "Number of migrations to roll back", + Default: 1, + } + c.Outputs["applied"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeInt, + Description: "Number of migrations rolled back", + } + c.Outputs["skipped"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeInt, + Description: "Number of migrations skipped", + } + c.Outputs["duration_ms"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeInt, + Description: "Total execution time in milliseconds", + } + return c +} + +// migrateStatusContract returns the field contract for step.migrate_status. +func migrateStatusContract() *dynamic.FieldContract { + c := dynamic.NewFieldContract() + c.RequiredInputs["source_dir"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeString, + Description: "Directory containing migration files", + } + c.OptionalInputs["driver"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeString, + Description: "Migration driver: golang-migrate (default) or goose", + Default: "golang-migrate", + } + c.OptionalInputs["dsn_env"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeString, + Description: "Environment variable name holding the database DSN", + Default: "DATABASE_URL", + } + c.OptionalInputs["dsn"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeString, + Description: "Literal database DSN (overrides dsn_env)", + } + c.OptionalInputs["timeout"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeString, + Description: "Operation timeout as a Go duration string (e.g. 5m)", + Default: "5m", + } + c.Outputs["current"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeString, + Description: "Current applied migration version", + } + c.Outputs["pending"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeInt, + Description: "Number of pending (unapplied) migrations", + } + c.Outputs["dirty"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeBool, + Description: "Whether the database is in a dirty migration state", + } + return c +} + +// migrateToContract returns the field contract for step.migrate_to. +func migrateToContract() *dynamic.FieldContract { + c := dynamic.NewFieldContract() + c.RequiredInputs["source_dir"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeString, + Description: "Directory containing migration files", + } + c.RequiredInputs["target"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeString, + Description: "Target migration version to migrate to", + } + c.OptionalInputs["driver"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeString, + Description: "Migration driver: golang-migrate (default) or goose", + Default: "golang-migrate", + } + c.OptionalInputs["dsn_env"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeString, + Description: "Environment variable name holding the database DSN", + Default: "DATABASE_URL", + } + c.OptionalInputs["dsn"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeString, + Description: "Literal database DSN (overrides dsn_env)", + } + c.OptionalInputs["timeout"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeString, + Description: "Operation timeout as a Go duration string (e.g. 5m)", + Default: "5m", + } + c.Outputs["applied"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeInt, + Description: "Number of migrations applied or rolled back to reach the target", + } + c.Outputs["skipped"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeInt, + Description: "Number of migrations skipped", + } + c.Outputs["duration_ms"] = dynamic.FieldSpec{ + Type: dynamic.FieldTypeInt, + Description: "Total execution time in milliseconds", + } + return c +} + +// PluginModuleSchemas returns the module schema data for all module types +// provided by this plugin. This is consumed by the Workflow SDK SchemaProvider +// interface and by wfctl editor schema generation. +func PluginModuleSchemas() []sdk.ModuleSchemaData { + return []sdk.ModuleSchemaData{ + { + Type: "database.migrations", + Label: "Database Migrations", + Category: "database", + Description: "Manages database migrations using golang-migrate or goose drivers. Provides up/down/status/goto operations.", + ConfigFields: []sdk.ConfigField{ + {Name: "source_dir", Type: "string", Description: "Directory containing migration files", Required: true}, + {Name: "driver", Type: "string", Description: "Migration driver: golang-migrate (default) or goose", DefaultValue: "golang-migrate"}, + {Name: "driver_ref", Type: "string", Description: "Literal driver name fallback for golang-migrate or goose; module-reference lookup is not implemented"}, + {Name: "dsn_env", Type: "string", Description: "Environment variable name holding the database DSN", DefaultValue: "DATABASE_URL"}, + {Name: "history_table", Type: "string", Description: "Reserved for future driver support; currently has no runtime effect"}, + {Name: "timeout", Type: "string", Description: "Operation timeout as a Go duration string (e.g. 5m)", DefaultValue: "5m"}, + }, + Outputs: []sdk.ServiceIO{ + {Name: "applied", Type: "int", Description: "Number of migrations applied (up/down/goto)"}, + {Name: "skipped", Type: "int", Description: "Number of migrations skipped"}, + {Name: "duration_ms", Type: "int", Description: "Operation time in milliseconds"}, + {Name: "current", Type: "string", Description: "Current migration version (status method)"}, + {Name: "pending", Type: "int", Description: "Pending migration count (status method)"}, + {Name: "dirty", Type: "bool", Description: "Dirty migration state flag (status method)"}, + }, + }, + { + Type: "database.migration_driver", + Label: "Migration Driver", + Category: "database", + Description: "Declares standalone migration driver metadata. database.migrations does not resolve module references yet.", + ConfigFields: []sdk.ConfigField{ + {Name: "driver", Type: "string", Description: "Driver name: golang-migrate, goose, or atlas", Required: true, Options: []string{"golang-migrate", "goose", "atlas"}}, + }, + Outputs: []sdk.ServiceIO{ + {Name: "driver", Type: "string", Description: "The resolved driver name (returned by the driver_name method)"}, + }, + }, + } +} + +// PluginStepSchemas returns the step schema data for all step types +// provided by this plugin. These are serialized into plugin.json as stepSchemas +// and consumed by the wfctl LSP / editor schema registry. +func PluginStepSchemas() []*schema.StepSchema { + return []*schema.StepSchema{ + { + Type: "step.migrate_up", + Plugin: "workflow-plugin-migrations", + Description: "Applies all pending database migrations (up direction).", + ConfigFields: []schema.ConfigFieldDef{ + {Key: "source_dir", Label: "Source Directory", Type: schema.FieldTypeString, Description: "Directory containing migration files", Required: true}, + {Key: "driver", Label: "Driver", Type: schema.FieldTypeSelect, Description: "Migration driver to use", Options: []string{"golang-migrate", "goose"}, DefaultValue: "golang-migrate"}, + {Key: "dsn_env", Label: "DSN Env Var", Type: schema.FieldTypeString, Description: "Environment variable name holding the database DSN", DefaultValue: "DATABASE_URL"}, + {Key: "dsn", Label: "DSN", Type: schema.FieldTypeString, Description: "Literal database DSN (overrides dsn_env)", Sensitive: true}, + {Key: "timeout", Label: "Timeout", Type: schema.FieldTypeDuration, Description: "Operation timeout (e.g. 5m)", DefaultValue: "5m"}, + }, + Outputs: []schema.StepOutputDef{ + {Key: "applied", Type: "int", Description: "Number of migrations applied"}, + {Key: "skipped", Type: "int", Description: "Number of migrations skipped (already applied)"}, + {Key: "duration_ms", Type: "int", Description: "Total execution time in milliseconds"}, + }, + }, + { + Type: "step.migrate_down", + Plugin: "workflow-plugin-migrations", + Description: "Rolls back N database migrations (down direction).", + ConfigFields: []schema.ConfigFieldDef{ + {Key: "source_dir", Label: "Source Directory", Type: schema.FieldTypeString, Description: "Directory containing migration files", Required: true}, + {Key: "driver", Label: "Driver", Type: schema.FieldTypeSelect, Description: "Migration driver to use", Options: []string{"golang-migrate", "goose"}, DefaultValue: "golang-migrate"}, + {Key: "dsn_env", Label: "DSN Env Var", Type: schema.FieldTypeString, Description: "Environment variable name holding the database DSN", DefaultValue: "DATABASE_URL"}, + {Key: "dsn", Label: "DSN", Type: schema.FieldTypeString, Description: "Literal database DSN (overrides dsn_env)", Sensitive: true}, + {Key: "timeout", Label: "Timeout", Type: schema.FieldTypeDuration, Description: "Operation timeout (e.g. 5m)", DefaultValue: "5m"}, + {Key: "steps", Label: "Steps", Type: schema.FieldTypeNumber, Description: "Number of migrations to roll back", DefaultValue: 1}, + }, + Outputs: []schema.StepOutputDef{ + {Key: "applied", Type: "int", Description: "Number of migrations rolled back"}, + {Key: "skipped", Type: "int", Description: "Number of migrations skipped"}, + {Key: "duration_ms", Type: "int", Description: "Total execution time in milliseconds"}, + }, + }, + { + Type: "step.migrate_status", + Plugin: "workflow-plugin-migrations", + Description: "Returns the current database migration status (current version, pending count, dirty flag).", + ConfigFields: []schema.ConfigFieldDef{ + {Key: "source_dir", Label: "Source Directory", Type: schema.FieldTypeString, Description: "Directory containing migration files", Required: true}, + {Key: "driver", Label: "Driver", Type: schema.FieldTypeSelect, Description: "Migration driver to use", Options: []string{"golang-migrate", "goose"}, DefaultValue: "golang-migrate"}, + {Key: "dsn_env", Label: "DSN Env Var", Type: schema.FieldTypeString, Description: "Environment variable name holding the database DSN", DefaultValue: "DATABASE_URL"}, + {Key: "dsn", Label: "DSN", Type: schema.FieldTypeString, Description: "Literal database DSN (overrides dsn_env)", Sensitive: true}, + {Key: "timeout", Label: "Timeout", Type: schema.FieldTypeDuration, Description: "Operation timeout (e.g. 5m)", DefaultValue: "5m"}, + }, + Outputs: []schema.StepOutputDef{ + {Key: "current", Type: "string", Description: "Current applied migration version"}, + {Key: "pending", Type: "int", Description: "Number of pending (unapplied) migrations"}, + {Key: "dirty", Type: "bool", Description: "Whether the database is in a dirty migration state"}, + }, + }, + { + Type: "step.migrate_to", + Plugin: "workflow-plugin-migrations", + Description: "Migrates the database to a specific version (up or down as needed).", + ConfigFields: []schema.ConfigFieldDef{ + {Key: "source_dir", Label: "Source Directory", Type: schema.FieldTypeString, Description: "Directory containing migration files", Required: true}, + {Key: "target", Label: "Target Version", Type: schema.FieldTypeString, Description: "Target migration version to migrate to", Required: true}, + {Key: "driver", Label: "Driver", Type: schema.FieldTypeSelect, Description: "Migration driver to use", Options: []string{"golang-migrate", "goose"}, DefaultValue: "golang-migrate"}, + {Key: "dsn_env", Label: "DSN Env Var", Type: schema.FieldTypeString, Description: "Environment variable name holding the database DSN", DefaultValue: "DATABASE_URL"}, + {Key: "dsn", Label: "DSN", Type: schema.FieldTypeString, Description: "Literal database DSN (overrides dsn_env)", Sensitive: true}, + {Key: "timeout", Label: "Timeout", Type: schema.FieldTypeDuration, Description: "Operation timeout (e.g. 5m)", DefaultValue: "5m"}, + }, + Outputs: []schema.StepOutputDef{ + {Key: "applied", Type: "int", Description: "Number of migrations applied or rolled back to reach the target"}, + {Key: "skipped", Type: "int", Description: "Number of migrations skipped"}, + {Key: "duration_ms", Type: "int", Description: "Total execution time in milliseconds"}, + }, + }, + } +} diff --git a/internal/plugin.go b/internal/plugin.go index a9f8505..fa5fa5c 100644 --- a/internal/plugin.go +++ b/internal/plugin.go @@ -7,6 +7,7 @@ package internal import ( "fmt" + "github.com/GoCodeAlone/workflow/dynamic" sdk "github.com/GoCodeAlone/workflow/plugin/external/sdk" "github.com/GoCodeAlone/workflow-plugin-migrations/internal/steps" @@ -15,7 +16,9 @@ import ( // Version is set at build time via -ldflags. var Version = "0.0.0" -// MigrationsPlugin implements sdk.PluginProvider, sdk.ModuleProvider, and sdk.StepProvider. +// MigrationsPlugin implements sdk.PluginProvider, sdk.ModuleProvider, +// sdk.StepProvider, sdk.SchemaProvider, and the strict ModuleContracts/ +// StepContracts methods consumed by Workflow contract tooling. type MigrationsPlugin struct{} // NewPlugin returns a new plugin instance. @@ -78,3 +81,24 @@ func (p *MigrationsPlugin) CreateStep(typeName, name string, cfg map[string]any) return nil, fmt.Errorf("workflow-plugin-migrations: unknown step type %q", typeName) } } + +// ModuleSchemas implements sdk.SchemaProvider. It returns schema metadata for +// each module type provided by this plugin so that the Workflow editor and +// wfctl tooling can provide field completions and documentation. +func (p *MigrationsPlugin) ModuleSchemas() []sdk.ModuleSchemaData { + return PluginModuleSchemas() +} + +// ModuleContracts returns strict field contracts for each module type. +// These contracts are used by the Workflow engine to validate configurations +// at startup and by wfctl audit to check for missing descriptors. +func (p *MigrationsPlugin) ModuleContracts() map[string]*dynamic.FieldContract { + return moduleContracts() +} + +// StepContracts returns strict field contracts for each step type. +// These contracts are used by the Workflow engine to validate step inputs +// at pipeline execution time and by wfctl audit for static analysis. +func (p *MigrationsPlugin) StepContracts() map[string]*dynamic.FieldContract { + return stepContracts() +} diff --git a/internal/plugin_test.go b/internal/plugin_test.go index df0e90f..9e42664 100644 --- a/internal/plugin_test.go +++ b/internal/plugin_test.go @@ -1,9 +1,17 @@ package internal_test import ( + "encoding/json" + "os" + "os/exec" + "reflect" + "sort" + "strings" "testing" sdk "github.com/GoCodeAlone/workflow/plugin/external/sdk" + "github.com/GoCodeAlone/workflow/schema" + "gopkg.in/yaml.v3" "github.com/GoCodeAlone/workflow-plugin-migrations/internal" ) @@ -112,3 +120,483 @@ func TestPlugin_CreateModule_MigrationsValidConfig(t *testing.T) { t.Fatalf("Init(): %v", err) } } + +// TestPlugin_SchemaProvider verifies that the plugin implements sdk.SchemaProvider +// and returns a schema for each advertised module type. +func TestPlugin_SchemaProvider(t *testing.T) { + p, ok := internal.NewPlugin().(sdk.SchemaProvider) + if !ok { + t.Fatal("plugin does not implement sdk.SchemaProvider") + } + schemas := p.ModuleSchemas() + if len(schemas) == 0 { + t.Fatal("ModuleSchemas() returned empty slice") + } + want := map[string]bool{ + "database.migrations": true, + "database.migration_driver": true, + } + for _, s := range schemas { + delete(want, s.Type) + } + if len(want) > 0 { + t.Errorf("missing module schemas for: %v", want) + } +} + +// TestPlugin_ModuleContracts verifies that each advertised module type has a +// strict field contract descriptor with at least one declared input. +func TestPlugin_ModuleContracts(t *testing.T) { + p := internal.NewPlugin().(*internal.MigrationsPlugin) + contracts := p.ModuleContracts() + wantTypes := []string{"database.migrations", "database.migration_driver"} + for _, typ := range wantTypes { + c, ok := contracts[typ] + if !ok { + t.Errorf("missing module contract for %q", typ) + continue + } + if len(c.RequiredInputs)+len(c.OptionalInputs) == 0 { + t.Errorf("module contract for %q has no declared inputs", typ) + } + } +} + +// TestPlugin_StepContracts verifies that each advertised step type has a strict +// field contract descriptor with at least one input and at least one output. +func TestPlugin_StepContracts(t *testing.T) { + p := internal.NewPlugin().(*internal.MigrationsPlugin) + contracts := p.StepContracts() + wantTypes := []string{ + "step.migrate_up", + "step.migrate_down", + "step.migrate_status", + "step.migrate_to", + } + for _, typ := range wantTypes { + c, ok := contracts[typ] + if !ok { + t.Errorf("missing step contract for %q", typ) + continue + } + if len(c.RequiredInputs)+len(c.OptionalInputs) == 0 { + t.Errorf("step contract for %q has no declared inputs", typ) + } + if len(c.Outputs) == 0 { + t.Errorf("step contract for %q has no declared outputs", typ) + } + } +} + +// TestPlugin_StepSchemas verifies that PluginStepSchemas returns a schema for +// every advertised step type. +func TestPlugin_StepSchemas(t *testing.T) { + schemas := internal.PluginStepSchemas() + want := map[string]bool{ + "step.migrate_up": true, + "step.migrate_down": true, + "step.migrate_status": true, + "step.migrate_to": true, + } + for _, s := range schemas { + if s.Type == "" { + t.Error("step schema has empty Type") + } + delete(want, s.Type) + if len(s.ConfigFields) == 0 { + t.Errorf("step schema %q has no config fields", s.Type) + } + if len(s.Outputs) == 0 { + t.Errorf("step schema %q has no outputs", s.Type) + } + } + if len(want) > 0 { + t.Errorf("missing step schemas for: %v", want) + } +} + +// TestPlugin_ContractCoverage ensures contract coverage matches advertised types: +// every module type and step type must have a corresponding strict contract. +func TestPlugin_ContractCoverage(t *testing.T) { + mp, ok := internal.NewPlugin().(sdk.ModuleProvider) + if !ok { + t.Fatal("plugin does not implement sdk.ModuleProvider") + } + sp, ok := internal.NewPlugin().(sdk.StepProvider) + if !ok { + t.Fatal("plugin does not implement sdk.StepProvider") + } + p := internal.NewPlugin().(*internal.MigrationsPlugin) + moduleContracts := p.ModuleContracts() + stepContracts := p.StepContracts() + + for _, modType := range mp.ModuleTypes() { + if _, ok := moduleContracts[modType]; !ok { + t.Errorf("module type %q has no strict contract descriptor", modType) + } + } + for _, stepType := range sp.StepTypes() { + if _, ok := stepContracts[stepType]; !ok { + t.Errorf("step type %q has no strict contract descriptor", stepType) + } + } +} + +// TestPlugin_PluginJSONStepSchemasDrift guards against the shipped plugin.json +// stepSchemas drifting from the Go PluginStepSchemas() definition. +// It verifies the full schema payload, not just step type names. +func TestPlugin_PluginJSONStepSchemasDrift(t *testing.T) { + data, err := os.ReadFile("../plugin.json") + if err != nil { + t.Fatalf("open plugin.json: %v", err) + } + var manifest struct { + StepSchemas []*schema.StepSchema `json:"stepSchemas"` + } + if err := json.Unmarshal(data, &manifest); err != nil { + t.Fatalf("parse plugin.json: %v", err) + } + + jsonSchemas := canonicalStepSchemas(t, manifest.StepSchemas) + goSchemas := canonicalStepSchemas(t, internal.PluginStepSchemas()) + if !reflect.DeepEqual(jsonSchemas, goSchemas) { + t.Fatalf("plugin.json stepSchemas drifted from PluginStepSchemas()\nplugin.json: %#v\nGo: %#v", jsonSchemas, goSchemas) + } +} + +// TestPlugin_ContractsJSONDrift guards against plugin.contracts.json drifting +// from the contract descriptors this plugin publishes for strict validation. +func TestPlugin_ContractsJSONDrift(t *testing.T) { + data, err := os.ReadFile("../plugin.contracts.json") + if err != nil { + t.Fatalf("open plugin.contracts.json: %v", err) + } + var file struct { + Version string `json:"version"` + Contracts []contractDescriptor `json:"contracts"` + } + if err := json.Unmarshal(data, &file); err != nil { + t.Fatalf("parse plugin.contracts.json: %v", err) + } + + if file.Version != "1" { + t.Fatalf("plugin.contracts.json version = %q; want 1", file.Version) + } + got := sortedContracts(file.Contracts) + want := sortedContracts(expectedContractDescriptors(t)) + if !reflect.DeepEqual(got, want) { + t.Fatalf("plugin.contracts.json drifted from expected descriptors\ngot: %#v\nwant: %#v", got, want) + } +} + +func TestPlugin_AtlasContractsJSONDrift(t *testing.T) { + data, err := os.ReadFile("../plugin.atlas.contracts.json") + if err != nil { + t.Fatalf("open plugin.atlas.contracts.json: %v", err) + } + var file struct { + Version string `json:"version"` + Contracts []contractDescriptor `json:"contracts"` + } + if err := json.Unmarshal(data, &file); err != nil { + t.Fatalf("parse plugin.atlas.contracts.json: %v", err) + } + if file.Version != "1" { + t.Fatalf("plugin.atlas.contracts.json version = %q; want 1", file.Version) + } + + want := moduleOnlyContractDescriptors(t) + got := sortedContracts(file.Contracts) + if !reflect.DeepEqual(got, want) { + t.Fatalf("plugin.atlas.contracts.json drifted from expected descriptors\ngot: %#v\nwant: %#v", got, want) + } +} + +func TestPlugin_DownloadsMatchGoReleaserMainArchiveMatrix(t *testing.T) { + manifest := readPluginManifest(t) + cfg := readGoReleaserConfig(t) + build := cfg.findBuild(t, "workflow-plugin-migrations") + + want := make(map[string]string) + for _, goos := range build.Goos { + for _, goarch := range build.Goarch { + key := goos + "/" + goarch + want[key] = "https://github.com/GoCodeAlone/workflow-plugin-migrations/releases/download/v" + + manifest.Version + "/workflow-plugin-migrations-" + goos + "-" + goarch + ".tar.gz" + } + } + + got := make(map[string]string) + for _, d := range manifest.Downloads { + got[d.OS+"/"+d.Arch] = d.URL + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("plugin.json downloads do not match GoReleaser main archive matrix\ngot: %#v\nwant: %#v", got, want) + } +} + +func TestPlugin_GoReleaserManifestRewriteUpdatesDownloadURLs(t *testing.T) { + cfg := readGoReleaserConfig(t) + hook := "" + for _, candidate := range cfg.Before.Hooks { + if strings.Contains(candidate, "/releases/download/") && + strings.Contains(candidate, "/releases/download/v{{ .Version }}/") { + hook = candidate + break + } + } + if hook == "" { + t.Fatalf("GoReleaser before hook must rewrite download URLs with the v tag prefix") + } + + tmp := t.TempDir() + for _, name := range []string{"plugin.json", "plugin.atlas.json"} { + data, err := os.ReadFile("../" + name) + if err != nil { + t.Fatalf("read %s: %v", name, err) + } + if err := os.WriteFile(tmp+"/"+name, data, 0o644); err != nil { + t.Fatalf("write temp %s: %v", name, err) + } + } + + const snapshotVersion = "0.3.6-SNAPSHOT-test" + cmd := exec.Command("sh", "-c", strings.ReplaceAll(hook, "{{ .Version }}", snapshotVersion)) + cmd.Dir = tmp + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("run GoReleaser manifest rewrite hook: %v\n%s", err, out) + } + + for _, name := range []string{"plugin.json", "plugin.atlas.json"} { + manifest := readPluginManifestAtPath(t, tmp+"/"+name) + if manifest.Version != snapshotVersion { + t.Fatalf("%s version = %q; want %q", name, manifest.Version, snapshotVersion) + } + for _, d := range manifest.Downloads { + if !strings.Contains(d.URL, "/releases/download/v"+snapshotVersion+"/") { + t.Fatalf("%s download URL was not rewritten to snapshot version: %s", name, d.URL) + } + } + } +} + +func TestPlugin_GoReleaserArchivesPackageCorrectManifestsAndContracts(t *testing.T) { + cfg := readGoReleaserConfig(t) + + mainArchive := cfg.findArchive(t, "workflow-plugin-migrations") + if !mainArchive.hasFile("plugin.json", "plugin.json") { + t.Fatal("workflow-plugin-migrations archive does not package plugin.json") + } + if !mainArchive.hasFile("plugin.contracts.json", "plugin.contracts.json") { + t.Fatal("workflow-plugin-migrations archive does not package plugin.contracts.json") + } + + atlasArchive := cfg.findArchive(t, "workflow-plugin-atlas-migrate") + if !atlasArchive.hasFile("plugin.atlas.json", "plugin.json") { + t.Fatal("workflow-plugin-atlas-migrate archive does not package plugin.atlas.json as plugin.json") + } + if !atlasArchive.hasFile("plugin.atlas.contracts.json", "plugin.contracts.json") { + t.Fatal("workflow-plugin-atlas-migrate archive does not package plugin.atlas.contracts.json as plugin.contracts.json") + } + if atlasArchive.hasFile("plugin.json", "plugin.json") || atlasArchive.hasFile("plugin.contracts.json", "plugin.contracts.json") { + t.Fatal("workflow-plugin-atlas-migrate archive must not package the main plugin manifest or contracts") + } +} + +type contractDescriptor struct { + Kind string `json:"kind"` + Type string `json:"type"` + Mode string `json:"mode"` + Config string `json:"config,omitempty"` + Input string `json:"input,omitempty"` + Output string `json:"output,omitempty"` +} + +func expectedContractDescriptors(t *testing.T) []contractDescriptor { + t.Helper() + out := moduleOnlyContractDescriptors(t) + p := internal.NewPlugin().(*internal.MigrationsPlugin) + stepInputs := map[string]string{ + "step.migrate_up": internal.ProtoMigrateUpInput, + "step.migrate_down": internal.ProtoMigrateDownInput, + "step.migrate_status": internal.ProtoMigrateStatusInput, + "step.migrate_to": internal.ProtoMigrateToInput, + } + stepOutputs := map[string]string{ + "step.migrate_up": internal.ProtoMigrateUpOutput, + "step.migrate_down": internal.ProtoMigrateDownOutput, + "step.migrate_status": internal.ProtoMigrateStatusOutput, + "step.migrate_to": internal.ProtoMigrateToOutput, + } + for stepType := range p.StepContracts() { + input, ok := stepInputs[stepType] + if !ok { + t.Fatalf("missing expected proto input for step contract %q", stepType) + } + output, ok := stepOutputs[stepType] + if !ok { + t.Fatalf("missing expected proto output for step contract %q", stepType) + } + out = append(out, contractDescriptor{Kind: "step", Type: stepType, Mode: "strict", Input: input, Output: output}) + } + return sortedContracts(out) +} + +func moduleOnlyContractDescriptors(t *testing.T) []contractDescriptor { + t.Helper() + p := internal.NewPlugin().(*internal.MigrationsPlugin) + moduleConfigs := map[string]string{ + "database.migrations": internal.ProtoMigrationsModuleConfig, + "database.migration_driver": internal.ProtoMigrationDriverConfig, + } + out := make([]contractDescriptor, 0, len(p.ModuleContracts())) + for moduleType := range p.ModuleContracts() { + config, ok := moduleConfigs[moduleType] + if !ok { + t.Fatalf("missing expected proto config for module contract %q", moduleType) + } + out = append(out, contractDescriptor{Kind: "module", Type: moduleType, Mode: "strict", Config: config}) + } + return sortedContracts(out) +} + +func sortedContracts(in []contractDescriptor) []contractDescriptor { + out := append([]contractDescriptor(nil), in...) + sort.Slice(out, func(i, j int) bool { + return out[i].Kind+":"+out[i].Type < out[j].Kind+":"+out[j].Type + }) + return out +} + +func canonicalStepSchemas(t *testing.T, schemas []*schema.StepSchema) map[string]any { + t.Helper() + out := make(map[string]any, len(schemas)) + for _, s := range schemas { + data, err := json.Marshal(s) + if err != nil { + t.Fatalf("marshal step schema %q: %v", s.Type, err) + } + var v any + if err := json.Unmarshal(data, &v); err != nil { + t.Fatalf("canonicalize step schema %q: %v", s.Type, err) + } + out[s.Type] = v + } + return out +} + +type pluginManifestFile struct { + Version string `json:"version"` + Downloads []struct { + OS string `json:"os"` + Arch string `json:"arch"` + URL string `json:"url"` + } `json:"downloads"` +} + +func readPluginManifest(t *testing.T) pluginManifestFile { + t.Helper() + return readPluginManifestAtPath(t, "../plugin.json") +} + +func readPluginManifestAtPath(t *testing.T, path string) pluginManifestFile { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("open %s: %v", path, err) + } + var manifest pluginManifestFile + if err := json.Unmarshal(data, &manifest); err != nil { + t.Fatalf("parse %s: %v", path, err) + } + return manifest +} + +type goReleaserConfig struct { + Before struct { + Hooks []string `yaml:"hooks"` + } `yaml:"before"` + Builds []goReleaserBuild `yaml:"builds"` + Archives []goReleaserArchive `yaml:"archives"` +} + +type goReleaserBuild struct { + ID string `yaml:"id"` + Goos []string `yaml:"goos"` + Goarch []string `yaml:"goarch"` +} + +type goReleaserArchive struct { + ID string `yaml:"id"` + Files []goReleaserFileRef `yaml:"files"` +} + +type goReleaserFileRef struct { + Src string + Dst string +} + +func (f *goReleaserFileRef) UnmarshalYAML(value *yaml.Node) error { + if value.Kind == yaml.ScalarNode { + f.Src = value.Value + f.Dst = value.Value + return nil + } + var mapped struct { + Src string `yaml:"src"` + Dst string `yaml:"dst"` + } + if err := value.Decode(&mapped); err != nil { + return err + } + f.Src = mapped.Src + f.Dst = mapped.Dst + if f.Dst == "" { + f.Dst = f.Src + } + return nil +} + +func readGoReleaserConfig(t *testing.T) goReleaserConfig { + t.Helper() + data, err := os.ReadFile("../.goreleaser.yaml") + if err != nil { + t.Fatalf("open .goreleaser.yaml: %v", err) + } + var cfg goReleaserConfig + if err := yaml.Unmarshal(data, &cfg); err != nil { + t.Fatalf("parse .goreleaser.yaml: %v", err) + } + return cfg +} + +func (cfg goReleaserConfig) findBuild(t *testing.T, id string) goReleaserBuild { + t.Helper() + for _, build := range cfg.Builds { + if build.ID == id { + return build + } + } + t.Fatalf("missing GoReleaser build %q", id) + return goReleaserBuild{} +} + +func (cfg goReleaserConfig) findArchive(t *testing.T, id string) goReleaserArchive { + t.Helper() + for _, archive := range cfg.Archives { + if archive.ID == id { + return archive + } + } + t.Fatalf("missing GoReleaser archive %q", id) + return goReleaserArchive{} +} + +func (a goReleaserArchive) hasFile(src, dst string) bool { + for _, file := range a.Files { + if file.Src == src && file.Dst == dst { + return true + } + } + return false +} diff --git a/plugin.atlas.contracts.json b/plugin.atlas.contracts.json new file mode 100644 index 0000000..982921c --- /dev/null +++ b/plugin.atlas.contracts.json @@ -0,0 +1,17 @@ +{ + "version": "1", + "contracts": [ + { + "kind": "module", + "type": "database.migrations", + "mode": "strict", + "config": "workflow.plugins.migrations.MigrationsModuleConfig" + }, + { + "kind": "module", + "type": "database.migration_driver", + "mode": "strict", + "config": "workflow.plugins.migrations.MigrationDriverConfig" + } + ] +} diff --git a/plugin.atlas.json b/plugin.atlas.json new file mode 100644 index 0000000..a015e24 --- /dev/null +++ b/plugin.atlas.json @@ -0,0 +1,47 @@ +{ + "name": "workflow-plugin-atlas-migrate", + "version": "0.3.6", + "description": "Atlas migration driver plugin for the workflow engine", + "author": "GoCodeAlone", + "license": "Apache-2.0", + "type": "external", + "tier": "community", + "minEngineVersion": "0.18.0", + "keywords": ["migrations", "database", "atlas"], + "homepage": "https://github.com/GoCodeAlone/workflow-plugin-migrations", + "repository": "https://github.com/GoCodeAlone/workflow-plugin-migrations", + "capabilities": { + "configProvider": false, + "moduleTypes": [ + "database.migrations", + "database.migration_driver" + ], + "stepTypes": [], + "triggerTypes": [], + "migrationDrivers": ["atlas"], + "cliCommands": [], + "buildHooks": [] + }, + "downloads": [ + { + "os": "linux", + "arch": "amd64", + "url": "https://github.com/GoCodeAlone/workflow-plugin-migrations/releases/download/v0.3.6/workflow-plugin-atlas-migrate-linux-amd64.tar.gz" + }, + { + "os": "linux", + "arch": "arm64", + "url": "https://github.com/GoCodeAlone/workflow-plugin-migrations/releases/download/v0.3.6/workflow-plugin-atlas-migrate-linux-arm64.tar.gz" + }, + { + "os": "darwin", + "arch": "amd64", + "url": "https://github.com/GoCodeAlone/workflow-plugin-migrations/releases/download/v0.3.6/workflow-plugin-atlas-migrate-darwin-amd64.tar.gz" + }, + { + "os": "darwin", + "arch": "arm64", + "url": "https://github.com/GoCodeAlone/workflow-plugin-migrations/releases/download/v0.3.6/workflow-plugin-atlas-migrate-darwin-arm64.tar.gz" + } + ] +} diff --git a/plugin.contracts.json b/plugin.contracts.json new file mode 100644 index 0000000..23aae6b --- /dev/null +++ b/plugin.contracts.json @@ -0,0 +1,45 @@ +{ + "version": "1", + "contracts": [ + { + "kind": "module", + "type": "database.migrations", + "mode": "strict", + "config": "workflow.plugins.migrations.MigrationsModuleConfig" + }, + { + "kind": "module", + "type": "database.migration_driver", + "mode": "strict", + "config": "workflow.plugins.migrations.MigrationDriverConfig" + }, + { + "kind": "step", + "type": "step.migrate_up", + "mode": "strict", + "input": "workflow.plugins.migrations.MigrateUpInput", + "output": "workflow.plugins.migrations.MigrateUpOutput" + }, + { + "kind": "step", + "type": "step.migrate_down", + "mode": "strict", + "input": "workflow.plugins.migrations.MigrateDownInput", + "output": "workflow.plugins.migrations.MigrateDownOutput" + }, + { + "kind": "step", + "type": "step.migrate_status", + "mode": "strict", + "input": "workflow.plugins.migrations.MigrateStatusInput", + "output": "workflow.plugins.migrations.MigrateStatusOutput" + }, + { + "kind": "step", + "type": "step.migrate_to", + "mode": "strict", + "input": "workflow.plugins.migrations.MigrateToInput", + "output": "workflow.plugins.migrations.MigrateToOutput" + } + ] +} diff --git a/plugin.json b/plugin.json index e0d70b5..ea0f320 100644 --- a/plugin.json +++ b/plugin.json @@ -1,12 +1,11 @@ { "name": "workflow-plugin-migrations", - "version": "0.0.0", + "version": "0.3.6", "description": "Database migration plugin for the workflow engine: golang-migrate + goose + atlas drivers, pre-deploy runner, wfctl migrate CLI, static lint tool", "author": "GoCodeAlone", "license": "Apache-2.0", "type": "external", "tier": "community", - "private": false, "minEngineVersion": "0.18.0", "keywords": ["migrations", "database", "postgres", "golang-migrate", "goose", "atlas"], "homepage": "https://github.com/GoCodeAlone/workflow-plugin-migrations", @@ -36,7 +35,7 @@ {"name": "goto", "description": "Migrate to a specific version"}, {"name": "force", "description": "Force-set the recorded migration version"}, {"name": "repair-dirty", "description": "Repair a known dirty migration metadata state"}, - {"name": "validate-upgrade", "description": "Validate baseline-to-candidate migration upgrade path"}, + {"name": "validate-upgrade", "description": "Validate a candidate migration directory against a baseline"}, {"name": "lint", "description": "Static analysis for migration files"}, {"name": "test", "description": "Run full-cycle and checkpoint migration tests"}, {"name": "tenant-ensure", "description": "Ensure a tenant schema exists in the database"} @@ -45,5 +44,109 @@ } ], "buildHooks": [] - } + }, + "downloads": [ + { + "os": "linux", + "arch": "amd64", + "url": "https://github.com/GoCodeAlone/workflow-plugin-migrations/releases/download/v0.3.6/workflow-plugin-migrations-linux-amd64.tar.gz" + }, + { + "os": "linux", + "arch": "arm64", + "url": "https://github.com/GoCodeAlone/workflow-plugin-migrations/releases/download/v0.3.6/workflow-plugin-migrations-linux-arm64.tar.gz" + }, + { + "os": "darwin", + "arch": "amd64", + "url": "https://github.com/GoCodeAlone/workflow-plugin-migrations/releases/download/v0.3.6/workflow-plugin-migrations-darwin-amd64.tar.gz" + }, + { + "os": "darwin", + "arch": "arm64", + "url": "https://github.com/GoCodeAlone/workflow-plugin-migrations/releases/download/v0.3.6/workflow-plugin-migrations-darwin-arm64.tar.gz" + }, + { + "os": "windows", + "arch": "amd64", + "url": "https://github.com/GoCodeAlone/workflow-plugin-migrations/releases/download/v0.3.6/workflow-plugin-migrations-windows-amd64.tar.gz" + }, + { + "os": "windows", + "arch": "arm64", + "url": "https://github.com/GoCodeAlone/workflow-plugin-migrations/releases/download/v0.3.6/workflow-plugin-migrations-windows-arm64.tar.gz" + } + ], + "stepSchemas": [ + { + "type": "step.migrate_up", + "plugin": "workflow-plugin-migrations", + "description": "Applies all pending database migrations (up direction).", + "configFields": [ + {"key": "source_dir", "label": "Source Directory", "type": "string", "description": "Directory containing migration files", "required": true}, + {"key": "driver", "label": "Driver", "type": "select", "description": "Migration driver to use", "options": ["golang-migrate", "goose"], "defaultValue": "golang-migrate"}, + {"key": "dsn_env", "label": "DSN Env Var", "type": "string", "description": "Environment variable name holding the database DSN", "defaultValue": "DATABASE_URL"}, + {"key": "dsn", "label": "DSN", "type": "string", "description": "Literal database DSN (overrides dsn_env)", "sensitive": true}, + {"key": "timeout", "label": "Timeout", "type": "duration", "description": "Operation timeout (e.g. 5m)", "defaultValue": "5m"} + ], + "outputs": [ + {"key": "applied", "type": "int", "description": "Number of migrations applied"}, + {"key": "skipped", "type": "int", "description": "Number of migrations skipped (already applied)"}, + {"key": "duration_ms", "type": "int", "description": "Total execution time in milliseconds"} + ] + }, + { + "type": "step.migrate_down", + "plugin": "workflow-plugin-migrations", + "description": "Rolls back N database migrations (down direction).", + "configFields": [ + {"key": "source_dir", "label": "Source Directory", "type": "string", "description": "Directory containing migration files", "required": true}, + {"key": "driver", "label": "Driver", "type": "select", "description": "Migration driver to use", "options": ["golang-migrate", "goose"], "defaultValue": "golang-migrate"}, + {"key": "dsn_env", "label": "DSN Env Var", "type": "string", "description": "Environment variable name holding the database DSN", "defaultValue": "DATABASE_URL"}, + {"key": "dsn", "label": "DSN", "type": "string", "description": "Literal database DSN (overrides dsn_env)", "sensitive": true}, + {"key": "timeout", "label": "Timeout", "type": "duration", "description": "Operation timeout (e.g. 5m)", "defaultValue": "5m"}, + {"key": "steps", "label": "Steps", "type": "number", "description": "Number of migrations to roll back", "defaultValue": 1} + ], + "outputs": [ + {"key": "applied", "type": "int", "description": "Number of migrations rolled back"}, + {"key": "skipped", "type": "int", "description": "Number of migrations skipped"}, + {"key": "duration_ms", "type": "int", "description": "Total execution time in milliseconds"} + ] + }, + { + "type": "step.migrate_status", + "plugin": "workflow-plugin-migrations", + "description": "Returns the current database migration status (current version, pending count, dirty flag).", + "configFields": [ + {"key": "source_dir", "label": "Source Directory", "type": "string", "description": "Directory containing migration files", "required": true}, + {"key": "driver", "label": "Driver", "type": "select", "description": "Migration driver to use", "options": ["golang-migrate", "goose"], "defaultValue": "golang-migrate"}, + {"key": "dsn_env", "label": "DSN Env Var", "type": "string", "description": "Environment variable name holding the database DSN", "defaultValue": "DATABASE_URL"}, + {"key": "dsn", "label": "DSN", "type": "string", "description": "Literal database DSN (overrides dsn_env)", "sensitive": true}, + {"key": "timeout", "label": "Timeout", "type": "duration", "description": "Operation timeout (e.g. 5m)", "defaultValue": "5m"} + ], + "outputs": [ + {"key": "current", "type": "string", "description": "Current applied migration version"}, + {"key": "pending", "type": "int", "description": "Number of pending (unapplied) migrations"}, + {"key": "dirty", "type": "bool", "description": "Whether the database is in a dirty migration state"} + ] + }, + { + "type": "step.migrate_to", + "plugin": "workflow-plugin-migrations", + "description": "Migrates the database to a specific version (up or down as needed).", + "configFields": [ + {"key": "source_dir", "label": "Source Directory", "type": "string", "description": "Directory containing migration files", "required": true}, + {"key": "target", "label": "Target Version", "type": "string", "description": "Target migration version to migrate to", "required": true}, + {"key": "driver", "label": "Driver", "type": "select", "description": "Migration driver to use", "options": ["golang-migrate", "goose"], "defaultValue": "golang-migrate"}, + {"key": "dsn_env", "label": "DSN Env Var", "type": "string", "description": "Environment variable name holding the database DSN", "defaultValue": "DATABASE_URL"}, + {"key": "dsn", "label": "DSN", "type": "string", "description": "Literal database DSN (overrides dsn_env)", "sensitive": true}, + {"key": "timeout", "label": "Timeout", "type": "duration", "description": "Operation timeout (e.g. 5m)", "defaultValue": "5m"} + ], + "outputs": [ + {"key": "applied", "type": "int", "description": "Number of migrations applied or rolled back to reach the target"}, + {"key": "skipped", "type": "int", "description": "Number of migrations skipped"}, + {"key": "duration_ms", "type": "int", "description": "Total execution time in milliseconds"} + ] + } + ] }