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.
685 lines
20 KiB
Go
685 lines
20 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/spf13/cobra"
|
|
|
|
"git.warky.dev/wdevs/relspecgo/pkg/jobs"
|
|
)
|
|
|
|
func writeFile(t *testing.T, path, content string) {
|
|
t.Helper()
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// jobFixture creates a job-file project with two DBML sources and returns the
|
|
// project directory.
|
|
func jobFixture(t *testing.T, manifest string) string {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
writeFile(t, filepath.Join(dir, "schema", "core.dbml"), "Table users {\n id int [pk]\n name varchar\n}\n")
|
|
writeFile(t, filepath.Join(dir, "schema", "tenant.dbml"), "Table posts {\n id int [pk]\n title varchar\n}\n")
|
|
writeFile(t, filepath.Join(dir, "relspec.yml"), manifest)
|
|
return dir
|
|
}
|
|
|
|
func mustLoadSet(t *testing.T, files ...string) *jobs.Set {
|
|
t.Helper()
|
|
set, err := jobs.Load(files)
|
|
if err != nil {
|
|
t.Fatalf("load: %v", err)
|
|
}
|
|
if err := set.Validate(); err != nil {
|
|
t.Fatalf("validate: %v", err)
|
|
}
|
|
return set
|
|
}
|
|
|
|
const convertMergeManifest = `version: 1
|
|
jobs:
|
|
build-schema:
|
|
command: convert
|
|
description: Merge DBML sources to PostgreSQL DDL
|
|
inputs:
|
|
- path: schema/core.dbml
|
|
format: dbml
|
|
- path: schema/tenant.dbml
|
|
format: dbml
|
|
output:
|
|
format: pgsql
|
|
path: build/schema.sql
|
|
overwrite: true
|
|
logfile: .relspec/log/build.log
|
|
`
|
|
|
|
func TestJobRun_ConvertMultiFileMerge(t *testing.T) {
|
|
dir := jobFixture(t, convertMergeManifest)
|
|
set := mustLoadSet(t, filepath.Join(dir, "relspec.yml"))
|
|
|
|
if err := executeJobPlan(set, "build-schema", false, false, &bytes.Buffer{}); err != nil {
|
|
t.Fatalf("executeJobPlan: %v", err)
|
|
}
|
|
|
|
out, err := os.ReadFile(filepath.Join(dir, "build", "schema.sql"))
|
|
if err != nil {
|
|
t.Fatalf("expected output file: %v", err)
|
|
}
|
|
sql := string(out)
|
|
if !strings.Contains(sql, "users") || !strings.Contains(sql, "posts") {
|
|
t.Fatalf("merged output missing tables:\n%s", sql)
|
|
}
|
|
|
|
logData, err := os.ReadFile(filepath.Join(dir, ".relspec", "log", "build.log"))
|
|
if err != nil {
|
|
t.Fatalf("expected logfile: %v", err)
|
|
}
|
|
if !strings.Contains(string(logData), "OK") {
|
|
t.Fatalf("logfile missing success marker:\n%s", logData)
|
|
}
|
|
}
|
|
|
|
func TestJobRun_DryRunDoesNotExecute(t *testing.T) {
|
|
dir := jobFixture(t, convertMergeManifest)
|
|
set := mustLoadSet(t, filepath.Join(dir, "relspec.yml"))
|
|
|
|
var buf bytes.Buffer
|
|
if err := executeJobPlan(set, "build-schema", true, false, &buf); err != nil {
|
|
t.Fatalf("dry run error: %v", err)
|
|
}
|
|
if !strings.Contains(buf.String(), "dry run") {
|
|
t.Fatalf("expected dry-run banner, got: %s", buf.String())
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dir, "build", "schema.sql")); !os.IsNotExist(err) {
|
|
t.Fatal("dry run must not create the output file")
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dir, ".relspec", "log", "build.log")); !os.IsNotExist(err) {
|
|
t.Fatal("dry run must not create the logfile")
|
|
}
|
|
}
|
|
|
|
func TestJobRun_ValidationFailureNoExecution(t *testing.T) {
|
|
badManifest := `version: 1
|
|
jobs:
|
|
evil:
|
|
command: convert
|
|
inputs:
|
|
- path: ../../../etc/passwd
|
|
format: dbml
|
|
output:
|
|
format: json
|
|
path: build/out.json
|
|
logfile: .relspec/evil.log
|
|
`
|
|
dir := jobFixture(t, badManifest)
|
|
if _, err := jobs.Load([]string{filepath.Join(dir, "relspec.yml")}); err != nil {
|
|
// structural load ok; validation should reject
|
|
t.Fatalf("unexpected load error: %v", err)
|
|
}
|
|
set, _ := jobs.Load([]string{filepath.Join(dir, "relspec.yml")})
|
|
if err := set.Validate(); err == nil {
|
|
t.Fatal("expected validation failure for path traversal")
|
|
}
|
|
// Nothing should have been produced.
|
|
if _, err := os.Stat(filepath.Join(dir, "build")); !os.IsNotExist(err) {
|
|
t.Fatal("validation failure must not create output dir")
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dir, ".relspec")); !os.IsNotExist(err) {
|
|
t.Fatal("validation failure must not create logfile dir")
|
|
}
|
|
}
|
|
|
|
func TestJobRun_MissingInputNoExecution(t *testing.T) {
|
|
manifest := `version: 1
|
|
jobs:
|
|
x:
|
|
command: convert
|
|
inputs:
|
|
- path: schema/does-not-exist.dbml
|
|
format: dbml
|
|
output:
|
|
format: json
|
|
path: build/out.json
|
|
logfile: .relspec/x.log
|
|
`
|
|
dir := jobFixture(t, manifest)
|
|
set := mustLoadSet(t, filepath.Join(dir, "relspec.yml"))
|
|
|
|
err := executeJobPlan(set, "x", false, false, &bytes.Buffer{})
|
|
if err == nil || !strings.Contains(err.Error(), "not found") {
|
|
t.Fatalf("expected missing-input error, got %v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dir, "build")); !os.IsNotExist(err) {
|
|
t.Fatal("missing input must not create output dir")
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dir, ".relspec")); !os.IsNotExist(err) {
|
|
t.Fatal("missing input must not create logfile")
|
|
}
|
|
}
|
|
|
|
func TestJobRun_MissingConnEnvNoExecution(t *testing.T) {
|
|
manifest := `version: 1
|
|
jobs:
|
|
remote:
|
|
command: convert
|
|
inputs:
|
|
- format: pgsql
|
|
conn_env: RELSPEC_TEST_MISSING_CONN
|
|
output:
|
|
format: json
|
|
path: build/out.json
|
|
logfile: .relspec/remote.log
|
|
`
|
|
dir := jobFixture(t, manifest)
|
|
os.Unsetenv("RELSPEC_TEST_MISSING_CONN")
|
|
set := mustLoadSet(t, filepath.Join(dir, "relspec.yml"))
|
|
|
|
err := executeJobPlan(set, "remote", false, false, &bytes.Buffer{})
|
|
if err == nil || !strings.Contains(err.Error(), "conn_env") {
|
|
t.Fatalf("expected missing conn_env error, got %v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dir, ".relspec")); !os.IsNotExist(err) {
|
|
t.Fatal("missing conn_env must not create logfile")
|
|
}
|
|
}
|
|
|
|
func TestJobRun_ExitCodePropagation(t *testing.T) {
|
|
// gorm output without options.package makes the underlying writer fail.
|
|
manifest := `version: 1
|
|
jobs:
|
|
fail:
|
|
command: convert
|
|
inputs:
|
|
- path: schema/core.dbml
|
|
format: dbml
|
|
output:
|
|
format: gorm
|
|
path: build/models
|
|
overwrite: true
|
|
logfile: .relspec/fail.log
|
|
`
|
|
dir := jobFixture(t, manifest)
|
|
set := mustLoadSet(t, filepath.Join(dir, "relspec.yml"))
|
|
|
|
err := executeJobPlan(set, "fail", false, false, &bytes.Buffer{})
|
|
if err == nil {
|
|
t.Fatal("expected underlying failure to propagate")
|
|
}
|
|
if !strings.Contains(err.Error(), "job \"fail\" failed") {
|
|
t.Fatalf("error should identify the failing job: %v", err)
|
|
}
|
|
// Logfile records the failure and no misleading success marker.
|
|
logData, _ := os.ReadFile(filepath.Join(dir, ".relspec", "fail.log"))
|
|
if strings.Contains(string(logData), "\nOK\n") || strings.HasSuffix(strings.TrimSpace(string(logData)), "OK") {
|
|
t.Fatalf("failed job must not log OK:\n%s", logData)
|
|
}
|
|
if !strings.Contains(string(logData), "FAILED") {
|
|
t.Fatalf("failed job should log FAILED:\n%s", logData)
|
|
}
|
|
}
|
|
|
|
func TestJobRun_DependencyChainExecutes(t *testing.T) {
|
|
manifest := `version: 1
|
|
jobs:
|
|
a:
|
|
command: convert
|
|
inputs:
|
|
- path: schema/core.dbml
|
|
format: dbml
|
|
output:
|
|
format: json
|
|
path: build/a.json
|
|
overwrite: true
|
|
b:
|
|
command: convert
|
|
depends_on: [a]
|
|
inputs:
|
|
- path: schema/tenant.dbml
|
|
format: dbml
|
|
output:
|
|
format: json
|
|
path: build/b.json
|
|
overwrite: true
|
|
`
|
|
dir := jobFixture(t, manifest)
|
|
set := mustLoadSet(t, filepath.Join(dir, "relspec.yml"))
|
|
|
|
if err := executeJobPlan(set, "b", false, false, &bytes.Buffer{}); err != nil {
|
|
t.Fatalf("executeJobPlan: %v", err)
|
|
}
|
|
for _, f := range []string{"a.json", "b.json"} {
|
|
if _, err := os.Stat(filepath.Join(dir, "build", f)); err != nil {
|
|
t.Fatalf("expected %s to be produced: %v", f, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestJobRun_ScriptsListMultipleDirs(t *testing.T) {
|
|
dir := t.TempDir()
|
|
writeFile(t, filepath.Join(dir, "migrations", "core", "1_001_create_users.sql"), "CREATE TABLE users();\n")
|
|
writeFile(t, filepath.Join(dir, "migrations", "tenant", "1_002_create_posts.sql"), "CREATE TABLE posts();\n")
|
|
writeFile(t, filepath.Join(dir, "migrations", "tenant", "2_001_add_index.sql"), "CREATE INDEX x ON posts(id);\n")
|
|
manifest := `version: 1
|
|
jobs:
|
|
list-all:
|
|
command: scripts-list
|
|
script_dirs:
|
|
- migrations/core
|
|
- migrations/tenant
|
|
logfile: .relspec/scripts.log
|
|
`
|
|
writeFile(t, filepath.Join(dir, "relspec.yml"), manifest)
|
|
set := mustLoadSet(t, filepath.Join(dir, "relspec.yml"))
|
|
|
|
if err := executeJobPlan(set, "list-all", false, false, &bytes.Buffer{}); err != nil {
|
|
t.Fatalf("executeJobPlan: %v", err)
|
|
}
|
|
logData, err := os.ReadFile(filepath.Join(dir, ".relspec", "scripts.log"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s := string(logData)
|
|
iUsers := strings.Index(s, "create_users")
|
|
iPosts := strings.Index(s, "create_posts")
|
|
iIndex := strings.Index(s, "add_index")
|
|
if iUsers < 0 || iPosts < 0 || iIndex < 0 {
|
|
t.Fatalf("expected all scripts listed:\n%s", s)
|
|
}
|
|
if !(iUsers < iPosts && iPosts < iIndex) {
|
|
t.Fatalf("scripts not in priority/sequence order:\n%s", s)
|
|
}
|
|
if !strings.Contains(s, "found 3 script(s) across 2") {
|
|
t.Fatalf("expected multi-directory summary:\n%s", s)
|
|
}
|
|
}
|
|
|
|
func TestJobRun_ConnEnvRedactedInPlan(t *testing.T) {
|
|
manifest := `version: 1
|
|
jobs:
|
|
remote:
|
|
command: convert
|
|
inputs:
|
|
- format: pgsql
|
|
conn_env: RELSPEC_TEST_PLAN_CONN
|
|
output:
|
|
format: json
|
|
path: build/out.json
|
|
`
|
|
dir := jobFixture(t, manifest)
|
|
secret := "postgres://user:supersecret@db.example/app"
|
|
t.Setenv("RELSPEC_TEST_PLAN_CONN", secret)
|
|
set := mustLoadSet(t, filepath.Join(dir, "relspec.yml"))
|
|
|
|
var buf bytes.Buffer
|
|
if err := executeJobPlan(set, "remote", true, false, &buf); err != nil {
|
|
t.Fatalf("dry run: %v", err)
|
|
}
|
|
if strings.Contains(buf.String(), "supersecret") || strings.Contains(buf.String(), secret) {
|
|
t.Fatalf("plan leaked secret:\n%s", buf.String())
|
|
}
|
|
if !strings.Contains(buf.String(), "env:RELSPEC_TEST_PLAN_CONN") {
|
|
t.Fatalf("plan should reference the env var name:\n%s", buf.String())
|
|
}
|
|
}
|
|
|
|
func TestJobLogger_Redaction(t *testing.T) {
|
|
lg := &jobLogger{secrets: []string{"topsecret"}}
|
|
got := lg.redact("connecting with password topsecret and postgres://u:p@h/db")
|
|
if strings.Contains(got, "topsecret") {
|
|
t.Fatalf("secret not redacted: %q", got)
|
|
}
|
|
if !strings.Contains(got, "***") {
|
|
t.Fatalf("expected redaction marker: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestJobList_DeterministicOutput(t *testing.T) {
|
|
manifest := `version: 1
|
|
jobs:
|
|
zebra:
|
|
command: convert
|
|
inputs: [{path: schema/core.dbml, format: dbml}]
|
|
output: {format: json, path: build/z.json}
|
|
alpha:
|
|
command: convert
|
|
inputs: [{path: schema/core.dbml, format: dbml}]
|
|
output: {format: json, path: build/a.json}
|
|
`
|
|
dir := jobFixture(t, manifest)
|
|
|
|
run := func() string {
|
|
jobDir = dir
|
|
jobFiles = nil
|
|
cmd := &cobra.Command{}
|
|
var buf bytes.Buffer
|
|
cmd.SetOut(&buf)
|
|
if err := runJobList(cmd, nil); err != nil {
|
|
t.Fatalf("runJobList: %v", err)
|
|
}
|
|
return buf.String()
|
|
}
|
|
first := run()
|
|
if strings.Index(first, "alpha") > strings.Index(first, "zebra") {
|
|
t.Fatalf("jobs not sorted:\n%s", first)
|
|
}
|
|
if first != run() {
|
|
t.Fatal("job list output not deterministic")
|
|
}
|
|
}
|
|
|
|
func TestJobRun_SplitJob(t *testing.T) {
|
|
dir := jobFixture(t, `version: 1
|
|
jobs:
|
|
extract:
|
|
command: split
|
|
inputs:
|
|
- path: schema/core.dbml
|
|
format: dbml
|
|
- path: schema/tenant.dbml
|
|
format: dbml
|
|
select:
|
|
tables: [users]
|
|
output:
|
|
format: json
|
|
path: build/subset.json
|
|
overwrite: true
|
|
`)
|
|
set := mustLoadSet(t, filepath.Join(dir, "relspec.yml"))
|
|
if err := executeJobPlan(set, "extract", false, false, &bytes.Buffer{}); err != nil {
|
|
t.Fatalf("execute split job: %v", err)
|
|
}
|
|
out, err := os.ReadFile(filepath.Join(dir, "build", "subset.json"))
|
|
if err != nil {
|
|
t.Fatalf("read split output: %v", err)
|
|
}
|
|
s := string(out)
|
|
if !strings.Contains(s, "users") {
|
|
t.Fatalf("split output missing selected table:\n%s", s)
|
|
}
|
|
if strings.Contains(s, "posts") {
|
|
t.Fatalf("split output should have excluded posts:\n%s", s)
|
|
}
|
|
}
|
|
|
|
func TestJobRun_InspectJob(t *testing.T) {
|
|
dir := jobFixture(t, `version: 1
|
|
jobs:
|
|
lint:
|
|
command: inspect
|
|
inputs:
|
|
- path: schema/core.dbml
|
|
format: dbml
|
|
report:
|
|
format: json
|
|
path: build/report.json
|
|
overwrite: true
|
|
logfile: .relspec/lint.log
|
|
`)
|
|
set := mustLoadSet(t, filepath.Join(dir, "relspec.yml"))
|
|
// Default rules only warn, so the job succeeds.
|
|
if err := executeJobPlan(set, "lint", false, false, &bytes.Buffer{}); err != nil {
|
|
t.Fatalf("execute inspect job: %v", err)
|
|
}
|
|
if _, err := os.ReadFile(filepath.Join(dir, "build", "report.json")); err != nil {
|
|
t.Fatalf("expected report file: %v", err)
|
|
}
|
|
logData, _ := os.ReadFile(filepath.Join(dir, ".relspec", "lint.log"))
|
|
if !strings.Contains(string(logData), "inspect:") {
|
|
t.Fatalf("logfile missing inspect summary:\n%s", logData)
|
|
}
|
|
}
|
|
|
|
func TestJobRun_InspectJobFailsOnRuleError(t *testing.T) {
|
|
dir := jobFixture(t, `version: 1
|
|
jobs:
|
|
lint:
|
|
command: inspect
|
|
inputs:
|
|
- path: schema/core.dbml
|
|
format: dbml
|
|
rules: rules.yaml
|
|
report:
|
|
format: json
|
|
path: build/report.json
|
|
overwrite: true
|
|
logfile: .relspec/lint.log
|
|
`)
|
|
// A rule set to "error" level for a violation the fixture triggers.
|
|
writeFile(t, filepath.Join(dir, "rules.yaml"), `version: "1.0"
|
|
rules:
|
|
primary_key_naming:
|
|
enabled: enforce
|
|
function: primary_key_naming
|
|
pattern: "^id_"
|
|
message: "Primary key columns should start with id_"
|
|
`)
|
|
set := mustLoadSet(t, filepath.Join(dir, "relspec.yml"))
|
|
err := executeJobPlan(set, "lint", false, false, &bytes.Buffer{})
|
|
if err == nil || !strings.Contains(err.Error(), "error(s)") {
|
|
t.Fatalf("expected inspect job to fail on rule error, got %v", err)
|
|
}
|
|
logData, _ := os.ReadFile(filepath.Join(dir, ".relspec", "lint.log"))
|
|
if !strings.Contains(string(logData), "FAILED") {
|
|
t.Fatalf("failed inspect job should log FAILED:\n%s", logData)
|
|
}
|
|
}
|
|
|
|
func TestJobRun_DiffJob(t *testing.T) {
|
|
dir := jobFixture(t, `version: 1
|
|
jobs:
|
|
compare:
|
|
command: diff
|
|
inputs:
|
|
- path: schema/core.dbml
|
|
format: dbml
|
|
- path: schema/tenant.dbml
|
|
format: dbml
|
|
report:
|
|
format: json
|
|
path: build/diff.json
|
|
overwrite: true
|
|
`)
|
|
set := mustLoadSet(t, filepath.Join(dir, "relspec.yml"))
|
|
if err := executeJobPlan(set, "compare", false, false, &bytes.Buffer{}); err != nil {
|
|
t.Fatalf("execute diff job: %v", err)
|
|
}
|
|
out, err := os.ReadFile(filepath.Join(dir, "build", "diff.json"))
|
|
if err != nil {
|
|
t.Fatalf("read diff report: %v", err)
|
|
}
|
|
if len(out) == 0 {
|
|
t.Fatal("diff report is empty")
|
|
}
|
|
}
|
|
|
|
func TestJobRun_FromJobWiring(t *testing.T) {
|
|
dir := jobFixture(t, `version: 1
|
|
jobs:
|
|
a:
|
|
command: convert
|
|
inputs:
|
|
- path: schema/core.dbml
|
|
format: dbml
|
|
output:
|
|
format: json
|
|
path: build/a.json
|
|
overwrite: true
|
|
b:
|
|
command: convert
|
|
inputs:
|
|
- from_job: a
|
|
output:
|
|
format: yaml
|
|
path: build/b.yaml
|
|
overwrite: true
|
|
`)
|
|
set := mustLoadSet(t, filepath.Join(dir, "relspec.yml"))
|
|
if err := executeJobPlan(set, "b", false, false, &bytes.Buffer{}); err != nil {
|
|
t.Fatalf("execute from_job chain: %v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dir, "build", "a.json")); err != nil {
|
|
t.Fatalf("producer output missing: %v", err)
|
|
}
|
|
out, err := os.ReadFile(filepath.Join(dir, "build", "b.yaml"))
|
|
if err != nil {
|
|
t.Fatalf("consumer output missing: %v", err)
|
|
}
|
|
if !strings.Contains(string(out), "users") {
|
|
t.Fatalf("consumer did not consume producer output:\n%s", out)
|
|
}
|
|
}
|
|
|
|
func TestJobRun_LogRotation(t *testing.T) {
|
|
dir := jobFixture(t, `version: 1
|
|
jobs:
|
|
build:
|
|
command: scripts-list
|
|
script_dirs: [migrations]
|
|
log_max_size: "150B"
|
|
log_keep: 2
|
|
logfile: .relspec/build.log
|
|
`)
|
|
writeFile(t, filepath.Join(dir, "migrations", "1_001_a.sql"), "CREATE TABLE a();\n")
|
|
logPath := filepath.Join(dir, ".relspec", "build.log")
|
|
writeFile(t, logPath, strings.Repeat("x", 300)+"\n")
|
|
|
|
set := mustLoadSet(t, filepath.Join(dir, "relspec.yml"))
|
|
if err := executeJobPlan(set, "build", false, false, &bytes.Buffer{}); err != nil {
|
|
t.Fatalf("execute job: %v", err)
|
|
}
|
|
rotated, err := os.ReadFile(logPath + ".1")
|
|
if err != nil {
|
|
t.Fatalf("expected rotated logfile build.log.1: %v", err)
|
|
}
|
|
if !strings.Contains(string(rotated), strings.Repeat("x", 300)) {
|
|
t.Fatalf("rotated logfile should hold the old content")
|
|
}
|
|
fresh, err := os.ReadFile(logPath)
|
|
if err != nil {
|
|
t.Fatalf("expected fresh logfile: %v", err)
|
|
}
|
|
if strings.Contains(string(fresh), strings.Repeat("x", 300)) {
|
|
t.Fatalf("fresh logfile should not contain the rotated-out content:\n%s", fresh)
|
|
}
|
|
if !strings.Contains(string(fresh), "OK") {
|
|
t.Fatalf("fresh logfile should hold the new run:\n%s", fresh)
|
|
}
|
|
}
|
|
|
|
func TestJobRun_AtomicOutputLeavesOriginalOnFailure(t *testing.T) {
|
|
dir := jobFixture(t, `version: 1
|
|
jobs:
|
|
x:
|
|
command: convert
|
|
inputs:
|
|
- path: schema/core.dbml
|
|
format: dbml
|
|
output:
|
|
format: json
|
|
path: build/out.json
|
|
overwrite: true
|
|
`)
|
|
// Seed the destination, then make its parent directory read-only so the
|
|
// rename step fails. The seeded file must survive intact.
|
|
seeded := filepath.Join(dir, "build", "out.json")
|
|
writeFile(t, seeded, `{"seeded":true}`)
|
|
if err := os.Chmod(filepath.Join(dir, "build"), 0o500); err != nil {
|
|
t.Skipf("cannot chmod: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = os.Chmod(filepath.Join(dir, "build"), 0o755) })
|
|
|
|
set := mustLoadSet(t, filepath.Join(dir, "relspec.yml"))
|
|
if err := executeJobPlan(set, "x", false, false, &bytes.Buffer{}); err == nil {
|
|
t.Skip("write unexpectedly succeeded (running as root?)")
|
|
}
|
|
if err := os.Chmod(filepath.Join(dir, "build"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
data, err := os.ReadFile(seeded)
|
|
if err != nil {
|
|
t.Fatalf("seeded file gone: %v", err)
|
|
}
|
|
if !strings.Contains(string(data), "seeded") {
|
|
t.Fatalf("seeded file was corrupted: %s", data)
|
|
}
|
|
}
|
|
|
|
func TestJobRun_ScriptsExecMissingConnEnv(t *testing.T) {
|
|
dir := jobFixture(t, `version: 1
|
|
jobs:
|
|
migrate:
|
|
command: scripts-exec
|
|
script_dirs: [migrations]
|
|
output:
|
|
conn_env: RELSPEC_TEST_EXEC_MISSING
|
|
logfile: .relspec/migrate.log
|
|
`)
|
|
writeFile(t, filepath.Join(dir, "migrations", "1_001_a.sql"), "CREATE TABLE a();\n")
|
|
os.Unsetenv("RELSPEC_TEST_EXEC_MISSING")
|
|
set := mustLoadSet(t, filepath.Join(dir, "relspec.yml"))
|
|
err := executeJobPlan(set, "migrate", false, false, &bytes.Buffer{})
|
|
if err == nil || !strings.Contains(err.Error(), "conn_env") {
|
|
t.Fatalf("expected missing conn_env error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestJobRun_ScriptsExecDryRun(t *testing.T) {
|
|
dir := jobFixture(t, `version: 1
|
|
jobs:
|
|
migrate:
|
|
command: scripts-exec
|
|
script_dirs: [migrations]
|
|
output:
|
|
conn_env: RELSPEC_TEST_EXEC_CONN
|
|
`)
|
|
writeFile(t, filepath.Join(dir, "migrations", "1_001_a.sql"), "CREATE TABLE a();\n")
|
|
t.Setenv("RELSPEC_TEST_EXEC_CONN", "postgres://u:secretpw@h/db")
|
|
set := mustLoadSet(t, filepath.Join(dir, "relspec.yml"))
|
|
var buf bytes.Buffer
|
|
if err := executeJobPlan(set, "migrate", true, false, &buf); err != nil {
|
|
t.Fatalf("dry run: %v", err)
|
|
}
|
|
if strings.Contains(buf.String(), "secretpw") {
|
|
t.Fatalf("plan leaked secret:\n%s", buf.String())
|
|
}
|
|
if !strings.Contains(buf.String(), "env:RELSPEC_TEST_EXEC_CONN") {
|
|
t.Fatalf("plan should name the env var:\n%s", buf.String())
|
|
}
|
|
}
|
|
|
|
func TestJobRun_TemplDatabaseMode(t *testing.T) {
|
|
dir := jobFixture(t, `version: 1
|
|
jobs:
|
|
docs:
|
|
command: templ
|
|
inputs:
|
|
- path: schema/core.dbml
|
|
format: dbml
|
|
template: templates/schema.tmpl
|
|
output:
|
|
path: build/schema.txt
|
|
overwrite: true
|
|
`)
|
|
writeFile(t, filepath.Join(dir, "templates", "schema.tmpl"), "{{range .Database.Schemas}}{{range .Tables}}{{.Name}} {{end}}{{end}}")
|
|
set := mustLoadSet(t, filepath.Join(dir, "relspec.yml"))
|
|
if err := executeJobPlan(set, "docs", false, false, &bytes.Buffer{}); err != nil {
|
|
t.Fatalf("execute templ job: %v", err)
|
|
}
|
|
out, err := os.ReadFile(filepath.Join(dir, "build", "schema.txt"))
|
|
if err != nil {
|
|
t.Fatalf("read templ output: %v", err)
|
|
}
|
|
if !strings.Contains(string(out), "users") {
|
|
t.Fatalf("templ output missing users table: %s", out)
|
|
}
|
|
}
|