Skip to content

Commit c2e3d33

Browse files
Implement migrate subcommand (closes #1)
- Add internal/cmd/migrate.go with full implementation: - Execute migration command via execve (no shell) using -- separator - Capture stdout/stderr with timestamps - Optional JSON log output via --json - Forward child process exit code - --workdir flag for file operations (default /work) - --lock-file flag for idempotency (skip if marker file exists) - Add internal/cmd/migrate_test.go with 14 tests covering: - No args error, success, exit code forwarding - stdout/stderr capture, JSON output - Lock file skip, creation, non-creation on failure - Path traversal prevention, command not found - ExitCodeFromError helper, runCommand success/failure - Register migrate command in cmd/initium/main.go - Update docs/usage.md with migrate section - Update CHANGELOG.md
1 parent d2ffd4b commit c2e3d33

6 files changed

Lines changed: 529 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
## [Unreleased]
99

1010
### Added
11+
- `migrate` subcommand: run database migration commands with structured logging, exit code forwarding, and optional idempotency via `--lock-file`
1112
- FAQ.md with functionality, security, and deployment questions for junior-to-mid-level engineers
1213
- Project scaffolding with Go module, CLI framework (cobra), and repo layout
1314
- `wait-for` subcommand: wait for TCP and HTTP(S) endpoints with retries, exponential backoff, and jitter

cmd/initium/context.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88

99
type loggerKey struct{}
1010

11+
// withLogger returns a new context.Context that carries a logger.
1112
func withLogger(ctx context.Context, log *logging.Logger) context.Context {
1213
return context.WithValue(ctx, loggerKey{}, log)
1314
}

docs/usage.md

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,15 +67,52 @@ initium wait-for \
6767

6868
Targets are checked sequentially. All must become reachable before the command succeeds.
6969

70-
### migrate _(coming soon)_
70+
### migrate
7171

72-
Run a database migration command with structured logging.
72+
Run a database migration command with structured logging, exit code forwarding,
73+
and optional idempotency via a lock file.
74+
75+
The command is executed directly via `execve` (no shell). Use `--` to separate
76+
initium flags from the migration command and its arguments.
7377

7478
```bash
79+
# Run a flyway migration
7580
initium migrate -- flyway migrate
81+
82+
# Run with JSON logs
7683
initium migrate --json -- /app/migrate -path /migrations up
84+
85+
# Idempotent: skip if already migrated
86+
initium migrate --lock-file .migrated --workdir /work -- /app/migrate up
7787
```
7888

89+
**Flags:**
90+
91+
| Flag | Default | Description |
92+
|------|---------|-------------|
93+
| `--workdir` | `/work` | Working directory for file operations |
94+
| `--lock-file` | _(none)_ | Skip migration if this file exists in workdir (idempotency) |
95+
| `--json` | `false` | Enable JSON log output |
96+
97+
**Behavior:**
98+
99+
- stdout and stderr from the migration command are captured and logged with timestamps
100+
- The child process exit code is forwarded: a non-zero exit code causes `migrate` to fail
101+
- When `--lock-file` is set:
102+
- If the lock file exists in `--workdir`, the migration is skipped (exit 0)
103+
- On successful completion, the lock file is created so subsequent runs become no-ops
104+
- If the migration fails, no lock file is created
105+
- Lock file paths are validated against `--workdir` to prevent path traversal
106+
- No shell is used: the command is executed directly via `execve`
107+
108+
**Exit codes:**
109+
110+
| Code | Meaning |
111+
|------|---------|
112+
| `0` | Migration succeeded (or skipped via lock file) |
113+
| `1` | Migration command failed, or invalid arguments |
114+
| _N_ | Forwarded from the migration command |
115+
79116
### seed _(coming soon)_
80117

81118
Run a database seed command.

internal/cmd/migrate.go

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
package cmd
2+
3+
import (
4+
"bufio"
5+
"fmt"
6+
"io"
7+
"os"
8+
"os/exec"
9+
"sync"
10+
"syscall"
11+
12+
"github.com/kitstream/initium/internal/logging"
13+
"github.com/kitstream/initium/internal/safety"
14+
"github.com/spf13/cobra"
15+
)
16+
17+
func NewMigrateCmd(log *logging.Logger) *cobra.Command {
18+
var (
19+
workdir string
20+
lockFile string
21+
jsonLogs bool
22+
)
23+
24+
cmd := &cobra.Command{
25+
Use: "migrate -- COMMAND [ARGS...]",
26+
Short: "Run a database migration command with structured logging",
27+
Long: `Execute a database migration command with structured logging, exit code
28+
forwarding, and optional idempotency via a lock file.
29+
30+
The command is executed directly via execve (no shell). Use "--" to separate
31+
initium flags from the migration command and its arguments.
32+
33+
If --lock-file is set, the migration is skipped when the lock file already
34+
exists inside --workdir. On successful completion the lock file is created
35+
so subsequent runs become no-ops.`,
36+
Example: ` # Run a flyway migration
37+
initium migrate -- flyway migrate
38+
39+
# Run with JSON logs
40+
initium migrate --json -- /app/migrate -path /migrations up
41+
42+
# Idempotent: skip if already migrated
43+
initium migrate --lock-file .migrated --workdir /work -- /app/migrate up`,
44+
SilenceUsage: true,
45+
SilenceErrors: true,
46+
RunE: func(cmd *cobra.Command, args []string) error {
47+
if jsonLogs {
48+
log.SetJSON(true)
49+
}
50+
51+
if len(args) == 0 {
52+
return fmt.Errorf("migration command is required after \"--\"")
53+
}
54+
55+
if lockFile != "" {
56+
lockPath, err := safety.ValidateFilePath(workdir, lockFile)
57+
if err != nil {
58+
return fmt.Errorf("invalid lock file path: %w", err)
59+
}
60+
61+
if _, err := os.Stat(lockPath); err == nil {
62+
log.Info("lock file exists, skipping migration", "lock-file", lockPath)
63+
return nil
64+
}
65+
}
66+
67+
log.Info("starting migration", "command", args[0])
68+
69+
exitCode, err := runCommand(log, args)
70+
if err != nil {
71+
return fmt.Errorf("migration failed: %w", err)
72+
}
73+
74+
if exitCode != 0 {
75+
return fmt.Errorf("migration exited with code %d", exitCode)
76+
}
77+
78+
if lockFile != "" {
79+
lockPath, err := safety.ValidateFilePath(workdir, lockFile)
80+
if err != nil {
81+
return fmt.Errorf("invalid lock file path: %w", err)
82+
}
83+
84+
if err := os.MkdirAll(workdir, 0o755); err != nil {
85+
return fmt.Errorf("creating workdir %s: %w", workdir, err)
86+
}
87+
88+
if err := os.WriteFile(lockPath, []byte("migrated\n"), 0o644); err != nil {
89+
return fmt.Errorf("writing lock file %s: %w", lockPath, err)
90+
}
91+
log.Info("lock file created", "lock-file", lockPath)
92+
}
93+
94+
log.Info("migration completed successfully")
95+
return nil
96+
},
97+
}
98+
99+
cmd.Flags().StringVar(&workdir, "workdir", "/work", "Working directory for file operations")
100+
cmd.Flags().StringVar(&lockFile, "lock-file", "", "Skip migration if this file exists in workdir (idempotency)")
101+
cmd.Flags().BoolVar(&jsonLogs, "json", false, "Enable JSON log output")
102+
103+
return cmd
104+
}
105+
106+
func runCommand(log *logging.Logger, args []string) (int, error) {
107+
c := exec.Command(args[0], args[1:]...)
108+
c.Stdin = nil
109+
110+
stdoutPipe, err := c.StdoutPipe()
111+
if err != nil {
112+
return -1, fmt.Errorf("creating stdout pipe: %w", err)
113+
}
114+
115+
stderrPipe, err := c.StderrPipe()
116+
if err != nil {
117+
return -1, fmt.Errorf("creating stderr pipe: %w", err)
118+
}
119+
120+
if err := c.Start(); err != nil {
121+
return -1, fmt.Errorf("starting command %q: %w", args[0], err)
122+
}
123+
124+
var wg sync.WaitGroup
125+
wg.Add(2)
126+
127+
go func() {
128+
defer wg.Done()
129+
streamLines(log, stdoutPipe, "stdout")
130+
}()
131+
132+
go func() {
133+
defer wg.Done()
134+
streamLines(log, stderrPipe, "stderr")
135+
}()
136+
137+
wg.Wait()
138+
139+
err = c.Wait()
140+
if err == nil {
141+
return 0, nil
142+
}
143+
144+
var exitErr *exec.ExitError
145+
if ok := asExitError(err, &exitErr); ok {
146+
return exitErr.ExitCode(), nil
147+
}
148+
149+
return -1, err
150+
}
151+
152+
func asExitError(err error, target **exec.ExitError) bool {
153+
if e, ok := err.(*exec.ExitError); ok {
154+
*target = e
155+
return true
156+
}
157+
return false
158+
}
159+
160+
func streamLines(log *logging.Logger, r io.Reader, stream string) {
161+
scanner := bufio.NewScanner(r)
162+
for scanner.Scan() {
163+
log.Info(scanner.Text(), "stream", stream)
164+
}
165+
}
166+
167+
// ExitCodeFromError extracts the exit code from a command error.
168+
// Used by callers that need to propagate exit codes (e.g., os.Exit).
169+
func ExitCodeFromError(err error) int {
170+
if err == nil {
171+
return 0
172+
}
173+
174+
// Check if the error message contains an exit code pattern
175+
var exitCode int
176+
if n, _ := fmt.Sscanf(err.Error(), "migration exited with code %d", &exitCode); n == 1 {
177+
return exitCode
178+
}
179+
180+
// Check for underlying process exit status
181+
if exitErr, ok := err.(*exec.ExitError); ok {
182+
if status, ok := exitErr.Sys().(syscall.WaitStatus); ok {
183+
return status.ExitStatus()
184+
}
185+
}
186+
187+
return 1
188+
}

0 commit comments

Comments
 (0)