Skip to content

Commit

Permalink
fix: Add support for relative paths when called from another dir (#619)
Browse files Browse the repository at this point in the history
* fix: re-evaluate all relative paths in relation to the directory from which `talhelper` is called

* key-aware substitution

* cleanup

* check if string HAS PREFIX "@" instead of CONTAINS

* add some tests

* add doc comment

* add `extraManifests` to `shouldSubstitute`

* replace with absolute paths
  • Loading branch information
mircea-pavel-anton authored Oct 1, 2024
1 parent f15c308 commit c052f15
Show file tree
Hide file tree
Showing 3 changed files with 275 additions and 0 deletions.
7 changes: 7 additions & 0 deletions pkg/config/loader.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
func LoadAndValidateFromFile(filePath string, envPaths []string, showWarns bool) (*TalhelperConfig, error) {
slog.Debug("start loading and validating config file")
slog.Debug(fmt.Sprintf("reading %s", filePath))

cfgByte, err := FromFile(filePath)
if err != nil {
return nil, fmt.Errorf("failed to read config file: %s", err)
Expand All @@ -32,6 +33,12 @@ func LoadAndValidateFromFile(filePath string, envPaths []string, showWarns bool)
return nil, fmt.Errorf("failed to substitute env: %s", err)
}

slog.Debug("substituting relative paths with absolute paths")
cfgByte, err = substitute.SubstituteRelativePaths(filePath, cfgByte)
if err != nil {
return nil, fmt.Errorf("failed to evaluate relative paths: %s", err)
}

cfg, err := NewFromByte(cfgByte)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal config file: %s", err)
Expand Down
86 changes: 86 additions & 0 deletions pkg/substitute/pathsubst.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package substitute

import (
"fmt"
"path/filepath"
"strings"

"gopkg.in/yaml.v2"
)

// SubstituteRelativePaths will replace all relative paths in the config file to new paths,
// relative to the working dir from which the CLI has been called.
// When using the `--config-file` flag to point to a file not in the current dir,
// relative path evaluation would fail. This function basically prepends the path to the config
// file to the relative paths in the config file so that their evaluation no longer fails.
func SubstituteRelativePaths(configFilePath string, yamlContent []byte) ([]byte, error) {
// Get the directory of the YAML file
absolutePath, err := filepath.Abs(filepath.Dir(configFilePath))
if err != nil {
return nil, err
}

// Parse the YAML content
var data interface{}
err = yaml.Unmarshal(yamlContent, &data)
if err != nil {
return nil, err
}

// Process the data
data = processNode(data, []string{}, absolutePath)

// Marshal back to YAML
newYamlContent, err := yaml.Marshal(data)
if err != nil {
return nil, err
}

return newYamlContent, nil
}

func processNode(node interface{}, path []string, yamlDir string) interface{} {
switch n := node.(type) {
case map[interface{}]interface{}:
newMap := make(map[interface{}]interface{})
for k, v := range n {
keyStr := fmt.Sprintf("%v", k)
newPath := append(path, keyStr)
newMap[k] = processNode(v, newPath, yamlDir)
}
return newMap

case []interface{}:
newArray := make([]interface{}, len(n))
for i, v := range n {
newPath := append(path, fmt.Sprintf("[%d]", i))
newArray[i] = processNode(v, newPath, yamlDir)
}
return newArray

case string:
if shouldSubstitute(path) {
if strings.HasPrefix(n, "@") {
parts := strings.SplitN(n, "@", 2)
if len(parts) == 2 && len(strings.TrimSpace(parts[1])) > 0 {
relativePath := strings.TrimSpace(parts[1])
absolutePath := filepath.Join(yamlDir, relativePath)
return parts[0] + "@" + absolutePath
}
}
}
return n

default:
return n
}
}

func shouldSubstitute(path []string) bool {
for _, p := range path {
if p == "machineFiles" || p == "patches" || p == "extraManifests" {
return true
}
}
return false
}
182 changes: 182 additions & 0 deletions pkg/substitute/pathsubst_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
package substitute

import (
"reflect"
"testing"

"gopkg.in/yaml.v2"
)

func TestSubstituteRelativePaths(t *testing.T) {
// Define the path to the YAML file (for testing purposes, we use a dummy path)
configFilePath := "/path/to/config.yaml"

// Define test cases
tests := []struct {
name string
yamlContent string
expectedOutput string
}{
{
name: "Substitute in machineFiles",
yamlContent: `
machineFiles:
- content: "@./relative/path/file.txt"
permissions: 0o644
path: /var/etc/tailscale/auth.env
op: create
`,
expectedOutput: `
machineFiles:
- content: "@/path/to/relative/path/file.txt"
permissions: 0o644
path: /var/etc/tailscale/auth.env
op: create
`,
},
{
name: "Substitute in patches",
yamlContent: `
patches:
- "@./relative/patch.yaml"
`,
expectedOutput: `
patches:
- "@/path/to/relative/patch.yaml"
`,
},
{
name: "No substitution outside specified sections",
yamlContent: `
nodes:
- hostname: kworker1
ipAddress: "@./should/not/replace"
`,
expectedOutput: `
nodes:
- hostname: kworker1
ipAddress: "@./should/not/replace"
`,
},
{
name: "Substitute in nested machineFiles",
yamlContent: `
nodes:
- hostname: kmaster1
machineFiles:
- content: "@./nested/path/file.conf"
path: /etc/config.conf
`,
expectedOutput: `
nodes:
- hostname: kmaster1
machineFiles:
- content: "@/path/to/nested/path/file.conf"
path: /etc/config.conf
`,
},
{
name: "Substitute in nested patches",
yamlContent: `
controlPlane:
patches:
- "@./control/plane/patch.yaml"
`,
expectedOutput: `
controlPlane:
patches:
- "@/path/to/control/plane/patch.yaml"
`,
},
{
name: "Multiple substitutions",
yamlContent: `
machineFiles:
- content: "@./file1.txt"
patches:
- "@./patch1.yaml"
nodes:
- hostname: node1
ipAddress: "@./should/not/replace"
machineFiles:
- content: "@./node1/file2.txt"
patches:
- "@./node1/patch2.yaml"
`,
expectedOutput: `
machineFiles:
- content: "@/path/to/file1.txt"
patches:
- "@/path/to/patch1.yaml"
nodes:
- hostname: node1
ipAddress: "@./should/not/replace"
machineFiles:
- content: "@/path/to/node1/file2.txt"
patches:
- "@/path/to/node1/patch2.yaml"
`,
},
{
name: "No substitution when '@' is not at the beginning",
yamlContent: `
machineFiles:
- content: "example@./should/not/replace"
`,
expectedOutput: `
machineFiles:
- content: "example@./should/not/replace"
`,
},
{
name: "Handle empty '@' reference",
yamlContent: `
machineFiles:
- content: "@"
`,
expectedOutput: `
machineFiles:
- content: "@"
`,
},
{
name: "Handle missing relative path after '@'",
yamlContent: `
machineFiles:
- content: "@ "
`,
expectedOutput: `
machineFiles:
- content: "@ "
`,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
outputBytes, err := SubstituteRelativePaths(configFilePath, []byte(tt.yamlContent))
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}

var expectedData interface{}
err = yaml.Unmarshal([]byte(tt.expectedOutput), &expectedData)
if err != nil {
t.Fatalf("Failed to unmarshal expected output: %v", err)
}

var actualData interface{}
err = yaml.Unmarshal(outputBytes, &actualData)
if err != nil {
t.Fatalf("Failed to unmarshal actual output: %v", err)
}

if !reflect.DeepEqual(actualData, expectedData) {
// For better error messages, re-marshal the data to YAML strings
expectedYAML, _ := yaml.Marshal(expectedData)
actualYAML, _ := yaml.Marshal(actualData)
t.Errorf("Output mismatch.\nExpected:\n%s\nGot:\n%s", expectedYAML, actualYAML)
}
})
}
}

0 comments on commit c052f15

Please sign in to comment.