Skip to content

Commit bf89407

Browse files
authored
Merge pull request #459 from cycloidio/feat/cli-template-render-offline
feat(template): offline `cy template render` command
2 parents 0a123d0 + 7190dce commit bf89407

16 files changed

Lines changed: 2058 additions & 0 deletions
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
component: Service Catalog
2+
kind: ADDED
3+
body: New `cy beta template render` command to render Cycloid stack templates locally, with no backend. Supports layered context (component base < context file < stdin/string < `--set` overrides), placeholder rendering for unset variables, and multi-file/`--dir`/stdin input.
4+
time: 2026-06-12T11:30:00Z
5+
custom:
6+
TYPE: CLI
7+
PR: 459
8+
DETAILS: ""

cmd/cycloid/beta/cmd.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55

66
bootstrapfirstorg "github.com/cycloidio/cycloid-cli/cmd/cycloid/beta/bootstrap_first_org"
77
"github.com/cycloidio/cycloid-cli/cmd/cycloid/beta/config"
8+
"github.com/cycloidio/cycloid-cli/cmd/cycloid/template"
89
)
910

1011
func NewCommands() *cobra.Command {
@@ -18,6 +19,7 @@ Those commands are feature in testing, retro-compatibility is not guaranteed.`,
1819
cmd.AddCommand(
1920
config.NewCommands(),
2021
bootstrapfirstorg.NewCommands(),
22+
template.NewCommands(),
2123
)
2224
return cmd
2325
}

cmd/cycloid/template/cmd.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package template
2+
3+
import (
4+
"github.com/spf13/cobra"
5+
)
6+
7+
// NewCommands builds the `cy template` command group: local templating and
8+
// interpolation tooling. Today it ships the offline `render` verb; the
9+
// backend-backed `context` verb (pull real context from an existing component)
10+
// lands in a follow-up once the templating endpoint exists.
11+
func NewCommands() *cobra.Command {
12+
cmd := &cobra.Command{
13+
Use: "template",
14+
Aliases: []string{"tpl", "tmpl"},
15+
Short: "Test Cycloid templating and interpolation locally",
16+
}
17+
18+
cmd.AddCommand(
19+
NewRenderCommand(),
20+
)
21+
22+
return cmd
23+
}

cmd/cycloid/template/render.go

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
package template
2+
3+
import (
4+
"fmt"
5+
"io"
6+
"os"
7+
"path/filepath"
8+
9+
"github.com/spf13/cobra"
10+
11+
"github.com/cycloidio/cycloid-cli/internal/cyargs"
12+
"github.com/cycloidio/cycloid-cli/internal/cyout"
13+
"github.com/cycloidio/cycloid-cli/internal/templating"
14+
)
15+
16+
func NewRenderCommand() *cobra.Command {
17+
cmd := &cobra.Command{
18+
Use: "render [flags]",
19+
Short: "Render templates offline with Cycloid interpolation",
20+
Long: `Render one or more templates locally using the Cycloid interpolation engine,
21+
with no backend call. Context variables are layered, lowest precedence first:
22+
23+
--context-file JSON or YAML file
24+
stdin piped JSON object (when no template is read from stdin)
25+
--context raw JSON object string
26+
--set key=value pairs (dotted keys nest); highest precedence
27+
28+
Variables referenced by a template but not provided render as the literal
29+
"<placeholder:$name>" when they are known Cycloid variables, or are reported as
30+
warnings when unknown.`,
31+
Example: `
32+
# render a file with a couple of variables
33+
cy beta template render -f main.tf.tpl --set project=my-app --set env=prod
34+
35+
# pull-once-iterate-locally: real context from a file, tweak one var
36+
cy beta template render -f main.tf.tpl --context-file ctx.yaml --set env_vars.region=eu-west-1
37+
38+
# render from stdin context, template from a directory, JSON output
39+
cat ctx.json | cy beta template render --dir ./templates -o json`,
40+
RunE: runRender,
41+
Args: cobra.NoArgs,
42+
}
43+
cyargs.AddTemplateRenderFlags(cmd)
44+
return cmd
45+
}
46+
47+
func runRender(cmd *cobra.Command, _ []string) error {
48+
// Step 1: all flags first.
49+
files, err := cyargs.GetTemplateFiles(cmd)
50+
if err != nil {
51+
return err
52+
}
53+
dir, err := cyargs.GetTemplateDir(cmd)
54+
if err != nil {
55+
return err
56+
}
57+
ctxFile, err := cyargs.GetTemplateContextFile(cmd)
58+
if err != nil {
59+
return err
60+
}
61+
ctxStr, err := cyargs.GetTemplateContextString(cmd)
62+
if err != nil {
63+
return err
64+
}
65+
sets, err := cyargs.GetTemplateSet(cmd)
66+
if err != nil {
67+
return err
68+
}
69+
70+
// Step 2: resolve stdin once. It feeds a "-" template if requested,
71+
// otherwise it is treated as a piped JSON context.
72+
wantStdinTemplate := false
73+
for _, f := range files {
74+
if f == "-" {
75+
wantStdinTemplate = true
76+
}
77+
}
78+
var stdinData []byte
79+
if hasStdinData() {
80+
stdinData, err = io.ReadAll(cmd.InOrStdin())
81+
if err != nil {
82+
return fmt.Errorf("failed to read stdin: %w", err)
83+
}
84+
}
85+
86+
// Step 3: build the layered context (ascending precedence).
87+
ctx := templating.Context{}
88+
if ctxFile != "" {
89+
fileCtx, err := templating.LoadContextFile(ctxFile)
90+
if err != nil {
91+
return err
92+
}
93+
templating.Merge(ctx, fileCtx)
94+
}
95+
if !wantStdinTemplate && len(stdinData) > 0 {
96+
stdinCtx, err := templating.ParseContextString(string(stdinData))
97+
if err != nil {
98+
return fmt.Errorf("stdin: %w", err)
99+
}
100+
templating.Merge(ctx, stdinCtx)
101+
}
102+
if ctxStr != "" {
103+
strCtx, err := templating.ParseContextString(ctxStr)
104+
if err != nil {
105+
return err
106+
}
107+
templating.Merge(ctx, strCtx)
108+
}
109+
if len(sets) > 0 {
110+
setCtx, err := templating.ParseSet(sets)
111+
if err != nil {
112+
return err
113+
}
114+
templating.Merge(ctx, setCtx)
115+
}
116+
117+
// Step 4: gather templates.
118+
type tmpl struct{ name, content string }
119+
var tmpls []tmpl
120+
for _, f := range files {
121+
if f == "-" {
122+
tmpls = append(tmpls, tmpl{name: "stdin", content: string(stdinData)})
123+
continue
124+
}
125+
content, err := os.ReadFile(f)
126+
if err != nil {
127+
return fmt.Errorf("failed to read template %q: %w", f, err)
128+
}
129+
tmpls = append(tmpls, tmpl{name: f, content: string(content)})
130+
}
131+
if dir != "" {
132+
err = filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
133+
if err != nil {
134+
return err
135+
}
136+
if d.IsDir() {
137+
return nil
138+
}
139+
content, err := os.ReadFile(path)
140+
if err != nil {
141+
return fmt.Errorf("failed to read template %q: %w", path, err)
142+
}
143+
tmpls = append(tmpls, tmpl{name: path, content: string(content)})
144+
return nil
145+
})
146+
if err != nil {
147+
return err
148+
}
149+
}
150+
if len(tmpls) == 0 {
151+
return fmt.Errorf("no template provided: pass --file, --dir, or pipe a template with --file -")
152+
}
153+
154+
// Step 5: render and report.
155+
reports := make([]templating.Report, 0, len(tmpls))
156+
failed := 0
157+
for _, t := range tmpls {
158+
r := templating.Render(t.name, t.content, ctx)
159+
if r.Error != "" {
160+
failed++
161+
}
162+
reports = append(reports, r)
163+
}
164+
165+
// A single template prints a single object; multiple print a list.
166+
var out any = reports
167+
if len(reports) == 1 {
168+
out = reports[0]
169+
}
170+
if printErr := cyout.Print(cmd, out, nil, ""); printErr != nil {
171+
return printErr
172+
}
173+
if failed > 0 {
174+
return fmt.Errorf("%d of %d template(s) failed to render", failed, len(tmpls))
175+
}
176+
return nil
177+
}
178+
179+
// hasStdinData reports whether stdin is a pipe or redirected file (i.e. has
180+
// data) rather than an interactive terminal.
181+
func hasStdinData() bool {
182+
info, err := os.Stdin.Stat()
183+
if err != nil {
184+
return false
185+
}
186+
return info.Mode()&os.ModeCharDevice == 0
187+
}

e2e/template_test.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package e2e_test
2+
3+
import (
4+
"encoding/json"
5+
"os"
6+
"path/filepath"
7+
"testing"
8+
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
// report mirrors internal/templating.Report for decoding JSON output.
14+
type report struct {
15+
Name string `json:"name"`
16+
Rendered string `json:"rendered"`
17+
Unset []string `json:"unset_vars"`
18+
Warnings []string `json:"warnings"`
19+
Error string `json:"error"`
20+
}
21+
22+
// TestTemplateRender exercises `cy beta template render` end to end. The command
23+
// is fully offline (no API calls), so it does not depend on backend fixtures.
24+
func TestTemplateRender(t *testing.T) {
25+
dir := t.TempDir()
26+
tplPath := filepath.Join(dir, "main.tpl")
27+
require.NoError(t, os.WriteFile(tplPath,
28+
[]byte("project=($ .project $)\nenv=($ .env $)\nupper=($ .project | upper $)\n"), 0o600))
29+
30+
t.Run("set flags and placeholder for unset known var", func(t *testing.T) {
31+
out, err := executeCommand([]string{"beta", "template", "render", "-f", tplPath, "--set", "project=my-app", "-o", "json"})
32+
require.NoError(t, err)
33+
var r report
34+
require.NoError(t, json.Unmarshal([]byte(out), &r))
35+
assert.Equal(t, "project=my-app\nenv=<placeholder:$env>\nupper=MY-APP\n", r.Rendered)
36+
assert.Equal(t, []string{"env"}, r.Unset)
37+
})
38+
39+
t.Run("yaml context file with dotted set override", func(t *testing.T) {
40+
ctxPath := filepath.Join(dir, "ctx.yaml")
41+
require.NoError(t, os.WriteFile(ctxPath, []byte("project: from-file\nenv_vars:\n region: us\n zone: a\n"), 0o600))
42+
rtpl := filepath.Join(dir, "r.tpl")
43+
require.NoError(t, os.WriteFile(rtpl, []byte("r=($ .env_vars.region $) z=($ .env_vars.zone $)\n"), 0o600))
44+
45+
out, err := executeCommand([]string{"beta", "template", "render", "-f", rtpl, "--context-file", ctxPath, "--set", "env_vars.region=eu", "-o", "json"})
46+
require.NoError(t, err)
47+
var r report
48+
require.NoError(t, json.Unmarshal([]byte(out), &r))
49+
// region overridden by --set; zone preserved from the file (deep merge).
50+
assert.Equal(t, "r=eu z=a\n", r.Rendered)
51+
})
52+
53+
t.Run("stdin json context", func(t *testing.T) {
54+
out, _, err := executeCommandStdin(`{"project":"piped"}`,
55+
[]string{"beta", "template", "render", "-f", tplPath, "-o", "json"})
56+
require.NoError(t, err)
57+
var r report
58+
require.NoError(t, json.Unmarshal([]byte(out), &r))
59+
assert.Contains(t, r.Rendered, "project=piped")
60+
})
61+
62+
t.Run("parse error sets error field and nonzero exit", func(t *testing.T) {
63+
bad := filepath.Join(dir, "bad.tpl")
64+
require.NoError(t, os.WriteFile(bad, []byte("($ range .items $)"), 0o600))
65+
out, err := executeCommand([]string{"beta", "template", "render", "-f", bad, "-o", "json"})
66+
assert.Error(t, err)
67+
var r report
68+
require.NoError(t, json.Unmarshal([]byte(out), &r))
69+
assert.NotEmpty(t, r.Error)
70+
})
71+
}

internal/cyargs/templating.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package cyargs
2+
3+
import "github.com/spf13/cobra"
4+
5+
// AddTemplateRenderFlags registers the flags for `cy template render`: the
6+
// template inputs (--file/--dir) and the layered context sources
7+
// (--context-file, --context, --set). Output format is the global --output.
8+
func AddTemplateRenderFlags(cmd *cobra.Command) {
9+
cmd.Flags().StringArrayP("file", "f", nil, "Template file to render (repeatable). Use - for stdin.")
10+
cmd.Flags().String("dir", "", "Directory of templates to render recursively.")
11+
cmd.Flags().String("context-file", "", "Path to a JSON or YAML file of context variables.")
12+
cmd.Flags().String("context", "", "Raw JSON object of context variables (highest-fidelity floor; merged below --set).")
13+
cmd.Flags().StringArray("set", nil, "Context variable as key=value (repeatable). Dotted keys nest, e.g. env_vars.region=eu-west-1. Highest precedence.")
14+
}
15+
16+
// GetTemplateFiles returns the --file values.
17+
func GetTemplateFiles(cmd *cobra.Command) ([]string, error) {
18+
return cmd.Flags().GetStringArray("file")
19+
}
20+
21+
// GetTemplateDir returns the --dir value.
22+
func GetTemplateDir(cmd *cobra.Command) (string, error) {
23+
return cmd.Flags().GetString("dir")
24+
}
25+
26+
// GetTemplateContextFile returns the --context-file value.
27+
func GetTemplateContextFile(cmd *cobra.Command) (string, error) {
28+
return cmd.Flags().GetString("context-file")
29+
}
30+
31+
// GetTemplateContextString returns the --context value.
32+
func GetTemplateContextString(cmd *cobra.Command) (string, error) {
33+
return cmd.Flags().GetString("context")
34+
}
35+
36+
// GetTemplateSet returns the --set key=value pairs.
37+
func GetTemplateSet(cmd *cobra.Command) ([]string, error) {
38+
return cmd.Flags().GetStringArray("set")
39+
}

0 commit comments

Comments
 (0)