diff --git a/data/data/manifests/bootkube/internal-release-image-registry-auth-secret.yaml.template b/data/data/manifests/bootkube/internal-release-image-registry-auth-secret.yaml.template new file mode 100644 index 00000000000..e64f4d3d892 --- /dev/null +++ b/data/data/manifests/bootkube/internal-release-image-registry-auth-secret.yaml.template @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Secret +metadata: + name: internal-release-image-registry-auth + namespace: openshift-machine-config-operator + annotations: + openshift.io/description: Secret containing the InternalReleaseImage registry authentication credentials + openshift.io/owning-component: Machine Config Operator +type: Opaque +data: + htpasswd: {{.IriRegistryHtpasswd}} + password: {{.IriRegistryPassword}} diff --git a/pkg/asset/ignition/bootstrap/common.go b/pkg/asset/ignition/bootstrap/common.go index 1ea2d88994f..a04b9e88ead 100644 --- a/pkg/asset/ignition/bootstrap/common.go +++ b/pkg/asset/ignition/bootstrap/common.go @@ -3,6 +3,7 @@ package bootstrap import ( "bytes" "crypto/x509" + "encoding/base64" "encoding/json" "encoding/pem" "fmt" @@ -169,6 +170,7 @@ func (a *Common) Dependencies() []asset.Asset { &tls.KubeletServingCABundle{}, &tls.MCSCertKey{}, &tls.IRICertKey{}, + &tls.IRIRegistryAuth{}, &tls.RootCA{}, &tls.ServiceAccountKeyPair{}, &tls.IronicTLSCert{}, @@ -378,10 +380,27 @@ func (a *Common) getTemplateData(dependencies asset.Parents, bootstrapInPlace bo openshiftInstallInvoker := os.Getenv("OPENSHIFT_INSTALL_INVOKER") + pullSecret := installConfig.Config.PullSecret + + // Merge IRI registry credentials into pull secret if available. + // This ensures kubelet/CRI-O on bootstrap and cluster nodes can + // authenticate to the IRI registry on master nodes. + iriAuth := &tls.IRIRegistryAuth{} + dependencies.Get(iriAuth) + if iriAuth.Password != "" { + iriRegistryHost := fmt.Sprintf("api-int.%s:22625", installConfig.Config.ClusterDomain()) + merged, err := mergeIRIAuthIntoPullSecret(pullSecret, iriAuth.Username, iriAuth.Password, iriRegistryHost) + if err != nil { + logrus.Warnf("Failed to merge IRI registry credentials into pull secret: %v", err) + } else { + pullSecret = merged + } + } + return &bootstrapTemplateData{ AdditionalTrustBundle: installConfig.Config.AdditionalTrustBundle, FIPS: installConfig.Config.FIPS, - PullSecret: installConfig.Config.PullSecret, + PullSecret: pullSecret, SSHKey: installConfig.Config.SSHKey, ReleaseImage: releaseImage.PullSpec, EtcdCluster: strings.Join(etcdEndpoints, ","), @@ -404,6 +423,31 @@ func (a *Common) getTemplateData(dependencies asset.Parents, bootstrapInPlace bo } } +// mergeIRIAuthIntoPullSecret merges IRI registry authentication credentials +// into the pull secret so that kubelet/CRI-O can authenticate to the IRI registry. +func mergeIRIAuthIntoPullSecret(pullSecret, username, password, registryHost string) (string, error) { + var pullSecretMap map[string]interface{} + if err := json.Unmarshal([]byte(pullSecret), &pullSecretMap); err != nil { + return "", fmt.Errorf("failed to parse pull secret: %w", err) + } + + auths, ok := pullSecretMap["auths"].(map[string]interface{}) + if !ok { + return "", fmt.Errorf("pull secret missing 'auths' field") + } + + authValue := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", username, password))) + auths[registryHost] = map[string]interface{}{ + "auth": authValue, + } + + mergedBytes, err := json.Marshal(pullSecretMap) + if err != nil { + return "", fmt.Errorf("failed to marshal merged pull secret: %w", err) + } + return string(mergedBytes), nil +} + // AddStorageFiles adds files to a Ignition config. // Parameters: // config - the ignition config to be modified @@ -672,6 +716,7 @@ func (a *Common) addParentFiles(dependencies asset.Parents) { &tls.KubeletServingCABundle{}, &tls.MCSCertKey{}, &tls.IRICertKey{}, + &tls.IRIRegistryAuth{}, &tls.ServiceAccountKeyPair{}, &tls.JournalCertKey{}, &tls.IronicTLSCert{}, diff --git a/pkg/asset/manifests/operators.go b/pkg/asset/manifests/operators.go index 9e35458da76..022c56728ce 100644 --- a/pkg/asset/manifests/operators.go +++ b/pkg/asset/manifests/operators.go @@ -78,6 +78,7 @@ func (m *Manifests) Dependencies() []asset.Asset { &tls.RootCA{}, &tls.MCSCertKey{}, &tls.IRICertKey{}, + &tls.IRIRegistryAuth{}, &manifests.InternalReleaseImage{}, new(rhcos.Image), @@ -89,6 +90,7 @@ func (m *Manifests) Dependencies() []asset.Asset { &bootkube.MachineConfigServerTLSSecret{}, &bootkube.OpenshiftConfigSecretPullSecret{}, &bootkube.InternalReleaseImageTLSSecret{}, + &bootkube.InternalReleaseImageRegistryAuthSecret{}, &BMCVerifyCAConfigMap{}, } } @@ -236,6 +238,7 @@ func (m *Manifests) generateBootKubeManifests(dependencies asset.Parents) []*ass // Skip if InternalReleaseImage manifest wasn't found. if len(iri.FileList) > 0 { files = append(files, appendIRIcerts(dependencies)) + files = append(files, appendIRIRegistryAuth(dependencies)) } } @@ -264,6 +267,29 @@ func appendIRIcerts(dependencies asset.Parents) *asset.File { } } +// appendIRIRegistryAuth renders the IRI registry auth secret template with the generated credentials. +func appendIRIRegistryAuth(dependencies asset.Parents) *asset.File { + iriAuth := &tls.IRIRegistryAuth{} + iriAuthSecret := &bootkube.InternalReleaseImageRegistryAuthSecret{} + dependencies.Get(iriAuth, iriAuthSecret) + + f := iriAuthSecret.Files()[0] + + templateData := struct { + IriRegistryHtpasswd string + IriRegistryPassword string + }{ + IriRegistryHtpasswd: base64.StdEncoding.EncodeToString([]byte(iriAuth.HtpasswdContent)), + IriRegistryPassword: base64.StdEncoding.EncodeToString([]byte(iriAuth.Password)), + } + fileData := applyTemplateData(f.Data, templateData) + + return &asset.File{ + Filename: path.Join(manifestDir, strings.TrimSuffix(filepath.Base(f.Filename), ".template")), + Data: fileData, + } +} + func applyTemplateData(data []byte, templateData interface{}) []byte { template := template.Must(template.New("template").Funcs(customTmplFuncs).Parse(string(data))) buf := &bytes.Buffer{} diff --git a/pkg/asset/store/assetcreate_test.go b/pkg/asset/store/assetcreate_test.go index 6ca84e28781..e1c5ead3384 100644 --- a/pkg/asset/store/assetcreate_test.go +++ b/pkg/asset/store/assetcreate_test.go @@ -116,15 +116,16 @@ func TestCreatedAssetsAreNotDirty(t *testing.T) { } emptyAssets := map[string]bool{ - "Arbiter Ignition Config": true, // no files for non arbiter cluster - "Arbiter Machines": true, // no files for the 'none' platform - "Master Machines": true, // no files for the 'none' platform - "Worker Machines": true, // no files for the 'none' platform - "Cluster API Manifests": true, // no files for the 'none' platform and ClusterAPIInstall feature gate not set - "Cluster API Machine Manifests": true, // no files for the 'none' platform and ClusterAPIInstall feature gate not set - "Metadata": true, // read-only - "Kubeadmin Password": true, // read-only - "InternalReleaseImageTLSSecret": true, // no files when NoRegistryClusterInstall feature gate is not set + "Arbiter Ignition Config": true, // no files for non arbiter cluster + "Arbiter Machines": true, // no files for the 'none' platform + "Master Machines": true, // no files for the 'none' platform + "Worker Machines": true, // no files for the 'none' platform + "Cluster API Manifests": true, // no files for the 'none' platform and ClusterAPIInstall feature gate not set + "Cluster API Machine Manifests": true, // no files for the 'none' platform and ClusterAPIInstall feature gate not set + "Metadata": true, // read-only + "Kubeadmin Password": true, // read-only + "InternalReleaseImageTLSSecret": true, // no files when NoRegistryClusterInstall feature gate is not set + "InternalReleaseImageRegistryAuthSecret": true, // no files when NoRegistryClusterInstall feature gate is not set } for _, a := range tc.targets { name := a.Name() diff --git a/pkg/asset/targets/targets.go b/pkg/asset/targets/targets.go index b1fc6775851..15d685ca86e 100644 --- a/pkg/asset/targets/targets.go +++ b/pkg/asset/targets/targets.go @@ -49,6 +49,7 @@ var ( &openshift.RoleCloudCredsSecretReader{}, &openshift.AzureCloudProviderSecret{}, &bootkube.InternalReleaseImageTLSSecret{}, + &bootkube.InternalReleaseImageRegistryAuthSecret{}, } // IgnitionConfigs are the ignition-configs targeted assets. diff --git a/pkg/asset/templates/content/bootkube/internal-release-image-registry-auth-secret.go b/pkg/asset/templates/content/bootkube/internal-release-image-registry-auth-secret.go new file mode 100644 index 00000000000..454b3a171d9 --- /dev/null +++ b/pkg/asset/templates/content/bootkube/internal-release-image-registry-auth-secret.go @@ -0,0 +1,85 @@ +package bootkube + +import ( + "context" + "os" + "path/filepath" + + "github.com/openshift/api/features" + "github.com/openshift/installer/pkg/asset" + "github.com/openshift/installer/pkg/asset/installconfig" + "github.com/openshift/installer/pkg/asset/templates/content" + "github.com/openshift/installer/pkg/asset/templates/content/manifests" +) + +const ( + internalReleaseImageRegistryAuthSecretFileName = "internal-release-image-registry-auth-secret.yaml.template" +) + +var _ asset.WritableAsset = (*InternalReleaseImageRegistryAuthSecret)(nil) + +// InternalReleaseImageRegistryAuthSecret is the constant to represent contents of internal-release-image-registry-auth-secret.yaml.template file. +type InternalReleaseImageRegistryAuthSecret struct { + FileList []*asset.File +} + +// Dependencies returns all of the dependencies directly needed by the asset. +func (t *InternalReleaseImageRegistryAuthSecret) Dependencies() []asset.Asset { + return []asset.Asset{ + &installconfig.InstallConfig{}, + &manifests.InternalReleaseImage{}, + } +} + +// Name returns the human-friendly name of the asset. +func (t *InternalReleaseImageRegistryAuthSecret) Name() string { + return "InternalReleaseImageRegistryAuthSecret" +} + +// Generate generates the actual files by this asset. +func (t *InternalReleaseImageRegistryAuthSecret) Generate(_ context.Context, dependencies asset.Parents) error { + installConfig := &installconfig.InstallConfig{} + iri := &manifests.InternalReleaseImage{} + + dependencies.Get(installConfig, iri) + + if !installConfig.Config.EnabledFeatureGates().Enabled(features.FeatureGateNoRegistryClusterInstall) { + return nil + } + + // Skip if InternalReleaseImage manifest wasn't found. + if len(iri.FileList) == 0 { + return nil + } + + fileName := internalReleaseImageRegistryAuthSecretFileName + data, err := content.GetBootkubeTemplate(fileName) + if err != nil { + return err + } + t.FileList = []*asset.File{ + { + Filename: filepath.Join(content.TemplateDir, fileName), + Data: data, + }, + } + return nil +} + +// Files returns the files generated by the asset. +func (t *InternalReleaseImageRegistryAuthSecret) Files() []*asset.File { + return t.FileList +} + +// Load returns the asset from disk. +func (t *InternalReleaseImageRegistryAuthSecret) Load(f asset.FileFetcher) (bool, error) { + file, err := f.FetchByName(filepath.Join(content.TemplateDir, internalReleaseImageRegistryAuthSecretFileName)) + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, err + } + t.FileList = []*asset.File{file} + return true, nil +} diff --git a/pkg/asset/tls/iriregistryauth.go b/pkg/asset/tls/iriregistryauth.go new file mode 100644 index 00000000000..01eca297944 --- /dev/null +++ b/pkg/asset/tls/iriregistryauth.go @@ -0,0 +1,98 @@ +package tls //nolint:revive // pre-existing package name + +import ( + "context" + "crypto/rand" + "encoding/base64" + "fmt" + + "golang.org/x/crypto/bcrypt" + + features "github.com/openshift/api/features" + "github.com/openshift/installer/pkg/asset" + "github.com/openshift/installer/pkg/asset/installconfig" + "github.com/openshift/installer/pkg/asset/templates/content/manifests" +) + +const ( + // IRIRegistryUsername is the fixed username for IRI registry authentication. + IRIRegistryUsername = "openshift" + // PasswordBytes is the number of random bytes to generate for the password (256-bit entropy). + PasswordBytes = 32 +) + +// IRIRegistryAuth is the asset for the IRI registry authentication credentials. +// This is an in-memory-only asset: credentials are consumed by other assets +// (operators.go, bootstrap/common.go) but not written to disk. +// +// This must NOT write files to the auth/ directory. In agent-based installs, +// assisted-service moves kubeadmin-password and kubeconfig out of auth/ and +// then calls os.Remove("auth") to delete the directory. That call fails if +// any extra files remain, which would break the deployment. See: +// https://github.com/openshift/assisted-service/blob/89897ade7135/internal/ignition/installmanifests.go#L356 +type IRIRegistryAuth struct { + Username string + Password string //nolint:gosec // this is a credential holder, not a hardcoded secret + HtpasswdContent string +} + +var _ asset.WritableAsset = (*IRIRegistryAuth)(nil) + +// Dependencies returns the dependencies for generating IRI registry auth. +func (a *IRIRegistryAuth) Dependencies() []asset.Asset { + return []asset.Asset{ + &installconfig.InstallConfig{}, + &manifests.InternalReleaseImage{}, + } +} + +// Generate generates the IRI registry authentication credentials. +func (a *IRIRegistryAuth) Generate(ctx context.Context, dependencies asset.Parents) error { + installConfig := &installconfig.InstallConfig{} + iri := &manifests.InternalReleaseImage{} + dependencies.Get(installConfig, iri) + + // Only generate if NoRegistryClusterInstall feature is enabled + if !installConfig.Config.EnabledFeatureGates().Enabled(features.FeatureGateNoRegistryClusterInstall) { + return nil + } + + // Skip if InternalReleaseImage manifest wasn't found + if len(iri.FileList) == 0 { + return nil + } + + // Generate random password (32 bytes = 256-bit entropy) + passwordBytes := make([]byte, PasswordBytes) + if _, err := rand.Read(passwordBytes); err != nil { + return fmt.Errorf("failed to generate random password: %w", err) + } + a.Password = base64.StdEncoding.EncodeToString(passwordBytes) + a.Username = IRIRegistryUsername + + // Create bcrypt hash + hash, err := bcrypt.GenerateFromPassword([]byte(a.Password), bcrypt.DefaultCost) + if err != nil { + return fmt.Errorf("failed to hash password: %w", err) + } + + // Create htpasswd format: username:bcrypt-hash + a.HtpasswdContent = fmt.Sprintf("%s:%s\n", a.Username, string(hash)) + + return nil +} + +// Name returns the human-friendly name of the asset. +func (a *IRIRegistryAuth) Name() string { + return "IRI Registry Authentication" +} + +// Files returns an empty list as this is an in-memory-only asset. +func (a *IRIRegistryAuth) Files() []*asset.File { + return []*asset.File{} +} + +// Load returns false as this asset is not persisted to disk. +func (a *IRIRegistryAuth) Load(f asset.FileFetcher) (bool, error) { + return false, nil +} diff --git a/pkg/asset/tls/iriregistryauth_test.go b/pkg/asset/tls/iriregistryauth_test.go new file mode 100644 index 00000000000..97bfb8530a8 --- /dev/null +++ b/pkg/asset/tls/iriregistryauth_test.go @@ -0,0 +1,162 @@ +package tls + +import ( + "context" + "encoding/base64" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "golang.org/x/crypto/bcrypt" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + configv1 "github.com/openshift/api/config/v1" + "github.com/openshift/installer/pkg/asset" + "github.com/openshift/installer/pkg/asset/installconfig" + "github.com/openshift/installer/pkg/asset/templates/content/manifests" + "github.com/openshift/installer/pkg/ipnet" + "github.com/openshift/installer/pkg/types" + "github.com/openshift/installer/pkg/types/baremetal" +) + +func TestIRIRegistryAuthGenerate(t *testing.T) { + tests := []struct { + name string + featureGate string + iriManifest bool + shouldGenerate bool + }{ + { + name: "Generate with feature gate enabled and IRI manifest present", + featureGate: "TechPreviewNoUpgrade", + iriManifest: true, + shouldGenerate: true, + }, + { + name: "Skip without feature gate", + featureGate: "", + iriManifest: true, + shouldGenerate: false, + }, + { + name: "Skip without IRI manifest", + featureGate: "TechPreviewNoUpgrade", + iriManifest: false, + shouldGenerate: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create install config with feature gate + ic := &installconfig.InstallConfig{ + AssetBase: installconfig.AssetBase{ + Config: &types.InstallConfig{ + TypeMeta: metav1.TypeMeta{ + APIVersion: types.InstallConfigVersion, + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cluster", + }, + BaseDomain: "example.com", + Networking: &types.Networking{ + MachineNetwork: []types.MachineNetworkEntry{ + {CIDR: *ipnet.MustParseCIDR("10.0.0.0/16")}, + }, + }, + ControlPlane: &types.MachinePool{ + Name: "master", + Replicas: pointer(int64(3)), + }, + Compute: []types.MachinePool{ + { + Name: "worker", + Replicas: pointer(int64(3)), + }, + }, + Platform: types.Platform{ + BareMetal: &baremetal.Platform{ + APIVIPs: []string{"192.168.111.5"}, + }, + }, + }, + }, + } + + if tt.featureGate != "" { + ic.Config.FeatureSet = configv1.FeatureSet(tt.featureGate) + } + + // Create IRI manifest asset + iri := &manifests.InternalReleaseImage{} + if tt.iriManifest { + iri.FileList = []*asset.File{ + {Filename: "manifests/internal-release-image.yaml"}, + } + } + + // Create IRIRegistryAuth asset and generate + auth := &IRIRegistryAuth{} + parents := asset.Parents{} + parents.Add(ic, iri) + + err := auth.Generate(context.Background(), parents) + if !assert.NoError(t, err) { + return + } + + if !tt.shouldGenerate { + assert.Empty(t, auth.Password, "Password should be empty when generation is skipped") + assert.Empty(t, auth.HtpasswdContent, "HtpasswdContent should be empty when generation is skipped") + return + } + + // Verify password was generated + assert.NotEmpty(t, auth.Password, "Password should not be empty") + assert.Equal(t, IRIRegistryUsername, auth.Username, "Username should be 'openshift'") + + // Verify password is base64-encoded 32 bytes (256-bit) + passwordBytes, err := base64.StdEncoding.DecodeString(auth.Password) + if !assert.NoError(t, err, "Password should be valid base64") { + return + } + assert.Equal(t, PasswordBytes, len(passwordBytes), "Password should be 32 bytes before encoding") + + // Verify htpasswd content format: "openshift:$2y$10$..." + assert.True(t, strings.HasPrefix(auth.HtpasswdContent, "openshift:$2"), "Htpasswd should start with 'openshift:$2'") + assert.True(t, strings.HasSuffix(auth.HtpasswdContent, "\n"), "Htpasswd should end with newline") + + // Extract bcrypt hash from htpasswd content + parts := strings.Split(strings.TrimSpace(auth.HtpasswdContent), ":") + if !assert.Equal(t, 2, len(parts), "Htpasswd should have format 'username:hash'") { + return + } + hash := parts[1] + + // Verify bcrypt hash validates against password + err = bcrypt.CompareHashAndPassword([]byte(hash), []byte(auth.Password)) + assert.NoError(t, err, "Bcrypt hash should validate against password") + + // Verify Files() returns empty (in-memory-only asset) + files := auth.Files() + assert.Empty(t, files) + }) + } +} + +func TestIRIRegistryAuthLoad(t *testing.T) { + auth := &IRIRegistryAuth{} + found, err := auth.Load(nil) + assert.NoError(t, err) + assert.False(t, found, "Load should always return false for in-memory-only asset") +} + +func TestIRIRegistryAuthName(t *testing.T) { + auth := &IRIRegistryAuth{} + assert.Equal(t, "IRI Registry Authentication", auth.Name()) +} + +// pointer returns a pointer to the given value. +func pointer(i int64) *int64 { + return &i +}