diff --git a/PROJECT b/PROJECT index f41b947..4fcd309 100644 --- a/PROJECT +++ b/PROJECT @@ -17,4 +17,13 @@ resources: kind: FluxInstance path: github.com/controlplaneio-fluxcd/flux-operator/api/v1 version: v1 +- api: + crdVersion: v1 + namespaced: true + controller: true + domain: controlplane.io + group: fluxcd + kind: FluxReport + path: github.com/controlplaneio-fluxcd/flux-operator/api/v1 + version: v1 version: "3" diff --git a/README.md b/README.md index 0de02b5..fbf234c 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ the lifecycle of CNCF [Flux CD](https://fluxcd.io) and the - Automates the patching of hotfixes and CVEs affecting the Flux controllers container images. - Simplifies the configuration of multi-tenancy lockdown on shared Kubernetes clusters. - Allows syncing the cluster state from Git repositories, OCI artifacts and S3-compatible storage. +- Generates detailed reports about the Flux deployment readiness status and reconcilers statistics. - Provides a security-first approach to the Flux deployment and FIPS compliance. - Incorporates best practices for running Flux at scale with persistent storage and vertical scaling. - Manages the update of Flux custom resources and prevents disruption during the upgrade process. @@ -32,6 +33,7 @@ the lifecycle of CNCF [Flux CD](https://fluxcd.io) and the - [Flux operator installation](https://fluxcd.control-plane.io/operator/install/) - [Flux controllers configuration](https://fluxcd.control-plane.io/operator/flux-config/) +- [Flux instance customization](https://fluxcd.control-plane.io/operator/flux-kustomize/) - [Cluster sync configuration](https://fluxcd.control-plane.io/operator/flux-sync/) - [Migration of bootstrapped clusters](https://fluxcd.control-plane.io/operator/flux-bootstrap-migration/) - [FluxInstance API reference](https://fluxcd.control-plane.io/operator/fluxinstance/) @@ -55,7 +57,7 @@ helm install flux-operator oci://ghcr.io/controlplaneio-fluxcd/charts/flux-opera ### Install the Flux Controllers Create a [FluxInstance](https://fluxcd.control-plane.io/operator/fluxinstance/) resource -in the `flux-system` namespace to install the latest Flux stable version: +named `flux` in the `flux-system` namespace to install the latest Flux stable version: ```yaml apiVersion: fluxcd.controlplane.io/v1 @@ -136,6 +138,20 @@ flux create secret git flux-system \ > container registries and S3-compatible storage, refer to the > [cluster sync guide](https://fluxcd.control-plane.io/operator/flux-sync/). +### Monitor the Flux Installation + +To monitor the Flux deployment status, check the +[FluxReport](https://fluxcd.control-plane.io/operator/fluxreport/) +resource in the `flux-system` namespace: + +```shell +kubectl get fluxreport/flux -n flux-system -o yaml +``` + +The report is update at regular intervals and contains information about the deployment +readiness status, the distribution details, reconcilers statistics, Flux CRDs versions, +the cluster sync status and more. + ## License The Flux Operator is an open-source project licensed under the diff --git a/api/v1/fluxinstance_types.go b/api/v1/fluxinstance_types.go index 857260a..a5c4dcd 100644 --- a/api/v1/fluxinstance_types.go +++ b/api/v1/fluxinstance_types.go @@ -15,9 +15,11 @@ import ( ) const ( - FluxInstanceKind = "FluxInstance" - EnabledValue = "enabled" - DisabledValue = "disabled" + DefaultInstanceName = "flux" + DefaultNamespace = "flux-system" + FluxInstanceKind = "FluxInstance" + EnabledValue = "enabled" + DisabledValue = "disabled" ) var ( diff --git a/api/v1/fluxreport_types.go b/api/v1/fluxreport_types.go new file mode 100644 index 0000000..7d127f6 --- /dev/null +++ b/api/v1/fluxreport_types.go @@ -0,0 +1,211 @@ +// Copyright 2024 Stefan Prodan. +// SPDX-License-Identifier: AGPL-3.0 + +package v1 + +import ( + "strings" + "time" + + "github.com/fluxcd/pkg/apis/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const FluxReportKind = "FluxReport" + +// FluxReportSpec defines the observed state of a Flux installation. +type FluxReportSpec struct { + // Distribution is the version information of the Flux installation. + // +required + Distribution FluxDistributionStatus `json:"distribution"` + + // ComponentsStatus is the status of the Flux controller deployments. + // +optional + ComponentsStatus []FluxComponentStatus `json:"components,omitempty"` + + // ReconcilersStatus is the list of Flux reconcilers and + // their statistics grouped by API kind. + // +optional + ReconcilersStatus []FluxReconcilerStatus `json:"reconcilers,omitempty"` + + // SyncStatus is the status of the cluster sync + // Source and Kustomization resources. + // +optional + SyncStatus *FluxSyncStatus `json:"sync,omitempty"` +} + +// FluxDistributionStatus defines the version information of the Flux instance. +type FluxDistributionStatus struct { + // Entitlement is the entitlement verification status. + // +required + Entitlement string `json:"entitlement"` + + // Status is a human-readable message indicating details + // about the distribution observed state. + // +required + Status string `json:"status"` + + // Version is the version of the Flux instance. + // +optional + Version string `json:"version,omitempty"` + + // ManagedBy is the name of the operator managing the Flux instance. + // +optional + ManagedBy string `json:"managedBy,omitempty"` +} + +// FluxComponentStatus defines the observed state of a Flux component. +type FluxComponentStatus struct { + // Name is the name of the Flux component. + // +required + Name string `json:"name"` + + // Ready is the readiness status of the Flux component. + // +required + Ready bool `json:"ready"` + + // Status is a human-readable message indicating details + // about the Flux component observed state. + // +required + Status string `json:"status"` + + // Image is the container image of the Flux component. + // +required + Image string `json:"image"` +} + +// FluxReconcilerStatus defines the observed state of a Flux reconciler. +type FluxReconcilerStatus struct { + // APIVersion is the API version of the Flux resource. + // +required + APIVersion string `json:"apiVersion"` + + // Kind is the kind of the Flux resource. + // +required + Kind string `json:"kind"` + + // Stats is the reconcile statics of the Flux resource kind. + // +optional + Stats FluxReconcilerStats `json:"stats,omitempty"` +} + +// FluxReconcilerStats defines the reconcile statistics. +type FluxReconcilerStats struct { + // Running is the number of reconciled + // resources in the Running state. + // +required + Running int `json:"running"` + + // Failing is the number of reconciled + // resources in the Failing state. + // +required + Failing int `json:"failing"` + + // Suspended is the number of reconciled + // resources in the Suspended state. + // +required + Suspended int `json:"suspended"` + + // TotalSize is the total size of the artifacts in storage. + // +optional + TotalSize string `json:"totalSize,omitempty"` +} + +// FluxSyncStatus defines the observed state of the cluster sync. +type FluxSyncStatus struct { + // ID is the identifier of the sync. + // +required + ID string `json:"id"` + + // Path is the kustomize path of the sync. + // +optional + Path string `json:"path,omitempty"` + + // Ready is the readiness status of the sync. + // +required + Ready bool `json:"ready"` + + // Status is a human-readable message indicating details + // about the sync observed state. + // +required + Status string `json:"status"` + + // Source is the URL of the source repository. + // +optional + Source string `json:"source,omitempty"` +} + +// FluxReportStatus defines the readiness of a FluxReport. +type FluxReportStatus struct { + meta.ReconcileRequestStatus `json:",inline"` + + // Conditions contains the readiness conditions of the object. + // +optional + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +// +kubebuilder:storageversion +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:printcolumn:name="Entitlement",type="string",JSONPath=".spec.distribution.entitlement",description="",priority=10 +// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp",description="" +// +kubebuilder:printcolumn:name="Ready",type="string",JSONPath=".status.conditions[?(@.type==\"Ready\")].status",description="" +// +kubebuilder:printcolumn:name="Status",type="string",JSONPath=".status.conditions[?(@.type==\"Ready\")].message",description="" +// +kubebuilder:printcolumn:name="LastUpdated",type="string",JSONPath=".status.conditions[?(@.type==\"Ready\")].lastTransitionTime",description="" + +// FluxReport is the Schema for the fluxreports API. +type FluxReport struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec FluxReportSpec `json:"spec,omitempty"` + Status FluxReportStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// FluxReportList contains a list of FluxReport. +type FluxReportList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []FluxReport `json:"items"` +} + +// GetConditions returns the status conditions of the object. +func (in *FluxReport) GetConditions() []metav1.Condition { + return in.Status.Conditions +} + +// SetConditions sets the status conditions on the object. +func (in *FluxReport) SetConditions(conditions []metav1.Condition) { + in.Status.Conditions = conditions +} + +// IsDisabled returns true if the object has the reconcile annotation set to 'disabled'. +func (in *FluxReport) IsDisabled() bool { + val, ok := in.GetAnnotations()[ReconcileAnnotation] + return ok && strings.ToLower(val) == DisabledValue +} + +// GetInterval returns the interval at which the object should be reconciled. +// If no interval is set, the default is 10 minutes. +func (in *FluxReport) GetInterval() time.Duration { + val, ok := in.GetAnnotations()[ReconcileAnnotation] + if ok && strings.ToLower(val) == DisabledValue { + return 0 + } + defaultInterval := 10 * time.Minute + val, ok = in.GetAnnotations()[ReconcileEveryAnnotation] + if !ok { + return defaultInterval + } + interval, err := time.ParseDuration(val) + if err != nil { + return defaultInterval + } + return interval +} + +func init() { + SchemeBuilder.Register(&FluxReport{}, &FluxReportList{}) +} diff --git a/api/v1/zz_generated.deepcopy.go b/api/v1/zz_generated.deepcopy.go index 53bcf50..3a382e8 100644 --- a/api/v1/zz_generated.deepcopy.go +++ b/api/v1/zz_generated.deepcopy.go @@ -58,6 +58,36 @@ func (in *Distribution) DeepCopy() *Distribution { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FluxComponentStatus) DeepCopyInto(out *FluxComponentStatus) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FluxComponentStatus. +func (in *FluxComponentStatus) DeepCopy() *FluxComponentStatus { + if in == nil { + return nil + } + out := new(FluxComponentStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FluxDistributionStatus) DeepCopyInto(out *FluxDistributionStatus) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FluxDistributionStatus. +func (in *FluxDistributionStatus) DeepCopy() *FluxDistributionStatus { + if in == nil { + return nil + } + out := new(FluxDistributionStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *FluxInstance) DeepCopyInto(out *FluxInstance) { *out = *in @@ -191,6 +221,165 @@ func (in *FluxInstanceStatus) DeepCopy() *FluxInstanceStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FluxReconcilerStats) DeepCopyInto(out *FluxReconcilerStats) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FluxReconcilerStats. +func (in *FluxReconcilerStats) DeepCopy() *FluxReconcilerStats { + if in == nil { + return nil + } + out := new(FluxReconcilerStats) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FluxReconcilerStatus) DeepCopyInto(out *FluxReconcilerStatus) { + *out = *in + out.Stats = in.Stats +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FluxReconcilerStatus. +func (in *FluxReconcilerStatus) DeepCopy() *FluxReconcilerStatus { + if in == nil { + return nil + } + out := new(FluxReconcilerStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FluxReport) DeepCopyInto(out *FluxReport) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FluxReport. +func (in *FluxReport) DeepCopy() *FluxReport { + if in == nil { + return nil + } + out := new(FluxReport) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *FluxReport) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FluxReportList) DeepCopyInto(out *FluxReportList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]FluxReport, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FluxReportList. +func (in *FluxReportList) DeepCopy() *FluxReportList { + if in == nil { + return nil + } + out := new(FluxReportList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *FluxReportList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FluxReportSpec) DeepCopyInto(out *FluxReportSpec) { + *out = *in + out.Distribution = in.Distribution + if in.ComponentsStatus != nil { + in, out := &in.ComponentsStatus, &out.ComponentsStatus + *out = make([]FluxComponentStatus, len(*in)) + copy(*out, *in) + } + if in.ReconcilersStatus != nil { + in, out := &in.ReconcilersStatus, &out.ReconcilersStatus + *out = make([]FluxReconcilerStatus, len(*in)) + copy(*out, *in) + } + if in.SyncStatus != nil { + in, out := &in.SyncStatus, &out.SyncStatus + *out = new(FluxSyncStatus) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FluxReportSpec. +func (in *FluxReportSpec) DeepCopy() *FluxReportSpec { + if in == nil { + return nil + } + out := new(FluxReportSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FluxReportStatus) DeepCopyInto(out *FluxReportStatus) { + *out = *in + out.ReconcileRequestStatus = in.ReconcileRequestStatus + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]metav1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FluxReportStatus. +func (in *FluxReportStatus) DeepCopy() *FluxReportStatus { + if in == nil { + return nil + } + out := new(FluxReportStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FluxSyncStatus) DeepCopyInto(out *FluxSyncStatus) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FluxSyncStatus. +func (in *FluxSyncStatus) DeepCopy() *FluxSyncStatus { + if in == nil { + return nil + } + out := new(FluxSyncStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Kustomize) DeepCopyInto(out *Kustomize) { *out = *in diff --git a/cmd/main.go b/cmd/main.go index c613c4d..a0b55ef 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -13,6 +13,7 @@ import ( "github.com/fluxcd/pkg/runtime/probes" flag "github.com/spf13/pflag" corev1 "k8s.io/api/core/v1" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" "k8s.io/apimachinery/pkg/fields" "k8s.io/apimachinery/pkg/runtime" utilruntime "k8s.io/apimachinery/pkg/util/runtime" @@ -31,10 +32,7 @@ import ( // +kubebuilder:scaffold:imports ) -const ( - controllerName = "flux-operator" - defaultNamespace = "flux-system" -) +const controllerName = "flux-operator" var ( scheme = runtime.NewScheme() @@ -44,6 +42,7 @@ var ( func init() { utilruntime.Must(clientgoscheme.AddToScheme(scheme)) + utilruntime.Must(apiextensionsv1.AddToScheme(scheme)) utilruntime.Must(fluxcdv1.AddToScheme(scheme)) // +kubebuilder:scaffold:scheme } @@ -76,8 +75,8 @@ func main() { runtimeNamespace := os.Getenv("RUNTIME_NAMESPACE") if runtimeNamespace == "" { - runtimeNamespace = defaultNamespace - setupLog.Info("RUNTIME_NAMESPACE env var not set, defaulting to " + defaultNamespace) + runtimeNamespace = fluxcdv1.DefaultNamespace + setupLog.Info("RUNTIME_NAMESPACE env var not set, defaulting to " + fluxcdv1.DefaultNamespace) } mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ @@ -104,7 +103,14 @@ func main() { &fluxcdv1.FluxInstance{}: { // Only the FluxInstance with the name 'flux' can be reconciled. Field: fields.SelectorFromSet(fields.Set{ - "metadata.name": "flux", + "metadata.name": fluxcdv1.DefaultInstanceName, + "metadata.namespace": runtimeNamespace, + }), + }, + &fluxcdv1.FluxReport{}: { + // Only the FluxReport with the name 'flux' can be reconciled. + Field: fields.SelectorFromSet(fields.Set{ + "metadata.name": fluxcdv1.DefaultInstanceName, "metadata.namespace": runtimeNamespace, }), }, @@ -152,6 +158,20 @@ func main() { setupLog.Error(err, "unable to create controller", "controller", fluxcdv1.FluxInstanceKind) os.Exit(1) } + + if err = (&controller.FluxReportReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + StatusManager: controllerName, + EventRecorder: mgr.GetEventRecorderFor(controllerName), + WatchNamespace: runtimeNamespace, + }).SetupWithManager(mgr, + controller.FluxReportReconcilerOptions{ + RateLimiter: runtimeCtrl.GetRateLimiter(rateLimiterOptions), + }); err != nil { + setupLog.Error(err, "unable to create controller", "controller", fluxcdv1.FluxReportKind) + os.Exit(1) + } // +kubebuilder:scaffold:builder probes.SetupChecks(mgr, setupLog) diff --git a/config/crd/bases/fluxcd.controlplane.io_fluxreports.yaml b/config/crd/bases/fluxcd.controlplane.io_fluxreports.yaml new file mode 100644 index 0000000..296679e --- /dev/null +++ b/config/crd/bases/fluxcd.controlplane.io_fluxreports.yaml @@ -0,0 +1,270 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.15.0 + name: fluxreports.fluxcd.controlplane.io +spec: + group: fluxcd.controlplane.io + names: + kind: FluxReport + listKind: FluxReportList + plural: fluxreports + singular: fluxreport + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.distribution.entitlement + name: Entitlement + priority: 10 + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .status.conditions[?(@.type=="Ready")].message + name: Status + type: string + - jsonPath: .status.conditions[?(@.type=="Ready")].lastTransitionTime + name: LastUpdated + type: string + name: v1 + schema: + openAPIV3Schema: + description: FluxReport is the Schema for the fluxreports API. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: FluxReportSpec defines the observed state of a Flux installation. + properties: + components: + description: ComponentsStatus is the status of the Flux controller + deployments. + items: + description: FluxComponentStatus defines the observed state of a + Flux component. + properties: + image: + description: Image is the container image of the Flux component. + type: string + name: + description: Name is the name of the Flux component. + type: string + ready: + description: Ready is the readiness status of the Flux component. + type: boolean + status: + description: |- + Status is a human-readable message indicating details + about the Flux component observed state. + type: string + required: + - image + - name + - ready + - status + type: object + type: array + distribution: + description: Distribution is the version information of the Flux installation. + properties: + entitlement: + description: Entitlement is the entitlement verification status. + type: string + managedBy: + description: ManagedBy is the name of the operator managing the + Flux instance. + type: string + status: + description: |- + Status is a human-readable message indicating details + about the distribution observed state. + type: string + version: + description: Version is the version of the Flux instance. + type: string + required: + - entitlement + - status + type: object + reconcilers: + description: |- + ReconcilersStatus is the list of Flux reconcilers and + their statistics grouped by API kind. + items: + description: FluxReconcilerStatus defines the observed state of + a Flux reconciler. + properties: + apiVersion: + description: APIVersion is the API version of the Flux resource. + type: string + kind: + description: Kind is the kind of the Flux resource. + type: string + stats: + description: Stats is the reconcile statics of the Flux resource + kind. + properties: + failing: + description: |- + Failing is the number of reconciled + resources in the Failing state. + type: integer + running: + description: |- + Running is the number of reconciled + resources in the Running state. + type: integer + suspended: + description: |- + Suspended is the number of reconciled + resources in the Suspended state. + type: integer + totalSize: + description: TotalSize is the total size of the artifacts + in storage. + type: string + required: + - failing + - running + - suspended + type: object + required: + - apiVersion + - kind + type: object + type: array + sync: + description: |- + SyncStatus is the status of the cluster sync + Source and Kustomization resources. + properties: + id: + description: ID is the identifier of the sync. + type: string + path: + description: Path is the kustomize path of the sync. + type: string + ready: + description: Ready is the readiness status of the sync. + type: boolean + source: + description: Source is the URL of the source repository. + type: string + status: + description: |- + Status is a human-readable message indicating details + about the sync observed state. + type: string + required: + - id + - ready + - status + type: object + required: + - distribution + type: object + status: + description: FluxReportStatus defines the readiness of a FluxReport. + properties: + conditions: + description: Conditions contains the readiness conditions of the object. + items: + description: "Condition contains details for one aspect of the current + state of this API Resource.\n---\nThis struct is intended for + direct use as an array at the field path .status.conditions. For + example,\n\n\n\ttype FooStatus struct{\n\t // Represents the + observations of a foo's current state.\n\t // Known .status.conditions.type + are: \"Available\", \"Progressing\", and \"Degraded\"\n\t // + +patchMergeKey=type\n\t // +patchStrategy=merge\n\t // +listType=map\n\t + \ // +listMapKey=type\n\t Conditions []metav1.Condition `json:\"conditions,omitempty\" + patchStrategy:\"merge\" patchMergeKey:\"type\" protobuf:\"bytes,1,rep,name=conditions\"`\n\n\n\t + \ // other fields\n\t}" + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: |- + type of condition in CamelCase or in foo.example.com/CamelCase. + --- + Many .condition.type values are consistent across resources like Available, but because arbitrary conditions can be + useful (see .node.status.conditions), the ability to deconflict is important. + The regex it matches is (dns1123SubdomainFmt/)?(qualifiedNameFmt) + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + lastHandledReconcileAt: + description: |- + LastHandledReconcileAt holds the value of the most recent + reconcile request value, so a change of the annotation value + can be detected. + type: string + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index 1e0f659..1631b82 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -2,3 +2,4 @@ apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization resources: - bases/fluxcd.controlplane.io_fluxinstances.yaml +- bases/fluxcd.controlplane.io_fluxreports.yaml diff --git a/config/crd/kustomizeconfig.yaml b/config/crd/kustomizeconfig.yaml new file mode 100644 index 0000000..ec5c150 --- /dev/null +++ b/config/crd/kustomizeconfig.yaml @@ -0,0 +1,19 @@ +# This file is for teaching kustomize how to substitute name and namespace reference in CRD +nameReference: +- kind: Service + version: v1 + fieldSpecs: + - kind: CustomResourceDefinition + version: v1 + group: apiextensions.k8s.io + path: spec/conversion/webhook/clientConfig/service/name + +namespace: +- kind: CustomResourceDefinition + version: v1 + group: apiextensions.k8s.io + path: spec/conversion/webhook/clientConfig/service/namespace + create: false + +varReference: +- path: metadata/annotations diff --git a/config/olm/bundle/manifests/flux-operator.clusterserviceversion.yaml b/config/olm/bundle/manifests/flux-operator.clusterserviceversion.yaml index 3ff714d..4993840 100644 --- a/config/olm/bundle/manifests/flux-operator.clusterserviceversion.yaml +++ b/config/olm/bundle/manifests/flux-operator.clusterserviceversion.yaml @@ -37,7 +37,65 @@ metadata: "domain": "cluster.local" } } - } + }, + { + "apiVersion": "fluxcd.controlplane.io/v1", + "kind": "FluxReport", + "metadata": { + "name": "flux", + "namespace": "flux-system" + }, + "spec": { + "distribution": { + "entitlement": "Issued by controlplane", + "managedBy": "flux-operator", + "status": "Installed", + "version": "v2.3.0" + }, + "components": [ + { + "image": "ghcr.io/fluxcd/kustomize-controller:v1.3.0@sha256:48a032574dd45c39750ba0f1488e6f1ae36756a38f40976a6b7a588d83acefc1", + "name": "kustomize-controller", + "ready": true, + "status": "Current Deployment is available. Replicas: 1" + }, + { + "image": "ghcr.io/fluxcd/source-controller:v1.3.0@sha256:161da425b16b64dda4b3cec2ba0f8d7442973aba29bb446db3b340626181a0bc", + "name": "source-controller", + "ready": true, + "status": "Current Deployment is available. Replicas: 1" + } + ], + "reconcilers": [ + { + "apiVersion": "kustomize.toolkit.fluxcd.io/v1", + "kind": "Kustomization", + "stats": { + "failing": 0, + "running": 1, + "suspended": 0 + } + }, + { + "apiVersion": "source.toolkit.fluxcd.io/v1", + "kind": "GitRepository", + "stats": { + "failing": 0, + "running": 1, + "suspended": 0, + "totalSize": "3.7 MiB" + } + } + ], + "sync": { + "ready": true, + "id": "kustomization/flux-system", + "path": "clusters/production", + "source": "https://github.com/my-org/my-fleet.git", + "status": "Applied revision: refs/heads/main@sha1:a90cd1ac35de01c175f7199315d3f4cd60195911" + } + } + } ] categories: Integration & Delivery certified: 'false' @@ -119,6 +177,11 @@ spec: kind: FluxInstance version: v1 description: Flux Instance + - name: fluxreports.fluxcd.controlplane.io + displayName: FluxReport + kind: FluxReport + version: v1 + description: Flux Report (Autogenerated) install: strategy: deployment spec: diff --git a/config/olm/bundle/manifests/fluxreports.fluxcd.controlplane.io.crd.yaml b/config/olm/bundle/manifests/fluxreports.fluxcd.controlplane.io.crd.yaml new file mode 100644 index 0000000..e69de29 diff --git a/config/rbac/fluxreport_editor_role.yaml b/config/rbac/fluxreport_editor_role.yaml new file mode 100644 index 0000000..a477f05 --- /dev/null +++ b/config/rbac/fluxreport_editor_role.yaml @@ -0,0 +1,27 @@ +# permissions for end users to edit fluxreports. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: flux-operator + app.kubernetes.io/managed-by: kustomize + name: fluxreport-editor-role +rules: +- apiGroups: + - fluxcd.controlplane.io + resources: + - fluxreports + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - fluxcd.controlplane.io + resources: + - fluxreports/status + verbs: + - get diff --git a/config/rbac/fluxreport_viewer_role.yaml b/config/rbac/fluxreport_viewer_role.yaml new file mode 100644 index 0000000..b3852b7 --- /dev/null +++ b/config/rbac/fluxreport_viewer_role.yaml @@ -0,0 +1,23 @@ +# permissions for end users to view fluxreports. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: flux-operator + app.kubernetes.io/managed-by: kustomize + name: fluxreport-viewer-role +rules: +- apiGroups: + - fluxcd.controlplane.io + resources: + - fluxreports + verbs: + - get + - list + - watch +- apiGroups: + - fluxcd.controlplane.io + resources: + - fluxreports/status + verbs: + - get diff --git a/config/rbac/kustomization.yaml b/config/rbac/kustomization.yaml index 290bb19..fb6af77 100644 --- a/config/rbac/kustomization.yaml +++ b/config/rbac/kustomization.yaml @@ -13,6 +13,8 @@ resources: # default, aiding admins in cluster management. Those roles are # not used by the Project itself. You can comment the following lines # if you do not want those helpers be installed with your Project. +- fluxreport_editor_role.yaml +- fluxreport_viewer_role.yaml - fluxinstance_editor_role.yaml - fluxinstance_viewer_role.yaml diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 01732f8..01b8cd9 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -30,3 +30,29 @@ rules: - get - patch - update +- apiGroups: + - fluxcd.controlplane.io + resources: + - fluxreports + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - fluxcd.controlplane.io + resources: + - fluxreports/finalizers + verbs: + - update +- apiGroups: + - fluxcd.controlplane.io + resources: + - fluxreports/status + verbs: + - get + - patch + - update diff --git a/config/samples/fluxcd_v1_fluxreport.yaml b/config/samples/fluxcd_v1_fluxreport.yaml new file mode 100644 index 0000000..00cb95b --- /dev/null +++ b/config/samples/fluxcd_v1_fluxreport.yaml @@ -0,0 +1,8 @@ +apiVersion: fluxcd.controlplane.io/v1 +kind: FluxReport +metadata: + name: flux +spec: + distribution: + entitlement: "unknown" + status: "unknown" diff --git a/config/samples/kustomization.yaml b/config/samples/kustomization.yaml index 5977452..c94efe2 100644 --- a/config/samples/kustomization.yaml +++ b/config/samples/kustomization.yaml @@ -1,4 +1,5 @@ ## Append samples of your project ## resources: - fluxcd_v1_fluxinstance.yaml +- fluxcd_v1_fluxreport.yaml # +kubebuilder:scaffold:manifestskustomizesamples diff --git a/docs/api/v1/fluxreport.md b/docs/api/v1/fluxreport.md new file mode 100644 index 0000000..70a249b --- /dev/null +++ b/docs/api/v1/fluxreport.md @@ -0,0 +1,266 @@ +# Flux Report CRD + +**FluxReport** is an API that reflects the observed state of a Flux installation. +Its purpose is to aid in monitoring and troubleshooting Flux by providing +information about the installed components and their readiness, the distribution details, +reconcilers statistics, cluster sync status, etc. + +A single custom resource of this kind can exist in a Kubernetes cluster +with the name `flux`. The resource is automatically generated in the same namespace +where the flux-operator is deployed and is updated by the operator at regular intervals. + +## Example + +The following example shows a FluxReport custom resource generated on a cluster +where a [FluxInstance](fluxinstance.md) was deployed: + +```yaml +apiVersion: fluxcd.controlplane.io/v1 +kind: FluxReport +metadata: + name: flux + namespace: flux-system +spec: + components: + - image: ghcr.io/fluxcd/helm-controller:v1.0.1@sha256:a67a037faa850220ff94d8090253732079589ad9ff10b6ddf294f3b7cd0f3424 + name: helm-controller + ready: true + status: 'Current Deployment is available. Replicas: 1' + - image: ghcr.io/fluxcd/kustomize-controller:v1.3.0@sha256:48a032574dd45c39750ba0f1488e6f1ae36756a38f40976a6b7a588d83acefc1 + name: kustomize-controller + ready: true + status: 'Current Deployment is available. Replicas: 1' + - image: ghcr.io/fluxcd/notification-controller:v1.3.0@sha256:c0fab940c7e578ea519097d36c040238b0cc039ce366fdb753947428bbf0c3d6 + name: notification-controller + ready: true + status: 'Current Deployment is available. Replicas: 1' + - image: ghcr.io/fluxcd/source-controller:v1.3.0@sha256:161da425b16b64dda4b3cec2ba0f8d7442973aba29bb446db3b340626181a0bc + name: source-controller + ready: true + status: 'Current Deployment is available. Replicas: 1' + distribution: + entitlement: Issued by controlplane + managedBy: flux-operator + status: Installed + version: v2.3.0 + reconcilers: + - apiVersion: helm.toolkit.fluxcd.io/v2 + kind: HelmRelease + stats: + failing: 1 + running: 42 + suspended: 3 + - apiVersion: kustomize.toolkit.fluxcd.io/v1 + kind: Kustomization + stats: + failing: 0 + running: 5 + suspended: 0 + - apiVersion: notification.toolkit.fluxcd.io/v1 + kind: Receiver + stats: + failing: 0 + running: 1 + suspended: 0 + - apiVersion: notification.toolkit.fluxcd.io/v1beta3 + kind: Alert + stats: + failing: 0 + running: 1 + suspended: 0 + - apiVersion: notification.toolkit.fluxcd.io/v1beta3 + kind: Provider + stats: + failing: 0 + running: 1 + suspended: 0 + - apiVersion: source.toolkit.fluxcd.io/v1 + kind: GitRepository + stats: + failing: 0 + running: 2 + suspended: 0 + totalSize: 3.7 MiB + - apiVersion: source.toolkit.fluxcd.io/v1 + kind: HelmChart + stats: + failing: 1 + running: 55 + suspended: 0 + totalSize: 15.7 MiB + - apiVersion: source.toolkit.fluxcd.io/v1 + kind: HelmRepository + stats: + failing: 0 + running: 7 + suspended: 3 + totalSize: 40.5 MiB + - apiVersion: source.toolkit.fluxcd.io/v1beta2 + kind: Bucket + stats: + failing: 0 + running: 0 + suspended: 0 + - apiVersion: source.toolkit.fluxcd.io/v1beta2 + kind: OCIRepository + stats: + failing: 0 + running: 1 + suspended: 0 + totalSize: 78.1 KiB + sync: + ready: true + id: kustomization/flux-system + path: clusters/production + source: https://github.com/my-org/my-fleet.git + status: 'Applied revision: refs/heads/main@sha1:a90cd1ac35de01c175f7199315d3f4cd60195911' +status: + conditions: + - lastTransitionTime: "2024-06-20T19:59:30Z" + message: Reporting finished in 272ms + observedGeneration: 4 + reason: ReconciliationSucceeded + status: "True" + type: Ready +``` + +1. Export the report in YAML format: + + ```shell + kubectl -n flux-system get fluxreport/flux -o yaml + ``` + +2. Trigger a reconciliation of the report: + + ```shell + kubectl -n flux-system annotate --overwrite fluxreport/flux \ + reconcile.fluxcd.io/requestedAt="$(date +%s)" + ``` + +3. Change the report reconciliation interval: + + ```shell + kubectl -n flux-system annotate --overwrite fluxreport/flux \ + fluxcd.controlplane.io/reconcileEvery=5m + ``` + +4. Pause the report reconciliation: + + ```shell + kubectl -n flux-system annotate --overwrite fluxreport/flux \ + fluxcd.controlplane.io/reconcile=disabled + ``` + +5. Resume the reconciliation of the report: + + ```shell + kubectl -n flux-system annotate --overwrite fluxreport/flux \ + fluxcd.controlplane.io/reconcile=enabled + ``` + +## Reading a FluxReport + +As with all other Kubernetes config, a FluxReport is identified by `apiVersion`, +`kind`, and `metadata` fields. The `spec` field contains detailed information +about the Flux installation, including statistic data for the Flux custom resources +that are reconciled by the Flux controllers. + +### Distribution information + +The `.spec.distribution` field contains information about the Flux distribution, +including the version, installation status, entitlement issuer +and tool that is managing the distribution. + +Example distribution information for when Flux +was installed using the bootstrap command: + +```yaml +spec: + distribution: + entitlement: Issued by controlplane + managedBy: 'flux bootstrap' + status: Installed + version: v2.3.0 +``` + +### Components information + +The `.spec.components` field contains information about the Flux controllers, +including the controller name, the image repository, tag, and digest, and the +deployment readiness status. + +Example: + +```yaml +spec: + components: + - image: ghcr.io/fluxcd/kustomize-controller:v1.3.0@sha256:48a032574dd45c39750ba0f1488e6f1ae36756a38f40976a6b7a588d83acefc1 + name: kustomize-controller + ready: true + status: 'Current Deployment is available. Replicas: 1' + - image: ghcr.io/fluxcd/source-controller:v1.3.0@sha256:161da425b16b64dda4b3cec2ba0f8d7442973aba29bb446db3b340626181a0bc + name: source-controller + ready: true + status: 'Current Deployment is available. Replicas: 1' +``` + +### Reconcilers statistics + +The `.spec.reconcilers` field contains statistics about the Flux custom resources +that are reconciled by the Flux controllers, including the API version, kind, and +the number of resources in each state: failing, running and suspended. +For source type resources, the storage size of the locally cached artifacts is also reported. + +Example: + +```yaml +spec: + reconcilers: + - apiVersion: kustomize.toolkit.fluxcd.io/v1 + kind: Kustomization + stats: + failing: 1 + running: 5 + suspended: 5 + - apiVersion: source.toolkit.fluxcd.io/v1 + kind: GitRepository + stats: + failing: 1 + running: 2 + suspended: 3 + totalSize: 5.5 MiB +``` + +### Cluster sync status + +The `.spec.sync` field contains information about the cluster sync status, +including the Flux Kustomization name, source URL, the applied revision, +and the sync readiness status. + +Example: + +```yaml +spec: + sync: + ready: true + id: kustomization/flux-system + path: tests/v2.3/sources + source: https://github.com/controlplaneio-fluxcd/distribution.git + status: 'Applied revision: refs/heads/main@sha1:a90cd1ac35de01c175f7199315d3f4cd60195911' +``` + +## Generating a FluxReport + +The FluxReport is automatically generated by the operator for the following conditions: + +- At startup, when the operator is installed or upgraded. +- When the [FluxInstance](fluxinstance.md) is created or updated. +- When the `reconcile.fluxcd.io/requestedAt` annotation is set on the FluxReport resource. +- At regular intervals, controlled by the `fluxcd.controlplane.io/reconcileEvery` annotation. + +### Reconciliation configuration + +The reconciliation behaviour can be configured using the following annotations: + +- `fluxcd.controlplane.io/reconcile`: Enable or disable the reconciliation loop. Default is `enabled`, set to `disabled` to pause the reconciliation. +- `fluxcd.controlplane.io/reconcileEvery`: Set the reconciliation interval. Default is `10m`. diff --git a/go.mod b/go.mod index a4f5e8e..9e6b31e 100644 --- a/go.mod +++ b/go.mod @@ -21,6 +21,7 @@ require ( github.com/opencontainers/go-digest v1.0.0 github.com/otiai10/copy v1.14.0 github.com/spf13/pflag v1.0.5 + golang.org/x/exp v0.0.0-20240416160154-fe59bbe5cc7f k8s.io/api v0.30.1 k8s.io/apiextensions-apiserver v0.30.1 k8s.io/apimachinery v0.30.1 @@ -117,7 +118,6 @@ require ( go.starlark.net v0.0.0-20231121155337-90ade8b19d09 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect - golang.org/x/exp v0.0.0-20240416160154-fe59bbe5cc7f // indirect golang.org/x/net v0.25.0 // indirect golang.org/x/oauth2 v0.19.0 // indirect golang.org/x/sync v0.7.0 // indirect diff --git a/hack/build-olm-manifests.sh b/hack/build-olm-manifests.sh index ca464b7..6322a6e 100755 --- a/hack/build-olm-manifests.sh +++ b/hack/build-olm-manifests.sh @@ -34,5 +34,8 @@ envsubst > ${DEST_DIR}/test/olm.yaml cat ${REPOSITORY_ROOT}/config/crd/bases/fluxcd.controlplane.io_fluxinstances.yaml > \ ${DEST_DIR}/bundle/manifests/fluxinstances.fluxcd.controlplane.io.crd.yaml +cat ${REPOSITORY_ROOT}/config/crd/bases/fluxcd.controlplane.io_fluxreports.yaml > \ +${DEST_DIR}/bundle/manifests/fluxreports.fluxcd.controlplane.io.crd.yaml + mv ${DEST_DIR}/bundle ${DEST_DIR}/${VERSION} info "OperatorHub bundle created in ${DEST_DIR}/${VERSION}" diff --git a/internal/controller/entitlement_controller.go b/internal/controller/entitlement_controller.go index 578ae31..078a2db 100644 --- a/internal/controller/entitlement_controller.go +++ b/internal/controller/entitlement_controller.go @@ -21,7 +21,9 @@ import ( "sigs.k8s.io/controller-runtime/pkg/predicate" "sigs.k8s.io/controller-runtime/pkg/ratelimiter" + fluxcdv1 "github.com/controlplaneio-fluxcd/flux-operator/api/v1" "github.com/controlplaneio-fluxcd/flux-operator/internal/entitlement" + "github.com/controlplaneio-fluxcd/flux-operator/internal/reporter" ) // EntitlementReconciler reconciles entitlements. @@ -76,6 +78,12 @@ func (r *EntitlementReconciler) Reconcile(ctx context.Context, req ctrl.Request) log.Info("Entitlement registered", "vendor", r.EntitlementClient.GetVendor()) + if err := reporter.RequestReportUpdate(ctx, + r.Client, fluxcdv1.DefaultInstanceName, + r.StatusManager, r.WatchNamespace); err != nil { + log.Error(err, "failed to request report update") + } + // Requeue to verify the token. return ctrl.Result{Requeue: true}, nil } diff --git a/internal/controller/fluxinstance_controller.go b/internal/controller/fluxinstance_controller.go index 607ae83..13354b9 100644 --- a/internal/controller/fluxinstance_controller.go +++ b/internal/controller/fluxinstance_controller.go @@ -32,6 +32,7 @@ import ( fluxcdv1 "github.com/controlplaneio-fluxcd/flux-operator/api/v1" "github.com/controlplaneio-fluxcd/flux-operator/internal/builder" "github.com/controlplaneio-fluxcd/flux-operator/internal/inventory" + "github.com/controlplaneio-fluxcd/flux-operator/internal/reporter" ) // FluxInstanceReconciler reconciles a FluxInstance object @@ -68,6 +69,12 @@ func (r *FluxInstanceReconciler) Reconcile(ctx context.Context, req ctrl.Request log.Error(err, "failed to update status") retErr = kerrors.NewAggregate([]error{retErr, err}) } + + if err := reporter.RequestReportUpdate(ctx, + r.Client, fluxcdv1.DefaultInstanceName, + r.StatusManager, obj.Namespace); err != nil { + log.Error(err, "failed to request report update") + } }() // Uninstall if the object is under deletion. diff --git a/internal/controller/fluxreport_controller.go b/internal/controller/fluxreport_controller.go new file mode 100644 index 0000000..c37550d --- /dev/null +++ b/internal/controller/fluxreport_controller.go @@ -0,0 +1,141 @@ +// Copyright 2024 Stefan Prodan. +// SPDX-License-Identifier: AGPL-3.0 + +package controller + +import ( + "context" + "fmt" + "time" + + "github.com/fluxcd/pkg/apis/meta" + "github.com/fluxcd/pkg/runtime/conditions" + "github.com/fluxcd/pkg/runtime/patch" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + kuberecorder "k8s.io/client-go/tools/record" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/ratelimiter" + + fluxcdv1 "github.com/controlplaneio-fluxcd/flux-operator/api/v1" + "github.com/controlplaneio-fluxcd/flux-operator/internal/reporter" +) + +// FluxReportReconciler reconciles a FluxReport object +type FluxReportReconciler struct { + client.Client + kuberecorder.EventRecorder + + Scheme *runtime.Scheme + StatusManager string + WatchNamespace string +} + +// +kubebuilder:rbac:groups=fluxcd.controlplane.io,resources=fluxreports,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=fluxcd.controlplane.io,resources=fluxreports/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=fluxcd.controlplane.io,resources=fluxreports/finalizers,verbs=update + +// Reconcile computes the report of the Flux instance. +func (r *FluxReportReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + log := ctrl.LoggerFrom(ctx) + reconcileStart := time.Now() + + obj := &fluxcdv1.FluxReport{} + if err := r.Get(ctx, req.NamespacedName, obj); err != nil { + if errors.IsNotFound(err) { + // Initialize the FluxReport if it doesn't exist. + err = r.initReport(ctx, fluxcdv1.DefaultInstanceName, r.WatchNamespace) + if err != nil { + return ctrl.Result{}, fmt.Errorf("failed to initialize FluxReport: %w", err) + } + return ctrl.Result{Requeue: true}, nil + } + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + // Pause reconciliation if the object has the reconcile annotation set to 'disabled'. + if obj.IsDisabled() { + log.Info("Reconciliation in disabled, cannot proceed with the report computation.") + return ctrl.Result{}, nil + } + + // Initialize the runtime patcher with the current version of the object. + patcher := patch.NewSerialPatcher(obj, r.Client) + + // Compute the status of the Flux instance. + rep := reporter.NewFluxStatusReporter(r.Client, fluxcdv1.DefaultInstanceName, r.StatusManager, obj.Namespace) + report, err := rep.Compute(ctx) + if err != nil { + log.Error(err, "report computed with errors") + } + + // Update the FluxReport with the computed spec. + obj.Spec = report + + // Update the report timestamp. + msg := fmt.Sprintf("Reporting finished in %s", fmtDuration(reconcileStart)) + conditions.MarkTrue(obj, + meta.ReadyCondition, + meta.SucceededReason, + msg) + + // Patch the FluxReport with the computed spec. + err = patcher.Patch(ctx, obj, patch.WithFieldOwner(r.StatusManager)) + if err != nil { + return ctrl.Result{}, err + } + + log.Info(msg) + return ctrl.Result{RequeueAfter: obj.GetInterval()}, nil +} + +// FluxReportReconcilerOptions contains options for the reconciler. +type FluxReportReconcilerOptions struct { + RateLimiter ratelimiter.RateLimiter +} + +// SetupWithManager sets up the controller with the Manager. +func (r *FluxReportReconciler) SetupWithManager(mgr ctrl.Manager, opts FluxReportReconcilerOptions) error { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + if err := r.initReport(ctx, fluxcdv1.DefaultInstanceName, r.WatchNamespace); err != nil { + return fmt.Errorf("failed to initialize FluxReport: %w", err) + } + + return ctrl.NewControllerManagedBy(mgr). + For(&fluxcdv1.FluxReport{}). + WithEventFilter(predicate.AnnotationChangedPredicate{}). + WithOptions(controller.Options{RateLimiter: opts.RateLimiter}). + Complete(r) +} + +func (r *FluxReportReconciler) initReport(ctx context.Context, name, namespace string) error { + report := &fluxcdv1.FluxReport{ + TypeMeta: metav1.TypeMeta{ + APIVersion: fluxcdv1.GroupVersion.String(), + Kind: fluxcdv1.FluxReportKind, + }, + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Spec: fluxcdv1.FluxReportSpec{ + Distribution: fluxcdv1.FluxDistributionStatus{ + Status: "Unknown", + Entitlement: "Unknown", + }, + }, + } + + if err := r.Client.Patch(ctx, report, client.Apply, client.FieldOwner(r.StatusManager)); err != nil { + if !errors.IsConflict(err) { + return err + } + } + return nil +} diff --git a/internal/controller/fluxreport_controller_test.go b/internal/controller/fluxreport_controller_test.go new file mode 100644 index 0000000..2f70ac0 --- /dev/null +++ b/internal/controller/fluxreport_controller_test.go @@ -0,0 +1,145 @@ +// Copyright 2024 Stefan Prodan. +// SPDX-License-Identifier: AGPL-3.0 + +package controller + +import ( + "context" + "testing" + + "github.com/fluxcd/pkg/apis/meta" + "github.com/fluxcd/pkg/runtime/conditions" + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + fluxcdv1 "github.com/controlplaneio-fluxcd/flux-operator/api/v1" + "github.com/controlplaneio-fluxcd/flux-operator/internal/entitlement" +) + +func TestFluxReportReconciler_Reconcile(t *testing.T) { + g := NewWithT(t) + instRec := getFluxInstanceReconciler() + reportRec := getFluxReportReconciler() + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + ns, err := testEnv.CreateNamespace(ctx, "test") + g.Expect(err).ToNot(HaveOccurred()) + + // Initialize the report. + report := &fluxcdv1.FluxReport{ + ObjectMeta: metav1.ObjectMeta{ + Name: fluxcdv1.DefaultInstanceName, + Namespace: ns.Name, + }, + } + err = reportRec.initReport(ctx, report.GetName(), report.GetNamespace()) + g.Expect(err).ToNot(HaveOccurred()) + + // Create the Flux instance. + instance := &fluxcdv1.FluxInstance{ + ObjectMeta: metav1.ObjectMeta{ + Name: ns.Name, + Namespace: ns.Name, + }, + Spec: getDefaultFluxSpec(), + } + err = testEnv.Create(ctx, instance) + g.Expect(err).ToNot(HaveOccurred()) + + // Initialize the instance. + r, err := instRec.Reconcile(ctx, reconcile.Request{ + NamespacedName: client.ObjectKeyFromObject(instance), + }) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(r.Requeue).To(BeTrue()) + + // Reconcile the instance. + r, err = instRec.Reconcile(ctx, reconcile.Request{ + NamespacedName: client.ObjectKeyFromObject(instance), + }) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(r.Requeue).To(BeFalse()) + + // Check if the instance was installed. + err = testClient.Get(ctx, client.ObjectKeyFromObject(instance), instance) + g.Expect(err).ToNot(HaveOccurred()) + checkInstanceReadiness(g, instance) + + // Compute instance report. + r, err = reportRec.Reconcile(ctx, reconcile.Request{ + NamespacedName: client.ObjectKeyFromObject(report), + }) + g.Expect(err).ToNot(HaveOccurred()) + + // Read the report. + err = testClient.Get(ctx, client.ObjectKeyFromObject(report), report) + g.Expect(err).ToNot(HaveOccurred()) + logObject(t, report) + + // Check annotation set by the instance reconciler. + g.Expect(report.GetAnnotations()).To(HaveKey(meta.ReconcileRequestAnnotation)) + + // Check reported components. + g.Expect(report.Spec.ComponentsStatus).To(HaveLen(len(instance.Status.Components))) + g.Expect(report.Spec.ComponentsStatus[0].Name).To(Equal("helm-controller")) + g.Expect(report.Spec.ComponentsStatus[0].Image).To(ContainSubstring("fluxcd/helm-controller")) + + // Check reported distribution. + g.Expect(instance.Status.LastAppliedRevision).To(ContainSubstring(report.Spec.Distribution.Version)) + g.Expect(report.Spec.Distribution.Status).To(Equal("Installed")) + g.Expect(report.Spec.Distribution.Entitlement).To(Equal("Unknown")) + g.Expect(report.Spec.Distribution.ManagedBy).To(Equal("flux-operator")) + + // Check reported reconcilers. + g.Expect(report.Spec.ReconcilersStatus).To(HaveLen(10)) + g.Expect(report.Spec.ReconcilersStatus[9].Kind).To(Equal("OCIRepository")) + g.Expect(report.Spec.ReconcilersStatus[9].Stats.Running).To(Equal(1)) + + // Check reported sync. + g.Expect(report.Spec.SyncStatus).ToNot(BeNil()) + g.Expect(report.Spec.SyncStatus.Source).To(Equal(instance.Spec.Sync.URL)) + g.Expect(report.Spec.SyncStatus.ID).To(Equal("kustomization/" + ns.Name)) + + // Check ready condition. + g.Expect(conditions.GetReason(report, meta.ReadyCondition)).To(BeIdenticalTo(meta.SucceededReason)) + + // Delete the instance. + err = testClient.Delete(ctx, instance) + g.Expect(err).ToNot(HaveOccurred()) + + r, err = instRec.Reconcile(ctx, reconcile.Request{ + NamespacedName: client.ObjectKeyFromObject(instance), + }) + g.Expect(err).ToNot(HaveOccurred()) + + // Generate entitlement secret. + entRec := getEntitlementReconciler(ns.Name) + _, err = entRec.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(ns)}) + g.Expect(err).ToNot(HaveOccurred()) + + // Generate the report with the instance deleted. + r, err = reportRec.Reconcile(ctx, reconcile.Request{ + NamespacedName: client.ObjectKeyFromObject(report), + }) + g.Expect(err).ToNot(HaveOccurred()) + + // Read the report and verify distribution. + emptyReport := &fluxcdv1.FluxReport{} + err = testClient.Get(ctx, client.ObjectKeyFromObject(report), emptyReport) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(emptyReport.Spec.Distribution.Status).To(Equal("Not Installed")) + g.Expect(emptyReport.Spec.Distribution.Entitlement).To(Equal("Issued by " + entitlement.DefaultVendor)) +} + +func getFluxReportReconciler() *FluxReportReconciler { + return &FluxReportReconciler{ + Client: testClient, + EventRecorder: testEnv.GetEventRecorderFor(controllerName), + Scheme: NewTestScheme(), + StatusManager: controllerName, + } +} diff --git a/internal/controller/suite_test.go b/internal/controller/suite_test.go index 01a3f32..b8ffdf2 100644 --- a/internal/controller/suite_test.go +++ b/internal/controller/suite_test.go @@ -100,6 +100,11 @@ func logObjectStatus(t *testing.T, obj client.Object) { t.Log(obj.GetName(), "status:\n", string(sts)) } +func logObject(t *testing.T, obj interface{}) { + sts, _ := yaml.Marshal(obj) + t.Log("object:\n", string(sts)) +} + func checkInstanceReadiness(g *gomega.WithT, obj *fluxcdv1.FluxInstance) { statusCheck := kcheck.NewInProgressChecker(testClient) statusCheck.DisableFetch = true diff --git a/internal/reporter/components.go b/internal/reporter/components.go new file mode 100644 index 0000000..cdb79f8 --- /dev/null +++ b/internal/reporter/components.go @@ -0,0 +1,59 @@ +// Copyright 2024 Stefan Prodan. +// SPDX-License-Identifier: AGPL-3.0 + +package reporter + +import ( + "cmp" + "context" + "fmt" + + "github.com/fluxcd/cli-utils/pkg/kstatus/status" + "golang.org/x/exp/slices" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "sigs.k8s.io/controller-runtime/pkg/client" + + fluxcdv1 "github.com/controlplaneio-fluxcd/flux-operator/api/v1" +) + +func (r *FluxStatusReporter) getComponentsStatus(ctx context.Context) ([]fluxcdv1.FluxComponentStatus, error) { + deployments := unstructured.UnstructuredList{ + Object: map[string]interface{}{ + "apiVersion": "apps/v1", + "kind": "Deployment", + }, + } + + if err := r.List(ctx, &deployments, client.InNamespace(r.namespace), r.labelSelector); err != nil { + return nil, fmt.Errorf("failed to list deployments: %w", err) + } + + components := make([]fluxcdv1.FluxComponentStatus, len(deployments.Items)) + for i, d := range deployments.Items { + res, err := status.Compute(&d) + if err != nil { + components[i] = fluxcdv1.FluxComponentStatus{ + Name: d.GetName(), + Ready: false, + Status: fmt.Sprintf("Failed to compute status: %s", err.Error()), + } + } else { + components[i] = fluxcdv1.FluxComponentStatus{ + Name: d.GetName(), + Ready: res.Status == status.CurrentStatus, + Status: fmt.Sprintf("%s %s", string(res.Status), res.Message), + } + } + + containers, found, _ := unstructured.NestedSlice(d.Object, "spec", "template", "spec", "containers") + if found && len(containers) > 0 { + components[i].Image = containers[0].(map[string]interface{})["image"].(string) + } + } + + slices.SortStableFunc(components, func(i, j fluxcdv1.FluxComponentStatus) int { + return cmp.Compare(i.Name, j.Name) + }) + + return components, nil +} diff --git a/internal/reporter/crds.go b/internal/reporter/crds.go new file mode 100644 index 0000000..9bb54db --- /dev/null +++ b/internal/reporter/crds.go @@ -0,0 +1,51 @@ +// Copyright 2024 Stefan Prodan. +// SPDX-License-Identifier: AGPL-3.0 + +package reporter + +import ( + "context" + "errors" + "fmt" + + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func (r *FluxStatusReporter) listCRDs(ctx context.Context) ([]metav1.GroupVersionKind, error) { + var list apiextensionsv1.CustomResourceDefinitionList + if err := r.List(ctx, &list, client.InNamespace(""), r.labelSelector); err != nil { + return nil, fmt.Errorf("failed to list CRDs: %w", err) + } + + if len(list.Items) == 0 { + return nil, errors.New("no Flux CRDs found") + } + + gvkList := make([]metav1.GroupVersionKind, len(list.Items)) + for i, crd := range list.Items { + gvk := metav1.GroupVersionKind{ + Group: crd.Spec.Group, + Kind: crd.Spec.Names.Kind, + } + versions := crd.Status.StoredVersions + if len(versions) > 0 { + gvk.Version = versions[len(versions)-1] + } else { + return nil, fmt.Errorf("no stored versions found for CRD %s", crd.Name) + } + gvkList[i] = gvk + } + + return gvkList, nil +} + +func gvkFor(kind string, crds []metav1.GroupVersionKind) *metav1.GroupVersionKind { + for _, gvk := range crds { + if gvk.Kind == kind { + return &gvk + } + } + return nil +} diff --git a/internal/reporter/distribution.go b/internal/reporter/distribution.go new file mode 100644 index 0000000..3706a1f --- /dev/null +++ b/internal/reporter/distribution.go @@ -0,0 +1,65 @@ +// Copyright 2024 Stefan Prodan. +// SPDX-License-Identifier: AGPL-3.0 + +package reporter + +import ( + "context" + "fmt" + + corev1 "k8s.io/api/core/v1" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + fluxcdv1 "github.com/controlplaneio-fluxcd/flux-operator/api/v1" + "github.com/controlplaneio-fluxcd/flux-operator/internal/entitlement" +) + +func (r *FluxStatusReporter) getDistributionStatus(ctx context.Context) fluxcdv1.FluxDistributionStatus { + result := fluxcdv1.FluxDistributionStatus{ + Status: "Unknown", + Entitlement: "Unknown", + } + + crdMeta := &metav1.PartialObjectMetadata{ + TypeMeta: metav1.TypeMeta{ + APIVersion: apiextensionsv1.SchemeGroupVersion.String(), + Kind: "CustomResourceDefinition", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "gitrepositories.source.toolkit.fluxcd.io", + }, + } + if err := r.Get(ctx, client.ObjectKeyFromObject(crdMeta), crdMeta); err == nil { + result.Status = "Installed" + + if version, found := crdMeta.Labels["app.kubernetes.io/version"]; found { + result.Version = version + } + + if manager, ok := crdMeta.Labels["app.kubernetes.io/managed-by"]; ok { + result.ManagedBy = manager + } else if _, ok := crdMeta.Labels["kustomize.toolkit.fluxcd.io/name"]; ok { + result.ManagedBy = "flux bootstrap" + } + } else { + result.Status = "Not Installed" + } + + entitlementSecret := &corev1.Secret{} + err := r.Get(ctx, client.ObjectKey{ + Namespace: r.namespace, + Name: fmt.Sprintf("%s-entitlement", r.manager), + }, entitlementSecret) + if err == nil { + if _, found := entitlementSecret.Data[entitlement.TokenKey]; found { + result.Entitlement = "Issued" + if vendor, found := entitlementSecret.Data[entitlement.VendorKey]; found { + result.Entitlement += " by " + string(vendor) + } + } + } + + return result +} diff --git a/internal/reporter/reconcilers.go b/internal/reporter/reconcilers.go new file mode 100644 index 0000000..6314a51 --- /dev/null +++ b/internal/reporter/reconcilers.go @@ -0,0 +1,97 @@ +// Copyright 2024 Stefan Prodan. +// SPDX-License-Identifier: AGPL-3.0 + +package reporter + +import ( + "cmp" + "context" + "fmt" + + "github.com/fluxcd/cli-utils/pkg/kstatus/status" + "github.com/fluxcd/pkg/apis/meta" + "golang.org/x/exp/slices" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + kerrors "k8s.io/apimachinery/pkg/util/errors" + "sigs.k8s.io/controller-runtime/pkg/client" + + fluxcdv1 "github.com/controlplaneio-fluxcd/flux-operator/api/v1" +) + +func (r *FluxStatusReporter) getReconcilersStatus(ctx context.Context, crds []metav1.GroupVersionKind) ([]fluxcdv1.FluxReconcilerStatus, error) { + var multiErr error + resStats := make([]fluxcdv1.FluxReconcilerStatus, len(crds)) + for i, gvk := range crds { + var total int + var suspended int + var failing int + var totalSize int64 + + list := unstructured.UnstructuredList{ + Object: map[string]interface{}{ + "apiVersion": gvk.Group + "/" + gvk.Version, + "kind": gvk.Kind, + }, + } + + if err := r.List(ctx, &list, client.InNamespace("")); err == nil { + total = len(list.Items) + + for _, item := range list.Items { + if s, _, _ := unstructured.NestedBool(item.Object, "spec", "suspend"); s { + suspended++ + } + + if obj, err := status.GetObjectWithConditions(item.Object); err == nil { + for _, cond := range obj.Status.Conditions { + if cond.Type == meta.ReadyCondition && cond.Status == corev1.ConditionFalse { + failing++ + } + } + } + + if size, found, _ := unstructured.NestedInt64(item.Object, "status", "artifact", "size"); found { + totalSize += size + } + } + } else { + multiErr = kerrors.NewAggregate([]error{multiErr, err}) + } + + resStats[i] = fluxcdv1.FluxReconcilerStatus{ + APIVersion: gvk.Group + "/" + gvk.Version, + Kind: gvk.Kind, + Stats: fluxcdv1.FluxReconcilerStats{ + Running: total - suspended, + Failing: failing, + Suspended: suspended, + TotalSize: formatSize(totalSize), + }, + } + } + + slices.SortStableFunc(resStats, func(i, j fluxcdv1.FluxReconcilerStatus) int { + return cmp.Compare(i.APIVersion+i.Kind, j.APIVersion+j.Kind) + }) + + return resStats, multiErr +} + +func formatSize(b int64) string { + if b == 0 { + return "" + } + const unit = 1024 + if b < unit { + return fmt.Sprintf("%d B", b) + } + div, exp := int64(unit), 0 + for n := b / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %ciB", + float64(b)/float64(div), "KMGTPE"[exp]) +} diff --git a/internal/reporter/reporter.go b/internal/reporter/reporter.go new file mode 100644 index 0000000..b36c63f --- /dev/null +++ b/internal/reporter/reporter.go @@ -0,0 +1,107 @@ +// Copyright 2024 Stefan Prodan. +// SPDX-License-Identifier: AGPL-3.0 + +package reporter + +import ( + "context" + "fmt" + "strconv" + + "github.com/fluxcd/pkg/apis/meta" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/client" + + fluxcdv1 "github.com/controlplaneio-fluxcd/flux-operator/api/v1" +) + +// FluxStatusReporter is responsible for computing +// the status report of the Flux installation. +type FluxStatusReporter struct { + client.Client + + instance string + manager string + namespace string + labelSelector client.MatchingLabels +} + +// NewFluxStatusReporter creates a new FluxStatusReporter +// for the given instance and namespace. +func NewFluxStatusReporter(kubeClient client.Client, instance, manager, namespace string) *FluxStatusReporter { + return &FluxStatusReporter{ + Client: kubeClient, + instance: instance, + manager: manager, + namespace: namespace, + labelSelector: client.MatchingLabels{"app.kubernetes.io/part-of": instance}, + } +} + +// Compute generate the status report of the Flux installation. +func (r *FluxStatusReporter) Compute(ctx context.Context) (fluxcdv1.FluxReportSpec, error) { + report := fluxcdv1.FluxReportSpec{} + report.Distribution = r.getDistributionStatus(ctx) + + crds, err := r.listCRDs(ctx) + if err != nil { + return report, fmt.Errorf("failed to list CRDs: %w", err) + } + + componentsStatus, err := r.getComponentsStatus(ctx) + if err != nil { + return report, fmt.Errorf("failed to compute components status: %w", err) + } + report.ComponentsStatus = componentsStatus + + reconcilersStatus, err := r.getReconcilersStatus(ctx, crds) + if err != nil { + return report, fmt.Errorf("failed to compute reconcilers status: %w", err) + } + report.ReconcilersStatus = reconcilersStatus + + syncStatus, err := r.getSyncStatus(ctx, crds) + if err != nil { + return report, fmt.Errorf("failed to compute sync status: %w", err) + } + report.SyncStatus = syncStatus + + return report, nil +} + +// RequestReportUpdate annotates the FluxReport object to trigger a reconciliation. +func RequestReportUpdate(ctx context.Context, kubeClient client.Client, instance, manager, namespace string) error { + report := &metav1.PartialObjectMetadata{} + report.SetGroupVersionKind(schema.GroupVersionKind{ + Group: fluxcdv1.GroupVersion.Group, + Kind: fluxcdv1.FluxReportKind, + Version: fluxcdv1.GroupVersion.Version, + }) + + objectKey := client.ObjectKey{ + Namespace: namespace, + Name: instance, + } + + if err := kubeClient.Get(ctx, objectKey, report); err != nil { + if errors.IsNotFound(err) { + return nil + } + return fmt.Errorf("failed to read %s '%s' error: %w", report.Kind, instance, err) + } + + patch := client.MergeFrom(report.DeepCopy()) + annotations := report.GetAnnotations() + if annotations == nil { + annotations = make(map[string]string) + } + annotations[meta.ReconcileRequestAnnotation] = strconv.FormatInt(metav1.Now().Unix(), 10) + report.SetAnnotations(annotations) + + if err := kubeClient.Patch(ctx, report, patch, client.FieldOwner(manager)); err != nil { + return fmt.Errorf("failed to annotate %s '%s' error: %w", report.Kind, instance, err) + } + return nil +} diff --git a/internal/reporter/sync.go b/internal/reporter/sync.go new file mode 100644 index 0000000..b24af98 --- /dev/null +++ b/internal/reporter/sync.go @@ -0,0 +1,102 @@ +// Copyright 2024 Stefan Prodan. +// SPDX-License-Identifier: AGPL-3.0 + +package reporter + +import ( + "context" + "fmt" + "strings" + + "github.com/fluxcd/cli-utils/pkg/kstatus/status" + "github.com/fluxcd/pkg/apis/meta" + corev1 "k8s.io/api/core/v1" + apiErrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "sigs.k8s.io/controller-runtime/pkg/client" + + fluxcdv1 "github.com/controlplaneio-fluxcd/flux-operator/api/v1" +) + +func (r *FluxStatusReporter) getSyncStatus(ctx context.Context, crds []metav1.GroupVersionKind) (*fluxcdv1.FluxSyncStatus, error) { + syncKind := "Kustomization" + syncGKV := gvkFor(syncKind, crds) + if syncGKV == nil { + return nil, fmt.Errorf("source kind %s not found", syncKind) + } + + syncObj := unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": syncGKV.Group + "/" + syncGKV.Version, + "kind": syncKind, + }, + } + + if err := r.Get(ctx, client.ObjectKey{ + Namespace: r.namespace, + Name: r.namespace, + }, &syncObj); err != nil { + if apiErrors.IsNotFound(err) { + // No sync configured, return empty status. + return nil, nil + } + return nil, fmt.Errorf("failed to assert sync status: %w", err) + } + + syncStatus := &fluxcdv1.FluxSyncStatus{ + ID: fmt.Sprintf("%s/%s", strings.ToLower(syncKind), r.namespace), + Ready: false, + Status: "not initialized", + } + + // Read spec.path from the sync object. + if path, found, _ := unstructured.NestedString(syncObj.Object, "spec", "path"); found { + syncStatus.Path = path + } + + // Set sync readiness based on the Kustomization object conditions. + if obj, err := status.GetObjectWithConditions(syncObj.Object); err == nil { + for _, cond := range obj.Status.Conditions { + if cond.Type == meta.ReadyCondition { + syncStatus.Ready = cond.Status != corev1.ConditionFalse + syncStatus.Status = cond.Message + } + } + } + + // Set source URL and readiness based on the source object conditions. + if sourceKind, found, _ := unstructured.NestedString(syncObj.Object, "spec", "sourceRef", "kind"); found { + sourceGVK := gvkFor(sourceKind, crds) + if sourceGVK == nil { + return nil, fmt.Errorf("source kind %s not found", sourceKind) + } + + sourceObj := unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": sourceGVK.Group + "/" + sourceGVK.Version, + "kind": sourceGVK.Kind, + }, + } + + if err := r.Get(ctx, client.ObjectKey{ + Namespace: r.namespace, + Name: r.namespace, + }, &sourceObj); err == nil { + if sourceURL, found, _ := unstructured.NestedString(sourceObj.Object, "spec", "url"); found { + syncStatus.Source = sourceURL + } + + if obj, err := status.GetObjectWithConditions(sourceObj.Object); err == nil { + for _, cond := range obj.Status.Conditions { + if cond.Type == meta.ReadyCondition && cond.Status == corev1.ConditionFalse { + syncStatus.Ready = false + // Append source error status to sync status. + syncStatus.Status += " " + cond.Message + } + } + } + } + } + return syncStatus, nil +}