diff --git a/Makefile b/Makefile index efb82042..87e5bd8d 100644 --- a/Makefile +++ b/Makefile @@ -93,6 +93,8 @@ help: ## Display this help. .PHONY: manifests manifests: controller-gen ## Generate WebhookConfiguration, ClusterRole and CustomResourceDefinition objects. $(CONTROLLER_GEN) rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases + # Normalize jsonPath filter quoting: controller-gen emits single quotes, kubectl prefers double + $(SED) -i "s/@.type=='Ready'/@.type==\"Ready\"/g" config/crd/bases/core.posit.team_chronicles.yaml config/crd/bases/core.posit.team_connects.yaml config/crd/bases/core.posit.team_flightdecks.yaml config/crd/bases/core.posit.team_packagemanagers.yaml config/crd/bases/core.posit.team_postgresdatabases.yaml config/crd/bases/core.posit.team_sites.yaml config/crd/bases/core.posit.team_workbenches.yaml .PHONY: copy-crds copy-crds: manifests ## Copy generated CRDs to internal/crdapply/bases for embedding. @@ -249,6 +251,8 @@ helm-generate: manifests kubebuilder ## Regenerate Helm chart from kustomize rm -f dist/chart/templates/rbac/auth_proxy_service.yaml # Remove kubebuilder-generated test workflow - we use our own CI workflows rm -f .github/workflows/test-chart.yml + # Normalize jsonPath filter quoting in Helm chart CRDs (matches config/crd/bases fixup above) + $(SED) -i "s/@.type=='Ready'/@.type==\"Ready\"/g" dist/chart/templates/crd/core.posit.team_chronicles.yaml dist/chart/templates/crd/core.posit.team_connects.yaml dist/chart/templates/crd/core.posit.team_flightdecks.yaml dist/chart/templates/crd/core.posit.team_packagemanagers.yaml dist/chart/templates/crd/core.posit.team_postgresdatabases.yaml dist/chart/templates/crd/core.posit.team_sites.yaml dist/chart/templates/crd/core.posit.team_workbenches.yaml .PHONY: helm-lint helm-lint: ## Lint the Helm chart diff --git a/api/core/v1beta1/chronicle_types.go b/api/core/v1beta1/chronicle_types.go index cb21c395..faf208d7 100644 --- a/api/core/v1beta1/chronicle_types.go +++ b/api/core/v1beta1/chronicle_types.go @@ -43,12 +43,17 @@ type ChronicleSpec struct { // ChronicleStatus defines the observed state of Chronicle type ChronicleStatus struct { + CommonProductStatus `json:",inline"` + // +optional Ready bool `json:"ready"` } // +kubebuilder:object:root=true // +kubebuilder:subresource:status // +kubebuilder:resource:shortName={pcr,chr},path=chronicles +// +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=='Ready')].status` +// +kubebuilder:printcolumn:name="Version",type=string,JSONPath=`.status.version` +// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` // +genclient // +k8s:openapi-gen=true diff --git a/api/core/v1beta1/connect_types.go b/api/core/v1beta1/connect_types.go index d6e02640..300b7302 100644 --- a/api/core/v1beta1/connect_types.go +++ b/api/core/v1beta1/connect_types.go @@ -155,13 +155,18 @@ type ConnectSpec struct { // ConnectStatus defines the observed state of Connect type ConnectStatus struct { - KeySecretRef corev1.SecretReference `json:"keySecretRef,omitempty"` - Ready bool `json:"ready"` + CommonProductStatus `json:",inline"` + KeySecretRef corev1.SecretReference `json:"keySecretRef,omitempty"` + // +optional + Ready bool `json:"ready"` } //+kubebuilder:object:root=true //+kubebuilder:subresource:status //+kubebuilder:resource:shortName={con,cons},path=connects +//+kubebuilder:printcolumn:name="Ready",type="string",JSONPath=".status.conditions[?(@.type=='Ready')].status" +//+kubebuilder:printcolumn:name="Version",type="string",JSONPath=".status.version" +//+kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp" //+genclient //+k8s:openapi-gen=true diff --git a/api/core/v1beta1/flightdeck_types.go b/api/core/v1beta1/flightdeck_types.go index a9406595..5ec8d325 100644 --- a/api/core/v1beta1/flightdeck_types.go +++ b/api/core/v1beta1/flightdeck_types.go @@ -65,12 +65,17 @@ type FlightdeckSpec struct { // FlightdeckStatus defines the observed state of Flightdeck type FlightdeckStatus struct { + CommonProductStatus `json:",inline"` // Ready indicates whether the Flightdeck deployment is ready + // +optional Ready bool `json:"ready"` } //+kubebuilder:object:root=true //+kubebuilder:subresource:status +//+kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=='Ready')].status` +//+kubebuilder:printcolumn:name="Version",type=string,JSONPath=`.status.version` +//+kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` //+genclient //+k8s:openapi-gen=true diff --git a/api/core/v1beta1/packagemanager_types.go b/api/core/v1beta1/packagemanager_types.go index 11a252b9..3ee2b932 100644 --- a/api/core/v1beta1/packagemanager_types.go +++ b/api/core/v1beta1/packagemanager_types.go @@ -93,13 +93,18 @@ type PackageManagerSpec struct { // PackageManagerStatus defines the observed state of PackageManager type PackageManagerStatus struct { - KeySecretRef v1.SecretReference `json:"keySecretRef,omitempty"` - Ready bool `json:"ready"` + CommonProductStatus `json:",inline"` + KeySecretRef v1.SecretReference `json:"keySecretRef,omitempty"` + // +optional + Ready bool `json:"ready"` } //+kubebuilder:object:root=true //+kubebuilder:subresource:status //+kubebuilder:resource:shortName={pm,pms},path=packagemanagers +//+kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=='Ready')].status` +//+kubebuilder:printcolumn:name="Version",type=string,JSONPath=`.status.version` +//+kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` //+genclient //+k8s:openapi-gen=true diff --git a/api/core/v1beta1/postgresdatabase_types.go b/api/core/v1beta1/postgresdatabase_types.go index af10f849..afca94e1 100644 --- a/api/core/v1beta1/postgresdatabase_types.go +++ b/api/core/v1beta1/postgresdatabase_types.go @@ -48,11 +48,15 @@ type PostgresDatabaseSpecTeardown struct { } // PostgresDatabaseStatus defines the observed state of PostgresDatabase -type PostgresDatabaseStatus struct{} +type PostgresDatabaseStatus struct { + CommonProductStatus `json:",inline"` +} //+kubebuilder:object:root=true //+kubebuilder:subresource:status //+kubebuilder:resource:shortName={pgdb,pgdbs},path=postgresdatabases +//+kubebuilder:printcolumn:name="Ready",type="string",JSONPath=".status.conditions[?(@.type=='Ready')].status" +//+kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp" //+genclient // PostgresDatabase is the Schema for the postgresdatabases API diff --git a/api/core/v1beta1/site_types.go b/api/core/v1beta1/site_types.go index 19eb1975..336788f2 100644 --- a/api/core/v1beta1/site_types.go +++ b/api/core/v1beta1/site_types.go @@ -147,6 +147,9 @@ type AzureFilesConfig struct { ShareSizeGiB int `json:"shareSizeGiB,omitempty"` } +// InternalFlightdeckSpec configures Flightdeck within a Site. +// Flightdeck is stateless, so there is no Teardown field: disabling removes all resources +// immediately (equivalent to teardown for stateful products). type InternalFlightdeckSpec struct { // Enabled controls whether Flightdeck is deployed. Defaults to true if not specified. // Set to false to explicitly disable Flightdeck deployment. @@ -626,12 +629,33 @@ type ApiSettingsConfig struct { // SiteStatus defines the observed state of Site type SiteStatus struct { - // INSERT ADDITIONAL STATUS FIELD - define observed state of cluster - // Important: Run "make" to regenerate code after modifying this file + CommonProductStatus `json:",inline"` + + // ConnectReady indicates whether the Connect child resource is ready. + // +optional + ConnectReady bool `json:"connectReady,omitempty"` + + // WorkbenchReady indicates whether the Workbench child resource is ready. + // +optional + WorkbenchReady bool `json:"workbenchReady,omitempty"` + + // PackageManagerReady indicates whether the PackageManager child resource is ready. + // +optional + PackageManagerReady bool `json:"packageManagerReady,omitempty"` + + // ChronicleReady indicates whether the Chronicle child resource is ready. + // +optional + ChronicleReady bool `json:"chronicleReady,omitempty"` + + // FlightdeckReady indicates whether the Flightdeck child resource is ready. + // +optional + FlightdeckReady bool `json:"flightdeckReady,omitempty"` } //+kubebuilder:object:root=true //+kubebuilder:subresource:status +//+kubebuilder:printcolumn:name="Ready",type="string",JSONPath=".status.conditions[?(@.type=='Ready')].status" +//+kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp" //+genclient //+k8s:openapi-gen=true diff --git a/api/core/v1beta1/status.go b/api/core/v1beta1/status.go new file mode 100644 index 00000000..a4ca5ee8 --- /dev/null +++ b/api/core/v1beta1/status.go @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2023-2026 Posit Software, PBC + +package v1beta1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// CommonProductStatus contains the common status fields shared by all product CRDs. +// Embed this struct inline in product-specific Status types. +type CommonProductStatus struct { + // Conditions represent the latest available observations of the resource's current state. + // +optional + // +patchMergeKey=type + // +patchStrategy=merge + // +listType=map + // +listMapKey=type + Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type"` + + // ObservedGeneration is the most recent generation observed for this resource. + // It corresponds to the resource's generation, which is updated on mutation by the API Server. + // +optional + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + + // Version is the version of the product image being deployed. + // +optional + Version string `json:"version,omitempty"` +} diff --git a/api/core/v1beta1/workbench_types.go b/api/core/v1beta1/workbench_types.go index c9bc28fa..b5f8315e 100644 --- a/api/core/v1beta1/workbench_types.go +++ b/api/core/v1beta1/workbench_types.go @@ -120,6 +120,8 @@ type WorkbenchSpec struct { // WorkbenchStatus defines the observed state of Workbench type WorkbenchStatus struct { + CommonProductStatus `json:",inline"` + // +optional Ready bool `json:"ready"` KeySecretRef corev1.SecretReference `json:"keySecretRef,omitempty"` } @@ -127,6 +129,9 @@ type WorkbenchStatus struct { //+kubebuilder:object:root=true //+kubebuilder:subresource:status //+kubebuilder:resource:shortName={wb,wbs},path=workbenches,singular=workbench +//+kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=='Ready')].status` +//+kubebuilder:printcolumn:name="Version",type=string,JSONPath=`.status.version` +//+kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` //+genclient //+k8s:openapi-gen=true diff --git a/api/core/v1beta1/zz_generated.deepcopy.go b/api/core/v1beta1/zz_generated.deepcopy.go index b76d0f75..4a7ed2d1 100644 --- a/api/core/v1beta1/zz_generated.deepcopy.go +++ b/api/core/v1beta1/zz_generated.deepcopy.go @@ -11,6 +11,7 @@ import ( "github.com/posit-dev/team-operator/api/product" "k8s.io/api/core/v1" apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" ) @@ -130,7 +131,7 @@ func (in *Chronicle) DeepCopyInto(out *Chronicle) { out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) in.Spec.DeepCopyInto(&out.Spec) - out.Status = in.Status + in.Status.DeepCopyInto(&out.Status) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Chronicle. @@ -361,6 +362,7 @@ func (in *ChronicleSpec) DeepCopy() *ChronicleSpec { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ChronicleStatus) DeepCopyInto(out *ChronicleStatus) { *out = *in + in.CommonProductStatus.DeepCopyInto(&out.CommonProductStatus) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ChronicleStatus. @@ -373,13 +375,35 @@ func (in *ChronicleStatus) DeepCopy() *ChronicleStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CommonProductStatus) DeepCopyInto(out *CommonProductStatus) { + *out = *in + 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 CommonProductStatus. +func (in *CommonProductStatus) DeepCopy() *CommonProductStatus { + if in == nil { + return nil + } + out := new(CommonProductStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Connect) DeepCopyInto(out *Connect) { *out = *in out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) in.Spec.DeepCopyInto(&out.Spec) - out.Status = in.Status + in.Status.DeepCopyInto(&out.Status) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Connect. @@ -912,6 +936,7 @@ func (in *ConnectSpec) DeepCopy() *ConnectSpec { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ConnectStatus) DeepCopyInto(out *ConnectStatus) { *out = *in + in.CommonProductStatus.DeepCopyInto(&out.CommonProductStatus) out.KeySecretRef = in.KeySecretRef } @@ -991,7 +1016,7 @@ func (in *Flightdeck) DeepCopyInto(out *Flightdeck) { out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) in.Spec.DeepCopyInto(&out.Spec) - out.Status = in.Status + in.Status.DeepCopyInto(&out.Status) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Flightdeck. @@ -1075,6 +1100,7 @@ func (in *FlightdeckSpec) DeepCopy() *FlightdeckSpec { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *FlightdeckStatus) DeepCopyInto(out *FlightdeckStatus) { *out = *in + in.CommonProductStatus.DeepCopyInto(&out.CommonProductStatus) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FlightdeckStatus. @@ -1561,7 +1587,7 @@ func (in *PackageManager) DeepCopyInto(out *PackageManager) { out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) in.Spec.DeepCopyInto(&out.Spec) - out.Status = in.Status + in.Status.DeepCopyInto(&out.Status) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PackageManager. @@ -1955,6 +1981,7 @@ func (in *PackageManagerSpec) DeepCopy() *PackageManagerSpec { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PackageManagerStatus) DeepCopyInto(out *PackageManagerStatus) { *out = *in + in.CommonProductStatus.DeepCopyInto(&out.CommonProductStatus) out.KeySecretRef = in.KeySecretRef } @@ -2030,7 +2057,7 @@ func (in *PostgresDatabase) DeepCopyInto(out *PostgresDatabase) { out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) in.Spec.DeepCopyInto(&out.Spec) - out.Status = in.Status + in.Status.DeepCopyInto(&out.Status) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgresDatabase. @@ -2149,6 +2176,7 @@ func (in *PostgresDatabaseSpecTeardown) DeepCopy() *PostgresDatabaseSpecTeardown // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PostgresDatabaseStatus) DeepCopyInto(out *PostgresDatabaseStatus) { *out = *in + in.CommonProductStatus.DeepCopyInto(&out.CommonProductStatus) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgresDatabaseStatus. @@ -2255,7 +2283,7 @@ func (in *Site) DeepCopyInto(out *Site) { out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) in.Spec.DeepCopyInto(&out.Spec) - out.Status = in.Status + in.Status.DeepCopyInto(&out.Status) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Site. @@ -2360,6 +2388,7 @@ func (in *SiteSpec) DeepCopy() *SiteSpec { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *SiteStatus) DeepCopyInto(out *SiteStatus) { *out = *in + in.CommonProductStatus.DeepCopyInto(&out.CommonProductStatus) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SiteStatus. @@ -2480,7 +2509,7 @@ func (in *Workbench) DeepCopyInto(out *Workbench) { out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) in.Spec.DeepCopyInto(&out.Spec) - out.Status = in.Status + in.Status.DeepCopyInto(&out.Status) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Workbench. @@ -3301,6 +3330,7 @@ func (in *WorkbenchSpec) DeepCopy() *WorkbenchSpec { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *WorkbenchStatus) DeepCopyInto(out *WorkbenchStatus) { *out = *in + in.CommonProductStatus.DeepCopyInto(&out.CommonProductStatus) out.KeySecretRef = in.KeySecretRef } diff --git a/api/localtest/fake.go b/api/localtest/fake.go index feb5c9bd..e7170b30 100644 --- a/api/localtest/fake.go +++ b/api/localtest/fake.go @@ -2,6 +2,7 @@ package localtest import ( "github.com/go-logr/logr" + v1beta1 "github.com/posit-dev/team-operator/api/core/v1beta1" "github.com/posit-dev/team-operator/api/product" "k8s.io/apimachinery/pkg/runtime" "sigs.k8s.io/controller-runtime/pkg/client" @@ -11,10 +12,28 @@ import ( type FakeTestEnv struct{} func (fte *FakeTestEnv) Start(loadSchemes func(scheme *runtime.Scheme)) (client.WithWatch, *runtime.Scheme, logr.Logger) { - cli := fakectrl.NewFakeClient() - cliScheme := cli.Scheme() - loadSchemes(cliScheme) + scheme := runtime.NewScheme() + loadSchemes(scheme) + + // WithStatusSubresource must list every v1beta1 type that carries a + // +kubebuilder:subresource:status marker. Without this registration, + // Status().Update() silently mutates the main object body instead of + // the status subresource, producing test false-positives. + // When adding a new v1beta1 type with +kubebuilder:subresource:status, + // add it here as well. + cli := fakectrl.NewClientBuilder(). + WithScheme(scheme). + WithStatusSubresource( + &v1beta1.Connect{}, + &v1beta1.Workbench{}, + &v1beta1.PackageManager{}, + &v1beta1.Chronicle{}, + &v1beta1.Flightdeck{}, + &v1beta1.PostgresDatabase{}, + &v1beta1.Site{}, + ). + Build() log := product.NewSimpleLogger() - return cli, cliScheme, log + return cli, scheme, log } diff --git a/api/localtest/fake_test.go b/api/localtest/fake_test.go new file mode 100644 index 00000000..b5fa24d1 --- /dev/null +++ b/api/localtest/fake_test.go @@ -0,0 +1,59 @@ +package localtest_test + +import ( + "context" + "testing" + + v1beta1 "github.com/posit-dev/team-operator/api/core/v1beta1" + "github.com/posit-dev/team-operator/api/localtest" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func loadFakeSchemes(scheme *runtime.Scheme) { + utilruntime.Must(clientgoscheme.AddToScheme(scheme)) + utilruntime.Must(v1beta1.AddToScheme(scheme)) +} + +// TestStatusUpdateOnlyMutatesStatus verifies that Status().Update() persists the +// status subresource independently from the main object body. Without +// WithStatusSubresource registration in the fake client, status updates silently +// mutate the whole object (including spec), producing test false-positives. +func TestStatusUpdateOnlyMutatesStatus(t *testing.T) { + r := require.New(t) + ctx := context.TODO() + + fte := &localtest.FakeTestEnv{} + cli, _, _ := fte.Start(loadFakeSchemes) + + site := &v1beta1.Site{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-site", + Namespace: "default", + }, + Spec: v1beta1.SiteSpec{ + Domain: "original.example.com", + }, + } + r.NoError(cli.Create(ctx, site)) + + // Mutate both spec and status on the in-memory object, then call + // Status().Update(). Only the status change should be persisted. + site.Spec.Domain = "should-not-persist.example.com" + site.Status.ConnectReady = true + r.NoError(cli.Status().Update(ctx, site)) + + fetched := &v1beta1.Site{} + r.NoError(cli.Get(ctx, client.ObjectKeyFromObject(site), fetched)) + + // Status update must be persisted. + r.True(fetched.Status.ConnectReady, "status.connectReady should be true after Status().Update()") + + // Spec must not be affected by the status update. + r.Equal("original.example.com", fetched.Spec.Domain, + "spec.domain must not be modified by Status().Update()") +} diff --git a/client-go/applyconfiguration/core/v1beta1/chroniclestatus.go b/client-go/applyconfiguration/core/v1beta1/chroniclestatus.go index 9f904dd5..fa88f9bf 100644 --- a/client-go/applyconfiguration/core/v1beta1/chroniclestatus.go +++ b/client-go/applyconfiguration/core/v1beta1/chroniclestatus.go @@ -5,10 +5,15 @@ package v1beta1 +import ( + v1 "k8s.io/client-go/applyconfigurations/meta/v1" +) + // ChronicleStatusApplyConfiguration represents a declarative configuration of the ChronicleStatus type for use // with apply. type ChronicleStatusApplyConfiguration struct { - Ready *bool `json:"ready,omitempty"` + CommonProductStatusApplyConfiguration `json:",inline"` + Ready *bool `json:"ready,omitempty"` } // ChronicleStatusApplyConfiguration constructs a declarative configuration of the ChronicleStatus type for use with @@ -17,6 +22,35 @@ func ChronicleStatus() *ChronicleStatusApplyConfiguration { return &ChronicleStatusApplyConfiguration{} } +// WithConditions adds the given value to the Conditions field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, values provided by each call will be appended to the Conditions field. +func (b *ChronicleStatusApplyConfiguration) WithConditions(values ...*v1.ConditionApplyConfiguration) *ChronicleStatusApplyConfiguration { + for i := range values { + if values[i] == nil { + panic("nil value passed to WithConditions") + } + b.CommonProductStatusApplyConfiguration.Conditions = append(b.CommonProductStatusApplyConfiguration.Conditions, *values[i]) + } + return b +} + +// WithObservedGeneration sets the ObservedGeneration field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the ObservedGeneration field is set to the value of the last call. +func (b *ChronicleStatusApplyConfiguration) WithObservedGeneration(value int64) *ChronicleStatusApplyConfiguration { + b.CommonProductStatusApplyConfiguration.ObservedGeneration = &value + return b +} + +// WithVersion sets the Version field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Version field is set to the value of the last call. +func (b *ChronicleStatusApplyConfiguration) WithVersion(value string) *ChronicleStatusApplyConfiguration { + b.CommonProductStatusApplyConfiguration.Version = &value + return b +} + // WithReady sets the Ready field in the declarative configuration to the given value // and returns the receiver, so that objects can be built by chaining "With" function invocations. // If called multiple times, the Ready field is set to the value of the last call. diff --git a/client-go/applyconfiguration/core/v1beta1/commonproductstatus.go b/client-go/applyconfiguration/core/v1beta1/commonproductstatus.go new file mode 100644 index 00000000..b8801c4e --- /dev/null +++ b/client-go/applyconfiguration/core/v1beta1/commonproductstatus.go @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2023-2026 Posit Software, PBC + +// Code generated by applyconfiguration-gen. DO NOT EDIT. + +package v1beta1 + +import ( + v1 "k8s.io/client-go/applyconfigurations/meta/v1" +) + +// CommonProductStatusApplyConfiguration represents a declarative configuration of the CommonProductStatus type for use +// with apply. +type CommonProductStatusApplyConfiguration struct { + Conditions []v1.ConditionApplyConfiguration `json:"conditions,omitempty"` + ObservedGeneration *int64 `json:"observedGeneration,omitempty"` + Version *string `json:"version,omitempty"` +} + +// CommonProductStatusApplyConfiguration constructs a declarative configuration of the CommonProductStatus type for use with +// apply. +func CommonProductStatus() *CommonProductStatusApplyConfiguration { + return &CommonProductStatusApplyConfiguration{} +} + +// WithConditions adds the given value to the Conditions field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, values provided by each call will be appended to the Conditions field. +func (b *CommonProductStatusApplyConfiguration) WithConditions(values ...*v1.ConditionApplyConfiguration) *CommonProductStatusApplyConfiguration { + for i := range values { + if values[i] == nil { + panic("nil value passed to WithConditions") + } + b.Conditions = append(b.Conditions, *values[i]) + } + return b +} + +// WithObservedGeneration sets the ObservedGeneration field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the ObservedGeneration field is set to the value of the last call. +func (b *CommonProductStatusApplyConfiguration) WithObservedGeneration(value int64) *CommonProductStatusApplyConfiguration { + b.ObservedGeneration = &value + return b +} + +// WithVersion sets the Version field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Version field is set to the value of the last call. +func (b *CommonProductStatusApplyConfiguration) WithVersion(value string) *CommonProductStatusApplyConfiguration { + b.Version = &value + return b +} diff --git a/client-go/applyconfiguration/core/v1beta1/connectstatus.go b/client-go/applyconfiguration/core/v1beta1/connectstatus.go index 9e00bdf3..3f8a3b98 100644 --- a/client-go/applyconfiguration/core/v1beta1/connectstatus.go +++ b/client-go/applyconfiguration/core/v1beta1/connectstatus.go @@ -7,13 +7,15 @@ package v1beta1 import ( v1 "k8s.io/api/core/v1" + metav1 "k8s.io/client-go/applyconfigurations/meta/v1" ) // ConnectStatusApplyConfiguration represents a declarative configuration of the ConnectStatus type for use // with apply. type ConnectStatusApplyConfiguration struct { - KeySecretRef *v1.SecretReference `json:"keySecretRef,omitempty"` - Ready *bool `json:"ready,omitempty"` + CommonProductStatusApplyConfiguration `json:",inline"` + KeySecretRef *v1.SecretReference `json:"keySecretRef,omitempty"` + Ready *bool `json:"ready,omitempty"` } // ConnectStatusApplyConfiguration constructs a declarative configuration of the ConnectStatus type for use with @@ -22,6 +24,35 @@ func ConnectStatus() *ConnectStatusApplyConfiguration { return &ConnectStatusApplyConfiguration{} } +// WithConditions adds the given value to the Conditions field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, values provided by each call will be appended to the Conditions field. +func (b *ConnectStatusApplyConfiguration) WithConditions(values ...*metav1.ConditionApplyConfiguration) *ConnectStatusApplyConfiguration { + for i := range values { + if values[i] == nil { + panic("nil value passed to WithConditions") + } + b.CommonProductStatusApplyConfiguration.Conditions = append(b.CommonProductStatusApplyConfiguration.Conditions, *values[i]) + } + return b +} + +// WithObservedGeneration sets the ObservedGeneration field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the ObservedGeneration field is set to the value of the last call. +func (b *ConnectStatusApplyConfiguration) WithObservedGeneration(value int64) *ConnectStatusApplyConfiguration { + b.CommonProductStatusApplyConfiguration.ObservedGeneration = &value + return b +} + +// WithVersion sets the Version field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Version field is set to the value of the last call. +func (b *ConnectStatusApplyConfiguration) WithVersion(value string) *ConnectStatusApplyConfiguration { + b.CommonProductStatusApplyConfiguration.Version = &value + return b +} + // WithKeySecretRef sets the KeySecretRef field in the declarative configuration to the given value // and returns the receiver, so that objects can be built by chaining "With" function invocations. // If called multiple times, the KeySecretRef field is set to the value of the last call. diff --git a/client-go/applyconfiguration/core/v1beta1/flightdeckstatus.go b/client-go/applyconfiguration/core/v1beta1/flightdeckstatus.go index d547b898..93a0873b 100644 --- a/client-go/applyconfiguration/core/v1beta1/flightdeckstatus.go +++ b/client-go/applyconfiguration/core/v1beta1/flightdeckstatus.go @@ -5,10 +5,15 @@ package v1beta1 +import ( + v1 "k8s.io/client-go/applyconfigurations/meta/v1" +) + // FlightdeckStatusApplyConfiguration represents a declarative configuration of the FlightdeckStatus type for use // with apply. type FlightdeckStatusApplyConfiguration struct { - Ready *bool `json:"ready,omitempty"` + CommonProductStatusApplyConfiguration `json:",inline"` + Ready *bool `json:"ready,omitempty"` } // FlightdeckStatusApplyConfiguration constructs a declarative configuration of the FlightdeckStatus type for use with @@ -17,6 +22,35 @@ func FlightdeckStatus() *FlightdeckStatusApplyConfiguration { return &FlightdeckStatusApplyConfiguration{} } +// WithConditions adds the given value to the Conditions field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, values provided by each call will be appended to the Conditions field. +func (b *FlightdeckStatusApplyConfiguration) WithConditions(values ...*v1.ConditionApplyConfiguration) *FlightdeckStatusApplyConfiguration { + for i := range values { + if values[i] == nil { + panic("nil value passed to WithConditions") + } + b.CommonProductStatusApplyConfiguration.Conditions = append(b.CommonProductStatusApplyConfiguration.Conditions, *values[i]) + } + return b +} + +// WithObservedGeneration sets the ObservedGeneration field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the ObservedGeneration field is set to the value of the last call. +func (b *FlightdeckStatusApplyConfiguration) WithObservedGeneration(value int64) *FlightdeckStatusApplyConfiguration { + b.CommonProductStatusApplyConfiguration.ObservedGeneration = &value + return b +} + +// WithVersion sets the Version field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Version field is set to the value of the last call. +func (b *FlightdeckStatusApplyConfiguration) WithVersion(value string) *FlightdeckStatusApplyConfiguration { + b.CommonProductStatusApplyConfiguration.Version = &value + return b +} + // WithReady sets the Ready field in the declarative configuration to the given value // and returns the receiver, so that objects can be built by chaining "With" function invocations. // If called multiple times, the Ready field is set to the value of the last call. diff --git a/client-go/applyconfiguration/core/v1beta1/packagemanagerstatus.go b/client-go/applyconfiguration/core/v1beta1/packagemanagerstatus.go index 322d988d..cbf96b59 100644 --- a/client-go/applyconfiguration/core/v1beta1/packagemanagerstatus.go +++ b/client-go/applyconfiguration/core/v1beta1/packagemanagerstatus.go @@ -7,13 +7,15 @@ package v1beta1 import ( v1 "k8s.io/api/core/v1" + metav1 "k8s.io/client-go/applyconfigurations/meta/v1" ) // PackageManagerStatusApplyConfiguration represents a declarative configuration of the PackageManagerStatus type for use // with apply. type PackageManagerStatusApplyConfiguration struct { - KeySecretRef *v1.SecretReference `json:"keySecretRef,omitempty"` - Ready *bool `json:"ready,omitempty"` + CommonProductStatusApplyConfiguration `json:",inline"` + KeySecretRef *v1.SecretReference `json:"keySecretRef,omitempty"` + Ready *bool `json:"ready,omitempty"` } // PackageManagerStatusApplyConfiguration constructs a declarative configuration of the PackageManagerStatus type for use with @@ -22,6 +24,35 @@ func PackageManagerStatus() *PackageManagerStatusApplyConfiguration { return &PackageManagerStatusApplyConfiguration{} } +// WithConditions adds the given value to the Conditions field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, values provided by each call will be appended to the Conditions field. +func (b *PackageManagerStatusApplyConfiguration) WithConditions(values ...*metav1.ConditionApplyConfiguration) *PackageManagerStatusApplyConfiguration { + for i := range values { + if values[i] == nil { + panic("nil value passed to WithConditions") + } + b.CommonProductStatusApplyConfiguration.Conditions = append(b.CommonProductStatusApplyConfiguration.Conditions, *values[i]) + } + return b +} + +// WithObservedGeneration sets the ObservedGeneration field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the ObservedGeneration field is set to the value of the last call. +func (b *PackageManagerStatusApplyConfiguration) WithObservedGeneration(value int64) *PackageManagerStatusApplyConfiguration { + b.CommonProductStatusApplyConfiguration.ObservedGeneration = &value + return b +} + +// WithVersion sets the Version field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Version field is set to the value of the last call. +func (b *PackageManagerStatusApplyConfiguration) WithVersion(value string) *PackageManagerStatusApplyConfiguration { + b.CommonProductStatusApplyConfiguration.Version = &value + return b +} + // WithKeySecretRef sets the KeySecretRef field in the declarative configuration to the given value // and returns the receiver, so that objects can be built by chaining "With" function invocations. // If called multiple times, the KeySecretRef field is set to the value of the last call. diff --git a/client-go/applyconfiguration/core/v1beta1/postgresdatabase.go b/client-go/applyconfiguration/core/v1beta1/postgresdatabase.go index 02e3ee70..5e57a7e0 100644 --- a/client-go/applyconfiguration/core/v1beta1/postgresdatabase.go +++ b/client-go/applyconfiguration/core/v1beta1/postgresdatabase.go @@ -6,7 +6,6 @@ package v1beta1 import ( - corev1beta1 "github.com/posit-dev/team-operator/api/core/v1beta1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" types "k8s.io/apimachinery/pkg/types" v1 "k8s.io/client-go/applyconfigurations/meta/v1" @@ -17,8 +16,8 @@ import ( type PostgresDatabaseApplyConfiguration struct { v1.TypeMetaApplyConfiguration `json:",inline"` *v1.ObjectMetaApplyConfiguration `json:"metadata,omitempty"` - Spec *PostgresDatabaseSpecApplyConfiguration `json:"spec,omitempty"` - Status *corev1beta1.PostgresDatabaseStatus `json:"status,omitempty"` + Spec *PostgresDatabaseSpecApplyConfiguration `json:"spec,omitempty"` + Status *PostgresDatabaseStatusApplyConfiguration `json:"status,omitempty"` } // PostgresDatabase constructs a declarative configuration of the PostgresDatabase type for use with @@ -202,8 +201,8 @@ func (b *PostgresDatabaseApplyConfiguration) WithSpec(value *PostgresDatabaseSpe // WithStatus sets the Status field in the declarative configuration to the given value // and returns the receiver, so that objects can be built by chaining "With" function invocations. // If called multiple times, the Status field is set to the value of the last call. -func (b *PostgresDatabaseApplyConfiguration) WithStatus(value corev1beta1.PostgresDatabaseStatus) *PostgresDatabaseApplyConfiguration { - b.Status = &value +func (b *PostgresDatabaseApplyConfiguration) WithStatus(value *PostgresDatabaseStatusApplyConfiguration) *PostgresDatabaseApplyConfiguration { + b.Status = value return b } diff --git a/client-go/applyconfiguration/core/v1beta1/postgresdatabasestatus.go b/client-go/applyconfiguration/core/v1beta1/postgresdatabasestatus.go new file mode 100644 index 00000000..0a221b4e --- /dev/null +++ b/client-go/applyconfiguration/core/v1beta1/postgresdatabasestatus.go @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2023-2026 Posit Software, PBC + +// Code generated by applyconfiguration-gen. DO NOT EDIT. + +package v1beta1 + +import ( + v1 "k8s.io/client-go/applyconfigurations/meta/v1" +) + +// PostgresDatabaseStatusApplyConfiguration represents a declarative configuration of the PostgresDatabaseStatus type for use +// with apply. +type PostgresDatabaseStatusApplyConfiguration struct { + CommonProductStatusApplyConfiguration `json:",inline"` +} + +// PostgresDatabaseStatusApplyConfiguration constructs a declarative configuration of the PostgresDatabaseStatus type for use with +// apply. +func PostgresDatabaseStatus() *PostgresDatabaseStatusApplyConfiguration { + return &PostgresDatabaseStatusApplyConfiguration{} +} + +// WithConditions adds the given value to the Conditions field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, values provided by each call will be appended to the Conditions field. +func (b *PostgresDatabaseStatusApplyConfiguration) WithConditions(values ...*v1.ConditionApplyConfiguration) *PostgresDatabaseStatusApplyConfiguration { + for i := range values { + if values[i] == nil { + panic("nil value passed to WithConditions") + } + b.CommonProductStatusApplyConfiguration.Conditions = append(b.CommonProductStatusApplyConfiguration.Conditions, *values[i]) + } + return b +} + +// WithObservedGeneration sets the ObservedGeneration field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the ObservedGeneration field is set to the value of the last call. +func (b *PostgresDatabaseStatusApplyConfiguration) WithObservedGeneration(value int64) *PostgresDatabaseStatusApplyConfiguration { + b.CommonProductStatusApplyConfiguration.ObservedGeneration = &value + return b +} + +// WithVersion sets the Version field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Version field is set to the value of the last call. +func (b *PostgresDatabaseStatusApplyConfiguration) WithVersion(value string) *PostgresDatabaseStatusApplyConfiguration { + b.CommonProductStatusApplyConfiguration.Version = &value + return b +} diff --git a/client-go/applyconfiguration/core/v1beta1/site.go b/client-go/applyconfiguration/core/v1beta1/site.go index ba5b74b4..6a891617 100644 --- a/client-go/applyconfiguration/core/v1beta1/site.go +++ b/client-go/applyconfiguration/core/v1beta1/site.go @@ -6,7 +6,6 @@ package v1beta1 import ( - corev1beta1 "github.com/posit-dev/team-operator/api/core/v1beta1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" types "k8s.io/apimachinery/pkg/types" v1 "k8s.io/client-go/applyconfigurations/meta/v1" @@ -17,8 +16,8 @@ import ( type SiteApplyConfiguration struct { v1.TypeMetaApplyConfiguration `json:",inline"` *v1.ObjectMetaApplyConfiguration `json:"metadata,omitempty"` - Spec *SiteSpecApplyConfiguration `json:"spec,omitempty"` - Status *corev1beta1.SiteStatus `json:"status,omitempty"` + Spec *SiteSpecApplyConfiguration `json:"spec,omitempty"` + Status *SiteStatusApplyConfiguration `json:"status,omitempty"` } // Site constructs a declarative configuration of the Site type for use with @@ -202,8 +201,8 @@ func (b *SiteApplyConfiguration) WithSpec(value *SiteSpecApplyConfiguration) *Si // WithStatus sets the Status field in the declarative configuration to the given value // and returns the receiver, so that objects can be built by chaining "With" function invocations. // If called multiple times, the Status field is set to the value of the last call. -func (b *SiteApplyConfiguration) WithStatus(value corev1beta1.SiteStatus) *SiteApplyConfiguration { - b.Status = &value +func (b *SiteApplyConfiguration) WithStatus(value *SiteStatusApplyConfiguration) *SiteApplyConfiguration { + b.Status = value return b } diff --git a/client-go/applyconfiguration/core/v1beta1/sitestatus.go b/client-go/applyconfiguration/core/v1beta1/sitestatus.go new file mode 100644 index 00000000..ca403d60 --- /dev/null +++ b/client-go/applyconfiguration/core/v1beta1/sitestatus.go @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2023-2026 Posit Software, PBC + +// Code generated by applyconfiguration-gen. DO NOT EDIT. + +package v1beta1 + +import ( + v1 "k8s.io/client-go/applyconfigurations/meta/v1" +) + +// SiteStatusApplyConfiguration represents a declarative configuration of the SiteStatus type for use +// with apply. +type SiteStatusApplyConfiguration struct { + CommonProductStatusApplyConfiguration `json:",inline"` + ConnectReady *bool `json:"connectReady,omitempty"` + WorkbenchReady *bool `json:"workbenchReady,omitempty"` + PackageManagerReady *bool `json:"packageManagerReady,omitempty"` + ChronicleReady *bool `json:"chronicleReady,omitempty"` + FlightdeckReady *bool `json:"flightdeckReady,omitempty"` +} + +// SiteStatusApplyConfiguration constructs a declarative configuration of the SiteStatus type for use with +// apply. +func SiteStatus() *SiteStatusApplyConfiguration { + return &SiteStatusApplyConfiguration{} +} + +// WithConditions adds the given value to the Conditions field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, values provided by each call will be appended to the Conditions field. +func (b *SiteStatusApplyConfiguration) WithConditions(values ...*v1.ConditionApplyConfiguration) *SiteStatusApplyConfiguration { + for i := range values { + if values[i] == nil { + panic("nil value passed to WithConditions") + } + b.CommonProductStatusApplyConfiguration.Conditions = append(b.CommonProductStatusApplyConfiguration.Conditions, *values[i]) + } + return b +} + +// WithObservedGeneration sets the ObservedGeneration field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the ObservedGeneration field is set to the value of the last call. +func (b *SiteStatusApplyConfiguration) WithObservedGeneration(value int64) *SiteStatusApplyConfiguration { + b.CommonProductStatusApplyConfiguration.ObservedGeneration = &value + return b +} + +// WithVersion sets the Version field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Version field is set to the value of the last call. +func (b *SiteStatusApplyConfiguration) WithVersion(value string) *SiteStatusApplyConfiguration { + b.CommonProductStatusApplyConfiguration.Version = &value + return b +} + +// WithConnectReady sets the ConnectReady field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the ConnectReady field is set to the value of the last call. +func (b *SiteStatusApplyConfiguration) WithConnectReady(value bool) *SiteStatusApplyConfiguration { + b.ConnectReady = &value + return b +} + +// WithWorkbenchReady sets the WorkbenchReady field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the WorkbenchReady field is set to the value of the last call. +func (b *SiteStatusApplyConfiguration) WithWorkbenchReady(value bool) *SiteStatusApplyConfiguration { + b.WorkbenchReady = &value + return b +} + +// WithPackageManagerReady sets the PackageManagerReady field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the PackageManagerReady field is set to the value of the last call. +func (b *SiteStatusApplyConfiguration) WithPackageManagerReady(value bool) *SiteStatusApplyConfiguration { + b.PackageManagerReady = &value + return b +} + +// WithChronicleReady sets the ChronicleReady field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the ChronicleReady field is set to the value of the last call. +func (b *SiteStatusApplyConfiguration) WithChronicleReady(value bool) *SiteStatusApplyConfiguration { + b.ChronicleReady = &value + return b +} + +// WithFlightdeckReady sets the FlightdeckReady field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the FlightdeckReady field is set to the value of the last call. +func (b *SiteStatusApplyConfiguration) WithFlightdeckReady(value bool) *SiteStatusApplyConfiguration { + b.FlightdeckReady = &value + return b +} diff --git a/client-go/applyconfiguration/core/v1beta1/workbenchstatus.go b/client-go/applyconfiguration/core/v1beta1/workbenchstatus.go index 6a42402d..cae196a5 100644 --- a/client-go/applyconfiguration/core/v1beta1/workbenchstatus.go +++ b/client-go/applyconfiguration/core/v1beta1/workbenchstatus.go @@ -7,13 +7,15 @@ package v1beta1 import ( v1 "k8s.io/api/core/v1" + metav1 "k8s.io/client-go/applyconfigurations/meta/v1" ) // WorkbenchStatusApplyConfiguration represents a declarative configuration of the WorkbenchStatus type for use // with apply. type WorkbenchStatusApplyConfiguration struct { - Ready *bool `json:"ready,omitempty"` - KeySecretRef *v1.SecretReference `json:"keySecretRef,omitempty"` + CommonProductStatusApplyConfiguration `json:",inline"` + Ready *bool `json:"ready,omitempty"` + KeySecretRef *v1.SecretReference `json:"keySecretRef,omitempty"` } // WorkbenchStatusApplyConfiguration constructs a declarative configuration of the WorkbenchStatus type for use with @@ -22,6 +24,35 @@ func WorkbenchStatus() *WorkbenchStatusApplyConfiguration { return &WorkbenchStatusApplyConfiguration{} } +// WithConditions adds the given value to the Conditions field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, values provided by each call will be appended to the Conditions field. +func (b *WorkbenchStatusApplyConfiguration) WithConditions(values ...*metav1.ConditionApplyConfiguration) *WorkbenchStatusApplyConfiguration { + for i := range values { + if values[i] == nil { + panic("nil value passed to WithConditions") + } + b.CommonProductStatusApplyConfiguration.Conditions = append(b.CommonProductStatusApplyConfiguration.Conditions, *values[i]) + } + return b +} + +// WithObservedGeneration sets the ObservedGeneration field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the ObservedGeneration field is set to the value of the last call. +func (b *WorkbenchStatusApplyConfiguration) WithObservedGeneration(value int64) *WorkbenchStatusApplyConfiguration { + b.CommonProductStatusApplyConfiguration.ObservedGeneration = &value + return b +} + +// WithVersion sets the Version field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Version field is set to the value of the last call. +func (b *WorkbenchStatusApplyConfiguration) WithVersion(value string) *WorkbenchStatusApplyConfiguration { + b.CommonProductStatusApplyConfiguration.Version = &value + return b +} + // WithReady sets the Ready field in the declarative configuration to the given value // and returns the receiver, so that objects can be built by chaining "With" function invocations. // If called multiple times, the Ready field is set to the value of the last call. diff --git a/client-go/applyconfiguration/utils.go b/client-go/applyconfiguration/utils.go index 3a87ec62..c523bcd4 100644 --- a/client-go/applyconfiguration/utils.go +++ b/client-go/applyconfiguration/utils.go @@ -49,6 +49,8 @@ func ForKind(kind schema.GroupVersionKind) interface{} { return &corev1beta1.ChronicleSpecApplyConfiguration{} case v1beta1.SchemeGroupVersion.WithKind("ChronicleStatus"): return &corev1beta1.ChronicleStatusApplyConfiguration{} + case v1beta1.SchemeGroupVersion.WithKind("CommonProductStatus"): + return &corev1beta1.CommonProductStatusApplyConfiguration{} case v1beta1.SchemeGroupVersion.WithKind("Connect"): return &corev1beta1.ConnectApplyConfiguration{} case v1beta1.SchemeGroupVersion.WithKind("ConnectApplicationsConfig"): @@ -167,6 +169,8 @@ func ForKind(kind schema.GroupVersionKind) interface{} { return &corev1beta1.PostgresDatabaseSpecApplyConfiguration{} case v1beta1.SchemeGroupVersion.WithKind("PostgresDatabaseSpecTeardown"): return &corev1beta1.PostgresDatabaseSpecTeardownApplyConfiguration{} + case v1beta1.SchemeGroupVersion.WithKind("PostgresDatabaseStatus"): + return &corev1beta1.PostgresDatabaseStatusApplyConfiguration{} case v1beta1.SchemeGroupVersion.WithKind("RPackageRepositoryConfig"): return &corev1beta1.RPackageRepositoryConfigApplyConfiguration{} case v1beta1.SchemeGroupVersion.WithKind("SecretConfig"): @@ -179,6 +183,8 @@ func ForKind(kind schema.GroupVersionKind) interface{} { return &corev1beta1.SiteApplyConfiguration{} case v1beta1.SchemeGroupVersion.WithKind("SiteSpec"): return &corev1beta1.SiteSpecApplyConfiguration{} + case v1beta1.SchemeGroupVersion.WithKind("SiteStatus"): + return &corev1beta1.SiteStatusApplyConfiguration{} case v1beta1.SchemeGroupVersion.WithKind("SnowflakeConfig"): return &corev1beta1.SnowflakeConfigApplyConfiguration{} case v1beta1.SchemeGroupVersion.WithKind("SSHKeyConfig"): diff --git a/config/crd/bases/core.posit.team_chronicles.yaml b/config/crd/bases/core.posit.team_chronicles.yaml index 10327708..0121bde2 100644 --- a/config/crd/bases/core.posit.team_chronicles.yaml +++ b/config/crd/bases/core.posit.team_chronicles.yaml @@ -17,7 +17,17 @@ spec: singular: chronicle scope: Namespaced versions: - - name: v1beta1 + - additionalPrinterColumns: + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .status.version + name: Version + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1beta1 schema: openAPIV3Schema: description: Chronicle is the Schema for the chronicles API @@ -130,10 +140,78 @@ spec: status: description: ChronicleStatus defines the observed state of Chronicle properties: + conditions: + description: Conditions represent the latest available observations + of the resource's current state. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + 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. + 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 + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + observedGeneration: + description: |- + ObservedGeneration is the most recent generation observed for this resource. + It corresponds to the resource's generation, which is updated on mutation by the API Server. + format: int64 + type: integer ready: type: boolean - required: - - ready + version: + description: Version is the version of the product image being deployed. + type: string type: object type: object served: true diff --git a/config/crd/bases/core.posit.team_connects.yaml b/config/crd/bases/core.posit.team_connects.yaml index 94495a6e..a93c9d0c 100644 --- a/config/crd/bases/core.posit.team_connects.yaml +++ b/config/crd/bases/core.posit.team_connects.yaml @@ -17,7 +17,17 @@ spec: singular: connect scope: Namespaced versions: - - name: v1beta1 + - additionalPrinterColumns: + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .status.version + name: Version + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1beta1 schema: openAPIV3Schema: description: Connect is the Schema for the connects API @@ -7401,6 +7411,67 @@ spec: status: description: ConnectStatus defines the observed state of Connect properties: + conditions: + description: Conditions represent the latest available observations + of the resource's current state. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + 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. + 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 + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map keySecretRef: description: |- SecretReference represents a Secret Reference. It has enough information to retrieve secret @@ -7416,10 +7487,17 @@ spec: type: string type: object x-kubernetes-map-type: atomic + observedGeneration: + description: |- + ObservedGeneration is the most recent generation observed for this resource. + It corresponds to the resource's generation, which is updated on mutation by the API Server. + format: int64 + type: integer ready: type: boolean - required: - - ready + version: + description: Version is the version of the product image being deployed. + type: string type: object type: object served: true diff --git a/config/crd/bases/core.posit.team_flightdecks.yaml b/config/crd/bases/core.posit.team_flightdecks.yaml index 74dd8f07..f38116a1 100644 --- a/config/crd/bases/core.posit.team_flightdecks.yaml +++ b/config/crd/bases/core.posit.team_flightdecks.yaml @@ -14,7 +14,17 @@ spec: singular: flightdeck scope: Namespaced versions: - - name: v1beta1 + - additionalPrinterColumns: + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .status.version + name: Version + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1beta1 schema: openAPIV3Schema: description: Flightdeck is the Schema for the flightdecks API @@ -112,12 +122,80 @@ spec: status: description: FlightdeckStatus defines the observed state of Flightdeck properties: + conditions: + description: Conditions represent the latest available observations + of the resource's current state. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + 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. + 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 + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + observedGeneration: + description: |- + ObservedGeneration is the most recent generation observed for this resource. + It corresponds to the resource's generation, which is updated on mutation by the API Server. + format: int64 + type: integer ready: description: Ready indicates whether the Flightdeck deployment is ready type: boolean - required: - - ready + version: + description: Version is the version of the product image being deployed. + type: string type: object type: object served: true diff --git a/config/crd/bases/core.posit.team_packagemanagers.yaml b/config/crd/bases/core.posit.team_packagemanagers.yaml index 571297cb..69187d11 100644 --- a/config/crd/bases/core.posit.team_packagemanagers.yaml +++ b/config/crd/bases/core.posit.team_packagemanagers.yaml @@ -17,7 +17,17 @@ spec: singular: packagemanager scope: Namespaced versions: - - name: v1beta1 + - additionalPrinterColumns: + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .status.version + name: Version + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1beta1 schema: openAPIV3Schema: description: PackageManager is the Schema for the packagemanagers API @@ -445,6 +455,67 @@ spec: status: description: PackageManagerStatus defines the observed state of PackageManager properties: + conditions: + description: Conditions represent the latest available observations + of the resource's current state. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + 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. + 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 + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map keySecretRef: description: |- SecretReference represents a Secret Reference. It has enough information to retrieve secret @@ -460,10 +531,17 @@ spec: type: string type: object x-kubernetes-map-type: atomic + observedGeneration: + description: |- + ObservedGeneration is the most recent generation observed for this resource. + It corresponds to the resource's generation, which is updated on mutation by the API Server. + format: int64 + type: integer ready: type: boolean - required: - - ready + version: + description: Version is the version of the product image being deployed. + type: string type: object type: object served: true diff --git a/config/crd/bases/core.posit.team_postgresdatabases.yaml b/config/crd/bases/core.posit.team_postgresdatabases.yaml index 7e490d47..741f78cb 100644 --- a/config/crd/bases/core.posit.team_postgresdatabases.yaml +++ b/config/crd/bases/core.posit.team_postgresdatabases.yaml @@ -17,7 +17,14 @@ spec: singular: postgresdatabase scope: Namespaced versions: - - name: v1beta1 + - additionalPrinterColumns: + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1beta1 schema: openAPIV3Schema: description: PostgresDatabase is the Schema for the postgresdatabases API @@ -99,6 +106,77 @@ spec: type: object status: description: PostgresDatabaseStatus defines the observed state of PostgresDatabase + properties: + conditions: + description: Conditions represent the latest available observations + of the resource's current state. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + 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. + 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 + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + observedGeneration: + description: |- + ObservedGeneration is the most recent generation observed for this resource. + It corresponds to the resource's generation, which is updated on mutation by the API Server. + format: int64 + type: integer + version: + description: Version is the version of the product image being deployed. + type: string type: object type: object served: true diff --git a/config/crd/bases/core.posit.team_sites.yaml b/config/crd/bases/core.posit.team_sites.yaml index 850d2489..6d36e5b3 100644 --- a/config/crd/bases/core.posit.team_sites.yaml +++ b/config/crd/bases/core.posit.team_sites.yaml @@ -14,7 +14,14 @@ spec: singular: site scope: Namespaced versions: - - name: v1beta1 + - additionalPrinterColumns: + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1beta1 schema: openAPIV3Schema: description: Site is the Schema for the sites API @@ -1737,6 +1744,97 @@ spec: type: object status: description: SiteStatus defines the observed state of Site + properties: + chronicleReady: + description: ChronicleReady indicates whether the Chronicle child + resource is ready. + type: boolean + conditions: + description: Conditions represent the latest available observations + of the resource's current state. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + 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. + 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 + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + connectReady: + description: ConnectReady indicates whether the Connect child resource + is ready. + type: boolean + flightdeckReady: + description: FlightdeckReady indicates whether the Flightdeck child + resource is ready. + type: boolean + observedGeneration: + description: |- + ObservedGeneration is the most recent generation observed for this resource. + It corresponds to the resource's generation, which is updated on mutation by the API Server. + format: int64 + type: integer + packageManagerReady: + description: PackageManagerReady indicates whether the PackageManager + child resource is ready. + type: boolean + version: + description: Version is the version of the product image being deployed. + type: string + workbenchReady: + description: WorkbenchReady indicates whether the Workbench child + resource is ready. + type: boolean type: object type: object served: true diff --git a/config/crd/bases/core.posit.team_workbenches.yaml b/config/crd/bases/core.posit.team_workbenches.yaml index d411d16d..a59f7f8e 100644 --- a/config/crd/bases/core.posit.team_workbenches.yaml +++ b/config/crd/bases/core.posit.team_workbenches.yaml @@ -17,7 +17,17 @@ spec: singular: workbench scope: Namespaced versions: - - name: v1beta1 + - additionalPrinterColumns: + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .status.version + name: Version + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1beta1 schema: openAPIV3Schema: description: Workbench is the Schema for the workbenches API @@ -7675,6 +7685,67 @@ spec: status: description: WorkbenchStatus defines the observed state of Workbench properties: + conditions: + description: Conditions represent the latest available observations + of the resource's current state. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + 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. + 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 + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map keySecretRef: description: |- SecretReference represents a Secret Reference. It has enough information to retrieve secret @@ -7690,10 +7761,17 @@ spec: type: string type: object x-kubernetes-map-type: atomic + observedGeneration: + description: |- + ObservedGeneration is the most recent generation observed for this resource. + It corresponds to the resource's generation, which is updated on mutation by the API Server. + format: int64 + type: integer ready: type: boolean - required: - - ready + version: + description: Version is the version of the product image being deployed. + type: string type: object type: object served: true diff --git a/dist/chart/templates/crd/core.posit.team_chronicles.yaml b/dist/chart/templates/crd/core.posit.team_chronicles.yaml index f6da6b4a..7d18f304 100755 --- a/dist/chart/templates/crd/core.posit.team_chronicles.yaml +++ b/dist/chart/templates/crd/core.posit.team_chronicles.yaml @@ -38,7 +38,17 @@ spec: singular: chronicle scope: Namespaced versions: - - name: v1beta1 + - additionalPrinterColumns: + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .status.version + name: Version + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1beta1 schema: openAPIV3Schema: description: Chronicle is the Schema for the chronicles API @@ -151,10 +161,78 @@ spec: status: description: ChronicleStatus defines the observed state of Chronicle properties: + conditions: + description: Conditions represent the latest available observations + of the resource's current state. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + 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. + 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 + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + observedGeneration: + description: |- + ObservedGeneration is the most recent generation observed for this resource. + It corresponds to the resource's generation, which is updated on mutation by the API Server. + format: int64 + type: integer ready: type: boolean - required: - - ready + version: + description: Version is the version of the product image being deployed. + type: string type: object type: object served: true diff --git a/dist/chart/templates/crd/core.posit.team_connects.yaml b/dist/chart/templates/crd/core.posit.team_connects.yaml index 1a5664de..27ebb685 100755 --- a/dist/chart/templates/crd/core.posit.team_connects.yaml +++ b/dist/chart/templates/crd/core.posit.team_connects.yaml @@ -38,7 +38,17 @@ spec: singular: connect scope: Namespaced versions: - - name: v1beta1 + - additionalPrinterColumns: + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .status.version + name: Version + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1beta1 schema: openAPIV3Schema: description: Connect is the Schema for the connects API @@ -7422,6 +7432,67 @@ spec: status: description: ConnectStatus defines the observed state of Connect properties: + conditions: + description: Conditions represent the latest available observations + of the resource's current state. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + 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. + 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 + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map keySecretRef: description: |- SecretReference represents a Secret Reference. It has enough information to retrieve secret @@ -7437,10 +7508,17 @@ spec: type: string type: object x-kubernetes-map-type: atomic + observedGeneration: + description: |- + ObservedGeneration is the most recent generation observed for this resource. + It corresponds to the resource's generation, which is updated on mutation by the API Server. + format: int64 + type: integer ready: type: boolean - required: - - ready + version: + description: Version is the version of the product image being deployed. + type: string type: object type: object served: true diff --git a/dist/chart/templates/crd/core.posit.team_flightdecks.yaml b/dist/chart/templates/crd/core.posit.team_flightdecks.yaml index d67f23eb..ff92a878 100755 --- a/dist/chart/templates/crd/core.posit.team_flightdecks.yaml +++ b/dist/chart/templates/crd/core.posit.team_flightdecks.yaml @@ -20,7 +20,17 @@ spec: singular: flightdeck scope: Namespaced versions: - - name: v1beta1 + - additionalPrinterColumns: + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .status.version + name: Version + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1beta1 schema: openAPIV3Schema: description: Flightdeck is the Schema for the flightdecks API @@ -118,12 +128,80 @@ spec: status: description: FlightdeckStatus defines the observed state of Flightdeck properties: + conditions: + description: Conditions represent the latest available observations + of the resource's current state. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + 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. + 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 + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + observedGeneration: + description: |- + ObservedGeneration is the most recent generation observed for this resource. + It corresponds to the resource's generation, which is updated on mutation by the API Server. + format: int64 + type: integer ready: description: Ready indicates whether the Flightdeck deployment is ready type: boolean - required: - - ready + version: + description: Version is the version of the product image being deployed. + type: string type: object type: object served: true diff --git a/dist/chart/templates/crd/core.posit.team_packagemanagers.yaml b/dist/chart/templates/crd/core.posit.team_packagemanagers.yaml index 60296f22..7eef9ac6 100755 --- a/dist/chart/templates/crd/core.posit.team_packagemanagers.yaml +++ b/dist/chart/templates/crd/core.posit.team_packagemanagers.yaml @@ -38,7 +38,17 @@ spec: singular: packagemanager scope: Namespaced versions: - - name: v1beta1 + - additionalPrinterColumns: + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .status.version + name: Version + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1beta1 schema: openAPIV3Schema: description: PackageManager is the Schema for the packagemanagers API @@ -466,6 +476,67 @@ spec: status: description: PackageManagerStatus defines the observed state of PackageManager properties: + conditions: + description: Conditions represent the latest available observations + of the resource's current state. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + 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. + 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 + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map keySecretRef: description: |- SecretReference represents a Secret Reference. It has enough information to retrieve secret @@ -481,10 +552,17 @@ spec: type: string type: object x-kubernetes-map-type: atomic + observedGeneration: + description: |- + ObservedGeneration is the most recent generation observed for this resource. + It corresponds to the resource's generation, which is updated on mutation by the API Server. + format: int64 + type: integer ready: type: boolean - required: - - ready + version: + description: Version is the version of the product image being deployed. + type: string type: object type: object served: true diff --git a/dist/chart/templates/crd/core.posit.team_postgresdatabases.yaml b/dist/chart/templates/crd/core.posit.team_postgresdatabases.yaml index e9e4125e..2dee7f57 100755 --- a/dist/chart/templates/crd/core.posit.team_postgresdatabases.yaml +++ b/dist/chart/templates/crd/core.posit.team_postgresdatabases.yaml @@ -38,7 +38,14 @@ spec: singular: postgresdatabase scope: Namespaced versions: - - name: v1beta1 + - additionalPrinterColumns: + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1beta1 schema: openAPIV3Schema: description: PostgresDatabase is the Schema for the postgresdatabases API @@ -120,6 +127,77 @@ spec: type: object status: description: PostgresDatabaseStatus defines the observed state of PostgresDatabase + properties: + conditions: + description: Conditions represent the latest available observations + of the resource's current state. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + 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. + 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 + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + observedGeneration: + description: |- + ObservedGeneration is the most recent generation observed for this resource. + It corresponds to the resource's generation, which is updated on mutation by the API Server. + format: int64 + type: integer + version: + description: Version is the version of the product image being deployed. + type: string type: object type: object served: true diff --git a/dist/chart/templates/crd/core.posit.team_sites.yaml b/dist/chart/templates/crd/core.posit.team_sites.yaml index c8c9f0c1..6fa36e35 100755 --- a/dist/chart/templates/crd/core.posit.team_sites.yaml +++ b/dist/chart/templates/crd/core.posit.team_sites.yaml @@ -35,7 +35,14 @@ spec: singular: site scope: Namespaced versions: - - name: v1beta1 + - additionalPrinterColumns: + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1beta1 schema: openAPIV3Schema: description: Site is the Schema for the sites API @@ -1758,6 +1765,97 @@ spec: type: object status: description: SiteStatus defines the observed state of Site + properties: + chronicleReady: + description: ChronicleReady indicates whether the Chronicle child + resource is ready. + type: boolean + conditions: + description: Conditions represent the latest available observations + of the resource's current state. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + 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. + 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 + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + connectReady: + description: ConnectReady indicates whether the Connect child resource + is ready. + type: boolean + flightdeckReady: + description: FlightdeckReady indicates whether the Flightdeck child + resource is ready. + type: boolean + observedGeneration: + description: |- + ObservedGeneration is the most recent generation observed for this resource. + It corresponds to the resource's generation, which is updated on mutation by the API Server. + format: int64 + type: integer + packageManagerReady: + description: PackageManagerReady indicates whether the PackageManager + child resource is ready. + type: boolean + version: + description: Version is the version of the product image being deployed. + type: string + workbenchReady: + description: WorkbenchReady indicates whether the Workbench child + resource is ready. + type: boolean type: object type: object served: true diff --git a/dist/chart/templates/crd/core.posit.team_workbenches.yaml b/dist/chart/templates/crd/core.posit.team_workbenches.yaml index d850ad12..40034410 100755 --- a/dist/chart/templates/crd/core.posit.team_workbenches.yaml +++ b/dist/chart/templates/crd/core.posit.team_workbenches.yaml @@ -38,7 +38,17 @@ spec: singular: workbench scope: Namespaced versions: - - name: v1beta1 + - additionalPrinterColumns: + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .status.version + name: Version + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1beta1 schema: openAPIV3Schema: description: Workbench is the Schema for the workbenches API @@ -7696,6 +7706,67 @@ spec: status: description: WorkbenchStatus defines the observed state of Workbench properties: + conditions: + description: Conditions represent the latest available observations + of the resource's current state. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + 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. + 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 + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map keySecretRef: description: |- SecretReference represents a Secret Reference. It has enough information to retrieve secret @@ -7711,10 +7782,17 @@ spec: type: string type: object x-kubernetes-map-type: atomic + observedGeneration: + description: |- + ObservedGeneration is the most recent generation observed for this resource. + It corresponds to the resource's generation, which is updated on mutation by the API Server. + format: int64 + type: integer ready: type: boolean - required: - - ready + version: + description: Version is the version of the product image being deployed. + type: string type: object type: object served: true diff --git a/internal/controller/core/chronicle_controller.go b/internal/controller/core/chronicle_controller.go index 82e609e8..994cd49f 100644 --- a/internal/controller/core/chronicle_controller.go +++ b/internal/controller/core/chronicle_controller.go @@ -10,6 +10,7 @@ import ( "github.com/go-logr/logr" "github.com/posit-dev/team-operator/api/product" "github.com/posit-dev/team-operator/internal" + "github.com/posit-dev/team-operator/internal/status" "github.com/rstudio/goex/ptr" v1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" @@ -90,6 +91,7 @@ func (r *ChronicleReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( func (r *ChronicleReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). For(&positcov1beta1.Chronicle{}). + Owns(&v1.StatefulSet{}). Complete(r) } @@ -101,9 +103,29 @@ func (r *ChronicleReconciler) ReconcileChronicle(ctx context.Context, req ctrl.R // If suspended, clean up serving resources but preserve configuration if c.Spec.Suspended != nil && *c.Spec.Suspended { - return r.suspendDeployedService(ctx, req, c) + // Capture patch base before suspend so any future in-memory mutations are included in the diff + patchBase := client.MergeFrom(c.DeepCopy()) + res, err := r.suspendDeployedService(ctx, req, c) + if err != nil { + if patchErr := status.PatchErrorStatus(ctx, r.Status(), c, patchBase, &c.Status.Conditions, c.Generation, err); patchErr != nil { + l.Error(patchErr, "Error patching error status") + } + return res, err + } + if patchErr := status.PatchSuspendedStatus(ctx, r.Status(), c, patchBase, &c.Status.Conditions, c.Generation, &c.Status.ObservedGeneration, &c.Status.Ready, &c.Status.Version); patchErr != nil { + l.Error(patchErr, "Error patching suspended status") + return res, patchErr + } + return res, nil } + // Save a copy for status patching + patchBase := client.MergeFrom(c.DeepCopy()) + + // Set observed generation and progressing condition + c.Status.ObservedGeneration = c.Generation + status.SetProgressing(&c.Status.Conditions, c.Generation, metav1.ConditionTrue, status.ReasonReconciling, "Reconciliation in progress") + // default config settings not in the original object // ... @@ -111,16 +133,30 @@ func (r *ChronicleReconciler) ReconcileChronicle(ctx context.Context, req ctrl.R res, err := r.ensureDeployedService(ctx, req, c) if err != nil { l.Error(err, "error deploying service") + if patchErr := status.PatchErrorStatus(ctx, r.Status(), c, patchBase, &c.Status.Conditions, c.Generation, err); patchErr != nil { + l.Error(patchErr, "Error patching error status") + } return res, err } - // set to ready if it is not set yet... - if !c.Status.Ready { - c.Status.Ready = true - if err := r.Status().Update(ctx, c); err != nil { - l.Error(err, "Error setting ready status") - return ctrl.Result{}, err + // Check StatefulSet health + sts := &v1.StatefulSet{} + if err := r.Get(ctx, client.ObjectKey{Name: c.ComponentName(), Namespace: req.Namespace}, sts); err != nil { + l.Error(err, "error fetching statefulset for status") + if patchErr := status.PatchErrorStatus(ctx, r.Status(), c, patchBase, &c.Status.Conditions, c.Generation, err); patchErr != nil { + l.Error(patchErr, "Error patching error status") } + return ctrl.Result{}, err + } + + status.SetStatefulSetHealth(&c.Status.Conditions, c.Generation, sts.Status.ReadyReplicas, status.DesiredReplicas(sts.Spec.Replicas)) + c.Status.Version = status.ExtractVersion(c.Spec.Image) + c.Status.Ready = status.IsReady(c.Status.Conditions) + + // Patch status + if err := r.Status().Patch(ctx, c, patchBase); err != nil { + l.Error(err, "Error patching status") + return ctrl.Result{}, err } return ctrl.Result{}, nil diff --git a/internal/controller/core/chronicle_controller_test.go b/internal/controller/core/chronicle_controller_test.go new file mode 100644 index 00000000..e4e2c6db --- /dev/null +++ b/internal/controller/core/chronicle_controller_test.go @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2023-2026 Posit Software, PBC + +package core + +import ( + "context" + "testing" + + "github.com/go-logr/logr" + positcov1beta1 "github.com/posit-dev/team-operator/api/core/v1beta1" + "github.com/posit-dev/team-operator/api/localtest" + "github.com/posit-dev/team-operator/internal/status" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + appsv1 "k8s.io/api/apps/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// TestChronicleReconciler_Suspended verifies that when Chronicle has Suspended=true, +// ReconcileChronicle does not create a StatefulSet and does not apply SetProgressing. +func TestChronicleReconciler_Suspended(t *testing.T) { + ctx := context.Background() + ns := "posit-team" + name := "chronicle-suspended" + + fakeEnv := localtest.FakeTestEnv{} + cli, scheme, log := fakeEnv.Start(loadSchemes) + + r := &ChronicleReconciler{ + Client: cli, + Scheme: scheme, + Log: log, + } + + ctx = logr.NewContext(ctx, log) + req := ctrl.Request{ + NamespacedName: types.NamespacedName{Namespace: ns, Name: name}, + } + + suspended := true + c := &positcov1beta1.Chronicle{ + TypeMeta: metav1.TypeMeta{ + Kind: "Chronicle", + APIVersion: "core.posit.team/v1beta1", + }, + ObjectMeta: metav1.ObjectMeta{Namespace: ns, Name: name}, + Spec: positcov1beta1.ChronicleSpec{Suspended: &suspended}, + } + + err := cli.Create(ctx, c) + require.NoError(t, err) + + res, err := r.ReconcileChronicle(ctx, req, c) + require.NoError(t, err) + require.True(t, res.IsZero()) + + // No StatefulSet should be created when suspended + sts := &appsv1.StatefulSet{} + err = cli.Get(ctx, client.ObjectKey{Name: c.ComponentName(), Namespace: ns}, sts) + assert.True(t, apierrors.IsNotFound(err), "expected not-found error, got: %v", err) + + // Status should reflect the suspended state + updated := &positcov1beta1.Chronicle{} + require.NoError(t, cli.Get(ctx, client.ObjectKey{Namespace: ns, Name: name}, updated)) + assert.False(t, updated.Status.Ready, "Ready bool should be false when suspended") + readyCond := apimeta.FindStatusCondition(updated.Status.Conditions, status.TypeReady) + require.NotNil(t, readyCond, "Ready condition should be set when suspended") + assert.Equal(t, metav1.ConditionFalse, readyCond.Status) + assert.Equal(t, status.ReasonSuspended, readyCond.Reason) + progressCond := apimeta.FindStatusCondition(updated.Status.Conditions, status.TypeProgressing) + require.NotNil(t, progressCond, "Progressing condition should be set when suspended") + assert.Equal(t, metav1.ConditionFalse, progressCond.Status) + assert.Equal(t, status.ReasonSuspended, progressCond.Reason) +} diff --git a/internal/controller/core/connect.go b/internal/controller/core/connect.go index 7beca1f2..cbc910e2 100644 --- a/internal/controller/core/connect.go +++ b/internal/controller/core/connect.go @@ -10,6 +10,7 @@ import ( "github.com/posit-dev/team-operator/api/templates" "github.com/posit-dev/team-operator/internal" "github.com/posit-dev/team-operator/internal/db" + "github.com/posit-dev/team-operator/internal/status" "github.com/rstudio/goex/ptr" v1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" @@ -39,9 +40,29 @@ func (r *ConnectReconciler) ReconcileConnect(ctx context.Context, req ctrl.Reque // If suspended, clean up serving resources (Deployment/Service/Ingress) but preserve data if c.Spec.Suspended != nil && *c.Spec.Suspended { - return r.suspendDeployedService(ctx, req, c) + // Capture patch base before suspend so any future in-memory mutations are included in the diff + patchBase := client.MergeFrom(c.DeepCopy()) + res, err := r.suspendDeployedService(ctx, req, c) + if err != nil { + if patchErr := status.PatchErrorStatus(ctx, r.Status(), c, patchBase, &c.Status.Conditions, c.Generation, err); patchErr != nil { + l.Error(patchErr, "Error patching error status") + } + return res, err + } + if patchErr := status.PatchSuspendedStatus(ctx, r.Status(), c, patchBase, &c.Status.Conditions, c.Generation, &c.Status.ObservedGeneration, &c.Status.Ready, &c.Status.Version); patchErr != nil { + l.Error(patchErr, "Error patching suspended status") + return res, patchErr + } + return res, nil } + // Save a copy for status patching + patchBase := client.MergeFrom(c.DeepCopy()) + + // Set observed generation and progressing condition + c.Status.ObservedGeneration = c.Generation + status.SetProgressing(&c.Status.Conditions, c.Generation, metav1.ConditionTrue, status.ReasonReconciling, "Reconciliation in progress") + // create database secretKey := "pub-db-password" @@ -62,6 +83,9 @@ func (r *ConnectReconciler) ReconcileConnect(ctx context.Context, req ctrl.Reque if err := db.EnsureDatabaseExists(ctx, r, req, c, c.Spec.DatabaseConfig, c.ComponentName(), "", dbSchemas, c.Spec.Secret, c.Spec.WorkloadSecret, c.Spec.MainDatabaseCredentialSecret, secretKey); err != nil { l.Error(err, "error creating database", "database", c.ComponentName()) + if patchErr := status.PatchErrorStatus(ctx, r.Status(), c, patchBase, &c.Status.Conditions, c.Generation, err); patchErr != nil { + l.Error(patchErr, "Error patching error status") + } return ctrl.Result{}, err } @@ -71,6 +95,9 @@ func (r *ConnectReconciler) ReconcileConnect(ctx context.Context, req ctrl.Reque // NOTE: we do not retain this value locally. Instead we just reference the key in the Status if _, err := internal.EnsureProvisioningKey(ctx, c, r, req, c); err != nil { l.Error(err, "error ensuring that provisioning key exists") + if patchErr := status.PatchErrorStatus(ctx, r.Status(), c, patchBase, &c.Status.Conditions, c.Generation, err); patchErr != nil { + l.Error(patchErr, "Error patching error status") + } return ctrl.Result{}, err } else { l.Info("successfully created or retrieved provisioning key value") @@ -81,10 +108,6 @@ func (r *ConnectReconciler) ReconcileConnect(ctx context.Context, req ctrl.Reque Name: c.KeySecretName(), Namespace: req.Namespace, } - if err := r.Status().Update(ctx, c); err != nil { - l.Error(err, "Error updating status") - return ctrl.Result{}, err - } } // TODO: at some point, postgres should probably be an option... (i.e. multi-tenant world?) @@ -112,18 +135,30 @@ func (r *ConnectReconciler) ReconcileConnect(ctx context.Context, req ctrl.Reque res, err := r.ensureDeployedService(ctx, req, c) if err != nil { l.Error(err, "error deploying service") + if patchErr := status.PatchErrorStatus(ctx, r.Status(), c, patchBase, &c.Status.Conditions, c.Generation, err); patchErr != nil { + l.Error(patchErr, "Error patching error status") + } return res, err } - // TODO: should we watch for happy pods? - - // set to ready if it is not set yet... - if !c.Status.Ready { - c.Status.Ready = true - if err := r.Status().Update(ctx, c); err != nil { - l.Error(err, "Error setting ready status") - return ctrl.Result{}, err + // Check deployment health + deploy := &v1.Deployment{} + if err := r.Get(ctx, client.ObjectKey{Name: c.ComponentName(), Namespace: req.Namespace}, deploy); err != nil { + l.Error(err, "error fetching deployment for status") + if patchErr := status.PatchErrorStatus(ctx, r.Status(), c, patchBase, &c.Status.Conditions, c.Generation, err); patchErr != nil { + l.Error(patchErr, "Error patching error status") } + return ctrl.Result{}, err + } + + status.SetDeploymentHealth(&c.Status.Conditions, c.Generation, deploy.Status.ReadyReplicas, status.DesiredReplicas(deploy.Spec.Replicas)) + c.Status.Version = status.ExtractVersion(c.Spec.Image) + c.Status.Ready = status.IsReady(c.Status.Conditions) + + // Patch status + if err := r.Status().Patch(ctx, c, patchBase); err != nil { + l.Error(err, "Error patching status") + return ctrl.Result{}, err } return ctrl.Result{}, nil diff --git a/internal/controller/core/connect_controller.go b/internal/controller/core/connect_controller.go index da27e250..e7328f4a 100644 --- a/internal/controller/core/connect_controller.go +++ b/internal/controller/core/connect_controller.go @@ -7,6 +7,7 @@ import ( "context" "github.com/go-logr/logr" + appsv1 "k8s.io/api/apps/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" @@ -93,5 +94,6 @@ func (r *ConnectReconciler) GetLogger(ctx context.Context) logr.Logger { func (r *ConnectReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). For(&positcov1beta1.Connect{}). + Owns(&appsv1.Deployment{}). Complete(r) } diff --git a/internal/controller/core/connect_test.go b/internal/controller/core/connect_test.go index ea14278a..3f31ce26 100644 --- a/internal/controller/core/connect_test.go +++ b/internal/controller/core/connect_test.go @@ -9,10 +9,13 @@ import ( localtest "github.com/posit-dev/team-operator/api/localtest" "github.com/posit-dev/team-operator/api/product" "github.com/posit-dev/team-operator/internal" + "github.com/posit-dev/team-operator/internal/status" "github.com/rstudio/goex/ptr" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" + apimeta "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" ctrl "sigs.k8s.io/controller-runtime" @@ -524,3 +527,44 @@ func TestConnectReconciler_OIDC_DisableGroupsClaim(t *testing.T) { // Ensure it's not set to a non-empty value assert.NotContains(t, config, "GroupsClaim = groups", "GroupsClaim should not have the default 'groups' value") } + +// TestConnectReconciler_Suspended verifies that when Connect has Suspended=true, +// ReconcileConnect does not create serving resources (Deployment, Service, Ingress). +func TestConnectReconciler_Suspended(t *testing.T) { + ctx := context.Background() + ns := "posit-team" + name := "connect-suspended" + + ctx, r, req, cli := initConnectReconciler(t, ctx, ns, name) + + c := defineDefaultConnect(t, ns, name) + suspended := true + c.Spec.Suspended = &suspended + + err := internal.BasicCreateOrUpdate(ctx, r, r.GetLogger(ctx), req.NamespacedName, &positcov1beta1.Connect{}, c) + require.NoError(t, err) + + c = getConnect(t, cli, ns, name) + + res, err := r.ReconcileConnect(ctx, req, c) + require.NoError(t, err) + require.True(t, res.IsZero()) + + // No Deployment should be created when suspended + dep := &appsv1.Deployment{} + err = cli.Get(ctx, client.ObjectKey{Name: c.ComponentName(), Namespace: ns}, dep) + assert.Error(t, err, "Deployment should not exist when Connect is suspended") + + // Status should reflect the suspended state + updated := &positcov1beta1.Connect{} + require.NoError(t, cli.Get(ctx, client.ObjectKey{Namespace: ns, Name: name}, updated)) + assert.False(t, updated.Status.Ready, "Ready bool should be false when suspended") + readyCond := apimeta.FindStatusCondition(updated.Status.Conditions, status.TypeReady) + require.NotNil(t, readyCond, "Ready condition should be set when suspended") + assert.Equal(t, metav1.ConditionFalse, readyCond.Status) + assert.Equal(t, status.ReasonSuspended, readyCond.Reason) + progressCond := apimeta.FindStatusCondition(updated.Status.Conditions, status.TypeProgressing) + require.NotNil(t, progressCond, "Progressing condition should be set when suspended") + assert.Equal(t, metav1.ConditionFalse, progressCond.Status) + assert.Equal(t, status.ReasonSuspended, progressCond.Reason) +} diff --git a/internal/controller/core/flightdeck_controller.go b/internal/controller/core/flightdeck_controller.go index 94595032..d0e9b644 100644 --- a/internal/controller/core/flightdeck_controller.go +++ b/internal/controller/core/flightdeck_controller.go @@ -9,6 +9,7 @@ import ( "github.com/go-logr/logr" positcov1beta1 "github.com/posit-dev/team-operator/api/core/v1beta1" "github.com/posit-dev/team-operator/internal" + "github.com/posit-dev/team-operator/internal/status" "github.com/rstudio/goex/ptr" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" @@ -67,11 +68,41 @@ func (r *FlightdeckReconciler) Reconcile(ctx context.Context, req ctrl.Request) "domain", fd.Spec.Domain, ) + // Save a copy for status patching + patchBase := client.MergeFrom(fd.DeepCopy()) + + // Set observed generation and progressing condition + fd.Status.ObservedGeneration = fd.Generation + status.SetProgressing(&fd.Status.Conditions, fd.Generation, metav1.ConditionTrue, status.ReasonReconciling, "Reconciliation in progress") + if res, err := r.reconcileFlightdeckResources(ctx, req, fd, l); err != nil { l.Error(err, "failed to reconcile flightdeck resources") + if patchErr := status.PatchErrorStatus(ctx, r.Status(), fd, patchBase, &fd.Status.Conditions, fd.Generation, err); patchErr != nil { + l.Error(patchErr, "Error patching error status") + } return res, err } + // Check deployment health + deploy := &appsv1.Deployment{} + if err := r.Get(ctx, client.ObjectKey{Name: fd.ComponentName(), Namespace: req.Namespace}, deploy); err != nil { + l.Error(err, "error fetching deployment for status") + if patchErr := status.PatchErrorStatus(ctx, r.Status(), fd, patchBase, &fd.Status.Conditions, fd.Generation, err); patchErr != nil { + l.Error(patchErr, "Error patching error status") + } + return ctrl.Result{}, err + } + + status.SetDeploymentHealth(&fd.Status.Conditions, fd.Generation, deploy.Status.ReadyReplicas, status.DesiredReplicas(deploy.Spec.Replicas)) + fd.Status.Version = status.ExtractVersion(fd.Spec.Image) + fd.Status.Ready = status.IsReady(fd.Status.Conditions) + + // Patch status + if err := r.Status().Patch(ctx, fd, patchBase); err != nil { + l.Error(err, "Error patching status") + return ctrl.Result{}, err + } + l.Info("reconciliation completed successfully", "component", fd.ComponentName(), "domain", fd.Spec.Domain, diff --git a/internal/controller/core/package_manager.go b/internal/controller/core/package_manager.go index e6f13b36..20238fc2 100644 --- a/internal/controller/core/package_manager.go +++ b/internal/controller/core/package_manager.go @@ -8,6 +8,7 @@ import ( "github.com/posit-dev/team-operator/api/product" "github.com/posit-dev/team-operator/internal" "github.com/posit-dev/team-operator/internal/db" + "github.com/posit-dev/team-operator/internal/status" "github.com/rstudio/goex/ptr" v1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" @@ -103,13 +104,36 @@ func (r *PackageManagerReconciler) ReconcilePackageManager(ctx context.Context, // If suspended, clean up serving resources but preserve data if pm.Spec.Suspended != nil && *pm.Spec.Suspended { - return r.suspendDeployedService(ctx, req, pm) + // Capture patch base before suspend so any future in-memory mutations are included in the diff + patchBase := client.MergeFrom(pm.DeepCopy()) + res, err := r.suspendDeployedService(ctx, req, pm) + if err != nil { + if patchErr := status.PatchErrorStatus(ctx, r.Status(), pm, patchBase, &pm.Status.Conditions, pm.Generation, err); patchErr != nil { + l.Error(patchErr, "Error patching error status") + } + return res, err + } + if patchErr := status.PatchSuspendedStatus(ctx, r.Status(), pm, patchBase, &pm.Status.Conditions, pm.Generation, &pm.Status.ObservedGeneration, &pm.Status.Ready, &pm.Status.Version); patchErr != nil { + l.Error(patchErr, "Error patching suspended status") + return res, patchErr + } + return res, nil } + // Save a copy for status patching + patchBase := client.MergeFrom(pm.DeepCopy()) + + // Set observed generation and progressing condition + pm.Status.ObservedGeneration = pm.Generation + status.SetProgressing(&pm.Status.Conditions, pm.Generation, metav1.ConditionTrue, status.ReasonReconciling, "Reconciliation in progress") + // create database secretKey := "pkg-db-password" if err := db.EnsureDatabaseExists(ctx, r, req, pm, pm.Spec.DatabaseConfig, pm.ComponentName(), "", []string{"pm", "metrics"}, pm.Spec.Secret, pm.Spec.WorkloadSecret, pm.Spec.MainDatabaseCredentialSecret, secretKey); err != nil { l.Error(err, "error creating database", "database", pm.ComponentName()) + if patchErr := status.PatchErrorStatus(ctx, r.Status(), pm, patchBase, &pm.Status.Conditions, pm.Generation, err); patchErr != nil { + l.Error(patchErr, "Error patching error status") + } return ctrl.Result{}, err } @@ -120,6 +144,9 @@ func (r *PackageManagerReconciler) ReconcilePackageManager(ctx context.Context, // For now, we just use it to give to Package Manager if _, err := internal.EnsureProvisioningKey(ctx, pm, r, req, pm); err != nil { l.Error(err, "error ensuring that provisioning key exists") + if patchErr := status.PatchErrorStatus(ctx, r.Status(), pm, patchBase, &pm.Status.Conditions, pm.Generation, err); patchErr != nil { + l.Error(patchErr, "Error patching error status") + } return ctrl.Result{}, err } else { l.Info("successfully created or retrieved provisioning key value") @@ -129,10 +156,6 @@ func (r *PackageManagerReconciler) ReconcilePackageManager(ctx context.Context, Name: pm.KeySecretName(), Namespace: req.Namespace, } - if err := r.Status().Update(ctx, pm); err != nil { - l.Error(err, "Error updating status") - return ctrl.Result{}, err - } // TODO: at some point, postgres should probably be an option... (i.e. multi-tenant world?) if pm.Spec.Config.Database == nil { @@ -162,6 +185,9 @@ func (r *PackageManagerReconciler) ReconcilePackageManager(ctx context.Context, if err := r.createAzureFilesStoragePVC(ctx, pm); err != nil { l.Error(err, "error creating Azure Files PVC") + if patchErr := status.PatchErrorStatus(ctx, r.Status(), pm, patchBase, &pm.Status.Conditions, pm.Generation, err); patchErr != nil { + l.Error(patchErr, "Error patching error status") + } return ctrl.Result{}, err } } @@ -170,18 +196,30 @@ func (r *PackageManagerReconciler) ReconcilePackageManager(ctx context.Context, res, err := r.ensureDeployedService(ctx, req, pm) if err != nil { l.Error(err, "error deploying service") + if patchErr := status.PatchErrorStatus(ctx, r.Status(), pm, patchBase, &pm.Status.Conditions, pm.Generation, err); patchErr != nil { + l.Error(patchErr, "Error patching error status") + } return res, err } - // TODO: should we watch for happy pods? - - // set to ready if it is not set yet... - if !pm.Status.Ready { - pm.Status.Ready = true - if err := r.Status().Update(ctx, pm); err != nil { - l.Error(err, "Error setting ready status") - return ctrl.Result{}, err + // Check deployment health + deploy := &v1.Deployment{} + if err := r.Get(ctx, client.ObjectKey{Name: pm.ComponentName(), Namespace: req.Namespace}, deploy); err != nil { + l.Error(err, "error fetching deployment for status") + if patchErr := status.PatchErrorStatus(ctx, r.Status(), pm, patchBase, &pm.Status.Conditions, pm.Generation, err); patchErr != nil { + l.Error(patchErr, "Error patching error status") } + return ctrl.Result{}, err + } + + status.SetDeploymentHealth(&pm.Status.Conditions, pm.Generation, deploy.Status.ReadyReplicas, status.DesiredReplicas(deploy.Spec.Replicas)) + pm.Status.Version = status.ExtractVersion(pm.Spec.Image) + pm.Status.Ready = status.IsReady(pm.Status.Conditions) + + // Patch status + if err := r.Status().Patch(ctx, pm, patchBase); err != nil { + l.Error(err, "Error patching status") + return ctrl.Result{}, err } return ctrl.Result{}, nil diff --git a/internal/controller/core/package_manager_controller_test.go b/internal/controller/core/package_manager_controller_test.go new file mode 100644 index 00000000..7a9727b5 --- /dev/null +++ b/internal/controller/core/package_manager_controller_test.go @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2023-2026 Posit Software, PBC + +package core + +import ( + "context" + "testing" + + "github.com/go-logr/logr" + positcov1beta1 "github.com/posit-dev/team-operator/api/core/v1beta1" + "github.com/posit-dev/team-operator/api/localtest" + "github.com/posit-dev/team-operator/internal/status" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + appsv1 "k8s.io/api/apps/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// TestPackageManagerReconciler_Suspended verifies that when PackageManager has Suspended=true, +// ReconcilePackageManager does not create a Deployment and does not apply SetProgressing. +func TestPackageManagerReconciler_Suspended(t *testing.T) { + ctx := context.Background() + ns := "posit-team" + name := "pm-suspended" + + fakeEnv := localtest.FakeTestEnv{} + cli, scheme, log := fakeEnv.Start(loadSchemes) + + r := &PackageManagerReconciler{ + Client: cli, + Scheme: scheme, + Log: log, + } + + ctx = logr.NewContext(ctx, log) + req := ctrl.Request{ + NamespacedName: types.NamespacedName{Namespace: ns, Name: name}, + } + + suspended := true + pm := &positcov1beta1.PackageManager{ + TypeMeta: metav1.TypeMeta{ + Kind: "PackageManager", + APIVersion: "core.posit.team/v1beta1", + }, + ObjectMeta: metav1.ObjectMeta{Namespace: ns, Name: name}, + Spec: positcov1beta1.PackageManagerSpec{Suspended: &suspended}, + } + + err := cli.Create(ctx, pm) + require.NoError(t, err) + + res, err := r.ReconcilePackageManager(ctx, req, pm) + require.NoError(t, err) + require.True(t, res.IsZero()) + + // No Deployment should be created when suspended + dep := &appsv1.Deployment{} + err = cli.Get(ctx, client.ObjectKey{Name: pm.ComponentName(), Namespace: ns}, dep) + assert.True(t, apierrors.IsNotFound(err), "expected not-found error, got: %v", err) + + // Status should reflect the suspended state + updated := &positcov1beta1.PackageManager{} + require.NoError(t, cli.Get(ctx, client.ObjectKey{Namespace: ns, Name: name}, updated)) + assert.False(t, updated.Status.Ready, "Ready bool should be false when suspended") + readyCond := apimeta.FindStatusCondition(updated.Status.Conditions, status.TypeReady) + require.NotNil(t, readyCond, "Ready condition should be set when suspended") + assert.Equal(t, metav1.ConditionFalse, readyCond.Status) + assert.Equal(t, status.ReasonSuspended, readyCond.Reason) + progressCond := apimeta.FindStatusCondition(updated.Status.Conditions, status.TypeProgressing) + require.NotNil(t, progressCond, "Progressing condition should be set when suspended") + assert.Equal(t, metav1.ConditionFalse, progressCond.Status) + assert.Equal(t, status.ReasonSuspended, progressCond.Reason) +} diff --git a/internal/controller/core/packagemanager_controller.go b/internal/controller/core/packagemanager_controller.go index 5d746396..d57b6529 100644 --- a/internal/controller/core/packagemanager_controller.go +++ b/internal/controller/core/packagemanager_controller.go @@ -7,6 +7,7 @@ import ( "context" "github.com/go-logr/logr" + appsv1 "k8s.io/api/apps/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" @@ -81,6 +82,7 @@ func (r *PackageManagerReconciler) Reconcile(ctx context.Context, req ctrl.Reque func (r *PackageManagerReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). For(&positcov1beta1.PackageManager{}). + Owns(&appsv1.Deployment{}). Complete(r) } diff --git a/internal/controller/core/postgresdatabase_controller.go b/internal/controller/core/postgresdatabase_controller.go index a1a94bf4..934b2cd7 100644 --- a/internal/controller/core/postgresdatabase_controller.go +++ b/internal/controller/core/postgresdatabase_controller.go @@ -16,7 +16,9 @@ import ( "github.com/posit-dev/team-operator/api/product" "github.com/posit-dev/team-operator/internal" "github.com/posit-dev/team-operator/internal/db" + "github.com/posit-dev/team-operator/internal/status" apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" ctrl "sigs.k8s.io/controller-runtime" @@ -80,7 +82,35 @@ func (r *PostgresDatabaseReconciler) Reconcile(ctx context.Context, req ctrl.Req l.Info("PostgresDatabase found; reconciling database") - return r.createDatabase(ctx, req, pgd) + // Save a copy for status patching + patchBase := client.MergeFrom(pgd.DeepCopy()) + + // Set observed generation and progressing condition + pgd.Status.ObservedGeneration = pgd.Generation + status.SetProgressing(&pgd.Status.Conditions, pgd.Generation, metav1.ConditionTrue, status.ReasonReconciling, "Reconciliation in progress") + + result, createErr := r.createDatabase(ctx, req, pgd) + + // Update status based on result + if createErr != nil { + msg := status.TruncateMessage(createErr.Error()) + status.SetReady(&pgd.Status.Conditions, pgd.Generation, metav1.ConditionFalse, status.ReasonReconcileError, msg) + status.SetProgressing(&pgd.Status.Conditions, pgd.Generation, metav1.ConditionFalse, status.ReasonReconcileError, msg) + } else { + status.SetReady(&pgd.Status.Conditions, pgd.Generation, metav1.ConditionTrue, status.ReasonDatabaseReady, "Database provisioned successfully") + status.SetProgressing(&pgd.Status.Conditions, pgd.Generation, metav1.ConditionFalse, status.ReasonReconcileComplete, "Reconciliation complete") + } + + // Patch status regardless of createDatabase result + if patchErr := r.Status().Patch(ctx, pgd, patchBase); patchErr != nil { + l.Error(patchErr, "Error patching status") + if createErr != nil { + return result, createErr + } + return ctrl.Result{}, patchErr + } + + return result, createErr } func (r *PostgresDatabaseReconciler) cleanupDatabase(ctx context.Context, req ctrl.Request, pg *positcov1beta1.PostgresDatabase) (ctrl.Result, error) { diff --git a/internal/controller/core/site_controller.go b/internal/controller/core/site_controller.go index fa719815..3b9611b2 100644 --- a/internal/controller/core/site_controller.go +++ b/internal/controller/core/site_controller.go @@ -13,6 +13,7 @@ import ( positcov1beta1 "github.com/posit-dev/team-operator/api/core/v1beta1" "github.com/posit-dev/team-operator/api/product" "github.com/posit-dev/team-operator/internal" + "github.com/posit-dev/team-operator/internal/status" "github.com/rstudio/goex/ptr" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" @@ -83,7 +84,50 @@ func (r *SiteReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl. l.Info("Site found; updating resources") - return r.reconcileResources(ctx, req, s) + // Save a copy for status patching + patchBase := client.MergeFrom(s.DeepCopy()) + + // Set observed generation and progressing condition + s.Status.ObservedGeneration = s.Generation + status.SetProgressing(&s.Status.Conditions, s.Generation, metav1.ConditionTrue, status.ReasonReconciling, "Reconciliation in progress") + + result, reconcileErr := r.reconcileResources(ctx, req, s) + + // Aggregate child component status + aggregateErr := r.aggregateChildStatus(ctx, req, s) + + // Update status based on reconciliation result + if reconcileErr != nil { + msg := status.TruncateMessage(reconcileErr.Error()) + status.SetReady(&s.Status.Conditions, s.Generation, metav1.ConditionFalse, status.ReasonReconcileError, msg) + status.SetProgressing(&s.Status.Conditions, s.Generation, metav1.ConditionFalse, status.ReasonReconcileError, msg) + } else { + // Overall Ready is true only if all children are ready + allReady := s.Status.ConnectReady && s.Status.WorkbenchReady && s.Status.PackageManagerReady && s.Status.ChronicleReady && s.Status.FlightdeckReady + if allReady { + status.SetReady(&s.Status.Conditions, s.Generation, metav1.ConditionTrue, status.ReasonAllComponentsReady, "All child components are ready") + } else { + status.SetReady(&s.Status.Conditions, s.Generation, metav1.ConditionFalse, status.ReasonComponentsNotReady, "One or more child components are not ready") + } + status.SetProgressing(&s.Status.Conditions, s.Generation, metav1.ConditionFalse, status.ReasonReconcileComplete, "Reconciliation complete") + } + + // Patch status + if patchErr := r.Status().Patch(ctx, s, patchBase); patchErr != nil { + l.Error(patchErr, "Error patching status") + if reconcileErr != nil { + return result, reconcileErr + } + return ctrl.Result{}, patchErr + } + + if reconcileErr != nil { + if aggregateErr != nil { + l.Error(aggregateErr, "Error aggregating child status (returning reconcile error instead)") + } + return result, reconcileErr + } + return result, aggregateErr } var rootVolumeSize = resource.MustParse("1Gi") @@ -294,10 +338,17 @@ func (r *SiteReconciler) reconcileResources(ctx context.Context, req ctrl.Reques } // FLIGHTDECK - - if err := r.reconcileFlightdeck(ctx, req, site); err != nil { - l.Error(err, "error reconciling flightdeck") - return ctrl.Result{}, err + flightdeckEnabled := checkBool(site.Spec.Flightdeck.Enabled, true) + if flightdeckEnabled { + if err := r.reconcileFlightdeck(ctx, req, site); err != nil { + l.Error(err, "error reconciling flightdeck") + return ctrl.Result{}, err + } + } else { + if err := r.disableFlightdeck(ctx, req, l); err != nil { + l.Error(err, "error disabling flightdeck") + return ctrl.Result{}, err + } } // ADDITIONAL SHARED DIRECTORY @@ -487,6 +538,113 @@ func (r *SiteReconciler) reconcileResources(ctx context.Context, req ctrl.Reques return ctrl.Result{}, nil } +// aggregateChildStatus fetches each child CR and populates per-component readiness bools on the Site status. +// Returns a non-nil error only for transient API errors (not NotFound), so the reconciler can requeue. +// On transient error, all products are still evaluated so the status snapshot is as complete as possible. +// +// Products are default-enabled (Connect, Workbench, PackageManager, Chronicle, Flightdeck): +// missing CR is ready only when explicitly disabled (Enabled != nil && !*Enabled). If Enabled +// is nil the product is expected → not ready. +func (r *SiteReconciler) aggregateChildStatus(ctx context.Context, req ctrl.Request, site *positcov1beta1.Site) error { + // Child CRs (Connect, Workbench, etc.) are created by reconcileResources with the same + // name as the parent Site. See site_controller_connect.go, site_controller_workbench.go, etc. + key := client.ObjectKey{Name: site.Name, Namespace: req.Namespace} + + var firstErr error + + // Connect + connect := &positcov1beta1.Connect{} + if err := r.Get(ctx, key, connect); err == nil { + if !checkBool(site.Spec.Connect.Enabled, true) { + site.Status.ConnectReady = status.IsSuspended(connect.Status.Conditions) + } else { + site.Status.ConnectReady = status.IsReady(connect.Status.Conditions) + } + } else if apierrors.IsNotFound(err) { + site.Status.ConnectReady = !checkBool(site.Spec.Connect.Enabled, true) + } else { + if firstErr == nil { + firstErr = fmt.Errorf("fetching Connect for status aggregation: %w", err) + } + site.Status.ConnectReady = false + } + + // Workbench + workbench := &positcov1beta1.Workbench{} + if err := r.Get(ctx, key, workbench); err == nil { + if !checkBool(site.Spec.Workbench.Enabled, true) { + site.Status.WorkbenchReady = status.IsSuspended(workbench.Status.Conditions) + } else { + site.Status.WorkbenchReady = status.IsReady(workbench.Status.Conditions) + } + } else if apierrors.IsNotFound(err) { + site.Status.WorkbenchReady = !checkBool(site.Spec.Workbench.Enabled, true) + } else { + if firstErr == nil { + firstErr = fmt.Errorf("fetching Workbench for status aggregation: %w", err) + } + site.Status.WorkbenchReady = false + } + + // PackageManager + pm := &positcov1beta1.PackageManager{} + if err := r.Get(ctx, key, pm); err == nil { + if !checkBool(site.Spec.PackageManager.Enabled, true) { + site.Status.PackageManagerReady = status.IsSuspended(pm.Status.Conditions) + } else { + site.Status.PackageManagerReady = status.IsReady(pm.Status.Conditions) + } + } else if apierrors.IsNotFound(err) { + site.Status.PackageManagerReady = !checkBool(site.Spec.PackageManager.Enabled, true) + } else { + if firstErr == nil { + firstErr = fmt.Errorf("fetching PackageManager for status aggregation: %w", err) + } + site.Status.PackageManagerReady = false + } + + // Chronicle + chronicle := &positcov1beta1.Chronicle{} + if err := r.Get(ctx, key, chronicle); err == nil { + if !checkBool(site.Spec.Chronicle.Enabled, true) { + site.Status.ChronicleReady = status.IsSuspended(chronicle.Status.Conditions) + } else { + site.Status.ChronicleReady = status.IsReady(chronicle.Status.Conditions) + } + } else if apierrors.IsNotFound(err) { + site.Status.ChronicleReady = !checkBool(site.Spec.Chronicle.Enabled, true) + } else { + if firstErr == nil { + firstErr = fmt.Errorf("fetching Chronicle for status aggregation: %w", err) + } + site.Status.ChronicleReady = false + } + + // Flightdeck + flightdeck := &positcov1beta1.Flightdeck{} + if err := r.Get(ctx, key, flightdeck); err == nil { + if !checkBool(site.Spec.Flightdeck.Enabled, true) { + // Flightdeck is stateless — disable deletes the CR entirely, so if + // the CR still exists during a race between delete and aggregation, + // treat it as ready since the delete will complete on the next reconcile. + // A stuck delete surfaces as a reconcile error from disableFlightdeck, + // not from this status field. + site.Status.FlightdeckReady = true + } else { + site.Status.FlightdeckReady = status.IsReady(flightdeck.Status.Conditions) + } + } else if apierrors.IsNotFound(err) { + site.Status.FlightdeckReady = !checkBool(site.Spec.Flightdeck.Enabled, true) + } else { + if firstErr == nil { + firstErr = fmt.Errorf("fetching Flightdeck for status aggregation: %w", err) + } + site.Status.FlightdeckReady = false + } + + return firstErr +} + func (r *SiteReconciler) GetLogger(ctx context.Context) logr.Logger { if v, err := logr.FromContext(ctx); err == nil { return v @@ -525,6 +683,12 @@ func (r *SiteReconciler) cleanupResources(ctx context.Context, req ctrl.Request) l.Error(err, "error cleaning up package manager", "product", "package-manager") } + existingChronicle := positcov1beta1.Chronicle{} + chronicleKey := client.ObjectKey{Name: req.Name, Namespace: req.Namespace} + if err := internal.BasicDelete(ctx, r, l, chronicleKey, &existingChronicle); err != nil { + l.Error(err, "error cleaning up chronicle", "product", "chronicle") + } + existingFlightdeck := positcov1beta1.Flightdeck{} flightdeckKey := client.ObjectKey{Name: req.Name, Namespace: req.Namespace} if err := internal.BasicDelete(ctx, r, l, flightdeckKey, &existingFlightdeck); err != nil { @@ -542,5 +706,10 @@ func (r *SiteReconciler) cleanupResources(ctx context.Context, req ctrl.Request) func (r *SiteReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). For(&positcov1beta1.Site{}). + Owns(&positcov1beta1.Connect{}). + Owns(&positcov1beta1.Workbench{}). + Owns(&positcov1beta1.PackageManager{}). + Owns(&positcov1beta1.Chronicle{}). + Owns(&positcov1beta1.Flightdeck{}). Complete(r) } diff --git a/internal/controller/core/site_controller_flightdeck.go b/internal/controller/core/site_controller_flightdeck.go index 29d229a8..78244b7b 100644 --- a/internal/controller/core/site_controller_flightdeck.go +++ b/internal/controller/core/site_controller_flightdeck.go @@ -5,10 +5,12 @@ import ( "fmt" "strings" + "github.com/go-logr/logr" "github.com/posit-dev/team-operator/api/core/v1beta1" "github.com/posit-dev/team-operator/internal" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" controllerruntime "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" ) const ( @@ -45,12 +47,6 @@ func (r *SiteReconciler) reconcileFlightdeck( "event", "reconcile-flightdeck", ) - // Skip Flightdeck reconciliation if explicitly disabled - if site.Spec.Flightdeck.Enabled != nil && !*site.Spec.Flightdeck.Enabled { - l.V(1).Info("skipping Flightdeck reconciliation: explicitly disabled via Site.Spec.Flightdeck.Enabled=false") - return nil - } - // Resolve the Flightdeck image (defaults to docker.io/posit/ptd-flightdeck:latest) flightdeckImage := ResolveFlightdeckImage(site.Spec.Flightdeck.Image) @@ -120,3 +116,11 @@ func (r *SiteReconciler) reconcileFlightdeck( return nil } + +// disableFlightdeck deletes the Flightdeck CR when disabled. +// Flightdeck is stateless, so disable and teardown have the same effect. +// BasicDelete already handles NotFound gracefully, so no pre-check is needed. +func (r *SiteReconciler) disableFlightdeck(ctx context.Context, req controllerruntime.Request, l logr.Logger) error { + l = l.WithValues("event", "disable-flightdeck") + return internal.BasicDelete(ctx, r, l, client.ObjectKey{Name: req.Name, Namespace: req.Namespace}, &v1beta1.Flightdeck{}) +} diff --git a/internal/controller/core/site_controller_networkpolicies.go b/internal/controller/core/site_controller_networkpolicies.go index 723ca412..a250b005 100644 --- a/internal/controller/core/site_controller_networkpolicies.go +++ b/internal/controller/core/site_controller_networkpolicies.go @@ -114,9 +114,17 @@ func (r *SiteReconciler) reconcileNetworkPolicies(ctx context.Context, req ctrl. } } - if err := r.reconcileFlightdeckNetworkPolicy(ctx, req.Namespace, l, site); err != nil { - l.Error(err, "error ensuring flightdeck network policy") - return err + flightdeckEnabled := checkBool(site.Spec.Flightdeck.Enabled, true) + if flightdeckEnabled { + if err := r.reconcileFlightdeckNetworkPolicy(ctx, req.Namespace, l, site); err != nil { + l.Error(err, "error ensuring flightdeck network policy") + return err + } + } else { + if err := r.cleanupFlightdeckNetworkPolicies(ctx, req, l); err != nil { + l.Error(err, "error cleaning up flightdeck network policies") + return err + } } return nil @@ -849,3 +857,8 @@ func (r *SiteReconciler) cleanupPackageManagerNetworkPolicies(ctx context.Contex key := client.ObjectKey{Name: req.Name + "-packagemanager", Namespace: req.Namespace} return internal.BasicDelete(ctx, r, l, key, &networkingv1.NetworkPolicy{}) } + +func (r *SiteReconciler) cleanupFlightdeckNetworkPolicies(ctx context.Context, req ctrl.Request, l logr.Logger) error { + key := client.ObjectKey{Name: req.Name + "-flightdeck", Namespace: req.Namespace} + return internal.BasicDelete(ctx, r, l, key, &networkingv1.NetworkPolicy{}) +} diff --git a/internal/controller/core/site_test.go b/internal/controller/core/site_test.go index 2deff50d..61fa6326 100644 --- a/internal/controller/core/site_test.go +++ b/internal/controller/core/site_test.go @@ -9,6 +9,7 @@ import ( "github.com/posit-dev/team-operator/api/keycloak/v2alpha1" "github.com/posit-dev/team-operator/api/localtest" "github.com/posit-dev/team-operator/api/product" + "github.com/posit-dev/team-operator/internal/status" "github.com/rstudio/goex/ptr" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -1582,3 +1583,343 @@ func TestSiteTeardownIgnoredWhileEnabled(t *testing.T) { err = cli.Get(context.TODO(), client.ObjectKey{Name: siteName, Namespace: siteNamespace}, chronicle) assert.NoError(t, err, "Chronicle CR should still exist: teardown has no effect while enabled=true") } + +// TestSiteReadyWithDisabledProducts verifies that a Site can be Ready when all +// products are explicitly disabled (enabled: false), since disabled products don't +// create CRs and therefore shouldn't block site readiness. +func TestSiteReadyWithDisabledProducts(t *testing.T) { + siteName := "ready-with-disabled-products" + siteNamespace := "posit-team" + site := defaultSite(siteName) + + // Disable all products so none create CRs that would block readiness + connectEnabled := false + workbenchEnabled := false + pmEnabled := false + chronicleEnabled := false + flightdeckEnabled := false + site.Spec.Connect.Enabled = &connectEnabled + site.Spec.Workbench.Enabled = &workbenchEnabled + site.Spec.PackageManager.Enabled = &pmEnabled + site.Spec.Chronicle.Enabled = &chronicleEnabled + site.Spec.Flightdeck.Enabled = &flightdeckEnabled + + // Use shared fake client to run multiple reconcile passes + fakeClient := localtest.FakeTestEnv{} + cli, scheme, log := fakeClient.Start(loadSchemes) + rec := SiteReconciler{Client: cli, Scheme: scheme, Log: log} + req := ctrl.Request{NamespacedName: types.NamespacedName{Namespace: siteNamespace, Name: siteName}} + + // Create the Site + err := cli.Create(context.TODO(), site) + assert.NoError(t, err) + + // Run initial reconcile + _, err = rec.Reconcile(context.TODO(), req) + assert.NoError(t, err) + + // Fetch the Site to check its status + fetchedSite := &v1beta1.Site{} + err = cli.Get(context.TODO(), client.ObjectKey{Name: siteName, Namespace: siteNamespace}, fetchedSite) + assert.NoError(t, err) + + // Verify per-product readiness for disabled products + assert.True(t, fetchedSite.Status.ConnectReady, "ConnectReady should be true when Connect is disabled") + assert.True(t, fetchedSite.Status.WorkbenchReady, "WorkbenchReady should be true when Workbench is disabled") + assert.True(t, fetchedSite.Status.PackageManagerReady, "PackageManagerReady should be true when PackageManager is disabled") + + // Verify aggregate site readiness - the main goal of the fix + assert.True(t, status.IsReady(fetchedSite.Status.Conditions), "site should be Ready when all required products are disabled") + + // Verify CRs do NOT exist for disabled products + connect := &v1beta1.Connect{} + err = cli.Get(context.TODO(), client.ObjectKey{Name: siteName, Namespace: siteNamespace}, connect) + assert.Error(t, err, "Connect CR should not exist when disabled") + + workbench := &v1beta1.Workbench{} + err = cli.Get(context.TODO(), client.ObjectKey{Name: siteName, Namespace: siteNamespace}, workbench) + assert.Error(t, err, "Workbench CR should not exist when disabled") + + pm := &v1beta1.PackageManager{} + err = cli.Get(context.TODO(), client.ObjectKey{Name: siteName, Namespace: siteNamespace}, pm) + assert.Error(t, err, "PackageManager CR should not exist when disabled") +} + +// TestSiteNilEnabledMissingCR is a regression test verifying that when Enabled=nil (the default) +// and the product CR does not exist, the product is NOT treated as ready. This guards against +// future refactors that might accidentally collapse the nil and false cases. +func TestSiteNilEnabledMissingCR(t *testing.T) { + siteName := "nil-enabled-missing-cr" + siteNamespace := "posit-team" + + fakeClient := localtest.FakeTestEnv{} + cli, scheme, log := fakeClient.Start(loadSchemes) + rec := SiteReconciler{Client: cli, Scheme: scheme, Log: log} + req := ctrl.Request{NamespacedName: types.NamespacedName{Namespace: siteNamespace, Name: siteName}} + + // site with Connect.Enabled = nil (default: not set) + site := defaultSite(siteName) + // Connect.Enabled is nil — product is expected but CR does not yet exist + + err := rec.aggregateChildStatus(context.TODO(), req, site) + assert.NoError(t, err) + + assert.False(t, site.Status.ConnectReady, "ConnectReady should be false when Enabled=nil and Connect CR does not exist") + assert.False(t, site.Status.WorkbenchReady, "WorkbenchReady should be false when Enabled=nil and Workbench CR does not exist") + assert.False(t, site.Status.PackageManagerReady, "PackageManagerReady should be false when Enabled=nil and PackageManager CR does not exist") +} + +// TestSiteReadyWithDisabledFlightdeck verifies that FlightdeckReady=true when Flightdeck is +// explicitly disabled (Enabled=false), and that the site is Ready when all products including +// Chronicle are also disabled. +func TestSiteReadyWithDisabledFlightdeck(t *testing.T) { + siteName := "disabled-flightdeck" + siteNamespace := "posit-team" + site := defaultSite(siteName) + + // Disable all products so none create CRs that would block readiness + connectEnabled := false + workbenchEnabled := false + pmEnabled := false + chronicleEnabled := false + flightdeckEnabled := false + site.Spec.Connect.Enabled = &connectEnabled + site.Spec.Workbench.Enabled = &workbenchEnabled + site.Spec.PackageManager.Enabled = &pmEnabled + site.Spec.Chronicle.Enabled = &chronicleEnabled + site.Spec.Flightdeck.Enabled = &flightdeckEnabled + + fakeClient := localtest.FakeTestEnv{} + cli, scheme, log := fakeClient.Start(loadSchemes) + rec := SiteReconciler{Client: cli, Scheme: scheme, Log: log} + req := ctrl.Request{NamespacedName: types.NamespacedName{Namespace: siteNamespace, Name: siteName}} + + err := cli.Create(context.TODO(), site) + assert.NoError(t, err) + + _, err = rec.Reconcile(context.TODO(), req) + assert.NoError(t, err) + + fetchedSite := &v1beta1.Site{} + err = cli.Get(context.TODO(), client.ObjectKey{Name: siteName, Namespace: siteNamespace}, fetchedSite) + assert.NoError(t, err) + + assert.True(t, fetchedSite.Status.FlightdeckReady, "FlightdeckReady should be true when Flightdeck is disabled") + assert.True(t, status.IsReady(fetchedSite.Status.Conditions), "site should be Ready when all products are disabled") +} + +// errorGetClient wraps a client.Client and injects a fixed error for Get calls on a specific type. +type errorGetClient struct { + client.Client + errForType func(obj client.Object) error +} + +func (c *errorGetClient) Get(ctx context.Context, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + if c.errForType != nil { + if err := c.errForType(obj); err != nil { + return err + } + } + return c.Client.Get(ctx, key, obj, opts...) +} + +// TestAggregateChildStatusContinuesOnTransientError verifies that when one product returns a +// transient API error, aggregateChildStatus still evaluates all remaining products and returns +// the error at the end (rather than returning early with stale status for the other products). +func TestAggregateChildStatusContinuesOnTransientError(t *testing.T) { + siteName := "transient-error-site" + siteNamespace := "posit-team" + site := defaultSite(siteName) + + transientErr := fmt.Errorf("transient server error") + + fakeClient := localtest.FakeTestEnv{} + baseCli, scheme, log := fakeClient.Start(loadSchemes) + + // Inject a transient error for Connect Get calls only + errCli := &errorGetClient{ + Client: baseCli, + errForType: func(obj client.Object) error { + if _, ok := obj.(*v1beta1.Connect); ok { + return transientErr + } + return nil + }, + } + + rec := SiteReconciler{Client: errCli, Scheme: scheme, Log: log} + req := ctrl.Request{NamespacedName: types.NamespacedName{Namespace: siteNamespace, Name: siteName}} + + err := rec.aggregateChildStatus(context.TODO(), req, site) + + // Error should be propagated + assert.Error(t, err, "transient API error should be returned") + assert.ErrorContains(t, err, "fetching Connect for status aggregation") + + // All products should have been evaluated (not left stale): remaining products have no CRs + // so they fall into the NotFound path and are set to false (Enabled=nil means expected but missing). + assert.False(t, site.Status.ConnectReady, "ConnectReady should be false on transient error") + assert.False(t, site.Status.WorkbenchReady, "WorkbenchReady should be false when CR missing") + assert.False(t, site.Status.PackageManagerReady, "PackageManagerReady should be false when CR missing") +} + +// TestSiteOptionalComponentsNilEnabledNoCR verifies that Chronicle and Flightdeck with Enabled=nil +// and no CR present are treated as not ready (Enabled=nil means enabled via checkBool, +// so the CR is expected but missing → not ready yet). +func TestSiteOptionalComponentsNilEnabledNoCR(t *testing.T) { + siteName := "optional-nil-no-cr" + siteNamespace := "posit-team" + + fakeClient := localtest.FakeTestEnv{} + cli, scheme, log := fakeClient.Start(loadSchemes) + rec := SiteReconciler{Client: cli, Scheme: scheme, Log: log} + req := ctrl.Request{NamespacedName: types.NamespacedName{Namespace: siteNamespace, Name: siteName}} + + // Enabled=nil (default) — no Chronicle or Flightdeck CRs pre-created + site := defaultSite(siteName) + // Chronicle.Enabled and Flightdeck.Enabled are nil by default + + err := rec.aggregateChildStatus(context.TODO(), req, site) + assert.NoError(t, err) + + assert.False(t, site.Status.ChronicleReady, "ChronicleReady should be false when Enabled=nil and no CR exists (CR expected but missing)") + assert.False(t, site.Status.FlightdeckReady, "FlightdeckReady should be false when Enabled=nil and no CR exists (CR expected but missing)") +} + +// TestSiteOptionalComponentsNilEnabledWithCR verifies that when Enabled=nil but a CR already +// exists (e.g., mid-teardown after disabling), readiness is derived from the CR conditions rather +// than unconditionally set to true. +func TestSiteOptionalComponentsNilEnabledWithCR(t *testing.T) { + siteName := "optional-nil-with-cr" + siteNamespace := "posit-team" + + fakeClient := localtest.FakeTestEnv{} + cli, scheme, log := fakeClient.Start(loadSchemes) + rec := SiteReconciler{Client: cli, Scheme: scheme, Log: log} + req := ctrl.Request{NamespacedName: types.NamespacedName{Namespace: siteNamespace, Name: siteName}} + + // Pre-create Chronicle CR (not ready — no Ready condition set) + chronicle := &v1beta1.Chronicle{ + ObjectMeta: metav1.ObjectMeta{Namespace: siteNamespace, Name: siteName}, + } + err := cli.Create(context.TODO(), chronicle) + require.NoError(t, err) + + // Pre-create Flightdeck CR (not ready — no Ready condition set) + flightdeck := &v1beta1.Flightdeck{ + ObjectMeta: metav1.ObjectMeta{Namespace: siteNamespace, Name: siteName}, + } + err = cli.Create(context.TODO(), flightdeck) + require.NoError(t, err) + + // Enabled=nil — CRs exist (simulating transition/teardown) + site := defaultSite(siteName) + + err = rec.aggregateChildStatus(context.TODO(), req, site) + assert.NoError(t, err) + + // CRs exist but have no Ready condition → IsReady returns false + assert.False(t, site.Status.ChronicleReady, "ChronicleReady should reflect CR conditions, not be unconditionally true when CR exists") + assert.False(t, site.Status.FlightdeckReady, "FlightdeckReady should reflect CR conditions, not be unconditionally true when CR exists") +} + +// TestAggregateChildStatusDisabledWithExistingCR verifies that when a product is explicitly +// disabled (Enabled=false) but the CR still exists (e.g. suspended), aggregateChildStatus +// treats it as ready from the Site's perspective. +func TestAggregateChildStatusDisabledWithExistingCR(t *testing.T) { + siteName := "disabled-with-cr" + siteNamespace := "posit-team" + + fakeClient := localtest.FakeTestEnv{} + cli, scheme, log := fakeClient.Start(loadSchemes) + rec := SiteReconciler{Client: cli, Scheme: scheme, Log: log} + req := ctrl.Request{NamespacedName: types.NamespacedName{Namespace: siteNamespace, Name: siteName}} + + // Pre-create Connect CR with suspended status (simulates disabled state) + connect := &v1beta1.Connect{ + ObjectMeta: metav1.ObjectMeta{Namespace: siteNamespace, Name: siteName}, + } + require.NoError(t, cli.Create(context.TODO(), connect)) + status.SetReady(&connect.Status.Conditions, 0, metav1.ConditionFalse, status.ReasonSuspended, "Product is suspended") + require.NoError(t, cli.Status().Update(context.TODO(), connect)) + + // Pre-create Chronicle CR with suspended status + chronicle := &v1beta1.Chronicle{ + ObjectMeta: metav1.ObjectMeta{Namespace: siteNamespace, Name: siteName}, + } + require.NoError(t, cli.Create(context.TODO(), chronicle)) + status.SetReady(&chronicle.Status.Conditions, 0, metav1.ConditionFalse, status.ReasonSuspended, "Product is suspended") + require.NoError(t, cli.Status().Update(context.TODO(), chronicle)) + + site := defaultSite(siteName) + // Explicitly disable Connect and Chronicle + site.Spec.Connect.Enabled = ptr.To(false) + site.Spec.Chronicle.Enabled = ptr.To(false) + + err := rec.aggregateChildStatus(context.TODO(), req, site) + assert.NoError(t, err) + + // Disabled products with existing CRs should be treated as ready + assert.True(t, site.Status.ConnectReady, "ConnectReady should be true when explicitly disabled, even if CR exists") + assert.True(t, site.Status.ChronicleReady, "ChronicleReady should be true when explicitly disabled, even if CR exists") + + // Products with Enabled=nil (default, not disabled) and no CR should be not ready + assert.False(t, site.Status.WorkbenchReady, "WorkbenchReady should be false when Enabled=nil and no CR") + assert.False(t, site.Status.PackageManagerReady, "PackageManagerReady should be false when Enabled=nil and no CR") +} + +// TestSiteFlightdeckDisableReenableCycle verifies that Flightdeck CR is deleted when disabled +// and recreated when re-enabled. +func TestSiteFlightdeckDisableReenableCycle(t *testing.T) { + siteName := "flightdeck-cycle" + siteNamespace := "posit-team" + + fakeClient := localtest.FakeTestEnv{} + cli, scheme, log := fakeClient.Start(loadSchemes) + rec := SiteReconciler{Client: cli, Scheme: scheme, Log: log} + req := ctrl.Request{NamespacedName: types.NamespacedName{Namespace: siteNamespace, Name: siteName}} + + // Create site with Flightdeck enabled (default) + site := defaultSite(siteName) + require.NoError(t, cli.Create(context.TODO(), site)) + + // First reconcile: Flightdeck CR should be created + _, err := rec.Reconcile(context.TODO(), req) + assert.NoError(t, err) + + fd := &v1beta1.Flightdeck{} + err = cli.Get(context.TODO(), client.ObjectKey{Name: siteName, Namespace: siteNamespace}, fd) + assert.NoError(t, err, "Flightdeck CR should exist after initial reconcile") + + // Disable Flightdeck + fetchedSite := &v1beta1.Site{} + require.NoError(t, cli.Get(context.TODO(), client.ObjectKey{Name: siteName, Namespace: siteNamespace}, fetchedSite)) + fetchedSite.Spec.Flightdeck.Enabled = ptr.To(false) + require.NoError(t, cli.Update(context.TODO(), fetchedSite)) + + // Reconcile with Flightdeck disabled + _, err = rec.Reconcile(context.TODO(), req) + assert.NoError(t, err) + + // Flightdeck CR should be deleted + err = cli.Get(context.TODO(), client.ObjectKey{Name: siteName, Namespace: siteNamespace}, fd) + assert.Error(t, err, "Flightdeck CR should not exist after disabling") + + // Verify FlightdeckReady is true for disabled product + fetchedSite = &v1beta1.Site{} + require.NoError(t, cli.Get(context.TODO(), client.ObjectKey{Name: siteName, Namespace: siteNamespace}, fetchedSite)) + assert.True(t, fetchedSite.Status.FlightdeckReady, "FlightdeckReady should be true when Flightdeck is disabled") + + // Re-enable Flightdeck + fetchedSite.Spec.Flightdeck.Enabled = nil + require.NoError(t, cli.Update(context.TODO(), fetchedSite)) + + // Reconcile with Flightdeck re-enabled + _, err = rec.Reconcile(context.TODO(), req) + assert.NoError(t, err) + + // Flightdeck CR should be recreated + fd = &v1beta1.Flightdeck{} + err = cli.Get(context.TODO(), client.ObjectKey{Name: siteName, Namespace: siteNamespace}, fd) + assert.NoError(t, err, "Flightdeck CR should be recreated after re-enabling") +} diff --git a/internal/controller/core/workbench.go b/internal/controller/core/workbench.go index d981e8cd..93c8aeeb 100644 --- a/internal/controller/core/workbench.go +++ b/internal/controller/core/workbench.go @@ -13,6 +13,7 @@ import ( "github.com/posit-dev/team-operator/api/templates" "github.com/posit-dev/team-operator/internal" "github.com/posit-dev/team-operator/internal/db" + "github.com/posit-dev/team-operator/internal/status" "github.com/rstudio/goex/ptr" "github.com/traefik/traefik/v3/pkg/config/dynamic" "github.com/traefik/traefik/v3/pkg/provider/kubernetes/crd/traefikio/v1alpha1" @@ -80,9 +81,29 @@ func (r *WorkbenchReconciler) ReconcileWorkbench(ctx context.Context, req ctrl.R // If suspended, clean up serving resources but preserve data if w.Spec.Suspended != nil && *w.Spec.Suspended { - return r.suspendDeployedService(ctx, req, w) + // Capture patch base before suspend so any future in-memory mutations are included in the diff + patchBase := client.MergeFrom(w.DeepCopy()) + res, err := r.suspendDeployedService(ctx, req, w) + if err != nil { + if patchErr := status.PatchErrorStatus(ctx, r.Status(), w, patchBase, &w.Status.Conditions, w.Generation, err); patchErr != nil { + l.Error(patchErr, "Error patching error status") + } + return res, err + } + if patchErr := status.PatchSuspendedStatus(ctx, r.Status(), w, patchBase, &w.Status.Conditions, w.Generation, &w.Status.ObservedGeneration, &w.Status.Ready, &w.Status.Version); patchErr != nil { + l.Error(patchErr, "Error patching suspended status") + return res, patchErr + } + return res, nil } + // Save a copy for status patching + patchBase := client.MergeFrom(w.DeepCopy()) + + // Set observed generation and progressing condition + w.Status.ObservedGeneration = w.Generation + status.SetProgressing(&w.Status.Conditions, w.Generation, metav1.ConditionTrue, status.ReasonReconciling, "Reconciliation in progress") + // TODO: should do formal spec validation / correction... // check for deprecated databricks location (we did not remove this yet for backwards compat and to allow an upgrade path) @@ -90,6 +111,9 @@ func (r *WorkbenchReconciler) ReconcileWorkbench(ctx context.Context, req ctrl.R if w.Spec.Config.Databricks != nil && len(w.Spec.Config.Databricks) > 0 { err := errors.New("the Databricks configuration should be in SecretConfig, not Config") l.Error(err, "invalid workbench specification") + if patchErr := status.PatchErrorStatus(ctx, r.Status(), w, patchBase, &w.Status.Conditions, w.Generation, err); patchErr != nil { + l.Error(patchErr, "Error patching error status") + } return ctrl.Result{}, err } @@ -97,6 +121,9 @@ func (r *WorkbenchReconciler) ReconcileWorkbench(ctx context.Context, req ctrl.R secretKey := "dev-db-password" if err := db.EnsureDatabaseExists(ctx, r, req, w, w.Spec.DatabaseConfig, w.ComponentName(), "", []string{}, w.Spec.Secret, w.Spec.WorkloadSecret, w.Spec.MainDatabaseCredentialSecret, secretKey); err != nil { l.Error(err, "error creating database", "database", w.ComponentName()) + if patchErr := status.PatchErrorStatus(ctx, r.Status(), w, patchBase, &w.Status.Conditions, w.Generation, err); patchErr != nil { + l.Error(patchErr, "Error patching error status") + } return ctrl.Result{}, err } @@ -104,6 +131,9 @@ func (r *WorkbenchReconciler) ReconcileWorkbench(ctx context.Context, req ctrl.R // TODO: we probably do not need to create this... it goes in a provisioning secret intentionally now...? if _, err := internal.EnsureWorkbenchSecretKey(ctx, w, r, req, w); err != nil { l.Error(err, "error ensuring that provisioning key exists") + if patchErr := status.PatchErrorStatus(ctx, r.Status(), w, patchBase, &w.Status.Conditions, w.Generation, err); patchErr != nil { + l.Error(patchErr, "Error patching error status") + } return ctrl.Result{}, err } else { l.Info("successfully created or retrieved provisioning key value") @@ -114,10 +144,6 @@ func (r *WorkbenchReconciler) ReconcileWorkbench(ctx context.Context, req ctrl.R Name: w.KeySecretName(), Namespace: req.Namespace, } - if err := r.Status().Update(ctx, w); err != nil { - l.Error(err, "Error updating status") - return ctrl.Result{}, err - } // define database stuff matches := dbHostRegexp.FindStringSubmatch(w.Spec.DatabaseConfig.Host) @@ -145,18 +171,30 @@ func (r *WorkbenchReconciler) ReconcileWorkbench(ctx context.Context, req ctrl.R res, err := r.ensureDeployedService(ctx, req, w) if err != nil { l.Error(err, "error deploying service") + if patchErr := status.PatchErrorStatus(ctx, r.Status(), w, patchBase, &w.Status.Conditions, w.Generation, err); patchErr != nil { + l.Error(patchErr, "Error patching error status") + } return res, err } - // TODO: should we watch for happy pods? - - // set to ready if it is not set yet... - if !w.Status.Ready { - w.Status.Ready = true - if err := r.Status().Update(ctx, w); err != nil { - l.Error(err, "Error updating status") - return ctrl.Result{}, err + // Check deployment health + deploy := &appsv1.Deployment{} + if err := r.Get(ctx, client.ObjectKey{Name: w.ComponentName(), Namespace: req.Namespace}, deploy); err != nil { + l.Error(err, "error fetching deployment for status") + if patchErr := status.PatchErrorStatus(ctx, r.Status(), w, patchBase, &w.Status.Conditions, w.Generation, err); patchErr != nil { + l.Error(patchErr, "Error patching error status") } + return ctrl.Result{}, err + } + + status.SetDeploymentHealth(&w.Status.Conditions, w.Generation, deploy.Status.ReadyReplicas, status.DesiredReplicas(deploy.Spec.Replicas)) + w.Status.Version = status.ExtractVersion(w.Spec.Image) + w.Status.Ready = status.IsReady(w.Status.Conditions) + + // Patch status + if err := r.Status().Patch(ctx, w, patchBase); err != nil { + l.Error(err, "Error patching status") + return ctrl.Result{}, err } return ctrl.Result{}, nil diff --git a/internal/controller/core/workbench_controller.go b/internal/controller/core/workbench_controller.go index a2db82e4..ec513dad 100644 --- a/internal/controller/core/workbench_controller.go +++ b/internal/controller/core/workbench_controller.go @@ -7,6 +7,7 @@ import ( "context" "github.com/go-logr/logr" + appsv1 "k8s.io/api/apps/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" @@ -88,6 +89,7 @@ func (r *WorkbenchReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( func (r *WorkbenchReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). For(&positcov1beta1.Workbench{}). + Owns(&appsv1.Deployment{}). Complete(r) } diff --git a/internal/controller/core/workbench_test.go b/internal/controller/core/workbench_test.go index c6d7e3a9..0ba47b6e 100644 --- a/internal/controller/core/workbench_test.go +++ b/internal/controller/core/workbench_test.go @@ -10,12 +10,14 @@ import ( "github.com/posit-dev/team-operator/api/product" "github.com/posit-dev/team-operator/internal" "github.com/posit-dev/team-operator/internal/db" + "github.com/posit-dev/team-operator/internal/status" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" networkingv1 "k8s.io/api/networking/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" + apimeta "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" ctrl "sigs.k8s.io/controller-runtime" @@ -428,6 +430,19 @@ func TestWorkbenchReconciler_Suspended(t *testing.T) { ing := &networkingv1.Ingress{} err = cli.Get(ctx, client.ObjectKey{Name: wb.ComponentName(), Namespace: ns}, ing) assert.Error(t, err, "Ingress should not exist when Workbench is suspended") + + // Status should reflect the suspended state + updated := &positcov1beta1.Workbench{} + require.NoError(t, cli.Get(ctx, client.ObjectKey{Namespace: ns, Name: name}, updated)) + assert.False(t, updated.Status.Ready, "Ready bool should be false when suspended") + readyCond := apimeta.FindStatusCondition(updated.Status.Conditions, status.TypeReady) + require.NotNil(t, readyCond, "Ready condition should be set when suspended") + assert.Equal(t, metav1.ConditionFalse, readyCond.Status) + assert.Equal(t, status.ReasonSuspended, readyCond.Reason) + progressCond := apimeta.FindStatusCondition(updated.Status.Conditions, status.TypeProgressing) + require.NotNil(t, progressCond, "Progressing condition should be set when suspended") + assert.Equal(t, metav1.ConditionFalse, progressCond.Status) + assert.Equal(t, status.ReasonSuspended, progressCond.Reason) } // TestWorkbenchReconciler_SuspendRemovesDeployment verifies that when Workbench transitions diff --git a/internal/crdapply/bases/core.posit.team_chronicles.yaml b/internal/crdapply/bases/core.posit.team_chronicles.yaml index 10327708..0121bde2 100644 --- a/internal/crdapply/bases/core.posit.team_chronicles.yaml +++ b/internal/crdapply/bases/core.posit.team_chronicles.yaml @@ -17,7 +17,17 @@ spec: singular: chronicle scope: Namespaced versions: - - name: v1beta1 + - additionalPrinterColumns: + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .status.version + name: Version + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1beta1 schema: openAPIV3Schema: description: Chronicle is the Schema for the chronicles API @@ -130,10 +140,78 @@ spec: status: description: ChronicleStatus defines the observed state of Chronicle properties: + conditions: + description: Conditions represent the latest available observations + of the resource's current state. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + 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. + 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 + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + observedGeneration: + description: |- + ObservedGeneration is the most recent generation observed for this resource. + It corresponds to the resource's generation, which is updated on mutation by the API Server. + format: int64 + type: integer ready: type: boolean - required: - - ready + version: + description: Version is the version of the product image being deployed. + type: string type: object type: object served: true diff --git a/internal/crdapply/bases/core.posit.team_connects.yaml b/internal/crdapply/bases/core.posit.team_connects.yaml index 94495a6e..a93c9d0c 100644 --- a/internal/crdapply/bases/core.posit.team_connects.yaml +++ b/internal/crdapply/bases/core.posit.team_connects.yaml @@ -17,7 +17,17 @@ spec: singular: connect scope: Namespaced versions: - - name: v1beta1 + - additionalPrinterColumns: + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .status.version + name: Version + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1beta1 schema: openAPIV3Schema: description: Connect is the Schema for the connects API @@ -7401,6 +7411,67 @@ spec: status: description: ConnectStatus defines the observed state of Connect properties: + conditions: + description: Conditions represent the latest available observations + of the resource's current state. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + 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. + 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 + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map keySecretRef: description: |- SecretReference represents a Secret Reference. It has enough information to retrieve secret @@ -7416,10 +7487,17 @@ spec: type: string type: object x-kubernetes-map-type: atomic + observedGeneration: + description: |- + ObservedGeneration is the most recent generation observed for this resource. + It corresponds to the resource's generation, which is updated on mutation by the API Server. + format: int64 + type: integer ready: type: boolean - required: - - ready + version: + description: Version is the version of the product image being deployed. + type: string type: object type: object served: true diff --git a/internal/crdapply/bases/core.posit.team_flightdecks.yaml b/internal/crdapply/bases/core.posit.team_flightdecks.yaml index 74dd8f07..f38116a1 100644 --- a/internal/crdapply/bases/core.posit.team_flightdecks.yaml +++ b/internal/crdapply/bases/core.posit.team_flightdecks.yaml @@ -14,7 +14,17 @@ spec: singular: flightdeck scope: Namespaced versions: - - name: v1beta1 + - additionalPrinterColumns: + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .status.version + name: Version + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1beta1 schema: openAPIV3Schema: description: Flightdeck is the Schema for the flightdecks API @@ -112,12 +122,80 @@ spec: status: description: FlightdeckStatus defines the observed state of Flightdeck properties: + conditions: + description: Conditions represent the latest available observations + of the resource's current state. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + 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. + 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 + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + observedGeneration: + description: |- + ObservedGeneration is the most recent generation observed for this resource. + It corresponds to the resource's generation, which is updated on mutation by the API Server. + format: int64 + type: integer ready: description: Ready indicates whether the Flightdeck deployment is ready type: boolean - required: - - ready + version: + description: Version is the version of the product image being deployed. + type: string type: object type: object served: true diff --git a/internal/crdapply/bases/core.posit.team_packagemanagers.yaml b/internal/crdapply/bases/core.posit.team_packagemanagers.yaml index 571297cb..69187d11 100644 --- a/internal/crdapply/bases/core.posit.team_packagemanagers.yaml +++ b/internal/crdapply/bases/core.posit.team_packagemanagers.yaml @@ -17,7 +17,17 @@ spec: singular: packagemanager scope: Namespaced versions: - - name: v1beta1 + - additionalPrinterColumns: + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .status.version + name: Version + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1beta1 schema: openAPIV3Schema: description: PackageManager is the Schema for the packagemanagers API @@ -445,6 +455,67 @@ spec: status: description: PackageManagerStatus defines the observed state of PackageManager properties: + conditions: + description: Conditions represent the latest available observations + of the resource's current state. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + 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. + 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 + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map keySecretRef: description: |- SecretReference represents a Secret Reference. It has enough information to retrieve secret @@ -460,10 +531,17 @@ spec: type: string type: object x-kubernetes-map-type: atomic + observedGeneration: + description: |- + ObservedGeneration is the most recent generation observed for this resource. + It corresponds to the resource's generation, which is updated on mutation by the API Server. + format: int64 + type: integer ready: type: boolean - required: - - ready + version: + description: Version is the version of the product image being deployed. + type: string type: object type: object served: true diff --git a/internal/crdapply/bases/core.posit.team_postgresdatabases.yaml b/internal/crdapply/bases/core.posit.team_postgresdatabases.yaml index 7e490d47..741f78cb 100644 --- a/internal/crdapply/bases/core.posit.team_postgresdatabases.yaml +++ b/internal/crdapply/bases/core.posit.team_postgresdatabases.yaml @@ -17,7 +17,14 @@ spec: singular: postgresdatabase scope: Namespaced versions: - - name: v1beta1 + - additionalPrinterColumns: + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1beta1 schema: openAPIV3Schema: description: PostgresDatabase is the Schema for the postgresdatabases API @@ -99,6 +106,77 @@ spec: type: object status: description: PostgresDatabaseStatus defines the observed state of PostgresDatabase + properties: + conditions: + description: Conditions represent the latest available observations + of the resource's current state. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + 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. + 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 + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + observedGeneration: + description: |- + ObservedGeneration is the most recent generation observed for this resource. + It corresponds to the resource's generation, which is updated on mutation by the API Server. + format: int64 + type: integer + version: + description: Version is the version of the product image being deployed. + type: string type: object type: object served: true diff --git a/internal/crdapply/bases/core.posit.team_sites.yaml b/internal/crdapply/bases/core.posit.team_sites.yaml index 850d2489..6d36e5b3 100644 --- a/internal/crdapply/bases/core.posit.team_sites.yaml +++ b/internal/crdapply/bases/core.posit.team_sites.yaml @@ -14,7 +14,14 @@ spec: singular: site scope: Namespaced versions: - - name: v1beta1 + - additionalPrinterColumns: + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1beta1 schema: openAPIV3Schema: description: Site is the Schema for the sites API @@ -1737,6 +1744,97 @@ spec: type: object status: description: SiteStatus defines the observed state of Site + properties: + chronicleReady: + description: ChronicleReady indicates whether the Chronicle child + resource is ready. + type: boolean + conditions: + description: Conditions represent the latest available observations + of the resource's current state. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + 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. + 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 + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + connectReady: + description: ConnectReady indicates whether the Connect child resource + is ready. + type: boolean + flightdeckReady: + description: FlightdeckReady indicates whether the Flightdeck child + resource is ready. + type: boolean + observedGeneration: + description: |- + ObservedGeneration is the most recent generation observed for this resource. + It corresponds to the resource's generation, which is updated on mutation by the API Server. + format: int64 + type: integer + packageManagerReady: + description: PackageManagerReady indicates whether the PackageManager + child resource is ready. + type: boolean + version: + description: Version is the version of the product image being deployed. + type: string + workbenchReady: + description: WorkbenchReady indicates whether the Workbench child + resource is ready. + type: boolean type: object type: object served: true diff --git a/internal/crdapply/bases/core.posit.team_workbenches.yaml b/internal/crdapply/bases/core.posit.team_workbenches.yaml index d411d16d..a59f7f8e 100644 --- a/internal/crdapply/bases/core.posit.team_workbenches.yaml +++ b/internal/crdapply/bases/core.posit.team_workbenches.yaml @@ -17,7 +17,17 @@ spec: singular: workbench scope: Namespaced versions: - - name: v1beta1 + - additionalPrinterColumns: + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .status.version + name: Version + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1beta1 schema: openAPIV3Schema: description: Workbench is the Schema for the workbenches API @@ -7675,6 +7685,67 @@ spec: status: description: WorkbenchStatus defines the observed state of Workbench properties: + conditions: + description: Conditions represent the latest available observations + of the resource's current state. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + 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. + 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 + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map keySecretRef: description: |- SecretReference represents a Secret Reference. It has enough information to retrieve secret @@ -7690,10 +7761,17 @@ spec: type: string type: object x-kubernetes-map-type: atomic + observedGeneration: + description: |- + ObservedGeneration is the most recent generation observed for this resource. + It corresponds to the resource's generation, which is updated on mutation by the API Server. + format: int64 + type: integer ready: type: boolean - required: - - ready + version: + description: Version is the version of the product image being deployed. + type: string type: object type: object served: true diff --git a/internal/status/status.go b/internal/status/status.go new file mode 100644 index 00000000..54368d40 --- /dev/null +++ b/internal/status/status.go @@ -0,0 +1,172 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2023-2026 Posit Software, PBC + +package status + +import ( + "context" + "fmt" + "strings" + "unicode/utf8" + + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// Condition type constants +const ( + TypeReady = "Ready" + TypeProgressing = "Progressing" +) + +// Reason constants +const ( + ReasonReconciling = "Reconciling" + ReasonReconcileComplete = "ReconcileComplete" + ReasonReconcileError = "ReconcileError" + ReasonDeploymentReady = "DeploymentReady" + ReasonDeploymentNotReady = "DeploymentNotReady" + ReasonStatefulSetReady = "StatefulSetReady" + ReasonStatefulSetNotReady = "StatefulSetNotReady" + ReasonAllComponentsReady = "AllComponentsReady" + ReasonComponentsNotReady = "ComponentsNotReady" + ReasonDatabaseReady = "DatabaseReady" + ReasonSuspended = "Suspended" +) + +// SetReady sets the Ready condition on the given conditions slice. +func SetReady(conditions *[]metav1.Condition, generation int64, status metav1.ConditionStatus, reason, message string) { + apimeta.SetStatusCondition(conditions, metav1.Condition{ + Type: TypeReady, + Status: status, + ObservedGeneration: generation, + Reason: reason, + Message: message, + }) +} + +// SetProgressing sets the Progressing condition on the given conditions slice. +func SetProgressing(conditions *[]metav1.Condition, generation int64, status metav1.ConditionStatus, reason, message string) { + apimeta.SetStatusCondition(conditions, metav1.Condition{ + Type: TypeProgressing, + Status: status, + ObservedGeneration: generation, + Reason: reason, + Message: message, + }) +} + +// IsReady returns true if the Ready condition is True. +func IsReady(conditions []metav1.Condition) bool { + return apimeta.IsStatusConditionTrue(conditions, TypeReady) +} + +// IsSuspended returns true if the Ready condition is False with ReasonSuspended. +func IsSuspended(conditions []metav1.Condition) bool { + c := apimeta.FindStatusCondition(conditions, TypeReady) + return c != nil && c.Status == metav1.ConditionFalse && c.Reason == ReasonSuspended +} + +// DesiredReplicas returns the desired replica count from a replica pointer, +// defaulting to 1 when nil (matching Kubernetes Deployment/StatefulSet behavior). +func DesiredReplicas(replicas *int32) int32 { + if replicas != nil { + return *replicas + } + return 1 +} + +// ExtractVersion extracts a version string from a container image reference. +// For example, "ghcr.io/rstudio/rstudio-connect:2024.06.0" returns "2024.06.0". +// Also handles digest references: "image:2024.06.0@sha256:abc" returns "2024.06.0". +// Returns empty string if no tag is found. +func ExtractVersion(image string) string { + // Strip digest suffix if present (image:tag@sha256:...) + if idx := strings.LastIndex(image, "@"); idx != -1 { + image = image[:idx] + } + // Isolate the last path segment to avoid matching registry port colons + lastSlash := strings.LastIndex(image, "/") + nameTag := image + if lastSlash != -1 { + nameTag = image[lastSlash+1:] + } + if idx := strings.LastIndex(nameTag, ":"); idx != -1 { + tag := nameTag[idx+1:] + // Skip "latest" as it's not a useful version + if tag == "latest" { + return "" + } + return tag + } + return "" +} + +// SetDeploymentHealth sets Ready and Progressing conditions based on Deployment replica counts. +func SetDeploymentHealth(conditions *[]metav1.Condition, generation int64, readyReplicas, desiredReplicas int32) { + if readyReplicas >= desiredReplicas { + SetReady(conditions, generation, metav1.ConditionTrue, ReasonDeploymentReady, "Deployment has minimum availability") + SetProgressing(conditions, generation, metav1.ConditionFalse, ReasonReconcileComplete, "Reconciliation complete") + } else { + SetReady(conditions, generation, metav1.ConditionFalse, ReasonDeploymentNotReady, + fmt.Sprintf("Deployment has %d/%d ready replicas", readyReplicas, desiredReplicas)) + SetProgressing(conditions, generation, metav1.ConditionTrue, ReasonReconciling, "Deployment rollout in progress") + } +} + +// SetStatefulSetHealth sets Ready and Progressing conditions based on StatefulSet replica counts. +func SetStatefulSetHealth(conditions *[]metav1.Condition, generation int64, readyReplicas, desiredReplicas int32) { + if readyReplicas >= desiredReplicas { + SetReady(conditions, generation, metav1.ConditionTrue, ReasonStatefulSetReady, "StatefulSet has minimum availability") + SetProgressing(conditions, generation, metav1.ConditionFalse, ReasonReconcileComplete, "Reconciliation complete") + } else { + SetReady(conditions, generation, metav1.ConditionFalse, ReasonStatefulSetNotReady, + fmt.Sprintf("StatefulSet has %d/%d ready replicas", readyReplicas, desiredReplicas)) + SetProgressing(conditions, generation, metav1.ConditionTrue, ReasonReconciling, "StatefulSet rollout in progress") + } +} + +// PatchSuspendedStatus is a best-effort helper that sets ObservedGeneration, Ready +// and Progressing to False with ReasonSuspended, then patches the status subresource. +// It also sets the product-level ready bool to false and clears the version string +// via the provided pointers. If the status patch fails, the conditions will be set +// on the in-memory object but not persisted; the next reconcile will retry. +func PatchSuspendedStatus(ctx context.Context, statusWriter client.StatusWriter, obj client.Object, patchBase client.Patch, conditions *[]metav1.Condition, generation int64, observedGeneration *int64, ready *bool, version *string) error { + *observedGeneration = generation + SetReady(conditions, generation, metav1.ConditionFalse, ReasonSuspended, "Product is suspended") + SetProgressing(conditions, generation, metav1.ConditionFalse, ReasonSuspended, "Product is suspended") + *ready = false + *version = "" + return statusWriter.Patch(ctx, obj, patchBase) +} + +// maxConditionMessageLength is the maximum length for condition messages to avoid +// leaking verbose internal details (connection strings, hostnames, etc.) in status. +const maxConditionMessageLength = 256 + +// PatchErrorStatus is a best-effort helper that sets Ready and Progressing to False +// with ReasonReconcileError, then patches the status subresource. The caller should +// log the returned error but still return the original reconcile error. +// If the status patch itself fails (e.g., due to a conflict), the conditions will be +// set on the in-memory object but not persisted; the next reconcile will retry. +func PatchErrorStatus(ctx context.Context, statusWriter client.StatusWriter, obj client.Object, patchBase client.Patch, conditions *[]metav1.Condition, generation int64, reconcileErr error) error { + msg := TruncateMessage(reconcileErr.Error()) + SetReady(conditions, generation, metav1.ConditionFalse, ReasonReconcileError, msg) + SetProgressing(conditions, generation, metav1.ConditionFalse, ReasonReconcileError, msg) + return statusWriter.Patch(ctx, obj, patchBase) +} + +// TruncateMessage truncates a message to maxConditionMessageLength to avoid +// leaking verbose internal details in status conditions. +func TruncateMessage(msg string) string { + if len(msg) <= maxConditionMessageLength { + return msg + } + // Truncate at a rune boundary to avoid splitting multi-byte UTF-8 characters. + truncated := msg[:maxConditionMessageLength-3] + for len(truncated) > 0 && !utf8.ValidString(truncated) { + truncated = truncated[:len(truncated)-1] + } + return truncated + "..." +} diff --git a/internal/status/status_test.go b/internal/status/status_test.go new file mode 100644 index 00000000..f70edba7 --- /dev/null +++ b/internal/status/status_test.go @@ -0,0 +1,441 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2023-2026 Posit Software, PBC + +package status + +import ( + "context" + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func TestExtractVersion(t *testing.T) { + tests := []struct { + name string + image string + expected string + }{ + { + name: "image with tag", + image: "ghcr.io/rstudio/rstudio-connect:2024.06.0", + expected: "2024.06.0", + }, + { + name: "image with latest tag returns empty", + image: "ghcr.io/rstudio/rstudio-connect:latest", + expected: "", + }, + { + name: "image with digest only", + image: "ghcr.io/rstudio/rstudio-connect@sha256:abc123", + expected: "", + }, + { + name: "image with tag and digest", + image: "ghcr.io/rstudio/rstudio-connect:2024.06.0@sha256:abc123", + expected: "2024.06.0", + }, + { + name: "registry with port and tag", + image: "localhost:5000/myimage:v1.0", + expected: "v1.0", + }, + { + name: "registry with port no tag", + image: "localhost:5000/myimage", + expected: "", + }, + { + name: "no tag", + image: "ghcr.io/rstudio/rstudio-connect", + expected: "", + }, + { + name: "empty string", + image: "", + expected: "", + }, + { + name: "complex registry with port and tag", + image: "registry.example.com:443/organization/repo:v2.3.4", + expected: "v2.3.4", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ExtractVersion(tt.image) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestIsReady(t *testing.T) { + tests := []struct { + name string + conditions []metav1.Condition + expected bool + }{ + { + name: "Ready condition is True", + conditions: []metav1.Condition{ + {Type: TypeReady, Status: metav1.ConditionTrue}, + }, + expected: true, + }, + { + name: "Ready condition is False", + conditions: []metav1.Condition{ + {Type: TypeReady, Status: metav1.ConditionFalse}, + }, + expected: false, + }, + { + name: "Ready condition absent", + conditions: []metav1.Condition{}, + expected: false, + }, + { + name: "Multiple conditions, Ready is True", + conditions: []metav1.Condition{ + {Type: TypeProgressing, Status: metav1.ConditionTrue}, + {Type: TypeReady, Status: metav1.ConditionTrue}, + }, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := IsReady(tt.conditions) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestSetReady(t *testing.T) { + t.Run("adds Ready condition when absent", func(t *testing.T) { + conditions := []metav1.Condition{} + SetReady(&conditions, 1, metav1.ConditionTrue, ReasonReconcileComplete, "All good") + + assert.Len(t, conditions, 1) + assert.Equal(t, TypeReady, conditions[0].Type) + assert.Equal(t, metav1.ConditionTrue, conditions[0].Status) + assert.Equal(t, ReasonReconcileComplete, conditions[0].Reason) + assert.Equal(t, "All good", conditions[0].Message) + assert.Equal(t, int64(1), conditions[0].ObservedGeneration) + }) + + t.Run("updates Ready condition when present", func(t *testing.T) { + conditions := []metav1.Condition{ + {Type: TypeReady, Status: metav1.ConditionFalse, Reason: "OldReason", Message: "Old message"}, + } + SetReady(&conditions, 2, metav1.ConditionTrue, ReasonReconcileComplete, "Updated message") + + assert.Len(t, conditions, 1) + assert.Equal(t, TypeReady, conditions[0].Type) + assert.Equal(t, metav1.ConditionTrue, conditions[0].Status) + assert.Equal(t, ReasonReconcileComplete, conditions[0].Reason) + assert.Equal(t, "Updated message", conditions[0].Message) + assert.Equal(t, int64(2), conditions[0].ObservedGeneration) + }) +} + +func TestSetProgressing(t *testing.T) { + t.Run("adds Progressing condition when absent", func(t *testing.T) { + conditions := []metav1.Condition{} + SetProgressing(&conditions, 1, metav1.ConditionTrue, ReasonReconciling, "In progress") + + assert.Len(t, conditions, 1) + assert.Equal(t, TypeProgressing, conditions[0].Type) + assert.Equal(t, metav1.ConditionTrue, conditions[0].Status) + assert.Equal(t, ReasonReconciling, conditions[0].Reason) + assert.Equal(t, "In progress", conditions[0].Message) + assert.Equal(t, int64(1), conditions[0].ObservedGeneration) + }) + + t.Run("updates Progressing condition when present", func(t *testing.T) { + conditions := []metav1.Condition{ + {Type: TypeProgressing, Status: metav1.ConditionTrue, Reason: ReasonReconciling, Message: "Old"}, + } + SetProgressing(&conditions, 2, metav1.ConditionFalse, ReasonReconcileComplete, "Done") + + assert.Len(t, conditions, 1) + assert.Equal(t, TypeProgressing, conditions[0].Type) + assert.Equal(t, metav1.ConditionFalse, conditions[0].Status) + assert.Equal(t, ReasonReconcileComplete, conditions[0].Reason) + assert.Equal(t, "Done", conditions[0].Message) + assert.Equal(t, int64(2), conditions[0].ObservedGeneration) + }) + + t.Run("preserves other conditions", func(t *testing.T) { + conditions := []metav1.Condition{ + {Type: TypeReady, Status: metav1.ConditionTrue}, + } + SetProgressing(&conditions, 1, metav1.ConditionTrue, ReasonReconciling, "In progress") + + assert.Len(t, conditions, 2) + // Verify both conditions exist + ready := false + progressing := false + for _, c := range conditions { + if c.Type == TypeReady { + ready = true + } + if c.Type == TypeProgressing { + progressing = true + } + } + assert.True(t, ready, "Ready condition should still exist") + assert.True(t, progressing, "Progressing condition should be added") + }) +} + +func TestSetDeploymentHealth(t *testing.T) { + t.Run("ready when replicas meet desired", func(t *testing.T) { + conditions := []metav1.Condition{} + SetDeploymentHealth(&conditions, 3, 2, 2) + + readyCond := findCondition(conditions, TypeReady) + require.NotNil(t, readyCond, "expected Ready condition to be set") + assert.Equal(t, metav1.ConditionTrue, readyCond.Status) + assert.Equal(t, ReasonDeploymentReady, readyCond.Reason) + + progCond := findCondition(conditions, TypeProgressing) + require.NotNil(t, progCond, "expected Progressing condition to be set") + assert.Equal(t, metav1.ConditionFalse, progCond.Status) + assert.Equal(t, ReasonReconcileComplete, progCond.Reason) + }) + + t.Run("not ready when replicas below desired", func(t *testing.T) { + conditions := []metav1.Condition{} + SetDeploymentHealth(&conditions, 3, 1, 3) + + readyCond := findCondition(conditions, TypeReady) + require.NotNil(t, readyCond, "expected Ready condition to be set") + assert.Equal(t, metav1.ConditionFalse, readyCond.Status) + assert.Equal(t, ReasonDeploymentNotReady, readyCond.Reason) + assert.Contains(t, readyCond.Message, "1/3") + + progCond := findCondition(conditions, TypeProgressing) + require.NotNil(t, progCond, "expected Progressing condition to be set") + assert.Equal(t, metav1.ConditionTrue, progCond.Status) + assert.Equal(t, ReasonReconciling, progCond.Reason) + }) +} + +func TestSetStatefulSetHealth(t *testing.T) { + t.Run("ready when replicas meet desired", func(t *testing.T) { + conditions := []metav1.Condition{} + SetStatefulSetHealth(&conditions, 5, 3, 3) + + readyCond := findCondition(conditions, TypeReady) + require.NotNil(t, readyCond, "expected Ready condition to be set") + assert.Equal(t, metav1.ConditionTrue, readyCond.Status) + assert.Equal(t, ReasonStatefulSetReady, readyCond.Reason) + + progCond := findCondition(conditions, TypeProgressing) + require.NotNil(t, progCond, "expected Progressing condition to be set") + assert.Equal(t, metav1.ConditionFalse, progCond.Status) + assert.Equal(t, ReasonReconcileComplete, progCond.Reason) + }) + + t.Run("not ready when replicas below desired", func(t *testing.T) { + conditions := []metav1.Condition{} + SetStatefulSetHealth(&conditions, 5, 0, 1) + + readyCond := findCondition(conditions, TypeReady) + require.NotNil(t, readyCond, "expected Ready condition to be set") + assert.Equal(t, metav1.ConditionFalse, readyCond.Status) + assert.Equal(t, ReasonStatefulSetNotReady, readyCond.Reason) + assert.Contains(t, readyCond.Message, "0/1") + + progCond := findCondition(conditions, TypeProgressing) + require.NotNil(t, progCond, "expected Progressing condition to be set") + assert.Equal(t, metav1.ConditionTrue, progCond.Status) + assert.Equal(t, ReasonReconciling, progCond.Reason) + }) +} + +type fakeStatusWriter struct { + patchCalled bool +} + +func (f *fakeStatusWriter) Create(_ context.Context, _ client.Object, _ client.Object, _ ...client.SubResourceCreateOption) error { + return nil +} +func (f *fakeStatusWriter) Update(_ context.Context, _ client.Object, _ ...client.SubResourceUpdateOption) error { + return nil +} +func (f *fakeStatusWriter) Patch(_ context.Context, _ client.Object, _ client.Patch, _ ...client.SubResourcePatchOption) error { + f.patchCalled = true + return nil +} + +func TestPatchSuspendedStatus(t *testing.T) { + t.Run("clears version and sets ready to false", func(t *testing.T) { + conditions := []metav1.Condition{} + var observedGen int64 + ready := true + version := "2024.06.0" + + sw := &fakeStatusWriter{} + err := PatchSuspendedStatus( + context.Background(), + sw, + &metav1.PartialObjectMetadata{ObjectMeta: metav1.ObjectMeta{Name: "test", UID: types.UID("test-uid")}}, + client.MergeFrom(&metav1.PartialObjectMetadata{ObjectMeta: metav1.ObjectMeta{Name: "test", UID: types.UID("test-uid")}}), + &conditions, 3, &observedGen, &ready, &version, + ) + + require.NoError(t, err) + assert.True(t, sw.patchCalled, "expected status Patch to be called") + assert.Equal(t, int64(3), observedGen) + assert.False(t, ready) + assert.Empty(t, version) + + readyCond := findCondition(conditions, TypeReady) + require.NotNil(t, readyCond, "expected Ready condition to be set") + assert.Equal(t, metav1.ConditionFalse, readyCond.Status) + assert.Equal(t, ReasonSuspended, readyCond.Reason) + + progCond := findCondition(conditions, TypeProgressing) + require.NotNil(t, progCond, "expected Progressing condition to be set") + assert.Equal(t, metav1.ConditionFalse, progCond.Status) + assert.Equal(t, ReasonSuspended, progCond.Reason) + }) +} + +func TestIsSuspended(t *testing.T) { + tests := []struct { + name string + conditions []metav1.Condition + expected bool + }{ + { + name: "Ready condition with Suspended reason", + conditions: []metav1.Condition{ + {Type: TypeReady, Status: metav1.ConditionFalse, Reason: ReasonSuspended}, + }, + expected: true, + }, + { + name: "Ready condition with different reason", + conditions: []metav1.Condition{ + {Type: TypeReady, Status: metav1.ConditionFalse, Reason: ReasonReconcileError}, + }, + expected: false, + }, + { + name: "No conditions", + conditions: []metav1.Condition{}, + expected: false, + }, + { + name: "Ready condition True is not suspended", + conditions: []metav1.Condition{ + {Type: TypeReady, Status: metav1.ConditionTrue, Reason: ReasonDeploymentReady}, + }, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := IsSuspended(tt.conditions) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestPatchErrorStatus_TruncatesLongMessages(t *testing.T) { + t.Run("short message is preserved", func(t *testing.T) { + conditions := []metav1.Condition{} + sw := &fakeStatusWriter{} + shortErr := fmt.Errorf("short error") + + err := PatchErrorStatus( + context.Background(), + sw, + &metav1.PartialObjectMetadata{ObjectMeta: metav1.ObjectMeta{Name: "test", UID: types.UID("test-uid")}}, + client.MergeFrom(&metav1.PartialObjectMetadata{ObjectMeta: metav1.ObjectMeta{Name: "test", UID: types.UID("test-uid")}}), + &conditions, 1, shortErr, + ) + + require.NoError(t, err) + readyCond := findCondition(conditions, TypeReady) + require.NotNil(t, readyCond) + assert.Equal(t, "short error", readyCond.Message) + }) + + t.Run("long message is truncated", func(t *testing.T) { + conditions := []metav1.Condition{} + sw := &fakeStatusWriter{} + longMsg := strings.Repeat("x", 300) + longErr := fmt.Errorf("%s", longMsg) + + err := PatchErrorStatus( + context.Background(), + sw, + &metav1.PartialObjectMetadata{ObjectMeta: metav1.ObjectMeta{Name: "test", UID: types.UID("test-uid")}}, + client.MergeFrom(&metav1.PartialObjectMetadata{ObjectMeta: metav1.ObjectMeta{Name: "test", UID: types.UID("test-uid")}}), + &conditions, 1, longErr, + ) + + require.NoError(t, err) + readyCond := findCondition(conditions, TypeReady) + require.NotNil(t, readyCond) + assert.Len(t, readyCond.Message, maxConditionMessageLength) + assert.True(t, strings.HasSuffix(readyCond.Message, "...")) + }) +} + +func TestTruncateMessage(t *testing.T) { + t.Run("short message unchanged", func(t *testing.T) { + assert.Equal(t, "hello", TruncateMessage("hello")) + }) + + t.Run("exactly at limit unchanged", func(t *testing.T) { + msg := strings.Repeat("a", maxConditionMessageLength) + assert.Equal(t, msg, TruncateMessage(msg)) + }) + + t.Run("long ASCII message truncated", func(t *testing.T) { + msg := strings.Repeat("x", 300) + result := TruncateMessage(msg) + assert.Len(t, result, maxConditionMessageLength) + assert.True(t, strings.HasSuffix(result, "...")) + }) + + t.Run("multi-byte UTF-8 at boundary is not split", func(t *testing.T) { + // Each '日' is 3 bytes. Fill up to near the limit with multi-byte chars + // so that a naive byte-slice would split a rune. + prefix := strings.Repeat("a", maxConditionMessageLength-5) // 251 ASCII bytes + // Add two 3-byte runes (6 bytes total) → 257 bytes, over limit + msg := prefix + "日日" + result := TruncateMessage(msg) + assert.True(t, len(result) <= maxConditionMessageLength) + assert.True(t, strings.HasSuffix(result, "...")) + // Verify the result is valid UTF-8 (no split runes) + assert.True(t, len(result) > 0) + for _, r := range result { + assert.NotEqual(t, rune(65533), r, "should not contain replacement character") + } + }) +} + +func findCondition(conditions []metav1.Condition, condType string) *metav1.Condition { + for i := range conditions { + if conditions[i].Type == condType { + return &conditions[i] + } + } + return nil +}