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
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,10 +204,12 @@ a closed output pipe (`budctl check | head`), which kills a Go program outright
unless it says otherwise. `--no-probe` is read-only; `budctl cleanup` sweeps leftovers from a
killed run.

The GPU probe requests `nvidia.com/gpu: 1` on a **busybox-class image**, not a
The GPU probe requests `nvidia.com/gpu: 1` on a **slim Debian image**, not a
CUDA image: asserting the injected device node is present proves allocation,
device-plugin injection and the runtime-hook chain for megabytes instead of
gigabytes.
gigabytes. It is not busybox because HAMi preloads its vGPU library into every
GPU container, and that library needs glibc's `libdl.so.2`; point
`--gpu-probe-image` at a mirror of any glibc image on an air-gapped cluster.

## SKIP is never a pass

Expand Down
2 changes: 1 addition & 1 deletion cmd/budctl/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ func parseFlags(argv []string) (config, error) {
fs.BoolVar(&c.keepProbes, "keep", false, "keep the probe namespace for debugging")
fs.StringVar(&c.probeNamespace, "probe-namespace", "", "namespace for probe objects")
fs.StringVar(&c.probeImage, "probe-image", "curlimages/curl:8.10.1", "image for network probes")
fs.StringVar(&c.gpuProbeImage, "gpu-probe-image", "busybox:1.36", "image for the GPU probe (deliberately not a CUDA image)")
fs.StringVar(&c.gpuProbeImage, "gpu-probe-image", "debian:trixie-slim", "image for the GPU probe (a slim glibc image, deliberately not a CUDA image)")
fs.StringVar(&c.egressFrom, "egress-from", "cluster", "where egress is tested: cluster | workstation | both")
fs.BoolVar(&c.hfThroughput, "hf-throughput", false, "sample Hugging Face download throughput")
fs.StringVar(&c.argocdNamespace, "argocd-namespace", "argocd", "namespace ArgoCD is (or will be) installed in")
Expand Down
9 changes: 2 additions & 7 deletions cmd/budctl/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ func run() int {
ArgoCDNamespace: cfg.argocdNamespace,
ArgoCDEnabled: !cfg.noArgoCD,
RegistryCreds: map[string]adapters.Credential{},
BudctlVersion: Version,
}
if answers.RegistryUser != "" {
c.Opts.RegistryCreds["registry.bud.studio"] = adapters.Credential{
Expand Down Expand Up @@ -178,13 +179,7 @@ func run() int {
} else {
rep = engine.Summarize(engine.Run(ctx, c, sel, nil))
}
rep.Platform = string(c.Platform.Distribution)
rep.Meta = map[string]string{
"budctlVersion": Version,
"catalogVersion": profile.CatalogVersion,
"serverVersion": c.Platform.Version,
"domain": answers.Domain,
}
rep.Stamp(c)

switch cfg.format {
case "json":
Expand Down
2 changes: 1 addition & 1 deletion install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ set -eu
# tag that disagrees with it. Pinned rather than resolved from the GitHub
# API at runtime: the unauthenticated API allows 60 requests per hour per IP,
# which one NATed customer site can exhaust between two engineers.
VERSION="${BUDCTL_VERSION:-0.3.0}"
VERSION="${BUDCTL_VERSION:-0.3.1}"
REPO="BudEcosystem/budctl"

die() { echo "install.sh: $*" >&2; exit 1; }
Expand Down
39 changes: 37 additions & 2 deletions internal/checks/argocd.go
Original file line number Diff line number Diff line change
Expand Up @@ -372,10 +372,33 @@ func init() {
Output: strings.Join(Sorted(seen), "\n"),
}
if match == nil {
// With no matching Secret ArgoCD pulls anonymously, and a charts
// project the registry serves to anonymous clients needs no
// Secret at all. Ask the registry the same question before
// predicting a 401 that the cluster may never see.
var anonEv []engine.Evidence
refused := ""
if c.OCI != nil {
ref, version := argocdUmbrellaChartRef(c)
status, note := c.OCI.ChartManifest(ctx, ref, version, nil)
anonEv = append(anonEv, engine.Evidence{
What: "HEAD " + ref + " at " + version + " with no credential (anonymous token)",
Output: string(status) + " — " + note,
})
if status == adapters.ManifestOK {
return ch.Pass(fmt.Sprintf("no repository Secret covers %s/%s, and none is needed: the registry serves the %s chart to an anonymous pull, which is how ArgoCD fetches it without one",
argocdChartRegistry, argocdChartRepo, argocdUmbrellaChart)).
WithEvidence(append([]engine.Evidence{ev}, anonEv...)...).
Bounds("anonymous access is a setting on the registry's charts project, not on this cluster: if that project is made private, every Application's next pull fails with 401 until a repository Secret exists — and this pull came from this workstation, so whether the repo-server can reach the registry is argocd.chart-repo-reachable's question")
}
if status == adapters.ManifestUnauthorized {
refused = ", and the registry refuses an anonymous pull"
}
}
return ch.Fail(
fmt.Sprintf("no ArgoCD repository Secret covers %s/%s, so every Application sourcing the %s chart fails its first pull with 401 unauthorized", argocdChartRegistry, argocdChartRepo, argocdUmbrellaChart),
fmt.Sprintf("no ArgoCD repository Secret covers %s/%s%s, so every Application sourcing the %s chart fails its first pull with 401 unauthorized", argocdChartRegistry, argocdChartRepo, refused, argocdUmbrellaChart),
"kubectl apply -n "+inst.namespace+" -f - <<'EOF'\napiVersion: v1\nkind: Secret\nmetadata:\n name: bud-charts-repo\n labels:\n argocd.argoproj.io/secret-type: repository\nstringData:\n type: helm\n url: "+argocdChartRegistry+"/"+argocdChartRepo+"\n enableOCI: \"true\"\n username: robot$yourname\n password: <token>\nEOF").
WithEvidence(ev)
WithEvidence(append([]engine.Evidence{ev}, anonEv...)...)
}

name := match.Name()
Expand Down Expand Up @@ -784,6 +807,18 @@ func argocdSecretField(o adapters.Object, key string) string {
return strings.TrimSpace(string(dec))
}

// argocdUmbrellaChartRef is the chart and version an Application sources: the
// pin charts.oci resolves, or the version in --chart-dir when one was given.
func argocdUmbrellaChartRef(c *engine.Ctx) (ref, version string) {
version = argocdTargetChartVersion(c)
for _, e := range chartsOCICatalogue {
if e.name == argocdUmbrellaChart && version == "" {
version = e.version
}
}
return "oci://" + argocdChartRegistry + "/" + argocdChartRepo + "/" + argocdUmbrellaChart, version
}

// argocdRepoHost extracts the registry host from an ArgoCD repository URL. OCI
// repository Secrets carry a bare "registry.bud.studio/charts" with no scheme,
// which url.Parse reads as a path rather than a host.
Expand Down
42 changes: 40 additions & 2 deletions internal/checks/argocd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -729,6 +729,27 @@ func TestArgoCDVersionSkipsWhenNotInstalled(t *testing.T) {
// argocd.chart-repo-credential
// ---------------------------------------------------------------------------

// argocdTestRegistry scripts registry.bud.studio's token flow. anonymous decides
// whether the token issued to a client with no credential can read the charts
// project — the one registry setting that decides whether ArgoCD needs a
// repository Secret at all.
func argocdTestRegistry(anonymous bool) func(*http.Request) (*http.Response, error) {
return func(r *http.Request) (*http.Response, error) {
switch {
case strings.HasSuffix(r.URL.Path, "/service/token"):
resp := argocdReply(http.StatusOK)
resp.Body = io.NopCloser(strings.NewReader(`{"token":"anon"}`))
return resp, nil
case anonymous && strings.Contains(r.URL.Path, "/manifests/") && r.Header.Get("Authorization") == "Bearer anon":
return argocdReply(http.StatusOK), nil
default:
resp := argocdReply(http.StatusUnauthorized)
resp.Header.Set("WWW-Authenticate", `Bearer realm="https://registry.bud.studio/service/token",service="harbor-registry"`)
return resp, nil
}
}
}

func TestArgoCDChartRepoCredentialRiskWhenNoSecretCoversTheRegistry(t *testing.T) {
cases := []struct {
name string
Expand Down Expand Up @@ -760,13 +781,30 @@ func TestArgoCDChartRepoCredentialRiskWhenNoSecretCoversTheRegistry(t *testing.T
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f := argocdTestInstall(argocdTestCluster()).with("secrets", "argocd", tc.secrets...)
r := run(t, f, "argocd.chart-repo-credential")
r := argocdRunHTTP(t, f, "argocd.chart-repo-credential", argocdTestRegistry(false))
assertStatus(t, r, "RISK")
argocdAssertMentions(t, r, tc.claims...)
argocdAssertMentions(t, r, append(tc.claims, "refuses an anonymous pull")...)
})
}
}

// A charts project the registry serves to anonymous clients needs no Secret:
// ArgoCD pulls anonymously when none matches. The tcs-vmware cluster synced
// every chart that way while this check predicted a 401 on the first pull.
func TestArgoCDChartRepoCredentialPassesWhenTheChartsProjectAllowsAnonymousPulls(t *testing.T) {
f := argocdTestInstall(argocdTestCluster()).with("secrets", "argocd",
argocdTestSecret("argocd", "bud-config-repo", "repository", map[string]string{
"type": "git", "url": "https://github.com/bud/config.git",
}))
r := argocdRunHTTP(t, f, "argocd.chart-repo-credential", argocdTestRegistry(true))

assertStatus(t, r, "PASS")
argocdAssertMentions(t, r, "anonymous pull", "none is needed")
if !strings.Contains(r.DoesNotProve, "made private") {
t.Fatalf("a pass that rests on a registry setting must say the setting can change: %q", r.DoesNotProve)
}
}

// enableOCI is the single field that decides whether ArgoCD speaks the OCI API
// or asks a registry for index.yaml — and asking yields a confusing 404, not an
// error naming the setting.
Expand Down
6 changes: 3 additions & 3 deletions internal/checks/charts.go
Original file line number Diff line number Diff line change
Expand Up @@ -253,11 +253,11 @@ func init() {
switch {
case len(blockers) > 0:
return ch.Fail(
fmt.Sprintf("%d chart %s in scope %s serve index.yaml, so the dependencies pinned against %s never resolve and those charts do not install",
fmt.Sprintf("%d chart %s an ApplicationSet installs from directly %s serve index.yaml, so the charts sourced from %s do not install",
len(blockers), Plural(len(blockers), "repository", "repositories"),
Plural(len(blockers), "does not", "do not"),
Plural(len(blockers), "it", "them")),
"allowlist these hosts on :443 from whatever runs the dependency resolve — this workstation for a direct `helm install`, the ArgoCD repo-server for a synced install — or mirror each repo and repoint the chart dependencies",
"allowlist these hosts on :443 from whatever fetches the chart — the ArgoCD repo-server for a synced install, this workstation for a direct `helm install` — or mirror each repo and repoint the Application's repoURL",
append(Sorted(blockers), Sorted(risks)...)...,
).WithEvidence(evidence...)
case len(risks) > 0:
Expand Down Expand Up @@ -435,7 +435,7 @@ func chartsClassicSeverity(c *engine.Ctx, t intake.EgressTarget) (engine.Severit
if t.When == "install" {
return engine.Block, ""
}
return engine.Risk, "optional: needed only if the feature behind it is enabled"
return engine.Risk, "optional: not fetched by the default install"
}

func chartsIsDataStoreRepo(u string) bool {
Expand Down
34 changes: 19 additions & 15 deletions internal/checks/charts_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -326,14 +326,15 @@ func TestChartsClassicSkipsWhenTheInventoryNamesNoRepositories(t *testing.T) {
assertSkipHasReason(t, r)
}

// One repo down blocks the install, and the finding has to name the chart that
// needs it: "bitnami unreachable" and "the common library subchart is absent"
// are not the same problem to the person holding the ticket.
// A repo an ApplicationSet installs from directly blocks the install when it is
// down, and the finding has to name what stops working: "dapr unreachable" and
// "the Dapr control plane cannot be installed" are not the same problem to the
// person holding the ticket.
func TestChartsClassicBlocksWhenAnInstallTimeRepoIsUnreachable(t *testing.T) {
r := chartsRun(t, vanilla(), "charts.classic",
chartsAllOK(chartsBlock("charts.bitnami.com")), nil)
chartsAllOK(chartsBlock("dapr.github.io")), nil)
assertStatus(t, r, "BLOCK")
chartsMentions(t, r, "bitnami charts", "common library subchart")
chartsMentions(t, r, "dapr charts", "Dapr control plane")
}

// index.yaml is a document, not a registry endpoint: the "401 proves the host is
Expand All @@ -355,25 +356,28 @@ func TestChartsClassicRisksWhenOnlyAnOptionalRepoIsUnreachable(t *testing.T) {
chartsMentions(t, r, "SigNoz charts")
}

// The same dead host is a blocker or a risk depending on whether the operator
// asked for in-cluster data stores. Reporting a repo nobody will contact as a
// blocker is how a report trains people to ignore it.
func TestChartsClassicScopesDataStoreReposToTheIntakeAnswer(t *testing.T) {
// The data-store operator charts are subcharts of the postgres, clickhouse and
// mongodb charts, and ArgoCD installs those from registry.bud.studio as packaged
// OCI charts with the subchart inside. A tcs-vmware cluster with
// docs.altinity.com blocked synced ClickHouse anyway. So a dead upstream repo
// is a risk for whoever builds the chart from source, never a blocker — and the
// note still says why when the operator chose external data stores.
func TestChartsClassicNeverBlocksOnAReposBundledIntoThePublishedCharts(t *testing.T) {
cases := []struct {
name string
inClusterData bool
want string
note string
}{
{"in-cluster data stores: no CNPG operator means no database for any service", true, "BLOCK"},
{"external data stores: the CNPG operator is never installed", false, "RISK"},
{"in-cluster data stores: the operator subchart ships inside the OCI chart", true, "bundled in the published OCI chart"},
{"external data stores: the operator is never installed", false, "external data stores selected"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f := vanilla().withAnswers(func(a *intake.Answers) { a.InClusterData = tc.inClusterData })
r := chartsRun(t, f, "charts.classic",
chartsAllOK(chartsBlock("cloudnative-pg.github.io")), nil)
assertStatus(t, r, tc.want)
chartsMentions(t, r, "CloudNativePG charts")
chartsAllOK(chartsBlock("cloudnative-pg.github.io"), chartsBlock("docs.altinity.com")), nil)
assertStatus(t, r, "RISK")
chartsMentions(t, r, "CloudNativePG charts", "Altinity ClickHouse charts", tc.note)
})
}
}
Expand Down
46 changes: 41 additions & 5 deletions internal/checks/egress.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,27 @@ func init() {
},
})

// The ACME directory is not an install-time fetch: the sync completes
// without it and cert-manager simply never issues. It is on the path only
// when the operator chose ACME — a provided certificate is a Secret, and
// 'none' publishes plain HTTP — so it gets its own check and its own gate.
engine.Register(&engine.Check{
ID: "egress.acme", Group: "egress", Severity: engine.Block,
DependsOn: []string{"platform"}, Probe: true,
Run: func(ctx context.Context, c *engine.Ctx) engine.Result {
ch := engine.Lookup("egress.acme")
if !strings.HasPrefix(string(c.Answers.TLS), "acme") {
return ch.Skip("TLS will be obtained by " + domainsTLSMethod(c) + ", so cert-manager never calls an ACME directory")
}
r := egressEvaluate(ctx, c, ch, "acme",
"cert-manager cannot register an ACME account, so no certificate is issued and every hostname serves an untrusted one")
if r.State == engine.StateFail {
r.Remedy += " — or answer TLS as 'provided' and supply the certificate, which takes ACME off the path"
}
return r
},
})

// Port 22, not 443. An egress allowlist written as "HTTPS to the internet"
// covers every other check in this group and still breaks every ArgoCD
// Application whose repoURL is ssh:// — and it breaks them quietly, as a
Expand Down Expand Up @@ -642,8 +663,11 @@ func egressEvaluate(ctx context.Context, c *engine.Ctx, ch *engine.Check, when,
// egressNoun names a catalogue slice the way the report should read it:
// "install-time", not "install", and never "runtime-time".
func egressNoun(when string) string {
if when == "install" {
switch when {
case "install":
return "install-time"
case "acme":
return "ACME"
}
return when
}
Expand Down Expand Up @@ -875,6 +899,10 @@ func egressReportProxy(ctx context.Context, c *engine.Ctx, ch *engine.Check) eng
// egressNodeProxyHints looks for proxy environment variables on workloads that are
// already running. It is a heuristic, not a reading of the node environment —
// which is why the result is INFO and says so.
//
// NO_PROXY on its own is not a hint: k3s's helm controller sets it on every
// helm-install pod whether or not a proxy exists, so it is reported only beside
// an HTTP_PROXY or HTTPS_PROXY on the same container.
func egressNodeProxyHints(ctx context.Context, c *engine.Ctx) []string {
if c.Kube == nil {
return nil
Expand All @@ -886,20 +914,28 @@ func egressNodeProxyHints(ctx context.Context, c *engine.Ctx) []string {
if !ok {
continue
}
var proxies, noProxy []string
for _, e := range envs {
em, ok := e.(map[string]any)
if !ok {
continue
}
name, _ := em["name"].(string)
value, _ := em["value"].(string)
if value == "" {
continue
}
line := fmt.Sprintf("pod/%s: %s=%s", p.Name(), strings.ToUpper(name), value)
switch strings.ToUpper(name) {
case "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY":
if value != "" {
out = append(out, fmt.Sprintf("pod/%s: %s=%s", p.Name(), strings.ToUpper(name), value))
}
case "HTTP_PROXY", "HTTPS_PROXY":
proxies = append(proxies, line)
case "NO_PROXY":
noProxy = append(noProxy, line)
}
}
if len(proxies) > 0 {
out = append(append(out, proxies...), noProxy...)
}
}
}
return Sorted(out)
Expand Down
Loading
Loading