appliance: reconciliation helpers (#61932)

* appliance: set pseudo-CRD name values

* appliance: receive and store requested version

Make the version part of the appliance-driven config, and store it on a
ConfigMap annotation. Service-specific upgrade logic then has access to
both the old and the new version, supporting complex multiversion
upgrades as needed.

The "Sourcegraph" config object is a kubebuilder-scaffolded custom type,
but we don't actually use CRDs. We drive its status field using this
ConfigMap annotation, so that lower-level code can behave as if we do
use CRDs. This is similar to what we do with the namespace field.

* appliance: add some standard labels to default deployment

Notably the version, which might be useful for debugging. Label keys
lifted from the helm chart.

* appliance: deploy blobstore deployment using new reconciliation logic
This commit is contained in:
Craig Furman 2024-04-19 09:14:56 +01:00 committed by GitHub
parent 51235ab4a0
commit 9bf06b664c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 230 additions and 149 deletions

View File

@ -5,6 +5,7 @@ go_library(
name = "appliance",
srcs = [
"blobstore.go",
"kubernetes.go",
"reconcile.go",
"spec.go",
],
@ -17,7 +18,6 @@ go_library(
"//internal/k8s/resource/pod",
"//internal/k8s/resource/pvc",
"//internal/k8s/resource/service",
"//internal/maps",
"//lib/errors",
"//lib/pointers",
"@io_k8s_api//apps/v1:apps",

View File

@ -3,35 +3,30 @@ package appliance
import (
"context"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/resource"
"k8s.io/apimachinery/pkg/util/intstr"
"sigs.k8s.io/controller-runtime/pkg/client"
"github.com/sourcegraph/sourcegraph/lib/errors"
"github.com/sourcegraph/sourcegraph/lib/pointers"
"github.com/sourcegraph/sourcegraph/internal/appliance/hash"
"github.com/sourcegraph/sourcegraph/internal/k8s/resource/container"
"github.com/sourcegraph/sourcegraph/internal/k8s/resource/deployment"
"github.com/sourcegraph/sourcegraph/internal/k8s/resource/pod"
"github.com/sourcegraph/sourcegraph/internal/k8s/resource/pvc"
"github.com/sourcegraph/sourcegraph/internal/k8s/resource/service"
"github.com/sourcegraph/sourcegraph/internal/maps"
"github.com/sourcegraph/sourcegraph/lib/errors"
"github.com/sourcegraph/sourcegraph/lib/pointers"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/resource"
"k8s.io/apimachinery/pkg/util/intstr"
"sigs.k8s.io/controller-runtime/pkg/client"
)
func (r *Reconciler) reconcileBlobstore(ctx context.Context, sg *Sourcegraph) error {
if err := r.reconcileBlobstorePersistentVolumeClaims(ctx, sg); err != nil {
func (r *Reconciler) reconcileBlobstore(ctx context.Context, sg *Sourcegraph, owner client.Object) error {
if err := r.reconcileBlobstorePersistentVolumeClaims(ctx, sg, owner); err != nil {
return err
}
if err := r.reconcileBlobstoreServices(ctx, sg); err != nil {
if err := r.reconcileBlobstoreServices(ctx, sg, owner); err != nil {
return err
}
if err := r.reconcileBlobstoreDeployments(ctx, sg); err != nil {
if err := r.reconcileBlobstoreDeployments(ctx, sg, owner); err != nil {
return err
}
@ -72,42 +67,13 @@ func buildBlobstorePersistentVolumeClaim(sg *Sourcegraph) (corev1.PersistentVolu
return p, nil
}
func (r *Reconciler) reconcileBlobstorePersistentVolumeClaims(ctx context.Context, sg *Sourcegraph) error {
func (r *Reconciler) reconcileBlobstorePersistentVolumeClaims(ctx context.Context, sg *Sourcegraph, owner client.Object) error {
p, err := buildBlobstorePersistentVolumeClaim(sg)
if err != nil {
return err
}
p.Labels = hash.SetTemplateHashLabel(p.Labels, p.Spec)
var existing corev1.PersistentVolumeClaim
if r.IsObjectFound(ctx, p.Name, p.Namespace, &existing) {
if sg.Spec.Blobstore.Disabled {
return nil
}
// Object exists update if needed
if hash.GetTemplateHashLabel(existing.Labels) == hash.GetTemplateHashLabel(p.Labels) {
// no updates needed
return nil
}
// need to update
existing.Labels = maps.Merge(existing.Labels, p.Labels)
existing.Annotations = maps.Merge(existing.Annotations, p.Annotations)
existing.Spec = p.Spec
return r.Update(ctx, &existing)
}
if sg.Spec.Blobstore.Disabled {
return nil
}
// Note: we don't set a controller reference here as we want PVCs to persist if blobstore is deleted.
// This helps to protect against accidental data deletions.
return r.Create(ctx, &p)
return reconcileBlobStoreObject(ctx, r, &p, &corev1.PersistentVolumeClaim{}, sg, owner)
}
func buildBlobstoreService(sg *Sourcegraph) (corev1.Service, error) {
@ -131,53 +97,12 @@ func buildBlobstoreService(sg *Sourcegraph) (corev1.Service, error) {
return s, nil
}
func (r *Reconciler) reconcileBlobstoreServices(ctx context.Context, sg *Sourcegraph) error {
func (r *Reconciler) reconcileBlobstoreServices(ctx context.Context, sg *Sourcegraph, owner client.Object) error {
s, err := buildBlobstoreService(sg)
if err != nil {
return err
}
s.Labels = hash.SetTemplateHashLabel(s.Labels, s.Spec)
var existing corev1.Service
if r.IsObjectFound(ctx, s.Name, sg.Namespace, &existing) {
if sg.Spec.Blobstore.Disabled {
// blobstore service exists, but has been disabled. Delete the service.
//
// Using a precondition to make sure the version of the resource that is deleted
// is the version we intend, and not a resource that was already resgeated.
err = r.Delete(ctx, &existing, client.Preconditions{
UID: &existing.UID,
ResourceVersion: &existing.ResourceVersion,
})
if err != nil && !apierrors.IsNotFound(err) {
return err
}
return nil
}
// Object exists update if needed
if hash.GetTemplateHashLabel(existing.Labels) == hash.GetTemplateHashLabel(s.Labels) {
// no updates needed
return nil
}
// need to update
existing.Labels = maps.Merge(existing.Labels, s.Labels)
existing.Annotations = maps.Merge(existing.Annotations, s.Annotations)
existing.Spec = s.Spec
return r.Update(ctx, &existing)
}
if sg.Spec.Blobstore.Disabled {
return nil
}
// TODO set owner ref
return r.Create(ctx, &s)
return reconcileBlobStoreObject(ctx, r, &s, &corev1.Service{}, sg, owner)
}
func buildBlobstoreDeployment(sg *Sourcegraph) (appsv1.Deployment, error) {
@ -272,6 +197,7 @@ func buildBlobstoreDeployment(sg *Sourcegraph) (appsv1.Deployment, error) {
defaultDeployment, err := deployment.NewDeployment(
name,
sg.Namespace,
sg.Spec.RequestedVersion,
deployment.WithPodTemplateSpec(podTemplate.Template),
)
@ -282,51 +208,29 @@ func buildBlobstoreDeployment(sg *Sourcegraph) (appsv1.Deployment, error) {
return defaultDeployment, nil
}
func (r *Reconciler) reconcileBlobstoreDeployments(ctx context.Context, sg *Sourcegraph) error {
func (r *Reconciler) reconcileBlobstoreDeployments(ctx context.Context, sg *Sourcegraph, owner client.Object) error {
d, err := buildBlobstoreDeployment(sg)
if err != nil {
return err
}
d.Labels = hash.SetTemplateHashLabel(d.Labels, d.Spec)
var existing appsv1.Deployment
if r.IsObjectFound(ctx, d.Name, sg.Namespace, &existing) {
if sg.Spec.Blobstore.Disabled {
// blobstore deployment exists, but has been disabled. Delete the deployment.
//
// Using a precondition to make sure the version of the resource that is deleted
// is the version we intend, and not a resource that was already recreated.
err = r.Delete(ctx, &existing, client.Preconditions{
UID: &existing.UID,
ResourceVersion: &existing.ResourceVersion,
})
if err != nil && !apierrors.IsNotFound(err) {
return err
}
return nil
}
// Object exists update if needed
if hash.GetTemplateHashLabel(existing.Labels) == hash.GetTemplateHashLabel(d.Labels) {
// no updates needed
return nil
}
// need to update
existing.Labels = maps.Merge(existing.Labels, d.Labels)
existing.Annotations = maps.Merge(existing.Annotations, d.Annotations)
existing.Spec = d.Spec
return r.Update(ctx, &existing)
}
if sg.Spec.Blobstore.Disabled {
return nil
}
// TODO set owner ref
return r.Create(ctx, &d)
return reconcileBlobStoreObject(ctx, r, &d, &appsv1.Deployment{}, sg, owner)
}
func reconcileBlobStoreObject[T client.Object](ctx context.Context, r *Reconciler, obj, objKind T, sg *Sourcegraph, owner client.Object) error {
if sg.Spec.Blobstore.Disabled {
return r.ensureObjectDeleted(ctx, obj)
}
// Any secrets (or other configmaps) referenced in BlobStoreSpec can be
// added to this struct so that they are hashed, and cause an update to the
// Deployment if changed.
updateIfChanged := struct {
BlobstoreSpec
Version string
}{
BlobstoreSpec: sg.Spec.Blobstore,
Version: sg.Spec.RequestedVersion,
}
return createOrUpdateObject(ctx, r, updateIfChanged, owner, obj, objKind)
}

View File

@ -0,0 +1,104 @@
package appliance
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
kerrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/types"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/log"
"github.com/sourcegraph/sourcegraph/lib/errors"
)
// Upsert a Kubernetes object.
//
// obj is the object you want to reconcile, updating an existing cluster object
// if it has changed, or creating it if none existed before.
//
// objKind should be the same type as obj, usually an instantiated
// struct-pointer to a particular Kubernetes object type, e.g.
// `&appsv1.Deployment{}`. It is used to hold data about any existing object of
// the same name, to compare it to obj, and possibly be replaced by obj.
//
// updateIfChanged is the object whose hash we store in an annotation to
// determine whether an existing in-cluster object is out of date and needs to
// be replaced.
//
// Takes the reconciler as a parameter rather than being a method on it due to
// limitations of Go generics.
func createOrUpdateObject[R client.Object](
ctx context.Context, r *Reconciler, updateIfChanged any,
owner client.Object, obj client.Object, objKind R,
) error {
logger := log.FromContext(ctx).WithValues("kind", obj.GetObjectKind().GroupVersionKind(), "namespace", obj.GetNamespace(), "name", obj.GetName())
namespacedName := types.NamespacedName{Namespace: obj.GetNamespace(), Name: obj.GetName()}
cfgHash, err := configHash(updateIfChanged)
if err != nil {
return err
}
annotations := obj.GetAnnotations()
if annotations == nil {
annotations = map[string]string{}
}
annotations[annotationKeyConfigHash] = cfgHash
obj.SetAnnotations(annotations)
existingRes := objKind
if err := r.Client.Get(ctx, namespacedName, existingRes); err != nil {
if kerrors.IsNotFound(err) {
logger.Info("didn't find existing object, creating it")
if err := r.Client.Create(ctx, obj); err != nil {
logger.Error(err, "error creating object")
return err
}
return nil
}
logger.Error(err, "unexpected error getting object")
return err
}
if err := ctrl.SetControllerReference(owner, obj, r.Scheme); err != nil {
return errors.Newf("setting controller reference: %w", err)
}
if cfgHash != existingRes.GetAnnotations()[annotationKeyConfigHash] {
logger.Info("Found existing object with spec that does not match desired state. Clobbering it.")
if err := r.Client.Update(ctx, obj); err != nil {
logger.Error(err, "error updating object")
return err
}
return nil
}
logger.Info("Found existing object with spec that matches the desired state. Will do nothing.")
return nil
}
func (r *Reconciler) ensureObjectDeleted(ctx context.Context, obj client.Object) error {
logger := log.FromContext(ctx).WithValues("kind", obj.GetObjectKind().GroupVersionKind(), "namespace", obj.GetNamespace(), "name", obj.GetName())
if err := r.Client.Delete(ctx, obj); err != nil {
if kerrors.IsNotFound(err) {
return nil
}
logger.Error(err, "unexpected error deleting resource")
return err
}
return nil
}
func configHash(configElement any) (string, error) {
cfgBytes, err := json.Marshal(configElement)
if err != nil {
return "", err
}
hash := sha256.Sum256(cfgBytes)
return hex.EncodeToString(hash[:]), nil
}

View File

@ -21,6 +21,11 @@ import (
"github.com/sourcegraph/sourcegraph/internal/appliance/hash"
)
const (
annotationKeyCurrentVersion = "appliance.sourcegraph.com/currentVersion"
annotationKeyConfigHash = "appliance.sourcegraph.com/configHash"
)
var _ reconcile.Reconciler = &Reconciler{}
type Reconciler struct {
@ -56,6 +61,28 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu
return reconcile.Result{}, err
}
// Sourcegraph is a kubebuilder-scaffolded custom type, but we do not
// actually ask operators to install CRDs. Therefore we set its namespace
// based on the actual object being reconciled, so that more deeply-nested
// code can treat it like a CRD.
sourcegraph.Namespace = applianceSpec.GetNamespace()
// Similarly, we simulate a CRD status using an annotation. ConfigMaps don't
// have Statuses, so we must use annotations to drive this.
// This can be empty string.
sourcegraph.Status.CurrentVersion = applianceSpec.GetAnnotations()[annotationKeyCurrentVersion]
// Reconcile services here
if err := r.reconcileBlobstore(ctx, &sourcegraph, &applianceSpec); err != nil {
return ctrl.Result{}, errors.Newf("failed to reconcile blobstore: %w", err)
}
// Set the current version annotation in case migration logic depends on it.
applianceSpec.Annotations[annotationKeyCurrentVersion] = sourcegraph.Spec.RequestedVersion
if err := r.Client.Update(ctx, &applianceSpec); err != nil {
return ctrl.Result{}, errors.Newf("failed to update current version annotation: %w", err)
}
return ctrl.Result{}, nil
}

View File

@ -349,6 +349,9 @@ type StorageClassSpec struct {
// SourcegraphSpec defines the desired state of Sourcegraph
type SourcegraphSpec struct {
// RequestedVersion is the user-requested version of Sourcegraph to deploy.
RequestedVersion string `json:"requestedVersion"`
// ManagementState defines if Sourcegraph should be managed by the operator or not.
// Default is managed.
ManagementState ManagementStateType `json:"managementState,omitempty"`

View File

@ -20,13 +20,16 @@ import (
// - DeploymentStrategy: RecreateDeploymentStrategy
//
// Additional options can be passed to modify the default values.
func NewDeployment(name, namespace string, options ...Option) (appsv1.Deployment, error) {
func NewDeployment(name, namespace, version string, options ...Option) (appsv1.Deployment, error) {
deployment := appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: namespace,
Labels: map[string]string{
"deploy": "sourcegraph",
"app.kubernetes.io/component": name,
"app.kubernetes.io/name": "sourcegraph",
"app.kubernetes.io/version": version,
"deploy": "sourcegraph",
},
},
Spec: appsv1.DeploymentSpec{

View File

@ -18,6 +18,7 @@ func TestNewDeployment(t *testing.T) {
type args struct {
name string
namespace string
version string
options []Option
}
@ -31,13 +32,17 @@ func TestNewDeployment(t *testing.T) {
args: args{
name: "foo",
namespace: "sourcegraph",
version: "1.2.3",
},
want: appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Name: "foo",
Namespace: "sourcegraph",
Labels: map[string]string{
"deploy": "sourcegraph",
"app.kubernetes.io/component": "foo",
"app.kubernetes.io/name": "sourcegraph",
"app.kubernetes.io/version": "1.2.3",
"deploy": "sourcegraph",
},
},
Spec: appsv1.DeploymentSpec{
@ -61,6 +66,7 @@ func TestNewDeployment(t *testing.T) {
args: args{
name: "foo",
namespace: "sourcegraph",
version: "1.2.3",
options: []Option{
WithLabels(map[string]string{
"deploy": "horsegraph",
@ -73,8 +79,11 @@ func TestNewDeployment(t *testing.T) {
Name: "foo",
Namespace: "sourcegraph",
Labels: map[string]string{
"app": "horsegraph",
"deploy": "sourcegraph",
"app.kubernetes.io/component": "foo",
"app.kubernetes.io/name": "sourcegraph",
"app.kubernetes.io/version": "1.2.3",
"app": "horsegraph",
"deploy": "sourcegraph",
},
},
Spec: appsv1.DeploymentSpec{
@ -98,6 +107,7 @@ func TestNewDeployment(t *testing.T) {
args: args{
name: "foo",
namespace: "sourcegraph",
version: "1.2.3",
options: []Option{
WithAnnotations(map[string]string{
"app": "horsegraph",
@ -110,7 +120,10 @@ func TestNewDeployment(t *testing.T) {
Name: "foo",
Namespace: "sourcegraph",
Labels: map[string]string{
"deploy": "sourcegraph",
"app.kubernetes.io/component": "foo",
"app.kubernetes.io/name": "sourcegraph",
"app.kubernetes.io/version": "1.2.3",
"deploy": "sourcegraph",
},
Annotations: map[string]string{
"app": "horsegraph",
@ -138,6 +151,7 @@ func TestNewDeployment(t *testing.T) {
args: args{
name: "foo",
namespace: "sourcegraph",
version: "1.2.3",
options: []Option{
WithMinReadySeconds(int32(20)),
},
@ -147,7 +161,10 @@ func TestNewDeployment(t *testing.T) {
Name: "foo",
Namespace: "sourcegraph",
Labels: map[string]string{
"deploy": "sourcegraph",
"app.kubernetes.io/component": "foo",
"app.kubernetes.io/name": "sourcegraph",
"app.kubernetes.io/version": "1.2.3",
"deploy": "sourcegraph",
},
},
Spec: appsv1.DeploymentSpec{
@ -171,6 +188,7 @@ func TestNewDeployment(t *testing.T) {
args: args{
name: "foo",
namespace: "sourcegraph",
version: "1.2.3",
options: []Option{
WithReplicas(int32(10)),
},
@ -180,7 +198,10 @@ func TestNewDeployment(t *testing.T) {
Name: "foo",
Namespace: "sourcegraph",
Labels: map[string]string{
"deploy": "sourcegraph",
"app.kubernetes.io/component": "foo",
"app.kubernetes.io/name": "sourcegraph",
"app.kubernetes.io/version": "1.2.3",
"deploy": "sourcegraph",
},
},
Spec: appsv1.DeploymentSpec{
@ -204,6 +225,7 @@ func TestNewDeployment(t *testing.T) {
args: args{
name: "foo",
namespace: "sourcegraph",
version: "1.2.3",
options: []Option{
WithRevisionHistoryLimit(int32(100)),
},
@ -213,7 +235,10 @@ func TestNewDeployment(t *testing.T) {
Name: "foo",
Namespace: "sourcegraph",
Labels: map[string]string{
"deploy": "sourcegraph",
"app.kubernetes.io/component": "foo",
"app.kubernetes.io/name": "sourcegraph",
"app.kubernetes.io/version": "1.2.3",
"deploy": "sourcegraph",
},
},
Spec: appsv1.DeploymentSpec{
@ -237,6 +262,7 @@ func TestNewDeployment(t *testing.T) {
args: args{
name: "foo",
namespace: "sourcegraph",
version: "1.2.3",
options: []Option{
WithSelector(metav1.LabelSelector{
MatchLabels: map[string]string{
@ -250,7 +276,10 @@ func TestNewDeployment(t *testing.T) {
Name: "foo",
Namespace: "sourcegraph",
Labels: map[string]string{
"deploy": "sourcegraph",
"app.kubernetes.io/component": "foo",
"app.kubernetes.io/name": "sourcegraph",
"app.kubernetes.io/version": "1.2.3",
"deploy": "sourcegraph",
},
},
Spec: appsv1.DeploymentSpec{
@ -274,6 +303,7 @@ func TestNewDeployment(t *testing.T) {
args: args{
name: "foo",
namespace: "sourcegraph",
version: "1.2.3",
options: []Option{
WithDeploymentStrategy(appsv1.DeploymentStrategy{
Type: appsv1.RollingUpdateDeploymentStrategyType,
@ -285,7 +315,10 @@ func TestNewDeployment(t *testing.T) {
Name: "foo",
Namespace: "sourcegraph",
Labels: map[string]string{
"deploy": "sourcegraph",
"app.kubernetes.io/component": "foo",
"app.kubernetes.io/name": "sourcegraph",
"app.kubernetes.io/version": "1.2.3",
"deploy": "sourcegraph",
},
},
Spec: appsv1.DeploymentSpec{
@ -309,6 +342,7 @@ func TestNewDeployment(t *testing.T) {
args: args{
name: "foo",
namespace: "sourcegraph",
version: "1.2.3",
options: []Option{
WithPodTemplateSpec(func() corev1.PodTemplateSpec {
ts, _ := pod.NewPodTemplate("foo", "sourcegraph")
@ -321,7 +355,10 @@ func TestNewDeployment(t *testing.T) {
Name: "foo",
Namespace: "sourcegraph",
Labels: map[string]string{
"deploy": "sourcegraph",
"app.kubernetes.io/component": "foo",
"app.kubernetes.io/name": "sourcegraph",
"app.kubernetes.io/version": "1.2.3",
"deploy": "sourcegraph",
},
},
Spec: appsv1.DeploymentSpec{
@ -366,7 +403,7 @@ func TestNewDeployment(t *testing.T) {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := NewDeployment(tt.args.name, tt.args.namespace, tt.args.options...)
got, err := NewDeployment(tt.args.name, tt.args.namespace, tt.args.version, tt.args.options...)
if err != nil {
t.Errorf("NewDeployment() error: %v", err)
}

View File

@ -8,7 +8,7 @@ import (
)
func ExampleDeployment() {
d, _ := NewDeployment("test", "sourcegraph")
d, _ := NewDeployment("test", "sourcegraph", "1.2.3")
jd, _ := json.Marshal(d)
fmt.Println(string(jd))
@ -17,10 +17,13 @@ func ExampleDeployment() {
fmt.Println(string(yd))
// Output:
// {"metadata":{"name":"test","namespace":"sourcegraph","creationTimestamp":null,"labels":{"deploy":"sourcegraph"}},"spec":{"replicas":1,"selector":{"matchLabels":{"app":"test"}},"template":{"metadata":{"creationTimestamp":null},"spec":{"containers":null}},"strategy":{"type":"Recreate"},"minReadySeconds":10,"revisionHistoryLimit":10},"status":{}}
// {"metadata":{"name":"test","namespace":"sourcegraph","creationTimestamp":null,"labels":{"app.kubernetes.io/component":"test","app.kubernetes.io/name":"sourcegraph","app.kubernetes.io/version":"1.2.3","deploy":"sourcegraph"}},"spec":{"replicas":1,"selector":{"matchLabels":{"app":"test"}},"template":{"metadata":{"creationTimestamp":null},"spec":{"containers":null}},"strategy":{"type":"Recreate"},"minReadySeconds":10,"revisionHistoryLimit":10},"status":{}}
// metadata:
// creationTimestamp: null
// labels:
// app.kubernetes.io/component: test
// app.kubernetes.io/name: sourcegraph
// app.kubernetes.io/version: 1.2.3
// deploy: sourcegraph
// name: test
// namespace: sourcegraph