diff --git a/.golangci.yml b/.golangci.yml index 0e57e462..ca148978 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -23,8 +23,13 @@ linters: - unconvert - unparam - unused + exclusions: + rules: + - linters: + - lll + source: "^// \\+kubebuilder:validation" formatters: enable: - gofmt - - goimports \ No newline at end of file + - goimports diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..7f4f4abd --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,17 @@ +# Change Log +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](http://keepachangelog.com/) +and this project adheres to [Semantic Versioning](http://semver.org/). + +## [Unreleased] + +### Added +- + +### Changed + +### Fixed + +## [x.x.x] + diff --git a/api/v1alpha1/modelvalidation_types.go b/api/v1alpha1/modelvalidation_types.go index 47a97831..7598db27 100644 --- a/api/v1alpha1/modelvalidation_types.go +++ b/api/v1alpha1/modelvalidation_types.go @@ -23,39 +23,65 @@ import ( // EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN! // NOTE: json tags are required. Any new fields you add must have json tags for the fields to be serialized. -// Model defines the details of the model to validate. +// Model defines the details of the model to validate, including its path and +// the path to its corresponding signature file. type Model struct { - Path string `json:"path"` + // Path to the model artifact. This could be a file path on a shared volume + // or a URI to an object store. + // +kubebuilder:validation:Pattern=`^(/|s3://|gs://|https?://)` + Path string `json:"path"` + // SignaturePath is the path to the cryptographic signature bundle for the model. + // This is used by the various validation methods to verify the model's integrity. + // +kubebuilder:validation:Pattern=`^(/|s3://|gs://|https?://)` SignaturePath string `json:"signaturePath"` } -// SigstoreConfig defines the Sigstore verification configuration -// for validating model signatures using certificate identity and OIDC issuer +// SigstoreConfig defines the configuration for validating model signatures using +// Sigstore's certificate-based method, which requires a specific certificate +// identity and OIDC issuer. type SigstoreConfig struct { - CertificateIdentity string `json:"certificateIdentity,omitempty"` + // CertificateIdentity is the expected identity of the signing certificate. + // For example, "email:jane.doe@example.com". + // +kubebuilder:validation:Required + CertificateIdentity string `json:"certificateIdentity,omitempty"` + // CertificateOidcIssuer is the URL of the OIDC issuer that issued the signing certificate. + // For example, "https://accounts.google.com". + // +kubebuilder:validation:Required CertificateOidcIssuer string `json:"certificateOidcIssuer,omitempty"` } -// PkiConfig defines the PKI-based verification configuration -// using a certificate authority for validating model signatures +// PkiConfig defines the configuration for PKI-based verification. +// This method validates the signature using a trusted certificate authority (CA). type PkiConfig struct { - // Path to the certificate authority for PKI. + // CertificateAuthorityPath is the path to the trusted certificate authority (CA) file. + // The signature's chain of trust will be verified against this CA. + // +kubebuilder:validation:Required CertificateAuthority string `json:"certificateAuthority,omitempty"` } -// PrivateKeyConfig defines the private key verification configuration -// for validating model signatures using a local private key -type PrivateKeyConfig struct { - // Path to the private key. +// PublicKeyConfig defines the configuration for public key-based verification. +// This method validates the signature directly against a public key. +type PublicKeyConfig struct { + // KeyPath is the file path to the public key used for signature verification. + // This should be the public key corresponding to the private key used for signing. + // +kubebuilder:validation:Required KeyPath string `json:"keyPath,omitempty"` } // ValidationConfig defines the various methods available for validating model signatures. -// At least one validation method must be specified. +// Only one validation method should be specified. The controller will use the first +// non-nil method it finds. +// +kubebuilder:validation:XValidation:rule="[has(self.sigstoreConfig), has(self.pkiConfig), has(self.publicKeyConfig)].filter(x, x).size() == 1", message="exactly one validation method must be specified" type ValidationConfig struct { - SigstoreConfig *SigstoreConfig `json:"sigstoreConfig,omitempty"` - PkiConfig *PkiConfig `json:"pkiConfig,omitempty"` - PrivateKeyConfig *PrivateKeyConfig `json:"privateKeyConfig,omitempty"` + // SigstoreConfig is the configuration for Sigstore-based signature verification. + // +kubebuilder:validation:Optional + SigstoreConfig *SigstoreConfig `json:"sigstoreConfig,omitempty"` + // +kubebuilder:validation:Optional + // PkiConfig is the configuration for traditional PKI-based signature verification. + PkiConfig *PkiConfig `json:"pkiConfig,omitempty"` + // +kubebuilder:validation:Optional + // PublicKeyConfig is the configuration for public key-based signature verification. + PublicKeyConfig *PublicKeyConfig `json:"publicKeyConfig,omitempty"` } // ModelValidationSpec defines the desired state of ModelValidation @@ -66,20 +92,21 @@ type ModelValidationSpec struct { // Model details. Model Model `json:"model"` // Configuration for validation methods. + // Exactly one validation method must be specified. Config ValidationConfig `json:"config"` } // ModelValidationStatus defines the observed state of ModelValidation type ModelValidationStatus struct { - // INSERT ADDITIONAL STATUS FIELD - define observed state of cluster - // Important: Run "make" to regenerate code after modifying this file Conditions []metav1.Condition `json:"conditions,omitempty"` } +// ModelValidation is the Schema for the modelvalidations API. // +kubebuilder:object:root=true // +kubebuilder:subresource:status - -// ModelValidation is the Schema for the modelvalidations API +// +kubebuilder:resource:shortName=mv +// +kubebuilder:printcolumn:name="Model Path",type="string",JSONPath=".spec.model.path" +// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp" type ModelValidation struct { metav1.TypeMeta `json:",inline"` metav1.ObjectMeta `json:"metadata,omitempty"` diff --git a/api/v1alpha1/modelvalidation_types_test.go b/api/v1alpha1/modelvalidation_types_test.go new file mode 100644 index 00000000..a1534e23 --- /dev/null +++ b/api/v1alpha1/modelvalidation_types_test.go @@ -0,0 +1,233 @@ +package v1alpha1 + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" //nolint:revive + . "github.com/onsi/gomega" //nolint:revive + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +var _ = Describe("ModelValidation", func() { + + // A helper function to generate a valid ModelValidation object for Sigstore. + generateSigstoreObject := func(name string) *ModelValidation { + return &ModelValidation{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: "default", + }, + Spec: ModelValidationSpec{ + Model: Model{ + Path: "/path/to/model.onnx", + SignaturePath: "/path/to/model.onnx.sig", + }, + Config: ValidationConfig{ + SigstoreConfig: &SigstoreConfig{ + CertificateIdentity: "email:test@example.com", + CertificateOidcIssuer: "https://accounts.google.com", + }, + }, + }, + } + } + + // A helper function to generate a valid ModelValidation object for PKI. + generatePkiObject := func(name string) *ModelValidation { + return &ModelValidation{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: "default", + }, + Spec: ModelValidationSpec{ + Model: Model{ + Path: "/path/to/model.onnx", + SignaturePath: "/path/to/model.onnx.sig", + }, + Config: ValidationConfig{ + PkiConfig: &PkiConfig{ + CertificateAuthority: "/path/to/ca.pem", + }, + }, + }, + } + } + + // A helper function to generate a valid ModelValidation object for PublicKey. + generatePublicKeyObject := func(name string) *ModelValidation { + return &ModelValidation{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: "default", + }, + Spec: ModelValidationSpec{ + Model: Model{ + Path: "/path/to/model.onnx", + SignaturePath: "/path/to/model.onnx.sig", + }, + Config: ValidationConfig{ + PublicKeyConfig: &PublicKeyConfig{ + KeyPath: "/path/to/publickey.pem", + }, + }, + }, + } + } + + Context("ModelValidationSpec", func() { + It("can be created and fetched successfully for Sigstore config", func() { + created := generateSigstoreObject("mv-create") + Expect(k8sClient.Create(context.Background(), created)).To(Succeed()) + + fetched := &ModelValidation{} + Expect(k8sClient.Get(context.Background(), getKey(created), fetched)).To(Succeed()) + Expect(fetched).To(Equal(created)) + }) + + It("can be created and fetched successfully for PKI config", func() { + created := generatePkiObject("mv-create-pki") + Expect(k8sClient.Create(context.Background(), created)).To(Succeed()) + }) + + It("can be created and fetched successfully for PublicKey config", func() { + created := generatePublicKeyObject("mv-create-publickey") + Expect(k8sClient.Create(context.Background(), created)).To(Succeed()) + }) + + It("can be updated with allowed fields", func() { + created := generateSigstoreObject("mv-update") + Expect(k8sClient.Create(context.Background(), created)).To(Succeed()) + + fetched := &ModelValidation{} + Expect(k8sClient.Get(context.Background(), getKey(created), fetched)).To(Succeed()) + Expect(fetched).To(Equal(created)) + + // Status is not immutable and can be updated + fetched.Status.Conditions = []metav1.Condition{ + { + Type: "Ready", + Status: "True", + LastTransitionTime: metav1.Now(), + Reason: "ValidationSuccess", + Message: "Model signature is valid", + }, + } + Expect(k8sClient.Status().Update(context.Background(), fetched)).To(Succeed()) + }) + + It("can be deleted", func() { + created := generateSigstoreObject("mv-delete") + Expect(k8sClient.Create(context.Background(), created)).To(Succeed()) + + Expect(k8sClient.Delete(context.Background(), created)).To(Succeed()) + Expect(k8sClient.Get(context.Background(), getKey(created), created)).ToNot(Succeed()) + }) + + Context("is validated", func() { + It("rejects an empty Model path", func() { + invalidObject := generateSigstoreObject("model-path-invalid") + invalidObject.Spec.Model.Path = "" + + err := k8sClient.Create(context.Background(), invalidObject) + Expect(apierrors.IsInvalid(err)).To(BeTrue()) + Expect(err).To(MatchError(ContainSubstring("spec.model.path: Invalid value: \"\""))) + }) + + It("rejects an empty Signature path", func() { + invalidObject := generateSigstoreObject("signature-path-invalid") + invalidObject.Spec.Model.SignaturePath = "" + + err := k8sClient.Create(context.Background(), invalidObject) + Expect(apierrors.IsInvalid(err)).To(BeTrue()) + Expect(err).To(MatchError(ContainSubstring("spec.model.signaturePath: Invalid value: \"\""))) + }) + + It("rejects multiple configs (XValidation violation)", func() { + invalidObject := generateSigstoreObject("xor-violation-multi") + invalidObject.Spec.Config.PkiConfig = &PkiConfig{ + CertificateAuthority: "/path/to/ca.pem", + } + + err := k8sClient.Create(context.Background(), invalidObject) + Expect(apierrors.IsInvalid(err)).To(BeTrue()) + Expect(err).To(MatchError(ContainSubstring("exactly one validation method must be specified"))) + }) + + It("rejects zero configs (XValidation violation)", func() { + invalidObject := generateSigstoreObject("xor-violation-zero") + invalidObject.Spec.Config.SigstoreConfig = nil + + err := k8sClient.Create(context.Background(), invalidObject) + Expect(apierrors.IsInvalid(err)).To(BeTrue()) + Expect(err).To(MatchError(ContainSubstring("exactly one validation method must be specified"))) + }) + + It("rejects a missing required field in SigstoreConfig", func() { + invalidObject := generateSigstoreObject("sigstore-missing-field") + invalidObject.Spec.Config.SigstoreConfig.CertificateIdentity = "" + + err := k8sClient.Create(context.Background(), invalidObject) + Expect(apierrors.IsInvalid(err)).To(BeTrue()) + Expect(err).To(MatchError(ContainSubstring("spec.config.sigstoreConfig.certificateIdentity: Required value"))) + }) + + It("rejects a missing required field in PkiConfig", func() { + invalidObject := generatePkiObject("pki-missing-field") + invalidObject.Spec.Config.PkiConfig.CertificateAuthority = "" + + err := k8sClient.Create(context.Background(), invalidObject) + Expect(apierrors.IsInvalid(err)).To(BeTrue()) + Expect(err).To(MatchError(ContainSubstring("spec.config.pkiConfig.certificateAuthority: Required value"))) + }) + + It("rejects a missing required field in PublicKeyConfig", func() { + invalidObject := generatePublicKeyObject("publickey-missing-field") + invalidObject.Spec.Config.PublicKeyConfig.KeyPath = "" + + err := k8sClient.Create(context.Background(), invalidObject) + Expect(apierrors.IsInvalid(err)).To(BeTrue()) + Expect(err).To(MatchError(ContainSubstring("spec.config.publicKeyConfig.keyPath: Required value"))) + }) + + It("allows an update to the Model path", func() { + created := generateSigstoreObject("mutable-model-test") + Expect(k8sClient.Create(context.Background(), created)).To(Succeed()) + + fetched := &ModelValidation{} + Expect(k8sClient.Get(context.Background(), getKey(created), fetched)).To(Succeed()) + + newPath := "/new/path/to/model.onnx" + fetched.Spec.Model.Path = newPath + Expect(k8sClient.Update(context.Background(), fetched)).To(Succeed()) + + // Fetch and verify the change + updated := &ModelValidation{} + Expect(k8sClient.Get(context.Background(), getKey(created), updated)).To(Succeed()) + Expect(updated.Spec.Model.Path).To(Equal(newPath)) + }) + + It("allows an update to the Config fields", func() { + created := generateSigstoreObject("mutable-config-test") + Expect(k8sClient.Create(context.Background(), created)).To(Succeed()) + + fetched := &ModelValidation{} + Expect(k8sClient.Get(context.Background(), getKey(created), fetched)).To(Succeed()) + + // Update the config from Sigstore to PKI + fetched.Spec.Config.SigstoreConfig = nil + fetched.Spec.Config.PkiConfig = &PkiConfig{ + CertificateAuthority: "new-ca-path", + } + Expect(k8sClient.Update(context.Background(), fetched)).To(Succeed()) + + // Fetch and verify the change + updated := &ModelValidation{} + Expect(k8sClient.Get(context.Background(), getKey(created), updated)).To(Succeed()) + Expect(updated.Spec.Config.SigstoreConfig).To(BeNil()) + Expect(updated.Spec.Config.PkiConfig).ToNot(BeNil()) + Expect(updated.Spec.Config.PkiConfig.CertificateAuthority).To(Equal("new-ca-path")) + }) + }) + }) +}) diff --git a/api/v1alpha1/suite_test.go b/api/v1alpha1/suite_test.go new file mode 100644 index 00000000..08264f79 --- /dev/null +++ b/api/v1alpha1/suite_test.go @@ -0,0 +1,72 @@ +package v1alpha1 + +import ( + "fmt" + "path/filepath" + "runtime" + "testing" + + "k8s.io/klog/v2" + "k8s.io/klog/v2/test" + ctrl "sigs.k8s.io/controller-runtime" + + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + + . "github.com/onsi/ginkgo/v2" //nolint:revive + . "github.com/onsi/gomega" //nolint:revive + + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/envtest" +) + +// These tests use Ginkgo (BDD-style Go testing framework). Refer to +// http://onsi.github.io/ginkgo/ to learn more about Ginkgo. + +var cfg *rest.Config +var k8sClient client.Client +var testEnv *envtest.Environment + +func TestAPIs(t *testing.T) { + fs := test.InitKlog(t) + _ = fs.Set("v", "5") + klog.SetOutput(GinkgoWriter) + ctrl.SetLogger(klog.NewKlogr()) + + RegisterFailHandler(Fail) + RunSpecs(t, "v1alpha1 Suite") +} + +var _ = BeforeSuite(func() { + By("bootstrapping test environment") + testEnv = &envtest.Environment{ + CRDDirectoryPaths: []string{filepath.Join("..", "..", "config", "crd", "bases")}, + BinaryAssetsDirectory: filepath.Join("..", "..", "bin", "k8s", + fmt.Sprintf("1.29.1-%s-%s", runtime.GOOS, runtime.GOARCH)), + } + + err := SchemeBuilder.AddToScheme(scheme.Scheme) + Expect(err).NotTo(HaveOccurred()) + + cfg, err = testEnv.Start() + Expect(err).ToNot(HaveOccurred()) + Expect(cfg).ToNot(BeNil()) + + k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme}) + Expect(err).ToNot(HaveOccurred()) + Expect(k8sClient).ToNot(BeNil()) +}) + +var _ = AfterSuite(func() { + By("tearing down the test environment") + Expect(testEnv.Stop()).To(Succeed()) +}) + +func getKey(instance v1.Object) types.NamespacedName { + return types.NamespacedName{ + Name: instance.GetName(), + Namespace: instance.GetNamespace(), + } +} diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 47af49d6..e5cc7d13 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -154,16 +154,16 @@ func (in *PkiConfig) DeepCopy() *PkiConfig { } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *PrivateKeyConfig) DeepCopyInto(out *PrivateKeyConfig) { +func (in *PublicKeyConfig) DeepCopyInto(out *PublicKeyConfig) { *out = *in } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PrivateKeyConfig. -func (in *PrivateKeyConfig) DeepCopy() *PrivateKeyConfig { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PublicKeyConfig. +func (in *PublicKeyConfig) DeepCopy() *PublicKeyConfig { if in == nil { return nil } - out := new(PrivateKeyConfig) + out := new(PublicKeyConfig) in.DeepCopyInto(out) return out } @@ -196,9 +196,9 @@ func (in *ValidationConfig) DeepCopyInto(out *ValidationConfig) { *out = new(PkiConfig) **out = **in } - if in.PrivateKeyConfig != nil { - in, out := &in.PrivateKeyConfig, &out.PrivateKeyConfig - *out = new(PrivateKeyConfig) + if in.PublicKeyConfig != nil { + in, out := &in.PublicKeyConfig, &out.PublicKeyConfig + *out = new(PublicKeyConfig) **out = **in } } diff --git a/config/crd/bases/ml.sigstore.dev_modelvalidations.yaml b/config/crd/bases/ml.sigstore.dev_modelvalidations.yaml index 8996ff4c..dcb85f19 100644 --- a/config/crd/bases/ml.sigstore.dev_modelvalidations.yaml +++ b/config/crd/bases/ml.sigstore.dev_modelvalidations.yaml @@ -11,13 +11,22 @@ spec: kind: ModelValidation listKind: ModelValidationList plural: modelvalidations + shortNames: + - mv singular: modelvalidation scope: Namespaced versions: - - name: v1alpha1 + - additionalPrinterColumns: + - jsonPath: .spec.model.path + name: Model Path + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 schema: openAPIV3Schema: - description: ModelValidation is the Schema for the modelvalidations API + description: ModelValidation is the Schema for the modelvalidations API. properties: apiVersion: description: |- @@ -40,43 +49,71 @@ spec: description: ModelValidationSpec defines the desired state of ModelValidation properties: config: - description: Configuration for validation methods. + description: |- + Configuration for validation methods. + Exactly one validation method must be specified. properties: pkiConfig: - description: |- - PkiConfig defines the PKI-based verification configuration - using a certificate authority for validating model signatures + description: PkiConfig is the configuration for traditional PKI-based + signature verification. properties: certificateAuthority: - description: Path to the certificate authority for PKI. + description: |- + CertificateAuthorityPath is the path to the trusted certificate authority (CA) file. + The signature's chain of trust will be verified against this CA. type: string + required: + - certificateAuthority type: object - privateKeyConfig: - description: |- - PrivateKeyConfig defines the private key verification configuration - for validating model signatures using a local private key + publicKeyConfig: + description: PublicKeyConfig is the configuration for public key-based + signature verification. properties: keyPath: - description: Path to the private key. + description: |- + KeyPath is the file path to the public key used for signature verification. + This should be the public key corresponding to the private key used for signing. type: string + required: + - keyPath type: object sigstoreConfig: - description: |- - SigstoreConfig defines the Sigstore verification configuration - for validating model signatures using certificate identity and OIDC issuer + description: SigstoreConfig is the configuration for Sigstore-based + signature verification. properties: certificateIdentity: + description: |- + CertificateIdentity is the expected identity of the signing certificate. + For example, "email:jane.doe@example.com". type: string certificateOidcIssuer: + description: |- + CertificateOidcIssuer is the URL of the OIDC issuer that issued the signing certificate. + For example, "https://accounts.google.com". type: string + required: + - certificateIdentity + - certificateOidcIssuer type: object type: object + x-kubernetes-validations: + - message: exactly one validation method must be specified + rule: '[has(self.sigstoreConfig), has(self.pkiConfig), has(self.publicKeyConfig)].filter(x, + x).size() == 1' model: description: Model details. properties: path: + description: |- + Path to the model artifact. This could be a file path on a shared volume + or a URI to an object store. + pattern: ^(/|s3://|gs://|https?://) type: string signaturePath: + description: |- + SignaturePath is the path to the cryptographic signature bundle for the model. + This is used by the various validation methods to verify the model's integrity. + pattern: ^(/|s3://|gs://|https?://) type: string required: - path @@ -90,9 +127,6 @@ spec: description: ModelValidationStatus defines the observed state of ModelValidation properties: conditions: - description: |- - INSERT ADDITIONAL STATUS FIELD - define observed state of cluster - Important: Run "make" to regenerate code after modifying this file items: description: Condition contains details for one aspect of the current state of this API Resource. diff --git a/internal/webhooks/pod_webhook.go b/internal/webhooks/pod_webhook.go index 5edc1426..801d47bf 100644 --- a/internal/webhooks/pod_webhook.go +++ b/internal/webhooks/pod_webhook.go @@ -123,12 +123,12 @@ func validationConfigToArgs(logger logr.Logger, cfg v1alpha1.ValidationConfig, s return res } - if cfg.PrivateKeyConfig != nil { - logger.Info("found private-key config") + if cfg.PublicKeyConfig != nil { + logger.Info("found public-key config") res = append(res, "key", fmt.Sprintf("--signature=%s", signaturePath), - "--public_key", cfg.PrivateKeyConfig.KeyPath, + "--public_key", cfg.PublicKeyConfig.KeyPath, ) return res } diff --git a/internal/webhooks/pod_webhook_test.go b/internal/webhooks/pod_webhook_test.go index 33a7096a..981c5423 100644 --- a/internal/webhooks/pod_webhook_test.go +++ b/internal/webhooks/pod_webhook_test.go @@ -44,14 +44,16 @@ var _ = Describe("Pod webhook", func() { }, Spec: v1alpha1.ModelValidationSpec{ Model: v1alpha1.Model{ - Path: "test", - SignaturePath: "test", + Path: "/path/to/model.onnx", + SignaturePath: "/path/to/model.onnx.sig", }, Config: v1alpha1.ValidationConfig{ - SigstoreConfig: nil, - PkiConfig: nil, - PrivateKeyConfig: nil, - }, + SigstoreConfig: &v1alpha1.SigstoreConfig{ + CertificateIdentity: "test-identity", + CertificateOidcIssuer: "test-issuer", + }, + PkiConfig: nil, + PublicKeyConfig: nil}, }, }) Expect(err).To(Not(HaveOccurred()))