diff --git a/chart/templates/crds.yaml b/chart/templates/crds.yaml index 46cfc33..36bf50b 100644 --- a/chart/templates/crds.yaml +++ b/chart/templates/crds.yaml @@ -50,9 +50,39 @@ spec: version: type: string type: object + status: + properties: + conditions: + items: + properties: + lastTransitionTime: + type: string + message: + type: string + observedGeneration: + type: integer + reason: + type: string + status: + type: string + type: + type: string + required: + - type + - status + - lastTransitionTime + - reason + - message + type: object + type: array + observedGeneration: + type: integer + type: object type: object served: true storage: true + subresources: + status: {} --- apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition @@ -110,9 +140,39 @@ spec: type: object x-kubernetes-preserve-unknown-fields: true type: object + status: + properties: + conditions: + items: + properties: + lastTransitionTime: + type: string + message: + type: string + observedGeneration: + type: integer + reason: + type: string + status: + type: string + type: + type: string + required: + - type + - status + - lastTransitionTime + - reason + - message + type: object + type: array + observedGeneration: + type: integer + type: object type: object served: true storage: true + subresources: + status: {} --- apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition @@ -225,9 +285,39 @@ spec: - repoUrl - revision type: object + status: + properties: + conditions: + items: + properties: + lastTransitionTime: + type: string + message: + type: string + observedGeneration: + type: integer + reason: + type: string + status: + type: string + type: + type: string + required: + - type + - status + - lastTransitionTime + - reason + - message + type: object + type: array + observedGeneration: + type: integer + type: object required: - spec type: object served: true storage: true + subresources: + status: {} {{- end }} diff --git a/chart/tests/common-labels/expected.yaml b/chart/tests/common-labels/expected.yaml index 29e97da..357f12b 100644 --- a/chart/tests/common-labels/expected.yaml +++ b/chart/tests/common-labels/expected.yaml @@ -51,9 +51,39 @@ spec: version: type: string type: object + status: + properties: + conditions: + items: + properties: + lastTransitionTime: + type: string + message: + type: string + observedGeneration: + type: integer + reason: + type: string + status: + type: string + type: + type: string + required: + - type + - status + - lastTransitionTime + - reason + - message + type: object + type: array + observedGeneration: + type: integer + type: object type: object served: true storage: true + subresources: + status: {} --- apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition @@ -111,9 +141,39 @@ spec: type: object x-kubernetes-preserve-unknown-fields: true type: object + status: + properties: + conditions: + items: + properties: + lastTransitionTime: + type: string + message: + type: string + observedGeneration: + type: integer + reason: + type: string + status: + type: string + type: + type: string + required: + - type + - status + - lastTransitionTime + - reason + - message + type: object + type: array + observedGeneration: + type: integer + type: object type: object served: true storage: true + subresources: + status: {} --- apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition @@ -226,11 +286,41 @@ spec: - repoUrl - revision type: object + status: + properties: + conditions: + items: + properties: + lastTransitionTime: + type: string + message: + type: string + observedGeneration: + type: integer + reason: + type: string + status: + type: string + type: + type: string + required: + - type + - status + - lastTransitionTime + - reason + - message + type: object + type: array + observedGeneration: + type: integer + type: object required: - spec type: object served: true storage: true + subresources: + status: {} --- # Source: joy-operator-chart/templates/deployment.yaml apiVersion: apps/v1 diff --git a/chart/tests/helm/expected.yaml b/chart/tests/helm/expected.yaml index 0284774..427b8cb 100644 --- a/chart/tests/helm/expected.yaml +++ b/chart/tests/helm/expected.yaml @@ -51,9 +51,39 @@ spec: version: type: string type: object + status: + properties: + conditions: + items: + properties: + lastTransitionTime: + type: string + message: + type: string + observedGeneration: + type: integer + reason: + type: string + status: + type: string + type: + type: string + required: + - type + - status + - lastTransitionTime + - reason + - message + type: object + type: array + observedGeneration: + type: integer + type: object type: object served: true storage: true + subresources: + status: {} --- apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition @@ -111,9 +141,39 @@ spec: type: object x-kubernetes-preserve-unknown-fields: true type: object + status: + properties: + conditions: + items: + properties: + lastTransitionTime: + type: string + message: + type: string + observedGeneration: + type: integer + reason: + type: string + status: + type: string + type: + type: string + required: + - type + - status + - lastTransitionTime + - reason + - message + type: object + type: array + observedGeneration: + type: integer + type: object type: object served: true storage: true + subresources: + status: {} --- apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition @@ -226,11 +286,41 @@ spec: - repoUrl - revision type: object + status: + properties: + conditions: + items: + properties: + lastTransitionTime: + type: string + message: + type: string + observedGeneration: + type: integer + reason: + type: string + status: + type: string + type: + type: string + required: + - type + - status + - lastTransitionTime + - reason + - message + type: object + type: array + observedGeneration: + type: integer + type: object required: - spec type: object served: true storage: true + subresources: + status: {} --- # Source: joy-operator-chart/templates/deployment.yaml apiVersion: apps/v1 diff --git a/chart/tests/standard/expected.yaml b/chart/tests/standard/expected.yaml index 9000159..330e5de 100644 --- a/chart/tests/standard/expected.yaml +++ b/chart/tests/standard/expected.yaml @@ -51,9 +51,39 @@ spec: version: type: string type: object + status: + properties: + conditions: + items: + properties: + lastTransitionTime: + type: string + message: + type: string + observedGeneration: + type: integer + reason: + type: string + status: + type: string + type: + type: string + required: + - type + - status + - lastTransitionTime + - reason + - message + type: object + type: array + observedGeneration: + type: integer + type: object type: object served: true storage: true + subresources: + status: {} --- apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition @@ -111,9 +141,39 @@ spec: type: object x-kubernetes-preserve-unknown-fields: true type: object + status: + properties: + conditions: + items: + properties: + lastTransitionTime: + type: string + message: + type: string + observedGeneration: + type: integer + reason: + type: string + status: + type: string + type: + type: string + required: + - type + - status + - lastTransitionTime + - reason + - message + type: object + type: array + observedGeneration: + type: integer + type: object type: object served: true storage: true + subresources: + status: {} --- apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition @@ -226,11 +286,41 @@ spec: - repoUrl - revision type: object + status: + properties: + conditions: + items: + properties: + lastTransitionTime: + type: string + message: + type: string + observedGeneration: + type: integer + reason: + type: string + status: + type: string + type: + type: string + required: + - type + - status + - lastTransitionTime + - reason + - message + type: object + type: array + observedGeneration: + type: integer + type: object required: - spec type: object served: true storage: true + subresources: + status: {} --- # Source: joy-operator-chart/templates/deployment.yaml apiVersion: apps/v1 diff --git a/cmd/crd-gen/main.go b/cmd/crd-gen/main.go index 64b95fc..1f512be 100644 --- a/cmd/crd-gen/main.go +++ b/cmd/crd-gen/main.go @@ -24,6 +24,12 @@ func main() { } func run() error { + // The generated file is a Helm template: wrap all CRDs in an installCRDs + // guard so `task crd-gen` output is reproducible (see chart values.installCRDs). + if _, err := fmt.Fprintln(os.Stdout, "{{- if .Values.installCRDs -}}"); err != nil { + return err + } + encoder := yaml.NewEncoder(os.Stdout) encoder.SetIndent(2) @@ -31,6 +37,9 @@ func run() error { Names apiextv1.CustomResourceDefinitionNames Type reflect.Type Scope apiextv1.ResourceScope + // Status enables the /status subresource. Only resources whose + // reconciler reports a status (see joy-operator/cmd/operator) set this. + Status bool } for _, item := range []CRD{ @@ -42,8 +51,9 @@ func run() error { Kind: v1alpha1.ReleaseKind, ListKind: "ReleaseList", }, - Type: reflect.TypeFor[v1alpha1.Release](), - Scope: apiextv1.NamespaceScoped, + Type: reflect.TypeFor[v1alpha1.Release](), + Scope: apiextv1.NamespaceScoped, + Status: true, }, { Names: apiextv1.CustomResourceDefinitionNames{ @@ -53,8 +63,9 @@ func run() error { Kind: v1alpha1.EnvironmentKind, ListKind: "EnvironmentList", }, - Type: reflect.TypeFor[v1alpha1.Environment](), - Scope: apiextv1.ClusterScoped, + Type: reflect.TypeFor[v1alpha1.Environment](), + Scope: apiextv1.ClusterScoped, + Status: true, }, { Names: apiextv1.CustomResourceDefinitionNames{ @@ -75,8 +86,9 @@ func run() error { Kind: v1alpha1.CatalogKind, ListKind: "CatalogList", }, - Type: reflect.TypeFor[v1alpha1.Catalog](), - Scope: apiextv1.ClusterScoped, + Type: reflect.TypeFor[v1alpha1.Catalog](), + Scope: apiextv1.ClusterScoped, + Status: true, }, } { crd := apiextv1.CustomResourceDefinition{ @@ -98,6 +110,12 @@ func run() error { Served: true, Storage: true, Schema: &apiextv1.CustomResourceValidation{OpenAPIV3Schema: sanitizeSchema(openapi.SchemaFrom(item.Type))}, + Subresources: func() *apiextv1.CustomResourceSubresources { + if !item.Status { + return nil + } + return &apiextv1.CustomResourceSubresources{Status: &apiextv1.CustomResourceSubresourceStatus{}} + }(), }, }, }, @@ -115,6 +133,14 @@ func run() error { } } + if err := encoder.Close(); err != nil { + return fmt.Errorf("failed to flush encoder: %w", err) + } + + if _, err := fmt.Fprintln(os.Stdout, "{{- end }}"); err != nil { + return err + } + return nil } diff --git a/cmd/operator/main_test.go b/cmd/operator/main_test.go index 36734fc..1e69585 100644 --- a/cmd/operator/main_test.go +++ b/cmd/operator/main_test.go @@ -27,6 +27,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/client-go/tools/clientcmd" + "k8s.io/client-go/util/retry" "github.com/nestoca/joy/api/v1alpha1" "github.com/nestoca/joy/pkg/helm" @@ -329,7 +330,9 @@ func TestHappyReconciliations(t *testing.T) { Resource: "releases", }) - releaseIntf = releaseIntf.Namespace(env.Namespace) + // Releases live in a namespace named after the environment (env is + // cluster-scoped, so env.Namespace is empty). + releaseIntf = releaseIntf.Namespace(env.Name) release, err := releaseIntf.Create( t.Context(), @@ -489,9 +492,19 @@ func TestHappyReconciliations(t *testing.T) { assert(t, app) } - maps.Copy(release.Spec.Values, map[string]any{"freehand": "updated"}) - - _, err = releaseIntf.Update(t.Context(), release, metav1.UpdateOptions{}) + // Refetch before mutating: the operator now writes a status subresource, + // so the object created above has a stale resourceVersion. Retry on + // conflict to tolerate a concurrent status write. + err = retry.RetryOnConflict(retry.DefaultRetry, func() error { + var getErr error + release, getErr = releaseIntf.Get(t.Context(), "test", metav1.GetOptions{}) + if getErr != nil { + return getErr + } + maps.Copy(release.Spec.Values, map[string]any{"freehand": "updated"}) + _, updateErr := releaseIntf.Update(t.Context(), release, metav1.UpdateOptions{}) + return updateErr + }) require.NoError(t, err) EventuallyNoErrorf( @@ -515,8 +528,16 @@ func TestHappyReconciliations(t *testing.T) { "failed to see freehand property updated", ) - env.Spec.Values["env"] = "updated" - _, err = envIntf.Update(t.Context(), env, metav1.UpdateOptions{}) + err = retry.RetryOnConflict(retry.DefaultRetry, func() error { + var getErr error + env, getErr = envIntf.Get(t.Context(), "staging", metav1.GetOptions{}) + if getErr != nil { + return getErr + } + env.Spec.Values["env"] = "updated" + _, updateErr := envIntf.Update(t.Context(), env, metav1.UpdateOptions{}) + return updateErr + }) require.NoError(t, err) EventuallyNoErrorf( @@ -540,13 +561,19 @@ func TestHappyReconciliations(t *testing.T) { "failed to see env property updated", ) - defaultChartRef := catalog.Spec.Charts.Refs["default"] - defaultChartRef.Name = "beta" - - catalog.Spec.Charts.Refs["default"] = defaultChartRef - catalog.Spec.Revision = "HEAD" - - _, err = catalogIntf.Update(t.Context(), catalog, metav1.UpdateOptions{}) + err = retry.RetryOnConflict(retry.DefaultRetry, func() error { + var getErr error + catalog, getErr = catalogIntf.Get(t.Context(), "catalog", metav1.GetOptions{}) + if getErr != nil { + return getErr + } + defaultChartRef := catalog.Spec.Charts.Refs["default"] + defaultChartRef.Name = "beta" + catalog.Spec.Charts.Refs["default"] = defaultChartRef + catalog.Spec.Revision = "HEAD" + _, updateErr := catalogIntf.Update(t.Context(), catalog, metav1.UpdateOptions{}) + return updateErr + }) require.NoError(t, err) EventuallyNoErrorf( @@ -580,7 +607,7 @@ func TestHappyReconciliations(t *testing.T) { // And finally, the operator is non-destructive. require.NoError(t, catalogIntf.Delete(t.Context(), "catalog", metav1.DeleteOptions{})) require.NoError(t, envIntf.Delete(t.Context(), "staging", metav1.DeleteOptions{})) - require.NoError(t, releaseIntf.Namespace("staging").Delete(t.Context(), "test", metav1.DeleteOptions{})) + require.NoError(t, releaseIntf.Delete(t.Context(), "test", metav1.DeleteOptions{})) EventuallyNoErrorf( t, diff --git a/cmd/operator/reconciler_catalog.go b/cmd/operator/reconciler_catalog.go index 9912249..dd2b8b5 100644 --- a/cmd/operator/reconciler_catalog.go +++ b/cmd/operator/reconciler_catalog.go @@ -25,7 +25,7 @@ type CatalogReconcilerParams struct { func CatalogReconciler(params CatalogReconcilerParams) ctrl.Funcs { return ctrl.Funcs{ - Handler: func(ctx context.Context, event ctrl.Event) (ctrl.Result, error) { + Handler: func(ctx context.Context, event ctrl.Event) (_ ctrl.Result, retErr error) { if event.Name != params.CatalogName { return ctrl.Result{}, ctrl.Terminalf("unsupported catalog: wanted %q got %q", params.CatalogName, event.Name) } @@ -40,6 +40,10 @@ func CatalogReconciler(params CatalogReconcilerParams) ctrl.Funcs { return ctrl.Result{}, fmt.Errorf("failed to get catalog: %w", err) } + defer func() { + writeStatus(ctx, v1alpha1.CatalogGVR, catalog, retErr) + }() + appIntf := k8s.TypedInterface[argocd.Application](ctrl.Client(ctx), argocd.ApplicationGVR).Namespace("argocd") if params.Pull { diff --git a/cmd/operator/reconciler_environment.go b/cmd/operator/reconciler_environment.go index 99d8cb9..bcca954 100644 --- a/cmd/operator/reconciler_environment.go +++ b/cmd/operator/reconciler_environment.go @@ -28,7 +28,7 @@ type EnvironmentReconcilerParams struct { func EnvironmentReconciler(params EnvironmentReconcilerParams) ctrl.Funcs { return ctrl.Funcs{ - Handler: func(ctx context.Context, event ctrl.Event) (ctrl.Result, error) { + Handler: func(ctx context.Context, event ctrl.Event) (_ ctrl.Result, retErr error) { if !params.ManagedEnvs.Has(event.Name) { return ctrl.Result{}, nil } @@ -44,6 +44,10 @@ func EnvironmentReconciler(params EnvironmentReconcilerParams) ctrl.Funcs { return ctrl.Result{}, err } + defer func() { + writeStatus(ctx, v1alpha1.EnvironmentGVR, env, retErr) + }() + catalogCache := ctrl.Cache[v1alpha1.Catalog](ctx, v1alpha1.CatalogGK, "") catalog, err := catalogCache.Get(params.CatalogName) diff --git a/cmd/operator/reconciler_release.go b/cmd/operator/reconciler_release.go index 407fa40..d0ec447 100644 --- a/cmd/operator/reconciler_release.go +++ b/cmd/operator/reconciler_release.go @@ -35,7 +35,7 @@ type ReleaseReconcilerParams struct { func ReleaseReconciler(params ReleaseReconcilerParams) ctrl.Funcs { return ctrl.Funcs{ - Handler: func(ctx context.Context, event ctrl.Event) (ctrl.Result, error) { + Handler: func(ctx context.Context, event ctrl.Event) (_ ctrl.Result, retErr error) { destination, ok := params.EnvDestinations[event.Namespace] if !ok { return ctrl.Result{}, ctrl.Terminalf("release with unmanaged environment: %v", event.Namespace) @@ -51,6 +51,10 @@ func ReleaseReconciler(params ReleaseReconcilerParams) ctrl.Funcs { return ctrl.Result{}, fmt.Errorf("failed to get release: %w", err) } + defer func() { + writeStatus(ctx, v1alpha1.ReleaseGVR, release, retErr) + }() + envCache := ctrl.Cache[v1alpha1.Environment](ctx, v1alpha1.EnvironmentGK, "") release.Environment, err = envCache.Get(release.Namespace) if err != nil { diff --git a/cmd/operator/status.go b/cmd/operator/status.go new file mode 100644 index 0000000..d1d3627 --- /dev/null +++ b/cmd/operator/status.go @@ -0,0 +1,54 @@ +package main + +import ( + "context" + + "github.com/yokecd/yoke/pkg/k8s" + "github.com/yokecd/yoke/pkg/k8s/ctrl" + + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + + "github.com/nestoca/joy/api/v1alpha1" +) + +func writeStatus[T any, PT v1alpha1.StatusObject[T]]( + ctx context.Context, + gvr schema.GroupVersionResource, + obj PT, + reconcileErr error, +) { + cond := metav1.Condition{ + Type: v1alpha1.ConditionReady, + ObservedGeneration: obj.GetGeneration(), + } + if reconcileErr != nil { + cond.Status = metav1.ConditionFalse + cond.Reason = "ReconcileError" + cond.Message = reconcileErr.Error() + } else { + cond.Status = metav1.ConditionTrue + cond.Reason = "ReconcileSuccess" + cond.Message = "Successfully reconciled" + } + + status := obj.GetStatus() + changed := apimeta.SetStatusCondition(&status.Conditions, cond) + if status.ObservedGeneration != obj.GetGeneration() { + status.ObservedGeneration = obj.GetGeneration() + changed = true + } + + // As the controller re-enqueues on every update, let's avoid an infinite reconcile loop. + if !changed { + return + } + + if _, err := k8s.TypedInterface[T, PT](ctrl.Client(ctx), gvr). + UpdateStatus(ctx, obj, metav1.UpdateOptions{FieldManager: joyOperator}); err != nil { + if logger := ctrl.Logger(ctx); logger != nil { + logger.Error("failed to update resource status", "gvr", gvr.String(), "name", obj.GetName(), "error", err.Error()) + } + } +} diff --git a/go.mod b/go.mod index b8e780a..079bc94 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require ( github.com/davidmdm/x/xcontainer v0.0.3 github.com/davidmdm/x/xcontext v0.0.4 github.com/davidmdm/x/xsync v0.0.3 - github.com/nestoca/joy v0.96.0 + github.com/nestoca/joy v0.99.0 github.com/stretchr/testify v1.11.1 github.com/yokecd/yoke v0.20.23 go.yaml.in/yaml/v3 v3.0.4 diff --git a/go.sum b/go.sum index 53ff039..0c50085 100644 --- a/go.sum +++ b/go.sum @@ -154,8 +154,8 @@ github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 h1:n6/ github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00/go.mod h1:Pm3mSP3c5uWn86xMLZ5Sa7JB9GsEZySvHYXCTK4E9q4= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/nestoca/joy v0.96.0 h1:KQmNZO3YkL2oFRTVsjhUMPT3zg74JvPYDkIsa4T07FM= -github.com/nestoca/joy v0.96.0/go.mod h1:IJYR2DdFyEqg6bmLY3v2STv1iEsGjnTrUTYIp+w5yb8= +github.com/nestoca/joy v0.99.0 h1:31vggJJTelV508oD5J+UnlqxoG2Z4zTf7hQD+MPeqkM= +github.com/nestoca/joy v0.99.0/go.mod h1:/RJ3tk/egrl2Ob/VkaadWK7st1P0Y6QP0642C5z0JsY= github.com/nestoca/survey/v2 v2.0.0 h1:orM/TXtBSQJCPiZy9N51hcwH0WzFjQJ7TQKCfMTnGoQ= github.com/nestoca/survey/v2 v2.0.0/go.mod h1:QfmcQfCRtqsiBpePFhHEW0MY9QH7lSpw3CQinsdC/dc= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=