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