diff --git a/Makefile b/Makefile index 6b722f212c..4a2e90a671 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ E2E_ROOT_DIR = ./test E2E_SUITES = $(notdir $(wildcard $(E2E_ROOT_DIR)/e2e*)) -MCO_COMPONENTS = daemon controller server operator +MCO_COMPONENTS = daemon controller server operator osimagestream EXTRA_COMPONENTS = apiserver-watcher machine-os-builder machine-config-tests-ext TEST_COMPONENTS = machine-config-tests-ext ALL_COMPONENTS = $(patsubst %,machine-config-%,$(MCO_COMPONENTS)) $(EXTRA_COMPONENTS) $(TEST_COMPONENTS) diff --git a/cmd/machine-config-osimagestream/README.md b/cmd/machine-config-osimagestream/README.md new file mode 100644 index 0000000000..f09ef7ad7e --- /dev/null +++ b/cmd/machine-config-osimagestream/README.md @@ -0,0 +1,75 @@ +# machine-config-osimagestream + +A CLI tool for retrieving OSImageStream information from OpenShift/OKD release payload images or ImageStream files. + +## Overview + +The `machine-config-osimagestream` tool is part of the Machine Config Operator and provides utilities for extracting OS image information from release payloads. It supports three main commands: + +1. **get osimagestream** - Retrieve the full OSImageStream resource +2. **get default-node-image** - Get the default node OS image pullspec +3. **get os-image-pullspec** - Get the OS image pullspec for a specific stream name + +This tool is useful for automation, debugging, and understanding which OS images are available in a given OpenShift/OKD release. + +## Basic Usage + +All commands require authentication to pull from image registries. Use the `--authfile` flag to provide credentials. + +### Get OSImageStream + +Retrieve the complete OSImageStream resource: + +```bash +# From release image (outputs JSON by default) +machine-config-osimagestream get osimagestream \ + --authfile /path/to/pull-secret.json \ + --release-image quay.io/openshift-release-dev/ocp-release:latest + +# From ImageStream file +machine-config-osimagestream get osimagestream \ + --authfile /path/to/pull-secret.json \ + --imagestream /path/to/imagestream.json +``` + +### Get Default Node OS Image Pullspec + +Retrieve the default node OS image pullspec: + +```bash +# Get the default node OS image pullspec from release image +machine-config-osimagestream get default-node-image \ + --authfile /path/to/pull-secret.json \ + --release-image quay.io/openshift-release-dev/ocp-release:latest + +# Get the default node OS image pullspec from ImageStream file +machine-config-osimagestream get default-node-image \ + --authfile /path/to/pull-secret.json \ + --imagestream /path/to/imagestream.json +``` + +### Get OS Image Pullspec by Name + +Retrieve the OS image pullspec for a specific OSImageStream name: + +```bash +# Get OS image pullspec for rhel-9 stream from release image +machine-config-osimagestream get os-image-pullspec \ + --authfile /path/to/pull-secret.json \ + --release-image quay.io/openshift-release-dev/ocp-release:latest \ + --osimagestream-name rhel-9 + +# Get OS image pullspec for rhel-10 stream from ImageStream file +machine-config-osimagestream get os-image-pullspec \ + --authfile /path/to/pull-secret.json \ + --imagestream /path/to/imagestream.json \ + --osimagestream-name rhel-10 +``` + +## Environment Variables + +The tool respects standard HTTP proxy environment variables: + +- `HTTP_PROXY` - HTTP proxy URL +- `HTTPS_PROXY` - HTTPS proxy URL +- `NO_PROXY` - Comma-separated list of hosts to exclude from proxying \ No newline at end of file diff --git a/cmd/machine-config-osimagestream/get.go b/cmd/machine-config-osimagestream/get.go new file mode 100644 index 0000000000..cb15a495f3 --- /dev/null +++ b/cmd/machine-config-osimagestream/get.go @@ -0,0 +1,209 @@ +package main + +import ( + "flag" + "fmt" + + "github.com/openshift/machine-config-operator/pkg/version" + "github.com/spf13/cobra" + "k8s.io/klog/v2" +) + +type getOpts struct { + certDirPath string + imageStreamPath string + osImageStreamName string + outputFile string + outputFormat string + perHostCertsPath string + authfilePath string + registryConfigPath string + releaseImage string + trustBundlePaths []string +} + +var ( + getCmd = &cobra.Command{ + Use: "get", + Short: "Retrieves OSImageStream or default node image information", + } + + osImageStreamCmd = &cobra.Command{ + Use: "osimagestream", + Short: "Get the default OSImageStream", + Long: `Get the OSImageStream from a release payload image or ImageStream file. + +You must provide exactly one of --release-image or --imagestream. These flags are mutually exclusive. + +Examples: + # Get OSImageStream from release image: + $ machine-config-osimagestream get osimagestream \ + --authfile /path/to/pull-secret.json \ + --release-image quay.io/openshift-release-dev/ocp-release:latest + + # Get OSImageStream from ImageStream file: + $ machine-config-osimagestream get osimagestream \ + --authfile /path/to/pull-secret.json \ + --imagestream /path/to/imagestream.json + +For a comprehensive list of all flags and options, see the README.md in this directory.`, + RunE: runOSImageStreamCmd, + } + + defaultNodeImageCmd = &cobra.Command{ + Use: "default-node-image", + Short: "Get the default node OS image pullspec", + Long: `Get the default node OS image pullspec from a release payload image or ImageStream file. + +This command retrieves and outputs the default node OS image pullspec (full container image reference). + +You must provide exactly one of --release-image or --imagestream. These flags are mutually exclusive. + +Examples: + # Get the default node OS image pullspec from the release image: + $ machine-config-osimagestream get default-node-image \ + --authfile /path/to/pull-secret.json \ + --release-image quay.io/openshift-release-dev/ocp-release:latest + + # Get the default node OS image pullspec from an ImageStream file: + $ machine-config-osimagestream get default-node-image \ + --authfile /path/to/pull-secret.json \ + --imagestream /path/to/imagestream.yaml + +For a comprehensive list of all flags and options, see the README.md in this directory.`, + RunE: runDefaultNodeImageCmd, + } + + osImagePullspecCmd = &cobra.Command{ + Use: "os-image-pullspec", + Short: "Get the OS image pullspec for the given OSImageStream name", + Long: `Get the OS image pullspec for a specific OSImageStream name. + +This command retrieves the OS image pullspec (full container image reference) for a given OSImageStream name. The OSImageStream name typically corresponds to a specific RHEL/RHCOS version stream (e.g., "rhel-9", "rhel-10", "rhel-9-coreos"). + +You must provide: +- The --osimagestream-name flag to specify which stream to look up +- Exactly one of --release-image or --imagestream (mutually exclusive) + +Examples: + # Get OS image pullspec for rhel-9 stream from release image: + $ machine-config-osimagestream get os-image-pullspec \ + --authfile /path/to/pull-secret.json \ + --release-image quay.io/openshift-release-dev/ocp-release:latest \ + --osimagestream-name rhel-9 + + # Get OS image pullspec for rhel-10 stream from ImageStream file: + $ machine-config-osimagestream get os-image-pullspec \ + --authfile /path/to/pull-secret.json \ + --imagestream /path/to/imagestream.json \ + --osimagestream-name rhel-10 + +For a comprehensive list of all flags and options, see the README.md in this directory.`, + RunE: runOSImagePullspecCmd, + } + + o = getOpts{} +) + +func init() { + getCmd.AddCommand(osImageStreamCmd) + getCmd.AddCommand(defaultNodeImageCmd) + getCmd.AddCommand(osImagePullspecCmd) + + rootCmd.AddCommand(getCmd) + + // Shared persistent flags for get command + getCmd.PersistentFlags().StringVar(&o.authfilePath, "authfile", "", "Path to an image registry authentication file.") + getCmd.PersistentFlags().StringVar(&o.certDirPath, "cert-dir", "", "Path to directory containing certificates.") + getCmd.PersistentFlags().StringVar(&o.registryConfigPath, "registry-config", "", "Path to registries.conf.") + getCmd.PersistentFlags().StringArrayVar(&o.trustBundlePaths, "trust-bundle", []string{}, "Path(s) to additional trust bundle file(s) or directory(ies). Can be specified multiple times.") + getCmd.PersistentFlags().StringVar(&o.perHostCertsPath, "per-host-certs", "", "Path to per-host certificates directory.") + getCmd.PersistentFlags().StringVar(&o.releaseImage, "release-image", "", "The OCP / OKD release payload image to run against.") + getCmd.PersistentFlags().StringVar(&o.imageStreamPath, "imagestream", "", "Path to the ImageStream file to run against.") + + getCmd.MarkPersistentFlagRequired("authfile") + + // osimagestream command flags (mutually exclusive - validated at runtime) + osImageStreamCmd.PersistentFlags().StringVar(&o.outputFormat, "output-format", "json", "The output format to use: 'json' or 'yaml'.") + osImageStreamCmd.PersistentFlags().StringVar(&o.outputFile, "output-file", "", "Path to write output to a file instead of stdout. Format is auto-detected from extension (.json, .yaml, .yml).") + + // os-image-pullspec command flags + osImagePullspecCmd.PersistentFlags().StringVar(&o.osImageStreamName, "osimagestream-name", "", "The OSImageStream name to look up.") + osImagePullspecCmd.MarkPersistentFlagRequired("osimagestream-name") +} + +// Validates common CLI flags are set correctly. +func validateCLIArgs(_ *cobra.Command, _ []string) error { + flag.Set("logtostderr", "true") + flag.Parse() + + // To help debugging, show version + klog.V(defaultLogVerbosity).Infof("Version: %+v (%s)", version.Raw, version.Hash) + + // Validate mutually exclusive flags + if o.imageStreamPath != "" && o.releaseImage != "" { + klog.Exitf("--imagestream and --release-image are mutually exclusive") + } + + if o.imageStreamPath == "" && o.releaseImage == "" { + klog.Exitf("either --imagestream or --release-image must be provided") + } + + return nil +} + +// Retrieves the OSImageStream and writes it to the expected location (stdout +// or file). +func runOSImageStreamCmd(cmd *cobra.Command, args []string) error { + if err := validateCLIArgs(cmd, args); err != nil { + return err + } + + osImageStream, err := getOSImageStreamFromReleaseImageOrImageStream(o) + if err != nil { + return err + } + + return writeOutput(osImageStream, o.outputFormat, o.outputFile) +} + +// Retrieves the default node image pullspec and prints it to stdout. +func runDefaultNodeImageCmd(cmd *cobra.Command, args []string) error { + if err := validateCLIArgs(cmd, args); err != nil { + return err + } + + osImageStream, err := getOSImageStreamFromReleaseImageOrImageStream(o) + if err != nil { + return err + } + + defaultOSImageStream, err := getOSImageStreamSetFromOSImageStream(osImageStream, osImageStream.Status.DefaultStream) + if err != nil { + return err + } + + fmt.Println(string(defaultOSImageStream.OSImage)) + return nil +} + +// Retrieves the node image pullspec for the given OSImageStream name and +// prints it to stdout. +func runOSImagePullspecCmd(cmd *cobra.Command, args []string) error { + if err := validateCLIArgs(cmd, args); err != nil { + return err + } + + osImageStream, err := getOSImageStreamFromReleaseImageOrImageStream(o) + if err != nil { + return err + } + + target, err := getOSImageStreamSetFromOSImageStream(osImageStream, o.osImageStreamName) + if err != nil { + return err + } + + fmt.Println(string(target.OSImage)) + return nil +} diff --git a/cmd/machine-config-osimagestream/helpers.go b/cmd/machine-config-osimagestream/helpers.go new file mode 100644 index 0000000000..b5cea4a47e --- /dev/null +++ b/cmd/machine-config-osimagestream/helpers.go @@ -0,0 +1,205 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "strings" + "time" + + configv1 "github.com/openshift/api/config/v1" + "sigs.k8s.io/yaml" + + imagev1 "github.com/openshift/api/image/v1" + mcfgv1alpha1 "github.com/openshift/api/machineconfiguration/v1alpha1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + k8serrors "k8s.io/apimachinery/pkg/api/errors" + + "github.com/openshift/machine-config-operator/pkg/imageutils" + "github.com/openshift/machine-config-operator/pkg/osimagestream" + "k8s.io/klog/v2" +) + +// Retrieves the OSImageStream for the given release image or ImageStream path. +func getOSImageStreamFromReleaseImageOrImageStream(opts getOpts) (_ *mcfgv1alpha1.OSImageStream, errOut error) { + ctx, cancel := context.WithTimeout(context.Background(), time.Minute*5) + defer cancel() + + if opts.outputFormat != "json" && opts.outputFormat != "yaml" { + return nil, fmt.Errorf("unsupported output format %q: must be 'json' or 'yaml'", opts.outputFormat) + } + + start := time.Now() + defer func() { + klog.V(defaultLogVerbosity).Infof("Retrieved OSImageStream in %s", time.Since(start)) + }() + + sysCtx, err := imageutils.NewSysContextFromFilesystem(imageutils.SysContextPaths{ + AdditionalTrustBundles: opts.trustBundlePaths, + CertDir: opts.certDirPath, + PerHostCertDir: opts.perHostCertsPath, + Proxy: getProxyConfig(), + PullSecret: opts.authfilePath, + RegistryConfig: opts.registryConfigPath, + }) + + if err != nil { + return nil, fmt.Errorf("could not prepare system context: %w", err) + } + + defer func() { + if err := sysCtx.Cleanup(); err != nil { + klog.Warningf("unable to clean resources after OSImageStream inspection: %s", err) + errOut = errors.Join(errOut, err) + } + }() + + var imageStream *imagev1.ImageStream + if opts.imageStreamPath != "" { + is, err := getImageStreamFromFile(opts.imageStreamPath) + if err != nil { + return nil, err + } + + imageStream = is + } + + factory := osimagestream.NewDefaultStreamSourceFactory(&osimagestream.DefaultImagesInspectorFactory{}) + + createOpts := osimagestream.CreateOptions{ + ReleaseImage: opts.releaseImage, + ReleaseImageStream: imageStream, + } + + osImageStream, err := factory.Create(ctx, sysCtx.SysContext, createOpts) + if err != nil { + return nil, err + } + + annoKey := fmt.Sprintf("machineconfiguration.openshift.io/generated-by-%s", componentName) + metav1.SetMetaDataAnnotation(&osImageStream.ObjectMeta, annoKey, "") + + return osImageStream, nil +} + +// Wraps osimagestream.GetOSImageStreamSetByName() and provides more helpful +// output whenever a not found error is returned. +func getOSImageStreamSetFromOSImageStream(osImageStream *mcfgv1alpha1.OSImageStream, name string) (*mcfgv1alpha1.OSImageStreamSet, error) { + if name == "" { + return nil, fmt.Errorf("empty OSImageStream name") + } + + streamSet, err := osimagestream.GetOSImageStreamSetByName(osImageStream, name) + if err == nil { + return streamSet, nil + } + + // If we have a not found error, provide the name(s) of the current OSImageStream. + if k8serrors.IsNotFound(err) { + names := osimagestream.GetStreamSetsNames(osImageStream.Status.AvailableStreams) + return nil, fmt.Errorf("missing OSImageStream %q, expected one of: %v: %w", name, names, err) + } + + return nil, err +} + +// Creates a proxy status if the appropriate env vars are set. Returns nil when +// none of the env vars are set. +func getProxyConfig() *configv1.ProxyStatus { + proxyStatus := &configv1.ProxyStatus{} + + if httpProxy := os.Getenv("HTTP_PROXY"); httpProxy != "" { + proxyStatus.HTTPProxy = httpProxy + } + + if httpsProxy := os.Getenv("HTTPS_PROXY"); httpsProxy != "" { + proxyStatus.HTTPSProxy = httpsProxy + } + + // Although a newer version of container-libs uses the NO_PROXY env var, the + // version we are using now does not. We should add that functionality here + // if https://redhat.atlassian.net/browse/MCO-2016 is addressed. + + // If none of the environment variables were set, return a nil config. + if proxyStatus.HTTPProxy == "" && proxyStatus.HTTPSProxy == "" { + return nil + } + + return proxyStatus +} + +// detectFormatFromFilepath determines the output format based on file extension. +// Returns the detected format or falls back to the provided fallbackFormat. +func detectFormatFromFilepath(filepath, fallbackFormat string) string { + if strings.HasSuffix(filepath, ".yaml") || strings.HasSuffix(filepath, ".yml") { + return "yaml" + } + + if strings.HasSuffix(filepath, ".json") { + return "json" + } + + return fallbackFormat +} + +// Writes the OSImageStream to a file or stdout in the desired format. +func writeOutput(osImageStream *mcfgv1alpha1.OSImageStream, format, outputFile string) error { + // Detect format from file extension if outputFile is provided + finalFormat := format + if outputFile != "" { + finalFormat = detectFormatFromFilepath(outputFile, format) + } + + var outBytes []byte + + switch finalFormat { + case "json": + out, err := json.MarshalIndent(osImageStream, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal OSImageStream to JSON: %w", err) + } + outBytes = out + case "yaml": + out, err := yaml.Marshal(osImageStream) + if err != nil { + return fmt.Errorf("failed to marshal OSImageStream to YAML: %w", err) + } + outBytes = append([]byte("---\n"), out...) // Add the --- to the YAML document. + default: + return fmt.Errorf("unsupported output format %q: must be 'json' or 'yaml'", finalFormat) + } + + // Write to file if outputFile is specified, otherwise write to stdout + if outputFile != "" { + if err := os.WriteFile(outputFile, append(outBytes, '\n'), 0o644); err != nil { + return fmt.Errorf("failed to write output to file %q: %w", outputFile, err) + } + klog.Infof("Successfully wrote output to %s in %s format", outputFile, finalFormat) + } else { + if _, err := fmt.Fprintf(os.Stdout, "%s\n", outBytes); err != nil { + return fmt.Errorf("failed to write output: %w", err) + } + } + + return nil +} + +// Reads the ImageStream from a given path on the fileystem. +func getImageStreamFromFile(imageStreamPath string) (*imagev1.ImageStream, error) { + isBytes, err := os.ReadFile(imageStreamPath) + if err != nil { + return nil, err + } + + is := &imagev1.ImageStream{} + if err := yaml.Unmarshal(isBytes, is); err != nil { + return nil, fmt.Errorf("could not decode imagestream: %w", err) + } + + klog.V(defaultLogVerbosity).Infof("Read ImageStream from %q", imageStreamPath) + + return is, nil +} diff --git a/cmd/machine-config-osimagestream/main.go b/cmd/machine-config-osimagestream/main.go new file mode 100644 index 0000000000..b25e03d475 --- /dev/null +++ b/cmd/machine-config-osimagestream/main.go @@ -0,0 +1,28 @@ +package main + +import ( + "flag" + "os" + + "github.com/spf13/cobra" + "k8s.io/component-base/cli" +) + +const componentName = "machine-config-osimagestream" +const defaultLogVerbosity = 2 + +var ( + rootCmd = &cobra.Command{ + Use: componentName, + Short: "Generate the OSImageStream for a given OCP / OKD release", + Long: "", + } +) + +func init() { + rootCmd.PersistentFlags().AddGoFlagSet(flag.CommandLine) +} + +func main() { + os.Exit(cli.Run(rootCmd)) +} diff --git a/cmd/machine-config-osimagestream/version.go b/cmd/machine-config-osimagestream/version.go new file mode 100644 index 0000000000..697297b295 --- /dev/null +++ b/cmd/machine-config-osimagestream/version.go @@ -0,0 +1,31 @@ +package main + +import ( + "flag" + "fmt" + + "github.com/openshift/machine-config-operator/pkg/version" + "github.com/spf13/cobra" +) + +var ( + versionCmd = &cobra.Command{ + Use: "version", + Short: "Print the version number of Machine Config OSImageStream", + Long: `All software has versions. This is Machine Config OSImageStream's.`, + Run: runVersionCmd, + } +) + +func init() { + rootCmd.AddCommand(versionCmd) +} + +func runVersionCmd(_ *cobra.Command, _ []string) { + flag.Set("logtostderr", "true") + flag.Parse() + + version := version.Raw + "-" + version.Hash + + fmt.Println(componentName, version) +} diff --git a/pkg/imageutils/sys_context_fs.go b/pkg/imageutils/sys_context_fs.go new file mode 100644 index 0000000000..5fb29251c2 --- /dev/null +++ b/pkg/imageutils/sys_context_fs.go @@ -0,0 +1,231 @@ +package imageutils + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + + configv1 "github.com/openshift/api/config/v1" + mcfgv1 "github.com/openshift/api/machineconfiguration/v1" + "github.com/openshift/machine-config-operator/pkg/secrets" + corev1 "k8s.io/api/core/v1" +) + +// Holds several paths and proxy information needed to create an appropriate +// SysContext. Where possible, we try to use the paths as-is to avoid requiring +// additional logic. +type SysContextPaths struct { + AdditionalTrustBundles []string + CertDir string + PerHostCertDir string + Proxy *configv1.ProxyStatus + PullSecret string + RegistryConfig string +} + +// Creates a temporary ControllerConfig which is used to build the SysContext +// object. +func (s *SysContextPaths) newControllerConfig() (*mcfgv1.ControllerConfig, error) { + cfg := &mcfgv1.ControllerConfig{} + + if s.Proxy != nil { + cfg.Spec.Proxy = s.Proxy + } + + // If we have additional trust bundles, then we need to merge its contents + // with the per-host certificates. + if s.PerHostCertDir != "" && len(s.AdditionalTrustBundles) > 0 { + perHostCerts, err := s.loadPerHostCertificates() + if err != nil { + return nil, fmt.Errorf("could not load certificates: %w", err) + } + + tb, err := newTrustBundleLoader().loadAll(s.AdditionalTrustBundles) + if err != nil { + return nil, fmt.Errorf("could not load additional trust bundles: %w", err) + } + + cfg.Spec.AdditionalTrustBundle = tb + cfg.Spec.ImageRegistryBundleData = perHostCerts + } + + return cfg, nil +} + +// Reads the pull secret from disk. Since we don't know whot format it will be +// in, we will process it into the format we need and use it later via our temp +// directory. +func (s *SysContextPaths) loadPullSecret() (*corev1.Secret, error) { + sb, err := os.ReadFile(s.PullSecret) + if err != nil { + return nil, fmt.Errorf("could not read pull secret: %w", err) + } + + rs, err := secrets.NewImageRegistrySecret(sb) + if err != nil { + return nil, fmt.Errorf("could not parse pull secret: %w", err) + } + + secret, err := rs.K8sSecret(corev1.SecretTypeDockerConfigJson) + if err != nil { + return nil, fmt.Errorf("could not convert pull secret: %w", err) + } + + return secret, nil +} + +// Loads per-host certificates into an ImageRegistryBundle. +func (s *SysContextPaths) loadPerHostCertificates() ([]mcfgv1.ImageRegistryBundle, error) { + certs := []mcfgv1.ImageRegistryBundle{} + + err := filepath.Walk(s.PerHostCertDir, func(path string, info fs.FileInfo, err error) error { + if err != nil { + return err + } + + if info.IsDir() { + return err + } + + fileBytes, err := os.ReadFile(path) + if err != nil { + return err + } + + certs = append(certs, mcfgv1.ImageRegistryBundle{Data: fileBytes, File: filepath.Base(path)}) + + return nil + }) + + return certs, err +} + +// Creates a SysContext instance from the supplied paths and configuration. +// This uses the SysContextBuilder to merge the additional trust bundles with +// the certificates and does most of its work in a tempdir. +func NewSysContextFromFilesystem(opts SysContextPaths) (*SysContext, error) { + sysCtxBuilder := NewSysContextBuilder() + + if opts.PullSecret != "" { + secret, err := opts.loadPullSecret() + if err != nil { + return nil, fmt.Errorf("could not load image pull secret: %w", err) + } + + sysCtxBuilder = sysCtxBuilder.WithSecret(secret) + } + + ctrlCfg, err := opts.newControllerConfig() + if err != nil { + return nil, fmt.Errorf("could not create new controllerconfig: %w", err) + } + + sysCtxBuilder = sysCtxBuilder.WithControllerConfig(ctrlCfg) + + sysCtx, err := sysCtxBuilder.Build() + if err != nil { + return nil, err + } + + sysCtx.SysContext.SystemRegistriesConfPath = opts.RegistryConfig + + // If we have no additional trust bundle or the file is empty, then we should + // use the provided paths as-is since we did not merge the trust hundle with + // the per-host certificates. + if len(opts.AdditionalTrustBundles) == 0 || len(ctrlCfg.Spec.AdditionalTrustBundle) == 0 { + sysCtx.SysContext.DockerCertPath = opts.CertDir + sysCtx.SysContext.DockerPerHostCertDirPath = opts.PerHostCertDir + } + + return sysCtx, nil +} + +// Loads all of the trust bundles given, traversing directories as needed. +type trustBundleLoader struct{} + +func newTrustBundleLoader() *trustBundleLoader { + return &trustBundleLoader{} +} + +// loadAll loads and merges trust bundles from multiple file or directory paths. +func (t *trustBundleLoader) loadAll(paths []string) ([]byte, error) { + if len(paths) == 0 { + return nil, nil + } + + var mergedBundle []byte + + for _, path := range paths { + data, err := t.loadTrustBundleFromPath(path) + if err != nil { + return nil, err + } + + if len(data) > 0 { + mergedBundle = append(mergedBundle, data...) + mergedBundle = append(mergedBundle, '\n') + } + } + + return mergedBundle, nil +} + +// loadTrustBundleFromPath loads certificate data from a single file or directory path. +func (t *trustBundleLoader) loadTrustBundleFromPath(path string) ([]byte, error) { + info, err := os.Stat(path) + if err != nil { + return nil, fmt.Errorf("could not stat path %q: %w", path, err) + } + + if info.IsDir() { + return t.loadTrustBundleFromDirectory(path) + } + + // It's a file - read it directly + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("could not read trust bundle file %q: %w", path, err) + } + + return data, nil +} + +// loadTrustBundleFromDirectory reads all certificate files from a directory (non-recursively). +func (t *trustBundleLoader) loadTrustBundleFromDirectory(dirPath string) ([]byte, error) { + var certBundle []byte + + err := filepath.Walk(dirPath, func(path string, info fs.FileInfo, err error) error { + if err != nil { + return err + } + + // Skip the root directory itself + if path == dirPath { + return nil + } + + // Skip subdirectories (non-recursive, like loadPerHostCertificates) + if info.IsDir() { + return filepath.SkipDir + } + + // Read the certificate file + fileBytes, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("could not read file %q: %w", path, err) + } + + // Append to bundle + certBundle = append(certBundle, fileBytes...) + certBundle = append(certBundle, '\n') + + return nil + }) + + if err != nil { + return nil, fmt.Errorf("could not walk directory %q: %w", dirPath, err) + } + + return certBundle, nil +} diff --git a/pkg/imageutils/sys_context_fs_test.go b/pkg/imageutils/sys_context_fs_test.go new file mode 100644 index 0000000000..b5a9aa88e6 --- /dev/null +++ b/pkg/imageutils/sys_context_fs_test.go @@ -0,0 +1,701 @@ +package imageutils + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + configv1 "github.com/openshift/api/config/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + testDockerConfigJSON = `{"auths":{"registry.hostname.com": {"username": "user", "password": "s3kr1t", "auth": "s00pers3kr1t", "email": "user@hostname.com"}}}` + + testDockerConfig = `{"registry.hostname.com": {"username": "user", "password": "s3kr1t", "auth": "s00pers3kr1t", "email": "user@hostname.com"}}` + + testCert1 = `-----BEGIN CERTIFICATE----- +MIICljCCAX4CCQCKz8Vz4VR5+jANBgkqhkiG9w0BAQsFADANMQswCQYDVQQGEwJV +UzAeFw0yMDAxMDEwMDAwMDBaFw0zMDAxMDEwMDAwMDBaMA0xCzAJBgNVBAYTAlVT +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuOSW8w== +-----END CERTIFICATE-----` + + testCert2 = `-----BEGIN CERTIFICATE----- +MIIDXTCCAkWgAwIBAgIJANXKLQzOJAoiMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV +BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX +aWRnaXRzIFB0eSBMdGQwHhcNMjAwMTAxMDAwMDAwWhcNMzAwMTAxMDAwMDAwWjBF +MQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50 +-----END CERTIFICATE-----` + + testRegistriesConf = `unqualified-search-registries = ["registry.access.redhat.com"]` +) + +func TestNewSysContextFromFilesystem_Success(t *testing.T) { + testCases := []struct { + name string + setupPaths func(t *testing.T, tmpDir string) SysContextPaths + expectTempDir bool + expectAuthFile bool + expectCerts bool + expectProxy bool + expectRegistries bool + }{ + { + name: "Empty SysContextPaths", + setupPaths: func(t *testing.T, tmpDir string) SysContextPaths { + return SysContextPaths{} + }, + expectTempDir: false, + }, + { + name: "Only PullSecret", + setupPaths: func(t *testing.T, tmpDir string) SysContextPaths { + return SysContextPaths{ + PullSecret: createPullSecretFile(t, tmpDir, testDockerConfigJSON), + } + }, + expectTempDir: true, + expectAuthFile: true, + }, + { + name: "Only AdditionalTrustBundle", + setupPaths: func(t *testing.T, tmpDir string) SysContextPaths { + return SysContextPaths{ + AdditionalTrustBundles: []string{createAdditionalTrustBundleFile(t, tmpDir, testCert1)}, + } + }, + expectTempDir: false, // No temp dir without pull secret or merged certs + }, + { + name: "Only PerHostCertificates", + setupPaths: func(t *testing.T, tmpDir string) SysContextPaths { + certs := map[string]string{ + "registry1.example.com.crt": testCert1, + } + return SysContextPaths{ + PerHostCertDir: createPerHostCertificatesDir(t, tmpDir, certs), + } + }, + expectTempDir: false, + expectCerts: true, + }, + { + name: "Only Proxy", + setupPaths: func(t *testing.T, tmpDir string) SysContextPaths { + return SysContextPaths{ + Proxy: &configv1.ProxyStatus{ + HTTPSProxy: "https://proxy.example.com:3128", + }, + } + }, + expectTempDir: false, + expectProxy: true, + }, + { + name: "Only RegistryConfig", + setupPaths: func(t *testing.T, tmpDir string) SysContextPaths { + return SysContextPaths{ + RegistryConfig: createRegistryConfigFile(t, tmpDir), + } + }, + expectTempDir: false, + expectRegistries: true, + }, + { + name: "All fields populated", + setupPaths: func(t *testing.T, tmpDir string) SysContextPaths { + certs := map[string]string{ + "registry1.example.com.crt": testCert1, + } + return SysContextPaths{ + PullSecret: createPullSecretFile(t, tmpDir, testDockerConfigJSON), + AdditionalTrustBundles: []string{createAdditionalTrustBundleFile(t, tmpDir, testCert2)}, + PerHostCertDir: createPerHostCertificatesDir(t, tmpDir, certs), + Proxy: &configv1.ProxyStatus{ + HTTPSProxy: "https://proxy.example.com:3128", + }, + RegistryConfig: createRegistryConfigFile(t, tmpDir), + } + }, + expectTempDir: true, + expectAuthFile: true, + expectCerts: true, + expectProxy: true, + expectRegistries: true, + }, + { + name: "PullSecret + Proxy", + setupPaths: func(t *testing.T, tmpDir string) SysContextPaths { + return SysContextPaths{ + PullSecret: createPullSecretFile(t, tmpDir, testDockerConfigJSON), + Proxy: &configv1.ProxyStatus{ + HTTPProxy: "http://proxy.example.com:8080", + }, + } + }, + expectTempDir: true, + expectAuthFile: true, + expectProxy: true, + }, + { + name: "AdditionalTrustBundle + PerHostCertificates (triggers merging)", + setupPaths: func(t *testing.T, tmpDir string) SysContextPaths { + certs := map[string]string{ + "registry1.example.com.crt": testCert1, + } + return SysContextPaths{ + AdditionalTrustBundles: []string{createAdditionalTrustBundleFile(t, tmpDir, testCert2)}, + PerHostCertDir: createPerHostCertificatesDir(t, tmpDir, certs), + } + }, + expectTempDir: true, + expectCerts: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + tmpDir := t.TempDir() + paths := tc.setupPaths(t, tmpDir) + + sysCtx, err := NewSysContextFromFilesystem(paths) + require.NoError(t, err, "NewSysContextFromFilesystem should not fail") + require.NotNil(t, sysCtx, "SysContext wrapper should not be nil") + require.NotNil(t, sysCtx.SysContext, "Underlying SystemContext should not be nil") + + // Check temp dir expectations + if tc.expectTempDir { + assert.NotEmpty(t, sysCtx.temporalDir, "Temporal directory should not be empty") + assert.DirExists(t, sysCtx.temporalDir, "Temporal directory should exist") + } else { + assert.Empty(t, sysCtx.temporalDir, "Temporal directory should be empty") + } + + // Check auth file expectations + if tc.expectAuthFile { + assert.NotEmpty(t, sysCtx.SysContext.AuthFilePath, "AuthFilePath should not be empty") + assert.FileExists(t, sysCtx.SysContext.AuthFilePath, "AuthFile should exist") + } + + // Check certs expectations + if tc.expectCerts { + // Either DockerPerHostCertDirPath or DockerCertPath should be set + hasPerHostCerts := sysCtx.SysContext.DockerPerHostCertDirPath != "" + hasCertPath := sysCtx.SysContext.DockerCertPath != "" + assert.True(t, hasPerHostCerts || hasCertPath, "Either DockerPerHostCertDirPath or DockerCertPath should be set") + } + + // Check proxy expectations + if tc.expectProxy { + assert.NotNil(t, sysCtx.SysContext.DockerProxyURL, "DockerProxyURL should not be nil") + } else { + assert.Nil(t, sysCtx.SysContext.DockerProxyURL, "DockerProxyURL should be nil") + } + + // Check registry config expectations + if tc.expectRegistries { + assert.NotEmpty(t, sysCtx.SysContext.SystemRegistriesConfPath, "SystemRegistriesConfPath should not be empty") + assert.Equal(t, paths.RegistryConfig, sysCtx.SysContext.SystemRegistriesConfPath, "SystemRegistriesConfPath should match provided path") + } + + // Cleanup + err = sysCtx.Cleanup() + require.NoError(t, err, "Cleanup should not fail") + + // Verify cleanup removed temp directory if it existed + if tc.expectTempDir { + assert.NoDirExists(t, sysCtx.temporalDir, "Temporal directory should be removed after cleanup") + } + }) + } +} + +func TestNewSysContextFromFilesystem_CertificateMerging(t *testing.T) { + testCases := []struct { + name string + setupPaths func(t *testing.T, tmpDir string) SysContextPaths + expectMerged bool + expectDockerCertPath bool + expectPerHostCertPath bool + expectMergedBundleFile bool + }{ + { + name: "Both AdditionalTrustBundle AND PerHostCertificates present - expect merged", + setupPaths: func(t *testing.T, tmpDir string) SysContextPaths { + certs := map[string]string{ + "registry1.example.com.crt": testCert1, + "registry2.example.com.crt": testCert2, + } + return SysContextPaths{ + AdditionalTrustBundles: []string{createAdditionalTrustBundleFile(t, tmpDir, testCert1)}, + PerHostCertDir: createPerHostCertificatesDir(t, tmpDir, certs), + } + }, + expectMerged: true, + expectDockerCertPath: true, + expectPerHostCertPath: true, + expectMergedBundleFile: true, + }, + { + name: "Only AdditionalTrustBundle (no PerHostCertificates) - no merge", + setupPaths: func(t *testing.T, tmpDir string) SysContextPaths { + return SysContextPaths{ + AdditionalTrustBundles: []string{createAdditionalTrustBundleFile(t, tmpDir, testCert1)}, + CertDir: filepath.Join(tmpDir, "certs"), + } + }, + expectMerged: false, + expectDockerCertPath: true, + expectPerHostCertPath: false, + }, + { + name: "Only PerHostCertificates (no AdditionalTrustBundle) - no merge", + setupPaths: func(t *testing.T, tmpDir string) SysContextPaths { + certs := map[string]string{ + "registry1.example.com.crt": testCert1, + } + return SysContextPaths{ + PerHostCertDir: createPerHostCertificatesDir(t, tmpDir, certs), + } + }, + expectMerged: false, + expectDockerCertPath: false, + expectPerHostCertPath: true, + }, + { + name: "Empty AdditionalTrustBundle file with PerHostCertificates - no merge", + setupPaths: func(t *testing.T, tmpDir string) SysContextPaths { + certs := map[string]string{ + "registry1.example.com.crt": testCert1, + } + return SysContextPaths{ + AdditionalTrustBundles: []string{createAdditionalTrustBundleFile(t, tmpDir, "")}, + PerHostCertDir: createPerHostCertificatesDir(t, tmpDir, certs), + } + }, + expectMerged: false, + expectDockerCertPath: false, + expectPerHostCertPath: true, + }, + { + name: "Multiple per-host certificates with AdditionalTrustBundle - merged", + setupPaths: func(t *testing.T, tmpDir string) SysContextPaths { + certs := map[string]string{ + "registry1.example.com.crt": testCert1, + "registry2.example.com.crt": testCert2, + "registry3.example.com.crt": testCert1, + } + return SysContextPaths{ + AdditionalTrustBundles: []string{createAdditionalTrustBundleFile(t, tmpDir, testCert2)}, + PerHostCertDir: createPerHostCertificatesDir(t, tmpDir, certs), + } + }, + expectMerged: true, + expectDockerCertPath: true, + expectPerHostCertPath: true, + expectMergedBundleFile: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + tmpDir := t.TempDir() + paths := tc.setupPaths(t, tmpDir) + + sysCtx, err := NewSysContextFromFilesystem(paths) + require.NoError(t, err, "NewSysContextFromFilesystem should not fail") + require.NotNil(t, sysCtx, "SysContext wrapper should not be nil") + + // Check DockerCertPath + if tc.expectDockerCertPath { + assert.NotEmpty(t, sysCtx.SysContext.DockerCertPath, "DockerCertPath should not be empty") + if tc.expectMerged { + assert.DirExists(t, sysCtx.SysContext.DockerCertPath, "DockerCertPath should point to a directory when merged") + } + } else { + if paths.CertDir != "" { + assert.Equal(t, paths.CertDir, sysCtx.SysContext.DockerCertPath, "DockerCertPath should match provided Certificates path") + } else { + assert.Empty(t, sysCtx.SysContext.DockerCertPath, "DockerCertPath should be empty") + } + } + + // Check DockerPerHostCertDirPath + if tc.expectPerHostCertPath { + assert.NotEmpty(t, sysCtx.SysContext.DockerPerHostCertDirPath, "DockerPerHostCertDirPath should not be empty") + if tc.expectMerged { + assert.DirExists(t, sysCtx.SysContext.DockerPerHostCertDirPath, "DockerPerHostCertDirPath should exist when merged") + } else { + assert.Equal(t, paths.PerHostCertDir, sysCtx.SysContext.DockerPerHostCertDirPath, "DockerPerHostCertDirPath should match provided path") + } + } else { + if paths.PerHostCertDir == "" { + assert.Empty(t, sysCtx.SysContext.DockerPerHostCertDirPath, "DockerPerHostCertDirPath should be empty") + } + } + + // Check for merged bundle file + if tc.expectMergedBundleFile { + mergedBundlePath := filepath.Join(sysCtx.SysContext.DockerCertPath, "ca-bundle.crt") + assert.FileExists(t, mergedBundlePath, "Merged bundle file should exist") + + // Read and verify merged content contains certificates + content, err := os.ReadFile(mergedBundlePath) + require.NoError(t, err, "Should be able to read merged bundle file") + assert.NotEmpty(t, content, "Merged bundle should not be empty") + assert.Contains(t, string(content), "BEGIN CERTIFICATE", "Merged bundle should contain certificates") + } + + // Cleanup + err = sysCtx.Cleanup() + require.NoError(t, err, "Cleanup should not fail") + }) + } +} + +func TestNewSysContextFromFilesystem_ErrorCases(t *testing.T) { + testCases := []struct { + name string + setupPaths func(t *testing.T, tmpDir string) SysContextPaths + expectError string + }{ + { + name: "Non-existent pull secret file", + setupPaths: func(t *testing.T, tmpDir string) SysContextPaths { + return SysContextPaths{ + PullSecret: filepath.Join(tmpDir, "nonexistent-pull-secret.json"), + } + }, + expectError: "could not load image pull secret", + }, + { + name: "Invalid JSON in pull secret file", + setupPaths: func(t *testing.T, tmpDir string) SysContextPaths { + secretPath := filepath.Join(tmpDir, "invalid-pull-secret.json") + err := os.WriteFile(secretPath, []byte(`{invalid json`), 0644) + require.NoError(t, err) + return SysContextPaths{ + PullSecret: secretPath, + } + }, + expectError: "could not load image pull secret", + }, + { + name: "Empty pull secret file", + setupPaths: func(t *testing.T, tmpDir string) SysContextPaths { + secretPath := filepath.Join(tmpDir, "empty-pull-secret.json") + err := os.WriteFile(secretPath, []byte(""), 0644) + require.NoError(t, err) + return SysContextPaths{ + PullSecret: secretPath, + } + }, + expectError: "could not load image pull secret", + }, + { + name: "Pull secret with null value", + setupPaths: func(t *testing.T, tmpDir string) SysContextPaths { + secretPath := filepath.Join(tmpDir, "null-pull-secret.json") + err := os.WriteFile(secretPath, []byte(`null`), 0644) + require.NoError(t, err) + return SysContextPaths{ + PullSecret: secretPath, + } + }, + expectError: "could not load image pull secret", + }, + { + name: "Non-existent AdditionalTrustBundle file (when PerHostCertificates also set)", + setupPaths: func(t *testing.T, tmpDir string) SysContextPaths { + certs := map[string]string{ + "registry1.example.com.crt": testCert1, + } + return SysContextPaths{ + AdditionalTrustBundles: []string{filepath.Join(tmpDir, "nonexistent-trust-bundle.pem")}, + PerHostCertDir: createPerHostCertificatesDir(t, tmpDir, certs), + } + }, + expectError: "could not create new controllerconfig", + }, + { + name: "Non-existent PerHostCertificates directory (when AdditionalTrustBundle also set)", + setupPaths: func(t *testing.T, tmpDir string) SysContextPaths { + return SysContextPaths{ + AdditionalTrustBundles: []string{createAdditionalTrustBundleFile(t, tmpDir, testCert1)}, + PerHostCertDir: filepath.Join(tmpDir, "nonexistent-certs-dir"), + } + }, + expectError: "could not create new controllerconfig", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + tmpDir := t.TempDir() + paths := tc.setupPaths(t, tmpDir) + + sysCtx, err := NewSysContextFromFilesystem(paths) + require.Error(t, err, "NewSysContextFromFilesystem should fail") + assert.Contains(t, err.Error(), tc.expectError, "Error message should contain expected text") + assert.Nil(t, sysCtx, "SysContext should be nil on error") + }) + } +} + +func TestNewSysContextFromFilesystem_PullSecretFormats(t *testing.T) { + testCases := []struct { + name string + secretFormat string + }{ + { + name: "DockerConfigJSON format (modern)", + secretFormat: testDockerConfigJSON, + }, + { + name: "DockerConfig format (legacy)", + secretFormat: testDockerConfig, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + tmpDir := t.TempDir() + paths := SysContextPaths{ + PullSecret: createPullSecretFile(t, tmpDir, tc.secretFormat), + } + + sysCtx, err := NewSysContextFromFilesystem(paths) + require.NoError(t, err, "NewSysContextFromFilesystem should not fail") + require.NotNil(t, sysCtx, "SysContext wrapper should not be nil") + + // Auth file should be valid and exist + assert.NotEmpty(t, sysCtx.SysContext.AuthFilePath, "AuthFilePath should not be empty") + assert.FileExists(t, sysCtx.SysContext.AuthFilePath, "AuthFile should exist") + + // Read and verify auth file is valid JSON with "auths" key + authContent, err := os.ReadFile(sysCtx.SysContext.AuthFilePath) + require.NoError(t, err, "Should be able to read auth file") + + var authData map[string]interface{} + err = json.Unmarshal(authContent, &authData) + require.NoError(t, err, "Auth file should be valid JSON") + assert.Contains(t, authData, "auths", "Auth file should have normalized 'auths' key") + + // Cleanup + err = sysCtx.Cleanup() + require.NoError(t, err, "Cleanup should not fail") + }) + } +} + +func TestNewSysContextFromFilesystem_Cleanup(t *testing.T) { + testCases := []struct { + name string + setupPaths func(t *testing.T, tmpDir string) SysContextPaths + }{ + { + name: "Cleanup with pull secret", + setupPaths: func(t *testing.T, tmpDir string) SysContextPaths { + return SysContextPaths{ + PullSecret: createPullSecretFile(t, tmpDir, testDockerConfigJSON), + } + }, + }, + { + name: "Cleanup with merged certificates", + setupPaths: func(t *testing.T, tmpDir string) SysContextPaths { + certs := map[string]string{ + "registry1.example.com.crt": testCert1, + } + return SysContextPaths{ + AdditionalTrustBundles: []string{createAdditionalTrustBundleFile(t, tmpDir, testCert2)}, + PerHostCertDir: createPerHostCertificatesDir(t, tmpDir, certs), + } + }, + }, + { + name: "Cleanup with all configurations", + setupPaths: func(t *testing.T, tmpDir string) SysContextPaths { + certs := map[string]string{ + "registry1.example.com.crt": testCert1, + } + return SysContextPaths{ + PullSecret: createPullSecretFile(t, tmpDir, testDockerConfigJSON), + AdditionalTrustBundles: []string{createAdditionalTrustBundleFile(t, tmpDir, testCert2)}, + PerHostCertDir: createPerHostCertificatesDir(t, tmpDir, certs), + Proxy: &configv1.ProxyStatus{ + HTTPSProxy: "https://proxy.example.com:3128", + }, + RegistryConfig: createRegistryConfigFile(t, tmpDir), + } + }, + }, + { + name: "Cleanup with no temp directory", + setupPaths: func(t *testing.T, tmpDir string) SysContextPaths { + return SysContextPaths{ + Proxy: &configv1.ProxyStatus{ + HTTPSProxy: "https://proxy.example.com:3128", + }, + } + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + tmpDir := t.TempDir() + paths := tc.setupPaths(t, tmpDir) + + sysCtx, err := NewSysContextFromFilesystem(paths) + require.NoError(t, err, "NewSysContextFromFilesystem should not fail") + + tempDir := sysCtx.temporalDir + + // First cleanup + err = sysCtx.Cleanup() + require.NoError(t, err, "First cleanup should not fail") + + // Verify temp directory is removed if it existed + if tempDir != "" { + assert.NoDirExists(t, tempDir, "Temp directory should be removed after cleanup") + } + + // Second cleanup (idempotent - should not error) + err = sysCtx.Cleanup() + require.NoError(t, err, "Second cleanup should not fail (idempotent)") + }) + } +} + +func TestNewSysContextFromFilesystem_EdgeCases(t *testing.T) { + t.Run("Empty PerHostCertificates directory", func(t *testing.T) { + tmpDir := t.TempDir() + emptyDir := filepath.Join(tmpDir, "empty-certs") + err := os.MkdirAll(emptyDir, 0755) + require.NoError(t, err) + + paths := SysContextPaths{ + PerHostCertDir: emptyDir, + } + + sysCtx, err := NewSysContextFromFilesystem(paths) + require.NoError(t, err, "Should succeed with empty directory") + require.NotNil(t, sysCtx, "SysContext should not be nil") + assert.Equal(t, emptyDir, sysCtx.SysContext.DockerPerHostCertDirPath, "DockerPerHostCertDirPath should match provided path") + + err = sysCtx.Cleanup() + require.NoError(t, err, "Cleanup should not fail") + }) + + t.Run("Nested certificate directory structure", func(t *testing.T) { + tmpDir := t.TempDir() + certs := map[string]string{ + "subdir1/registry1.example.com.crt": testCert1, + "subdir1/registry2.example.com.crt": testCert2, + "subdir2/registry3.example.com.crt": testCert1, + } + certsDir := createPerHostCertificatesDir(t, tmpDir, certs) + + paths := SysContextPaths{ + AdditionalTrustBundles: []string{createAdditionalTrustBundleFile(t, tmpDir, testCert2)}, + PerHostCertDir: certsDir, + } + + sysCtx, err := NewSysContextFromFilesystem(paths) + require.NoError(t, err, "Should handle nested directories") + require.NotNil(t, sysCtx, "SysContext should not be nil") + + // Verify per-host cert dir is set and exists + assert.NotEmpty(t, sysCtx.SysContext.DockerPerHostCertDirPath, "DockerPerHostCertDirPath should be set") + assert.DirExists(t, sysCtx.SysContext.DockerPerHostCertDirPath, "DockerPerHostCertDirPath should exist") + + err = sysCtx.Cleanup() + require.NoError(t, err, "Cleanup should not fail") + }) + + t.Run("Both HTTP and HTTPS proxy set - HTTPS should take precedence", func(t *testing.T) { + paths := SysContextPaths{ + Proxy: &configv1.ProxyStatus{ + HTTPProxy: "http://http-proxy.example.com:8080", + HTTPSProxy: "https://https-proxy.example.com:3128", + }, + } + + sysCtx, err := NewSysContextFromFilesystem(paths) + require.NoError(t, err, "Should not fail") + require.NotNil(t, sysCtx, "SysContext should not be nil") + require.NotNil(t, sysCtx.SysContext.DockerProxyURL, "DockerProxyURL should not be nil") + + assert.Equal(t, "https", sysCtx.SysContext.DockerProxyURL.Scheme, "HTTPS proxy should take precedence") + assert.Equal(t, "https-proxy.example.com:3128", sysCtx.SysContext.DockerProxyURL.Host, "Proxy host should match HTTPS proxy") + + err = sysCtx.Cleanup() + require.NoError(t, err, "Cleanup should not fail") + }) + + t.Run("Registry config path verification", func(t *testing.T) { + tmpDir := t.TempDir() + registryConfigPath := createRegistryConfigFile(t, tmpDir) + + paths := SysContextPaths{ + RegistryConfig: registryConfigPath, + } + + sysCtx, err := NewSysContextFromFilesystem(paths) + require.NoError(t, err, "Should not fail") + require.NotNil(t, sysCtx, "SysContext should not be nil") + + assert.Equal(t, registryConfigPath, sysCtx.SysContext.SystemRegistriesConfPath, "SystemRegistriesConfPath should match provided path") + + err = sysCtx.Cleanup() + require.NoError(t, err, "Cleanup should not fail") + }) +} + +func createPullSecretFile(t *testing.T, dir string, content string) string { + t.Helper() + secretPath := filepath.Join(dir, "pull-secret.json") + err := os.WriteFile(secretPath, []byte(content), 0644) + require.NoError(t, err, "Failed to create pull secret file") + return secretPath +} + +func createAdditionalTrustBundleFile(t *testing.T, dir string, content string) string { + t.Helper() + bundlePath := filepath.Join(dir, "additional-trust-bundle.pem") + err := os.WriteFile(bundlePath, []byte(content), 0644) + require.NoError(t, err, "Failed to create additional trust bundle file") + return bundlePath +} + +func createPerHostCertificatesDir(t *testing.T, dir string, certs map[string]string) string { + t.Helper() + certsDir := filepath.Join(dir, "per-host-certs") + err := os.MkdirAll(certsDir, 0755) + require.NoError(t, err, "Failed to create per-host certificates directory") + + for filename, content := range certs { + certPath := filepath.Join(certsDir, filename) + // Handle nested directories + certDir := filepath.Dir(certPath) + if certDir != certsDir { + err := os.MkdirAll(certDir, 0755) + require.NoError(t, err, "Failed to create nested certificate directory") + } + err := os.WriteFile(certPath, []byte(content), 0644) + require.NoError(t, err, "Failed to create certificate file") + } + + return certsDir +} + +func createRegistryConfigFile(t *testing.T, dir string) string { + t.Helper() + configPath := filepath.Join(dir, "registries.conf") + err := os.WriteFile(configPath, []byte(testRegistriesConf), 0644) + require.NoError(t, err, "Failed to create registry config file") + return configPath +} diff --git a/pkg/imageutils/sys_context_test.go b/pkg/imageutils/sys_context_test.go index 801fb6d287..86e8f3cd7e 100644 --- a/pkg/imageutils/sys_context_test.go +++ b/pkg/imageutils/sys_context_test.go @@ -108,15 +108,15 @@ MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuOSW8w== func TestSysContextBuilder(t *testing.T) { testCases := []struct { - name string - secret *corev1.Secret - controllerConfig *mcfgv1.ControllerConfig - registriesConfig *sysregistriesv2.V2RegistriesConf - expectTempDir bool - expectAuthFile bool - expectCerts bool - expectProxy bool - expectRegistries bool + name string + secret *corev1.Secret + controllerConfig *mcfgv1.ControllerConfig + registriesConfig *sysregistriesv2.V2RegistriesConf + expectTempDir bool + expectAuthFile bool + expectCerts bool + expectProxy bool + expectRegistries bool }{ { name: "Empty context - no options", diff --git a/pkg/secrets/secrets_test.go b/pkg/secrets/secrets_test.go index 7537e73695..b830942207 100644 --- a/pkg/secrets/secrets_test.go +++ b/pkg/secrets/secrets_test.go @@ -86,6 +86,62 @@ metadata: creationTimestamp: type: kubernetes.io/dockercfg` + k8sDockerConfigJsonSecretMissingKind := `apiVersion: v1 +data: + .dockerconfigjson: eyJhdXRocyI6eyJyZWdpc3RyeS5ob3N0bmFtZS5jb20iOnsidXNlcm5hbWUiOiJ1c2VyIiwiZW1haWwiOiJ1c2VyQGhvc3RuYW1lLmNvbSIsImF1dGgiOiJzMDBwZXJzM2tyMXQifX19 +metadata: + name: secret + # This is needed + creationTimestamp: +type: kubernetes.io/dockerconfigjson` + + k8sDockerConfigYAMLBytesMissingKind := []byte(k8sDockerConfigJsonSecretMissingKind) + + k8sDockerConfigJSONBytesMissingKind, err := yaml.YAMLToJSON(k8sDockerConfigYAMLBytesMissingKind) + require.NoError(t, err) + + k8sDockercfgSecretMissingKind := `apiVersion: v1 +data: + .dockercfg: eyJyZWdpc3RyeS5ob3N0bmFtZS5jb20iOnsidXNlcm5hbWUiOiJ1c2VyIiwiZW1haWwiOiJ1c2VyQGhvc3RuYW1lLmNvbSIsImF1dGgiOiJzMDBwZXJzM2tyMXQifX0= +metadata: + name: secret + # This is needed + creationTimestamp: +type: kubernetes.io/dockercfg` + + k8sDockercfgYAMLBytesMissingKind := []byte(k8sDockercfgSecretMissingKind) + + k8sDockercfgJSONBytesMissingKind, err := yaml.YAMLToJSON(k8sDockercfgYAMLBytesMissingKind) + require.NoError(t, err) + + k8sDockerConfigJsonSecretMissingAPIVersion := `kind: Secret +data: + .dockerconfigjson: eyJhdXRocyI6eyJyZWdpc3RyeS5ob3N0bmFtZS5jb20iOnsidXNlcm5hbWUiOiJ1c2VyIiwiZW1haWwiOiJ1c2VyQGhvc3RuYW1lLmNvbSIsImF1dGgiOiJzMDBwZXJzM2tyMXQifX19 +metadata: + name: secret + # This is needed + creationTimestamp: +type: kubernetes.io/dockerconfigjson` + + k8sDockerConfigYAMLBytesMissingAPIVersion := []byte(k8sDockerConfigJsonSecretMissingAPIVersion) + + k8sDockerConfigJSONBytesMissingAPIVersion, err := yaml.YAMLToJSON(k8sDockerConfigYAMLBytesMissingAPIVersion) + require.NoError(t, err) + + k8sDockercfgSecretMissingAPIVersion := `kind: Secret +data: + .dockercfg: eyJyZWdpc3RyeS5ob3N0bmFtZS5jb20iOnsidXNlcm5hbWUiOiJ1c2VyIiwiZW1haWwiOiJ1c2VyQGhvc3RuYW1lLmNvbSIsImF1dGgiOiJzMDBwZXJzM2tyMXQifX0= +metadata: + name: secret + # This is needed + creationTimestamp: +type: kubernetes.io/dockercfg` + + k8sDockercfgYAMLBytesMissingAPIVersion := []byte(k8sDockercfgSecretMissingAPIVersion) + + k8sDockercfgJSONBytesMissingAPIVersion, err := yaml.YAMLToJSON(k8sDockercfgYAMLBytesMissingAPIVersion) + require.NoError(t, err) + cfg := DockerConfigJSON{ Auths: map[string]DockerConfigEntry{ "registry.hostname.com": DockerConfigEntry{ @@ -194,6 +250,46 @@ type: kubernetes.io/dockercfg` bytes: []byte(`{"kind":"ConfigMap","apiVersion":"v1","metadata":{"name":"configmap"},"data":{"key":"value"}}`), errExpected: true, }, + { + name: "K8s DockerConfigJSON secret YAML missing kind", + bytes: k8sDockerConfigYAMLBytesMissingKind, + errExpected: true, + }, + { + name: "K8s DockerConfigJSON secret JSON missing kind", + bytes: k8sDockerConfigJSONBytesMissingKind, + errExpected: true, + }, + { + name: "K8s Dockercfg secret YAML missing kind", + bytes: k8sDockercfgYAMLBytesMissingKind, + errExpected: true, + }, + { + name: "K8s Dockercfg secret JSON missing kind", + bytes: k8sDockercfgJSONBytesMissingKind, + errExpected: true, + }, + { + name: "K8s DockerConfigJSON secret YAML missing API version", + bytes: k8sDockerConfigYAMLBytesMissingAPIVersion, + errExpected: true, + }, + { + name: "K8s DockerConfigJSON secret JSON missing API version", + bytes: k8sDockerConfigJSONBytesMissingAPIVersion, + errExpected: true, + }, + { + name: "K8s Dockercfg secret YAML missing API version", + bytes: k8sDockercfgYAMLBytesMissingAPIVersion, + errExpected: true, + }, + { + name: "K8s Dockercfg secret JSON missing API version", + bytes: k8sDockercfgJSONBytesMissingAPIVersion, + errExpected: true, + }, } for _, testCase := range testCases {