diff --git a/PROJECT b/PROJECT index 02b76c41d..d59c79a41 100644 --- a/PROJECT +++ b/PROJECT @@ -38,4 +38,13 @@ resources: kind: OperatorConfig path: github.com/centos-automotive-suite/automotive-dev-operator/api/v1alpha1 version: v1alpha1 +- api: + crdVersion: v1 + namespaced: true + controller: true + domain: sdv.cloud.redhat.com + group: automotive + kind: ScheduledImageBuild + path: github.com/centos-automotive-suite/automotive-dev-operator/api/v1alpha1 + version: v1alpha1 version: "3" diff --git a/api/v1alpha1/catalogimage_types.go b/api/v1alpha1/catalogimage_types.go index 898ed7f48..ac686e2b9 100644 --- a/api/v1alpha1/catalogimage_types.go +++ b/api/v1alpha1/catalogimage_types.go @@ -172,6 +172,11 @@ type CatalogImageStatus struct { // +optional PublishedAt *metav1.Time `json:"publishedAt,omitempty"` + // VerificationFailures counts consecutive verification failures. + // After the max is reached the phase transitions to Failed. + // +optional + VerificationFailures int32 `json:"verificationFailures,omitempty"` + // SourceImageBuild references the ImageBuild that created this catalog entry // +optional SourceImageBuild string `json:"sourceImageBuild,omitempty"` diff --git a/api/v1alpha1/imagebuild_types.go b/api/v1alpha1/imagebuild_types.go index 8a245fdd8..b119cd835 100644 --- a/api/v1alpha1/imagebuild_types.go +++ b/api/v1alpha1/imagebuild_types.go @@ -48,6 +48,8 @@ func IsTerminalBuildPhase(phase string) bool { // ImageBuildSpec defines the desired state of ImageBuild // +kubebuilder:printcolumn:name="StorageClass",type=string,JSONPath=`.spec.storageClass` // +kubebuilder:validation:XValidation:rule="!has(self.reproducible) || !self.reproducible || self.secureBuild",message="reproducible builds require secureBuild to be true" +// +kubebuilder:validation:XValidation:rule="!(has(self.export) && has(self.export.disk) && has(self.export.disk.oci) && size(self.export.disk.oci) > 0) || size(self.secretRef) > 0 || (has(self.export) && has(self.export.useServiceAccountAuth) && self.export.useServiceAccountAuth)",message="secretRef is required when export.disk.oci is set (unless useServiceAccountAuth is true)" +// +kubebuilder:validation:XValidation:rule="!(has(self.export) && has(self.export.container) && size(self.export.container) > 0) || size(self.secretRef) > 0 || (has(self.export) && has(self.export.useServiceAccountAuth) && self.export.useServiceAccountAuth)",message="secretRef is required when export.container is set (unless useServiceAccountAuth is true)" type ImageBuildSpec struct { // ─── Common fields ─── @@ -218,8 +220,9 @@ type AIBSpec struct { // ExportSpec defines the configuration for exporting build artifacts type ExportSpec struct { - // Format specifies the disk image output format (e.g., raw, qcow2, simg, or any AIB-supported format) - // +kubebuilder:default=qcow2 + // Format specifies the disk image output format (e.g., raw, qcow2, simg, or any AIB-supported format). + // When omitted, the controller resolves the format from the aib-target-defaults ConfigMap, + // falling back to qcow2 if no target default is configured. Format string `json:"format,omitempty"` // Compression specifies the compression algorithm for artifacts @@ -313,6 +316,12 @@ type ImageBuildStatus struct { // Used to determine whether an expired build originally succeeded or failed. // +optional PreviousPhase string `json:"previousPhase,omitempty"` + + // ResolvedExportFormat is the export format resolved at build creation time. + // Persisted so the push task uses the same format even if the + // aib-target-defaults ConfigMap changes between build and push. + // +optional + ResolvedExportFormat string `json:"resolvedExportFormat,omitempty"` } // +kubebuilder:object:root=true diff --git a/api/v1alpha1/labels.go b/api/v1alpha1/labels.go index 905514695..ff90f3422 100644 --- a/api/v1alpha1/labels.go +++ b/api/v1alpha1/labels.go @@ -3,12 +3,13 @@ package v1alpha1 // Observability label and annotation keys. // LabelDistro, LabelTarget, LabelArchitecture are defined in catalogimage_types.go. const ( - LabelBuildMode = "automotive.sdv.cloud.redhat.com/build-mode" - LabelTraceID = "automotive.sdv.cloud.redhat.com/trace-id" - LabelImageBuildName = "automotive.sdv.cloud.redhat.com/imagebuild-name" - LabelTaskType = "automotive.sdv.cloud.redhat.com/task-type" - LabelWorkspaceName = "automotive.sdv.cloud.redhat.com/workspace-name" - LabelOwner = "automotive.sdv.cloud.redhat.com/owner" + LabelBuildMode = "automotive.sdv.cloud.redhat.com/build-mode" + LabelTraceID = "automotive.sdv.cloud.redhat.com/trace-id" + LabelImageBuildName = "automotive.sdv.cloud.redhat.com/imagebuild-name" + LabelTaskType = "automotive.sdv.cloud.redhat.com/task-type" + LabelWorkspaceName = "automotive.sdv.cloud.redhat.com/workspace-name" + LabelScheduledImageBuildName = "automotive.sdv.cloud.redhat.com/scheduledimagebuild-name" + LabelOwner = "automotive.sdv.cloud.redhat.com/owner" AnnotationTraceID = "automotive.sdv.cloud.redhat.com/trace-id" AnnotationRequestedBy = "automotive.sdv.cloud.redhat.com/requested-by" diff --git a/api/v1alpha1/scheduledimagebuild_types.go b/api/v1alpha1/scheduledimagebuild_types.go new file mode 100644 index 000000000..cd3f421af --- /dev/null +++ b/api/v1alpha1/scheduledimagebuild_types.go @@ -0,0 +1,220 @@ +/* +Copyright 2025. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1alpha1 + +import ( + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// ConcurrencyPolicy describes how the schedule treats overlapping builds. +// +kubebuilder:validation:Enum=Allow;Forbid;Replace +type ConcurrencyPolicy string + +// ConcurrencyPolicy values. +const ( + AllowConcurrent ConcurrencyPolicy = "Allow" + ForbidConcurrent ConcurrencyPolicy = "Forbid" + ReplaceConcurrent ConcurrencyPolicy = "Replace" +) + +// ScheduledImageBuildSpec defines the desired state of ScheduledImageBuild +// +kubebuilder:validation:XValidation:rule="!has(self.matrix) || !has(self.matrix.distros) || size(self.matrix.distros) == 0 || has(self.imageBuildTemplate.spec.aib)",message="matrix distros requires aib in imageBuildTemplate" +// +kubebuilder:validation:XValidation:rule="!has(self.matrix) || !has(self.matrix.targets) || size(self.matrix.targets) == 0 || has(self.imageBuildTemplate.spec.aib)",message="matrix targets requires aib in imageBuildTemplate" +type ScheduledImageBuildSpec struct { + // Schedule is a cron expression defining when builds should run (5-field standard format). + // Examples: "0 2 * * *" (daily at 2am), "0 */6 * * *" (every 6 hours) + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=9 + // +kubebuilder:validation:Pattern=`^([-0-9*/,]+\s+){4}[-0-9*/,]+$` + Schedule string `json:"schedule"` + + // Suspend tells the controller to suspend subsequent executions. + // Existing running builds will not be affected. + // +optional + Suspend *bool `json:"suspend,omitempty"` + + // ConcurrencyPolicy specifies how to treat concurrent builds. + // +kubebuilder:default=Forbid + // +optional + ConcurrencyPolicy ConcurrencyPolicy `json:"concurrencyPolicy,omitempty"` + + // StartingDeadlineSeconds is the deadline in seconds for starting a build + // if it misses its scheduled time. Missed builds beyond this window are skipped. + // +kubebuilder:validation:Minimum=0 + // +optional + StartingDeadlineSeconds *int64 `json:"startingDeadlineSeconds,omitempty"` + + // SuccessfulBuildsHistoryLimit is the number of successful finished builds to retain. + // +kubebuilder:default=3 + // +kubebuilder:validation:Minimum=0 + // +optional + SuccessfulBuildsHistoryLimit *int32 `json:"successfulBuildsHistoryLimit,omitempty"` + + // FailedBuildsHistoryLimit is the number of failed finished builds to retain. + // +kubebuilder:default=1 + // +kubebuilder:validation:Minimum=0 + // +optional + FailedBuildsHistoryLimit *int32 `json:"failedBuildsHistoryLimit,omitempty"` + + // ImageBuildTemplate is the template for creating ImageBuild CRs. + // +kubebuilder:validation:Required + ImageBuildTemplate ImageBuildTemplateSpec `json:"imageBuildTemplate"` + + // Matrix defines a build matrix that creates multiple ImageBuilds per schedule tick. + // Each tick creates one ImageBuild for each combination of the specified dimensions, + // overriding the corresponding fields in the imageBuildTemplate. + // +optional + Matrix *BuildMatrix `json:"matrix,omitempty"` + + // PublishToCatalog configures automatic publishing of completed builds to the catalog. + // +optional + PublishToCatalog *PublishToCatalogSpec `json:"publishToCatalog,omitempty"` +} + +// ImageBuildTemplateSpec describes the ImageBuild that will be created on each schedule tick. +type ImageBuildTemplateSpec struct { + // Metadata contains labels and annotations to apply to created ImageBuilds. + // +optional + Metadata ScheduledBuildMetadata `json:"metadata,omitempty"` + + // Spec is the ImageBuildSpec used as the template for child ImageBuilds. + // +kubebuilder:validation:Required + Spec ImageBuildSpec `json:"spec"` +} + +// ScheduledBuildMetadata contains metadata to apply to child ImageBuilds. +type ScheduledBuildMetadata struct { + // Labels to set on created ImageBuilds. + // +optional + Labels map[string]string `json:"labels,omitempty"` + + // Annotations to set on created ImageBuilds. + // +optional + Annotations map[string]string `json:"annotations,omitempty"` +} + +// BuildMatrix defines multiple configurations to build on each schedule tick. +// Each dimension list overrides the corresponding scalar field in the template spec. +// The cross-product of all dimensions determines how many ImageBuilds are created per tick. +type BuildMatrix struct { + // Architectures lists target architectures to build for. + // Each value overrides imageBuildTemplate.spec.architecture. + // +optional + // +kubebuilder:validation:MaxItems=4 + Architectures []string `json:"architectures,omitempty"` + + // Distros lists distributions to build for. + // Each value overrides imageBuildTemplate.spec.aib.distro. + // +optional + // +kubebuilder:validation:MaxItems=4 + Distros []string `json:"distros,omitempty"` + + // Targets lists hardware targets to build for. + // Each value overrides imageBuildTemplate.spec.aib.target. + // +optional + // +kubebuilder:validation:MaxItems=4 + Targets []string `json:"targets,omitempty"` +} + +// PublishToCatalogSpec configures automatic catalog publishing for completed builds. +type PublishToCatalogSpec struct { + // Enabled controls whether completed builds are automatically published to the catalog. + Enabled bool `json:"enabled"` + + // Tags are category tags to apply to the CatalogImage. + // +optional + Tags []string `json:"tags,omitempty"` + + // AuthSecretRef references a secret containing registry credentials + // for verifying the published image. + // +optional + AuthSecretRef *AuthSecretReference `json:"authSecretRef,omitempty"` +} + +// ScheduledImageBuildPhase represents the current state of the schedule. +// +kubebuilder:validation:Enum=Active;Suspended +type ScheduledImageBuildPhase string + +// ScheduledImageBuildPhase values. +const ( + ScheduledImageBuildPhaseActive ScheduledImageBuildPhase = "Active" + ScheduledImageBuildPhaseSuspended ScheduledImageBuildPhase = "Suspended" +) + +// ScheduledImageBuildStatus defines the observed state of ScheduledImageBuild +type ScheduledImageBuildStatus struct { + // ObservedGeneration is the most recent generation observed by the controller. + // +optional + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + + // Phase represents the current state of the schedule. + // +optional + Phase ScheduledImageBuildPhase `json:"phase,omitempty"` + + // LastScheduleTime is when the last build was created. + // +optional + LastScheduleTime *metav1.Time `json:"lastScheduleTime,omitempty"` + + // LastSuccessfulTime is when the last build completed successfully. + // +optional + LastSuccessfulTime *metav1.Time `json:"lastSuccessfulTime,omitempty"` + + // LastFailedTime is when the last build failed. + // +optional + LastFailedTime *metav1.Time `json:"lastFailedTime,omitempty"` + + // Active is a list of currently running ImageBuild references. + // +optional + Active []corev1.ObjectReference `json:"active,omitempty"` + + // Conditions represent the latest available observations. + // +optional + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:printcolumn:name="Schedule",type=string,JSONPath=`.spec.schedule`,priority=0 +// +kubebuilder:printcolumn:name="Suspend",type=boolean,JSONPath=`.spec.suspend`,priority=0 +// +kubebuilder:printcolumn:name="Last Schedule",type=date,JSONPath=`.status.lastScheduleTime`,priority=0 +// +kubebuilder:printcolumn:name="Last Success",type=date,JSONPath=`.status.lastSuccessfulTime`,priority=0 +// +kubebuilder:printcolumn:name="Last Failure",type=date,JSONPath=`.status.lastFailedTime`,priority=0 +// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp`,priority=0 + +// ScheduledImageBuild defines a cron schedule for creating ImageBuild CRs +// with optional automatic publishing to the catalog. +type ScheduledImageBuild struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec ScheduledImageBuildSpec `json:"spec,omitempty"` + Status ScheduledImageBuildStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// ScheduledImageBuildList contains a list of ScheduledImageBuild +type ScheduledImageBuildList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []ScheduledImageBuild `json:"items"` +} + +func init() { + SchemeBuilder.Register(&ScheduledImageBuild{}, &ScheduledImageBuildList{}) +} diff --git a/api/v1alpha1/scheduledimagebuild_types_test.go b/api/v1alpha1/scheduledimagebuild_types_test.go new file mode 100644 index 000000000..1a38de1ed --- /dev/null +++ b/api/v1alpha1/scheduledimagebuild_types_test.go @@ -0,0 +1,44 @@ +package v1alpha1 + +import ( + "regexp" + "testing" +) + +// cronPattern mirrors the Pattern marker on ScheduledImageBuildSpec.Schedule. +var cronPattern = regexp.MustCompile(`^([-0-9*/,]+\s+){4}[-0-9*/,]+$`) + +func TestScheduleCronPattern(t *testing.T) { + tests := []struct { + name string + input string + isValid bool + }{ + // valid expressions + {name: "daily at 2am", input: "0 2 * * *", isValid: true}, + {name: "every 6 hours", input: "0 */6 * * *", isValid: true}, + {name: "weekdays at midnight", input: "0 0 * * 1-5", isValid: true}, + {name: "every 15 minutes", input: "*/15 * * * *", isValid: true}, + {name: "specific day and time", input: "30 4 1,15 * *", isValid: true}, + {name: "complex range", input: "0 0-6/2 * * 0,6", isValid: true}, + {name: "all wildcards", input: "* * * * *", isValid: true}, + // invalid expressions + {name: "text input", input: "every tuesday", isValid: false}, + {name: "only 3 fields", input: "* * *", isValid: false}, + {name: "only 4 fields", input: "0 2 * *", isValid: false}, + {name: "6 fields", input: "0 2 * * * *", isValid: false}, + {name: "empty string", input: "", isValid: false}, + {name: "letters mixed", input: "0 2 * jan *", isValid: false}, + {name: "at-syntax", input: "@daily", isValid: false}, + {name: "natural language", input: "run at 2am", isValid: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := cronPattern.MatchString(tt.input) + if got != tt.isValid { + t.Errorf("cronPattern.MatchString(%q) = %v, want %v", tt.input, got, tt.isValid) + } + }) + } +} diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 071b62c5d..1aa66c5e3 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -189,6 +189,36 @@ func (in *BuildCertificatesConfig) DeepCopy() *BuildCertificatesConfig { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BuildMatrix) DeepCopyInto(out *BuildMatrix) { + *out = *in + if in.Architectures != nil { + in, out := &in.Architectures, &out.Architectures + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Distros != nil { + in, out := &in.Distros, &out.Distros + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Targets != nil { + in, out := &in.Targets, &out.Targets + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BuildMatrix. +func (in *BuildMatrix) DeepCopy() *BuildMatrix { + if in == nil { + return nil + } + out := new(BuildMatrix) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *CatalogImage) DeepCopyInto(out *CatalogImage) { *out = *in @@ -713,6 +743,23 @@ func (in *ImageBuildStatus) DeepCopy() *ImageBuildStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ImageBuildTemplateSpec) DeepCopyInto(out *ImageBuildTemplateSpec) { + *out = *in + in.Metadata.DeepCopyInto(&out.Metadata) + in.Spec.DeepCopyInto(&out.Spec) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ImageBuildTemplateSpec. +func (in *ImageBuildTemplateSpec) DeepCopy() *ImageBuildTemplateSpec { + if in == nil { + return nil + } + out := new(ImageBuildTemplateSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ImageList) DeepCopyInto(out *ImageList) { *out = *in @@ -1352,6 +1399,31 @@ func (in *PlatformVariant) DeepCopy() *PlatformVariant { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PublishToCatalogSpec) DeepCopyInto(out *PublishToCatalogSpec) { + *out = *in + if in.Tags != nil { + in, out := &in.Tags, &out.Tags + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.AuthSecretRef != nil { + in, out := &in.AuthSecretRef, &out.AuthSecretRef + *out = new(AuthSecretReference) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PublishToCatalogSpec. +func (in *PublishToCatalogSpec) DeepCopy() *PublishToCatalogSpec { + if in == nil { + return nil + } + out := new(PublishToCatalogSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *RegistryLocation) DeepCopyInto(out *RegistryLocation) { *out = *in @@ -1396,6 +1468,179 @@ func (in *RegistryMetadata) DeepCopy() *RegistryMetadata { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ScheduledBuildMetadata) DeepCopyInto(out *ScheduledBuildMetadata) { + *out = *in + if in.Labels != nil { + in, out := &in.Labels, &out.Labels + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.Annotations != nil { + in, out := &in.Annotations, &out.Annotations + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ScheduledBuildMetadata. +func (in *ScheduledBuildMetadata) DeepCopy() *ScheduledBuildMetadata { + if in == nil { + return nil + } + out := new(ScheduledBuildMetadata) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ScheduledImageBuild) DeepCopyInto(out *ScheduledImageBuild) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ScheduledImageBuild. +func (in *ScheduledImageBuild) DeepCopy() *ScheduledImageBuild { + if in == nil { + return nil + } + out := new(ScheduledImageBuild) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ScheduledImageBuild) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ScheduledImageBuildList) DeepCopyInto(out *ScheduledImageBuildList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]ScheduledImageBuild, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ScheduledImageBuildList. +func (in *ScheduledImageBuildList) DeepCopy() *ScheduledImageBuildList { + if in == nil { + return nil + } + out := new(ScheduledImageBuildList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ScheduledImageBuildList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ScheduledImageBuildSpec) DeepCopyInto(out *ScheduledImageBuildSpec) { + *out = *in + if in.Suspend != nil { + in, out := &in.Suspend, &out.Suspend + *out = new(bool) + **out = **in + } + if in.StartingDeadlineSeconds != nil { + in, out := &in.StartingDeadlineSeconds, &out.StartingDeadlineSeconds + *out = new(int64) + **out = **in + } + if in.SuccessfulBuildsHistoryLimit != nil { + in, out := &in.SuccessfulBuildsHistoryLimit, &out.SuccessfulBuildsHistoryLimit + *out = new(int32) + **out = **in + } + if in.FailedBuildsHistoryLimit != nil { + in, out := &in.FailedBuildsHistoryLimit, &out.FailedBuildsHistoryLimit + *out = new(int32) + **out = **in + } + in.ImageBuildTemplate.DeepCopyInto(&out.ImageBuildTemplate) + if in.Matrix != nil { + in, out := &in.Matrix, &out.Matrix + *out = new(BuildMatrix) + (*in).DeepCopyInto(*out) + } + if in.PublishToCatalog != nil { + in, out := &in.PublishToCatalog, &out.PublishToCatalog + *out = new(PublishToCatalogSpec) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ScheduledImageBuildSpec. +func (in *ScheduledImageBuildSpec) DeepCopy() *ScheduledImageBuildSpec { + if in == nil { + return nil + } + out := new(ScheduledImageBuildSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ScheduledImageBuildStatus) DeepCopyInto(out *ScheduledImageBuildStatus) { + *out = *in + if in.LastScheduleTime != nil { + in, out := &in.LastScheduleTime, &out.LastScheduleTime + *out = (*in).DeepCopy() + } + if in.LastSuccessfulTime != nil { + in, out := &in.LastSuccessfulTime, &out.LastSuccessfulTime + *out = (*in).DeepCopy() + } + if in.LastFailedTime != nil { + in, out := &in.LastFailedTime, &out.LastFailedTime + *out = (*in).DeepCopy() + } + if in.Active != nil { + in, out := &in.Active, &out.Active + *out = make([]corev1.ObjectReference, len(*in)) + copy(*out, *in) + } + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ScheduledImageBuildStatus. +func (in *ScheduledImageBuildStatus) DeepCopy() *ScheduledImageBuildStatus { + if in == nil { + return nil + } + out := new(ScheduledImageBuildStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *SecretKeySelector) DeepCopyInto(out *SecretKeySelector) { *out = *in diff --git a/cmd/caib/catalog/get.go b/cmd/caib/catalog/get.go index 8e93c21dc..dad67686d 100644 --- a/cmd/caib/catalog/get.go +++ b/cmd/caib/catalog/get.go @@ -156,6 +156,13 @@ func printImageDetails(img CatalogImageResponse) { {"Targets", target}, {"Created At", img.CreatedAt}, } + if img.StatusReason != "" { + reason := img.StatusReason + if img.StatusMessage != "" { + reason += ": " + img.StatusMessage + } + rows = append(rows, [2]string{"Status Reason", reason}) + } if img.SizeBytes > 0 { rows = append(rows, [2]string{"Size", fmt.Sprintf("%d bytes", img.SizeBytes)}) } diff --git a/cmd/caib/catalog/list.go b/cmd/caib/catalog/list.go index 47f650e21..440b59ec1 100644 --- a/cmd/caib/catalog/list.go +++ b/cmd/caib/catalog/list.go @@ -74,15 +74,23 @@ type CatalogImageListResponse struct { // //nolint:revive // Name intentionally includes package name for clarity in CLI context type CatalogImageResponse struct { - Name string `json:"name"` - Namespace string `json:"namespace"` - RegistryURL string `json:"registryUrl"` - Phase string `json:"phase"` - Architecture string `json:"architecture,omitempty"` - Distro string `json:"distro,omitempty"` - Targets []Target `json:"targets,omitempty"` - SizeBytes int64 `json:"sizeBytes,omitempty"` - CreatedAt string `json:"createdAt"` + Name string `json:"name"` + Namespace string `json:"namespace"` + RegistryURL string `json:"registryUrl"` + Phase string `json:"phase"` + Architecture string `json:"architecture,omitempty"` + Distro string `json:"distro,omitempty"` + Targets []Target `json:"targets,omitempty"` + Tags []string `json:"tags,omitempty"` + SourceType string `json:"sourceType,omitempty"` + SourceImageBuild string `json:"sourceImageBuild,omitempty"` + BuildMode string `json:"buildMode,omitempty"` + ExportFormat string `json:"exportFormat,omitempty"` + Labels map[string]string `json:"labels,omitempty"` + SizeBytes int64 `json:"sizeBytes,omitempty"` + CreatedAt string `json:"createdAt"` + StatusReason string `json:"statusReason,omitempty"` + StatusMessage string `json:"statusMessage,omitempty"` } // Target mirrors target info from API @@ -202,7 +210,7 @@ func printTable(items []CatalogImageResponse) { } }() - if _, err := fmt.Fprintln(w, "NAME\tREGISTRY\tARCHITECTURE\tDISTRO\tTARGET\tPHASE\tAGE"); err != nil { + if _, err := fmt.Fprintln(w, "NAME\tSOURCE\tARCH\tDISTRO\tTARGET\tFORMAT\tTAGS\tPHASE\tIMAGE\tCREATED"); err != nil { fmt.Fprintf(os.Stderr, "Warning: failed to write header: %v\n", err) return } @@ -213,19 +221,18 @@ func printTable(items []CatalogImageResponse) { target = img.Targets[0].Name } - // Truncate registry URL for display - registryDisplay := img.RegistryURL - if len(registryDisplay) > 50 { - registryDisplay = registryDisplay[:47] + "..." - } + tags := strings.Join(img.Tags, ",") - if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", + if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", img.Name, - registryDisplay, + img.SourceType, img.Architecture, img.Distro, target, + img.ExportFormat, + tags, img.Phase, + img.RegistryURL, img.CreatedAt, ); err != nil { fmt.Fprintf(os.Stderr, "Warning: failed to write row: %v\n", err) diff --git a/cmd/main.go b/cmd/main.go index 57747cab1..e970e6de2 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -53,6 +53,7 @@ import ( "github.com/centos-automotive-suite/automotive-dev-operator/internal/controller/imagebuild" "github.com/centos-automotive-suite/automotive-dev-operator/internal/controller/imagereseal" "github.com/centos-automotive-suite/automotive-dev-operator/internal/controller/operatorconfig" + "github.com/centos-automotive-suite/automotive-dev-operator/internal/controller/scheduledimagebuild" "github.com/centos-automotive-suite/automotive-dev-operator/internal/controller/workspace" // +kubebuilder:scaffold:imports ) @@ -63,6 +64,20 @@ const ( modeBuild = "build" ) +type catalogPublisherAdapter struct { + publisher *catalogimage.Publisher +} + +func (a *catalogPublisherAdapter) PublishFromImageBuild( + ctx context.Context, + imageBuild *automotivev1alpha1.ImageBuild, + catalogName string, + tags []string, + authSecretRef *automotivev1alpha1.AuthSecretReference, +) (*catalogimage.PublishResult, error) { + return a.publisher.PublishFromImageBuild(ctx, imageBuild, catalogName, tags, authSecretRef, catalogimage.PublishSourceScheduled) +} + var ( scheme = runtime.NewScheme() setupLog = ctrl.Log.WithName("setup") @@ -311,6 +326,24 @@ func main() { setupLog.Error(err, "unable to create controller", "controller", "Workspace") os.Exit(1) } + + sibPublisher := catalogimage.NewPublisher( + mgr.GetClient(), + catalogimage.NewRegistryClient(), + nil, + ctrl.Log.WithName("controllers").WithName("ScheduledImageBuild"), + ) + scheduledImageBuildReconciler := &scheduledimagebuild.Reconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Log: ctrl.Log.WithName("controllers").WithName("ScheduledImageBuild"), + Recorder: mgr.GetEventRecorderFor("scheduledimagebuild-controller"), + Publisher: &catalogPublisherAdapter{publisher: sibPublisher}, + } + if err = scheduledImageBuildReconciler.SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "ScheduledImageBuild") + os.Exit(1) + } } // Health checks diff --git a/config/crd/bases/automotive.sdv.cloud.redhat.com_catalogimages.yaml b/config/crd/bases/automotive.sdv.cloud.redhat.com_catalogimages.yaml index 8e3e2fa06..3e6edfb1c 100644 --- a/config/crd/bases/automotive.sdv.cloud.redhat.com_catalogimages.yaml +++ b/config/crd/bases/automotive.sdv.cloud.redhat.com_catalogimages.yaml @@ -348,6 +348,12 @@ spec: description: SourceImageBuild references the ImageBuild that created this catalog entry type: string + verificationFailures: + description: |- + VerificationFailures counts consecutive verification failures. + After the max is reached the phase transitions to Failed. + format: int32 + type: integer type: object type: object served: true diff --git a/config/crd/bases/automotive.sdv.cloud.redhat.com_imagebuilds.yaml b/config/crd/bases/automotive.sdv.cloud.redhat.com_imagebuilds.yaml index 01824cb0e..a76433404 100644 --- a/config/crd/bases/automotive.sdv.cloud.redhat.com_imagebuilds.yaml +++ b/config/crd/bases/automotive.sdv.cloud.redhat.com_imagebuilds.yaml @@ -159,9 +159,10 @@ spec: type: string type: object format: - default: qcow2 - description: Format specifies the disk image output format (e.g., - raw, qcow2, simg, or any AIB-supported format) + description: |- + Format specifies the disk image output format (e.g., raw, qcow2, simg, or any AIB-supported format). + When omitted, the controller resolves the format from the aib-target-defaults ConfigMap, + falling back to qcow2 if no target default is configured. type: string useServiceAccountAuth: description: |- @@ -264,6 +265,16 @@ spec: x-kubernetes-validations: - message: reproducible builds require secureBuild to be true rule: '!has(self.reproducible) || !self.reproducible || self.secureBuild' + - message: secretRef is required when export.disk.oci is set (unless useServiceAccountAuth + is true) + rule: '!(has(self.export) && has(self.export.disk) && has(self.export.disk.oci) + && size(self.export.disk.oci) > 0) || size(self.secretRef) > 0 || + (has(self.export) && has(self.export.useServiceAccountAuth) && self.export.useServiceAccountAuth)' + - message: secretRef is required when export.container is set (unless + useServiceAccountAuth is true) + rule: '!(has(self.export) && has(self.export.container) && size(self.export.container) + > 0) || size(self.secretRef) > 0 || (has(self.export) && has(self.export.useServiceAccountAuth) + && self.export.useServiceAccountAuth)' status: description: ImageBuildStatus defines the observed state of ImageBuild properties: @@ -391,6 +402,12 @@ spec: description: PVCName is the name of the PVC where the artifact is stored type: string + resolvedExportFormat: + description: |- + ResolvedExportFormat is the export format resolved at build creation time. + Persisted so the push task uses the same format even if the + aib-target-defaults ConfigMap changes between build and push. + type: string startTime: description: StartTime is when the build started format: date-time diff --git a/config/crd/bases/automotive.sdv.cloud.redhat.com_scheduledimagebuilds.yaml b/config/crd/bases/automotive.sdv.cloud.redhat.com_scheduledimagebuilds.yaml new file mode 100644 index 000000000..375f22763 --- /dev/null +++ b/config/crd/bases/automotive.sdv.cloud.redhat.com_scheduledimagebuilds.yaml @@ -0,0 +1,582 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.19.0 + name: scheduledimagebuilds.automotive.sdv.cloud.redhat.com +spec: + group: automotive.sdv.cloud.redhat.com + names: + kind: ScheduledImageBuild + listKind: ScheduledImageBuildList + plural: scheduledimagebuilds + singular: scheduledimagebuild + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.schedule + name: Schedule + type: string + - jsonPath: .spec.suspend + name: Suspend + type: boolean + - jsonPath: .status.lastScheduleTime + name: Last Schedule + type: date + - jsonPath: .status.lastSuccessfulTime + name: Last Success + type: date + - jsonPath: .status.lastFailedTime + name: Last Failure + type: date + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + ScheduledImageBuild defines a cron schedule for creating ImageBuild CRs + with optional automatic publishing to the catalog. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: ScheduledImageBuildSpec defines the desired state of ScheduledImageBuild + properties: + concurrencyPolicy: + default: Forbid + description: ConcurrencyPolicy specifies how to treat concurrent builds. + enum: + - Allow + - Forbid + - Replace + type: string + failedBuildsHistoryLimit: + default: 1 + description: FailedBuildsHistoryLimit is the number of failed finished + builds to retain. + format: int32 + minimum: 0 + type: integer + imageBuildTemplate: + description: ImageBuildTemplate is the template for creating ImageBuild + CRs. + properties: + metadata: + description: Metadata contains labels and annotations to apply + to created ImageBuilds. + properties: + annotations: + additionalProperties: + type: string + description: Annotations to set on created ImageBuilds. + type: object + labels: + additionalProperties: + type: string + description: Labels to set on created ImageBuilds. + type: object + type: object + spec: + description: Spec is the ImageBuildSpec used as the template for + child ImageBuilds. + properties: + aib: + description: AIB contains automotive-image-builder specific + configuration + properties: + aibExtraArgs: + description: AIBExtraArgs are extra arguments to pass + to automotive-image-builder + items: + type: string + type: array + builderImage: + description: |- + BuilderImage specifies a custom osbuild builder container image + If not specified for bootc builds, one is automatically built and cached + type: string + containerRef: + description: |- + ContainerRef is the reference to an existing bootc container image + Required when mode=disk to create a disk image from an existing container + type: string + customDefs: + description: CustomDefs are custom environment variable + definitions for the build + items: + type: string + type: array + distro: + description: Distro specifies the distribution to build + for (e.g., "autosd") + type: string + image: + description: |- + Image specifies the automotive-image-builder container image to use + If not specified, the default from OperatorConfig is used + type: string + inputFilesServer: + description: |- + InputFilesServer indicates if an upload server should be created for local file references + When true, the build waits in "Uploading" phase until files are uploaded + type: boolean + manifest: + description: Manifest holds the inline AIB manifest YAML + content + type: string + manifestFileName: + description: |- + ManifestFileName is the original filename of the manifest, used for naming the file + when writing it to disk before invoking automotive-image-builder + type: string + mode: + default: image + description: Mode specifies the build mode + enum: + - package + - image + - bootc + - disk + type: string + ociRepoImages: + description: |- + OCIRepoImages are OCI image references containing RPM repositories. + Each image is mounted as a read-only volume via ImageVolumeSource in the build pod, + providing RPM repos at file:///extra-repos/oci-repo-N paths. + items: + type: string + maxItems: 1 + type: array + rebuildBuilder: + description: RebuildBuilder forces rebuilding the bootc + builder image even if a cached version exists in the + registry. + type: boolean + rootPassword: + description: |- + RootPassword is a hashed root password passed to AIB's --root-password flag. + See crypt(5) for supported hash formats. + type: string + target: + description: Target specifies the build target platform + (e.g., "qemu", "aws") + type: string + required: + - distro + - target + type: object + architecture: + description: Architecture specifies the target architecture + (e.g., "amd64", "arm64") + type: string + buildCachePVC: + description: |- + BuildCachePVC is the name of a PVC to mount as the osbuild build cache directory. + When set, the build pod mounts this PVC and passes --build-dir to AIB, + enabling osbuild checkpoint reuse and dnf cache persistence across builds. + type: string + export: + description: Export contains configuration for exporting build + artifacts + properties: + buildDiskImage: + description: BuildDiskImage indicates whether to build + a disk image from the bootc container + type: boolean + compression: + default: gzip + description: Compression specifies the compression algorithm + for artifacts + enum: + - lz4 + - gzip + - xz + type: string + container: + description: Container is the OCI registry URL to push + the bootc container image + type: string + disk: + description: Disk contains configuration for disk image + export + properties: + oci: + description: OCI is the registry URL to push the disk + image as an OCI artifact + type: string + type: object + format: + description: |- + Format specifies the disk image output format (e.g., raw, qcow2, simg, or any AIB-supported format). + When omitted, the controller resolves the format from the aib-target-defaults ConfigMap, + falling back to qcow2 if no target default is configured. + type: string + useServiceAccountAuth: + description: |- + UseServiceAccountAuth indicates the build should authenticate to the registry + using a service account token instead of explicit credentials + type: boolean + type: object + flash: + description: Flash contains configuration for flashing the + built image to hardware via Jumpstarter + properties: + clientConfigSecretRef: + description: |- + ClientConfigSecretRef is the name of the secret containing the Jumpstarter client config + The secret should have a key "client.yaml" with the config contents + If set, flash is enabled automatically + type: string + exporterSelector: + description: |- + ExporterSelector overrides the exporter selector from OperatorConfig target mappings + When set, the target-based lookup is skipped entirely + type: string + flashCmd: + description: FlashCmd overrides the flash command from + OperatorConfig target mappings + type: string + leaseDuration: + default: "03:00:00" + description: LeaseDuration is the duration for the device + lease in HH:MM:SS format + type: string + leaseName: + description: |- + LeaseName is an existing Jumpstarter lease name to use instead of creating a new one + Mutually exclusive with LeaseDuration + type: string + leaseTags: + description: LeaseTags are additional key=value tags for + the Jumpstarter lease (comma-separated) + type: string + type: object + pushSecretRef: + description: |- + PushSecretRef is the name of the kubernetes.io/dockerconfigjson secret for pushing artifacts + This is separate from SecretRef because push operations require docker config format + type: string + reproducible: + description: |- + Reproducible enables full build provenance: saves RPMs, AIB manifest, + and task bundle ref as OCI referrers for future reproduction. + Requires SecureBuild to be true for task bundle pinning. + type: boolean + restoreSourcesRef: + description: |- + RestoreSourcesRef is the OCI image reference from a prior reproducible build. + The build pod will pull the sources archive (OCI referrer) attached to this + image and pre-populate the osbuild store, ensuring identical RPM inputs. + type: string + runtimeClassName: + description: RuntimeClassName specifies the runtime class + to use for the build pod + type: string + secretRef: + description: |- + SecretRef is the name of the secret containing credentials for registry operations + The secret should contain keys like REGISTRY_AUTH_FILE for authentication + type: string + secureBuild: + description: |- + SecureBuild enables supply chain security for this build. + When true, pipeline tasks are resolved from the signed Tekton Bundle + specified in TaskBundleRef instead of cluster-installed tasks. + type: boolean + storageClass: + description: StorageClass is the name of the storage class + to use for the build PVC + type: string + taskBundleRef: + description: |- + TaskBundleRef is the digest-pinned OCI reference to the Tekton Bundle + used for this build. Set automatically by the Build API from the + OperatorConfig at request time to prevent TOCTOU races. + type: string + ttl: + description: |- + TTL is the time-to-live for this build. After this duration past its + completion, the build transitions to the Expired phase and its resources + (PipelineRuns, TaskRuns, PVCs, registry images) are cleaned up. + The ImageBuild CR itself is preserved. In-progress builds never expire. + Uses Go duration format (e.g. "24h", "72h", "168h"). + Empty uses the OperatorConfig default. Set to "0" to disable expiry. + type: string + workspace: + description: |- + Workspace is the name of the Workspace CR this build belongs to. + When set, the controller writes the acquired lease back to the workspace + on completion so subsequent builds can reuse it. + type: string + type: object + x-kubernetes-validations: + - message: reproducible builds require secureBuild to be true + rule: '!has(self.reproducible) || !self.reproducible || self.secureBuild' + - message: secretRef is required when export.disk.oci is set (unless + useServiceAccountAuth is true) + rule: '!(has(self.export) && has(self.export.disk) && has(self.export.disk.oci) + && size(self.export.disk.oci) > 0) || size(self.secretRef) + > 0 || (has(self.export) && has(self.export.useServiceAccountAuth) + && self.export.useServiceAccountAuth)' + - message: secretRef is required when export.container is set + (unless useServiceAccountAuth is true) + rule: '!(has(self.export) && has(self.export.container) && size(self.export.container) + > 0) || size(self.secretRef) > 0 || (has(self.export) && has(self.export.useServiceAccountAuth) + && self.export.useServiceAccountAuth)' + required: + - spec + type: object + matrix: + description: |- + Matrix defines a build matrix that creates multiple ImageBuilds per schedule tick. + Each tick creates one ImageBuild for each combination of the specified dimensions, + overriding the corresponding fields in the imageBuildTemplate. + properties: + architectures: + description: |- + Architectures lists target architectures to build for. + Each value overrides imageBuildTemplate.spec.architecture. + items: + type: string + maxItems: 4 + type: array + distros: + description: |- + Distros lists distributions to build for. + Each value overrides imageBuildTemplate.spec.aib.distro. + items: + type: string + maxItems: 4 + type: array + targets: + description: |- + Targets lists hardware targets to build for. + Each value overrides imageBuildTemplate.spec.aib.target. + items: + type: string + maxItems: 4 + type: array + type: object + publishToCatalog: + description: PublishToCatalog configures automatic publishing of completed + builds to the catalog. + properties: + authSecretRef: + description: |- + AuthSecretRef references a secret containing registry credentials + for verifying the published image. + properties: + name: + description: Name is the name of the secret containing registry + credentials + type: string + namespace: + description: Namespace is the namespace of the secret (defaults + to CatalogImage namespace) + type: string + required: + - name + type: object + enabled: + description: Enabled controls whether completed builds are automatically + published to the catalog. + type: boolean + tags: + description: Tags are category tags to apply to the CatalogImage. + items: + type: string + type: array + required: + - enabled + type: object + schedule: + description: |- + Schedule is a cron expression defining when builds should run (5-field standard format). + Examples: "0 2 * * *" (daily at 2am), "0 */6 * * *" (every 6 hours) + minLength: 9 + pattern: ^([-0-9*/,]+\s+){4}[-0-9*/,]+$ + type: string + startingDeadlineSeconds: + description: |- + StartingDeadlineSeconds is the deadline in seconds for starting a build + if it misses its scheduled time. Missed builds beyond this window are skipped. + format: int64 + minimum: 0 + type: integer + successfulBuildsHistoryLimit: + default: 3 + description: SuccessfulBuildsHistoryLimit is the number of successful + finished builds to retain. + format: int32 + minimum: 0 + type: integer + suspend: + description: |- + Suspend tells the controller to suspend subsequent executions. + Existing running builds will not be affected. + type: boolean + required: + - imageBuildTemplate + - schedule + type: object + x-kubernetes-validations: + - message: matrix distros requires aib in imageBuildTemplate + rule: '!has(self.matrix) || !has(self.matrix.distros) || size(self.matrix.distros) + == 0 || has(self.imageBuildTemplate.spec.aib)' + - message: matrix targets requires aib in imageBuildTemplate + rule: '!has(self.matrix) || !has(self.matrix.targets) || size(self.matrix.targets) + == 0 || has(self.imageBuildTemplate.spec.aib)' + status: + description: ScheduledImageBuildStatus defines the observed state of ScheduledImageBuild + properties: + active: + description: Active is a list of currently running ImageBuild references. + items: + description: ObjectReference contains enough information to let + you inspect or modify the referred object. + properties: + apiVersion: + description: API version of the referent. + type: string + fieldPath: + description: |- + If referring to a piece of an object instead of an entire object, this string + should contain a valid JSON/Go field access statement, such as desiredState.manifest.containers[2]. + For example, if the object reference is to a container within a pod, this would take on a value like: + "spec.containers{name}" (where "name" refers to the name of the container that triggered + the event) or if no container name is specified "spec.containers[2]" (container with + index 2 in this pod). This syntax is chosen only to have some well-defined way of + referencing a part of an object. + type: string + kind: + description: |- + Kind of the referent. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + name: + description: |- + Name of the referent. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + namespace: + description: |- + Namespace of the referent. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces/ + type: string + resourceVersion: + description: |- + Specific resourceVersion to which this reference is made, if any. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency + type: string + uid: + description: |- + UID of the referent. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#uids + type: string + type: object + x-kubernetes-map-type: atomic + type: array + conditions: + description: Conditions represent the latest available observations. + 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 + lastFailedTime: + description: LastFailedTime is when the last build failed. + format: date-time + type: string + lastScheduleTime: + description: LastScheduleTime is when the last build was created. + format: date-time + type: string + lastSuccessfulTime: + description: LastSuccessfulTime is when the last build completed successfully. + format: date-time + type: string + observedGeneration: + description: ObservedGeneration is the most recent generation observed + by the controller. + format: int64 + type: integer + phase: + description: Phase represents the current state of the schedule. + enum: + - Active + - Suspended + type: string + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index 42720b620..6e1df3f97 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -9,6 +9,7 @@ resources: - bases/automotive.sdv.cloud.redhat.com_catalogimages.yaml - bases/automotive.sdv.cloud.redhat.com_containerbuilds.yaml - bases/automotive.sdv.cloud.redhat.com_workspaces.yaml +- bases/automotive.sdv.cloud.redhat.com_scheduledimagebuilds.yaml # +kubebuilder:scaffold:crdkustomizeresource patches: diff --git a/config/manifests/bases/automotive-dev-operator.clusterserviceversion.yaml b/config/manifests/bases/automotive-dev-operator.clusterserviceversion.yaml index f77157768..8ddc55787 100644 --- a/config/manifests/bases/automotive-dev-operator.clusterserviceversion.yaml +++ b/config/manifests/bases/automotive-dev-operator.clusterserviceversion.yaml @@ -133,6 +133,13 @@ spec: kind: OperatorConfig name: operatorconfigs.automotive.sdv.cloud.redhat.com version: v1alpha1 + - description: |- + ScheduledImageBuild defines a cron schedule for creating ImageBuild CRs + with optional automatic publishing to the catalog. + displayName: Scheduled Image Build + kind: ScheduledImageBuild + name: scheduledimagebuilds.automotive.sdv.cloud.redhat.com + version: v1alpha1 description: | The CentOS Automotive Suite Operator enables building automotive OS images on OpenShift clusters. diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 2223bed14..7fdfc09a8 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -124,6 +124,7 @@ rules: - imagereseals - images - operatorconfigs + - scheduledimagebuilds - workspaces verbs: - create @@ -142,6 +143,7 @@ rules: - imagereseals/finalizers - images/finalizers - operatorconfigs/finalizers + - scheduledimagebuilds/finalizers - workspaces/finalizers verbs: - update @@ -154,6 +156,7 @@ rules: - imagereseals/status - images/status - operatorconfigs/status + - scheduledimagebuilds/status - workspaces/status verbs: - get diff --git a/config/samples/automotive_v1alpha1_scheduledimagebuild.yaml b/config/samples/automotive_v1alpha1_scheduledimagebuild.yaml new file mode 100644 index 000000000..4736ede7b --- /dev/null +++ b/config/samples/automotive_v1alpha1_scheduledimagebuild.yaml @@ -0,0 +1,55 @@ +apiVersion: automotive.sdv.cloud.redhat.com/v1alpha1 +kind: ScheduledImageBuild +metadata: + name: nightly-autosd-qemu +spec: + schedule: "0 2 * * *" + concurrencyPolicy: Forbid + successfulBuildsHistoryLimit: 3 + failedBuildsHistoryLimit: 1 + publishToCatalog: + enabled: true + tags: + - nightly + - autosd + - qemu + imageBuildTemplate: + metadata: + labels: + team: platform + image-type: qa + spec: + architecture: x86_64 + aib: + distro: autosd + target: qemu + mode: image + manifest: | + name: qa + content: + rpms: + - openssh-server + - chrony + - iproute + - vim + systemd: + enabled_services: + - sshd.service + qm: + content: + rpms: + - vim + image: + image_size: 8 GiB + sealed: false + partitions: + var_qm: + relative_size: 0.1 + auth: + root_password: "$6$xoLqEUz0cGGJRx01$H3H/bFm0myJPULNMtbSsOFd/2BnHqHkMD92Sfxd.EKM9hXTWSmELG8cf205l6dktomuTcgKGGtGDgtvHVXSWU." + sshd_config: + PermitRootLogin: true + PasswordAuthentication: true + export: + format: qcow2 + container: registry.example.com/autosd/nightly:latest diff --git a/config/samples/scheduledimagebuild_qa_ebbr.yaml b/config/samples/scheduledimagebuild_qa_ebbr.yaml new file mode 100644 index 000000000..7844d9938 --- /dev/null +++ b/config/samples/scheduledimagebuild_qa_ebbr.yaml @@ -0,0 +1,373 @@ +apiVersion: automotive.sdv.cloud.redhat.com/v1alpha1 +kind: ScheduledImageBuild +metadata: + name: nightly-qa-ebbr +spec: + schedule: "23 17 * * *" + concurrencyPolicy: Forbid + successfulBuildsHistoryLimit: 3 + failedBuildsHistoryLimit: 1 + startingDeadlineSeconds: 3600 + publishToCatalog: + enabled: true + tags: + - nightly + - qa + - ebbr + imageBuildTemplate: + metadata: + labels: + team: qa + schedule: nightly + target: ebbr + spec: + architecture: aarch64 + aib: + distro: autosd + target: ebbr + mode: image + image: quay.io/centos-sig-automotive/automotive-image-builder:1.3.2 + manifest: | + name: qa + + content: + add_files: + - path: /etc/sysctl.d/99-custom-networking.conf + text: | + net.ipv4.conf.all.arp_ignore=1 + net.ipv4.conf.all.arp_announce=2 + net.ipv4.conf.all.rp_filter=2 + - path: /etc/systemd/system/fixup.service + text: | + [Unit] + Description=One-time config fixup + After=local-fs.target + + [Service] + Type=oneshot + ExecStart=/usr/bin/sed -i 's/.*LOGIN_TIMEOUT.*/LOGIN_TIMEOUT 600/' /etc/login.defs + ExecStartPost=/usr/bin/systemctl disable fixup.service + + [Install] + WantedBy=multi-user.target + + repos: + - id: EPEL + metalink: https://mirrors.fedoraproject.org/metalink?repo=epel-10&arch=$arch + + rpms: + - openssh-server + - chrony + - CUnit + - NetworkManager-libreswan + - NetworkManager-ovs + - aardvark-dns + - annobin-annocheck + - asciidoc + - audit + - augeas-libs + - automotive-image-builder + - avahi-libs + - bash + - bash-completion + - bc + - bcc + - bcc-tools + - beakerlib + - bind + - bind-dnssec-utils + - bind-utils + - binutils + - blktrace + - bluechi-agent + - bluechi-controller + - bluechi-ctl + - bluechi-is-online + - bluechi-selinux + - boost + - bpftool + - bpftrace + - bridge-utils + - btrfs-progs + - buildah + - busybox + - byacc + - bzip2 + - c-ares + - catatonit + - checkpolicy + - chrpath + - cifs-utils + - clang + - cmake + - conntrack-tools + - container-tools + - createrepo_c + - criu + - criu-libs + - cronie + - crypto-policies-scripts + - cryptsetup + - cups-lpd + - dbench + - dblatex + - dejagnu + - device-mapper-multipath + - dfuzzer + - dhcp-common + - dialog + - dnsmasq + - docbook-style-xsl + - docbook5-style-xsl + - dosfstools + - doxygen + - duperemove + - dwarves + - e2fsprogs + - erofs-utils + - ethtool + - exfatprogs + - expect + - findutils + - fio + - fio-engine-libaio + - fusa-gcc-plugin + - fuse + - gcc + - gcc-c++ + - gcc-gfortran + - gdb + - genisoimage + - geolite2-city + - geolite2-country + - git + - glibc-static + - glibc-utils + - gnu-efi + - golang + - gperf + - graphviz + - guestfs-tools + - hdparm + - hostapd + - httpd + - httpd-tools + - indent + - integritysetup + - ipcalc + - iperf3 + - iproute + - iproute-tc + - iputils + - irqbalance + - kabi-dw + - kernel-automotive-devel + - kernel-automotive-modules-extra + - kernel-automotive-modules-internal + - kernel-automotive-selftests-internal + - kernel-headers + - kernel-rpm-macros + - kernel-tools + - kexec-tools + - ksh + - libdhash + - libgccjit + - libgfortran + - libitm + - libldb + - liblsan + - libsss_certmap + - libsss_idmap + - libsss_nss_idmap + - libsss_sudo + - libstdc++-docs + - libstdc++-static + - libtalloc + - libtdb + - libtevent + - libtool + - libtsan + - liburing-devel + - libuuid-devel + - lld + - llvm + - lm_sensors-libs + - lshw + - lsof + - lsscsi + - ltrace + - lvm2 + - make + - man + - man-pages + - mdadm + - meson + - mold + - nasm + - nc + - net-tools + - netlabel_tools + - netperf + - netronome-firmware + - netsniff-ng + - nmap + - nmap-ncat + - nmstate + - nss_db + - nss_hesiod + - ntfs-3g-libs + - ntfsprogs + - numactl + - nvme-cli + - patch + - patchutils + - pciutils + - pcp-conf + - pcp-libs + - perf + - perl + - perl-Net-DNS-Nameserver + - perl-generators + - pesign + - podman + - policycoreutils + - policycoreutils-python-utils + - postfix + - procmail + - procps-ng + - psmisc + - python + - python3-augeas + - python3-click + - python3-cryptography + - python3-docutils + - python3-gobject-base + - python3-jinja2 + - python3-jsonschema + - python3-libnmstate + - python3-libsemanage + - python3-lxml + - python3-pefile + - python3-pexpect + - python3-pip + - python3-ply + - python3-policycoreutils + - python3-psycopg2 + - python3-scapy + - python3-sphinx + - python3-sss + - python3-sssdconfig + - python3-test + - qemu-kvm + - realtime-tests + - rpcgen + - rpmdevtools + - rsync + - rsyslog + - rtla + - screen + - selinux-policy-mls + - selinux-policy-targeted + - setools + - setools-console + - sharutils + - skopeo + - slirp4netns + - socat + - sqlite + - sshpass + - sssd-client + - sssd-common + - sssd-dbus + - sssd-nfs-idmap + - sssd-tools + - stalld + - strace + - stress-ng + - subscription-manager + - sudo + - sysstat + - systemd-boot-unsigned + - systemd-container + - systemd-journal-remote + - systemd-oomd + - systemd-ukify + - systemtap + - tcpdump + - tcsh + - telnet + - telnet-server + - texinfo + - texinfo-tex + - texlive-collection-latex + - tmux + - toolbox + - tpm-tools + - trace-cmd + - traceroute + - tree + - trousers-lib + - tuna + - /usr/bin/flock + - unifdef + - util-linux-user + - valgrind + - veritysetup + - vim + - vim-minimal + - virt-what + - vsftpd + - wget + - wireguard-tools + - xfsdump + - xfsprogs + - xfsprogs-xfs_scrub + - xmlsec1-openssl + - xmlto + - yum + - zlib-static + + systemd: + enabled_services: + - sshd.service + - fixup.service + + qm: + content: + rpms: + - stress-ng + + image: + sealed: false + image_size: 10GiB + partitions: + var_qm: + size: 1GiB + + auth: + root_password: "$6$xoLqEUz0cGGJRx01$H3H/bFm0myJPULNMtbSsOFd/2BnHqHkMD92Sfxd.EKM9hXTWSmELG8cf205l6dktomuTcgKGGtGDgtvHVXSWU." + root_ssh_keys: + - "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILKx5O9oCqfSyOZQP/VNMIlK4o/ru0TxJmFwpD3Bc43B auto-qe" + sshd_config: + PermitRootLogin: true + PasswordAuthentication: true + users: + guest: + uid: 2000 + gid: 2000 + password: "$6$xoLqEUz0cGGJRx01$H3H/bFm0myJPULNMtbSsOFd/2BnHqHkMD92Sfxd.EKM9hXTWSmELG8cf205l6dktomuTcgKGGtGDgtvHVXSWU." + groups: + - guest + groups: + guest: + gid: 2000 + + experimental: + internal_defines: + grow_rootfs: false + use_module_sig_enforce: false + use_transient_etc: false + export: + format: simg + disk: + oci: quay.io/bzlotnik/ebbr:disk diff --git a/go.mod b/go.mod index 93b1b43f9..e5ea71b46 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( github.com/onsi/gomega v1.39.0 github.com/opencontainers/go-digest v1.0.0 github.com/opencontainers/image-spec v1.1.1 + github.com/robfig/cron/v3 v3.0.1 github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 github.com/shipwright-io/build v0.18.3 github.com/sigstore/cosign/v3 v3.0.6 diff --git a/go.sum b/go.sum index 122eeca4b..39c34a6b4 100644 --- a/go.sum +++ b/go.sum @@ -509,6 +509,8 @@ github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEo github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM= github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= diff --git a/internal/buildapi/catalog/handlers.go b/internal/buildapi/catalog/handlers.go index 55e26574a..9d8ad2917 100644 --- a/internal/buildapi/catalog/handlers.go +++ b/internal/buildapi/catalog/handlers.go @@ -30,21 +30,22 @@ import ( automotivev1alpha1 "github.com/centos-automotive-suite/automotive-dev-operator/api/v1alpha1" ) -const ( - defaultNamespace = "default" -) - // Handler handles catalog API requests type Handler struct { - client client.Client - log logr.Logger + client client.Client + log logr.Logger + defaultNamespace string } // NewHandler creates a new catalog API handler -func NewHandler(client client.Client, log logr.Logger) *Handler { +func NewHandler(client client.Client, log logr.Logger, defaultNamespace string) *Handler { + if defaultNamespace == "" { + defaultNamespace = "default" + } return &Handler{ - client: client, - log: log.WithName("catalog-handler"), + client: client, + log: log.WithName("catalog-handler"), + defaultNamespace: defaultNamespace, } } @@ -62,10 +63,12 @@ func (h *Handler) HandleListCatalogImages(c *gin.Context) { // Build list options listOpts := []client.ListOption{} - // Namespace filtering - if params.Namespace != "" { - listOpts = append(listOpts, client.InNamespace(params.Namespace)) + // Namespace filtering — always scope to a namespace + ns := params.Namespace + if ns == "" { + ns = h.defaultNamespace } + listOpts = append(listOpts, client.InNamespace(ns)) // Build label selector for filtering labelRequirements := []string{} @@ -145,7 +148,7 @@ func (h *Handler) HandleGetCatalogImage(c *gin.Context) { namespace := c.Query("namespace") if namespace == "" { - namespace = defaultNamespace + namespace = h.defaultNamespace } catalogImage := &automotivev1alpha1.CatalogImage{} @@ -159,13 +162,6 @@ func (h *Handler) HandleGetCatalogImage(c *gin.Context) { return } - // Increment access count (best effort, don't fail the request on error) - catalogImage.Status.AccessCount++ - if err := h.client.Status().Update(ctx, catalogImage); err != nil { - h.log.V(1).Info("failed to update access count", "name", name, "error", err) - // Continue with response even if access count update fails - } - response := ToCatalogImageResponse(catalogImage) c.JSON(http.StatusOK, response) } @@ -175,7 +171,7 @@ func (h *Handler) HandleCreateCatalogImage(c *gin.Context) { ctx := context.Background() namespace := c.Query("namespace") if namespace == "" { - namespace = defaultNamespace + namespace = h.defaultNamespace } var req CreateCatalogImageRequest @@ -247,7 +243,7 @@ func (h *Handler) HandleDeleteCatalogImage(c *gin.Context) { namespace := c.Query("namespace") if namespace == "" { - namespace = defaultNamespace + namespace = h.defaultNamespace } catalogImage := &automotivev1alpha1.CatalogImage{} @@ -278,7 +274,7 @@ func (h *Handler) HandleVerifyCatalogImage(c *gin.Context) { namespace := c.Query("namespace") if namespace == "" { - namespace = defaultNamespace + namespace = h.defaultNamespace } catalogImage := &automotivev1alpha1.CatalogImage{} diff --git a/internal/buildapi/catalog/handlers_test.go b/internal/buildapi/catalog/handlers_test.go new file mode 100644 index 000000000..25df0f23e --- /dev/null +++ b/internal/buildapi/catalog/handlers_test.go @@ -0,0 +1,100 @@ +package catalog + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/go-logr/logr" + 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" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + automotivev1alpha1 "github.com/centos-automotive-suite/automotive-dev-operator/api/v1alpha1" +) + +func newTestScheme() *runtime.Scheme { + s := runtime.NewScheme() + utilruntime.Must(clientgoscheme.AddToScheme(s)) + utilruntime.Must(automotivev1alpha1.AddToScheme(s)) + return s +} + +func newTestHandler(objs ...client.Object) (*Handler, client.Client) { + scheme := newTestScheme() + builder := fake.NewClientBuilder().WithScheme(scheme).WithObjects(objs...). + WithStatusSubresource(&automotivev1alpha1.CatalogImage{}) + c := builder.Build() + h := NewHandler(c, logr.Discard(), "default") + return h, c +} + +func TestHandleGetCatalogImage_DoesNotWrite(t *testing.T) { + gin.SetMode(gin.TestMode) + + img := &automotivev1alpha1.CatalogImage{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-image", + Namespace: "default", + }, + Spec: automotivev1alpha1.CatalogImageSpec{ + RegistryURL: "quay.io/test/image:latest", + }, + Status: automotivev1alpha1.CatalogImageStatus{ + Phase: automotivev1alpha1.CatalogImagePhaseAvailable, + AccessCount: 5, + }, + } + + h, c := newTestHandler(img) + + router := gin.New() + router.GET("/catalog/images/:name", h.HandleGetCatalogImage) + + req := httptest.NewRequest(http.MethodGet, "/catalog/images/test-image?namespace=default", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var resp CatalogImageResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + if resp.Name != "test-image" { + t.Errorf("expected name test-image, got %s", resp.Name) + } + + // Verify the object was NOT modified (AccessCount unchanged) + var after automotivev1alpha1.CatalogImage + if err := c.Get(t.Context(), client.ObjectKey{Name: "test-image", Namespace: "default"}, &after); err != nil { + t.Fatalf("failed to get catalog image: %v", err) + } + if after.Status.AccessCount != 5 { + t.Errorf("AccessCount changed from 5 to %d — GET should not write", after.Status.AccessCount) + } +} + +func TestHandleGetCatalogImage_NotFound(t *testing.T) { + gin.SetMode(gin.TestMode) + + h, _ := newTestHandler() + + router := gin.New() + router.GET("/catalog/images/:name", h.HandleGetCatalogImage) + + req := httptest.NewRequest(http.MethodGet, "/catalog/images/nonexistent?namespace=default", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("expected 404, got %d", w.Code) + } +} diff --git a/internal/buildapi/catalog/models.go b/internal/buildapi/catalog/models.go index 35a42a9f9..6dc0b8550 100644 --- a/internal/buildapi/catalog/models.go +++ b/internal/buildapi/catalog/models.go @@ -20,6 +20,7 @@ import ( "time" automotivev1alpha1 "github.com/centos-automotive-suite/automotive-dev-operator/api/v1alpha1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) // CatalogImageResponse represents a catalog image in API responses @@ -43,12 +44,17 @@ type CatalogImageResponse struct { PublishedAt *time.Time `json:"publishedAt,omitempty"` CreatedAt time.Time `json:"createdAt"` SourceImageBuild string `json:"sourceImageBuild,omitempty"` + SourceType string `json:"sourceType,omitempty"` + BuildMode string `json:"buildMode,omitempty"` + ExportFormat string `json:"exportFormat,omitempty"` Labels map[string]string `json:"labels,omitempty"` ArtifactRefs []ArtifactRefInfo `json:"artifactRefs,omitempty"` DownloadURL string `json:"downloadUrl,omitempty"` IsMultiArch bool `json:"isMultiArch,omitempty"` PlatformVariants []PlatformVariantInfo `json:"platformVariants,omitempty"` AccessCount int64 `json:"accessCount,omitempty"` + StatusReason string `json:"statusReason,omitempty"` + StatusMessage string `json:"statusMessage,omitempty"` } // ArtifactRefInfo represents artifact reference information in responses @@ -144,6 +150,8 @@ func ToCatalogImageResponse(catalogImage *automotivev1alpha1.CatalogImage) Catal response.Distro = catalogImage.Spec.Metadata.Distro response.DistroVersion = catalogImage.Spec.Metadata.DistroVersion response.Bootc = catalogImage.Spec.Metadata.Bootc + response.BuildMode = catalogImage.Spec.Metadata.BuildMode + response.ExportFormat = catalogImage.Spec.Metadata.ExportFormat for _, target := range catalogImage.Spec.Metadata.Targets { response.Targets = append(response.Targets, HardwareTargetInfo{ @@ -154,6 +162,11 @@ func ToCatalogImageResponse(catalogImage *automotivev1alpha1.CatalogImage) Catal } } + // Extract source type from label + if sourceType, ok := catalogImage.Labels[automotivev1alpha1.LabelSourceType]; ok { + response.SourceType = sourceType + } + // Extract registry metadata if catalogImage.Status.RegistryMetadata != nil { response.SizeBytes = catalogImage.Status.RegistryMetadata.SizeBytes @@ -185,6 +198,17 @@ func ToCatalogImageResponse(catalogImage *automotivev1alpha1.CatalogImage) Catal response.SourceImageBuild = catalogImage.Status.SourceImageBuild response.AccessCount = catalogImage.Status.AccessCount + if catalogImage.Status.Phase == automotivev1alpha1.CatalogImagePhaseUnavailable || + catalogImage.Status.Phase == automotivev1alpha1.CatalogImagePhaseFailed { + for _, c := range catalogImage.Status.Conditions { + if c.Type == automotivev1alpha1.CatalogImageConditionAvailable && c.Status == metav1.ConditionFalse { + response.StatusReason = c.Reason + response.StatusMessage = c.Message + break + } + } + } + // Extract artifact references for _, ref := range catalogImage.Status.ArtifactRefs { response.ArtifactRefs = append(response.ArtifactRefs, ArtifactRefInfo{ diff --git a/internal/buildapi/catalog/routes.go b/internal/buildapi/catalog/routes.go index 072160aaa..2a53f39b5 100644 --- a/internal/buildapi/catalog/routes.go +++ b/internal/buildapi/catalog/routes.go @@ -23,8 +23,8 @@ import ( ) // RegisterRoutes registers catalog API routes on the given router group -func RegisterRoutes(group *gin.RouterGroup, k8sClient client.Client, log logr.Logger) { - handler := NewHandler(k8sClient, log) +func RegisterRoutes(group *gin.RouterGroup, k8sClient client.Client, log logr.Logger, defaultNamespace string) { + handler := NewHandler(k8sClient, log, defaultNamespace) // Catalog image routes catalogGroup := group.Group("/catalog") diff --git a/internal/buildapi/server.go b/internal/buildapi/server.go index cc8c63aad..b7c000ca8 100644 --- a/internal/buildapi/server.go +++ b/internal/buildapi/server.go @@ -434,7 +434,7 @@ func (a *APIServer) createRouter() *gin.Engine { a.log.Error(err, "failed to create catalog client, catalog routes will not be available") } else if catalogClient != nil { a.log.Info("registering catalog routes") - catalog.RegisterRoutes(v1, catalogClient, a.log) + catalog.RegisterRoutes(v1, catalogClient, a.log, resolveNamespace()) } } diff --git a/internal/common/tasks/scripts/common.sh b/internal/common/tasks/scripts/common.sh index d4b9862e9..3a9417428 100644 --- a/internal/common/tasks/scripts/common.sh +++ b/internal/common/tasks/scripts/common.sh @@ -155,8 +155,9 @@ validate_custom_def() { setup_container_config() { mkdir -p /etc/containers cat > /etc/containers/registries.conf << EOF -[registries.insecure] -registries = ['$INTERNAL_REGISTRY'] +[[registry]] +location = "$INTERNAL_REGISTRY" +insecure = true EOF echo "Configuring kernel overlay storage driver" @@ -314,6 +315,7 @@ read_registry_creds() { [ -f "$auth_dir/REGISTRY_PASSWORD" ] && REGISTRY_PASSWORD=$(cat "$auth_dir/REGISTRY_PASSWORD") && echo "DEBUG: Found REGISTRY_PASSWORD" [ -f "$auth_dir/REGISTRY_TOKEN" ] && REGISTRY_TOKEN=$(cat "$auth_dir/REGISTRY_TOKEN") && echo "DEBUG: Found REGISTRY_TOKEN" [ -f "$auth_dir/REGISTRY_AUTH_FILE_CONTENT" ] && REGISTRY_AUTH_FILE_CONTENT=$(cat "$auth_dir/REGISTRY_AUTH_FILE_CONTENT") && echo "DEBUG: Found REGISTRY_AUTH_FILE_CONTENT" + [ -z "$REGISTRY_AUTH_FILE_CONTENT" ] && [ -f "$auth_dir/.dockerconfigjson" ] && REGISTRY_AUTH_FILE_CONTENT=$(cat "$auth_dir/.dockerconfigjson") && echo "DEBUG: Found .dockerconfigjson" echo "DEBUG: Registry creds read completed" } @@ -328,6 +330,16 @@ setup_registry_auth() { if [ -n "$REGISTRY_AUTH_FILE_CONTENT" ]; then echo "Using provided registry auth file content" echo "$REGISTRY_AUTH_FILE_CONTENT" > "$auth_file" + if [ -n "${TOKEN:-}" ] && [ -n "${REGISTRY:-}" ]; then + python3 -c " +import json, sys +f = sys.argv[1] +with open(f) as fh: d = json.load(fh) +d.setdefault('auths', {})[sys.argv[2]] = {'auth': sys.argv[3]} +with open(f, 'w') as fh: json.dump(d, fh) +" "$auth_file" "$REGISTRY" "$(echo -n "serviceaccount:$TOKEN" | base64 -w0)" + echo "Merged cluster registry auth into provided credentials" + fi elif [ -n "$REGISTRY_USERNAME" ] && [ -n "$REGISTRY_PASSWORD" ] && [ -n "$REGISTRY_URL" ]; then echo "Creating registry auth from username/password for $REGISTRY_URL" create_auth_json "$auth_file" "$REGISTRY_URL" "$(echo -n "$REGISTRY_USERNAME:$REGISTRY_PASSWORD" | base64 -w0)" diff --git a/internal/controller/catalogimage/catalogimage_controller.go b/internal/controller/catalogimage/catalogimage_controller.go index b7845c4df..ace308f56 100644 --- a/internal/controller/catalogimage/catalogimage_controller.go +++ b/internal/controller/catalogimage/catalogimage_controller.go @@ -37,6 +37,7 @@ const ( defaultVerificationInterval = 1 * time.Hour retryInterval = 30 * time.Second unavailableRetryInterval = 5 * time.Minute + maxVerificationFailures = 5 ) // CatalogImageReconciler reconciles a CatalogImage object @@ -182,6 +183,7 @@ func (r *CatalogImageReconciler) handleVerifyingPhase( // Update status with metadata and transition to Available catalogImage.Status.RegistryMetadata = metadata catalogImage.Status.LastVerificationTime = GetCurrentTime() + catalogImage.Status.VerificationFailures = 0 // Set Published timestamp if not already set if catalogImage.Status.PublishedAt == nil { @@ -312,9 +314,20 @@ func (r *CatalogImageReconciler) transitionToUnavailable( r.setCondition(catalogImage, automotivev1alpha1.CatalogImageConditionAvailable, metav1.ConditionFalse, reason, message) r.setCondition(catalogImage, automotivev1alpha1.CatalogImageConditionReady, metav1.ConditionFalse, reason, message) - catalogImage.Status.Phase = automotivev1alpha1.CatalogImagePhaseUnavailable + catalogImage.Status.VerificationFailures++ catalogImage.Status.ObservedGeneration = catalogImage.Generation + if catalogImage.Status.VerificationFailures >= maxVerificationFailures { + catalogImage.Status.Phase = automotivev1alpha1.CatalogImagePhaseFailed + r.setCondition(catalogImage, automotivev1alpha1.CatalogImageConditionReady, metav1.ConditionFalse, reason, + fmt.Sprintf("%s (gave up after %d attempts)", message, catalogImage.Status.VerificationFailures)) + if err := r.Status().Update(ctx, catalogImage); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{}, nil + } + + catalogImage.Status.Phase = automotivev1alpha1.CatalogImagePhaseUnavailable if err := r.Status().Update(ctx, catalogImage); err != nil { return ctrl.Result{}, err } diff --git a/internal/controller/catalogimage/publisher.go b/internal/controller/catalogimage/publisher.go index 310d4f724..a24607f88 100644 --- a/internal/controller/catalogimage/publisher.go +++ b/internal/controller/catalogimage/publisher.go @@ -38,6 +38,8 @@ const ( PublishSourceExternal PublishSource = "External" // PublishSourceManual indicates the image was manually added via API PublishSourceManual PublishSource = "Manual" + // PublishSourceScheduled indicates the image was published from a scheduled build + PublishSourceScheduled PublishSource = "Scheduled" ) // PublishOptions contains options for publishing an image to the catalog @@ -153,12 +155,14 @@ func (p *Publisher) Publish(ctx context.Context, opts PublishOptions) (*PublishR }, nil } -// PublishFromImageBuild creates a CatalogImage from a completed ImageBuild +// PublishFromImageBuild creates a CatalogImage from a completed ImageBuild. func (p *Publisher) PublishFromImageBuild( ctx context.Context, imageBuild *automotivev1alpha1.ImageBuild, catalogName string, tags []string, + authSecretRef *automotivev1alpha1.AuthSecretReference, + source PublishSource, ) (*PublishResult, error) { log := p.log.WithValues("imageBuild", imageBuild.Name, "namespace", imageBuild.Namespace) @@ -179,10 +183,17 @@ func (p *Publisher) PublishFromImageBuild( } // Build metadata from ImageBuild + exportFormat := resolvedExportFormat(imageBuild) + if imageBuild.Spec.GetContainerPush() != "" { + exportFormat = "oci" + } + metadata := &automotivev1alpha1.CatalogImageMetadata{ Architecture: NormalizeArchitecture(imageBuild.Spec.Architecture), Distro: imageBuild.Spec.GetDistro(), BuildMode: imageBuild.Spec.GetMode(), + ExportFormat: exportFormat, + Bootc: imageBuild.Spec.GetMode() == "bootc", } // Add hardware target if specified @@ -192,7 +203,12 @@ func (p *Publisher) PublishFromImageBuild( } } - log.Info("Publishing ImageBuild to catalog", "catalogName", catalogName, "registryURL", registryURL) + publishSource := source + if publishSource == "" { + publishSource = PublishSourceImageBuild + } + + log.Info("Publishing ImageBuild to catalog", "catalogName", catalogName, "registryURL", registryURL, "source", publishSource) return p.Publish(ctx, PublishOptions{ Name: catalogName, @@ -200,7 +216,8 @@ func (p *Publisher) PublishFromImageBuild( RegistryURL: registryURL, Tags: tags, Metadata: metadata, - Source: PublishSourceImageBuild, + AuthSecretRef: authSecretRef, + Source: publishSource, SourceImageBuildName: imageBuild.Name, VerifyAccessibility: true, }) @@ -339,3 +356,10 @@ func (p *Publisher) Unpublish(ctx context.Context, name, namespace string) error log.Info("Successfully removed CatalogImage from catalog") return nil } + +func resolvedExportFormat(imageBuild *automotivev1alpha1.ImageBuild) string { + if imageBuild.Status.ResolvedExportFormat != "" { + return imageBuild.Status.ResolvedExportFormat + } + return imageBuild.Spec.GetExportFormat() +} diff --git a/internal/controller/catalogimage/publisher_test.go b/internal/controller/catalogimage/publisher_test.go new file mode 100644 index 000000000..45a003af6 --- /dev/null +++ b/internal/controller/catalogimage/publisher_test.go @@ -0,0 +1,109 @@ +/* +Copyright 2025. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package catalogimage + +import ( + "testing" + + automotivev1alpha1 "github.com/centos-automotive-suite/automotive-dev-operator/api/v1alpha1" +) + +func TestResolvedExportFormatForCatalog(t *testing.T) { + tests := []struct { + name string + spec automotivev1alpha1.ImageBuildSpec + statusFormat string + wantFormat string + wantBootc bool + }{ + { + name: "container push build shows oci format", + spec: automotivev1alpha1.ImageBuildSpec{ + AIB: &automotivev1alpha1.AIBSpec{Mode: "bootc"}, + Export: &automotivev1alpha1.ExportSpec{ + Container: "quay.io/test/img:latest", + Format: "qcow2", + }, + }, + statusFormat: "qcow2", + wantFormat: "oci", + wantBootc: true, + }, + { + name: "disk-only build shows resolved format", + spec: automotivev1alpha1.ImageBuildSpec{ + AIB: &automotivev1alpha1.AIBSpec{Mode: "image"}, + Export: &automotivev1alpha1.ExportSpec{ + Format: "simg", + Disk: &automotivev1alpha1.DiskExport{OCI: "quay.io/test/disk:latest"}, + }, + }, + statusFormat: "simg", + wantFormat: "simg", + wantBootc: false, + }, + { + name: "disk-only build uses status resolved format over spec", + spec: automotivev1alpha1.ImageBuildSpec{ + AIB: &automotivev1alpha1.AIBSpec{Mode: "image"}, + Export: &automotivev1alpha1.ExportSpec{ + Format: "qcow2", + Disk: &automotivev1alpha1.DiskExport{OCI: "quay.io/test/disk:latest"}, + }, + }, + statusFormat: "simg", + wantFormat: "simg", + wantBootc: false, + }, + { + name: "image mode with container push still shows oci", + spec: automotivev1alpha1.ImageBuildSpec{ + AIB: &automotivev1alpha1.AIBSpec{Mode: "image"}, + Export: &automotivev1alpha1.ExportSpec{ + Container: "quay.io/test/img:latest", + Format: "simg", + }, + }, + statusFormat: "simg", + wantFormat: "oci", + wantBootc: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ib := &automotivev1alpha1.ImageBuild{ + Spec: tt.spec, + Status: automotivev1alpha1.ImageBuildStatus{ResolvedExportFormat: tt.statusFormat}, + } + + exportFormat := resolvedExportFormat(ib) + if ib.Spec.GetContainerPush() != "" { + exportFormat = "oci" + } + + if exportFormat != tt.wantFormat { + t.Errorf("ExportFormat = %q, want %q", exportFormat, tt.wantFormat) + } + + bootc := ib.Spec.GetMode() == "bootc" + if bootc != tt.wantBootc { + t.Errorf("Bootc = %v, want %v", bootc, tt.wantBootc) + } + }) + } +} diff --git a/internal/controller/imagebuild/controller.go b/internal/controller/imagebuild/controller.go index 83e00e372..224193361 100644 --- a/internal/controller/imagebuild/controller.go +++ b/internal/controller/imagebuild/controller.go @@ -42,6 +42,7 @@ import ( ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/yaml" ) var ibTracer = otel.Tracer("imagebuild-controller") @@ -873,6 +874,8 @@ func (r *ImageBuildReconciler) createBuildTaskRun( log := r.buildLogger(imageBuild) log.Info("Creating PipelineRun for ImageBuild") + exportFormat := r.resolveExportFormat(ctx, imageBuild) + // Fetch OperatorConfig from the operator namespace to get build configuration operatorConfig := &automotivev1alpha1.OperatorConfig{} err := r.Get(ctx, types.NamespacedName{Name: "config", Namespace: controllerutils.OperatorNamespace()}, operatorConfig) @@ -988,7 +991,7 @@ func (r *ImageBuildReconciler) createBuildTaskRun( Name: "export-format", Value: tektonv1.ParamValue{ Type: tektonv1.ParamTypeString, - StringVal: imageBuild.Spec.GetExportFormat(), + StringVal: exportFormat, }, }, { @@ -1100,7 +1103,7 @@ func (r *ImageBuildReconciler) createBuildTaskRun( Name: "aib-extra-args", Value: tektonv1.ParamValue{ Type: tektonv1.ParamTypeString, - StringVal: strings.Join(imageBuild.Spec.GetAIBExtraArgs(), "\n"), + StringVal: strings.Join(r.resolveExtraArgs(ctx, imageBuild), "\n"), }, }, { @@ -1518,6 +1521,7 @@ func (r *ImageBuildReconciler) createBuildTaskRun( } fresh.Status.PipelineRunName = pipelineRun.Name + fresh.Status.ResolvedExportFormat = exportFormat if err := r.Status().Update(ctx, fresh); err != nil { return fmt.Errorf("failed to update ImageBuild with PipelineRun name: %w", err) } @@ -1572,7 +1576,7 @@ func (r *ImageBuildReconciler) createOrUpdateManifestConfigMap( if customDefs := imageBuild.Spec.GetCustomDefs(); len(customDefs) > 0 { cm.Data["custom-definitions.env"] = strings.Join(customDefs, "\n") } - if extraArgs := imageBuild.Spec.GetAIBExtraArgs(); len(extraArgs) > 0 { + if extraArgs := r.resolveExtraArgs(ctx, imageBuild); len(extraArgs) > 0 { cm.Data["aib-extra-args.txt"] = strings.Join(extraArgs, "\n") } if rootPw := imageBuild.Spec.GetRootPassword(); rootPw != "" { @@ -1615,10 +1619,9 @@ func (r *ImageBuildReconciler) createPushTaskRun(ctx context.Context, imageBuild return fmt.Errorf("target is required for push: aib.target must be set") } - exportFormat := imageBuild.Spec.GetExportFormat() - // exportFormat has a default of "qcow2", but validate anyway + exportFormat := imageBuild.Status.ResolvedExportFormat if exportFormat == "" { - return fmt.Errorf("export format is required for push") + exportFormat = r.resolveExportFormat(ctx, imageBuild) } pushSecretRef := imageBuild.Spec.GetPushSecretRef() @@ -1728,7 +1731,7 @@ func (r *ImageBuildReconciler) createPushTaskRun(ctx context.Context, imageBuild Name: "aib-extra-args", Value: tektonv1.ParamValue{ Type: tektonv1.ParamTypeString, - StringVal: strings.Join(imageBuild.Spec.GetAIBExtraArgs(), "\n"), + StringVal: strings.Join(r.resolveExtraArgs(ctx, imageBuild), "\n"), }, }, { @@ -2154,43 +2157,54 @@ func (r *ImageBuildReconciler) cleanupTransientSecrets( firstErr = err } } + uid := imageBuild.UID if imageBuild.Spec.SecretRef != "" { - collect(r.deleteSecret(ctx, imageBuild.Namespace, imageBuild.Spec.SecretRef, "registry auth", log)) + collect(r.deleteSecret(ctx, imageBuild.Namespace, imageBuild.Spec.SecretRef, "registry auth", log, uid)) } if imageBuild.Spec.PushSecretRef != "" { - collect(r.deleteSecret(ctx, imageBuild.Namespace, imageBuild.Spec.PushSecretRef, "push auth", log)) + collect(r.deleteSecret(ctx, imageBuild.Namespace, imageBuild.Spec.PushSecretRef, "push auth", log, uid)) } if flashSecretRef := imageBuild.Spec.GetFlashClientConfigSecretRef(); flashSecretRef != "" { - collect(r.deleteSecret(ctx, imageBuild.Namespace, flashSecretRef, "flash client config", log)) + collect(r.deleteSecret(ctx, imageBuild.Namespace, flashSecretRef, "flash client config", log, uid)) } - collect(r.deleteSecret(ctx, imageBuild.Namespace, imageBuild.Name+"-flash-oci-auth", "flash OCI auth", log)) + collect(r.deleteSecret(ctx, imageBuild.Namespace, imageBuild.Name+"-flash-oci-auth", "flash OCI auth", log, uid)) return firstErr } -// deleteSecret attempts to delete a secret. Returns nil on success or if -// the secret is already gone (NotFound). Returns the error on transient -// failure so the caller can schedule a retry. +// deleteSecret deletes a secret only if it is owned by the given ImageBuild +// (i.e. has a matching controller owner reference). User-provided shared +// secrets are left untouched. Returns nil on success, if the secret is +// already gone, or if it is not owned by this build. func (r *ImageBuildReconciler) deleteSecret( ctx context.Context, namespace, secretName, secretType string, log logr.Logger, + ownerUID types.UID, ) error { - secret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: secretName, - Namespace: namespace, - }, + secret := &corev1.Secret{} + if err := r.Get(ctx, types.NamespacedName{Name: secretName, Namespace: namespace}, secret); err != nil { + if errors.IsNotFound(err) { + return nil + } + log.Error(err, "Failed to get "+secretType+" secret (will retry)", "secret", secretName) + return err } - err := r.Delete(ctx, secret) - if err == nil { - log.Info("Deleted "+secretType+" secret", "secret", secretName) + + owner := metav1.GetControllerOf(secret) + if owner == nil || owner.UID != ownerUID { + log.V(1).Info("Skipping deletion of "+secretType+" secret not owned by this build", "secret", secretName) return nil } - if errors.IsNotFound(err) { - return nil + + if err := r.Delete(ctx, secret, client.Preconditions{UID: &secret.UID}); err != nil { + if errors.IsNotFound(err) { + return nil + } + log.Error(err, "Failed to delete "+secretType+" secret (will retry)", "secret", secretName) + return err } - log.Error(err, "Failed to delete "+secretType+" secret (will retry)", "secret", secretName) - return err + log.Info("Deleted "+secretType+" secret", "secret", secretName) + return nil } // SetupWithManager sets up the controller with the Manager. @@ -2603,6 +2617,71 @@ func (r *ImageBuildReconciler) resolveBuildConfig(ctx context.Context) *tasks.Bu return bc } +type targetDefaults struct { + DefaultFormat string `yaml:"defaultFormat"` + ExtraArgs []string `yaml:"extraArgs"` +} + +func (r *ImageBuildReconciler) getTargetDefaults(ctx context.Context, target string) *targetDefaults { + if target == "" { + return nil + } + cm := &corev1.ConfigMap{} + if err := r.Get(ctx, types.NamespacedName{ + Name: "aib-target-defaults", + Namespace: controllerutils.OperatorNamespace(), + }, cm); err != nil { + if !errors.IsNotFound(err) { + r.Log.Error(err, "Failed to read aib-target-defaults ConfigMap, falling back to defaults") + } + return nil + } + data, ok := cm.Data["target-defaults.yaml"] + if !ok { + return nil + } + var parsed struct { + Targets map[string]targetDefaults `yaml:"targets"` + } + if err := yaml.Unmarshal([]byte(data), &parsed); err != nil { + return nil + } + if t, ok := parsed.Targets[target]; ok { + return &t + } + return nil +} + +// resolveExportFormat returns the effective export format for a build. +// Priority: user-specified Export.Format > target-defaults ConfigMap defaultFormat > "qcow2". +func (r *ImageBuildReconciler) resolveExportFormat(ctx context.Context, imageBuild *automotivev1alpha1.ImageBuild) string { + if imageBuild.Spec.Export != nil && imageBuild.Spec.Export.Format != "" { + return imageBuild.Spec.Export.Format + } + target := imageBuild.Spec.GetTarget() + if td := r.getTargetDefaults(ctx, target); td != nil && td.DefaultFormat != "" { + r.buildLogger(imageBuild).Info("Resolved export format from target-defaults", + "target", target, "format", td.DefaultFormat) + return td.DefaultFormat + } + return "qcow2" +} + +// resolveExtraArgs returns the effective AIB extra args for a build. +// Priority: user-specified AIBExtraArgs > target-defaults ConfigMap extraArgs > empty. +func (r *ImageBuildReconciler) resolveExtraArgs(ctx context.Context, imageBuild *automotivev1alpha1.ImageBuild) []string { + if specArgs := imageBuild.Spec.GetAIBExtraArgs(); len(specArgs) > 0 { + return specArgs + } + target := imageBuild.Spec.GetTarget() + if td := r.getTargetDefaults(ctx, target); td != nil && len(td.ExtraArgs) > 0 { + r.buildLogger(imageBuild).Info("Resolved extra args from target-defaults", + "target", target, "extraArgs", td.ExtraArgs) + return td.ExtraArgs + } + return nil +} + func (r *ImageBuildReconciler) updateStatus( ctx context.Context, imageBuild *automotivev1alpha1.ImageBuild, diff --git a/internal/controller/imagebuild/controller_test.go b/internal/controller/imagebuild/controller_test.go index dc083761a..79a52dc35 100644 --- a/internal/controller/imagebuild/controller_test.go +++ b/internal/controller/imagebuild/controller_test.go @@ -7,6 +7,7 @@ import ( automotivev1alpha1 "github.com/centos-automotive-suite/automotive-dev-operator/api/v1alpha1" "github.com/centos-automotive-suite/automotive-dev-operator/internal/common/tasks" + controllerutils "github.com/centos-automotive-suite/automotive-dev-operator/internal/controller/controllerutils" tektonv1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/meta" @@ -639,6 +640,271 @@ func TestEnsureImageStreamOwnerRefNoMatch(t *testing.T) { } } +func TestResolveExportFormat(t *testing.T) { + tests := []struct { + name string + spec automotivev1alpha1.ImageBuildSpec + configMap *corev1.ConfigMap + wantFormat string + }{ + { + name: "user-specified format wins", + spec: automotivev1alpha1.ImageBuildSpec{ + AIB: &automotivev1alpha1.AIBSpec{Target: "ebbr"}, + Export: &automotivev1alpha1.ExportSpec{Format: "raw"}, + }, + wantFormat: "raw", + }, + { + name: "target-defaults ConfigMap provides format", + spec: automotivev1alpha1.ImageBuildSpec{ + AIB: &automotivev1alpha1.AIBSpec{Target: "ebbr"}, + }, + configMap: &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "aib-target-defaults", + Namespace: controllerutils.OperatorNamespace(), + }, + Data: map[string]string{ + "target-defaults.yaml": "targets:\n ebbr:\n defaultFormat: simg\n qemu:\n defaultFormat: raw\n", + }, + }, + wantFormat: "simg", + }, + { + name: "target not in ConfigMap falls back to qcow2", + spec: automotivev1alpha1.ImageBuildSpec{ + AIB: &automotivev1alpha1.AIBSpec{Target: "unknown-board"}, + }, + configMap: &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "aib-target-defaults", + Namespace: controllerutils.OperatorNamespace(), + }, + Data: map[string]string{ + "target-defaults.yaml": "targets:\n ebbr:\n defaultFormat: simg\n", + }, + }, + wantFormat: "qcow2", + }, + { + name: "no ConfigMap falls back to qcow2", + spec: automotivev1alpha1.ImageBuildSpec{ + AIB: &automotivev1alpha1.AIBSpec{Target: "ebbr"}, + }, + wantFormat: "qcow2", + }, + { + name: "no target falls back to qcow2", + spec: automotivev1alpha1.ImageBuildSpec{}, + wantFormat: "qcow2", + }, + { + name: "user-specified format overrides ConfigMap", + spec: automotivev1alpha1.ImageBuildSpec{ + AIB: &automotivev1alpha1.AIBSpec{Target: "ebbr"}, + Export: &automotivev1alpha1.ExportSpec{Format: "qcow2"}, + }, + configMap: &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "aib-target-defaults", + Namespace: controllerutils.OperatorNamespace(), + }, + Data: map[string]string{ + "target-defaults.yaml": "targets:\n ebbr:\n defaultFormat: simg\n", + }, + }, + wantFormat: "qcow2", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + scheme := newTestSchemeWithTekton() + builder := fake.NewClientBuilder().WithScheme(scheme) + if tt.configMap != nil { + builder = builder.WithObjects(tt.configMap) + } + r := &ImageBuildReconciler{Client: builder.Build(), Scheme: scheme} + + ib := &automotivev1alpha1.ImageBuild{ + ObjectMeta: metav1.ObjectMeta{Name: "test-build", Namespace: "test-ns"}, + Spec: tt.spec, + } + + got := r.resolveExportFormat(context.Background(), ib) + if got != tt.wantFormat { + t.Errorf("resolveExportFormat() = %q, want %q", got, tt.wantFormat) + } + }) + } +} + +func TestResolveExtraArgs(t *testing.T) { + tests := []struct { + name string + spec automotivev1alpha1.ImageBuildSpec + configMap *corev1.ConfigMap + wantArgs []string + }{ + { + name: "user-specified extra args win", + spec: automotivev1alpha1.ImageBuildSpec{ + AIB: &automotivev1alpha1.AIBSpec{ + Target: "ride4_sa8775p_sx", + AIBExtraArgs: []string{"--custom-flag"}, + }, + }, + configMap: &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "aib-target-defaults", + Namespace: controllerutils.OperatorNamespace(), + }, + Data: map[string]string{ + "target-defaults.yaml": "targets:\n ride4_sa8775p_sx:\n extraArgs:\n - --separate-partitions\n", + }, + }, + wantArgs: []string{"--custom-flag"}, + }, + { + name: "ConfigMap provides extra args", + spec: automotivev1alpha1.ImageBuildSpec{ + AIB: &automotivev1alpha1.AIBSpec{Target: "ride4_sa8775p_sx"}, + }, + configMap: &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "aib-target-defaults", + Namespace: controllerutils.OperatorNamespace(), + }, + Data: map[string]string{ + "target-defaults.yaml": "targets:\n ride4_sa8775p_sx:\n extraArgs:\n - --separate-partitions\n", + }, + }, + wantArgs: []string{"--separate-partitions"}, + }, + { + name: "target without extra args returns nil", + spec: automotivev1alpha1.ImageBuildSpec{ + AIB: &automotivev1alpha1.AIBSpec{Target: "qemu"}, + }, + configMap: &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "aib-target-defaults", + Namespace: controllerutils.OperatorNamespace(), + }, + Data: map[string]string{ + "target-defaults.yaml": "targets:\n qemu:\n defaultFormat: raw\n", + }, + }, + wantArgs: nil, + }, + { + name: "no ConfigMap returns nil", + spec: automotivev1alpha1.ImageBuildSpec{ + AIB: &automotivev1alpha1.AIBSpec{Target: "ride4_sa8775p_sx"}, + }, + wantArgs: nil, + }, + { + name: "no target returns nil", + spec: automotivev1alpha1.ImageBuildSpec{}, + wantArgs: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + scheme := newTestSchemeWithTekton() + builder := fake.NewClientBuilder().WithScheme(scheme) + if tt.configMap != nil { + builder = builder.WithObjects(tt.configMap) + } + r := &ImageBuildReconciler{Client: builder.Build(), Scheme: scheme} + + ib := &automotivev1alpha1.ImageBuild{ + ObjectMeta: metav1.ObjectMeta{Name: "test-build", Namespace: "test-ns"}, + Spec: tt.spec, + } + + got := r.resolveExtraArgs(context.Background(), ib) + if len(got) != len(tt.wantArgs) { + t.Errorf("resolveExtraArgs() = %v, want %v", got, tt.wantArgs) + return + } + for i := range got { + if got[i] != tt.wantArgs[i] { + t.Errorf("resolveExtraArgs()[%d] = %q, want %q", i, got[i], tt.wantArgs[i]) + } + } + }) + } +} + +func TestPushUsesPersistedExportFormat(t *testing.T) { + tests := []struct { + name string + statusFormat string + configMapFormat string + wantUsedByPush string + }{ + { + name: "uses persisted format from status", + statusFormat: "simg", + configMapFormat: "raw", + wantUsedByPush: "simg", + }, + { + name: "falls back to resolve when status empty", + statusFormat: "", + configMapFormat: "raw", + wantUsedByPush: "raw", + }, + { + name: "falls back to qcow2 when both empty", + statusFormat: "", + configMapFormat: "", + wantUsedByPush: "qcow2", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + scheme := newTestSchemeWithTekton() + builder := fake.NewClientBuilder().WithScheme(scheme) + if tt.configMapFormat != "" { + builder = builder.WithObjects(&corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "aib-target-defaults", + Namespace: controllerutils.OperatorNamespace(), + }, + Data: map[string]string{ + "target-defaults.yaml": "targets:\n ebbr:\n defaultFormat: " + tt.configMapFormat + "\n", + }, + }) + } + r := &ImageBuildReconciler{Client: builder.Build(), Scheme: scheme} + + ib := &automotivev1alpha1.ImageBuild{ + ObjectMeta: metav1.ObjectMeta{Name: "test-build", Namespace: "test-ns"}, + Spec: automotivev1alpha1.ImageBuildSpec{ + AIB: &automotivev1alpha1.AIBSpec{Target: "ebbr"}, + }, + Status: automotivev1alpha1.ImageBuildStatus{ + ResolvedExportFormat: tt.statusFormat, + }, + } + + got := ib.Status.ResolvedExportFormat + if got == "" { + got = r.resolveExportFormat(context.Background(), ib) + } + if got != tt.wantUsedByPush { + t.Errorf("push format = %q, want %q", got, tt.wantUsedByPush) + } + }) + } +} + func TestOCIRepoVolumes(t *testing.T) { tests := []struct { name string diff --git a/internal/controller/scheduledimagebuild/controller.go b/internal/controller/scheduledimagebuild/controller.go new file mode 100644 index 000000000..53b0b215d --- /dev/null +++ b/internal/controller/scheduledimagebuild/controller.go @@ -0,0 +1,728 @@ +/* +Copyright 2025. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package scheduledimagebuild implements the ScheduledImageBuild controller. +package scheduledimagebuild + +import ( + "context" + "crypto/sha256" + "fmt" + "sort" + "strings" + "time" + + "github.com/go-logr/logr" + "github.com/robfig/cron/v3" + corev1 "k8s.io/api/core/v1" + apiequality "k8s.io/apimachinery/pkg/api/equality" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/record" + clockutil "k8s.io/utils/clock" + "k8s.io/utils/ptr" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + + automotivev1alpha1 "github.com/centos-automotive-suite/automotive-dev-operator/api/v1alpha1" + "github.com/centos-automotive-suite/automotive-dev-operator/internal/controller/catalogimage" +) + +// Controller constants. +const ( + maxK8sNameLength = 63 + + AnnotationCatalogPublished = "automotive.sdv.cloud.redhat.com/catalog-published" + AnnotationCatalogPublishedValue = "true" + + ConditionScheduled = "Scheduled" + ConditionLastBuildSucceeded = "LastBuildSucceeded" + ConditionLastPublishSucceeded = "LastPublishSucceeded" +) + +var cronParser = cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow) + +// CatalogPublisher abstracts catalog publishing for testability. +type CatalogPublisher interface { + PublishFromImageBuild( + ctx context.Context, + imageBuild *automotivev1alpha1.ImageBuild, + catalogName string, + tags []string, + authSecretRef *automotivev1alpha1.AuthSecretReference, + ) (*catalogimage.PublishResult, error) +} + +// Reconciler reconciles ScheduledImageBuild objects. +// +//nolint:revive // Name follows Kubebuilder convention +type Reconciler struct { + client.Client + Scheme *runtime.Scheme + Log logr.Logger + Recorder record.EventRecorder + Clock clockutil.Clock + Publisher CatalogPublisher +} + +// +kubebuilder:rbac:groups=automotive.sdv.cloud.redhat.com,namespace=system,resources=scheduledimagebuilds,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=automotive.sdv.cloud.redhat.com,namespace=system,resources=scheduledimagebuilds/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=automotive.sdv.cloud.redhat.com,namespace=system,resources=scheduledimagebuilds/finalizers,verbs=update +// +kubebuilder:rbac:groups=automotive.sdv.cloud.redhat.com,namespace=system,resources=imagebuilds,verbs=get;list;watch;create;delete;update;patch +// +kubebuilder:rbac:groups=automotive.sdv.cloud.redhat.com,namespace=system,resources=catalogimages,verbs=get;list;create +// +kubebuilder:rbac:groups="",namespace=system,resources=events,verbs=create;patch + +// Reconcile handles a single ScheduledImageBuild reconciliation loop. +func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + log := r.Log.WithValues("scheduledimagebuild", req.NamespacedName) + + sib := &automotivev1alpha1.ScheduledImageBuild{} + if err := r.Get(ctx, req.NamespacedName, sib); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + savedStatus := sib.Status.DeepCopy() + + now := r.now() + + childBuilds, err := r.listOwnedBuilds(ctx, sib) + if err != nil { + log.Error(err, "Failed to list owned ImageBuilds") + return ctrl.Result{}, err + } + + active, finished := classifyBuilds(childBuilds) + + r.handleCompletedBuilds(ctx, sib, finished) + + if err := r.cleanupHistory(ctx, sib, finished); err != nil { + log.Error(err, "Failed to cleanup history") + return ctrl.Result{}, err + } + + r.updateActiveStatus(sib, active) + + if isSuspended(sib) { + return r.handleSuspended(ctx, sib, savedStatus) + } + + schedule, err := parseScheduleUTC(sib.Spec.Schedule) + if err != nil { + log.Error(err, "Invalid cron schedule", "schedule", sib.Spec.Schedule) + r.setScheduledCondition(sib, metav1.ConditionFalse, "InvalidSchedule", err.Error()) + if !apiequality.Semantic.DeepEqual(savedStatus, &sib.Status) { + if statusErr := r.Status().Update(ctx, sib); statusErr != nil { + return ctrl.Result{}, statusErr + } + } + return ctrl.Result{}, nil + } + + missedRun, nextRun := r.getMissedAndNext(sib, schedule, now) + + shouldCreate, err := r.checkConcurrency(ctx, sib, active, missedRun) + if err != nil { + return ctrl.Result{}, err + } + + if shouldCreate && missedRun != nil { + count, err := r.createImageBuilds(ctx, sib, *missedRun) + if err != nil { + log.Error(err, "Failed to create ImageBuild") + r.Recorder.Eventf(sib, corev1.EventTypeWarning, "CreateFailed", "Failed to create ImageBuild: %v", err) + return ctrl.Result{}, err + } + + sib.Status.LastScheduleTime = &metav1.Time{Time: *missedRun} + r.Recorder.Eventf(sib, corev1.EventTypeNormal, "BuildCreated", "Created %d scheduled ImageBuild(s)", count) + } + + requeueAfter := nextRun.Sub(now) + if requeueAfter < 0 { + requeueAfter = time.Second + } + + sib.Status.Phase = automotivev1alpha1.ScheduledImageBuildPhaseActive + sib.Status.ObservedGeneration = sib.Generation + r.setScheduledCondition(sib, metav1.ConditionTrue, "Scheduled", fmt.Sprintf("Next run at %s", nextRun.Format(time.RFC3339))) + + if !apiequality.Semantic.DeepEqual(savedStatus, &sib.Status) { + if err := r.Status().Update(ctx, sib); err != nil { + return ctrl.Result{}, err + } + } + + return ctrl.Result{RequeueAfter: requeueAfter}, nil +} + +func (r *Reconciler) handleSuspended(ctx context.Context, sib *automotivev1alpha1.ScheduledImageBuild, savedStatus *automotivev1alpha1.ScheduledImageBuildStatus) (ctrl.Result, error) { + sib.Status.Phase = automotivev1alpha1.ScheduledImageBuildPhaseSuspended + sib.Status.ObservedGeneration = sib.Generation + r.setScheduledCondition(sib, metav1.ConditionFalse, "Suspended", "Schedule is suspended") + + if !apiequality.Semantic.DeepEqual(savedStatus, &sib.Status) { + if err := r.Status().Update(ctx, sib); err != nil { + return ctrl.Result{}, err + } + } + return ctrl.Result{}, nil +} + +func (r *Reconciler) listOwnedBuilds(ctx context.Context, sib *automotivev1alpha1.ScheduledImageBuild) ([]automotivev1alpha1.ImageBuild, error) { + var buildList automotivev1alpha1.ImageBuildList + err := r.List(ctx, &buildList, + client.InNamespace(sib.Namespace), + client.MatchingLabels{automotivev1alpha1.LabelScheduledImageBuildName: sib.Name}, + ) + if err != nil { + return nil, err + } + + var owned []automotivev1alpha1.ImageBuild + for i := range buildList.Items { + build := &buildList.Items[i] + if isOwnedBy(build, sib) { + owned = append(owned, *build) + } + } + return owned, nil +} + +func classifyBuilds(builds []automotivev1alpha1.ImageBuild) (active, finished []automotivev1alpha1.ImageBuild) { + for i := range builds { + if automotivev1alpha1.IsTerminalBuildPhase(builds[i].Status.Phase) { + finished = append(finished, builds[i]) + } else { + active = append(active, builds[i]) + } + } + return +} + +func (r *Reconciler) cleanupHistory(ctx context.Context, sib *automotivev1alpha1.ScheduledImageBuild, finished []automotivev1alpha1.ImageBuild) error { + successLimit := int32(3) + if sib.Spec.SuccessfulBuildsHistoryLimit != nil { + successLimit = *sib.Spec.SuccessfulBuildsHistoryLimit + } + failedLimit := int32(1) + if sib.Spec.FailedBuildsHistoryLimit != nil { + failedLimit = *sib.Spec.FailedBuildsHistoryLimit + } + + var successful, failed []automotivev1alpha1.ImageBuild + for i := range finished { + if finished[i].Status.Phase == automotivev1alpha1.ImageBuildPhaseCompleted { + successful = append(successful, finished[i]) + } else { + failed = append(failed, finished[i]) + } + } + + sortByCreation(successful) + sortByCreation(failed) + + retainUnpublished := sib.Spec.PublishToCatalog != nil && sib.Spec.PublishToCatalog.Enabled + if err := r.deleteExcess(ctx, successful, int(successLimit), retainUnpublished); err != nil { + return err + } + return r.deleteExcess(ctx, failed, int(failedLimit), false) +} + +func sortByCreation(builds []automotivev1alpha1.ImageBuild) { + sort.Slice(builds, func(i, j int) bool { + return builds[i].CreationTimestamp.Before(&builds[j].CreationTimestamp) + }) +} + +func (r *Reconciler) deleteExcess(ctx context.Context, builds []automotivev1alpha1.ImageBuild, limit int, retainUnpublished bool) error { + if len(builds) <= limit { + return nil + } + maxUnpublished := limit * 2 + if maxUnpublished < 5 { + maxUnpublished = 5 + } + unpublishedCount := 0 + excess := builds[:len(builds)-limit] + for i := range excess { + if retainUnpublished && excess[i].Annotations[AnnotationCatalogPublished] == "" { + unpublishedCount++ + if unpublishedCount <= maxUnpublished { + r.Log.Info("Retaining unpublished ImageBuild", "name", excess[i].Name) + continue + } + r.Log.Info("Deleting unpublished ImageBuild (exceeded retention cap)", "name", excess[i].Name, "cap", maxUnpublished) + } + if err := r.Delete(ctx, &excess[i]); err != nil && !apierrors.IsNotFound(err) { + return fmt.Errorf("failed to delete old ImageBuild %s: %w", excess[i].Name, err) + } + r.Log.Info("Deleted old ImageBuild", "name", excess[i].Name) + } + return nil +} + +func (r *Reconciler) handleCompletedBuilds(ctx context.Context, sib *automotivev1alpha1.ScheduledImageBuild, finished []automotivev1alpha1.ImageBuild) { + for i := range finished { + build := &finished[i] + + switch build.Status.Phase { + case automotivev1alpha1.ImageBuildPhaseCompleted: + completionTime := build.Status.CompletionTime + if completionTime == nil { + completionTime = &metav1.Time{Time: build.CreationTimestamp.Time} + } + if sib.Status.LastSuccessfulTime == nil || completionTime.After(sib.Status.LastSuccessfulTime.Time) { + sib.Status.LastSuccessfulTime = completionTime + r.Recorder.Eventf(sib, corev1.EventTypeNormal, "BuildSucceeded", + "ImageBuild %s completed successfully", build.Name) + r.setLastBuildCondition(sib, metav1.ConditionTrue, "BuildSucceeded", + fmt.Sprintf("ImageBuild %s completed successfully", build.Name)) + } + + if r.shouldPublish(sib, build) { + r.publishToCatalog(ctx, sib, build) + } + + case automotivev1alpha1.ImageBuildPhaseFailed: + completionTime := build.Status.CompletionTime + if completionTime == nil { + completionTime = &metav1.Time{Time: build.CreationTimestamp.Time} + } + if sib.Status.LastFailedTime == nil || completionTime.After(sib.Status.LastFailedTime.Time) { + sib.Status.LastFailedTime = completionTime + msg := build.Status.Message + if msg == "" { + msg = "unknown error" + } + r.Recorder.Eventf(sib, corev1.EventTypeWarning, "BuildFailed", + "ImageBuild %s failed: %s", build.Name, msg) + r.setLastBuildCondition(sib, metav1.ConditionFalse, "BuildFailed", + fmt.Sprintf("ImageBuild %s failed: %s", build.Name, msg)) + } + } + } +} + +func (r *Reconciler) shouldPublish(sib *automotivev1alpha1.ScheduledImageBuild, build *automotivev1alpha1.ImageBuild) bool { + if sib.Spec.PublishToCatalog == nil || !sib.Spec.PublishToCatalog.Enabled { + return false + } + if build.Annotations != nil && build.Annotations[AnnotationCatalogPublished] == AnnotationCatalogPublishedValue { + return false + } + return true +} + +func (r *Reconciler) publishToCatalog(ctx context.Context, sib *automotivev1alpha1.ScheduledImageBuild, build *automotivev1alpha1.ImageBuild) { + log := r.Log.WithValues("imagebuild", build.Name) + + if r.Publisher == nil { + log.Info("No publisher configured, skipping catalog publish") + return + } + + // Re-fetch to avoid double-publish when two reconciles race + fresh := &automotivev1alpha1.ImageBuild{} + if err := r.Get(ctx, client.ObjectKeyFromObject(build), fresh); err != nil { + log.Error(err, "Failed to re-fetch ImageBuild before publish") + return + } + if fresh.Annotations != nil && fresh.Annotations[AnnotationCatalogPublished] == AnnotationCatalogPublishedValue { + log.Info("ImageBuild already published (detected on re-fetch), skipping") + return + } + + var tags []string + var authSecretRef *automotivev1alpha1.AuthSecretReference + if sib.Spec.PublishToCatalog != nil { + tags = sib.Spec.PublishToCatalog.Tags + authSecretRef = sib.Spec.PublishToCatalog.AuthSecretRef + } + + result, err := r.Publisher.PublishFromImageBuild(ctx, build, "", tags, authSecretRef) + if err != nil { + log.Error(err, "Failed to publish to catalog") + r.Recorder.Eventf(sib, corev1.EventTypeWarning, "PublishFailed", + "Failed to publish ImageBuild %s to catalog: %v", build.Name, err) + meta.SetStatusCondition(&sib.Status.Conditions, metav1.Condition{ + Type: ConditionLastPublishSucceeded, + Status: metav1.ConditionFalse, + Reason: "PublishFailed", + Message: fmt.Sprintf("Failed to publish ImageBuild %s: %v", build.Name, err), + ObservedGeneration: sib.Generation, + }) + return + } + + patch := client.MergeFrom(build.DeepCopy()) + if build.Annotations == nil { + build.Annotations = make(map[string]string) + } + build.Annotations[AnnotationCatalogPublished] = AnnotationCatalogPublishedValue + if err := r.Patch(ctx, build, patch); err != nil { + log.Error(err, "Failed to annotate ImageBuild as published") + } + + reason := "Published" + if result != nil && result.Verified { + reason = "PublishedAndVerified" + } + meta.SetStatusCondition(&sib.Status.Conditions, metav1.Condition{ + Type: ConditionLastPublishSucceeded, + Status: metav1.ConditionTrue, + Reason: reason, + Message: fmt.Sprintf("ImageBuild %s published to catalog", build.Name), + ObservedGeneration: sib.Generation, + }) + r.Recorder.Eventf(sib, corev1.EventTypeNormal, "Published", + "Published ImageBuild %s to catalog", build.Name) +} + +func (r *Reconciler) updateActiveStatus(sib *automotivev1alpha1.ScheduledImageBuild, active []automotivev1alpha1.ImageBuild) { + gvk := automotivev1alpha1.GroupVersion.WithKind("ImageBuild") + sib.Status.Active = make([]corev1.ObjectReference, 0, len(active)) + for i := range active { + sib.Status.Active = append(sib.Status.Active, corev1.ObjectReference{ + APIVersion: gvk.GroupVersion().String(), + Kind: gvk.Kind, + Name: active[i].Name, + Namespace: active[i].Namespace, + UID: active[i].UID, + }) + } +} + +func (r *Reconciler) checkConcurrency(ctx context.Context, sib *automotivev1alpha1.ScheduledImageBuild, active []automotivev1alpha1.ImageBuild, missedRun *time.Time) (bool, error) { + if len(active) == 0 { + return true, nil + } + + if missedRun == nil { + return false, nil + } + + switch sib.Spec.ConcurrencyPolicy { + case automotivev1alpha1.AllowConcurrent: + return true, nil + + case automotivev1alpha1.ReplaceConcurrent: + for i := range active { + r.Log.Info("Deleting active ImageBuild for Replace policy", "name", active[i].Name) + if err := r.Delete(ctx, &active[i]); err != nil && !apierrors.IsNotFound(err) { + return false, fmt.Errorf("failed to delete active ImageBuild %s: %w", active[i].Name, err) + } + r.Recorder.Eventf(sib, corev1.EventTypeNormal, "Replaced", + "Deleted active ImageBuild %s for replacement", active[i].Name) + } + return true, nil + + default: // Forbid + r.Log.Info("Skipping build creation, active build exists (Forbid policy)", "active", len(active)) + return false, nil + } +} + +func (r *Reconciler) getMissedAndNext( + sib *automotivev1alpha1.ScheduledImageBuild, + schedule cron.Schedule, + now time.Time, +) (*time.Time, time.Time) { + var refTime time.Time + if sib.Status.LastScheduleTime != nil { + refTime = sib.Status.LastScheduleTime.Time + } else { + refTime = sib.CreationTimestamp.Time + } + + // Pre-clamp refTime with deadline window to avoid iterating over a huge gap + if sib.Spec.StartingDeadlineSeconds != nil { + earliest := now.Add(-time.Duration(*sib.Spec.StartingDeadlineSeconds) * time.Second) + if refTime.Before(earliest) { + refTime = earliest + } + } + + const maxIterations = 1000 + var mostRecent *time.Time + count := 0 + for t := schedule.Next(refTime); !t.After(now); t = schedule.Next(t) { + ts := t + mostRecent = &ts + count++ + if count >= maxIterations { + r.Log.Info("Missed-run scan capped", "iterations", maxIterations) + break + } + } + + if sib.Spec.StartingDeadlineSeconds != nil && mostRecent != nil { + deadline := now.Add(-time.Duration(*sib.Spec.StartingDeadlineSeconds) * time.Second) + if mostRecent.Before(deadline) { + mostRecent = nil + } + } + + nextRun := schedule.Next(now) + + if mostRecent == nil { + return nil, nextRun + } + + return mostRecent, nextRun +} + +type matrixCombo struct { + Architecture string + Distro string + Target string +} + +func expandMatrix(sib *automotivev1alpha1.ScheduledImageBuild) []matrixCombo { + if sib.Spec.Matrix == nil { + return []matrixCombo{{}} + } + + arches := sib.Spec.Matrix.Architectures + if len(arches) == 0 { + arches = []string{""} + } + distros := sib.Spec.Matrix.Distros + if len(distros) == 0 { + distros = []string{""} + } + targets := sib.Spec.Matrix.Targets + if len(targets) == 0 { + targets = []string{""} + } + + combos := make([]matrixCombo, 0, len(arches)*len(distros)*len(targets)) + for _, arch := range arches { + for _, distro := range distros { + for _, target := range targets { + combos = append(combos, matrixCombo{ + Architecture: arch, + Distro: distro, + Target: target, + }) + } + } + } + return combos +} + +func sanitizeNamePart(s string) string { + return strings.ReplaceAll(strings.ToLower(s), "_", "-") +} + +func (c matrixCombo) suffix() string { + var parts []string + if c.Architecture != "" { + parts = append(parts, sanitizeNamePart(c.Architecture)) + } + if c.Distro != "" { + parts = append(parts, sanitizeNamePart(c.Distro)) + } + if c.Target != "" { + parts = append(parts, sanitizeNamePart(c.Target)) + } + if len(parts) == 0 { + return "" + } + return "-" + strings.Join(parts, "-") +} + +func (r *Reconciler) createImageBuilds(ctx context.Context, sib *automotivev1alpha1.ScheduledImageBuild, scheduledTime time.Time) (int, error) { + combos := expandMatrix(sib) + for _, combo := range combos { + if err := r.createSingleImageBuild(ctx, sib, scheduledTime, combo); err != nil { + return 0, err + } + } + return len(combos), nil +} + +func (r *Reconciler) createSingleImageBuild(ctx context.Context, sib *automotivev1alpha1.ScheduledImageBuild, scheduledTime time.Time, combo matrixCombo) error { + tsSuffix := fmt.Sprintf("-%x", scheduledTime.Unix()) + name := safeDerivedName(sib.Name, combo.suffix()+tsSuffix) + + labels := make(map[string]string) + for k, v := range sib.Spec.ImageBuildTemplate.Metadata.Labels { + labels[k] = v + } + labels[automotivev1alpha1.LabelScheduledImageBuildName] = sib.Name + + if combo.Architecture != "" { + labels[automotivev1alpha1.LabelArchitecture] = combo.Architecture + } + if combo.Distro != "" { + labels[automotivev1alpha1.LabelDistro] = combo.Distro + } + if combo.Target != "" { + labels[automotivev1alpha1.LabelTarget] = combo.Target + } + + annotations := make(map[string]string) + for k, v := range sib.Spec.ImageBuildTemplate.Metadata.Annotations { + annotations[k] = v + } + + spec := sib.Spec.ImageBuildTemplate.Spec.DeepCopy() + if combo.Architecture != "" { + spec.Architecture = combo.Architecture + } + if combo.Distro != "" && spec.AIB != nil { + spec.AIB.Distro = combo.Distro + } + if combo.Target != "" && spec.AIB != nil { + spec.AIB.Target = combo.Target + } + + if suffix := combo.suffix(); suffix != "" { + if spec.Export != nil && spec.Export.Disk != nil && spec.Export.Disk.OCI != "" { + spec.Export.Disk.OCI = appendOCITagSuffix(spec.Export.Disk.OCI, suffix) + } + if spec.Export != nil && spec.Export.Container != "" { + spec.Export.Container = appendOCITagSuffix(spec.Export.Container, suffix) + } + } + + imageBuild := &automotivev1alpha1.ImageBuild{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: sib.Namespace, + Labels: labels, + Annotations: annotations, + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: automotivev1alpha1.GroupVersion.String(), + Kind: "ScheduledImageBuild", + Name: sib.Name, + UID: sib.UID, + Controller: ptr.To(true), + BlockOwnerDeletion: ptr.To(true), + }, + }, + }, + Spec: *spec, + } + + if err := r.Create(ctx, imageBuild); err != nil { + if apierrors.IsAlreadyExists(err) { + existing := &automotivev1alpha1.ImageBuild{} + if getErr := r.Get(ctx, client.ObjectKeyFromObject(imageBuild), existing); getErr != nil { + return fmt.Errorf("failed to get existing ImageBuild %s: %w", name, getErr) + } + if metav1.IsControlledBy(existing, sib) { + r.Log.Info("ImageBuild already exists (owned by this SIB), treating as success", "name", name) + return nil + } + } + return fmt.Errorf("failed to create ImageBuild %s: %w", name, err) + } + + r.Log.Info("Created ImageBuild", "name", name, "scheduledTime", scheduledTime, "matrix", combo) + return nil +} + +func (r *Reconciler) setScheduledCondition(sib *automotivev1alpha1.ScheduledImageBuild, status metav1.ConditionStatus, reason, message string) { + meta.SetStatusCondition(&sib.Status.Conditions, metav1.Condition{ + Type: ConditionScheduled, + Status: status, + Reason: reason, + Message: message, + ObservedGeneration: sib.Generation, + }) +} + +func (r *Reconciler) setLastBuildCondition(sib *automotivev1alpha1.ScheduledImageBuild, status metav1.ConditionStatus, reason, message string) { + meta.SetStatusCondition(&sib.Status.Conditions, metav1.Condition{ + Type: ConditionLastBuildSucceeded, + Status: status, + Reason: reason, + Message: message, + ObservedGeneration: sib.Generation, + }) +} + +func (r *Reconciler) now() time.Time { + if r.Clock != nil { + return r.Clock.Now() + } + return time.Now() +} + +// SetupWithManager registers the controller with the manager. +func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&automotivev1alpha1.ScheduledImageBuild{}). + Owns(&automotivev1alpha1.ImageBuild{}). + Complete(r) +} + +func isSuspended(sib *automotivev1alpha1.ScheduledImageBuild) bool { + return sib.Spec.Suspend != nil && *sib.Spec.Suspend +} + +func isOwnedBy(build *automotivev1alpha1.ImageBuild, owner *automotivev1alpha1.ScheduledImageBuild) bool { + for _, ref := range build.OwnerReferences { + if ref.UID == owner.UID { + return true + } + } + return false +} + +func parseScheduleUTC(expr string) (cron.Schedule, error) { + sched, err := cronParser.Parse(expr) + if err != nil { + return nil, err + } + if specSched, ok := sched.(*cron.SpecSchedule); ok { + specSched.Location = time.UTC + } + return sched, nil +} + +func appendOCITagSuffix(ref, suffix string) string { + if i := strings.LastIndex(ref, ":"); i > strings.LastIndex(ref, "/") { + return ref[:i] + ":" + ref[i+1:] + suffix + } + return ref + ":latest" + suffix +} + +func safeDerivedName(baseName, suffix string) string { + maxBaseLength := maxK8sNameLength - len(suffix) - 9 + + if maxBaseLength >= len(baseName) { + return fmt.Sprintf("%s%s", baseName, suffix) + } + + hash := sha256.Sum256([]byte(baseName)) + hexHash := fmt.Sprintf("%x", hash[:4]) + + if maxBaseLength <= 0 { + name := hexHash + suffix + if len(name) > maxK8sNameLength { + name = name[:maxK8sNameLength] + } + return name + } + + truncated := baseName[:maxBaseLength] + return fmt.Sprintf("%s-%s%s", truncated, hexHash, suffix) +} diff --git a/internal/controller/scheduledimagebuild/controller_test.go b/internal/controller/scheduledimagebuild/controller_test.go new file mode 100644 index 000000000..5520a1837 --- /dev/null +++ b/internal/controller/scheduledimagebuild/controller_test.go @@ -0,0 +1,917 @@ +package scheduledimagebuild + +import ( + "context" + "testing" + "time" + + automotivev1alpha1 "github.com/centos-automotive-suite/automotive-dev-operator/api/v1alpha1" + "github.com/centos-automotive-suite/automotive-dev-operator/internal/controller/catalogimage" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/tools/record" + clocktesting "k8s.io/utils/clock/testing" + "k8s.io/utils/ptr" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/log/zap" +) + +const ( + archX86 = "x86_64" + archARM = "aarch64" + distroASD = "autosd" + targetQ = "qemu" +) + +func newScheme() *runtime.Scheme { + s := runtime.NewScheme() + utilruntime.Must(clientgoscheme.AddToScheme(s)) + utilruntime.Must(automotivev1alpha1.AddToScheme(s)) + return s +} + +func newReconciler(objs []runtime.Object, now time.Time) *Reconciler { + scheme := newScheme() + clock := clocktesting.NewFakeClock(now) + + builder := fake.NewClientBuilder().WithScheme(scheme) + for _, obj := range objs { + builder = builder.WithRuntimeObjects(obj) + } + builder = builder.WithStatusSubresource( + &automotivev1alpha1.ScheduledImageBuild{}, + &automotivev1alpha1.ImageBuild{}, + ) + client := builder.Build() + + return &Reconciler{ + Client: client, + Scheme: scheme, + Log: zap.New(zap.UseDevMode(true)), + Recorder: record.NewFakeRecorder(100), + Clock: clock, + } +} + +func baseSIB(name string) *automotivev1alpha1.ScheduledImageBuild { + return &automotivev1alpha1.ScheduledImageBuild{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "automotive.sdv.cloud.redhat.com/v1alpha1", + Kind: "ScheduledImageBuild", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: "default", + UID: types.UID("test-uid-" + name), + CreationTimestamp: metav1.NewTime(time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)), + Generation: 1, + }, + Spec: automotivev1alpha1.ScheduledImageBuildSpec{ + Schedule: "0 2 * * *", // daily at 2am + ConcurrencyPolicy: automotivev1alpha1.ForbidConcurrent, + ImageBuildTemplate: automotivev1alpha1.ImageBuildTemplateSpec{ + Spec: automotivev1alpha1.ImageBuildSpec{ + Architecture: archX86, + AIB: &automotivev1alpha1.AIBSpec{ + Distro: distroASD, + Target: targetQ, + }, + }, + }, + }, + } +} + +func childBuild(sibName, buildName string, phase string, creationTime time.Time) *automotivev1alpha1.ImageBuild { + build := &automotivev1alpha1.ImageBuild{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "automotive.sdv.cloud.redhat.com/v1alpha1", + Kind: "ImageBuild", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: buildName, + Namespace: "default", + CreationTimestamp: metav1.NewTime(creationTime), + Labels: map[string]string{ + automotivev1alpha1.LabelScheduledImageBuildName: sibName, + }, + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: "automotive.sdv.cloud.redhat.com/v1alpha1", + Kind: "ScheduledImageBuild", + Name: sibName, + UID: types.UID("test-uid-" + sibName), + Controller: ptr.To(true), + }, + }, + }, + Status: automotivev1alpha1.ImageBuildStatus{ + Phase: phase, + }, + } + if automotivev1alpha1.IsTerminalBuildPhase(phase) { + build.Status.CompletionTime = &metav1.Time{Time: creationTime.Add(30 * time.Minute)} + } + return build +} + +func TestReconcile_CreatesImageBuild(t *testing.T) { + now := time.Date(2025, 1, 1, 2, 30, 0, 0, time.UTC) // 30 min past 2am + sib := baseSIB("test-schedule") + + r := newReconciler([]runtime.Object{sib}, now) + + result, err := r.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: types.NamespacedName{Name: "test-schedule", Namespace: "default"}, + }) + if err != nil { + t.Fatalf("Reconcile failed: %v", err) + } + + if result.RequeueAfter <= 0 { + t.Error("Expected RequeueAfter > 0") + } + + var buildList automotivev1alpha1.ImageBuildList + if err := r.List(context.Background(), &buildList); err != nil { + t.Fatalf("Failed to list ImageBuilds: %v", err) + } + + if len(buildList.Items) != 1 { + t.Fatalf("Expected 1 ImageBuild, got %d", len(buildList.Items)) + } + + build := buildList.Items[0] + if build.Spec.Architecture != archX86 { + t.Errorf("Expected architecture %s, got %s", archX86, build.Spec.Architecture) + } + if build.Spec.AIB == nil || build.Spec.AIB.Distro != distroASD { + t.Errorf("Expected distro %s in child build", distroASD) + } + if build.Labels[automotivev1alpha1.LabelScheduledImageBuildName] != "test-schedule" { + t.Errorf("Expected schedule label, got %v", build.Labels) + } + if len(build.OwnerReferences) == 0 { + t.Fatal("Expected owner reference on child build") + } + ownerRef := build.OwnerReferences[0] + if ownerRef.APIVersion != automotivev1alpha1.GroupVersion.String() { + t.Errorf("Expected owner ref APIVersion %s, got %s", automotivev1alpha1.GroupVersion.String(), ownerRef.APIVersion) + } + if ownerRef.Kind != "ScheduledImageBuild" { + t.Errorf("Expected owner ref Kind ScheduledImageBuild, got %s", ownerRef.Kind) + } +} + +func TestReconcile_Suspended(t *testing.T) { + now := time.Date(2025, 1, 1, 2, 30, 0, 0, time.UTC) + sib := baseSIB("test-suspend") + sib.Spec.Suspend = ptr.To(true) + + r := newReconciler([]runtime.Object{sib}, now) + + result, err := r.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: types.NamespacedName{Name: "test-suspend", Namespace: "default"}, + }) + if err != nil { + t.Fatalf("Reconcile failed: %v", err) + } + if result.RequeueAfter != 0 { + t.Error("Suspended schedule should not requeue") + } + + var buildList automotivev1alpha1.ImageBuildList + if err := r.List(context.Background(), &buildList); err != nil { + t.Fatalf("Failed to list: %v", err) + } + if len(buildList.Items) != 0 { + t.Error("Suspended schedule should not create builds") + } +} + +func TestReconcile_ForbidConcurrency(t *testing.T) { + now := time.Date(2025, 1, 1, 2, 30, 0, 0, time.UTC) + sib := baseSIB("test-forbid") + + activeBuild := childBuild("test-forbid", "test-forbid-active", "Building", now.Add(-20*time.Minute)) + + r := newReconciler([]runtime.Object{sib, activeBuild}, now) + + _, err := r.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: types.NamespacedName{Name: "test-forbid", Namespace: "default"}, + }) + if err != nil { + t.Fatalf("Reconcile failed: %v", err) + } + + var buildList automotivev1alpha1.ImageBuildList + if err := r.List(context.Background(), &buildList); err != nil { + t.Fatalf("Failed to list: %v", err) + } + if len(buildList.Items) != 1 { + t.Errorf("Forbid policy should keep exactly 1 build, got %d", len(buildList.Items)) + } +} + +func TestReconcile_ActiveStatusHasGVK(t *testing.T) { + now := time.Date(2025, 1, 1, 2, 30, 0, 0, time.UTC) + sib := baseSIB("test-gvk") + sib.Spec.ConcurrencyPolicy = automotivev1alpha1.ForbidConcurrent + + activeBuild := childBuild("test-gvk", "test-gvk-active", "Building", now.Add(-20*time.Minute)) + + r := newReconciler([]runtime.Object{sib, activeBuild}, now) + + _, err := r.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: types.NamespacedName{Name: "test-gvk", Namespace: "default"}, + }) + if err != nil { + t.Fatalf("Reconcile failed: %v", err) + } + + var updated automotivev1alpha1.ScheduledImageBuild + if err := r.Get(context.Background(), types.NamespacedName{Name: "test-gvk", Namespace: "default"}, &updated); err != nil { + t.Fatalf("Failed to get SIB: %v", err) + } + + if len(updated.Status.Active) == 0 { + t.Fatal("Expected at least one active reference") + } + ref := updated.Status.Active[0] + wantAPIVersion := automotivev1alpha1.GroupVersion.String() + if ref.APIVersion != wantAPIVersion { + t.Errorf("Expected APIVersion %s, got %q", wantAPIVersion, ref.APIVersion) + } + if ref.Kind != "ImageBuild" { + t.Errorf("Expected Kind ImageBuild, got %q", ref.Kind) + } +} + +func TestReconcile_AllowConcurrency(t *testing.T) { + now := time.Date(2025, 1, 1, 2, 30, 0, 0, time.UTC) + sib := baseSIB("test-allow") + sib.Spec.ConcurrencyPolicy = automotivev1alpha1.AllowConcurrent + + activeBuild := childBuild("test-allow", "test-allow-active", "Building", now.Add(-20*time.Minute)) + + r := newReconciler([]runtime.Object{sib, activeBuild}, now) + + _, err := r.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: types.NamespacedName{Name: "test-allow", Namespace: "default"}, + }) + if err != nil { + t.Fatalf("Reconcile failed: %v", err) + } + + var buildList automotivev1alpha1.ImageBuildList + if err := r.List(context.Background(), &buildList); err != nil { + t.Fatalf("Failed to list: %v", err) + } + if len(buildList.Items) != 2 { + t.Errorf("Allow policy should have 2 builds (1 active + 1 new), got %d", len(buildList.Items)) + } +} + +func TestReconcile_ReplaceConcurrency(t *testing.T) { + now := time.Date(2025, 1, 1, 2, 30, 0, 0, time.UTC) + sib := baseSIB("test-replace") + sib.Spec.ConcurrencyPolicy = automotivev1alpha1.ReplaceConcurrent + + activeBuild := childBuild("test-replace", "test-replace-active", "Building", now.Add(-20*time.Minute)) + + r := newReconciler([]runtime.Object{sib, activeBuild}, now) + + _, err := r.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: types.NamespacedName{Name: "test-replace", Namespace: "default"}, + }) + if err != nil { + t.Fatalf("Reconcile failed: %v", err) + } + + var buildList automotivev1alpha1.ImageBuildList + if err := r.List(context.Background(), &buildList); err != nil { + t.Fatalf("Failed to list: %v", err) + } + + // Old build should be deleted, new one created + if len(buildList.Items) != 1 { + t.Errorf("Replace policy should have exactly 1 build (new), got %d", len(buildList.Items)) + } + if buildList.Items[0].Name == "test-replace-active" { + t.Error("Old active build should have been replaced") + } +} + +func TestReconcile_HistoryCleanup(t *testing.T) { + now := time.Date(2025, 1, 2, 2, 30, 0, 0, time.UTC) + sib := baseSIB("test-history") + sib.Spec.SuccessfulBuildsHistoryLimit = ptr.To(int32(1)) + sib.Status.LastScheduleTime = &metav1.Time{Time: now.Add(-time.Hour)} + + old1 := childBuild("test-history", "old-1", automotivev1alpha1.ImageBuildPhaseCompleted, now.Add(-3*time.Hour)) + old2 := childBuild("test-history", "old-2", automotivev1alpha1.ImageBuildPhaseCompleted, now.Add(-2*time.Hour)) + recent := childBuild("test-history", "recent", automotivev1alpha1.ImageBuildPhaseCompleted, now.Add(-1*time.Hour)) + + r := newReconciler([]runtime.Object{sib, old1, old2, recent}, now) + + _, err := r.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: types.NamespacedName{Name: "test-history", Namespace: "default"}, + }) + if err != nil { + t.Fatalf("Reconcile failed: %v", err) + } + + var buildList automotivev1alpha1.ImageBuildList + if err := r.List(context.Background(), &buildList); err != nil { + t.Fatalf("Failed to list: %v", err) + } + + completedCount := 0 + for _, b := range buildList.Items { + if b.Status.Phase == automotivev1alpha1.ImageBuildPhaseCompleted { + completedCount++ + } + } + + // limit=1 means keep 1 completed, but reconcile also creates a new one (non-completed) + if completedCount > 1 { + t.Errorf("Expected at most 1 completed build (history limit), got %d", completedCount) + } +} + +func TestReconcile_NoRunBeforeSchedule(t *testing.T) { + // Schedule is "0 2 * * *" (2am), current time is 1am — no run should happen + now := time.Date(2025, 1, 1, 1, 0, 0, 0, time.UTC) + sib := baseSIB("test-not-yet") + + r := newReconciler([]runtime.Object{sib}, now) + + result, err := r.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: types.NamespacedName{Name: "test-not-yet", Namespace: "default"}, + }) + if err != nil { + t.Fatalf("Reconcile failed: %v", err) + } + + if result.RequeueAfter <= 0 { + t.Error("Expected RequeueAfter for next schedule") + } + + var buildList automotivev1alpha1.ImageBuildList + if err := r.List(context.Background(), &buildList); err != nil { + t.Fatalf("Failed to list: %v", err) + } + if len(buildList.Items) != 0 { + t.Errorf("Should not create build before schedule time, got %d", len(buildList.Items)) + } +} + +func TestReconcile_StartingDeadline(t *testing.T) { + // Schedule is "0 2 * * *" (2am). Now is 6am. Deadline is 1 hour. + // The 2am run is 4 hours ago, beyond 1 hour deadline — should skip. + now := time.Date(2025, 1, 1, 6, 0, 0, 0, time.UTC) + sib := baseSIB("test-deadline") + sib.Spec.StartingDeadlineSeconds = ptr.To(int64(3600)) // 1 hour + + r := newReconciler([]runtime.Object{sib}, now) + + _, err := r.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: types.NamespacedName{Name: "test-deadline", Namespace: "default"}, + }) + if err != nil { + t.Fatalf("Reconcile failed: %v", err) + } + + var buildList automotivev1alpha1.ImageBuildList + if err := r.List(context.Background(), &buildList); err != nil { + t.Fatalf("Failed to list: %v", err) + } + if len(buildList.Items) != 0 { + t.Errorf("Should not create build beyond starting deadline, got %d", len(buildList.Items)) + } +} + +func TestReconcile_InvalidSchedule(t *testing.T) { + now := time.Date(2025, 1, 1, 2, 0, 0, 0, time.UTC) + sib := baseSIB("test-invalid") + sib.Spec.Schedule = "not-a-cron" + + r := newReconciler([]runtime.Object{sib}, now) + + _, err := r.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: types.NamespacedName{Name: "test-invalid", Namespace: "default"}, + }) + if err != nil { + t.Fatalf("Expected no error for invalid schedule (handled via condition), got: %v", err) + } +} + +func TestReconcile_InvalidScheduleNoStatusChurn(t *testing.T) { + now := time.Date(2025, 1, 1, 2, 0, 0, 0, time.UTC) + sib := baseSIB("test-invalid-churn") + sib.Spec.Schedule = "not-a-cron" + + r := newReconciler([]runtime.Object{sib}, now) + req := ctrl.Request{NamespacedName: types.NamespacedName{Name: "test-invalid-churn", Namespace: "default"}} + + if _, err := r.Reconcile(context.Background(), req); err != nil { + t.Fatalf("First reconcile failed: %v", err) + } + + var after1 automotivev1alpha1.ScheduledImageBuild + if err := r.Get(context.Background(), req.NamespacedName, &after1); err != nil { + t.Fatalf("Failed to get SIB: %v", err) + } + rv1 := after1.ResourceVersion + + if _, err := r.Reconcile(context.Background(), req); err != nil { + t.Fatalf("Second reconcile failed: %v", err) + } + + var after2 automotivev1alpha1.ScheduledImageBuild + if err := r.Get(context.Background(), req.NamespacedName, &after2); err != nil { + t.Fatalf("Failed to get SIB: %v", err) + } + + if after2.ResourceVersion != rv1 { + t.Error("Second reconcile wrote status when nothing changed — causes reconcile churn") + } +} + +func TestReconcile_AutoPublish(t *testing.T) { + now := time.Date(2025, 1, 2, 2, 30, 0, 0, time.UTC) + sib := baseSIB("test-publish") + sib.Spec.PublishToCatalog = &automotivev1alpha1.PublishToCatalogSpec{ + Enabled: true, + Tags: []string{"nightly"}, + } + sib.Status.LastScheduleTime = &metav1.Time{Time: now.Add(-time.Hour)} + + completedBuild := childBuild("test-publish", "completed-build", automotivev1alpha1.ImageBuildPhaseCompleted, now.Add(-1*time.Hour)) + + published := false + publisher := &mockPublisher{ + publishFn: func(_ context.Context, _ *automotivev1alpha1.ImageBuild, _ string, tags []string, _ *automotivev1alpha1.AuthSecretReference) (*catalogimage.PublishResult, error) { + published = true + if len(tags) != 1 || tags[0] != "nightly" { + t.Errorf("Expected tags [nightly], got %v", tags) + } + return &catalogimage.PublishResult{}, nil + }, + } + + r := newReconciler([]runtime.Object{sib, completedBuild}, now) + r.Publisher = publisher + + _, err := r.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: types.NamespacedName{Name: "test-publish", Namespace: "default"}, + }) + if err != nil { + t.Fatalf("Reconcile failed: %v", err) + } + + if !published { + t.Error("Expected publisher to be called for completed build") + } + + var build automotivev1alpha1.ImageBuild + if err := r.Get(context.Background(), types.NamespacedName{Name: "completed-build", Namespace: "default"}, &build); err != nil { + t.Fatalf("Failed to get build: %v", err) + } + if _, ok := build.Annotations[AnnotationCatalogPublished]; !ok { + t.Error("Expected catalog-published annotation to be set") + } +} + +func TestReconcile_SkipsAlreadyPublished(t *testing.T) { + now := time.Date(2025, 1, 2, 2, 30, 0, 0, time.UTC) + sib := baseSIB("test-skip-publish") + sib.Spec.PublishToCatalog = &automotivev1alpha1.PublishToCatalogSpec{Enabled: true} + sib.Status.LastScheduleTime = &metav1.Time{Time: now.Add(-time.Hour)} + + completedBuild := childBuild("test-skip-publish", "already-published", automotivev1alpha1.ImageBuildPhaseCompleted, now.Add(-1*time.Hour)) + completedBuild.Annotations = map[string]string{AnnotationCatalogPublished: AnnotationCatalogPublishedValue} + + published := false + publisher := &mockPublisher{ + publishFn: func(_ context.Context, _ *automotivev1alpha1.ImageBuild, _ string, _ []string, _ *automotivev1alpha1.AuthSecretReference) (*catalogimage.PublishResult, error) { + published = true + return &catalogimage.PublishResult{}, nil + }, + } + + r := newReconciler([]runtime.Object{sib, completedBuild}, now) + r.Publisher = publisher + + _, err := r.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: types.NamespacedName{Name: "test-skip-publish", Namespace: "default"}, + }) + if err != nil { + t.Fatalf("Reconcile failed: %v", err) + } + + if published { + t.Error("Should not publish already-published build") + } +} + +func TestReconcile_TemplateLabelsAndAnnotations(t *testing.T) { + now := time.Date(2025, 1, 1, 2, 30, 0, 0, time.UTC) + sib := baseSIB("test-meta") + sib.Spec.ImageBuildTemplate.Metadata = automotivev1alpha1.ScheduledBuildMetadata{ + Labels: map[string]string{"team": "platform", "env": "staging"}, + Annotations: map[string]string{"note": "nightly"}, + } + + r := newReconciler([]runtime.Object{sib}, now) + + _, err := r.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: types.NamespacedName{Name: "test-meta", Namespace: "default"}, + }) + if err != nil { + t.Fatalf("Reconcile failed: %v", err) + } + + var buildList automotivev1alpha1.ImageBuildList + if err := r.List(context.Background(), &buildList); err != nil { + t.Fatalf("Failed to list: %v", err) + } + if len(buildList.Items) != 1 { + t.Fatalf("Expected 1 build, got %d", len(buildList.Items)) + } + + build := buildList.Items[0] + if build.Labels["team"] != "platform" { + t.Errorf("Expected label team=platform, got %v", build.Labels) + } + if build.Labels["env"] != "staging" { + t.Errorf("Expected label env=staging, got %v", build.Labels) + } + if build.Annotations["note"] != "nightly" { + t.Errorf("Expected annotation note=nightly, got %v", build.Annotations) + } +} + +func TestSafeDerivedName(t *testing.T) { + tests := []struct { + name string + base string + suffix string + wantLen bool // check len <= 63 + }{ + {"short name", "my-schedule", "-12345678", false}, + {"long name", "this-is-a-very-long-scheduled-image-build-name-that-exceeds-limits", "-12345678", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := safeDerivedName(tt.base, tt.suffix) + if len(result) > maxK8sNameLength { + t.Errorf("Name too long: %d chars: %s", len(result), result) + } + if tt.wantLen && len(result) > maxK8sNameLength { + t.Errorf("Expected name <= %d chars, got %d", maxK8sNameLength, len(result)) + } + }) + } +} + +func TestClassifyBuilds(t *testing.T) { + builds := []automotivev1alpha1.ImageBuild{ + {Status: automotivev1alpha1.ImageBuildStatus{Phase: "Building"}}, + {Status: automotivev1alpha1.ImageBuildStatus{Phase: automotivev1alpha1.ImageBuildPhaseCompleted}}, + {Status: automotivev1alpha1.ImageBuildStatus{Phase: "Pending"}}, + {Status: automotivev1alpha1.ImageBuildStatus{Phase: automotivev1alpha1.ImageBuildPhaseFailed}}, + } + + active, finished := classifyBuilds(builds) + if len(active) != 2 { + t.Errorf("Expected 2 active, got %d", len(active)) + } + if len(finished) != 2 { + t.Errorf("Expected 2 finished, got %d", len(finished)) + } +} + +func TestReconcile_BuildFailedVisibility(t *testing.T) { + now := time.Date(2025, 1, 2, 2, 30, 0, 0, time.UTC) + sib := baseSIB("test-fail-visible") + sib.Status.LastScheduleTime = &metav1.Time{Time: now.Add(-time.Hour)} + + failedBuild := childBuild("test-fail-visible", "failed-build", automotivev1alpha1.ImageBuildPhaseFailed, now.Add(-1*time.Hour)) + failedBuild.Status.Message = "disk space exhausted" + + r := newReconciler([]runtime.Object{sib, failedBuild}, now) + + _, err := r.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: types.NamespacedName{Name: "test-fail-visible", Namespace: "default"}, + }) + if err != nil { + t.Fatalf("Reconcile failed: %v", err) + } + + var updated automotivev1alpha1.ScheduledImageBuild + if err := r.Get(context.Background(), types.NamespacedName{Name: "test-fail-visible", Namespace: "default"}, &updated); err != nil { + t.Fatalf("Failed to get SIB: %v", err) + } + + if updated.Status.LastFailedTime == nil { + t.Fatal("Expected lastFailedTime to be set") + } + + var foundCondition bool + for _, c := range updated.Status.Conditions { + if c.Type == ConditionLastBuildSucceeded { + foundCondition = true + if c.Status != metav1.ConditionFalse { + t.Errorf("Expected LastBuildSucceeded=False, got %s", c.Status) + } + if c.Reason != "BuildFailed" { + t.Errorf("Expected reason BuildFailed, got %s", c.Reason) + } + } + } + if !foundCondition { + t.Error("Expected LastBuildSucceeded condition to be set") + } +} + +func TestReconcile_BuildSucceededCondition(t *testing.T) { + now := time.Date(2025, 1, 2, 2, 30, 0, 0, time.UTC) + sib := baseSIB("test-succeed-cond") + sib.Status.LastScheduleTime = &metav1.Time{Time: now.Add(-time.Hour)} + + completedBuild := childBuild("test-succeed-cond", "good-build", automotivev1alpha1.ImageBuildPhaseCompleted, now.Add(-1*time.Hour)) + + r := newReconciler([]runtime.Object{sib, completedBuild}, now) + + _, err := r.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: types.NamespacedName{Name: "test-succeed-cond", Namespace: "default"}, + }) + if err != nil { + t.Fatalf("Reconcile failed: %v", err) + } + + var updated automotivev1alpha1.ScheduledImageBuild + if err := r.Get(context.Background(), types.NamespacedName{Name: "test-succeed-cond", Namespace: "default"}, &updated); err != nil { + t.Fatalf("Failed to get SIB: %v", err) + } + + var foundCondition bool + for _, c := range updated.Status.Conditions { + if c.Type == ConditionLastBuildSucceeded { + foundCondition = true + if c.Status != metav1.ConditionTrue { + t.Errorf("Expected LastBuildSucceeded=True, got %s", c.Status) + } + if c.Reason != "BuildSucceeded" { + t.Errorf("Expected reason BuildSucceeded, got %s", c.Reason) + } + } + } + if !foundCondition { + t.Error("Expected LastBuildSucceeded condition to be set") + } +} + +func TestExpandMatrix_NoMatrix(t *testing.T) { + sib := baseSIB("no-matrix") + combos := expandMatrix(sib) + if len(combos) != 1 { + t.Fatalf("Expected 1 combo (passthrough), got %d", len(combos)) + } + if combos[0].Architecture != "" || combos[0].Distro != "" || combos[0].Target != "" { + t.Error("Empty combo should have all zero-value fields") + } +} + +func TestExpandMatrix_ArchOnly(t *testing.T) { + sib := baseSIB("arch-matrix") + sib.Spec.Matrix = &automotivev1alpha1.BuildMatrix{ + Architectures: []string{archX86, archARM}, + } + combos := expandMatrix(sib) + if len(combos) != 2 { + t.Fatalf("Expected 2 combos, got %d", len(combos)) + } + if combos[0].Architecture != archX86 { + t.Errorf("Expected %s, got %s", archX86, combos[0].Architecture) + } + if combos[1].Architecture != archARM { + t.Errorf("Expected %s, got %s", archARM, combos[1].Architecture) + } +} + +func TestExpandMatrix_CrossProduct(t *testing.T) { + sib := baseSIB("cross-matrix") + sib.Spec.Matrix = &automotivev1alpha1.BuildMatrix{ + Architectures: []string{archX86, archARM}, + Targets: []string{targetQ, "aws"}, + } + combos := expandMatrix(sib) + if len(combos) != 4 { + t.Fatalf("Expected 4 combos (2x2), got %d", len(combos)) + } + + expected := []matrixCombo{ + {Architecture: archX86, Target: targetQ}, + {Architecture: archX86, Target: "aws"}, + {Architecture: archARM, Target: targetQ}, + {Architecture: archARM, Target: "aws"}, + } + for i, want := range expected { + got := combos[i] + if got.Architecture != want.Architecture || got.Target != want.Target { + t.Errorf("combo[%d]: got {%s, %s}, want {%s, %s}", i, got.Architecture, got.Target, want.Architecture, want.Target) + } + } +} + +func TestExpandMatrix_AllDimensions(t *testing.T) { + sib := baseSIB("full-matrix") + sib.Spec.Matrix = &automotivev1alpha1.BuildMatrix{ + Architectures: []string{archX86, archARM}, + Distros: []string{distroASD, "cs9"}, + Targets: []string{targetQ}, + } + combos := expandMatrix(sib) + if len(combos) != 4 { + t.Fatalf("Expected 4 combos (2x2x1), got %d", len(combos)) + } +} + +func TestMatrixComboSuffix(t *testing.T) { + tests := []struct { + name string + combo matrixCombo + want string + }{ + {"empty", matrixCombo{}, ""}, + {"arch only", matrixCombo{Architecture: archX86}, "-x86-64"}, + {"arch+target", matrixCombo{Architecture: archARM, Target: targetQ}, "-aarch64-qemu"}, + {"all", matrixCombo{Architecture: archX86, Distro: distroASD, Target: targetQ}, "-x86-64-autosd-qemu"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.combo.suffix() + if got != tt.want { + t.Errorf("suffix() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestAppendOCITagSuffix(t *testing.T) { + tests := []struct { + name string + ref string + suffix string + want string + }{ + {"with tag", "registry.example.com/repo:disk", "-qemu", "registry.example.com/repo:disk-qemu"}, + {"no tag", "registry.example.com/repo", "-qemu", "registry.example.com/repo:latest-qemu"}, + {"port and tag", "registry:5000/ns/repo:latest", "-ebbr", "registry:5000/ns/repo:latest-ebbr"}, + {"port no tag", "registry:5000/ns/repo", "-ebbr", "registry:5000/ns/repo:latest-ebbr"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := appendOCITagSuffix(tt.ref, tt.suffix) + if got != tt.want { + t.Errorf("appendOCITagSuffix(%q, %q) = %q, want %q", tt.ref, tt.suffix, got, tt.want) + } + }) + } +} + +func TestReconcile_MatrixCreatesMultipleBuilds(t *testing.T) { + now := time.Date(2025, 1, 1, 2, 30, 0, 0, time.UTC) + sib := baseSIB("test-matrix") + sib.Spec.Matrix = &automotivev1alpha1.BuildMatrix{ + Architectures: []string{archX86, archARM}, + } + + r := newReconciler([]runtime.Object{sib}, now) + + _, err := r.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: types.NamespacedName{Name: "test-matrix", Namespace: "default"}, + }) + if err != nil { + t.Fatalf("Reconcile failed: %v", err) + } + + var buildList automotivev1alpha1.ImageBuildList + if err := r.List(context.Background(), &buildList); err != nil { + t.Fatalf("Failed to list: %v", err) + } + if len(buildList.Items) != 2 { + t.Fatalf("Expected 2 ImageBuilds from matrix, got %d", len(buildList.Items)) + } + + archSeen := map[string]bool{} + for _, b := range buildList.Items { + archSeen[b.Spec.Architecture] = true + if b.Labels[automotivev1alpha1.LabelScheduledImageBuildName] != "test-matrix" { + t.Errorf("Missing schedule label on build %s", b.Name) + } + if b.Labels[automotivev1alpha1.LabelArchitecture] == "" { + t.Errorf("Missing architecture label on build %s", b.Name) + } + } + if !archSeen[archX86] || !archSeen[archARM] { + t.Errorf("Expected both architectures, got %v", archSeen) + } +} + +func TestReconcile_MatrixOverridesTemplateSpec(t *testing.T) { + now := time.Date(2025, 1, 1, 2, 30, 0, 0, time.UTC) + sib := baseSIB("test-matrix-override") + sib.Spec.ImageBuildTemplate.Spec.Export = &automotivev1alpha1.ExportSpec{ + UseServiceAccountAuth: true, + Disk: &automotivev1alpha1.DiskExport{ + OCI: "registry:5000/ns/image:disk", + }, + } + sib.Spec.Matrix = &automotivev1alpha1.BuildMatrix{ + Architectures: []string{archARM}, + Targets: []string{"aws"}, + } + + r := newReconciler([]runtime.Object{sib}, now) + + _, err := r.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: types.NamespacedName{Name: "test-matrix-override", Namespace: "default"}, + }) + if err != nil { + t.Fatalf("Reconcile failed: %v", err) + } + + var buildList automotivev1alpha1.ImageBuildList + if err := r.List(context.Background(), &buildList); err != nil { + t.Fatalf("Failed to list: %v", err) + } + if len(buildList.Items) != 1 { + t.Fatalf("Expected 1 build, got %d", len(buildList.Items)) + } + + build := buildList.Items[0] + if build.Spec.Architecture != archARM { + t.Errorf("Expected architecture aarch64 (overridden), got %s", build.Spec.Architecture) + } + if build.Spec.AIB == nil || build.Spec.AIB.Target != "aws" { + t.Errorf("Expected target aws (overridden), got %v", build.Spec.AIB) + } + if build.Spec.AIB.Distro != distroASD { + t.Errorf("Expected distro autosd (from template), got %s", build.Spec.AIB.Distro) + } + expectedOCI := "registry:5000/ns/image:disk-aarch64-aws" + if build.Spec.Export == nil || build.Spec.Export.Disk == nil || build.Spec.Export.Disk.OCI != expectedOCI { + got := "" + if build.Spec.Export != nil && build.Spec.Export.Disk != nil { + got = build.Spec.Export.Disk.OCI + } + t.Errorf("Expected OCI path %s, got %s", expectedOCI, got) + } +} + +func TestReconcile_NoMatrixBackwardCompatible(t *testing.T) { + now := time.Date(2025, 1, 1, 2, 30, 0, 0, time.UTC) + sib := baseSIB("test-no-matrix") + + r := newReconciler([]runtime.Object{sib}, now) + + _, err := r.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: types.NamespacedName{Name: "test-no-matrix", Namespace: "default"}, + }) + if err != nil { + t.Fatalf("Reconcile failed: %v", err) + } + + var buildList automotivev1alpha1.ImageBuildList + if err := r.List(context.Background(), &buildList); err != nil { + t.Fatalf("Failed to list: %v", err) + } + if len(buildList.Items) != 1 { + t.Fatalf("Expected exactly 1 build (no matrix), got %d", len(buildList.Items)) + } + if buildList.Items[0].Spec.Architecture != archX86 { + t.Errorf("Expected template architecture x86_64, got %s", buildList.Items[0].Spec.Architecture) + } +} + +// mockPublisher implements CatalogPublisher for testing +type mockPublisher struct { + publishFn func(ctx context.Context, ib *automotivev1alpha1.ImageBuild, name string, tags []string, authSecretRef *automotivev1alpha1.AuthSecretReference) (*catalogimage.PublishResult, error) +} + +func (m *mockPublisher) PublishFromImageBuild(ctx context.Context, ib *automotivev1alpha1.ImageBuild, name string, tags []string, authSecretRef *automotivev1alpha1.AuthSecretReference) (*catalogimage.PublishResult, error) { + if m.publishFn != nil { + return m.publishFn(ctx, ib, name, tags, authSecretRef) + } + return &catalogimage.PublishResult{}, nil +} + +var _ CatalogPublisher = &mockPublisher{} diff --git a/vendor/github.com/robfig/cron/v3/.gitignore b/vendor/github.com/robfig/cron/v3/.gitignore new file mode 100644 index 000000000..00268614f --- /dev/null +++ b/vendor/github.com/robfig/cron/v3/.gitignore @@ -0,0 +1,22 @@ +# Compiled Object files, Static and Dynamic libs (Shared Objects) +*.o +*.a +*.so + +# Folders +_obj +_test + +# Architecture specific extensions/prefixes +*.[568vq] +[568vq].out + +*.cgo1.go +*.cgo2.c +_cgo_defun.c +_cgo_gotypes.go +_cgo_export.* + +_testmain.go + +*.exe diff --git a/vendor/github.com/robfig/cron/v3/.travis.yml b/vendor/github.com/robfig/cron/v3/.travis.yml new file mode 100644 index 000000000..4f2ee4d97 --- /dev/null +++ b/vendor/github.com/robfig/cron/v3/.travis.yml @@ -0,0 +1 @@ +language: go diff --git a/vendor/github.com/robfig/cron/v3/LICENSE b/vendor/github.com/robfig/cron/v3/LICENSE new file mode 100644 index 000000000..3a0f627ff --- /dev/null +++ b/vendor/github.com/robfig/cron/v3/LICENSE @@ -0,0 +1,21 @@ +Copyright (C) 2012 Rob Figueiredo +All Rights Reserved. + +MIT LICENSE + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/vendor/github.com/robfig/cron/v3/README.md b/vendor/github.com/robfig/cron/v3/README.md new file mode 100644 index 000000000..984c537c0 --- /dev/null +++ b/vendor/github.com/robfig/cron/v3/README.md @@ -0,0 +1,125 @@ +[![GoDoc](http://godoc.org/github.com/robfig/cron?status.png)](http://godoc.org/github.com/robfig/cron) +[![Build Status](https://travis-ci.org/robfig/cron.svg?branch=master)](https://travis-ci.org/robfig/cron) + +# cron + +Cron V3 has been released! + +To download the specific tagged release, run: + + go get github.com/robfig/cron/v3@v3.0.0 + +Import it in your program as: + + import "github.com/robfig/cron/v3" + +It requires Go 1.11 or later due to usage of Go Modules. + +Refer to the documentation here: +http://godoc.org/github.com/robfig/cron + +The rest of this document describes the the advances in v3 and a list of +breaking changes for users that wish to upgrade from an earlier version. + +## Upgrading to v3 (June 2019) + +cron v3 is a major upgrade to the library that addresses all outstanding bugs, +feature requests, and rough edges. It is based on a merge of master which +contains various fixes to issues found over the years and the v2 branch which +contains some backwards-incompatible features like the ability to remove cron +jobs. In addition, v3 adds support for Go Modules, cleans up rough edges like +the timezone support, and fixes a number of bugs. + +New features: + +- Support for Go modules. Callers must now import this library as + `github.com/robfig/cron/v3`, instead of `gopkg.in/...` + +- Fixed bugs: + - 0f01e6b parser: fix combining of Dow and Dom (#70) + - dbf3220 adjust times when rolling the clock forward to handle non-existent midnight (#157) + - eeecf15 spec_test.go: ensure an error is returned on 0 increment (#144) + - 70971dc cron.Entries(): update request for snapshot to include a reply channel (#97) + - 1cba5e6 cron: fix: removing a job causes the next scheduled job to run too late (#206) + +- Standard cron spec parsing by default (first field is "minute"), with an easy + way to opt into the seconds field (quartz-compatible). Although, note that the + year field (optional in Quartz) is not supported. + +- Extensible, key/value logging via an interface that complies with + the https://github.com/go-logr/logr project. + +- The new Chain & JobWrapper types allow you to install "interceptors" to add + cross-cutting behavior like the following: + - Recover any panics from jobs + - Delay a job's execution if the previous run hasn't completed yet + - Skip a job's execution if the previous run hasn't completed yet + - Log each job's invocations + - Notification when jobs are completed + +It is backwards incompatible with both v1 and v2. These updates are required: + +- The v1 branch accepted an optional seconds field at the beginning of the cron + spec. This is non-standard and has led to a lot of confusion. The new default + parser conforms to the standard as described by [the Cron wikipedia page]. + + UPDATING: To retain the old behavior, construct your Cron with a custom + parser: + + // Seconds field, required + cron.New(cron.WithSeconds()) + + // Seconds field, optional + cron.New( + cron.WithParser( + cron.SecondOptional | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor)) + +- The Cron type now accepts functional options on construction rather than the + previous ad-hoc behavior modification mechanisms (setting a field, calling a setter). + + UPDATING: Code that sets Cron.ErrorLogger or calls Cron.SetLocation must be + updated to provide those values on construction. + +- CRON_TZ is now the recommended way to specify the timezone of a single + schedule, which is sanctioned by the specification. The legacy "TZ=" prefix + will continue to be supported since it is unambiguous and easy to do so. + + UPDATING: No update is required. + +- By default, cron will no longer recover panics in jobs that it runs. + Recovering can be surprising (see issue #192) and seems to be at odds with + typical behavior of libraries. Relatedly, the `cron.WithPanicLogger` option + has been removed to accommodate the more general JobWrapper type. + + UPDATING: To opt into panic recovery and configure the panic logger: + + cron.New(cron.WithChain( + cron.Recover(logger), // or use cron.DefaultLogger + )) + +- In adding support for https://github.com/go-logr/logr, `cron.WithVerboseLogger` was + removed, since it is duplicative with the leveled logging. + + UPDATING: Callers should use `WithLogger` and specify a logger that does not + discard `Info` logs. For convenience, one is provided that wraps `*log.Logger`: + + cron.New( + cron.WithLogger(cron.VerbosePrintfLogger(logger))) + + +### Background - Cron spec format + +There are two cron spec formats in common usage: + +- The "standard" cron format, described on [the Cron wikipedia page] and used by + the cron Linux system utility. + +- The cron format used by [the Quartz Scheduler], commonly used for scheduled + jobs in Java software + +[the Cron wikipedia page]: https://en.wikipedia.org/wiki/Cron +[the Quartz Scheduler]: http://www.quartz-scheduler.org/documentation/quartz-2.3.0/tutorials/tutorial-lesson-06.html + +The original version of this package included an optional "seconds" field, which +made it incompatible with both of these formats. Now, the "standard" format is +the default format accepted, and the Quartz format is opt-in. diff --git a/vendor/github.com/robfig/cron/v3/chain.go b/vendor/github.com/robfig/cron/v3/chain.go new file mode 100644 index 000000000..9565b418e --- /dev/null +++ b/vendor/github.com/robfig/cron/v3/chain.go @@ -0,0 +1,92 @@ +package cron + +import ( + "fmt" + "runtime" + "sync" + "time" +) + +// JobWrapper decorates the given Job with some behavior. +type JobWrapper func(Job) Job + +// Chain is a sequence of JobWrappers that decorates submitted jobs with +// cross-cutting behaviors like logging or synchronization. +type Chain struct { + wrappers []JobWrapper +} + +// NewChain returns a Chain consisting of the given JobWrappers. +func NewChain(c ...JobWrapper) Chain { + return Chain{c} +} + +// Then decorates the given job with all JobWrappers in the chain. +// +// This: +// NewChain(m1, m2, m3).Then(job) +// is equivalent to: +// m1(m2(m3(job))) +func (c Chain) Then(j Job) Job { + for i := range c.wrappers { + j = c.wrappers[len(c.wrappers)-i-1](j) + } + return j +} + +// Recover panics in wrapped jobs and log them with the provided logger. +func Recover(logger Logger) JobWrapper { + return func(j Job) Job { + return FuncJob(func() { + defer func() { + if r := recover(); r != nil { + const size = 64 << 10 + buf := make([]byte, size) + buf = buf[:runtime.Stack(buf, false)] + err, ok := r.(error) + if !ok { + err = fmt.Errorf("%v", r) + } + logger.Error(err, "panic", "stack", "...\n"+string(buf)) + } + }() + j.Run() + }) + } +} + +// DelayIfStillRunning serializes jobs, delaying subsequent runs until the +// previous one is complete. Jobs running after a delay of more than a minute +// have the delay logged at Info. +func DelayIfStillRunning(logger Logger) JobWrapper { + return func(j Job) Job { + var mu sync.Mutex + return FuncJob(func() { + start := time.Now() + mu.Lock() + defer mu.Unlock() + if dur := time.Since(start); dur > time.Minute { + logger.Info("delay", "duration", dur) + } + j.Run() + }) + } +} + +// SkipIfStillRunning skips an invocation of the Job if a previous invocation is +// still running. It logs skips to the given logger at Info level. +func SkipIfStillRunning(logger Logger) JobWrapper { + return func(j Job) Job { + var ch = make(chan struct{}, 1) + ch <- struct{}{} + return FuncJob(func() { + select { + case v := <-ch: + j.Run() + ch <- v + default: + logger.Info("skip") + } + }) + } +} diff --git a/vendor/github.com/robfig/cron/v3/constantdelay.go b/vendor/github.com/robfig/cron/v3/constantdelay.go new file mode 100644 index 000000000..cd6e7b1be --- /dev/null +++ b/vendor/github.com/robfig/cron/v3/constantdelay.go @@ -0,0 +1,27 @@ +package cron + +import "time" + +// ConstantDelaySchedule represents a simple recurring duty cycle, e.g. "Every 5 minutes". +// It does not support jobs more frequent than once a second. +type ConstantDelaySchedule struct { + Delay time.Duration +} + +// Every returns a crontab Schedule that activates once every duration. +// Delays of less than a second are not supported (will round up to 1 second). +// Any fields less than a Second are truncated. +func Every(duration time.Duration) ConstantDelaySchedule { + if duration < time.Second { + duration = time.Second + } + return ConstantDelaySchedule{ + Delay: duration - time.Duration(duration.Nanoseconds())%time.Second, + } +} + +// Next returns the next time this should be run. +// This rounds so that the next activation time will be on the second. +func (schedule ConstantDelaySchedule) Next(t time.Time) time.Time { + return t.Add(schedule.Delay - time.Duration(t.Nanosecond())*time.Nanosecond) +} diff --git a/vendor/github.com/robfig/cron/v3/cron.go b/vendor/github.com/robfig/cron/v3/cron.go new file mode 100644 index 000000000..c7e917665 --- /dev/null +++ b/vendor/github.com/robfig/cron/v3/cron.go @@ -0,0 +1,355 @@ +package cron + +import ( + "context" + "sort" + "sync" + "time" +) + +// Cron keeps track of any number of entries, invoking the associated func as +// specified by the schedule. It may be started, stopped, and the entries may +// be inspected while running. +type Cron struct { + entries []*Entry + chain Chain + stop chan struct{} + add chan *Entry + remove chan EntryID + snapshot chan chan []Entry + running bool + logger Logger + runningMu sync.Mutex + location *time.Location + parser ScheduleParser + nextID EntryID + jobWaiter sync.WaitGroup +} + +// ScheduleParser is an interface for schedule spec parsers that return a Schedule +type ScheduleParser interface { + Parse(spec string) (Schedule, error) +} + +// Job is an interface for submitted cron jobs. +type Job interface { + Run() +} + +// Schedule describes a job's duty cycle. +type Schedule interface { + // Next returns the next activation time, later than the given time. + // Next is invoked initially, and then each time the job is run. + Next(time.Time) time.Time +} + +// EntryID identifies an entry within a Cron instance +type EntryID int + +// Entry consists of a schedule and the func to execute on that schedule. +type Entry struct { + // ID is the cron-assigned ID of this entry, which may be used to look up a + // snapshot or remove it. + ID EntryID + + // Schedule on which this job should be run. + Schedule Schedule + + // Next time the job will run, or the zero time if Cron has not been + // started or this entry's schedule is unsatisfiable + Next time.Time + + // Prev is the last time this job was run, or the zero time if never. + Prev time.Time + + // WrappedJob is the thing to run when the Schedule is activated. + WrappedJob Job + + // Job is the thing that was submitted to cron. + // It is kept around so that user code that needs to get at the job later, + // e.g. via Entries() can do so. + Job Job +} + +// Valid returns true if this is not the zero entry. +func (e Entry) Valid() bool { return e.ID != 0 } + +// byTime is a wrapper for sorting the entry array by time +// (with zero time at the end). +type byTime []*Entry + +func (s byTime) Len() int { return len(s) } +func (s byTime) Swap(i, j int) { s[i], s[j] = s[j], s[i] } +func (s byTime) Less(i, j int) bool { + // Two zero times should return false. + // Otherwise, zero is "greater" than any other time. + // (To sort it at the end of the list.) + if s[i].Next.IsZero() { + return false + } + if s[j].Next.IsZero() { + return true + } + return s[i].Next.Before(s[j].Next) +} + +// New returns a new Cron job runner, modified by the given options. +// +// Available Settings +// +// Time Zone +// Description: The time zone in which schedules are interpreted +// Default: time.Local +// +// Parser +// Description: Parser converts cron spec strings into cron.Schedules. +// Default: Accepts this spec: https://en.wikipedia.org/wiki/Cron +// +// Chain +// Description: Wrap submitted jobs to customize behavior. +// Default: A chain that recovers panics and logs them to stderr. +// +// See "cron.With*" to modify the default behavior. +func New(opts ...Option) *Cron { + c := &Cron{ + entries: nil, + chain: NewChain(), + add: make(chan *Entry), + stop: make(chan struct{}), + snapshot: make(chan chan []Entry), + remove: make(chan EntryID), + running: false, + runningMu: sync.Mutex{}, + logger: DefaultLogger, + location: time.Local, + parser: standardParser, + } + for _, opt := range opts { + opt(c) + } + return c +} + +// FuncJob is a wrapper that turns a func() into a cron.Job +type FuncJob func() + +func (f FuncJob) Run() { f() } + +// AddFunc adds a func to the Cron to be run on the given schedule. +// The spec is parsed using the time zone of this Cron instance as the default. +// An opaque ID is returned that can be used to later remove it. +func (c *Cron) AddFunc(spec string, cmd func()) (EntryID, error) { + return c.AddJob(spec, FuncJob(cmd)) +} + +// AddJob adds a Job to the Cron to be run on the given schedule. +// The spec is parsed using the time zone of this Cron instance as the default. +// An opaque ID is returned that can be used to later remove it. +func (c *Cron) AddJob(spec string, cmd Job) (EntryID, error) { + schedule, err := c.parser.Parse(spec) + if err != nil { + return 0, err + } + return c.Schedule(schedule, cmd), nil +} + +// Schedule adds a Job to the Cron to be run on the given schedule. +// The job is wrapped with the configured Chain. +func (c *Cron) Schedule(schedule Schedule, cmd Job) EntryID { + c.runningMu.Lock() + defer c.runningMu.Unlock() + c.nextID++ + entry := &Entry{ + ID: c.nextID, + Schedule: schedule, + WrappedJob: c.chain.Then(cmd), + Job: cmd, + } + if !c.running { + c.entries = append(c.entries, entry) + } else { + c.add <- entry + } + return entry.ID +} + +// Entries returns a snapshot of the cron entries. +func (c *Cron) Entries() []Entry { + c.runningMu.Lock() + defer c.runningMu.Unlock() + if c.running { + replyChan := make(chan []Entry, 1) + c.snapshot <- replyChan + return <-replyChan + } + return c.entrySnapshot() +} + +// Location gets the time zone location +func (c *Cron) Location() *time.Location { + return c.location +} + +// Entry returns a snapshot of the given entry, or nil if it couldn't be found. +func (c *Cron) Entry(id EntryID) Entry { + for _, entry := range c.Entries() { + if id == entry.ID { + return entry + } + } + return Entry{} +} + +// Remove an entry from being run in the future. +func (c *Cron) Remove(id EntryID) { + c.runningMu.Lock() + defer c.runningMu.Unlock() + if c.running { + c.remove <- id + } else { + c.removeEntry(id) + } +} + +// Start the cron scheduler in its own goroutine, or no-op if already started. +func (c *Cron) Start() { + c.runningMu.Lock() + defer c.runningMu.Unlock() + if c.running { + return + } + c.running = true + go c.run() +} + +// Run the cron scheduler, or no-op if already running. +func (c *Cron) Run() { + c.runningMu.Lock() + if c.running { + c.runningMu.Unlock() + return + } + c.running = true + c.runningMu.Unlock() + c.run() +} + +// run the scheduler.. this is private just due to the need to synchronize +// access to the 'running' state variable. +func (c *Cron) run() { + c.logger.Info("start") + + // Figure out the next activation times for each entry. + now := c.now() + for _, entry := range c.entries { + entry.Next = entry.Schedule.Next(now) + c.logger.Info("schedule", "now", now, "entry", entry.ID, "next", entry.Next) + } + + for { + // Determine the next entry to run. + sort.Sort(byTime(c.entries)) + + var timer *time.Timer + if len(c.entries) == 0 || c.entries[0].Next.IsZero() { + // If there are no entries yet, just sleep - it still handles new entries + // and stop requests. + timer = time.NewTimer(100000 * time.Hour) + } else { + timer = time.NewTimer(c.entries[0].Next.Sub(now)) + } + + for { + select { + case now = <-timer.C: + now = now.In(c.location) + c.logger.Info("wake", "now", now) + + // Run every entry whose next time was less than now + for _, e := range c.entries { + if e.Next.After(now) || e.Next.IsZero() { + break + } + c.startJob(e.WrappedJob) + e.Prev = e.Next + e.Next = e.Schedule.Next(now) + c.logger.Info("run", "now", now, "entry", e.ID, "next", e.Next) + } + + case newEntry := <-c.add: + timer.Stop() + now = c.now() + newEntry.Next = newEntry.Schedule.Next(now) + c.entries = append(c.entries, newEntry) + c.logger.Info("added", "now", now, "entry", newEntry.ID, "next", newEntry.Next) + + case replyChan := <-c.snapshot: + replyChan <- c.entrySnapshot() + continue + + case <-c.stop: + timer.Stop() + c.logger.Info("stop") + return + + case id := <-c.remove: + timer.Stop() + now = c.now() + c.removeEntry(id) + c.logger.Info("removed", "entry", id) + } + + break + } + } +} + +// startJob runs the given job in a new goroutine. +func (c *Cron) startJob(j Job) { + c.jobWaiter.Add(1) + go func() { + defer c.jobWaiter.Done() + j.Run() + }() +} + +// now returns current time in c location +func (c *Cron) now() time.Time { + return time.Now().In(c.location) +} + +// Stop stops the cron scheduler if it is running; otherwise it does nothing. +// A context is returned so the caller can wait for running jobs to complete. +func (c *Cron) Stop() context.Context { + c.runningMu.Lock() + defer c.runningMu.Unlock() + if c.running { + c.stop <- struct{}{} + c.running = false + } + ctx, cancel := context.WithCancel(context.Background()) + go func() { + c.jobWaiter.Wait() + cancel() + }() + return ctx +} + +// entrySnapshot returns a copy of the current cron entry list. +func (c *Cron) entrySnapshot() []Entry { + var entries = make([]Entry, len(c.entries)) + for i, e := range c.entries { + entries[i] = *e + } + return entries +} + +func (c *Cron) removeEntry(id EntryID) { + var entries []*Entry + for _, e := range c.entries { + if e.ID != id { + entries = append(entries, e) + } + } + c.entries = entries +} diff --git a/vendor/github.com/robfig/cron/v3/doc.go b/vendor/github.com/robfig/cron/v3/doc.go new file mode 100644 index 000000000..fa5d08b4d --- /dev/null +++ b/vendor/github.com/robfig/cron/v3/doc.go @@ -0,0 +1,231 @@ +/* +Package cron implements a cron spec parser and job runner. + +Installation + +To download the specific tagged release, run: + + go get github.com/robfig/cron/v3@v3.0.0 + +Import it in your program as: + + import "github.com/robfig/cron/v3" + +It requires Go 1.11 or later due to usage of Go Modules. + +Usage + +Callers may register Funcs to be invoked on a given schedule. Cron will run +them in their own goroutines. + + c := cron.New() + c.AddFunc("30 * * * *", func() { fmt.Println("Every hour on the half hour") }) + c.AddFunc("30 3-6,20-23 * * *", func() { fmt.Println(".. in the range 3-6am, 8-11pm") }) + c.AddFunc("CRON_TZ=Asia/Tokyo 30 04 * * *", func() { fmt.Println("Runs at 04:30 Tokyo time every day") }) + c.AddFunc("@hourly", func() { fmt.Println("Every hour, starting an hour from now") }) + c.AddFunc("@every 1h30m", func() { fmt.Println("Every hour thirty, starting an hour thirty from now") }) + c.Start() + .. + // Funcs are invoked in their own goroutine, asynchronously. + ... + // Funcs may also be added to a running Cron + c.AddFunc("@daily", func() { fmt.Println("Every day") }) + .. + // Inspect the cron job entries' next and previous run times. + inspect(c.Entries()) + .. + c.Stop() // Stop the scheduler (does not stop any jobs already running). + +CRON Expression Format + +A cron expression represents a set of times, using 5 space-separated fields. + + Field name | Mandatory? | Allowed values | Allowed special characters + ---------- | ---------- | -------------- | -------------------------- + Minutes | Yes | 0-59 | * / , - + Hours | Yes | 0-23 | * / , - + Day of month | Yes | 1-31 | * / , - ? + Month | Yes | 1-12 or JAN-DEC | * / , - + Day of week | Yes | 0-6 or SUN-SAT | * / , - ? + +Month and Day-of-week field values are case insensitive. "SUN", "Sun", and +"sun" are equally accepted. + +The specific interpretation of the format is based on the Cron Wikipedia page: +https://en.wikipedia.org/wiki/Cron + +Alternative Formats + +Alternative Cron expression formats support other fields like seconds. You can +implement that by creating a custom Parser as follows. + + cron.New( + cron.WithParser( + cron.NewParser( + cron.SecondOptional | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor))) + +Since adding Seconds is the most common modification to the standard cron spec, +cron provides a builtin function to do that, which is equivalent to the custom +parser you saw earlier, except that its seconds field is REQUIRED: + + cron.New(cron.WithSeconds()) + +That emulates Quartz, the most popular alternative Cron schedule format: +http://www.quartz-scheduler.org/documentation/quartz-2.x/tutorials/crontrigger.html + +Special Characters + +Asterisk ( * ) + +The asterisk indicates that the cron expression will match for all values of the +field; e.g., using an asterisk in the 5th field (month) would indicate every +month. + +Slash ( / ) + +Slashes are used to describe increments of ranges. For example 3-59/15 in the +1st field (minutes) would indicate the 3rd minute of the hour and every 15 +minutes thereafter. The form "*\/..." is equivalent to the form "first-last/...", +that is, an increment over the largest possible range of the field. The form +"N/..." is accepted as meaning "N-MAX/...", that is, starting at N, use the +increment until the end of that specific range. It does not wrap around. + +Comma ( , ) + +Commas are used to separate items of a list. For example, using "MON,WED,FRI" in +the 5th field (day of week) would mean Mondays, Wednesdays and Fridays. + +Hyphen ( - ) + +Hyphens are used to define ranges. For example, 9-17 would indicate every +hour between 9am and 5pm inclusive. + +Question mark ( ? ) + +Question mark may be used instead of '*' for leaving either day-of-month or +day-of-week blank. + +Predefined schedules + +You may use one of several pre-defined schedules in place of a cron expression. + + Entry | Description | Equivalent To + ----- | ----------- | ------------- + @yearly (or @annually) | Run once a year, midnight, Jan. 1st | 0 0 1 1 * + @monthly | Run once a month, midnight, first of month | 0 0 1 * * + @weekly | Run once a week, midnight between Sat/Sun | 0 0 * * 0 + @daily (or @midnight) | Run once a day, midnight | 0 0 * * * + @hourly | Run once an hour, beginning of hour | 0 * * * * + +Intervals + +You may also schedule a job to execute at fixed intervals, starting at the time it's added +or cron is run. This is supported by formatting the cron spec like this: + + @every + +where "duration" is a string accepted by time.ParseDuration +(http://golang.org/pkg/time/#ParseDuration). + +For example, "@every 1h30m10s" would indicate a schedule that activates after +1 hour, 30 minutes, 10 seconds, and then every interval after that. + +Note: The interval does not take the job runtime into account. For example, +if a job takes 3 minutes to run, and it is scheduled to run every 5 minutes, +it will have only 2 minutes of idle time between each run. + +Time zones + +By default, all interpretation and scheduling is done in the machine's local +time zone (time.Local). You can specify a different time zone on construction: + + cron.New( + cron.WithLocation(time.UTC)) + +Individual cron schedules may also override the time zone they are to be +interpreted in by providing an additional space-separated field at the beginning +of the cron spec, of the form "CRON_TZ=Asia/Tokyo". + +For example: + + # Runs at 6am in time.Local + cron.New().AddFunc("0 6 * * ?", ...) + + # Runs at 6am in America/New_York + nyc, _ := time.LoadLocation("America/New_York") + c := cron.New(cron.WithLocation(nyc)) + c.AddFunc("0 6 * * ?", ...) + + # Runs at 6am in Asia/Tokyo + cron.New().AddFunc("CRON_TZ=Asia/Tokyo 0 6 * * ?", ...) + + # Runs at 6am in Asia/Tokyo + c := cron.New(cron.WithLocation(nyc)) + c.SetLocation("America/New_York") + c.AddFunc("CRON_TZ=Asia/Tokyo 0 6 * * ?", ...) + +The prefix "TZ=(TIME ZONE)" is also supported for legacy compatibility. + +Be aware that jobs scheduled during daylight-savings leap-ahead transitions will +not be run! + +Job Wrappers + +A Cron runner may be configured with a chain of job wrappers to add +cross-cutting functionality to all submitted jobs. For example, they may be used +to achieve the following effects: + + - Recover any panics from jobs (activated by default) + - Delay a job's execution if the previous run hasn't completed yet + - Skip a job's execution if the previous run hasn't completed yet + - Log each job's invocations + +Install wrappers for all jobs added to a cron using the `cron.WithChain` option: + + cron.New(cron.WithChain( + cron.SkipIfStillRunning(logger), + )) + +Install wrappers for individual jobs by explicitly wrapping them: + + job = cron.NewChain( + cron.SkipIfStillRunning(logger), + ).Then(job) + +Thread safety + +Since the Cron service runs concurrently with the calling code, some amount of +care must be taken to ensure proper synchronization. + +All cron methods are designed to be correctly synchronized as long as the caller +ensures that invocations have a clear happens-before ordering between them. + +Logging + +Cron defines a Logger interface that is a subset of the one defined in +github.com/go-logr/logr. It has two logging levels (Info and Error), and +parameters are key/value pairs. This makes it possible for cron logging to plug +into structured logging systems. An adapter, [Verbose]PrintfLogger, is provided +to wrap the standard library *log.Logger. + +For additional insight into Cron operations, verbose logging may be activated +which will record job runs, scheduling decisions, and added or removed jobs. +Activate it with a one-off logger as follows: + + cron.New( + cron.WithLogger( + cron.VerbosePrintfLogger(log.New(os.Stdout, "cron: ", log.LstdFlags)))) + + +Implementation + +Cron entries are stored in an array, sorted by their next activation time. Cron +sleeps until the next job is due to be run. + +Upon waking: + - it runs each entry that is active on that second + - it calculates the next run times for the jobs that were run + - it re-sorts the array of entries by next activation time. + - it goes to sleep until the soonest job. +*/ +package cron diff --git a/vendor/github.com/robfig/cron/v3/logger.go b/vendor/github.com/robfig/cron/v3/logger.go new file mode 100644 index 000000000..b4efcc053 --- /dev/null +++ b/vendor/github.com/robfig/cron/v3/logger.go @@ -0,0 +1,86 @@ +package cron + +import ( + "io/ioutil" + "log" + "os" + "strings" + "time" +) + +// DefaultLogger is used by Cron if none is specified. +var DefaultLogger Logger = PrintfLogger(log.New(os.Stdout, "cron: ", log.LstdFlags)) + +// DiscardLogger can be used by callers to discard all log messages. +var DiscardLogger Logger = PrintfLogger(log.New(ioutil.Discard, "", 0)) + +// Logger is the interface used in this package for logging, so that any backend +// can be plugged in. It is a subset of the github.com/go-logr/logr interface. +type Logger interface { + // Info logs routine messages about cron's operation. + Info(msg string, keysAndValues ...interface{}) + // Error logs an error condition. + Error(err error, msg string, keysAndValues ...interface{}) +} + +// PrintfLogger wraps a Printf-based logger (such as the standard library "log") +// into an implementation of the Logger interface which logs errors only. +func PrintfLogger(l interface{ Printf(string, ...interface{}) }) Logger { + return printfLogger{l, false} +} + +// VerbosePrintfLogger wraps a Printf-based logger (such as the standard library +// "log") into an implementation of the Logger interface which logs everything. +func VerbosePrintfLogger(l interface{ Printf(string, ...interface{}) }) Logger { + return printfLogger{l, true} +} + +type printfLogger struct { + logger interface{ Printf(string, ...interface{}) } + logInfo bool +} + +func (pl printfLogger) Info(msg string, keysAndValues ...interface{}) { + if pl.logInfo { + keysAndValues = formatTimes(keysAndValues) + pl.logger.Printf( + formatString(len(keysAndValues)), + append([]interface{}{msg}, keysAndValues...)...) + } +} + +func (pl printfLogger) Error(err error, msg string, keysAndValues ...interface{}) { + keysAndValues = formatTimes(keysAndValues) + pl.logger.Printf( + formatString(len(keysAndValues)+2), + append([]interface{}{msg, "error", err}, keysAndValues...)...) +} + +// formatString returns a logfmt-like format string for the number of +// key/values. +func formatString(numKeysAndValues int) string { + var sb strings.Builder + sb.WriteString("%s") + if numKeysAndValues > 0 { + sb.WriteString(", ") + } + for i := 0; i < numKeysAndValues/2; i++ { + if i > 0 { + sb.WriteString(", ") + } + sb.WriteString("%v=%v") + } + return sb.String() +} + +// formatTimes formats any time.Time values as RFC3339. +func formatTimes(keysAndValues []interface{}) []interface{} { + var formattedArgs []interface{} + for _, arg := range keysAndValues { + if t, ok := arg.(time.Time); ok { + arg = t.Format(time.RFC3339) + } + formattedArgs = append(formattedArgs, arg) + } + return formattedArgs +} diff --git a/vendor/github.com/robfig/cron/v3/option.go b/vendor/github.com/robfig/cron/v3/option.go new file mode 100644 index 000000000..09e4278e7 --- /dev/null +++ b/vendor/github.com/robfig/cron/v3/option.go @@ -0,0 +1,45 @@ +package cron + +import ( + "time" +) + +// Option represents a modification to the default behavior of a Cron. +type Option func(*Cron) + +// WithLocation overrides the timezone of the cron instance. +func WithLocation(loc *time.Location) Option { + return func(c *Cron) { + c.location = loc + } +} + +// WithSeconds overrides the parser used for interpreting job schedules to +// include a seconds field as the first one. +func WithSeconds() Option { + return WithParser(NewParser( + Second | Minute | Hour | Dom | Month | Dow | Descriptor, + )) +} + +// WithParser overrides the parser used for interpreting job schedules. +func WithParser(p ScheduleParser) Option { + return func(c *Cron) { + c.parser = p + } +} + +// WithChain specifies Job wrappers to apply to all jobs added to this cron. +// Refer to the Chain* functions in this package for provided wrappers. +func WithChain(wrappers ...JobWrapper) Option { + return func(c *Cron) { + c.chain = NewChain(wrappers...) + } +} + +// WithLogger uses the provided logger. +func WithLogger(logger Logger) Option { + return func(c *Cron) { + c.logger = logger + } +} diff --git a/vendor/github.com/robfig/cron/v3/parser.go b/vendor/github.com/robfig/cron/v3/parser.go new file mode 100644 index 000000000..3cf8879f7 --- /dev/null +++ b/vendor/github.com/robfig/cron/v3/parser.go @@ -0,0 +1,434 @@ +package cron + +import ( + "fmt" + "math" + "strconv" + "strings" + "time" +) + +// Configuration options for creating a parser. Most options specify which +// fields should be included, while others enable features. If a field is not +// included the parser will assume a default value. These options do not change +// the order fields are parse in. +type ParseOption int + +const ( + Second ParseOption = 1 << iota // Seconds field, default 0 + SecondOptional // Optional seconds field, default 0 + Minute // Minutes field, default 0 + Hour // Hours field, default 0 + Dom // Day of month field, default * + Month // Month field, default * + Dow // Day of week field, default * + DowOptional // Optional day of week field, default * + Descriptor // Allow descriptors such as @monthly, @weekly, etc. +) + +var places = []ParseOption{ + Second, + Minute, + Hour, + Dom, + Month, + Dow, +} + +var defaults = []string{ + "0", + "0", + "0", + "*", + "*", + "*", +} + +// A custom Parser that can be configured. +type Parser struct { + options ParseOption +} + +// NewParser creates a Parser with custom options. +// +// It panics if more than one Optional is given, since it would be impossible to +// correctly infer which optional is provided or missing in general. +// +// Examples +// +// // Standard parser without descriptors +// specParser := NewParser(Minute | Hour | Dom | Month | Dow) +// sched, err := specParser.Parse("0 0 15 */3 *") +// +// // Same as above, just excludes time fields +// subsParser := NewParser(Dom | Month | Dow) +// sched, err := specParser.Parse("15 */3 *") +// +// // Same as above, just makes Dow optional +// subsParser := NewParser(Dom | Month | DowOptional) +// sched, err := specParser.Parse("15 */3") +// +func NewParser(options ParseOption) Parser { + optionals := 0 + if options&DowOptional > 0 { + optionals++ + } + if options&SecondOptional > 0 { + optionals++ + } + if optionals > 1 { + panic("multiple optionals may not be configured") + } + return Parser{options} +} + +// Parse returns a new crontab schedule representing the given spec. +// It returns a descriptive error if the spec is not valid. +// It accepts crontab specs and features configured by NewParser. +func (p Parser) Parse(spec string) (Schedule, error) { + if len(spec) == 0 { + return nil, fmt.Errorf("empty spec string") + } + + // Extract timezone if present + var loc = time.Local + if strings.HasPrefix(spec, "TZ=") || strings.HasPrefix(spec, "CRON_TZ=") { + var err error + i := strings.Index(spec, " ") + eq := strings.Index(spec, "=") + if loc, err = time.LoadLocation(spec[eq+1 : i]); err != nil { + return nil, fmt.Errorf("provided bad location %s: %v", spec[eq+1:i], err) + } + spec = strings.TrimSpace(spec[i:]) + } + + // Handle named schedules (descriptors), if configured + if strings.HasPrefix(spec, "@") { + if p.options&Descriptor == 0 { + return nil, fmt.Errorf("parser does not accept descriptors: %v", spec) + } + return parseDescriptor(spec, loc) + } + + // Split on whitespace. + fields := strings.Fields(spec) + + // Validate & fill in any omitted or optional fields + var err error + fields, err = normalizeFields(fields, p.options) + if err != nil { + return nil, err + } + + field := func(field string, r bounds) uint64 { + if err != nil { + return 0 + } + var bits uint64 + bits, err = getField(field, r) + return bits + } + + var ( + second = field(fields[0], seconds) + minute = field(fields[1], minutes) + hour = field(fields[2], hours) + dayofmonth = field(fields[3], dom) + month = field(fields[4], months) + dayofweek = field(fields[5], dow) + ) + if err != nil { + return nil, err + } + + return &SpecSchedule{ + Second: second, + Minute: minute, + Hour: hour, + Dom: dayofmonth, + Month: month, + Dow: dayofweek, + Location: loc, + }, nil +} + +// normalizeFields takes a subset set of the time fields and returns the full set +// with defaults (zeroes) populated for unset fields. +// +// As part of performing this function, it also validates that the provided +// fields are compatible with the configured options. +func normalizeFields(fields []string, options ParseOption) ([]string, error) { + // Validate optionals & add their field to options + optionals := 0 + if options&SecondOptional > 0 { + options |= Second + optionals++ + } + if options&DowOptional > 0 { + options |= Dow + optionals++ + } + if optionals > 1 { + return nil, fmt.Errorf("multiple optionals may not be configured") + } + + // Figure out how many fields we need + max := 0 + for _, place := range places { + if options&place > 0 { + max++ + } + } + min := max - optionals + + // Validate number of fields + if count := len(fields); count < min || count > max { + if min == max { + return nil, fmt.Errorf("expected exactly %d fields, found %d: %s", min, count, fields) + } + return nil, fmt.Errorf("expected %d to %d fields, found %d: %s", min, max, count, fields) + } + + // Populate the optional field if not provided + if min < max && len(fields) == min { + switch { + case options&DowOptional > 0: + fields = append(fields, defaults[5]) // TODO: improve access to default + case options&SecondOptional > 0: + fields = append([]string{defaults[0]}, fields...) + default: + return nil, fmt.Errorf("unknown optional field") + } + } + + // Populate all fields not part of options with their defaults + n := 0 + expandedFields := make([]string, len(places)) + copy(expandedFields, defaults) + for i, place := range places { + if options&place > 0 { + expandedFields[i] = fields[n] + n++ + } + } + return expandedFields, nil +} + +var standardParser = NewParser( + Minute | Hour | Dom | Month | Dow | Descriptor, +) + +// ParseStandard returns a new crontab schedule representing the given +// standardSpec (https://en.wikipedia.org/wiki/Cron). It requires 5 entries +// representing: minute, hour, day of month, month and day of week, in that +// order. It returns a descriptive error if the spec is not valid. +// +// It accepts +// - Standard crontab specs, e.g. "* * * * ?" +// - Descriptors, e.g. "@midnight", "@every 1h30m" +func ParseStandard(standardSpec string) (Schedule, error) { + return standardParser.Parse(standardSpec) +} + +// getField returns an Int with the bits set representing all of the times that +// the field represents or error parsing field value. A "field" is a comma-separated +// list of "ranges". +func getField(field string, r bounds) (uint64, error) { + var bits uint64 + ranges := strings.FieldsFunc(field, func(r rune) bool { return r == ',' }) + for _, expr := range ranges { + bit, err := getRange(expr, r) + if err != nil { + return bits, err + } + bits |= bit + } + return bits, nil +} + +// getRange returns the bits indicated by the given expression: +// number | number "-" number [ "/" number ] +// or error parsing range. +func getRange(expr string, r bounds) (uint64, error) { + var ( + start, end, step uint + rangeAndStep = strings.Split(expr, "/") + lowAndHigh = strings.Split(rangeAndStep[0], "-") + singleDigit = len(lowAndHigh) == 1 + err error + ) + + var extra uint64 + if lowAndHigh[0] == "*" || lowAndHigh[0] == "?" { + start = r.min + end = r.max + extra = starBit + } else { + start, err = parseIntOrName(lowAndHigh[0], r.names) + if err != nil { + return 0, err + } + switch len(lowAndHigh) { + case 1: + end = start + case 2: + end, err = parseIntOrName(lowAndHigh[1], r.names) + if err != nil { + return 0, err + } + default: + return 0, fmt.Errorf("too many hyphens: %s", expr) + } + } + + switch len(rangeAndStep) { + case 1: + step = 1 + case 2: + step, err = mustParseInt(rangeAndStep[1]) + if err != nil { + return 0, err + } + + // Special handling: "N/step" means "N-max/step". + if singleDigit { + end = r.max + } + if step > 1 { + extra = 0 + } + default: + return 0, fmt.Errorf("too many slashes: %s", expr) + } + + if start < r.min { + return 0, fmt.Errorf("beginning of range (%d) below minimum (%d): %s", start, r.min, expr) + } + if end > r.max { + return 0, fmt.Errorf("end of range (%d) above maximum (%d): %s", end, r.max, expr) + } + if start > end { + return 0, fmt.Errorf("beginning of range (%d) beyond end of range (%d): %s", start, end, expr) + } + if step == 0 { + return 0, fmt.Errorf("step of range should be a positive number: %s", expr) + } + + return getBits(start, end, step) | extra, nil +} + +// parseIntOrName returns the (possibly-named) integer contained in expr. +func parseIntOrName(expr string, names map[string]uint) (uint, error) { + if names != nil { + if namedInt, ok := names[strings.ToLower(expr)]; ok { + return namedInt, nil + } + } + return mustParseInt(expr) +} + +// mustParseInt parses the given expression as an int or returns an error. +func mustParseInt(expr string) (uint, error) { + num, err := strconv.Atoi(expr) + if err != nil { + return 0, fmt.Errorf("failed to parse int from %s: %s", expr, err) + } + if num < 0 { + return 0, fmt.Errorf("negative number (%d) not allowed: %s", num, expr) + } + + return uint(num), nil +} + +// getBits sets all bits in the range [min, max], modulo the given step size. +func getBits(min, max, step uint) uint64 { + var bits uint64 + + // If step is 1, use shifts. + if step == 1 { + return ^(math.MaxUint64 << (max + 1)) & (math.MaxUint64 << min) + } + + // Else, use a simple loop. + for i := min; i <= max; i += step { + bits |= 1 << i + } + return bits +} + +// all returns all bits within the given bounds. (plus the star bit) +func all(r bounds) uint64 { + return getBits(r.min, r.max, 1) | starBit +} + +// parseDescriptor returns a predefined schedule for the expression, or error if none matches. +func parseDescriptor(descriptor string, loc *time.Location) (Schedule, error) { + switch descriptor { + case "@yearly", "@annually": + return &SpecSchedule{ + Second: 1 << seconds.min, + Minute: 1 << minutes.min, + Hour: 1 << hours.min, + Dom: 1 << dom.min, + Month: 1 << months.min, + Dow: all(dow), + Location: loc, + }, nil + + case "@monthly": + return &SpecSchedule{ + Second: 1 << seconds.min, + Minute: 1 << minutes.min, + Hour: 1 << hours.min, + Dom: 1 << dom.min, + Month: all(months), + Dow: all(dow), + Location: loc, + }, nil + + case "@weekly": + return &SpecSchedule{ + Second: 1 << seconds.min, + Minute: 1 << minutes.min, + Hour: 1 << hours.min, + Dom: all(dom), + Month: all(months), + Dow: 1 << dow.min, + Location: loc, + }, nil + + case "@daily", "@midnight": + return &SpecSchedule{ + Second: 1 << seconds.min, + Minute: 1 << minutes.min, + Hour: 1 << hours.min, + Dom: all(dom), + Month: all(months), + Dow: all(dow), + Location: loc, + }, nil + + case "@hourly": + return &SpecSchedule{ + Second: 1 << seconds.min, + Minute: 1 << minutes.min, + Hour: all(hours), + Dom: all(dom), + Month: all(months), + Dow: all(dow), + Location: loc, + }, nil + + } + + const every = "@every " + if strings.HasPrefix(descriptor, every) { + duration, err := time.ParseDuration(descriptor[len(every):]) + if err != nil { + return nil, fmt.Errorf("failed to parse duration %s: %s", descriptor, err) + } + return Every(duration), nil + } + + return nil, fmt.Errorf("unrecognized descriptor: %s", descriptor) +} diff --git a/vendor/github.com/robfig/cron/v3/spec.go b/vendor/github.com/robfig/cron/v3/spec.go new file mode 100644 index 000000000..fa1e241e5 --- /dev/null +++ b/vendor/github.com/robfig/cron/v3/spec.go @@ -0,0 +1,188 @@ +package cron + +import "time" + +// SpecSchedule specifies a duty cycle (to the second granularity), based on a +// traditional crontab specification. It is computed initially and stored as bit sets. +type SpecSchedule struct { + Second, Minute, Hour, Dom, Month, Dow uint64 + + // Override location for this schedule. + Location *time.Location +} + +// bounds provides a range of acceptable values (plus a map of name to value). +type bounds struct { + min, max uint + names map[string]uint +} + +// The bounds for each field. +var ( + seconds = bounds{0, 59, nil} + minutes = bounds{0, 59, nil} + hours = bounds{0, 23, nil} + dom = bounds{1, 31, nil} + months = bounds{1, 12, map[string]uint{ + "jan": 1, + "feb": 2, + "mar": 3, + "apr": 4, + "may": 5, + "jun": 6, + "jul": 7, + "aug": 8, + "sep": 9, + "oct": 10, + "nov": 11, + "dec": 12, + }} + dow = bounds{0, 6, map[string]uint{ + "sun": 0, + "mon": 1, + "tue": 2, + "wed": 3, + "thu": 4, + "fri": 5, + "sat": 6, + }} +) + +const ( + // Set the top bit if a star was included in the expression. + starBit = 1 << 63 +) + +// Next returns the next time this schedule is activated, greater than the given +// time. If no time can be found to satisfy the schedule, return the zero time. +func (s *SpecSchedule) Next(t time.Time) time.Time { + // General approach + // + // For Month, Day, Hour, Minute, Second: + // Check if the time value matches. If yes, continue to the next field. + // If the field doesn't match the schedule, then increment the field until it matches. + // While incrementing the field, a wrap-around brings it back to the beginning + // of the field list (since it is necessary to re-verify previous field + // values) + + // Convert the given time into the schedule's timezone, if one is specified. + // Save the original timezone so we can convert back after we find a time. + // Note that schedules without a time zone specified (time.Local) are treated + // as local to the time provided. + origLocation := t.Location() + loc := s.Location + if loc == time.Local { + loc = t.Location() + } + if s.Location != time.Local { + t = t.In(s.Location) + } + + // Start at the earliest possible time (the upcoming second). + t = t.Add(1*time.Second - time.Duration(t.Nanosecond())*time.Nanosecond) + + // This flag indicates whether a field has been incremented. + added := false + + // If no time is found within five years, return zero. + yearLimit := t.Year() + 5 + +WRAP: + if t.Year() > yearLimit { + return time.Time{} + } + + // Find the first applicable month. + // If it's this month, then do nothing. + for 1< 12 { + t = t.Add(time.Duration(24-t.Hour()) * time.Hour) + } else { + t = t.Add(time.Duration(-t.Hour()) * time.Hour) + } + } + + if t.Day() == 1 { + goto WRAP + } + } + + for 1< 0 + dowMatch bool = 1< 0 + ) + if s.Dom&starBit > 0 || s.Dow&starBit > 0 { + return domMatch && dowMatch + } + return domMatch || dowMatch +} diff --git a/vendor/k8s.io/utils/clock/testing/fake_clock.go b/vendor/k8s.io/utils/clock/testing/fake_clock.go new file mode 100644 index 000000000..7274299ea --- /dev/null +++ b/vendor/k8s.io/utils/clock/testing/fake_clock.go @@ -0,0 +1,374 @@ +/* +Copyright 2014 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package testing + +import ( + "sync" + "time" + + "k8s.io/utils/clock" +) + +var ( + _ = clock.PassiveClock(&FakePassiveClock{}) + _ = clock.WithTicker(&FakeClock{}) + _ = clock.Clock(&IntervalClock{}) +) + +// FakePassiveClock implements PassiveClock, but returns an arbitrary time. +type FakePassiveClock struct { + lock sync.RWMutex + time time.Time +} + +// FakeClock implements clock.Clock, but returns an arbitrary time. +type FakeClock struct { + FakePassiveClock + + // waiters are waiting for the fake time to pass their specified time + waiters []*fakeClockWaiter +} + +type fakeClockWaiter struct { + targetTime time.Time + stepInterval time.Duration + skipIfBlocked bool + destChan chan time.Time + afterFunc func() +} + +// NewFakePassiveClock returns a new FakePassiveClock. +func NewFakePassiveClock(t time.Time) *FakePassiveClock { + return &FakePassiveClock{ + time: t, + } +} + +// NewFakeClock constructs a fake clock set to the provided time. +func NewFakeClock(t time.Time) *FakeClock { + return &FakeClock{ + FakePassiveClock: *NewFakePassiveClock(t), + } +} + +// Now returns f's time. +func (f *FakePassiveClock) Now() time.Time { + f.lock.RLock() + defer f.lock.RUnlock() + return f.time +} + +// Since returns time since the time in f. +func (f *FakePassiveClock) Since(ts time.Time) time.Duration { + f.lock.RLock() + defer f.lock.RUnlock() + return f.time.Sub(ts) +} + +// SetTime sets the time on the FakePassiveClock. +func (f *FakePassiveClock) SetTime(t time.Time) { + f.lock.Lock() + defer f.lock.Unlock() + f.time = t +} + +// After is the fake version of time.After(d). +func (f *FakeClock) After(d time.Duration) <-chan time.Time { + f.lock.Lock() + defer f.lock.Unlock() + stopTime := f.time.Add(d) + ch := make(chan time.Time, 1) // Don't block! + f.waiters = append(f.waiters, &fakeClockWaiter{ + targetTime: stopTime, + destChan: ch, + }) + return ch +} + +// NewTimer constructs a fake timer, akin to time.NewTimer(d). +func (f *FakeClock) NewTimer(d time.Duration) clock.Timer { + f.lock.Lock() + defer f.lock.Unlock() + stopTime := f.time.Add(d) + ch := make(chan time.Time, 1) // Don't block! + timer := &fakeTimer{ + fakeClock: f, + waiter: fakeClockWaiter{ + targetTime: stopTime, + destChan: ch, + }, + } + f.waiters = append(f.waiters, &timer.waiter) + return timer +} + +// AfterFunc is the Fake version of time.AfterFunc(d, cb). +func (f *FakeClock) AfterFunc(d time.Duration, cb func()) clock.Timer { + f.lock.Lock() + defer f.lock.Unlock() + stopTime := f.time.Add(d) + ch := make(chan time.Time, 1) // Don't block! + + timer := &fakeTimer{ + fakeClock: f, + waiter: fakeClockWaiter{ + targetTime: stopTime, + destChan: ch, + afterFunc: cb, + }, + } + f.waiters = append(f.waiters, &timer.waiter) + return timer +} + +// Tick constructs a fake ticker, akin to time.Tick +func (f *FakeClock) Tick(d time.Duration) <-chan time.Time { + if d <= 0 { + return nil + } + f.lock.Lock() + defer f.lock.Unlock() + tickTime := f.time.Add(d) + ch := make(chan time.Time, 1) // hold one tick + f.waiters = append(f.waiters, &fakeClockWaiter{ + targetTime: tickTime, + stepInterval: d, + skipIfBlocked: true, + destChan: ch, + }) + + return ch +} + +// NewTicker returns a new Ticker. +func (f *FakeClock) NewTicker(d time.Duration) clock.Ticker { + f.lock.Lock() + defer f.lock.Unlock() + tickTime := f.time.Add(d) + ch := make(chan time.Time, 1) // hold one tick + f.waiters = append(f.waiters, &fakeClockWaiter{ + targetTime: tickTime, + stepInterval: d, + skipIfBlocked: true, + destChan: ch, + }) + + return &fakeTicker{ + c: ch, + } +} + +// Step moves the clock by Duration and notifies anyone that's called After, +// Tick, or NewTimer. +func (f *FakeClock) Step(d time.Duration) { + f.lock.Lock() + defer f.lock.Unlock() + f.setTimeLocked(f.time.Add(d)) +} + +// SetTime sets the time. +func (f *FakeClock) SetTime(t time.Time) { + f.lock.Lock() + defer f.lock.Unlock() + f.setTimeLocked(t) +} + +// Actually changes the time and checks any waiters. f must be write-locked. +func (f *FakeClock) setTimeLocked(t time.Time) { + f.time = t + newWaiters := make([]*fakeClockWaiter, 0, len(f.waiters)) + for i := range f.waiters { + w := f.waiters[i] + if !w.targetTime.After(t) { + if w.skipIfBlocked { + select { + case w.destChan <- t: + default: + } + } else { + w.destChan <- t + } + + if w.afterFunc != nil { + w.afterFunc() + } + + if w.stepInterval > 0 { + for !w.targetTime.After(t) { + w.targetTime = w.targetTime.Add(w.stepInterval) + } + newWaiters = append(newWaiters, w) + } + + } else { + newWaiters = append(newWaiters, f.waiters[i]) + } + } + f.waiters = newWaiters +} + +// HasWaiters returns true if Waiters() returns non-0 (so you can write race-free tests). +func (f *FakeClock) HasWaiters() bool { + f.lock.RLock() + defer f.lock.RUnlock() + return len(f.waiters) > 0 +} + +// Waiters returns the number of "waiters" on the clock (so you can write race-free +// tests). A waiter exists for: +// - every call to After that has not yet signaled its channel. +// - every call to AfterFunc that has not yet called its callback. +// - every timer created with NewTimer which is currently ticking. +// - every ticker created with NewTicker which is currently ticking. +// - every ticker created with Tick. +func (f *FakeClock) Waiters() int { + f.lock.RLock() + defer f.lock.RUnlock() + return len(f.waiters) +} + +// Sleep is akin to time.Sleep +func (f *FakeClock) Sleep(d time.Duration) { + f.Step(d) +} + +// IntervalClock implements clock.PassiveClock, but each invocation of Now steps the clock forward the specified duration. +// IntervalClock technically implements the other methods of clock.Clock, but each implementation is just a panic. +// +// Deprecated: See SimpleIntervalClock for an alternative that only has the methods of PassiveClock. +type IntervalClock struct { + Time time.Time + Duration time.Duration +} + +// Now returns i's time. +func (i *IntervalClock) Now() time.Time { + i.Time = i.Time.Add(i.Duration) + return i.Time +} + +// Since returns time since the time in i. +func (i *IntervalClock) Since(ts time.Time) time.Duration { + return i.Time.Sub(ts) +} + +// After is unimplemented, will panic. +// TODO: make interval clock use FakeClock so this can be implemented. +func (*IntervalClock) After(_ time.Duration) <-chan time.Time { + panic("IntervalClock doesn't implement After") +} + +// NewTimer is unimplemented, will panic. +// TODO: make interval clock use FakeClock so this can be implemented. +func (*IntervalClock) NewTimer(_ time.Duration) clock.Timer { + panic("IntervalClock doesn't implement NewTimer") +} + +// AfterFunc is unimplemented, will panic. +// TODO: make interval clock use FakeClock so this can be implemented. +func (*IntervalClock) AfterFunc(_ time.Duration, _ func()) clock.Timer { + panic("IntervalClock doesn't implement AfterFunc") +} + +// Tick is unimplemented, will panic. +// TODO: make interval clock use FakeClock so this can be implemented. +func (*IntervalClock) Tick(_ time.Duration) <-chan time.Time { + panic("IntervalClock doesn't implement Tick") +} + +// NewTicker has no implementation yet and is omitted. +// TODO: make interval clock use FakeClock so this can be implemented. +func (*IntervalClock) NewTicker(_ time.Duration) clock.Ticker { + panic("IntervalClock doesn't implement NewTicker") +} + +// Sleep is unimplemented, will panic. +func (*IntervalClock) Sleep(_ time.Duration) { + panic("IntervalClock doesn't implement Sleep") +} + +var _ = clock.Timer(&fakeTimer{}) + +// fakeTimer implements clock.Timer based on a FakeClock. +type fakeTimer struct { + fakeClock *FakeClock + waiter fakeClockWaiter +} + +// C returns the channel that notifies when this timer has fired. +func (f *fakeTimer) C() <-chan time.Time { + return f.waiter.destChan +} + +// Stop prevents the Timer from firing. It returns true if the call stops the +// timer, false if the timer has already expired or been stopped. +func (f *fakeTimer) Stop() bool { + f.fakeClock.lock.Lock() + defer f.fakeClock.lock.Unlock() + + active := false + newWaiters := make([]*fakeClockWaiter, 0, len(f.fakeClock.waiters)) + for i := range f.fakeClock.waiters { + w := f.fakeClock.waiters[i] + if w != &f.waiter { + newWaiters = append(newWaiters, w) + continue + } + // If timer is found, it has not been fired yet. + active = true + } + + f.fakeClock.waiters = newWaiters + + return active +} + +// Reset changes the timer to expire after duration d. It returns true if the +// timer had been active, false if the timer had expired or been stopped. +func (f *fakeTimer) Reset(d time.Duration) bool { + f.fakeClock.lock.Lock() + defer f.fakeClock.lock.Unlock() + + active := false + + f.waiter.targetTime = f.fakeClock.time.Add(d) + + for i := range f.fakeClock.waiters { + w := f.fakeClock.waiters[i] + if w == &f.waiter { + // If timer is found, it has not been fired yet. + active = true + break + } + } + if !active { + f.fakeClock.waiters = append(f.fakeClock.waiters, &f.waiter) + } + + return active +} + +type fakeTicker struct { + c <-chan time.Time +} + +func (t *fakeTicker) C() <-chan time.Time { + return t.c +} + +func (t *fakeTicker) Stop() { +} diff --git a/vendor/k8s.io/utils/clock/testing/simple_interval_clock.go b/vendor/k8s.io/utils/clock/testing/simple_interval_clock.go new file mode 100644 index 000000000..951ca4d17 --- /dev/null +++ b/vendor/k8s.io/utils/clock/testing/simple_interval_clock.go @@ -0,0 +1,44 @@ +/* +Copyright 2021 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package testing + +import ( + "time" + + "k8s.io/utils/clock" +) + +var ( + _ = clock.PassiveClock(&SimpleIntervalClock{}) +) + +// SimpleIntervalClock implements clock.PassiveClock, but each invocation of Now steps the clock forward the specified duration +type SimpleIntervalClock struct { + Time time.Time + Duration time.Duration +} + +// Now returns i's time. +func (i *SimpleIntervalClock) Now() time.Time { + i.Time = i.Time.Add(i.Duration) + return i.Time +} + +// Since returns time since the time in i. +func (i *SimpleIntervalClock) Since(ts time.Time) time.Duration { + return i.Time.Sub(ts) +} diff --git a/vendor/modules.txt b/vendor/modules.txt index cd1552383..b727d31a4 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -743,6 +743,9 @@ github.com/prometheus/otlptranslator github.com/prometheus/procfs github.com/prometheus/procfs/internal/fs github.com/prometheus/procfs/internal/util +# github.com/robfig/cron/v3 v3.0.1 +## explicit; go 1.12 +github.com/robfig/cron/v3 # github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 ## explicit; go 1.21 github.com/santhosh-tekuri/jsonschema/v6 @@ -1924,6 +1927,7 @@ k8s.io/kube-openapi/pkg/validation/strfmt/bson ## explicit; go 1.25 k8s.io/utils/buffer k8s.io/utils/clock +k8s.io/utils/clock/testing k8s.io/utils/internal/third_party/forked/golang/golang-lru k8s.io/utils/internal/third_party/forked/golang/net k8s.io/utils/lru