diff --git a/PROJECT b/PROJECT index 02b76c41d..6127123f0 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: SoftwareBuild + path: github.com/centos-automotive-suite/automotive-dev-operator/api/v1alpha1 + version: v1alpha1 version: "3" diff --git a/README.md b/README.md index 971010877..4b1cdd154 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ An operator for building automotive OS images on OpenShift. This operator provid The CentOS Automotive Suite Operator enables automotive OS image building through: - **ImageBuild Custom Resource**: Declaratively define and trigger automotive OS image builds +- **SoftwareBuild Custom Resource**: Build software for any target OS using arbitrary container images and stage commands (Zephyr, Ubuntu, OpenBSW, etc.) - **Multiple Build Modes**: Support for traditional AIB manifests and bootc container builds - **CLI Tool (caib)**: Command-line interface for creating and monitoring builds - **Artifact Management**: Serve built images via OpenShift Routes or push to OCI registries @@ -135,6 +136,52 @@ oc get imagebuild my-automotive-image -w oc logs -f job/my-automotive-image-build ``` +### Building Software for Other Target OSes + +Use `SoftwareBuild` to build firmware or software with any toolchain. The build +runs inside the container image you specify, executing five sequential stages: + +```yaml +apiVersion: automotive.sdv.cloud.redhat.com/v1alpha1 +kind: SoftwareBuild +metadata: + name: body-ecu-zephyr +spec: + runtime: + image: ghcr.io/zephyrproject-rtos/ci-base:v0.27.4 + source: + type: git + git: + url: https://github.com/vtz/body-ecu + revision: main + stages: + fetch: + command: "west init -l . && west update" + prebuild: + command: "echo 'Dependencies ready'" + build: + command: "west build -b native_sim app" + postbuild: + command: "ctest --test-dir build/tests --output-on-failure" + deploy: + command: "cp build/zephyr/zephyr.elf /workspace/artifacts/" + destination: + type: sharedFolder + path: /workspace/artifacts +``` + +Enable the software build pipeline in your `OperatorConfig`: + +```yaml +apiVersion: automotive.sdv.cloud.redhat.com/v1alpha1 +kind: OperatorConfig +metadata: + name: default +spec: + softwareBuilds: + enabled: true +``` + ## Uninstallation ### For OperatorHub Installation @@ -164,7 +211,8 @@ make uninstall ### Custom Resources -- **ImageBuild**: Defines an automotive OS image build job +- **ImageBuild**: Defines an automotive OS image build job using AIB +- **SoftwareBuild**: Defines a generic, stage-based software build for any target OS or toolchain - **Image**: Represents a built image with metadata and location information - **OperatorConfig**: Cluster-wide configuration for the operator diff --git a/api/v1alpha1/operatorconfig_types.go b/api/v1alpha1/operatorconfig_types.go index f5fffdb19..5e3facde2 100644 --- a/api/v1alpha1/operatorconfig_types.go +++ b/api/v1alpha1/operatorconfig_types.go @@ -430,6 +430,28 @@ func (c *WorkspacesConfig) GetAutoPauseTimeoutMinutes() int32 { return DefaultAutoPauseTimeoutMinutes } +// SoftwareBuildsConfig defines configuration for generic software build operations +type SoftwareBuildsConfig struct { + // Enabled determines if Tekton pipeline for generic software builds should be deployed + // +kubebuilder:default=false + Enabled bool `json:"enabled"` + + // PVCSize specifies the size for persistent volume claims created for build workspaces + // Default: "1Gi" + // +optional + PVCSize string `json:"pvcSize,omitempty"` + + // BuildTimeoutMinutes is the timeout for software build pipeline tasks in minutes + // Default: 30 + // +optional + BuildTimeoutMinutes int32 `json:"buildTimeoutMinutes,omitempty"` + + // DefaultImage is the default container image for software builds when not specified in the CR + // Default: "ubuntu:24.04" + // +optional + DefaultImage string `json:"defaultImage,omitempty"` +} + // OperatorConfigSpec defines the desired state of OperatorConfig type OperatorConfigSpec struct { // OSBuilds defines the configuration for OS build operations @@ -455,6 +477,12 @@ type OperatorConfigSpec struct { // Workspaces defines configuration for developer workspaces // +optional Workspaces *WorkspacesConfig `json:"workspaces,omitempty"` + + // SoftwareBuilds defines configuration for generic software build operations. + // When enabled, the operator deploys a stage-based Tekton pipeline that can + // build software for arbitrary target OSes using user-specified container images. + // +optional + SoftwareBuilds *SoftwareBuildsConfig `json:"softwareBuilds,omitempty"` } // OSBuildsConfig defines configuration for OS build operations diff --git a/api/v1alpha1/softwarebuild_types.go b/api/v1alpha1/softwarebuild_types.go new file mode 100644 index 000000000..6d29ac01d --- /dev/null +++ b/api/v1alpha1/softwarebuild_types.go @@ -0,0 +1,192 @@ +/* +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 ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// SoftwareBuildSourceType identifies where source code is obtained from. +type SoftwareBuildSourceType string + +// Source can be a Git repository or an existing PVC with pre-populated content. +const ( + SoftwareBuildSourceGit SoftwareBuildSourceType = "git" + SoftwareBuildSourcePVC SoftwareBuildSourceType = "pvc" +) + +// SoftwareBuildDestinationType identifies where build artifacts are stored. +type SoftwareBuildDestinationType string + +// Artifacts are written to a shared folder on the workspace PVC. +const ( + SoftwareBuildDestinationSharedFolder SoftwareBuildDestinationType = "sharedFolder" +) + +// SoftwareBuildPhase represents the current lifecycle phase. +type SoftwareBuildPhase string + +// Phases track the SoftwareBuild lifecycle from submission through completion. +const ( + SoftwareBuildPhasePending SoftwareBuildPhase = "Pending" + SoftwareBuildPhaseRunning SoftwareBuildPhase = "Running" + SoftwareBuildPhaseSucceeded SoftwareBuildPhase = "Succeeded" + SoftwareBuildPhaseFailed SoftwareBuildPhase = "Failed" +) + +// SoftwareBuildRuntimeSpec configures the container environment used for every +// pipeline stage unless overridden per-stage. +type SoftwareBuildRuntimeSpec struct { + // Image is the container image that provides the build toolchain. + // +kubebuilder:default="ubuntu:24.04" + // +kubebuilder:validation:MinLength=1 + Image string `json:"image,omitempty"` + + // ServiceAccountName is the Kubernetes SA the build pod runs as. + // +optional + ServiceAccountName string `json:"serviceAccountName,omitempty"` +} + +// SoftwareBuildGitSource describes a Git repository to clone. +type SoftwareBuildGitSource struct { + // +kubebuilder:validation:Pattern=`^https?://[^\s;|&$'"]+$` + URL string `json:"url"` + // +kubebuilder:validation:Pattern=`^[a-zA-Z0-9._/-]+$` + // +kubebuilder:default=main + Revision string `json:"revision,omitempty"` +} + +// SoftwareBuildPVCSource references an existing PVC. +// +kubebuilder:validation:XValidation:rule="!has(self.path) || !self.path.contains('..')",message="path must not contain '..'" +type SoftwareBuildPVCSource struct { + // +kubebuilder:validation:MinLength=1 + ClaimName string `json:"claimName"` + // +kubebuilder:default=/ + Path string `json:"path,omitempty"` +} + +// SoftwareBuildSourceSpec identifies the code location. +// +kubebuilder:validation:XValidation:rule="self.type != 'git' || has(self.git)",message="git source details required when type is git" +// +kubebuilder:validation:XValidation:rule="self.type != 'pvc' || has(self.pvc)",message="pvc source details required when type is pvc" +type SoftwareBuildSourceSpec struct { + // +kubebuilder:validation:Enum=git;pvc + Type SoftwareBuildSourceType `json:"type"` + // +optional + Git *SoftwareBuildGitSource `json:"git,omitempty"` + // +optional + PVC *SoftwareBuildPVCSource `json:"pvc,omitempty"` +} + +// SoftwareBuildStageSpec defines a single pipeline stage. +type SoftwareBuildStageSpec struct { + // Command is executed via bash inside the runtime image. + // +kubebuilder:validation:MinLength=1 + Command string `json:"command"` + // Image overrides the runtime image for this stage only. + // +optional + Image string `json:"image,omitempty"` +} + +// SoftwareBuildPipelineStages groups the five sequential stages. +type SoftwareBuildPipelineStages struct { + Fetch SoftwareBuildStageSpec `json:"fetch"` + Prebuild SoftwareBuildStageSpec `json:"prebuild"` + Build SoftwareBuildStageSpec `json:"build"` + Postbuild SoftwareBuildStageSpec `json:"postbuild"` + Deploy SoftwareBuildStageSpec `json:"deploy"` +} + +// SoftwareBuildDestinationSpec describes where artifacts go. +type SoftwareBuildDestinationSpec struct { + // +kubebuilder:validation:Enum=sharedFolder + Type SoftwareBuildDestinationType `json:"type"` + // +optional + Path string `json:"path,omitempty"` +} + +// SoftwareBuildSpec defines the desired state of SoftwareBuild. +type SoftwareBuildSpec struct { + // +optional + Runtime SoftwareBuildRuntimeSpec `json:"runtime,omitempty"` + Source SoftwareBuildSourceSpec `json:"source"` + Stages SoftwareBuildPipelineStages `json:"stages"` + Destination SoftwareBuildDestinationSpec `json:"destination"` + // +kubebuilder:validation:Minimum=0 + // +optional + TimeoutSeconds int64 `json:"timeoutSeconds,omitempty"` +} + +// SoftwareBuildStageStatus captures per-stage progress. +type SoftwareBuildStageStatus struct { + Name string `json:"name,omitempty"` + // +optional + StartedAt *metav1.Time `json:"startedAt,omitempty"` + // +optional + FinishedAt *metav1.Time `json:"finishedAt,omitempty"` + // +optional + State string `json:"state,omitempty"` + // +optional + Message string `json:"message,omitempty"` +} + +// SoftwareBuildStatus defines the observed state of SoftwareBuild. +type SoftwareBuildStatus struct { + // +optional + Phase SoftwareBuildPhase `json:"phase,omitempty"` + // +optional + PipelineRunName string `json:"pipelineRunName,omitempty"` + // +optional + ArtifactURI string `json:"artifactURI,omitempty"` + // +optional + FailureReason string `json:"failureReason,omitempty"` + // +optional + Stages []SoftwareBuildStageStatus `json:"stages,omitempty"` + // +optional + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:path=softwarebuilds,scope=Namespaced,shortName=sb +// +kubebuilder:printcolumn:name="Phase",type=string,JSONPath=`.status.phase` +// +kubebuilder:printcolumn:name="Image",type=string,JSONPath=`.spec.runtime.image` +// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` + +// SoftwareBuild is the Schema for the softwarebuilds API. +// It drives a generic, stage-based Tekton pipeline that can build software +// for any target OS or toolchain by specifying a runtime container image and +// five sequential shell commands. +type SoftwareBuild struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec SoftwareBuildSpec `json:"spec,omitempty"` + Status SoftwareBuildStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// SoftwareBuildList contains a list of SoftwareBuild. +type SoftwareBuildList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []SoftwareBuild `json:"items"` +} + +func init() { + SchemeBuilder.Register(&SoftwareBuild{}, &SoftwareBuildList{}) +} diff --git a/api/v1alpha1/softwarebuild_types_test.go b/api/v1alpha1/softwarebuild_types_test.go new file mode 100644 index 000000000..8b63b4d4d --- /dev/null +++ b/api/v1alpha1/softwarebuild_types_test.go @@ -0,0 +1,169 @@ +package v1alpha1 + +import ( + "encoding/json" + "testing" +) + +func TestSoftwareBuildSpec_JSONRoundTrip(t *testing.T) { + original := SoftwareBuild{ + Spec: SoftwareBuildSpec{ + Runtime: SoftwareBuildRuntimeSpec{Image: "ghcr.io/zephyrproject-rtos/ci-base:latest"}, + Source: SoftwareBuildSourceSpec{ + Type: SoftwareBuildSourceGit, + Git: &SoftwareBuildGitSource{ + URL: "https://github.com/vtz/body-ecu", + Revision: "main", + }, + }, + Stages: SoftwareBuildPipelineStages{ + Fetch: SoftwareBuildStageSpec{Command: "west init -l . && west update"}, + Prebuild: SoftwareBuildStageSpec{Command: "echo prebuild"}, + Build: SoftwareBuildStageSpec{Command: "west build -b native_sim app"}, + Postbuild: SoftwareBuildStageSpec{Command: "ctest --test-dir build/tests"}, + Deploy: SoftwareBuildStageSpec{Command: "cp build/zephyr/zephyr.elf /out/"}, + }, + Destination: SoftwareBuildDestinationSpec{ + Type: SoftwareBuildDestinationSharedFolder, + Path: "/out", + }, + TimeoutSeconds: 1800, + }, + } + + data, err := json.Marshal(original) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + var roundTripped SoftwareBuild + if err := json.Unmarshal(data, &roundTripped); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + + if roundTripped.Spec.Runtime.Image != original.Spec.Runtime.Image { + t.Errorf("image: got %q, want %q", roundTripped.Spec.Runtime.Image, original.Spec.Runtime.Image) + } + if roundTripped.Spec.Source.Type != SoftwareBuildSourceGit { + t.Errorf("source type: got %q, want %q", roundTripped.Spec.Source.Type, SoftwareBuildSourceGit) + } + if roundTripped.Spec.Source.Git == nil || roundTripped.Spec.Source.Git.URL != "https://github.com/vtz/body-ecu" { + t.Errorf("git URL not preserved through round-trip") + } + if roundTripped.Spec.Stages.Build.Command != "west build -b native_sim app" { + t.Errorf("build command: got %q", roundTripped.Spec.Stages.Build.Command) + } + if roundTripped.Spec.TimeoutSeconds != 1800 { + t.Errorf("timeout: got %d, want 1800", roundTripped.Spec.TimeoutSeconds) + } + if roundTripped.Spec.Destination.Type != SoftwareBuildDestinationSharedFolder { + t.Errorf("destination type: got %q, want %q", roundTripped.Spec.Destination.Type, SoftwareBuildDestinationSharedFolder) + } +} + +func TestSoftwareBuildSpec_PVCSource_JSONRoundTrip(t *testing.T) { + original := SoftwareBuild{ + Spec: SoftwareBuildSpec{ + Source: SoftwareBuildSourceSpec{ + Type: SoftwareBuildSourcePVC, + PVC: &SoftwareBuildPVCSource{ClaimName: "my-pvc", Path: "/data"}, + }, + Stages: SoftwareBuildPipelineStages{ + Fetch: SoftwareBuildStageSpec{Command: "echo fetched"}, + Build: SoftwareBuildStageSpec{Command: "make"}, + }, + }, + } + + data, err := json.Marshal(original) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + var roundTripped SoftwareBuild + if err := json.Unmarshal(data, &roundTripped); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + + if roundTripped.Spec.Source.Type != SoftwareBuildSourcePVC { + t.Errorf("source type: got %q, want pvc", roundTripped.Spec.Source.Type) + } + if roundTripped.Spec.Source.PVC == nil { + t.Fatal("PVC source lost during round-trip") + } + if roundTripped.Spec.Source.PVC.ClaimName != "my-pvc" { + t.Errorf("claimName: got %q, want my-pvc", roundTripped.Spec.Source.PVC.ClaimName) + } + if roundTripped.Spec.Source.Git != nil { + t.Error("git source should be nil for PVC source type") + } +} + +func TestSoftwareBuildStatus_JSONRoundTrip(t *testing.T) { + original := SoftwareBuildStatus{ + Phase: SoftwareBuildPhaseSucceeded, + PipelineRunName: "build-gen1", + ArtifactURI: "/workspace/artifacts", + Stages: []SoftwareBuildStageStatus{ + {Name: "fetch", State: "Completed"}, + {Name: "build", State: "Completed"}, + }, + } + + data, err := json.Marshal(original) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + var roundTripped SoftwareBuildStatus + if err := json.Unmarshal(data, &roundTripped); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + + if roundTripped.Phase != SoftwareBuildPhaseSucceeded { + t.Errorf("phase: got %q, want Succeeded", roundTripped.Phase) + } + if roundTripped.PipelineRunName != "build-gen1" { + t.Errorf("pipelineRunName: got %q", roundTripped.PipelineRunName) + } + if len(roundTripped.Stages) != 2 { + t.Fatalf("stages: got %d, want 2", len(roundTripped.Stages)) + } + if roundTripped.Stages[1].Name != "build" { + t.Errorf("stage[1] name: got %q, want build", roundTripped.Stages[1].Name) + } +} + +func TestSoftwareBuildSpec_PerStageImage_JSONRoundTrip(t *testing.T) { + original := SoftwareBuild{ + Spec: SoftwareBuildSpec{ + Runtime: SoftwareBuildRuntimeSpec{Image: "ubuntu:24.04"}, + Source: SoftwareBuildSourceSpec{Type: SoftwareBuildSourceGit, Git: &SoftwareBuildGitSource{URL: "https://example.com/repo"}}, + Stages: SoftwareBuildPipelineStages{ + Fetch: SoftwareBuildStageSpec{Command: "echo fetch"}, + Prebuild: SoftwareBuildStageSpec{Command: "echo prebuild"}, + Build: SoftwareBuildStageSpec{Command: "make", Image: "gcc:14"}, + Postbuild: SoftwareBuildStageSpec{Command: "echo postbuild"}, + Deploy: SoftwareBuildStageSpec{Command: "echo deploy"}, + }, + Destination: SoftwareBuildDestinationSpec{Type: SoftwareBuildDestinationSharedFolder}, + }, + } + + data, err := json.Marshal(original) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + var roundTripped SoftwareBuild + if err := json.Unmarshal(data, &roundTripped); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + + if roundTripped.Spec.Stages.Build.Image != "gcc:14" { + t.Errorf("build stage image: got %q, want gcc:14", roundTripped.Spec.Stages.Build.Image) + } + if roundTripped.Spec.Stages.Fetch.Image != "" { + t.Errorf("fetch stage image should be empty, got %q", roundTripped.Spec.Stages.Fetch.Image) + } +} diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 898912b34..7b096622d 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -1166,6 +1166,11 @@ func (in *OperatorConfigSpec) DeepCopyInto(out *OperatorConfigSpec) { *out = new(WorkspacesConfig) (*in).DeepCopyInto(*out) } + if in.SoftwareBuilds != nil { + in, out := &in.SoftwareBuilds, &out.SoftwareBuilds + *out = new(SoftwareBuildsConfig) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OperatorConfigSpec. @@ -1267,6 +1272,271 @@ 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 *SoftwareBuild) DeepCopyInto(out *SoftwareBuild) { + *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 SoftwareBuild. +func (in *SoftwareBuild) DeepCopy() *SoftwareBuild { + if in == nil { + return nil + } + out := new(SoftwareBuild) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *SoftwareBuild) 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 *SoftwareBuildDestinationSpec) DeepCopyInto(out *SoftwareBuildDestinationSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SoftwareBuildDestinationSpec. +func (in *SoftwareBuildDestinationSpec) DeepCopy() *SoftwareBuildDestinationSpec { + if in == nil { + return nil + } + out := new(SoftwareBuildDestinationSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SoftwareBuildGitSource) DeepCopyInto(out *SoftwareBuildGitSource) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SoftwareBuildGitSource. +func (in *SoftwareBuildGitSource) DeepCopy() *SoftwareBuildGitSource { + if in == nil { + return nil + } + out := new(SoftwareBuildGitSource) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SoftwareBuildList) DeepCopyInto(out *SoftwareBuildList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]SoftwareBuild, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SoftwareBuildList. +func (in *SoftwareBuildList) DeepCopy() *SoftwareBuildList { + if in == nil { + return nil + } + out := new(SoftwareBuildList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *SoftwareBuildList) 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 *SoftwareBuildPVCSource) DeepCopyInto(out *SoftwareBuildPVCSource) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SoftwareBuildPVCSource. +func (in *SoftwareBuildPVCSource) DeepCopy() *SoftwareBuildPVCSource { + if in == nil { + return nil + } + out := new(SoftwareBuildPVCSource) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SoftwareBuildPipelineStages) DeepCopyInto(out *SoftwareBuildPipelineStages) { + *out = *in + out.Fetch = in.Fetch + out.Prebuild = in.Prebuild + out.Build = in.Build + out.Postbuild = in.Postbuild + out.Deploy = in.Deploy +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SoftwareBuildPipelineStages. +func (in *SoftwareBuildPipelineStages) DeepCopy() *SoftwareBuildPipelineStages { + if in == nil { + return nil + } + out := new(SoftwareBuildPipelineStages) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SoftwareBuildRuntimeSpec) DeepCopyInto(out *SoftwareBuildRuntimeSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SoftwareBuildRuntimeSpec. +func (in *SoftwareBuildRuntimeSpec) DeepCopy() *SoftwareBuildRuntimeSpec { + if in == nil { + return nil + } + out := new(SoftwareBuildRuntimeSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SoftwareBuildSourceSpec) DeepCopyInto(out *SoftwareBuildSourceSpec) { + *out = *in + if in.Git != nil { + in, out := &in.Git, &out.Git + *out = new(SoftwareBuildGitSource) + **out = **in + } + if in.PVC != nil { + in, out := &in.PVC, &out.PVC + *out = new(SoftwareBuildPVCSource) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SoftwareBuildSourceSpec. +func (in *SoftwareBuildSourceSpec) DeepCopy() *SoftwareBuildSourceSpec { + if in == nil { + return nil + } + out := new(SoftwareBuildSourceSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SoftwareBuildSpec) DeepCopyInto(out *SoftwareBuildSpec) { + *out = *in + out.Runtime = in.Runtime + in.Source.DeepCopyInto(&out.Source) + out.Stages = in.Stages + out.Destination = in.Destination +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SoftwareBuildSpec. +func (in *SoftwareBuildSpec) DeepCopy() *SoftwareBuildSpec { + if in == nil { + return nil + } + out := new(SoftwareBuildSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SoftwareBuildStageSpec) DeepCopyInto(out *SoftwareBuildStageSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SoftwareBuildStageSpec. +func (in *SoftwareBuildStageSpec) DeepCopy() *SoftwareBuildStageSpec { + if in == nil { + return nil + } + out := new(SoftwareBuildStageSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SoftwareBuildStageStatus) DeepCopyInto(out *SoftwareBuildStageStatus) { + *out = *in + if in.StartedAt != nil { + in, out := &in.StartedAt, &out.StartedAt + *out = (*in).DeepCopy() + } + if in.FinishedAt != nil { + in, out := &in.FinishedAt, &out.FinishedAt + *out = (*in).DeepCopy() + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SoftwareBuildStageStatus. +func (in *SoftwareBuildStageStatus) DeepCopy() *SoftwareBuildStageStatus { + if in == nil { + return nil + } + out := new(SoftwareBuildStageStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SoftwareBuildStatus) DeepCopyInto(out *SoftwareBuildStatus) { + *out = *in + if in.Stages != nil { + in, out := &in.Stages, &out.Stages + *out = make([]SoftwareBuildStageStatus, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + 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 SoftwareBuildStatus. +func (in *SoftwareBuildStatus) DeepCopy() *SoftwareBuildStatus { + if in == nil { + return nil + } + out := new(SoftwareBuildStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SoftwareBuildsConfig) DeepCopyInto(out *SoftwareBuildsConfig) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SoftwareBuildsConfig. +func (in *SoftwareBuildsConfig) DeepCopy() *SoftwareBuildsConfig { + if in == nil { + return nil + } + out := new(SoftwareBuildsConfig) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Workspace) DeepCopyInto(out *Workspace) { *out = *in diff --git a/cmd/main.go b/cmd/main.go index 990de2e12..0e283e5e7 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -49,6 +49,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/softwarebuild" "github.com/centos-automotive-suite/automotive-dev-operator/internal/controller/workspace" // +kubebuilder:scaffold:imports ) @@ -290,6 +291,17 @@ func main() { setupLog.Error(err, "unable to create controller", "controller", "Workspace") os.Exit(1) } + + softwareBuildReconciler := &softwarebuild.Reconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Log: ctrl.Log.WithName("controllers").WithName("SoftwareBuild"), + OperatorNamespace: imagebuild.OperatorNamespace, + } + if err = softwareBuildReconciler.SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "SoftwareBuild") + os.Exit(1) + } } // Health checks diff --git a/config/crd/bases/automotive.sdv.cloud.redhat.com_operatorconfigs.yaml b/config/crd/bases/automotive.sdv.cloud.redhat.com_operatorconfigs.yaml index 9a010684d..3023eec4e 100644 --- a/config/crd/bases/automotive.sdv.cloud.redhat.com_operatorconfigs.yaml +++ b/config/crd/bases/automotive.sdv.cloud.redhat.com_operatorconfigs.yaml @@ -828,6 +828,36 @@ spec: required: - enabled type: object + softwareBuilds: + description: |- + SoftwareBuilds defines configuration for generic software build operations. + When enabled, the operator deploys a stage-based Tekton pipeline that can + build software for arbitrary target OSes using user-specified container images. + properties: + buildTimeoutMinutes: + description: |- + BuildTimeoutMinutes is the timeout for software build pipeline tasks in minutes + Default: 30 + format: int32 + type: integer + defaultImage: + description: |- + DefaultImage is the default container image for software builds when not specified in the CR + Default: "ubuntu:24.04" + type: string + enabled: + default: false + description: Enabled determines if Tekton pipeline for generic + software builds should be deployed + type: boolean + pvcSize: + description: |- + PVCSize specifies the size for persistent volume claims created for build workspaces + Default: "1Gi" + type: string + required: + - enabled + type: object workspaces: description: Workspaces defines configuration for developer workspaces properties: diff --git a/config/crd/bases/automotive.sdv.cloud.redhat.com_softwarebuilds.yaml b/config/crd/bases/automotive.sdv.cloud.redhat.com_softwarebuilds.yaml new file mode 100644 index 000000000..744449356 --- /dev/null +++ b/config/crd/bases/automotive.sdv.cloud.redhat.com_softwarebuilds.yaml @@ -0,0 +1,327 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.19.0 + name: softwarebuilds.automotive.sdv.cloud.redhat.com +spec: + group: automotive.sdv.cloud.redhat.com + names: + kind: SoftwareBuild + listKind: SoftwareBuildList + plural: softwarebuilds + shortNames: + - sb + singular: softwarebuild + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .status.phase + name: Phase + type: string + - jsonPath: .spec.runtime.image + name: Image + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + SoftwareBuild is the Schema for the softwarebuilds API. + It drives a generic, stage-based Tekton pipeline that can build software + for any target OS or toolchain by specifying a runtime container image and + five sequential shell commands. + 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: SoftwareBuildSpec defines the desired state of SoftwareBuild. + properties: + destination: + description: SoftwareBuildDestinationSpec describes where artifacts + go. + properties: + path: + type: string + type: + description: SoftwareBuildDestinationType identifies where build + artifacts are stored. + enum: + - sharedFolder + type: string + required: + - type + type: object + runtime: + description: |- + SoftwareBuildRuntimeSpec configures the container environment used for every + pipeline stage unless overridden per-stage. + properties: + image: + default: ubuntu:24.04 + description: Image is the container image that provides the build + toolchain. + minLength: 1 + type: string + serviceAccountName: + description: ServiceAccountName is the Kubernetes SA the build + pod runs as. + type: string + type: object + source: + description: SoftwareBuildSourceSpec identifies the code location. + properties: + git: + description: SoftwareBuildGitSource describes a Git repository + to clone. + properties: + revision: + default: main + pattern: ^[a-zA-Z0-9._/-]+$ + type: string + url: + pattern: ^https?://[^\s;|&$'"]+$ + type: string + required: + - url + type: object + pvc: + description: SoftwareBuildPVCSource references an existing PVC. + properties: + claimName: + minLength: 1 + type: string + path: + default: / + type: string + required: + - claimName + type: object + x-kubernetes-validations: + - message: path must not contain '..' + rule: '!has(self.path) || !self.path.contains(''..'')' + type: + description: SoftwareBuildSourceType identifies where source code + is obtained from. + enum: + - git + - pvc + type: string + required: + - type + type: object + x-kubernetes-validations: + - message: git source details required when type is git + rule: self.type != 'git' || has(self.git) + - message: pvc source details required when type is pvc + rule: self.type != 'pvc' || has(self.pvc) + stages: + description: SoftwareBuildPipelineStages groups the five sequential + stages. + properties: + build: + description: SoftwareBuildStageSpec defines a single pipeline + stage. + properties: + command: + description: Command is executed via bash inside the runtime + image. + minLength: 1 + type: string + image: + description: Image overrides the runtime image for this stage + only. + type: string + required: + - command + type: object + deploy: + description: SoftwareBuildStageSpec defines a single pipeline + stage. + properties: + command: + description: Command is executed via bash inside the runtime + image. + minLength: 1 + type: string + image: + description: Image overrides the runtime image for this stage + only. + type: string + required: + - command + type: object + fetch: + description: SoftwareBuildStageSpec defines a single pipeline + stage. + properties: + command: + description: Command is executed via bash inside the runtime + image. + minLength: 1 + type: string + image: + description: Image overrides the runtime image for this stage + only. + type: string + required: + - command + type: object + postbuild: + description: SoftwareBuildStageSpec defines a single pipeline + stage. + properties: + command: + description: Command is executed via bash inside the runtime + image. + minLength: 1 + type: string + image: + description: Image overrides the runtime image for this stage + only. + type: string + required: + - command + type: object + prebuild: + description: SoftwareBuildStageSpec defines a single pipeline + stage. + properties: + command: + description: Command is executed via bash inside the runtime + image. + minLength: 1 + type: string + image: + description: Image overrides the runtime image for this stage + only. + type: string + required: + - command + type: object + required: + - build + - deploy + - fetch + - postbuild + - prebuild + type: object + timeoutSeconds: + format: int64 + minimum: 0 + type: integer + required: + - destination + - source + - stages + type: object + status: + description: SoftwareBuildStatus defines the observed state of SoftwareBuild. + properties: + artifactURI: + type: string + conditions: + 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 + failureReason: + type: string + phase: + description: SoftwareBuildPhase represents the current lifecycle phase. + type: string + pipelineRunName: + type: string + stages: + items: + description: SoftwareBuildStageStatus captures per-stage progress. + properties: + finishedAt: + format: date-time + type: string + message: + type: string + name: + type: string + startedAt: + format: date-time + type: string + state: + type: string + type: object + type: array + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index 42720b620..eaf2a6634 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_softwarebuilds.yaml # +kubebuilder:scaffold:crdkustomizeresource patches: diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index fecc483eb..c15f3669b 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -85,6 +85,7 @@ rules: - imagereseals - images - operatorconfigs + - softwarebuilds - workspaces verbs: - create @@ -103,6 +104,7 @@ rules: - imagereseals/finalizers - images/finalizers - operatorconfigs/finalizers + - softwarebuilds/finalizers - workspaces/finalizers verbs: - update @@ -115,6 +117,7 @@ rules: - imagereseals/status - images/status - operatorconfigs/status + - softwarebuilds/status - workspaces/status verbs: - get diff --git a/config/samples/automotive_v1alpha1_softwarebuild.yaml b/config/samples/automotive_v1alpha1_softwarebuild.yaml new file mode 100644 index 000000000..88322338e --- /dev/null +++ b/config/samples/automotive_v1alpha1_softwarebuild.yaml @@ -0,0 +1,30 @@ +apiVersion: automotive.sdv.cloud.redhat.com/v1alpha1 +kind: SoftwareBuild +metadata: + name: softwarebuild-sample + labels: + app.kubernetes.io/name: automotive-dev-operator + app.kubernetes.io/managed-by: kustomize +spec: + runtime: + image: ubuntu:24.04 + source: + type: git + git: + url: https://github.com/example/my-project + revision: main + stages: + fetch: + command: "git clone https://github.com/example/my-project ." + prebuild: + command: "apt-get update && apt-get install -y build-essential cmake" + build: + command: "cmake -B build && cmake --build build -j$(nproc)" + postbuild: + command: "ctest --test-dir build --output-on-failure" + deploy: + command: "cp build/output/* /workspace/artifacts/" + destination: + type: sharedFolder + path: /workspace/artifacts + timeoutSeconds: 600 diff --git a/config/samples/automotive_v1alpha1_softwarebuild_zephyr.yaml b/config/samples/automotive_v1alpha1_softwarebuild_zephyr.yaml new file mode 100644 index 000000000..8e6250ba6 --- /dev/null +++ b/config/samples/automotive_v1alpha1_softwarebuild_zephyr.yaml @@ -0,0 +1,35 @@ +apiVersion: automotive.sdv.cloud.redhat.com/v1alpha1 +kind: SoftwareBuild +metadata: + name: softwarebuild-zephyr + labels: + app.kubernetes.io/name: automotive-dev-operator + app.kubernetes.io/managed-by: kustomize +spec: + runtime: + image: ghcr.io/zephyrproject-rtos/ci-base:v0.27.4 + source: + type: git + git: + url: https://github.com/vtz/body-ecu + revision: main + stages: + fetch: + command: | + west init -l . + west update + prebuild: + command: "echo 'Dependencies fetched via west update'" + build: + command: "west build -b native_sim app" + postbuild: + command: | + cmake -B build/tests -S tests/unit -DBUILD_TESTS=ON + cmake --build build/tests -j$(nproc) + ctest --test-dir build/tests --output-on-failure + deploy: + command: "cp build/zephyr/zephyr.elf /workspace/artifacts/" + destination: + type: sharedFolder + path: /workspace/artifacts + timeoutSeconds: 1800 diff --git a/internal/common/tasks/software_build.go b/internal/common/tasks/software_build.go new file mode 100644 index 000000000..1e0f8929d --- /dev/null +++ b/internal/common/tasks/software_build.go @@ -0,0 +1,275 @@ +package tasks + +import ( + "fmt" + "log" + "regexp" + "time" + + automotivev1alpha1 "github.com/centos-automotive-suite/automotive-dev-operator/api/v1alpha1" + tektonv1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" +) + +// SoftwareBuildPipelineName is the well-known name of the shared Tekton Pipeline. +const ( + SoftwareBuildPipelineName = "software-build-pipeline" + defaultSoftwareBuildImage = "ubuntu:24.04" + softwareBuildPVCSize = "1Gi" +) + +var safeGitRefPattern = regexp.MustCompile(`^[a-zA-Z0-9._/-]+$`) + +func softwareBuildStageTask(stageName, paramImage, paramCommand string) tektonv1.PipelineTask { + return tektonv1.PipelineTask{ + Name: stageName, + TaskSpec: &tektonv1.EmbeddedTask{ + TaskSpec: tektonv1.TaskSpec{ + Params: []tektonv1.ParamSpec{ + {Name: "image", Type: tektonv1.ParamTypeString}, + {Name: "command", Type: tektonv1.ParamTypeString}, + }, + Workspaces: []tektonv1.WorkspaceDeclaration{ + {Name: "ws", MountPath: "/workspace"}, + }, + Steps: []tektonv1.Step{ + { + Name: "run", + Image: "$(params.image)", + ImagePullPolicy: corev1.PullIfNotPresent, + SecurityContext: &corev1.SecurityContext{ + AllowPrivilegeEscalation: ptr.To(false), + }, + Script: `#!/usr/bin/env bash +set -euo pipefail +cd $(workspaces.ws.path) +bash -lc "$(params.command)" +`, + }, + }, + }, + }, + Params: []tektonv1.Param{ + {Name: "image", Value: tektonv1.ParamValue{Type: tektonv1.ParamTypeString, StringVal: paramImage}}, + {Name: "command", Value: tektonv1.ParamValue{Type: tektonv1.ParamTypeString, StringVal: paramCommand}}, + }, + Workspaces: []tektonv1.WorkspacePipelineTaskBinding{ + {Name: "ws", Workspace: "shared-workspace"}, + }, + } +} + +// GenerateSoftwareBuildPipeline creates the Tekton Pipeline that runs five +// sequential stages inside a user-chosen container image. +func GenerateSoftwareBuildPipeline(name, namespace string, config *BuildConfig) *tektonv1.Pipeline { + stages := []string{"fetch", "prebuild", "build", "postbuild", "deploy"} + + defaultImage := defaultSoftwareBuildImage + if config != nil && config.DefaultImage != "" { + defaultImage = config.DefaultImage + } + + tasks := make([]tektonv1.PipelineTask, len(stages)) + for i, s := range stages { + tasks[i] = softwareBuildStageTask( + s, + fmt.Sprintf("$(params.%sImage)", s), + fmt.Sprintf("$(params.%sCommand)", s), + ) + if i > 0 { + tasks[i].RunAfter = []string{stages[i-1]} + } + } + + params := []tektonv1.ParamSpec{ + { + Name: "containerImage", Type: tektonv1.ParamTypeString, + Default: &tektonv1.ParamValue{Type: tektonv1.ParamTypeString, StringVal: defaultImage}, + Description: "Container image providing the build toolchain", + }, + } + for _, s := range stages { + params = append(params, + tektonv1.ParamSpec{ + Name: fmt.Sprintf("%sImage", s), + Type: tektonv1.ParamTypeString, + Default: &tektonv1.ParamValue{Type: tektonv1.ParamTypeString, StringVal: "$(params.containerImage)"}, + Description: fmt.Sprintf("Image for %s stage (defaults to containerImage)", s), + }, + tektonv1.ParamSpec{ + Name: fmt.Sprintf("%sCommand", s), + Type: tektonv1.ParamTypeString, + Description: fmt.Sprintf("%s stage command", s), + }, + ) + } + + return &tektonv1.Pipeline{ + TypeMeta: metav1.TypeMeta{APIVersion: "tekton.dev/v1", Kind: "Pipeline"}, + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + Labels: map[string]string{ + "app.kubernetes.io/managed-by": "automotive-dev-operator", + }, + }, + Spec: tektonv1.PipelineSpec{ + Params: params, + Workspaces: []tektonv1.PipelineWorkspaceDeclaration{{Name: "shared-workspace"}}, + Tasks: tasks, + }, + } +} + +// GenerateSoftwareBuildPipelineRun creates a PipelineRun for the given +// SoftwareBuild CR, referencing the software-build-pipeline. +// The PipelineRun is placed in operatorNS so the PipelineRef resolves to the +// shared Pipeline deployed by OperatorConfig. When operatorNS is empty the +// SoftwareBuild's own namespace is used as a fallback. +func GenerateSoftwareBuildPipelineRun(sb *automotivev1alpha1.SoftwareBuild, config *BuildConfig, operatorNS string) *tektonv1.PipelineRun { + ns := operatorNS + if ns == "" { + ns = sb.Namespace + } + image := sb.Spec.Runtime.Image + if image == "" { + if config != nil && config.DefaultImage != "" { + image = config.DefaultImage + } else { + image = defaultSoftwareBuildImage + } + } + + pvcSize := parsePVCSize(config) + + prName := fmt.Sprintf("%s-gen%d", sb.Name, sb.Generation) + + pr := &tektonv1.PipelineRun{ + TypeMeta: metav1.TypeMeta{APIVersion: "tekton.dev/v1", Kind: "PipelineRun"}, + ObjectMeta: metav1.ObjectMeta{ + Name: prName, + Namespace: ns, + Labels: map[string]string{ + "automotive.sdv.cloud.redhat.com/softwarebuild": sb.Name, + "app.kubernetes.io/managed-by": "automotive-dev-operator", + }, + }, + Spec: tektonv1.PipelineRunSpec{ + PipelineRef: &tektonv1.PipelineRef{Name: SoftwareBuildPipelineName}, + Params: buildPipelineRunParams(sb, image), + Workspaces: buildWorkspaceBinding(sb, pvcSize), + }, + } + + if sb.Spec.Runtime.ServiceAccountName != "" { + pr.Spec.TaskRunTemplate = tektonv1.PipelineTaskRunTemplate{ + ServiceAccountName: sb.Spec.Runtime.ServiceAccountName, + } + } + + pr.Spec.Timeouts = buildTimeouts(sb, config) + + return pr +} + +func buildPipelineRunParams(sb *automotivev1alpha1.SoftwareBuild, globalImage string) []tektonv1.Param { + fetchCommand := sb.Spec.Stages.Fetch.Command + if sb.Spec.Source.Type == automotivev1alpha1.SoftwareBuildSourceGit && sb.Spec.Source.Git != nil { + revision := sb.Spec.Source.Git.Revision + if revision == "" { + revision = "main" + } + if !safeGitRefPattern.MatchString(revision) { + revision = "main" + } + gitClone := fmt.Sprintf("git clone --branch '%s' --single-branch '%s' src\n", revision, sb.Spec.Source.Git.URL) + fetchCommand = gitClone + fetchCommand + } + + type stageInfo struct { + name string + command string + image string + } + stages := []stageInfo{ + {"fetch", fetchCommand, sb.Spec.Stages.Fetch.Image}, + {"prebuild", sb.Spec.Stages.Prebuild.Command, sb.Spec.Stages.Prebuild.Image}, + {"build", sb.Spec.Stages.Build.Command, sb.Spec.Stages.Build.Image}, + {"postbuild", sb.Spec.Stages.Postbuild.Command, sb.Spec.Stages.Postbuild.Image}, + {"deploy", sb.Spec.Stages.Deploy.Command, sb.Spec.Stages.Deploy.Image}, + } + + params := []tektonv1.Param{ + {Name: "containerImage", Value: tektonv1.ParamValue{Type: tektonv1.ParamTypeString, StringVal: globalImage}}, + } + for _, s := range stages { + stageImage := globalImage + if s.image != "" { + stageImage = s.image + } + params = append(params, + tektonv1.Param{Name: fmt.Sprintf("%sImage", s.name), Value: tektonv1.ParamValue{Type: tektonv1.ParamTypeString, StringVal: stageImage}}, + tektonv1.Param{Name: fmt.Sprintf("%sCommand", s.name), Value: tektonv1.ParamValue{Type: tektonv1.ParamTypeString, StringVal: s.command}}, + ) + } + return params +} + +func buildTimeouts(sb *automotivev1alpha1.SoftwareBuild, config *BuildConfig) *tektonv1.TimeoutFields { + var d time.Duration + if sb.Spec.TimeoutSeconds > 0 { + d = time.Duration(sb.Spec.TimeoutSeconds) * time.Second + } else if config != nil && config.BuildTimeoutMinutes > 0 { + d = time.Duration(config.BuildTimeoutMinutes) * time.Minute + } + if d > 0 { + return &tektonv1.TimeoutFields{ + Pipeline: &metav1.Duration{Duration: d}, + } + } + return nil +} + +func parsePVCSize(config *BuildConfig) string { + if config != nil && config.PVCSize != "" { + if _, err := resource.ParseQuantity(config.PVCSize); err != nil { + log.Printf("WARNING: invalid PVCSize %q in OperatorConfig, falling back to %s: %v", config.PVCSize, softwareBuildPVCSize, err) + } else { + return config.PVCSize + } + } + return softwareBuildPVCSize +} + +func buildWorkspaceBinding(sb *automotivev1alpha1.SoftwareBuild, pvcSize string) []tektonv1.WorkspaceBinding { + if sb.Spec.Source.Type == automotivev1alpha1.SoftwareBuildSourcePVC && sb.Spec.Source.PVC != nil { + wb := tektonv1.WorkspaceBinding{ + Name: "shared-workspace", + PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ + ClaimName: sb.Spec.Source.PVC.ClaimName, + }, + } + if sb.Spec.Source.PVC.Path != "" && sb.Spec.Source.PVC.Path != "/" { + wb.SubPath = sb.Spec.Source.PVC.Path + } + return []tektonv1.WorkspaceBinding{wb} + } + return []tektonv1.WorkspaceBinding{ + { + Name: "shared-workspace", + VolumeClaimTemplate: &corev1.PersistentVolumeClaim{ + Spec: corev1.PersistentVolumeClaimSpec{ + AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, + Resources: corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceStorage: resource.MustParse(pvcSize), + }, + }, + }, + }, + }, + } +} diff --git a/internal/common/tasks/software_build_test.go b/internal/common/tasks/software_build_test.go new file mode 100644 index 000000000..188fd8d84 --- /dev/null +++ b/internal/common/tasks/software_build_test.go @@ -0,0 +1,496 @@ +package tasks + +import ( + "strings" + "testing" + + automotivev1alpha1 "github.com/centos-automotive-suite/automotive-dev-operator/api/v1alpha1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + testContainerImage = "containerImage" + testFetchCommand = "fetchCommand" + testUserNamespace = "user-ns" +) + +func TestGenerateSoftwareBuildPipeline_HasCorrectParams(t *testing.T) { + p := GenerateSoftwareBuildPipeline("test-pipe", "ns", nil) + + wantParams := map[string]bool{ + testContainerImage: false, + testFetchCommand: false, "fetchImage": false, + "prebuildCommand": false, "prebuildImage": false, + "buildCommand": false, "buildImage": false, + "postbuildCommand": false, "postbuildImage": false, + "deployCommand": false, "deployImage": false, + } + for _, param := range p.Spec.Params { + if _, ok := wantParams[param.Name]; ok { + wantParams[param.Name] = true + } + } + for name, found := range wantParams { + if !found { + t.Errorf("missing pipeline param %q", name) + } + } +} + +func TestGenerateSoftwareBuildPipeline_DefaultImage(t *testing.T) { + p := GenerateSoftwareBuildPipeline("test-pipe", "ns", nil) + + for _, param := range p.Spec.Params { + if param.Name == testContainerImage { + if param.Default == nil || param.Default.StringVal != "ubuntu:24.04" { + t.Fatalf("expected default containerImage ubuntu:24.04, got %v", param.Default) + } + return + } + } + t.Fatal("containerImage param not found") +} + +func TestGenerateSoftwareBuildPipeline_ConfigDefaultImage(t *testing.T) { + config := &BuildConfig{DefaultImage: "fedora:40"} + p := GenerateSoftwareBuildPipeline("test-pipe", "ns", config) + + for _, param := range p.Spec.Params { + if param.Name == testContainerImage { + if param.Default == nil || param.Default.StringVal != "fedora:40" { + t.Fatalf("expected config default image fedora:40, got %v", param.Default) + } + return + } + } + t.Fatal("containerImage param not found") +} + +func TestGenerateSoftwareBuildPipeline_FiveSequentialTasks(t *testing.T) { + p := GenerateSoftwareBuildPipeline("test-pipe", "ns", nil) + + if len(p.Spec.Tasks) != 5 { + t.Fatalf("expected 5 tasks, got %d", len(p.Spec.Tasks)) + } + + expected := []string{"fetch", "prebuild", "build", "postbuild", "deploy"} + for i, task := range p.Spec.Tasks { + if task.Name != expected[i] { + t.Errorf("task %d: got name %q, want %q", i, task.Name, expected[i]) + } + if i > 0 && (len(task.RunAfter) == 0 || task.RunAfter[0] != expected[i-1]) { + t.Errorf("task %q should runAfter %q", task.Name, expected[i-1]) + } + } +} + +func TestGenerateSoftwareBuildPipeline_Labels(t *testing.T) { + p := GenerateSoftwareBuildPipeline("test-pipe", "ns", nil) + + if p.Labels["app.kubernetes.io/managed-by"] != "automotive-dev-operator" { + t.Errorf("expected managed-by label, got %v", p.Labels) + } +} + +func TestGenerateSoftwareBuildPipeline_Workspace(t *testing.T) { + p := GenerateSoftwareBuildPipeline("test-pipe", "ns", nil) + + if len(p.Spec.Workspaces) != 1 || p.Spec.Workspaces[0].Name != "shared-workspace" { + t.Fatalf("expected one workspace named shared-workspace, got %v", p.Spec.Workspaces) + } +} + +func TestGenerateSoftwareBuildPipeline_ImagePullPolicy(t *testing.T) { + p := GenerateSoftwareBuildPipeline("test-pipe", "ns", nil) + + for _, task := range p.Spec.Tasks { + if task.TaskSpec == nil { + t.Errorf("task %q: expected inline TaskSpec", task.Name) + continue + } + for _, step := range task.TaskSpec.Steps { + if step.ImagePullPolicy != corev1.PullIfNotPresent { + t.Errorf("task %q step %q: expected ImagePullPolicy IfNotPresent, got %q", + task.Name, step.Name, step.ImagePullPolicy) + } + } + } +} + +func TestGenerateSoftwareBuildPipeline_SecurityContext(t *testing.T) { + p := GenerateSoftwareBuildPipeline("test-pipe", "ns", nil) + + for _, task := range p.Spec.Tasks { + if task.TaskSpec == nil { + continue + } + for _, step := range task.TaskSpec.Steps { + if step.SecurityContext == nil { + t.Errorf("task %q step %q: expected SecurityContext", task.Name, step.Name) + continue + } + if step.SecurityContext.AllowPrivilegeEscalation == nil || *step.SecurityContext.AllowPrivilegeEscalation { + t.Errorf("task %q step %q: expected AllowPrivilegeEscalation=false", task.Name, step.Name) + } + } + } +} + +func newTestSoftwareBuild() *automotivev1alpha1.SoftwareBuild { + return &automotivev1alpha1.SoftwareBuild{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-build", + Namespace: "default", + Generation: 1, + }, + Spec: automotivev1alpha1.SoftwareBuildSpec{ + Runtime: automotivev1alpha1.SoftwareBuildRuntimeSpec{Image: "ghcr.io/zephyrproject-rtos/ci-base:latest"}, + Stages: automotivev1alpha1.SoftwareBuildPipelineStages{ + Fetch: automotivev1alpha1.SoftwareBuildStageSpec{Command: "west init -l . && west update"}, + Prebuild: automotivev1alpha1.SoftwareBuildStageSpec{Command: "echo prebuild"}, + Build: automotivev1alpha1.SoftwareBuildStageSpec{Command: "west build -b native_sim app"}, + Postbuild: automotivev1alpha1.SoftwareBuildStageSpec{Command: "echo postbuild"}, + Deploy: automotivev1alpha1.SoftwareBuildStageSpec{Command: "echo deploy"}, + }, + }, + } +} + +func TestGenerateSoftwareBuildPipelineRun_PipelineRef(t *testing.T) { + pr := GenerateSoftwareBuildPipelineRun(newTestSoftwareBuild(), nil, "") + + if pr.Spec.PipelineRef == nil || pr.Spec.PipelineRef.Name != SoftwareBuildPipelineName { + t.Fatalf("expected pipelineRef %q, got %v", SoftwareBuildPipelineName, pr.Spec.PipelineRef) + } +} + +func TestGenerateSoftwareBuildPipelineRun_OperatorNamespace(t *testing.T) { + sb := newTestSoftwareBuild() + sb.Namespace = testUserNamespace + pr := GenerateSoftwareBuildPipelineRun(sb, nil, "operator-ns") + + if pr.Namespace != "operator-ns" { + t.Errorf("expected PipelineRun in operator-ns, got %q", pr.Namespace) + } +} + +func TestGenerateSoftwareBuildPipelineRun_FallbackNamespace(t *testing.T) { + sb := newTestSoftwareBuild() + sb.Namespace = testUserNamespace + pr := GenerateSoftwareBuildPipelineRun(sb, nil, "") + + if pr.Namespace != testUserNamespace { + t.Errorf("expected PipelineRun fallback to sb namespace, got %q", pr.Namespace) + } +} + +func TestGenerateSoftwareBuildPipelineRun_DeterministicName(t *testing.T) { + sb := newTestSoftwareBuild() + sb.Generation = 5 + pr := GenerateSoftwareBuildPipelineRun(sb, nil, "") + + if pr.Name != "test-build-gen5" { + t.Errorf("expected deterministic name test-build-gen5, got %q", pr.Name) + } +} + +func TestGenerateSoftwareBuildPipelineRun_Params(t *testing.T) { + sb := newTestSoftwareBuild() + pr := GenerateSoftwareBuildPipelineRun(sb, nil, "") + + paramMap := make(map[string]string) + for _, p := range pr.Spec.Params { + paramMap[p.Name] = p.Value.StringVal + } + + if paramMap[testContainerImage] != "ghcr.io/zephyrproject-rtos/ci-base:latest" { + t.Errorf("unexpected containerImage: %s", paramMap[testContainerImage]) + } + if paramMap["buildCommand"] != "west build -b native_sim app" { + t.Errorf("unexpected buildCommand: %s", paramMap["buildCommand"]) + } + if paramMap[testFetchCommand] != "west init -l . && west update" { + t.Errorf("unexpected fetchCommand: %s", paramMap[testFetchCommand]) + } +} + +func TestGenerateSoftwareBuildPipelineRun_DefaultImage(t *testing.T) { + sb := newTestSoftwareBuild() + sb.Spec.Runtime.Image = "" + pr := GenerateSoftwareBuildPipelineRun(sb, nil, "") + + for _, p := range pr.Spec.Params { + if p.Name == testContainerImage { + if p.Value.StringVal != "ubuntu:24.04" { + t.Fatalf("expected default image ubuntu:24.04, got %q", p.Value.StringVal) + } + return + } + } + t.Fatal("containerImage param not found") +} + +func TestGenerateSoftwareBuildPipelineRun_ConfigDefaultImage(t *testing.T) { + sb := newTestSoftwareBuild() + sb.Spec.Runtime.Image = "" + config := &BuildConfig{DefaultImage: "fedora:40"} + pr := GenerateSoftwareBuildPipelineRun(sb, config, "") + + for _, p := range pr.Spec.Params { + if p.Name == testContainerImage { + if p.Value.StringVal != "fedora:40" { + t.Fatalf("expected config default image fedora:40, got %q", p.Value.StringVal) + } + return + } + } + t.Fatal("containerImage param not found") +} + +func TestGenerateSoftwareBuildPipelineRun_Labels(t *testing.T) { + pr := GenerateSoftwareBuildPipelineRun(newTestSoftwareBuild(), nil, "") + + if pr.Labels["automotive.sdv.cloud.redhat.com/softwarebuild"] != "test-build" { + t.Errorf("expected softwarebuild label, got %v", pr.Labels) + } +} + +func TestGenerateSoftwareBuildPipelineRun_Workspace(t *testing.T) { + pr := GenerateSoftwareBuildPipelineRun(newTestSoftwareBuild(), nil, "") + + if len(pr.Spec.Workspaces) != 1 { + t.Fatalf("expected 1 workspace, got %d", len(pr.Spec.Workspaces)) + } + ws := pr.Spec.Workspaces[0] + if ws.Name != "shared-workspace" { + t.Errorf("expected workspace shared-workspace, got %s", ws.Name) + } + if ws.VolumeClaimTemplate == nil { + t.Fatal("expected volumeClaimTemplate") + } +} + +func TestGenerateSoftwareBuildPipelineRun_CustomPVCSize(t *testing.T) { + sb := newTestSoftwareBuild() + config := &BuildConfig{PVCSize: "10Gi"} + pr := GenerateSoftwareBuildPipelineRun(sb, config, "") + + ws := pr.Spec.Workspaces[0] + storageReq := ws.VolumeClaimTemplate.Spec.Resources.Requests["storage"] + if storageReq.String() != "10Gi" { + t.Errorf("expected PVC size 10Gi, got %s", storageReq.String()) + } +} + +func TestGenerateSoftwareBuildPipelineRun_InvalidPVCSizeFallback(t *testing.T) { + sb := newTestSoftwareBuild() + config := &BuildConfig{PVCSize: "not-a-size"} + pr := GenerateSoftwareBuildPipelineRun(sb, config, "") + + ws := pr.Spec.Workspaces[0] + storageReq := ws.VolumeClaimTemplate.Spec.Resources.Requests["storage"] + if storageReq.String() != "1Gi" { + t.Errorf("expected fallback PVC size 1Gi, got %s", storageReq.String()) + } +} + +func TestGenerateSoftwareBuildPipelineRun_PVCSource(t *testing.T) { + sb := newTestSoftwareBuild() + sb.Spec.Source = automotivev1alpha1.SoftwareBuildSourceSpec{ + Type: automotivev1alpha1.SoftwareBuildSourcePVC, + PVC: &automotivev1alpha1.SoftwareBuildPVCSource{ClaimName: "my-workspace"}, + } + pr := GenerateSoftwareBuildPipelineRun(sb, nil, "") + + if len(pr.Spec.Workspaces) != 1 { + t.Fatalf("expected 1 workspace, got %d", len(pr.Spec.Workspaces)) + } + ws := pr.Spec.Workspaces[0] + if ws.VolumeClaimTemplate != nil { + t.Fatal("PVC source should not use VolumeClaimTemplate") + } + if ws.PersistentVolumeClaim == nil { + t.Fatal("PVC source should use PersistentVolumeClaim binding") + } + if ws.PersistentVolumeClaim.ClaimName != "my-workspace" { + t.Errorf("expected claimName my-workspace, got %s", ws.PersistentVolumeClaim.ClaimName) + } + if ws.SubPath != "" { + t.Errorf("default path should not set SubPath, got %q", ws.SubPath) + } +} + +func TestGenerateSoftwareBuildPipelineRun_PVCSourceWithSubPath(t *testing.T) { + sb := newTestSoftwareBuild() + sb.Spec.Source = automotivev1alpha1.SoftwareBuildSourceSpec{ + Type: automotivev1alpha1.SoftwareBuildSourcePVC, + PVC: &automotivev1alpha1.SoftwareBuildPVCSource{ClaimName: "my-workspace", Path: "src/project"}, + } + pr := GenerateSoftwareBuildPipelineRun(sb, nil, "") + + ws := pr.Spec.Workspaces[0] + if ws.SubPath != "src/project" { + t.Errorf("expected SubPath src/project, got %q", ws.SubPath) + } +} + +func TestGenerateSoftwareBuildPipelineRun_PVCSourceRootPathNoSubPath(t *testing.T) { + sb := newTestSoftwareBuild() + sb.Spec.Source = automotivev1alpha1.SoftwareBuildSourceSpec{ + Type: automotivev1alpha1.SoftwareBuildSourcePVC, + PVC: &automotivev1alpha1.SoftwareBuildPVCSource{ClaimName: "my-workspace", Path: "/"}, + } + pr := GenerateSoftwareBuildPipelineRun(sb, nil, "") + + ws := pr.Spec.Workspaces[0] + if ws.SubPath != "" { + t.Errorf("root path should not set SubPath, got %q", ws.SubPath) + } +} + +func TestGenerateSoftwareBuildPipelineRun_GitSourcePrependsClone(t *testing.T) { + sb := newTestSoftwareBuild() + sb.Spec.Source = automotivev1alpha1.SoftwareBuildSourceSpec{ + Type: automotivev1alpha1.SoftwareBuildSourceGit, + Git: &automotivev1alpha1.SoftwareBuildGitSource{ + URL: "https://github.com/example/repo", + Revision: "develop", + }, + } + pr := GenerateSoftwareBuildPipelineRun(sb, nil, "") + + for _, p := range pr.Spec.Params { + if p.Name == testFetchCommand { + if !strings.Contains(p.Value.StringVal, "git clone") { + t.Errorf("fetchCommand should contain git clone, got %q", p.Value.StringVal) + } + if !strings.Contains(p.Value.StringVal, "'develop'") { + t.Errorf("fetchCommand should contain quoted revision, got %q", p.Value.StringVal) + } + if !strings.Contains(p.Value.StringVal, "'https://github.com/example/repo'") { + t.Errorf("fetchCommand should contain quoted repo URL, got %q", p.Value.StringVal) + } + return + } + } + t.Fatal("fetchCommand param not found") +} + +func TestGenerateSoftwareBuildPipelineRun_GitSourceDefaultRevision(t *testing.T) { + sb := newTestSoftwareBuild() + sb.Spec.Source = automotivev1alpha1.SoftwareBuildSourceSpec{ + Type: automotivev1alpha1.SoftwareBuildSourceGit, + Git: &automotivev1alpha1.SoftwareBuildGitSource{ + URL: "https://github.com/example/repo", + }, + } + pr := GenerateSoftwareBuildPipelineRun(sb, nil, "") + + for _, p := range pr.Spec.Params { + if p.Name == testFetchCommand { + if !strings.Contains(p.Value.StringVal, "'main'") { + t.Errorf("fetchCommand should default to main revision, got %q", p.Value.StringVal) + } + return + } + } + t.Fatal("fetchCommand param not found") +} + +func TestGenerateSoftwareBuildPipelineRun_UnsafeRevisionSanitized(t *testing.T) { + sb := newTestSoftwareBuild() + sb.Spec.Source = automotivev1alpha1.SoftwareBuildSourceSpec{ + Type: automotivev1alpha1.SoftwareBuildSourceGit, + Git: &automotivev1alpha1.SoftwareBuildGitSource{ + URL: "https://github.com/example/repo", + Revision: "main; rm -rf /", + }, + } + pr := GenerateSoftwareBuildPipelineRun(sb, nil, "") + + for _, p := range pr.Spec.Params { + if p.Name == testFetchCommand { + if strings.Contains(p.Value.StringVal, "rm -rf") { + t.Errorf("unsafe revision should be sanitized, got %q", p.Value.StringVal) + } + if !strings.Contains(p.Value.StringVal, "'main'") { + t.Errorf("unsafe revision should fall back to main, got %q", p.Value.StringVal) + } + return + } + } + t.Fatal("fetchCommand param not found") +} + +func TestGenerateSoftwareBuildPipelineRun_Timeout(t *testing.T) { + sb := newTestSoftwareBuild() + sb.Spec.TimeoutSeconds = 3600 + pr := GenerateSoftwareBuildPipelineRun(sb, nil, "") + + if pr.Spec.Timeouts == nil { + t.Fatal("expected Timeouts to be set") + } + if pr.Spec.Timeouts.Pipeline == nil || pr.Spec.Timeouts.Pipeline.Minutes() != 60 { + t.Errorf("expected 60min timeout, got %v", pr.Spec.Timeouts.Pipeline) + } +} + +func TestGenerateSoftwareBuildPipelineRun_TimeoutFromConfig(t *testing.T) { + sb := newTestSoftwareBuild() + config := &BuildConfig{BuildTimeoutMinutes: 45} + pr := GenerateSoftwareBuildPipelineRun(sb, config, "") + + if pr.Spec.Timeouts == nil { + t.Fatal("expected Timeouts to be set from config") + } + if pr.Spec.Timeouts.Pipeline == nil || pr.Spec.Timeouts.Pipeline.Minutes() != 45 { + t.Errorf("expected 45min timeout, got %v", pr.Spec.Timeouts.Pipeline) + } +} + +func TestGenerateSoftwareBuildPipelineRun_NoTimeoutWhenZero(t *testing.T) { + sb := newTestSoftwareBuild() + pr := GenerateSoftwareBuildPipelineRun(sb, nil, "") + + if pr.Spec.Timeouts != nil { + t.Errorf("expected no Timeouts when zero, got %v", pr.Spec.Timeouts) + } +} + +func TestGenerateSoftwareBuildPipelineRun_ServiceAccountName(t *testing.T) { + sb := newTestSoftwareBuild() + sb.Spec.Runtime.ServiceAccountName = "build-sa" + pr := GenerateSoftwareBuildPipelineRun(sb, nil, "") + + if pr.Spec.TaskRunTemplate.ServiceAccountName != "build-sa" { + t.Errorf("expected ServiceAccountName build-sa, got %q", pr.Spec.TaskRunTemplate.ServiceAccountName) + } +} + +func TestGenerateSoftwareBuildPipelineRun_NoServiceAccountByDefault(t *testing.T) { + sb := newTestSoftwareBuild() + pr := GenerateSoftwareBuildPipelineRun(sb, nil, "") + + if pr.Spec.TaskRunTemplate.ServiceAccountName != "" { + t.Errorf("expected empty ServiceAccountName, got %q", pr.Spec.TaskRunTemplate.ServiceAccountName) + } +} + +func TestGenerateSoftwareBuildPipelineRun_PerStageImage(t *testing.T) { + sb := newTestSoftwareBuild() + sb.Spec.Stages.Build.Image = "gcc:14" + pr := GenerateSoftwareBuildPipelineRun(sb, nil, "") + + paramMap := make(map[string]string) + for _, p := range pr.Spec.Params { + paramMap[p.Name] = p.Value.StringVal + } + + if paramMap["buildImage"] != "gcc:14" { + t.Errorf("expected buildImage gcc:14, got %q", paramMap["buildImage"]) + } + if paramMap["fetchImage"] != paramMap[testContainerImage] { + t.Errorf("expected fetchImage to default to containerImage value %q, got %q", paramMap[testContainerImage], paramMap["fetchImage"]) + } +} diff --git a/internal/common/tasks/tasks.go b/internal/common/tasks/tasks.go index 75bfdd019..6af12f472 100644 --- a/internal/common/tasks/tasks.go +++ b/internal/common/tasks/tasks.go @@ -29,6 +29,19 @@ type BuildConfig struct { TrustedCABundleKind string TrustedCABundleName string UsePVCScratchVolumes bool + DefaultImage string +} + +// BuildConfigFromSoftwareBuilds maps SoftwareBuildsConfig fields into a BuildConfig. +func BuildConfigFromSoftwareBuilds(swb *automotivev1alpha1.SoftwareBuildsConfig) *BuildConfig { + if swb == nil { + return nil + } + return &BuildConfig{ + PVCSize: swb.PVCSize, + BuildTimeoutMinutes: swb.BuildTimeoutMinutes, + DefaultImage: swb.DefaultImage, + } } // getAutomotiveImageBuilderImage returns the AIB image from config or the default constant diff --git a/internal/controller/operatorconfig/controller.go b/internal/controller/operatorconfig/controller.go index f8408a326..2d1786104 100644 --- a/internal/controller/operatorconfig/controller.go +++ b/internal/controller/operatorconfig/controller.go @@ -286,6 +286,26 @@ func (r *OperatorConfigReconciler) Reconcile(ctx context.Context, req ctrl.Reque } } + // Deploy or cleanup software build pipeline + if config.Spec.SoftwareBuilds != nil && config.Spec.SoftwareBuilds.Enabled { + if err := r.deploySoftwareBuilds(ctx, config); err != nil { + log.Error(err, "Failed to deploy SoftwareBuilds pipeline") + if config.Status.Phase != phaseFailed { + config.Status.Phase = phaseFailed + config.Status.Message = "Failed to deploy SoftwareBuilds pipeline" + statusChanged = true + } + if statusChanged { + _ = r.Status().Update(ctx, config) + } + return ctrl.Result{}, err + } + } else { + if err := r.cleanupSoftwareBuilds(ctx, config); err != nil { + log.Error(err, "Failed to cleanup SoftwareBuilds pipeline") + } + } + // Detect Jumpstarter availability: explicitly configured or auto-detected from local CRDs jumpstarterAvailable := config.Spec.Jumpstarter != nil || r.detectJumpstarter(ctx) if config.Status.JumpstarterAvailable != jumpstarterAvailable { @@ -983,7 +1003,7 @@ func (r *OperatorConfigReconciler) cleanupWorkspaceInfra(ctx context.Context, co scc := &securityv1.SecurityContextConstraints{} scc.Name = workspaceSCCName - if err := r.Delete(ctx, scc); err != nil && !errors.IsNotFound(err) { + if err := r.Delete(ctx, scc); err != nil && !errors.IsNotFound(err) && !apimeta.IsNoMatchError(err) { return fmt.Errorf("failed to delete workspace SCC: %w", err) } @@ -1012,6 +1032,46 @@ func (r *OperatorConfigReconciler) createOrUpdatePipeline(ctx context.Context, p return r.createOrUpdate(ctx, pipeline, nil) } +func (r *OperatorConfigReconciler) deploySoftwareBuilds( + ctx context.Context, + config *automotivev1alpha1.OperatorConfig, +) error { + r.Log.Info("Deploying SoftwareBuilds pipeline") + + buildConfig := tasks.BuildConfigFromSoftwareBuilds(config.Spec.SoftwareBuilds) + + pipeline := tasks.GenerateSoftwareBuildPipeline( + tasks.SoftwareBuildPipelineName, + config.Namespace, + buildConfig, + ) + pipeline.Labels["automotive.sdv.cloud.redhat.com/managed-by"] = config.Name + + if err := controllerutil.SetControllerReference(config, pipeline, r.Scheme); err != nil { + return fmt.Errorf("failed to set controller reference on software-build pipeline: %w", err) + } + + if err := r.createOrUpdatePipeline(ctx, pipeline); err != nil { + return fmt.Errorf("failed to create/update software-build pipeline: %w", err) + } + + r.Log.Info("SoftwareBuilds pipeline deployed successfully") + return nil +} + +func (r *OperatorConfigReconciler) cleanupSoftwareBuilds( + ctx context.Context, + config *automotivev1alpha1.OperatorConfig, +) error { + pipeline := &tektonv1.Pipeline{} + pipeline.Name = tasks.SoftwareBuildPipelineName + pipeline.Namespace = config.Namespace + if err := r.Delete(ctx, pipeline); err != nil && !errors.IsNotFound(err) { + return fmt.Errorf("failed to delete software-build pipeline: %w", err) + } + return nil +} + // SetupWithManager sets up the controller with the Manager. func (r *OperatorConfigReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). diff --git a/internal/controller/softwarebuild/controller.go b/internal/controller/softwarebuild/controller.go new file mode 100644 index 000000000..1bb419016 --- /dev/null +++ b/internal/controller/softwarebuild/controller.go @@ -0,0 +1,211 @@ +// Package softwarebuild provides the controller for managing SoftwareBuild custom resources. +package softwarebuild + +import ( + "context" + "fmt" + "time" + + automotivev1alpha1 "github.com/centos-automotive-suite/automotive-dev-operator/api/v1alpha1" + "github.com/centos-automotive-suite/automotive-dev-operator/internal/common/tasks" + "github.com/go-logr/logr" + tektonv1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1" + corev1 "k8s.io/api/core/v1" + "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/apimachinery/pkg/types" + knativeapis "knative.dev/pkg/apis" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" +) + +const ( + conditionReady = "Ready" + reasonRunning = "Running" +) + +// Reconciler reconciles SoftwareBuild objects. +type Reconciler struct { + client.Client + Scheme *runtime.Scheme + Log logr.Logger + OperatorNamespace string +} + +// +kubebuilder:rbac:groups=automotive.sdv.cloud.redhat.com,resources=softwarebuilds,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=automotive.sdv.cloud.redhat.com,resources=softwarebuilds/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=automotive.sdv.cloud.redhat.com,resources=softwarebuilds/finalizers,verbs=update +// +kubebuilder:rbac:groups=tekton.dev,resources=pipelineruns,verbs=get;list;watch;create;update;patch;delete + +// Reconcile handles a single reconciliation loop for a SoftwareBuild resource. +func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + logger := r.Log.WithValues("softwarebuild", req.NamespacedName) + + var sb automotivev1alpha1.SoftwareBuild + if err := r.Get(ctx, req.NamespacedName, &sb); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + if sb.Status.PipelineRunName == "" { + return r.createPipelineRun(ctx, logger, &sb) + } + + return r.syncStatus(ctx, logger, &sb) +} + +func (r *Reconciler) createPipelineRun(ctx context.Context, logger logr.Logger, sb *automotivev1alpha1.SoftwareBuild) (ctrl.Result, error) { + config := r.loadBuildConfig(ctx) + pr := tasks.GenerateSoftwareBuildPipelineRun(sb, config, r.OperatorNamespace) + + if err := controllerutil.SetControllerReference(sb, pr, r.Scheme); err != nil { + return ctrl.Result{}, fmt.Errorf("setting controller reference: %w", err) + } + + if err := r.Create(ctx, pr); err != nil { + if errors.IsAlreadyExists(err) { + sb.Status.PipelineRunName = pr.Name + if statusErr := r.Status().Update(ctx, sb); statusErr != nil { + return ctrl.Result{}, fmt.Errorf("updating status after AlreadyExists: %w", statusErr) + } + return ctrl.Result{Requeue: true}, nil + } + return ctrl.Result{}, fmt.Errorf("creating PipelineRun: %w", err) + } + + sb.Status.PipelineRunName = pr.Name + sb.Status.Phase = automotivev1alpha1.SoftwareBuildPhasePending + meta.SetStatusCondition(&sb.Status.Conditions, metav1.Condition{ + Type: conditionReady, + Status: metav1.ConditionFalse, + Reason: "PipelineRunCreated", + Message: "PipelineRun created for SoftwareBuild", + ObservedGeneration: sb.Generation, + }) + + if err := r.Status().Update(ctx, sb); err != nil { + return ctrl.Result{}, fmt.Errorf("updating status after PipelineRun creation: %w", err) + } + + logger.Info("created PipelineRun", "name", pr.Name) + return ctrl.Result{RequeueAfter: 5 * time.Second}, nil +} + +func (r *Reconciler) syncStatus(ctx context.Context, logger logr.Logger, sb *automotivev1alpha1.SoftwareBuild) (ctrl.Result, error) { + var pr tektonv1.PipelineRun + ns := r.OperatorNamespace + if ns == "" { + ns = sb.Namespace + } + prKey := types.NamespacedName{Namespace: ns, Name: sb.Status.PipelineRunName} + if err := r.Get(ctx, prKey, &pr); err != nil { + if errors.IsNotFound(err) { + sb.Status.Phase = automotivev1alpha1.SoftwareBuildPhaseFailed + sb.Status.FailureReason = "PipelineRunNotFound" + meta.SetStatusCondition(&sb.Status.Conditions, metav1.Condition{ + Type: conditionReady, + Status: metav1.ConditionFalse, + Reason: "PipelineRunMissing", + Message: "Referenced PipelineRun no longer exists", + ObservedGeneration: sb.Generation, + }) + if statusErr := r.Status().Update(ctx, sb); statusErr != nil { + return ctrl.Result{}, fmt.Errorf("updating status after PipelineRun not found: %w", statusErr) + } + return ctrl.Result{}, nil + } + return ctrl.Result{}, fmt.Errorf("fetching PipelineRun %s: %w", prKey.Name, err) + } + + r.syncStatusFromPipelineRun(sb, &pr) + + if err := r.Status().Update(ctx, sb); err != nil { + return ctrl.Result{}, fmt.Errorf("updating SoftwareBuild status: %w", err) + } + + if sb.Status.Phase == automotivev1alpha1.SoftwareBuildPhaseRunning || + sb.Status.Phase == automotivev1alpha1.SoftwareBuildPhasePending { + return ctrl.Result{RequeueAfter: 10 * time.Second}, nil + } + + switch sb.Status.Phase { + case automotivev1alpha1.SoftwareBuildPhaseSucceeded: + logger.Info("build succeeded", "pipelineRun", sb.Status.PipelineRunName) + case automotivev1alpha1.SoftwareBuildPhaseFailed: + logger.Info("build failed", "pipelineRun", sb.Status.PipelineRunName, "reason", sb.Status.FailureReason) + } + + return ctrl.Result{}, nil +} + +func (r *Reconciler) syncStatusFromPipelineRun(sb *automotivev1alpha1.SoftwareBuild, pr *tektonv1.PipelineRun) { + phase, condStatus, reason, message := mapPipelineRunPhase(pr) + if phase == automotivev1alpha1.SoftwareBuildPhaseFailed { + sb.Status.FailureReason = reason + } + + sb.Status.Phase = phase + meta.SetStatusCondition(&sb.Status.Conditions, metav1.Condition{ + Type: conditionReady, + Status: condStatus, + Reason: reason, + Message: message, + ObservedGeneration: sb.Generation, + }) + + sb.Status.Stages = buildStageStatuses(pr) + + if sb.Spec.Destination.Path != "" { + sb.Status.ArtifactURI = sb.Spec.Destination.Path + } +} + +func mapPipelineRunPhase(pr *tektonv1.PipelineRun) (automotivev1alpha1.SoftwareBuildPhase, metav1.ConditionStatus, string, string) { + for _, c := range pr.Status.Conditions { + if c.Type == knativeapis.ConditionSucceeded { + switch c.Status { + case corev1.ConditionTrue: + return automotivev1alpha1.SoftwareBuildPhaseSucceeded, metav1.ConditionTrue, c.Reason, c.Message + case corev1.ConditionFalse: + return automotivev1alpha1.SoftwareBuildPhaseFailed, metav1.ConditionFalse, c.Reason, c.Message + default: + return automotivev1alpha1.SoftwareBuildPhaseRunning, metav1.ConditionFalse, reasonRunning, "PipelineRun is in progress" + } + } + } + + if pr.Status.StartTime != nil { + return automotivev1alpha1.SoftwareBuildPhaseRunning, metav1.ConditionFalse, reasonRunning, "PipelineRun is in progress" + } + return automotivev1alpha1.SoftwareBuildPhasePending, metav1.ConditionFalse, "Pending", "PipelineRun is pending" +} + +func buildStageStatuses(pr *tektonv1.PipelineRun) []automotivev1alpha1.SoftwareBuildStageStatus { + stages := make([]automotivev1alpha1.SoftwareBuildStageStatus, 0, len(pr.Status.ChildReferences)) + for _, childRef := range pr.Status.ChildReferences { + stages = append(stages, automotivev1alpha1.SoftwareBuildStageStatus{ + Name: childRef.PipelineTaskName, + State: "Created", + Message: fmt.Sprintf("TaskRun: %s", childRef.Name), + }) + } + return stages +} + +func (r *Reconciler) loadBuildConfig(ctx context.Context) *tasks.BuildConfig { + var opConfig automotivev1alpha1.OperatorConfig + if err := r.Get(ctx, types.NamespacedName{Name: "default", Namespace: r.OperatorNamespace}, &opConfig); err != nil { + return nil + } + return tasks.BuildConfigFromSoftwareBuilds(opConfig.Spec.SoftwareBuilds) +} + +// SetupWithManager sets up the controller with the Manager. +func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&automotivev1alpha1.SoftwareBuild{}). + Owns(&tektonv1.PipelineRun{}). + Complete(r) +} diff --git a/internal/controller/softwarebuild/controller_test.go b/internal/controller/softwarebuild/controller_test.go new file mode 100644 index 000000000..5434a8ac1 --- /dev/null +++ b/internal/controller/softwarebuild/controller_test.go @@ -0,0 +1,307 @@ +package softwarebuild + +import ( + "testing" + + automotivev1alpha1 "github.com/centos-automotive-suite/automotive-dev-operator/api/v1alpha1" + tektonv1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + knativeapis "knative.dev/pkg/apis" + duckv1 "knative.dev/pkg/apis/duck/v1" +) + +func newSB() *automotivev1alpha1.SoftwareBuild { + return &automotivev1alpha1.SoftwareBuild{ + ObjectMeta: metav1.ObjectMeta{Name: "demo", Generation: 3}, + Spec: automotivev1alpha1.SoftwareBuildSpec{ + Destination: automotivev1alpha1.SoftwareBuildDestinationSpec{ + Path: "/workspace/artifacts", + }, + }, + } +} + +func prWithCondition(status corev1.ConditionStatus, reason, message string) *tektonv1.PipelineRun { + return &tektonv1.PipelineRun{ + Status: tektonv1.PipelineRunStatus{ + Status: duckv1.Status{ + Conditions: duckv1.Conditions{ + { + Type: knativeapis.ConditionSucceeded, + Status: status, + Reason: reason, + Message: message, + }, + }, + }, + }, + } +} + +func TestSyncStatusFromPipelineRun_Succeeded(t *testing.T) { + r := &Reconciler{} + sb := newSB() + + pr := prWithCondition(corev1.ConditionTrue, "Completed", "All tasks finished") + pr.Status.PipelineRunStatusFields = tektonv1.PipelineRunStatusFields{ + ChildReferences: []tektonv1.ChildStatusReference{ + {Name: "taskrun-build", PipelineTaskName: "build"}, + }, + } + + r.syncStatusFromPipelineRun(sb, pr) + + if sb.Status.Phase != automotivev1alpha1.SoftwareBuildPhaseSucceeded { + t.Fatalf("expected Succeeded, got %s", sb.Status.Phase) + } + if sb.Status.ArtifactURI != "/workspace/artifacts" { + t.Fatalf("expected artifactURI to be populated") + } + if len(sb.Status.Stages) != 1 { + t.Fatalf("expected 1 stage, got %d", len(sb.Status.Stages)) + } + if sb.Status.Stages[0].Name != "build" { + t.Errorf("expected stage name build, got %s", sb.Status.Stages[0].Name) + } +} + +func TestSyncStatusFromPipelineRun_Failed(t *testing.T) { + r := &Reconciler{} + sb := newSB() + + pr := prWithCondition(corev1.ConditionFalse, "TaskRunFailed", "build task failed") + + r.syncStatusFromPipelineRun(sb, pr) + + if sb.Status.Phase != automotivev1alpha1.SoftwareBuildPhaseFailed { + t.Fatalf("expected Failed, got %s", sb.Status.Phase) + } + if sb.Status.FailureReason != "TaskRunFailed" { + t.Fatalf("expected FailureReason TaskRunFailed, got %s", sb.Status.FailureReason) + } +} + +func TestSyncStatusFromPipelineRun_Running(t *testing.T) { + r := &Reconciler{} + sb := newSB() + + now := metav1.Now() + pr := &tektonv1.PipelineRun{ + Status: tektonv1.PipelineRunStatus{ + Status: duckv1.Status{ + Conditions: duckv1.Conditions{}, + }, + PipelineRunStatusFields: tektonv1.PipelineRunStatusFields{ + StartTime: &now, + }, + }, + } + + r.syncStatusFromPipelineRun(sb, pr) + + if sb.Status.Phase != automotivev1alpha1.SoftwareBuildPhaseRunning { + t.Fatalf("expected Running, got %s", sb.Status.Phase) + } +} + +func TestSyncStatusFromPipelineRun_Pending(t *testing.T) { + r := &Reconciler{} + sb := newSB() + + pr := &tektonv1.PipelineRun{ + Status: tektonv1.PipelineRunStatus{ + Status: duckv1.Status{ + Conditions: duckv1.Conditions{}, + }, + }, + } + + r.syncStatusFromPipelineRun(sb, pr) + + if sb.Status.Phase != automotivev1alpha1.SoftwareBuildPhasePending { + t.Fatalf("expected Pending when no conditions and no StartTime, got %s", sb.Status.Phase) + } +} + +func TestSyncStatusFromPipelineRun_ConditionSet(t *testing.T) { + r := &Reconciler{} + sb := newSB() + + pr := prWithCondition(corev1.ConditionTrue, "Succeeded", "done") + + r.syncStatusFromPipelineRun(sb, pr) + + if len(sb.Status.Conditions) == 0 { + t.Fatal("expected at least one condition") + } + found := false + for _, c := range sb.Status.Conditions { + if c.Type == conditionReady { + found = true + if c.Status != metav1.ConditionTrue { + t.Errorf("expected Ready=True, got %s", c.Status) + } + } + } + if !found { + t.Fatal("Ready condition not found") + } +} + +func TestSyncStatusFromPipelineRun_FailedConditionSetsReadyFalse(t *testing.T) { + r := &Reconciler{} + sb := newSB() + + pr := prWithCondition(corev1.ConditionFalse, "TaskRunFailed", "build step failed") + + r.syncStatusFromPipelineRun(sb, pr) + + if sb.Status.Phase != automotivev1alpha1.SoftwareBuildPhaseFailed { + t.Fatalf("expected Failed, got %s", sb.Status.Phase) + } + + found := false + for _, c := range sb.Status.Conditions { + if c.Type == conditionReady { + found = true + if c.Status != metav1.ConditionFalse { + t.Errorf("expected Ready=False on failure, got %s", c.Status) + } + if c.Reason != "TaskRunFailed" { + t.Errorf("expected reason TaskRunFailed, got %s", c.Reason) + } + } + } + if !found { + t.Fatal("Ready condition not found") + } +} + +func TestSyncStatusFromPipelineRun_ObservedGenerationTracked(t *testing.T) { + r := &Reconciler{} + sb := newSB() + sb.Generation = 7 + + pr := prWithCondition(corev1.ConditionTrue, "Completed", "all done") + r.syncStatusFromPipelineRun(sb, pr) + + for _, c := range sb.Status.Conditions { + if c.Type == conditionReady { + if c.ObservedGeneration != 7 { + t.Errorf("expected ObservedGeneration=7, got %d", c.ObservedGeneration) + } + return + } + } + t.Fatal("Ready condition not found") +} + +func TestSyncStatusFromPipelineRun_StagesPopulatedFromChildRefs(t *testing.T) { + r := &Reconciler{} + sb := newSB() + + pr := prWithCondition(corev1.ConditionTrue, "Completed", "done") + pr.Status.PipelineRunStatusFields = tektonv1.PipelineRunStatusFields{ + ChildReferences: []tektonv1.ChildStatusReference{ + {Name: "tr-fetch", PipelineTaskName: "fetch"}, + {Name: "tr-build", PipelineTaskName: "build"}, + {Name: "tr-deploy", PipelineTaskName: "deploy"}, + }, + } + + r.syncStatusFromPipelineRun(sb, pr) + + if len(sb.Status.Stages) != 3 { + t.Fatalf("expected 3 stages, got %d", len(sb.Status.Stages)) + } + + expectedNames := []string{"fetch", "build", "deploy"} + for i, s := range sb.Status.Stages { + if s.Name != expectedNames[i] { + t.Errorf("stage %d: got %q, want %q", i, s.Name, expectedNames[i]) + } + } +} + +func TestMapPipelineRunPhase_Succeeded(t *testing.T) { + pr := prWithCondition(corev1.ConditionTrue, "Completed", "done") + phase, condStatus, reason, _ := mapPipelineRunPhase(pr) + + if phase != automotivev1alpha1.SoftwareBuildPhaseSucceeded { + t.Errorf("expected Succeeded, got %s", phase) + } + if condStatus != metav1.ConditionTrue { + t.Errorf("expected ConditionTrue, got %s", condStatus) + } + if reason != "Completed" { + t.Errorf("expected reason Completed, got %s", reason) + } +} + +func TestMapPipelineRunPhase_Failed(t *testing.T) { + pr := prWithCondition(corev1.ConditionFalse, "BuildFailed", "error") + phase, condStatus, reason, _ := mapPipelineRunPhase(pr) + + if phase != automotivev1alpha1.SoftwareBuildPhaseFailed { + t.Errorf("expected Failed, got %s", phase) + } + if condStatus != metav1.ConditionFalse { + t.Errorf("expected ConditionFalse, got %s", condStatus) + } + if reason != "BuildFailed" { + t.Errorf("expected reason BuildFailed, got %s", reason) + } +} + +func TestMapPipelineRunPhase_PendingNoStartTime(t *testing.T) { + pr := &tektonv1.PipelineRun{} + phase, _, reason, _ := mapPipelineRunPhase(pr) + + if phase != automotivev1alpha1.SoftwareBuildPhasePending { + t.Errorf("expected Pending, got %s", phase) + } + if reason != "Pending" { + t.Errorf("expected reason Pending, got %s", reason) + } +} + +func TestMapPipelineRunPhase_RunningWithStartTime(t *testing.T) { + now := metav1.Now() + pr := &tektonv1.PipelineRun{ + Status: tektonv1.PipelineRunStatus{ + PipelineRunStatusFields: tektonv1.PipelineRunStatusFields{ + StartTime: &now, + }, + }, + } + phase, _, reason, _ := mapPipelineRunPhase(pr) + + if phase != automotivev1alpha1.SoftwareBuildPhaseRunning { + t.Errorf("expected Running, got %s", phase) + } + if reason != reasonRunning { + t.Errorf("expected reason Running, got %s", reason) + } +} + +func TestBuildStageStatuses(t *testing.T) { + pr := &tektonv1.PipelineRun{ + Status: tektonv1.PipelineRunStatus{ + PipelineRunStatusFields: tektonv1.PipelineRunStatusFields{ + ChildReferences: []tektonv1.ChildStatusReference{ + {Name: "tr-1", PipelineTaskName: "fetch"}, + {Name: "tr-2", PipelineTaskName: "build"}, + }, + }, + }, + } + + stages := buildStageStatuses(pr) + if len(stages) != 2 { + t.Fatalf("expected 2 stages, got %d", len(stages)) + } + if stages[0].Name != "fetch" || stages[1].Name != "build" { + t.Errorf("unexpected stage names: %v", stages) + } +} diff --git a/internal/controller/test/softwarebuild_controller_test.go b/internal/controller/test/softwarebuild_controller_test.go new file mode 100644 index 000000000..48e5c6ef3 --- /dev/null +++ b/internal/controller/test/softwarebuild_controller_test.go @@ -0,0 +1,170 @@ +/* +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 test + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" //nolint:revive // Dot import is standard for Ginkgo + . "github.com/onsi/gomega" //nolint:revive // Dot import is standard for Gomega + tektonv1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/centos-automotive-suite/automotive-dev-operator/internal/controller/softwarebuild" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + ctrl "sigs.k8s.io/controller-runtime" + + automotivev1alpha1 "github.com/centos-automotive-suite/automotive-dev-operator/api/v1alpha1" +) + +var _ = Describe("SoftwareBuild Controller", func() { + Context("When reconciling a resource", func() { + const resourceName = "test-sb" + + ctx := context.Background() + + typeNamespacedName := types.NamespacedName{ + Name: resourceName, + Namespace: "default", + } + sb := &automotivev1alpha1.SoftwareBuild{} + + BeforeEach(func() { + By("creating the custom resource for the Kind SoftwareBuild") + err := k8sClient.Get(ctx, typeNamespacedName, sb) + if err != nil && errors.IsNotFound(err) { + resource := &automotivev1alpha1.SoftwareBuild{ + ObjectMeta: metav1.ObjectMeta{ + Name: resourceName, + Namespace: "default", + }, + Spec: automotivev1alpha1.SoftwareBuildSpec{ + Runtime: automotivev1alpha1.SoftwareBuildRuntimeSpec{ + Image: "ghcr.io/zephyrproject-rtos/ci-base:latest", + }, + Source: automotivev1alpha1.SoftwareBuildSourceSpec{ + Type: automotivev1alpha1.SoftwareBuildSourceGit, + Git: &automotivev1alpha1.SoftwareBuildGitSource{ + URL: "https://github.com/vtz/body-ecu", + Revision: "main", + }, + }, + Stages: automotivev1alpha1.SoftwareBuildPipelineStages{ + Fetch: automotivev1alpha1.SoftwareBuildStageSpec{Command: "west init -l . && west update"}, + Prebuild: automotivev1alpha1.SoftwareBuildStageSpec{Command: "echo prebuild"}, + Build: automotivev1alpha1.SoftwareBuildStageSpec{Command: "west build -b native_sim app"}, + Postbuild: automotivev1alpha1.SoftwareBuildStageSpec{Command: "echo postbuild"}, + Deploy: automotivev1alpha1.SoftwareBuildStageSpec{Command: "echo deploy"}, + }, + Destination: automotivev1alpha1.SoftwareBuildDestinationSpec{ + Type: automotivev1alpha1.SoftwareBuildDestinationSharedFolder, + Path: "/workspace/artifacts", + }, + }, + } + Expect(k8sClient.Create(ctx, resource)).To(Succeed()) + } + }) + + AfterEach(func() { + resource := &automotivev1alpha1.SoftwareBuild{} + err := k8sClient.Get(ctx, typeNamespacedName, resource) + Expect(err).NotTo(HaveOccurred()) + + By("Cleanup the specific resource instance SoftwareBuild") + Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) + }) + + It("should create a PipelineRun and set status", func() { + controllerReconciler := &softwarebuild.Reconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + Log: ctrl.Log.WithName("test"), + OperatorNamespace: "default", + } + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + + By("verifying PipelineRunName was set in status") + Expect(k8sClient.Get(ctx, typeNamespacedName, sb)).To(Succeed()) + Expect(sb.Status.PipelineRunName).NotTo(BeEmpty()) + Expect(sb.Status.Phase).To(Equal(automotivev1alpha1.SoftwareBuildPhasePending)) + + By("verifying the PipelineRun was created in the cluster") + var pr tektonv1.PipelineRun + prKey := types.NamespacedName{Name: sb.Status.PipelineRunName, Namespace: "default"} + Expect(k8sClient.Get(ctx, prKey, &pr)).To(Succeed()) + Expect(pr.Spec.PipelineRef).NotTo(BeNil()) + Expect(pr.Spec.PipelineRef.Name).To(Equal("software-build-pipeline")) + }) + + It("should accept ubuntu runtime image", func() { + ubuntuName := "test-sb-ubuntu" + resource := &automotivev1alpha1.SoftwareBuild{ + ObjectMeta: metav1.ObjectMeta{ + Name: ubuntuName, + Namespace: "default", + }, + Spec: automotivev1alpha1.SoftwareBuildSpec{ + Runtime: automotivev1alpha1.SoftwareBuildRuntimeSpec{ + Image: "ubuntu:24.04", + }, + Source: automotivev1alpha1.SoftwareBuildSourceSpec{ + Type: automotivev1alpha1.SoftwareBuildSourcePVC, + PVC: &automotivev1alpha1.SoftwareBuildPVCSource{ClaimName: "test-pvc"}, + }, + Stages: automotivev1alpha1.SoftwareBuildPipelineStages{ + Fetch: automotivev1alpha1.SoftwareBuildStageSpec{Command: "echo fetch"}, + Prebuild: automotivev1alpha1.SoftwareBuildStageSpec{Command: "echo pre"}, + Build: automotivev1alpha1.SoftwareBuildStageSpec{Command: "make"}, + Postbuild: automotivev1alpha1.SoftwareBuildStageSpec{Command: "echo post"}, + Deploy: automotivev1alpha1.SoftwareBuildStageSpec{Command: "echo deploy"}, + }, + Destination: automotivev1alpha1.SoftwareBuildDestinationSpec{ + Type: automotivev1alpha1.SoftwareBuildDestinationSharedFolder, + Path: "/out", + }, + }, + } + Expect(k8sClient.Create(ctx, resource)).To(Succeed()) + + controllerReconciler := &softwarebuild.Reconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + Log: ctrl.Log.WithName("test"), + OperatorNamespace: "default", + } + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{Name: ubuntuName, Namespace: "default"}, + }) + Expect(err).NotTo(HaveOccurred()) + + By("verifying PipelineRun was created for ubuntu build") + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: ubuntuName, Namespace: "default"}, resource)).To(Succeed()) + Expect(resource.Status.PipelineRunName).NotTo(BeEmpty()) + Expect(resource.Status.Phase).To(Equal(automotivev1alpha1.SoftwareBuildPhasePending)) + + Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) + }) + }) +}) diff --git a/internal/controller/test/suite_test.go b/internal/controller/test/suite_test.go index 0766b3876..b4b43bbff 100644 --- a/internal/controller/test/suite_test.go +++ b/internal/controller/test/suite_test.go @@ -26,6 +26,7 @@ import ( . "github.com/onsi/ginkgo/v2" //nolint:revive // Dot import is standard for Ginkgo . "github.com/onsi/gomega" //nolint:revive // Dot import is standard for Gomega + tektonv1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1" "k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/rest" "sigs.k8s.io/controller-runtime/pkg/client" @@ -59,7 +60,10 @@ var _ = BeforeSuite(func() { By("bootstrapping test environment") testEnv = &envtest.Environment{ - CRDDirectoryPaths: []string{filepath.Join("..", "..", "..", "config", "crd", "bases")}, + CRDDirectoryPaths: []string{ + filepath.Join("..", "..", "..", "config", "crd", "bases"), + filepath.Join("testdata"), + }, ErrorIfCRDPathMissing: true, // The BinaryAssetsDirectory is only required if you want to run the tests directly @@ -80,6 +84,9 @@ var _ = BeforeSuite(func() { err = automotivev1alpha1.AddToScheme(scheme.Scheme) Expect(err).NotTo(HaveOccurred()) + err = tektonv1.AddToScheme(scheme.Scheme) + Expect(err).NotTo(HaveOccurred()) + // +kubebuilder:scaffold:scheme k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme}) diff --git a/internal/controller/test/testdata/tekton-pipeline-crd.yaml b/internal/controller/test/testdata/tekton-pipeline-crd.yaml new file mode 100644 index 000000000..d030d520a --- /dev/null +++ b/internal/controller/test/testdata/tekton-pipeline-crd.yaml @@ -0,0 +1,51 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: pipelineruns.tekton.dev +spec: + group: tekton.dev + names: + kind: PipelineRun + listKind: PipelineRunList + plural: pipelineruns + singular: pipelinerun + categories: + - tekton + - tekton-pipelines + scope: Namespaced + versions: + - name: v1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + x-kubernetes-preserve-unknown-fields: true + subresources: + status: {} +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: pipelines.tekton.dev +spec: + group: tekton.dev + names: + kind: Pipeline + listKind: PipelineList + plural: pipelines + singular: pipeline + categories: + - tekton + - tekton-pipelines + scope: Namespaced + versions: + - name: v1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + x-kubernetes-preserve-unknown-fields: true + subresources: + status: {}