Skip to content

Commit 053c6e3

Browse files
committed
feat(commands): detect vendored charts/talm drift by content
CheckChartDrift compares a project's vendored charts/talm/ against the binary's built-in copy via the chart-tree digest. The vendored tree is read through an os.Root scoped to charts/talm/, confining traversal to that subtree so a symlink inside it cannot redirect a read elsewhere. The match is by content, with the Chart.yaml version stamp normalized away on both sides: a binary version bump that left the library byte- identical is not reported as drift. A project with no vendored library stays silent rather than erroring, so the check never blocks a command on a tree it cannot compare. Config gains an opt-in StrictCharts field (Chart.yaml strictCharts) for turning drift from a warning into a hard error; absent keeps today's behavior. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la>
1 parent 014aca9 commit 053c6e3

3 files changed

Lines changed: 263 additions & 1 deletion

File tree

pkg/commands/chart_drift.go

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
// Copyright Cozystack Authors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package commands
16+
17+
import (
18+
"fmt"
19+
"io/fs"
20+
"os"
21+
"path"
22+
"path/filepath"
23+
24+
"github.com/cockroachdb/errors"
25+
26+
"github.com/cozystack/talm/pkg/generated"
27+
)
28+
29+
// vendoredTalmFiles reads a project's vendored talm library from
30+
// rootDir/charts/talm/, keyed by forward-slash path relative to that
31+
// directory (so the keys line up with the embedded TalmLibraryFiles output)
32+
// and with Chart.yaml metadata normalized the same way. The bool is false
33+
// when no vendored library exists — an unconfigured or freshly cloned
34+
// project the drift check should simply skip rather than treat as an error.
35+
//
36+
// Reads go through an os.Root rooted at charts/talm/, confining traversal to
37+
// that subtree so a symlink inside it cannot redirect a read outside the
38+
// project (gosec G122).
39+
func vendoredTalmFiles(rootDir string) (map[string]string, bool, error) {
40+
base := filepath.Join(rootDir, "charts", "talm")
41+
42+
root, err := os.OpenRoot(base)
43+
if err != nil {
44+
if os.IsNotExist(err) {
45+
return nil, false, nil
46+
}
47+
48+
return nil, false, errors.Wrapf(err, "opening vendored talm library %q", base)
49+
}
50+
defer root.Close()
51+
52+
fsys := root.FS()
53+
filesMap := make(map[string]string)
54+
55+
err = fs.WalkDir(fsys, ".", func(filePath string, entry fs.DirEntry, err error) error {
56+
if err != nil {
57+
return errors.Wrapf(err, "walking vendored talm library at %q", filePath)
58+
}
59+
60+
if entry.IsDir() {
61+
return nil
62+
}
63+
64+
data, err := fs.ReadFile(fsys, filePath)
65+
if err != nil {
66+
return errors.Wrapf(err, "reading vendored talm file %q", filePath)
67+
}
68+
69+
// root.FS() keys are already "/"-separated and relative to base, so
70+
// they match the embedded TalmLibraryFiles output on every platform.
71+
filesMap[filePath] = generated.NormalizeChartMeta(path.Base(filePath), string(data))
72+
73+
return nil
74+
})
75+
if err != nil {
76+
return nil, false, errors.Wrap(err, "collecting vendored talm library")
77+
}
78+
79+
return filesMap, true, nil
80+
}
81+
82+
// CheckChartDrift reports whether a project's vendored charts/talm/ library
83+
// diverges, by content, from the copy built into this talm binary.
84+
//
85+
// talm vendors its library chart into the project at `talm init` time;
86+
// rendering (template/apply/upgrade) reads that local copy, never the
87+
// binary's embedded charts. Upgrading the binary therefore leaves
88+
// charts/talm/ frozen at the version that last ran init. CheckChartDrift
89+
// surfaces that staleness so the operator can re-run `talm init --update`.
90+
//
91+
// The comparison is by content: the Chart.yaml version stamp is normalized
92+
// away on both sides, so a pure version bump that left the library
93+
// byte-identical is NOT reported as drift. binaryVersion is used only for
94+
// the operator-facing message.
95+
//
96+
// It returns (false, "", nil) — staying silent — when the project has no
97+
// vendored library to compare. A read or walk failure is returned as an
98+
// error; callers treat drift detection as best-effort and must not block a
99+
// command on it.
100+
func CheckChartDrift(rootDir, binaryVersion string) (bool, string, error) {
101+
vendored, ok, err := vendoredTalmFiles(rootDir)
102+
if err != nil {
103+
return false, "", err
104+
}
105+
106+
if !ok {
107+
return false, "", nil
108+
}
109+
110+
embedded, err := generated.TalmLibraryFiles()
111+
if err != nil {
112+
return false, "", errors.Wrap(err, "loading embedded talm library")
113+
}
114+
115+
if generated.HashChartFiles(vendored) == generated.HashChartFiles(embedded) {
116+
return false, "", nil
117+
}
118+
119+
return true, fmt.Sprintf(
120+
"project's vendored charts/talm/ library differs from the copy built into talm %s; "+
121+
"run `talm init --update` to re-sync (or ignore if this is intentional)",
122+
binaryVersion,
123+
), nil
124+
}

pkg/commands/chart_drift_test.go

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
// Copyright Cozystack Authors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package commands
16+
17+
import (
18+
"os"
19+
"path/filepath"
20+
"strings"
21+
"testing"
22+
23+
"github.com/cozystack/talm/pkg/generated"
24+
)
25+
26+
// writeVendoredTalmLibrary materializes a project's charts/talm/ tree from
27+
// the binary's embedded library, stamping version into the library
28+
// Chart.yaml so the result looks like a real `talm init` would produce. It
29+
// returns the project root.
30+
func writeVendoredTalmLibrary(t *testing.T, version string) string {
31+
t.Helper()
32+
33+
files, err := generated.TalmLibraryFiles()
34+
if err != nil {
35+
t.Fatalf("TalmLibraryFiles: %v", err)
36+
}
37+
38+
root := t.TempDir()
39+
for rel, content := range files {
40+
// TalmLibraryFiles normalizes Chart.yaml name/version to %s; fill
41+
// them back in (name, version) so the vendored copy mirrors init.
42+
if filepath.Base(rel) == "Chart.yaml" {
43+
content = strings.ReplaceAll(content, "name: %s", "name: talm")
44+
content = strings.ReplaceAll(content, "version: %s", "version: "+version)
45+
}
46+
47+
dest := filepath.Join(root, "charts", "talm", filepath.FromSlash(rel))
48+
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
49+
t.Fatalf("mkdir %q: %v", dest, err)
50+
}
51+
if err := os.WriteFile(dest, []byte(content), 0o644); err != nil {
52+
t.Fatalf("write %q: %v", dest, err)
53+
}
54+
}
55+
56+
return root
57+
}
58+
59+
// TestCheckChartDrift_MatchingCopy_NoDrift pins the happy path: a project
60+
// whose vendored charts/talm/ matches the embedded library reports no
61+
// drift, even though its Chart.yaml carries a concrete version stamp.
62+
func TestCheckChartDrift_MatchingCopy_NoDrift(t *testing.T) {
63+
root := writeVendoredTalmLibrary(t, "0.30.0")
64+
65+
drift, msg, err := CheckChartDrift(root, "0.30.0")
66+
if err != nil {
67+
t.Fatalf("CheckChartDrift: %v", err)
68+
}
69+
if drift {
70+
t.Errorf("reported drift for a matching vendored copy: %s", msg)
71+
}
72+
}
73+
74+
// TestCheckChartDrift_VersionStampOnly_NoDrift is the core regression
75+
// guard. A project vendored by an older release carries an older version
76+
// stamp in charts/talm/Chart.yaml, but its helpers are byte-identical to
77+
// the running binary's. That MUST NOT be reported as drift — flagging a
78+
// pure version difference is exactly the false positive the version-number
79+
// comparison approach produced.
80+
func TestCheckChartDrift_VersionStampOnly_NoDrift(t *testing.T) {
81+
root := writeVendoredTalmLibrary(t, "0.1.0")
82+
83+
drift, msg, err := CheckChartDrift(root, "0.30.0")
84+
if err != nil {
85+
t.Fatalf("CheckChartDrift: %v", err)
86+
}
87+
if drift {
88+
t.Errorf("reported drift for a version-only difference; this is a false positive: %s", msg)
89+
}
90+
}
91+
92+
// TestCheckChartDrift_ContentChange_Drift pins detection: a vendored
93+
// helpers template that diverges from the embedded copy is real drift and
94+
// must be surfaced.
95+
func TestCheckChartDrift_ContentChange_Drift(t *testing.T) {
96+
root := writeVendoredTalmLibrary(t, "0.30.0")
97+
98+
helpers := filepath.Join(root, "charts", "talm", "templates", "_helpers.tpl")
99+
data, err := os.ReadFile(helpers)
100+
if err != nil {
101+
t.Fatalf("read helpers: %v", err)
102+
}
103+
if err := os.WriteFile(helpers, append(data, []byte("\n{{- /* stale local edit */ -}}\n")...), 0o644); err != nil {
104+
t.Fatalf("write helpers: %v", err)
105+
}
106+
107+
drift, msg, err := CheckChartDrift(root, "0.30.0")
108+
if err != nil {
109+
t.Fatalf("CheckChartDrift: %v", err)
110+
}
111+
if !drift {
112+
t.Error("did not detect a divergent vendored helpers template; real drift went unreported")
113+
}
114+
if !strings.Contains(msg, "talm init --update") {
115+
t.Errorf("drift message should point at the remediation; got %q", msg)
116+
}
117+
}
118+
119+
// TestCheckChartDrift_MissingVendoredDir_NoDriftNoError pins graceful
120+
// handling: a project without a charts/talm/ directory (nothing vendored
121+
// yet) yields no drift and no error, so the check never blocks a command
122+
// on a tree it cannot compare.
123+
func TestCheckChartDrift_MissingVendoredDir_NoDriftNoError(t *testing.T) {
124+
root := t.TempDir()
125+
126+
drift, msg, err := CheckChartDrift(root, "0.30.0")
127+
if err != nil {
128+
t.Fatalf("CheckChartDrift returned an error for a missing vendored dir: %v", err)
129+
}
130+
if drift {
131+
t.Errorf("reported drift with no vendored library to compare: %s", msg)
132+
}
133+
}

pkg/commands/root.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,12 @@ var GlobalArgs global.Args
4242
var Config struct {
4343
RootDir string
4444
RootDirExplicit bool // true if --root was explicitly set
45-
GlobalOptions struct {
45+
// StrictCharts turns vendored-chart drift into a hard error instead of a
46+
// warning. Opt-in per project via Chart.yaml (strictCharts: true) so a
47+
// whole team/CI inherits it; absent means a warning only (the historical
48+
// behavior). The --strict-charts flag forces it on for a single run.
49+
StrictCharts bool `yaml:"strictCharts"`
50+
GlobalOptions struct {
4651
Talosconfig string `yaml:"talosconfig"`
4752
Kubeconfig string `yaml:"kubeconfig"`
4853
} `yaml:"globalOptions"`

0 commit comments

Comments
 (0)