Skip to content
Closed
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.
14 changes: 13 additions & 1 deletion butane/base/util/file.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,17 @@ import (
"github.com/coreos/ignition/v2/butane/config/common"
)

var localFileReader = os.ReadFile

// SetLocalFileReader sets the reader used for local file contents. Passing nil
// restores the default reader.
func SetLocalFileReader(reader func(string) ([]byte, error)) {
if reader == nil {
reader = os.ReadFile
}
localFileReader = reader
}

func EnsurePathWithinFilesDir(path, filesDir string) error {
absBase, err := filepath.Abs(filesDir)
if err != nil {
Expand All @@ -47,7 +58,8 @@ func ReadLocalFile(configPath, filesDir string) ([]byte, error) {
if err := EnsurePathWithinFilesDir(filePath, filesDir); err != nil {
return nil, err
}
return os.ReadFile(filePath)

return localFileReader(filePath)
}

// CheckForDecimalMode fails if the specified mode appears to have been
Expand Down
111 changes: 111 additions & 0 deletions butane/internal/gomplate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// 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 main

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 gomplateReadFile(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
}

func gomplateReadLocalFile(path string) ([]byte, error) {
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer file.Close()
return gomplateReadFile(file)
}
222 changes: 222 additions & 0 deletions butane/internal/gomplate_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
// 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 main

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

baseutil "github.com/coreos/ignition/v2/butane/base/util"
)

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.
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 := gomplateReadFile(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 TestGomplateLocalFile(t *testing.T) {
defer preserveGlobals(t)()
baseutil.SetLocalFileReader(gomplateReadLocalFile)
defer baseutil.SetLocalFileReader(nil)

if err := initGomplate(t, "#empty config"); err != nil {
t.Fatalf("failed to write config: %v", err)
}
tmpDir := t.TempDir()
if err := os.WriteFile(filepath.Join(tmpDir, "template"), []byte(`{{ "foo" | strings.ToUpper }}`), 0644); err != nil {
t.Fatalf("failed to write template: %v", err)
}
rendered, err := baseutil.ReadLocalFile("template", tmpDir)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if string(rendered) != "FOO" {
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)
}
}
Loading
Loading