Skip to content

Commit dbc2a57

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, and a charts/talm that is a file rather than a directory surfaces as an error (callers degrade it to a non-fatal warning) instead of a crash. The drift message points operators at `talm init --update --preset <preset>` — the bare form cannot resolve the preset from an init'd project's Chart.yaml and would not re-vendor. 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 8c9288b commit dbc2a57

3 files changed

Lines changed: 291 additions & 1 deletion

File tree

pkg/commands/chart_drift.go

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
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
90+
// `talm init --update --preset <preset>`.
91+
//
92+
// The comparison is by content: the Chart.yaml version stamp is normalized
93+
// away on both sides, so a pure version bump that left the library
94+
// byte-identical is NOT reported as drift. binaryVersion is used only for
95+
// the operator-facing message.
96+
//
97+
// It returns (false, "", nil) — staying silent — when the project has no
98+
// vendored library to compare. A read or walk failure is returned as an
99+
// error; callers treat drift detection as best-effort and must not block a
100+
// command on it.
101+
func CheckChartDrift(rootDir, binaryVersion string) (bool, string, error) {
102+
vendored, ok, err := vendoredTalmFiles(rootDir)
103+
if err != nil {
104+
return false, "", err
105+
}
106+
107+
if !ok {
108+
return false, "", nil
109+
}
110+
111+
embedded, err := generated.TalmLibraryFiles()
112+
if err != nil {
113+
return false, "", errors.Wrap(err, "loading embedded talm library")
114+
}
115+
116+
if generated.HashChartFiles(vendored) == generated.HashChartFiles(embedded) {
117+
return false, "", nil
118+
}
119+
120+
return true, fmt.Sprintf(
121+
"project's vendored charts/talm/ library differs from the copy built into talm %s; "+
122+
"run `talm init --update --preset <preset>` to re-sync (or ignore if this is intentional)",
123+
binaryVersion,
124+
), nil
125+
}

pkg/commands/chart_drift_test.go

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
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+
// The remediation must carry --preset: bare `talm init --update` cannot
115+
// resolve the preset from an init'd project's Chart.yaml and errors out
116+
// without re-vendoring, so pointing at it would send operators to a
117+
// command that does not clear the drift.
118+
if !strings.Contains(msg, "talm init --update --preset") {
119+
t.Errorf("drift message must point at `talm init --update --preset`; got %q", msg)
120+
}
121+
}
122+
123+
// TestCheckChartDrift_VendoredTalmIsFile_ErrorsGracefully pins that a
124+
// charts/talm that exists as a file (not a directory) is surfaced as an
125+
// error rather than a panic or a spurious drift result. Callers
126+
// (evaluateChartDrift) downgrade this to a non-fatal warning, so a
127+
// malformed project never crashes a command.
128+
func TestCheckChartDrift_VendoredTalmIsFile_ErrorsGracefully(t *testing.T) {
129+
root := t.TempDir()
130+
if err := os.MkdirAll(filepath.Join(root, "charts"), 0o755); err != nil {
131+
t.Fatalf("mkdir: %v", err)
132+
}
133+
if err := os.WriteFile(filepath.Join(root, "charts", "talm"), []byte("not a directory"), 0o644); err != nil {
134+
t.Fatalf("write file: %v", err)
135+
}
136+
137+
drift, _, err := CheckChartDrift(root, "0.30.0")
138+
if err == nil {
139+
t.Error("expected an error when charts/talm is a file, got nil")
140+
}
141+
if drift {
142+
t.Error("must not report drift when the vendored tree cannot be read")
143+
}
144+
}
145+
146+
// TestCheckChartDrift_MissingVendoredDir_NoDriftNoError pins graceful
147+
// handling: a project without a charts/talm/ directory (nothing vendored
148+
// yet) yields no drift and no error, so the check never blocks a command
149+
// on a tree it cannot compare.
150+
func TestCheckChartDrift_MissingVendoredDir_NoDriftNoError(t *testing.T) {
151+
root := t.TempDir()
152+
153+
drift, msg, err := CheckChartDrift(root, "0.30.0")
154+
if err != nil {
155+
t.Fatalf("CheckChartDrift returned an error for a missing vendored dir: %v", err)
156+
}
157+
if drift {
158+
t.Errorf("reported drift with no vendored library to compare: %s", msg)
159+
}
160+
}

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)