mirror of
https://github.com/replicatedhq/troubleshoot.git
synced 2026-08-27 00:37:20 +00:00
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) <noreply@anthropic.com> * Fixes from manual tests Signed-off-by: Evans Mungai <evans@replicated.com> * Updates from manual tests Signed-off-by: Evans Mungai <evans@replicated.com> * Sort template lists Signed-off-by: Evans Mungai <evans@replicated.com> * More changes Signed-off-by: Evans Mungai <evans@replicated.com> --------- Signed-off-by: Evans Mungai <evans@replicated.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
b56b98742e
commit
21948fe959
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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())
|
||||
})
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
+19
-3
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user