Implements the remaining items from issue #20: - version is now forward-permissive: any value >= 1 is accepted; a newer-than-known version loads best-effort (unknown fields ignored, warning printed) instead of hard-failing on "must be 1" - from_job input reference: `inputs: [{ from_job: <job> }]` resolves to that job's single-file output + format and implies a dependency edge; combined depends_on + from_job graph gets topological ordering and cycle detection - logfile size-rotation, on by default (5MB, keep 3), overridable per job (log_max_size / log_keep) or file-wide via a top-level defaults block - new commands: split (schema/table subsetting via select:), inspect (rule validation -> markdown/json report, fails job on enforced-rule errors), diff (compare exactly two schemas, never fails), scripts-exec (run SQL script dirs against a live PostgreSQL database) - atomic single-file output/report writes (temp file + rename) - symlink-escape hardening in SafeJoin via EvalSymlinks preflight Updates docs/JOB_FILES.md and examples/jobs/relspec.yml accordingly.
936 lines
27 KiB
Go
936 lines
27 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/spf13/cobra"
|
|
|
|
"git.warky.dev/wdevs/relspecgo/pkg/diff"
|
|
"git.warky.dev/wdevs/relspecgo/pkg/inspector"
|
|
"git.warky.dev/wdevs/relspecgo/pkg/jobs"
|
|
"git.warky.dev/wdevs/relspecgo/pkg/merge"
|
|
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
|
"git.warky.dev/wdevs/relspecgo/pkg/readers"
|
|
"git.warky.dev/wdevs/relspecgo/pkg/readers/sqldir"
|
|
"git.warky.dev/wdevs/relspecgo/pkg/writers"
|
|
wpgsql "git.warky.dev/wdevs/relspecgo/pkg/writers/pgsql"
|
|
"git.warky.dev/wdevs/relspecgo/pkg/writers/sqlexec"
|
|
wtemplate "git.warky.dev/wdevs/relspecgo/pkg/writers/template"
|
|
)
|
|
|
|
var (
|
|
jobDir string
|
|
jobFiles []string
|
|
jobDryRun bool
|
|
jobNoDeps bool
|
|
)
|
|
|
|
var jobCmd = &cobra.Command{
|
|
Use: "job",
|
|
Short: "Run declarative RelSpec jobs from job files",
|
|
Long: `Run named jobs declared in job files instead of repeating command-line arguments.
|
|
|
|
A job file is a YAML manifest (relspec.yml, or relspec.<name>.yml for extra
|
|
files) describing one or more jobs. Each job names a RelSpec command plus its
|
|
inputs, output and options:
|
|
|
|
version: 1
|
|
jobs:
|
|
build-schema:
|
|
command: convert
|
|
description: Merge the DBML sources and emit PostgreSQL DDL
|
|
inputs:
|
|
- path: schema/core.dbml
|
|
format: dbml
|
|
- path: schema/tenant.dbml
|
|
format: dbml
|
|
output:
|
|
format: pgsql
|
|
path: build/schema.sql
|
|
overwrite: true
|
|
options:
|
|
flatten_schema: false
|
|
logfile: .relspec/log/build-schema.log
|
|
|
|
Rules and guarantees:
|
|
- command is a closed allow-list (convert, merge, scripts-list, templ). Arbitrary
|
|
shell strings are never executed.
|
|
- Every path is relative to the directory holding the job file and may not
|
|
escape it. Absolute and home-relative paths are rejected.
|
|
- Remote database credentials are referenced by environment-variable name
|
|
via conn_env; connection strings are never stored in the manifest and are
|
|
redacted from logs and diagnostics.
|
|
- Discovery and listing are deterministic.
|
|
- The whole plan is validated - unknown commands/formats, duplicate job
|
|
names, missing inputs, path traversal, dependency cycles - before any job
|
|
runs. Nothing is read, written or executed when validation fails.
|
|
- A failed job propagates the underlying non-zero exit status and writes no
|
|
success marker.`,
|
|
}
|
|
|
|
var jobListCmd = &cobra.Command{
|
|
Use: "list",
|
|
Short: "List jobs discovered in job files (deterministic order)",
|
|
RunE: runJobList,
|
|
}
|
|
|
|
var jobRunCmd = &cobra.Command{
|
|
Use: "run <job-name>",
|
|
Short: "Run a named job (and its dependencies) from a job file",
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: runJobRun,
|
|
}
|
|
|
|
func init() {
|
|
for _, c := range []*cobra.Command{jobListCmd, jobRunCmd} {
|
|
c.Flags().StringVar(&jobDir, "dir", ".", "Directory to discover job files in")
|
|
c.Flags().StringSliceVar(&jobFiles, "file", nil, "Explicit job file(s) to load (repeatable); disables discovery")
|
|
}
|
|
jobRunCmd.Flags().BoolVar(&jobDryRun, "dry-run", false, "Validate and print the execution plan without running anything")
|
|
jobRunCmd.Flags().BoolVar(&jobDryRun, "plan", false, "Alias for --dry-run")
|
|
jobRunCmd.Flags().BoolVar(&jobNoDeps, "no-deps", false, "Run only the named job, skipping its declared dependencies")
|
|
|
|
jobCmd.AddCommand(jobListCmd)
|
|
jobCmd.AddCommand(jobRunCmd)
|
|
}
|
|
|
|
// loadJobSet discovers or loads the requested job files and runs full
|
|
// validation. The returned Set is safe to plan and execute.
|
|
func loadJobSet() (*jobs.Set, error) {
|
|
paths := jobFiles
|
|
if len(paths) == 0 {
|
|
discovered, err := jobs.Discover(jobDir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
paths = discovered
|
|
} else {
|
|
for i, p := range paths {
|
|
if _, err := os.Stat(p); err != nil {
|
|
return nil, fmt.Errorf("job file %q: %w", p, err)
|
|
}
|
|
paths[i] = p
|
|
}
|
|
}
|
|
set, err := jobs.Load(paths)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := set.Validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
for _, w := range set.Warnings {
|
|
fmt.Fprintf(os.Stderr, "warning: %s\n", w)
|
|
}
|
|
return set, nil
|
|
}
|
|
|
|
func runJobList(cmd *cobra.Command, args []string) error {
|
|
set, err := loadJobSet()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
out := cmd.OutOrStdout()
|
|
|
|
fmt.Fprintf(os.Stderr, "\n=== RelSpec Jobs ===\n")
|
|
fmt.Fprintf(os.Stderr, "Job files:\n")
|
|
for _, f := range set.Files {
|
|
fmt.Fprintf(os.Stderr, " - %s\n", f)
|
|
}
|
|
fmt.Fprintln(os.Stderr)
|
|
|
|
names := set.Names()
|
|
if len(names) == 0 {
|
|
fmt.Fprintln(out, "(no jobs defined)")
|
|
return nil
|
|
}
|
|
|
|
nameW, cmdW, srcW := len("NAME"), len("COMMAND"), len("SOURCE")
|
|
for _, n := range names {
|
|
j := set.Jobs[n]
|
|
nameW = maxInt(nameW, len(n))
|
|
cmdW = maxInt(cmdW, len(j.Command))
|
|
srcW = maxInt(srcW, len(j.SourceFile))
|
|
}
|
|
fmt.Fprintf(out, "%-*s %-*s %-*s %s\n", nameW, "NAME", cmdW, "COMMAND", srcW, "SOURCE", "DESCRIPTION")
|
|
for _, n := range names {
|
|
j := set.Jobs[n]
|
|
fmt.Fprintf(out, "%-*s %-*s %-*s %s\n", nameW, n, cmdW, j.Command, srcW, j.SourceFile, j.Description)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func runJobRun(cmd *cobra.Command, args []string) error {
|
|
set, err := loadJobSet()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return executeJobPlan(set, args[0], jobDryRun, jobNoDeps, cmd.OutOrStdout())
|
|
}
|
|
|
|
// executeJobPlan resolves the plan for name, runs pre-flight checks over
|
|
// EVERY job in the plan, and only then executes. When dryRun is set it prints
|
|
// the plan and returns without touching any input, output or database.
|
|
func executeJobPlan(set *jobs.Set, name string, dryRun, noDeps bool, out io.Writer) error {
|
|
plan, err := set.Plan(name, !noDeps)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Pre-flight: resolve and check paths, output policy and env vars for the
|
|
// whole plan before anything runs. A failure here means no job executes.
|
|
resolved := make([]*resolvedJob, len(plan))
|
|
byName := make(map[string]*resolvedJob, len(plan))
|
|
for i, j := range plan {
|
|
rj, perr := preflightJob(j, byName)
|
|
if perr != nil {
|
|
return fmt.Errorf("job %q: %w", j.Name, perr)
|
|
}
|
|
resolved[i] = rj
|
|
byName[j.Name] = rj
|
|
}
|
|
|
|
if dryRun {
|
|
fmt.Fprintf(out, "RelSpec job plan for %q (dry run - nothing executed):\n\n", name)
|
|
for i, rj := range resolved {
|
|
printResolvedJob(out, i+1, len(resolved), rj)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
for _, rj := range resolved {
|
|
if err := executeResolvedJob(rj); err != nil {
|
|
// Propagate the underlying failure; no success marker is written.
|
|
return fmt.Errorf("job %q failed: %w", rj.job.Name, err)
|
|
}
|
|
}
|
|
fmt.Fprintf(os.Stderr, "\n=== Job %q complete ===\n", name)
|
|
return nil
|
|
}
|
|
|
|
// resolvedJob is a job with every manifest path turned into a checked
|
|
// absolute filesystem path and every conn_env resolved to its value.
|
|
type resolvedJob struct {
|
|
job *jobs.Job
|
|
root string
|
|
inputs []resolvedInput
|
|
scriptDirs []string
|
|
outputPath string // "" when the output is a database
|
|
outputConn string // resolved connection string (secret)
|
|
outputConnEnv string
|
|
logPath string
|
|
logPolicy jobs.LogPolicy
|
|
templatePath string
|
|
reportPath string // "" for a diff summary written to the log
|
|
reportFormat string
|
|
rulesPath string // "" means inspector defaults
|
|
selection *splitSelection
|
|
secrets []string // resolved secret values to redact from logs
|
|
}
|
|
|
|
type resolvedInput struct {
|
|
format string
|
|
path string // "" when the input is a database
|
|
conn string // resolved connection string (secret)
|
|
connEnv string
|
|
fromJob string // producer job name when this input came from from_job
|
|
}
|
|
|
|
func preflightJob(j *jobs.Job, resolvedByName map[string]*resolvedJob) (*resolvedJob, error) {
|
|
root := j.Dir()
|
|
rj := &resolvedJob{job: j, root: root, logPolicy: j.ResolvedLogPolicy()}
|
|
|
|
if j.Logfile != "" {
|
|
p, err := jobs.SafeJoin(root, j.Logfile)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("logfile: %w", err)
|
|
}
|
|
rj.logPath = p
|
|
}
|
|
if j.Template != "" {
|
|
p, err := jobs.SafeJoin(root, j.Template)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("template: %w", err)
|
|
}
|
|
info, err := os.Stat(p)
|
|
if err != nil || info.IsDir() {
|
|
return nil, fmt.Errorf("template %q: not found or is a directory", j.Template)
|
|
}
|
|
rj.templatePath = p
|
|
}
|
|
|
|
for i, in := range j.Inputs {
|
|
ri := resolvedInput{format: strings.ToLower(in.Format)}
|
|
if in.FromJob != "" {
|
|
producer, ok := resolvedByName[in.FromJob]
|
|
if !ok {
|
|
return nil, fmt.Errorf("input[%d]: from_job %q is not in this plan (do not use --no-deps with from_job inputs)", i, in.FromJob)
|
|
}
|
|
if producer.outputPath == "" {
|
|
return nil, fmt.Errorf("input[%d]: from_job %q does not write a file output", i, in.FromJob)
|
|
}
|
|
ri.path = producer.outputPath
|
|
ri.format = strings.ToLower(producer.job.Output.Format)
|
|
ri.fromJob = in.FromJob
|
|
rj.inputs = append(rj.inputs, ri)
|
|
continue
|
|
}
|
|
if in.ConnEnv != "" {
|
|
v, ok := os.LookupEnv(in.ConnEnv)
|
|
if !ok || v == "" {
|
|
return nil, fmt.Errorf("input[%d]: environment variable %q (conn_env) is not set", i, in.ConnEnv)
|
|
}
|
|
ri.conn = v
|
|
ri.connEnv = in.ConnEnv
|
|
rj.secrets = append(rj.secrets, v)
|
|
} else {
|
|
p, err := jobs.SafeJoin(root, in.Path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("input[%d]: %w", i, err)
|
|
}
|
|
info, err := os.Stat(p)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("input[%d]: %s: file not found", i, in.Path)
|
|
}
|
|
if info.IsDir() {
|
|
return nil, fmt.Errorf("input[%d]: %s: is a directory, not a file", i, in.Path)
|
|
}
|
|
ri.path = p
|
|
}
|
|
rj.inputs = append(rj.inputs, ri)
|
|
}
|
|
|
|
for _, d := range j.ScriptDirs {
|
|
p, err := jobs.SafeJoin(root, d)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("script_dir %q: %w", d, err)
|
|
}
|
|
info, err := os.Stat(p)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("script_dir %q: not found", d)
|
|
}
|
|
if !info.IsDir() {
|
|
return nil, fmt.Errorf("script_dir %q: not a directory", d)
|
|
}
|
|
rj.scriptDirs = append(rj.scriptDirs, p)
|
|
}
|
|
|
|
if j.Output != nil {
|
|
if j.Output.ConnEnv != "" {
|
|
v, ok := os.LookupEnv(j.Output.ConnEnv)
|
|
if !ok || v == "" {
|
|
return nil, fmt.Errorf("output: environment variable %q (conn_env) is not set", j.Output.ConnEnv)
|
|
}
|
|
rj.outputConn = v
|
|
rj.outputConnEnv = j.Output.ConnEnv
|
|
rj.secrets = append(rj.secrets, v)
|
|
} else {
|
|
p, err := jobs.SafeJoin(root, j.Output.Path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("output: %w", err)
|
|
}
|
|
if _, err := os.Stat(p); err == nil && !j.Output.Overwrite {
|
|
return nil, fmt.Errorf("output %s already exists (set output.overwrite: true to replace it)", j.Output.Path)
|
|
}
|
|
rj.outputPath = p
|
|
}
|
|
}
|
|
|
|
if j.Rules != "" {
|
|
p, err := jobs.SafeJoin(root, j.Rules)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("rules: %w", err)
|
|
}
|
|
info, err := os.Stat(p)
|
|
if err != nil || info.IsDir() {
|
|
return nil, fmt.Errorf("rules %q: not found or is a directory", j.Rules)
|
|
}
|
|
rj.rulesPath = p
|
|
}
|
|
|
|
if j.Report != nil {
|
|
rj.reportFormat = strings.ToLower(j.Report.Format)
|
|
if j.Report.Path != "" {
|
|
p, err := jobs.SafeJoin(root, j.Report.Path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("report: %w", err)
|
|
}
|
|
if _, err := os.Stat(p); err == nil && !j.Report.Overwrite {
|
|
return nil, fmt.Errorf("report %s already exists (set report.overwrite: true to replace it)", j.Report.Path)
|
|
}
|
|
rj.reportPath = p
|
|
}
|
|
}
|
|
|
|
if j.Select != nil {
|
|
rj.selection = &splitSelection{
|
|
Schemas: j.Select.Schemas,
|
|
Tables: j.Select.Tables,
|
|
ExcludeSchemas: j.Select.ExcludeSchemas,
|
|
ExcludeTables: j.Select.ExcludeTables,
|
|
DatabaseName: j.Select.DatabaseName,
|
|
}
|
|
}
|
|
|
|
return rj, nil
|
|
}
|
|
|
|
func printResolvedJob(out io.Writer, n, total int, rj *resolvedJob) {
|
|
j := rj.job
|
|
fmt.Fprintf(out, "[%d/%d] %s\n", n, total, j.Name)
|
|
fmt.Fprintf(out, " command: %s\n", j.Command)
|
|
if j.Description != "" {
|
|
fmt.Fprintf(out, " description: %s\n", j.Description)
|
|
}
|
|
fmt.Fprintf(out, " job file: %s\n", j.SourceFile)
|
|
for _, ri := range rj.inputs {
|
|
switch {
|
|
case ri.fromJob != "":
|
|
fmt.Fprintf(out, " input: %s (%s) from job %q\n", ri.path, ri.format, ri.fromJob)
|
|
case ri.path != "":
|
|
fmt.Fprintf(out, " input: %s (%s)\n", ri.path, ri.format)
|
|
default:
|
|
fmt.Fprintf(out, " input: env:%s (%s)\n", ri.connEnv, ri.format)
|
|
}
|
|
}
|
|
for _, d := range rj.scriptDirs {
|
|
fmt.Fprintf(out, " script dir: %s\n", d)
|
|
}
|
|
if rj.outputPath != "" {
|
|
fmt.Fprintf(out, " output: %s (%s)\n", rj.outputPath, j.Output.Format)
|
|
} else if rj.outputConnEnv != "" {
|
|
fmt.Fprintf(out, " output: env:%s (%s)\n", rj.outputConnEnv, j.Output.Format)
|
|
}
|
|
if j.Report != nil {
|
|
format := valueOr(rj.reportFormat, "default")
|
|
if rj.reportPath != "" {
|
|
fmt.Fprintf(out, " report: %s (%s)\n", rj.reportPath, format)
|
|
} else {
|
|
fmt.Fprintf(out, " report: (log) (%s)\n", format)
|
|
}
|
|
}
|
|
if rj.rulesPath != "" {
|
|
fmt.Fprintf(out, " rules: %s\n", rj.rulesPath)
|
|
} else if j.Command == jobs.CommandInspect {
|
|
fmt.Fprintf(out, " rules: (built-in defaults)\n")
|
|
}
|
|
if rj.selection != nil {
|
|
fmt.Fprintf(out, " select: %s\n", rj.selection.summary())
|
|
}
|
|
if rj.logPath != "" {
|
|
fmt.Fprintf(out, " logfile: %s (rotate >= %d bytes, keep %d)\n", rj.logPath, rj.logPolicy.MaxSizeBytes, rj.logPolicy.Keep)
|
|
}
|
|
fmt.Fprintln(out)
|
|
}
|
|
|
|
// executeResolvedJob runs a single already-validated job.
|
|
func executeResolvedJob(rj *resolvedJob) (err error) {
|
|
lg, closeLog, lerr := newJobLogger(rj.logPath, rj.logPolicy, rj.secrets)
|
|
if lerr != nil {
|
|
return lerr
|
|
}
|
|
defer func() { closeLog(err) }()
|
|
|
|
lg.logf("=== job %q (%s) started at %s ===", rj.job.Name, rj.job.Command, time.Now().Format(time.RFC3339))
|
|
|
|
switch rj.job.Command {
|
|
case jobs.CommandConvert:
|
|
err = runConvertJob(rj, lg)
|
|
case jobs.CommandMerge:
|
|
err = runMergeJob(rj, lg)
|
|
case jobs.CommandScriptsList:
|
|
err = runScriptsListJob(rj, lg)
|
|
case jobs.CommandTempl:
|
|
err = runTemplJob(rj, lg)
|
|
case jobs.CommandSplit:
|
|
err = runSplitJob(rj, lg)
|
|
case jobs.CommandInspect:
|
|
err = runInspectJob(rj, lg)
|
|
case jobs.CommandDiff:
|
|
err = runDiffJob(rj, lg)
|
|
case jobs.CommandScriptsExec:
|
|
err = runScriptsExecJob(rj, lg)
|
|
default:
|
|
err = fmt.Errorf("unsupported command %q", rj.job.Command)
|
|
}
|
|
if err != nil {
|
|
lg.logf("FAILED: %v", err)
|
|
} else {
|
|
lg.logf("OK")
|
|
}
|
|
return err
|
|
}
|
|
|
|
func runTemplJob(rj *resolvedJob, lg *jobLogger) error {
|
|
db, err := readJobInputs(rj, lg)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if schema := rj.job.Options.Schema; schema != "" {
|
|
found := false
|
|
for _, s := range db.Schemas {
|
|
if s.Name == schema {
|
|
db.Schemas = []*models.Schema{s}
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
return fmt.Errorf("schema not found: %s", schema)
|
|
}
|
|
}
|
|
mode := rj.job.Mode
|
|
if mode == "" {
|
|
mode = "database"
|
|
}
|
|
pattern := rj.job.FilenamePattern
|
|
if pattern == "" {
|
|
pattern = "{{.Name}}.txt"
|
|
}
|
|
writer, err := wtemplate.NewWriter(&writers.WriterOptions{
|
|
OutputPath: rj.outputPath,
|
|
Metadata: map[string]interface{}{
|
|
"template_path": rj.templatePath,
|
|
"mode": mode,
|
|
"filename_pattern": pattern,
|
|
},
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("create template writer: %w", err)
|
|
}
|
|
lg.logf("applying template: %s (mode %s)", rj.templatePath, mode)
|
|
if err := writer.WriteDatabase(db); err != nil {
|
|
return fmt.Errorf("execute template: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func runConvertJob(rj *resolvedJob, lg *jobLogger) error {
|
|
db, err := readJobInputs(rj, lg)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return writeJobOutput(rj, db, lg)
|
|
}
|
|
|
|
func runMergeJob(rj *resolvedJob, lg *jobLogger) error {
|
|
opts := &merge.MergeOptions{
|
|
SkipDomains: rj.job.Options.SkipDomains,
|
|
SkipRelations: rj.job.Options.SkipRelations,
|
|
SkipEnums: rj.job.Options.SkipEnums,
|
|
SkipViews: rj.job.Options.SkipViews,
|
|
SkipSequences: rj.job.Options.SkipSequences,
|
|
}
|
|
var base *models.Database
|
|
for i, ri := range rj.inputs {
|
|
db, err := readOneJobInput(ri)
|
|
if err != nil {
|
|
return fmt.Errorf("input[%d]: %w", i, err)
|
|
}
|
|
if base == nil {
|
|
base = db
|
|
lg.logf("merge target: %s", inputLabel(ri))
|
|
continue
|
|
}
|
|
lg.logf("merging: %s", inputLabel(ri))
|
|
merge.MergeDatabases(base, db, opts)
|
|
}
|
|
base.UpdateDate()
|
|
return writeJobOutput(rj, base, lg)
|
|
}
|
|
|
|
func runScriptsListJob(rj *resolvedJob, lg *jobLogger) error {
|
|
type row struct {
|
|
priority int
|
|
sequence uint
|
|
name string
|
|
dir string
|
|
lines int
|
|
}
|
|
var rows []row
|
|
for _, dir := range rj.scriptDirs {
|
|
reader := sqldir.NewReader(&readers.ReaderOptions{
|
|
FilePath: dir,
|
|
Metadata: map[string]any{
|
|
"schema_name": valueOr(rj.job.Options.Schema, "public"),
|
|
"database_name": "database",
|
|
},
|
|
})
|
|
db, err := reader.ReadDatabase()
|
|
if err != nil {
|
|
return fmt.Errorf("%s: %w", dir, err)
|
|
}
|
|
if len(db.Schemas) == 0 {
|
|
continue
|
|
}
|
|
for _, s := range db.Schemas[0].Scripts {
|
|
lines := strings.Count(s.SQL, "\n")
|
|
if len(s.SQL) > 0 && !strings.HasSuffix(s.SQL, "\n") {
|
|
lines++
|
|
}
|
|
rows = append(rows, row{s.Priority, s.Sequence, s.Name, dir, lines})
|
|
}
|
|
}
|
|
sort.Slice(rows, func(i, j int) bool {
|
|
if rows[i].priority != rows[j].priority {
|
|
return rows[i].priority < rows[j].priority
|
|
}
|
|
if rows[i].sequence != rows[j].sequence {
|
|
return rows[i].sequence < rows[j].sequence
|
|
}
|
|
if rows[i].name != rows[j].name {
|
|
return rows[i].name < rows[j].name
|
|
}
|
|
return rows[i].dir < rows[j].dir
|
|
})
|
|
lg.logf("found %d script(s) across %d director(y/ies):", len(rows), len(rj.scriptDirs))
|
|
lg.logf("%-4s %-9s %-9s %-30s %-6s %s", "No.", "Priority", "Sequence", "Name", "Lines", "Directory")
|
|
for i, r := range rows {
|
|
lg.logf("%-4d %-9d %-9d %-30s %-6d %s", i+1, r.priority, r.sequence, r.name, r.lines, r.dir)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func runSplitJob(rj *resolvedJob, lg *jobLogger) error {
|
|
db, err := readJobInputs(rj, lg)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
sel := splitSelection{}
|
|
if rj.selection != nil {
|
|
sel = *rj.selection
|
|
}
|
|
filtered, err := filterDatabaseSelection(db, sel)
|
|
if err != nil {
|
|
return fmt.Errorf("split selection: %w", err)
|
|
}
|
|
if sel.DatabaseName != "" {
|
|
filtered.Name = sel.DatabaseName
|
|
}
|
|
tables := 0
|
|
for _, s := range filtered.Schemas {
|
|
tables += len(s.Tables)
|
|
}
|
|
lg.logf("split: selected %d schema(s), %d table(s)", len(filtered.Schemas), tables)
|
|
return writeJobOutput(rj, filtered, lg)
|
|
}
|
|
|
|
func runInspectJob(rj *resolvedJob, lg *jobLogger) error {
|
|
db, err := readJobInputs(rj, lg)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
config, err := inspector.LoadConfig(rj.rulesPath) // "" -> built-in defaults
|
|
if err != nil {
|
|
return fmt.Errorf("load rules: %w", err)
|
|
}
|
|
report, err := inspector.NewInspector(db, config).Inspect()
|
|
if err != nil {
|
|
return fmt.Errorf("inspection failed: %w", err)
|
|
}
|
|
|
|
var formatted string
|
|
switch valueOr(rj.reportFormat, "markdown") {
|
|
case "json":
|
|
formatted, err = inspector.NewJSONFormatter().Format(report)
|
|
default:
|
|
formatted, err = inspector.NewMarkdownFormatter(io.Discard).Format(report)
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("format report: %w", err)
|
|
}
|
|
if werr := atomicWrite(rj.reportPath, func(tmp string) error {
|
|
return os.WriteFile(tmp, []byte(formatted), 0o644)
|
|
}); werr != nil {
|
|
return werr
|
|
}
|
|
lg.logf("inspect: %d error(s), %d warning(s) -> %s",
|
|
report.Summary.ErrorCount, report.Summary.WarningCount, rj.reportPath)
|
|
if report.HasErrors() {
|
|
return fmt.Errorf("inspection found %d error(s)", report.Summary.ErrorCount)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func runDiffJob(rj *resolvedJob, lg *jobLogger) error {
|
|
if len(rj.inputs) != 2 {
|
|
return fmt.Errorf("diff requires exactly 2 inputs, got %d", len(rj.inputs))
|
|
}
|
|
source, err := readOneJobInput(rj.inputs[0])
|
|
if err != nil {
|
|
return fmt.Errorf("input[0]: %w", err)
|
|
}
|
|
lg.logf("diff source: %s", inputLabel(rj.inputs[0]))
|
|
target, err := readOneJobInput(rj.inputs[1])
|
|
if err != nil {
|
|
return fmt.Errorf("input[1]: %w", err)
|
|
}
|
|
lg.logf("diff target: %s", inputLabel(rj.inputs[1]))
|
|
|
|
result := diff.CompareDatabases(source, target)
|
|
s := diff.ComputeSummary(result)
|
|
lg.logf("diff: schemas %d/%d/%d, tables %d/%d/%d, columns %d/%d/%d (missing/extra/modified)",
|
|
s.Schemas.Missing, s.Schemas.Extra, s.Schemas.Modified,
|
|
s.Tables.Missing, s.Tables.Extra, s.Tables.Modified,
|
|
s.Columns.Missing, s.Columns.Extra, s.Columns.Modified)
|
|
|
|
format := diff.FormatSummary
|
|
switch rj.reportFormat {
|
|
case "json":
|
|
format = diff.FormatJSON
|
|
case "html":
|
|
format = diff.FormatHTML
|
|
}
|
|
|
|
if rj.reportPath == "" {
|
|
var buf bytes.Buffer
|
|
if err := diff.FormatDiff(result, format, &buf); err != nil {
|
|
return fmt.Errorf("format diff: %w", err)
|
|
}
|
|
for _, line := range strings.Split(strings.TrimRight(buf.String(), "\n"), "\n") {
|
|
lg.logf("%s", line)
|
|
}
|
|
return nil
|
|
}
|
|
if werr := atomicWrite(rj.reportPath, func(tmp string) error {
|
|
f, err := os.Create(tmp)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
return diff.FormatDiff(result, format, f)
|
|
}); werr != nil {
|
|
return werr
|
|
}
|
|
lg.logf("diff report written: %s", rj.reportPath)
|
|
return nil
|
|
}
|
|
|
|
func runScriptsExecJob(rj *resolvedJob, lg *jobLogger) error {
|
|
schemaName := valueOr(rj.job.Options.Schema, "public")
|
|
combined := &models.Schema{Name: schemaName}
|
|
for _, dir := range rj.scriptDirs {
|
|
reader := sqldir.NewReader(&readers.ReaderOptions{
|
|
FilePath: dir,
|
|
Metadata: map[string]any{
|
|
"schema_name": schemaName,
|
|
"database_name": "database",
|
|
},
|
|
})
|
|
db, err := reader.ReadDatabase()
|
|
if err != nil {
|
|
return fmt.Errorf("%s: %w", dir, err)
|
|
}
|
|
if len(db.Schemas) == 0 {
|
|
continue
|
|
}
|
|
combined.Scripts = append(combined.Scripts, db.Schemas[0].Scripts...)
|
|
}
|
|
if len(combined.Scripts) == 0 {
|
|
lg.logf("no scripts found; nothing to execute")
|
|
return nil
|
|
}
|
|
lg.logf("executing %d script(s) against database env:%s", len(combined.Scripts), rj.outputConnEnv)
|
|
|
|
writer := sqlexec.NewWriter(&writers.WriterOptions{
|
|
Metadata: map[string]any{
|
|
"connection_string": rj.outputConn,
|
|
"ignore_errors": rj.job.Options.ContinueOnError,
|
|
},
|
|
})
|
|
if err := writer.WriteSchema(combined); err != nil {
|
|
return fmt.Errorf("script execution failed: %w", err)
|
|
}
|
|
|
|
opts := writer.Options()
|
|
total, _ := opts.Metadata["execution_total"].(int)
|
|
success, _ := opts.Metadata["execution_success"].(int)
|
|
failed, _ := opts.Metadata["execution_failed"].(int)
|
|
lg.logf("executed %d script(s): %d succeeded, %d failed", total, success, failed)
|
|
if failed > 0 && !rj.job.Options.ContinueOnError {
|
|
return fmt.Errorf("%d script(s) failed", failed)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// readJobInputs reads every input and additively merges them into one model.
|
|
func readJobInputs(rj *resolvedJob, lg *jobLogger) (*models.Database, error) {
|
|
var base *models.Database
|
|
for i, ri := range rj.inputs {
|
|
db, err := readOneJobInput(ri)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("input[%d]: %w", i, err)
|
|
}
|
|
lg.logf("read input: %s", inputLabel(ri))
|
|
if base == nil {
|
|
base = db
|
|
} else {
|
|
merge.MergeDatabases(base, db, &merge.MergeOptions{})
|
|
}
|
|
}
|
|
if base == nil {
|
|
return nil, fmt.Errorf("no inputs produced a database")
|
|
}
|
|
return base, nil
|
|
}
|
|
|
|
func readOneJobInput(ri resolvedInput) (*models.Database, error) {
|
|
if ri.conn != "" {
|
|
return readDatabaseForConvert(ri.format, "", ri.conn)
|
|
}
|
|
return readDatabaseForConvert(ri.format, ri.path, "")
|
|
}
|
|
|
|
func inputLabel(ri resolvedInput) string {
|
|
if ri.path != "" {
|
|
return fmt.Sprintf("%s (%s)", ri.path, ri.format)
|
|
}
|
|
return fmt.Sprintf("env:%s (%s)", ri.connEnv, ri.format)
|
|
}
|
|
|
|
// writeJobOutput writes db to the job's output target (file or database).
|
|
func writeJobOutput(rj *resolvedJob, db *models.Database, lg *jobLogger) error {
|
|
o := rj.job.Options
|
|
format := strings.ToLower(rj.job.Output.Format)
|
|
|
|
if rj.outputConn != "" {
|
|
if format != "pgsql" {
|
|
return fmt.Errorf("database output is only supported for pgsql (got %q)", rj.job.Output.Format)
|
|
}
|
|
lg.logf("writing output to database env:%s", rj.outputConnEnv)
|
|
writerOpts := newWriterOptions("", o.Package, o.FlattenSchema, "", "", o.ContinueOnError)
|
|
writerOpts.Metadata = map[string]interface{}{"connection_string": rj.outputConn}
|
|
return wpgsql.NewWriter(writerOpts).WriteDatabase(db)
|
|
}
|
|
|
|
if err := os.MkdirAll(filepath.Dir(rj.outputPath), 0o755); err != nil {
|
|
return fmt.Errorf("failed to create output directory: %w", err)
|
|
}
|
|
lg.logf("writing output: %s (%s)", rj.outputPath, format)
|
|
|
|
write := func(target string) error {
|
|
return writeDatabase(db, format, target, o.Package, o.Schema, o.FlattenSchema, "", "", o.ContinueOnError, "")
|
|
}
|
|
// Single-file formats are written to a temp file and renamed into place so
|
|
// a failure never leaves a partial or truncated output. Directory-emitting
|
|
// formats (gorm/bun/drizzle/typeorm/prisma) write in place.
|
|
if jobs.SingleFileOutputFormat(format) {
|
|
return atomicWrite(rj.outputPath, write)
|
|
}
|
|
return write(rj.outputPath)
|
|
}
|
|
|
|
// atomicWrite calls produce with a temp path in the same directory as
|
|
// finalPath, then renames it over finalPath. The temp file is removed on any
|
|
// error so the destination is only ever replaced by a complete file.
|
|
func atomicWrite(finalPath string, produce func(tmpPath string) error) error {
|
|
dir := filepath.Dir(finalPath)
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
return fmt.Errorf("failed to create output directory: %w", err)
|
|
}
|
|
tmp := filepath.Join(dir, fmt.Sprintf(".%s.relspec-tmp-%d", filepath.Base(finalPath), os.Getpid()))
|
|
if err := produce(tmp); err != nil {
|
|
_ = os.Remove(tmp)
|
|
return err
|
|
}
|
|
if err := os.Rename(tmp, finalPath); err != nil {
|
|
_ = os.Remove(tmp)
|
|
return fmt.Errorf("failed to finalize %s: %w", finalPath, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// --- logging + redaction ---------------------------------------------------
|
|
|
|
type jobLogger struct {
|
|
file io.Writer
|
|
secrets []string
|
|
}
|
|
|
|
// newJobLogger returns a logger that mirrors to stderr and, when path is set,
|
|
// to a job logfile. Connection strings and known secret values are redacted
|
|
// from everything it writes.
|
|
func newJobLogger(path string, policy jobs.LogPolicy, secrets []string) (*jobLogger, func(err error), error) {
|
|
lg := &jobLogger{secrets: secrets}
|
|
if path == "" {
|
|
return lg, func(error) {}, nil
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
return nil, nil, fmt.Errorf("failed to create log directory: %w", err)
|
|
}
|
|
rotateLogIfNeeded(path, policy)
|
|
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("failed to open logfile %q: %w", path, err)
|
|
}
|
|
lg.file = f
|
|
return lg, func(runErr error) {
|
|
if runErr != nil {
|
|
fmt.Fprintf(f, "%s job ended with error\n", time.Now().Format(time.RFC3339))
|
|
}
|
|
_ = f.Close()
|
|
}, nil
|
|
}
|
|
|
|
// rotateLogIfNeeded renames path -> path.1 -> path.2 ... up to policy.Keep
|
|
// when path has grown to policy.MaxSizeBytes or more. The oldest file beyond
|
|
// Keep is deleted. A zero/negative MaxSizeBytes disables rotation.
|
|
func rotateLogIfNeeded(path string, policy jobs.LogPolicy) {
|
|
if policy.MaxSizeBytes <= 0 {
|
|
return
|
|
}
|
|
info, err := os.Stat(path)
|
|
if err != nil || info.Size() < policy.MaxSizeBytes {
|
|
return
|
|
}
|
|
if policy.Keep < 1 {
|
|
_ = os.Remove(path)
|
|
return
|
|
}
|
|
_ = os.Remove(fmt.Sprintf("%s.%d", path, policy.Keep))
|
|
for i := policy.Keep - 1; i >= 1; i-- {
|
|
_ = os.Rename(fmt.Sprintf("%s.%d", path, i), fmt.Sprintf("%s.%d", path, i+1))
|
|
}
|
|
_ = os.Rename(path, path+".1")
|
|
}
|
|
|
|
func (l *jobLogger) logf(format string, args ...interface{}) {
|
|
line := l.redact(fmt.Sprintf(format, args...))
|
|
fmt.Fprintf(os.Stderr, " %s\n", line)
|
|
if l.file != nil {
|
|
fmt.Fprintf(l.file, "%s %s\n", time.Now().Format(time.RFC3339), line)
|
|
}
|
|
}
|
|
|
|
func (l *jobLogger) redact(s string) string {
|
|
for _, sec := range l.secrets {
|
|
if sec != "" {
|
|
s = strings.ReplaceAll(s, sec, "***")
|
|
}
|
|
}
|
|
return maskPassword(s)
|
|
}
|
|
|
|
// --- small helpers -------------------------------------------------------
|
|
|
|
func maxInt(a, b int) int {
|
|
if a > b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
func valueOr(v, def string) string {
|
|
if v == "" {
|
|
return def
|
|
}
|
|
return v
|
|
}
|