From de966a12aaa578d3c68664454bb8d0dc4927586b Mon Sep 17 00:00:00 2001 From: Alessio Greggi Date: Tue, 19 May 2026 16:25:27 +0200 Subject: [PATCH 1/5] feat(api): define crds for nodescan Signed-off-by: Alessio Greggi --- api/labels.go | 2 + api/storage/register.go | 6 + api/storage/v1alpha1/node_metadata.go | 18 + api/storage/v1alpha1/nodesbom_types.go | 37 ++ .../v1alpha1/nodevulnerabilityreport_types.go | 38 +++ api/storage/v1alpha1/register.go | 50 ++- api/storage/v1alpha1/zz_generated.deepcopy.go | 138 ++++++++ .../v1alpha1/zz_generated.model_name.go | 25 ++ api/v1alpha1/nodescanconfiguration_types.go | 66 ++++ api/v1alpha1/nodescanjob_types.go | 319 ++++++++++++++++++ api/v1alpha1/scanjob_types.go | 116 +++---- api/v1alpha1/zz_generated.deepcopy.go | 197 +++++++++++ ...bomscanner.kubewarden.io_nodescanjobs.yaml | 137 ++++++++ .../storage/v1alpha1/nodemetadata.go | 36 ++ .../storage/v1alpha1/nodesbom.go | 230 +++++++++++++ .../v1alpha1/nodevulnerabilityreport.go | 230 +++++++++++++ pkg/generated/applyconfiguration/utils.go | 6 + .../storage/v1alpha1/fake/fake_nodesbom.go | 35 ++ .../fake/fake_nodevulnerabilityreport.go | 37 ++ .../v1alpha1/fake/fake_storage_client.go | 8 + .../storage/v1alpha1/generated_expansion.go | 4 + .../typed/storage/v1alpha1/nodesbom.go | 54 +++ .../v1alpha1/nodevulnerabilityreport.go | 56 +++ .../typed/storage/v1alpha1/storage_client.go | 10 + .../informers/externalversions/generic.go | 4 + .../storage/v1alpha1/interface.go | 14 + .../storage/v1alpha1/nodesbom.go | 99 ++++++ .../v1alpha1/nodevulnerabilityreport.go | 99 ++++++ .../storage/v1alpha1/expansion_generated.go | 8 + .../listers/storage/v1alpha1/nodesbom.go | 32 ++ .../v1alpha1/nodevulnerabilityreport.go | 32 ++ pkg/generated/openapi/zz_generated.openapi.go | 231 +++++++++++++ ...e.sbomscanner.kubewarden.io_nodesboms.yaml | 65 ++++ ...ubewarden.io_nodevulnerabilityreports.yaml | 227 +++++++++++++ 34 files changed, 2607 insertions(+), 59 deletions(-) create mode 100644 api/storage/v1alpha1/node_metadata.go create mode 100644 api/storage/v1alpha1/nodesbom_types.go create mode 100644 api/storage/v1alpha1/nodevulnerabilityreport_types.go create mode 100644 api/v1alpha1/nodescanconfiguration_types.go create mode 100644 api/v1alpha1/nodescanjob_types.go create mode 100644 charts/sbomscanner/templates/crd/sbomscanner.kubewarden.io_nodescanjobs.yaml create mode 100644 pkg/generated/applyconfiguration/storage/v1alpha1/nodemetadata.go create mode 100644 pkg/generated/applyconfiguration/storage/v1alpha1/nodesbom.go create mode 100644 pkg/generated/applyconfiguration/storage/v1alpha1/nodevulnerabilityreport.go create mode 100644 pkg/generated/clientset/versioned/typed/storage/v1alpha1/fake/fake_nodesbom.go create mode 100644 pkg/generated/clientset/versioned/typed/storage/v1alpha1/fake/fake_nodevulnerabilityreport.go create mode 100644 pkg/generated/clientset/versioned/typed/storage/v1alpha1/nodesbom.go create mode 100644 pkg/generated/clientset/versioned/typed/storage/v1alpha1/nodevulnerabilityreport.go create mode 100644 pkg/generated/informers/externalversions/storage/v1alpha1/nodesbom.go create mode 100644 pkg/generated/informers/externalversions/storage/v1alpha1/nodevulnerabilityreport.go create mode 100644 pkg/generated/listers/storage/v1alpha1/nodesbom.go create mode 100644 pkg/generated/listers/storage/v1alpha1/nodevulnerabilityreport.go create mode 100644 test/crd/storage.sbomscanner.kubewarden.io_nodesboms.yaml create mode 100644 test/crd/storage.sbomscanner.kubewarden.io_nodevulnerabilityreports.yaml diff --git a/api/labels.go b/api/labels.go index 02e718bb3..9c0c4b481 100644 --- a/api/labels.go +++ b/api/labels.go @@ -9,4 +9,6 @@ const ( LabelPartOfValue = "sbomscanner" LabelWorkloadScanKey = "sbomscanner.kubewarden.io/workloadscan" LabelWorkloadScanValue = "true" + LabelNodeScanKey = "sbomscanner.kubewarden.io/nodescan" + LabelNodeScanValue = "true" ) diff --git a/api/storage/register.go b/api/storage/register.go index d0588e282..f8f4e330e 100644 --- a/api/storage/register.go +++ b/api/storage/register.go @@ -40,6 +40,12 @@ func addKnownTypes(scheme *runtime.Scheme) error { &v1alpha1.VulnerabilityReport{}, &v1alpha1.VulnerabilityReportList{}, + + &v1alpha1.NodeSBOM{}, + &v1alpha1.NodeSBOMList{}, + + &v1alpha1.NodeVulnerabilityReport{}, + &v1alpha1.NodeVulnerabilityReportList{}, ) return nil } diff --git a/api/storage/v1alpha1/node_metadata.go b/api/storage/v1alpha1/node_metadata.go new file mode 100644 index 000000000..9ca715ab6 --- /dev/null +++ b/api/storage/v1alpha1/node_metadata.go @@ -0,0 +1,18 @@ +package v1alpha1 + +// IndexNodeMetadataName is the field index for the digest of a node. +const ( + IndexNodeMetadataName = "nodeMetadata.name" +) + +// NodeMetadata contains the metadata details of a node. +type NodeMetadata struct { + // Name specifies the name of the node. + Name string `json:"name" protobuf:"bytes,1,req,name=name"` + // Platform specifies the platform of the image. Example "linux/amd64". + Platform string `json:"platform" protobuf:"bytes,2,req,name=platform"` +} + +type NodeMetadataAccessor interface { + GetNodeMetadata() NodeMetadata +} diff --git a/api/storage/v1alpha1/nodesbom_types.go b/api/storage/v1alpha1/nodesbom_types.go new file mode 100644 index 000000000..9c0479285 --- /dev/null +++ b/api/storage/v1alpha1/nodesbom_types.go @@ -0,0 +1,37 @@ +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object + +// NodeSBOMList contains a list of Software Bill of Materials for nodes +type NodeSBOMList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty" protobuf:"bytes,1,opt,name=metadata"` + + Items []NodeSBOM `json:"items" protobuf:"bytes,2,rep,name=items"` +} + +// +genclient +// +genclient:nonNamespaced +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +// +kubebuilder:resource:scope=Cluster +// +kubebuilder:selectablefield:JSONPath=`.nodeMetadata.name` +// +kubebuilder:selectablefield:JSONPath=`.nodeMetadata.platform` + +// NodeSBOM represents a Software Bill of Materials of a node +type NodeSBOM struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty" protobuf:"bytes,1,opt,name=metadata"` + + NodeMetadata NodeMetadata `json:"nodeMetadata" protobuf:"bytes,2,req,name=nodeMetadata"` + // SPDX contains the SPDX document of the SBOM in JSON format + SPDX runtime.RawExtension `json:"spdx" protobuf:"bytes,3,req,name=spdx"` +} + +func (s *NodeSBOM) GetNodeMetadata() NodeMetadata { + return s.NodeMetadata +} diff --git a/api/storage/v1alpha1/nodevulnerabilityreport_types.go b/api/storage/v1alpha1/nodevulnerabilityreport_types.go new file mode 100644 index 000000000..ac348de3e --- /dev/null +++ b/api/storage/v1alpha1/nodevulnerabilityreport_types.go @@ -0,0 +1,38 @@ +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object + +// NodeVulnerabilityReportList contains a list of NodeVulnerabilityReport +type NodeVulnerabilityReportList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty" protobuf:"bytes,1,opt,name=metadata"` + + Items []NodeVulnerabilityReport `json:"items" protobuf:"bytes,2,rep,name=items"` +} + +// +genclient +// +genclient:nonNamespaced +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +// +kubebuilder:resource:scope=Cluster +// +kubebuilder:selectablefield:JSONPath=`.nodeMetadata.name` +// +kubebuilder:selectablefield:JSONPath=`.nodeMetadata.platform` + +// NodeVulnerabilityReport is the Schema for the scanresults API +type NodeVulnerabilityReport struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty" protobuf:"bytes,1,opt,name=metadata"` + + // NodeMetadata contains info about the scanned node + NodeMetadata NodeMetadata `json:"nodeMetadata" protobuf:"bytes,2,req,name=nodeMetadata"` + + // Report is the actual vulnerability scan report + Report Report `json:"report" protobuf:"bytes,3,req,name=report"` +} + +func (v *NodeVulnerabilityReport) GetNodeMetadata() NodeMetadata { + return v.NodeMetadata +} diff --git a/api/storage/v1alpha1/register.go b/api/storage/v1alpha1/register.go index 1fc8348a7..f16b0124f 100644 --- a/api/storage/v1alpha1/register.go +++ b/api/storage/v1alpha1/register.go @@ -42,6 +42,10 @@ func AddKnownTypes(scheme *runtime.Scheme) error { &VulnerabilityReportList{}, &WorkloadScanReport{}, &WorkloadScanReportList{}, + &NodeSBOM{}, + &NodeSBOMList{}, + &NodeVulnerabilityReport{}, + &NodeVulnerabilityReportList{}, &metav1.GetOptions{}, &metav1.CreateOptions{}, &metav1.UpdateOptions{}, @@ -58,7 +62,10 @@ func AddKnownTypes(scheme *runtime.Scheme) error { return fmt.Errorf("unable to add field selector conversion function to Image: %w", err) } - err = scheme.AddFieldLabelConversionFunc(SchemeGroupVersion.WithKind("SBOM"), imageMetadataFieldSelectorConversion) + err = scheme.AddFieldLabelConversionFunc( + SchemeGroupVersion.WithKind("SBOM"), + imageMetadataFieldSelectorConversion, + ) if err != nil { return fmt.Errorf("unable to add field selector conversion function to SBOM: %w", err) } @@ -71,9 +78,26 @@ func AddKnownTypes(scheme *runtime.Scheme) error { return fmt.Errorf("unable to add field selector conversion function to VulnerabilityReport: %w", err) } + err = scheme.AddFieldLabelConversionFunc( + SchemeGroupVersion.WithKind("NodeSBOM"), + nodeMetadataFieldSelectorConversion, + ) + if err != nil { + return fmt.Errorf("unable to add field selector conversion function to NodeSBOM: %w", err) + } + + err = scheme.AddFieldLabelConversionFunc( + SchemeGroupVersion.WithKind("NodeVulnerabilityReport"), + nodeMetadataFieldSelectorConversion, + ) + if err != nil { + return fmt.Errorf("unable to add field selector conversion function to NodeVulnerabilityReport: %w", err) + } + return nil } +// imageMetadataFieldSelectorConversion allows field selection on the image metadata fields. func imageMetadataFieldSelectorConversion(label, value string) (string, string, error) { switch label { case "metadata.name": @@ -104,3 +128,27 @@ func imageMetadataFieldSelectorConversion(label, value string) (string, string, ) } } + +// nodeMetadataFieldSelectorConversion allows field selection on the node metadata fields. +// This is needed to allow listing NodeSBOMs and NodeVulnerabilityReports by node metadata, +// since the node name and platform are part of the node metadata and not the top-level resource metadata. +func nodeMetadataFieldSelectorConversion(label, value string) (string, string, error) { + switch label { + case "metadata.name": + return label, value, nil + case "metadata.namespace": + return label, value, nil + case "nodeMetadata.name": + return label, value, nil + case "nodeMetadata.platform": + return label, value, nil + default: + return "", "", fmt.Errorf( + "%q is not a known field selector: only %q, %q, %q", + label, + "metadata.name", + "metadata.namespace", + "nodeMetadata.*", + ) + } +} diff --git a/api/storage/v1alpha1/zz_generated.deepcopy.go b/api/storage/v1alpha1/zz_generated.deepcopy.go index c8d9d6bc9..f7adb133f 100644 --- a/api/storage/v1alpha1/zz_generated.deepcopy.go +++ b/api/storage/v1alpha1/zz_generated.deepcopy.go @@ -232,6 +232,144 @@ func (in *ImageWorkloadScanReports) DeepCopy() *ImageWorkloadScanReports { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *NodeMetadata) DeepCopyInto(out *NodeMetadata) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NodeMetadata. +func (in *NodeMetadata) DeepCopy() *NodeMetadata { + if in == nil { + return nil + } + out := new(NodeMetadata) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *NodeSBOM) DeepCopyInto(out *NodeSBOM) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.NodeMetadata = in.NodeMetadata + in.SPDX.DeepCopyInto(&out.SPDX) + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NodeSBOM. +func (in *NodeSBOM) DeepCopy() *NodeSBOM { + if in == nil { + return nil + } + out := new(NodeSBOM) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *NodeSBOM) 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 *NodeSBOMList) DeepCopyInto(out *NodeSBOMList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]NodeSBOM, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NodeSBOMList. +func (in *NodeSBOMList) DeepCopy() *NodeSBOMList { + if in == nil { + return nil + } + out := new(NodeSBOMList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *NodeSBOMList) 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 *NodeVulnerabilityReport) DeepCopyInto(out *NodeVulnerabilityReport) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.NodeMetadata = in.NodeMetadata + in.Report.DeepCopyInto(&out.Report) + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NodeVulnerabilityReport. +func (in *NodeVulnerabilityReport) DeepCopy() *NodeVulnerabilityReport { + if in == nil { + return nil + } + out := new(NodeVulnerabilityReport) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *NodeVulnerabilityReport) 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 *NodeVulnerabilityReportList) DeepCopyInto(out *NodeVulnerabilityReportList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]NodeVulnerabilityReport, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NodeVulnerabilityReportList. +func (in *NodeVulnerabilityReportList) DeepCopy() *NodeVulnerabilityReportList { + if in == nil { + return nil + } + out := new(NodeVulnerabilityReportList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *NodeVulnerabilityReportList) 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 *Report) DeepCopyInto(out *Report) { *out = *in diff --git a/api/storage/v1alpha1/zz_generated.model_name.go b/api/storage/v1alpha1/zz_generated.model_name.go index 9771bf3d5..193bc574a 100644 --- a/api/storage/v1alpha1/zz_generated.model_name.go +++ b/api/storage/v1alpha1/zz_generated.model_name.go @@ -60,6 +60,31 @@ func (in ImageWorkloadScanReports) OpenAPIModelName() string { return "storage.sbomscanner.kubewarden.io.v1alpha1.ImageWorkloadScanReports" } +// OpenAPIModelName returns the OpenAPI model name for this type. +func (in NodeMetadata) OpenAPIModelName() string { + return "storage.sbomscanner.kubewarden.io.v1alpha1.NodeMetadata" +} + +// OpenAPIModelName returns the OpenAPI model name for this type. +func (in NodeSBOM) OpenAPIModelName() string { + return "storage.sbomscanner.kubewarden.io.v1alpha1.NodeSBOM" +} + +// OpenAPIModelName returns the OpenAPI model name for this type. +func (in NodeSBOMList) OpenAPIModelName() string { + return "storage.sbomscanner.kubewarden.io.v1alpha1.NodeSBOMList" +} + +// OpenAPIModelName returns the OpenAPI model name for this type. +func (in NodeVulnerabilityReport) OpenAPIModelName() string { + return "storage.sbomscanner.kubewarden.io.v1alpha1.NodeVulnerabilityReport" +} + +// OpenAPIModelName returns the OpenAPI model name for this type. +func (in NodeVulnerabilityReportList) OpenAPIModelName() string { + return "storage.sbomscanner.kubewarden.io.v1alpha1.NodeVulnerabilityReportList" +} + // OpenAPIModelName returns the OpenAPI model name for this type. func (in Report) OpenAPIModelName() string { return "storage.sbomscanner.kubewarden.io.v1alpha1.Report" diff --git a/api/v1alpha1/nodescanconfiguration_types.go b/api/v1alpha1/nodescanconfiguration_types.go new file mode 100644 index 000000000..1d38026b0 --- /dev/null +++ b/api/v1alpha1/nodescanconfiguration_types.go @@ -0,0 +1,66 @@ +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + // NodeScanConfigurationName is the only allowed name for the singleton NodeScanConfiguration resource. + NodeScanConfigurationName = "default" + + // AnnotationForceNodeScanKey triggers an immediate rescan of all matching nodes, + // bypassing the scan interval timer. The annotation is removed after the scan jobs are created. + AnnotationForceNodeScanKey = "force-node-scan" +) + +// NodeScanConfigurationSpec defines the desired configuration for node scanning. +type NodeScanConfigurationSpec struct { + // NodeSelector filters which nodes are scanned. + // If not specified, all the nodes are scanned. + // +optional + NodeSelector *metav1.LabelSelector `json:"nodeSelector,omitempty"` + + // ScanInterval is the interval at which nodes are scanned. + // +optional + ScanInterval *metav1.Duration `json:"scanInterval,omitempty"` + + // SkipPatterns specifies gitignore-style patterns for directories and files to skip during node scanning. + // Patterns ending with "/" are treated as directories. + // All other patterns are treated as files. + // Glob patterns like "**/vendor/" or "*.min.js" are supported. + // +optional + SkipPatterns []string `json:"skipPatterns,omitempty"` + + // Platforms allows to specify the list of platforms to scan. + // If not set, all nodes are scanned regardless of their platform. + // +optional + Platforms []Platform `json:"platforms,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:scope=Cluster +// +kubebuilder:validation:XValidation:rule="self.metadata.name == 'default'",message="NodeScanConfiguration name must be 'default'" + +// NodeScanConfiguration is the Schema for the nodescanconfigurations API. +// This is a singleton resource - only one instance named "default" is allowed. +type NodeScanConfiguration struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec NodeScanConfigurationSpec `json:"spec,omitempty"` +} + +// +kubebuilder:object:root=true + +// NodeScanConfigurationList contains a list of NodeScanConfiguration. +type NodeScanConfigurationList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + + Items []NodeScanConfiguration `json:"items"` +} + +func init() { + register(&NodeScanConfiguration{}, &NodeScanConfigurationList{}) +} diff --git a/api/v1alpha1/nodescanjob_types.go b/api/v1alpha1/nodescanjob_types.go new file mode 100644 index 000000000..94a1056da --- /dev/null +++ b/api/v1alpha1/nodescanjob_types.go @@ -0,0 +1,319 @@ +package v1alpha1 + +import ( + "time" + + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const LabelNodeScanJobUIDKey = "sbomscanner.kubewarden.io/nodescanjob-uid" + +const ( + // IndexNodeScanJobSpecNodeName is the field index for the node name of a NodeScanJob. + IndexNodeScanJobSpecNodeName = "spec.nodeName" +) + +// RegistryAnnotation stores a snapshot of the Registry targeted by the NodeScanJob. +const ( + // AnnotationNodeScanJobCreationTimestampKey is used to store the creation timestamp of the NodeScanJob. + AnnotationNodeScanJobCreationTimestampKey = "sbomscanner.kubewarden.io/creation-timestamp" + // AnnotationNodeScanJobTriggerKey is used to identify the source of the NodeScanJob trigger. + AnnotationNodeScanJobTriggerKey = "sbomscanner.kubewarden.io/trigger" +) + +const ( + ConditionNodeScanJobTypeScheduled = "Scheduled" + ConditionNodeScanJobTypeInProgress = "InProgress" + ConditionNodeScanJobTypeComplete = "Complete" + ConditionNodeScanJobTypeFailed = "Failed" +) + +const ( + ReasonNodeScanJobInProgress = "NodeScanInProgress" + ReasonNodeScanJobScanned = "NodeScanned" + ReasonNodeScanJobConfigurationMissing = "NodeScanConfigurationMissing" + ReasonNodeScanJobNotMatching = "NodeNotMatching" + ReasonNodeScanJobPending = "NodeScanPending" + ReasonNodeScanJobScheduled = "NodeScanScheduled" + ReasonNodeScanJobComplete = "NodeScanComplete" + ReasonNodeScanJobFailed = "NodeScanFailed" +) + +// NodeScanJobSpec defines the desired state of NodeScanJob. +type NodeScanJobSpec struct { + // NodeName specifies the name of the node to be scanned. + NodeName string `json:"nodeName"` +} + +// NodeScanJobStatus defines the observed state of NodeScanJob. +type NodeScanJobStatus struct { + // Conditions represent the latest available observations of ScanJob state + // +optional + Conditions []metav1.Condition `json:"conditions,omitempty"` + + // StartTime is when the job started processing. + // +optional + StartTime *metav1.Time `json:"startTime,omitempty"` + + // CompletionTime is when the job completed or failed. + // +optional + CompletionTime *metav1.Time `json:"completionTime,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:scope=Cluster +// +kubebuilder:selectablefield:JSONPath=`.spec.nodeName` +// +kubebuilder:printcolumn:name="Status",type="string",JSONPath=".status.conditions[?(@.status=='True')].type",description="Current status" +// +kubebuilder:printcolumn:name="Reason",type="string",JSONPath=".status.conditions[?(@.status=='True')].reason",description="Status reason" +// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp" + +// NodeScanJob is the Schema for the nodescanjobs API. +type NodeScanJob struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec NodeScanJobSpec `json:"spec,omitempty"` + Status NodeScanJobStatus `json:"status,omitempty"` +} + +// GetCreationTimestampFromAnnotation returns the creation timestamp of the NodeScanJob. +// It first attempts to parse the timestamp from the CreationTimestampAnnotation. +// If the annotation is missing or malformed, it falls back to the Kubernetes object's +// standard metadata.CreationTimestamp. +func (s *NodeScanJob) GetCreationTimestampFromAnnotation() time.Time { + if timestampStr, ok := s.Annotations[AnnotationNodeScanJobCreationTimestampKey]; ok { + if timestamp, err := time.Parse(time.RFC3339Nano, timestampStr); err == nil { + return timestamp + } + } + + return s.CreationTimestamp.Time +} + +// InitializeConditions initializes status fields and conditions. +func (s *NodeScanJob) InitializeConditions() { + s.Status.Conditions = []metav1.Condition{} + + meta.SetStatusCondition(&s.Status.Conditions, metav1.Condition{ + Type: ConditionNodeScanJobTypeScheduled, + Status: metav1.ConditionUnknown, + Reason: ReasonNodeScanJobPending, + Message: messagePending, + ObservedGeneration: s.Generation, + }) + meta.SetStatusCondition(&s.Status.Conditions, metav1.Condition{ + Type: ConditionNodeScanJobTypeInProgress, + Status: metav1.ConditionUnknown, + Reason: ReasonNodeScanJobPending, + Message: messagePending, + ObservedGeneration: s.Generation, + }) + meta.SetStatusCondition(&s.Status.Conditions, metav1.Condition{ + Type: ConditionNodeScanJobTypeComplete, + Status: metav1.ConditionUnknown, + Reason: ReasonNodeScanJobPending, + Message: messagePending, + ObservedGeneration: s.Generation, + }) + meta.SetStatusCondition(&s.Status.Conditions, metav1.Condition{ + Type: ConditionNodeScanJobTypeFailed, + Status: metav1.ConditionUnknown, + Reason: ReasonNodeScanJobPending, + Message: messagePending, + ObservedGeneration: s.Generation, + }) +} + +// MarkScheduled marks the job as scheduled. +func (s *NodeScanJob) MarkScheduled(reason, message string) { + meta.SetStatusCondition(&s.Status.Conditions, metav1.Condition{ + Type: ConditionNodeScanJobTypeScheduled, + Status: metav1.ConditionTrue, + Reason: reason, + Message: message, + ObservedGeneration: s.Generation, + }) + meta.SetStatusCondition(&s.Status.Conditions, metav1.Condition{ + Type: ConditionNodeScanJobTypeInProgress, + Status: metav1.ConditionFalse, + Reason: ReasonNodeScanJobScheduled, + Message: messageScheduled, + ObservedGeneration: s.Generation, + }) + meta.SetStatusCondition(&s.Status.Conditions, metav1.Condition{ + Type: ConditionNodeScanJobTypeComplete, + Status: metav1.ConditionFalse, + Reason: ReasonNodeScanJobScheduled, + Message: messageScheduled, + ObservedGeneration: s.Generation, + }) + meta.SetStatusCondition(&s.Status.Conditions, metav1.Condition{ + Type: ConditionNodeScanJobTypeFailed, + Status: metav1.ConditionFalse, + Reason: ReasonNodeScanJobScheduled, + Message: messageScheduled, + ObservedGeneration: s.Generation, + }) +} + +// MarkInProgress marks the job as in progress. +func (s *NodeScanJob) MarkInProgress(reason, message string) { + now := metav1.Now() + s.Status.StartTime = &now + + meta.SetStatusCondition(&s.Status.Conditions, metav1.Condition{ + Type: ConditionNodeScanJobTypeScheduled, + Status: metav1.ConditionFalse, + Reason: ReasonNodeScanJobInProgress, + Message: messageInProgress, + ObservedGeneration: s.Generation, + }) + meta.SetStatusCondition(&s.Status.Conditions, metav1.Condition{ + Type: ConditionNodeScanJobTypeInProgress, + Status: metav1.ConditionTrue, + Reason: reason, + Message: message, + ObservedGeneration: s.Generation, + }) + meta.SetStatusCondition(&s.Status.Conditions, metav1.Condition{ + Type: ConditionNodeScanJobTypeComplete, + Status: metav1.ConditionFalse, + Reason: ReasonNodeScanJobInProgress, + Message: messageInProgress, + ObservedGeneration: s.Generation, + }) + meta.SetStatusCondition(&s.Status.Conditions, metav1.Condition{ + Type: ConditionNodeScanJobTypeFailed, + Status: metav1.ConditionFalse, + Reason: ReasonNodeScanJobInProgress, + Message: messageInProgress, + ObservedGeneration: s.Generation, + }) +} + +// MarkComplete marks the job as complete. +func (s *NodeScanJob) MarkComplete(reason, message string) { + now := metav1.Now() + s.Status.CompletionTime = &now + + meta.SetStatusCondition(&s.Status.Conditions, metav1.Condition{ + Type: ConditionNodeScanJobTypeScheduled, + Status: metav1.ConditionFalse, + Reason: ReasonNodeScanJobComplete, + Message: messageCompleted, + ObservedGeneration: s.Generation, + }) + meta.SetStatusCondition(&s.Status.Conditions, metav1.Condition{ + Type: ConditionNodeScanJobTypeInProgress, + Status: metav1.ConditionFalse, + Reason: ReasonNodeScanJobComplete, + Message: messageCompleted, + ObservedGeneration: s.Generation, + }) + meta.SetStatusCondition(&s.Status.Conditions, metav1.Condition{ + Type: ConditionNodeScanJobTypeComplete, + Status: metav1.ConditionTrue, + Reason: reason, + Message: message, + ObservedGeneration: s.Generation, + }) + meta.SetStatusCondition(&s.Status.Conditions, metav1.Condition{ + Type: ConditionNodeScanJobTypeFailed, + Status: metav1.ConditionFalse, + Reason: ReasonNodeScanJobComplete, + Message: messageCompleted, + ObservedGeneration: s.Generation, + }) +} + +// MarkFailed marks the job as failed. +func (s *NodeScanJob) MarkFailed(reason, message string) { + now := metav1.Now() + s.Status.CompletionTime = &now + + meta.SetStatusCondition(&s.Status.Conditions, metav1.Condition{ + Type: ConditionNodeScanJobTypeScheduled, + Status: metav1.ConditionFalse, + Reason: ReasonNodeScanJobFailed, + Message: messageFailed, + ObservedGeneration: s.Generation, + }) + meta.SetStatusCondition(&s.Status.Conditions, metav1.Condition{ + Type: ConditionNodeScanJobTypeInProgress, + Status: metav1.ConditionFalse, + Reason: ReasonNodeScanJobFailed, + Message: messageFailed, + ObservedGeneration: s.Generation, + }) + meta.SetStatusCondition(&s.Status.Conditions, metav1.Condition{ + Type: ConditionNodeScanJobTypeComplete, + Status: metav1.ConditionFalse, + Reason: ReasonNodeScanJobFailed, + Message: messageFailed, + ObservedGeneration: s.Generation, + }) + meta.SetStatusCondition(&s.Status.Conditions, metav1.Condition{ + Type: ConditionNodeScanJobTypeFailed, + Status: metav1.ConditionTrue, + Reason: reason, + Message: message, + ObservedGeneration: s.Generation, + }) +} + +// IsPending returns true if the job is not in any other state. +func (s *NodeScanJob) IsPending() bool { + return !s.IsScheduled() && !s.IsInProgress() && !s.IsComplete() && !s.IsFailed() +} + +// IsScheduled returns true if the job is scheduled. +func (s *NodeScanJob) IsScheduled() bool { + scheduledCond := meta.FindStatusCondition(s.Status.Conditions, ConditionNodeScanJobTypeScheduled) + if scheduledCond == nil { + return false + } + return scheduledCond.Status == metav1.ConditionTrue +} + +// IsInProgress returns true if the job is currently in progress. +func (s *NodeScanJob) IsInProgress() bool { + inProgressCond := meta.FindStatusCondition(s.Status.Conditions, ConditionNodeScanJobTypeInProgress) + if inProgressCond == nil { + return false + } + return inProgressCond.Status == metav1.ConditionTrue +} + +// IsComplete returns true if the job has completed successfully. +func (s *NodeScanJob) IsComplete() bool { + completeCond := meta.FindStatusCondition(s.Status.Conditions, ConditionNodeScanJobTypeComplete) + if completeCond == nil { + return false + } + return completeCond.Status == metav1.ConditionTrue +} + +// IsFailed returns true if the job has failed. +func (s *NodeScanJob) IsFailed() bool { + failedCond := meta.FindStatusCondition(s.Status.Conditions, ConditionNodeScanJobTypeFailed) + if failedCond == nil { + return false + } + return failedCond.Status == metav1.ConditionTrue +} + +// +kubebuilder:object:root=true + +// NodeScanJobList contains a list of NodeScanJob. +type NodeScanJobList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + + Items []NodeScanJob `json:"items"` +} + +func init() { + register(&NodeScanJob{}, &NodeScanJobList{}) +} diff --git a/api/v1alpha1/scanjob_types.go b/api/v1alpha1/scanjob_types.go index ce140a16b..77a7314cb 100644 --- a/api/v1alpha1/scanjob_types.go +++ b/api/v1alpha1/scanjob_types.go @@ -50,27 +50,27 @@ type ScanJobRepository struct { } const ( - ConditionTypeScheduled = "Scheduled" - ConditionTypeInProgress = "InProgress" - ConditionTypeComplete = "Complete" - ConditionTypeFailed = "Failed" + ConditionScanJobTypeScheduled = "Scheduled" + ConditionScanJobTypeInProgress = "InProgress" + ConditionScanJobTypeComplete = "Complete" + ConditionScanJobTypeFailed = "Failed" ) const ( - ReasonPending = "Pending" - ReasonScheduled = "Scheduled" - ReasonInProgress = "InProgress" - ReasonCatalogCreationInProgress = "CatalogCreationInProgress" - ReasonSBOMGenerationInProgress = "SBOMGenerationInProgress" - ReasonImageScanInProgress = "ImageScanInProgress" - ReasonComplete = "Complete" - ReasonFailed = "Failed" - ReasonNoImagesToScan = "NoImagesToScan" - ReasonAllImagesScanned = "AllImagesScanned" - ReasonRegistryNotFound = "RegistryNotFound" - ReasonRepositoryNotFound = "RepositoryNotFound" - ReasonMatchConditionNotFound = "MatchConditionNotFound" - ReasonInternalError = "InternalError" + ReasonScanJobPending = "Pending" + ReasonScanJobScheduled = "Scheduled" + ReasonScanJobInProgress = "InProgress" + ReasonScanJobCatalogCreationInProgress = "CatalogCreationInProgress" + ReasonScanJobSBOMGenerationInProgress = "SBOMGenerationInProgress" + ReasonScanJobImageScanInProgress = "ImageScanInProgress" + ReasonScanJobComplete = "Complete" + ReasonScanJobFailed = "Failed" + ReasonScanJobNoImagesToScan = "NoImagesToScan" + ReasonScanJobAllImagesScanned = "AllImagesScanned" + ReasonScanJobRegistryNotFound = "RegistryNotFound" + ReasonScanJobRepositoryNotFound = "RepositoryNotFound" + ReasonScanJobMatchConditionNotFound = "MatchConditionNotFound" + ReasonScanJobInternalError = "InternalError" ) const ( @@ -140,30 +140,30 @@ func (s *ScanJob) InitializeConditions() { s.Status.Conditions = []metav1.Condition{} meta.SetStatusCondition(&s.Status.Conditions, metav1.Condition{ - Type: ConditionTypeScheduled, + Type: ConditionScanJobTypeScheduled, Status: metav1.ConditionUnknown, - Reason: ReasonPending, + Reason: ReasonScanJobPending, Message: messagePending, ObservedGeneration: s.Generation, }) meta.SetStatusCondition(&s.Status.Conditions, metav1.Condition{ - Type: ConditionTypeInProgress, + Type: ConditionScanJobTypeInProgress, Status: metav1.ConditionUnknown, - Reason: ReasonPending, + Reason: ReasonScanJobPending, Message: messagePending, ObservedGeneration: s.Generation, }) meta.SetStatusCondition(&s.Status.Conditions, metav1.Condition{ - Type: ConditionTypeComplete, + Type: ConditionScanJobTypeComplete, Status: metav1.ConditionUnknown, - Reason: ReasonPending, + Reason: ReasonScanJobPending, Message: messagePending, ObservedGeneration: s.Generation, }) meta.SetStatusCondition(&s.Status.Conditions, metav1.Condition{ - Type: ConditionTypeFailed, + Type: ConditionScanJobTypeFailed, Status: metav1.ConditionUnknown, - Reason: ReasonPending, + Reason: ReasonScanJobPending, Message: messagePending, ObservedGeneration: s.Generation, }) @@ -172,30 +172,30 @@ func (s *ScanJob) InitializeConditions() { // MarkScheduled marks the job as scheduled. func (s *ScanJob) MarkScheduled(reason, message string) { meta.SetStatusCondition(&s.Status.Conditions, metav1.Condition{ - Type: ConditionTypeScheduled, + Type: ConditionScanJobTypeScheduled, Status: metav1.ConditionTrue, Reason: reason, Message: message, ObservedGeneration: s.Generation, }) meta.SetStatusCondition(&s.Status.Conditions, metav1.Condition{ - Type: ConditionTypeInProgress, + Type: ConditionScanJobTypeInProgress, Status: metav1.ConditionFalse, - Reason: ReasonScheduled, + Reason: ReasonScanJobScheduled, Message: messageScheduled, ObservedGeneration: s.Generation, }) meta.SetStatusCondition(&s.Status.Conditions, metav1.Condition{ - Type: ConditionTypeComplete, + Type: ConditionScanJobTypeComplete, Status: metav1.ConditionFalse, - Reason: ReasonScheduled, + Reason: ReasonScanJobScheduled, Message: messageScheduled, ObservedGeneration: s.Generation, }) meta.SetStatusCondition(&s.Status.Conditions, metav1.Condition{ - Type: ConditionTypeFailed, + Type: ConditionScanJobTypeFailed, Status: metav1.ConditionFalse, - Reason: ReasonScheduled, + Reason: ReasonScanJobScheduled, Message: messageScheduled, ObservedGeneration: s.Generation, }) @@ -207,30 +207,30 @@ func (s *ScanJob) MarkInProgress(reason, message string) { s.Status.StartTime = &now meta.SetStatusCondition(&s.Status.Conditions, metav1.Condition{ - Type: ConditionTypeScheduled, + Type: ConditionScanJobTypeScheduled, Status: metav1.ConditionFalse, - Reason: ReasonInProgress, + Reason: ReasonScanJobInProgress, Message: messageInProgress, ObservedGeneration: s.Generation, }) meta.SetStatusCondition(&s.Status.Conditions, metav1.Condition{ - Type: ConditionTypeInProgress, + Type: ConditionScanJobTypeInProgress, Status: metav1.ConditionTrue, Reason: reason, Message: message, ObservedGeneration: s.Generation, }) meta.SetStatusCondition(&s.Status.Conditions, metav1.Condition{ - Type: ConditionTypeComplete, + Type: ConditionScanJobTypeComplete, Status: metav1.ConditionFalse, - Reason: ReasonInProgress, + Reason: ReasonScanJobInProgress, Message: messageInProgress, ObservedGeneration: s.Generation, }) meta.SetStatusCondition(&s.Status.Conditions, metav1.Condition{ - Type: ConditionTypeFailed, + Type: ConditionScanJobTypeFailed, Status: metav1.ConditionFalse, - Reason: ReasonInProgress, + Reason: ReasonScanJobInProgress, Message: messageInProgress, ObservedGeneration: s.Generation, }) @@ -242,30 +242,30 @@ func (s *ScanJob) MarkComplete(reason, message string) { s.Status.CompletionTime = &now meta.SetStatusCondition(&s.Status.Conditions, metav1.Condition{ - Type: ConditionTypeScheduled, + Type: ConditionScanJobTypeScheduled, Status: metav1.ConditionFalse, - Reason: ReasonComplete, + Reason: ReasonScanJobComplete, Message: messageCompleted, ObservedGeneration: s.Generation, }) meta.SetStatusCondition(&s.Status.Conditions, metav1.Condition{ - Type: ConditionTypeInProgress, + Type: ConditionScanJobTypeInProgress, Status: metav1.ConditionFalse, - Reason: ReasonComplete, + Reason: ReasonScanJobComplete, Message: messageCompleted, ObservedGeneration: s.Generation, }) meta.SetStatusCondition(&s.Status.Conditions, metav1.Condition{ - Type: ConditionTypeComplete, + Type: ConditionScanJobTypeComplete, Status: metav1.ConditionTrue, Reason: reason, Message: message, ObservedGeneration: s.Generation, }) meta.SetStatusCondition(&s.Status.Conditions, metav1.Condition{ - Type: ConditionTypeFailed, + Type: ConditionScanJobTypeFailed, Status: metav1.ConditionFalse, - Reason: ReasonComplete, + Reason: ReasonScanJobComplete, Message: messageCompleted, ObservedGeneration: s.Generation, }) @@ -277,28 +277,28 @@ func (s *ScanJob) MarkFailed(reason, message string) { s.Status.CompletionTime = &now meta.SetStatusCondition(&s.Status.Conditions, metav1.Condition{ - Type: ConditionTypeScheduled, + Type: ConditionScanJobTypeScheduled, Status: metav1.ConditionFalse, - Reason: ReasonFailed, + Reason: ReasonScanJobFailed, Message: messageFailed, ObservedGeneration: s.Generation, }) meta.SetStatusCondition(&s.Status.Conditions, metav1.Condition{ - Type: ConditionTypeInProgress, + Type: ConditionScanJobTypeInProgress, Status: metav1.ConditionFalse, - Reason: ReasonFailed, + Reason: ReasonScanJobFailed, Message: messageFailed, ObservedGeneration: s.Generation, }) meta.SetStatusCondition(&s.Status.Conditions, metav1.Condition{ - Type: ConditionTypeComplete, + Type: ConditionScanJobTypeComplete, Status: metav1.ConditionFalse, - Reason: ReasonFailed, + Reason: ReasonScanJobFailed, Message: messageFailed, ObservedGeneration: s.Generation, }) meta.SetStatusCondition(&s.Status.Conditions, metav1.Condition{ - Type: ConditionTypeFailed, + Type: ConditionScanJobTypeFailed, Status: metav1.ConditionTrue, Reason: reason, Message: message, @@ -313,7 +313,7 @@ func (s *ScanJob) IsPending() bool { // IsScheduled returns true if the job is scheduled. func (s *ScanJob) IsScheduled() bool { - scheduledCond := meta.FindStatusCondition(s.Status.Conditions, ConditionTypeScheduled) + scheduledCond := meta.FindStatusCondition(s.Status.Conditions, ConditionScanJobTypeScheduled) if scheduledCond == nil { return false } @@ -322,7 +322,7 @@ func (s *ScanJob) IsScheduled() bool { // IsInProgress returns true if the job is currently in progress. func (s *ScanJob) IsInProgress() bool { - inProgressCond := meta.FindStatusCondition(s.Status.Conditions, ConditionTypeInProgress) + inProgressCond := meta.FindStatusCondition(s.Status.Conditions, ConditionScanJobTypeInProgress) if inProgressCond == nil { return false } @@ -331,7 +331,7 @@ func (s *ScanJob) IsInProgress() bool { // IsComplete returns true if the job has completed successfully. func (s *ScanJob) IsComplete() bool { - completeCond := meta.FindStatusCondition(s.Status.Conditions, ConditionTypeComplete) + completeCond := meta.FindStatusCondition(s.Status.Conditions, ConditionScanJobTypeComplete) if completeCond == nil { return false } @@ -340,7 +340,7 @@ func (s *ScanJob) IsComplete() bool { // IsFailed returns true if the job has failed. func (s *ScanJob) IsFailed() bool { - failedCond := meta.FindStatusCondition(s.Status.Conditions, ConditionTypeFailed) + failedCond := meta.FindStatusCondition(s.Status.Conditions, ConditionScanJobTypeFailed) if failedCond == nil { return false } diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 8cf57eb96..09725c2cc 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -31,6 +31,203 @@ func (in *MatchCondition) DeepCopy() *MatchCondition { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *NodeScanConfiguration) DeepCopyInto(out *NodeScanConfiguration) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NodeScanConfiguration. +func (in *NodeScanConfiguration) DeepCopy() *NodeScanConfiguration { + if in == nil { + return nil + } + out := new(NodeScanConfiguration) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *NodeScanConfiguration) 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 *NodeScanConfigurationList) DeepCopyInto(out *NodeScanConfigurationList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]NodeScanConfiguration, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NodeScanConfigurationList. +func (in *NodeScanConfigurationList) DeepCopy() *NodeScanConfigurationList { + if in == nil { + return nil + } + out := new(NodeScanConfigurationList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *NodeScanConfigurationList) 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 *NodeScanConfigurationSpec) DeepCopyInto(out *NodeScanConfigurationSpec) { + *out = *in + if in.NodeSelector != nil { + in, out := &in.NodeSelector, &out.NodeSelector + *out = new(v1.LabelSelector) + (*in).DeepCopyInto(*out) + } + if in.ScanInterval != nil { + in, out := &in.ScanInterval, &out.ScanInterval + *out = new(v1.Duration) + **out = **in + } + if in.SkipPatterns != nil { + in, out := &in.SkipPatterns, &out.SkipPatterns + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Platforms != nil { + in, out := &in.Platforms, &out.Platforms + *out = make([]Platform, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NodeScanConfigurationSpec. +func (in *NodeScanConfigurationSpec) DeepCopy() *NodeScanConfigurationSpec { + if in == nil { + return nil + } + out := new(NodeScanConfigurationSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *NodeScanJob) DeepCopyInto(out *NodeScanJob) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.Spec = in.Spec + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NodeScanJob. +func (in *NodeScanJob) DeepCopy() *NodeScanJob { + if in == nil { + return nil + } + out := new(NodeScanJob) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *NodeScanJob) 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 *NodeScanJobList) DeepCopyInto(out *NodeScanJobList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]NodeScanJob, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NodeScanJobList. +func (in *NodeScanJobList) DeepCopy() *NodeScanJobList { + if in == nil { + return nil + } + out := new(NodeScanJobList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *NodeScanJobList) 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 *NodeScanJobSpec) DeepCopyInto(out *NodeScanJobSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NodeScanJobSpec. +func (in *NodeScanJobSpec) DeepCopy() *NodeScanJobSpec { + if in == nil { + return nil + } + out := new(NodeScanJobSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *NodeScanJobStatus) DeepCopyInto(out *NodeScanJobStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.StartTime != nil { + in, out := &in.StartTime, &out.StartTime + *out = (*in).DeepCopy() + } + if in.CompletionTime != nil { + in, out := &in.CompletionTime, &out.CompletionTime + *out = (*in).DeepCopy() + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NodeScanJobStatus. +func (in *NodeScanJobStatus) DeepCopy() *NodeScanJobStatus { + if in == nil { + return nil + } + out := new(NodeScanJobStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Platform) DeepCopyInto(out *Platform) { *out = *in diff --git a/charts/sbomscanner/templates/crd/sbomscanner.kubewarden.io_nodescanjobs.yaml b/charts/sbomscanner/templates/crd/sbomscanner.kubewarden.io_nodescanjobs.yaml new file mode 100644 index 000000000..cd137e96d --- /dev/null +++ b/charts/sbomscanner/templates/crd/sbomscanner.kubewarden.io_nodescanjobs.yaml @@ -0,0 +1,137 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + helm.sh/resource-policy: keep + controller-gen.kubebuilder.io/version: v0.16.5 + name: nodescanjobs.sbomscanner.kubewarden.io +spec: + group: sbomscanner.kubewarden.io + names: + kind: NodeScanJob + listKind: NodeScanJobList + plural: nodescanjobs + singular: nodescanjob + scope: Cluster + versions: + - additionalPrinterColumns: + - description: Current status + jsonPath: .status.conditions[?(@.status=='True')].type + name: Status + type: string + - description: Status reason + jsonPath: .status.conditions[?(@.status=='True')].reason + name: Reason + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: NodeScanJob is the Schema for the nodescanjobs API. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: NodeScanJobSpec defines the desired state of NodeScanJob. + properties: + nodeName: + description: NodeName specifies the name of the node to be scanned. + type: string + required: + - nodeName + type: object + status: + description: NodeScanJobStatus defines the observed state of NodeScanJob. + properties: + completionTime: + description: CompletionTime is when the job completed or failed. + format: date-time + type: string + conditions: + description: Conditions represent the latest available observations + of ScanJob state + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + startTime: + description: StartTime is when the job started processing. + format: date-time + type: string + type: object + type: object + selectableFields: + - jsonPath: .spec.nodeName + served: true + storage: true + subresources: + status: {} diff --git a/pkg/generated/applyconfiguration/storage/v1alpha1/nodemetadata.go b/pkg/generated/applyconfiguration/storage/v1alpha1/nodemetadata.go new file mode 100644 index 000000000..2be2103da --- /dev/null +++ b/pkg/generated/applyconfiguration/storage/v1alpha1/nodemetadata.go @@ -0,0 +1,36 @@ +// Code generated by applyconfiguration-gen. DO NOT EDIT. + +package v1alpha1 + +// NodeMetadataApplyConfiguration represents a declarative configuration of the NodeMetadata type for use +// with apply. +// +// NodeMetadata contains the metadata details of a node. +type NodeMetadataApplyConfiguration struct { + // Name specifies the name of the node. + Name *string `json:"name,omitempty"` + // Platform specifies the platform of the image. Example "linux/amd64". + Platform *string `json:"platform,omitempty"` +} + +// NodeMetadataApplyConfiguration constructs a declarative configuration of the NodeMetadata type for use with +// apply. +func NodeMetadata() *NodeMetadataApplyConfiguration { + return &NodeMetadataApplyConfiguration{} +} + +// WithName sets the Name field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Name field is set to the value of the last call. +func (b *NodeMetadataApplyConfiguration) WithName(value string) *NodeMetadataApplyConfiguration { + b.Name = &value + return b +} + +// WithPlatform sets the Platform field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Platform field is set to the value of the last call. +func (b *NodeMetadataApplyConfiguration) WithPlatform(value string) *NodeMetadataApplyConfiguration { + b.Platform = &value + return b +} diff --git a/pkg/generated/applyconfiguration/storage/v1alpha1/nodesbom.go b/pkg/generated/applyconfiguration/storage/v1alpha1/nodesbom.go new file mode 100644 index 000000000..2ce16fc71 --- /dev/null +++ b/pkg/generated/applyconfiguration/storage/v1alpha1/nodesbom.go @@ -0,0 +1,230 @@ +// Code generated by applyconfiguration-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + types "k8s.io/apimachinery/pkg/types" + v1 "k8s.io/client-go/applyconfigurations/meta/v1" +) + +// NodeSBOMApplyConfiguration represents a declarative configuration of the NodeSBOM type for use +// with apply. +// +// NodeSBOM represents a Software Bill of Materials of a node +type NodeSBOMApplyConfiguration struct { + v1.TypeMetaApplyConfiguration `json:",inline"` + *v1.ObjectMetaApplyConfiguration `json:"metadata,omitempty"` + NodeMetadata *NodeMetadataApplyConfiguration `json:"nodeMetadata,omitempty"` + // SPDX contains the SPDX document of the SBOM in JSON format + SPDX *runtime.RawExtension `json:"spdx,omitempty"` +} + +// NodeSBOM constructs a declarative configuration of the NodeSBOM type for use with +// apply. +func NodeSBOM(name string) *NodeSBOMApplyConfiguration { + b := &NodeSBOMApplyConfiguration{} + b.WithName(name) + b.WithKind("NodeSBOM") + b.WithAPIVersion("storage.sbomscanner.kubewarden.io/v1alpha1") + return b +} + +func (b NodeSBOMApplyConfiguration) IsApplyConfiguration() {} + +// WithKind sets the Kind field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Kind field is set to the value of the last call. +func (b *NodeSBOMApplyConfiguration) WithKind(value string) *NodeSBOMApplyConfiguration { + b.TypeMetaApplyConfiguration.Kind = &value + return b +} + +// WithAPIVersion sets the APIVersion field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the APIVersion field is set to the value of the last call. +func (b *NodeSBOMApplyConfiguration) WithAPIVersion(value string) *NodeSBOMApplyConfiguration { + b.TypeMetaApplyConfiguration.APIVersion = &value + return b +} + +// WithName sets the Name field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Name field is set to the value of the last call. +func (b *NodeSBOMApplyConfiguration) WithName(value string) *NodeSBOMApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ObjectMetaApplyConfiguration.Name = &value + return b +} + +// WithGenerateName sets the GenerateName field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the GenerateName field is set to the value of the last call. +func (b *NodeSBOMApplyConfiguration) WithGenerateName(value string) *NodeSBOMApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ObjectMetaApplyConfiguration.GenerateName = &value + return b +} + +// WithNamespace sets the Namespace field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Namespace field is set to the value of the last call. +func (b *NodeSBOMApplyConfiguration) WithNamespace(value string) *NodeSBOMApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ObjectMetaApplyConfiguration.Namespace = &value + return b +} + +// WithUID sets the UID field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the UID field is set to the value of the last call. +func (b *NodeSBOMApplyConfiguration) WithUID(value types.UID) *NodeSBOMApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ObjectMetaApplyConfiguration.UID = &value + return b +} + +// WithResourceVersion sets the ResourceVersion field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the ResourceVersion field is set to the value of the last call. +func (b *NodeSBOMApplyConfiguration) WithResourceVersion(value string) *NodeSBOMApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ObjectMetaApplyConfiguration.ResourceVersion = &value + return b +} + +// WithGeneration sets the Generation field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Generation field is set to the value of the last call. +func (b *NodeSBOMApplyConfiguration) WithGeneration(value int64) *NodeSBOMApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ObjectMetaApplyConfiguration.Generation = &value + return b +} + +// WithCreationTimestamp sets the CreationTimestamp field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the CreationTimestamp field is set to the value of the last call. +func (b *NodeSBOMApplyConfiguration) WithCreationTimestamp(value metav1.Time) *NodeSBOMApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ObjectMetaApplyConfiguration.CreationTimestamp = &value + return b +} + +// WithDeletionTimestamp sets the DeletionTimestamp field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the DeletionTimestamp field is set to the value of the last call. +func (b *NodeSBOMApplyConfiguration) WithDeletionTimestamp(value metav1.Time) *NodeSBOMApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ObjectMetaApplyConfiguration.DeletionTimestamp = &value + return b +} + +// WithDeletionGracePeriodSeconds sets the DeletionGracePeriodSeconds field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the DeletionGracePeriodSeconds field is set to the value of the last call. +func (b *NodeSBOMApplyConfiguration) WithDeletionGracePeriodSeconds(value int64) *NodeSBOMApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ObjectMetaApplyConfiguration.DeletionGracePeriodSeconds = &value + return b +} + +// WithLabels puts the entries into the Labels field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, the entries provided by each call will be put on the Labels field, +// overwriting an existing map entries in Labels field with the same key. +func (b *NodeSBOMApplyConfiguration) WithLabels(entries map[string]string) *NodeSBOMApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + if b.ObjectMetaApplyConfiguration.Labels == nil && len(entries) > 0 { + b.ObjectMetaApplyConfiguration.Labels = make(map[string]string, len(entries)) + } + for k, v := range entries { + b.ObjectMetaApplyConfiguration.Labels[k] = v + } + return b +} + +// WithAnnotations puts the entries into the Annotations field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, the entries provided by each call will be put on the Annotations field, +// overwriting an existing map entries in Annotations field with the same key. +func (b *NodeSBOMApplyConfiguration) WithAnnotations(entries map[string]string) *NodeSBOMApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + if b.ObjectMetaApplyConfiguration.Annotations == nil && len(entries) > 0 { + b.ObjectMetaApplyConfiguration.Annotations = make(map[string]string, len(entries)) + } + for k, v := range entries { + b.ObjectMetaApplyConfiguration.Annotations[k] = v + } + return b +} + +// WithOwnerReferences adds the given value to the OwnerReferences field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, values provided by each call will be appended to the OwnerReferences field. +func (b *NodeSBOMApplyConfiguration) WithOwnerReferences(values ...*v1.OwnerReferenceApplyConfiguration) *NodeSBOMApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + for i := range values { + if values[i] == nil { + panic("nil value passed to WithOwnerReferences") + } + b.ObjectMetaApplyConfiguration.OwnerReferences = append(b.ObjectMetaApplyConfiguration.OwnerReferences, *values[i]) + } + return b +} + +// WithFinalizers adds the given value to the Finalizers field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, values provided by each call will be appended to the Finalizers field. +func (b *NodeSBOMApplyConfiguration) WithFinalizers(values ...string) *NodeSBOMApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + for i := range values { + b.ObjectMetaApplyConfiguration.Finalizers = append(b.ObjectMetaApplyConfiguration.Finalizers, values[i]) + } + return b +} + +func (b *NodeSBOMApplyConfiguration) ensureObjectMetaApplyConfigurationExists() { + if b.ObjectMetaApplyConfiguration == nil { + b.ObjectMetaApplyConfiguration = &v1.ObjectMetaApplyConfiguration{} + } +} + +// WithNodeMetadata sets the NodeMetadata field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the NodeMetadata field is set to the value of the last call. +func (b *NodeSBOMApplyConfiguration) WithNodeMetadata(value *NodeMetadataApplyConfiguration) *NodeSBOMApplyConfiguration { + b.NodeMetadata = value + return b +} + +// WithSPDX sets the SPDX field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the SPDX field is set to the value of the last call. +func (b *NodeSBOMApplyConfiguration) WithSPDX(value runtime.RawExtension) *NodeSBOMApplyConfiguration { + b.SPDX = &value + return b +} + +// GetKind retrieves the value of the Kind field in the declarative configuration. +func (b *NodeSBOMApplyConfiguration) GetKind() *string { + return b.TypeMetaApplyConfiguration.Kind +} + +// GetAPIVersion retrieves the value of the APIVersion field in the declarative configuration. +func (b *NodeSBOMApplyConfiguration) GetAPIVersion() *string { + return b.TypeMetaApplyConfiguration.APIVersion +} + +// GetName retrieves the value of the Name field in the declarative configuration. +func (b *NodeSBOMApplyConfiguration) GetName() *string { + b.ensureObjectMetaApplyConfigurationExists() + return b.ObjectMetaApplyConfiguration.Name +} + +// GetNamespace retrieves the value of the Namespace field in the declarative configuration. +func (b *NodeSBOMApplyConfiguration) GetNamespace() *string { + b.ensureObjectMetaApplyConfigurationExists() + return b.ObjectMetaApplyConfiguration.Namespace +} diff --git a/pkg/generated/applyconfiguration/storage/v1alpha1/nodevulnerabilityreport.go b/pkg/generated/applyconfiguration/storage/v1alpha1/nodevulnerabilityreport.go new file mode 100644 index 000000000..e3d967189 --- /dev/null +++ b/pkg/generated/applyconfiguration/storage/v1alpha1/nodevulnerabilityreport.go @@ -0,0 +1,230 @@ +// Code generated by applyconfiguration-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + types "k8s.io/apimachinery/pkg/types" + v1 "k8s.io/client-go/applyconfigurations/meta/v1" +) + +// NodeVulnerabilityReportApplyConfiguration represents a declarative configuration of the NodeVulnerabilityReport type for use +// with apply. +// +// NodeVulnerabilityReport is the Schema for the scanresults API +type NodeVulnerabilityReportApplyConfiguration struct { + v1.TypeMetaApplyConfiguration `json:",inline"` + *v1.ObjectMetaApplyConfiguration `json:"metadata,omitempty"` + // NodeMetadata contains info about the scanned node + NodeMetadata *NodeMetadataApplyConfiguration `json:"nodeMetadata,omitempty"` + // Report is the actual vulnerability scan report + Report *ReportApplyConfiguration `json:"report,omitempty"` +} + +// NodeVulnerabilityReport constructs a declarative configuration of the NodeVulnerabilityReport type for use with +// apply. +func NodeVulnerabilityReport(name string) *NodeVulnerabilityReportApplyConfiguration { + b := &NodeVulnerabilityReportApplyConfiguration{} + b.WithName(name) + b.WithKind("NodeVulnerabilityReport") + b.WithAPIVersion("storage.sbomscanner.kubewarden.io/v1alpha1") + return b +} + +func (b NodeVulnerabilityReportApplyConfiguration) IsApplyConfiguration() {} + +// WithKind sets the Kind field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Kind field is set to the value of the last call. +func (b *NodeVulnerabilityReportApplyConfiguration) WithKind(value string) *NodeVulnerabilityReportApplyConfiguration { + b.TypeMetaApplyConfiguration.Kind = &value + return b +} + +// WithAPIVersion sets the APIVersion field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the APIVersion field is set to the value of the last call. +func (b *NodeVulnerabilityReportApplyConfiguration) WithAPIVersion(value string) *NodeVulnerabilityReportApplyConfiguration { + b.TypeMetaApplyConfiguration.APIVersion = &value + return b +} + +// WithName sets the Name field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Name field is set to the value of the last call. +func (b *NodeVulnerabilityReportApplyConfiguration) WithName(value string) *NodeVulnerabilityReportApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ObjectMetaApplyConfiguration.Name = &value + return b +} + +// WithGenerateName sets the GenerateName field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the GenerateName field is set to the value of the last call. +func (b *NodeVulnerabilityReportApplyConfiguration) WithGenerateName(value string) *NodeVulnerabilityReportApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ObjectMetaApplyConfiguration.GenerateName = &value + return b +} + +// WithNamespace sets the Namespace field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Namespace field is set to the value of the last call. +func (b *NodeVulnerabilityReportApplyConfiguration) WithNamespace(value string) *NodeVulnerabilityReportApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ObjectMetaApplyConfiguration.Namespace = &value + return b +} + +// WithUID sets the UID field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the UID field is set to the value of the last call. +func (b *NodeVulnerabilityReportApplyConfiguration) WithUID(value types.UID) *NodeVulnerabilityReportApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ObjectMetaApplyConfiguration.UID = &value + return b +} + +// WithResourceVersion sets the ResourceVersion field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the ResourceVersion field is set to the value of the last call. +func (b *NodeVulnerabilityReportApplyConfiguration) WithResourceVersion(value string) *NodeVulnerabilityReportApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ObjectMetaApplyConfiguration.ResourceVersion = &value + return b +} + +// WithGeneration sets the Generation field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Generation field is set to the value of the last call. +func (b *NodeVulnerabilityReportApplyConfiguration) WithGeneration(value int64) *NodeVulnerabilityReportApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ObjectMetaApplyConfiguration.Generation = &value + return b +} + +// WithCreationTimestamp sets the CreationTimestamp field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the CreationTimestamp field is set to the value of the last call. +func (b *NodeVulnerabilityReportApplyConfiguration) WithCreationTimestamp(value metav1.Time) *NodeVulnerabilityReportApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ObjectMetaApplyConfiguration.CreationTimestamp = &value + return b +} + +// WithDeletionTimestamp sets the DeletionTimestamp field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the DeletionTimestamp field is set to the value of the last call. +func (b *NodeVulnerabilityReportApplyConfiguration) WithDeletionTimestamp(value metav1.Time) *NodeVulnerabilityReportApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ObjectMetaApplyConfiguration.DeletionTimestamp = &value + return b +} + +// WithDeletionGracePeriodSeconds sets the DeletionGracePeriodSeconds field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the DeletionGracePeriodSeconds field is set to the value of the last call. +func (b *NodeVulnerabilityReportApplyConfiguration) WithDeletionGracePeriodSeconds(value int64) *NodeVulnerabilityReportApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ObjectMetaApplyConfiguration.DeletionGracePeriodSeconds = &value + return b +} + +// WithLabels puts the entries into the Labels field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, the entries provided by each call will be put on the Labels field, +// overwriting an existing map entries in Labels field with the same key. +func (b *NodeVulnerabilityReportApplyConfiguration) WithLabels(entries map[string]string) *NodeVulnerabilityReportApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + if b.ObjectMetaApplyConfiguration.Labels == nil && len(entries) > 0 { + b.ObjectMetaApplyConfiguration.Labels = make(map[string]string, len(entries)) + } + for k, v := range entries { + b.ObjectMetaApplyConfiguration.Labels[k] = v + } + return b +} + +// WithAnnotations puts the entries into the Annotations field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, the entries provided by each call will be put on the Annotations field, +// overwriting an existing map entries in Annotations field with the same key. +func (b *NodeVulnerabilityReportApplyConfiguration) WithAnnotations(entries map[string]string) *NodeVulnerabilityReportApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + if b.ObjectMetaApplyConfiguration.Annotations == nil && len(entries) > 0 { + b.ObjectMetaApplyConfiguration.Annotations = make(map[string]string, len(entries)) + } + for k, v := range entries { + b.ObjectMetaApplyConfiguration.Annotations[k] = v + } + return b +} + +// WithOwnerReferences adds the given value to the OwnerReferences field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, values provided by each call will be appended to the OwnerReferences field. +func (b *NodeVulnerabilityReportApplyConfiguration) WithOwnerReferences(values ...*v1.OwnerReferenceApplyConfiguration) *NodeVulnerabilityReportApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + for i := range values { + if values[i] == nil { + panic("nil value passed to WithOwnerReferences") + } + b.ObjectMetaApplyConfiguration.OwnerReferences = append(b.ObjectMetaApplyConfiguration.OwnerReferences, *values[i]) + } + return b +} + +// WithFinalizers adds the given value to the Finalizers field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, values provided by each call will be appended to the Finalizers field. +func (b *NodeVulnerabilityReportApplyConfiguration) WithFinalizers(values ...string) *NodeVulnerabilityReportApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + for i := range values { + b.ObjectMetaApplyConfiguration.Finalizers = append(b.ObjectMetaApplyConfiguration.Finalizers, values[i]) + } + return b +} + +func (b *NodeVulnerabilityReportApplyConfiguration) ensureObjectMetaApplyConfigurationExists() { + if b.ObjectMetaApplyConfiguration == nil { + b.ObjectMetaApplyConfiguration = &v1.ObjectMetaApplyConfiguration{} + } +} + +// WithNodeMetadata sets the NodeMetadata field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the NodeMetadata field is set to the value of the last call. +func (b *NodeVulnerabilityReportApplyConfiguration) WithNodeMetadata(value *NodeMetadataApplyConfiguration) *NodeVulnerabilityReportApplyConfiguration { + b.NodeMetadata = value + return b +} + +// WithReport sets the Report field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Report field is set to the value of the last call. +func (b *NodeVulnerabilityReportApplyConfiguration) WithReport(value *ReportApplyConfiguration) *NodeVulnerabilityReportApplyConfiguration { + b.Report = value + return b +} + +// GetKind retrieves the value of the Kind field in the declarative configuration. +func (b *NodeVulnerabilityReportApplyConfiguration) GetKind() *string { + return b.TypeMetaApplyConfiguration.Kind +} + +// GetAPIVersion retrieves the value of the APIVersion field in the declarative configuration. +func (b *NodeVulnerabilityReportApplyConfiguration) GetAPIVersion() *string { + return b.TypeMetaApplyConfiguration.APIVersion +} + +// GetName retrieves the value of the Name field in the declarative configuration. +func (b *NodeVulnerabilityReportApplyConfiguration) GetName() *string { + b.ensureObjectMetaApplyConfigurationExists() + return b.ObjectMetaApplyConfiguration.Name +} + +// GetNamespace retrieves the value of the Namespace field in the declarative configuration. +func (b *NodeVulnerabilityReportApplyConfiguration) GetNamespace() *string { + b.ensureObjectMetaApplyConfigurationExists() + return b.ObjectMetaApplyConfiguration.Namespace +} diff --git a/pkg/generated/applyconfiguration/utils.go b/pkg/generated/applyconfiguration/utils.go index 25686616c..65fcc9e37 100644 --- a/pkg/generated/applyconfiguration/utils.go +++ b/pkg/generated/applyconfiguration/utils.go @@ -36,6 +36,12 @@ func ForKind(kind schema.GroupVersionKind) interface{} { return &storagev1alpha1.ImageStatusApplyConfiguration{} case v1alpha1.SchemeGroupVersion.WithKind("ImageWorkloadScanReports"): return &storagev1alpha1.ImageWorkloadScanReportsApplyConfiguration{} + case v1alpha1.SchemeGroupVersion.WithKind("NodeMetadata"): + return &storagev1alpha1.NodeMetadataApplyConfiguration{} + case v1alpha1.SchemeGroupVersion.WithKind("NodeSBOM"): + return &storagev1alpha1.NodeSBOMApplyConfiguration{} + case v1alpha1.SchemeGroupVersion.WithKind("NodeVulnerabilityReport"): + return &storagev1alpha1.NodeVulnerabilityReportApplyConfiguration{} case v1alpha1.SchemeGroupVersion.WithKind("Report"): return &storagev1alpha1.ReportApplyConfiguration{} case v1alpha1.SchemeGroupVersion.WithKind("Result"): diff --git a/pkg/generated/clientset/versioned/typed/storage/v1alpha1/fake/fake_nodesbom.go b/pkg/generated/clientset/versioned/typed/storage/v1alpha1/fake/fake_nodesbom.go new file mode 100644 index 000000000..d75a64c3c --- /dev/null +++ b/pkg/generated/clientset/versioned/typed/storage/v1alpha1/fake/fake_nodesbom.go @@ -0,0 +1,35 @@ +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + v1alpha1 "github.com/kubewarden/sbomscanner/api/storage/v1alpha1" + storagev1alpha1 "github.com/kubewarden/sbomscanner/pkg/generated/applyconfiguration/storage/v1alpha1" + typedstoragev1alpha1 "github.com/kubewarden/sbomscanner/pkg/generated/clientset/versioned/typed/storage/v1alpha1" + gentype "k8s.io/client-go/gentype" +) + +// fakeNodeSBOMs implements NodeSBOMInterface +type fakeNodeSBOMs struct { + *gentype.FakeClientWithListAndApply[*v1alpha1.NodeSBOM, *v1alpha1.NodeSBOMList, *storagev1alpha1.NodeSBOMApplyConfiguration] + Fake *FakeStorageV1alpha1 +} + +func newFakeNodeSBOMs(fake *FakeStorageV1alpha1) typedstoragev1alpha1.NodeSBOMInterface { + return &fakeNodeSBOMs{ + gentype.NewFakeClientWithListAndApply[*v1alpha1.NodeSBOM, *v1alpha1.NodeSBOMList, *storagev1alpha1.NodeSBOMApplyConfiguration]( + fake.Fake, + "", + v1alpha1.SchemeGroupVersion.WithResource("nodesboms"), + v1alpha1.SchemeGroupVersion.WithKind("NodeSBOM"), + func() *v1alpha1.NodeSBOM { return &v1alpha1.NodeSBOM{} }, + func() *v1alpha1.NodeSBOMList { return &v1alpha1.NodeSBOMList{} }, + func(dst, src *v1alpha1.NodeSBOMList) { dst.ListMeta = src.ListMeta }, + func(list *v1alpha1.NodeSBOMList) []*v1alpha1.NodeSBOM { return gentype.ToPointerSlice(list.Items) }, + func(list *v1alpha1.NodeSBOMList, items []*v1alpha1.NodeSBOM) { + list.Items = gentype.FromPointerSlice(items) + }, + ), + fake, + } +} diff --git a/pkg/generated/clientset/versioned/typed/storage/v1alpha1/fake/fake_nodevulnerabilityreport.go b/pkg/generated/clientset/versioned/typed/storage/v1alpha1/fake/fake_nodevulnerabilityreport.go new file mode 100644 index 000000000..a1a82fa21 --- /dev/null +++ b/pkg/generated/clientset/versioned/typed/storage/v1alpha1/fake/fake_nodevulnerabilityreport.go @@ -0,0 +1,37 @@ +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + v1alpha1 "github.com/kubewarden/sbomscanner/api/storage/v1alpha1" + storagev1alpha1 "github.com/kubewarden/sbomscanner/pkg/generated/applyconfiguration/storage/v1alpha1" + typedstoragev1alpha1 "github.com/kubewarden/sbomscanner/pkg/generated/clientset/versioned/typed/storage/v1alpha1" + gentype "k8s.io/client-go/gentype" +) + +// fakeNodeVulnerabilityReports implements NodeVulnerabilityReportInterface +type fakeNodeVulnerabilityReports struct { + *gentype.FakeClientWithListAndApply[*v1alpha1.NodeVulnerabilityReport, *v1alpha1.NodeVulnerabilityReportList, *storagev1alpha1.NodeVulnerabilityReportApplyConfiguration] + Fake *FakeStorageV1alpha1 +} + +func newFakeNodeVulnerabilityReports(fake *FakeStorageV1alpha1) typedstoragev1alpha1.NodeVulnerabilityReportInterface { + return &fakeNodeVulnerabilityReports{ + gentype.NewFakeClientWithListAndApply[*v1alpha1.NodeVulnerabilityReport, *v1alpha1.NodeVulnerabilityReportList, *storagev1alpha1.NodeVulnerabilityReportApplyConfiguration]( + fake.Fake, + "", + v1alpha1.SchemeGroupVersion.WithResource("nodevulnerabilityreports"), + v1alpha1.SchemeGroupVersion.WithKind("NodeVulnerabilityReport"), + func() *v1alpha1.NodeVulnerabilityReport { return &v1alpha1.NodeVulnerabilityReport{} }, + func() *v1alpha1.NodeVulnerabilityReportList { return &v1alpha1.NodeVulnerabilityReportList{} }, + func(dst, src *v1alpha1.NodeVulnerabilityReportList) { dst.ListMeta = src.ListMeta }, + func(list *v1alpha1.NodeVulnerabilityReportList) []*v1alpha1.NodeVulnerabilityReport { + return gentype.ToPointerSlice(list.Items) + }, + func(list *v1alpha1.NodeVulnerabilityReportList, items []*v1alpha1.NodeVulnerabilityReport) { + list.Items = gentype.FromPointerSlice(items) + }, + ), + fake, + } +} diff --git a/pkg/generated/clientset/versioned/typed/storage/v1alpha1/fake/fake_storage_client.go b/pkg/generated/clientset/versioned/typed/storage/v1alpha1/fake/fake_storage_client.go index 75de77209..7eaaeb33e 100644 --- a/pkg/generated/clientset/versioned/typed/storage/v1alpha1/fake/fake_storage_client.go +++ b/pkg/generated/clientset/versioned/typed/storage/v1alpha1/fake/fake_storage_client.go @@ -16,6 +16,14 @@ func (c *FakeStorageV1alpha1) Images(namespace string) v1alpha1.ImageInterface { return newFakeImages(c, namespace) } +func (c *FakeStorageV1alpha1) NodeSBOMs() v1alpha1.NodeSBOMInterface { + return newFakeNodeSBOMs(c) +} + +func (c *FakeStorageV1alpha1) NodeVulnerabilityReports() v1alpha1.NodeVulnerabilityReportInterface { + return newFakeNodeVulnerabilityReports(c) +} + func (c *FakeStorageV1alpha1) SBOMs(namespace string) v1alpha1.SBOMInterface { return newFakeSBOMs(c, namespace) } diff --git a/pkg/generated/clientset/versioned/typed/storage/v1alpha1/generated_expansion.go b/pkg/generated/clientset/versioned/typed/storage/v1alpha1/generated_expansion.go index c59cd7502..8f660d0e0 100644 --- a/pkg/generated/clientset/versioned/typed/storage/v1alpha1/generated_expansion.go +++ b/pkg/generated/clientset/versioned/typed/storage/v1alpha1/generated_expansion.go @@ -4,6 +4,10 @@ package v1alpha1 type ImageExpansion interface{} +type NodeSBOMExpansion interface{} + +type NodeVulnerabilityReportExpansion interface{} + type SBOMExpansion interface{} type VulnerabilityReportExpansion interface{} diff --git a/pkg/generated/clientset/versioned/typed/storage/v1alpha1/nodesbom.go b/pkg/generated/clientset/versioned/typed/storage/v1alpha1/nodesbom.go new file mode 100644 index 000000000..d6bed2132 --- /dev/null +++ b/pkg/generated/clientset/versioned/typed/storage/v1alpha1/nodesbom.go @@ -0,0 +1,54 @@ +// Code generated by client-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + context "context" + + storagev1alpha1 "github.com/kubewarden/sbomscanner/api/storage/v1alpha1" + applyconfigurationstoragev1alpha1 "github.com/kubewarden/sbomscanner/pkg/generated/applyconfiguration/storage/v1alpha1" + scheme "github.com/kubewarden/sbomscanner/pkg/generated/clientset/versioned/scheme" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + gentype "k8s.io/client-go/gentype" +) + +// NodeSBOMsGetter has a method to return a NodeSBOMInterface. +// A group's client should implement this interface. +type NodeSBOMsGetter interface { + NodeSBOMs() NodeSBOMInterface +} + +// NodeSBOMInterface has methods to work with NodeSBOM resources. +type NodeSBOMInterface interface { + Create(ctx context.Context, nodeSBOM *storagev1alpha1.NodeSBOM, opts v1.CreateOptions) (*storagev1alpha1.NodeSBOM, error) + Update(ctx context.Context, nodeSBOM *storagev1alpha1.NodeSBOM, opts v1.UpdateOptions) (*storagev1alpha1.NodeSBOM, error) + Delete(ctx context.Context, name string, opts v1.DeleteOptions) error + DeleteCollection(ctx context.Context, opts v1.DeleteOptions, listOpts v1.ListOptions) error + Get(ctx context.Context, name string, opts v1.GetOptions) (*storagev1alpha1.NodeSBOM, error) + List(ctx context.Context, opts v1.ListOptions) (*storagev1alpha1.NodeSBOMList, error) + Watch(ctx context.Context, opts v1.ListOptions) (watch.Interface, error) + Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts v1.PatchOptions, subresources ...string) (result *storagev1alpha1.NodeSBOM, err error) + Apply(ctx context.Context, nodeSBOM *applyconfigurationstoragev1alpha1.NodeSBOMApplyConfiguration, opts v1.ApplyOptions) (result *storagev1alpha1.NodeSBOM, err error) + NodeSBOMExpansion +} + +// nodeSBOMs implements NodeSBOMInterface +type nodeSBOMs struct { + *gentype.ClientWithListAndApply[*storagev1alpha1.NodeSBOM, *storagev1alpha1.NodeSBOMList, *applyconfigurationstoragev1alpha1.NodeSBOMApplyConfiguration] +} + +// newNodeSBOMs returns a NodeSBOMs +func newNodeSBOMs(c *StorageV1alpha1Client) *nodeSBOMs { + return &nodeSBOMs{ + gentype.NewClientWithListAndApply[*storagev1alpha1.NodeSBOM, *storagev1alpha1.NodeSBOMList, *applyconfigurationstoragev1alpha1.NodeSBOMApplyConfiguration]( + "nodesboms", + c.RESTClient(), + scheme.ParameterCodec, + "", + func() *storagev1alpha1.NodeSBOM { return &storagev1alpha1.NodeSBOM{} }, + func() *storagev1alpha1.NodeSBOMList { return &storagev1alpha1.NodeSBOMList{} }, + ), + } +} diff --git a/pkg/generated/clientset/versioned/typed/storage/v1alpha1/nodevulnerabilityreport.go b/pkg/generated/clientset/versioned/typed/storage/v1alpha1/nodevulnerabilityreport.go new file mode 100644 index 000000000..736a1682d --- /dev/null +++ b/pkg/generated/clientset/versioned/typed/storage/v1alpha1/nodevulnerabilityreport.go @@ -0,0 +1,56 @@ +// Code generated by client-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + context "context" + + storagev1alpha1 "github.com/kubewarden/sbomscanner/api/storage/v1alpha1" + applyconfigurationstoragev1alpha1 "github.com/kubewarden/sbomscanner/pkg/generated/applyconfiguration/storage/v1alpha1" + scheme "github.com/kubewarden/sbomscanner/pkg/generated/clientset/versioned/scheme" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + gentype "k8s.io/client-go/gentype" +) + +// NodeVulnerabilityReportsGetter has a method to return a NodeVulnerabilityReportInterface. +// A group's client should implement this interface. +type NodeVulnerabilityReportsGetter interface { + NodeVulnerabilityReports() NodeVulnerabilityReportInterface +} + +// NodeVulnerabilityReportInterface has methods to work with NodeVulnerabilityReport resources. +type NodeVulnerabilityReportInterface interface { + Create(ctx context.Context, nodeVulnerabilityReport *storagev1alpha1.NodeVulnerabilityReport, opts v1.CreateOptions) (*storagev1alpha1.NodeVulnerabilityReport, error) + Update(ctx context.Context, nodeVulnerabilityReport *storagev1alpha1.NodeVulnerabilityReport, opts v1.UpdateOptions) (*storagev1alpha1.NodeVulnerabilityReport, error) + Delete(ctx context.Context, name string, opts v1.DeleteOptions) error + DeleteCollection(ctx context.Context, opts v1.DeleteOptions, listOpts v1.ListOptions) error + Get(ctx context.Context, name string, opts v1.GetOptions) (*storagev1alpha1.NodeVulnerabilityReport, error) + List(ctx context.Context, opts v1.ListOptions) (*storagev1alpha1.NodeVulnerabilityReportList, error) + Watch(ctx context.Context, opts v1.ListOptions) (watch.Interface, error) + Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts v1.PatchOptions, subresources ...string) (result *storagev1alpha1.NodeVulnerabilityReport, err error) + Apply(ctx context.Context, nodeVulnerabilityReport *applyconfigurationstoragev1alpha1.NodeVulnerabilityReportApplyConfiguration, opts v1.ApplyOptions) (result *storagev1alpha1.NodeVulnerabilityReport, err error) + NodeVulnerabilityReportExpansion +} + +// nodeVulnerabilityReports implements NodeVulnerabilityReportInterface +type nodeVulnerabilityReports struct { + *gentype.ClientWithListAndApply[*storagev1alpha1.NodeVulnerabilityReport, *storagev1alpha1.NodeVulnerabilityReportList, *applyconfigurationstoragev1alpha1.NodeVulnerabilityReportApplyConfiguration] +} + +// newNodeVulnerabilityReports returns a NodeVulnerabilityReports +func newNodeVulnerabilityReports(c *StorageV1alpha1Client) *nodeVulnerabilityReports { + return &nodeVulnerabilityReports{ + gentype.NewClientWithListAndApply[*storagev1alpha1.NodeVulnerabilityReport, *storagev1alpha1.NodeVulnerabilityReportList, *applyconfigurationstoragev1alpha1.NodeVulnerabilityReportApplyConfiguration]( + "nodevulnerabilityreports", + c.RESTClient(), + scheme.ParameterCodec, + "", + func() *storagev1alpha1.NodeVulnerabilityReport { return &storagev1alpha1.NodeVulnerabilityReport{} }, + func() *storagev1alpha1.NodeVulnerabilityReportList { + return &storagev1alpha1.NodeVulnerabilityReportList{} + }, + ), + } +} diff --git a/pkg/generated/clientset/versioned/typed/storage/v1alpha1/storage_client.go b/pkg/generated/clientset/versioned/typed/storage/v1alpha1/storage_client.go index ea6eca370..ed9e0ce7f 100644 --- a/pkg/generated/clientset/versioned/typed/storage/v1alpha1/storage_client.go +++ b/pkg/generated/clientset/versioned/typed/storage/v1alpha1/storage_client.go @@ -13,6 +13,8 @@ import ( type StorageV1alpha1Interface interface { RESTClient() rest.Interface ImagesGetter + NodeSBOMsGetter + NodeVulnerabilityReportsGetter SBOMsGetter VulnerabilityReportsGetter WorkloadScanReportsGetter @@ -27,6 +29,14 @@ func (c *StorageV1alpha1Client) Images(namespace string) ImageInterface { return newImages(c, namespace) } +func (c *StorageV1alpha1Client) NodeSBOMs() NodeSBOMInterface { + return newNodeSBOMs(c) +} + +func (c *StorageV1alpha1Client) NodeVulnerabilityReports() NodeVulnerabilityReportInterface { + return newNodeVulnerabilityReports(c) +} + func (c *StorageV1alpha1Client) SBOMs(namespace string) SBOMInterface { return newSBOMs(c, namespace) } diff --git a/pkg/generated/informers/externalversions/generic.go b/pkg/generated/informers/externalversions/generic.go index 3f196bfaf..c5ba9d591 100644 --- a/pkg/generated/informers/externalversions/generic.go +++ b/pkg/generated/informers/externalversions/generic.go @@ -39,6 +39,10 @@ func (f *sharedInformerFactory) ForResource(resource schema.GroupVersionResource // Group=storage.sbomscanner.kubewarden.io, Version=v1alpha1 case v1alpha1.SchemeGroupVersion.WithResource("images"): return &genericInformer{resource: resource.GroupResource(), informer: f.Storage().V1alpha1().Images().Informer()}, nil + case v1alpha1.SchemeGroupVersion.WithResource("nodesboms"): + return &genericInformer{resource: resource.GroupResource(), informer: f.Storage().V1alpha1().NodeSBOMs().Informer()}, nil + case v1alpha1.SchemeGroupVersion.WithResource("nodevulnerabilityreports"): + return &genericInformer{resource: resource.GroupResource(), informer: f.Storage().V1alpha1().NodeVulnerabilityReports().Informer()}, nil case v1alpha1.SchemeGroupVersion.WithResource("sboms"): return &genericInformer{resource: resource.GroupResource(), informer: f.Storage().V1alpha1().SBOMs().Informer()}, nil case v1alpha1.SchemeGroupVersion.WithResource("vulnerabilityreports"): diff --git a/pkg/generated/informers/externalversions/storage/v1alpha1/interface.go b/pkg/generated/informers/externalversions/storage/v1alpha1/interface.go index ed58b463d..719d59b14 100644 --- a/pkg/generated/informers/externalversions/storage/v1alpha1/interface.go +++ b/pkg/generated/informers/externalversions/storage/v1alpha1/interface.go @@ -10,6 +10,10 @@ import ( type Interface interface { // Images returns a ImageInformer. Images() ImageInformer + // NodeSBOMs returns a NodeSBOMInformer. + NodeSBOMs() NodeSBOMInformer + // NodeVulnerabilityReports returns a NodeVulnerabilityReportInformer. + NodeVulnerabilityReports() NodeVulnerabilityReportInformer // SBOMs returns a SBOMInformer. SBOMs() SBOMInformer // VulnerabilityReports returns a VulnerabilityReportInformer. @@ -34,6 +38,16 @@ func (v *version) Images() ImageInformer { return &imageInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions} } +// NodeSBOMs returns a NodeSBOMInformer. +func (v *version) NodeSBOMs() NodeSBOMInformer { + return &nodeSBOMInformer{factory: v.factory, tweakListOptions: v.tweakListOptions} +} + +// NodeVulnerabilityReports returns a NodeVulnerabilityReportInformer. +func (v *version) NodeVulnerabilityReports() NodeVulnerabilityReportInformer { + return &nodeVulnerabilityReportInformer{factory: v.factory, tweakListOptions: v.tweakListOptions} +} + // SBOMs returns a SBOMInformer. func (v *version) SBOMs() SBOMInformer { return &sBOMInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions} diff --git a/pkg/generated/informers/externalversions/storage/v1alpha1/nodesbom.go b/pkg/generated/informers/externalversions/storage/v1alpha1/nodesbom.go new file mode 100644 index 000000000..24bb76292 --- /dev/null +++ b/pkg/generated/informers/externalversions/storage/v1alpha1/nodesbom.go @@ -0,0 +1,99 @@ +// Code generated by informer-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + context "context" + time "time" + + apistoragev1alpha1 "github.com/kubewarden/sbomscanner/api/storage/v1alpha1" + versioned "github.com/kubewarden/sbomscanner/pkg/generated/clientset/versioned" + internalinterfaces "github.com/kubewarden/sbomscanner/pkg/generated/informers/externalversions/internalinterfaces" + storagev1alpha1 "github.com/kubewarden/sbomscanner/pkg/generated/listers/storage/v1alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + schema "k8s.io/apimachinery/pkg/runtime/schema" + watch "k8s.io/apimachinery/pkg/watch" + cache "k8s.io/client-go/tools/cache" +) + +// NodeSBOMInformer provides access to a shared informer and lister for +// NodeSBOMs. +type NodeSBOMInformer interface { + Informer() cache.SharedIndexInformer + Lister() storagev1alpha1.NodeSBOMLister +} + +type nodeSBOMInformer struct { + factory internalinterfaces.SharedInformerFactory + tweakListOptions internalinterfaces.TweakListOptionsFunc +} + +// NewNodeSBOMInformer constructs a new informer for NodeSBOM type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewNodeSBOMInformer(client versioned.Interface, resyncPeriod time.Duration, indexers cache.Indexers) cache.SharedIndexInformer { + return NewNodeSBOMInformerWithOptions(client, internalinterfaces.InformerOptions{ResyncPeriod: resyncPeriod, Indexers: indexers}) +} + +// NewFilteredNodeSBOMInformer constructs a new informer for NodeSBOM type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewFilteredNodeSBOMInformer(client versioned.Interface, resyncPeriod time.Duration, indexers cache.Indexers, tweakListOptions internalinterfaces.TweakListOptionsFunc) cache.SharedIndexInformer { + return NewNodeSBOMInformerWithOptions(client, internalinterfaces.InformerOptions{ResyncPeriod: resyncPeriod, Indexers: indexers, TweakListOptions: tweakListOptions}) +} + +// NewNodeSBOMInformerWithOptions constructs a new informer for NodeSBOM type with additional options. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewNodeSBOMInformerWithOptions(client versioned.Interface, options internalinterfaces.InformerOptions) cache.SharedIndexInformer { + gvr := schema.GroupVersionResource{Group: "storage.sbomscanner.kubewarden.io", Version: "v1alpha1", Resource: "nodesboms"} + identifier := options.InformerName.WithResource(gvr) + tweakListOptions := options.TweakListOptions + return cache.NewSharedIndexInformerWithOptions( + cache.ToListWatcherWithWatchListSemantics(&cache.ListWatch{ + ListFunc: func(opts v1.ListOptions) (runtime.Object, error) { + if tweakListOptions != nil { + tweakListOptions(&opts) + } + return client.StorageV1alpha1().NodeSBOMs().List(context.Background(), opts) + }, + WatchFunc: func(opts v1.ListOptions) (watch.Interface, error) { + if tweakListOptions != nil { + tweakListOptions(&opts) + } + return client.StorageV1alpha1().NodeSBOMs().Watch(context.Background(), opts) + }, + ListWithContextFunc: func(ctx context.Context, opts v1.ListOptions) (runtime.Object, error) { + if tweakListOptions != nil { + tweakListOptions(&opts) + } + return client.StorageV1alpha1().NodeSBOMs().List(ctx, opts) + }, + WatchFuncWithContext: func(ctx context.Context, opts v1.ListOptions) (watch.Interface, error) { + if tweakListOptions != nil { + tweakListOptions(&opts) + } + return client.StorageV1alpha1().NodeSBOMs().Watch(ctx, opts) + }, + }, client), + &apistoragev1alpha1.NodeSBOM{}, + cache.SharedIndexInformerOptions{ + ResyncPeriod: options.ResyncPeriod, + Indexers: options.Indexers, + Identifier: identifier, + }, + ) +} + +func (f *nodeSBOMInformer) defaultInformer(client versioned.Interface, resyncPeriod time.Duration) cache.SharedIndexInformer { + return NewNodeSBOMInformerWithOptions(client, internalinterfaces.InformerOptions{ResyncPeriod: resyncPeriod, Indexers: cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}, InformerName: f.factory.InformerName(), TweakListOptions: f.tweakListOptions}) +} + +func (f *nodeSBOMInformer) Informer() cache.SharedIndexInformer { + return f.factory.InformerFor(&apistoragev1alpha1.NodeSBOM{}, f.defaultInformer) +} + +func (f *nodeSBOMInformer) Lister() storagev1alpha1.NodeSBOMLister { + return storagev1alpha1.NewNodeSBOMLister(f.Informer().GetIndexer()) +} diff --git a/pkg/generated/informers/externalversions/storage/v1alpha1/nodevulnerabilityreport.go b/pkg/generated/informers/externalversions/storage/v1alpha1/nodevulnerabilityreport.go new file mode 100644 index 000000000..ae862816f --- /dev/null +++ b/pkg/generated/informers/externalversions/storage/v1alpha1/nodevulnerabilityreport.go @@ -0,0 +1,99 @@ +// Code generated by informer-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + context "context" + time "time" + + apistoragev1alpha1 "github.com/kubewarden/sbomscanner/api/storage/v1alpha1" + versioned "github.com/kubewarden/sbomscanner/pkg/generated/clientset/versioned" + internalinterfaces "github.com/kubewarden/sbomscanner/pkg/generated/informers/externalversions/internalinterfaces" + storagev1alpha1 "github.com/kubewarden/sbomscanner/pkg/generated/listers/storage/v1alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + schema "k8s.io/apimachinery/pkg/runtime/schema" + watch "k8s.io/apimachinery/pkg/watch" + cache "k8s.io/client-go/tools/cache" +) + +// NodeVulnerabilityReportInformer provides access to a shared informer and lister for +// NodeVulnerabilityReports. +type NodeVulnerabilityReportInformer interface { + Informer() cache.SharedIndexInformer + Lister() storagev1alpha1.NodeVulnerabilityReportLister +} + +type nodeVulnerabilityReportInformer struct { + factory internalinterfaces.SharedInformerFactory + tweakListOptions internalinterfaces.TweakListOptionsFunc +} + +// NewNodeVulnerabilityReportInformer constructs a new informer for NodeVulnerabilityReport type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewNodeVulnerabilityReportInformer(client versioned.Interface, resyncPeriod time.Duration, indexers cache.Indexers) cache.SharedIndexInformer { + return NewNodeVulnerabilityReportInformerWithOptions(client, internalinterfaces.InformerOptions{ResyncPeriod: resyncPeriod, Indexers: indexers}) +} + +// NewFilteredNodeVulnerabilityReportInformer constructs a new informer for NodeVulnerabilityReport type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewFilteredNodeVulnerabilityReportInformer(client versioned.Interface, resyncPeriod time.Duration, indexers cache.Indexers, tweakListOptions internalinterfaces.TweakListOptionsFunc) cache.SharedIndexInformer { + return NewNodeVulnerabilityReportInformerWithOptions(client, internalinterfaces.InformerOptions{ResyncPeriod: resyncPeriod, Indexers: indexers, TweakListOptions: tweakListOptions}) +} + +// NewNodeVulnerabilityReportInformerWithOptions constructs a new informer for NodeVulnerabilityReport type with additional options. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewNodeVulnerabilityReportInformerWithOptions(client versioned.Interface, options internalinterfaces.InformerOptions) cache.SharedIndexInformer { + gvr := schema.GroupVersionResource{Group: "storage.sbomscanner.kubewarden.io", Version: "v1alpha1", Resource: "nodevulnerabilityreports"} + identifier := options.InformerName.WithResource(gvr) + tweakListOptions := options.TweakListOptions + return cache.NewSharedIndexInformerWithOptions( + cache.ToListWatcherWithWatchListSemantics(&cache.ListWatch{ + ListFunc: func(opts v1.ListOptions) (runtime.Object, error) { + if tweakListOptions != nil { + tweakListOptions(&opts) + } + return client.StorageV1alpha1().NodeVulnerabilityReports().List(context.Background(), opts) + }, + WatchFunc: func(opts v1.ListOptions) (watch.Interface, error) { + if tweakListOptions != nil { + tweakListOptions(&opts) + } + return client.StorageV1alpha1().NodeVulnerabilityReports().Watch(context.Background(), opts) + }, + ListWithContextFunc: func(ctx context.Context, opts v1.ListOptions) (runtime.Object, error) { + if tweakListOptions != nil { + tweakListOptions(&opts) + } + return client.StorageV1alpha1().NodeVulnerabilityReports().List(ctx, opts) + }, + WatchFuncWithContext: func(ctx context.Context, opts v1.ListOptions) (watch.Interface, error) { + if tweakListOptions != nil { + tweakListOptions(&opts) + } + return client.StorageV1alpha1().NodeVulnerabilityReports().Watch(ctx, opts) + }, + }, client), + &apistoragev1alpha1.NodeVulnerabilityReport{}, + cache.SharedIndexInformerOptions{ + ResyncPeriod: options.ResyncPeriod, + Indexers: options.Indexers, + Identifier: identifier, + }, + ) +} + +func (f *nodeVulnerabilityReportInformer) defaultInformer(client versioned.Interface, resyncPeriod time.Duration) cache.SharedIndexInformer { + return NewNodeVulnerabilityReportInformerWithOptions(client, internalinterfaces.InformerOptions{ResyncPeriod: resyncPeriod, Indexers: cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}, InformerName: f.factory.InformerName(), TweakListOptions: f.tweakListOptions}) +} + +func (f *nodeVulnerabilityReportInformer) Informer() cache.SharedIndexInformer { + return f.factory.InformerFor(&apistoragev1alpha1.NodeVulnerabilityReport{}, f.defaultInformer) +} + +func (f *nodeVulnerabilityReportInformer) Lister() storagev1alpha1.NodeVulnerabilityReportLister { + return storagev1alpha1.NewNodeVulnerabilityReportLister(f.Informer().GetIndexer()) +} diff --git a/pkg/generated/listers/storage/v1alpha1/expansion_generated.go b/pkg/generated/listers/storage/v1alpha1/expansion_generated.go index 7ea30ef6b..8b114429a 100644 --- a/pkg/generated/listers/storage/v1alpha1/expansion_generated.go +++ b/pkg/generated/listers/storage/v1alpha1/expansion_generated.go @@ -10,6 +10,14 @@ type ImageListerExpansion interface{} // ImageNamespaceLister. type ImageNamespaceListerExpansion interface{} +// NodeSBOMListerExpansion allows custom methods to be added to +// NodeSBOMLister. +type NodeSBOMListerExpansion interface{} + +// NodeVulnerabilityReportListerExpansion allows custom methods to be added to +// NodeVulnerabilityReportLister. +type NodeVulnerabilityReportListerExpansion interface{} + // SBOMListerExpansion allows custom methods to be added to // SBOMLister. type SBOMListerExpansion interface{} diff --git a/pkg/generated/listers/storage/v1alpha1/nodesbom.go b/pkg/generated/listers/storage/v1alpha1/nodesbom.go new file mode 100644 index 000000000..4c3196102 --- /dev/null +++ b/pkg/generated/listers/storage/v1alpha1/nodesbom.go @@ -0,0 +1,32 @@ +// Code generated by lister-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + storagev1alpha1 "github.com/kubewarden/sbomscanner/api/storage/v1alpha1" + labels "k8s.io/apimachinery/pkg/labels" + listers "k8s.io/client-go/listers" + cache "k8s.io/client-go/tools/cache" +) + +// NodeSBOMLister helps list NodeSBOMs. +// All objects returned here must be treated as read-only. +type NodeSBOMLister interface { + // List lists all NodeSBOMs in the indexer. + // Objects returned here must be treated as read-only. + List(selector labels.Selector) (ret []*storagev1alpha1.NodeSBOM, err error) + // Get retrieves the NodeSBOM from the index for a given name. + // Objects returned here must be treated as read-only. + Get(name string) (*storagev1alpha1.NodeSBOM, error) + NodeSBOMListerExpansion +} + +// nodeSBOMLister implements the NodeSBOMLister interface. +type nodeSBOMLister struct { + listers.ResourceIndexer[*storagev1alpha1.NodeSBOM] +} + +// NewNodeSBOMLister returns a new NodeSBOMLister. +func NewNodeSBOMLister(indexer cache.Indexer) NodeSBOMLister { + return &nodeSBOMLister{listers.New[*storagev1alpha1.NodeSBOM](indexer, storagev1alpha1.Resource("nodesbom"))} +} diff --git a/pkg/generated/listers/storage/v1alpha1/nodevulnerabilityreport.go b/pkg/generated/listers/storage/v1alpha1/nodevulnerabilityreport.go new file mode 100644 index 000000000..67b7faf91 --- /dev/null +++ b/pkg/generated/listers/storage/v1alpha1/nodevulnerabilityreport.go @@ -0,0 +1,32 @@ +// Code generated by lister-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + storagev1alpha1 "github.com/kubewarden/sbomscanner/api/storage/v1alpha1" + labels "k8s.io/apimachinery/pkg/labels" + listers "k8s.io/client-go/listers" + cache "k8s.io/client-go/tools/cache" +) + +// NodeVulnerabilityReportLister helps list NodeVulnerabilityReports. +// All objects returned here must be treated as read-only. +type NodeVulnerabilityReportLister interface { + // List lists all NodeVulnerabilityReports in the indexer. + // Objects returned here must be treated as read-only. + List(selector labels.Selector) (ret []*storagev1alpha1.NodeVulnerabilityReport, err error) + // Get retrieves the NodeVulnerabilityReport from the index for a given name. + // Objects returned here must be treated as read-only. + Get(name string) (*storagev1alpha1.NodeVulnerabilityReport, error) + NodeVulnerabilityReportListerExpansion +} + +// nodeVulnerabilityReportLister implements the NodeVulnerabilityReportLister interface. +type nodeVulnerabilityReportLister struct { + listers.ResourceIndexer[*storagev1alpha1.NodeVulnerabilityReport] +} + +// NewNodeVulnerabilityReportLister returns a new NodeVulnerabilityReportLister. +func NewNodeVulnerabilityReportLister(indexer cache.Indexer) NodeVulnerabilityReportLister { + return &nodeVulnerabilityReportLister{listers.New[*storagev1alpha1.NodeVulnerabilityReport](indexer, storagev1alpha1.Resource("nodevulnerabilityreport"))} +} diff --git a/pkg/generated/openapi/zz_generated.openapi.go b/pkg/generated/openapi/zz_generated.openapi.go index 5aed7cafb..3be37d70b 100644 --- a/pkg/generated/openapi/zz_generated.openapi.go +++ b/pkg/generated/openapi/zz_generated.openapi.go @@ -28,6 +28,11 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA v1alpha1.ImageRef{}.OpenAPIModelName(): schema_sbomscanner_api_storage_v1alpha1_ImageRef(ref), v1alpha1.ImageStatus{}.OpenAPIModelName(): schema_sbomscanner_api_storage_v1alpha1_ImageStatus(ref), v1alpha1.ImageWorkloadScanReports{}.OpenAPIModelName(): schema_sbomscanner_api_storage_v1alpha1_ImageWorkloadScanReports(ref), + v1alpha1.NodeMetadata{}.OpenAPIModelName(): schema_sbomscanner_api_storage_v1alpha1_NodeMetadata(ref), + v1alpha1.NodeSBOM{}.OpenAPIModelName(): schema_sbomscanner_api_storage_v1alpha1_NodeSBOM(ref), + v1alpha1.NodeSBOMList{}.OpenAPIModelName(): schema_sbomscanner_api_storage_v1alpha1_NodeSBOMList(ref), + v1alpha1.NodeVulnerabilityReport{}.OpenAPIModelName(): schema_sbomscanner_api_storage_v1alpha1_NodeVulnerabilityReport(ref), + v1alpha1.NodeVulnerabilityReportList{}.OpenAPIModelName(): schema_sbomscanner_api_storage_v1alpha1_NodeVulnerabilityReportList(ref), v1alpha1.Report{}.OpenAPIModelName(): schema_sbomscanner_api_storage_v1alpha1_Report(ref), v1alpha1.Result{}.OpenAPIModelName(): schema_sbomscanner_api_storage_v1alpha1_Result(ref), v1alpha1.SBOM{}.OpenAPIModelName(): schema_sbomscanner_api_storage_v1alpha1_SBOM(ref), @@ -554,6 +559,232 @@ func schema_sbomscanner_api_storage_v1alpha1_ImageWorkloadScanReports(ref common } } +func schema_sbomscanner_api_storage_v1alpha1_NodeMetadata(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "NodeMetadata contains the metadata details of a node.", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "name": { + SchemaProps: spec.SchemaProps{ + Description: "Name specifies the name of the node.", + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "platform": { + SchemaProps: spec.SchemaProps{ + Description: "Platform specifies the platform of the image. Example \"linux/amd64\".", + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + }, + Required: []string{"name", "platform"}, + }, + }, + } +} + +func schema_sbomscanner_api_storage_v1alpha1_NodeSBOM(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "NodeSBOM represents a Software Bill of Materials of a node", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + 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{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + 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{"string"}, + Format: "", + }, + }, + "metadata": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref(v1.ObjectMeta{}.OpenAPIModelName()), + }, + }, + "nodeMetadata": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref(v1alpha1.NodeMetadata{}.OpenAPIModelName()), + }, + }, + "spdx": { + SchemaProps: spec.SchemaProps{ + Description: "SPDX contains the SPDX document of the SBOM in JSON format", + Ref: ref(runtime.RawExtension{}.OpenAPIModelName()), + }, + }, + }, + Required: []string{"nodeMetadata", "spdx"}, + }, + }, + Dependencies: []string{ + v1alpha1.NodeMetadata{}.OpenAPIModelName(), v1.ObjectMeta{}.OpenAPIModelName(), runtime.RawExtension{}.OpenAPIModelName()}, + } +} + +func schema_sbomscanner_api_storage_v1alpha1_NodeSBOMList(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "NodeSBOMList contains a list of Software Bill of Materials for nodes", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + 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{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + 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{"string"}, + Format: "", + }, + }, + "metadata": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref(v1.ListMeta{}.OpenAPIModelName()), + }, + }, + "items": { + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref(v1alpha1.NodeSBOM{}.OpenAPIModelName()), + }, + }, + }, + }, + }, + }, + Required: []string{"items"}, + }, + }, + Dependencies: []string{ + v1alpha1.NodeSBOM{}.OpenAPIModelName(), v1.ListMeta{}.OpenAPIModelName()}, + } +} + +func schema_sbomscanner_api_storage_v1alpha1_NodeVulnerabilityReport(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "NodeVulnerabilityReport is the Schema for the scanresults API", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + 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{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + 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{"string"}, + Format: "", + }, + }, + "metadata": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref(v1.ObjectMeta{}.OpenAPIModelName()), + }, + }, + "nodeMetadata": { + SchemaProps: spec.SchemaProps{ + Description: "NodeMetadata contains info about the scanned node", + Default: map[string]interface{}{}, + Ref: ref(v1alpha1.NodeMetadata{}.OpenAPIModelName()), + }, + }, + "report": { + SchemaProps: spec.SchemaProps{ + Description: "Report is the actual vulnerability scan report", + Default: map[string]interface{}{}, + Ref: ref(v1alpha1.Report{}.OpenAPIModelName()), + }, + }, + }, + Required: []string{"nodeMetadata", "report"}, + }, + }, + Dependencies: []string{ + v1alpha1.NodeMetadata{}.OpenAPIModelName(), v1alpha1.Report{}.OpenAPIModelName(), v1.ObjectMeta{}.OpenAPIModelName()}, + } +} + +func schema_sbomscanner_api_storage_v1alpha1_NodeVulnerabilityReportList(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "NodeVulnerabilityReportList contains a list of NodeVulnerabilityReport", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + 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{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + 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{"string"}, + Format: "", + }, + }, + "metadata": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref(v1.ListMeta{}.OpenAPIModelName()), + }, + }, + "items": { + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref(v1alpha1.NodeVulnerabilityReport{}.OpenAPIModelName()), + }, + }, + }, + }, + }, + }, + Required: []string{"items"}, + }, + }, + Dependencies: []string{ + v1alpha1.NodeVulnerabilityReport{}.OpenAPIModelName(), v1.ListMeta{}.OpenAPIModelName()}, + } +} + func schema_sbomscanner_api_storage_v1alpha1_Report(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ diff --git a/test/crd/storage.sbomscanner.kubewarden.io_nodesboms.yaml b/test/crd/storage.sbomscanner.kubewarden.io_nodesboms.yaml new file mode 100644 index 000000000..ee0eee0d5 --- /dev/null +++ b/test/crd/storage.sbomscanner.kubewarden.io_nodesboms.yaml @@ -0,0 +1,65 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.16.5 + name: nodesboms.storage.sbomscanner.kubewarden.io +spec: + group: storage.sbomscanner.kubewarden.io + names: + kind: NodeSBOM + listKind: NodeSBOMList + plural: nodesboms + singular: nodesbom + scope: Cluster + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: NodeSBOM represents a Software Bill of Materials of a node + 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 + nodeMetadata: + description: NodeMetadata contains the metadata details of a node. + properties: + name: + description: Name specifies the name of the node. + type: string + platform: + description: Platform specifies the platform of the image. Example + "linux/amd64". + type: string + required: + - name + - platform + type: object + spdx: + description: SPDX contains the SPDX document of the SBOM in JSON format + type: object + x-kubernetes-preserve-unknown-fields: true + required: + - nodeMetadata + - spdx + type: object + selectableFields: + - jsonPath: .nodeMetadata.name + - jsonPath: .nodeMetadata.platform + served: true + storage: true diff --git a/test/crd/storage.sbomscanner.kubewarden.io_nodevulnerabilityreports.yaml b/test/crd/storage.sbomscanner.kubewarden.io_nodevulnerabilityreports.yaml new file mode 100644 index 000000000..34b6d2e7c --- /dev/null +++ b/test/crd/storage.sbomscanner.kubewarden.io_nodevulnerabilityreports.yaml @@ -0,0 +1,227 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.16.5 + name: nodevulnerabilityreports.storage.sbomscanner.kubewarden.io +spec: + group: storage.sbomscanner.kubewarden.io + names: + kind: NodeVulnerabilityReport + listKind: NodeVulnerabilityReportList + plural: nodevulnerabilityreports + singular: nodevulnerabilityreport + scope: Cluster + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: NodeVulnerabilityReport is the Schema for the scanresults API + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + nodeMetadata: + description: NodeMetadata contains info about the scanned node + properties: + name: + description: Name specifies the name of the node. + type: string + platform: + description: Platform specifies the platform of the image. Example + "linux/amd64". + type: string + required: + - name + - platform + type: object + report: + description: Report is the actual vulnerability scan report + properties: + results: + description: Results per target (e.g., layer, package type) + items: + description: Result represents scan findings for a specific target + and class of packages + properties: + class: + description: Class is the classification of the target + type: string + target: + description: Target is the specific target scanned + type: string + type: + description: Type is the language type + type: string + vulnerabilities: + description: Vulnerabilities found in this target + items: + description: |- + Vulnerability contains detailed information about a single vulnerability + found in a package + properties: + cve: + description: CVE identifier + type: string + cvss: + additionalProperties: + description: CVSS holds Common Vulnerability Scoring + System data for a vulnerability. + properties: + v3score: + description: V3Score numerical score + type: string + v3vector: + description: V3Vector string (e.g., "AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H") + type: string + required: + - v3score + - v3vector + type: object + description: CVSS scoring details + type: object + cwes: + description: CWEs with which the CVE is classified + items: + type: string + type: array + description: + description: Description of the vulnerability + type: string + diffID: + description: DiffID of the image layer where the vulnerability + was introduced + type: string + fixedVersions: + description: FixedVersions is the list of versions where + the vulnerability is fixed + items: + type: string + type: array + installedVersion: + description: InstalledVersion of the package that was + found + type: string + packageName: + description: |- + PackageName is the name of the vulnerable package + (empty when Class is "binary") + type: string + packagePath: + description: |- + PackagePath is the path where the package was found + (equal to Target when Class is "binary"). + trivy removes the "/" at the beginning of the path + so we have to restore it. + type: string + purl: + description: PURL (Package URL) identify the package uniquely + type: string + references: + description: References contains URLs for more information + items: + type: string + type: array + severity: + description: Severity rating (e.g., "HIGH", "MEDIUM") + type: string + suppressed: + description: |- + Suppressed identify when vulnerability has + been suppressed by VEX documents + type: boolean + title: + description: Title is the title of the vulnerability + type: string + vexStatus: + description: VEXStatus information + properties: + repository: + description: Repository providing the VEX document + type: string + statement: + description: Statement optionally explain statement + from the VEX document + type: string + status: + description: VEX status (e.g., "not_affected", "fixed", + "under_investigation") + type: string + required: + - repository + - statement + - status + type: object + required: + - cve + - diffID + - installedVersion + - purl + - severity + - suppressed + type: object + type: array + required: + - class + - target + - type + - vulnerabilities + type: object + type: array + summary: + description: Summary of vulnerabilities found + properties: + critical: + description: Critical vulnerabilities count + type: integer + high: + description: High vulnerabilities count + type: integer + low: + description: Low vulnerabilities count + type: integer + medium: + description: Medium vulnerabilities count + type: integer + suppressed: + description: Suppressed vulnerabilities count + type: integer + unknown: + description: Unknown vulnerabilities count + type: integer + required: + - critical + - high + - low + - medium + - suppressed + - unknown + type: object + required: + - results + - summary + type: object + required: + - nodeMetadata + - report + type: object + selectableFields: + - jsonPath: .nodeMetadata.name + - jsonPath: .nodeMetadata.platform + served: true + storage: true From bac8c5b6d7b2f9a6574d6020070589db760b5f53 Mon Sep 17 00:00:00 2001 From: Alessio Greggi Date: Tue, 19 May 2026 16:26:21 +0200 Subject: [PATCH 2/5] feat: add nodescan logic Signed-off-by: Alessio Greggi --- Makefile | 2 +- cmd/controller/main.go | 40 ++ cmd/worker/main.go | 48 +- internal/apiserver/storage.go | 34 +- internal/controller/indexer.go | 10 + internal/controller/nodescan_controller.go | 120 +++++ .../controller/nodescan_controller_test.go | 158 ++++++ internal/controller/nodescan_runner.go | 280 ++++++++++ internal/controller/nodescan_runner_test.go | 302 +++++++++++ internal/controller/nodescanjob_controller.go | 253 +++++++++ .../controller/nodescanjob_controller_test.go | 344 ++++++++++++ .../controller/registry_scan_runner_test.go | 8 +- internal/controller/scanjob_controller.go | 8 +- .../controller/scanjob_controller_test.go | 8 +- .../vulnerabilityreport_controller.go | 4 +- .../vulnerabilityreport_controller_test.go | 4 +- internal/handlers/create_catalog.go | 8 +- internal/handlers/create_catalog_test.go | 2 +- internal/handlers/generate_node_sbom.go | 307 +++++++++++ internal/handlers/generate_node_sbom_test.go | 351 +++++++++++++ internal/handlers/generate_sbom.go | 6 + internal/handlers/generate_sbom_test.go | 2 +- internal/handlers/image_scan_sbom.go | 156 ++++++ internal/handlers/messages.go | 27 +- internal/handlers/node_scan_sbom.go | 182 +++++++ internal/handlers/nodescanjob_failure.go | 70 +++ internal/handlers/scan_sbom.go | 316 ----------- internal/handlers/scan_sbom_helpers.go | 181 +++++++ internal/handlers/scan_sbom_test.go | 2 +- internal/handlers/scanjob_failure.go | 2 +- internal/handlers/scanjob_failure_test.go | 4 +- internal/messaging/subscriber.go | 8 +- internal/messaging/subscriber_test.go | 8 +- internal/skippatterns/doc.go | 8 + internal/skippatterns/skippatterns.go | 32 ++ internal/skippatterns/skippatterns_test.go | 89 ++++ internal/storage/matcher.go | 1 + internal/storage/migrations.go | 6 + internal/storage/node_sbom_store.go | 169 ++++++ internal/storage/node_sbom_strategy.go | 58 ++ .../storage/nodevulnerabilityreport_store.go | 163 ++++++ .../nodevulnerabilityreport_strategy.go | 58 ++ .../cluster_scoped_object_repository.go | 184 +++++++ internal/storage/store.go | 62 ++- internal/storage/transform.go | 26 + internal/storage/watcher.go | 7 +- test/e2e/main_test.go | 4 +- test/e2e/nodescan_test.go | 212 ++++++++ test/fixtures/node/go.mod | 494 ++++++++++++++++++ 49 files changed, 4438 insertions(+), 390 deletions(-) create mode 100644 internal/controller/nodescan_controller.go create mode 100644 internal/controller/nodescan_controller_test.go create mode 100644 internal/controller/nodescan_runner.go create mode 100644 internal/controller/nodescan_runner_test.go create mode 100644 internal/controller/nodescanjob_controller.go create mode 100644 internal/controller/nodescanjob_controller_test.go create mode 100644 internal/handlers/generate_node_sbom.go create mode 100644 internal/handlers/generate_node_sbom_test.go create mode 100644 internal/handlers/image_scan_sbom.go create mode 100644 internal/handlers/node_scan_sbom.go create mode 100644 internal/handlers/nodescanjob_failure.go delete mode 100644 internal/handlers/scan_sbom.go create mode 100644 internal/handlers/scan_sbom_helpers.go create mode 100644 internal/skippatterns/doc.go create mode 100644 internal/skippatterns/skippatterns.go create mode 100644 internal/skippatterns/skippatterns_test.go create mode 100644 internal/storage/node_sbom_store.go create mode 100644 internal/storage/node_sbom_strategy.go create mode 100644 internal/storage/nodevulnerabilityreport_store.go create mode 100644 internal/storage/nodevulnerabilityreport_strategy.go create mode 100644 internal/storage/repository/cluster_scoped_object_repository.go create mode 100644 test/e2e/nodescan_test.go create mode 100644 test/fixtures/node/go.mod diff --git a/Makefile b/Makefile index 940914eb9..4992bcb8f 100644 --- a/Makefile +++ b/Makefile @@ -77,7 +77,7 @@ storage-image: -t "$(REGISTRY)/$(REPO)/storage:$(TAG)" . @echo "Built $(REGISTRY)/$(REPO)/storage:$(TAG)" -WORKER_SRC_DIRS := cmd/worker api internal/messaging internal/handlers +WORKER_SRC_DIRS := cmd/worker api internal/messaging internal/handlers internal/skippatterns WORKER_GO_SRCS := $(shell find $(WORKER_SRC_DIRS) -type f -name '*.go') WORKER_SRCS := $(GO_MOD_SRCS) $(WORKER_GO_SRCS) .PHONY: worker diff --git a/cmd/controller/main.go b/cmd/controller/main.go index b8fdd221e..565804136 100644 --- a/cmd/controller/main.go +++ b/cmd/controller/main.go @@ -74,6 +74,9 @@ type Config struct { Init bool LogLevel string WorkloadScan bool + NodeScan bool + NodeScanImage string + NodeScanNamespace string } func parseFlags() Config { @@ -99,6 +102,7 @@ func parseFlags() Config { flag.BoolVar(&cfg.Init, "init", false, "Run initialization tasks and exit.") flag.StringVar(&cfg.LogLevel, "log-level", slog.LevelInfo.String(), "Log level") flag.BoolVar(&cfg.WorkloadScan, "workloadscan", true, "Enable workload scan controllers.") + flag.BoolVar(&cfg.NodeScan, "nodescan", true, "Enable node scan controllers.") flag.Parse() return cfg @@ -346,6 +350,42 @@ func main() { } } + if cfg.NodeScan { + if err = (&controller.NodeScanRunner{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create runner", "runner", "NodeScanRunner") + os.Exit(1) + } + + if err = (&controller.NodeScanReconciler{ + Client: mgr.GetClient(), + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "NodeScan") + os.Exit(1) + } + + if err = (&controller.NodeScanJobReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Publisher: publisher, + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "NodeScanJob") + os.Exit(1) + } + + if err = webhookv1alpha1.SetupNodeScanConfigurationWebhookWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create webhook", "webhook", "NodeScanConfiguration") + os.Exit(1) + } + + if err = webhookv1alpha1.SetupNodeScanJobWebhookWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create webhook", "webhook", "NodeScanJob") + os.Exit(1) + } + } + if err = webhookv1alpha1.SetupRegistryWebhookWithManager(mgr, cfg.ServiceAccountNamespace, cfg.ServiceAccountName); err != nil { setupLog.Error(err, "unable to create webhook", "webhook", "Registry") os.Exit(1) diff --git a/cmd/worker/main.go b/cmd/worker/main.go index 270d5aa81..d66fdcc66 100644 --- a/cmd/worker/main.go +++ b/cmd/worker/main.go @@ -25,6 +25,12 @@ import ( k8sscheme "k8s.io/client-go/kubernetes/scheme" ) +const ( + targetDir = "/host" + nodeMode = "node" + registryMode = "registry" +) + func main() { var natsURL string var natsCertFile string @@ -36,6 +42,8 @@ func main() { var installationNamespace string var init bool var logLevel string + var mode string + var nodeName string flag.StringVar(&natsURL, "nats-url", "localhost:4222", "The URL of the NATS server.") flag.StringVar(&natsCertFile, "nats-cert-file", "/nats/tls/tls.crt", "The path to the NATS client certificate.") @@ -47,6 +55,8 @@ func main() { flag.StringVar(&installationNamespace, "installation-namespace", "sbomscanner", "The namespace where sbomscanner is installed.") flag.BoolVar(&init, "init", false, "Run initialization tasks and exit.") flag.StringVar(&logLevel, "log-level", slog.LevelInfo.String(), "Log level.") + flag.StringVar(&mode, "mode", "registry", "Mode of operation ('registry' or 'node').") + flag.StringVar(&nodeName, "node-name", "", "The name of the node (required if mode is 'node').") flag.Parse() slogLevel, err := cmdutil.ParseLogLevel(logLevel) @@ -97,6 +107,11 @@ func main() { os.Exit(0) } + if mode == nodeMode && nodeName == "" { + logger.Error("Node name must be provided when mode is 'node'") + os.Exit(1) + } + nc, err := nats.Connect(natsURL, natsOpts..., ) @@ -133,19 +148,40 @@ func main() { return registry.NewClient(transport, logger) } - registry := messaging.HandlerRegistry{ - handlers.CreateCatalogSubject: handlers.NewCreateCatalogHandler(registryClientFactory, k8sClient, scheme, publisher, installationNamespace, logger), - handlers.GenerateSBOMSubject: handlers.NewGenerateSBOMHandler(k8sClient, scheme, runDir, trivyJavaDBRepository, publisher, installationNamespace, logger), - handlers.ScanSBOMSubject: handlers.NewScanSBOMHandler(k8sClient, scheme, runDir, trivyDBRepository, trivyJavaDBRepository, logger), + var scanMode messaging.HandlerScan + durableName := "worker" + switch mode { + case registryMode: + scanMode = messaging.HandlerScan{ + handlers.CreateCatalogSubject: handlers.NewCreateCatalogHandler(registryClientFactory, k8sClient, scheme, publisher, installationNamespace, logger), + handlers.GenerateSBOMSubject: handlers.NewGenerateSBOMHandler(k8sClient, scheme, runDir, trivyJavaDBRepository, publisher, installationNamespace, logger), + handlers.ScanSBOMSubject: handlers.NewScanSBOMHandler(k8sClient, scheme, runDir, trivyDBRepository, trivyJavaDBRepository, logger), + } + case nodeMode: + scanMode = messaging.HandlerScan{ + handlers.GenerateNodeSBOMSubject + "." + nodeName: handlers.NewGenerateNodeSBOMHandler(k8sClient, scheme, runDir, targetDir, trivyJavaDBRepository, publisher, installationNamespace, logger), + handlers.ScanNodeSBOMSubject + "." + nodeName: handlers.NewScanNodeSBOMHandler(k8sClient, scheme, runDir, trivyDBRepository, trivyJavaDBRepository, logger), + } + durableName = "worker-node-" + nodeName + default: + logger.Error("Invalid scanning mode", "mode", mode) + os.Exit(1) + } + + var failureHandler messaging.FailureHandler + switch mode { + case nodeMode: + failureHandler = handlers.NewNodeScanJobFailureHandler(k8sClient, logger) + default: + failureHandler = handlers.NewScanJobFailureHandler(k8sClient, logger) } - failureHandler := handlers.NewScanJobFailureHandler(k8sClient, logger) retryConfig := &messaging.RetryConfig{ BaseDelay: 5 * time.Second, Jitter: 0.2, MaxAttempts: 5, } - subscriber, err := messaging.NewNatsSubscriber(ctx, nc, "worker", registry, failureHandler, retryConfig, logger) + subscriber, err := messaging.NewNatsSubscriber(ctx, nc, durableName, scanMode, failureHandler, retryConfig, logger) if err != nil { logger.Error("Error creating NATS subscriber", "error", err) os.Exit(1) diff --git a/internal/apiserver/storage.go b/internal/apiserver/storage.go index 53bfdaccc..4708436e2 100644 --- a/internal/apiserver/storage.go +++ b/internal/apiserver/storage.go @@ -187,11 +187,35 @@ func NewStorageAPIServer(db *pgxpool.Pool, nc *nats.Conn, logger *slog.Logger, c return nil, fmt.Errorf("error creating WorkloadScanReport store: %w", err) } + nodeSBOMStore, nodeSBOMWatchers, err := storage.NewNodeSBOMStore( + Scheme, + serverConfig.RESTOptionsGetter, + db, + nc, + logger, + ) + if err != nil { + return nil, fmt.Errorf("error creating NodeSBOM store: %w", err) + } + + nodeVulnerabilityReportStore, nodeVulnerabilityReportWatchers, err := storage.NewNodeVulnerabilityReportStore( + Scheme, + serverConfig.RESTOptionsGetter, + db, + nc, + logger, + ) + if err != nil { + return nil, fmt.Errorf("error creating NodeVulnerabilityReport store: %w", err) + } + v1alpha1storage := map[string]rest.Storage{ - "images": imageStore, - "sboms": sbomStore, - "vulnerabilityreports": vulnerabilityReportStore, - "workloadscanreports": workloadScanReportStore, + "images": imageStore, + "sboms": sbomStore, + "vulnerabilityreports": vulnerabilityReportStore, + "workloadscanreports": workloadScanReportStore, + "nodesboms": nodeSBOMStore, + "nodevulnerabilityreports": nodeVulnerabilityReportStore, } apiGroupInfo.VersionedResourcesStorageMap["v1alpha1"] = v1alpha1storage @@ -201,7 +225,7 @@ func NewStorageAPIServer(db *pgxpool.Pool, nc *nats.Conn, logger *slog.Logger, c return &StorageAPIServer{ db: db, - watchers: slices.Concat(imageWatchers, sbomWatchers, vulnerabilityReportWatchers, workloadScanReportWatchers), + watchers: slices.Concat(imageWatchers, sbomWatchers, vulnerabilityReportWatchers, workloadScanReportWatchers, nodeSBOMWatchers, nodeVulnerabilityReportWatchers), logger: logger, server: genericServer, dynamicCertKeyPairContent: dynamicCertKeyPairContent, diff --git a/internal/controller/indexer.go b/internal/controller/indexer.go index 09c60ad7a..219df5480 100644 --- a/internal/controller/indexer.go +++ b/internal/controller/indexer.go @@ -33,6 +33,16 @@ func SetupIndexer(ctx context.Context, mgr ctrl.Manager) error { return fmt.Errorf("unable to create field indexer: %w", err) } + if err := mgr.GetFieldIndexer().IndexField(ctx, &v1alpha1.NodeScanJob{}, v1alpha1.IndexNodeScanJobSpecNodeName, func(rawObj client.Object) []string { + nodeScanJob, ok := rawObj.(*v1alpha1.NodeScanJob) + if !ok { + panic(fmt.Sprintf("Expected NodeScanJob, got %T", rawObj)) + } + return []string{nodeScanJob.Spec.NodeName} + }); err != nil { + return fmt.Errorf("failed to setup field indexer for spec.nodeName: %w", err) + } + if err := mgr.GetFieldIndexer().IndexField(ctx, &storagev1alpha1.Image{}, storagev1alpha1.IndexImageMetadataComposite, indexImageByMetadata); err != nil { return fmt.Errorf("failed to setup field indexer for %s: %w", storagev1alpha1.IndexImageMetadataComposite, err) } diff --git a/internal/controller/nodescan_controller.go b/internal/controller/nodescan_controller.go new file mode 100644 index 000000000..85dac5e82 --- /dev/null +++ b/internal/controller/nodescan_controller.go @@ -0,0 +1,120 @@ +package controller + +import ( + "context" + "fmt" + + "github.com/kubewarden/sbomscanner/api" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/predicate" + + storagev1alpha1 "github.com/kubewarden/sbomscanner/api/storage/v1alpha1" + v1alpha1 "github.com/kubewarden/sbomscanner/api/v1alpha1" +) + +// NodeScanReconciler watches Nodes and cleans up NodeScanJobs and NodeSBOMs +// when a Node is deleted. +type NodeScanReconciler struct { + client.Client +} + +// +kubebuilder:rbac:groups=sbomscanner.kubewarden.io,resources=nodescanconfigurations,verbs=get;list;watch;update +// +kubebuilder:rbac:groups=sbomscanner.kubewarden.io,resources=nodescanconfigurations/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=storage.sbomscanner.kubewarden.io,resources=nodesboms,verbs=list;watch;delete +// +kubebuilder:rbac:groups=sbomscanner.kubewarden.io,resources=nodescanjobs,verbs=list;delete +// +kubebuilder:rbac:groups=storage.sbomscanner.kubewarden.io,resources=nodesboms,verbs=list;delete +// +kubebuilder:rbac:groups=core,resources=nodes,verbs=get;list;watch + +func (r *NodeScanReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + logger := log.FromContext(ctx) + + var node corev1.Node + if err := r.Get(ctx, req.NamespacedName, &node); err != nil { + if apierrors.IsNotFound(err) { + logger.Info("Node deleted, cleaning up related resources", "node", req.Name) + if err := r.cleanupNodeResources(ctx, req.Name); err != nil { + return ctrl.Result{}, fmt.Errorf("failed to cleanup resources for deleted node %s: %w", req.Name, err) + } + return ctrl.Result{}, nil + } + return ctrl.Result{}, fmt.Errorf("failed to get Node: %w", err) + } + + return ctrl.Result{}, nil +} + +func (r *NodeScanReconciler) cleanupNodeResources(ctx context.Context, nodeName string) error { + logger := log.FromContext(ctx) + + var nodeScanJobs v1alpha1.NodeScanJobList + if err := r.List(ctx, &nodeScanJobs, + client.MatchingLabels{ + api.LabelManagedByKey: api.LabelManagedByValue, + api.LabelNodeScanKey: api.LabelNodeScanValue, + }, + ); err != nil { + return fmt.Errorf("failed to list managed NodeScanJobs: %w", err) + } + + for i := range nodeScanJobs.Items { + job := &nodeScanJobs.Items[i] + if job.Spec.NodeName == nodeName { + logger.Info("Deleting NodeScanJob for deleted node", "nodeScanJob", job.Name, "nodeName", nodeName) + if err := r.Delete(ctx, job); err != nil && !apierrors.IsNotFound(err) { + return fmt.Errorf("failed to delete NodeScanJob %s: %w", job.Name, err) + } + } + } + + var nodesboms storagev1alpha1.NodeSBOMList + if err := r.List(ctx, &nodesboms, + client.MatchingLabels{ + api.LabelManagedByKey: api.LabelManagedByValue, + }, + ); err != nil { + return fmt.Errorf("failed to list managed NodeSBOMs: %w", err) + } + + for i := range nodesboms.Items { + nodesbom := &nodesboms.Items[i] + if nodesbom.NodeMetadata.Name == nodeName { + logger.Info("Deleting NodeSBOM for deleted node", "nodesbom", nodesbom.Name, "nodeName", nodeName) + if err := r.Delete(ctx, nodesbom); err != nil && !apierrors.IsNotFound(err) { + return fmt.Errorf("failed to delete NodeSBOM %s: %w", nodesbom.Name, err) + } + } + } + + return nil +} + +// SetupWithManager sets up the controller with the Manager. +func (r *NodeScanReconciler) SetupWithManager(manager ctrl.Manager) error { + err := ctrl.NewControllerManagedBy(manager). + Named("nodescan-controller"). + Watches(&corev1.Node{}, + handler.EnqueueRequestsFromMapFunc(func(_ context.Context, obj client.Object) []ctrl.Request { + return []ctrl.Request{{NamespacedName: types.NamespacedName{Name: obj.GetName()}}} + }), + builder.WithPredicates(predicate.Funcs{ + // Only trigger reconciliation on Node deletions, ignore creates and updates. + DeleteFunc: func(e event.DeleteEvent) bool { return true }, + CreateFunc: func(e event.CreateEvent) bool { return false }, + UpdateFunc: func(e event.UpdateEvent) bool { return false }, + GenericFunc: func(e event.GenericEvent) bool { return false }, + }), + ). + Complete(r) + if err != nil { + return fmt.Errorf("failed to create nodescan controller: %w", err) + } + return nil +} diff --git a/internal/controller/nodescan_controller_test.go b/internal/controller/nodescan_controller_test.go new file mode 100644 index 000000000..115772afd --- /dev/null +++ b/internal/controller/nodescan_controller_test.go @@ -0,0 +1,158 @@ +package controller + +import ( + "context" + "fmt" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/google/uuid" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/kubewarden/sbomscanner/api" + storagev1alpha1 "github.com/kubewarden/sbomscanner/api/storage/v1alpha1" + "github.com/kubewarden/sbomscanner/api/v1alpha1" +) + +var _ = Describe("NodeScan Controller", func() { + When("a Node still exists", func() { + var reconciler NodeScanReconciler + var node corev1.Node + + BeforeEach(func(ctx context.Context) { + reconciler = NodeScanReconciler{ + Client: k8sClient, + } + + By("Creating a Node") + node = corev1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("node-%s", uuid.New().String()), + }, + } + Expect(k8sClient.Create(ctx, &node)).To(Succeed()) + }) + + It("should be a no-op", func(ctx context.Context) { + _, err := reconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{Name: node.Name}, + }) + Expect(err).NotTo(HaveOccurred()) + }) + }) + + When("a Node is deleted", func() { + var reconciler NodeScanReconciler + var nodeName string + + BeforeEach(func(ctx context.Context) { + reconciler = NodeScanReconciler{ + Client: k8sClient, + } + + nodeName = fmt.Sprintf("node-%s", uuid.New().String()) + + By("Creating and then deleting a Node") + node := corev1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: nodeName, + }, + } + Expect(k8sClient.Create(ctx, &node)).To(Succeed()) + Expect(k8sClient.Delete(ctx, &node)).To(Succeed()) + }) + + It("should cleanup NodeScanJobs for the deleted node", func(ctx context.Context) { + By("Creating a NodeScanJob tied to the deleted node") + nodeScanJob := v1alpha1.NodeScanJob{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("nodescanjob-%s", nodeName), + Labels: map[string]string{ + api.LabelManagedByKey: api.LabelManagedByValue, + api.LabelNodeScanKey: api.LabelNodeScanValue, + }, + }, + Spec: v1alpha1.NodeScanJobSpec{ + NodeName: nodeName, + }, + } + Expect(k8sClient.Create(ctx, &nodeScanJob)).To(Succeed()) + + By("Reconciling the deleted node") + _, err := reconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{Name: nodeName}, + }) + Expect(err).NotTo(HaveOccurred()) + + By("Verifying the NodeScanJob was deleted") + deletedJob := &v1alpha1.NodeScanJob{} + err = k8sClient.Get(ctx, types.NamespacedName{Name: nodeScanJob.Name}, deletedJob) + Expect(client.IgnoreNotFound(err)).NotTo(HaveOccurred()) + Expect(deletedJob.Name).To(BeEmpty()) + }) + + It("should cleanup NodeSBOMs for the deleted node", func(ctx context.Context) { + By("Creating a NodeSBOM tied to the deleted node") + nodesbom := storagev1alpha1.NodeSBOM{ + ObjectMeta: metav1.ObjectMeta{ + Name: nodeName, + Labels: map[string]string{ + api.LabelManagedByKey: api.LabelManagedByValue, + }, + }, + NodeMetadata: storagev1alpha1.NodeMetadata{ + Name: nodeName, + Platform: "linux/amd64", + }, + SPDX: runtime.RawExtension{Raw: []byte("{}")}, + } + Expect(k8sClient.Create(ctx, &nodesbom)).To(Succeed()) + + By("Reconciling the deleted node") + _, err := reconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{Name: nodeName}, + }) + Expect(err).NotTo(HaveOccurred()) + + By("Verifying the NodeSBOM was deleted") + deletedSBOM := &storagev1alpha1.NodeSBOM{} + err = k8sClient.Get(ctx, types.NamespacedName{Name: nodesbom.Name}, deletedSBOM) + Expect(client.IgnoreNotFound(err)).NotTo(HaveOccurred()) + Expect(deletedSBOM.Name).To(BeEmpty()) + }) + + It("should not affect resources for other nodes", func(ctx context.Context) { + By("Creating a NodeScanJob for a different node") + otherNodeName := fmt.Sprintf("other-node-%s", uuid.New().String()) + otherJob := v1alpha1.NodeScanJob{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("nodescanjob-%s", otherNodeName), + Labels: map[string]string{ + api.LabelManagedByKey: api.LabelManagedByValue, + api.LabelNodeScanKey: api.LabelNodeScanValue, + }, + }, + Spec: v1alpha1.NodeScanJobSpec{ + NodeName: otherNodeName, + }, + } + Expect(k8sClient.Create(ctx, &otherJob)).To(Succeed()) + + By("Reconciling the deleted node") + _, err := reconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{Name: nodeName}, + }) + Expect(err).NotTo(HaveOccurred()) + + By("Verifying the other node's NodeScanJob still exists") + remainingJob := &v1alpha1.NodeScanJob{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: otherJob.Name}, remainingJob)).To(Succeed()) + }) + }) +}) diff --git a/internal/controller/nodescan_runner.go b/internal/controller/nodescan_runner.go new file mode 100644 index 000000000..0d9f84971 --- /dev/null +++ b/internal/controller/nodescan_runner.go @@ -0,0 +1,280 @@ +package controller + +import ( + "context" + "fmt" + "sort" + "time" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/util/retry" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/log" + + "github.com/kubewarden/sbomscanner/api" + "github.com/kubewarden/sbomscanner/api/v1alpha1" + "github.com/kubewarden/sbomscanner/internal/filters" +) + +const nodeScanRunnerPeriod = 10 * time.Second + +// NodeScanRunner handles periodic scanning of nodes based on the NodeScanConfiguration. +type NodeScanRunner struct { + client.Client + Scheme *runtime.Scheme +} + +func (r *NodeScanRunner) Start(ctx context.Context) error { + log := log.FromContext(ctx) + log.Info("Starting node scan runner") + + ticker := time.NewTicker(nodeScanRunnerPeriod) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + log.Info("Stopping node scan runner") + + return nil + case <-ticker.C: + if err := r.scanNodes(ctx); err != nil { + log.Error(err, "Failed to scan nodes") + } + } + } +} + +func (r *NodeScanRunner) scanNodes(ctx context.Context) error { + log := log.FromContext(ctx) + + log.Info("Starting node scan cycle") + var config v1alpha1.NodeScanConfiguration + if err := r.Get(ctx, types.NamespacedName{Name: v1alpha1.NodeScanConfigurationName}, &config); err != nil { + if apierrors.IsNotFound(err) { + log.V(1).Info("NodeScanConfiguration not found, skipping") + return nil + } + + return fmt.Errorf("failed to get NodeScanConfiguration: %w", err) + } + + forceRescan := hasForceNodeScanAnnotation(&config) + + nodes, err := r.getMatchingNodes(ctx, &config) + if err != nil { + return fmt.Errorf("failed to list matching nodes: %w", err) + } + + log.V(1).Info("Checking nodes for scanning", "count", len(nodes)) + + for i := range nodes { + if err := r.checkNodeForScan(ctx, &config, &nodes[i], forceRescan); err != nil { + log.Error(err, "Failed to check node for scan", "node", nodes[i].Name) + continue + } + } + + log.V(1).Info("Finished checking nodes for scanning", "force scan", forceRescan) + if forceRescan { + log.Info("Removing force rescan annotation from NodeScanConfiguration") + if err := r.removeForceNodeScanAnnotation(ctx, &config); err != nil { + log.Error(err, "Failed to remove force-node-scan annotation: %w", "error", err) + return fmt.Errorf("failed to remove force-node-scan annotation: %w", err) + } + } + + return nil +} + +func (r *NodeScanRunner) getMatchingNodes(ctx context.Context, config *v1alpha1.NodeScanConfiguration) ([]corev1.Node, error) { + var nodeList corev1.NodeList + + listOpts := []client.ListOption{} + + if config.Spec.NodeSelector != nil { + selector, err := metav1.LabelSelectorAsSelector(config.Spec.NodeSelector) + if err != nil { + return nil, fmt.Errorf("failed to parse node selector: %w", err) + } + + listOpts = append(listOpts, client.MatchingLabelsSelector{Selector: selector}) + } + + if err := r.List(ctx, &nodeList, listOpts...); err != nil { + return nil, fmt.Errorf("failed to list nodes: %w", err) + } + + return nodeList.Items, nil +} + +func (r *NodeScanRunner) checkNodeForScan(ctx context.Context, config *v1alpha1.NodeScanConfiguration, node *corev1.Node, forceRescan bool) error { + log := log.FromContext(ctx) + + if !filters.IsPlatformAllowed( + node.Status.NodeInfo.OperatingSystem, + node.Status.NodeInfo.Architecture, + "", + config.Spec.Platforms, + ) { + log.V(1).Info("Skipping node with disallowed platform", + "node", node.Name, + "platform", fmt.Sprintf("%s/%s", node.Status.NodeInfo.OperatingSystem, node.Status.NodeInfo.Architecture), + ) + return nil + } + + lastScanJob, err := r.getLastNodeScanJob(ctx, node.Name) + if err != nil && !apierrors.IsNotFound(err) { + return fmt.Errorf("failed to get last node scan job for node %s: %w", node.Name, err) + } + + if lastScanJob != nil && !lastScanJob.IsComplete() && !lastScanJob.IsFailed() { + log.V(1).Info("Node has a running NodeScanJob, skipping", "node", node.Name, "nodeScanJob", lastScanJob.Name) + + return nil + } + + if !r.shouldCreateNodeScanJob(ctx, config, node.Name, lastScanJob, forceRescan) { + return nil + } + + if err := r.createNodeScanJob(ctx, config, node.Name); err != nil { + return fmt.Errorf("failed to create node scan job for node %s: %w", node.Name, err) + } + + log.Info("Created node scan job for node", "node", node.Name) + + return nil +} + +func (r *NodeScanRunner) shouldCreateNodeScanJob(ctx context.Context, config *v1alpha1.NodeScanConfiguration, nodeName string, lastScanJob *v1alpha1.NodeScanJob, forceRescan bool) bool { + log := log.FromContext(ctx) + + if forceRescan { + log.Info("Force rescan requested for node", "node", nodeName) + return true + } + + if config.Spec.ScanInterval == nil || config.Spec.ScanInterval.Duration == 0 { + if lastScanJob != nil { + log.V(1).Info("Skipping node with disabled scan interval", "node", nodeName) + } + + return false + } + + if lastScanJob == nil { + return true + } + + if lastScanJob.Status.CompletionTime != nil { + timeSinceLastScan := time.Since(lastScanJob.Status.CompletionTime.Time) + if timeSinceLastScan < config.Spec.ScanInterval.Duration { + log.V(1).Info("Node doesn't need scanning yet", "node", nodeName, "timeSinceLastScan", timeSinceLastScan) + + return false + } + } + + return true +} + +func hasForceNodeScanAnnotation(config *v1alpha1.NodeScanConfiguration) bool { + if config.Annotations == nil { + return false + } + + return config.Annotations[v1alpha1.AnnotationForceNodeScanKey] == "true" +} + +func (r *NodeScanRunner) removeForceNodeScanAnnotation(ctx context.Context, config *v1alpha1.NodeScanConfiguration) error { + return retry.RetryOnConflict(retry.DefaultRetry, func() error { + var current v1alpha1.NodeScanConfiguration + if err := r.Get(ctx, types.NamespacedName{Name: config.Name}, ¤t); err != nil { + return fmt.Errorf("failed to get current NodeScanConfiguration: %w", err) + } + + if _, ok := current.Annotations[v1alpha1.AnnotationForceNodeScanKey]; !ok { + return nil + } + + delete(current.Annotations, v1alpha1.AnnotationForceNodeScanKey) + + if err := r.Update(ctx, ¤t); err != nil { + return fmt.Errorf("failed to update NodeScanConfiguration: %w", err) + } + + return nil + }) +} + +func (r *NodeScanRunner) getLastNodeScanJob(ctx context.Context, nodeName string) (*v1alpha1.NodeScanJob, error) { + var nodeScanJobs v1alpha1.NodeScanJobList + + listOpts := []client.ListOption{ + client.MatchingFields{v1alpha1.IndexNodeScanJobSpecNodeName: nodeName}, + } + if err := r.List(ctx, &nodeScanJobs, listOpts...); err != nil { + return nil, fmt.Errorf("failed to list node scan jobs: %w", err) + } + + if len(nodeScanJobs.Items) == 0 { + return nil, apierrors.NewNotFound( + v1alpha1.GroupVersion.WithResource("nodescanjobs").GroupResource(), + fmt.Sprintf("for node %s", nodeName), + ) + } + + sort.Slice(nodeScanJobs.Items, func(i, j int) bool { + return nodeScanJobs.Items[i].CreationTimestamp.After(nodeScanJobs.Items[j].CreationTimestamp.Time) + }) + + return &nodeScanJobs.Items[0], nil +} + +func (r *NodeScanRunner) createNodeScanJob(ctx context.Context, config *v1alpha1.NodeScanConfiguration, nodeName string) error { + nodeScanJob := &v1alpha1.NodeScanJob{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: fmt.Sprintf("node-%s-", nodeName), + Annotations: map[string]string{ + v1alpha1.AnnotationNodeScanJobTriggerKey: "runner", + }, + Labels: map[string]string{ + api.LabelManagedByKey: api.LabelManagedByValue, + api.LabelNodeScanKey: api.LabelNodeScanValue, + }, + }, + Spec: v1alpha1.NodeScanJobSpec{ + NodeName: nodeName, + }, + } + + if err := controllerutil.SetControllerReference(config, nodeScanJob, r.Scheme); err != nil { + return fmt.Errorf("failed to set owner reference on NodeScanJob: %w", err) + } + + if err := r.Create(ctx, nodeScanJob); err != nil { + return fmt.Errorf("failed to create NodeScanJob: %w", err) + } + + return nil +} + +func (r *NodeScanRunner) NeedLeaderElection() bool { + return true +} + +func (r *NodeScanRunner) SetupWithManager(mgr ctrl.Manager) error { + if err := mgr.Add(r); err != nil { + return fmt.Errorf("failed to create NodeScanRunner: %w", err) + } + + return nil +} diff --git a/internal/controller/nodescan_runner_test.go b/internal/controller/nodescan_runner_test.go new file mode 100644 index 000000000..33a762905 --- /dev/null +++ b/internal/controller/nodescan_runner_test.go @@ -0,0 +1,302 @@ +package controller + +import ( + "context" + "fmt" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/google/uuid" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/kubewarden/sbomscanner/api/v1alpha1" +) + +var _ = Describe("NodeScanRunner", func() { + Describe("scanNodes", func() { + var ( + runner *NodeScanRunner + config *v1alpha1.NodeScanConfiguration + node *corev1.Node + ) + + BeforeEach(func() { + runner = &NodeScanRunner{ + Client: k8sClient, + } + }) + + When("A node needs scanning", func() { + BeforeEach(func(ctx context.Context) { + By("Creating a Node") + node = &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("node-%s", uuid.New().String()), + }, + } + Expect(k8sClient.Create(ctx, node)).To(Succeed()) + + By("Creating a NodeScanConfiguration with a scan interval of 1 hour") + config = &v1alpha1.NodeScanConfiguration{ + ObjectMeta: metav1.ObjectMeta{ + Name: v1alpha1.NodeScanConfigurationName, + }, + Spec: v1alpha1.NodeScanConfigurationSpec{ + ScanInterval: &metav1.Duration{Duration: 1 * time.Hour}, + }, + } + Expect(k8sClient.Create(ctx, config)).To(Succeed()) + }) + + AfterEach(func(ctx context.Context) { + Expect(k8sClient.Delete(ctx, config)).To(Succeed()) + }) + + It("Should create a NodeScanJob for the node", func(ctx context.Context) { + By("Running the node scanner") + err := runner.scanNodes(ctx) + Expect(err).To(Succeed()) + + By("Verifying a NodeScanJob was created for the node") + nodeScanJobs := &v1alpha1.NodeScanJobList{} + Expect(k8sClient.List(ctx, nodeScanJobs, + client.MatchingFields{v1alpha1.IndexNodeScanJobSpecNodeName: node.Name}, + )).To(Succeed()) + Expect(nodeScanJobs.Items).To(HaveLen(1)) + + By("Checking the NodeScanJob has correct node name and trigger annotation") + Expect(nodeScanJobs.Items[0].Spec.NodeName).To(Equal(node.Name)) + Expect(nodeScanJobs.Items[0].Annotations).To(HaveKeyWithValue(v1alpha1.AnnotationNodeScanJobTriggerKey, "runner")) + }) + }) + + When("Node platform is not allowed by the configuration", func() { + BeforeEach(func(ctx context.Context) { + By("Creating a Node with linux/arm64 platform") + node = &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("node-%s", uuid.New().String()), + }, + } + Expect(k8sClient.Create(ctx, node)).To(Succeed()) + node.Status = corev1.NodeStatus{ + NodeInfo: corev1.NodeSystemInfo{ + OperatingSystem: "linux", + Architecture: "arm64", + }, + } + Expect(k8sClient.Status().Update(ctx, node)).To(Succeed()) + + By("Creating a NodeScanConfiguration that only allows linux/amd64") + config = &v1alpha1.NodeScanConfiguration{ + ObjectMeta: metav1.ObjectMeta{ + Name: v1alpha1.NodeScanConfigurationName, + }, + Spec: v1alpha1.NodeScanConfigurationSpec{ + ScanInterval: &metav1.Duration{Duration: 1 * time.Hour}, + Platforms: []v1alpha1.Platform{ + {OS: "linux", Architecture: "amd64"}, + }, + }, + } + Expect(k8sClient.Create(ctx, config)).To(Succeed()) + }) + + AfterEach(func(ctx context.Context) { + Expect(k8sClient.Delete(ctx, config)).To(Succeed()) + }) + + It("Should not create a NodeScanJob for the node", func(ctx context.Context) { + By("Running the node scanner") + err := runner.scanNodes(ctx) + Expect(err).To(Succeed()) + + By("Verifying no NodeScanJobs were created for the node") + nodeScanJobs := &v1alpha1.NodeScanJobList{} + Expect(k8sClient.List(ctx, nodeScanJobs, + client.MatchingFields{v1alpha1.IndexNodeScanJobSpecNodeName: node.Name}, + )).To(Succeed()) + Expect(nodeScanJobs.Items).To(BeEmpty()) + }) + }) + + When("NodeScanConfiguration has the force-node-scan annotation", func() { + BeforeEach(func(ctx context.Context) { + By("Creating a Node") + node = &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("node-%s", uuid.New().String()), + }, + } + Expect(k8sClient.Create(ctx, node)).To(Succeed()) + + By("Creating a NodeScanConfiguration with the force-node-scan annotation") + config = &v1alpha1.NodeScanConfiguration{ + ObjectMeta: metav1.ObjectMeta{ + Name: v1alpha1.NodeScanConfigurationName, + Annotations: map[string]string{ + v1alpha1.AnnotationForceNodeScanKey: "true", + }, + }, + Spec: v1alpha1.NodeScanConfigurationSpec{ + ScanInterval: &metav1.Duration{Duration: 1 * time.Hour}, + }, + } + Expect(k8sClient.Create(ctx, config)).To(Succeed()) + }) + + AfterEach(func(ctx context.Context) { + Expect(k8sClient.Delete(ctx, config)).To(Succeed()) + }) + + It("Should create a NodeScanJob even if the timer has not expired and remove the annotation", func(ctx context.Context) { + By("Creating a recently completed NodeScanJob within the scan interval") + recentJob := &v1alpha1.NodeScanJob{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("recent-node-job-%s", uuid.New().String()), + }, + Spec: v1alpha1.NodeScanJobSpec{ + NodeName: node.Name, + }, + } + Expect(k8sClient.Create(ctx, recentJob)).To(Succeed()) + recentJob.MarkComplete(v1alpha1.ReasonScanJobComplete, "Done") + recentJob.Status.CompletionTime = &metav1.Time{Time: time.Now()} + Expect(k8sClient.Status().Update(ctx, recentJob)).To(Succeed()) + + By("Running the node scanner") + err := runner.scanNodes(ctx) + Expect(err).To(Succeed()) + + By("Verifying a new NodeScanJob was created despite the timer not being expired") + nodeScanJobs := &v1alpha1.NodeScanJobList{} + Expect(k8sClient.List(ctx, nodeScanJobs, + client.MatchingFields{v1alpha1.IndexNodeScanJobSpecNodeName: node.Name}, + )).To(Succeed()) + Expect(nodeScanJobs.Items).To(HaveLen(2)) + + By("Verifying the force-node-scan annotation was removed") + var updatedConfig v1alpha1.NodeScanConfiguration + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: v1alpha1.NodeScanConfigurationName}, &updatedConfig)).To(Succeed()) + Expect(updatedConfig.Annotations).NotTo(HaveKey(v1alpha1.AnnotationForceNodeScanKey)) + }) + + It("Should not create a NodeScanJob when one is already running", func(ctx context.Context) { + By("Creating an existing running NodeScanJob for the node") + existingJob := &v1alpha1.NodeScanJob{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("running-node-job-%s", uuid.New().String()), + }, + Spec: v1alpha1.NodeScanJobSpec{ + NodeName: node.Name, + }, + } + Expect(k8sClient.Create(ctx, existingJob)).To(Succeed()) + + By("Running the node scanner") + err := runner.scanNodes(ctx) + Expect(err).To(Succeed()) + + By("Verifying no additional NodeScanJob was created") + nodeScanJobs := &v1alpha1.NodeScanJobList{} + Expect(k8sClient.List(ctx, nodeScanJobs, + client.MatchingFields{v1alpha1.IndexNodeScanJobSpecNodeName: node.Name}, + )).To(Succeed()) + Expect(nodeScanJobs.Items).To(HaveLen(1)) + }) + }) + + When("NodeScanConfiguration has the force-node-scan annotation but no scan interval", func() { + BeforeEach(func(ctx context.Context) { + By("Creating a Node") + node = &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("node-%s", uuid.New().String()), + }, + } + Expect(k8sClient.Create(ctx, node)).To(Succeed()) + + By("Creating a NodeScanConfiguration with force annotation and disabled interval") + config = &v1alpha1.NodeScanConfiguration{ + ObjectMeta: metav1.ObjectMeta{ + Name: v1alpha1.NodeScanConfigurationName, + Annotations: map[string]string{ + v1alpha1.AnnotationForceNodeScanKey: "true", + }, + }, + Spec: v1alpha1.NodeScanConfigurationSpec{ + ScanInterval: &metav1.Duration{Duration: 0}, + }, + } + Expect(k8sClient.Create(ctx, config)).To(Succeed()) + }) + + AfterEach(func(ctx context.Context) { + Expect(k8sClient.Delete(ctx, config)).To(Succeed()) + }) + + It("Should create a NodeScanJob even with disabled interval", func(ctx context.Context) { + By("Running the node scanner") + err := runner.scanNodes(ctx) + Expect(err).To(Succeed()) + + By("Verifying a NodeScanJob was created due to force annotation") + nodeScanJobs := &v1alpha1.NodeScanJobList{} + Expect(k8sClient.List(ctx, nodeScanJobs, + client.MatchingFields{v1alpha1.IndexNodeScanJobSpecNodeName: node.Name}, + )).To(Succeed()) + Expect(nodeScanJobs.Items).To(HaveLen(1)) + + By("Verifying the force-node-scan annotation was removed") + var updatedConfig v1alpha1.NodeScanConfiguration + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: v1alpha1.NodeScanConfigurationName}, &updatedConfig)).To(Succeed()) + Expect(updatedConfig.Annotations).NotTo(HaveKey(v1alpha1.AnnotationForceNodeScanKey)) + }) + }) + + When("NodeScanConfiguration has no scan interval", func() { + BeforeEach(func(ctx context.Context) { + By("Creating a Node") + node = &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("node-%s", uuid.New().String()), + }, + } + Expect(k8sClient.Create(ctx, node)).To(Succeed()) + + By("Creating a NodeScanConfiguration with scan interval disabled (0 duration)") + config = &v1alpha1.NodeScanConfiguration{ + ObjectMeta: metav1.ObjectMeta{ + Name: v1alpha1.NodeScanConfigurationName, + }, + Spec: v1alpha1.NodeScanConfigurationSpec{ + ScanInterval: &metav1.Duration{Duration: 0}, + }, + } + Expect(k8sClient.Create(ctx, config)).To(Succeed()) + }) + + AfterEach(func(ctx context.Context) { + Expect(k8sClient.Delete(ctx, config)).To(Succeed()) + }) + + It("Should not create any NodeScanJob", func(ctx context.Context) { + By("Running the node scanner") + err := runner.scanNodes(ctx) + Expect(err).To(Succeed()) + + By("Verifying no NodeScanJobs were created for the node") + nodeScanJobs := &v1alpha1.NodeScanJobList{} + Expect(k8sClient.List(ctx, nodeScanJobs, + client.MatchingFields{v1alpha1.IndexNodeScanJobSpecNodeName: node.Name}, + )).To(Succeed()) + Expect(nodeScanJobs.Items).To(BeEmpty()) + }) + }) + }) +}) diff --git a/internal/controller/nodescanjob_controller.go b/internal/controller/nodescanjob_controller.go new file mode 100644 index 000000000..41bef9604 --- /dev/null +++ b/internal/controller/nodescanjob_controller.go @@ -0,0 +1,253 @@ +package controller + +import ( + "context" + "encoding/json" + "fmt" + "sort" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/handler" + logf "sigs.k8s.io/controller-runtime/pkg/log" + + "github.com/kubewarden/sbomscanner/api/v1alpha1" + "github.com/kubewarden/sbomscanner/internal/filters" + "github.com/kubewarden/sbomscanner/internal/handlers" + "github.com/kubewarden/sbomscanner/internal/messaging" +) + +// NodeScanJobReconciler reconciles a NodeScanJob object +type NodeScanJobReconciler struct { + client.Client + + Scheme *runtime.Scheme + Publisher messaging.Publisher +} + +// +kubebuilder:rbac:groups=sbomscanner.kubewarden.io,resources=nodescanjobs,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=sbomscanner.kubewarden.io,resources=nodescanjobs/status,verbs=get;update;patch + +// Reconcile reconciles a NodeScanJob object. +func (r *NodeScanJobReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + log := logf.FromContext(ctx) + log.Info("Reconciling NodeScanJob") + + nodeScanJob := &v1alpha1.NodeScanJob{} + if err := r.Get(ctx, req.NamespacedName, nodeScanJob); err != nil { + if errors.IsNotFound(err) { + log.V(1).Info("NodeScanJob not found, skipping reconciliation", "nodeScanJob", req.NamespacedName) + return ctrl.Result{}, nil + } + return ctrl.Result{}, fmt.Errorf("unable to get NodeScanJob: %w", err) + } + + if !nodeScanJob.DeletionTimestamp.IsZero() { + log.V(1).Info("NodeScanJob is being deleted, skipping reconciliation", "nodeScanJob", req.NamespacedName) + return ctrl.Result{}, nil + } + + var node corev1.Node + if err := r.Get(ctx, types.NamespacedName{Name: nodeScanJob.Spec.NodeName}, &node); err != nil { + if errors.IsNotFound(err) { + log.Info("Node no longer exists, deleting NodeScanJob", "nodeScanJob", req.NamespacedName, "nodeName", nodeScanJob.Spec.NodeName) + if delErr := r.Delete(ctx, nodeScanJob); delErr != nil && !errors.IsNotFound(delErr) { + return ctrl.Result{}, fmt.Errorf("failed to delete NodeScanJob for missing node: %w", delErr) + } + return ctrl.Result{}, nil + } + return ctrl.Result{}, fmt.Errorf("failed to check if node %s exists: %w", nodeScanJob.Spec.NodeName, err) + } + + if !nodeScanJob.IsPending() { + log.V(1).Info("NodeScanJob is not in pending state, skipping reconciliation", "nodeScanJob", req.NamespacedName) + return ctrl.Result{}, nil + } + + nodeScanJob.InitializeConditions() + + if failed, err := r.validateNodeAgainstConfig(ctx, nodeScanJob, &node); err != nil { + return ctrl.Result{}, err + } else if failed { + if err := r.Status().Update(ctx, nodeScanJob); err != nil { + return ctrl.Result{}, fmt.Errorf("failed to update NodeScanJob status: %w", err) + } + return ctrl.Result{}, nil + } + + reconcileResult, reconcileErr := r.reconcileNodeScanJob(ctx, nodeScanJob) + + if err := r.Status().Update(ctx, nodeScanJob); err != nil { + return ctrl.Result{}, fmt.Errorf("failed to update NodeScanJob status: %w", err) + } + + log.V(1).Info("Successfully reconciled NodeScanJob", "nodeScanJob", req.NamespacedName) + return reconcileResult, reconcileErr +} + +// validateNodeAgainstConfig checks the NodeScanConfiguration exists and +// the node matches it. Returns (true, nil) when the job was marked Failed. +func (r *NodeScanJobReconciler) validateNodeAgainstConfig(ctx context.Context, job *v1alpha1.NodeScanJob, node *corev1.Node) (bool, error) { + log := logf.FromContext(ctx) + + var config v1alpha1.NodeScanConfiguration + if err := r.Get(ctx, types.NamespacedName{Name: v1alpha1.NodeScanConfigurationName}, &config); err != nil { + if errors.IsNotFound(err) { + log.Info("NodeScanConfiguration not found, marking NodeScanJob as failed", "nodeScanJob", job.Name) + job.MarkFailed(v1alpha1.ReasonNodeScanJobConfigurationMissing, "NodeScanConfiguration not found: node scanning is not configured") + return true, nil + } + return false, fmt.Errorf("failed to get NodeScanConfiguration: %w", err) + } + + if config.Spec.NodeSelector != nil { + selector, err := metav1.LabelSelectorAsSelector(config.Spec.NodeSelector) + if err != nil { + return false, fmt.Errorf("failed to parse NodeSelector: %w", err) + } + if !selector.Matches(labels.Set(node.Labels)) { + log.Info("Node does not match NodeScanConfiguration nodeSelector, marking NodeScanJob as failed", + "nodeScanJob", job.Name, "node", node.Name) + job.MarkFailed(v1alpha1.ReasonNodeScanJobNotMatching, + fmt.Sprintf("node %q does not match the NodeScanConfiguration nodeSelector", node.Name)) + return true, nil + } + } + + if !filters.IsPlatformAllowed( + node.Status.NodeInfo.OperatingSystem, + node.Status.NodeInfo.Architecture, + "", + config.Spec.Platforms, + ) { + log.Info("Node platform not allowed by NodeScanConfiguration, marking NodeScanJob as failed", + "nodeScanJob", job.Name, "node", node.Name, + "platform", fmt.Sprintf("%s/%s", node.Status.NodeInfo.OperatingSystem, node.Status.NodeInfo.Architecture)) + job.MarkFailed(v1alpha1.ReasonNodeScanJobNotMatching, + fmt.Sprintf("node %q platform %s/%s is not allowed by the NodeScanConfiguration", + node.Name, node.Status.NodeInfo.OperatingSystem, node.Status.NodeInfo.Architecture)) + return true, nil + } + + return false, nil +} + +// reconcileScanJob implements the actual reconciliation logic. +func (r *NodeScanJobReconciler) reconcileNodeScanJob(ctx context.Context, nodeScanJob *v1alpha1.NodeScanJob) (ctrl.Result, error) { + log := logf.FromContext(ctx) + + if err := r.cleanupOldNodeScanJobs(ctx); err != nil { + return ctrl.Result{}, fmt.Errorf("failed to cleanup old NodeScanJobs: %w", err) + } + + log.V(1).Info("Publishing GenerateNodeSBOM message for NodeScanJob", "nodescanJob", nodeScanJob.Name) + messageID := fmt.Sprintf("generateNodeSBOM/%s", nodeScanJob.GetUID()) + message, err := json.Marshal(&handlers.GenerateNodeSBOMMessage{ + NodeBaseMessage: handlers.NodeBaseMessage{ + NodeScanJob: handlers.ObjectRef{ + Name: nodeScanJob.Name, + Namespace: nodeScanJob.Namespace, + UID: string(nodeScanJob.GetUID()), + }, + }, + Node: handlers.ObjectRef{ + Name: nodeScanJob.Spec.NodeName, + }, + }) + if err != nil { + return ctrl.Result{}, fmt.Errorf("unable to marshal GenerateNodeSBOM message: %w", err) + } + + if err := r.Publisher.Publish(ctx, handlers.GenerateNodeSBOMSubject+"."+nodeScanJob.Spec.NodeName, messageID, message); err != nil { + return ctrl.Result{}, fmt.Errorf("unable to publish GenerateNodeSBOM message: %w", err) + } + + nodeScanJob.MarkScheduled(v1alpha1.ReasonScanJobScheduled, "NodeScanJob has been scheduled for processing by the controller") + + return ctrl.Result{}, nil +} + +// cleanupOldNodeScanJobs ensures we don't have more than scanJobsHistoryLimit +func (r *NodeScanJobReconciler) cleanupOldNodeScanJobs(ctx context.Context) error { + log := logf.FromContext(ctx) + + scanJobList := &v1alpha1.NodeScanJobList{} + + if err := r.List(ctx, scanJobList); err != nil { + return fmt.Errorf("failed to list NodeScanJobs: %w", err) + } + + if len(scanJobList.Items) <= scanJobsHistoryLimit { + return nil + } + + sort.Slice(scanJobList.Items, func(i, j int) bool { + ti := scanJobList.Items[i].GetCreationTimestampFromAnnotation() + tj := scanJobList.Items[j].GetCreationTimestampFromAnnotation() + + return ti.Before(tj) + }) + + log.V(1).Info("Sorting NodeScanJobs by creation timestamp for cleanup", + "scanjobs", scanJobList.Items) + + scanJobsToDelete := len(scanJobList.Items) - scanJobsHistoryLimit + for _, scanJob := range scanJobList.Items[:scanJobsToDelete] { + if err := r.Delete(ctx, &scanJob); err != nil { + return fmt.Errorf("failed to delete old NodeScanJob %s: %w", scanJob.Name, err) + } + log.Info("cleaned up old NodeScanJob", + "name", scanJob.Name, + "creationTimestamp", scanJob.CreationTimestamp) + } + + return nil +} + +func (r *NodeScanJobReconciler) mapNodeToNodeScanJobs(ctx context.Context, obj client.Object) []ctrl.Request { + log := logf.FromContext(ctx) + + var nodeScanJobs v1alpha1.NodeScanJobList + if err := r.List(ctx, &nodeScanJobs, + client.MatchingFields{v1alpha1.IndexNodeScanJobSpecNodeName: obj.GetName()}, + ); err != nil { + log.Error(err, "Failed to list NodeScanJobs for node", "node", obj.GetName()) + return nil + } + + requests := make([]ctrl.Request, 0, len(nodeScanJobs.Items)) + for i := range nodeScanJobs.Items { + requests = append(requests, ctrl.Request{ + NamespacedName: types.NamespacedName{ + Name: nodeScanJobs.Items[i].Name, + }, + }) + } + + return requests +} + +// SetupWithManager sets up the controller with the Manager. +func (r *NodeScanJobReconciler) SetupWithManager(mgr ctrl.Manager) error { + err := ctrl.NewControllerManagedBy(mgr). + For(&v1alpha1.NodeScanJob{}). + Watches(&corev1.Node{}, + handler.EnqueueRequestsFromMapFunc(r.mapNodeToNodeScanJobs), + ). + WithOptions(controller.Options{ + MaxConcurrentReconciles: maxConcurrentReconciles, + }). + Complete(r) + if err != nil { + return fmt.Errorf("failed to create NodeScanJob controller: %w", err) + } + + return nil +} diff --git a/internal/controller/nodescanjob_controller_test.go b/internal/controller/nodescanjob_controller_test.go new file mode 100644 index 000000000..0e02c6e2d --- /dev/null +++ b/internal/controller/nodescanjob_controller_test.go @@ -0,0 +1,344 @@ +package controller + +import ( + "context" + "encoding/json" + "fmt" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/stretchr/testify/mock" + + "github.com/google/uuid" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/kubewarden/sbomscanner/api/v1alpha1" + "github.com/kubewarden/sbomscanner/internal/handlers" + messagingMocks "github.com/kubewarden/sbomscanner/internal/messaging/mocks" +) + +var _ = Describe("NodeScanJob Controller", func() { + When("A NodeScanJob is created for a valid Node", func() { + var reconciler NodeScanJobReconciler + var nodeScanJob v1alpha1.NodeScanJob + var node corev1.Node + var mockPublisher *messagingMocks.MockPublisher + + BeforeEach(func(ctx context.Context) { + By("Creating a new NodeScanJobReconciler") + mockPublisher = messagingMocks.NewMockPublisher(GinkgoT()) + reconciler = NodeScanJobReconciler{ + Client: k8sClient, + Publisher: mockPublisher, + Scheme: k8sClient.Scheme(), + } + + By("Creating a Node") + node = corev1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("node-%s", uuid.New().String()), + }, + } + Expect(k8sClient.Create(ctx, &node)).To(Succeed()) + + By("Creating a NodeScanJob for the node") + nodeScanJob = v1alpha1.NodeScanJob{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("nodescanjob-%s", uuid.New().String()), + }, + Spec: v1alpha1.NodeScanJobSpec{ + NodeName: node.Name, + }, + } + Expect(k8sClient.Create(ctx, &nodeScanJob)).To(Succeed()) + }) + + It("should successfully reconcile and publish GenerateNodeSBOM message", func(ctx context.Context) { + By("Setting up the expected message publication") + message, err := json.Marshal(&handlers.GenerateNodeSBOMMessage{ + NodeBaseMessage: handlers.NodeBaseMessage{ + NodeScanJob: handlers.ObjectRef{ + Name: nodeScanJob.Name, + Namespace: nodeScanJob.Namespace, + UID: string(nodeScanJob.GetUID()), + }, + }, + Node: handlers.ObjectRef{ + Name: node.Name, + }, + }) + Expect(err).NotTo(HaveOccurred()) + mockPublisher.On("Publish", mock.Anything, handlers.GenerateNodeSBOMSubject+"."+node.Name, fmt.Sprintf("generateNodeSBOM/%s", nodeScanJob.GetUID()), message).Return(nil) + + By("Reconciling the NodeScanJob") + _, err = reconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{ + Name: nodeScanJob.Name, + }, + }) + Expect(err).NotTo(HaveOccurred()) + + By("Verifying the NodeScanJob is marked as scheduled") + updatedJob := &v1alpha1.NodeScanJob{} + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: nodeScanJob.Name, + }, updatedJob) + Expect(err).NotTo(HaveOccurred()) + Expect(updatedJob.IsScheduled()).To(BeTrue()) + }) + }) + + When("A NodeScanJob is created but NodeScanConfiguration is missing", func() { + var reconciler NodeScanJobReconciler + var nodeScanJob v1alpha1.NodeScanJob + var node corev1.Node + var mockPublisher *messagingMocks.MockPublisher + + BeforeEach(func(ctx context.Context) { + By("Creating a new NodeScanJobReconciler") + mockPublisher = messagingMocks.NewMockPublisher(GinkgoT()) + reconciler = NodeScanJobReconciler{ + Client: k8sClient, + Publisher: mockPublisher, + Scheme: k8sClient.Scheme(), + } + + By("Creating a Node") + node = corev1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("node-%s", uuid.New().String()), + }, + } + Expect(k8sClient.Create(ctx, &node)).To(Succeed()) + + By("Creating a NodeScanJob for the node") + nodeScanJob = v1alpha1.NodeScanJob{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("nodescanjob-%s", uuid.New().String()), + }, + Spec: v1alpha1.NodeScanJobSpec{ + NodeName: node.Name, + }, + } + Expect(k8sClient.Create(ctx, &nodeScanJob)).To(Succeed()) + }) + + It("should mark the NodeScanJob as failed", func(ctx context.Context) { + By("Reconciling the NodeScanJob") + _, err := reconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{ + Name: nodeScanJob.Name, + }, + }) + Expect(err).NotTo(HaveOccurred()) + + By("Verifying the NodeScanJob is marked as failed") + updatedJob := &v1alpha1.NodeScanJob{} + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: nodeScanJob.Name, + }, updatedJob) + Expect(err).NotTo(HaveOccurred()) + Expect(updatedJob.IsFailed()).To(BeTrue()) + }) + }) + + When("A NodeScanJob is created but node does not match NodeScanConfiguration nodeSelector", func() { + var reconciler NodeScanJobReconciler + var nodeScanJob v1alpha1.NodeScanJob + var node corev1.Node + var config v1alpha1.NodeScanConfiguration + var mockPublisher *messagingMocks.MockPublisher + + BeforeEach(func(ctx context.Context) { + By("Creating a new NodeScanJobReconciler") + mockPublisher = messagingMocks.NewMockPublisher(GinkgoT()) + reconciler = NodeScanJobReconciler{ + Client: k8sClient, + Publisher: mockPublisher, + Scheme: k8sClient.Scheme(), + } + + By("Creating a NodeScanConfiguration with a nodeSelector") + config = v1alpha1.NodeScanConfiguration{ + ObjectMeta: metav1.ObjectMeta{ + Name: v1alpha1.NodeScanConfigurationName, + }, + Spec: v1alpha1.NodeScanConfigurationSpec{ + NodeSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"env": "production"}, + }, + }, + } + Expect(k8sClient.Create(ctx, &config)).To(Succeed()) + + By("Creating a Node without matching labels") + node = corev1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("node-%s", uuid.New().String()), + Labels: map[string]string{"env": "staging"}, + }, + } + Expect(k8sClient.Create(ctx, &node)).To(Succeed()) + + By("Creating a NodeScanJob for the node") + nodeScanJob = v1alpha1.NodeScanJob{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("nodescanjob-%s", uuid.New().String()), + }, + Spec: v1alpha1.NodeScanJobSpec{ + NodeName: node.Name, + }, + } + Expect(k8sClient.Create(ctx, &nodeScanJob)).To(Succeed()) + }) + + AfterEach(func(ctx context.Context) { + Expect(k8sClient.Delete(ctx, &config)).To(Succeed()) + }) + + It("should mark the NodeScanJob as failed", func(ctx context.Context) { + By("Reconciling the NodeScanJob") + _, err := reconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{ + Name: nodeScanJob.Name, + }, + }) + Expect(err).NotTo(HaveOccurred()) + + By("Verifying the NodeScanJob is marked as failed") + updatedJob := &v1alpha1.NodeScanJob{} + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: nodeScanJob.Name, + }, updatedJob) + Expect(err).NotTo(HaveOccurred()) + Expect(updatedJob.IsFailed()).To(BeTrue()) + }) + }) + + When("A NodeScanJob is created but node platform is not allowed", func() { + var reconciler NodeScanJobReconciler + var nodeScanJob v1alpha1.NodeScanJob + var node corev1.Node + var config v1alpha1.NodeScanConfiguration + var mockPublisher *messagingMocks.MockPublisher + + BeforeEach(func(ctx context.Context) { + By("Creating a new NodeScanJobReconciler") + mockPublisher = messagingMocks.NewMockPublisher(GinkgoT()) + reconciler = NodeScanJobReconciler{ + Client: k8sClient, + Publisher: mockPublisher, + Scheme: k8sClient.Scheme(), + } + + By("Creating a NodeScanConfiguration with platform filter") + config = v1alpha1.NodeScanConfiguration{ + ObjectMeta: metav1.ObjectMeta{ + Name: v1alpha1.NodeScanConfigurationName, + }, + Spec: v1alpha1.NodeScanConfigurationSpec{ + Platforms: []v1alpha1.Platform{ + {Architecture: "amd64", OS: "linux"}, + }, + }, + } + Expect(k8sClient.Create(ctx, &config)).To(Succeed()) + + By("Creating a Node with a different platform") + node = corev1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("node-%s", uuid.New().String()), + }, + Status: corev1.NodeStatus{ + NodeInfo: corev1.NodeSystemInfo{ + OperatingSystem: "linux", + Architecture: "arm64", + }, + }, + } + Expect(k8sClient.Create(ctx, &node)).To(Succeed()) + + By("Creating a NodeScanJob for the node") + nodeScanJob = v1alpha1.NodeScanJob{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("nodescanjob-%s", uuid.New().String()), + }, + Spec: v1alpha1.NodeScanJobSpec{ + NodeName: node.Name, + }, + } + Expect(k8sClient.Create(ctx, &nodeScanJob)).To(Succeed()) + }) + + AfterEach(func(ctx context.Context) { + Expect(k8sClient.Delete(ctx, &config)).To(Succeed()) + }) + + It("should mark the NodeScanJob as failed", func(ctx context.Context) { + By("Reconciling the NodeScanJob") + _, err := reconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{ + Name: nodeScanJob.Name, + }, + }) + Expect(err).NotTo(HaveOccurred()) + + By("Verifying the NodeScanJob is marked as failed") + updatedJob := &v1alpha1.NodeScanJob{} + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: nodeScanJob.Name, + }, updatedJob) + Expect(err).NotTo(HaveOccurred()) + Expect(updatedJob.IsFailed()).To(BeTrue()) + }) + }) + + When("A NodeScanJob is created for an invalid Node", func() { + var reconciler NodeScanJobReconciler + var nodeScanJob v1alpha1.NodeScanJob + var mockPublisher *messagingMocks.MockPublisher + + BeforeEach(func(ctx context.Context) { + By("Creating a new NodeScanJobReconciler") + mockPublisher = messagingMocks.NewMockPublisher(GinkgoT()) + reconciler = NodeScanJobReconciler{ + Client: k8sClient, + Publisher: mockPublisher, + Scheme: k8sClient.Scheme(), + } + + By("Creating a NodeScanJob referencing a non-existent node") + nodeScanJob = v1alpha1.NodeScanJob{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("nodescanjob-%s", uuid.New().String()), + }, + Spec: v1alpha1.NodeScanJobSpec{ + NodeName: "non-existent-node", + }, + } + Expect(k8sClient.Create(ctx, &nodeScanJob)).To(Succeed()) + }) + + It("should delete the NodeScanJob when the node no longer exists", func(ctx context.Context) { + By("Reconciling the NodeScanJob") + _, err := reconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{ + Name: nodeScanJob.Name, + }, + }) + Expect(err).NotTo(HaveOccurred()) + + By("Verifying the NodeScanJob was deleted") + deletedJob := &v1alpha1.NodeScanJob{} + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: nodeScanJob.Name, + }, deletedJob) + Expect(client.IgnoreNotFound(err)).NotTo(HaveOccurred()) + Expect(deletedJob.Name).To(BeEmpty()) + }) + }) +}) diff --git a/internal/controller/registry_scan_runner_test.go b/internal/controller/registry_scan_runner_test.go index 4437948d0..c5dc6ab69 100644 --- a/internal/controller/registry_scan_runner_test.go +++ b/internal/controller/registry_scan_runner_test.go @@ -110,7 +110,7 @@ var _ = Describe("RegistryScanRunner", func() { }, } Expect(k8sClient.Create(ctx, completedJob)).To(Succeed()) - completedJob.MarkComplete(v1alpha1.ReasonComplete, "Done") + completedJob.MarkComplete(v1alpha1.ReasonScanJobComplete, "Done") completedJob.Status.CompletionTime = &metav1.Time{Time: time.Now().Add(-2 * time.Hour)} Expect(k8sClient.Status().Update(ctx, completedJob)).To(Succeed()) @@ -140,7 +140,7 @@ var _ = Describe("RegistryScanRunner", func() { }, } Expect(k8sClient.Create(ctx, recentJob)).To(Succeed()) - recentJob.MarkComplete(v1alpha1.ReasonComplete, "Done") + recentJob.MarkComplete(v1alpha1.ReasonScanJobComplete, "Done") recentJob.Status.CompletionTime = &metav1.Time{Time: time.Now()} Expect(k8sClient.Status().Update(ctx, recentJob)).To(Succeed()) @@ -277,7 +277,7 @@ var _ = Describe("RegistryScanRunner", func() { }, } Expect(k8sClient.Create(ctx, completedJob)).To(Succeed()) - completedJob.MarkComplete(v1alpha1.ReasonComplete, "Done") + completedJob.MarkComplete(v1alpha1.ReasonScanJobComplete, "Done") completedJob.Status.CompletionTime = &metav1.Time{Time: time.Now()} Expect(k8sClient.Status().Update(ctx, completedJob)).To(Succeed()) @@ -314,7 +314,7 @@ var _ = Describe("RegistryScanRunner", func() { }, } Expect(k8sClient.Create(ctx, completedJob)).To(Succeed()) - completedJob.MarkComplete(v1alpha1.ReasonComplete, "Done") + completedJob.MarkComplete(v1alpha1.ReasonScanJobComplete, "Done") completedJob.Status.CompletionTime = &metav1.Time{Time: time.Now()} Expect(k8sClient.Status().Update(ctx, completedJob)).To(Succeed()) diff --git a/internal/controller/scanjob_controller.go b/internal/controller/scanjob_controller.go index 88c90f72e..770dd7134 100644 --- a/internal/controller/scanjob_controller.go +++ b/internal/controller/scanjob_controller.go @@ -87,7 +87,7 @@ func (r *ScanJobReconciler) reconcileScanJob(ctx context.Context, scanJob *v1alp }, registry); err != nil { if errors.IsNotFound(err) { log.Error(err, "Registry not found", "registry", scanJob.Spec.Registry) - scanJob.MarkFailed(v1alpha1.ReasonRegistryNotFound, fmt.Sprintf("Registry %s not found", scanJob.Spec.Registry)) + scanJob.MarkFailed(v1alpha1.ReasonScanJobRegistryNotFound, fmt.Sprintf("Registry %s not found", scanJob.Spec.Registry)) return ctrl.Result{}, nil } @@ -149,7 +149,7 @@ func (r *ScanJobReconciler) reconcileScanJob(ctx context.Context, scanJob *v1alp return ctrl.Result{}, fmt.Errorf("unable to publish CreateSBOM message: %w", err) } - scanJob.MarkScheduled(v1alpha1.ReasonScheduled, "ScanJob has been scheduled for processing by the controller") + scanJob.MarkScheduled(v1alpha1.ReasonScanJobScheduled, "ScanJob has been scheduled for processing by the controller") return ctrl.Result{}, nil } @@ -203,14 +203,14 @@ func validateScanJobTargets(scanJob *v1alpha1.ScanJob, registry *v1alpha1.Regist for _, target := range scanJob.Spec.Repositories { repository := registry.GetRepository(target.Name) if repository == nil { - return v1alpha1.ReasonRepositoryNotFound, + return v1alpha1.ReasonScanJobRepositoryNotFound, fmt.Errorf("repository %q is not declared on registry %q", target.Name, registry.Name) } for _, conditionName := range target.MatchConditions { if !slices.ContainsFunc(repository.MatchConditions, func(mc v1alpha1.MatchCondition) bool { return mc.Name == conditionName }) { - return v1alpha1.ReasonMatchConditionNotFound, + return v1alpha1.ReasonScanJobMatchConditionNotFound, fmt.Errorf("match condition %q is not declared on repository %q of registry %q", conditionName, target.Name, registry.Name) } } diff --git a/internal/controller/scanjob_controller_test.go b/internal/controller/scanjob_controller_test.go index 90764bb1e..487563efb 100644 --- a/internal/controller/scanjob_controller_test.go +++ b/internal/controller/scanjob_controller_test.go @@ -246,17 +246,17 @@ var _ = Describe("ScanJob Controller", func() { Namespace: scanJob.Namespace, }, updated)).To(Succeed()) Expect(updated.IsFailed()).To(BeTrue()) - cond := meta.FindStatusCondition(updated.Status.Conditions, v1alpha1.ConditionTypeFailed) + cond := meta.FindStatusCondition(updated.Status.Conditions, v1alpha1.ConditionScanJobTypeFailed) Expect(cond).NotTo(BeNil()) Expect(cond.Reason).To(Equal(expectedReason)) }, Entry("unknown repository => RepositoryNotFound", []v1alpha1.ScanJobRepository{{Name: "missing/repo"}}, - v1alpha1.ReasonRepositoryNotFound, + v1alpha1.ReasonScanJobRepositoryNotFound, ), Entry("unknown matchCondition => MatchConditionNotFound", []v1alpha1.ScanJobRepository{{Name: "foo/bar", MatchConditions: []string{"tag-missing"}}}, - v1alpha1.ReasonMatchConditionNotFound, + v1alpha1.ReasonScanJobMatchConditionNotFound, ), ) }) @@ -288,7 +288,7 @@ var _ = Describe("ScanJob Controller", func() { Expect(k8sClient.Create(ctx, &scanJob)).To(Succeed()) By("Marking the ScanJob as completed") - scanJob.MarkComplete(v1alpha1.ReasonAllImagesScanned, "Scan completed successfully") + scanJob.MarkComplete(v1alpha1.ReasonScanJobAllImagesScanned, "Scan completed successfully") Expect(k8sClient.Status().Update(ctx, &scanJob)).To(Succeed()) }) diff --git a/internal/controller/vulnerabilityreport_controller.go b/internal/controller/vulnerabilityreport_controller.go index 844623075..deffcbab9 100644 --- a/internal/controller/vulnerabilityreport_controller.go +++ b/internal/controller/vulnerabilityreport_controller.go @@ -92,9 +92,9 @@ func (r *VulnerabilityReportReconciler) Reconcile(ctx context.Context, req ctrl. if scanJob.Status.ScannedImagesCount == scanJob.Status.ImagesCount { now := metav1.Now() scanJob.Status.CompletionTime = &now - scanJob.MarkComplete(v1alpha1.ReasonAllImagesScanned, "All images scanned successfully") + scanJob.MarkComplete(v1alpha1.ReasonScanJobAllImagesScanned, "All images scanned successfully") } else { - scanJob.MarkInProgress(v1alpha1.ReasonImageScanInProgress, "Image scan in progress") + scanJob.MarkInProgress(v1alpha1.ReasonScanJobImageScanInProgress, "Image scan in progress") } } if err := r.Status().Update(ctx, scanJob); err != nil { diff --git a/internal/controller/vulnerabilityreport_controller_test.go b/internal/controller/vulnerabilityreport_controller_test.go index 5ed0f7b3c..ce5d80705 100644 --- a/internal/controller/vulnerabilityreport_controller_test.go +++ b/internal/controller/vulnerabilityreport_controller_test.go @@ -164,7 +164,7 @@ var _ = Describe("VulnerabilityReport Controller", func() { ), Entry("should update count but preserve failed status when ScanJob is already failed", func(scanJob *v1alpha1.ScanJob) { - scanJob.MarkFailed(v1alpha1.ReasonInternalError, "kaboom") + scanJob.MarkFailed(v1alpha1.ReasonScanJobInternalError, "kaboom") }, func(scanJob *v1alpha1.ScanJob) { Expect(scanJob.IsFailed()).To(BeTrue()) @@ -193,7 +193,7 @@ var _ = Describe("VulnerabilityReport Controller", func() { scanJob.Status.ImagesCount = 2 scanJob.Status.ScannedImagesCount = 2 scanJob.Status.CompletionTime = &originalCompletionTime - scanJob.MarkComplete(v1alpha1.ReasonAllImagesScanned, "All images scanned successfully") + scanJob.MarkComplete(v1alpha1.ReasonScanJobAllImagesScanned, "All images scanned successfully") // MarkComplete overwrites CompletionTime, so set it back to our known value scanJob.Status.CompletionTime = &originalCompletionTime Expect(k8sClient.Status().Update(ctx, scanJob)).To(Succeed()) diff --git a/internal/handlers/create_catalog.go b/internal/handlers/create_catalog.go index f5e09a649..0b4f72726 100644 --- a/internal/handlers/create_catalog.go +++ b/internal/handlers/create_catalog.go @@ -98,7 +98,7 @@ func (h *CreateCatalogHandler) Handle(ctx context.Context, message messaging.Mes ) } - scanJob.MarkInProgress(v1alpha1.ReasonCatalogCreationInProgress, "Catalog creation in progress") + scanJob.MarkInProgress(v1alpha1.ReasonScanJobCatalogCreationInProgress, "Catalog creation in progress") return h.k8sClient.Status().Update(ctx, scanJob) }) if err != nil { @@ -289,10 +289,10 @@ func (h *CreateCatalogHandler) Handle(ctx context.Context, message messaging.Mes if len(discoveredImages) == 0 { h.logger.InfoContext(ctx, "No images to process", "scanjob", scanJob.Name, "namespace", scanJob.Namespace) - scanJob.MarkComplete(v1alpha1.ReasonNoImagesToScan, "No images to process") + scanJob.MarkComplete(v1alpha1.ReasonScanJobNoImagesToScan, "No images to process") } else { h.logger.InfoContext(ctx, "Images to process", "count", len(discoveredImages)) - scanJob.MarkInProgress(v1alpha1.ReasonSBOMGenerationInProgress, "SBOM generation in progress") + scanJob.MarkInProgress(v1alpha1.ReasonScanJobSBOMGenerationInProgress, "SBOM generation in progress") scanJob.Status.ImagesCount = len(discoveredImages) scanJob.Status.ScannedImagesCount = 0 } @@ -311,7 +311,7 @@ func (h *CreateCatalogHandler) Handle(ctx context.Context, message messaging.Mes for _, image := range discoveredImages { h.logger.DebugContext(ctx, "Sending generate SBOM message", "image", image.Name, "namespace", image.Namespace) - messageID := fmt.Sprintf("generateSBOM/%s/%s", scanJob.UID, image.Name) + messageID := fmt.Sprintf("generateSBOM/%s/%s", scanJob.GetUID(), image.Name) message, err := json.Marshal(&GenerateSBOMMessage{ BaseMessage: BaseMessage{ ScanJob: createCatalogMessage.ScanJob, diff --git a/internal/handlers/create_catalog_test.go b/internal/handlers/create_catalog_test.go index 6e12297a8..807b8b8fa 100644 --- a/internal/handlers/create_catalog_test.go +++ b/internal/handlers/create_catalog_test.go @@ -531,7 +531,7 @@ func TestCreateCatalogHandler_Handle(t *testing.T) { mockPublisher := messagingMocks.NewMockPublisher(t) for _, expectedImage := range test.expectedImages { - messageID := fmt.Sprintf("generateSBOM/%s/%s", scanJob.UID, expectedImage.Name) + messageID := fmt.Sprintf("generateSBOM/%s/%s", scanJob.GetUID(), expectedImage.Name) expectedMessage, err := json.Marshal(&GenerateSBOMMessage{ BaseMessage: BaseMessage{ ScanJob: ObjectRef{ diff --git a/internal/handlers/generate_node_sbom.go b/internal/handlers/generate_node_sbom.go new file mode 100644 index 000000000..02c89ff36 --- /dev/null +++ b/internal/handlers/generate_node_sbom.go @@ -0,0 +1,307 @@ +package handlers + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "os" + "path" + + _ "modernc.org/sqlite" // sqlite driver for RPM DB and Java DB + + trivyCommands "github.com/aquasecurity/trivy/pkg/commands" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/util/retry" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + + "github.com/kubewarden/sbomscanner/api" + storagev1alpha1 "github.com/kubewarden/sbomscanner/api/storage/v1alpha1" + "github.com/kubewarden/sbomscanner/api/v1alpha1" + "github.com/kubewarden/sbomscanner/internal/messaging" + "github.com/kubewarden/sbomscanner/internal/skippatterns" +) + +// GenerateNodeSBOMHandler is responsible for handling SBOM generation requests. +type GenerateNodeSBOMHandler struct { + k8sClient client.Client + scheme *runtime.Scheme + workDir string + targetDir string + trivyJavaDBRepository string + publisher messaging.Publisher + installationNamespace string + logger *slog.Logger +} + +// NewGenerateNodeSBOMHandler creates a new instance of GenerateNodeSBOMHandler. +func NewGenerateNodeSBOMHandler( + k8sClient client.Client, + scheme *runtime.Scheme, + workDir string, + targetDir string, + trivyJavaDBRepository string, + publisher messaging.Publisher, + installationNamespace string, + logger *slog.Logger, +) *GenerateNodeSBOMHandler { + return &GenerateNodeSBOMHandler{ + k8sClient: k8sClient, + scheme: scheme, + workDir: workDir, + targetDir: targetDir, + trivyJavaDBRepository: trivyJavaDBRepository, + publisher: publisher, + installationNamespace: installationNamespace, + logger: logger.With("handler", "generate_node_sbom_handler"), + } +} + +// Handle processes the GenerateNodeSBOMMessage and generates a SBOM resource from the specified image. +// +//nolint:gocognit +func (h *GenerateNodeSBOMHandler) Handle(ctx context.Context, message messaging.Message) error { + generateNodeSBOMMessage := &GenerateNodeSBOMMessage{} + if err := json.Unmarshal(message.Data(), generateNodeSBOMMessage); err != nil { + return fmt.Errorf("failed to unmarshal GenerateNodeSBOM message: %w", err) + } + + h.logger.InfoContext(ctx, "Node SBOM generation requested", + "node", generateNodeSBOMMessage.Node.Name, + ) + + nodeScanJob := &v1alpha1.NodeScanJob{} + err := h.k8sClient.Get(ctx, client.ObjectKey{ + Name: generateNodeSBOMMessage.NodeScanJob.Name, + }, nodeScanJob) + if err != nil { + // Stop processing if the scanjob is not found, since it might have been deleted. + if apierrors.IsNotFound(err) { + h.logger.InfoContext(ctx, "NodeScanJob not found, stopping NodeSBOM generation", "nodescanjob", generateNodeSBOMMessage.NodeScanJob.Name) + return nil + } + + return fmt.Errorf("cannot get NodeScanJob %s: %w", generateNodeSBOMMessage.NodeScanJob.Name, err) + } + if nodeScanJob.Name != generateNodeSBOMMessage.NodeScanJob.Name { + h.logger.InfoContext(ctx, "NodeScanJob not found, stopping NodeSBOM generation", "nodescanjob", generateNodeSBOMMessage.NodeScanJob.Name, + "uid", generateNodeSBOMMessage.NodeScanJob.UID) + return nil + } + + node := &corev1.Node{} + err = h.k8sClient.Get(ctx, client.ObjectKey{ + Name: generateNodeSBOMMessage.Node.Name, + }, node) + if err != nil { + // Stop processing if the node is not found, since it might have been deleted. + if apierrors.IsNotFound(err) { + h.logger.InfoContext(ctx, "Node not found, stopping NodeSBOM generation", "node", generateNodeSBOMMessage.Node.Name) + return nil + } + + return fmt.Errorf("cannot get node %s: %w", generateNodeSBOMMessage.Node.Name, err) + } + h.logger.DebugContext(ctx, "Node found", "node", node.Name) + + if nodeScanJob.IsFailed() { + h.logger.InfoContext(ctx, "NodeScanJob is in failed state, stopping NodeSBOM generation", "nodescanjob", nodeScanJob.Name) + return nil + } + + err = retry.RetryOnConflict(retry.DefaultRetry, func() error { + if err := h.k8sClient.Get(ctx, client.ObjectKey{ + Name: generateNodeSBOMMessage.NodeScanJob.Name, + }, nodeScanJob); err != nil { + return fmt.Errorf("cannot get NodeScanJob %s: %w", generateNodeSBOMMessage.NodeScanJob.Name, err) + } + + nodeScanJob.MarkInProgress(v1alpha1.ReasonNodeScanJobInProgress, "Scanning node filesystem and collecting data") + return h.k8sClient.Status().Update(ctx, nodeScanJob) + }) + if err != nil { + return fmt.Errorf("failed to update NodeScanJob status to in progress: %w", err) + } + + // Get the NodeScanConfiguration to determine where to create the NodeSBOM. + nodescanconfig := &v1alpha1.NodeScanConfiguration{} + err = h.k8sClient.Get(ctx, client.ObjectKey{ + Name: v1alpha1.NodeScanConfigurationName, + }, nodescanconfig) + if err != nil { + // Stop processing if the scanjob is not found, since it might have been deleted. + if apierrors.IsNotFound(err) { + h.logger.InfoContext(ctx, "NodeScanConfiguration not found, stopping NodeSBOM generation", "node name", node.Name) + return fmt.Errorf("NodeScanConfiguration %s not found: %w", v1alpha1.NodeScanConfigurationName, err) + } + return fmt.Errorf("cannot get NodeScanConfiguration: %w", err) + } + + nodeSbom, err := h.getOrGenerateNodeSBOM(ctx, node, generateNodeSBOMMessage, nodescanconfig) + if err != nil { + return fmt.Errorf("failed to get or generate NodeSBOM: %w", err) + } + + if err = message.InProgress(); err != nil { + return fmt.Errorf("failed to ack message as in progress: %w", err) + } + + if err = h.k8sClient.Create(ctx, nodeSbom); err != nil { + if apierrors.IsAlreadyExists(err) { + h.logger.InfoContext(ctx, "NodeSBOM already exists, skipping creation", "nodesbom", generateNodeSBOMMessage.Node.Name) + } else { + return fmt.Errorf("failed to create NodeSBOM: %w", err) + } + } + + scanNodeSBOMMessageID := fmt.Sprintf("nodeScanSBOM/%s/%s", nodeScanJob.GetUID(), generateNodeSBOMMessage.Node.Name) + scanNodeSBOMMessage, err := json.Marshal(&ScanNodeSBOMMessage{ + NodeBaseMessage: NodeBaseMessage{ + NodeScanJob: generateNodeSBOMMessage.NodeScanJob, + }, + NodeSBOM: ObjectRef{ + Name: generateNodeSBOMMessage.Node.Name, + }, + }) + if err != nil { + return fmt.Errorf("cannot marshal scan NodeSBOM message: %w", err) + } + + if err = h.publisher.Publish(ctx, ScanNodeSBOMSubject+"."+nodeScanJob.Spec.NodeName, scanNodeSBOMMessageID, scanNodeSBOMMessage); err != nil { + return fmt.Errorf("failed to publish scan NodeSBOM message: %w", err) + } + + return nil +} + +// getOrGenerateNodeSBOM checks if an SBOM with the same node name exists and reuses it, or generates a new one. +func (h *GenerateNodeSBOMHandler) getOrGenerateNodeSBOM(ctx context.Context, node *corev1.Node, message *GenerateNodeSBOMMessage, config *v1alpha1.NodeScanConfiguration) (*storagev1alpha1.NodeSBOM, error) { + // Check if an SBOM with the same machine ID already exists + existingSBOM, err := h.findSBOMByNodeName(ctx, node.Name) + if err != nil && !apierrors.IsNotFound(err) { + return nil, fmt.Errorf("failed to check for existing NodeSBOM: %w", err) + } + + var spdxBytes []byte + if existingSBOM != nil { + h.logger.InfoContext(ctx, "Found existing NodeSBOM with matching node name, reusing content", + "sbom", existingSBOM.Name, + "nodeName", node.Name, + ) + spdxBytes = existingSBOM.SPDX.Raw + } else { + h.logger.InfoContext(ctx, "No existing NodeSBOM found, generating new one", "node name", node.Name) + spdxBytes, err = h.generateSPDX(ctx, config.Spec.SkipPatterns) + if err != nil { + return nil, err + } + } + + sbomLabels := map[string]string{ + api.LabelManagedByKey: api.LabelManagedByValue, + api.LabelPartOfKey: api.LabelPartOfValue, + } + + nodePlatform := fmt.Sprintf("%s/%s", node.Status.NodeInfo.OperatingSystem, node.Status.NodeInfo.Architecture) + nodeSbom := &storagev1alpha1.NodeSBOM{ + ObjectMeta: metav1.ObjectMeta{ + Name: message.Node.Name, + Labels: sbomLabels, + }, + NodeMetadata: storagev1alpha1.NodeMetadata{ + Name: node.Name, + Platform: nodePlatform, + }, + SPDX: runtime.RawExtension{Raw: spdxBytes}, + } + + if err := controllerutil.SetControllerReference(config, nodeSbom, h.scheme); err != nil { + return nil, fmt.Errorf("failed to set owner reference: %w", err) + } + + return nodeSbom, nil +} + +// findSBOMByMachineID searches for an existing SBOM with the given machine ID. +func (h *GenerateNodeSBOMHandler) findSBOMByNodeName(ctx context.Context, nodeName string) (*storagev1alpha1.NodeSBOM, error) { + sbomList := &storagev1alpha1.NodeSBOMList{} + err := h.k8sClient.List(ctx, sbomList, + client.MatchingFields{storagev1alpha1.IndexNodeMetadataName: nodeName}, + client.Limit(1), + ) + if err != nil { + return nil, fmt.Errorf("failed to find NodeSBOM by node name: %w", err) + } + + if len(sbomList.Items) == 0 { + return nil, apierrors.NewNotFound(storagev1alpha1.Resource("nodesbom"), nodeName) + } + + return &sbomList.Items[0], nil +} + +// generateSPDX generates SPDX JSON content for an image using Trivy. +func (h *GenerateNodeSBOMHandler) generateSPDX(ctx context.Context, skipPats []string) ([]byte, error) { + sbomFile, err := os.CreateTemp(h.workDir, "trivy.sbom.*.json") + if err != nil { + return nil, fmt.Errorf("failed to create temporary SBOM file: %w", err) + } + defer func() { + if err = sbomFile.Close(); err != nil { + h.logger.ErrorContext(ctx, "failed to close temporary SBOM file", "error", err) + } + if err = os.Remove(sbomFile.Name()); err != nil { + h.logger.ErrorContext(ctx, "failed to remove temporary SBOM file", "error", err) + } + }() + + args := []string{ + "filesystem", + //nolint:goconst // These are specific Trivy command arguments, not constant values used elsewhere + "--skip-version-check", + //nolint:goconst // These are specific Trivy command arguments, not constant values used elsewhere + "--disable-telemetry", + //nolint:goconst // These are specific Trivy command arguments, not constant values used elsewhere + "--cache-dir", h.workDir, + //nolint:goconst // These are specific Trivy command arguments, not constant values used elsewhere + "--format", "spdx-json", + "--skip-db-update", + // The Java DB is needed to generate SBOMs for images containing Java components + // See: https://github.com/aquasecurity/trivy/discussions/9666 + //nolint:goconst // These are specific Trivy command arguments, not constant values used elsewhere + "--java-db-repository", h.trivyJavaDBRepository, + //nolint:goconst // These are specific Trivy command arguments, not constant values used elsewhere + "--output", sbomFile.Name(), + } + + parsed := skippatterns.Parse(skipPats) + for _, dir := range parsed.SkipDirs { + args = append(args, "--skip-dirs", path.Join(h.targetDir, dir)) + } + for _, file := range parsed.SkipFiles { + args = append(args, "--skip-files", path.Join(h.targetDir, file)) + } + + args = append(args, h.targetDir) + + app := trivyCommands.NewApp() + app.SetArgs(args) + + h.logger.DebugContext(ctx, "Executing Trivy to generate SPDX SBOM", "args", args) + + if err = app.ExecuteContext(ctx); err != nil { + return nil, fmt.Errorf("failed to execute trivy: %w", err) + } + + spdxBytes, err := io.ReadAll(sbomFile) + if err != nil { + return nil, fmt.Errorf("failed to read NodeSBOM output: %w", err) + } + + return spdxBytes, nil +} diff --git a/internal/handlers/generate_node_sbom_test.go b/internal/handlers/generate_node_sbom_test.go new file mode 100644 index 000000000..5713b58e1 --- /dev/null +++ b/internal/handlers/generate_node_sbom_test.go @@ -0,0 +1,351 @@ +package handlers + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + "github.com/kubewarden/sbomscanner/api" + storagev1alpha1 "github.com/kubewarden/sbomscanner/api/storage/v1alpha1" + "github.com/kubewarden/sbomscanner/api/v1alpha1" + messagingMocks "github.com/kubewarden/sbomscanner/internal/messaging/mocks" + "github.com/kubewarden/sbomscanner/pkg/generated/clientset/versioned/scheme" +) + +func TestGenerateNodeSBOMHandler_Handle(t *testing.T) { + targetDir := filepath.Join("..", "..", "test", "fixtures", "node") + nodeName := "test-node" + + node := &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: nodeName, + UID: "test-node-uid", + }, + Status: corev1.NodeStatus{ + NodeInfo: corev1.NodeSystemInfo{ + OperatingSystem: "linux", + Architecture: "amd64", + }, + }, + } + + nodeScanJob := &v1alpha1.NodeScanJob{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-nodescanjob", + UID: "test-nodescanjob-uid", + }, + Spec: v1alpha1.NodeScanJobSpec{ + NodeName: nodeName, + }, + } + + config := &v1alpha1.NodeScanConfiguration{ + ObjectMeta: metav1.ObjectMeta{ + Name: v1alpha1.NodeScanConfigurationName, + }, + } + + scheme := scheme.Scheme + require.NoError(t, storagev1alpha1.AddToScheme(scheme)) + require.NoError(t, v1alpha1.AddToScheme(scheme)) + require.NoError(t, corev1.AddToScheme(scheme)) + + k8sClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithRuntimeObjects(node, nodeScanJob, config). + WithStatusSubresource(&v1alpha1.NodeScanJob{}). + WithIndex(&storagev1alpha1.NodeSBOM{}, storagev1alpha1.IndexNodeMetadataName, func(obj client.Object) []string { + sbom, ok := obj.(*storagev1alpha1.NodeSBOM) + if !ok { + return nil + } + return []string{sbom.NodeMetadata.Name} + }). + Build() + + publisher := messagingMocks.NewMockPublisher(t) + + expectedScanMessage, err := json.Marshal(&ScanNodeSBOMMessage{ + NodeBaseMessage: NodeBaseMessage{ + NodeScanJob: ObjectRef{ + Name: nodeScanJob.Name, + UID: string(nodeScanJob.UID), + }, + }, + NodeSBOM: ObjectRef{ + Name: nodeName, + }, + }) + require.NoError(t, err) + + publisher.On("Publish", + mock.Anything, + ScanNodeSBOMSubject+"."+nodeName, + fmt.Sprintf("nodeScanSBOM/%s/%s", nodeScanJob.UID, nodeName), + expectedScanMessage, + ).Return(nil).Once() + + handler := NewGenerateNodeSBOMHandler(k8sClient, scheme, t.TempDir(), targetDir, testTrivyJavaDBRepository, publisher, "sbomscanner", slog.Default()) + + message, err := json.Marshal(&GenerateNodeSBOMMessage{ + NodeBaseMessage: NodeBaseMessage{ + NodeScanJob: ObjectRef{ + Name: nodeScanJob.Name, + UID: string(nodeScanJob.UID), + }, + }, + Node: ObjectRef{ + Name: nodeName, + }, + }) + require.NoError(t, err) + + err = handler.Handle(t.Context(), &testMessage{data: message}) + require.NoError(t, err) + + sbom := &storagev1alpha1.NodeSBOM{} + err = k8sClient.Get(t.Context(), types.NamespacedName{Name: nodeName}, sbom) + require.NoError(t, err) + + assert.Equal(t, nodeName, sbom.NodeMetadata.Name) + assert.Equal(t, "linux/amd64", sbom.NodeMetadata.Platform) + assert.Equal(t, api.LabelManagedByValue, sbom.Labels[api.LabelManagedByKey]) + assert.Equal(t, api.LabelPartOfValue, sbom.Labels[api.LabelPartOfKey]) + assert.Equal(t, node.UID, sbom.GetOwnerReferences()[0].UID) + assert.NotEmpty(t, sbom.SPDX.Raw) +} + +func TestGenerateNodeSBOMHandler_Handle_StopProcessing(t *testing.T) { + nodeName := "test-node" + + node := &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: nodeName, + UID: "test-node-uid", + }, + Status: corev1.NodeStatus{ + NodeInfo: corev1.NodeSystemInfo{ + OperatingSystem: "linux", + Architecture: "amd64", + }, + }, + } + + nodeScanJob := &v1alpha1.NodeScanJob{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-nodescanjob", + UID: "test-nodescanjob-uid", + }, + Spec: v1alpha1.NodeScanJobSpec{ + NodeName: nodeName, + }, + } + + failedNodeScanJob := nodeScanJob.DeepCopy() + failedNodeScanJob.MarkFailed(v1alpha1.ReasonScanJobInternalError, "kaboom") + + config := &v1alpha1.NodeScanConfiguration{ + ObjectMeta: metav1.ObjectMeta{ + Name: v1alpha1.NodeScanConfigurationName, + }, + } + + tests := []struct { + name string + nodeScanJob *v1alpha1.NodeScanJob + existingObjects []runtime.Object + }{ + { + name: "nodescanjob not found", + nodeScanJob: nodeScanJob, + existingObjects: []runtime.Object{node, config}, + }, + { + name: "nodescanjob is failed", + nodeScanJob: failedNodeScanJob, + existingObjects: []runtime.Object{failedNodeScanJob, node, config}, + }, + { + name: "node not found", + nodeScanJob: nodeScanJob, + existingObjects: []runtime.Object{nodeScanJob, config}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + scheme := scheme.Scheme + require.NoError(t, storagev1alpha1.AddToScheme(scheme)) + require.NoError(t, v1alpha1.AddToScheme(scheme)) + require.NoError(t, corev1.AddToScheme(scheme)) + + k8sClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithRuntimeObjects(test.existingObjects...). + WithIndex(&storagev1alpha1.NodeSBOM{}, storagev1alpha1.IndexNodeMetadataName, func(obj client.Object) []string { + sbom, ok := obj.(*storagev1alpha1.NodeSBOM) + if !ok { + return nil + } + return []string{sbom.NodeMetadata.Name} + }). + Build() + + publisher := messagingMocks.NewMockPublisher(t) + + handler := NewGenerateNodeSBOMHandler(k8sClient, scheme, t.TempDir(), "/tmp", testTrivyJavaDBRepository, publisher, "sbomscanner", slog.Default()) + + message, err := json.Marshal(&GenerateNodeSBOMMessage{ + NodeBaseMessage: NodeBaseMessage{ + NodeScanJob: ObjectRef{ + Name: test.nodeScanJob.Name, + UID: string(test.nodeScanJob.UID), + }, + }, + Node: ObjectRef{ + Name: nodeName, + }, + }) + require.NoError(t, err) + + err = handler.Handle(context.Background(), &testMessage{data: message}) + require.NoError(t, err) + + sbom := &storagev1alpha1.NodeSBOM{} + err = k8sClient.Get(context.Background(), types.NamespacedName{Name: nodeName}, sbom) + assert.True(t, apierrors.IsNotFound(err), "NodeSBOM should not exist") + }) + } +} + +func TestGenerateNodeSBOMHandler_Handle_ReuseSBOMWithSameNodeName(t *testing.T) { + targetDir := filepath.Join("..", "..", "test", "fixtures", "node") + nodeName := "test-node" + + expectedSPDXContent := []byte(`{"spdxVersion":"SPDX-2.3","dataLicense":"CC0-1.0"}`) + + existingNodeSBOM := &storagev1alpha1.NodeSBOM{ + ObjectMeta: metav1.ObjectMeta{ + Name: "existing-node-sbom", + UID: "existing-sbom-uid", + }, + NodeMetadata: storagev1alpha1.NodeMetadata{ + Name: nodeName, + Platform: "linux/amd64", + }, + SPDX: runtime.RawExtension{Raw: expectedSPDXContent}, + } + + node := &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: nodeName, + UID: "test-node-uid", + }, + Status: corev1.NodeStatus{ + NodeInfo: corev1.NodeSystemInfo{ + OperatingSystem: "linux", + Architecture: "amd64", + }, + }, + } + + nodeScanJob := &v1alpha1.NodeScanJob{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-nodescanjob", + UID: "test-nodescanjob-uid", + }, + Spec: v1alpha1.NodeScanJobSpec{ + NodeName: nodeName, + }, + } + + config := &v1alpha1.NodeScanConfiguration{ + ObjectMeta: metav1.ObjectMeta{ + Name: v1alpha1.NodeScanConfigurationName, + }, + } + + scheme := scheme.Scheme + require.NoError(t, storagev1alpha1.AddToScheme(scheme)) + require.NoError(t, v1alpha1.AddToScheme(scheme)) + require.NoError(t, corev1.AddToScheme(scheme)) + + k8sClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithRuntimeObjects(existingNodeSBOM, node, nodeScanJob, config). + WithStatusSubresource(&v1alpha1.NodeScanJob{}). + WithIndex(&storagev1alpha1.NodeSBOM{}, storagev1alpha1.IndexNodeMetadataName, func(obj client.Object) []string { + sbom, ok := obj.(*storagev1alpha1.NodeSBOM) + if !ok { + return nil + } + return []string{sbom.NodeMetadata.Name} + }). + Build() + + publisher := messagingMocks.NewMockPublisher(t) + + expectedScanMessage, err := json.Marshal(&ScanNodeSBOMMessage{ + NodeBaseMessage: NodeBaseMessage{ + NodeScanJob: ObjectRef{ + Name: nodeScanJob.Name, + UID: string(nodeScanJob.UID), + }, + }, + NodeSBOM: ObjectRef{ + Name: nodeName, + }, + }) + require.NoError(t, err) + + publisher.On("Publish", + mock.Anything, + ScanNodeSBOMSubject+"."+nodeScanJob.Spec.NodeName, + fmt.Sprintf("nodeScanSBOM/%s/%s", nodeScanJob.UID, nodeName), + expectedScanMessage, + ).Return(nil).Once() + + handler := NewGenerateNodeSBOMHandler(k8sClient, scheme, t.TempDir(), targetDir, testTrivyJavaDBRepository, publisher, "sbomscanner", slog.Default()) + + message, err := json.Marshal(&GenerateNodeSBOMMessage{ + NodeBaseMessage: NodeBaseMessage{ + NodeScanJob: ObjectRef{ + Name: nodeScanJob.Name, + UID: string(nodeScanJob.UID), + }, + }, + Node: ObjectRef{ + Name: nodeName, + }, + }) + require.NoError(t, err) + + err = handler.Handle(t.Context(), &testMessage{data: message}) + require.NoError(t, err) + + newSBOM := &storagev1alpha1.NodeSBOM{} + err = k8sClient.Get(t.Context(), types.NamespacedName{Name: nodeName}, newSBOM) + require.NoError(t, err) + + assert.Equal(t, expectedSPDXContent, newSBOM.SPDX.Raw, "SPDX content should be reused from existing NodeSBOM") + assert.Equal(t, nodeName, newSBOM.NodeMetadata.Name) + assert.Equal(t, "linux/amd64", newSBOM.NodeMetadata.Platform) + assert.Equal(t, node.UID, newSBOM.GetOwnerReferences()[0].UID) + assert.Equal(t, api.LabelManagedByValue, newSBOM.Labels[api.LabelManagedByKey]) + assert.Equal(t, api.LabelPartOfValue, newSBOM.Labels[api.LabelPartOfKey]) +} diff --git a/internal/handlers/generate_sbom.go b/internal/handlers/generate_sbom.go index 819d0fffd..207c0f7c1 100644 --- a/internal/handlers/generate_sbom.go +++ b/internal/handlers/generate_sbom.go @@ -266,14 +266,20 @@ func (h *GenerateSBOMHandler) generateSPDX(ctx context.Context, image *storagev1 args := []string{ "image", + //nolint:goconst // These are specific Trivy command arguments, not constant values used elsewhere "--skip-version-check", + //nolint:goconst // These are specific Trivy command arguments, not constant values used elsewhere "--disable-telemetry", + //nolint:goconst // These are specific Trivy command arguments, not constant values used elsewhere "--cache-dir", h.workDir, + //nolint:goconst // These are specific Trivy command arguments, not constant values used elsewhere "--format", "spdx-json", "--skip-db-update", // The Java DB is needed to generate SBOMs for images containing Java components // See: https://github.com/aquasecurity/trivy/discussions/9666 + //nolint:goconst // These are specific Trivy command arguments, not constant values used elsewhere "--java-db-repository", h.trivyJavaDBRepository, + //nolint:goconst // These are specific Trivy command arguments, not constant values used elsewhere "--output", sbomFile.Name(), } diff --git a/internal/handlers/generate_sbom_test.go b/internal/handlers/generate_sbom_test.go index a5b42d9d1..89aae2b6c 100644 --- a/internal/handlers/generate_sbom_test.go +++ b/internal/handlers/generate_sbom_test.go @@ -409,7 +409,7 @@ func TestGenerateSBOMHandler_Handle_StopProcessing(t *testing.T) { differentUIDScanJob.UID = "test-scanjob-different-uid" failedScanJob := scanJob.DeepCopy() - failedScanJob.MarkFailed(v1alpha1.ReasonInternalError, "kaboom") + failedScanJob.MarkFailed(v1alpha1.ReasonScanJobInternalError, "kaboom") tests := []struct { name string diff --git a/internal/handlers/image_scan_sbom.go b/internal/handlers/image_scan_sbom.go new file mode 100644 index 000000000..fd2596c94 --- /dev/null +++ b/internal/handlers/image_scan_sbom.go @@ -0,0 +1,156 @@ +package handlers + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + + "github.com/kubewarden/sbomscanner/api" + storagev1alpha1 "github.com/kubewarden/sbomscanner/api/storage/v1alpha1" + "github.com/kubewarden/sbomscanner/api/v1alpha1" + "github.com/kubewarden/sbomscanner/internal/messaging" +) + +// ImageScanSBOMHandler handles SBOM scan requests for container images. +type ImageScanSBOMHandler struct { + scanSBOMBase +} + +// NewScanSBOMHandler creates a new instance of ImageScanSBOMHandler for container images. +func NewScanSBOMHandler( + k8sClient client.Client, + scheme *runtime.Scheme, + workDir string, + trivyDBRepository string, + trivyJavaDBRepository string, + logger *slog.Logger, +) *ImageScanSBOMHandler { + return &ImageScanSBOMHandler{ + scanSBOMBase: scanSBOMBase{ + k8sClient: k8sClient, + scheme: scheme, + workDir: workDir, + trivyDBRepository: trivyDBRepository, + trivyJavaDBRepository: trivyJavaDBRepository, + logger: logger.With("handler", "scan_sbom_handler"), + }, + } +} + +//nolint:funlen +func (h *ImageScanSBOMHandler) Handle(ctx context.Context, message messaging.Message) error { + scanSBOMMessage := &ScanSBOMMessage{} + if err := json.Unmarshal(message.Data(), scanSBOMMessage); err != nil { + return fmt.Errorf("failed to unmarshal scan job message: %w", err) + } + + scanJobName := scanSBOMMessage.ScanJob.Name + scanJobNamespace := scanSBOMMessage.ScanJob.Namespace + scanJobUID := scanSBOMMessage.ScanJob.UID + sbomName := scanSBOMMessage.SBOM.Name + sbomNamespace := scanSBOMMessage.SBOM.Namespace + + h.logger.InfoContext(ctx, "SBOM scan requested", + "sbom", sbomName, + "namespace", sbomNamespace, + ) + + scanJob := &v1alpha1.ScanJob{} + if err := h.k8sClient.Get(ctx, client.ObjectKey{ + Name: scanJobName, + Namespace: scanJobNamespace, + }, scanJob); err != nil { + if apierrors.IsNotFound(err) { + h.logger.ErrorContext(ctx, "ScanJob not found, stopping SBOM scan", "scanJob", scanJobName, "namespace", scanJobNamespace) + return nil + } + + return fmt.Errorf("failed to get ScanJob: %w", err) + } + + if string(scanJob.GetUID()) != scanJobUID { + h.logger.InfoContext(ctx, "ScanJob not found, stopping SBOM generation (UID changed)", "scanjob", scanJobName, "namespace", scanJobNamespace, + "uid", scanJobUID) + return nil + } + + if scanJob.IsFailed() { + h.logger.InfoContext(ctx, "ScanJob is in failed state, stopping SBOM scan", "scanjob", scanJobName, "namespace", scanJobNamespace) + return nil + } + + registryData, ok := scanJob.Annotations[v1alpha1.AnnotationScanJobRegistryKey] + if !ok { + return fmt.Errorf("scan job %s/%s does not have a registry annotation", scanJobNamespace, scanJobName) + } + + registry := &v1alpha1.Registry{} + if err := json.Unmarshal([]byte(registryData), registry); err != nil { + return fmt.Errorf("cannot unmarshal registry data from scan job %s/%s: %w", scanJobNamespace, scanJobName, err) + } + + sbom := &storagev1alpha1.SBOM{} + if err := h.k8sClient.Get(ctx, client.ObjectKey{ + Name: sbomName, + Namespace: sbomNamespace, + }, sbom); err != nil { + if apierrors.IsNotFound(err) { + h.logger.ErrorContext(ctx, "SBOM not found, stopping SBOM scan", "sbom", sbomName, "namespace", sbomNamespace) + return nil + } + + return fmt.Errorf("failed to get SBOM: %w", err) + } + + results, summary, err := h.runTrivyScan(ctx, sbom.SPDX.Raw, message) + if err != nil { + return err + } + + h.logger.InfoContext(ctx, "SBOM scanned", + "sbom", sbomName, + "namespace", sbomNamespace, + ) + + vulnerabilityReport := &storagev1alpha1.VulnerabilityReport{ + ObjectMeta: metav1.ObjectMeta{ + Name: sbomName, + Namespace: sbomNamespace, + }, + } + if err = controllerutil.SetControllerReference(sbom, vulnerabilityReport, h.scheme); err != nil { + return fmt.Errorf("failed to set owner reference: %w", err) + } + + _, err = controllerutil.CreateOrUpdate(ctx, h.k8sClient, vulnerabilityReport, func() error { + vulnerabilityReport.Labels = map[string]string{ + v1alpha1.LabelScanJobUIDKey: scanJobUID, + api.LabelManagedByKey: api.LabelManagedByValue, + api.LabelPartOfKey: api.LabelPartOfValue, + } + if registry.Labels[api.LabelWorkloadScanKey] == api.LabelWorkloadScanValue { + vulnerabilityReport.Labels[api.LabelWorkloadScanKey] = api.LabelWorkloadScanValue + } + + vulnerabilityReport.ImageMetadata = sbom.GetImageMetadata() + vulnerabilityReport.Report = storagev1alpha1.Report{ + Summary: summary, + Results: results, + } + + return nil + }) + if err != nil { + return fmt.Errorf("failed to create or update vulnerability report: %w", err) + } + + return nil +} diff --git a/internal/handlers/messages.go b/internal/handlers/messages.go index 48b137d43..096abf299 100644 --- a/internal/handlers/messages.go +++ b/internal/handlers/messages.go @@ -1,9 +1,11 @@ package handlers const ( - GenerateSBOMSubject = "sbomscanner.sbom.generate" - ScanSBOMSubject = "sbomscanner.sbom.scan" - CreateCatalogSubject = "sbomscanner.catalog.create" + GenerateSBOMSubject = "sbomscanner.sbom.generate" + ScanSBOMSubject = "sbomscanner.sbom.scan" + CreateCatalogSubject = "sbomscanner.catalog.create" + GenerateNodeSBOMSubject = "sbomscanner.nodesbom.generate" + ScanNodeSBOMSubject = "sbomscanner.nodesbom.scan" ) // ObjectRef is a reference to a Kubernetes object, used in messages to identify resources. @@ -38,3 +40,22 @@ type ScanSBOMMessage struct { SBOM ObjectRef `json:"sbom"` } + +// NodeBaseMessage is the base structure for node messages. +type NodeBaseMessage struct { + NodeScanJob ObjectRef `json:"nodescanjob"` +} + +// GenerateNodeSBOMMessage represents the request message for generating a node SBOM. +type GenerateNodeSBOMMessage struct { + NodeBaseMessage + + Node ObjectRef `json:"node"` +} + +// ScanNodeSBOMMessage represents the request message for scanning a node SBOM. +type ScanNodeSBOMMessage struct { + NodeBaseMessage + + NodeSBOM ObjectRef `json:"nodesbom"` +} diff --git a/internal/handlers/node_scan_sbom.go b/internal/handlers/node_scan_sbom.go new file mode 100644 index 000000000..1a1fa4ee8 --- /dev/null +++ b/internal/handlers/node_scan_sbom.go @@ -0,0 +1,182 @@ +package handlers + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/util/retry" + + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + + "github.com/kubewarden/sbomscanner/api" + storagev1alpha1 "github.com/kubewarden/sbomscanner/api/storage/v1alpha1" + "github.com/kubewarden/sbomscanner/api/v1alpha1" + "github.com/kubewarden/sbomscanner/internal/messaging" +) + +// NodeScanSBOMHandler handles SBOM scan requests for nodes. +type NodeScanSBOMHandler struct { + scanSBOMBase +} + +// NewScanNodeSBOMHandler creates a new instance of NodeScanSBOMHandler for nodes. +func NewScanNodeSBOMHandler( + k8sClient client.Client, + scheme *runtime.Scheme, + workDir string, + trivyDBRepository string, + trivyJavaDBRepository string, + logger *slog.Logger, +) *NodeScanSBOMHandler { + return &NodeScanSBOMHandler{ + scanSBOMBase: scanSBOMBase{ + k8sClient: k8sClient, + scheme: scheme, + workDir: workDir, + trivyDBRepository: trivyDBRepository, + trivyJavaDBRepository: trivyJavaDBRepository, + logger: logger.With("handler", "scan_node_sbom_handler"), + }, + } +} + +//nolint:funlen +func (h *NodeScanSBOMHandler) Handle(ctx context.Context, message messaging.Message) error { + scanNodeSBOMMessage := &ScanNodeSBOMMessage{} + if err := json.Unmarshal(message.Data(), scanNodeSBOMMessage); err != nil { + return fmt.Errorf("failed to unmarshal scan job message: %w", err) + } + + nodeScanJobName := scanNodeSBOMMessage.NodeScanJob.Name + nodeScanJobNamespace := scanNodeSBOMMessage.NodeScanJob.Namespace + nodeScanJobUID := scanNodeSBOMMessage.NodeScanJob.UID + nodeSBOMName := scanNodeSBOMMessage.NodeSBOM.Name + nodeSBOMNamespace := scanNodeSBOMMessage.NodeSBOM.Namespace + + h.logger.InfoContext(ctx, "SBOM scan requested", + "sbom", nodeSBOMName, + "namespace", nodeSBOMNamespace, + ) + + scanJob := &v1alpha1.NodeScanJob{} + if err := h.k8sClient.Get(ctx, client.ObjectKey{ + Name: nodeScanJobName, + Namespace: nodeScanJobNamespace, + }, scanJob); err != nil { + if apierrors.IsNotFound(err) { + h.logger.ErrorContext(ctx, "ScanJob not found, stopping SBOM scan", "scanJob", nodeScanJobName, "namespace", nodeScanJobNamespace) + return nil + } + + return fmt.Errorf("failed to get ScanJob: %w", err) + } + + if string(scanJob.GetUID()) != nodeScanJobUID { + h.logger.InfoContext(ctx, "ScanJob not found, stopping SBOM generation (UID changed)", "scanjob", nodeScanJobName, "namespace", nodeScanJobNamespace, + "uid", nodeScanJobUID) + return nil + } + + if scanJob.IsFailed() { + h.logger.InfoContext(ctx, "ScanJob is in failed state, stopping SBOM scan", "scanjob", nodeScanJobName, "namespace", nodeScanJobNamespace) + return nil + } + + err := retry.RetryOnConflict(retry.DefaultRetry, func() error { + if err := h.k8sClient.Get(ctx, client.ObjectKey{ + Name: nodeScanJobName, + Namespace: nodeScanJobNamespace, + }, scanJob); err != nil { + return fmt.Errorf("failed to get ScanJob: %w", err) + } + + scanJob.MarkInProgress(v1alpha1.ReasonScanJobSBOMGenerationInProgress, "SBOM vulnerability scan in progress") + return h.k8sClient.Status().Update(ctx, scanJob) + }) + if err != nil { + return fmt.Errorf("failed to update NodeScanJob status to SBOM generation in progress: %w", err) + } + + sbom := &storagev1alpha1.NodeSBOM{} + if err := h.k8sClient.Get(ctx, client.ObjectKey{ + Name: nodeSBOMName, + Namespace: nodeSBOMNamespace, + }, sbom); err != nil { + if apierrors.IsNotFound(err) { + h.logger.ErrorContext(ctx, "SBOM not found, stopping SBOM scan", "sbom", nodeSBOMName, "namespace", nodeSBOMNamespace) + return nil + } + + return fmt.Errorf("failed to get SBOM: %w", err) + } + + results, summary, err := h.runTrivyScan(ctx, sbom.SPDX.Raw, message) + if err != nil { + return err + } + + h.logger.InfoContext(ctx, "SBOM scanned", + "sbom", nodeSBOMName, + "namespace", nodeSBOMNamespace, + ) + + nodeVulnerabilityReport := &storagev1alpha1.NodeVulnerabilityReport{ + ObjectMeta: metav1.ObjectMeta{ + Name: nodeSBOMName, + }, + } + + _, err = controllerutil.CreateOrUpdate(ctx, h.k8sClient, nodeVulnerabilityReport, func() error { + if err = controllerutil.SetControllerReference(sbom, nodeVulnerabilityReport, h.scheme); err != nil { + return fmt.Errorf("failed to set owner reference: %w", err) + } + + nodeVulnerabilityReport.Labels = map[string]string{ + v1alpha1.LabelNodeScanJobUIDKey: nodeScanJobUID, + api.LabelManagedByKey: api.LabelManagedByValue, + api.LabelPartOfKey: api.LabelPartOfValue, + } + + nodeVulnerabilityReport.NodeMetadata = sbom.GetNodeMetadata() + nodeVulnerabilityReport.Report = storagev1alpha1.Report{ + Summary: summary, + Results: results, + } + + return nil + }) + if err != nil { + return fmt.Errorf("failed to create or update nodevulnerability report: %w", err) + } + h.logger.InfoContext(ctx, "Vulnerability report created or updated", + "sbom", nodeSBOMName, + "namespace", nodeSBOMNamespace, + ) + + err = retry.RetryOnConflict(retry.DefaultRetry, func() error { + if err := h.k8sClient.Get(ctx, client.ObjectKey{ + Name: nodeScanJobName, + Namespace: nodeScanJobNamespace, + }, scanJob); err != nil { + return fmt.Errorf("failed to get ScanJob: %w", err) + } + + scanJob.MarkComplete(v1alpha1.ReasonNodeScanJobScanned, "Node SBOM scanned successfully") + return h.k8sClient.Status().Update(ctx, scanJob) + }) + if err != nil { + return fmt.Errorf("failed to update NodeScanJob status: %w", err) + } + h.logger.InfoContext(ctx, "SBOM scanned", + "sbom", nodeSBOMName, + "namespace", nodeSBOMNamespace, + ) + + return nil +} diff --git a/internal/handlers/nodescanjob_failure.go b/internal/handlers/nodescanjob_failure.go new file mode 100644 index 000000000..5454e2a32 --- /dev/null +++ b/internal/handlers/nodescanjob_failure.go @@ -0,0 +1,70 @@ +package handlers + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/client-go/util/retry" + "sigs.k8s.io/controller-runtime/pkg/client" + + v1alpha1 "github.com/kubewarden/sbomscanner/api/v1alpha1" + "github.com/kubewarden/sbomscanner/internal/messaging" +) + +// NodeScanJobFailureHandler handles failures for messages related to node scan jobs. +type NodeScanJobFailureHandler struct { + k8sClient client.Client + logger *slog.Logger +} + +// NewNodeScanJobFailureHandler creates a new instance of NodeScanJobFailureHandler. +func NewNodeScanJobFailureHandler( + k8sClient client.Client, + logger *slog.Logger, +) *NodeScanJobFailureHandler { + return &NodeScanJobFailureHandler{ + k8sClient: k8sClient, + logger: logger.With("handler", "nodescanjob_failure_handler"), + } +} + +// HandleFailure processes message failures and updates the associated NodeScanJob status. +func (h *NodeScanJobFailureHandler) HandleFailure(ctx context.Context, message messaging.Message, errorMessage string) error { + nodeBaseMessage := &NodeBaseMessage{} + if err := json.Unmarshal(message.Data(), nodeBaseMessage); err != nil { + return fmt.Errorf("failed to unmarshal node base message: %w", err) + } + h.logger.DebugContext(ctx, "Handling NodeScanJob failure", + "nodescanjob", nodeBaseMessage.NodeScanJob.Name, + "error", errorMessage, + ) + + nodeScanJob := &v1alpha1.NodeScanJob{} + + err := retry.RetryOnConflict(retry.DefaultRetry, func() error { + if err := h.k8sClient.Get(ctx, client.ObjectKey{ + Name: nodeBaseMessage.NodeScanJob.Name, + }, nodeScanJob); err != nil { + return fmt.Errorf("cannot get NodeScanJob %s: %w", nodeBaseMessage.NodeScanJob.Name, err) + } + + nodeScanJob.MarkFailed(v1alpha1.ReasonScanJobInternalError, errorMessage) + return h.k8sClient.Status().Update(ctx, nodeScanJob) + }) + if err != nil { + if apierrors.IsNotFound(err) { + h.logger.InfoContext(ctx, "NodeScanJob not found, skipping updating NodeScanJob status to failed", "nodescanjob", nodeBaseMessage.NodeScanJob.Name) + return nil + } + return fmt.Errorf("failed to update NodeScanJob %s status to failed: %w", nodeScanJob.Name, err) + } + + h.logger.DebugContext(ctx, "NodeScanJob marked as failed", + "nodescanjob", nodeScanJob.Name, + "error_message", errorMessage, + ) + return nil +} diff --git a/internal/handlers/scan_sbom.go b/internal/handlers/scan_sbom.go deleted file mode 100644 index f4307dc95..000000000 --- a/internal/handlers/scan_sbom.go +++ /dev/null @@ -1,316 +0,0 @@ -package handlers - -import ( - "context" - "encoding/json" - "fmt" - "io" - "log/slog" - "os" - "path" - - "go.yaml.in/yaml/v3" - _ "modernc.org/sqlite" // sqlite driver for RPM DB and Java DB - - apierrors "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - - vexrepo "github.com/aquasecurity/trivy/pkg/vex/repo" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" - - trivyCommands "github.com/aquasecurity/trivy/pkg/commands" - trivyTypes "github.com/aquasecurity/trivy/pkg/types" - "github.com/kubewarden/sbomscanner/api" - storagev1alpha1 "github.com/kubewarden/sbomscanner/api/storage/v1alpha1" - "github.com/kubewarden/sbomscanner/api/v1alpha1" - trivyreport "github.com/kubewarden/sbomscanner/internal/handlers/trivyreport" - "github.com/kubewarden/sbomscanner/internal/messaging" -) - -const ( - // trivyVEXSubPath is the directory used by trivy to hold VEX repositories. - trivyVEXSubPath = ".trivy/vex" - // trivyVEXRepoFile is the file used by trivy to hold VEX repositories. - trivyVEXRepoFile = "repository.yaml" -) - -// ScanSBOMHandler is responsible for handling SBOM scan requests. -type ScanSBOMHandler struct { - k8sClient client.Client - scheme *runtime.Scheme - workDir string - trivyDBRepository string - trivyJavaDBRepository string - logger *slog.Logger -} - -// NewScanSBOMHandler creates a new instance of ScanSBOMHandler. -func NewScanSBOMHandler( - k8sClient client.Client, - scheme *runtime.Scheme, - workDir string, - trivyDBRepository string, - trivyJavaDBRepository string, - logger *slog.Logger, -) *ScanSBOMHandler { - return &ScanSBOMHandler{ - k8sClient: k8sClient, - scheme: scheme, - workDir: workDir, - trivyDBRepository: trivyDBRepository, - trivyJavaDBRepository: trivyJavaDBRepository, - logger: logger.With("handler", "scan_sbom_handler"), - } -} - -// Handle processes the ScanSBOMMessage and scans the specified SBOM resource for vulnerabilities. -func (h *ScanSBOMHandler) Handle(ctx context.Context, message messaging.Message) error { //nolint:funlen,gocognit,gocyclo,cyclop // TODO: refactor this function in smaller ones - scanSBOMMessage := &ScanSBOMMessage{} - if err := json.Unmarshal(message.Data(), scanSBOMMessage); err != nil { - return fmt.Errorf("failed to unmarshal scan job message: %w", err) - } - - h.logger.InfoContext(ctx, "SBOM scan requested", - "sbom", scanSBOMMessage.SBOM.Name, - "namespace", scanSBOMMessage.SBOM.Namespace, - ) - - scanJob := &v1alpha1.ScanJob{} - err := h.k8sClient.Get(ctx, client.ObjectKey{ - Name: scanSBOMMessage.ScanJob.Name, - Namespace: scanSBOMMessage.ScanJob.Namespace, - }, scanJob) - if err != nil { - if apierrors.IsNotFound(err) { - // Stop processing if the scanjob is not found, since it might have been deleted. - h.logger.ErrorContext(ctx, "ScanJob not found, stopping SBOM scan", "scanJob", scanSBOMMessage.ScanJob.Name, "namespace", scanSBOMMessage.ScanJob.Namespace) - return nil - } - return fmt.Errorf("failed to get ScanJob: %w", err) - } - if string(scanJob.GetUID()) != scanSBOMMessage.ScanJob.UID { - h.logger.InfoContext(ctx, "ScanJob not found, stopping SBOM generation (UID changed)", "scanjob", scanSBOMMessage.ScanJob.Name, "namespace", scanSBOMMessage.ScanJob.Namespace, - "uid", scanSBOMMessage.ScanJob.UID) - return nil - } - - h.logger.DebugContext(ctx, "ScanJob found", "scanjob", scanJob) - - if scanJob.IsFailed() { - h.logger.InfoContext(ctx, "ScanJob is in failed state, stopping SBOM scan", "scanjob", scanJob.Name, "namespace", scanJob.Namespace) - return nil - } - - // Retrieve the registry from the scan job annotations. - registryData, ok := scanJob.Annotations[v1alpha1.AnnotationScanJobRegistryKey] - if !ok { - return fmt.Errorf("scan job %s/%s does not have a registry annotation", scanJob.Namespace, scanJob.Name) - } - registry := &v1alpha1.Registry{} - if err = json.Unmarshal([]byte(registryData), registry); err != nil { - return fmt.Errorf("cannot unmarshal registry data from scan job %s/%s: %w", scanJob.Namespace, scanJob.Name, err) - } - - sbom := &storagev1alpha1.SBOM{} - err = h.k8sClient.Get(ctx, client.ObjectKey{ - Name: scanSBOMMessage.SBOM.Name, - Namespace: scanSBOMMessage.SBOM.Namespace, - }, sbom) - if err != nil { - // Stop processing if the SBOM is not found, since it might have been deleted. - if apierrors.IsNotFound(err) { - h.logger.ErrorContext(ctx, "SBOM not found, stopping SBOM scan", "sbom", scanSBOMMessage.SBOM.Name, "namespace", scanSBOMMessage.SBOM.Namespace) - return nil - } - return fmt.Errorf("failed to get SBOM: %w", err) - } - - vexHubList := &v1alpha1.VEXHubList{} - err = h.k8sClient.List(ctx, vexHubList, &client.ListOptions{}) - if err != nil { - return fmt.Errorf("failed to list VEXHub: %w", err) - } - - sbomFile, err := os.CreateTemp(h.workDir, "trivy.sbom.*.json") - if err != nil { - return fmt.Errorf("failed to create temporary SBOM file: %w", err) - } - defer func() { - if err = sbomFile.Close(); err != nil { - h.logger.ErrorContext(ctx, "failed to close temporary SBOM file", "error", err) - } - - if err = os.Remove(sbomFile.Name()); err != nil { - h.logger.ErrorContext(ctx, "failed to remove temporary SBOM file", "error", err) - } - }() - - _, err = sbomFile.Write(sbom.SPDX.Raw) - if err != nil { - return fmt.Errorf("failed to write SBOM file: %w", err) - } - reportFile, err := os.CreateTemp(h.workDir, "trivy.report.*.json") - if err != nil { - return fmt.Errorf("failed to create temporary report file: %w", err) - } - defer func() { - if err = reportFile.Close(); err != nil { - h.logger.ErrorContext(ctx, "failed to close temporary report file", "error", err) - } - - if err = os.Remove(reportFile.Name()); err != nil { - h.logger.ErrorContext(ctx, "failed to remove temporary report file", "error", err) - } - }() - - trivyArgs := []string{ - "sbom", - "--skip-version-check", - "--disable-telemetry", - "--cache-dir", h.workDir, - "--format", "json", - "--db-repository", h.trivyDBRepository, - "--java-db-repository", h.trivyJavaDBRepository, - "--output", reportFile.Name(), - } - // Set XDG_DATA_HOME environment variable to /tmp because trivy expects - // the repository file in that location and there is no way to change it - // through input flags: - // https://trivy.dev/v0.64/docs/supply-chain/vex/repo/#default-configuration - // TODO(alegrey91): fix upstream - trivyHome, err := os.MkdirTemp("/tmp", "trivy-") - if err != nil { - return fmt.Errorf("failed to create temporary trivy home: %w", err) - } - err = os.Setenv("XDG_DATA_HOME", trivyHome) - if err != nil { - return fmt.Errorf("failed to set XDG_DATA_HOME to %s: %w", trivyHome, err) - } - - if len(vexHubList.Items) > 0 { - trivyVEXPath := path.Join(trivyHome, trivyVEXSubPath) - vexRepoPath := path.Join(trivyVEXPath, trivyVEXRepoFile) - if err = h.setupVEXHubRepositories(vexHubList, trivyVEXPath, vexRepoPath); err != nil { - return fmt.Errorf("failed to setup VEX Hub repositories: %w", err) - } - // Clean up the trivy home directory after each handler execution to - // ensure VEX repositories are refreshed on every run. - defer func() { - h.logger.DebugContext(ctx, "Removing trivy home") - if err = os.RemoveAll(trivyHome); err != nil { - h.logger.ErrorContext(ctx, "failed to remove temporary trivy home", "error", err) - } - }() - - // We explicitly set the `--vex` option only when needed - // (VEXHub resources are found). This is because trivy automatically - // fills the repository file with aquasecurity VEX files, when - // `--vex` is specificed. - trivyArgs = append(trivyArgs, "--vex", "repo", "--show-suppressed") - } - - app := trivyCommands.NewApp() - // add SBOM file name at the end. - trivyArgs = append(trivyArgs, sbomFile.Name()) - app.SetArgs(trivyArgs) - - if err = app.ExecuteContext(ctx); err != nil { - return fmt.Errorf("failed to execute trivy: %w", err) - } - - h.logger.InfoContext(ctx, "SBOM scanned", - "sbom", scanSBOMMessage.SBOM.Name, - "namespace", scanSBOMMessage.SBOM.Namespace, - ) - - if err = message.InProgress(); err != nil { - return fmt.Errorf("failed to ack message as in progress: %w", err) - } - - reportBytes, err := io.ReadAll(reportFile) - if err != nil { - return fmt.Errorf("failed to read SBOM output: %w", err) - } - - reportOrig := trivyTypes.Report{} - err = json.Unmarshal(reportBytes, &reportOrig) - if err != nil { - return fmt.Errorf("failed to unmarshal report: %w", err) - } - - results, err := trivyreport.NewResultsFromTrivyReport(reportOrig) - if err != nil { - return fmt.Errorf("failed to convert from trivy results: %w", err) - } - summary := storagev1alpha1.NewSummaryFromResults(results) - - vulnerabilityReport := &storagev1alpha1.VulnerabilityReport{ - ObjectMeta: metav1.ObjectMeta{ - Name: sbom.Name, - Namespace: sbom.Namespace, - }, - } - if err = controllerutil.SetControllerReference(sbom, vulnerabilityReport, h.scheme); err != nil { - return fmt.Errorf("failed to set owner reference: %w", err) - } - - _, err = controllerutil.CreateOrUpdate(ctx, h.k8sClient, vulnerabilityReport, func() error { - vulnerabilityReport.Labels = map[string]string{ - v1alpha1.LabelScanJobUIDKey: string(scanJob.UID), - api.LabelManagedByKey: api.LabelManagedByValue, - api.LabelPartOfKey: api.LabelPartOfValue, - } - if registry.Labels[api.LabelWorkloadScanKey] == api.LabelWorkloadScanValue { - vulnerabilityReport.Labels[api.LabelWorkloadScanKey] = api.LabelWorkloadScanValue - } - - vulnerabilityReport.ImageMetadata = sbom.GetImageMetadata() - vulnerabilityReport.Report = storagev1alpha1.Report{ - Summary: summary, - Results: results, - } - return nil - }) - if err != nil { - return fmt.Errorf("failed to create or update vulnerability report: %w", err) - } - - return nil -} - -// setupVEXHubRepositories creates all the necessary files and directories -// to use VEX Hub repositories. -func (h *ScanSBOMHandler) setupVEXHubRepositories(vexHubList *v1alpha1.VEXHubList, trivyVEXPath, vexRepoPath string) error { - config := vexrepo.Config{} - var err error - for _, repo := range vexHubList.Items { - repo := vexrepo.Repository{ - Name: repo.Name, - URL: repo.Spec.URL, - Enabled: repo.Spec.Enabled, - } - config.Repositories = append(config.Repositories, repo) - } - - var repositories []byte - repositories, err = yaml.Marshal(config) - if err != nil { - return fmt.Errorf("failed to marshal struct: %w", err) - } - - h.logger.Debug("Creating VEX repository directory", "vexhub", trivyVEXPath) - err = os.MkdirAll(trivyVEXPath, 0o750) - if err != nil { - return fmt.Errorf("failed to create VEX configuration directory: %w", err) - } - - h.logger.Debug("Creating VEX repository file", "vexhub", vexRepoPath) - err = os.WriteFile(vexRepoPath, repositories, 0o600) - if err != nil { - return fmt.Errorf("failed to create VEX repository file: %w", err) - } - - return nil -} diff --git a/internal/handlers/scan_sbom_helpers.go b/internal/handlers/scan_sbom_helpers.go new file mode 100644 index 000000000..f58ba2dd0 --- /dev/null +++ b/internal/handlers/scan_sbom_helpers.go @@ -0,0 +1,181 @@ +package handlers + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "os" + "path" + + "go.yaml.in/yaml/v3" + _ "modernc.org/sqlite" // sqlite driver for RPM DB and Java DB + + "k8s.io/apimachinery/pkg/runtime" + + vexrepo "github.com/aquasecurity/trivy/pkg/vex/repo" + "sigs.k8s.io/controller-runtime/pkg/client" + + trivyCommands "github.com/aquasecurity/trivy/pkg/commands" + trivyTypes "github.com/aquasecurity/trivy/pkg/types" + storagev1alpha1 "github.com/kubewarden/sbomscanner/api/storage/v1alpha1" + "github.com/kubewarden/sbomscanner/api/v1alpha1" + trivyreport "github.com/kubewarden/sbomscanner/internal/handlers/trivyreport" + "github.com/kubewarden/sbomscanner/internal/messaging" +) + +const ( + trivyVEXSubPath = ".trivy/vex" + trivyVEXRepoFile = "repository.yaml" +) + +type scanSBOMBase struct { + k8sClient client.Client + scheme *runtime.Scheme + workDir string + trivyDBRepository string + trivyJavaDBRepository string + logger *slog.Logger +} + +// runTrivyScan executes a trivy scan on the given SPDX data and returns the parsed results and summary. +func (b *scanSBOMBase) runTrivyScan(ctx context.Context, rawSPDX []byte, message messaging.Message) ([]storagev1alpha1.Result, storagev1alpha1.Summary, error) { //nolint:funlen,gocognit // trivy setup requires sequential steps + vexHubList := &v1alpha1.VEXHubList{} + if err := b.k8sClient.List(ctx, vexHubList, &client.ListOptions{}); err != nil { + return nil, storagev1alpha1.Summary{}, fmt.Errorf("failed to list VEXHub: %w", err) + } + + sbomFile, err := os.CreateTemp(b.workDir, "trivy.sbom.*.json") + if err != nil { + return nil, storagev1alpha1.Summary{}, fmt.Errorf("failed to create temporary SBOM file: %w", err) + } + defer func() { + if err = sbomFile.Close(); err != nil { + b.logger.ErrorContext(ctx, "failed to close temporary SBOM file", "error", err) + } + + if err = os.Remove(sbomFile.Name()); err != nil { + b.logger.ErrorContext(ctx, "failed to remove temporary SBOM file", "error", err) + } + }() + + if _, err = sbomFile.Write(rawSPDX); err != nil { + return nil, storagev1alpha1.Summary{}, fmt.Errorf("failed to write SBOM file: %w", err) + } + + reportFile, err := os.CreateTemp(b.workDir, "trivy.report.*.json") + if err != nil { + return nil, storagev1alpha1.Summary{}, fmt.Errorf("failed to create temporary report file: %w", err) + } + defer func() { + if err = reportFile.Close(); err != nil { + b.logger.ErrorContext(ctx, "failed to close temporary report file", "error", err) + } + + if err = os.Remove(reportFile.Name()); err != nil { + b.logger.ErrorContext(ctx, "failed to remove temporary report file", "error", err) + } + }() + + trivyArgs := []string{ + "sbom", + //nolint:goconst // These are specific trivy command arguments, not constant values used elsewhere + "--skip-version-check", + //nolint:goconst // These are specific trivy command arguments, not constant values used elsewhere + "--disable-telemetry", + //nolint:goconst // These are specific trivy command arguments, not constant values used elsewhere + "--cache-dir", b.workDir, + //nolint:goconst // These are specific trivy command arguments, not constant values used elsewhere + "--format", "json", + "--db-repository", b.trivyDBRepository, + //nolint:goconst // These are specific trivy command arguments, not constant values used elsewhere + "--java-db-repository", b.trivyJavaDBRepository, + //nolint:goconst // These are specific trivy command arguments, not constant values used elsewhere + "--output", reportFile.Name(), + } + + trivyHome, err := os.MkdirTemp("/tmp", "trivy-") + if err != nil { + return nil, storagev1alpha1.Summary{}, fmt.Errorf("failed to create temporary trivy home: %w", err) + } + if err = os.Setenv("XDG_DATA_HOME", trivyHome); err != nil { + return nil, storagev1alpha1.Summary{}, fmt.Errorf("failed to set XDG_DATA_HOME to %s: %w", trivyHome, err) + } + + if len(vexHubList.Items) > 0 { + trivyVEXPath := path.Join(trivyHome, trivyVEXSubPath) + vexRepoPath := path.Join(trivyVEXPath, trivyVEXRepoFile) + if err = b.setupVEXHubRepositories(vexHubList, trivyVEXPath, vexRepoPath); err != nil { + return nil, storagev1alpha1.Summary{}, fmt.Errorf("failed to setup VEX Hub repositories: %w", err) + } + defer func() { + b.logger.DebugContext(ctx, "Removing trivy home") + if err = os.RemoveAll(trivyHome); err != nil { + b.logger.ErrorContext(ctx, "failed to remove temporary trivy home", "error", err) + } + }() + + trivyArgs = append(trivyArgs, "--vex", "repo", "--show-suppressed") + } + + app := trivyCommands.NewApp() + trivyArgs = append(trivyArgs, sbomFile.Name()) + app.SetArgs(trivyArgs) + + if err = app.ExecuteContext(ctx); err != nil { + return nil, storagev1alpha1.Summary{}, fmt.Errorf("failed to execute trivy: %w", err) + } + + if err = message.InProgress(); err != nil { + return nil, storagev1alpha1.Summary{}, fmt.Errorf("failed to ack message as in progress: %w", err) + } + + reportBytes, err := io.ReadAll(reportFile) + if err != nil { + return nil, storagev1alpha1.Summary{}, fmt.Errorf("failed to read SBOM output: %w", err) + } + + reportOrig := trivyTypes.Report{} + if err = json.Unmarshal(reportBytes, &reportOrig); err != nil { + return nil, storagev1alpha1.Summary{}, fmt.Errorf("failed to unmarshal report: %w", err) + } + + results, err := trivyreport.NewResultsFromTrivyReport(reportOrig) + if err != nil { + return nil, storagev1alpha1.Summary{}, fmt.Errorf("failed to convert from trivy results: %w", err) + } + + summary := storagev1alpha1.NewSummaryFromResults(results) + + return results, summary, nil +} + +func (b *scanSBOMBase) setupVEXHubRepositories(vexHubList *v1alpha1.VEXHubList, trivyVEXPath, vexRepoPath string) error { + config := vexrepo.Config{} + for _, repo := range vexHubList.Items { + repo := vexrepo.Repository{ + Name: repo.Name, + URL: repo.Spec.URL, + Enabled: repo.Spec.Enabled, + } + config.Repositories = append(config.Repositories, repo) + } + + repositories, err := yaml.Marshal(config) + if err != nil { + return fmt.Errorf("failed to marshal struct: %w", err) + } + + b.logger.Debug("Creating VEX repository directory", "vexhub", trivyVEXPath) + if err = os.MkdirAll(trivyVEXPath, 0o750); err != nil { + return fmt.Errorf("failed to create VEX configuration directory: %w", err) + } + + b.logger.Debug("Creating VEX repository file", "vexhub", vexRepoPath) + if err = os.WriteFile(vexRepoPath, repositories, 0o600); err != nil { + return fmt.Errorf("failed to create VEX repository file: %w", err) + } + + return nil +} diff --git a/internal/handlers/scan_sbom_test.go b/internal/handlers/scan_sbom_test.go index 060b193c2..119fffe76 100644 --- a/internal/handlers/scan_sbom_test.go +++ b/internal/handlers/scan_sbom_test.go @@ -277,7 +277,7 @@ func TestScanSBOMHandler_Handle_StopProcessing(t *testing.T) { differentUIDScanJob.UID = "test-scanjob-different-uid" failedScanJob := scanJob.DeepCopy() - failedScanJob.MarkFailed(v1alpha1.ReasonInternalError, "kaboom") + failedScanJob.MarkFailed(v1alpha1.ReasonScanJobInternalError, "kaboom") tests := []struct { name string diff --git a/internal/handlers/scanjob_failure.go b/internal/handlers/scanjob_failure.go index 681e99c2a..eb1160195 100644 --- a/internal/handlers/scanjob_failure.go +++ b/internal/handlers/scanjob_failure.go @@ -55,7 +55,7 @@ func (h *ScanJobFailureHandler) HandleFailure(ctx context.Context, message messa return fmt.Errorf("cannot get scanjob %s/%s: %w", baseMessage.ScanJob.Namespace, baseMessage.ScanJob.Name, err) } - scanJob.MarkFailed(sbombasticv1alpha1.ReasonInternalError, errorMessage) + scanJob.MarkFailed(sbombasticv1alpha1.ReasonScanJobInternalError, errorMessage) return h.k8sClient.Status().Update(ctx, scanJob) }) if err != nil { diff --git a/internal/handlers/scanjob_failure_test.go b/internal/handlers/scanjob_failure_test.go index 3615cc71a..66c1e739b 100644 --- a/internal/handlers/scanjob_failure_test.go +++ b/internal/handlers/scanjob_failure_test.go @@ -66,8 +66,8 @@ func TestScanJobFailureHandler_HandleFailure(t *testing.T) { require.NoError(t, err) assert.True(t, updatedScanJob.IsFailed()) - failedCondition := meta.FindStatusCondition(updatedScanJob.Status.Conditions, string(sbombasticv1alpha1.ConditionTypeFailed)) + failedCondition := meta.FindStatusCondition(updatedScanJob.Status.Conditions, string(sbombasticv1alpha1.ConditionScanJobTypeFailed)) require.NotNil(t, failedCondition) - assert.Equal(t, sbombasticv1alpha1.ReasonInternalError, failedCondition.Reason) + assert.Equal(t, sbombasticv1alpha1.ReasonScanJobInternalError, failedCondition.Reason) assert.Equal(t, errorMessage, failedCondition.Message) } diff --git a/internal/messaging/subscriber.go b/internal/messaging/subscriber.go index c77530d1c..d87f24c93 100644 --- a/internal/messaging/subscriber.go +++ b/internal/messaging/subscriber.go @@ -26,13 +26,13 @@ type RetryConfig struct { MaxAttempts int } -// HandlerRegistry is a map that associates subjects with their respective handlers. -type HandlerRegistry map[string]Handler +// HandlerScan is a map that associates subjects with their respective handlers. +type HandlerScan map[string]Handler // NatsSubscriber is an implementation of a message subscriber that uses NATS JetStream to receive messages. type NatsSubscriber struct { cons jetstream.Consumer - handlers HandlerRegistry + handlers HandlerScan failureHandler FailureHandler retryConfig *RetryConfig logger *slog.Logger @@ -42,7 +42,7 @@ type NatsSubscriber struct { func NewNatsSubscriber(ctx context.Context, nc *nats.Conn, durable string, - handlers HandlerRegistry, + handlers HandlerScan, failureHandler FailureHandler, retryConfig *RetryConfig, logger *slog.Logger, diff --git a/internal/messaging/subscriber_test.go b/internal/messaging/subscriber_test.go index 8e7740de2..cc01563ae 100644 --- a/internal/messaging/subscriber_test.go +++ b/internal/messaging/subscriber_test.go @@ -68,7 +68,7 @@ func TestSubscriber_Run(t *testing.T) { } testHandler := &testHandler{handleFunc: handleFunc} - handlers := HandlerRegistry{ + handlers := HandlerScan{ testSubscriberSubject: testHandler, } subscriber, err := NewNatsSubscriber(t.Context(), nc, "test-durable", handlers, nil, nil, slog.Default()) @@ -129,7 +129,7 @@ func TestSubscriber_Run_WithRetry(t *testing.T) { } testHandler := &testHandler{handleFunc: handleFunc} - handlers := HandlerRegistry{ + handlers := HandlerScan{ testSubscriberSubject: testHandler, } retryConfig := &RetryConfig{ @@ -202,7 +202,7 @@ func TestSubscriber_Run_WithMaxRetriesExceeded(t *testing.T) { testHandler := &testHandler{handleFunc: handleFunc} testFailureHandler := &testFailureHandler{handleFailureFunc: failureHandleFunc} - handlers := HandlerRegistry{ + handlers := HandlerScan{ testSubscriberSubject: testHandler, } retryConfig := &RetryConfig{ @@ -279,7 +279,7 @@ func TestSubscriber_handleMessage(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { - handlers := HandlerRegistry{} + handlers := HandlerScan{} handlers[testSubscriberSubject] = &testHandler{ handleFunc: test.handleFunc, } diff --git a/internal/skippatterns/doc.go b/internal/skippatterns/doc.go new file mode 100644 index 000000000..f25853356 --- /dev/null +++ b/internal/skippatterns/doc.go @@ -0,0 +1,8 @@ +// Package skippatterns classifies gitignore-style skip patterns into +// directory and file categories for use with trivy's --skip-dirs and +// --skip-files flags. +// +// Patterns ending with "/" are treated as directories; all others are +// treated as files. Glob syntax (e.g. "**/vendor/", "*.min.js") is +// passed through as-is since trivy handles expansion natively. +package skippatterns diff --git a/internal/skippatterns/skippatterns.go b/internal/skippatterns/skippatterns.go new file mode 100644 index 000000000..6afad0f40 --- /dev/null +++ b/internal/skippatterns/skippatterns.go @@ -0,0 +1,32 @@ +package skippatterns + +import "strings" + +// ParseResult holds the classified skip patterns for trivy flags. +type ParseResult struct { + SkipDirs []string + SkipFiles []string +} + +// Parse classifies gitignore-style patterns into directory and file patterns. +// Patterns ending with "/" are directory patterns (trivy --skip-dirs). +// All other patterns are file patterns (trivy --skip-files). +// The trailing "/" is stripped from directory patterns. +func Parse(patterns []string) ParseResult { + var result ParseResult + + for _, p := range patterns { + p = strings.TrimSpace(p) + if p == "" { + continue + } + + if dir, ok := strings.CutSuffix(p, "/"); ok { + result.SkipDirs = append(result.SkipDirs, dir) + } else { + result.SkipFiles = append(result.SkipFiles, p) + } + } + + return result +} diff --git a/internal/skippatterns/skippatterns_test.go b/internal/skippatterns/skippatterns_test.go new file mode 100644 index 000000000..3bf0dc81a --- /dev/null +++ b/internal/skippatterns/skippatterns_test.go @@ -0,0 +1,89 @@ +package skippatterns + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParse(t *testing.T) { + tests := []struct { + name string + patterns []string + wantDirs []string + wantFiles []string + }{ + { + name: "nil input", + patterns: nil, + }, + { + name: "empty slice", + patterns: []string{}, + }, + { + name: "directory pattern", + patterns: []string{"node_modules/"}, + wantDirs: []string{"node_modules"}, + }, + { + name: "file pattern", + patterns: []string{"package-lock.json"}, + wantFiles: []string{"package-lock.json"}, + }, + { + name: "mixed patterns", + patterns: []string{"node_modules/", "*.min.js", ".git/", "package-lock.json"}, + wantDirs: []string{"node_modules", ".git"}, + wantFiles: []string{"*.min.js", "package-lock.json"}, + }, + { + name: "glob directory pattern", + patterns: []string{"**/vendor/"}, + wantDirs: []string{"**/vendor"}, + }, + { + name: "glob file pattern", + patterns: []string{"*.min.js"}, + wantFiles: []string{"*.min.js"}, + }, + { + name: "whitespace-only patterns are skipped", + patterns: []string{" ", "", "\t"}, + }, + { + name: "nested directory path", + patterns: []string{"foo/bar/"}, + wantDirs: []string{"foo/bar"}, + }, + { + name: "absolute path without trailing slash is file", + patterns: []string{"/tmp"}, + wantFiles: []string{"/tmp"}, + }, + { + name: "absolute path with trailing slash is dir", + patterns: []string{"/tmp/"}, + wantDirs: []string{"/tmp"}, + }, + { + name: "pattern with leading double star", + patterns: []string{"**/node_modules/"}, + wantDirs: []string{"**/node_modules"}, + }, + { + name: "whitespace around pattern is trimmed", + patterns: []string{" node_modules/ ", " *.log "}, + wantDirs: []string{"node_modules"}, + wantFiles: []string{"*.log"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := Parse(tt.patterns) + assert.Equal(t, tt.wantDirs, result.SkipDirs) + assert.Equal(t, tt.wantFiles, result.SkipFiles) + }) + } +} diff --git a/internal/storage/matcher.go b/internal/storage/matcher.go index 9536865b0..5721f22cc 100644 --- a/internal/storage/matcher.go +++ b/internal/storage/matcher.go @@ -35,6 +35,7 @@ func getAttrs(obj runtime.Object) (labels.Set, fields.Set, error) { } selectableMetadata := fields.Set{ + //nolint:goconst // These are user-friendly field names, not constant values used elsewhere "metadata.name": objMeta.GetName(), "metadata.namespace": objMeta.GetNamespace(), } diff --git a/internal/storage/migrations.go b/internal/storage/migrations.go index 1b96d7dbe..eae698086 100644 --- a/internal/storage/migrations.go +++ b/internal/storage/migrations.go @@ -32,6 +32,12 @@ func RunMigrations(ctx context.Context, db *pgxpool.Pool) error { if _, err := db.Exec(ctx, createWorkloadScanReportTableSQL); err != nil { return fmt.Errorf("creating workload scan report table: %w", err) } + if _, err := db.Exec(ctx, createNodeSBOMTableSQL); err != nil { + return fmt.Errorf("creating node sbom table: %w", err) + } + if _, err := db.Exec(ctx, createNodeVulnerabilityReportTableSQL); err != nil { + return fmt.Errorf("creating node vulnerability report table: %w", err) + } return nil } diff --git a/internal/storage/node_sbom_store.go b/internal/storage/node_sbom_store.go new file mode 100644 index 000000000..5c1ddfb29 --- /dev/null +++ b/internal/storage/node_sbom_store.go @@ -0,0 +1,169 @@ +package storage + +import ( + "context" + "errors" + "fmt" + "log/slog" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/nats-io/nats.go" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/fields" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/watch" + "k8s.io/apiserver/pkg/registry/generic" + "k8s.io/apiserver/pkg/registry/generic/registry" + apistorage "k8s.io/apiserver/pkg/storage" + + storagev1alpha1 "github.com/kubewarden/sbomscanner/api/storage/v1alpha1" + "github.com/kubewarden/sbomscanner/internal/storage/repository" +) + +const ( + nodeSBOMResourceSingularName = "nodesbom" + nodeSBOMResourcePluralName = "nodesboms" +) + +const createNodeSBOMTableSQL = ` +CREATE TABLE IF NOT EXISTS nodesboms ( + name VARCHAR(253) NOT NULL, + object JSONB NOT NULL, + PRIMARY KEY (name) +); + +ALTER TABLE nodesboms ADD COLUMN IF NOT EXISTS id BIGSERIAL; +CREATE INDEX IF NOT EXISTS idx_nodesboms_id ON nodesboms(id); +` + +// NewNodeSBOMStore returns a store registry that will work against API services. +func NewNodeSBOMStore( + scheme *runtime.Scheme, + optsGetter generic.RESTOptionsGetter, + db *pgxpool.Pool, + nc *nats.Conn, + logger *slog.Logger, +) (*registry.Store, []Watcher, error) { + strategy := newNodeSBOMStrategy(scheme) + newFunc := func() runtime.Object { return &storagev1alpha1.NodeSBOM{} } + newListFunc := func() runtime.Object { return &storagev1alpha1.NodeSBOMList{} } + + watchBroadcaster := watch.NewBroadcaster(1000, watch.WaitIfChannelFull) + natsBroadcaster := newNatsBroadcaster(nc, nodeSBOMResourcePluralName, watchBroadcaster, TransformStripNodeSBOM, logger) + + repo := repository.NewClusterScopedObjectRepository(nodeSBOMResourcePluralName, newFunc) + + store := &store{ + db: db, + repository: repo, + broadcaster: natsBroadcaster, + newFunc: newFunc, + newListFunc: newListFunc, + logger: logger.With("store", nodeSBOMResourceSingularName), + clusterScoped: true, + } + + natsWatcher := newNatsWatcher(nc, nodeSBOMResourcePluralName, watchBroadcaster, store, logger) + + registryStore := ®istry.Store{ + NewFunc: newFunc, + NewListFunc: newListFunc, + PredicateFunc: nodeSBOMMatcher, + DefaultQualifiedResource: storagev1alpha1.Resource(nodeSBOMResourcePluralName), + SingularQualifiedResource: storagev1alpha1.Resource(nodeSBOMResourceSingularName), + Storage: registry.DryRunnableStorage{ + Storage: store, + }, + CreateStrategy: strategy, + UpdateStrategy: strategy, + DeleteStrategy: strategy, + TableConvertor: &nodeSBOMTableConvertor{}, + } + + options := &generic.StoreOptions{RESTOptions: optsGetter, AttrFunc: getNodeSBOMAttrs} + if err := registryStore.CompleteWithOptions(options); err != nil { + return nil, nil, fmt.Errorf("unable to complete store with options: %w", err) + } + + return registryStore, []Watcher{natsWatcher}, nil +} + +// nodeSBOMMatcher returns a storage.SelectionPredicate that matches the given label and field selectors. +func nodeSBOMMatcher(label labels.Selector, field fields.Selector) apistorage.SelectionPredicate { + return apistorage.SelectionPredicate{ + Label: label, + Field: field, + GetAttrs: getNodeSBOMAttrs, + } +} + +// getNodeSBOMAttrs returns labels and fields that can be used in a selection. +func getNodeSBOMAttrs(obj runtime.Object) (labels.Set, fields.Set, error) { + objMeta, err := meta.Accessor(obj) + if err != nil { + return nil, nil, fmt.Errorf("failed to get metadata: %w", err) + } + + nodeMetadataAccessor, ok := obj.(storagev1alpha1.NodeMetadataAccessor) + if !ok { + return nil, nil, errors.New("object does not implement NodeMetadataAccessor") + } + + selectableMetadata := fields.Set{ + //nolint:goconst // These are user-friendly field names, not constant values used elsewhere + "metadata.name": objMeta.GetName(), + } + + selectableFields := fields.Set{ + "nodeMetadata.platform": nodeMetadataAccessor.GetNodeMetadata().Platform, + } + + return labels.Set(objMeta.GetLabels()), generic.MergeFieldsSets(selectableMetadata, selectableFields), nil +} + +type nodeSBOMTableConvertor struct{} + +func (c *nodeSBOMTableConvertor) ConvertToTable(_ context.Context, obj runtime.Object, _ runtime.Object) (*metav1.Table, error) { + table := &metav1.Table{ + ColumnDefinitions: nodeMetadataTableColumns(), + Rows: []metav1.TableRow{}, + } + + var nodeSBOMs []storagev1alpha1.NodeSBOM + switch t := obj.(type) { + case *storagev1alpha1.NodeSBOMList: + nodeSBOMs = t.Items + case *storagev1alpha1.NodeSBOM: + nodeSBOMs = []storagev1alpha1.NodeSBOM{*t} + default: + return nil, fmt.Errorf("unexpected type %T", obj) + } + + for _, nodeSBOM := range nodeSBOMs { + row := metav1.TableRow{ + Object: runtime.RawExtension{Object: &nodeSBOM}, + Cells: nodeMetadataTableRowCells(nodeSBOM.Name, &nodeSBOM), + } + table.Rows = append(table.Rows, row) + } + + return table, nil +} + +func nodeMetadataTableColumns() []metav1.TableColumnDefinition { + return []metav1.TableColumnDefinition{ + //nolint:goconst // These are user-friendly column names, not constant values used elsewhere + {Name: "Name", Type: "string", Description: "Name"}, + {Name: "Platform", Type: "string", Description: "Node platform"}, + } +} + +func nodeMetadataTableRowCells(name string, obj storagev1alpha1.NodeMetadataAccessor) []any { + nodeMeta := obj.GetNodeMetadata() + return []any{ + name, + nodeMeta.Platform, + } +} diff --git a/internal/storage/node_sbom_strategy.go b/internal/storage/node_sbom_strategy.go new file mode 100644 index 000000000..db9f77428 --- /dev/null +++ b/internal/storage/node_sbom_strategy.go @@ -0,0 +1,58 @@ +package storage + +import ( + "context" + + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/validation/field" + "k8s.io/apiserver/pkg/storage/names" +) + +// newNodeSBOMStrategy creates and returns a nodeSBOMStrategy instance +func newNodeSBOMStrategy(typer runtime.ObjectTyper) nodeSBOMStrategy { + return nodeSBOMStrategy{typer, names.SimpleNameGenerator} +} + +type nodeSBOMStrategy struct { + runtime.ObjectTyper + names.NameGenerator +} + +func (nodeSBOMStrategy) NamespaceScoped() bool { + return false +} + +func (nodeSBOMStrategy) PrepareForCreate(_ context.Context, _ runtime.Object) { +} + +func (nodeSBOMStrategy) PrepareForUpdate(_ context.Context, _, _ runtime.Object) { +} + +func (nodeSBOMStrategy) Validate(_ context.Context, _ runtime.Object) field.ErrorList { + return field.ErrorList{} +} + +// WarningsOnCreate returns warnings for the creation of the given object. +func (nodeSBOMStrategy) WarningsOnCreate(_ context.Context, _ runtime.Object) []string { + return nil +} + +func (nodeSBOMStrategy) AllowCreateOnUpdate() bool { + return false +} + +func (nodeSBOMStrategy) AllowUnconditionalUpdate() bool { + return false +} + +func (nodeSBOMStrategy) Canonicalize(_ runtime.Object) { +} + +func (nodeSBOMStrategy) ValidateUpdate(_ context.Context, _, _ runtime.Object) field.ErrorList { + return field.ErrorList{} +} + +// WarningsOnUpdate returns warnings for the given update. +func (nodeSBOMStrategy) WarningsOnUpdate(_ context.Context, _, _ runtime.Object) []string { + return nil +} diff --git a/internal/storage/nodevulnerabilityreport_store.go b/internal/storage/nodevulnerabilityreport_store.go new file mode 100644 index 000000000..1fc8521aa --- /dev/null +++ b/internal/storage/nodevulnerabilityreport_store.go @@ -0,0 +1,163 @@ +package storage + +import ( + "context" + "errors" + "fmt" + "log/slog" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/nats-io/nats.go" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/fields" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/watch" + "k8s.io/apiserver/pkg/registry/generic" + "k8s.io/apiserver/pkg/registry/generic/registry" + apistorage "k8s.io/apiserver/pkg/storage" + + storagev1alpha1 "github.com/kubewarden/sbomscanner/api/storage/v1alpha1" + "github.com/kubewarden/sbomscanner/internal/storage/repository" +) + +const ( + nodeVulnerabilityReportResourceSingularName = "nodevulnerabilityreport" + nodeVulnerabilityReportResourcePluralName = "nodevulnerabilityreports" +) + +const createNodeVulnerabilityReportTableSQL = ` +CREATE TABLE IF NOT EXISTS nodevulnerabilityreports ( + name VARCHAR(253) NOT NULL, + object JSONB NOT NULL, + PRIMARY KEY (name) +); + +ALTER TABLE nodevulnerabilityreports ADD COLUMN IF NOT EXISTS id BIGSERIAL; +CREATE INDEX IF NOT EXISTS idx_nodevulnerabilityreports_id ON nodevulnerabilityreports(id); +` + +// NewNodeVulnerabilityReportStore returns a store registry that will work against API services. +func NewNodeVulnerabilityReportStore( + scheme *runtime.Scheme, + optsGetter generic.RESTOptionsGetter, + db *pgxpool.Pool, + nc *nats.Conn, + logger *slog.Logger, +) (*registry.Store, []Watcher, error) { + strategy := newNodeVulnerabilityReportStrategy(scheme) + newFunc := func() runtime.Object { return &storagev1alpha1.NodeVulnerabilityReport{} } + newListFunc := func() runtime.Object { return &storagev1alpha1.NodeVulnerabilityReportList{} } + + watchBroadcaster := watch.NewBroadcaster(1000, watch.WaitIfChannelFull) + natsBroadcaster := newNatsBroadcaster(nc, nodeVulnerabilityReportResourcePluralName, watchBroadcaster, TransformStripNodeVulnerabilityReport, logger) + + repo := repository.NewClusterScopedObjectRepository(nodeVulnerabilityReportResourcePluralName, newFunc) + + store := &store{ + db: db, + repository: repo, + broadcaster: natsBroadcaster, + newFunc: newFunc, + newListFunc: newListFunc, + logger: logger.With("store", nodeVulnerabilityReportResourceSingularName), + clusterScoped: true, + } + + natsWatcher := newNatsWatcher(nc, nodeVulnerabilityReportResourcePluralName, watchBroadcaster, store, logger) + + registryStore := ®istry.Store{ + NewFunc: newFunc, + NewListFunc: newListFunc, + PredicateFunc: nodeVulnerabilityReportMatcher, + DefaultQualifiedResource: storagev1alpha1.Resource(nodeVulnerabilityReportResourcePluralName), + SingularQualifiedResource: storagev1alpha1.Resource(nodeVulnerabilityReportResourceSingularName), + Storage: registry.DryRunnableStorage{ + Storage: store, + }, + CreateStrategy: strategy, + UpdateStrategy: strategy, + DeleteStrategy: strategy, + TableConvertor: &nodeVulnerabilityReportTableConvertor{}, + } + + options := &generic.StoreOptions{RESTOptions: optsGetter, AttrFunc: getNodeVulnerabilityReportAttrs} + if err := registryStore.CompleteWithOptions(options); err != nil { + return nil, nil, fmt.Errorf("unable to complete store with options: %w", err) + } + + return registryStore, []Watcher{natsWatcher}, nil +} + +// nodeVulnerabilityReportMatcher returns a storage.SelectionPredicate that matches the given label and field selectors. +func nodeVulnerabilityReportMatcher(label labels.Selector, field fields.Selector) apistorage.SelectionPredicate { + return apistorage.SelectionPredicate{ + Label: label, + Field: field, + GetAttrs: getNodeVulnerabilityReportAttrs, + } +} + +// getNodeVulnerabilityReportAttrs returns labels and fields that can be used in a selection. +func getNodeVulnerabilityReportAttrs(obj runtime.Object) (labels.Set, fields.Set, error) { + objMeta, err := meta.Accessor(obj) + if err != nil { + return nil, nil, fmt.Errorf("failed to get metadata: %w", err) + } + + nodeMetadataAccessor, ok := obj.(storagev1alpha1.NodeMetadataAccessor) + if !ok { + return nil, nil, errors.New("object does not implement NodeMetadataAccessor") + } + + selectableMetadata := fields.Set{ + //nolint:goconst // These are user-friendly field names, not constant values used elsewhere + "metadata.name": objMeta.GetName(), + } + + selectableFields := fields.Set{ + "nodeMetadata.platform": nodeMetadataAccessor.GetNodeMetadata().Platform, + } + + return labels.Set(objMeta.GetLabels()), generic.MergeFieldsSets(selectableMetadata, selectableFields), nil +} + +type nodeVulnerabilityReportTableConvertor struct{} + +func (c *nodeVulnerabilityReportTableConvertor) ConvertToTable(_ context.Context, obj runtime.Object, _ runtime.Object) (*metav1.Table, error) { + columns := append( + nodeMetadataTableColumns(), + //nolint:goconst // These are user-friendly column names, not constant values used elsewhere + metav1.TableColumnDefinition{Name: "Vulnerabilities", Type: "string", Description: "Vulnerabilities"}, + ) + + table := &metav1.Table{ + ColumnDefinitions: columns, + Rows: []metav1.TableRow{}, + } + + var nodeVulnerabilityReports []storagev1alpha1.NodeVulnerabilityReport + switch t := obj.(type) { + case *storagev1alpha1.NodeVulnerabilityReportList: + nodeVulnerabilityReports = t.Items + case *storagev1alpha1.NodeVulnerabilityReport: + nodeVulnerabilityReports = []storagev1alpha1.NodeVulnerabilityReport{*t} + default: + return nil, fmt.Errorf("unexpected type %T", obj) + } + + for _, nodeVulnerabilityReport := range nodeVulnerabilityReports { + cells := append( + nodeMetadataTableRowCells(nodeVulnerabilityReport.Name, &nodeVulnerabilityReport), + computeVulnerabilities(nodeVulnerabilityReport.Report.Summary), + ) + row := metav1.TableRow{ + Object: runtime.RawExtension{Object: &nodeVulnerabilityReport}, + Cells: cells, + } + table.Rows = append(table.Rows, row) + } + + return table, nil +} diff --git a/internal/storage/nodevulnerabilityreport_strategy.go b/internal/storage/nodevulnerabilityreport_strategy.go new file mode 100644 index 000000000..bf7ab97fa --- /dev/null +++ b/internal/storage/nodevulnerabilityreport_strategy.go @@ -0,0 +1,58 @@ +package storage + +import ( + "context" + + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/validation/field" + "k8s.io/apiserver/pkg/storage/names" +) + +// newNodeVulnerabilityReportStrategy creates and returns a nodeVulnerabilityReportStrategy instance +func newNodeVulnerabilityReportStrategy(typer runtime.ObjectTyper) nodeVulnerabilityReportStrategy { + return nodeVulnerabilityReportStrategy{typer, names.SimpleNameGenerator} +} + +type nodeVulnerabilityReportStrategy struct { + runtime.ObjectTyper + names.NameGenerator +} + +func (nodeVulnerabilityReportStrategy) NamespaceScoped() bool { + return false +} + +func (nodeVulnerabilityReportStrategy) PrepareForCreate(_ context.Context, _ runtime.Object) { +} + +func (nodeVulnerabilityReportStrategy) PrepareForUpdate(_ context.Context, _, _ runtime.Object) { +} + +func (nodeVulnerabilityReportStrategy) Validate(_ context.Context, _ runtime.Object) field.ErrorList { + return field.ErrorList{} +} + +// WarningsOnCreate returns warnings for the creation of the given object. +func (nodeVulnerabilityReportStrategy) WarningsOnCreate(_ context.Context, _ runtime.Object) []string { + return nil +} + +func (nodeVulnerabilityReportStrategy) AllowCreateOnUpdate() bool { + return false +} + +func (nodeVulnerabilityReportStrategy) AllowUnconditionalUpdate() bool { + return false +} + +func (nodeVulnerabilityReportStrategy) Canonicalize(_ runtime.Object) { +} + +func (nodeVulnerabilityReportStrategy) ValidateUpdate(_ context.Context, _, _ runtime.Object) field.ErrorList { + return field.ErrorList{} +} + +// WarningsOnUpdate returns warnings for the given update. +func (nodeVulnerabilityReportStrategy) WarningsOnUpdate(_ context.Context, _, _ runtime.Object) []string { + return nil +} diff --git a/internal/storage/repository/cluster_scoped_object_repository.go b/internal/storage/repository/cluster_scoped_object_repository.go new file mode 100644 index 000000000..38f125e10 --- /dev/null +++ b/internal/storage/repository/cluster_scoped_object_repository.go @@ -0,0 +1,184 @@ +package repository + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/stephenafamo/bob/dialect/psql" + "github.com/stephenafamo/bob/dialect/psql/dm" + "github.com/stephenafamo/bob/dialect/psql/im" + "github.com/stephenafamo/bob/dialect/psql/sm" + "github.com/stephenafamo/bob/dialect/psql/um" + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/storage" +) + +// ClusterScopedObjectRepository is a repository for cluster-scoped Kubernetes objects. +// Unlike GenericObjectRepository, it does not use a namespace column. +// +// Expected table schema: +// +// CREATE TABLE ( +// id BIGSERIAL, +// name TEXT NOT NULL, +// object JSONB NOT NULL, +// PRIMARY KEY (name) +// ); +type ClusterScopedObjectRepository struct { + table string + newFunc func() runtime.Object +} + +var _ Repository = &ClusterScopedObjectRepository{} + +func NewClusterScopedObjectRepository(table string, newFunc func() runtime.Object) *ClusterScopedObjectRepository { + return &ClusterScopedObjectRepository{ + table, + newFunc, + } +} + +func (r *ClusterScopedObjectRepository) Create(ctx context.Context, tx pgx.Tx, obj runtime.Object) error { + meta, err := meta.Accessor(obj) + if err != nil { + return fmt.Errorf("failed to get object metadata: %w", err) + } + + bytes, err := json.Marshal(obj) + if err != nil { + return fmt.Errorf("failed to marshal object: %w", err) + } + + query, args, err := psql.Insert( + im.Into(psql.Quote(r.table), "name", "object"), + im.Values(psql.Arg(meta.GetName()), psql.Arg(bytes)), + im.OnConflict().DoNothing(), + ).Build(ctx) + if err != nil { + return fmt.Errorf("failed to build insert query: %w", err) + } + + result, err := tx.Exec(ctx, query, args...) + if err != nil { + return fmt.Errorf("failed to execute insert: %w", err) + } + + if result.RowsAffected() == 0 { + return ErrAlreadyExists + } + + return nil +} + +func (r *ClusterScopedObjectRepository) Delete(ctx context.Context, tx pgx.Tx, name, _ string) (runtime.Object, error) { + query, args, err := psql.Delete( + dm.From(psql.Quote(r.table)), + dm.Where(psql.Quote("name").EQ(psql.Arg(name))), + dm.Returning("object"), + ).Build(ctx) + if err != nil { + return nil, fmt.Errorf("failed to build delete query: %w", err) + } + + var bytes []byte + err = tx.QueryRow(ctx, query, args...).Scan(&bytes) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return nil, fmt.Errorf("failed to execute delete: %w", err) + } + + obj := r.newFunc() + if err := json.Unmarshal(bytes, obj); err != nil { + return nil, fmt.Errorf("failed to unmarshal object: %w", err) + } + + return obj, nil +} + +func (r *ClusterScopedObjectRepository) Get(ctx context.Context, db Querier, name, _ string) (runtime.Object, error) { + query, args, err := psql.Select( + sm.Columns("object"), + sm.From(psql.Quote(r.table)), + sm.Where(psql.Quote("name").EQ(psql.Arg(name))), + ).Build(ctx) + if err != nil { + return nil, fmt.Errorf("failed to build select query: %w", err) + } + + var bytes []byte + err = db.QueryRow(ctx, query, args...).Scan(&bytes) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return nil, fmt.Errorf("failed to execute select: %w", err) + } + + obj := r.newFunc() + if err := json.Unmarshal(bytes, obj); err != nil { + return nil, fmt.Errorf("failed to unmarshal object: %w", err) + } + + return obj, nil +} + +func (r *ClusterScopedObjectRepository) List(ctx context.Context, db Querier, _ string, opts storage.ListOptions) ([]runtime.Object, string, error) { + qb := psql.Select( + sm.From(psql.Quote(r.table)), + sm.Columns("id", "object"), + sm.OrderBy(psql.Quote("id")), + ) + + // Pass empty namespace so the list helper does not filter by namespace. + return list(ctx, db, qb, "", opts, r.newFunc) +} + +func (r *ClusterScopedObjectRepository) Update(ctx context.Context, tx pgx.Tx, name, _ string, obj runtime.Object) error { + bytes, err := json.Marshal(obj) + if err != nil { + return fmt.Errorf("failed to marshal object: %w", err) + } + + query, args, err := psql.Update( + um.Table(psql.Quote(r.table)), + um.SetCol("object").To(psql.Arg(bytes)), + um.Where(psql.Quote("name").EQ(psql.Arg(name))), + ).Build(ctx) + if err != nil { + return fmt.Errorf("failed to build update query: %w", err) + } + + result, err := tx.Exec(ctx, query, args...) + if err != nil { + return fmt.Errorf("failed to execute update: %w", err) + } + + if result.RowsAffected() == 0 { + return ErrNotFound + } + + return nil +} + +func (r *ClusterScopedObjectRepository) Count(ctx context.Context, db Querier, _ string) (int64, error) { + query, args, err := psql.Select( + sm.Columns("COUNT(*)"), + sm.From(psql.Quote(r.table)), + ).Build(ctx) + if err != nil { + return 0, fmt.Errorf("failed to build count query: %w", err) + } + + var count int64 + if err := db.QueryRow(ctx, query, args...).Scan(&count); err != nil { + return 0, fmt.Errorf("failed to execute count query: %w", err) + } + + return count, nil +} diff --git a/internal/storage/store.go b/internal/storage/store.go index 7c69ddd08..d33d15d38 100644 --- a/internal/storage/store.go +++ b/internal/storage/store.go @@ -28,12 +28,13 @@ import ( var _ storage.Interface = &store{} type store struct { - db *pgxpool.Pool - repository repository.Repository - broadcaster *natsBroadcaster - newFunc func() runtime.Object - newListFunc func() runtime.Object - logger *slog.Logger + db *pgxpool.Pool + repository repository.Repository + broadcaster *natsBroadcaster + newFunc func() runtime.Object + newListFunc func() runtime.Object + logger *slog.Logger + clusterScoped bool } // Versioner returns API object versioner associated with this interface. @@ -138,8 +139,8 @@ func (s *store) Delete( ) error { s.logger.DebugContext(ctx, "Deleting object", "key", key) - name, namespace := extractNameAndNamespace(key) - if name == "" || namespace == "" { + name, namespace := s.extractKeyNameAndNamespace(key) + if name == "" { return storage.NewInternalError(fmt.Errorf("invalid key: %s", key)) } @@ -342,8 +343,8 @@ func (s *store) Get(ctx context.Context, key string, opts storage.GetOptions, ob opts.ResourceVersion, ) - name, namespace := extractNameAndNamespace(key) - if name == "" || namespace == "" { + name, namespace := s.extractKeyNameAndNamespace(key) + if name == "" { return storage.NewInternalError(fmt.Errorf("invalid key: %s", key)) } @@ -391,7 +392,7 @@ func (s *store) GetList(ctx context.Context, key string, opts storage.ListOption return apierrors.NewBadRequest(fmt.Sprintf("invalid resource version: %v", err)) } - namespace := extractNamespace(key) + namespace := s.extractKeyNamespace(key) items, continueToken, err := s.repository.List(ctx, s.db, namespace, opts) if err != nil { return storage.NewInternalError(err) @@ -473,8 +474,8 @@ func (s *store) GuaranteedUpdate( ) error { s.logger.DebugContext(ctx, "Guaranteed update", "key", key) - name, namespace := extractNameAndNamespace(key) - if name == "" || namespace == "" { + name, namespace := s.extractKeyNameAndNamespace(key) + if name == "" { return storage.NewInternalError(fmt.Errorf("invalid key: %s", key)) } @@ -557,7 +558,7 @@ func (s *store) GuaranteedUpdate( func (s *store) Count(key string) (int64, error) { s.logger.Debug("Counting objects", "key", key) - namespace := extractNamespace(key) + namespace := s.extractKeyNamespace(key) count, err := s.repository.Count(context.Background(), s.db, namespace) if err != nil { @@ -619,23 +620,38 @@ func (s *store) EnableResourceSizeEstimation(_ storage.KeysFunc) error { return nil } -// extractNameAndNamespace extracts the name and namespace from the key. -// Used for single object operations. -// Key format: /storage.sbomscanner.kubewarden.io/// -func extractNameAndNamespace(key string) (string, string) { +// extractKeyNameAndNamespace extracts the name and namespace from the key, +// handling both namespaced and cluster-scoped key formats. +// +// Namespaced key format: /storage.sbomscanner.kubewarden.io/// +// Cluster-scoped key format: /storage.sbomscanner.kubewarden.io// +func (s *store) extractKeyNameAndNamespace(key string) (string, string) { key = strings.TrimPrefix(key, "/") parts := strings.Split(key, "/") + + if s.clusterScoped { + if len(parts) == 3 { + return parts[2], "" + } + return "", "" + } + if len(parts) == 4 { return parts[3], parts[2] } - return "", "" } -// extractNamespace extracts the namespace from the key. -// Used for list operations. -// Key format: /storage.sbomscanner.kubewarden.io// -func extractNamespace(key string) string { +// extractKeyNamespace extracts the namespace from the key. +// For cluster-scoped resources, always returns empty string. +// +// Namespaced key format: /storage.sbomscanner.kubewarden.io// +// Cluster-scoped key format: /storage.sbomscanner.kubewarden.io/ +func (s *store) extractKeyNamespace(key string) string { + if s.clusterScoped { + return "" + } + key = strings.TrimPrefix(key, "/") parts := strings.Split(key, "/") if len(parts) == 3 { diff --git a/internal/storage/transform.go b/internal/storage/transform.go index c0cef3a71..f66e3ab03 100644 --- a/internal/storage/transform.go +++ b/internal/storage/transform.go @@ -34,6 +34,19 @@ func TransformStripSBOM(obj any) (any, error) { return cache.TransformStripManagedFields()(sbom) } +// TransformStripNodeSBOM strips the NodeSBOM object of its SPDX field and managed fields. +// This is useful for caching scenarios where the SPDX data is not needed, reducing memory usage. +func TransformStripNodeSBOM(obj any) (any, error) { + nodeSBOM, ok := obj.(*storagev1alpha1.NodeSBOM) + if !ok { + return nil, fmt.Errorf("expected NodeSBOM object, got %T", obj) + } + + nodeSBOM.SPDX = runtime.RawExtension{} + + return cache.TransformStripManagedFields()(nodeSBOM) +} + // TransformStripVulnerabilityReport strips the VulnerabilityReport object of its Results field and managed fields. // This is useful for caching scenarios where the Results data is not needed, reducing memory usage. func TransformStripVulnerabilityReport(obj any) (any, error) { @@ -47,6 +60,19 @@ func TransformStripVulnerabilityReport(obj any) (any, error) { return cache.TransformStripManagedFields()(vulnerabilityReport) } +// TransformStripNodeVulnerabilityReport strips the NodeVulnerabilityReport object of its Results field and managed fields. +// This is useful for caching scenarios where the Results data is not needed, reducing memory usage. +func TransformStripNodeVulnerabilityReport(obj any) (any, error) { + nodeVulnerabilityReport, ok := obj.(*storagev1alpha1.NodeVulnerabilityReport) + if !ok { + return obj, fmt.Errorf("expected NodeVulnerabilityReport object, got %T", obj) + } + + nodeVulnerabilityReport.Report.Results = nil + + return cache.TransformStripManagedFields()(nodeVulnerabilityReport) +} + // TransformStripWorkloadScanReport strips the WorkloadScanReport object of its status. // This is useful for caching scenarios where the Reports data is not needed, reducing memory usage. func TransformStripWorkloadScanReport(object any) (any, error) { diff --git a/internal/storage/watcher.go b/internal/storage/watcher.go index bb00a316a..05e695395 100644 --- a/internal/storage/watcher.go +++ b/internal/storage/watcher.go @@ -150,7 +150,12 @@ func (w *natsWatcher) rehydrate(ctx context.Context, payloadObj runtime.Object) if err != nil { return nil, fmt.Errorf("failed to get meta accessor: %w", err) } - key := fmt.Sprintf("%s/%s/%s/%s", storagev1alpha1.GroupName, w.resource, payloadAccessor.GetNamespace(), payloadAccessor.GetName()) + var key string + if w.store.clusterScoped { + key = fmt.Sprintf("%s/%s/%s", storagev1alpha1.GroupName, w.resource, payloadAccessor.GetName()) + } else { + key = fmt.Sprintf("%s/%s/%s/%s", storagev1alpha1.GroupName, w.resource, payloadAccessor.GetNamespace(), payloadAccessor.GetName()) + } fetched := w.store.newFunc() if err := w.store.Get(ctx, key, storage.GetOptions{}, fetched); err != nil { diff --git a/test/e2e/main_test.go b/test/e2e/main_test.go index ce6c17295..50df2b028 100644 --- a/test/e2e/main_test.go +++ b/test/e2e/main_test.go @@ -38,7 +38,9 @@ func TestMain(m *testing.M) { testenv.Setup( envfuncs.CreateCluster(kind.NewProvider(), kindClusterName), envfuncs.CreateNamespace(namespace, envfuncs.WithLabels(map[string]string{ - "pod-security.kubernetes.io/enforce": "restricted", + "pod-security.kubernetes.io/enforce": "privileged", + "pod-security.kubernetes.io/warn": "restricted", + "pod-security.kubernetes.io/audit": "restricted", "pod-security.kubernetes.io/enforce-version": "latest", })), envfuncs.LoadImageToCluster(kindClusterName, workerImage, "--verbose", "--mode", "direct"), diff --git a/test/e2e/nodescan_test.go b/test/e2e/nodescan_test.go new file mode 100644 index 000000000..77c2c0e05 --- /dev/null +++ b/test/e2e/nodescan_test.go @@ -0,0 +1,212 @@ +package e2e + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "sigs.k8s.io/e2e-framework/klient/k8s" + "sigs.k8s.io/e2e-framework/klient/k8s/resources" + "sigs.k8s.io/e2e-framework/klient/wait" + "sigs.k8s.io/e2e-framework/klient/wait/conditions" + "sigs.k8s.io/e2e-framework/pkg/envconf" + "sigs.k8s.io/e2e-framework/pkg/features" + + "github.com/kubewarden/sbomscanner/api" + storagev1alpha1 "github.com/kubewarden/sbomscanner/api/storage/v1alpha1" + v1alpha1 "github.com/kubewarden/sbomscanner/api/v1alpha1" +) + +func TestNodeScan(t *testing.T) { + nodeScanLabelSelector := labels.FormatLabels(map[string]string{ + api.LabelManagedByKey: api.LabelManagedByValue, + api.LabelNodeScanKey: api.LabelNodeScanValue, + }) + + f := features.New("Node Scan"). + Assess("Create NodeScanConfiguration", func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { + nodeScanConfig := &v1alpha1.NodeScanConfiguration{ + ObjectMeta: metav1.ObjectMeta{ + Name: v1alpha1.NodeScanConfigurationName, + }, + Spec: v1alpha1.NodeScanConfigurationSpec{ + SkipPatterns: []string{ + // Exclude containerd runtime files since they + // can cause issues with file access and are not + // relevant for node SBOM generation. + "/run/containerd/", + }, + Platforms: []v1alpha1.Platform{ + {Architecture: "amd64", OS: "linux"}, + {Architecture: "arm64", OS: "linux"}, + }, + }, + } + err := cfg.Client().Resources().Create(ctx, nodeScanConfig) + require.NoError(t, err, "failed to create NodeScanConfiguration") + + return ctx + }). + Assess("Wait for NodeScanJob to be created", func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { + nodeScanJobs := &v1alpha1.NodeScanJobList{} + err := wait.For( + conditions.New(cfg.Client().Resources()).ResourceListN( + nodeScanJobs, 1, + resources.WithLabelSelector(nodeScanLabelSelector), + ), + wait.WithTimeout(scanTimeout), + ) + require.NoError(t, err, "expected at least 1 NodeScanJob to be created") + + return ctx + }). + Assess("Wait for NodeScanJob to complete", func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { + nodeScanJobs := &v1alpha1.NodeScanJobList{} + err := wait.For(func(ctx context.Context) (bool, error) { + if err := cfg.Client().Resources().List(ctx, nodeScanJobs, + resources.WithLabelSelector(nodeScanLabelSelector), + ); err != nil { + return false, err + } + + if len(nodeScanJobs.Items) == 0 { + return false, nil + } + + for i := range nodeScanJobs.Items { + if !nodeScanJobs.Items[i].IsComplete() { + return false, nil + } + } + return true, nil + }, wait.WithTimeout(scanTimeout)) + require.NoError(t, err, "timeout waiting for NodeScanJobs to complete") + + for _, job := range nodeScanJobs.Items { + assert.NotNil(t, job.Status.CompletionTime, "NodeScanJob %s should have a CompletionTime", job.Name) + } + + return ctx + }). + Assess("Verify NodeSBOM is created", func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { + nodeSBOMs := &storagev1alpha1.NodeSBOMList{} + err := wait.For( + conditions.New(cfg.Client().Resources()).ResourceListN( + nodeSBOMs, 1, + resources.WithLabelSelector(labels.FormatLabels(map[string]string{ + api.LabelManagedByKey: api.LabelManagedByValue, + })), + ), + wait.WithTimeout(scanTimeout), + ) + require.NoError(t, err, "expected at least 1 NodeSBOM to be created") + + for _, sbom := range nodeSBOMs.Items { + assert.NotEmpty(t, sbom.NodeMetadata.Name, "NodeSBOM should have a node name") + assert.NotEmpty(t, sbom.NodeMetadata.Platform, "NodeSBOM should have a platform") + assert.NotEmpty(t, sbom.SPDX.Raw, "NodeSBOM should have SPDX data") + } + + return ctx + }). + Assess("Verify NodeVulnerabilityReport is created", func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { + nodeVulnReports := &storagev1alpha1.NodeVulnerabilityReportList{} + err := wait.For( + conditions.New(cfg.Client().Resources()).ResourceListN( + nodeVulnReports, 1, + resources.WithLabelSelector(labels.FormatLabels(map[string]string{ + api.LabelManagedByKey: api.LabelManagedByValue, + })), + ), + wait.WithTimeout(scanTimeout), + ) + require.NoError(t, err, "expected at least 1 NodeVulnerabilityReport to be created") + + for _, report := range nodeVulnReports.Items { + assert.NotEmpty(t, report.NodeMetadata.Name, "NodeVulnerabilityReport should have a node name") + assert.NotEmpty(t, report.NodeMetadata.Platform, "NodeVulnerabilityReport should have a platform") + } + + return ctx + }). + Assess("Delete NodeScanConfiguration and verify cleanup", func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { + nodeScanConfig := &v1alpha1.NodeScanConfiguration{ + ObjectMeta: metav1.ObjectMeta{ + Name: v1alpha1.NodeScanConfigurationName, + }, + } + err := cfg.Client().Resources().Delete(ctx, nodeScanConfig) + require.NoError(t, err, "failed to delete NodeScanConfiguration") + + err = wait.For(func(ctx context.Context) (bool, error) { + jobs := &v1alpha1.NodeScanJobList{} + if err := cfg.Client().Resources().List(ctx, jobs, + resources.WithLabelSelector(nodeScanLabelSelector), + ); err != nil { + return false, err + } + return len(jobs.Items) == 0, nil + }, wait.WithTimeout(scanTimeout)) + require.NoError(t, err, "NodeScanJobs should be cleaned up after deleting NodeScanConfiguration") + + err = wait.For(func(ctx context.Context) (bool, error) { + sboms := &storagev1alpha1.NodeSBOMList{} + if err := cfg.Client().Resources().List(ctx, sboms, + resources.WithLabelSelector(labels.FormatLabels(map[string]string{ + api.LabelManagedByKey: api.LabelManagedByValue, + }))); err != nil { + return false, err + } + return len(sboms.Items) == 0, nil + }, wait.WithTimeout(scanTimeout)) + require.NoError(t, err, "NodeSBOMs should be cleaned up after deleting NodeScanConfiguration") + + return ctx + }). + Assess("Recreate NodeScanConfiguration with NodeSelector and verify filtering", func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { + nodeScanConfig := &v1alpha1.NodeScanConfiguration{ + ObjectMeta: metav1.ObjectMeta{ + Name: v1alpha1.NodeScanConfigurationName, + }, + Spec: v1alpha1.NodeScanConfigurationSpec{ + NodeSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "non-existent-label": "no-match", + }, + }, + }, + } + err := cfg.Client().Resources().Create(ctx, nodeScanConfig) + require.NoError(t, err, "failed to create NodeScanConfiguration with NodeSelector") + + err = wait.For(conditions.New(cfg.Client().Resources()).ResourceMatch(nodeScanConfig, func(object k8s.Object) bool { + return object.(*v1alpha1.NodeScanConfiguration) != nil + }), wait.WithTimeout(scanTimeout)) + require.NoError(t, err) + + // No NodeScanJobs should be created since no nodes match the selector + jobs := &v1alpha1.NodeScanJobList{} + err = cfg.Client().Resources().List(ctx, jobs, + resources.WithLabelSelector(nodeScanLabelSelector), + ) + require.NoError(t, err) + assert.Empty(t, jobs.Items, "no NodeScanJobs should exist when NodeSelector matches no nodes") + + return ctx + }). + Assess("Final cleanup", func(ctx context.Context, _ *testing.T, cfg *envconf.Config) context.Context { + nodeScanConfig := &v1alpha1.NodeScanConfiguration{ + ObjectMeta: metav1.ObjectMeta{ + Name: v1alpha1.NodeScanConfigurationName, + }, + } + _ = cfg.Client().Resources().Delete(ctx, nodeScanConfig) + + return ctx + }) + + testenv.Test(t, f.Feature()) +} diff --git a/test/fixtures/node/go.mod b/test/fixtures/node/go.mod new file mode 100644 index 000000000..e7153d55f --- /dev/null +++ b/test/fixtures/node/go.mod @@ -0,0 +1,494 @@ +module github.com/kubewarden/sbomscanner + +go 1.26.3 + +require ( + github.com/aquasecurity/trivy v0.70.0 + github.com/aquasecurity/trivy-db v0.0.0-20260429080452-bd11dd425d21 + github.com/avast/retry-go/v4 v4.7.0 + github.com/aws/smithy-go v1.25.1 + github.com/docker/cli v29.4.3+incompatible + github.com/docker/go-units v0.5.0 + github.com/go-logr/logr v1.4.3 + github.com/google/cel-go v0.28.1 + github.com/google/go-cmp v0.7.0 + github.com/google/go-containerregistry v0.21.5 + github.com/google/uuid v1.6.0 + github.com/jackc/pgx/v5 v5.9.2 + github.com/modelcontextprotocol/go-sdk v1.6.0 + github.com/nats-io/nats-server/v2 v2.14.0 + github.com/nats-io/nats.go v1.52.0 + github.com/onsi/ginkgo/v2 v2.28.3 + github.com/onsi/gomega v1.40.0 + github.com/spdx/tools-golang v0.5.7 + github.com/stephenafamo/bob v0.43.0 + github.com/stretchr/testify v1.11.1 + github.com/testcontainers/testcontainers-go v0.42.0 + github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 + github.com/testcontainers/testcontainers-go/modules/registry v0.42.0 + go.yaml.in/yaml/v3 v3.0.4 + golang.org/x/sync v0.20.0 + golang.org/x/time v0.15.0 + k8s.io/api v0.35.4 + k8s.io/apimachinery v0.35.4 + k8s.io/apiserver v0.35.4 + k8s.io/client-go v0.35.4 + k8s.io/code-generator v0.35.4 + k8s.io/component-base v0.35.4 + k8s.io/klog/v2 v2.140.0 + k8s.io/kube-openapi v0.0.0-20260509192518-b540ad9def2b + k8s.io/utils v0.0.0-20260507154919-ff6756f316d2 + modernc.org/sqlite v1.50.1 + sigs.k8s.io/controller-runtime v0.23.3 + sigs.k8s.io/e2e-framework v0.7.0 + sigs.k8s.io/structured-merge-diff/v6 v6.4.0 +) + +require ( + al.essio.dev/pkg/shellescape v1.6.0 // indirect + cel.dev/expr v0.25.1 // indirect + cloud.google.com/go v0.123.0 // indirect + cloud.google.com/go/auth v0.18.2 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect + cloud.google.com/go/compute/metadata v0.9.0 // indirect + cloud.google.com/go/iam v1.5.3 // indirect + cloud.google.com/go/monitoring v1.24.3 // indirect + cloud.google.com/go/storage v1.61.3 // indirect + cyphar.com/go-pathrs v0.2.1 // indirect + dario.cat/mergo v1.0.2 // indirect + github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 // indirect + github.com/Azure/azure-sdk-for-go/sdk/containers/azcontainerregistry v0.2.3 // indirect + github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect + github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect + github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect + github.com/BurntSushi/toml v1.6.0 // indirect + github.com/CycloneDX/cyclonedx-go v0.10.0 // indirect + github.com/DataDog/zstd v1.5.7 // indirect + github.com/GoogleCloudPlatform/docker-credential-gcr/v2 v2.1.32 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 // indirect + github.com/Intevation/gval v1.3.0 // indirect + github.com/Intevation/jsonpath v0.2.1 // indirect + github.com/MakeNowJust/heredoc v1.0.0 // indirect + github.com/Masterminds/goutils v1.1.1 // indirect + github.com/Masterminds/semver/v3 v3.4.0 // indirect + github.com/Masterminds/sprig/v3 v3.3.0 // indirect + github.com/Masterminds/squirrel v1.5.4 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/Microsoft/hcsshim v0.14.0-rc.1 // indirect + github.com/NYTimes/gziphandler v1.1.1 // indirect + github.com/ProtonMail/go-crypto v1.3.0 // indirect + github.com/VividCortex/ewma v1.2.0 // indirect + github.com/aarondl/opt v0.0.0-20250607033636-982744e1bd65 // indirect + github.com/agext/levenshtein v1.2.3 // indirect + github.com/agnivade/levenshtein v1.2.1 // indirect + github.com/alecthomas/chroma v0.10.0 // indirect + github.com/anchore/go-struct-converter v0.1.0 // indirect + github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op // indirect + github.com/antlr4-go/antlr/v4 v4.13.1 // indirect + github.com/apparentlymart/go-cidr v1.1.0 // indirect + github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect + github.com/aquasecurity/go-gem-version v0.0.0-20201115065557-8eed6fe000ce // indirect + github.com/aquasecurity/go-npm-version v0.0.2 // indirect + github.com/aquasecurity/go-pep440-version v0.0.1 // indirect + github.com/aquasecurity/go-version v0.0.1 // indirect + github.com/aquasecurity/iamgo v0.0.10 // indirect + github.com/aquasecurity/jfather v0.0.8 // indirect + github.com/aquasecurity/table v1.11.0 // indirect + github.com/aquasecurity/tml v0.6.1 // indirect + github.com/aquasecurity/trivy-checks v1.12.2-0.20251219190323-79d27547baf5 // indirect + github.com/aquasecurity/trivy-java-db v0.0.0-20250912094916-94bd09842933 // indirect + github.com/aquasecurity/trivy-kubernetes v0.9.1 // indirect + github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect + github.com/aws/aws-sdk-go-v2 v1.41.5 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect + github.com/aws/aws-sdk-go-v2/config v1.32.12 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.12 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 // indirect + github.com/aws/aws-sdk-go-v2/service/ebs v1.33.2 // indirect + github.com/aws/aws-sdk-go-v2/service/ec2 v1.290.0 // indirect + github.com/aws/aws-sdk-go-v2/service/ecr v1.55.2 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 // indirect + github.com/aws/aws-sdk-go-v2/service/s3 v1.99.0 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.0.8 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d // indirect + github.com/bitnami/go-version v0.0.0-20250916072751-cb23e8405957 // indirect + github.com/blang/semver v3.5.1+incompatible // indirect + github.com/blang/semver/v4 v4.0.0 // indirect + github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect + github.com/briandowns/spinner v1.23.2 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/chai2010/gettext-go v1.0.3 // indirect + github.com/cheggaaa/pb/v3 v3.1.7 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.3.0 // indirect + github.com/cloudflare/circl v1.6.3 // indirect + github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 // indirect + github.com/containerd/cgroups/v3 v3.1.3 // indirect + github.com/containerd/containerd v1.7.30 // indirect + github.com/containerd/containerd/api v1.10.0 // indirect + github.com/containerd/containerd/v2 v2.2.2 // indirect + github.com/containerd/continuity v0.4.5 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/fifo v1.1.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/containerd/platforms v1.0.0-rc.4 // indirect + github.com/containerd/plugin v1.0.0 // indirect + github.com/containerd/stargz-snapshotter/estargz v0.18.2 // indirect + github.com/containerd/ttrpc v1.2.8 // indirect + github.com/containerd/typeurl/v2 v2.2.3 // indirect + github.com/coreos/go-oidc/v3 v3.17.0 // indirect + github.com/coreos/go-semver v0.3.1 // indirect + github.com/coreos/go-systemd/v22 v22.7.0 // indirect + github.com/cpuguy83/dockercfg v0.3.2 // indirect + github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 // indirect + github.com/cyphar/filepath-securejoin v0.6.1 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/digitorus/pkcs7 v0.0.0-20250730155240-ffadbf3f398c // indirect + github.com/digitorus/timestamp v0.0.0-20250524132541-c45532741eea // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/dlclark/regexp2 v1.11.5 // indirect + github.com/docker/docker-credential-helpers v0.9.5 // indirect + github.com/docker/go-connections v0.6.0 // indirect + github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/ebitengine/purego v0.10.0 // indirect + github.com/emicklei/go-restful/v3 v3.13.0 // indirect + github.com/emirpasic/gods v1.18.1 // indirect + github.com/envoyproxy/go-control-plane/envoy v1.36.0 // indirect + github.com/envoyproxy/protoc-gen-validate v1.3.0 // indirect + github.com/evanphx/json-patch v5.9.11+incompatible // indirect + github.com/evanphx/json-patch/v5 v5.9.11 // indirect + github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f // indirect + github.com/fatih/color v1.19.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/fvbommel/sortorder v1.1.0 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/go-chi/chi/v5 v5.2.5 // indirect + github.com/go-errors/errors v1.5.1 // indirect + github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect + github.com/go-git/go-billy/v5 v5.9.0 // indirect + github.com/go-git/go-git/v5 v5.19.0 // indirect + github.com/go-gorp/gorp/v3 v3.1.0 // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-logr/zapr v1.3.0 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect + github.com/go-openapi/analysis v0.24.3 // indirect + github.com/go-openapi/errors v0.22.7 // indirect + github.com/go-openapi/jsonpointer v0.22.5 // indirect + github.com/go-openapi/jsonreference v0.21.5 // indirect + github.com/go-openapi/loads v0.23.3 // indirect + github.com/go-openapi/runtime v0.29.3 // indirect + github.com/go-openapi/spec v0.22.4 // indirect + github.com/go-openapi/strfmt v0.26.1 // indirect + github.com/go-openapi/swag v0.25.5 // indirect + github.com/go-openapi/swag/cmdutils v0.25.5 // indirect + github.com/go-openapi/swag/conv v0.25.5 // indirect + github.com/go-openapi/swag/fileutils v0.25.5 // indirect + github.com/go-openapi/swag/jsonname v0.25.5 // indirect + github.com/go-openapi/swag/jsonutils v0.25.5 // indirect + github.com/go-openapi/swag/loading v0.25.5 // indirect + github.com/go-openapi/swag/mangling v0.25.5 // indirect + github.com/go-openapi/swag/netutils v0.25.5 // indirect + github.com/go-openapi/swag/stringutils v0.25.5 // indirect + github.com/go-openapi/swag/typeutils v0.25.5 // indirect + github.com/go-openapi/swag/yamlutils v0.25.5 // indirect + github.com/go-openapi/validate v0.25.2 // indirect + github.com/go-redis/redis/v8 v8.11.5 // indirect + github.com/go-task/slim-sprig/v3 v3.0.0 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect + github.com/gobwas/glob v0.2.3 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/gocsaf/csaf/v3 v3.5.1 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect + github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/golang/snappy v1.0.0 // indirect + github.com/google/btree v1.1.3 // indirect + github.com/google/certificate-transparency-go v1.3.2 // indirect + github.com/google/gnostic-models v0.7.0 // indirect + github.com/google/go-github/v62 v62.0.0 // indirect + github.com/google/go-querystring v1.1.0 // indirect + github.com/google/go-tpm v0.9.8 // indirect + github.com/google/jsonschema-go v0.4.3 // indirect + github.com/google/licenseclassifier/v2 v2.0.0 // indirect + github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 // indirect + github.com/google/s2a-go v0.1.9 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect + github.com/googleapis/gax-go/v2 v2.19.0 // indirect + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect + github.com/gosuri/uitable v0.0.4 // indirect + github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect + github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 // indirect + github.com/hashicorp/aws-sdk-go-base/v2 v2.0.0-beta.72 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-getter v1.8.6 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/hashicorp/go-retryablehttp v0.7.8 // indirect + github.com/hashicorp/go-uuid v1.0.3 // indirect + github.com/hashicorp/go-version v1.9.0 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + github.com/hashicorp/hcl/v2 v2.24.0 // indirect + github.com/huandu/xstrings v1.5.0 // indirect + github.com/in-toto/attestation v1.1.2 // indirect + github.com/in-toto/in-toto-golang v0.11.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect + github.com/jedisct1/go-minisign v0.0.0-20241212093149-d2f9f49435c7 // indirect + github.com/jmoiron/sqlx v1.4.0 // indirect + github.com/josephburnett/jd/v2 v2.3.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/kevinburke/ssh_config v1.4.0 // indirect + github.com/klauspost/compress v1.18.5 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/knqyf263/go-apk-version v0.0.0-20200609155635-041fdbb8563f // indirect + github.com/knqyf263/go-deb-version v0.0.0-20241115132648-6f4aee6ccd23 // indirect + github.com/knqyf263/go-rpm-version v0.0.0-20240918084003-2afd7dc6a38f // indirect + github.com/knqyf263/go-rpmdb v0.1.1 // indirect + github.com/knqyf263/nested v0.0.1 // indirect + github.com/kylelemons/godebug v1.1.0 // indirect + github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect + github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect + github.com/lestrrat-go/blackmagic v1.0.4 // indirect + github.com/lestrrat-go/dsig v1.0.0 // indirect + github.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect + github.com/lestrrat-go/httpcc v1.0.1 // indirect + github.com/lestrrat-go/httprc/v3 v3.0.2 // indirect + github.com/lestrrat-go/jwx/v3 v3.0.13 // indirect + github.com/lestrrat-go/option/v2 v2.0.0 // indirect + github.com/letsencrypt/boulder v0.20260223.0 // indirect + github.com/lib/pq v1.10.9 // indirect + github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect + github.com/lufia/plan9stats v0.0.0-20240226150601-1dcf7310316a // indirect + github.com/lunixbochs/struc v0.0.0-20241101090106-8d528fa2c543 // indirect + github.com/magiconair/properties v1.8.10 // indirect + github.com/masahiro331/go-disk v0.0.0-20260423015231-f7a470ebd472 // indirect + github.com/masahiro331/go-ebs-file v0.0.0-20260422020928-9d24e29aac27 // indirect + github.com/masahiro331/go-ext4-filesystem v0.0.0-20260423010602-fe51f5b5e52b // indirect + github.com/masahiro331/go-mvn-version v0.0.0-20250131095131-f4974fa13b8a // indirect + github.com/masahiro331/go-vmdk-parser v0.0.0-20260423020818-08305fa668d2 // indirect + github.com/masahiro331/go-xfs-filesystem v0.0.0-20260422061116-d21e5e4481bb // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/mattn/go-shellwords v1.0.12 // indirect + github.com/minio/highwayhash v1.0.4 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/mitchellh/go-wordwrap v1.0.1 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect + github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/moby/buildkit v0.29.0 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/go-archive v0.2.0 // indirect + github.com/moby/locker v1.0.1 // indirect + github.com/moby/moby/api v1.54.1 // indirect + github.com/moby/moby/client v0.4.0 // indirect + github.com/moby/patternmatcher v0.6.1 // indirect + github.com/moby/spdystream v0.5.1 // indirect + github.com/moby/sys/atomicwriter v0.1.0 // indirect + github.com/moby/sys/mountinfo v0.7.2 // indirect + github.com/moby/sys/sequential v0.6.0 // indirect + github.com/moby/sys/signal v0.7.1 // indirect + github.com/moby/sys/user v0.4.0 // indirect + github.com/moby/sys/userns v0.1.0 // indirect + github.com/moby/term v0.5.2 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect + github.com/morikuni/aec v1.1.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect + github.com/nats-io/jwt/v2 v2.8.1 // indirect + github.com/nats-io/nkeys v0.4.15 // indirect + github.com/nats-io/nuid v1.0.1 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/nikolalohinski/gonja/v2 v2.7.0 // indirect + github.com/nozzle/throttler v0.0.0-20180817012639-2ea982251481 // indirect + github.com/oklog/ulid/v2 v2.1.1 // indirect + github.com/open-policy-agent/opa v1.15.2 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/opencontainers/runtime-spec v1.3.0 // indirect + github.com/opencontainers/selinux v1.13.1 // indirect + github.com/openvex/discovery v0.1.1-0.20240802171711-7c54efc57553 // indirect + github.com/openvex/go-vex v0.2.7 // indirect + github.com/owenrumney/go-sarif/v2 v2.3.3 // indirect + github.com/owenrumney/squealer v1.2.12 // indirect + github.com/package-url/packageurl-go v0.1.5 // indirect + github.com/pandatix/go-cvss v0.6.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/peterbourgon/diskv v2.0.1+incompatible // indirect + github.com/pjbgf/sha1cd v0.6.0 // indirect + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect + github.com/prometheus/client_golang v1.23.2 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.67.4 // indirect + github.com/prometheus/procfs v0.19.2 // indirect + github.com/qdm12/reprint v0.0.0-20200326205758-722754a53494 // indirect + github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/rubenv/sql-migrate v1.8.1 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/rust-secure-code/go-rustaudit v0.0.0-20250226111315-e20ec32e963c // indirect + github.com/sagikazarmark/locafero v0.12.0 // indirect + github.com/samber/lo v1.53.0 // indirect + github.com/samber/oops v1.19.3 // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect + github.com/sassoftware/go-rpmutils v0.4.0 // indirect + github.com/sassoftware/relic v7.2.1+incompatible // indirect + github.com/secure-systems-lab/go-securesystemslib v0.10.0 // indirect + github.com/segmentio/asm v1.2.1 // indirect + github.com/segmentio/encoding v0.5.4 // indirect + github.com/sergi/go-diff v1.4.0 // indirect + github.com/shibumi/go-pathspec v1.3.0 // indirect + github.com/shirou/gopsutil/v4 v4.26.3 // indirect + github.com/shopspring/decimal v1.4.0 // indirect + github.com/sigstore/cosign/v2 v2.6.3 // indirect + github.com/sigstore/protobuf-specs v0.5.0 // indirect + github.com/sigstore/rekor v1.5.1 // indirect + github.com/sigstore/rekor-tiles/v2 v2.0.1 // indirect + github.com/sigstore/sigstore v1.10.5 // indirect + github.com/sigstore/sigstore-go v1.1.4 // indirect + github.com/sigstore/timestamp-authority/v2 v2.0.6 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect + github.com/skeema/knownhosts v1.3.2 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/cobra v1.10.2 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/spf13/viper v1.21.0 // indirect + github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect + github.com/stephenafamo/scan v0.7.0 // indirect + github.com/stretchr/objx v0.5.3 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/syndtr/goleveldb v1.0.1-0.20220721030215-126854af5e6d // indirect + github.com/tchap/go-patricia/v2 v2.3.3 // indirect + github.com/tetratelabs/wazero v1.11.0 // indirect + github.com/theupdateframework/go-tuf v0.7.0 // indirect + github.com/theupdateframework/go-tuf/v2 v2.4.1 // indirect + github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 // indirect + github.com/tklauser/go-sysconf v0.3.16 // indirect + github.com/tklauser/numcpus v0.11.0 // indirect + github.com/tonistiigi/go-csvvalue v0.0.0-20240814133006-030d3b2625d0 // indirect + github.com/toqueteos/webbrowser v1.2.0 // indirect + github.com/transparency-dev/formats v0.0.0-20251103090025-99ec6f4410eb // indirect + github.com/transparency-dev/merkle v0.0.2 // indirect + github.com/twitchtv/twirp v8.1.3+incompatible // indirect + github.com/ulikunitz/xz v0.5.15 // indirect + github.com/valyala/fastjson v1.6.7 // indirect + github.com/vbatts/tar-split v0.12.2 // indirect + github.com/vektah/gqlparser/v2 v2.5.32 // indirect + github.com/vladimirvivien/gexe v0.5.0 // indirect + github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect + github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect + github.com/x448/float16 v0.8.4 // indirect + github.com/xanzy/ssh-agent v0.3.3 // indirect + github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect + github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect + github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect + github.com/xlab/treeprint v1.2.0 // indirect + github.com/yashtewari/glob-intersection v0.2.0 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + github.com/zclconf/go-cty v1.18.0 // indirect + github.com/zclconf/go-cty-yaml v1.2.0 // indirect + go.etcd.io/bbolt v1.4.3 // indirect + go.etcd.io/etcd/api/v3 v3.6.5 // indirect + go.etcd.io/etcd/client/pkg/v3 v3.6.5 // indirect + go.etcd.io/etcd/client/v3 v3.6.5 // indirect + go.opencensus.io v0.24.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/detectors/gcp v1.39.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 // indirect + go.opentelemetry.io/otel v1.43.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.40.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0 // indirect + go.opentelemetry.io/otel/metric v1.43.0 // indirect + go.opentelemetry.io/otel/sdk v1.43.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect + go.opentelemetry.io/otel/trace v1.43.0 // indirect + go.opentelemetry.io/proto/otlp v1.9.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.1 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + go.yaml.in/yaml/v4 v4.0.0-rc.3 // indirect + golang.org/x/crypto v0.50.0 // indirect + golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect + golang.org/x/mod v0.35.0 // indirect + golang.org/x/net v0.53.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/term v0.42.0 // indirect + golang.org/x/text v0.36.0 // indirect + golang.org/x/tools v0.44.0 // indirect + golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect + gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect + google.golang.org/api v0.272.0 // indirect + google.golang.org/genproto v0.0.0-20260316180232-0b37fe3546d5 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260316180232-0b37fe3546d5 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 // indirect + google.golang.org/grpc v1.79.3 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/ini.v1 v1.67.1 // indirect + gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect + gopkg.in/warnings.v0 v0.1.2 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + helm.sh/helm/v3 v3.20.2 // indirect + k8s.io/apiextensions-apiserver v0.35.1 // indirect + k8s.io/cli-runtime v0.35.1 // indirect + k8s.io/gengo/v2 v2.0.0-20250922181213-ec3ebc5fd46b // indirect + k8s.io/kms v0.35.4 // indirect + k8s.io/kubectl v0.35.1 // indirect + modernc.org/libc v1.72.3 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + mvdan.cc/sh/v3 v3.12.0 // indirect + oras.land/oras-go/v2 v2.6.0 // indirect + sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.34.0 // indirect + sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect + sigs.k8s.io/kustomize/api v0.20.1 // indirect + sigs.k8s.io/kustomize/kyaml v0.20.1 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect +) + +// TODO: remove https://github.com/containerd/containerd/issues/12493 is resolved. +replace github.com/opencontainers/runtime-spec => github.com/opencontainers/runtime-spec v1.3.0 + +replace github.com/aquasecurity/trivy => github.com/alegrey91/trivy v0.0.0-20260506084153-920fad253709 From 65176e55b0c66038116a1bad58266dae70bb05b6 Mon Sep 17 00:00:00 2001 From: Alessio Greggi Date: Tue, 19 May 2026 16:27:48 +0200 Subject: [PATCH 3/5] feat(helm): add chart for nodescan Signed-off-by: Alessio Greggi --- .../templates/controller/role.yaml | 31 +++- .../templates/controller/webhooks.yaml | 43 ++++++ ....kubewarden.io_nodescanconfigurations.yaml | 141 ++++++++++++++++++ .../templates/worker/daemonset.yaml | 109 ++++++++++++++ .../templates/worker/deployment.yaml | 1 + .../templates/worker/node-role.yaml | 47 ++++++ .../templates/worker/node-rolebinding.yaml | 15 ++ .../templates/worker/node-serviceaccount.yaml | 8 + .../worker/{role.yaml => registry-role.yaml} | 0 ...binding.yaml => registry-rolebinding.yaml} | 0 ...ount.yaml => registry-serviceaccount.yaml} | 0 11 files changed, 390 insertions(+), 5 deletions(-) create mode 100644 charts/sbomscanner/templates/crd/sbomscanner.kubewarden.io_nodescanconfigurations.yaml create mode 100644 charts/sbomscanner/templates/worker/daemonset.yaml create mode 100644 charts/sbomscanner/templates/worker/node-role.yaml create mode 100644 charts/sbomscanner/templates/worker/node-rolebinding.yaml create mode 100644 charts/sbomscanner/templates/worker/node-serviceaccount.yaml rename charts/sbomscanner/templates/worker/{role.yaml => registry-role.yaml} (100%) rename charts/sbomscanner/templates/worker/{rolebinding.yaml => registry-rolebinding.yaml} (100%) rename charts/sbomscanner/templates/worker/{serviceaccount.yaml => registry-serviceaccount.yaml} (100%) diff --git a/charts/sbomscanner/templates/controller/role.yaml b/charts/sbomscanner/templates/controller/role.yaml index a23583f49..7d6d16d34 100644 --- a/charts/sbomscanner/templates/controller/role.yaml +++ b/charts/sbomscanner/templates/controller/role.yaml @@ -11,6 +11,7 @@ rules: - "" resources: - namespaces + - nodes - pods verbs: - get @@ -35,24 +36,36 @@ rules: - apiGroups: - sbomscanner.kubewarden.io resources: - - registries - - scanjobs + - nodescanconfigurations verbs: - - create - - delete - get - list - - patch - update - watch - apiGroups: - sbomscanner.kubewarden.io resources: + - nodescanconfigurations/status + - nodescanjobs/status - scanjobs/status verbs: - get - patch - update +- apiGroups: + - sbomscanner.kubewarden.io + resources: + - nodescanjobs + - registries + - scanjobs + verbs: + - create + - delete + - get + - list + - patch + - update + - watch - apiGroups: - sbomscanner.kubewarden.io resources: @@ -70,6 +83,14 @@ rules: - list - patch - watch +- apiGroups: + - storage.sbomscanner.kubewarden.io + resources: + - nodesboms + verbs: + - delete + - list + - watch - apiGroups: - storage.sbomscanner.kubewarden.io resources: diff --git a/charts/sbomscanner/templates/controller/webhooks.yaml b/charts/sbomscanner/templates/controller/webhooks.yaml index f9edcd33b..bb4a8dbda 100644 --- a/charts/sbomscanner/templates/controller/webhooks.yaml +++ b/charts/sbomscanner/templates/controller/webhooks.yaml @@ -126,3 +126,46 @@ webhooks: resources: - workloadscanconfigurations sideEffects: None + - admissionReviewVersions: + - v1 + - v1beta1 + clientConfig: + service: + name: {{ include "sbomscanner.fullname" . }}-controller-webhook + namespace: {{ .Release.Namespace }} + path: /validate-sbomscanner-kubewarden-io-v1alpha1-nodescanconfiguration + failurePolicy: Fail + name: vnodescanconfiguration.sbomscanner.kubewarden.io + rules: + - apiGroups: + - sbomscanner.kubewarden.io + apiVersions: + - v1alpha1 + operations: + - CREATE + - UPDATE + - DELETE + resources: + - nodescanconfigurations + sideEffects: None + - admissionReviewVersions: + - v1 + - v1beta1 + clientConfig: + service: + name: {{ include "sbomscanner.fullname" . }}-controller-webhook + namespace: {{ .Release.Namespace }} + path: /validate-sbomscanner-kubewarden-io-v1alpha1-nodescanjob + failurePolicy: Fail + name: vnodescanjob.sbomscanner.kubewarden.io + rules: + - apiGroups: + - sbomscanner.kubewarden.io + apiVersions: + - v1alpha1 + operations: + - CREATE + - UPDATE + resources: + - nodescanjobs + sideEffects: None diff --git a/charts/sbomscanner/templates/crd/sbomscanner.kubewarden.io_nodescanconfigurations.yaml b/charts/sbomscanner/templates/crd/sbomscanner.kubewarden.io_nodescanconfigurations.yaml new file mode 100644 index 000000000..712c6ccb3 --- /dev/null +++ b/charts/sbomscanner/templates/crd/sbomscanner.kubewarden.io_nodescanconfigurations.yaml @@ -0,0 +1,141 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + helm.sh/resource-policy: keep + controller-gen.kubebuilder.io/version: v0.16.5 + name: nodescanconfigurations.sbomscanner.kubewarden.io +spec: + group: sbomscanner.kubewarden.io + names: + kind: NodeScanConfiguration + listKind: NodeScanConfigurationList + plural: nodescanconfigurations + singular: nodescanconfiguration + scope: Cluster + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + NodeScanConfiguration is the Schema for the nodescanconfigurations API. + This is a singleton resource - only one instance named "default" is allowed. + 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: NodeScanConfigurationSpec defines the desired configuration + for node scanning. + properties: + nodeSelector: + description: |- + NodeSelector filters which nodes are scanned. + If not specified, all the nodes are scanned. + properties: + matchExpressions: + description: matchExpressions is a list of label selector requirements. + The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + platforms: + description: |- + Platforms allows to specify the list of platforms to scan. + If not set, all nodes are scanned regardless of their platform. + items: + description: Platform describes the platform which the image in + the manifest runs on. + properties: + arch: + description: |- + Architecture field specifies the CPU architecture, for example + `amd64` or `ppc64le`. + type: string + os: + description: OS specifies the operating system, for example + `linux` or `windows`. + type: string + variant: + description: |- + Variant is an optional field specifying a variant of the CPU, for + example `v7` to specify ARMv7 when architecture is `arm`. + type: string + required: + - arch + - os + type: object + type: array + scanInterval: + description: ScanInterval is the interval at which nodes are scanned. + type: string + skipPatterns: + description: |- + SkipPatterns specifies gitignore-style patterns for directories and files to skip during node scanning. + Patterns ending with "/" are treated as directories. + All other patterns are treated as files. + Glob patterns like "**/vendor/" or "*.min.js" are supported. + items: + type: string + type: array + type: object + type: object + x-kubernetes-validations: + - message: NodeScanConfiguration name must be 'default' + rule: self.metadata.name == 'default' + served: true + storage: true + subresources: + status: {} diff --git a/charts/sbomscanner/templates/worker/daemonset.yaml b/charts/sbomscanner/templates/worker/daemonset.yaml new file mode 100644 index 000000000..38a24eecb --- /dev/null +++ b/charts/sbomscanner/templates/worker/daemonset.yaml @@ -0,0 +1,109 @@ +apiVersion: apps/v1 +kind: DaemonSet +metadata: + name: {{ include "sbomscanner.fullname" . }}-worker-node + namespace: {{ .Release.Namespace }} + labels: + {{ include "sbomscanner.labels" .| nindent 4 }} + app.kubernetes.io/component: worker-node +spec: + selector: + matchLabels: + {{ include "sbomscanner.selectorLabels" .| nindent 6 }} + app.kubernetes.io/component: worker-node + template: + metadata: + labels: + {{ include "sbomscanner.labels" .| nindent 8 }} + app.kubernetes.io/component: worker-node + spec: + serviceAccountName: {{ include "sbomscanner.fullname" . }}-worker-node + initContainers: + - name: init + image: '{{ template "system_default_registry" . }}{{ .Values.worker.image.repository }}:{{ .Values.worker.image.tag }}' + imagePullPolicy: {{ .Values.worker.image.pullPolicy }} + securityContext: + {{ include "sbomscanner.securityContext" . | nindent 12 }} + args: + - --init + - -nats-url + - {{ .Release.Name }}-nats.{{ .Release.Namespace }}.svc.cluster.local:4222 + {{- if .Values.worker.logLevel }} + - -log-level={{ .Values.worker.logLevel }} + {{- end }} + volumeMounts: + - mountPath: "/nats/tls" + name: nats-tls + readOnly: true + containers: + - name: worker + image: '{{ template "system_default_registry" . }}{{ .Values.worker.image.repository }}:{{ .Values.worker.image.tag }}' + env: + - name: NODE_NAME + valueFrom: + fieldRef: + fieldPath: spec.nodeName + imagePullPolicy: {{ .Values.worker.image.pullPolicy }} + securityContext: + readOnlyRootFilesystem: true + runAsNonRoot: false + runAsUser: 0 + seccompProfile: + type: RuntimeDefault + allowPrivilegeEscalation: false + capabilities: + drop: + - "ALL" + add: + - "DAC_READ_SEARCH" + args: + - -nats-url + - {{ .Release.Name }}-nats.{{ .Release.Namespace }}.svc.cluster.local:4222 + {{- if .Values.worker.trivyDBRepository }} + - -trivy-db-repository={{ .Values.worker.trivyDBRepository | quote }} + {{- end }} + {{- if .Values.worker.trivyJavaDBRepository }} + - -trivy-java-db-repository={{ .Values.worker.trivyJavaDBRepository | quote }} + {{- end }} + - -installation-namespace={{ .Release.Namespace }} + {{- if .Values.worker.logLevel }} + - -log-level={{ .Values.worker.logLevel }} + {{- end }} + - -mode=node + - -node-name=$(NODE_NAME) + {{- if and .Values.worker .Values.worker.resources }} + resources: +{{ toYaml .Values.worker.resources | indent 12 }} + {{- end }} + livenessProbe: + httpGet: + path: /livez + port: 8081 + readinessProbe: + httpGet: + path: /readyz + port: 8081 + volumeMounts: + - name: host-root + mountPath: /host + readOnly: true + mountPropagation: None + - mountPath: /var/run/worker + name: run-volume + - mountPath: /tmp + name: tmp-dir + - mountPath: "/nats/tls" + name: nats-tls + readOnly: true + volumes: + - name: host-root + hostPath: + path: / + type: Directory + - name: run-volume + emptyDir: {} + - name: tmp-dir + emptyDir: {} + - name: nats-tls + secret: + secretName: {{ include "sbomscanner.fullname" . }}-nats-worker-client-tls diff --git a/charts/sbomscanner/templates/worker/deployment.yaml b/charts/sbomscanner/templates/worker/deployment.yaml index 09359f1b2..f4ce4942e 100644 --- a/charts/sbomscanner/templates/worker/deployment.yaml +++ b/charts/sbomscanner/templates/worker/deployment.yaml @@ -55,6 +55,7 @@ spec: {{- if .Values.worker.logLevel }} - -log-level={{ .Values.worker.logLevel }} {{- end }} + - -mode=registry {{- if and .Values.worker .Values.worker.resources }} resources: {{ toYaml .Values.worker.resources | indent 12 }} diff --git a/charts/sbomscanner/templates/worker/node-role.yaml b/charts/sbomscanner/templates/worker/node-role.yaml new file mode 100644 index 000000000..0bf9f21bc --- /dev/null +++ b/charts/sbomscanner/templates/worker/node-role.yaml @@ -0,0 +1,47 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: {{ include "sbomscanner.fullname" . }}-worker-node + labels: + {{ include "sbomscanner.labels" .| nindent 4 }} + app.kubernetes.io/component: worker-node +rules: + - apiGroups: + - sbomscanner.kubewarden.io + resources: + - vexhubs + verbs: + - get + - list + - watch + - apiGroups: + - sbomscanner.kubewarden.io + resources: + - nodescanjobs + - nodescanjobs/status + - nodescanconfigurations + verbs: + - get + - list + - watch + - patch + - update + - apiGroups: + - storage.sbomscanner.kubewarden.io + resources: + - nodesboms + - nodevulnerabilityreports + verbs: + - create + - delete + - get + - list + - patch + - update + - watch + - apiGroups: + - "" + resources: + - nodes + verbs: + - get diff --git a/charts/sbomscanner/templates/worker/node-rolebinding.yaml b/charts/sbomscanner/templates/worker/node-rolebinding.yaml new file mode 100644 index 000000000..c550ecf1f --- /dev/null +++ b/charts/sbomscanner/templates/worker/node-rolebinding.yaml @@ -0,0 +1,15 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: {{ include "sbomscanner.fullname" . }}-worker-node + labels: + {{ include "sbomscanner.labels" .| nindent 4 }} + app.kubernetes.io/component: worker-node +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: {{ include "sbomscanner.fullname" . }}-worker-node +subjects: + - kind: ServiceAccount + name: {{ include "sbomscanner.fullname" . }}-worker-node + namespace: {{ .Release.Namespace }} diff --git a/charts/sbomscanner/templates/worker/node-serviceaccount.yaml b/charts/sbomscanner/templates/worker/node-serviceaccount.yaml new file mode 100644 index 000000000..c54bd5edb --- /dev/null +++ b/charts/sbomscanner/templates/worker/node-serviceaccount.yaml @@ -0,0 +1,8 @@ +kind: ServiceAccount +apiVersion: v1 +metadata: + name: {{ include "sbomscanner.fullname" . }}-worker-node + namespace: {{ .Release.Namespace }} + labels: + {{ include "sbomscanner.labels" .| nindent 4 }} + app.kubernetes.io/component: worker-node diff --git a/charts/sbomscanner/templates/worker/role.yaml b/charts/sbomscanner/templates/worker/registry-role.yaml similarity index 100% rename from charts/sbomscanner/templates/worker/role.yaml rename to charts/sbomscanner/templates/worker/registry-role.yaml diff --git a/charts/sbomscanner/templates/worker/rolebinding.yaml b/charts/sbomscanner/templates/worker/registry-rolebinding.yaml similarity index 100% rename from charts/sbomscanner/templates/worker/rolebinding.yaml rename to charts/sbomscanner/templates/worker/registry-rolebinding.yaml diff --git a/charts/sbomscanner/templates/worker/serviceaccount.yaml b/charts/sbomscanner/templates/worker/registry-serviceaccount.yaml similarity index 100% rename from charts/sbomscanner/templates/worker/serviceaccount.yaml rename to charts/sbomscanner/templates/worker/registry-serviceaccount.yaml From 24b3dd52e6456974aec4d7130173219c15001a3c Mon Sep 17 00:00:00 2001 From: Alessio Greggi Date: Tue, 19 May 2026 16:28:12 +0200 Subject: [PATCH 4/5] feat(webhook): add nodescanconfigiration validation Signed-off-by: Alessio Greggi --- .../v1alpha1/nodescanconfiguration_webhook.go | 101 ++++++ .../nodescanconfiguration_webhook_test.go | 239 ++++++++++++++ .../v1alpha1/nodescanjob_validators.go | 75 +++++ .../webhook/v1alpha1/nodescanjob_webhook.go | 80 +++++ .../v1alpha1/nodescanjob_webhook_test.go | 309 ++++++++++++++++++ .../webhook/v1alpha1/scanjob_webhook_test.go | 6 +- 6 files changed, 807 insertions(+), 3 deletions(-) create mode 100644 internal/webhook/v1alpha1/nodescanconfiguration_webhook.go create mode 100644 internal/webhook/v1alpha1/nodescanconfiguration_webhook_test.go create mode 100644 internal/webhook/v1alpha1/nodescanjob_validators.go create mode 100644 internal/webhook/v1alpha1/nodescanjob_webhook.go create mode 100644 internal/webhook/v1alpha1/nodescanjob_webhook_test.go diff --git a/internal/webhook/v1alpha1/nodescanconfiguration_webhook.go b/internal/webhook/v1alpha1/nodescanconfiguration_webhook.go new file mode 100644 index 000000000..bf28263b2 --- /dev/null +++ b/internal/webhook/v1alpha1/nodescanconfiguration_webhook.go @@ -0,0 +1,101 @@ +package v1alpha1 + +import ( + "context" + "fmt" + + "github.com/go-logr/logr" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + metav1validation "k8s.io/apimachinery/pkg/apis/meta/v1/validation" + "k8s.io/apimachinery/pkg/util/validation/field" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + "github.com/kubewarden/sbomscanner/api/v1alpha1" +) + +// SetupNodeScanConfigurationWebhookWithManager registers the webhook for NodeScanConfiguration in the manager. +func SetupNodeScanConfigurationWebhookWithManager(mgr ctrl.Manager) error { + err := ctrl.NewWebhookManagedBy(mgr, &v1alpha1.NodeScanConfiguration{}). + WithValidator(&NodeScanConfigurationCustomValidator{ + logger: mgr.GetLogger().WithName("NodeScanConfiguration_validator"), + }). + Complete() + if err != nil { + return fmt.Errorf("failed to setup NodeScanConfiguration webhook: %w", err) + } + return nil +} + +// +kubebuilder:webhook:path=/validate-sbomscanner-kubewarden-io-v1alpha1-nodescanconfiguration,mutating=false,failurePolicy=fail,sideEffects=None,groups=sbomscanner.kubewarden.io,resources=nodescanconfigurations,verbs=create;update;delete,versions=v1alpha1,name=vnodescanconfiguration.sbomscanner.kubewarden.io,admissionReviewVersions=v1 + +type NodeScanConfigurationCustomValidator struct { + logger logr.Logger +} + +var _ admission.Validator[*v1alpha1.NodeScanConfiguration] = &NodeScanConfigurationCustomValidator{} + +func (v *NodeScanConfigurationCustomValidator) ValidateCreate(_ context.Context, config *v1alpha1.NodeScanConfiguration) (admission.Warnings, error) { + v.logger.Info("Validation for NodeScanConfiguration upon creation", "name", config.GetName()) + + allErrs := validateNodeScanConfiguration(config) + + if len(allErrs) > 0 { + return nil, apierrors.NewInvalid( + v1alpha1.GroupVersion.WithKind("NodeScanConfiguration").GroupKind(), + config.Name, + allErrs, + ) + } + + return nil, nil +} + +func (v *NodeScanConfigurationCustomValidator) ValidateUpdate(_ context.Context, _, config *v1alpha1.NodeScanConfiguration) (admission.Warnings, error) { + v.logger.Info("Validation for NodeScanConfiguration upon update", "name", config.GetName()) + + allErrs := validateNodeScanConfiguration(config) + + if len(allErrs) > 0 { + return nil, apierrors.NewInvalid( + v1alpha1.GroupVersion.WithKind("NodeScanConfiguration").GroupKind(), + config.Name, + allErrs, + ) + } + + return nil, nil +} + +func (v *NodeScanConfigurationCustomValidator) ValidateDelete(_ context.Context, config *v1alpha1.NodeScanConfiguration) (admission.Warnings, error) { + v.logger.Info("Validation for NodeScanConfiguration upon deletion", "name", config.GetName()) + + return admission.Warnings{ + "NodeScanConfiguration deleted. Node scan feature is now disabled", + }, nil +} + +func validateNodeScanConfiguration(config *v1alpha1.NodeScanConfiguration) field.ErrorList { + var allErrs field.ErrorList + + if err := validateScanInterval(config.Spec.ScanInterval); err != nil { + allErrs = append(allErrs, err) + } + allErrs = append(allErrs, validatePlatforms(config.Spec.Platforms)...) + allErrs = append(allErrs, validateNodeSelector(config.Spec.NodeSelector)...) + + return allErrs +} + +func validateNodeSelector(selector *metav1.LabelSelector) field.ErrorList { + if selector == nil { + return nil + } + + fieldPath := field.NewPath("spec").Child("nodeSelector") + opts := metav1validation.LabelSelectorValidationOptions{} + + return metav1validation.ValidateLabelSelector(selector, opts, fieldPath) +} diff --git a/internal/webhook/v1alpha1/nodescanconfiguration_webhook_test.go b/internal/webhook/v1alpha1/nodescanconfiguration_webhook_test.go new file mode 100644 index 000000000..f34930090 --- /dev/null +++ b/internal/webhook/v1alpha1/nodescanconfiguration_webhook_test.go @@ -0,0 +1,239 @@ +package v1alpha1 + +import ( + "testing" + "time" + + "github.com/go-logr/logr" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/kubewarden/sbomscanner/api/v1alpha1" +) + +type nodeScanConfigurationTestCase struct { + name string + configuration *v1alpha1.NodeScanConfiguration + expectedError string + expectedField string +} + +var nodeScanConfigurationTestCases = []nodeScanConfigurationTestCase{ + { + name: "should allow when all fields are empty", + configuration: &v1alpha1.NodeScanConfiguration{ + ObjectMeta: metav1.ObjectMeta{ + Name: "default", + }, + Spec: v1alpha1.NodeScanConfigurationSpec{}, + }, + }, + // ScanInterval test cases + { + name: "should allow when scanInterval is nil", + configuration: &v1alpha1.NodeScanConfiguration{ + ObjectMeta: metav1.ObjectMeta{ + Name: "default", + }, + Spec: v1alpha1.NodeScanConfigurationSpec{ + ScanInterval: nil, + }, + }, + }, + { + name: "should admit when scanInterval is exactly 1 minute", + configuration: &v1alpha1.NodeScanConfiguration{ + ObjectMeta: metav1.ObjectMeta{ + Name: "default", + }, + Spec: v1alpha1.NodeScanConfigurationSpec{ + ScanInterval: &metav1.Duration{ + Duration: time.Minute, + }, + }, + }, + }, + { + name: "should admit when scanInterval is greater than 1 minute", + configuration: &v1alpha1.NodeScanConfiguration{ + ObjectMeta: metav1.ObjectMeta{ + Name: "default", + }, + Spec: v1alpha1.NodeScanConfigurationSpec{ + ScanInterval: &metav1.Duration{ + Duration: 1 * time.Hour, + }, + }, + }, + }, + { + name: "should deny when scanInterval is less than 1 minute", + configuration: &v1alpha1.NodeScanConfiguration{ + ObjectMeta: metav1.ObjectMeta{ + Name: "default", + }, + Spec: v1alpha1.NodeScanConfigurationSpec{ + ScanInterval: &metav1.Duration{ + Duration: 30 * time.Second, + }, + }, + }, + expectedField: "spec.scanInterval", + expectedError: "scanInterval must be at least 1 minute", + }, + // Platform test cases + { + name: "should allow when platforms are valid", + configuration: &v1alpha1.NodeScanConfiguration{ + ObjectMeta: metav1.ObjectMeta{ + Name: "default", + }, + Spec: v1alpha1.NodeScanConfigurationSpec{ + Platforms: []v1alpha1.Platform{ + { + Architecture: "amd64", + OS: "linux", + }, + }, + }, + }, + }, + { + name: "should deny when platforms are not valid", + configuration: &v1alpha1.NodeScanConfiguration{ + ObjectMeta: metav1.ObjectMeta{ + Name: "default", + }, + Spec: v1alpha1.NodeScanConfigurationSpec{ + Platforms: []v1alpha1.Platform{ + { + Architecture: "xxx", + OS: "yyy", + }, + }, + }, + }, + expectedField: "spec.platforms[0]", + expectedError: "unsupported OS: yyy", + }, + // NodeSelector test cases + { + name: "should allow when nodeSelector is nil", + configuration: &v1alpha1.NodeScanConfiguration{ + ObjectMeta: metav1.ObjectMeta{ + Name: "default", + }, + Spec: v1alpha1.NodeScanConfigurationSpec{ + NodeSelector: nil, + }, + }, + }, + { + name: "should allow when nodeSelector is valid", + configuration: &v1alpha1.NodeScanConfiguration{ + ObjectMeta: metav1.ObjectMeta{ + Name: "default", + }, + Spec: v1alpha1.NodeScanConfigurationSpec{ + NodeSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "env": "production", + }, + }, + }, + }, + }, + { + name: "should deny when nodeSelector is invalid", + configuration: &v1alpha1.NodeScanConfiguration{ + ObjectMeta: metav1.ObjectMeta{ + Name: "default", + }, + Spec: v1alpha1.NodeScanConfigurationSpec{ + NodeSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "invalid key": "value", + }, + }, + }, + }, + expectedField: "spec.nodeSelector.matchLabels", + expectedError: "Invalid value", + }, +} + +func TestNodeScanConfigurationCustomValidator_ValidateCreate(t *testing.T) { + for _, test := range nodeScanConfigurationTestCases { + t.Run(test.name, func(t *testing.T) { + validator := &NodeScanConfigurationCustomValidator{ + logger: logr.Discard(), + } + warnings, err := validator.ValidateCreate(t.Context(), test.configuration) + + if test.expectedError != "" { + require.Error(t, err) + statusErr, ok := err.(interface{ Status() metav1.Status }) + require.True(t, ok) + details := statusErr.Status().Details + require.NotNil(t, details) + require.Len(t, details.Causes, 1) + assert.Equal(t, test.expectedField, details.Causes[0].Field) + assert.Contains(t, details.Causes[0].Message, test.expectedError) + } else { + require.NoError(t, err) + } + + assert.Empty(t, warnings) + }) + } +} + +func TestNodeScanConfigurationCustomValidator_ValidateUpdate(t *testing.T) { + for _, test := range nodeScanConfigurationTestCases { + t.Run(test.name, func(t *testing.T) { + validator := &NodeScanConfigurationCustomValidator{ + logger: logr.Discard(), + } + + old := &v1alpha1.NodeScanConfiguration{} + warnings, err := validator.ValidateUpdate(t.Context(), old, test.configuration) + + if test.expectedError != "" { + require.Error(t, err) + statusErr, ok := err.(interface{ Status() metav1.Status }) + require.True(t, ok) + details := statusErr.Status().Details + require.NotNil(t, details) + require.Len(t, details.Causes, 1) + assert.Equal(t, test.expectedField, details.Causes[0].Field) + assert.Contains(t, details.Causes[0].Message, test.expectedError) + } else { + require.NoError(t, err) + } + + assert.Empty(t, warnings) + }) + } +} + +func TestNodeScanConfigurationCustomValidator_ValidateDelete(t *testing.T) { + t.Run("should return warning on delete", func(t *testing.T) { + validator := &NodeScanConfigurationCustomValidator{ + logger: logr.Discard(), + } + + config := &v1alpha1.NodeScanConfiguration{ + ObjectMeta: metav1.ObjectMeta{ + Name: "default", + }, + } + + warnings, err := validator.ValidateDelete(t.Context(), config) + + require.NoError(t, err) + require.Len(t, warnings, 1) + assert.Equal(t, "NodeScanConfiguration deleted. Node scan feature is now disabled", warnings[0]) + }) +} diff --git a/internal/webhook/v1alpha1/nodescanjob_validators.go b/internal/webhook/v1alpha1/nodescanjob_validators.go new file mode 100644 index 000000000..07c234464 --- /dev/null +++ b/internal/webhook/v1alpha1/nodescanjob_validators.go @@ -0,0 +1,75 @@ +package v1alpha1 + +import ( + "context" + "fmt" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/validation/field" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/kubewarden/sbomscanner/api/v1alpha1" + "github.com/kubewarden/sbomscanner/internal/filters" +) + +// ValidateNodeScanJobAgainstConfig checks that the node targeted by a +// NodeScanJob exists and matches the singleton NodeScanConfiguration. +// It returns field-level errors suitable for both webhook rejection and +// controller failure reporting. +func ValidateNodeScanJobAgainstConfig(ctx context.Context, c client.Reader, nodeName string) field.ErrorList { + var allErrs field.ErrorList + nodeNameField := field.NewPath("spec").Child("nodeName") + + var config v1alpha1.NodeScanConfiguration + if err := c.Get(ctx, types.NamespacedName{Name: v1alpha1.NodeScanConfigurationName}, &config); err != nil { + if apierrors.IsNotFound(err) { + allErrs = append(allErrs, field.Forbidden(nodeNameField, + "NodeScanConfiguration not found: node scanning is not configured")) + return allErrs + } + allErrs = append(allErrs, field.InternalError(nodeNameField, + fmt.Errorf("fetching NodeScanConfiguration: %w", err))) + return allErrs + } + + var node corev1.Node + if err := c.Get(ctx, types.NamespacedName{Name: nodeName}, &node); err != nil { + if apierrors.IsNotFound(err) { + allErrs = append(allErrs, field.NotFound(nodeNameField, nodeName)) + return allErrs + } + allErrs = append(allErrs, field.InternalError(nodeNameField, + fmt.Errorf("fetching Node %q: %w", nodeName, err))) + return allErrs + } + + if config.Spec.NodeSelector != nil { + selector, err := metav1.LabelSelectorAsSelector(config.Spec.NodeSelector) + if err != nil { + allErrs = append(allErrs, field.InternalError(nodeNameField, + fmt.Errorf("parsing NodeSelector: %w", err))) + return allErrs + } + if !selector.Matches(labels.Set(node.Labels)) { + allErrs = append(allErrs, field.Forbidden(nodeNameField, + fmt.Sprintf("node %q does not match the NodeScanConfiguration nodeSelector", nodeName))) + } + } + + if !filters.IsPlatformAllowed( + node.Status.NodeInfo.OperatingSystem, + node.Status.NodeInfo.Architecture, + "", + config.Spec.Platforms, + ) { + allErrs = append(allErrs, field.Forbidden(nodeNameField, + fmt.Sprintf("node %q platform %s/%s is not allowed by the NodeScanConfiguration", + nodeName, node.Status.NodeInfo.OperatingSystem, node.Status.NodeInfo.Architecture))) + } + + return allErrs +} diff --git a/internal/webhook/v1alpha1/nodescanjob_webhook.go b/internal/webhook/v1alpha1/nodescanjob_webhook.go new file mode 100644 index 000000000..a7e0f6230 --- /dev/null +++ b/internal/webhook/v1alpha1/nodescanjob_webhook.go @@ -0,0 +1,80 @@ +package v1alpha1 + +import ( + "context" + "fmt" + + "github.com/go-logr/logr" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/util/validation/field" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + "github.com/kubewarden/sbomscanner/api/v1alpha1" +) + +// SetupNodeScanJobWebhookWithManager registers the webhook for NodeScanJob in the manager. +func SetupNodeScanJobWebhookWithManager(mgr ctrl.Manager) error { + err := ctrl.NewWebhookManagedBy(mgr, &v1alpha1.NodeScanJob{}). + WithValidator(&NodeScanJobCustomValidator{ + client: mgr.GetClient(), + logger: mgr.GetLogger().WithName("NodeScanJob_validator"), + }). + Complete() + if err != nil { + return fmt.Errorf("failed to setup NodeScanJob webhook: %w", err) + } + return nil +} + +// +kubebuilder:webhook:path=/validate-sbomscanner-kubewarden-io-v1alpha1-nodescanjob,mutating=false,failurePolicy=fail,sideEffects=None,groups=sbomscanner.kubewarden.io,resources=nodescanjobs,verbs=create;update,versions=v1alpha1,name=vnodescanjob.sbomscanner.kubewarden.io,admissionReviewVersions=v1 + +type NodeScanJobCustomValidator struct { + client client.Client + logger logr.Logger +} + +var _ admission.Validator[*v1alpha1.NodeScanJob] = &NodeScanJobCustomValidator{} + +func (v *NodeScanJobCustomValidator) ValidateCreate(ctx context.Context, job *v1alpha1.NodeScanJob) (admission.Warnings, error) { + v.logger.Info("Validation for NodeScanJob upon creation", "name", job.GetName()) + + allErrs := ValidateNodeScanJobAgainstConfig(ctx, v.client, job.Spec.NodeName) + + if len(allErrs) > 0 { + return nil, apierrors.NewInvalid( + v1alpha1.GroupVersion.WithKind("NodeScanJob").GroupKind(), + job.Name, + allErrs, + ) + } + + return nil, nil +} + +func (v *NodeScanJobCustomValidator) ValidateUpdate(_ context.Context, oldJob, newJob *v1alpha1.NodeScanJob) (admission.Warnings, error) { + v.logger.Info("Validation for NodeScanJob upon update", "name", newJob.GetName()) + + var allErrs field.ErrorList + if oldJob.Spec.NodeName != newJob.Spec.NodeName { + fieldPath := field.NewPath("spec").Child("nodeName") + allErrs = append(allErrs, field.Invalid(fieldPath, newJob.Spec.NodeName, "field is immutable")) + } + + if len(allErrs) > 0 { + return nil, apierrors.NewInvalid( + v1alpha1.GroupVersion.WithKind("NodeScanJob").GroupKind(), + newJob.Name, + allErrs, + ) + } + + return nil, nil +} + +func (v *NodeScanJobCustomValidator) ValidateDelete(_ context.Context, job *v1alpha1.NodeScanJob) (admission.Warnings, error) { + v.logger.Info("Validation for NodeScanJob upon deletion", "name", job.GetName()) + return nil, nil +} diff --git a/internal/webhook/v1alpha1/nodescanjob_webhook_test.go b/internal/webhook/v1alpha1/nodescanjob_webhook_test.go new file mode 100644 index 000000000..2aa423da3 --- /dev/null +++ b/internal/webhook/v1alpha1/nodescanjob_webhook_test.go @@ -0,0 +1,309 @@ +package v1alpha1 + +import ( + "fmt" + "testing" + + "github.com/go-logr/logr" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + "github.com/kubewarden/sbomscanner/api/v1alpha1" +) + +func TestNodeScanJobCustomValidator_ValidateCreate(t *testing.T) { + tests := []struct { + name string + config *v1alpha1.NodeScanConfiguration + node *corev1.Node + job *v1alpha1.NodeScanJob + expectedError string + expectedField string + }{ + { + name: "should admit when config exists and node matches", + config: &v1alpha1.NodeScanConfiguration{ + ObjectMeta: metav1.ObjectMeta{Name: "default"}, + Spec: v1alpha1.NodeScanConfigurationSpec{}, + }, + node: &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{Name: "worker-1"}, + }, + job: &v1alpha1.NodeScanJob{ + ObjectMeta: metav1.ObjectMeta{Name: "test-job"}, + Spec: v1alpha1.NodeScanJobSpec{NodeName: "worker-1"}, + }, + }, + { + name: "should deny when NodeScanConfiguration is missing", + config: nil, + node: &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{Name: "worker-1"}, + }, + job: &v1alpha1.NodeScanJob{ + ObjectMeta: metav1.ObjectMeta{Name: "test-job"}, + Spec: v1alpha1.NodeScanJobSpec{NodeName: "worker-1"}, + }, + expectedField: "spec.nodeName", + expectedError: "NodeScanConfiguration not found", + }, + { + name: "should deny when node does not exist", + config: &v1alpha1.NodeScanConfiguration{ + ObjectMeta: metav1.ObjectMeta{Name: "default"}, + Spec: v1alpha1.NodeScanConfigurationSpec{}, + }, + node: nil, + job: &v1alpha1.NodeScanJob{ + ObjectMeta: metav1.ObjectMeta{Name: "test-job"}, + Spec: v1alpha1.NodeScanJobSpec{NodeName: "non-existent"}, + }, + expectedField: "spec.nodeName", + expectedError: "not found", + }, + { + name: "should deny when node does not match nodeSelector", + config: &v1alpha1.NodeScanConfiguration{ + ObjectMeta: metav1.ObjectMeta{Name: "default"}, + Spec: v1alpha1.NodeScanConfigurationSpec{ + NodeSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"env": "production"}, + }, + }, + }, + node: &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: "worker-1", + Labels: map[string]string{"env": "staging"}, + }, + }, + job: &v1alpha1.NodeScanJob{ + ObjectMeta: metav1.ObjectMeta{Name: "test-job"}, + Spec: v1alpha1.NodeScanJobSpec{NodeName: "worker-1"}, + }, + expectedField: "spec.nodeName", + expectedError: "does not match the NodeScanConfiguration nodeSelector", + }, + { + name: "should admit when node matches nodeSelector", + config: &v1alpha1.NodeScanConfiguration{ + ObjectMeta: metav1.ObjectMeta{Name: "default"}, + Spec: v1alpha1.NodeScanConfigurationSpec{ + NodeSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"env": "production"}, + }, + }, + }, + node: &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: "worker-1", + Labels: map[string]string{"env": "production"}, + }, + }, + job: &v1alpha1.NodeScanJob{ + ObjectMeta: metav1.ObjectMeta{Name: "test-job"}, + Spec: v1alpha1.NodeScanJobSpec{NodeName: "worker-1"}, + }, + }, + { + name: "should deny when node platform is not allowed", + config: &v1alpha1.NodeScanConfiguration{ + ObjectMeta: metav1.ObjectMeta{Name: "default"}, + Spec: v1alpha1.NodeScanConfigurationSpec{ + Platforms: []v1alpha1.Platform{ + {Architecture: "amd64", OS: "linux"}, + }, + }, + }, + node: &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{Name: "worker-1"}, + Status: corev1.NodeStatus{ + NodeInfo: corev1.NodeSystemInfo{ + OperatingSystem: "linux", + Architecture: "arm64", + }, + }, + }, + job: &v1alpha1.NodeScanJob{ + ObjectMeta: metav1.ObjectMeta{Name: "test-job"}, + Spec: v1alpha1.NodeScanJobSpec{NodeName: "worker-1"}, + }, + expectedField: "spec.nodeName", + expectedError: "platform linux/arm64 is not allowed", + }, + { + name: "should admit when node platform matches", + config: &v1alpha1.NodeScanConfiguration{ + ObjectMeta: metav1.ObjectMeta{Name: "default"}, + Spec: v1alpha1.NodeScanConfigurationSpec{ + Platforms: []v1alpha1.Platform{ + {Architecture: "amd64", OS: "linux"}, + }, + }, + }, + node: &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{Name: "worker-1"}, + Status: corev1.NodeStatus{ + NodeInfo: corev1.NodeSystemInfo{ + OperatingSystem: "linux", + Architecture: "amd64", + }, + }, + }, + job: &v1alpha1.NodeScanJob{ + ObjectMeta: metav1.ObjectMeta{Name: "test-job"}, + Spec: v1alpha1.NodeScanJobSpec{NodeName: "worker-1"}, + }, + }, + { + name: "should deny when nodeSelector and platform both fail", + config: &v1alpha1.NodeScanConfiguration{ + ObjectMeta: metav1.ObjectMeta{Name: "default"}, + Spec: v1alpha1.NodeScanConfigurationSpec{ + NodeSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"env": "production"}, + }, + Platforms: []v1alpha1.Platform{ + {Architecture: "amd64", OS: "linux"}, + }, + }, + }, + node: &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: "worker-1", + Labels: map[string]string{"env": "staging"}, + }, + Status: corev1.NodeStatus{ + NodeInfo: corev1.NodeSystemInfo{ + OperatingSystem: "linux", + Architecture: "arm64", + }, + }, + }, + job: &v1alpha1.NodeScanJob{ + ObjectMeta: metav1.ObjectMeta{Name: "test-job"}, + Spec: v1alpha1.NodeScanJobSpec{NodeName: "worker-1"}, + }, + expectedField: "spec.nodeName", + expectedError: "does not match the NodeScanConfiguration nodeSelector", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + scheme := runtime.NewScheme() + require.NoError(t, v1alpha1.AddToScheme(scheme)) + require.NoError(t, corev1.AddToScheme(scheme)) + + builder := fake.NewClientBuilder().WithScheme(scheme) + if test.config != nil { + builder = builder.WithObjects(test.config) + } + if test.node != nil { + builder = builder.WithObjects(test.node) + } + fakeClient := builder.Build() + + validator := &NodeScanJobCustomValidator{ + client: fakeClient, + logger: logr.Discard(), + } + + warnings, err := validator.ValidateCreate(t.Context(), test.job) + + if test.expectedError != "" { + require.Error(t, err) + statusErr, ok := err.(interface{ Status() metav1.Status }) + require.True(t, ok) + details := statusErr.Status().Details + require.NotNil(t, details) + require.NotEmpty(t, details.Causes) + assert.Equal(t, test.expectedField, details.Causes[0].Field) + assert.Contains(t, details.Causes[0].Message, test.expectedError) + } else { + require.NoError(t, err) + } + + assert.Empty(t, warnings) + }) + } +} + +func TestNodeScanJobCustomValidator_ValidateUpdate(t *testing.T) { + tests := []struct { + name string + oldNodeName string + newNodeName string + expectedFields []string + }{ + { + name: "nodeName changed is rejected", + oldNodeName: "worker-1", + newNodeName: "worker-2", + expectedFields: []string{"spec.nodeName"}, + }, + { + name: "no changes is admitted", + oldNodeName: "worker-1", + newNodeName: "worker-1", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + validator := &NodeScanJobCustomValidator{ + logger: logr.Discard(), + } + + oldObj := &v1alpha1.NodeScanJob{ + ObjectMeta: metav1.ObjectMeta{Name: "test-job"}, + Spec: v1alpha1.NodeScanJobSpec{NodeName: test.oldNodeName}, + } + newObj := &v1alpha1.NodeScanJob{ + ObjectMeta: metav1.ObjectMeta{Name: "test-job"}, + Spec: v1alpha1.NodeScanJobSpec{NodeName: test.newNodeName}, + } + + warnings, err := validator.ValidateUpdate(t.Context(), oldObj, newObj) + assert.Empty(t, warnings) + + if len(test.expectedFields) == 0 { + require.NoError(t, err) + return + } + + require.Error(t, err) + statusErr, ok := err.(interface{ Status() metav1.Status }) + require.True(t, ok) + details := statusErr.Status().Details + require.NotNil(t, details) + require.Len(t, details.Causes, len(test.expectedFields)) + for i, f := range test.expectedFields { + assert.Equal(t, f, details.Causes[i].Field) + assert.Contains(t, details.Causes[i].Message, "immutable") + } + }) + } +} + +func TestNodeScanJobCustomValidator_ValidateDelete(t *testing.T) { + t.Run("should allow deletion", func(t *testing.T) { + validator := &NodeScanJobCustomValidator{ + logger: logr.Discard(), + } + + job := &v1alpha1.NodeScanJob{ + ObjectMeta: metav1.ObjectMeta{Name: fmt.Sprintf("test-job")}, + } + + warnings, err := validator.ValidateDelete(t.Context(), job) + + require.NoError(t, err) + assert.Empty(t, warnings) + }) +} diff --git a/internal/webhook/v1alpha1/scanjob_webhook_test.go b/internal/webhook/v1alpha1/scanjob_webhook_test.go index 1cc3af053..3d7f036cb 100644 --- a/internal/webhook/v1alpha1/scanjob_webhook_test.go +++ b/internal/webhook/v1alpha1/scanjob_webhook_test.go @@ -100,7 +100,7 @@ func TestScanJobCustomValidator_ValidateCreate(t *testing.T) { }, } job.InitializeConditions() - job.MarkInProgress(v1alpha1.ReasonImageScanInProgress, "Image scan in progress") + job.MarkInProgress(v1alpha1.ReasonScanJobImageScanInProgress, "Image scan in progress") return job }(), scanJob: &v1alpha1.ScanJob{ @@ -128,7 +128,7 @@ func TestScanJobCustomValidator_ValidateCreate(t *testing.T) { }, } job.InitializeConditions() - job.MarkComplete(v1alpha1.ReasonAllImagesScanned, "Done") + job.MarkComplete(v1alpha1.ReasonScanJobAllImagesScanned, "Done") return job }(), scanJob: &v1alpha1.ScanJob{ @@ -154,7 +154,7 @@ func TestScanJobCustomValidator_ValidateCreate(t *testing.T) { }, } job.InitializeConditions() - job.MarkFailed(v1alpha1.ReasonInternalError, "Failed") + job.MarkFailed(v1alpha1.ReasonScanJobInternalError, "Failed") return job }(), scanJob: &v1alpha1.ScanJob{ From 4e105309d3e237d60fade17d918976cfd7c7cebc Mon Sep 17 00:00:00 2001 From: Alessio Greggi Date: Tue, 19 May 2026 16:28:35 +0200 Subject: [PATCH 5/5] docs(examples): add nodescan crds Signed-off-by: Alessio Greggi --- docs/crds/CRD-docs-for-docs-repo.adoc | 259 ++++++++++++++++++++++++++ docs/crds/CRD-docs-for-docs-repo.md | 195 +++++++++++++++++++ examples/nodesbom.yaml | 134 +++++++++++++ examples/nodescanconfiguration.yaml | 20 ++ examples/nodescanjob.yaml | 5 + 5 files changed, 613 insertions(+) create mode 100644 examples/nodesbom.yaml create mode 100644 examples/nodescanconfiguration.yaml create mode 100644 examples/nodescanjob.yaml diff --git a/docs/crds/CRD-docs-for-docs-repo.adoc b/docs/crds/CRD-docs-for-docs-repo.adoc index ea1afd775..e6a6b917c 100644 --- a/docs/crds/CRD-docs-for-docs-repo.adoc +++ b/docs/crds/CRD-docs-for-docs-repo.adoc @@ -15,6 +15,10 @@ Package v1alpha1 contains API Schema definitions for the SBOMscanner v1alpha1 API group. .Resource Types +- xref:{anchor_prefix}-github-com-kubewarden-sbomscanner-api-v1alpha1-nodescanconfiguration[$$NodeScanConfiguration$$] +- xref:{anchor_prefix}-github-com-kubewarden-sbomscanner-api-v1alpha1-nodescanconfigurationlist[$$NodeScanConfigurationList$$] +- xref:{anchor_prefix}-github-com-kubewarden-sbomscanner-api-v1alpha1-nodescanjob[$$NodeScanJob$$] +- xref:{anchor_prefix}-github-com-kubewarden-sbomscanner-api-v1alpha1-nodescanjoblist[$$NodeScanJobList$$] - xref:{anchor_prefix}-github-com-kubewarden-sbomscanner-api-v1alpha1-registry[$$Registry$$] - xref:{anchor_prefix}-github-com-kubewarden-sbomscanner-api-v1alpha1-registrylist[$$RegistryList$$] - xref:{anchor_prefix}-github-com-kubewarden-sbomscanner-api-v1alpha1-scanjob[$$ScanJob$$] @@ -69,6 +73,182 @@ MatchOperator defines how multiple match conditions are combined. +[id="{anchor_prefix}-github-com-kubewarden-sbomscanner-api-v1alpha1-nodescanconfiguration"] +==== NodeScanConfiguration + + + +NodeScanConfiguration is the Schema for the nodescanconfigurations API. +This is a singleton resource - only one instance named "default" is allowed. + + + +.Appears In: +**** +- xref:{anchor_prefix}-github-com-kubewarden-sbomscanner-api-v1alpha1-nodescanconfigurationlist[$$NodeScanConfigurationList$$] +**** + +[cols="20a,50a,15a,15a", options="header"] +|=== +| Field | Description | Default | Validation +| *`apiVersion`* __string__ | `sbomscanner.kubewarden.io/v1alpha1` | | +| *`kind`* __string__ | `NodeScanConfiguration` | | +| *`metadata`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.36/#objectmeta-v1-meta[$$ObjectMeta$$]__ | Refer to Kubernetes API documentation for fields of `metadata`. + | | +| *`spec`* __xref:{anchor_prefix}-github-com-kubewarden-sbomscanner-api-v1alpha1-nodescanconfigurationspec[$$NodeScanConfigurationSpec$$]__ | | | +|=== + + +[id="{anchor_prefix}-github-com-kubewarden-sbomscanner-api-v1alpha1-nodescanconfigurationlist"] +==== NodeScanConfigurationList + + + +NodeScanConfigurationList contains a list of NodeScanConfiguration. + + + + + +[cols="20a,50a,15a,15a", options="header"] +|=== +| Field | Description | Default | Validation +| *`apiVersion`* __string__ | `sbomscanner.kubewarden.io/v1alpha1` | | +| *`kind`* __string__ | `NodeScanConfigurationList` | | +| *`metadata`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.36/#listmeta-v1-meta[$$ListMeta$$]__ | Refer to Kubernetes API documentation for fields of `metadata`. + | | +| *`items`* __xref:{anchor_prefix}-github-com-kubewarden-sbomscanner-api-v1alpha1-nodescanconfiguration[$$NodeScanConfiguration$$] array__ | | | +|=== + + +[id="{anchor_prefix}-github-com-kubewarden-sbomscanner-api-v1alpha1-nodescanconfigurationspec"] +==== NodeScanConfigurationSpec + + + +NodeScanConfigurationSpec defines the desired configuration for node scanning. + + + +.Appears In: +**** +- xref:{anchor_prefix}-github-com-kubewarden-sbomscanner-api-v1alpha1-nodescanconfiguration[$$NodeScanConfiguration$$] +**** + +[cols="20a,50a,15a,15a", options="header"] +|=== +| Field | Description | Default | Validation +| *`nodeSelector`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.36/#labelselector-v1-meta[$$LabelSelector$$]__ | NodeSelector filters which nodes are scanned. + +If not specified, all the nodes are scanned. + | | Optional: \{} + + +| *`scanInterval`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.36/#duration-v1-meta[$$Duration$$]__ | ScanInterval is the interval at which nodes are scanned. + | | Optional: \{} + + +| *`skipPatterns`* __string array__ | SkipPatterns specifies gitignore-style patterns for directories and files to skip during node scanning. + +Patterns ending with "/" are treated as directories. + +All other patterns are treated as files. + +Glob patterns like "**/vendor/" or "*.min.js" are supported. + | | Optional: \{} + + +| *`platforms`* __xref:{anchor_prefix}-github-com-kubewarden-sbomscanner-api-v1alpha1-platform[$$Platform$$] array__ | Platforms allows to specify the list of platforms to scan. + +If not set, all nodes are scanned regardless of their platform. + | | Optional: \{} + + +|=== + + +[id="{anchor_prefix}-github-com-kubewarden-sbomscanner-api-v1alpha1-nodescanjob"] +==== NodeScanJob + + + +NodeScanJob is the Schema for the nodescanjobs API. + + + +.Appears In: +**** +- xref:{anchor_prefix}-github-com-kubewarden-sbomscanner-api-v1alpha1-nodescanjoblist[$$NodeScanJobList$$] +**** + +[cols="20a,50a,15a,15a", options="header"] +|=== +| Field | Description | Default | Validation +| *`apiVersion`* __string__ | `sbomscanner.kubewarden.io/v1alpha1` | | +| *`kind`* __string__ | `NodeScanJob` | | +| *`metadata`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.36/#objectmeta-v1-meta[$$ObjectMeta$$]__ | Refer to Kubernetes API documentation for fields of `metadata`. + | | +| *`spec`* __xref:{anchor_prefix}-github-com-kubewarden-sbomscanner-api-v1alpha1-nodescanjobspec[$$NodeScanJobSpec$$]__ | | | +| *`status`* __xref:{anchor_prefix}-github-com-kubewarden-sbomscanner-api-v1alpha1-nodescanjobstatus[$$NodeScanJobStatus$$]__ | | | +|=== + + +[id="{anchor_prefix}-github-com-kubewarden-sbomscanner-api-v1alpha1-nodescanjoblist"] +==== NodeScanJobList + + + +NodeScanJobList contains a list of NodeScanJob. + + + + + +[cols="20a,50a,15a,15a", options="header"] +|=== +| Field | Description | Default | Validation +| *`apiVersion`* __string__ | `sbomscanner.kubewarden.io/v1alpha1` | | +| *`kind`* __string__ | `NodeScanJobList` | | +| *`metadata`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.36/#listmeta-v1-meta[$$ListMeta$$]__ | Refer to Kubernetes API documentation for fields of `metadata`. + | | +| *`items`* __xref:{anchor_prefix}-github-com-kubewarden-sbomscanner-api-v1alpha1-nodescanjob[$$NodeScanJob$$] array__ | | | +|=== + + +[id="{anchor_prefix}-github-com-kubewarden-sbomscanner-api-v1alpha1-nodescanjobspec"] +==== NodeScanJobSpec + + + +NodeScanJobSpec defines the desired state of NodeScanJob. + + + +.Appears In: +**** +- xref:{anchor_prefix}-github-com-kubewarden-sbomscanner-api-v1alpha1-nodescanjob[$$NodeScanJob$$] +**** + +[cols="20a,50a,15a,15a", options="header"] +|=== +| Field | Description | Default | Validation +| *`nodeName`* __string__ | NodeName specifies the name of the node to be scanned. + | | +|=== + + +[id="{anchor_prefix}-github-com-kubewarden-sbomscanner-api-v1alpha1-nodescanjobstatus"] +==== NodeScanJobStatus + + + +NodeScanJobStatus defines the observed state of NodeScanJob. + + + +.Appears In: +**** +- xref:{anchor_prefix}-github-com-kubewarden-sbomscanner-api-v1alpha1-nodescanjob[$$NodeScanJob$$] +**** + +[cols="20a,50a,15a,15a", options="header"] +|=== +| Field | Description | Default | Validation +| *`conditions`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.36/#condition-v1-meta[$$Condition$$] array__ | Conditions represent the latest available observations of ScanJob state + | | Optional: \{} + + +| *`startTime`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.36/#time-v1-meta[$$Time$$]__ | StartTime is when the job started processing. + | | Optional: \{} + + +| *`completionTime`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.36/#time-v1-meta[$$Time$$]__ | CompletionTime is when the job completed or failed. + | | Optional: \{} + + +|=== + + [id="{anchor_prefix}-github-com-kubewarden-sbomscanner-api-v1alpha1-platform"] ==== Platform @@ -80,6 +260,7 @@ Platform describes the platform which the image in the manifest runs on. .Appears In: **** +- xref:{anchor_prefix}-github-com-kubewarden-sbomscanner-api-v1alpha1-nodescanconfigurationspec[$$NodeScanConfigurationSpec$$] - xref:{anchor_prefix}-github-com-kubewarden-sbomscanner-api-v1alpha1-registryspec[$$RegistrySpec$$] - xref:{anchor_prefix}-github-com-kubewarden-sbomscanner-api-v1alpha1-workloadscanconfigurationspec[$$WorkloadScanConfigurationSpec$$] **** @@ -798,6 +979,83 @@ ImageWorkloadScanReports identifies a workload that references this image |=== +[id="{anchor_prefix}-github-com-kubewarden-sbomscanner-api-storage-v1alpha1-nodemetadata"] +==== NodeMetadata + + + +NodeMetadata contains the metadata details of a node. + + + +.Appears In: +**** +- xref:{anchor_prefix}-github-com-kubewarden-sbomscanner-api-storage-v1alpha1-nodesbom[$$NodeSBOM$$] +- xref:{anchor_prefix}-github-com-kubewarden-sbomscanner-api-storage-v1alpha1-nodevulnerabilityreport[$$NodeVulnerabilityReport$$] +**** + +[cols="20a,50a,15a,15a", options="header"] +|=== +| Field | Description | Default | Validation +| *`name`* __string__ | Name specifies the name of the node. + | | +| *`platform`* __string__ | Platform specifies the platform of the image. Example "linux/amd64". + | | +|=== + + + + +[id="{anchor_prefix}-github-com-kubewarden-sbomscanner-api-storage-v1alpha1-nodesbom"] +==== NodeSBOM + + + +NodeSBOM represents a Software Bill of Materials of a node + + + +.Appears In: +**** +- xref:{anchor_prefix}-github-com-kubewarden-sbomscanner-api-storage-v1alpha1-nodesbomlist[$$NodeSBOMList$$] +**** + +[cols="20a,50a,15a,15a", options="header"] +|=== +| Field | Description | Default | Validation +| *`metadata`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.36/#objectmeta-v1-meta[$$ObjectMeta$$]__ | Refer to Kubernetes API documentation for fields of `metadata`. + | | +| *`nodeMetadata`* __xref:{anchor_prefix}-github-com-kubewarden-sbomscanner-api-storage-v1alpha1-nodemetadata[$$NodeMetadata$$]__ | | | +| *`spdx`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.36/#rawextension-runtime-pkg[$$RawExtension$$]__ | SPDX contains the SPDX document of the SBOM in JSON format + | | +|=== + + + + +[id="{anchor_prefix}-github-com-kubewarden-sbomscanner-api-storage-v1alpha1-nodevulnerabilityreport"] +==== NodeVulnerabilityReport + + + +NodeVulnerabilityReport is the Schema for the scanresults API + + + +.Appears In: +**** +- xref:{anchor_prefix}-github-com-kubewarden-sbomscanner-api-storage-v1alpha1-nodevulnerabilityreportlist[$$NodeVulnerabilityReportList$$] +**** + +[cols="20a,50a,15a,15a", options="header"] +|=== +| Field | Description | Default | Validation +| *`metadata`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.36/#objectmeta-v1-meta[$$ObjectMeta$$]__ | Refer to Kubernetes API documentation for fields of `metadata`. + | | +| *`nodeMetadata`* __xref:{anchor_prefix}-github-com-kubewarden-sbomscanner-api-storage-v1alpha1-nodemetadata[$$NodeMetadata$$]__ | NodeMetadata contains info about the scanned node + | | +| *`report`* __xref:{anchor_prefix}-github-com-kubewarden-sbomscanner-api-storage-v1alpha1-report[$$Report$$]__ | Report is the actual vulnerability scan report + | | +|=== + + + + [id="{anchor_prefix}-github-com-kubewarden-sbomscanner-api-storage-v1alpha1-report"] ==== Report @@ -809,6 +1067,7 @@ Report contains metadata about the scanned image and a list of vulnerability res .Appears In: **** +- xref:{anchor_prefix}-github-com-kubewarden-sbomscanner-api-storage-v1alpha1-nodevulnerabilityreport[$$NodeVulnerabilityReport$$] - xref:{anchor_prefix}-github-com-kubewarden-sbomscanner-api-storage-v1alpha1-vulnerabilityreport[$$VulnerabilityReport$$] - xref:{anchor_prefix}-github-com-kubewarden-sbomscanner-api-storage-v1alpha1-workloadscanvulnerabilityreport[$$WorkloadScanVulnerabilityReport$$] **** diff --git a/docs/crds/CRD-docs-for-docs-repo.md b/docs/crds/CRD-docs-for-docs-repo.md index dc68eaf01..989eb2e69 100644 --- a/docs/crds/CRD-docs-for-docs-repo.md +++ b/docs/crds/CRD-docs-for-docs-repo.md @@ -10,6 +10,10 @@ Package v1alpha1 contains API Schema definitions for the SBOMscanner v1alpha1 API group. ### Resource Types +- [NodeScanConfiguration](#nodescanconfiguration) +- [NodeScanConfigurationList](#nodescanconfigurationlist) +- [NodeScanJob](#nodescanjob) +- [NodeScanJobList](#nodescanjoblist) - [Registry](#registry) - [RegistryList](#registrylist) - [ScanJob](#scanjob) @@ -57,6 +61,135 @@ _Appears in:_ | `Or` | MatchOperatorOr requires at least one condition to pass.
| +#### NodeScanConfiguration + + + +NodeScanConfiguration is the Schema for the nodescanconfigurations API. +This is a singleton resource - only one instance named "default" is allowed. + + + +_Appears in:_ +- [NodeScanConfigurationList](#nodescanconfigurationlist) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `sbomscanner.kubewarden.io/v1alpha1` | | | +| `kind` _string_ | `NodeScanConfiguration` | | | +| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.36/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | +| `spec` _[NodeScanConfigurationSpec](#nodescanconfigurationspec)_ | | | | + + +#### NodeScanConfigurationList + + + +NodeScanConfigurationList contains a list of NodeScanConfiguration. + + + + + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `sbomscanner.kubewarden.io/v1alpha1` | | | +| `kind` _string_ | `NodeScanConfigurationList` | | | +| `metadata` _[ListMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.36/#listmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | +| `items` _[NodeScanConfiguration](#nodescanconfiguration) array_ | | | | + + +#### NodeScanConfigurationSpec + + + +NodeScanConfigurationSpec defines the desired configuration for node scanning. + + + +_Appears in:_ +- [NodeScanConfiguration](#nodescanconfiguration) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `nodeSelector` _[LabelSelector](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.36/#labelselector-v1-meta)_ | NodeSelector filters which nodes are scanned.
If not specified, all the nodes are scanned. | | Optional: \{\}
| +| `scanInterval` _[Duration](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.36/#duration-v1-meta)_ | ScanInterval is the interval at which nodes are scanned. | | Optional: \{\}
| +| `skipPatterns` _string array_ | SkipPatterns specifies gitignore-style patterns for directories and files to skip during node scanning.
Patterns ending with "/" are treated as directories.
All other patterns are treated as files.
Glob patterns like "**/vendor/" or "*.min.js" are supported. | | Optional: \{\}
| +| `platforms` _[Platform](#platform) array_ | Platforms allows to specify the list of platforms to scan.
If not set, all nodes are scanned regardless of their platform. | | Optional: \{\}
| + + +#### NodeScanJob + + + +NodeScanJob is the Schema for the nodescanjobs API. + + + +_Appears in:_ +- [NodeScanJobList](#nodescanjoblist) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `sbomscanner.kubewarden.io/v1alpha1` | | | +| `kind` _string_ | `NodeScanJob` | | | +| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.36/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | +| `spec` _[NodeScanJobSpec](#nodescanjobspec)_ | | | | +| `status` _[NodeScanJobStatus](#nodescanjobstatus)_ | | | | + + +#### NodeScanJobList + + + +NodeScanJobList contains a list of NodeScanJob. + + + + + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `sbomscanner.kubewarden.io/v1alpha1` | | | +| `kind` _string_ | `NodeScanJobList` | | | +| `metadata` _[ListMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.36/#listmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | +| `items` _[NodeScanJob](#nodescanjob) array_ | | | | + + +#### NodeScanJobSpec + + + +NodeScanJobSpec defines the desired state of NodeScanJob. + + + +_Appears in:_ +- [NodeScanJob](#nodescanjob) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `nodeName` _string_ | NodeName specifies the name of the node to be scanned. | | | + + +#### NodeScanJobStatus + + + +NodeScanJobStatus defines the observed state of NodeScanJob. + + + +_Appears in:_ +- [NodeScanJob](#nodescanjob) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.36/#condition-v1-meta) array_ | Conditions represent the latest available observations of ScanJob state | | Optional: \{\}
| +| `startTime` _[Time](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.36/#time-v1-meta)_ | StartTime is when the job started processing. | | Optional: \{\}
| +| `completionTime` _[Time](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.36/#time-v1-meta)_ | CompletionTime is when the job completed or failed. | | Optional: \{\}
| + + #### Platform @@ -66,6 +199,7 @@ Platform describes the platform which the image in the manifest runs on. _Appears in:_ +- [NodeScanConfigurationSpec](#nodescanconfigurationspec) - [RegistrySpec](#registryspec) - [WorkloadScanConfigurationSpec](#workloadscanconfigurationspec) @@ -602,6 +736,66 @@ _Appears in:_ | `namespace` _string_ | Namespace of the WorkloadScanReport | | | +#### NodeMetadata + + + +NodeMetadata contains the metadata details of a node. + + + +_Appears in:_ +- [NodeSBOM](#nodesbom) +- [NodeVulnerabilityReport](#nodevulnerabilityreport) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `name` _string_ | Name specifies the name of the node. | | | +| `platform` _string_ | Platform specifies the platform of the image. Example "linux/amd64". | | | + + + + +#### NodeSBOM + + + +NodeSBOM represents a Software Bill of Materials of a node + + + +_Appears in:_ +- [NodeSBOMList](#nodesbomlist) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.36/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | +| `nodeMetadata` _[NodeMetadata](#nodemetadata)_ | | | | +| `spdx` _[RawExtension](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.36/#rawextension-runtime-pkg)_ | SPDX contains the SPDX document of the SBOM in JSON format | | | + + + + +#### NodeVulnerabilityReport + + + +NodeVulnerabilityReport is the Schema for the scanresults API + + + +_Appears in:_ +- [NodeVulnerabilityReportList](#nodevulnerabilityreportlist) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.36/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | +| `nodeMetadata` _[NodeMetadata](#nodemetadata)_ | NodeMetadata contains info about the scanned node | | | +| `report` _[Report](#report)_ | Report is the actual vulnerability scan report | | | + + + + #### Report @@ -611,6 +805,7 @@ Report contains metadata about the scanned image and a list of vulnerability res _Appears in:_ +- [NodeVulnerabilityReport](#nodevulnerabilityreport) - [VulnerabilityReport](#vulnerabilityreport) - [WorkloadScanVulnerabilityReport](#workloadscanvulnerabilityreport) diff --git a/examples/nodesbom.yaml b/examples/nodesbom.yaml new file mode 100644 index 000000000..c0b0bd8d3 --- /dev/null +++ b/examples/nodesbom.yaml @@ -0,0 +1,134 @@ +apiVersion: storage.sbomscanner.kubewarden.io/v1alpha1 +kind: NodeSBOM +metadata: + name: worker-node-1 +nodeMetadata: + name: worker-node-1 + platform: linux/amd64 +spdx: + SPDXID: SPDXRef-DOCUMENT + creationInfo: + created: "2026-05-19T10:15:00Z" + creators: + - "Organization: aquasecurity" + - "Tool: trivy-dev" + dataLicense: CC0-1.0 + documentNamespace: http://aquasecurity.github.io/trivy/filesystem/worker-node-1-3b2d8e10-0c4b-4a7f-9d2a-1f6b8e0d4c5a + name: worker-node-1 + packages: + - SPDXID: SPDXRef-Package-2a1c4e7b9d0f3a52 + annotations: + - annotationDate: "2026-05-19T10:15:00Z" + annotationType: OTHER + annotator: "Tool: trivy-dev" + comment: "PkgID: libc6@2.39-0ubuntu8.3" + - annotationDate: "2026-05-19T10:15:00Z" + annotationType: OTHER + annotator: "Tool: trivy-dev" + comment: "PkgType: ubuntu" + checksums: + - algorithm: SHA256 + checksumValue: c1f4e1b2d3a4c5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0 + downloadLocation: NONE + externalRefs: + - referenceCategory: PACKAGE-MANAGER + referenceLocator: pkg:deb/ubuntu/libc6@2.39-0ubuntu8.3?arch=amd64&distro=ubuntu-24.04 + referenceType: purl + filesAnalyzed: false + licenseConcluded: GPL-2.0-only AND LGPL-2.1-only + licenseDeclared: GPL-2.0-only AND LGPL-2.1-only + name: libc6 + primaryPackagePurpose: LIBRARY + sourceInfo: "built package from: glibc 2.39-0ubuntu8.3" + supplier: "Organization: Ubuntu Developers " + versionInfo: 2.39-0ubuntu8.3 + - SPDXID: SPDXRef-Package-6f3b8d2a4c1e9b07 + annotations: + - annotationDate: "2026-05-19T10:15:00Z" + annotationType: OTHER + annotator: "Tool: trivy-dev" + comment: "PkgID: systemd@255.4-1ubuntu8.4" + - annotationDate: "2026-05-19T10:15:00Z" + annotationType: OTHER + annotator: "Tool: trivy-dev" + comment: "PkgType: ubuntu" + checksums: + - algorithm: SHA256 + checksumValue: a9b8c7d6e5f4039281a7b6c5d4e3f201928374a5b6c7d8e9f0a1b2c3d4e5f607 + downloadLocation: NONE + externalRefs: + - referenceCategory: PACKAGE-MANAGER + referenceLocator: pkg:deb/ubuntu/systemd@255.4-1ubuntu8.4?arch=amd64&distro=ubuntu-24.04 + referenceType: purl + filesAnalyzed: false + licenseConcluded: LGPL-2.1-or-later AND GPL-2.0-or-later + licenseDeclared: LGPL-2.1-or-later AND GPL-2.0-or-later + name: systemd + primaryPackagePurpose: APPLICATION + sourceInfo: "built package from: systemd 255.4-1ubuntu8.4" + supplier: "Organization: Ubuntu Developers " + versionInfo: 255.4-1ubuntu8.4 + - SPDXID: SPDXRef-Package-8e2a1d5c7b9f4036 + annotations: + - annotationDate: "2026-05-19T10:15:00Z" + annotationType: OTHER + annotator: "Tool: trivy-dev" + comment: "PkgID: containerd@1.7.24-0ubuntu1~24.04.1" + - annotationDate: "2026-05-19T10:15:00Z" + annotationType: OTHER + annotator: "Tool: trivy-dev" + comment: "PkgType: ubuntu" + checksums: + - algorithm: SHA256 + checksumValue: 5f8e7d6c5b4a3928170615243342516273849500a1b2c3d4e5f6a7b8c9d0e1f2 + downloadLocation: NONE + externalRefs: + - referenceCategory: PACKAGE-MANAGER + referenceLocator: pkg:deb/ubuntu/containerd@1.7.24-0ubuntu1~24.04.1?arch=amd64&distro=ubuntu-24.04 + referenceType: purl + filesAnalyzed: false + licenseConcluded: Apache-2.0 + licenseDeclared: Apache-2.0 + name: containerd + primaryPackagePurpose: APPLICATION + sourceInfo: "built package from: containerd 1.7.24-0ubuntu1~24.04.1" + supplier: "Organization: Ubuntu Developers " + versionInfo: 1.7.24-0ubuntu1~24.04.1 + - SPDXID: SPDXRef-Package-3c7b2e5a8d1f9046 + annotations: + - annotationDate: "2026-05-19T10:15:00Z" + annotationType: OTHER + annotator: "Tool: trivy-dev" + comment: "PkgID: kubelet@1.31.2-1.1" + - annotationDate: "2026-05-19T10:15:00Z" + annotationType: OTHER + annotator: "Tool: trivy-dev" + comment: "PkgType: ubuntu" + checksums: + - algorithm: SHA256 + checksumValue: 0e1d2c3b4a5968778695a4b3c2d1e0f9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3 + downloadLocation: NONE + externalRefs: + - referenceCategory: PACKAGE-MANAGER + referenceLocator: pkg:deb/ubuntu/kubelet@1.31.2-1.1?arch=amd64&distro=ubuntu-24.04 + referenceType: purl + filesAnalyzed: false + licenseConcluded: Apache-2.0 + licenseDeclared: Apache-2.0 + name: kubelet + primaryPackagePurpose: APPLICATION + versionInfo: 1.31.2-1.1 + relationships: + - relatedSpdxElement: SPDXRef-Package-2a1c4e7b9d0f3a52 + relationshipType: DESCRIBES + spdxElementId: SPDXRef-DOCUMENT + - relatedSpdxElement: SPDXRef-Package-6f3b8d2a4c1e9b07 + relationshipType: DESCRIBES + spdxElementId: SPDXRef-DOCUMENT + - relatedSpdxElement: SPDXRef-Package-8e2a1d5c7b9f4036 + relationshipType: DESCRIBES + spdxElementId: SPDXRef-DOCUMENT + - relatedSpdxElement: SPDXRef-Package-3c7b2e5a8d1f9046 + relationshipType: DESCRIBES + spdxElementId: SPDXRef-DOCUMENT + spdxVersion: SPDX-2.3 diff --git a/examples/nodescanconfiguration.yaml b/examples/nodescanconfiguration.yaml new file mode 100644 index 000000000..9b36cc221 --- /dev/null +++ b/examples/nodescanconfiguration.yaml @@ -0,0 +1,20 @@ +apiVersion: sbomscanner.kubewarden.io/v1alpha1 +kind: NodeScanConfiguration +metadata: + name: default + annotations: + "force-node-scan": "true" +spec: + skipPatterns: + - "/run/k3s/containerd/" + scanInterval: 5m + nodeSelector: + matchExpressions: + - key: kubernetes.io/hostname + operator: In + values: + - "node1" + - "node2" + platforms: + - arch: "amd64" + os: "linux" diff --git a/examples/nodescanjob.yaml b/examples/nodescanjob.yaml new file mode 100644 index 000000000..f84417c85 --- /dev/null +++ b/examples/nodescanjob.yaml @@ -0,0 +1,5 @@ +apiVersion: sbomscanner.kubewarden.io/v1alpha1 +kind: NodeScanJob +metadata: + name: node-scan-1 +spec: