diff --git a/controllers/zonek8sversion_controller.go b/controllers/zonek8sversion_controller.go new file mode 100644 index 0000000..fc40c03 --- /dev/null +++ b/controllers/zonek8sversion_controller.go @@ -0,0 +1,146 @@ +package controllers + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + "unicode" + + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" + + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/version" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/record" + + controlv1 "github.com/appuio/control-api/apis/v1" + configv1 "github.com/openshift/api/config/v1" + + "go.uber.org/multierr" +) + +type ZoneK8sVersionReconciler struct { + client.Client + Scheme *runtime.Scheme + Recorder record.EventRecorder + + ForeignClient client.Client + RESTClient rest.Interface + + // upstream zone ID. The agent expects that the control-api zone + // object is labeled with + ZoneID string +} + +const ( + upstreamZoneIdentifierLabelKey = "control.appuio.io/zone-cluster-id" + kubernetesVersionFeatureKey = "kubernetesVersion" + openshiftVersionFeatureKey = "openshiftVersion" +) + +func extractOpenShiftVersion(cv *configv1.ClusterVersion) string { + currentVersion := "" + lastUpdate := time.Time{} + for _, h := range cv.Status.History { + if h.State == "Completed" && h.Verified == true && h.CompletionTime.Time.After(lastUpdate) { + currentVersion = h.Version + lastUpdate = h.CompletionTime.Time + } + } + if currentVersion == "" { + return cv.Status.Desired.Version + } + return currentVersion +} + +func extractK8sVersion(v *version.Info) (string, error) { + major := trimVersion(v.Major) + if major == "" { + return "", fmt.Errorf("unknown major version %q", v.Major) + } + minor := trimVersion(v.Minor) + if minor == "" { + return "", fmt.Errorf("unknown minor version %q", v.Minor) + } + gitverparts := strings.Split(v.GitVersion, ".") + patchV := strings.Split(gitverparts[2], "+")[0] + patch := trimVersion(patchV) + if patch == "" { + return "", fmt.Errorf("unknown patch version %q", patchV) + } + return fmt.Sprintf("%s.%s.%s", major, minor, patch), nil +} + +func trimVersion(v string) string { + res := []rune{} + for _, r := range v { + if !unicode.IsDigit(r) { + break + } + res = append(res, r) + } + return string(res) +} + +// Reconcile reads the K8s and OCP versions and writes them to the upstream +// zone +func (r *ZoneK8sVersionReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + l := log.FromContext(ctx) + l.Info("Reconciling zone K8s version") + + var cv = configv1.ClusterVersion{} + if err := r.Client.Get(ctx, req.NamespacedName, &cv); err != nil { + return ctrl.Result{}, err + } + + ocpVer := extractOpenShiftVersion(&cv) + + l.Info("OCP current version", "version", ocpVer) + + body, err := r.RESTClient.Get().AbsPath("/version").Do(ctx).Raw() + if err != nil { + return ctrl.Result{}, err + } + var info version.Info + err = json.Unmarshal(body, &info) + if err != nil { + return ctrl.Result{}, err + } + k8sVer, err := extractK8sVersion(&info) + if err != nil { + return ctrl.Result{}, err + } + + l.Info("K8s current version", "version", k8sVer) + + var zones = controlv1.ZoneList{} + if err := r.ForeignClient.List(ctx, &zones, client.MatchingLabels{upstreamZoneIdentifierLabelKey: r.ZoneID}); err != nil { + return ctrl.Result{}, err + } + var errs []error + for _, z := range zones.Items { + if k8sVer != "" { + z.Data.Features[kubernetesVersionFeatureKey] = k8sVer + } + if ocpVer != "" { + z.Data.Features[openshiftVersionFeatureKey] = ocpVer + } + if err := r.ForeignClient.Update(ctx, &z); err != nil { + errs = append(errs, err) + } + } + + return ctrl.Result{}, multierr.Combine(errs...) +} + +// SetupWithManager sets up the controller with the Manager. +func (r *ZoneK8sVersionReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&configv1.ClusterVersion{}). + Named("zone_k8s_version"). + Complete(r) +} diff --git a/main.go b/main.go index 7c1c254..1fdea34 100644 --- a/main.go +++ b/main.go @@ -15,6 +15,7 @@ import ( authenticationv1 "k8s.io/api/authentication/v1" "k8s.io/apimachinery/pkg/runtime" utilruntime "k8s.io/apimachinery/pkg/util/runtime" + "k8s.io/client-go/kubernetes" clientgoscheme "k8s.io/client-go/kubernetes/scheme" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/cluster" @@ -33,6 +34,8 @@ import ( "github.com/appuio/appuio-cloud-agent/skipper" "github.com/appuio/appuio-cloud-agent/webhooks" whoamicli "github.com/appuio/appuio-cloud-agent/whoami" + + configv1 "github.com/openshift/api/config/v1" ) var ( @@ -56,6 +59,7 @@ func init() { utilruntime.Must(projectv1.AddToScheme(scheme)) utilruntime.Must(agentv1.AddToScheme(scheme)) utilruntime.Must(controlv1.AddToScheme(scheme)) + utilruntime.Must(configv1.AddToScheme(scheme)) //+kubebuilder:scaffold:scheme } @@ -77,7 +81,7 @@ func main() { flag.StringVar(&controlAPIURL, "control-api-url", "", "URL of the control API. If set agent does not use `-kubeconfig-control-api`. Expects a bearer token in `CONTROL_API_BEARER_TOKEN` env var.") var upstreamZoneIdentifier string - flag.StringVar(&upstreamZoneIdentifier, "upstream-zone-identifier", "", "Identifies the agent in the control API. Currently used for Team/OrganizationMembers finalizer. Must be set if the GroupSync controller is enabled.") + flag.StringVar(&upstreamZoneIdentifier, "upstream-zone-identifier", "", "Identifies the agent in the control API. Currently used for Team/OrganizationMembers finalizer and the K8s version reporting.") var selectedUsageProfile string flag.StringVar(&selectedUsageProfile, "usage-profile", "", "UsageProfile to use. Applies all profiles if empty. Dynamic selection is not supported yet.") @@ -175,8 +179,14 @@ func main() { os.Exit(1) } + if upstreamZoneIdentifier == "" { + setupLog.Error(err, "upstream-zone-identifier must be set.") + os.Exit(1) + } + registerRatioController(mgr, conf, conf.OrganizationLabel) registerOrganizationRBACController(mgr, conf.OrganizationLabel, conf.DefaultOrganizationClusterRoles) + registerZoneK8sVersionController(mgr, controlAPICluster, upstreamZoneIdentifier) if !disableUserAttributeSync { if err := (&controllers.UserAttributeSyncReconciler{ @@ -191,10 +201,6 @@ func main() { } } if !disableGroupSync { - if upstreamZoneIdentifier == "" { - setupLog.Error(err, "upstream-zone-identifier must be set if GroupSync controller is enabled") - os.Exit(1) - } if err := (&controllers.GroupSyncReconciler{ Client: mgr.GetClient(), Scheme: mgr.GetScheme(), @@ -452,3 +458,24 @@ func registerRatioController(mgr ctrl.Manager, conf Config, orgLabel string) { os.Exit(1) } } + +func registerZoneK8sVersionController(mgr ctrl.Manager, controlAPICluster cluster.Cluster, upstreamZoneIdentifier string) { + restclient, err := kubernetes.NewForConfig(mgr.GetConfig()) + if err != nil { + setupLog.Error(err, "unable to create clientset for config", "controller", "zone-k8s-version") + os.Exit(1) + } + if err := (&controllers.ZoneK8sVersionReconciler{ + Client: mgr.GetClient(), + RESTClient: restclient.RESTClient(), + Recorder: mgr.GetEventRecorderFor("zone-k8s-version-controller"), + Scheme: mgr.GetScheme(), + + ForeignClient: controlAPICluster.GetClient(), + + ZoneID: upstreamZoneIdentifier, + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "zone-k8s-version") + os.Exit(1) + } +}