From 6be13601c49872ddcdd3ded5ca464a13ee8d6a33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Barras=20Hampel?= Date: Fri, 12 Jun 2026 14:07:34 +0200 Subject: [PATCH 1/3] feat(template): add offline `cy template render` command Render Cycloid stack templates locally with no backend, the missing cornerstone for local stack editing and debugging by humans and agents. The interpolation engine is vendored from youdeploy-http-api under internal/templating/engine (thin adapter: yderr/errtmpl swapped for the stdlib, see VENDORED.md) so offline output is byte-identical to the backend and stays parity-testable. The CLI wrapper adds: - layered context input: component base < context file (JSON/YAML) < stdin/string < --set k=v (highest), with dotted keys and deep-merge - placeholder rendering: unset *known* vars render as instead of erroring, so a template can be exercised without a full platform context; unknown refs warn - multi-template input via --file (repeatable), --dir walk, or stdin; JSON report per template, non-zero exit on any render failure Flags live in internal/cyargs/templating.go; the command is registered in cmd/root.go. Unit tests cover render, layering precedence, and the YAML deep-merge regression; e2e covers the command surface. Co-Authored-By: Claude Opus 4.8 --- cmd/cycloid/template/cmd.go | 23 + cmd/cycloid/template/render.go | 187 +++++ cmd/root.go | 4 +- e2e/template_test.go | 71 ++ internal/cyargs/templating.go | 39 + internal/templating/context.go | 139 ++++ internal/templating/engine/VENDORED.md | 35 + internal/templating/engine/func_map.go | 121 +++ internal/templating/engine/interpolator.go | 716 ++++++++++++++++++ .../engine/interpolator_entity_string.go | 374 +++++++++ .../templating/engine/interpolator_error.go | 27 + internal/templating/engine/known_keys.go | 22 + internal/templating/engine/version.go | 35 + internal/templating/templating.go | 96 +++ internal/templating/templating_test.go | 163 ++++ 15 files changed, 2051 insertions(+), 1 deletion(-) create mode 100644 cmd/cycloid/template/cmd.go create mode 100644 cmd/cycloid/template/render.go create mode 100644 e2e/template_test.go create mode 100644 internal/cyargs/templating.go create mode 100644 internal/templating/context.go create mode 100644 internal/templating/engine/VENDORED.md create mode 100644 internal/templating/engine/func_map.go create mode 100644 internal/templating/engine/interpolator.go create mode 100644 internal/templating/engine/interpolator_entity_string.go create mode 100644 internal/templating/engine/interpolator_error.go create mode 100644 internal/templating/engine/known_keys.go create mode 100644 internal/templating/engine/version.go create mode 100644 internal/templating/templating.go create mode 100644 internal/templating/templating_test.go diff --git a/cmd/cycloid/template/cmd.go b/cmd/cycloid/template/cmd.go new file mode 100644 index 00000000..db82944a --- /dev/null +++ b/cmd/cycloid/template/cmd.go @@ -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 +} diff --git a/cmd/cycloid/template/render.go b/cmd/cycloid/template/render.go new file mode 100644 index 00000000..8d7c014d --- /dev/null +++ b/cmd/cycloid/template/render.go @@ -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 +"" when they are known Cycloid variables, or are reported as +warnings when unknown.`, + Example: ` + # render a file with a couple of variables + cy 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 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 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 +} diff --git a/cmd/root.go b/cmd/root.go index eaab8288..9b9bd5e3 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -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" @@ -30,6 +30,7 @@ import ( "github.com/cycloidio/cycloid-cli/cmd/cycloid/roles" "github.com/cycloidio/cycloid-cli/cmd/cycloid/stacks" "github.com/cycloidio/cycloid-cli/cmd/cycloid/teams" + "github.com/cycloidio/cycloid-cli/cmd/cycloid/template" "github.com/cycloidio/cycloid-cli/cmd/cycloid/terracost" "github.com/cycloidio/cycloid-cli/cmd/cycloid/uri" "github.com/cycloidio/cycloid-cli/internal/cyout" @@ -175,6 +176,7 @@ func AttachCommands(cmd *cobra.Command) { kpis.NewCommands(), roles.NewCommands(), stacks.NewCommands(), + template.NewCommands(), login.NewCommands(), output.NewOutputCmd(), terracost.NewCommands(), diff --git a/e2e/template_test.go b/e2e/template_test.go new file mode 100644 index 00000000..5cbd46e8 --- /dev/null +++ b/e2e/template_test.go @@ -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 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{"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=\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{"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{"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{"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) + }) +} diff --git a/internal/cyargs/templating.go b/internal/cyargs/templating.go new file mode 100644 index 00000000..5a21f0f4 --- /dev/null +++ b/internal/cyargs/templating.go @@ -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") +} diff --git a/internal/templating/context.go b/internal/templating/context.go new file mode 100644 index 00000000..0e9febda --- /dev/null +++ b/internal/templating/context.go @@ -0,0 +1,139 @@ +package templating + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" +) + +// Context is the bag of template variables fed to the interpolation engine. +// Keys are the snake_case names used in templates (e.g. "project", +// "env_vars"). Values are decoded Go values (string, bool, float64, []any, +// map[string]any). +type Context map[string]any + +// LoadContextFile reads a JSON or YAML file into a Context. Format is chosen by +// extension (.json → JSON, .yaml/.yml → YAML); any other extension is decoded +// as YAML, which is a superset of JSON. +func LoadContextFile(path string) (Context, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read context file %q: %w", path, err) + } + ctx := Context{} + switch strings.ToLower(filepath.Ext(path)) { + case ".json": + if err := json.Unmarshal(raw, &ctx); err != nil { + return nil, fmt.Errorf("failed to parse JSON context file %q: %w", path, err) + } + default: + if err := yaml.Unmarshal(raw, &ctx); err != nil { + return nil, fmt.Errorf("failed to parse YAML context file %q: %w", path, err) + } + } + return normalizeContext(ctx), nil +} + +// ParseContextString decodes a raw JSON object string into a Context. +func ParseContextString(s string) (Context, error) { + if strings.TrimSpace(s) == "" { + return Context{}, nil + } + ctx := Context{} + if err := json.Unmarshal([]byte(s), &ctx); err != nil { + return nil, fmt.Errorf("failed to parse --context JSON: %w", err) + } + return normalizeContext(ctx), nil +} + +// normalizeContext rewrites every nested map to map[string]any regardless of +// how the decoder typed it (yaml.v3 reuses the named Context type for nested +// mappings; yaml.v2 can produce map[any]any). Without this, Merge's +// map[string]any type assertion would fail on nested maps and a partial --set +// override would clobber sibling keys instead of deep-merging. +func normalizeContext(ctx Context) Context { + out := Context{} + for k, v := range ctx { + out[k] = normalizeValue(v) + } + return out +} + +func normalizeValue(v any) any { + switch m := v.(type) { + case Context: + return map[string]any(normalizeContext(m)) + case map[string]any: + return map[string]any(normalizeContext(Context(m))) + case map[any]any: + nm := map[string]any{} + for k, vv := range m { + nm[fmt.Sprint(k)] = normalizeValue(vv) + } + return nm + case []any: + for i := range m { + m[i] = normalizeValue(m[i]) + } + return m + default: + return v + } +} + +// ParseSet turns repeatable `key=value` pairs into a Context. Dotted keys +// address nested maps: "env_vars.region=eu-west-1" becomes +// {"env_vars": {"region": "eu-west-1"}}. Values are kept as strings; wrap a +// whole-object override in --context or a context file when richer types are +// needed. +func ParseSet(pairs []string) (Context, error) { + ctx := Context{} + for _, p := range pairs { + eq := strings.IndexByte(p, '=') + if eq < 0 { + return nil, fmt.Errorf("invalid --set %q: expected key=value", p) + } + key, val := p[:eq], p[eq+1:] + if key == "" { + return nil, fmt.Errorf("invalid --set %q: empty key", p) + } + setPath(ctx, strings.Split(key, "."), val) + } + return ctx, nil +} + +// setPath assigns val at the dotted path within m, creating intermediate maps. +func setPath(m map[string]any, path []string, val any) { + for i := 0; i < len(path)-1; i++ { + next, ok := m[path[i]].(map[string]any) + if !ok { + next = map[string]any{} + m[path[i]] = next + } + m = next + } + m[path[len(path)-1]] = val +} + +// Merge deep-merges src into dst in place and returns dst. Nested maps are +// merged recursively; for any other type src overwrites dst. Call with sources +// in ascending precedence (later wins) — the §2.5 layering contract. +func Merge(dst, src Context) Context { + if dst == nil { + dst = Context{} + } + for k, sv := range src { + if sm, ok := sv.(map[string]any); ok { + if dm, ok := dst[k].(map[string]any); ok { + Merge(dm, sm) + continue + } + } + dst[k] = sv + } + return dst +} diff --git a/internal/templating/engine/VENDORED.md b/internal/templating/engine/VENDORED.md new file mode 100644 index 00000000..a949c2f3 --- /dev/null +++ b/internal/templating/engine/VENDORED.md @@ -0,0 +1,35 @@ +# Vendored interpolation engine — TEMPORARY + +This package is a **thin-adapted copy** of the Cycloid interpolation engine from +`youdeploy-http-api`, pulled in so the CLI can render templates **offline** with +no backend. It exists only until the CLI→backend merge. + +## Source + +- Repo: `cycloidio/youdeploy-http-api` +- Commit: `39d97e36b1dc683fe3582416888fb26df4f0da70` +- Files: + - `utils/interpolator.go` → `interpolator.go` + - `utils/interpolator_entity_string.go` → `interpolator_entity_string.go` (verbatim) + - `utils/helmutils/func_map.go` → `func_map.go` (verbatim, repackaged) + - `utils/interpolator_error.go` → `interpolator_error.go` (adapted) + - `services/youdeploy/svccat/version`→ `version.go` (minimal local reimplementation) + +## Adaptations + +The only changes from upstream are error plumbing: the backend's +`yderr`/`errtmpl` taxonomy (~4.8k lines, DB/service-coupled) is replaced with +stdlib `errors`/`fmt`. **Rendered output is identical** — only error *types* and +*wording* differ. This is what the render-parity test guards (output, not error +internals). + +## On the CLI→backend merge + +Delete this whole directory and import the engine directly from the backend +(`utils.Interpolator`). Re-point `internal/templating` at it. The parity test +becomes redundant for the engine half at that point. + +## Do not + +- Add features here. Behavioural changes belong upstream, then re-vendor. +- Re-introduce `yderr`/`errtmpl` — keep the adapter surface minimal. diff --git a/internal/templating/engine/func_map.go b/internal/templating/engine/func_map.go new file mode 100644 index 00000000..17c3ca52 --- /dev/null +++ b/internal/templating/engine/func_map.go @@ -0,0 +1,121 @@ +package engine + +import ( + "errors" + "fmt" + "strings" + "text/template" +) + +// recursionMaxNums defined the maximal count of nested references +const recursionMaxNums = 1000 + +// InitFuncMap inits a function map used by helm templating engine and sets it to the template. +// It is based on spring functions with helm extensions. +// If strict parameter is set to true, missing keys will be treated as errors. +// The content of this function is taken from +// https://github.com/helm/helm/blob/v3.19.0/pkg/engine/engine.go#L193 +// The function reference can be found here: +// https://helm.sh/docs/howto/charts_tips_and_tricks/#know-your-template-functions +func FuncMap(t *template.Template, strict bool) template.FuncMap { + funcMap := make(template.FuncMap, 3) + + // Add the template-rendering functions here so we can close over t. + includedNames := make(map[string]int) + funcMap["include"] = includeFun(t, includedNames) + funcMap["tpl"] = tplFun(t, includedNames, strict) + + // Add the `required` function here so we can use lintMode + // Reference: https://helm.sh/docs/howto/charts_tips_and_tricks/#using-the-required-function + funcMap["required"] = func(warn string, val interface{}) (interface{}, error) { + if val == nil { + // if e.LintMode { + // // Don't fail on missing required values when linting + // slog.Warn("missing required value", "message", warn) + // return "", nil + // } + return val, errors.New(warn) + } else if _, ok := val.(string); ok { + if val == "" { + // if e.LintMode { + // // Don't fail on missing required values when linting + // slog.Warn("missing required values", "message", warn) + // return "", nil + // } + return val, errors.New(warn) + } + } + return val, nil + } + + return funcMap +} + +// 'include' needs to be defined in the scope of a 'tpl' template as +// well as regular file-loaded templates. +// Copied from https://github.com/helm/helm/blob/v3.19.0/pkg/engine/engine.go#L129 +// Reference: https://helm.sh/docs/howto/charts_tips_and_tricks/#using-the-include-function +func includeFun(t *template.Template, includedNames map[string]int) func(string, interface{}) (string, error) { + return func(name string, data interface{}) (string, error) { + var buf strings.Builder + if v, ok := includedNames[name]; ok { + if v > recursionMaxNums { + return "", fmt.Errorf("include recursion limit exceeded: included name: %s", name) + } + includedNames[name]++ + } else { + includedNames[name] = 1 + } + err := t.ExecuteTemplate(&buf, name, data) + includedNames[name]-- + return buf.String(), err + } +} + +// As does 'tpl', so that nested calls to 'tpl' see the templates +// defined by their enclosing contexts. +// Copied from https://github.com/helm/helm/blob/v3.19.0/pkg/engine/engine.go#L148 +// Reference: https://helm.sh/docs/howto/charts_tips_and_tricks/#using-the-tpl-function +func tplFun(parent *template.Template, includedNames map[string]int, strict bool) func(string, interface{}) (string, error) { + return func(tpl string, vals interface{}) (string, error) { + t, err := parent.Clone() + if err != nil { + return "", errors.New("cannot clone template") + } + + // Re-inject the missingkey option, see text/template issue https://github.com/golang/go/issues/43022 + // We have to go by strict from our engine configuration, as the option fields are private in Template. + // TODO: Remove workaround (and the strict parameter) once we build only with golang versions with a fix. + if strict { + t.Option("missingkey=error") + } else { + t.Option("missingkey=zero") + } + + // Re-inject 'include' so that it can close over our clone of t; + // this lets any 'define's inside tpl be 'include'd. + t.Funcs(template.FuncMap{ + "include": includeFun(t, includedNames), + "tpl": tplFun(t, includedNames, strict), + }) + + // We need a .New template, as template text which is just blanks + // or comments after parsing out defines just adds new named + // template definitions without changing the main template. + // https://pkg.go.dev/text/template#Template.Parse + // Use the parent's name for lack of a better way to identify the tpl + // text string. (Maybe we could use a hash appended to the name?) + t, err = t.New(parent.Name()).Parse(tpl) + if err != nil { + return "", fmt.Errorf("cannot parse template %q", tpl) + } + + var buf strings.Builder + if err := t.Execute(&buf, vals); err != nil { + return "", fmt.Errorf("error during tpl function execution for %q", tpl) + } + + // See comment in renderWithReferences explaining the hack. + return strings.ReplaceAll(buf.String(), "", ""), nil + } +} diff --git a/internal/templating/engine/interpolator.go b/internal/templating/engine/interpolator.go new file mode 100644 index 00000000..c97e8237 --- /dev/null +++ b/internal/templating/engine/interpolator.go @@ -0,0 +1,716 @@ +package engine + +import ( + "bytes" + "errors" + "fmt" + "maps" + "regexp" + "strings" + "text/template" + + "github.com/Masterminds/sprig/v3" +) + +//go:generate go tool enumer -type=interpolatorEntity -transform=snake -output=interpolator_entity_string.go -linecomment=true + +// Interpolator is used for interpolating placeholder variables like +// ($ .project $) or ($ project $) with actual values +type Interpolator struct { + Version Version + + Organization string + OrganizationName string + + ParentOrganization string + ParentOrganizationName string + + Project string + ProjectName string + ProjectOwnerCan string + ProjectOwnerName string + ProjectOwnerSurname string + ProjectOwnerEmail string + + Environment string + EnvironmentName string + EnvironmentType string + EnvironmentTypeName string + + SCSURL string + SCSBranch string + SCSCredentialType string + SCSCredentialPath string + SCS string + SCSName string + StackPath string + Stack string + StackName string + StackVersionType string + StackVersionName string + StackVersionCommit string + + CRURL string + CRBranch string + CRCredentialType string + CRCredentialPath string + CR string + CRName string + + Component string + ComponentName string + ComponentUseCase string + + StackFormUpdatedByUser string + StackFormUpdatedByUserEmail string + + InventoryJWT string + APIURL string + ConsoleURL string + + ConfigRoot string + + CurrentUserUsername string + + // EnvVars holds the per-environment variables exposed to templates as + // `.env_vars.` (aliased `.environment_vars.`). Keys map to + // user-defined variable names; values are the decoded Go values (string, + // bool, int64, list, map). + EnvVars map[string]any + + // EnvProvider holds the cloud accounts attached to the environment, + // exposed to templates under the `.env_providers` top-level key (aliased + // `.environment_providers`) and keyed by the cloud provider canonical (aws, + // google, azurerm) or, for custom providers, the cloud account canonical. + // Each entry flattens the account name/canonical with the credential + // metadata and decoded values, e.g. `($ .env_providers.aws.access_key $)`. + EnvProvider map[string]any +} + +const ( + InterpolatorDelimLeft = "($" + InterpolatorDelimRight = "$)" + defaultTemplateName = "cycloid_interpolator" +) + +var reCyInterpolation = regexp.MustCompile(`\(\$([^)]+)\$\)`) + +// HasInterpolation checks if 's' has ($ $) +func HasInterpolation(s string) bool { + if res := reCyInterpolation.FindAllStringSubmatch(s, -1); len(res) != 0 { + return true + } + return false +} + +// Interpolate will interpolate the given string with the actual values +// defined by the interpolator. Parameter templateName is used to build a meaningful error message +// if the interpolation fails. +func (i Interpolator) Interpolate(s, templateName string) (string, error) { + result, err := i.InterpolateWithExtraData(s, templateName, nil) + if err != nil { + return "", fmt.Errorf("failed to interpolate: %w", err) + } + + return result, nil +} + +// InterpolateWithExtraData will interpolate the given string with the actual values +// defined by the interpolator and additional, passed by extraData parameter. +// If extraData defines fields already defined by the interpolator, they will be overridden. +// Extra data is omitted if using a deprecated version. +// Parameter templateName is optional. +func (i Interpolator) InterpolateWithExtraData(s, templateName string, extraData map[string]interface{}) (string, error) { + if !i.Version.IsAVersion() { + return "", fmt.Errorf("invalid interpolator version: %s", i.Version) + } + + s = escapeLinesWithIncompleteDelimiter(s) + + // inventory_jwt must never be substituted with an empty value: callers + // persist the rendered string and a silently empty JWT defeats inventory + // authorization at the consumer. extraData can override interpolator + // fields on the new-style path (the deprecated string-replace ignores + // extraData), so a caller-supplied JWT counts as available there. + effectiveJWT := i.InventoryJWT + if effectiveJWT == "" && i.Version.IsNewInterpolation() { + if v, ok := extraData[inventoryJWT.String()].(string); ok { + effectiveJWT = v + } + } + if effectiveJWT == "" && reInventoryJWTRef.MatchString(s) { + msg := `inventory_jwt is referenced by the template but is not available — ensure an ExternalBackend providing a JWT is configured at the org, project, environment, or component scope` + if templateName != "" { + msg = fmt.Sprintf("template %q: %s", templateName, msg) + } + return "", errors.New(msg) + } + + if !i.Version.IsNewInterpolation() { + result, err := i.interpolateDeprecatedVersion(s) + if err != nil { + return "", fmt.Errorf("error interpolating: %w", err) + } + + return unescapeIncompleteDelimiters(result), nil + } + + result, err := i.interpolate(s, templateName, extraData) + if err != nil { + return "", fmt.Errorf("error interpolating: %w", err) + } + + return unescapeIncompleteDelimiters(result), nil +} + +// escapeLinesWithIncompleteDelimiter will escape the lines that have the left delimiter +// but not the right one +// It works for multiline incomplete delimiters as well +func escapeLinesWithIncompleteDelimiter(s string) string { + lines := strings.Split(s, "\n") + var result []string + var buffer string + inIncompleteDelimiter := false + + for _, line := range lines { + if strings.Contains(line, InterpolatorDelimLeft) && !inIncompleteDelimiter { + if strings.Contains(line, InterpolatorDelimRight) { + result = append(result, line) + } else { + buffer = line + inIncompleteDelimiter = true + } + } else if inIncompleteDelimiter { + buffer += "\n" + line + if strings.Contains(line, InterpolatorDelimRight) { + result = append(result, buffer) + buffer = "" + inIncompleteDelimiter = false + } + } else { + result = append(result, line) + } + } + + if inIncompleteDelimiter { + result = append(result, escapeDelimiter(buffer)) + } + + return strings.Join(result, "\n") +} + +// escapeDelimiter will escape the given delimiter in the string +func escapeDelimiter(s string) string { + s = strings.ReplaceAll(s, InterpolatorDelimLeft, `\(\$`) + return s +} + +// unescapeIncompleteDelimiters will unescape the delimiters in the given string +func unescapeIncompleteDelimiters(s string) string { + s = strings.ReplaceAll(s, `\(\$`, InterpolatorDelimLeft) + s = strings.ReplaceAll(s, `\$\)`, InterpolatorDelimRight) + return s +} + +// interpolate will replace all placeholders with actual values from +// Interpolator. If value is not present, the placeholder will not be +// interpolated. This version of the interpolator (> V2) uses the Go template +// for rendering. Parameter templateName is optional and set to default if not given. +// Parameter extraData defines additional variables used to execute a template. +// If extraData defines fields already defined by the interpolator, they will be overridden. +func (i Interpolator) interpolate(s, templateName string, extraData map[string]interface{}) (string, error) { + data := i.dataMap() + // Add extra data if any + maps.Copy(data, extraData) + + if templateName == "" { + templateName = defaultTemplateName + } + + getOption := func(strict bool) string { + if strict { + return "missingkey=error" + } + return "missingkey=zero" + } + + strict := false + tmpl := template.New(templateName). + Delims(InterpolatorDelimLeft, InterpolatorDelimRight). + Option(getOption(strict)) + + // Init function map with extra functions + funcMap := sprig.FuncMap() + // Security reasons + delete(funcMap, "env") + delete(funcMap, "expandenv") + // Add helm functions + helmFuncs := FuncMap(tmpl, strict) + maps.Copy(funcMap, helmFuncs) + // Assign to the template + tmpl.Funcs(funcMap) + + tmpl, err := tmpl.Parse(s) + if err != nil { + return "", wrapInterpolatorErr(templateName, err) + } + + var buff bytes.Buffer + err = tmpl.Execute(&buff, data) + if err != nil { + return "", fmt.Errorf("error rendering template: %w", err) + } + + return buff.String(), nil +} + +type interpolatorEntity int + +// These are the key for the rendering data +const ( + // Organization related interpolation keys + // org == organization == orgCanonical == organizationCanonical + // orgName == organizationName + org interpolatorEntity = iota // org + organization // organization + orgCanonical // org_canonical + organizationCanonical // organization_canonical + orgName // org_name + organizationName // organization_name + + parentOrg // parent_org + parentOrganization // parent_organization + parentOrgCanonical // parent_org_canonical + parentOrganizationCanonical // parent_organization_canonical + parentOrgName // parent_org_name + parentOrganizationName // parent_organization_name + + // Project related interpolation keys + // project == projectCanonical + // projectOwner == projectOwnerCanonical + project // project + projectCanonical // project_canonical + projectName // project_name + projectOwnerCanonical // project_owner_canonical + projectOwner // project_owner + projectOwnerName // project_owner_name + projectOwnerSurname // project_owner_surname + projectOwnerEmail // project_owner_email + + // Environment related interpolation keys + // env == environment == envCanonical == environmentCanonical + // envName == environmentName + env // env + environment // environment + envCanonical // env_canonical + environmentCanonical // environment_canonical + envName // env_name + environmentName // environment_name + envType // env_type + environmentType // environment_type + envTypeCanonical // env_type_canonical + environmentTypeCanonical // environment_type_canonical + envTypeName // env_type_name + environmentTypeName // environment_type_name + + // Component related interpolation keys + // component == componentCanonical + component // component + componentCanonical // component_canonical + componentName // component_name + stackFormUpdatedByUser // stackform_updated_by_user + stackFormUpdatedByUserUsername // stackform_updated_by_user_username + stackFormUpdatedByUserEmail // stackform_updated_by_user_email + + // Stack related interpolation keys + // stack == stackCanonical + // catalogRepository == catalogRepositoryCanonical + // Deprecated, use catalogRepositoryURL + scsURL // scs_url + // Deprecated, use catalogRepositoryBranch + scsBranch // scs_branch + // Deprecated, use catalogRepositoryCredentialType + scsCredType // scs_cred_type + // Deprecated, use catalogRepositoryCredentialPath + scsCredPath // scs_cred_path + // Deprecated, use catalogRepositoryCanonical + scsCanonical // scs_canonical + // Deprecated, use catalogRepositoryName + scsName // scs_name + catalogRepository // catalog_repository + catalogRepositoryCanonical // catalog_repository_canonical + catalogRepositoryName // catalog_repository_name + catalogRepositoryURL // catalog_repository_url + catalogRepositoryBranch // catalog_repository_branch + catalogRepositoryCredentialType // catalog_repository_credential_type + catalogRepositoryCredentialPath // catalog_repository_credential_path + stack // stack + stackCanonical // stack_canonical + stackName // stack_name + stackPath // stack_path + stackVersionName // stack_version_name + stackVersionRef // stack_version_ref + stackVersionCommit // stack_version_commit + stackVersionType // stack_version_type + + // CR related interpolation keys + // configRepository == configRepositoryCanonical + // Deprecated, use configRepositoryURL + crURL // cr_url + // Deprecated, use configRepositoryBranch + crBranch // cr_branch + // Deprecated, use configRepositoryCredentialType + crCredType // cr_cred_type + // Deprecated, use configRepositoryCredentialPath + crCredPath // cr_cred_path + configRepository // config_repository + configRepositoryCanonical // config_repository_canonical + configRepositoryName // config_repository_name + configRepositoryURL // config_repository_url + configRepositoryBranch // config_repository_branch + configRepositoryCredentialType // config_repository_credential_type + configRepositoryCredentialPath // config_repository_credential_path + + // Miscellaneous interpolation keys + inventoryJWT // inventory_jwt + apiURL // api_url + consoleURL // console_url + useCase // use_case + configRoot // config_root + + currentUserUsername // current_user_username +) + +func (i Interpolator) dataMap() map[string]any { + // These are the mappings used to render the template + mappings := map[interpolatorEntity]any{} + if i.Organization != "" { + mappings[organizationCanonical] = i.Organization + mappings[organization] = i.Organization + mappings[orgCanonical] = i.Organization + mappings[org] = i.Organization + } + if i.OrganizationName != "" { + mappings[organizationName] = i.OrganizationName + mappings[orgName] = i.OrganizationName + } + if i.ParentOrganization != "" { + mappings[parentOrg] = i.ParentOrganization + mappings[parentOrganization] = i.ParentOrganization + mappings[parentOrgCanonical] = i.ParentOrganization + mappings[parentOrganizationCanonical] = i.ParentOrganization + } + if i.ParentOrganizationName != "" { + mappings[parentOrgName] = i.ParentOrganizationName + mappings[parentOrganizationName] = i.ParentOrganizationName + } + if i.Project != "" { + mappings[project] = i.Project + mappings[projectCanonical] = i.Project + } + if i.ProjectName != "" { + mappings[projectName] = i.ProjectName + } + if i.ProjectOwnerCan != "" { + mappings[projectOwnerCanonical] = i.ProjectOwnerCan + mappings[projectOwner] = i.ProjectOwnerCan + } + if i.ProjectOwnerName != "" { + mappings[projectOwnerName] = i.ProjectOwnerName + } + if i.ProjectOwnerSurname != "" { + mappings[projectOwnerSurname] = i.ProjectOwnerSurname + } + if i.ProjectOwnerEmail != "" { + mappings[projectOwnerEmail] = i.ProjectOwnerEmail + } + if i.Environment != "" { + mappings[environment] = i.Environment + mappings[environmentCanonical] = i.Environment + mappings[env] = i.Environment + mappings[envCanonical] = i.Environment + } + if i.EnvironmentName != "" { + mappings[environmentName] = i.EnvironmentName + mappings[envName] = i.EnvironmentName + } + if i.EnvironmentType != "" { + mappings[envType] = i.EnvironmentType + mappings[environmentType] = i.EnvironmentType + mappings[envTypeCanonical] = i.EnvironmentType + mappings[environmentTypeCanonical] = i.EnvironmentType + } + if i.EnvironmentTypeName != "" { + mappings[envTypeName] = i.EnvironmentTypeName + mappings[environmentTypeName] = i.EnvironmentTypeName + } + if i.SCS != "" { + mappings[scsCanonical] = i.SCS + mappings[catalogRepositoryCanonical] = i.SCS + mappings[catalogRepository] = i.SCS + } + if i.SCSName != "" { + mappings[scsName] = i.SCSName + mappings[catalogRepositoryName] = i.SCSName + } + if i.SCSURL != "" { + mappings[scsURL] = i.SCSURL + mappings[catalogRepositoryURL] = i.SCSURL + } + if i.SCSBranch != "" { + mappings[scsBranch] = i.SCSBranch + mappings[catalogRepositoryBranch] = i.SCSBranch + } + if i.SCSCredentialType != "" { + mappings[scsCredType] = i.SCSCredentialType + mappings[catalogRepositoryCredentialType] = i.SCSCredentialType + } + if i.SCSCredentialPath != "" { + mappings[scsCredPath] = i.SCSCredentialPath + mappings[catalogRepositoryCredentialPath] = i.SCSCredentialPath + } + if i.Stack != "" { + mappings[stack] = i.Stack + mappings[stackCanonical] = i.Stack + } + if i.StackName != "" { + mappings[stackName] = i.StackName + } + if i.StackVersionName != "" { + mappings[stackVersionName] = i.StackVersionName + mappings[stackVersionRef] = i.StackVersionName + } + if i.StackVersionCommit != "" { + mappings[stackVersionCommit] = i.StackVersionCommit + } + if i.StackVersionType != "" { + mappings[stackVersionType] = i.StackVersionType + } + if i.StackPath != "" { + mappings[stackPath] = i.StackPath + } + if i.CR != "" { + mappings[configRepository] = i.CR + mappings[configRepositoryCanonical] = i.CR + } + if i.CRName != "" { + mappings[configRepositoryName] = i.CRName + } + if i.CRURL != "" { + mappings[crURL] = i.CRURL + mappings[configRepositoryURL] = i.CRURL + } + if i.CRBranch != "" { + mappings[crBranch] = i.CRBranch + mappings[configRepositoryBranch] = i.CRBranch + } + if i.CRCredentialType != "" { + mappings[crCredType] = i.CRCredentialType + mappings[configRepositoryCredentialType] = i.CRCredentialType + } + if i.CRCredentialPath != "" { + mappings[crCredPath] = i.CRCredentialPath + mappings[configRepositoryCredentialPath] = i.CRCredentialPath + } + if i.InventoryJWT != "" { + mappings[inventoryJWT] = i.InventoryJWT + } + if i.APIURL != "" { + mappings[apiURL] = i.APIURL + } + if i.ConsoleURL != "" { + mappings[consoleURL] = i.ConsoleURL + } + if i.ComponentUseCase != "" { + mappings[useCase] = i.ComponentUseCase + } + if i.Component != "" { + mappings[component] = i.Component + mappings[componentCanonical] = i.Component + } + if i.ComponentName != "" { + mappings[componentName] = i.ComponentName + } + if i.StackFormUpdatedByUser != "" { + mappings[stackFormUpdatedByUser] = i.StackFormUpdatedByUser + mappings[stackFormUpdatedByUserUsername] = i.StackFormUpdatedByUser + } + if i.StackFormUpdatedByUserEmail != "" { + mappings[stackFormUpdatedByUserEmail] = i.StackFormUpdatedByUserEmail + } + if i.ConfigRoot != "" { + mappings[configRoot] = i.ConfigRoot + } + if i.CurrentUserUsername != "" { + mappings[currentUserUsername] = i.CurrentUserUsername + } + + // Convert the map to a map[string]interface{} + // so it can be used in the template + + data := make(map[string]any) + for k, v := range mappings { + data[k.String()] = v + } + + // environment stays a flat string (set above via mappings) so it keeps + // working in comparisons and pipes, e.g. ($ if eq .environment "prod" $) + // or ($ .environment | upper $), exactly as before environment governance. + // Per-environment variables and cloud accounts are dynamic collections, so + // they are exposed as their own top-level map keys (indexable by user or + // cloud data), each under both the env_ and environment_ prefix to alias + // like the scalar env_*/environment_* vars above. + if len(i.EnvVars) > 0 { + data["env_vars"] = i.EnvVars + data["environment_vars"] = i.EnvVars + } + if len(i.EnvProvider) > 0 { + data["env_providers"] = i.EnvProvider + data["environment_providers"] = i.EnvProvider + } + + return data +} + +// Values below represent known placeholders +// Deprecated, they're being used in the outdated version interpolation +var ( + // rePrj is the regexp to replace '($ project $)' + rePrj = regexp.MustCompile(`\(\$(?:\s+)?project(?:\s+)?\$\)`) + + // rePrjOwnerCan is the regexp to replace '($ project_owner_canonical $)' + rePrjOwnerCan = regexp.MustCompile(`\(\$(?:\s+)?project_owner_canonical(?:\s+)?\$\)`) + + // rePrjOwnerName is the regexp to replace '($ project_owner_name $)' + rePrjOwnerName = regexp.MustCompile(`\(\$(?:\s+)?project_owner_name(?:\s+)?\$\)`) + + // rePrjOwnerSurname is the regexp to replace '($ project_owner_surname $)' + rePrjOwnerSurname = regexp.MustCompile(`\(\$(?:\s+)?project_owner_surname(?:\s+)?\$\)`) + + // rePrjOwnerEmail is the regexp to replace '($ project_owner_email $)' + rePrjOwnerEmail = regexp.MustCompile(`\(\$(?:\s+)?project_owner_email(?:\s+)?\$\)`) + + // reEnv is the regexp to replace '($ environment $)' + reEnv = regexp.MustCompile(`\(\$(?:\s+)?environment(?:\s+)?\$\)`) + + // reOrg is the regexp to replace '($ organization_canonical $)' + reOrg = regexp.MustCompile(`\(\$(?:\s+)?organization_canonical(?:\s+)?\$\)`) + + // reSCSURL is the regexp to replace '($ scs_url $)' + reSCSURL = regexp.MustCompile(`\(\$(?:\s+)?scs_url(?:\s+)?\$\)`) + + // reSCSBranch is the regexp to replace '($ scs_branch $)' + reSCSBranch = regexp.MustCompile(`\(\$(?:\s+)?scs_branch(?:\s+)?\$\)`) + + // reSCSrecentialType is the regexp to replace '($ scs_cred_type $)' + reSCSrecentialType = regexp.MustCompile(`\(\$(?:\s+)?scs_cred_type(?:\s+)?\$\)`) + + // reSCSrecentialPath is the regexp to replace '($ scs_cred_path $)' + reSCSrecentialPath = regexp.MustCompile(`\(\$(?:\s+)?scs_cred_path(?:\s+)?\$\)`) + + // reStackPath is the regexp to replace '($ stack_path $)' + reStackPath = regexp.MustCompile(`\(\$(?:\s+)?stack_path(?:\s+)?\$\)`) + + // reCRURL is the regexp to replace '($ cr_url $)' + reCRURL = regexp.MustCompile(`\(\$(?:\s+)?cr_url(?:\s+)?\$\)`) + + // reCRBranch is the regexp to replace '($ cr_branch $)' + reCRBranch = regexp.MustCompile(`\(\$(?:\s+)?cr_branch(?:\s+)?\$\)`) + + // reCRCrecentialType is the regexp to replace '($ cr_cred_type $)' + reCRCrecentialType = regexp.MustCompile(`\(\$(?:\s+)?cr_cred_type(?:\s+)?\$\)`) + + // reCRCrecentialPath is the regexp to replace '($ cr_cred_path $)' + reCRCrecentialPath = regexp.MustCompile(`\(\$(?:\s+)?cr_cred_path(?:\s+)?\$\)`) + + // reInventoryJWT is the regexp to replace '($ inventory_jwt $)' + reInventoryJWT = regexp.MustCompile(`\(\$(?:\s+)?inventory_jwt(?:\s+)?\$\)`) + + // reInventoryJWTRef matches a bare `($ inventory_jwt $)` or + // `($ .inventory_jwt $)`. Wrapper expressions + // (`($ if .inventory_jwt $)`, `($ default "" .inventory_jwt $)`, + // `($ .inventory_jwt | upper $)`) don't match, but a bare reference + // inside a guarded body does — and that's intentional: those guards + // existed only to work around the EB-creation-ordering bug that this + // change fixes at the source, so callers can drop them. + reInventoryJWTRef = regexp.MustCompile(`\(\$\s*\.?\s*inventory_jwt\s*\$\)`) + + // reAPIURL is the regexp to replace '($ api_url $)' + reAPIURL = regexp.MustCompile(`\(\$(?:\s+)?api_url(?:\s+)?\$\)`) + + // reConsoleURL is the regexp to replace '($ console_url $)' + reConsoleURL = regexp.MustCompile(`\(\$(?:\s+)?console_url(?:\s+)?\$\)`) + + // reUseCase is the regexp to replace '($ use_case $)' + reUseCase = regexp.MustCompile(`\(\$(?:\s+)?use_case(?:\s+)?\$\)`) + + // reComponent is the regexp to replace '($ component $)' + reComponent = regexp.MustCompile(`\(\$(?:\s+)?component(?:\s+)?\$\)`) +) + +func (i Interpolator) interpolateDeprecatedVersion(s string) (string, error) { + if i.Organization != "" { + s = reOrg.ReplaceAllString(s, i.Organization) + } + if i.Project != "" { + s = rePrj.ReplaceAllString(s, i.Project) + } + if i.ProjectOwnerCan != "" { + s = rePrjOwnerCan.ReplaceAllString(s, i.ProjectOwnerCan) + } + if i.ProjectOwnerName != "" { + s = rePrjOwnerName.ReplaceAllString(s, i.ProjectOwnerName) + } + if i.ProjectOwnerSurname != "" { + s = rePrjOwnerSurname.ReplaceAllString(s, i.ProjectOwnerSurname) + } + if i.ProjectOwnerEmail != "" { + s = rePrjOwnerEmail.ReplaceAllString(s, i.ProjectOwnerEmail) + } + if i.Environment != "" { + s = reEnv.ReplaceAllString(s, i.Environment) + } + if i.SCSURL != "" { + s = reSCSURL.ReplaceAllString(s, i.SCSURL) + } + if i.SCSBranch != "" { + s = reSCSBranch.ReplaceAllString(s, i.SCSBranch) + } + if i.SCSCredentialType != "" { + s = reSCSrecentialType.ReplaceAllString(s, i.SCSCredentialType) + } + if i.SCSCredentialPath != "" { + s = reSCSrecentialPath.ReplaceAllString(s, i.SCSCredentialPath) + } + if i.StackPath != "" { + s = reStackPath.ReplaceAllString(s, i.StackPath) + } + if i.CRURL != "" { + s = reCRURL.ReplaceAllString(s, i.CRURL) + } + if i.CRBranch != "" { + s = reCRBranch.ReplaceAllString(s, i.CRBranch) + } + if i.CRCredentialType != "" { + s = reCRCrecentialType.ReplaceAllString(s, i.CRCredentialType) + } + if i.CRCredentialPath != "" { + s = reCRCrecentialPath.ReplaceAllString(s, i.CRCredentialPath) + } + if i.InventoryJWT != "" { + s = reInventoryJWT.ReplaceAllString(s, i.InventoryJWT) + } + if i.APIURL != "" { + s = reAPIURL.ReplaceAllString(s, i.APIURL) + } + if i.ConsoleURL != "" { + s = reConsoleURL.ReplaceAllString(s, i.ConsoleURL) + } + if i.ComponentUseCase != "" { + s = reUseCase.ReplaceAllString(s, i.ComponentUseCase) + } + if i.Component != "" { + s = reComponent.ReplaceAllString(s, i.Component) + } + return s, nil +} diff --git a/internal/templating/engine/interpolator_entity_string.go b/internal/templating/engine/interpolator_entity_string.go new file mode 100644 index 00000000..f5d3e757 --- /dev/null +++ b/internal/templating/engine/interpolator_entity_string.go @@ -0,0 +1,374 @@ +// Code generated by "enumer -type=interpolatorEntity -transform=snake -output=interpolator_entity_string.go -linecomment=true"; DO NOT EDIT. + +package engine + +import ( + "fmt" + "strings" +) + +const _interpolatorEntityName = "orgorganizationorg_canonicalorganization_canonicalorg_nameorganization_nameparent_orgparent_organizationparent_org_canonicalparent_organization_canonicalparent_org_nameparent_organization_nameprojectproject_canonicalproject_nameproject_owner_canonicalproject_ownerproject_owner_nameproject_owner_surnameproject_owner_emailenvenvironmentenv_canonicalenvironment_canonicalenv_nameenvironment_nameenv_typeenvironment_typeenv_type_canonicalenvironment_type_canonicalenv_type_nameenvironment_type_namecomponentcomponent_canonicalcomponent_namestackform_updated_by_userstackform_updated_by_user_usernamestackform_updated_by_user_emailscs_urlscs_branchscs_cred_typescs_cred_pathscs_canonicalscs_namecatalog_repositorycatalog_repository_canonicalcatalog_repository_namecatalog_repository_urlcatalog_repository_branchcatalog_repository_credential_typecatalog_repository_credential_pathstackstack_canonicalstack_namestack_pathstack_version_namestack_version_refstack_version_commitstack_version_typecr_urlcr_branchcr_cred_typecr_cred_pathconfig_repositoryconfig_repository_canonicalconfig_repository_nameconfig_repository_urlconfig_repository_branchconfig_repository_credential_typeconfig_repository_credential_pathinventory_jwtapi_urlconsole_urluse_caseconfig_rootcurrent_user_username" + +var _interpolatorEntityIndex = [...]uint16{0, 3, 15, 28, 50, 58, 75, 85, 104, 124, 153, 168, 192, 199, 216, 228, 251, 264, 282, 303, 322, 325, 336, 349, 370, 378, 394, 402, 418, 436, 462, 475, 496, 505, 524, 538, 563, 597, 628, 635, 645, 658, 671, 684, 692, 710, 738, 761, 783, 808, 842, 876, 881, 896, 906, 916, 934, 951, 971, 989, 995, 1004, 1016, 1028, 1045, 1072, 1094, 1115, 1139, 1172, 1205, 1218, 1225, 1236, 1244, 1255, 1276} + +const _interpolatorEntityLowerName = "orgorganizationorg_canonicalorganization_canonicalorg_nameorganization_nameparent_orgparent_organizationparent_org_canonicalparent_organization_canonicalparent_org_nameparent_organization_nameprojectproject_canonicalproject_nameproject_owner_canonicalproject_ownerproject_owner_nameproject_owner_surnameproject_owner_emailenvenvironmentenv_canonicalenvironment_canonicalenv_nameenvironment_nameenv_typeenvironment_typeenv_type_canonicalenvironment_type_canonicalenv_type_nameenvironment_type_namecomponentcomponent_canonicalcomponent_namestackform_updated_by_userstackform_updated_by_user_usernamestackform_updated_by_user_emailscs_urlscs_branchscs_cred_typescs_cred_pathscs_canonicalscs_namecatalog_repositorycatalog_repository_canonicalcatalog_repository_namecatalog_repository_urlcatalog_repository_branchcatalog_repository_credential_typecatalog_repository_credential_pathstackstack_canonicalstack_namestack_pathstack_version_namestack_version_refstack_version_commitstack_version_typecr_urlcr_branchcr_cred_typecr_cred_pathconfig_repositoryconfig_repository_canonicalconfig_repository_nameconfig_repository_urlconfig_repository_branchconfig_repository_credential_typeconfig_repository_credential_pathinventory_jwtapi_urlconsole_urluse_caseconfig_rootcurrent_user_username" + +func (i interpolatorEntity) String() string { + if i < 0 || i >= interpolatorEntity(len(_interpolatorEntityIndex)-1) { + return fmt.Sprintf("interpolatorEntity(%d)", i) + } + return _interpolatorEntityName[_interpolatorEntityIndex[i]:_interpolatorEntityIndex[i+1]] +} + +// An "invalid array index" compiler error signifies that the constant values have changed. +// Re-run the stringer command to generate them again. +func _interpolatorEntityNoOp() { + var x [1]struct{} + _ = x[org-(0)] + _ = x[organization-(1)] + _ = x[orgCanonical-(2)] + _ = x[organizationCanonical-(3)] + _ = x[orgName-(4)] + _ = x[organizationName-(5)] + _ = x[parentOrg-(6)] + _ = x[parentOrganization-(7)] + _ = x[parentOrgCanonical-(8)] + _ = x[parentOrganizationCanonical-(9)] + _ = x[parentOrgName-(10)] + _ = x[parentOrganizationName-(11)] + _ = x[project-(12)] + _ = x[projectCanonical-(13)] + _ = x[projectName-(14)] + _ = x[projectOwnerCanonical-(15)] + _ = x[projectOwner-(16)] + _ = x[projectOwnerName-(17)] + _ = x[projectOwnerSurname-(18)] + _ = x[projectOwnerEmail-(19)] + _ = x[env-(20)] + _ = x[environment-(21)] + _ = x[envCanonical-(22)] + _ = x[environmentCanonical-(23)] + _ = x[envName-(24)] + _ = x[environmentName-(25)] + _ = x[envType-(26)] + _ = x[environmentType-(27)] + _ = x[envTypeCanonical-(28)] + _ = x[environmentTypeCanonical-(29)] + _ = x[envTypeName-(30)] + _ = x[environmentTypeName-(31)] + _ = x[component-(32)] + _ = x[componentCanonical-(33)] + _ = x[componentName-(34)] + _ = x[stackFormUpdatedByUser-(35)] + _ = x[stackFormUpdatedByUserUsername-(36)] + _ = x[stackFormUpdatedByUserEmail-(37)] + _ = x[scsURL-(38)] + _ = x[scsBranch-(39)] + _ = x[scsCredType-(40)] + _ = x[scsCredPath-(41)] + _ = x[scsCanonical-(42)] + _ = x[scsName-(43)] + _ = x[catalogRepository-(44)] + _ = x[catalogRepositoryCanonical-(45)] + _ = x[catalogRepositoryName-(46)] + _ = x[catalogRepositoryURL-(47)] + _ = x[catalogRepositoryBranch-(48)] + _ = x[catalogRepositoryCredentialType-(49)] + _ = x[catalogRepositoryCredentialPath-(50)] + _ = x[stack-(51)] + _ = x[stackCanonical-(52)] + _ = x[stackName-(53)] + _ = x[stackPath-(54)] + _ = x[stackVersionName-(55)] + _ = x[stackVersionRef-(56)] + _ = x[stackVersionCommit-(57)] + _ = x[stackVersionType-(58)] + _ = x[crURL-(59)] + _ = x[crBranch-(60)] + _ = x[crCredType-(61)] + _ = x[crCredPath-(62)] + _ = x[configRepository-(63)] + _ = x[configRepositoryCanonical-(64)] + _ = x[configRepositoryName-(65)] + _ = x[configRepositoryURL-(66)] + _ = x[configRepositoryBranch-(67)] + _ = x[configRepositoryCredentialType-(68)] + _ = x[configRepositoryCredentialPath-(69)] + _ = x[inventoryJWT-(70)] + _ = x[apiURL-(71)] + _ = x[consoleURL-(72)] + _ = x[useCase-(73)] + _ = x[configRoot-(74)] + _ = x[currentUserUsername-(75)] +} + +var _interpolatorEntityValues = []interpolatorEntity{org, organization, orgCanonical, organizationCanonical, orgName, organizationName, parentOrg, parentOrganization, parentOrgCanonical, parentOrganizationCanonical, parentOrgName, parentOrganizationName, project, projectCanonical, projectName, projectOwnerCanonical, projectOwner, projectOwnerName, projectOwnerSurname, projectOwnerEmail, env, environment, envCanonical, environmentCanonical, envName, environmentName, envType, environmentType, envTypeCanonical, environmentTypeCanonical, envTypeName, environmentTypeName, component, componentCanonical, componentName, stackFormUpdatedByUser, stackFormUpdatedByUserUsername, stackFormUpdatedByUserEmail, scsURL, scsBranch, scsCredType, scsCredPath, scsCanonical, scsName, catalogRepository, catalogRepositoryCanonical, catalogRepositoryName, catalogRepositoryURL, catalogRepositoryBranch, catalogRepositoryCredentialType, catalogRepositoryCredentialPath, stack, stackCanonical, stackName, stackPath, stackVersionName, stackVersionRef, stackVersionCommit, stackVersionType, crURL, crBranch, crCredType, crCredPath, configRepository, configRepositoryCanonical, configRepositoryName, configRepositoryURL, configRepositoryBranch, configRepositoryCredentialType, configRepositoryCredentialPath, inventoryJWT, apiURL, consoleURL, useCase, configRoot, currentUserUsername} + +var _interpolatorEntityNameToValueMap = map[string]interpolatorEntity{ + _interpolatorEntityName[0:3]: org, + _interpolatorEntityLowerName[0:3]: org, + _interpolatorEntityName[3:15]: organization, + _interpolatorEntityLowerName[3:15]: organization, + _interpolatorEntityName[15:28]: orgCanonical, + _interpolatorEntityLowerName[15:28]: orgCanonical, + _interpolatorEntityName[28:50]: organizationCanonical, + _interpolatorEntityLowerName[28:50]: organizationCanonical, + _interpolatorEntityName[50:58]: orgName, + _interpolatorEntityLowerName[50:58]: orgName, + _interpolatorEntityName[58:75]: organizationName, + _interpolatorEntityLowerName[58:75]: organizationName, + _interpolatorEntityName[75:85]: parentOrg, + _interpolatorEntityLowerName[75:85]: parentOrg, + _interpolatorEntityName[85:104]: parentOrganization, + _interpolatorEntityLowerName[85:104]: parentOrganization, + _interpolatorEntityName[104:124]: parentOrgCanonical, + _interpolatorEntityLowerName[104:124]: parentOrgCanonical, + _interpolatorEntityName[124:153]: parentOrganizationCanonical, + _interpolatorEntityLowerName[124:153]: parentOrganizationCanonical, + _interpolatorEntityName[153:168]: parentOrgName, + _interpolatorEntityLowerName[153:168]: parentOrgName, + _interpolatorEntityName[168:192]: parentOrganizationName, + _interpolatorEntityLowerName[168:192]: parentOrganizationName, + _interpolatorEntityName[192:199]: project, + _interpolatorEntityLowerName[192:199]: project, + _interpolatorEntityName[199:216]: projectCanonical, + _interpolatorEntityLowerName[199:216]: projectCanonical, + _interpolatorEntityName[216:228]: projectName, + _interpolatorEntityLowerName[216:228]: projectName, + _interpolatorEntityName[228:251]: projectOwnerCanonical, + _interpolatorEntityLowerName[228:251]: projectOwnerCanonical, + _interpolatorEntityName[251:264]: projectOwner, + _interpolatorEntityLowerName[251:264]: projectOwner, + _interpolatorEntityName[264:282]: projectOwnerName, + _interpolatorEntityLowerName[264:282]: projectOwnerName, + _interpolatorEntityName[282:303]: projectOwnerSurname, + _interpolatorEntityLowerName[282:303]: projectOwnerSurname, + _interpolatorEntityName[303:322]: projectOwnerEmail, + _interpolatorEntityLowerName[303:322]: projectOwnerEmail, + _interpolatorEntityName[322:325]: env, + _interpolatorEntityLowerName[322:325]: env, + _interpolatorEntityName[325:336]: environment, + _interpolatorEntityLowerName[325:336]: environment, + _interpolatorEntityName[336:349]: envCanonical, + _interpolatorEntityLowerName[336:349]: envCanonical, + _interpolatorEntityName[349:370]: environmentCanonical, + _interpolatorEntityLowerName[349:370]: environmentCanonical, + _interpolatorEntityName[370:378]: envName, + _interpolatorEntityLowerName[370:378]: envName, + _interpolatorEntityName[378:394]: environmentName, + _interpolatorEntityLowerName[378:394]: environmentName, + _interpolatorEntityName[394:402]: envType, + _interpolatorEntityLowerName[394:402]: envType, + _interpolatorEntityName[402:418]: environmentType, + _interpolatorEntityLowerName[402:418]: environmentType, + _interpolatorEntityName[418:436]: envTypeCanonical, + _interpolatorEntityLowerName[418:436]: envTypeCanonical, + _interpolatorEntityName[436:462]: environmentTypeCanonical, + _interpolatorEntityLowerName[436:462]: environmentTypeCanonical, + _interpolatorEntityName[462:475]: envTypeName, + _interpolatorEntityLowerName[462:475]: envTypeName, + _interpolatorEntityName[475:496]: environmentTypeName, + _interpolatorEntityLowerName[475:496]: environmentTypeName, + _interpolatorEntityName[496:505]: component, + _interpolatorEntityLowerName[496:505]: component, + _interpolatorEntityName[505:524]: componentCanonical, + _interpolatorEntityLowerName[505:524]: componentCanonical, + _interpolatorEntityName[524:538]: componentName, + _interpolatorEntityLowerName[524:538]: componentName, + _interpolatorEntityName[538:563]: stackFormUpdatedByUser, + _interpolatorEntityLowerName[538:563]: stackFormUpdatedByUser, + _interpolatorEntityName[563:597]: stackFormUpdatedByUserUsername, + _interpolatorEntityLowerName[563:597]: stackFormUpdatedByUserUsername, + _interpolatorEntityName[597:628]: stackFormUpdatedByUserEmail, + _interpolatorEntityLowerName[597:628]: stackFormUpdatedByUserEmail, + _interpolatorEntityName[628:635]: scsURL, + _interpolatorEntityLowerName[628:635]: scsURL, + _interpolatorEntityName[635:645]: scsBranch, + _interpolatorEntityLowerName[635:645]: scsBranch, + _interpolatorEntityName[645:658]: scsCredType, + _interpolatorEntityLowerName[645:658]: scsCredType, + _interpolatorEntityName[658:671]: scsCredPath, + _interpolatorEntityLowerName[658:671]: scsCredPath, + _interpolatorEntityName[671:684]: scsCanonical, + _interpolatorEntityLowerName[671:684]: scsCanonical, + _interpolatorEntityName[684:692]: scsName, + _interpolatorEntityLowerName[684:692]: scsName, + _interpolatorEntityName[692:710]: catalogRepository, + _interpolatorEntityLowerName[692:710]: catalogRepository, + _interpolatorEntityName[710:738]: catalogRepositoryCanonical, + _interpolatorEntityLowerName[710:738]: catalogRepositoryCanonical, + _interpolatorEntityName[738:761]: catalogRepositoryName, + _interpolatorEntityLowerName[738:761]: catalogRepositoryName, + _interpolatorEntityName[761:783]: catalogRepositoryURL, + _interpolatorEntityLowerName[761:783]: catalogRepositoryURL, + _interpolatorEntityName[783:808]: catalogRepositoryBranch, + _interpolatorEntityLowerName[783:808]: catalogRepositoryBranch, + _interpolatorEntityName[808:842]: catalogRepositoryCredentialType, + _interpolatorEntityLowerName[808:842]: catalogRepositoryCredentialType, + _interpolatorEntityName[842:876]: catalogRepositoryCredentialPath, + _interpolatorEntityLowerName[842:876]: catalogRepositoryCredentialPath, + _interpolatorEntityName[876:881]: stack, + _interpolatorEntityLowerName[876:881]: stack, + _interpolatorEntityName[881:896]: stackCanonical, + _interpolatorEntityLowerName[881:896]: stackCanonical, + _interpolatorEntityName[896:906]: stackName, + _interpolatorEntityLowerName[896:906]: stackName, + _interpolatorEntityName[906:916]: stackPath, + _interpolatorEntityLowerName[906:916]: stackPath, + _interpolatorEntityName[916:934]: stackVersionName, + _interpolatorEntityLowerName[916:934]: stackVersionName, + _interpolatorEntityName[934:951]: stackVersionRef, + _interpolatorEntityLowerName[934:951]: stackVersionRef, + _interpolatorEntityName[951:971]: stackVersionCommit, + _interpolatorEntityLowerName[951:971]: stackVersionCommit, + _interpolatorEntityName[971:989]: stackVersionType, + _interpolatorEntityLowerName[971:989]: stackVersionType, + _interpolatorEntityName[989:995]: crURL, + _interpolatorEntityLowerName[989:995]: crURL, + _interpolatorEntityName[995:1004]: crBranch, + _interpolatorEntityLowerName[995:1004]: crBranch, + _interpolatorEntityName[1004:1016]: crCredType, + _interpolatorEntityLowerName[1004:1016]: crCredType, + _interpolatorEntityName[1016:1028]: crCredPath, + _interpolatorEntityLowerName[1016:1028]: crCredPath, + _interpolatorEntityName[1028:1045]: configRepository, + _interpolatorEntityLowerName[1028:1045]: configRepository, + _interpolatorEntityName[1045:1072]: configRepositoryCanonical, + _interpolatorEntityLowerName[1045:1072]: configRepositoryCanonical, + _interpolatorEntityName[1072:1094]: configRepositoryName, + _interpolatorEntityLowerName[1072:1094]: configRepositoryName, + _interpolatorEntityName[1094:1115]: configRepositoryURL, + _interpolatorEntityLowerName[1094:1115]: configRepositoryURL, + _interpolatorEntityName[1115:1139]: configRepositoryBranch, + _interpolatorEntityLowerName[1115:1139]: configRepositoryBranch, + _interpolatorEntityName[1139:1172]: configRepositoryCredentialType, + _interpolatorEntityLowerName[1139:1172]: configRepositoryCredentialType, + _interpolatorEntityName[1172:1205]: configRepositoryCredentialPath, + _interpolatorEntityLowerName[1172:1205]: configRepositoryCredentialPath, + _interpolatorEntityName[1205:1218]: inventoryJWT, + _interpolatorEntityLowerName[1205:1218]: inventoryJWT, + _interpolatorEntityName[1218:1225]: apiURL, + _interpolatorEntityLowerName[1218:1225]: apiURL, + _interpolatorEntityName[1225:1236]: consoleURL, + _interpolatorEntityLowerName[1225:1236]: consoleURL, + _interpolatorEntityName[1236:1244]: useCase, + _interpolatorEntityLowerName[1236:1244]: useCase, + _interpolatorEntityName[1244:1255]: configRoot, + _interpolatorEntityLowerName[1244:1255]: configRoot, + _interpolatorEntityName[1255:1276]: currentUserUsername, + _interpolatorEntityLowerName[1255:1276]: currentUserUsername, +} + +var _interpolatorEntityNames = []string{ + _interpolatorEntityName[0:3], + _interpolatorEntityName[3:15], + _interpolatorEntityName[15:28], + _interpolatorEntityName[28:50], + _interpolatorEntityName[50:58], + _interpolatorEntityName[58:75], + _interpolatorEntityName[75:85], + _interpolatorEntityName[85:104], + _interpolatorEntityName[104:124], + _interpolatorEntityName[124:153], + _interpolatorEntityName[153:168], + _interpolatorEntityName[168:192], + _interpolatorEntityName[192:199], + _interpolatorEntityName[199:216], + _interpolatorEntityName[216:228], + _interpolatorEntityName[228:251], + _interpolatorEntityName[251:264], + _interpolatorEntityName[264:282], + _interpolatorEntityName[282:303], + _interpolatorEntityName[303:322], + _interpolatorEntityName[322:325], + _interpolatorEntityName[325:336], + _interpolatorEntityName[336:349], + _interpolatorEntityName[349:370], + _interpolatorEntityName[370:378], + _interpolatorEntityName[378:394], + _interpolatorEntityName[394:402], + _interpolatorEntityName[402:418], + _interpolatorEntityName[418:436], + _interpolatorEntityName[436:462], + _interpolatorEntityName[462:475], + _interpolatorEntityName[475:496], + _interpolatorEntityName[496:505], + _interpolatorEntityName[505:524], + _interpolatorEntityName[524:538], + _interpolatorEntityName[538:563], + _interpolatorEntityName[563:597], + _interpolatorEntityName[597:628], + _interpolatorEntityName[628:635], + _interpolatorEntityName[635:645], + _interpolatorEntityName[645:658], + _interpolatorEntityName[658:671], + _interpolatorEntityName[671:684], + _interpolatorEntityName[684:692], + _interpolatorEntityName[692:710], + _interpolatorEntityName[710:738], + _interpolatorEntityName[738:761], + _interpolatorEntityName[761:783], + _interpolatorEntityName[783:808], + _interpolatorEntityName[808:842], + _interpolatorEntityName[842:876], + _interpolatorEntityName[876:881], + _interpolatorEntityName[881:896], + _interpolatorEntityName[896:906], + _interpolatorEntityName[906:916], + _interpolatorEntityName[916:934], + _interpolatorEntityName[934:951], + _interpolatorEntityName[951:971], + _interpolatorEntityName[971:989], + _interpolatorEntityName[989:995], + _interpolatorEntityName[995:1004], + _interpolatorEntityName[1004:1016], + _interpolatorEntityName[1016:1028], + _interpolatorEntityName[1028:1045], + _interpolatorEntityName[1045:1072], + _interpolatorEntityName[1072:1094], + _interpolatorEntityName[1094:1115], + _interpolatorEntityName[1115:1139], + _interpolatorEntityName[1139:1172], + _interpolatorEntityName[1172:1205], + _interpolatorEntityName[1205:1218], + _interpolatorEntityName[1218:1225], + _interpolatorEntityName[1225:1236], + _interpolatorEntityName[1236:1244], + _interpolatorEntityName[1244:1255], + _interpolatorEntityName[1255:1276], +} + +// interpolatorEntityString retrieves an enum value from the enum constants string name. +// Throws an error if the param is not part of the enum. +func interpolatorEntityString(s string) (interpolatorEntity, error) { + if val, ok := _interpolatorEntityNameToValueMap[s]; ok { + return val, nil + } + + if val, ok := _interpolatorEntityNameToValueMap[strings.ToLower(s)]; ok { + return val, nil + } + return 0, fmt.Errorf("%s does not belong to interpolatorEntity values", s) +} + +// interpolatorEntityValues returns all values of the enum +func interpolatorEntityValues() []interpolatorEntity { + return _interpolatorEntityValues +} + +// interpolatorEntityStrings returns a slice of all String values of the enum +func interpolatorEntityStrings() []string { + strs := make([]string, len(_interpolatorEntityNames)) + copy(strs, _interpolatorEntityNames) + return strs +} + +// IsAinterpolatorEntity returns "true" if the value is listed in the enum definition. "false" otherwise +func (i interpolatorEntity) IsAinterpolatorEntity() bool { + for _, v := range _interpolatorEntityValues { + if i == v { + return true + } + } + return false +} diff --git a/internal/templating/engine/interpolator_error.go b/internal/templating/engine/interpolator_error.go new file mode 100644 index 00000000..dfe2d78d --- /dev/null +++ b/internal/templating/engine/interpolator_error.go @@ -0,0 +1,27 @@ +package engine + +import ( + "fmt" + "regexp" +) + +var functionNotDefinedRe = regexp.MustCompile("function \"(.*?)\" not defined") + +// wrapInterpolatorErr produces a friendly message for template parse errors. +// Adapted from youdeploy-http-api/utils/interpolator_error.go: the backend wraps +// these in its yderr/errtmpl taxonomy; offline we only need the human-readable +// message, so it collapses to fmt.Errorf. Rendered output is unaffected. +func wrapInterpolatorErr(templateName string, err error) error { + rm := functionNotDefinedRe.FindStringSubmatch(err.Error()) + if len(rm) != 0 { + args := rm[1:] + return fmt.Errorf( + "interpolation error: %s function %q not defined, did you mean %q - a variable?", + templateName, + args[0], + "."+args[0], + ) + } + + return fmt.Errorf("interpolation error: %s", err.Error()) +} diff --git a/internal/templating/engine/known_keys.go b/internal/templating/engine/known_keys.go new file mode 100644 index 00000000..41666850 --- /dev/null +++ b/internal/templating/engine/known_keys.go @@ -0,0 +1,22 @@ +package engine + +// KnownKeys returns the set of top-level template variables the Cycloid +// interpolator recognizes: every scalar context key (org, project, env, +// component, repo coordinates, …) plus the dynamic collection keys. The +// offline render wrapper uses this to tell a *known-but-unset* variable +// (rendered as a placeholder) apart from an *unknown* one (a likely typo, +// surfaced as a warning). +func KnownKeys() map[string]struct{} { + keys := map[string]struct{}{ + "env_vars": {}, + "environment_vars": {}, + "env_providers": {}, + "environment_providers": {}, + } + // org is the first interpolatorEntity (iota 0) and currentUserUsername the + // last; iterate the full enum and collect each snake-cased name. + for e := org; e <= currentUserUsername; e++ { + keys[e.String()] = struct{}{} + } + return keys +} diff --git a/internal/templating/engine/version.go b/internal/templating/engine/version.go new file mode 100644 index 00000000..3218e7e5 --- /dev/null +++ b/internal/templating/engine/version.go @@ -0,0 +1,35 @@ +package engine + +import "strconv" + +// Version is the Cycloid service-catalog interpolation version. It is a local, +// dependency-free copy of youdeploy-http-api/services/youdeploy/svccat/version +// Version, carrying only what the interpolator needs. On the CLI→backend merge +// this file is deleted and the backend type imported directly (see VENDORED.md). +type Version uint8 + +const ( + V1 Version = iota + 1 // 1 + V2 // 2 + V3 // 3 + V4 // 4 + + // Latest is the default used by the offline templating engine. + Latest = V4 + // LatestNotDeprecated is the oldest non-deprecated version. + LatestNotDeprecated = V2 +) + +// IsNewInterpolation reports whether the version uses the Go text/template +// engine (V3+) rather than the deprecated regex string-replace path. +func (v Version) IsNewInterpolation() bool { return v > V2 } + +// IsDeprecatedVersion reports whether the version predates LatestNotDeprecated. +func (v Version) IsDeprecatedVersion() bool { return v < LatestNotDeprecated } + +// IsAVersion reports whether v is a known version value. +func (v Version) IsAVersion() bool { return v >= V1 && v <= V4 } + +// String renders the version as its numeric form, matching the backend's +// snake/linecomment enumer output ("1".."4"). +func (v Version) String() string { return strconv.Itoa(int(v)) } diff --git a/internal/templating/templating.go b/internal/templating/templating.go new file mode 100644 index 00000000..d81fa3c4 --- /dev/null +++ b/internal/templating/templating.go @@ -0,0 +1,96 @@ +// Package templating renders Cycloid stack templates locally, offline, with no +// backend. It wraps the interpolation engine vendored from youdeploy-http-api +// (internal/templating/engine) and adds the CLI-facing behavior: layered +// context input (file / stdin / --set) and placeholder rendering for unset +// variables. +// +// Unset *known* variables render as the literal "" instead +// of erroring or rendering empty, so a template can be exercised without a full +// platform context. Unknown bare references are surfaced as warnings (likely +// typos). This wrapper never mutates the engine, keeping it byte-identical to +// the backend for the render-parity test. +package templating + +import ( + "fmt" + "regexp" + "strings" + + "github.com/cycloidio/cycloid-cli/internal/templating/engine" +) + +// reToken matches a Cycloid interpolation token: ($ ... $). +var reToken = regexp.MustCompile(`\(\$([^)]+)\$\)`) + +// reBareRef matches a single dotted field reference inside a token, e.g. +// ".project". Function calls (no leading dot) and compound expressions +// (pipes, multiple segments, arguments) deliberately do not match — those are +// left to the engine. +var reBareRef = regexp.MustCompile(`^\.([a-z_][a-z0-9_]*)$`) + +// Report is the outcome of rendering one template. +type Report struct { + Name string `json:"name"` + Rendered string `json:"rendered"` + Unset []string `json:"unset_vars,omitempty"` // known vars referenced but not provided → placeholder + Warnings []string `json:"warnings,omitempty"` // unknown references and other non-fatal notes + Error string `json:"error,omitempty"` // parse/render failure (Rendered is empty) +} + +// PlaceholderFor returns the sentinel rendered for an unset known variable. +func PlaceholderFor(name string) string { return fmt.Sprintf("", name) } + +// Render interpolates tmpl with ctx using the offline engine (latest, non-deprecated +// interpolation version). ctx is not mutated. name labels the template in +// errors and the report. +func Render(name, tmpl string, ctx Context) Report { + report := Report{Name: name} + + // Work on a copy so placeholder injection never leaks into the caller's + // shared context across multiple files. + data := Merge(Context{}, ctx) + + known := engine.KnownKeys() + for _, ref := range bareRefs(tmpl) { + if _, provided := data[ref]; provided { + continue + } + if _, ok := known[ref]; ok { + data[ref] = PlaceholderFor(ref) + report.Unset = append(report.Unset, ref) + } else { + report.Warnings = append(report.Warnings, + fmt.Sprintf("unknown variable %q referenced; rendered empty", ref)) + } + } + + interp := engine.Interpolator{Version: engine.Latest} + out, err := interp.InterpolateWithExtraData(tmpl, name, map[string]any(data)) + if err != nil { + report.Error = err.Error() + return report + } + report.Rendered = out + return report +} + +// bareRefs returns the de-duplicated set of single-field references (without the +// leading dot) used in tmpl. +func bareRefs(tmpl string) []string { + seen := map[string]struct{}{} + var refs []string + for _, m := range reToken.FindAllStringSubmatch(tmpl, -1) { + inner := strings.TrimSpace(m[1]) + sub := reBareRef.FindStringSubmatch(inner) + if sub == nil { + continue + } + ref := sub[1] + if _, ok := seen[ref]; ok { + continue + } + seen[ref] = struct{}{} + refs = append(refs, ref) + } + return refs +} diff --git a/internal/templating/templating_test.go b/internal/templating/templating_test.go new file mode 100644 index 00000000..56e4eacb --- /dev/null +++ b/internal/templating/templating_test.go @@ -0,0 +1,163 @@ +package templating + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRender(t *testing.T) { + tcs := []struct { + name string + tmpl string + ctx Context + wantOut string + wantUnset []string + wantWarnLen int + wantErr bool + }{ + { + name: "provided variable", + tmpl: `p=($ .project $)`, + ctx: Context{"project": "my-app"}, + wantOut: `p=my-app`, + }, + { + name: "unset known variable renders placeholder", + tmpl: `e=($ .env $)`, + ctx: Context{}, + wantOut: `e=`, + wantUnset: []string{"env"}, + }, + { + name: "unknown variable warns and renders empty", + tmpl: `x=($ .nonsense $)`, + ctx: Context{}, + wantOut: `x=`, + wantWarnLen: 1, + }, + { + name: "sprig function", + tmpl: `u=($ .project | upper $)`, + ctx: Context{"project": "app"}, + wantOut: `u=APP`, + }, + { + name: "inventory_jwt unset is a placeholder, not an error", + tmpl: `j=($ .inventory_jwt $)`, + ctx: Context{}, + wantOut: `j=`, + wantUnset: []string{"inventory_jwt"}, + }, + { + name: "parse error is captured in report", + tmpl: `($ range .items $)`, + ctx: Context{}, + wantErr: true, + }, + { + name: "nested context value", + tmpl: `r=($ .env_vars.region $)`, + ctx: Context{"env_vars": map[string]any{"region": "eu-west-1"}}, + wantOut: `r=eu-west-1`, + }, + } + + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + r := Render(tc.name, tc.tmpl, tc.ctx) + if tc.wantErr { + assert.NotEmpty(t, r.Error) + assert.Empty(t, r.Rendered) + return + } + assert.Empty(t, r.Error) + assert.Equal(t, tc.wantOut, r.Rendered) + assert.Equal(t, tc.wantUnset, r.Unset) + assert.Len(t, r.Warnings, tc.wantWarnLen) + }) + } +} + +// TestRenderDoesNotMutateContext guards that placeholder injection for one +// template never leaks into the caller's context (which is reused across files). +func TestRenderDoesNotMutateContext(t *testing.T) { + ctx := Context{"project": "p"} + _ = Render("t", `($ .env $)`, ctx) + _, leaked := ctx["env"] + assert.False(t, leaked, "placeholder for unset var must not leak into caller context") +} + +func TestParseSet(t *testing.T) { + t.Run("flat and dotted keys", func(t *testing.T) { + got, err := ParseSet([]string{"project=app", "env_vars.region=eu", "env_vars.zone=a"}) + require.NoError(t, err) + assert.Equal(t, Context{ + "project": "app", + "env_vars": map[string]any{ + "region": "eu", + "zone": "a", + }, + }, got) + }) + + t.Run("value may contain equals", func(t *testing.T) { + got, err := ParseSet([]string{"token=a=b=c"}) + require.NoError(t, err) + assert.Equal(t, Context{"token": "a=b=c"}, got) + }) + + t.Run("rejects missing equals", func(t *testing.T) { + _, err := ParseSet([]string{"noeq"}) + assert.Error(t, err) + }) +} + +func TestMergePrecedence(t *testing.T) { + base := Context{"project": "base", "env_vars": map[string]any{"region": "us", "zone": "a"}} + over := Context{"project": "over", "env_vars": map[string]any{"region": "eu"}} + got := Merge(base, over) + assert.Equal(t, "over", got["project"]) + // nested maps deep-merge: region overridden, zone preserved. + assert.Equal(t, map[string]any{"region": "eu", "zone": "a"}, got["env_vars"]) +} + +func TestLoadContextFile(t *testing.T) { + dir := t.TempDir() + jsonPath := filepath.Join(dir, "ctx.json") + yamlPath := filepath.Join(dir, "ctx.yaml") + require.NoError(t, os.WriteFile(jsonPath, []byte(`{"project":"j","env_vars":{"region":"eu"}}`), 0o600)) + require.NoError(t, os.WriteFile(yamlPath, []byte("project: y\nenv_vars:\n region: us\n"), 0o600)) + + gotJSON, err := LoadContextFile(jsonPath) + require.NoError(t, err) + assert.Equal(t, "j", gotJSON["project"]) + + gotYAML, err := LoadContextFile(yamlPath) + require.NoError(t, err) + assert.Equal(t, "y", gotYAML["project"]) + // YAML nested maps must normalize to map[string]any so templates can index + // them AND so Merge can deep-merge them (see TestMergeNormalisedYAML). + assert.Equal(t, map[string]any{"region": "us"}, gotYAML["env_vars"]) +} + +// TestMergeNormalisedYAML guards the layering bug where a YAML-loaded nested +// map (decoded as the named Context type) defeated Merge's map[string]any deep +// merge, so a partial --set override silently clobbered sibling keys. +func TestMergeNormalisedYAML(t *testing.T) { + dir := t.TempDir() + yamlPath := filepath.Join(dir, "ctx.yaml") + require.NoError(t, os.WriteFile(yamlPath, []byte("env_vars:\n region: us\n zone: a\n"), 0o600)) + + fileCtx, err := LoadContextFile(yamlPath) + require.NoError(t, err) + setCtx, err := ParseSet([]string{"env_vars.region=eu"}) + require.NoError(t, err) + + merged := Merge(fileCtx, setCtx) + // region overridden by --set, zone preserved from the file. + assert.Equal(t, map[string]any{"region": "eu", "zone": "a"}, merged["env_vars"]) +} From b40adcefbda174c391c4d45b42ad7cde1ee0bdb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Barras=20Hampel?= Date: Fri, 12 Jun 2026 14:08:56 +0200 Subject: [PATCH 2/3] docs(changelog): add entry for offline `cy template render` (CLI#459) Co-Authored-By: Claude Opus 4.8 --- .../unreleased/Service-Catalog-ADDED-20260612-120851.yaml | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 changelog/unreleased/Service-Catalog-ADDED-20260612-120851.yaml diff --git a/changelog/unreleased/Service-Catalog-ADDED-20260612-120851.yaml b/changelog/unreleased/Service-Catalog-ADDED-20260612-120851.yaml new file mode 100644 index 00000000..408c97e5 --- /dev/null +++ b/changelog/unreleased/Service-Catalog-ADDED-20260612-120851.yaml @@ -0,0 +1,8 @@ +component: Service Catalog +kind: ADDED +body: New `cy 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: "" From 634e178c9d1d63f75c062ecbbb9bb00adbf776a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Barras=20Hampel?= Date: Wed, 17 Jun 2026 14:06:55 +0200 Subject: [PATCH 3/3] refactor(template): move `cy template render` under `cy beta` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `cy template render` is an experimental offline feature — park it under `cy beta template render` until the backend render+context endpoints land. Updates e2e tests, help examples, and changelog entry to match. Includes collateral gci import-sort and alignment fixes on four files touched by `make format`. Co-Authored-By: Claude Sonnet 4.6 --- .../Service-Catalog-ADDED-20260612-120851.yaml | 2 +- cmd/cycloid/beta/cmd.go | 2 ++ cmd/cycloid/environmenttypes/list.go | 8 ++++---- cmd/cycloid/plugins/registry/plugin/get.go | 2 +- cmd/cycloid/template/render.go | 6 +++--- cmd/root.go | 2 -- e2e/template_test.go | 12 ++++++------ internal/cyargs/common.go | 2 +- pkg/testcfg/config.go | 2 +- 9 files changed, 19 insertions(+), 19 deletions(-) diff --git a/changelog/unreleased/Service-Catalog-ADDED-20260612-120851.yaml b/changelog/unreleased/Service-Catalog-ADDED-20260612-120851.yaml index 408c97e5..e8906f71 100644 --- a/changelog/unreleased/Service-Catalog-ADDED-20260612-120851.yaml +++ b/changelog/unreleased/Service-Catalog-ADDED-20260612-120851.yaml @@ -1,6 +1,6 @@ component: Service Catalog kind: ADDED -body: New `cy 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. +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 diff --git a/cmd/cycloid/beta/cmd.go b/cmd/cycloid/beta/cmd.go index f3828bb7..69801017 100644 --- a/cmd/cycloid/beta/cmd.go +++ b/cmd/cycloid/beta/cmd.go @@ -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 { @@ -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 } diff --git a/cmd/cycloid/environmenttypes/list.go b/cmd/cycloid/environmenttypes/list.go index 26974129..3d2f76bc 100644 --- a/cmd/cycloid/environmenttypes/list.go +++ b/cmd/cycloid/environmenttypes/list.go @@ -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{}) diff --git a/cmd/cycloid/plugins/registry/plugin/get.go b/cmd/cycloid/plugins/registry/plugin/get.go index e7b4de6f..0828bb4c 100644 --- a/cmd/cycloid/plugins/registry/plugin/get.go +++ b/cmd/cycloid/plugins/registry/plugin/get.go @@ -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" diff --git a/cmd/cycloid/template/render.go b/cmd/cycloid/template/render.go index 8d7c014d..6c8ccbd2 100644 --- a/cmd/cycloid/template/render.go +++ b/cmd/cycloid/template/render.go @@ -30,13 +30,13 @@ Variables referenced by a template but not provided render as the literal warnings when unknown.`, Example: ` # render a file with a couple of variables - cy template render -f main.tf.tpl --set project=my-app --set env=prod + 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 template render -f main.tf.tpl --context-file ctx.yaml --set env_vars.region=eu-west-1 + 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 template render --dir ./templates -o json`, + cat ctx.json | cy beta template render --dir ./templates -o json`, RunE: runRender, Args: cobra.NoArgs, } diff --git a/cmd/root.go b/cmd/root.go index 9b9bd5e3..43f4adc9 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -30,7 +30,6 @@ import ( "github.com/cycloidio/cycloid-cli/cmd/cycloid/roles" "github.com/cycloidio/cycloid-cli/cmd/cycloid/stacks" "github.com/cycloidio/cycloid-cli/cmd/cycloid/teams" - "github.com/cycloidio/cycloid-cli/cmd/cycloid/template" "github.com/cycloidio/cycloid-cli/cmd/cycloid/terracost" "github.com/cycloidio/cycloid-cli/cmd/cycloid/uri" "github.com/cycloidio/cycloid-cli/internal/cyout" @@ -176,7 +175,6 @@ func AttachCommands(cmd *cobra.Command) { kpis.NewCommands(), roles.NewCommands(), stacks.NewCommands(), - template.NewCommands(), login.NewCommands(), output.NewOutputCmd(), terracost.NewCommands(), diff --git a/e2e/template_test.go b/e2e/template_test.go index 5cbd46e8..d76b8d93 100644 --- a/e2e/template_test.go +++ b/e2e/template_test.go @@ -19,8 +19,8 @@ type report struct { Error string `json:"error"` } -// TestTemplateRender exercises `cy template render` end to end. The command is -// fully offline (no API calls), so it does not depend on backend fixtures. +// 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") @@ -28,7 +28,7 @@ func TestTemplateRender(t *testing.T) { []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{"template", "render", "-f", tplPath, "--set", "project=my-app", "-o", "json"}) + 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)) @@ -42,7 +42,7 @@ func TestTemplateRender(t *testing.T) { 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{"template", "render", "-f", rtpl, "--context-file", ctxPath, "--set", "env_vars.region=eu", "-o", "json"}) + 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)) @@ -52,7 +52,7 @@ func TestTemplateRender(t *testing.T) { t.Run("stdin json context", func(t *testing.T) { out, _, err := executeCommandStdin(`{"project":"piped"}`, - []string{"template", "render", "-f", tplPath, "-o", "json"}) + []string{"beta", "template", "render", "-f", tplPath, "-o", "json"}) require.NoError(t, err) var r report require.NoError(t, json.Unmarshal([]byte(out), &r)) @@ -62,7 +62,7 @@ func TestTemplateRender(t *testing.T) { 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{"template", "render", "-f", bad, "-o", "json"}) + 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)) diff --git a/internal/cyargs/common.go b/internal/cyargs/common.go index f62b5762..030ed291 100644 --- a/internal/cyargs/common.go +++ b/internal/cyargs/common.go @@ -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)") } diff --git a/pkg/testcfg/config.go b/pkg/testcfg/config.go index 4bf42ff5..405ebba5 100644 --- a/pkg/testcfg/config.go +++ b/pkg/testcfg/config.go @@ -276,7 +276,7 @@ func (config *Config) NewTestProject(identifier string) (*models.Project, error) // The func will always be returned so even if err != nil, defer the func. func (config *Config) NewTestEnv(identifier, project string) (*models.Environment, error) { var ( - env = RandomCanonical(identifier) + env = RandomCanonical(identifier) envType = "production" )