Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
component: Service Catalog
kind: ADDED
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.
time: 2026-06-12T11:30:00Z
custom:
TYPE: CLI
PR: 459
DETAILS: ""
2 changes: 2 additions & 0 deletions cmd/cycloid/beta/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (

bootstrapfirstorg "github.com/cycloidio/cycloid-cli/cmd/cycloid/beta/bootstrap_first_org"
"github.com/cycloidio/cycloid-cli/cmd/cycloid/beta/config"
"github.com/cycloidio/cycloid-cli/cmd/cycloid/template"
)

func NewCommands() *cobra.Command {
Expand All @@ -18,6 +19,7 @@ Those commands are feature in testing, retro-compatibility is not guaranteed.`,
cmd.AddCommand(
config.NewCommands(),
bootstrapfirstorg.NewCommands(),
template.NewCommands(),
)
return cmd
}
8 changes: 4 additions & 4 deletions cmd/cycloid/environmenttypes/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,10 @@ import (

func NewListCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "list",
Short: "List environment types",
RunE: list,
Args: cobra.NoArgs,
Use: "list",
Short: "List environment types",
RunE: list,
Args: cobra.NoArgs,
}

cyout.RegisterModel(cmd, models.EnvironmentType{})
Expand Down
2 changes: 1 addition & 1 deletion cmd/cycloid/plugins/registry/plugin/get.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
package plugin

import (
"github.com/cycloidio/cycloid-cli/client/models"
"github.com/spf13/cobra"

"github.com/cycloidio/cycloid-cli/client/models"
"github.com/cycloidio/cycloid-cli/cmd/cycloid/common"
"github.com/cycloidio/cycloid-cli/cmd/cycloid/middleware"
"github.com/cycloidio/cycloid-cli/internal/cyargs"
Expand Down
23 changes: 23 additions & 0 deletions cmd/cycloid/template/cmd.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package template

import (
"github.com/spf13/cobra"
)

// NewCommands builds the `cy template` command group: local templating and
// interpolation tooling. Today it ships the offline `render` verb; the
// backend-backed `context` verb (pull real context from an existing component)
// lands in a follow-up once the templating endpoint exists.
func NewCommands() *cobra.Command {
cmd := &cobra.Command{
Use: "template",
Aliases: []string{"tpl", "tmpl"},
Short: "Test Cycloid templating and interpolation locally",
}

cmd.AddCommand(
NewRenderCommand(),
)

return cmd
}
187 changes: 187 additions & 0 deletions cmd/cycloid/template/render.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
package template

import (
"fmt"
"io"
"os"
"path/filepath"

"github.com/spf13/cobra"

"github.com/cycloidio/cycloid-cli/internal/cyargs"
"github.com/cycloidio/cycloid-cli/internal/cyout"
"github.com/cycloidio/cycloid-cli/internal/templating"
)

func NewRenderCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "render [flags]",
Short: "Render templates offline with Cycloid interpolation",
Long: `Render one or more templates locally using the Cycloid interpolation engine,
with no backend call. Context variables are layered, lowest precedence first:

--context-file JSON or YAML file
stdin piped JSON object (when no template is read from stdin)
--context raw JSON object string
--set key=value pairs (dotted keys nest); highest precedence

Variables referenced by a template but not provided render as the literal
"<placeholder:$name>" when they are known Cycloid variables, or are reported as
warnings when unknown.`,
Example: `
# render a file with a couple of variables
cy beta template render -f main.tf.tpl --set project=my-app --set env=prod

# pull-once-iterate-locally: real context from a file, tweak one var
cy beta template render -f main.tf.tpl --context-file ctx.yaml --set env_vars.region=eu-west-1

# render from stdin context, template from a directory, JSON output
cat ctx.json | cy beta template render --dir ./templates -o json`,
RunE: runRender,
Args: cobra.NoArgs,
}
cyargs.AddTemplateRenderFlags(cmd)
return cmd
}

func runRender(cmd *cobra.Command, _ []string) error {
// Step 1: all flags first.
files, err := cyargs.GetTemplateFiles(cmd)
if err != nil {
return err
}
dir, err := cyargs.GetTemplateDir(cmd)
if err != nil {
return err
}
ctxFile, err := cyargs.GetTemplateContextFile(cmd)
if err != nil {
return err
}
ctxStr, err := cyargs.GetTemplateContextString(cmd)
if err != nil {
return err
}
sets, err := cyargs.GetTemplateSet(cmd)
if err != nil {
return err
}

// Step 2: resolve stdin once. It feeds a "-" template if requested,
// otherwise it is treated as a piped JSON context.
wantStdinTemplate := false
for _, f := range files {
if f == "-" {
wantStdinTemplate = true
}
}
var stdinData []byte
if hasStdinData() {
stdinData, err = io.ReadAll(cmd.InOrStdin())
if err != nil {
return fmt.Errorf("failed to read stdin: %w", err)
}
}

// Step 3: build the layered context (ascending precedence).
ctx := templating.Context{}
if ctxFile != "" {
fileCtx, err := templating.LoadContextFile(ctxFile)
if err != nil {
return err
}
templating.Merge(ctx, fileCtx)
}
if !wantStdinTemplate && len(stdinData) > 0 {
stdinCtx, err := templating.ParseContextString(string(stdinData))
if err != nil {
return fmt.Errorf("stdin: %w", err)
}
templating.Merge(ctx, stdinCtx)
}
if ctxStr != "" {
strCtx, err := templating.ParseContextString(ctxStr)
if err != nil {
return err
}
templating.Merge(ctx, strCtx)
}
if len(sets) > 0 {
setCtx, err := templating.ParseSet(sets)
if err != nil {
return err
}
templating.Merge(ctx, setCtx)
}

// Step 4: gather templates.
type tmpl struct{ name, content string }
var tmpls []tmpl
for _, f := range files {
if f == "-" {
tmpls = append(tmpls, tmpl{name: "stdin", content: string(stdinData)})
continue
}
content, err := os.ReadFile(f)
if err != nil {
return fmt.Errorf("failed to read template %q: %w", f, err)
}
tmpls = append(tmpls, tmpl{name: f, content: string(content)})
}
if dir != "" {
err = filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
content, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("failed to read template %q: %w", path, err)
}
tmpls = append(tmpls, tmpl{name: path, content: string(content)})
return nil
})
if err != nil {
return err
}
}
if len(tmpls) == 0 {
return fmt.Errorf("no template provided: pass --file, --dir, or pipe a template with --file -")
}

// Step 5: render and report.
reports := make([]templating.Report, 0, len(tmpls))
failed := 0
for _, t := range tmpls {
r := templating.Render(t.name, t.content, ctx)
if r.Error != "" {
failed++
}
reports = append(reports, r)
}

// A single template prints a single object; multiple print a list.
var out any = reports
if len(reports) == 1 {
out = reports[0]
}
if printErr := cyout.Print(cmd, out, nil, ""); printErr != nil {
return printErr
}
if failed > 0 {
return fmt.Errorf("%d of %d template(s) failed to render", failed, len(tmpls))
}
return nil
}

// hasStdinData reports whether stdin is a pipe or redirected file (i.e. has
// data) rather than an interactive terminal.
func hasStdinData() bool {
info, err := os.Stdin.Stat()
if err != nil {
return false
}
return info.Mode()&os.ModeCharDevice == 0
}
2 changes: 1 addition & 1 deletion cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ import (
"github.com/cycloidio/cycloid-cli/cmd/cycloid/components"
"github.com/cycloidio/cycloid-cli/cmd/cycloid/configrepositories"
"github.com/cycloidio/cycloid-cli/cmd/cycloid/credentials"
"github.com/cycloidio/cycloid-cli/cmd/cycloid/environmenttypes"
"github.com/cycloidio/cycloid-cli/cmd/cycloid/environments"
"github.com/cycloidio/cycloid-cli/cmd/cycloid/environmenttypes"
"github.com/cycloidio/cycloid-cli/cmd/cycloid/events"
"github.com/cycloidio/cycloid-cli/cmd/cycloid/externalbackends"
"github.com/cycloidio/cycloid-cli/cmd/cycloid/kpis"
Expand Down
71 changes: 71 additions & 0 deletions e2e/template_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package e2e_test

import (
"encoding/json"
"os"
"path/filepath"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// report mirrors internal/templating.Report for decoding JSON output.
type report struct {
Name string `json:"name"`
Rendered string `json:"rendered"`
Unset []string `json:"unset_vars"`
Warnings []string `json:"warnings"`
Error string `json:"error"`
}

// TestTemplateRender exercises `cy beta template render` end to end. The command
// is fully offline (no API calls), so it does not depend on backend fixtures.
func TestTemplateRender(t *testing.T) {
dir := t.TempDir()
tplPath := filepath.Join(dir, "main.tpl")
require.NoError(t, os.WriteFile(tplPath,
[]byte("project=($ .project $)\nenv=($ .env $)\nupper=($ .project | upper $)\n"), 0o600))

t.Run("set flags and placeholder for unset known var", func(t *testing.T) {
out, err := executeCommand([]string{"beta", "template", "render", "-f", tplPath, "--set", "project=my-app", "-o", "json"})
require.NoError(t, err)
var r report
require.NoError(t, json.Unmarshal([]byte(out), &r))
assert.Equal(t, "project=my-app\nenv=<placeholder:$env>\nupper=MY-APP\n", r.Rendered)
assert.Equal(t, []string{"env"}, r.Unset)
})

t.Run("yaml context file with dotted set override", func(t *testing.T) {
ctxPath := filepath.Join(dir, "ctx.yaml")
require.NoError(t, os.WriteFile(ctxPath, []byte("project: from-file\nenv_vars:\n region: us\n zone: a\n"), 0o600))
rtpl := filepath.Join(dir, "r.tpl")
require.NoError(t, os.WriteFile(rtpl, []byte("r=($ .env_vars.region $) z=($ .env_vars.zone $)\n"), 0o600))

out, err := executeCommand([]string{"beta", "template", "render", "-f", rtpl, "--context-file", ctxPath, "--set", "env_vars.region=eu", "-o", "json"})
require.NoError(t, err)
var r report
require.NoError(t, json.Unmarshal([]byte(out), &r))
// region overridden by --set; zone preserved from the file (deep merge).
assert.Equal(t, "r=eu z=a\n", r.Rendered)
})

t.Run("stdin json context", func(t *testing.T) {
out, _, err := executeCommandStdin(`{"project":"piped"}`,
[]string{"beta", "template", "render", "-f", tplPath, "-o", "json"})
require.NoError(t, err)
var r report
require.NoError(t, json.Unmarshal([]byte(out), &r))
assert.Contains(t, r.Rendered, "project=piped")
})

t.Run("parse error sets error field and nonzero exit", func(t *testing.T) {
bad := filepath.Join(dir, "bad.tpl")
require.NoError(t, os.WriteFile(bad, []byte("($ range .items $)"), 0o600))
out, err := executeCommand([]string{"beta", "template", "render", "-f", bad, "-o", "json"})
assert.Error(t, err)
var r report
require.NoError(t, json.Unmarshal([]byte(out), &r))
assert.NotEmpty(t, r.Error)
})
}
2 changes: 1 addition & 1 deletion internal/cyargs/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -452,7 +452,7 @@ func GetDescription(cmd *cobra.Command) (string, error) {

func AddDeleteFlags(cmd *cobra.Command) {
cmd.Flags().Bool("force", false, "shorthand for --skip-hooks --ignore-config-files-err")
cmd.Flags().Bool("skip-hooks", false, "skip component on_delete hooks (sets skip_hooks=true)")
cmd.Flags().Bool("skip-hooks", false, "skip component on_delete hooks (sets skip_hooks=true)")
cmd.Flags().Bool("ignore-config-files-err", false, "ignore possible errors on config repository update (sets ignore_config_files_err=true)")
}

Expand Down
39 changes: 39 additions & 0 deletions internal/cyargs/templating.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package cyargs

import "github.com/spf13/cobra"

// AddTemplateRenderFlags registers the flags for `cy template render`: the
// template inputs (--file/--dir) and the layered context sources
// (--context-file, --context, --set). Output format is the global --output.
func AddTemplateRenderFlags(cmd *cobra.Command) {
cmd.Flags().StringArrayP("file", "f", nil, "Template file to render (repeatable). Use - for stdin.")
cmd.Flags().String("dir", "", "Directory of templates to render recursively.")
cmd.Flags().String("context-file", "", "Path to a JSON or YAML file of context variables.")
cmd.Flags().String("context", "", "Raw JSON object of context variables (highest-fidelity floor; merged below --set).")
cmd.Flags().StringArray("set", nil, "Context variable as key=value (repeatable). Dotted keys nest, e.g. env_vars.region=eu-west-1. Highest precedence.")
}

// GetTemplateFiles returns the --file values.
func GetTemplateFiles(cmd *cobra.Command) ([]string, error) {
return cmd.Flags().GetStringArray("file")
}

// GetTemplateDir returns the --dir value.
func GetTemplateDir(cmd *cobra.Command) (string, error) {
return cmd.Flags().GetString("dir")
}

// GetTemplateContextFile returns the --context-file value.
func GetTemplateContextFile(cmd *cobra.Command) (string, error) {
return cmd.Flags().GetString("context-file")
}

// GetTemplateContextString returns the --context value.
func GetTemplateContextString(cmd *cobra.Command) (string, error) {
return cmd.Flags().GetString("context")
}

// GetTemplateSet returns the --set key=value pairs.
func GetTemplateSet(cmd *cobra.Command) ([]string, error) {
return cmd.Flags().GetStringArray("set")
}
Loading