Skip to content

Commit fb411c4

Browse files
committed
fix(apiserver): decide the API server migration from live cluster state
On a direct upgrade from the deprecated API server layout, the apiserver controller moved the API server into calico-system and repointed the v3.projectcalico.org APIService at it before the leftover allow-tigera.default-deny policy had been removed. That policy sits in the earlier-evaluated allow-tigera tier and selects all() endpoints, so it denied the moved pod at end-of-tier and the pod never became ready. The aggregated API then stayed down permanently, because removing the policy requires the API server that had just been taken out of service. The controller now decides whether to perform the move by reading live state through an uncached reader, so the decision cannot be made from cached state that is no longer true: only v3.LicenseKey is excluded from the manager cache, so a cached read of a projectcalico.org/v3 object is served from the informer's last known contents even when the aggregated API cannot serve. The v3.projectcalico.org APIService supplies both inputs, and the kube-apiserver serves it directly, so reading it does not depend on the thing being measured. spec.service.namespace says which layout is deployed; the Available condition reports the aggregator's own most recent verdict on whether requests can be served. A migration is pending only while that APIService still points into tigera-system, so an upgrade that has already moved the API server does not wait on a policy that can no longer select the pod. When a migration is pending and the aggregated API can serve, the move waits for the deprecated policy to be removed by the installation controller. When a migration is pending and the aggregated API cannot serve, nothing is applied at all: the trap cannot be confirmed gone and nothing can clear it while the API is down, so moving would only repoint the aggregated API onto a pod that cannot be vouched for. The periodic reconcile already registered in Add() retriggers the decision, and an unrecognised decision holds rather than proceeding, since failing open on this gate is not recoverable. Two unsound tests are removed. The tigera-system namespace fallback treated the namespace's absence as proof the gate had passed; that does not hold on a two-hop upgrade, where the intermediate release deletes the namespace while leaving the policy in place, nor after an administrator deletes the namespace to clear a stuck upgrade. The NoMatch exemption treated a missing RESTMapper mapping as proof the policy was absent, which is not evidence about the policy at all. On the pass that performs the migration the projectcalico.org/v3 NetworkPolicy component is applied before the workload rather than after it, removing the window in which the moved pod runs with no policy of its own. Every other pass keeps the existing order, which exists so that a fresh install is not blocked on an API server that cannot become available until the install has progressed. Refs: EV-6821
1 parent 3fce8ac commit fb411c4

4 files changed

Lines changed: 648 additions & 9 deletions

File tree

pkg/controller/apiserver/apiserver_controller.go

Lines changed: 96 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ var log = logf.Log.WithName("controller_apiserver")
6767
func Add(mgr manager.Manager, opts options.ControllerOptions) error {
6868
r := &ReconcileAPIServer{
6969
client: mgr.GetClient(),
70+
apiReader: mgr.GetAPIReader(),
7071
scheme: mgr.GetScheme(),
7172
status: status.New(mgr.GetClient(), "apiserver", opts.KubernetesVersion),
7273
tierWatchReady: &utils.ReadyFlag{},
@@ -228,7 +229,13 @@ var _ reconcile.Reconciler = &ReconcileAPIServer{}
228229
type ReconcileAPIServer struct {
229230
// This client, initialized using mgr.Client() above, is a split client
230231
// that reads objects from the cache and writes to the apiserver
231-
client client.Client
232+
client client.Client
233+
// apiReader reads directly from the API server, bypassing the manager's cache.
234+
// The migration decision below must not be made from cached state: a cached read of a
235+
// projectcalico.org/v3 object succeeds against the informer's last known contents even
236+
// when the aggregated API server is unable to serve, which would let us act on state
237+
// that is no longer true.
238+
apiReader client.Reader
232239
scheme *runtime.Scheme
233240
status status.StatusManager
234241
tierWatchReady *utils.ReadyFlag
@@ -487,6 +494,76 @@ func (r *ReconcileAPIServer) Reconcile(ctx context.Context, request reconcile.Re
487494
}
488495
}
489496

497+
// A direct upgrade from the deprecated layout (tigera-apiserver in the tigera-system
498+
// namespace, allow-tigera policy tier) has to clear allow-tigera.default-deny before the
499+
// calico-apiserver pod is moved into calico-system. That policy sits in the
500+
// earlier-evaluated allow-tigera tier and selects all() endpoints, so it denies the moved
501+
// pod at end-of-tier before the calico-system tier - whose default-deny excludes the API
502+
// server - is ever reached. The installation controller removes it through the
503+
// still-serving API server. Repointing the aggregated API onto a trapped pod takes that
504+
// API down permanently, so the move waits.
505+
//
506+
// This is a no-op when the projectcalico.org/v3 API group is backed by CRDs natively:
507+
// there is no aggregated API server to deadlock.
508+
moveIsPending := false
509+
if !r.opts.UseV3CRDs {
510+
layout, aggregatedAPIAvailable, err := readAPIServiceState(ctx, r.apiReader)
511+
if err != nil {
512+
r.status.SetDegraded(operatorv1.ResourceReadError, "Error reading the projectcalico.org/v3 APIService", err, reqLogger)
513+
return reconcile.Result{}, err
514+
}
515+
516+
denyPresent := false
517+
if layout == layoutDeprecated && aggregatedAPIAvailable {
518+
denyPresent, err = deprecatedDenyPresent(ctx, r.apiReader)
519+
if err != nil {
520+
r.status.SetDegraded(operatorv1.ResourceReadError, "Error checking for the deprecated allow-tigera.default-deny policy", err, reqLogger)
521+
return reconcile.Result{}, err
522+
}
523+
}
524+
525+
decision := decideMigration(layout, aggregatedAPIAvailable, denyPresent)
526+
switch decision {
527+
case decisionHoldAPIUnavailable:
528+
// Do not apply anything. We cannot confirm the deprecated deny is gone, and
529+
// nothing can clear it while the aggregated API is unable to serve, so moving
530+
// the workload now could only make the situation harder to recover. The
531+
// periodic reconcile re-runs this every utils.PeriodicReconcileTime regardless
532+
// of the requeue below.
533+
reqLogger.Info("The projectcalico.org/v3 API is unavailable and the API server has not been migrated; holding the migration until it can serve")
534+
r.status.SetDegraded(operatorv1.ResourceNotReady, "Waiting for the projectcalico.org/v3 API to become available before migrating the API server", nil, reqLogger)
535+
return reconcile.Result{RequeueAfter: utils.StandardRetry}, nil
536+
case decisionWaitForDenyRemoval:
537+
// The deny is removed by the installation controller only after it renders v3
538+
// NetworkPolicy into the calico-system tier (pkg/render/kubecontrollers/kube-controllers.go),
539+
// which it only does once the tiers controller has created that tier
540+
// (pkg/controller/installation/core_controller.go), which the tiers controller only
541+
// does once IsProjectCalicoV3Available reports APIServer.Status.State == Ready
542+
// (pkg/controller/tiers/tiers_controller.go). This controller is the only writer of
543+
// that field, and it does so on a path this hold returns before reaching. On a
544+
// supported upgrade that field was already persisted by the previous operator version
545+
// before this reconcile ever ran, so the wait clears on its own; this is not a bug in
546+
// the upgrade path. It would only spin forever if that status were never latched - for
547+
// example the tigera-secure APIServer CR was deleted and recreated around the upgrade.
548+
// Holding is still correct in that case: the alternative is repointing the aggregated
549+
// API onto a pod the deny traps, which is a worse and equally permanent failure, while
550+
// holding here leaves the cluster recoverable.
551+
reqLogger.Info("Waiting for the deprecated allow-tigera.default-deny policy to be removed before migrating the API server")
552+
r.status.SetDegraded(operatorv1.ResourceNotReady, "Waiting for the deprecated allow-tigera.default-deny policy in calico-system to be removed before migrating the API server; this clears once the installation controller deletes that policy", nil, reqLogger)
553+
return reconcile.Result{RequeueAfter: utils.StandardRetry}, nil
554+
case decisionProceed:
555+
moveIsPending = layout == layoutDeprecated
556+
default:
557+
// Fail closed: an unrecognised decision must not fall through to applying the
558+
// move. Repointing the aggregated API onto a pod we have not vouched for would be
559+
// unrecoverable, so treat anything we don't explicitly know about the same as a
560+
// hold.
561+
reqLogger.Error(nil, "Unrecognised migration decision; holding the migration", "decision", decision)
562+
r.status.SetDegraded(operatorv1.ResourceNotReady, "Waiting to migrate the API server: unrecognised migration decision", nil, reqLogger)
563+
return reconcile.Result{RequeueAfter: utils.StandardRetry}, nil
564+
}
565+
}
566+
490567
err = utils.PopulateK8sServiceEndPoint(r.client)
491568
if err != nil {
492569
r.status.SetDegraded(operatorv1.ResourceReadError, "Error reading services endpoint configmap", err, reqLogger)
@@ -592,6 +669,21 @@ func (r *ReconcileAPIServer) Reconcile(ctx context.Context, request reconcile.Re
592669
return reconcile.Result{}, err
593670
}
594671

672+
// If the projectcalico.org/v3 API group is being backed by our aggregated API server, then
673+
// v3 NetworkPolicy will fail to reconcile until the Calico API server is healthy. Thus, we
674+
// normally only render v3.NetworkPolicy after the aggregated API server becomes available,
675+
// to avoid a chicken-and-egg scenario.
676+
//
677+
// If the projectcalico.org/v3 API group is implemented using CRDs natively, we can install
678+
// network policies immediately, as there is no dependency on the API server deployment.
679+
renderPolicy := r.opts.UseV3CRDs || includeV3NetworkPolicy
680+
681+
if policyComponentFirst(moveIsPending, renderPolicy) {
682+
// On the migration pass the aggregated API has already been confirmed available, so
683+
// the policy can be applied before the workload it protects rather than after it.
684+
components = append(components, render.APIServerPolicy(&apiServerCfg))
685+
}
686+
595687
components = append(components,
596688
component,
597689
rcertificatemanagement.CertificateManagement(&rcertificatemanagement.Config{
@@ -602,14 +694,9 @@ func (r *ReconcileAPIServer) Reconcile(ctx context.Context, request reconcile.Re
602694
}),
603695
)
604696

605-
// If the projectcalico.org/v3 API group is being backed by our aggregated API server, then v3 NetworkPolicy will fail to reconcile until the Calico API server is healthy.
606-
// Thus, we only render v3.NetworkPolicy after the aggregated API server becomes available to avoid a chicken-and-egg scenario.
607-
//
608-
// If the projectcalico.org/v3 API group is implemented using CRDs natively, we can install network policies immediately, as there is no
609-
// dependency on the API server deployment.
610-
//
611-
// We do this last to avoid transient errors with policy preventing progression of the controller.
612-
if r.opts.UseV3CRDs || includeV3NetworkPolicy {
697+
if renderPolicy && !policyComponentFirst(moveIsPending, renderPolicy) {
698+
// We do this last to avoid transient errors with policy preventing progression of the
699+
// controller.
613700
components = append(components, render.APIServerPolicy(&apiServerCfg))
614701
}
615702

0 commit comments

Comments
 (0)