Skip to content

Commit 02cd94b

Browse files
Implement render subcommand and internal/render package (closes #3)
- Add internal/render/render.go: - Envsubst mode: replace ${VAR} and $VAR with env var values - GoTemplate mode: full Go text/template with env vars as data map - Add internal/render/render_test.go with 17 tests: - envsubst: basic, missing var, empty, no vars, empty value, special chars, multiline, adjacent vars, underscores, dollar without var - gotemplate: basic, missing var, empty, invalid syntax, conditional, special chars, envToMap - Add internal/cmd/render.go: render subcommand with --template, --output, --workdir, --mode (envsubst|gotemplate), --json flags - Add internal/cmd/render_test.go with 11 command-level tests: - no template, no output, invalid mode, envsubst, gotemplate, path traversal, missing template, nested output dir, invalid go template syntax, JSON output, empty template - Register render command in cmd/initium/main.go - Update docs/usage.md with render section - Update CHANGELOG.md
1 parent f86f4f9 commit 02cd94b

7 files changed

Lines changed: 653 additions & 3 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+
- `render` subcommand and `internal/render` package: render templates into config files with `envsubst` (default) and Go `text/template` modes, path traversal prevention, and automatic intermediate directory creation
1112
- `seed` subcommand: run database seed commands with structured logging and exit code forwarding (no idempotency — distinct from `migrate`)
1213
- `migrate` subcommand: run database migration commands with structured logging, exit code forwarding, and optional idempotency via `--lock-file`
1314
- FAQ.md with functionality, security, and deployment questions for junior-to-mid-level engineers

cmd/initium/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ and security guardrails.`,
3535
root.AddCommand(cmd.NewWaitForCmd(log))
3636
root.AddCommand(cmd.NewMigrateCmd(log))
3737
root.AddCommand(cmd.NewSeedCmd(log))
38+
root.AddCommand(cmd.NewRenderCmd(log))
3839
if err := root.Execute(); err != nil {
3940
log.Error(err.Error())
4041
os.Exit(1)

docs/usage.md

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -154,14 +154,48 @@ initium seed --json -- python3 /scripts/seed.py
154154
| `1` | Seed command failed, or invalid arguments |
155155
| _N_ | Forwarded from the seed command |
156156

157-
### render _(coming soon)_
157+
### render
158158

159-
Render templates into config files.
159+
Render a template file into a config file using environment variable substitution.
160+
161+
Two modes are supported:
162+
163+
- **envsubst** (default) — replaces `${VAR}` and `$VAR` patterns with environment variable values. Missing variables are left as-is.
164+
- **gotemplate** — full Go `text/template` support with environment variables as `.VarName`. Missing variables produce empty strings.
165+
166+
Output files are written relative to `--workdir` with path traversal prevention. Intermediate directories are created automatically.
160167

161168
```bash
162-
initium render --template /templates/app.conf.tmpl --output config/app.conf --workdir /work
169+
# envsubst mode (default)
170+
initium render --template /templates/app.conf.tmpl --output app.conf
171+
172+
# Go template mode
173+
initium render --mode gotemplate --template /templates/app.conf.tmpl --output app.conf
174+
175+
# Custom workdir
176+
initium render --template /tpl/nginx.conf.tmpl --output nginx.conf --workdir /etc/nginx
177+
178+
# Nested output directory (created automatically)
179+
initium render --template /tpl/db.conf.tmpl --output config/db.conf --workdir /work
163180
```
164181

182+
**Flags:**
183+
184+
| Flag | Default | Description |
185+
|------|---------|-------------|
186+
| `--template` | _(required)_ | Path to template file |
187+
| `--output` | _(required)_ | Output file path relative to workdir |
188+
| `--workdir` | `/work` | Working directory for output files |
189+
| `--mode` | `envsubst` | Template mode: `envsubst` or `gotemplate` |
190+
| `--json` | `false` | Enable JSON log output |
191+
192+
**Exit codes:**
193+
194+
| Code | Meaning |
195+
|------|---------|
196+
| `0` | Render succeeded |
197+
| `1` | Invalid arguments, missing template, template syntax error, or path traversal |
198+
165199
### fetch _(coming soon)_
166200

167201
Fetch secrets or config from HTTP endpoints.

internal/cmd/render.go

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"path/filepath"
7+
8+
"github.com/kitstream/initium/internal/logging"
9+
"github.com/kitstream/initium/internal/render"
10+
"github.com/kitstream/initium/internal/safety"
11+
"github.com/spf13/cobra"
12+
)
13+
14+
func NewRenderCmd(log *logging.Logger) *cobra.Command {
15+
var (
16+
templatePath string
17+
outputPath string
18+
workdir string
19+
mode string
20+
jsonLogs bool
21+
)
22+
23+
cmd := &cobra.Command{
24+
Use: "render",
25+
Short: "Render templates into config files",
26+
Long: `Render a template file into a config file using environment variable
27+
substitution. Supports two modes:
28+
29+
envsubst — replaces ${VAR} and $VAR patterns (default)
30+
gotemplate — full Go text/template with env vars as .VarName
31+
32+
Output files are written relative to --workdir with path traversal prevention.
33+
Intermediate directories are created automatically.`,
34+
Example: ` # envsubst mode (default)
35+
initium render --template /templates/app.conf.tmpl --output app.conf
36+
37+
# Go template mode
38+
initium render --mode gotemplate --template /templates/app.conf.tmpl --output app.conf
39+
40+
# Custom workdir
41+
initium render --template /tpl/nginx.conf.tmpl --output nginx.conf --workdir /etc/nginx`,
42+
SilenceUsage: true,
43+
SilenceErrors: true,
44+
RunE: func(cmd *cobra.Command, args []string) error {
45+
if jsonLogs {
46+
log.SetJSON(true)
47+
}
48+
49+
if templatePath == "" {
50+
return fmt.Errorf("--template is required")
51+
}
52+
if outputPath == "" {
53+
return fmt.Errorf("--output is required")
54+
}
55+
if mode != "envsubst" && mode != "gotemplate" {
56+
return fmt.Errorf("--mode must be envsubst or gotemplate, got %q", mode)
57+
}
58+
59+
outPath, err := safety.ValidateFilePath(workdir, outputPath)
60+
if err != nil {
61+
return fmt.Errorf("invalid output path: %w", err)
62+
}
63+
64+
data, err := os.ReadFile(templatePath)
65+
if err != nil {
66+
return fmt.Errorf("reading template %s: %w", templatePath, err)
67+
}
68+
69+
log.Info("rendering template", "template", templatePath, "output", outPath, "mode", mode)
70+
71+
var result string
72+
switch mode {
73+
case "envsubst":
74+
result = render.Envsubst(string(data))
75+
case "gotemplate":
76+
result, err = render.GoTemplate(string(data))
77+
if err != nil {
78+
return fmt.Errorf("rendering template: %w", err)
79+
}
80+
}
81+
82+
if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil {
83+
return fmt.Errorf("creating output directory: %w", err)
84+
}
85+
86+
if err := os.WriteFile(outPath, []byte(result), 0o644); err != nil {
87+
return fmt.Errorf("writing output %s: %w", outPath, err)
88+
}
89+
90+
log.Info("render completed", "output", outPath)
91+
return nil
92+
},
93+
}
94+
95+
cmd.Flags().StringVar(&templatePath, "template", "", "Path to template file (required)")
96+
cmd.Flags().StringVar(&outputPath, "output", "", "Output file path relative to workdir (required)")
97+
cmd.Flags().StringVar(&workdir, "workdir", "/work", "Working directory for output files")
98+
cmd.Flags().StringVar(&mode, "mode", "envsubst", "Template mode: envsubst or gotemplate")
99+
cmd.Flags().BoolVar(&jsonLogs, "json", false, "Enable JSON log output")
100+
101+
return cmd
102+
}

0 commit comments

Comments
 (0)