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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
The diff you're trying to view is too large. We only load the first 3000 changed files.
10 changes: 9 additions & 1 deletion butane/base/util/file.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,15 @@ func ReadLocalFile(configPath, filesDir string) ([]byte, error) {
if err := EnsurePathWithinFilesDir(filePath, filesDir); err != nil {
return nil, err
}
return os.ReadFile(filePath)

file, err := os.Open(filePath)
if err != nil {
return nil, err
}
defer file.Close()

// TODO: keep old branch, if based on version?
return GomplateReadLocalFile(file)
}

// CheckForDecimalMode fails if the specified mode appears to have been
Expand Down
102 changes: 102 additions & 0 deletions butane/base/util/gomplate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// Copyright 2026 Red Hat, Inc
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package util
Comment thread
coderabbitai[bot] marked this conversation as resolved.

import (
"bytes"
"context"
"errors"
"io"
"os"

"github.com/hairyhenderson/gomplate/v5"
)

var (
EnableGomplate = false

GomplateConfigPath = ".gomplate.yaml"
renderer = gomplate.NewRenderer(gomplate.RenderOptions{})
renderContext = context.Background()
)

func parseGomplateConfig() (*gomplate.Config, error) {
f, err := os.Open(GomplateConfigPath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
return nil, err
}
defer f.Close()

return gomplate.Parse(f)
}

func InitGomplateRenderer() error {
config, err := parseGomplateConfig()
if err != nil {
renderer = nil
return err
}

if config != nil {
if config.Experimental {
renderContext = gomplate.SetExperimental(renderContext)
}

// Inspired by `gomplate.bindPlugins`
funcMap := map[string]any{}
for pluginName, plugin := range config.Plugins {
// default the timeout to the one in the config
timeout := config.PluginTimeout
if plugin.Timeout != 0 {
timeout = plugin.Timeout
}

funcMap[pluginName] = gomplate.PluginFunc(renderContext, plugin.Cmd, gomplate.PluginOpts{
Timeout: timeout,
Pipe: plugin.Pipe,
Stderr: config.Stderr,
Args: plugin.Args,
})
}

renderer = gomplate.NewRenderer(gomplate.RenderOptions{
Funcs: funcMap,
Datasources: config.DataSources,
Context: config.Context,
Templates: config.Templates,
LDelim: config.LDelim,
RDelim: config.RDelim,
MissingKey: config.MissingKey,
})
}
return nil
}

func GomplateReadLocalFile(file *os.File) ([]byte, error) {
fileContent, err := io.ReadAll(file)
if err != nil {
return nil, err
}
if !EnableGomplate {
return fileContent, nil
}

var buf bytes.Buffer
err = renderer.Render(renderContext, file.Name(), string(fileContent), &buf)
return buf.Bytes(), err
}
199 changes: 199 additions & 0 deletions butane/base/util/gomplate_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
// Copyright 2026 Red Hat, Inc
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package util

import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
)

func preserveGlobals(t *testing.T) func() {
t.Helper()
oldEnableGomplate := EnableGomplate
oldConfigPath := GomplateConfigPath
oldRenderer := renderer
oldContext := renderContext

return func() {
EnableGomplate = oldEnableGomplate
GomplateConfigPath = oldConfigPath
renderer = oldRenderer
renderContext = oldContext
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

func initGomplate(t *testing.T, gomplateConfig string) error {
t.Helper()
EnableGomplate = true

if gomplateConfig != "" {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, ".gomplate.yaml")

err := os.WriteFile(configPath, []byte(gomplateConfig), 0644)
if err != nil {
return err
}

GomplateConfigPath = configPath
} else {
GomplateConfigPath = ""
}

return InitGomplateRenderer()
}

func evalTemplate(t *testing.T, template string) (string, error) {
t.Helper()

tmpFile, err := os.CreateTemp("", "template-*.tmpl")
if err != nil {
t.Fatalf("failed to create temp file: %v", err)
}
defer os.Remove(tmpFile.Name())
defer tmpFile.Close()

if _, err := tmpFile.WriteString(template); err != nil {
t.Fatalf("failed to write to temp file: %v", err)
}
_, err = tmpFile.Seek(0, 0)
if err != nil {
t.Fatalf("failed to return to begining of temp file: %v", err)
}

output, err := GomplateReadLocalFile(tmpFile)
return string(output), err
}

func TestInvalidGomplateConfig(t *testing.T) {
defer preserveGlobals(t)()
if err := initGomplate(t, "not: valid: config: ["); err == nil {
t.Fatalf("gomplate initialization should have failed: %v", err)
}
}

func TestInvalidTemplate(t *testing.T) {
defer preserveGlobals(t)()
if err := initGomplate(t, "#empty config"); err != nil {
t.Fatalf("failed to write config: %v", err)
}
_, err := evalTemplate(t, "{{ .NonExistentField }}")

if err == nil {
t.Fatalf("expected error for missing key, got nil")
}
}

func TestNoCustomConfig(t *testing.T) {
defer preserveGlobals(t)()
if err := initGomplate(t, "#empty config"); err != nil {
t.Fatalf("failed to write config: %v", err)
}
rendered, err := evalTemplate(t, `{{ "foobarbazquxquux" | strings.Abbrev 9 }}`)

if err != nil {
t.Fatalf("unexpected error: %+v\n", err)
}

if rendered != "foobar..." {
t.Fatalf("Invalid rendered template, got: '%s'\n", rendered)
}
}

func TestGomplateConfigApplication(t *testing.T) {
defer preserveGlobals(t)()
// Create a mock HTTP server that returns a fixed JSON response
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
resp := map[string]string{"hello": "Hello"}
w.Header().Set("Content-Type", "application/json")
err := json.NewEncoder(w).Encode(resp)
if err != nil {
t.Fatalf("json encoding failed: %v", err)
}
}))
defer ts.Close()

configContent := `
leftDelim: ($(
rightDelim: )$)
context:
data:
url: ` + ts.URL
if err := initGomplate(t, configContent); err != nil {
t.Fatalf("failed to write config: %v", err)
}
rendered, err := evalTemplate(t, "($( .data.hello )$)!")

if err != nil {
t.Fatalf("unexpected error: %+v\n", err)
}
if rendered != "Hello!" {
t.Fatalf("Invalid rendered template, got: '%s'\n", rendered)
}
}

func TestGomplateDisabled(t *testing.T) {
defer preserveGlobals(t)()
EnableGomplate = false

expected := "some raw content"
rendered, err := evalTemplate(t, expected)

if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if rendered != expected {
t.Fatalf("Invalid rendered template, got: '%s'\n", rendered)
}
}

func TestMissingGomplateConfigFile(t *testing.T) {
defer preserveGlobals(t)()
EnableGomplate = true
GomplateConfigPath = "/nonexistent/path/.gomplate.yaml"

err := InitGomplateRenderer()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}

func TestGomplatePlugins(t *testing.T) {
defer preserveGlobals(t)()

configContent := `
plugins:
echo:
cmd: /bin/echo
args:
- foo
`
if err := initGomplate(t, configContent); err != nil {
t.Fatalf("failed to write config: %v", err)
}
// we also ensure no built-in functions got erased
rendered, err := evalTemplate(t, `{{ echo "bar" | strings.Trunc 6 }}`)

if err != nil {
t.Fatalf("unexpected error: %+v\n", err)
}
if rendered != "foo ba" {
t.Fatalf("Invalid rendered template, got: '%s'\n", rendered)
}
}
20 changes: 16 additions & 4 deletions butane/internal/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,15 @@ package main

import (
"fmt"
"io"
"os"

"github.com/spf13/pflag"

baseutil "github.com/coreos/ignition/v2/butane/base/util"
"github.com/coreos/ignition/v2/butane/config"
"github.com/coreos/ignition/v2/butane/config/common"
breport "github.com/coreos/ignition/v2/butane/internal/report"
"github.com/coreos/ignition/v2/butane/internal/version"

"github.com/spf13/pflag"
)

func fail(format string, args ...interface{}) {
Expand Down Expand Up @@ -61,6 +61,8 @@ func main() {
pflag.BoolVarP(&strict, "strict", "s", false, "fail on any warning")
pflag.BoolVarP(&options.Pretty, "pretty", "p", false, "output formatted json")
pflag.BoolVarP(&options.Raw, "raw", "r", false, "never wrap in a MachineConfig; force Ignition output")
pflag.BoolVarP(&baseutil.EnableGomplate, "enable-gomplate", "", false, "Enable gomplate evaluation")
pflag.StringVar(&baseutil.GomplateConfigPath, "gomplate-config", baseutil.GomplateConfigPath, "path to the gomplate configuration file")
pflag.BoolVar(&rawErrors, "raw-errors", false, "show raw errors, rather than pretty printing them")
pflag.StringVar(&colorFlag, "color", "auto", `control color output: "auto", "always", or "never"`)
pflag.Lookup("color").NoOptDefVal = "always"
Expand Down Expand Up @@ -110,6 +112,16 @@ func main() {
os.Exit(0)
}

if pflag.CommandLine.Changed("gomplate-config") {
baseutil.EnableGomplate = true
}
if baseutil.EnableGomplate {
err := baseutil.InitGomplateRenderer()
if err != nil {
fail("failed to initialize gomplate: %v\n", err)
}
}

infile := os.Stdin
filename := "<stdin>"
if input != "" {
Expand All @@ -122,7 +134,7 @@ func main() {
filename = input
}

dataIn, err := io.ReadAll(infile)
dataIn, err := baseutil.GomplateReadLocalFile(infile)
if err != nil {
fail("failed to read %s: %v\n", infile.Name(), err)
}
Expand Down
1 change: 1 addition & 0 deletions docs/release-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ nav_order: 9

### Changes

- Add support for [`gomplate`](https://github.com/hairyhenderson/gomplate) integration, opt-in via `--enable-gomplate`
- Refactored the Makefile and build script to match the Fedora RPM spec: separate build targets per binary, with `VERSION` and linker flags passed in at build time
- `build_blackbox_tests` builds a blackbox-specific `ignition` binary with `make`
- CI and GitHub Actions updated to build via `make ignition` and `make ignition-validate`
Expand Down
Loading
Loading