Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add controller which reports K8s and OCP version to control-api #133

Merged
merged 2 commits into from
Nov 14, 2024
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
2 changes: 2 additions & 0 deletions controllers/utils_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"testing"

controlv1 "github.com/appuio/control-api/apis/v1"
configv1 "github.com/openshift/api/config/v1"
userv1 "github.com/openshift/api/user/v1"
"github.com/stretchr/testify/require"
corev1 "k8s.io/api/core/v1"
Expand Down Expand Up @@ -43,6 +44,7 @@ func prepareClient(t *testing.T, initObjs ...client.Object) (client.WithWatch, *
require.NoError(t, cloudagentv1.AddToScheme(scheme))
require.NoError(t, controlv1.AddToScheme(scheme))
require.NoError(t, userv1.AddToScheme(scheme))
require.NoError(t, configv1.AddToScheme(scheme))

client := fake.NewClientBuilder().
WithScheme(scheme).
Expand Down
138 changes: 138 additions & 0 deletions controllers/zonek8sversion_controller.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
package controllers

import (
"context"
"encoding/json"
"fmt"
"strings"
"time"

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"
)

// Reconcile reads the K8s and OCP versions and writes them to the upstream
// zone
// The logic in this reconcile function is adapted from
// https://github.com/projectsyn/steward
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
}

ocpVersion, err := extractOpenShiftVersion(&cv)
if err != nil {
return ctrl.Result{}, err
}

l.Info("OCP current version", "version", ocpVersion)

// We don't use client-go's ServerVersion() so we get context support
body, err := r.RESTClient.Get().AbsPath("/version").Do(ctx).Raw()
if err != nil {
return ctrl.Result{}, err
}
var k8sVersion version.Info
err = json.Unmarshal(body, &k8sVersion)
if err != nil {
return ctrl.Result{}, err
}
l.Info("K8s current version", "version", k8sVersion)

// List zones by label because we don't enforce any naming conventions
// for the Zone objects on the control-api cluster.
var zones = controlv1.ZoneList{}
if err := r.ForeignClient.List(ctx, &zones, client.MatchingLabels{upstreamZoneIdentifierLabelKey: r.ZoneID}); err != nil {
return ctrl.Result{}, err
}

if len(zones.Items) == 0 {
l.Info("No upstream zone found", "zone ID", r.ZoneID)
return ctrl.Result{}, nil
}

if len(zones.Items) > 1 {
l.Info("Multiple upstream zones found, updating all", "zone ID", r.ZoneID)
}

var errs []error
for _, z := range zones.Items {
z.Data.Features[kubernetesVersionFeatureKey] =
fmt.Sprintf("%s.%s", k8sVersion.Major, k8sVersion.Minor)
z.Data.Features[openshiftVersionFeatureKey] =
fmt.Sprintf("%s.%s", ocpVersion.Major, ocpVersion.Minor)
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)
}

// extract version of latest completed and verified upgrade from the OCP ClusterVersion resource.
func extractOpenShiftVersion(cv *configv1.ClusterVersion) (*version.Info, error) {
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 == "" {
currentVersion = cv.Status.Desired.Version
}

if currentVersion == "" {
return nil, fmt.Errorf("Unable to extract current OpenShift version")
}

verparts := strings.Split(currentVersion, ".")
version := version.Info{
Major: verparts[0],
Minor: verparts[1],
}
return &version, nil
}
175 changes: 175 additions & 0 deletions controllers/zonek8sversion_controller_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
package controllers

import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"testing"
"time"

controlv1 "github.com/appuio/control-api/apis/v1"
configv1 "github.com/openshift/api/config/v1"
"github.com/stretchr/testify/require"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
//"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/serializer"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/version"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
ctrl "sigs.k8s.io/controller-runtime"

restfake "k8s.io/client-go/rest/fake"
)

func Test_ZoneK8sVersionReconciler_Reconcile(t *testing.T) {
zoneID := "c-appuio-test-cluster"
zone := controlv1.Zone{
ObjectMeta: metav1.ObjectMeta{
Name: "test",
Labels: map[string]string{
upstreamZoneIdentifierLabelKey: zoneID,
},
},
Data: controlv1.ZoneData{
Features: map[string]string{
"foo": "bar",
},
},
}
otherZone := controlv1.Zone{
ObjectMeta: metav1.ObjectMeta{
Name: "test-2",
Labels: map[string]string{
upstreamZoneIdentifierLabelKey: "c-appuio-other-cluster",
},
},
Data: controlv1.ZoneData{
Features: map[string]string{
"foo": "bar",
},
},
}
foreignClient, _, _ := prepareClient(t, &zone, &otherZone)
cv := makeClusterVersion("4.16.19", []configv1.UpdateHistory{})
client, scheme, recorder := prepareClient(t, &cv)

version := version.Info{
Major: "1",
Minor: "29",
}
marshaledVersion, err := json.Marshal(version)
require.NoError(t, err)

// Setup a fake REST client which returns the marshaled version.Info
// on requests on /version
restclient := restfake.RESTClient{
NegotiatedSerializer: serializer.WithoutConversionCodecFactory{CodecFactory: clientgoscheme.Codecs},
Client: restfake.CreateHTTPClient(
func(req *http.Request) (*http.Response, error) {
if req.Method == "GET" && req.URL.Path == "/version" {
resp := http.Response{
Header: make(http.Header, 0),
Body: io.NopCloser(bytes.NewBuffer(marshaledVersion)),
ContentLength: int64(len(marshaledVersion)),
Status: "200 OK",
StatusCode: 200,
Proto: "HTTP/1.1",
ProtoMajor: 1,
ProtoMinor: 1,
Request: req,
}
resp.Header.Add("Content-Type", "application/json; charset=utf-8")
return &resp, nil
}
return nil, fmt.Errorf("Unexpected request")
},
),
}

subject := ZoneK8sVersionReconciler{
Client: client,
Scheme: scheme,
Recorder: recorder,
ForeignClient: foreignClient,
RESTClient: &restclient,
ZoneID: zoneID,
}

// NOTE(sg): ClusterVersion is a singleton which is always named
// version
_, err = subject.Reconcile(context.Background(), ctrl.Request{NamespacedName: types.NamespacedName{Name: "version"}})
require.NoError(t, err)

// Get updated zone from the foreign client to check added fields
updatedZone := controlv1.Zone{}
err = foreignClient.Get(context.Background(), types.NamespacedName{Name: "test"}, &updatedZone)
require.NoError(t, err)
require.Equal(t, "4.16", updatedZone.Data.Features[openshiftVersionFeatureKey], "OCP version is set")
require.Equal(t, "1.29", updatedZone.Data.Features[kubernetesVersionFeatureKey], "K8s version is set")
require.Equal(t, "bar", updatedZone.Data.Features["foo"], "Unrelated fields are left in place")

// Verify that unrelated zone isn't updated
updatedOtherZone := controlv1.Zone{}
err = foreignClient.Get(context.Background(), types.NamespacedName{Name: "test-2"}, &updatedOtherZone)
require.NoError(t, err)
require.Equal(t, controlv1.Features{"foo": "bar"}, updatedOtherZone.Data.Features, "unrelated zones are untouched")
}

func Test_extractOpenShiftVersion(t *testing.T) {
cv := makeClusterVersion("4.16.5", []configv1.UpdateHistory{})
v, err := extractOpenShiftVersion(&cv)
require.NoError(t, err)
require.Equal(t, "4", v.Major)
require.Equal(t, "16", v.Minor)
}

func Test_extractOpenShiftVersionWithHistory(t *testing.T) {
history := []configv1.UpdateHistory{
configv1.UpdateHistory{
State: "Partial",
StartedTime: metav1.Time{Time: time.Now().Add(-1 * time.Hour)},
CompletionTime: nil,
Version: "4.16.5",
},
configv1.UpdateHistory{
State: "Completed",
StartedTime: metav1.Time{Time: time.Now().Add(-3 * time.Hour)},
CompletionTime: &metav1.Time{Time: time.Now().Add(-2 * time.Hour)},
Version: "4.15.25",
Verified: true,
},
configv1.UpdateHistory{
State: "Completed",
StartedTime: metav1.Time{Time: time.Now().Add(-24 * time.Hour)},
CompletionTime: &metav1.Time{Time: time.Now().Add(-23 * time.Hour)},
Version: "4.14.29",
Verified: true,
},
}
cv := makeClusterVersion("4.16.5", history)
v, err := extractOpenShiftVersion(&cv)
require.NoError(t, err)
require.Equal(t, "4", v.Major)
require.Equal(t, "15", v.Minor, "Prefer completed upgrade in history over desired upgrade")
}

func makeClusterVersion(desired string, history []configv1.UpdateHistory) configv1.ClusterVersion {
return configv1.ClusterVersion{
ObjectMeta: metav1.ObjectMeta{
Name: "version",
},
Spec: configv1.ClusterVersionSpec{
ClusterID: "ocpID",
Channel: "stable-4.16",
},
Status: configv1.ClusterVersionStatus{
Desired: configv1.Release{
Version: desired,
},
History: history,
},
}
}
Loading
Loading