From 21948fe9599e6c407910521b37ba509159ea775d Mon Sep 17 00:00:00 2001 From: Evans Mungai Date: Wed, 29 Apr 2026 10:26:18 +0100 Subject: [PATCH] feat: add host registryImages collector and analyzer (#2029) * feat: add host registryImages collector and analyzer Adds a host-level registryImages collector and analyzer that can check image existence in registries without requiring a Kubernetes cluster. Supports inline username/password auth or ambient credentials from ~/.docker/config.json. Refactors imageExistsWithAuth from the cluster-level registry collector to share the core image existence check logic. Co-Authored-By: Claude Opus 4.6 (1M context) * Fixes from manual tests Signed-off-by: Evans Mungai * Updates from manual tests Signed-off-by: Evans Mungai * Sort template lists Signed-off-by: Evans Mungai * More changes Signed-off-by: Evans Mungai --------- Signed-off-by: Evans Mungai Co-authored-by: Claude Opus 4.6 (1M context) --- config/crds/troubleshoot.sh_analyzers.yaml | 101 +++++ config/crds/troubleshoot.sh_collectors.yaml | 44 ++ .../crds/troubleshoot.sh_hostcollectors.yaml | 70 +++ .../crds/troubleshoot.sh_hostpreflights.yaml | 70 +++ config/crds/troubleshoot.sh_preflights.yaml | 75 ++++ .../crds/troubleshoot.sh_supportbundles.yaml | 145 ++++++ pkg/analyze/host_analyzer.go | 2 + pkg/analyze/host_registry.go | 199 +++++++++ pkg/analyze/host_registry_test.go | 412 ++++++++++++++++++ .../v1beta2/hostanalyzer_shared.go | 7 + .../v1beta2/hostcollector_shared.go | 11 + .../v1beta2/zz_generated.deepcopy.go | 58 +++ pkg/collect/host_collector.go | 2 + pkg/collect/host_registry.go | 90 ++++ pkg/collect/host_registry_test.go | 97 +++++ pkg/collect/registry.go | 22 +- 16 files changed, 1402 insertions(+), 3 deletions(-) create mode 100644 pkg/analyze/host_registry.go create mode 100644 pkg/analyze/host_registry_test.go create mode 100644 pkg/collect/host_registry.go create mode 100644 pkg/collect/host_registry_test.go diff --git a/config/crds/troubleshoot.sh_analyzers.yaml b/config/crds/troubleshoot.sh_analyzers.yaml index de256697..8ae4aa0c 100644 --- a/config/crds/troubleshoot.sh_analyzers.yaml +++ b/config/crds/troubleshoot.sh_analyzers.yaml @@ -1612,6 +1612,58 @@ spec: - outcomes - selector type: object + s3Status: + properties: + annotations: + additionalProperties: + type: string + type: object + checkName: + type: string + collectorName: + type: string + exclude: + type: BoolString + fileName: + type: string + outcomes: + items: + properties: + fail: + properties: + message: + type: string + uri: + type: string + when: + type: string + type: object + pass: + properties: + message: + type: string + uri: + type: string + when: + type: string + type: object + warn: + properties: + message: + type: string + uri: + type: string + when: + type: string + type: object + type: object + type: array + strict: + type: BoolString + required: + - collectorName + - outcomes + type: object secret: properties: annotations: @@ -2783,6 +2835,55 @@ spec: required: - outcomes type: object + registryImages: + properties: + annotations: + additionalProperties: + type: string + type: object + checkName: + type: string + collectorName: + type: string + exclude: + type: BoolString + outcomes: + items: + properties: + fail: + properties: + message: + type: string + uri: + type: string + when: + type: string + type: object + pass: + properties: + message: + type: string + uri: + type: string + when: + type: string + type: object + warn: + properties: + message: + type: string + uri: + type: string + when: + type: string + type: object + type: object + type: array + strict: + type: BoolString + required: + - outcomes + type: object subnetAvailable: properties: annotations: diff --git a/config/crds/troubleshoot.sh_collectors.yaml b/config/crds/troubleshoot.sh_collectors.yaml index 586628ea..51c085d6 100644 --- a/config/crds/troubleshoot.sh_collectors.yaml +++ b/config/crds/troubleshoot.sh_collectors.yaml @@ -17969,6 +17969,29 @@ spec: required: - namespace type: object + s3Status: + properties: + accessKeyID: + type: string + bucketName: + type: string + collectorName: + type: string + endpoint: + type: string + exclude: + type: BoolString + insecure: + type: boolean + region: + type: string + secretAccessKey: + type: string + usePathStyle: + type: boolean + required: + - bucketName + type: object secret: properties: collectorName: @@ -18462,6 +18485,27 @@ spec: - port - toCIDR type: object + registryImages: + description: |- + HostRegistryImages checks whether images are accessible from the host, + without requiring a Kubernetes cluster. Auth can be supplied inline via + Username/Password or omitted to rely on ambient credentials (e.g. ~/.docker/config.json). + properties: + collectorName: + type: string + exclude: + type: BoolString + images: + items: + type: string + type: array + password: + type: string + username: + type: string + required: + - images + type: object run: properties: args: diff --git a/config/crds/troubleshoot.sh_hostcollectors.yaml b/config/crds/troubleshoot.sh_hostcollectors.yaml index 3b20be37..df90c038 100644 --- a/config/crds/troubleshoot.sh_hostcollectors.yaml +++ b/config/crds/troubleshoot.sh_hostcollectors.yaml @@ -855,6 +855,55 @@ spec: required: - outcomes type: object + registryImages: + properties: + annotations: + additionalProperties: + type: string + type: object + checkName: + type: string + collectorName: + type: string + exclude: + type: BoolString + outcomes: + items: + properties: + fail: + properties: + message: + type: string + uri: + type: string + when: + type: string + type: object + pass: + properties: + message: + type: string + uri: + type: string + when: + type: string + type: object + warn: + properties: + message: + type: string + uri: + type: string + when: + type: string + type: object + type: object + type: array + strict: + type: BoolString + required: + - outcomes + type: object subnetAvailable: properties: annotations: @@ -1784,6 +1833,27 @@ spec: - port - toCIDR type: object + registryImages: + description: |- + HostRegistryImages checks whether images are accessible from the host, + without requiring a Kubernetes cluster. Auth can be supplied inline via + Username/Password or omitted to rely on ambient credentials (e.g. ~/.docker/config.json). + properties: + collectorName: + type: string + exclude: + type: BoolString + images: + items: + type: string + type: array + password: + type: string + username: + type: string + required: + - images + type: object run: properties: args: diff --git a/config/crds/troubleshoot.sh_hostpreflights.yaml b/config/crds/troubleshoot.sh_hostpreflights.yaml index 5eabe91f..85fd4821 100644 --- a/config/crds/troubleshoot.sh_hostpreflights.yaml +++ b/config/crds/troubleshoot.sh_hostpreflights.yaml @@ -855,6 +855,55 @@ spec: required: - outcomes type: object + registryImages: + properties: + annotations: + additionalProperties: + type: string + type: object + checkName: + type: string + collectorName: + type: string + exclude: + type: BoolString + outcomes: + items: + properties: + fail: + properties: + message: + type: string + uri: + type: string + when: + type: string + type: object + pass: + properties: + message: + type: string + uri: + type: string + when: + type: string + type: object + warn: + properties: + message: + type: string + uri: + type: string + when: + type: string + type: object + type: object + type: array + strict: + type: BoolString + required: + - outcomes + type: object subnetAvailable: properties: annotations: @@ -1784,6 +1833,27 @@ spec: - port - toCIDR type: object + registryImages: + description: |- + HostRegistryImages checks whether images are accessible from the host, + without requiring a Kubernetes cluster. Auth can be supplied inline via + Username/Password or omitted to rely on ambient credentials (e.g. ~/.docker/config.json). + properties: + collectorName: + type: string + exclude: + type: BoolString + images: + items: + type: string + type: array + password: + type: string + username: + type: string + required: + - images + type: object run: properties: args: diff --git a/config/crds/troubleshoot.sh_preflights.yaml b/config/crds/troubleshoot.sh_preflights.yaml index 6d01c962..3181ffba 100644 --- a/config/crds/troubleshoot.sh_preflights.yaml +++ b/config/crds/troubleshoot.sh_preflights.yaml @@ -1612,6 +1612,58 @@ spec: - outcomes - selector type: object + s3Status: + properties: + annotations: + additionalProperties: + type: string + type: object + checkName: + type: string + collectorName: + type: string + exclude: + type: BoolString + fileName: + type: string + outcomes: + items: + properties: + fail: + properties: + message: + type: string + uri: + type: string + when: + type: string + type: object + pass: + properties: + message: + type: string + uri: + type: string + when: + type: string + type: object + warn: + properties: + message: + type: string + uri: + type: string + when: + type: string + type: object + type: object + type: array + strict: + type: BoolString + required: + - collectorName + - outcomes + type: object secret: properties: annotations: @@ -19866,6 +19918,29 @@ spec: required: - namespace type: object + s3Status: + properties: + accessKeyID: + type: string + bucketName: + type: string + collectorName: + type: string + endpoint: + type: string + exclude: + type: BoolString + insecure: + type: boolean + region: + type: string + secretAccessKey: + type: string + usePathStyle: + type: boolean + required: + - bucketName + type: object secret: properties: collectorName: diff --git a/config/crds/troubleshoot.sh_supportbundles.yaml b/config/crds/troubleshoot.sh_supportbundles.yaml index d784f2aa..cac0efc4 100644 --- a/config/crds/troubleshoot.sh_supportbundles.yaml +++ b/config/crds/troubleshoot.sh_supportbundles.yaml @@ -1643,6 +1643,58 @@ spec: - outcomes - selector type: object + s3Status: + properties: + annotations: + additionalProperties: + type: string + type: object + checkName: + type: string + collectorName: + type: string + exclude: + type: BoolString + fileName: + type: string + outcomes: + items: + properties: + fail: + properties: + message: + type: string + uri: + type: string + when: + type: string + type: object + pass: + properties: + message: + type: string + uri: + type: string + when: + type: string + type: object + warn: + properties: + message: + type: string + uri: + type: string + when: + type: string + type: object + type: object + type: array + strict: + type: BoolString + required: + - collectorName + - outcomes + type: object secret: properties: annotations: @@ -19897,6 +19949,29 @@ spec: required: - namespace type: object + s3Status: + properties: + accessKeyID: + type: string + bucketName: + type: string + collectorName: + type: string + endpoint: + type: string + exclude: + type: BoolString + insecure: + type: boolean + region: + type: string + secretAccessKey: + type: string + usePathStyle: + type: boolean + required: + - bucketName + type: object secret: properties: collectorName: @@ -20787,6 +20862,55 @@ spec: required: - outcomes type: object + registryImages: + properties: + annotations: + additionalProperties: + type: string + type: object + checkName: + type: string + collectorName: + type: string + exclude: + type: BoolString + outcomes: + items: + properties: + fail: + properties: + message: + type: string + uri: + type: string + when: + type: string + type: object + pass: + properties: + message: + type: string + uri: + type: string + when: + type: string + type: object + warn: + properties: + message: + type: string + uri: + type: string + when: + type: string + type: object + type: object + type: array + strict: + type: BoolString + required: + - outcomes + type: object subnetAvailable: properties: annotations: @@ -21716,6 +21840,27 @@ spec: - port - toCIDR type: object + registryImages: + description: |- + HostRegistryImages checks whether images are accessible from the host, + without requiring a Kubernetes cluster. Auth can be supplied inline via + Username/Password or omitted to rely on ambient credentials (e.g. ~/.docker/config.json). + properties: + collectorName: + type: string + exclude: + type: BoolString + images: + items: + type: string + type: array + password: + type: string + username: + type: string + required: + - images + type: object run: properties: args: diff --git a/pkg/analyze/host_analyzer.go b/pkg/analyze/host_analyzer.go index fb450ff7..75b7fba7 100644 --- a/pkg/analyze/host_analyzer.go +++ b/pkg/analyze/host_analyzer.go @@ -67,6 +67,8 @@ func GetHostAnalyzer(analyzer *troubleshootv1beta2.HostAnalyze) (HostAnalyzer, b return &AnalyzeHostNetworkNamespaceConnectivity{analyzer.NetworkNamespaceConnectivity}, true case analyzer.Sysctl != nil: return &AnalyzeHostSysctl{analyzer.Sysctl}, true + case analyzer.RegistryImages != nil: + return &AnalyzeHostRegistryImages{analyzer.RegistryImages}, true default: return nil, false } diff --git a/pkg/analyze/host_registry.go b/pkg/analyze/host_registry.go new file mode 100644 index 00000000..2d926c08 --- /dev/null +++ b/pkg/analyze/host_registry.go @@ -0,0 +1,199 @@ +package analyzer + +import ( + "encoding/json" + "fmt" + "slices" + + "github.com/pkg/errors" + "github.com/replicatedhq/troubleshoot/internal/util" + troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2" + "github.com/replicatedhq/troubleshoot/pkg/collect" + "k8s.io/klog/v2" +) + +// RegistryImagesSummary is passed as template data when rendering outcome messages. +// Fields are exported so Go templates can reference them. +// +// - Verified: images confirmed to exist in the registry. +// - Missing: images confirmed not to exist in the registry. +// - Errors: images that could not be checked (parse failures, timeouts, auth errors, etc). +// - UnverifiedReasons: map of image name to reason string for every unverified image +// (union of Missing and Errors). +// +// The `when` conditions follow the existing registry images analyzer nomenclature: +// "verified", "missing", and "errors" (see https://troubleshoot.sh/docs/analyze/registry-images). +type RegistryImagesSummary struct { + Verified []string + Missing []string + Errors []string + UnverifiedReasons map[string]string +} + +type AnalyzeHostRegistryImages struct { + hostAnalyzer *troubleshootv1beta2.HostRegistryImagesAnalyze +} + +func (a *AnalyzeHostRegistryImages) Title() string { + return hostAnalyzerTitleOrDefault(a.hostAnalyzer.AnalyzeMeta, "Registry Images") +} + +func (a *AnalyzeHostRegistryImages) IsExcluded() (bool, error) { + return isExcluded(a.hostAnalyzer.Exclude) +} + +func (a *AnalyzeHostRegistryImages) Analyze( + getCollectedFileContents func(string) ([]byte, error), findFiles getChildCollectedFileContents, +) ([]*AnalyzeResult, error) { + collectorName := a.hostAnalyzer.CollectorName + if collectorName == "" { + collectorName = "images" + } + + const nodeBaseDir = "host-collectors/registry-images" + localPath := fmt.Sprintf("%s/%s.json", nodeBaseDir, collectorName) + fileName := fmt.Sprintf("%s.json", collectorName) + + collectedContents, err := retrieveCollectedContents( + getCollectedFileContents, + localPath, + nodeBaseDir, + fileName, + ) + if err != nil { + return []*AnalyzeResult{{Title: a.Title()}}, err + } + + var results []*AnalyzeResult + for _, content := range collectedContents { + currentTitle := a.Title() + if content.NodeName != "" { + currentTitle = fmt.Sprintf("%s - Node %s", a.Title(), content.NodeName) + } + + result, err := a.evaluateOutcomesWithTemplate(content.Data, currentTitle) + if err != nil { + return nil, errors.Wrap(err, "failed to analyze host registry images") + } + if result != nil { + klog.V(2).Infof("registry images analysis result: title=%q pass=%t warn=%t fail=%t message=%q", + result.Title, result.IsPass, result.IsWarn, result.IsFail, result.Message) + results = append(results, result) + } + } + + return results, nil +} + +func (a *AnalyzeHostRegistryImages) evaluateOutcomesWithTemplate(data []byte, title string) (*AnalyzeResult, error) { + summary, err := buildRegistryImagesSummary(data) + if err != nil { + return nil, err + } + + for _, outcome := range a.hostAnalyzer.Outcomes { + result := &AnalyzeResult{Title: title} + + switch { + case outcome.Fail != nil: + if outcome.Fail.When == "" { + result.IsFail = true + result.Message = renderRegistryMessage(outcome.Fail.Message, summary) + result.URI = outcome.Fail.URI + return result, nil + } + isMatch, err := compareRegistryConditionalToActual(outcome.Fail.When, len(summary.Verified), len(summary.Missing), len(summary.Errors)) + if err != nil { + return result, errors.Wrapf(err, "failed to compare %s", outcome.Fail.When) + } + if isMatch { + result.IsFail = true + result.Message = renderRegistryMessage(outcome.Fail.Message, summary) + result.URI = outcome.Fail.URI + return result, nil + } + + case outcome.Warn != nil: + if outcome.Warn.When == "" { + result.IsWarn = true + result.Message = renderRegistryMessage(outcome.Warn.Message, summary) + result.URI = outcome.Warn.URI + return result, nil + } + isMatch, err := compareRegistryConditionalToActual(outcome.Warn.When, len(summary.Verified), len(summary.Missing), len(summary.Errors)) + if err != nil { + return result, errors.Wrapf(err, "failed to compare %s", outcome.Warn.When) + } + if isMatch { + result.IsWarn = true + result.Message = renderRegistryMessage(outcome.Warn.Message, summary) + result.URI = outcome.Warn.URI + return result, nil + } + + case outcome.Pass != nil: + if outcome.Pass.When == "" { + result.IsPass = true + result.Message = renderRegistryMessage(outcome.Pass.Message, summary) + result.URI = outcome.Pass.URI + return result, nil + } + isMatch, err := compareRegistryConditionalToActual(outcome.Pass.When, len(summary.Verified), len(summary.Missing), len(summary.Errors)) + if err != nil { + return result, errors.Wrapf(err, "failed to compare %s", outcome.Pass.When) + } + if isMatch { + result.IsPass = true + result.Message = renderRegistryMessage(outcome.Pass.Message, summary) + result.URI = outcome.Pass.URI + return result, nil + } + } + } + + return nil, nil +} + +func buildRegistryImagesSummary(data []byte) (*RegistryImagesSummary, error) { + var registryInfo collect.RegistryInfo + if err := json.Unmarshal(data, ®istryInfo); err != nil { + return nil, errors.Wrap(err, "failed to unmarshal registry info") + } + + summary := &RegistryImagesSummary{ + UnverifiedReasons: map[string]string{}, + } + for image, info := range registryInfo.Images { + if info.Error != "" { + summary.Errors = append(summary.Errors, image) + summary.UnverifiedReasons[image] = info.Error + } else if !info.Exists { + summary.Missing = append(summary.Missing, image) + summary.UnverifiedReasons[image] = "image not found in registry" + } else { + summary.Verified = append(summary.Verified, image) + } + } + slices.Sort(summary.Verified) + slices.Sort(summary.Missing) + slices.Sort(summary.Errors) + return summary, nil +} + +func renderRegistryMessage(message string, summary *RegistryImagesSummary) string { + rendered, err := util.RenderTemplate(message, summary) + if err != nil { + klog.V(2).Infof("Failed to render registry message template: %v", err) + return message + } + return rendered +} + +func (a *AnalyzeHostRegistryImages) CheckCondition(when string, data []byte) (bool, error) { + summary, err := buildRegistryImagesSummary(data) + if err != nil { + return false, err + } + + return compareRegistryConditionalToActual(when, len(summary.Verified), len(summary.Missing), len(summary.Errors)) +} diff --git a/pkg/analyze/host_registry_test.go b/pkg/analyze/host_registry_test.go new file mode 100644 index 00000000..0186fa35 --- /dev/null +++ b/pkg/analyze/host_registry_test.go @@ -0,0 +1,412 @@ +package analyzer + +import ( + "encoding/json" + "testing" + + "github.com/pkg/errors" + troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2" + "github.com/replicatedhq/troubleshoot/pkg/collect" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAnalyzeHostRegistryImagesCheckCondition(t *testing.T) { + tests := []struct { + name string + conditional string + data collect.RegistryInfo + expected bool + expectErr string + }{ + { + name: "all images found", + conditional: "missing == 0", + data: collect.RegistryInfo{ + Images: map[string]collect.RegistryImage{ + "registry.example.com/app:v1": {Exists: true}, + "registry.example.com/app:v2": {Exists: true}, + }, + }, + expected: true, + }, + { + name: "some images not found", + conditional: "missing > 0", + data: collect.RegistryInfo{ + Images: map[string]collect.RegistryImage{ + "registry.example.com/app:v1": {Exists: true}, + "registry.example.com/app:v2": {Exists: false}, + }, + }, + expected: true, + }, + { + name: "verified count matches found", + conditional: "verified == 2", + data: collect.RegistryInfo{ + Images: map[string]collect.RegistryImage{ + "registry.example.com/app:v1": {Exists: true}, + "registry.example.com/app:v2": {Exists: true}, + }, + }, + expected: true, + }, + { + name: "errored images counted under errors", + conditional: "errors > 0", + data: collect.RegistryInfo{ + Images: map[string]collect.RegistryImage{ + "registry.example.com/app:v1": {Error: "connection refused"}, + }, + }, + expected: true, + }, + { + name: "no errors when all found", + conditional: "missing == 0", + data: collect.RegistryInfo{ + Images: map[string]collect.RegistryImage{ + "registry.example.com/app:v1": {Exists: true}, + }, + }, + expected: true, + }, + { + name: "mixed results - missing and errors counted separately", + conditional: "missing == 1", + data: collect.RegistryInfo{ + Images: map[string]collect.RegistryImage{ + "registry.example.com/app:v1": {Exists: true}, + "registry.example.com/app:v2": {Exists: false}, + "registry.example.com/app:v3": {Error: "timeout"}, + }, + }, + expected: true, + }, + { + name: "invalid conditional format", + conditional: "missing", + data: collect.RegistryInfo{ + Images: map[string]collect.RegistryImage{}, + }, + expected: false, + expectErr: "unable to parse conditional", + }, + { + name: "unmarshal error", + conditional: "missing == 0", + expected: false, + expectErr: "failed to unmarshal registry info", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + req := require.New(t) + a := &AnalyzeHostRegistryImages{} + + var data []byte + if test.expectErr == "failed to unmarshal registry info" { + data = []byte(`{not valid json}`) + } else { + var err error + data, err = json.Marshal(test.data) + req.NoError(err) + } + + result, err := a.CheckCondition(test.conditional, data) + if test.expectErr != "" { + req.ErrorContains(err, test.expectErr) + } else { + req.NoError(err) + } + assert.Equal(t, test.expected, result) + }) + } +} + +func TestAnalyzeHostRegistryImages(t *testing.T) { + tests := []struct { + name string + hostAnalyzer *troubleshootv1beta2.HostRegistryImagesAnalyze + getCollectedFileContents func(string) ([]byte, error) + expectedResults []*AnalyzeResult + expectedError string + }{ + { + name: "pass when all images found", + hostAnalyzer: &troubleshootv1beta2.HostRegistryImagesAnalyze{ + Outcomes: []*troubleshootv1beta2.Outcome{ + { + Pass: &troubleshootv1beta2.SingleOutcome{ + When: "missing == 0", + Message: "All images are available", + }, + }, + }, + }, + getCollectedFileContents: func(path string) ([]byte, error) { + if path == "host-collectors/registry-images/images.json" { + return json.Marshal(collect.RegistryInfo{ + Images: map[string]collect.RegistryImage{ + "registry.example.com/app:v1": {Exists: true}, + }, + }) + } + return nil, errors.New("file not found") + }, + expectedResults: []*AnalyzeResult{ + { + Title: "Registry Images", + IsPass: true, + Message: "All images are available", + }, + }, + }, + { + name: "fail when images not found", + hostAnalyzer: &troubleshootv1beta2.HostRegistryImagesAnalyze{ + Outcomes: []*troubleshootv1beta2.Outcome{ + { + Fail: &troubleshootv1beta2.SingleOutcome{ + When: "missing > 0", + Message: "Some images are not available", + }, + }, + }, + }, + getCollectedFileContents: func(path string) ([]byte, error) { + if path == "host-collectors/registry-images/images.json" { + return json.Marshal(collect.RegistryInfo{ + Images: map[string]collect.RegistryImage{ + "registry.example.com/app:v1": {Exists: false}, + }, + }) + } + return nil, errors.New("file not found") + }, + expectedResults: []*AnalyzeResult{ + { + Title: "Registry Images", + IsFail: true, + Message: "Some images are not available", + }, + }, + }, + { + name: "errored images matched by errors condition", + hostAnalyzer: &troubleshootv1beta2.HostRegistryImagesAnalyze{ + Outcomes: []*troubleshootv1beta2.Outcome{ + { + Fail: &troubleshootv1beta2.SingleOutcome{ + When: "errors > 0", + Message: "Some images are not available", + }, + }, + }, + }, + getCollectedFileContents: func(path string) ([]byte, error) { + if path == "host-collectors/registry-images/images.json" { + return json.Marshal(collect.RegistryInfo{ + Images: map[string]collect.RegistryImage{ + "registry.example.com/app:v1": {Error: "connection refused"}, + }, + }) + } + return nil, errors.New("file not found") + }, + expectedResults: []*AnalyzeResult{ + { + Title: "Registry Images", + IsFail: true, + Message: "Some images are not available", + }, + }, + }, + { + name: "custom collector name used in path", + hostAnalyzer: &troubleshootv1beta2.HostRegistryImagesAnalyze{ + CollectorName: "my-registry", + Outcomes: []*troubleshootv1beta2.Outcome{ + { + Pass: &troubleshootv1beta2.SingleOutcome{ + When: "missing == 0", + Message: "All images are available", + }, + }, + }, + }, + getCollectedFileContents: func(path string) ([]byte, error) { + if path == "host-collectors/registry-images/my-registry.json" { + return json.Marshal(collect.RegistryInfo{ + Images: map[string]collect.RegistryImage{ + "registry.example.com/app:v1": {Exists: true}, + }, + }) + } + return nil, errors.New("file not found") + }, + expectedResults: []*AnalyzeResult{ + { + Title: "Registry Images", + IsPass: true, + Message: "All images are available", + }, + }, + }, + { + name: "return error when collection data missing", + hostAnalyzer: &troubleshootv1beta2.HostRegistryImagesAnalyze{ + Outcomes: []*troubleshootv1beta2.Outcome{ + { + Pass: &troubleshootv1beta2.SingleOutcome{ + When: "missing == 0", + Message: "All images are available", + }, + }, + }, + }, + getCollectedFileContents: func(path string) ([]byte, error) { + return nil, errors.New("file not found") + }, + expectedResults: []*AnalyzeResult{ + { + Title: "Registry Images", + }, + }, + expectedError: "file not found", + }, + { + name: "template rendering with NotFound list", + hostAnalyzer: &troubleshootv1beta2.HostRegistryImagesAnalyze{ + Outcomes: []*troubleshootv1beta2.Outcome{ + { + Fail: &troubleshootv1beta2.SingleOutcome{ + When: "missing > 0", + Message: "Missing: {{ .Missing | join \", \" }}", + }, + }, + }, + }, + getCollectedFileContents: func(path string) ([]byte, error) { + if path == "host-collectors/registry-images/images.json" { + return json.Marshal(collect.RegistryInfo{ + Images: map[string]collect.RegistryImage{ + "registry.example.com/app:v1": {Exists: false}, + "registry.example.com/app:v2": {Exists: true}, + }, + }) + } + return nil, errors.New("file not found") + }, + expectedResults: []*AnalyzeResult{ + { + Title: "Registry Images", + IsFail: true, + Message: "Missing: registry.example.com/app:v1", + }, + }, + }, + { + name: "template rendering with NotFoundReasons map", + hostAnalyzer: &troubleshootv1beta2.HostRegistryImagesAnalyze{ + Outcomes: []*troubleshootv1beta2.Outcome{ + { + Fail: &troubleshootv1beta2.SingleOutcome{ + When: "errors > 0", + Message: `{{ range $image, $reason := .UnverifiedReasons }}{{ $image }}: {{ $reason }}; {{ end }}`, + }, + }, + }, + }, + getCollectedFileContents: func(path string) ([]byte, error) { + if path == "host-collectors/registry-images/images.json" { + return json.Marshal(collect.RegistryInfo{ + Images: map[string]collect.RegistryImage{ + "registry.example.com/app:v1": {Error: "connection refused"}, + }, + }) + } + return nil, errors.New("file not found") + }, + expectedResults: []*AnalyzeResult{ + { + Title: "Registry Images", + IsFail: true, + Message: "registry.example.com/app:v1: connection refused; ", + }, + }, + }, + { + name: "template rendering with Found count", + hostAnalyzer: &troubleshootv1beta2.HostRegistryImagesAnalyze{ + Outcomes: []*troubleshootv1beta2.Outcome{ + { + Pass: &troubleshootv1beta2.SingleOutcome{ + When: "missing == 0", + Message: "All {{ len .Verified }} images are available", + }, + }, + }, + }, + getCollectedFileContents: func(path string) ([]byte, error) { + if path == "host-collectors/registry-images/images.json" { + return json.Marshal(collect.RegistryInfo{ + Images: map[string]collect.RegistryImage{ + "registry.example.com/app:v1": {Exists: true}, + "registry.example.com/app:v2": {Exists: true}, + }, + }) + } + return nil, errors.New("file not found") + }, + expectedResults: []*AnalyzeResult{ + { + Title: "Registry Images", + IsPass: true, + Message: "All 2 images are available", + }, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + req := require.New(t) + + a := &AnalyzeHostRegistryImages{ + hostAnalyzer: test.hostAnalyzer, + } + + results, err := a.Analyze(test.getCollectedFileContents, nil) + + if test.expectedError != "" { + req.ErrorContains(err, test.expectedError) + } else { + req.NoError(err) + } + req.Equal(test.expectedResults, results) + }) + } +} + +func TestAnalyzeHostRegistryImagesTitle(t *testing.T) { + t.Run("default title", func(t *testing.T) { + a := &AnalyzeHostRegistryImages{ + hostAnalyzer: &troubleshootv1beta2.HostRegistryImagesAnalyze{}, + } + assert.Equal(t, "Registry Images", a.Title()) + }) + + t.Run("custom title", func(t *testing.T) { + a := &AnalyzeHostRegistryImages{ + hostAnalyzer: &troubleshootv1beta2.HostRegistryImagesAnalyze{ + AnalyzeMeta: troubleshootv1beta2.AnalyzeMeta{ + CheckName: "My Registry Check", + }, + }, + } + assert.Equal(t, "My Registry Check", a.Title()) + }) +} diff --git a/pkg/apis/troubleshoot/v1beta2/hostanalyzer_shared.go b/pkg/apis/troubleshoot/v1beta2/hostanalyzer_shared.go index a9a38619..d8e88ec0 100644 --- a/pkg/apis/troubleshoot/v1beta2/hostanalyzer_shared.go +++ b/pkg/apis/troubleshoot/v1beta2/hostanalyzer_shared.go @@ -153,6 +153,12 @@ type HostSysctlAnalyze struct { Outcomes []*Outcome `json:"outcomes" yaml:"outcomes"` } +type HostRegistryImagesAnalyze struct { + AnalyzeMeta `json:",inline" yaml:",inline"` + CollectorName string `json:"collectorName,omitempty" yaml:"collectorName,omitempty"` + Outcomes []*Outcome `json:"outcomes" yaml:"outcomes"` +} + type HostAnalyze struct { CPU *CPUAnalyze `json:"cpu,omitempty" yaml:"cpu,omitempty"` TCPLoadBalancer *TCPLoadBalancerAnalyze `json:"tcpLoadBalancer,omitempty" yaml:"tcpLoadBalancer,omitempty"` @@ -180,4 +186,5 @@ type HostAnalyze struct { JsonCompare *JsonCompare `json:"jsonCompare,omitempty" yaml:"jsonCompare,omitempty"` NetworkNamespaceConnectivity *NetworkNamespaceConnectivityAnalyze `json:"networkNamespaceConnectivity,omitempty" yaml:"networkNamespaceConnectivity,omitempty"` Sysctl *HostSysctlAnalyze `json:"sysctl,omitempty" yaml:"sysctl,omitempty"` + RegistryImages *HostRegistryImagesAnalyze `json:"registryImages,omitempty" yaml:"registryImages,omitempty"` } diff --git a/pkg/apis/troubleshoot/v1beta2/hostcollector_shared.go b/pkg/apis/troubleshoot/v1beta2/hostcollector_shared.go index 383d6102..b8bd81ac 100644 --- a/pkg/apis/troubleshoot/v1beta2/hostcollector_shared.go +++ b/pkg/apis/troubleshoot/v1beta2/hostcollector_shared.go @@ -235,6 +235,16 @@ type HostSysctl struct { HostCollectorMeta `json:",inline" yaml:",inline"` } +// HostRegistryImages checks whether images are accessible from the host, +// without requiring a Kubernetes cluster. Auth can be supplied inline via +// Username/Password or omitted to rely on ambient credentials (e.g. ~/.docker/config.json). +type HostRegistryImages struct { + HostCollectorMeta `json:",inline" yaml:",inline"` + Images []string `json:"images" yaml:"images"` + Username string `json:"username,omitempty" yaml:"username,omitempty"` + Password string `json:"password,omitempty" yaml:"password,omitempty"` +} + type HostCollect struct { CPU *CPU `json:"cpu,omitempty" yaml:"cpu,omitempty"` Memory *Memory `json:"memory,omitempty" yaml:"memory,omitempty"` @@ -265,6 +275,7 @@ type HostCollect struct { HostDNS *HostDNS `json:"dns,omitempty" yaml:"dns,omitempty"` NetworkNamespaceConnectivity *HostNetworkNamespaceConnectivity `json:"networkNamespaceConnectivity,omitempty" yaml:"networkNamespaceConnectivity,omitempty"` HostSysctl *HostSysctl `json:"sysctl,omitempty" yaml:"sysctl,omitempty"` + RegistryImages *HostRegistryImages `json:"registryImages,omitempty" yaml:"registryImages,omitempty"` } // GetName gets the name of the collector diff --git a/pkg/apis/troubleshoot/v1beta2/zz_generated.deepcopy.go b/pkg/apis/troubleshoot/v1beta2/zz_generated.deepcopy.go index 96e3e0c0..b10e18bc 100644 --- a/pkg/apis/troubleshoot/v1beta2/zz_generated.deepcopy.go +++ b/pkg/apis/troubleshoot/v1beta2/zz_generated.deepcopy.go @@ -1999,6 +1999,11 @@ func (in *HostAnalyze) DeepCopyInto(out *HostAnalyze) { *out = new(HostSysctlAnalyze) (*in).DeepCopyInto(*out) } + if in.RegistryImages != nil { + in, out := &in.RegistryImages, &out.RegistryImages + *out = new(HostRegistryImagesAnalyze) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HostAnalyze. @@ -2239,6 +2244,11 @@ func (in *HostCollect) DeepCopyInto(out *HostCollect) { *out = new(HostSysctl) (*in).DeepCopyInto(*out) } + if in.RegistryImages != nil { + in, out := &in.RegistryImages, &out.RegistryImages + *out = new(HostRegistryImages) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HostCollect. @@ -2684,6 +2694,54 @@ func (in *HostPreflightStatus) DeepCopy() *HostPreflightStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *HostRegistryImages) DeepCopyInto(out *HostRegistryImages) { + *out = *in + in.HostCollectorMeta.DeepCopyInto(&out.HostCollectorMeta) + if in.Images != nil { + in, out := &in.Images, &out.Images + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HostRegistryImages. +func (in *HostRegistryImages) DeepCopy() *HostRegistryImages { + if in == nil { + return nil + } + out := new(HostRegistryImages) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *HostRegistryImagesAnalyze) DeepCopyInto(out *HostRegistryImagesAnalyze) { + *out = *in + in.AnalyzeMeta.DeepCopyInto(&out.AnalyzeMeta) + if in.Outcomes != nil { + in, out := &in.Outcomes, &out.Outcomes + *out = make([]*Outcome, len(*in)) + for i := range *in { + if (*in)[i] != nil { + in, out := &(*in)[i], &(*out)[i] + *out = new(Outcome) + (*in).DeepCopyInto(*out) + } + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HostRegistryImagesAnalyze. +func (in *HostRegistryImagesAnalyze) DeepCopy() *HostRegistryImagesAnalyze { + if in == nil { + return nil + } + out := new(HostRegistryImagesAnalyze) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *HostRun) DeepCopyInto(out *HostRun) { *out = *in diff --git a/pkg/collect/host_collector.go b/pkg/collect/host_collector.go index 855c06ec..bcbb8123 100644 --- a/pkg/collect/host_collector.go +++ b/pkg/collect/host_collector.go @@ -105,6 +105,8 @@ func GetHostCollector(collector *troubleshootv1beta2.HostCollect, bundlePath str return &CollectHostNetworkNamespaceConnectivity{collector.NetworkNamespaceConnectivity, bundlePath}, true case collector.HostSysctl != nil: return &CollectHostSysctl{collector.HostSysctl, bundlePath}, true + case collector.RegistryImages != nil: + return &CollectHostRegistryImages{collector.RegistryImages, bundlePath}, true default: return nil, false } diff --git a/pkg/collect/host_registry.go b/pkg/collect/host_registry.go new file mode 100644 index 00000000..889d1f0a --- /dev/null +++ b/pkg/collect/host_registry.go @@ -0,0 +1,90 @@ +package collect + +import ( + "bytes" + "encoding/json" + "path/filepath" + "time" + + "github.com/pkg/errors" + troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2" + "k8s.io/klog/v2" +) + +type CollectHostRegistryImages struct { + hostCollector *troubleshootv1beta2.HostRegistryImages + BundlePath string +} + +func (c *CollectHostRegistryImages) Title() string { + return hostCollectorTitleOrDefault(c.hostCollector.HostCollectorMeta, "Registry Images") +} + +func (c *CollectHostRegistryImages) IsExcluded() (bool, error) { + return isExcluded(c.hostCollector.Exclude) +} + +func (c *CollectHostRegistryImages) Collect(progressChan chan<- interface{}) (map[string][]byte, error) { + registryInfo := RegistryInfo{ + Images: map[string]RegistryImage{}, + } + + auth := c.resolveAuth() + if auth != nil { + klog.V(2).Infof("using inline credentials for registry check (username=%s)", c.hostCollector.Username) + } else { + klog.V(2).Info("no inline credentials provided, using ambient auth") + } + + klog.V(2).Infof("checking %d images", len(c.hostCollector.Images)) + for _, image := range c.hostCollector.Images { + klog.V(2).Infof("checking image: %s", image) + imageRef, err := parseImageRef(image) + if err != nil { + klog.Errorf("failed to parse image ref %s: %v", image, err) + registryInfo.Images[image] = RegistryImage{Error: err.Error()} + continue + } + exists, err := imageExistsWithAuth(auth, imageRef, image, 10*time.Second) + if err != nil { + klog.Errorf("image check failed for %s: %v", image, err) + registryInfo.Images[image] = RegistryImage{Error: err.Error()} + } else { + klog.V(2).Infof("image %s exists=%t", image, exists) + registryInfo.Images[image] = RegistryImage{Exists: exists} + } + } + + b, err := json.MarshalIndent(registryInfo, "", " ") + if err != nil { + return nil, errors.Wrap(err, "failed to marshal registry info") + } + + collectorName := c.hostCollector.CollectorName + if collectorName == "" { + collectorName = "images" + } + + name := filepath.Join("host-collectors/registry-images", collectorName+".json") + + output := NewResult() + output.SaveResult(c.BundlePath, name, bytes.NewBuffer(b)) + + return output, nil +} + +func (c *CollectHostRegistryImages) RemoteCollect(progressChan chan<- interface{}) (map[string][]byte, error) { + return nil, ErrRemoteCollectorNotImplemented +} + +// resolveAuth returns auth config from inline credentials or nil for ambient auth. +func (c *CollectHostRegistryImages) resolveAuth() *registryAuthConfig { + if c.hostCollector.Username != "" { + return ®istryAuthConfig{ + username: c.hostCollector.Username, + password: c.hostCollector.Password, + } + } + // No credentials: rely on ambient auth (~/.docker/config.json) + return nil +} diff --git a/pkg/collect/host_registry_test.go b/pkg/collect/host_registry_test.go new file mode 100644 index 00000000..84b4cab5 --- /dev/null +++ b/pkg/collect/host_registry_test.go @@ -0,0 +1,97 @@ +package collect + +import ( + "testing" + + troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCollectHostRegistryImagesTitle(t *testing.T) { + tests := []struct { + name string + meta troubleshootv1beta2.HostCollectorMeta + expected string + }{ + { + name: "default title", + meta: troubleshootv1beta2.HostCollectorMeta{}, + expected: "Registry Images", + }, + { + name: "custom title", + meta: troubleshootv1beta2.HostCollectorMeta{ + CollectorName: "My Registry", + }, + expected: "My Registry", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + c := &CollectHostRegistryImages{ + hostCollector: &troubleshootv1beta2.HostRegistryImages{ + HostCollectorMeta: test.meta, + }, + } + assert.Equal(t, test.expected, c.Title()) + }) + } +} + +func TestCollectHostRegistryImagesResolveAuth(t *testing.T) { + tests := []struct { + name string + username string + password string + expected *registryAuthConfig + }{ + { + name: "nil when no credentials", + username: "", + password: "", + expected: nil, + }, + { + name: "returns auth with credentials", + username: "user", + password: "pass", + expected: ®istryAuthConfig{ + username: "user", + password: "pass", + }, + }, + { + name: "returns auth with username only", + username: "user", + password: "", + expected: ®istryAuthConfig{ + username: "user", + password: "", + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + c := &CollectHostRegistryImages{ + hostCollector: &troubleshootv1beta2.HostRegistryImages{ + Username: test.username, + Password: test.password, + }, + } + result := c.resolveAuth() + assert.Equal(t, test.expected, result) + }) + } +} + +func TestCollectHostRegistryImagesRemoteCollect(t *testing.T) { + c := &CollectHostRegistryImages{ + hostCollector: &troubleshootv1beta2.HostRegistryImages{}, + } + result, err := c.RemoteCollect(nil) + require.ErrorIs(t, err, ErrRemoteCollectorNotImplemented) + assert.Nil(t, result) +} diff --git a/pkg/collect/registry.go b/pkg/collect/registry.go index 2cb871b1..9ad52f26 100644 --- a/pkg/collect/registry.go +++ b/pkg/collect/registry.go @@ -91,9 +91,9 @@ func (c *CollectRegistry) Collect(progressChan chan<- interface{}) (CollectorRes } func imageExists(namespace string, clientConfig *rest.Config, registryCollector *troubleshootv1beta2.RegistryImages, image string, deadline time.Duration) (bool, error) { - imageRef, err := alltransports.ParseImageName(fmt.Sprintf("docker://%s", image)) + imageRef, err := parseImageRef(image) if err != nil { - return false, errors.Wrapf(err, "failed to parse image name %s", image) + return false, err } authConfig, err := getImageAuthConfig(namespace, clientConfig, registryCollector, imageRef) @@ -102,6 +102,21 @@ func imageExists(namespace string, clientConfig *rest.Config, registryCollector return false, errors.Wrap(err, "failed to get auth config") } + return imageExistsWithAuth(authConfig, imageRef, image, deadline) +} + +func parseImageRef(image string) (types.ImageReference, error) { + imageRef, err := alltransports.ParseImageName(fmt.Sprintf("docker://%s", image)) + if err != nil { + return nil, errors.Wrapf(err, "failed to parse image name %s", image) + } + return imageRef, nil +} + +// imageExistsWithAuth checks if an image exists in a registry using optional auth credentials. +// authConfig may be nil for ambient credentials (e.g. ~/.docker/config.json). +// This is the shared core used by both the cluster-level and host-level registry collectors. +func imageExistsWithAuth(authConfig *registryAuthConfig, imageRef types.ImageReference, image string, deadline time.Duration) (bool, error) { sysCtx := types.SystemContext{ DockerDisableV1Ping: true, DockerInsecureSkipTLSVerify: types.OptionalBoolTrue, @@ -141,7 +156,8 @@ func imageExists(namespace string, clientConfig *rest.Config, registryCollector return false, errors.Wrap(err, "failed to get image manifest") } - if strings.Contains(err.Error(), "no image found in manifest list for architecture") { + if strings.Contains(err.Error(), "no image found in manifest list for architecture") || + strings.Contains(err.Error(), "no image found in image index for architecture") { // manifest was downloaded, but no matching architecture found in manifest // should this count as image does not exist? // this binary's architecture is not necessarily what will run in the cluster