Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ const (
var (
volumeMounts = util.PodVolumeMounts{
kasContainerBootstrap().Name: {
kasVolumeBootstrapManifests().Name: "/work",
kasVolumeLocalhostKubeconfig().Name: "/var/secrets/localhost-kubeconfig",
},
kasContainerBootstrapRender().Name: {
kasVolumeBootstrapManifests().Name: "/work",
},
kasContainerApplyBootstrap().Name: {
Expand Down Expand Up @@ -200,9 +204,11 @@ func ReconcileKubeAPIServerDeployment(deployment *appsv1.Deployment,
SchedulerName: corev1.DefaultSchedulerName,
AutomountServiceAccountToken: ptr.To(false),
InitContainers: []corev1.Container{
util.BuildContainer(kasContainerBootstrap(), buildKASContainerBootstrap(images.ClusterConfigOperator, payloadVersion, featureGateYaml)),
util.BuildContainer(kasContainerBootstrapRender(), buildKASContainerBootstrapRender(images.ClusterConfigOperator, payloadVersion, featureGateYaml)),
},
Containers: []corev1.Container{
// TODO(alberto): Move the logic from kasContainerApplyBootstrap to kasContainerBootstrap and drop the former.
util.BuildContainer(kasContainerBootstrap(), buildKASContainerNewBootstrap(images.KASBootstrap)),
util.BuildContainer(kasContainerApplyBootstrap(), buildKASContainerApplyBootstrap(images.CLI)),
util.BuildContainer(kasContainerMain(), buildKASContainerMain(images.HyperKube, port, additionalNoProxyCIDRS, hcp)),
util.BuildContainer(konnectivityServerContainer(), buildKonnectivityServerContainer(images.KonnectivityServer, deploymentConfig.Replicas, cipherSuites)),
Expand Down Expand Up @@ -335,11 +341,41 @@ func ReconcileKubeAPIServerDeployment(deployment *appsv1.Deployment,

func kasContainerBootstrap() *corev1.Container {
return &corev1.Container{
Name: "init-bootstrap",
Name: "bootstrap",
}
}
func buildKASContainerNewBootstrap(image string) func(c *corev1.Container) {
return func(c *corev1.Container) {
c.Image = image
c.TerminationMessagePolicy = corev1.TerminationMessageReadFile
c.TerminationMessagePath = corev1.TerminationMessagePathDefault
c.ImagePullPolicy = corev1.PullIfNotPresent
c.Command = []string{
"/usr/bin/control-plane-operator",
"kas-bootstrap",
"--rendered-featuregate-path", volumeMounts.Path(c.Name, kasVolumeBootstrapManifests().Name),
}
c.Resources.Requests = corev1.ResourceList{
corev1.ResourceCPU: resource.MustParse("10m"),
corev1.ResourceMemory: resource.MustParse("10Mi"),
}
c.Env = []corev1.EnvVar{
{
Name: "KUBECONFIG",
Value: path.Join(volumeMounts.Path(kasContainerBootstrap().Name, kasVolumeLocalhostKubeconfig().Name), KubeconfigKey),
},
}
c.VolumeMounts = volumeMounts.ContainerMounts(c.Name)
}
}

func buildKASContainerBootstrap(image, payloadVersion, featureGateYaml string) func(c *corev1.Container) {
func kasContainerBootstrapRender() *corev1.Container {
return &corev1.Container{
Name: "bootstrap-render",
}
}

func buildKASContainerBootstrapRender(image, payloadVersion, featureGateYaml string) func(c *corev1.Container) {
return func(c *corev1.Container) {
c.Command = []string{
"/bin/bash",
Expand All @@ -349,7 +385,7 @@ func buildKASContainerBootstrap(image, payloadVersion, featureGateYaml string) f
c.TerminationMessagePath = corev1.TerminationMessagePathDefault
c.Args = []string{
"-c",
invokeBootstrapRenderScript(volumeMounts.Path(kasContainerBootstrap().Name, kasVolumeBootstrapManifests().Name), payloadVersion, featureGateYaml),
invokeBootstrapRenderScript(volumeMounts.Path(kasContainerBootstrapRender().Name, kasVolumeBootstrapManifests().Name), payloadVersion, featureGateYaml),
}
c.Resources.Requests = corev1.ResourceList{
corev1.ResourceCPU: resource.MustParse("10m"),
Expand Down Expand Up @@ -812,13 +848,6 @@ while true; do
fi
sleep 1
done
while true; do
if oc replace --subresource=status -f %[1]s/99_feature-gate.yaml; then
echo "FeatureGate status applied successfully."
break
fi
sleep 1
done
while true; do
sleep 1000 &
wait $!
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ type KubeAPIServerImages struct {
TokenMinterImage string
AWSPodIdentityWebhookImage string
KonnectivityServer string
KASBootstrap string
}

type KubeAPIServerParams struct {
Expand Down Expand Up @@ -116,6 +117,7 @@ func NewKubeAPIServerParams(ctx context.Context, hcp *hyperv1.HostedControlPlane
AzureKMS: releaseImageProvider.GetImage("azure-kms-encryption-provider"),
AWSPodIdentityWebhookImage: releaseImageProvider.GetImage("aws-pod-identity-webhook"),
KonnectivityServer: releaseImageProvider.GetImage("apiserver-network-proxy"),
KASBootstrap: releaseImageProvider.GetImage(util.CPOImageName),
},
MaxRequestsInflight: fmt.Sprint(defaultMaxRequestsInflight),
MaxMutatingRequestsInflight: fmt.Sprint(defaultMaxMutatingRequestsInflight),
Expand Down
6 changes: 5 additions & 1 deletion control-plane-operator/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
etcdbackup "github.com/openshift/hypershift/etcd-backup"
etcddefrag "github.com/openshift/hypershift/etcd-defrag"
ignitionserver "github.com/openshift/hypershift/ignition-server/cmd"
kasbootstrap "github.com/openshift/hypershift/kas-bootstrap"
konnectivityhttpsproxy "github.com/openshift/hypershift/konnectivity-https-proxy"
konnectivitysocks5proxy "github.com/openshift/hypershift/konnectivity-socks5-proxy"
kubernetesdefaultproxy "github.com/openshift/hypershift/kubernetes-default-proxy"
Expand Down Expand Up @@ -78,6 +79,8 @@ func main() {
func commandFor(name string) *cobra.Command {
var cmd *cobra.Command
switch name {
case "kas-bootstrap":
cmd = kasbootstrap.NewRunCommand()
case "ignition-server":
cmd = ignitionserver.NewStartCommand()
case "konnectivity-socks5-proxy":
Expand Down Expand Up @@ -140,7 +143,7 @@ func defaultCommand() *cobra.Command {
cmd.AddCommand(kubernetesdefaultproxy.NewStartCommand())
cmd.AddCommand(dnsresolver.NewCommand())
cmd.AddCommand(etcdbackup.NewStartCommand())

cmd.AddCommand(kasbootstrap.NewRunCommand())
return cmd

}
Expand Down Expand Up @@ -358,6 +361,7 @@ func NewStartCommand() *cobra.Command {
}
setupLog.Info("using token minter image", "image", tokenMinterImage)

cpoImage = os.Getenv("CONTROL_PLANE_OPERATOR_IMAGE")
cpoImage, err = lookupOperatorImage(cpoImage)
if err != nil {
setupLog.Error(err, "failed to find controlplane-operator-image")
Expand Down
142 changes: 142 additions & 0 deletions kas-bootstrap/kas_boostrap.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
package kasbootstrap

import (
"context"
"fmt"
"os"
"path/filepath"

configv1 "github.com/openshift/api/config/v1"

equality "k8s.io/apimachinery/pkg/api/equality"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/serializer"
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
"k8s.io/apimachinery/pkg/util/sets"

ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/log/zap"

"go.uber.org/zap/zapcore"
)

func init() {
utilruntime.Must(configv1.Install(configScheme))
}

var (
configScheme = runtime.NewScheme()
configCodecs = serializer.NewCodecFactory(configScheme)
)

func run(ctx context.Context, opts Options) error {
logger := zap.New(zap.JSONEncoder(func(o *zapcore.EncoderConfig) {
o.EncodeTime = zapcore.RFC3339TimeEncoder
}))
ctrl.SetLogger(logger)

cfg, err := ctrl.GetConfig()
if err != nil {
return fmt.Errorf("failed to get config: %w", err)
}
c, err := client.New(cfg, client.Options{Scheme: configScheme})
if err != nil {
return fmt.Errorf("failed to create client: %w", err)
}

content, err := os.ReadFile(filepath.Join(opts.RenderedFeatureGatePath, "99_feature-gate.yaml"))
if err != nil {
return fmt.Errorf("failed to read featureGate file: %w", err)
}

renderedFeatureGate, err := parseFeatureGateV1(content)
if err != nil {
return fmt.Errorf("failed to parse featureGate file: %w", err)
}

if err := reconcileFeatureGate(ctx, c, renderedFeatureGate); err != nil {
return fmt.Errorf("failed to reconcile featureGate: %w", err)
}

// we want to keep the process running during the lifecycle of the Pod because the Pod runs with restartPolicy=Always
// and it's not possible for individual containers to have a dedicated restartPolicy like onFailure.

// start a goroutine that will close the done channel when the context is done.
done := make(chan struct{})
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you explain why we want to keep the process running?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

because the pod RestartPolicy is Always, this mimics current behaviour and I would defer deviating from it to a different change

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ack, any reason we are not adding this an an init container instead?

Copy link
Member

@wking wking Mar 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd guess it can't be an init container because it needs the actual API-server in another long-lived container in this same Pod to talk to. But couldn't we set a container-scoped restartPolicy: OnFailure for this container to get both:

  • The ability to exit 0 when we were successfully reconciled and recover the resources the container process had been consuming. Until some future when management of these resources moves from "successfully reconciled once per 4.y.z release" to "actively watched and managed with some regularity", which would be nice, but is likely more than we want to bite off in a single pull request.
  • Reporting via KubePodCrashLooping if the container has trouble, while the container continues to relaunch and retry. Not as direct as having the controlling CPO know why the container was having trouble, but at least there would be a sign of trouble visible in Kube at a higher level than "dip into the container's logs".

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@wking this last comment led me to do a little bit of experimentation on a 4.19 ci cluster :)

  • Tried changing the restart policy of a side container under .spec.containers, and failed admission with:
    * spec.template.spec.containers[1].restartPolicy: Forbidden: may not be set for non-init containers

  • Then tried moving the container under .spec.initContainers with a restartPolicy of OnFailure, and also failed admission with:
    * spec.template.spec.initContainers[0].restartPolicy: Unsupported value: "OnFailure": supported values: "Always"

  • Then tried changing the initContainer restartPolicy to Always and the deployment was accepted. The init container ended up running as a side container (which was new to me :)). I could not see a difference though between the additional container under .spec.containers or the init container with restartPolicy as Always.

Bottom line, I think what we have here is fine.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, the reason I didn't just set restart on failure for this container is that afaik individual containers can't supersede the Pod restartPolicy. Moving it to init as a side container will technically differ operationally from what we do at the moment and possibly causing more restarts while racing rendering for no value? so I'd rather keep it as it is to keep changes scoped and defer any further change to different PRs. After this one we'll still need to move the apply logic to this binary.
I added a comment in code to clarify about the restart policies.

go func() {
<-ctx.Done()
close(done)
}()

logger.Info("kas-bootstrap process completed successfully, waiting for termination signal")
<-done

return nil
}

// reconcileFeatureGate reconciles the featureGate CR status appending the renderedFeatureGate status.featureGates to the existing featureGates.
// It will not fail if the clusterVersion is not found as this is expected for a brand new cluster.
// But it will remove any featureGates that are not in the clusterVersion.Status.History if it exists.
func reconcileFeatureGate(ctx context.Context, c client.Client, renderedFeatureGate *configv1.FeatureGate) error {
logger := ctrl.LoggerFrom(ctx).WithName("kas-bootstrap")

knownVersions := sets.NewString()
var clusterVersion configv1.ClusterVersion
err := c.Get(ctx, client.ObjectKey{Name: "version"}, &clusterVersion)
if err != nil {
// we don't fail if we can't get the clusterVersion, we will just not update the featureGate.
// This is always the case for a brand new cluster as the clusterVersion is not created yet.
logger.Info("WARNING: failed to get clusterVersion. This is expected for a brand new cluster", "error", err)
} else {
knownVersions = sets.NewString(clusterVersion.Status.Desired.Version)
for _, cvoVersion := range clusterVersion.Status.History {
knownVersions.Insert(cvoVersion.Version)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

standalone OCP currently doesn't do any garbage collection, so what you have now is fine as it stands. But once you hit your first Completed entry and insert that into knownVersions, you can break, because there shouldn't be anything left on the cluster that cares about those ancient releases anymore:

for _, cvoVersion := range clusterVersion.Status.History {
	knownVersions = sets.NewString(clusterVersion.Status.Desired.Version)
	knownVersions.Insert(cvoVersion.Version)
	if cvoVersion.State == configv1.CompletedUpdate {
		break
	}
}

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, updated the logic and unit coverage to reflect this


// Once we hit the first Completed entry and insert that into knownVersions
// we can break, because there shouldn't be anything left on the cluster that cares about those ancient releases anymore.
if cvoVersion.State == configv1.CompletedUpdate {
break
}
}
}

var featureGate configv1.FeatureGate
if err := c.Get(ctx, client.ObjectKey{Name: "cluster"}, &featureGate); err != nil {
return fmt.Errorf("failed to get featureGate: %w", err)
}

desiredFeatureGates := renderedFeatureGate.Status.FeatureGates
currentVersion := renderedFeatureGate.Status.FeatureGates[0].Version
for i := range featureGate.Status.FeatureGates {
featureGateValues := featureGate.Status.FeatureGates[i]
if featureGateValues.Version == currentVersion {
continue
}
if len(knownVersions) > 0 && !knownVersions.Has(featureGateValues.Version) {
continue
}
desiredFeatureGates = append(desiredFeatureGates, featureGateValues)
}

if equality.Semantic.DeepEqual(desiredFeatureGates, featureGate.Status.FeatureGates) {
logger.Info("There is no update for featureGate.Status.FeatureGates")
return nil
}

original := featureGate.DeepCopy()
featureGate.Status.FeatureGates = desiredFeatureGates
if err := c.Status().Patch(ctx, &featureGate, client.MergeFromWithOptions(original, client.MergeFromWithOptimisticLock{})); err != nil {
return fmt.Errorf("failed to update featureGate: %w", err)
}
return nil
}

func parseFeatureGateV1(objBytes []byte) (*configv1.FeatureGate, error) {
requiredObj, err := runtime.Decode(configCodecs.UniversalDecoder(configv1.SchemeGroupVersion), objBytes)
if err != nil {
return nil, fmt.Errorf("failed to decode featureGate: %w", err)
}

return requiredObj.(*configv1.FeatureGate), nil
}
Loading