Skip to content
Merged
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
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .mega-linter.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,22 @@ IGNORE_GITIGNORED_FILES: true
REPORTERS_MARKDOWN_SUMMARY_TYPE: sections
LINTER_RULES_PATH: .
DISABLE_LINTERS:
# zizmor runs as a dedicated, standalone CI check (the `zizmor` job) with the
# repo's own configuration; MegaLinter's copy is redundant and, newly
# activated in v9.5.0, otherwise fails (it lacks GitHub API auth and reports
# pre-existing workflow-audit findings the dedicated check already governs).
- ACTION_ZIZMOR
Comment thread
devantler marked this conversation as resolved.
- GO_GOLANGCI_LINT # Disabled: MegaLinter container has Go 1.24.7, but project requires Go 1.26.0. CI runs golangci-lint separately with correct Go version.
- GO_REVIVE
- REPOSITORY_CHECKOV
- REPOSITORY_GITLEAKS
- REPOSITORY_GRYPE
# Disabled: consistent with the other dependency vulnerability scanners above
# (trivy/grype) — this repo relies on Dependabot for dependency CVE triage, not
# a blocking MegaLinter gate. osv-scanner was newly activated by MegaLinter
# v9.5.0 and otherwise fails on pre-existing transitive advisories and on
# building the desktop webview cgo module (gtk/webkit2gtk not in the image).
- REPOSITORY_OSV_SCANNER
- REPOSITORY_SECRETLINT
- REPOSITORY_TRIVY
- SPELL_CSPELL
Expand Down
50 changes: 13 additions & 37 deletions pkg/fsutil/configmanager/talos/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import (
"fmt"
"os"
"path/filepath"
"strings"

"github.com/devantler-tech/ksail/v7/pkg/envvar"
"github.com/devantler-tech/ksail/v7/pkg/fsutil"
configmanager "github.com/devantler-tech/ksail/v7/pkg/fsutil/configmanager"
talosconfig "github.com/siderolabs/talos/pkg/machinery/config"
"sigs.k8s.io/yaml"
Expand Down Expand Up @@ -221,43 +221,19 @@ func (m *ConfigManager) ValidateConfigs() (*Configs, error) {
return configs, nil
}

// forEachYAMLFile iterates over YAML files in a directory and calls the callback for each.
// This is a shared helper to avoid code duplication between manager and patches.
// forEachYAMLFile iterates over YAML files in a directory and calls the callback
// for each, with environment variables expanded in the file content. It delegates
// directory walking and path-safe reads to fsutil.ForEachYAMLFile so the traversal
// logic is shared across the codebase.
func forEachYAMLFile(dir string, callback func(filePath string, content []byte) error) error {
cleanDir := filepath.Clean(dir)

entries, err := os.ReadDir(cleanDir)
if err != nil {
return fmt.Errorf("failed to read directory %s: %w", cleanDir, err)
}

for _, entry := range entries {
if entry.IsDir() {
continue
}

name := entry.Name()
if !strings.HasSuffix(name, ".yaml") && !strings.HasSuffix(name, ".yml") {
continue
}

filePath := filepath.Join(cleanDir, filepath.Clean(name))

content, readErr := os.ReadFile(filePath) //nolint:gosec // Path from validated directory
if readErr != nil {
return fmt.Errorf("failed to read file '%s': %w", filePath, readErr)
}

// Expand environment variables in file content
content = envvar.ExpandBytes(content)

callbackErr := callback(filePath, content)
if callbackErr != nil {
return callbackErr
}
}

return nil
// Thin adapter: fsutil.ForEachYAMLFile wraps its own traversal errors and the
// callbacks supplied by talos callers wrap theirs, so this pass-through does
// not re-wrap.
//nolint:wrapcheck // pass-through adapter; fsutil and callbacks wrap their own errors
return fsutil.ForEachYAMLFile(dir, func(filePath string, content []byte) error {
// Expand environment variables in file content before handing to the caller.
return callback(filePath, envvar.ExpandBytes(content))
})
}

// validateYAMLFilesInDir checks that all .yaml and .yml files in a directory are valid YAML.
Expand Down
42 changes: 42 additions & 0 deletions pkg/fsutil/reader.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,48 @@ func ReadFileSafe(basePath, filePath string) ([]byte, error) {
return data, nil
}

// ForEachYAMLFile walks the .yaml/.yml files (non-recursively) in dir and calls
// callback with each file's path and contents. The directory is canonicalized
// and each file is read with ReadFileSafe, so reads are confined to dir.
// Iteration stops early and returns the error if callback returns a non-nil
// error (callers can use a sentinel error to break on a match).
func ForEachYAMLFile(dir string, callback func(path string, content []byte) error) error {
Comment thread
devantler marked this conversation as resolved.
Comment thread
devantler marked this conversation as resolved.
canonDir, err := EvalCanonicalPath(dir)
if err != nil {
return fmt.Errorf("resolving directory %s: %w", dir, err)
}

entries, err := os.ReadDir(canonDir)
if err != nil {
return fmt.Errorf("reading directory %s: %w", canonDir, err)
}

for _, entry := range entries {
if entry.IsDir() {
continue
}

name := entry.Name()
if !strings.HasSuffix(name, ".yaml") && !strings.HasSuffix(name, ".yml") {
continue
}

filePath := filepath.Join(canonDir, name)

content, readErr := ReadFileSafe(canonDir, filePath)
if readErr != nil {
return fmt.Errorf("reading %s: %w", filePath, readErr)
}

callbackErr := callback(filePath, content)
if callbackErr != nil {
return callbackErr
}
}

return nil
}

// EvalCanonicalPath returns the absolute, symlink-resolved form of a path.
// If the path itself does not exist, it resolves the parent directory's symlinks
// and appends the final component, so that containment checks remain accurate for
Expand Down
159 changes: 159 additions & 0 deletions pkg/fsutil/reader_test.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package fsutil_test

import (
"errors"
"os"
"path/filepath"
"sort"
"testing"

"github.com/devantler-tech/ksail/v7/pkg/fsutil"
Expand Down Expand Up @@ -149,6 +151,163 @@ func testReadFileSafeMissingFile(t *testing.T) {
assert.ErrorContains(t, err, "failed to read file", "ReadFileSafe")
}

// errStopIteration is a sentinel used by callbacks to break out of ForEachYAMLFile
// early; see the "callback early stop" subtest.
var errStopIteration = errors.New("stop")

// testYAMLFileA is reused across ForEachYAMLFile subtests; extracted to satisfy goconst.
const testYAMLFileA = "a.yaml"

func TestForEachYAMLFile(t *testing.T) {
t.Parallel()

t.Run("yaml extension filter", testForEachYAMLFileExtensionFilter)
t.Run("non-recursive walk skips subdirectories", testForEachYAMLFileNonRecursive)
t.Run("callback early stop returns sentinel", testForEachYAMLFileCallbackEarlyStop)
t.Run("symlink escape rejected", testForEachYAMLFileSymlinkEscape)
t.Run("missing directory returns error", testForEachYAMLFileMissingDir)
t.Run("empty directory invokes no callback", testForEachYAMLFileEmptyDir)
}

func testForEachYAMLFileExtensionFilter(t *testing.T) {
t.Helper()
t.Parallel()

dir := t.TempDir()
// .yaml and .yml should be visited; .txt, .json, .yaml.bak should not.
yamlFiles := map[string]string{
testYAMLFileA: "kind: A",
"b.yml": "kind: B",
"ignore.txt": "no",
"c.json": `{"kind":"C"}`,
"d.yaml.bak": "backup",
}
for name, content := range yamlFiles {
err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600)
require.NoError(t, err, "WriteFile %s", name)
}

var visited []string

visitedContent := make(map[string]string)

err := fsutil.ForEachYAMLFile(dir, func(path string, content []byte) error {
name := filepath.Base(path)
visited = append(visited, name)
visitedContent[name] = string(content)

return nil
})

require.NoError(t, err, "ForEachYAMLFile")
sort.Strings(visited)
assert.Equal(t, []string{testYAMLFileA, "b.yml"}, visited, "only .yaml/.yml visited")
assert.Equal(t, "kind: A", visitedContent[testYAMLFileA], "a.yaml content passed to callback")
assert.Equal(t, "kind: B", visitedContent["b.yml"], "b.yml content passed to callback")
}

func testForEachYAMLFileNonRecursive(t *testing.T) {
t.Helper()
t.Parallel()

dir := t.TempDir()
err := os.WriteFile(filepath.Join(dir, "top.yaml"), []byte("top"), 0o600)
require.NoError(t, err, "WriteFile top.yaml")

sub := filepath.Join(dir, "nested")
require.NoError(t, os.Mkdir(sub, 0o700), "Mkdir nested")
require.NoError(t,
os.WriteFile(filepath.Join(sub, "child.yaml"), []byte("child"), 0o600),
"WriteFile child.yaml")

var visited []string

err = fsutil.ForEachYAMLFile(dir, func(path string, _ []byte) error {
visited = append(visited, filepath.Base(path))

return nil
})

require.NoError(t, err, "ForEachYAMLFile")
assert.Equal(t, []string{"top.yaml"}, visited, "subdirectory entries not visited")
}

func testForEachYAMLFileCallbackEarlyStop(t *testing.T) {
t.Helper()
t.Parallel()

dir := t.TempDir()
// Three YAML files; callback stops on the first one it sees.
for _, name := range []string{testYAMLFileA, "b.yaml", "c.yaml"} {
require.NoError(t,
os.WriteFile(filepath.Join(dir, name), []byte(name), 0o600),
"WriteFile %s", name)
}

calls := 0
err := fsutil.ForEachYAMLFile(dir, func(_ string, _ []byte) error {
calls++

return errStopIteration
})

require.ErrorIs(t, err, errStopIteration, "ForEachYAMLFile propagates callback error verbatim")
assert.Equal(t, 1, calls, "iteration stops after first callback error")
}

func testForEachYAMLFileSymlinkEscape(t *testing.T) {
t.Helper()
t.Parallel()

// A YAML symlink inside dir that resolves to a file outside dir must be
// rejected by the underlying ReadFileSafe, surfacing ErrPathOutsideBase.
outsideDir := t.TempDir()
secret := filepath.Join(outsideDir, "secret.yaml")
require.NoError(t, os.WriteFile(secret, []byte("secret"), 0o600), "WriteFile secret")

dir := t.TempDir()
link := filepath.Join(dir, "escape.yaml")
require.NoError(t, os.Symlink(secret, link), "Symlink")

err := fsutil.ForEachYAMLFile(dir, func(_ string, _ []byte) error {
t.Fatalf("callback must not run for symlink that escapes dir")

return nil
})

require.ErrorIs(t, err, fsutil.ErrPathOutsideBase, "symlink escape rejected")
}

func testForEachYAMLFileMissingDir(t *testing.T) {
t.Helper()
t.Parallel()

dir := filepath.Join(t.TempDir(), "does-not-exist")

err := fsutil.ForEachYAMLFile(dir, func(_ string, _ []byte) error {
t.Fatalf("callback must not run for missing directory")

return nil
})

require.Error(t, err, "missing directory returns error")
}

func testForEachYAMLFileEmptyDir(t *testing.T) {
t.Helper()
t.Parallel()

dir := t.TempDir()

err := fsutil.ForEachYAMLFile(dir, func(_ string, _ []byte) error {
t.Fatalf("callback must not run for empty directory")

return nil
})

require.NoError(t, err, "empty directory completes without error")
}

//nolint:paralleltest,tparallel // Cannot use t.Parallel() with t.Chdir()
func TestFindFile(t *testing.T) {
t.Run("absolute path", testFindFileAbsolutePath)
Expand Down
43 changes: 15 additions & 28 deletions pkg/svc/tenant/argocd.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
package tenant

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

"github.com/devantler-tech/ksail/v7/pkg/fsutil"
Expand All @@ -12,6 +12,10 @@ import (
"sigs.k8s.io/yaml"
)

// errFoundRBACCM is a sentinel used to stop YAML iteration once the
// argocd-rbac-cm ConfigMap is found; it is never returned to callers.
var errFoundRBACCM = errors.New("argocd-rbac-cm found")

const (
appProjectKind = "AppProject"
k8sDefaultServer = "https://kubernetes.default.svc"
Expand Down Expand Up @@ -390,39 +394,22 @@ func isTenantPolicyLine(line, tenantName string) bool {
// Returns the file path if found, or empty string if not found.
// Supports multi-document YAML files separated by "---".
func FindArgoCDRBACCM(dir string) (string, error) {
canonDir, err := fsutil.EvalCanonicalPath(dir)
if err != nil {
return "", fmt.Errorf("resolving directory %s: %w", dir, err)
}

entries, err := os.ReadDir(canonDir)
if err != nil {
return "", fmt.Errorf("reading directory %s: %w", canonDir, err)
}

for _, entry := range entries {
if entry.IsDir() {
continue
}
var found string

name := entry.Name()
if !strings.HasSuffix(name, ".yaml") && !strings.HasSuffix(name, ".yml") {
continue
}
err := fsutil.ForEachYAMLFile(dir, func(filePath string, content []byte) error {
if containsArgoCDRBACCM(content) {
found = filePath

filePath := filepath.Join(canonDir, name)

data, readErr := fsutil.ReadFileSafe(canonDir, filePath)
if readErr != nil {
return "", fmt.Errorf("reading %s: %w", filePath, readErr)
return errFoundRBACCM
}

if containsArgoCDRBACCM(data) {
return filePath, nil
}
return nil
})
if err != nil && !errors.Is(err, errFoundRBACCM) {
return "", fmt.Errorf("scanning %s for %s: %w", dir, rbacConfigMapName, err)
}

return "", nil
return found, nil
}

// containsArgoCDRBACCM checks whether YAML data (possibly multi-document)
Expand Down
Loading
Loading