From 43b57153077ca5536f29195103d72a2a5ccbb309 Mon Sep 17 00:00:00 2001 From: Jose Szychowski Date: Tue, 14 Oct 2025 11:06:26 -0300 Subject: [PATCH 1/9] feat: add Document resource and associated roles This commit introduces a new Document resource along with its corresponding roles: documentation-document-admin, documentation-document-editor, and documentation-document-reader. The Document resource includes specifications for title, description, and document type, while the roles define permissions for creating, updating, and managing documents. Additionally, validation webhooks are implemented to enforce rules on document deletion based on existing revisions. --- .../notification/document.yaml | 18 ++++ .../roles/documentation-document-admin.yaml | 8 ++ .../roles/documentation-document-editor.yaml | 13 +++ .../roles/documentation-document-reader.yaml | 10 ++ .../v1alpha1/document_webhook.go | 62 +++++++++++ .../v1alpha1/document_webhook_test.go | 61 +++++++++++ pkg/apis/documentation/scheme.go | 11 ++ .../documentation/v1alpha1/document_types.go | 102 ++++++++++++++++++ 8 files changed, 285 insertions(+) create mode 100644 config/protected-resources/notification/document.yaml create mode 100644 config/roles/documentation-document-admin.yaml create mode 100644 config/roles/documentation-document-editor.yaml create mode 100644 config/roles/documentation-document-reader.yaml create mode 100644 internal/webhooks/documentation/v1alpha1/document_webhook.go create mode 100644 internal/webhooks/documentation/v1alpha1/document_webhook_test.go create mode 100644 pkg/apis/documentation/scheme.go create mode 100644 pkg/apis/documentation/v1alpha1/document_types.go diff --git a/config/protected-resources/notification/document.yaml b/config/protected-resources/notification/document.yaml new file mode 100644 index 00000000..25f1627d --- /dev/null +++ b/config/protected-resources/notification/document.yaml @@ -0,0 +1,18 @@ +apiVersion: iam.miloapis.com/v1alpha1 +kind: ProtectedResource +metadata: + name: documentation.miloapis.com-document +spec: + serviceRef: + name: "documentation.miloapis.com" + kind: Document + plural: documents + singular: document + permissions: + - list + - get + - create + - update + - delete + - patch + - watch \ No newline at end of file diff --git a/config/roles/documentation-document-admin.yaml b/config/roles/documentation-document-admin.yaml new file mode 100644 index 00000000..eb189393 --- /dev/null +++ b/config/roles/documentation-document-admin.yaml @@ -0,0 +1,8 @@ +apiVersion: iam.miloapis.com/v1alpha1 +kind: Role +metadata: + name: documentation-document-admin +spec: + launchStage: Beta + inheritedRoles: + - name: documentation-document-editor diff --git a/config/roles/documentation-document-editor.yaml b/config/roles/documentation-document-editor.yaml new file mode 100644 index 00000000..7b98f13a --- /dev/null +++ b/config/roles/documentation-document-editor.yaml @@ -0,0 +1,13 @@ +apiVersion: iam.miloapis.com/v1alpha1 +kind: Role +metadata: + name: documentation-document-editor +spec: + launchStage: Beta + inheritedRoles: + - name: documentation-document-reader + includedPermissions: + - documentation.miloapis.com/documents.create + - documentation.miloapis.com/documents.update + - documentation.miloapis.com/documents.patch + - documentation.miloapis.com/documents.delete diff --git a/config/roles/documentation-document-reader.yaml b/config/roles/documentation-document-reader.yaml new file mode 100644 index 00000000..7f54064e --- /dev/null +++ b/config/roles/documentation-document-reader.yaml @@ -0,0 +1,10 @@ +apiVersion: iam.miloapis.com/v1alpha1 +kind: Role +metadata: + name: documentation-document-reader +spec: + launchStage: Beta + includedPermissions: + - documentation.miloapis.com/documents.get + - documentation.miloapis.com/documents.list + - documentation.miloapis.com/documents.watch diff --git a/internal/webhooks/documentation/v1alpha1/document_webhook.go b/internal/webhooks/documentation/v1alpha1/document_webhook.go new file mode 100644 index 00000000..7a631420 --- /dev/null +++ b/internal/webhooks/documentation/v1alpha1/document_webhook.go @@ -0,0 +1,62 @@ +package v1alpha1 + +import ( + "context" + "fmt" + + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + ctrl "sigs.k8s.io/controller-runtime" + + documentationv1alpha1 "go.miloapis.com/milo/pkg/apis/documentation/v1alpha1" +) + +var documentLog = logf.Log.WithName("documentation-resource").WithName("document") + +// SetupDocumentWebhooksWithManager sets up the webhooks for the Document resource. +func SetupDocumentWebhooksWithManager(mgr ctrl.Manager) error { + documentLog.Info("Setting up documentation.miloapis.com documentation webhooks") + + return ctrl.NewWebhookManagedBy(mgr). + For(&documentationv1alpha1.Document{}). + WithValidator(&DocumentValidator{ + Client: mgr.GetClient(), + }). + Complete() +} + +// +kubebuilder:webhook:path=/validate-documentation-miloapis-com-v1alpha1-documentation,mutating=false,failurePolicy=fail,sideEffects=None,groups=documentation.miloapis.com,resources=documents,verbs=delete,versions=v1alpha1,name=vdocument.documentation.miloapis.com,admissionReviewVersions={v1,v1beta1},serviceName=milo-controller-manager,servicePort=9443,serviceNamespace=milo-system + +type DocumentValidator struct { + Client client.Client +} + +// ValidateCreate implements webhook.Validator so a webhook will be registered for the type +func (r *DocumentValidator) ValidateCreate(ctx context.Context, obj runtime.Object) (admission.Warnings, error) { + return nil, nil +} + +// ValidateUpdate implements webhook.Validator so a webhook will be registered for the type +func (r *DocumentValidator) ValidateUpdate(ctx context.Context, oldObj, newObj runtime.Object) (admission.Warnings, error) { + return nil, nil +} + +// ValidateDelete implements webhook.Validator so a webhook will be registered for the type +func (r *DocumentValidator) ValidateDelete(ctx context.Context, obj runtime.Object) (admission.Warnings, error) { + document, ok := obj.(*documentationv1alpha1.Document) + if !ok { + documentLog.Error(fmt.Errorf("failed to cast object to Document"), "failed to cast object to Document") + return nil, errors.NewInternalError(fmt.Errorf("failed to cast object to Document")) + } + + if document.Status.LatestRevisionRef != nil { + documentLog.Info("Rejecting delete; related revisions exist", "namespace", document.Namespace, "name", document.Name) + return nil, errors.NewBadRequest("cannot delete Document. It has related revision/s.") + } + + return nil, nil +} diff --git a/internal/webhooks/documentation/v1alpha1/document_webhook_test.go b/internal/webhooks/documentation/v1alpha1/document_webhook_test.go new file mode 100644 index 00000000..f088e308 --- /dev/null +++ b/internal/webhooks/documentation/v1alpha1/document_webhook_test.go @@ -0,0 +1,61 @@ +package v1alpha1 + +import ( + "context" + "testing" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + documentationv1alpha1 "go.miloapis.com/milo/pkg/apis/documentation/v1alpha1" +) + +func TestDocumentValidator_ValidateDelete(t *testing.T) { + ctx := context.TODO() + validator := &DocumentValidator{} + + t.Run("allowed when no latest revision", func(t *testing.T) { + doc := &documentationv1alpha1.Document{ + TypeMeta: metav1.TypeMeta{ + Kind: "Document", + APIVersion: "documentation.miloapis.com/v1alpha1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "terms-of-service", + Namespace: "default", + }, + Status: documentationv1alpha1.DocumentStatus{}, // LatestRevisionRef is nil + } + + if _, err := validator.ValidateDelete(ctx, doc); err != nil { + t.Fatalf("expected no error, got %v", err) + } + }) + + t.Run("denied when latest revision exists", func(t *testing.T) { + doc := &documentationv1alpha1.Document{ + TypeMeta: metav1.TypeMeta{ + Kind: "Document", + APIVersion: "documentation.miloapis.com/v1alpha1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "privacy-policy", + Namespace: "default", + }, + Status: documentationv1alpha1.DocumentStatus{ + LatestRevisionRef: &documentationv1alpha1.LatestRevisionRef{ + Name: "privacy-policy-v1.0.0", + Namespace: "default", + Version: documentationv1alpha1.DocumentVersion("v1.0.0"), + PublishedAt: metav1.Now(), + }, + }, + } + + if _, err := validator.ValidateDelete(ctx, doc); err == nil { + t.Fatalf("expected error, got nil") + } else if !apierrors.IsBadRequest(err) { + t.Fatalf("expected BadRequest error, got %v", err) + } + }) +} diff --git a/pkg/apis/documentation/scheme.go b/pkg/apis/documentation/scheme.go new file mode 100644 index 00000000..796932e8 --- /dev/null +++ b/pkg/apis/documentation/scheme.go @@ -0,0 +1,11 @@ +package document + +import ( + "k8s.io/apimachinery/pkg/runtime" + + "go.miloapis.com/milo/pkg/apis/iam/v1alpha1" +) + +func Install(scheme *runtime.Scheme) { + v1alpha1.AddToScheme(scheme) +} diff --git a/pkg/apis/documentation/v1alpha1/document_types.go b/pkg/apis/documentation/v1alpha1/document_types.go new file mode 100644 index 00000000..bae96644 --- /dev/null +++ b/pkg/apis/documentation/v1alpha1/document_types.go @@ -0,0 +1,102 @@ +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// Create conditions +const ( + // DocumentReadyCondition is the condition Type that tracks document creation status. + DocumentReadyCondition = "Ready" + // DocumentCreatedReason is used when document creation succeeds. + DocumentCreatedReason = "CreateSuccessful" +) + +// +kubebuilder:validation:Pattern=`^v\d+\.\d+\.\d+$` +type DocumentVersion string + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status + +// Document is the Schema for the documents API. +// It represents a document that can be used to create a document revision. +// +kubebuilder:printcolumn:name="Title",type="string",JSONPath=".spec.title" +// +kubebuilder:printcolumn:name="Category",type="string",JSONPath=".metadata.documentMetadata.category" +// +kubebuilder:printcolumn:name="Jurisdiction",type="string",JSONPath=".metadata.documentMetadata.jurisdiction" +// +kubebuilder:printcolumn:name="Ready",type="string",JSONPath=".status.conditions[?(@.type=='Ready')].status" +// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp" +// +kubebuilder:resource:scope=Namespaced +type Document struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec DocumentSpec `json:"spec,omitempty"` + Metadata DocumentMetadata `json:"documentMetadata,omitempty"` + Status DocumentStatus `json:"status,omitempty"` +} + +// DocumentSpec defines the desired state of Document. +// +kubebuilder:validation:Type=object +type DocumentSpec struct { + // Title is the title of the Document. + // +kubebuilder:validation:Required + Title string `json:"title"` + + // Description is the description of the Document. + // +kubebuilder:validation:Required + Description string `json:"description"` + + // DocumentType is the type of the document. + // +kubebuilder:validation:Required + DocumentType string `json:"documentType"` +} + +// DocumentMetadata defines the metadata of the Document. +// +kubebuilder:validation:Type=object +type DocumentMetadata struct { + // Category is the category of the Document. + // +kubebuilder:validation:Required + Category string `json:"category"` + + // Jurisdiction is the jurisdiction of the Document. + // +kubebuilder:validation:Required + Jurisdiction string `json:"jurisdiction"` +} + +// +kubebuilder:object:root=true + +// DocumentList contains a list of Document. +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type DocumentList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []Document `json:"items"` +} + +// DocumentStatus defines the observed state of Document. +// +kubebuilder:validation:Type=object +type DocumentStatus struct { + // Conditions represent the latest available observations of an object's current state. + // +kubebuilder:default={{type: "Ready", status: "Unknown", reason: "Unknown", message: "Waiting for control plane to reconcile", lastTransitionTime: "1970-01-01T00:00:00Z"}} + // +kubebuilder:validation:Optional + // +patchMergeKey=type + // +patchStrategy=merge + Conditions []metav1.Condition `json:"conditions,omitempty"` + + // +kubebuilder:validation:Optional + LatestRevisionRef *LatestRevisionRef `json:"latestRevisionRef,omitempty"` +} + +// LatestRevisionRef is a reference to the latest revision of the document. +// +kubebuilder:validation:Type=object +type LatestRevisionRef struct { + // +kubebuilder:validation:Optional + Name string `json:"name,omitempty"` + // +kubebuilder:validation:Optional + Namespace string `json:"namespace,omitempty"` + // +kubebuilder:validation:Optional + Version DocumentVersion `json:"version,omitempty"` + // +kubebuilder:validation:Optional + PublishedAt metav1.Time `json:"publishedAt,omitempty"` +} From fa61c88e89fa88fec8957953e684f3fd61b29c75 Mon Sep 17 00:00:00 2001 From: Jose Szychowski Date: Tue, 14 Oct 2025 11:10:38 -0300 Subject: [PATCH 2/9] feat: introduce DocumentRevision resource and validation webhooks --- .../notification/documentrevision.yaml | 18 ++ .../documentation-documentrevision-admin.yaml | 8 + ...documentation-documentrevision-editor.yaml | 13 ++ ...documentation-documentrevision-reader.yaml | 10 ++ .../documentation/v1alpha1/document.yaml | 11 ++ .../v1alpha1/documentrevision.yaml | 23 +++ .../v1alpha1/documentrevision_webhook.go | 103 ++++++++++++ .../v1alpha1/documentrevision_webhook_test.go | 154 ++++++++++++++++++ pkg/apis/documentation/v1alpha1/doc.go | 5 + .../v1alpha1/documentrevision_types.go | 154 ++++++++++++++++++ pkg/apis/documentation/v1alpha1/register.go | 29 ++++ pkg/version/version.go | 55 +++++++ pkg/version/version_test.go | 84 ++++++++++ 13 files changed, 667 insertions(+) create mode 100644 config/protected-resources/notification/documentrevision.yaml create mode 100644 config/roles/documentation-documentrevision-admin.yaml create mode 100644 config/roles/documentation-documentrevision-editor.yaml create mode 100644 config/roles/documentation-documentrevision-reader.yaml create mode 100644 config/samples/documentation/v1alpha1/document.yaml create mode 100644 config/samples/documentation/v1alpha1/documentrevision.yaml create mode 100644 internal/webhooks/documentation/v1alpha1/documentrevision_webhook.go create mode 100644 internal/webhooks/documentation/v1alpha1/documentrevision_webhook_test.go create mode 100644 pkg/apis/documentation/v1alpha1/doc.go create mode 100644 pkg/apis/documentation/v1alpha1/documentrevision_types.go create mode 100644 pkg/apis/documentation/v1alpha1/register.go create mode 100644 pkg/version/version.go create mode 100644 pkg/version/version_test.go diff --git a/config/protected-resources/notification/documentrevision.yaml b/config/protected-resources/notification/documentrevision.yaml new file mode 100644 index 00000000..86181a69 --- /dev/null +++ b/config/protected-resources/notification/documentrevision.yaml @@ -0,0 +1,18 @@ +apiVersion: iam.miloapis.com/v1alpha1 +kind: ProtectedResource +metadata: + name: documentation.miloapis.com-documentrevision +spec: + serviceRef: + name: "documentation.miloapis.com" + kind: DocumentRevision + plural: documentrevisions + singular: documentrevision + permissions: + - list + - get + - create + - update + - delete + - patch + - watch \ No newline at end of file diff --git a/config/roles/documentation-documentrevision-admin.yaml b/config/roles/documentation-documentrevision-admin.yaml new file mode 100644 index 00000000..0b99b0b0 --- /dev/null +++ b/config/roles/documentation-documentrevision-admin.yaml @@ -0,0 +1,8 @@ +apiVersion: iam.miloapis.com/v1alpha1 +kind: Role +metadata: + name: documentation-documentrevision-admin +spec: + launchStage: Beta + inheritedRoles: + - name: documentation-documentrevision-editor diff --git a/config/roles/documentation-documentrevision-editor.yaml b/config/roles/documentation-documentrevision-editor.yaml new file mode 100644 index 00000000..328d5fa9 --- /dev/null +++ b/config/roles/documentation-documentrevision-editor.yaml @@ -0,0 +1,13 @@ +apiVersion: iam.miloapis.com/v1alpha1 +kind: Role +metadata: + name: documentation-documentrevision-editor +spec: + launchStage: Beta + inheritedRoles: + - name: documentation-documentrevision-reader + includedPermissions: + - documentation.miloapis.com/documentrevisions.create + - documentation.miloapis.com/documentrevisions.update + - documentation.miloapis.com/documentrevisions.patch + - documentation.miloapis.com/documentrevisions.delete diff --git a/config/roles/documentation-documentrevision-reader.yaml b/config/roles/documentation-documentrevision-reader.yaml new file mode 100644 index 00000000..e88d3377 --- /dev/null +++ b/config/roles/documentation-documentrevision-reader.yaml @@ -0,0 +1,10 @@ +apiVersion: iam.miloapis.com/v1alpha1 +kind: Role +metadata: + name: documentation-documentrevision-reader +spec: + launchStage: Beta + includedPermissions: + - documentation.miloapis.com/documentrevisions.get + - documentation.miloapis.com/documentrevisions.list + - documentation.miloapis.com/documentrevisions.watch diff --git a/config/samples/documentation/v1alpha1/document.yaml b/config/samples/documentation/v1alpha1/document.yaml new file mode 100644 index 00000000..c794b898 --- /dev/null +++ b/config/samples/documentation/v1alpha1/document.yaml @@ -0,0 +1,11 @@ +apiVersion: documentation.miloapis.com/v1alpha1 +kind: Document +metadata: + name: sample-document +spec: + title: "Terms of Serevice" + description: "Standard terms of service governing usage of the platform." + documentType: "tos" +documentMetadata: + category: "Legal" + jurisdiction: "US" diff --git a/config/samples/documentation/v1alpha1/documentrevision.yaml b/config/samples/documentation/v1alpha1/documentrevision.yaml new file mode 100644 index 00000000..a182db33 --- /dev/null +++ b/config/samples/documentation/v1alpha1/documentrevision.yaml @@ -0,0 +1,23 @@ +apiVersion: documentation.miloapis.com/v1alpha1 +kind: DocumentRevision +metadata: + name: sample-daocument-revision + namespace: default +spec: + documentRef: + name: sample-document + namespace: default + version: v1.0.3 + content: + format: markdown + data: | + # Terms of Service – v1.0.0 + Welcome to Milo. This is the first published Terms of Service. + effectiveDate: "2025-01-01T00:00:00Z" + changesSummary: "Initial publication of the Terms of Service." + expectedSubjectKinds: + - apiGroup: resourcemanager.miloapis.com + kind: Project + expectedAccepterKinds: + - apiGroup: iam.miloapis.com + kind: User diff --git a/internal/webhooks/documentation/v1alpha1/documentrevision_webhook.go b/internal/webhooks/documentation/v1alpha1/documentrevision_webhook.go new file mode 100644 index 00000000..0ff2d1f6 --- /dev/null +++ b/internal/webhooks/documentation/v1alpha1/documentrevision_webhook.go @@ -0,0 +1,103 @@ +package v1alpha1 + +import ( + "context" + "fmt" + "time" + + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/validation/field" + "sigs.k8s.io/controller-runtime/pkg/client" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + ctrl "sigs.k8s.io/controller-runtime" + + version "go.miloapis.com/milo/pkg/version" + + documentationv1alpha1 "go.miloapis.com/milo/pkg/apis/documentation/v1alpha1" +) + +var drLog = logf.Log.WithName("documentation-resource").WithName("documentrevision") + +// SetupDocumentWebhooksWithManager sets up the webhooks for the Document resource. +func SetupDocumentRevisionWebhooksWithManager(mgr ctrl.Manager) error { + drLog.Info("Setting up documentation.miloapis.com document revision webhooks") + + return ctrl.NewWebhookManagedBy(mgr). + For(&documentationv1alpha1.DocumentRevision{}). + WithValidator(&DocumentRevisionValidator{ + Client: mgr.GetClient(), + }). + Complete() +} + +// +kubebuilder:webhook:path=/validate-documentation-miloapis-com-v1alpha1-documentation,mutating=false,failurePolicy=fail,sideEffects=None,groups=documentation.miloapis.com,resources=documentrevisions,verbs=delete;create,versions=v1alpha1,name=vdocumentrevision.documentation.miloapis.com,admissionReviewVersions={v1,v1beta1},serviceName=milo-controller-manager,servicePort=9443,serviceNamespace=milo-system + +type DocumentRevisionValidator struct { + Client client.Client +} + +// ValidateCreate implements webhook.Validator so a webhook will be registered for the type +func (r *DocumentRevisionValidator) ValidateCreate(ctx context.Context, obj runtime.Object) (admission.Warnings, error) { + dr, ok := obj.(*documentationv1alpha1.DocumentRevision) + if !ok { + drLog.Error(fmt.Errorf("failed to cast object to DocumentRevision"), "failed to cast object to DocumentRevision") + return nil, errors.NewInternalError(fmt.Errorf("failed to cast object to DocumentRevision")) + } + drLog.Info("Validating DocumentRevision", "name", dr.Name) + + var errs field.ErrorList + + // Referenced Document must exist + document := &documentationv1alpha1.Document{} + err := r.Client.Get(ctx, client.ObjectKey{Namespace: dr.Spec.DocumentRef.Namespace, Name: dr.Spec.DocumentRef.Name}, document) + if err != nil { + if errors.IsNotFound(err) { + drLog.Info("Document not found", "namespace", dr.Spec.DocumentRef.Namespace, "name", dr.Spec.DocumentRef.Name) + errs = append(errs, field.NotFound(field.NewPath("spec", "documentRef"), dr.Spec.DocumentRef.Name)) + } else { + drLog.Error(err, "failed to get document", "namespace", dr.Spec.DocumentRef.Namespace, "name", dr.Spec.DocumentRef.Name) + return nil, errors.NewInternalError(err) + } + } + + // Version must be higher than the latest referenced revision version + if err == nil && document.Status.LatestRevisionRef != nil { + higher, cmpErr := version.IsVersionHigher(dr.Spec.Version, document.Status.LatestRevisionRef.Version) + if cmpErr != nil { + drLog.Error(cmpErr, "failed to compare versions", "namespace", dr.Spec.DocumentRef.Namespace, "name", dr.Spec.DocumentRef.Name, "version", dr.Spec.Version, "latestRevisionVersion", document.Status.LatestRevisionRef.Version) + return nil, errors.NewInternalError(cmpErr) + } + if !higher { + drLog.Info("Document revision version is not higher than the latest revision version", "namespace", dr.Spec.DocumentRef.Namespace, "name", dr.Spec.DocumentRef.Name, "version", dr.Spec.Version, "latestRevisionVersion", document.Status.LatestRevisionRef.Version) + errs = append(errs, field.Invalid(field.NewPath("spec", "version"), dr.Spec.Version, "Document revision version is not higher than the latest referenced revision version")) + } + } + + // EffectiveDate must be in the future + if !dr.Spec.EffectiveDate.Time.After(time.Now()) { + drLog.Info("EffectiveDate is not in the future", "effectiveDate", dr.Spec.EffectiveDate.Time) + errs = append(errs, field.Invalid(field.NewPath("spec", "effectiveDate"), dr.Spec.EffectiveDate, "EffectiveDate must be in the future")) + } + + if len(errs) > 0 { + invalidErr := errors.NewInvalid(documentationv1alpha1.SchemeGroupVersion.WithKind("DocumentRevision").GroupKind(), dr.Name, errs) + drLog.Error(invalidErr, "invalid document revision") + return nil, invalidErr + } + + return nil, nil +} + +// ValidateUpdate implements webhook.Validator so a webhook will be registered for the type +func (r *DocumentRevisionValidator) ValidateUpdate(ctx context.Context, oldObj, newObj runtime.Object) (admission.Warnings, error) { + // Update is not allowed as it is immutable at API level + return nil, nil +} + +// ValidateDelete implements webhook.Validator so a webhook will be registered for the type +func (r *DocumentRevisionValidator) ValidateDelete(ctx context.Context, obj runtime.Object) (admission.Warnings, error) { + return nil, errors.NewMethodNotSupported(documentationv1alpha1.SchemeGroupVersion.WithResource("DocumentRevision").GroupResource(), "delete") +} diff --git a/internal/webhooks/documentation/v1alpha1/documentrevision_webhook_test.go b/internal/webhooks/documentation/v1alpha1/documentrevision_webhook_test.go new file mode 100644 index 00000000..b6f4cfbe --- /dev/null +++ b/internal/webhooks/documentation/v1alpha1/documentrevision_webhook_test.go @@ -0,0 +1,154 @@ +package v1alpha1 + +import ( + "context" + "testing" + "time" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + documentationv1alpha1 "go.miloapis.com/milo/pkg/apis/documentation/v1alpha1" +) + +func TestDocumentRevisionValidator_ValidateCreate(t *testing.T) { + scheme := runtime.NewScheme() + _ = clientgoscheme.AddToScheme(scheme) + _ = documentationv1alpha1.AddToScheme(scheme) + + now := time.Now() + future := metav1.NewTime(now.Add(24 * time.Hour)) + past := metav1.NewTime(now.Add(-1 * time.Hour)) + + baseDoc := &documentationv1alpha1.Document{ + ObjectMeta: metav1.ObjectMeta{ + Name: "doc", + Namespace: "default", + }, + Status: documentationv1alpha1.DocumentStatus{ + LatestRevisionRef: &documentationv1alpha1.LatestRevisionRef{ + Version: "v1.0.0", + }, + }, + } + + tests := []struct { + name string + objects []runtime.Object + dr *documentationv1alpha1.DocumentRevision + wantError bool + }{ + { + name: "valid revision", + objects: []runtime.Object{baseDoc.DeepCopy()}, + dr: &documentationv1alpha1.DocumentRevision{ + ObjectMeta: metav1.ObjectMeta{ + Name: "rev1", + Namespace: "default", + }, + Spec: documentationv1alpha1.DocumentRevisionSpec{ + DocumentRef: documentationv1alpha1.DocumentReference{ + Name: "doc", + Namespace: "default", + }, + Version: "v1.0.1", + EffectiveDate: future, + Content: documentationv1alpha1.DocumentRevisionContent{ + Format: "markdown", + Data: "some data", + }, + ChangesSummary: "changes", + ExpectedSubjectKinds: []documentationv1alpha1.DocumentRevisionExpectedSubjectKind{{APIGroup: "test", Kind: "Kind"}}, + ExpectedAccepterKinds: []documentationv1alpha1.DocumentRevisionExpectedAccepterKind{{APIGroup: "test", Kind: "Kind"}}, + }, + }, + wantError: false, + }, + { + name: "document not found", + objects: []runtime.Object{}, + dr: &documentationv1alpha1.DocumentRevision{ + ObjectMeta: metav1.ObjectMeta{ + Name: "rev2", + Namespace: "default", + }, + Spec: documentationv1alpha1.DocumentRevisionSpec{ + DocumentRef: documentationv1alpha1.DocumentReference{ + Name: "doc", + Namespace: "default", + }, + Version: "v1.0.1", + EffectiveDate: future, + Content: documentationv1alpha1.DocumentRevisionContent{Format: "markdown", Data: "x"}, + ChangesSummary: "changes", + ExpectedSubjectKinds: []documentationv1alpha1.DocumentRevisionExpectedSubjectKind{{APIGroup: "test", Kind: "Kind"}}, + ExpectedAccepterKinds: []documentationv1alpha1.DocumentRevisionExpectedAccepterKind{{APIGroup: "test", Kind: "Kind"}}, + }, + }, + wantError: true, + }, + { + name: "version not higher", + objects: []runtime.Object{baseDoc.DeepCopy()}, + dr: &documentationv1alpha1.DocumentRevision{ + ObjectMeta: metav1.ObjectMeta{ + Name: "rev3", + Namespace: "default", + }, + Spec: documentationv1alpha1.DocumentRevisionSpec{ + DocumentRef: documentationv1alpha1.DocumentReference{Name: "doc", Namespace: "default"}, + Version: "v0.9.0", + EffectiveDate: future, + Content: documentationv1alpha1.DocumentRevisionContent{Format: "markdown", Data: "x"}, + ChangesSummary: "changes", + ExpectedSubjectKinds: []documentationv1alpha1.DocumentRevisionExpectedSubjectKind{{APIGroup: "test", Kind: "Kind"}}, + ExpectedAccepterKinds: []documentationv1alpha1.DocumentRevisionExpectedAccepterKind{{APIGroup: "test", Kind: "Kind"}}, + }, + }, + wantError: true, + }, + { + name: "effective date not future", + objects: []runtime.Object{baseDoc.DeepCopy()}, + dr: &documentationv1alpha1.DocumentRevision{ + ObjectMeta: metav1.ObjectMeta{ + Name: "rev4", + Namespace: "default", + }, + Spec: documentationv1alpha1.DocumentRevisionSpec{ + DocumentRef: documentationv1alpha1.DocumentReference{Name: "doc", Namespace: "default"}, + Version: "v1.0.1", + EffectiveDate: past, + Content: documentationv1alpha1.DocumentRevisionContent{Format: "markdown", Data: "x"}, + ChangesSummary: "changes", + ExpectedSubjectKinds: []documentationv1alpha1.DocumentRevisionExpectedSubjectKind{{APIGroup: "test", Kind: "Kind"}}, + ExpectedAccepterKinds: []documentationv1alpha1.DocumentRevisionExpectedAccepterKind{{APIGroup: "test", Kind: "Kind"}}, + }, + }, + wantError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := fake.NewClientBuilder().WithScheme(scheme).WithRuntimeObjects(tt.objects...).Build() + v := &DocumentRevisionValidator{Client: c} + _, err := v.ValidateCreate(context.TODO(), tt.dr) + if tt.wantError { + if err == nil { + t.Fatalf("expected error, got nil") + } + if !apierrors.IsInvalid(err) && !apierrors.IsNotFound(err) { + t.Fatalf("expected admission invalid/notfound error, got %v", err) + } + } else { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + } + }) + } +} diff --git a/pkg/apis/documentation/v1alpha1/doc.go b/pkg/apis/documentation/v1alpha1/doc.go new file mode 100644 index 00000000..525c5639 --- /dev/null +++ b/pkg/apis/documentation/v1alpha1/doc.go @@ -0,0 +1,5 @@ +// Package v1alpha1 contains API Schema definitions for the documentation v1alpha1 API group +// +// +k8s:deepcopy-gen=package,register +// +groupName=documentation.miloapis.com +package v1alpha1 diff --git a/pkg/apis/documentation/v1alpha1/documentrevision_types.go b/pkg/apis/documentation/v1alpha1/documentrevision_types.go new file mode 100644 index 00000000..c01c719b --- /dev/null +++ b/pkg/apis/documentation/v1alpha1/documentrevision_types.go @@ -0,0 +1,154 @@ +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// Create conditions +const ( + // DocumentRevisionReadyCondition is the condition Type that tracks document revision creation status. + DocumentRevisionReadyCondition = "Ready" + // DocumentRevisionCreatedReason is used when document revision creation succeeds. + DocumentRevisionCreatedReason = "CreateSuccessful" +) + +// DocumentReference contains information that points to the Document being referenced. +// Document is a namespaced resource. +// +kubebuilder:validation:Type=object +type DocumentReference struct { + // Name is the name of the Document being referenced. + // +kubebuilder:validation:Required + Name string `json:"name"` + // Namespace of the referenced Document. + // +kubebuilder:validation:Required + Namespace string `json:"namespace"` +} + +// DocumentRevisionContent contains the content of the document revision. +// +kubebuilder:validation:Type=object +type DocumentRevisionContent struct { + // Format is the format of the document revision. + // +kubebuilder:validation:Required + // +kubebuilder:validation:Enum=html;markdown + Format string `json:"format"` + + // Data is the data of the document revision. + // +kubebuilder:validation:Required + Data string `json:"data"` +} + +// DocumentRevisionExpectedSubjectKind is the kind of the resource that is expected to reference this revision. +// +kubebuilder:validation:Type=object +type DocumentRevisionExpectedSubjectKind struct { + // APIGroup is the group for the resource being referenced. + // +kubebuilder:validation:Required + APIGroup string `json:"apiGroup"` + + // Kind is the type of resource being referenced. + // +kubebuilder:validation:Required + Kind string `json:"kind"` +} + +// DocumentRevisionExpectedAccepterKind is the kind of the resource that is expected to accept this revision. +// +kubebuilder:validation:Type=object +type DocumentRevisionExpectedAccepterKind struct { + // APIGroup is the group for the resource being referenced. + // +kubebuilder:validation:Required + // +kubebuilder:validation:XValidation:rule="self == 'iam.miloapis.com'",message="apiGroup must be iam.miloapis.com" + APIGroup string `json:"apiGroup"` + + // Kind is the type of resource being referenced. + // +kubebuilder:validation:Required + // +kubebuilder:validation:Enum=User;MachineAccount + Kind string `json:"kind"` +} + +// DocumentRevisionSpec defines the desired state of DocumentRevision. +// +kubebuilder:validation:Type=object +// +kubebuilder:validation:XValidation:rule="self == oldSelf",message="spec is immutable" +type DocumentRevisionSpec struct { + // DocumentRef is a reference to the document that this revision is based on. + // +kubebuilder:validation:Required + DocumentRef DocumentReference `json:"documentRef"` + + // Version is the version of the document revision. + // +kubebuilder:validation:Required + Version DocumentVersion `json:"version"` + + // Content is the content of the document revision. + // +kubebuilder:validation:Required + Content DocumentRevisionContent `json:"content"` + + // EffectiveDate is the date in which the document revision starts to be effective. + // +kubebuilder:validation:Required + EffectiveDate metav1.Time `json:"effectiveDate"` + + // ChangesSummary is the summary of the changes in the document revision. + // +kubebuilder:validation:Required + ChangesSummary string `json:"changesSummary"` + + // ExpectedSubjectKinds is the resource kinds that this revision affects to. + // +kubebuilder:validation:Required + ExpectedSubjectKinds []DocumentRevisionExpectedSubjectKind `json:"expectedSubjectKinds"` + + // ExpectedAccepterKinds is the resource kinds that are expected to accept this revision. + // +kubebuilder:validation:Required + ExpectedAccepterKinds []DocumentRevisionExpectedAccepterKind `json:"expectedAccepterKinds"` +} + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status + +// DocumentRevision is the Schema for the documentrevisions API. +// It represents a revision of a document. +// +kubebuilder:resource:scope=Namespaced +type DocumentRevision struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec DocumentRevisionSpec `json:"spec,omitempty"` + Status DocumentRevisionStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// DocumentRevisionList contains a list of DocumentRevision. +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type DocumentRevisionList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []DocumentRevision `json:"items"` +} + +// DocumentRevisionStatus defines the observed state of DocumentRevision. +// +kubebuilder:validation:Type=object +type DocumentRevisionStatus struct { + // Conditions represent the latest available observations of an object's current state. + // +kubebuilder:default={{type: "Ready", status: "Unknown", reason: "Unknown", message: "Waiting for control plane to reconcile", lastTransitionTime: "1970-01-01T00:00:00Z"}} + // +kubebuilder:validation:Optional + // +patchMergeKey=type + // +patchStrategy=merge + Conditions []metav1.Condition `json:"conditions,omitempty"` + + // ContentHash is the hash of the content of the document revision. + // This is used to detect if the content of the document revision has changed. + // +kubebuilder:validation:Optional + ContentHash string `json:"contentHash,omitempty"` +} + +// DocumentRevisionReference contains information that points to the DocumentRevision being referenced. +// +kubebuilder:validation:Type=object +type DocumentRevisionReference struct { + // Name is the name of the DocumentRevision being referenced. + // +kubebuilder:validation:Required + Name string `json:"name"` + + // Namespace of the referenced document revision. + // +kubebuilder:validation:Required + Namespace string `json:"namespace"` + + // Version is the version of the DocumentRevision being referenced. + // +kubebuilder:validation:Required + Version DocumentVersion `json:"version"` +} diff --git a/pkg/apis/documentation/v1alpha1/register.go b/pkg/apis/documentation/v1alpha1/register.go new file mode 100644 index 00000000..929f924c --- /dev/null +++ b/pkg/apis/documentation/v1alpha1/register.go @@ -0,0 +1,29 @@ +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +// SchemeGroupVersion is group version used to register these objects. +var SchemeGroupVersion = schema.GroupVersion{Group: "document.miloapis.com", Version: "v1alpha1"} + +var ( + // SchemeBuilder is used to add go types to the GroupVersionKind scheme + SchemeBuilder = runtime.NewSchemeBuilder(addKnownTypes) + // AddToScheme allows addition of this group to a scheme + AddToScheme = SchemeBuilder.AddToScheme +) + +// addKnownTypes adds the set of types defined in this package to the supplied scheme. +func addKnownTypes(scheme *runtime.Scheme) error { + scheme.AddKnownTypes(SchemeGroupVersion, + &Document{}, + &DocumentList{}, + &DocumentRevision{}, + &DocumentRevisionList{}, + ) + metav1.AddToGroupVersion(scheme, SchemeGroupVersion) + return nil +} diff --git a/pkg/version/version.go b/pkg/version/version.go new file mode 100644 index 00000000..9dbe9d35 --- /dev/null +++ b/pkg/version/version.go @@ -0,0 +1,55 @@ +package version + +import ( + "fmt" + "strconv" + "strings" + + documentationv1alpha1 "go.miloapis.com/milo/pkg/apis/documentation/v1alpha1" +) + +// IsVersionHigher returns true if newVersion is strictly greater than prevVersion using semantic versioning rules. +// Both versions must follow the `vMAJOR.MINOR.PATCH` format (validated by kubebuilder tag on DocumentVersion). +// If either version is invalid the function returns an error. +func IsVersionHigher(newVersion, prevVersion documentationv1alpha1.DocumentVersion) (bool, error) { + newParts, err := parseSemver(string(newVersion)) + if err != nil { + return false, fmt.Errorf("invalid new version: %w", err) + } + prevParts, err := parseSemver(string(prevVersion)) + if err != nil { + return false, fmt.Errorf("invalid previous version: %w", err) + } + + for i := 0; i < 3; i++ { + if newParts[i] > prevParts[i] { + return true, nil + } + if newParts[i] < prevParts[i] { + return false, nil + } + } + // versions are equal + return false, nil +} + +// parseSemver converts a string in form vMAJOR.MINOR.PATCH into a slice of three integers. +func parseSemver(v string) ([3]int, error) { + if !strings.HasPrefix(v, "v") { + return [3]int{}, fmt.Errorf("version must start with 'v'") + } + v = strings.TrimPrefix(v, "v") + segments := strings.Split(v, ".") + if len(segments) != 3 { + return [3]int{}, fmt.Errorf("version must have three segments") + } + var parts [3]int + for i, s := range segments { + n, err := strconv.Atoi(s) + if err != nil { + return [3]int{}, fmt.Errorf("invalid segment %q: %w", s, err) + } + parts[i] = n + } + return parts, nil +} diff --git a/pkg/version/version_test.go b/pkg/version/version_test.go new file mode 100644 index 00000000..541f6946 --- /dev/null +++ b/pkg/version/version_test.go @@ -0,0 +1,84 @@ +package version + +import ( + "testing" + + documentationv1alpha1 "go.miloapis.com/milo/pkg/apis/documentation/v1alpha1" +) + +func TestIsVersionHigher(t *testing.T) { + tests := []struct { + name string + newVersion documentationv1alpha1.DocumentVersion + prevVersion documentationv1alpha1.DocumentVersion + wantHigher bool + wantErr bool + }{ + { + name: "patch higher", + newVersion: "v1.0.1", + prevVersion: "v1.0.0", + wantHigher: true, + }, + { + name: "equal versions", + newVersion: "v1.2.3", + prevVersion: "v1.2.3", + wantHigher: false, + }, + { + name: "minor lower", + newVersion: "v1.1.0", + prevVersion: "v1.2.0", + wantHigher: false, + }, + { + name: "major higher", + newVersion: "v2.0.0", + prevVersion: "v1.9.9", + wantHigher: true, + }, + { + name: "invalid new", + newVersion: "1.0.0", // missing leading v + prevVersion: "v0.9.0", + wantErr: true, + }, + { + name: "invalid prev", + newVersion: "v1.0.0", + prevVersion: "v1.0", // not three segments + wantErr: true, + }, + { + name: "minor higher multiple digits", + newVersion: "v1.10.0", + prevVersion: "v1.2.99", + wantHigher: true, + }, + { + name: "patch lower with multiple digits", + newVersion: "v1.2.10", + prevVersion: "v1.2.11", + wantHigher: false, + }, + { + name: "major higher multiple digits", + newVersion: "v11.0.0", + prevVersion: "v2.0.0", + wantHigher: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotHigher, err := IsVersionHigher(tt.newVersion, tt.prevVersion) + if (err != nil) != tt.wantErr { + t.Fatalf("expected error=%v, got %v", tt.wantErr, err) + } + if err == nil && gotHigher != tt.wantHigher { + t.Fatalf("expected higher=%v, got %v", tt.wantHigher, gotHigher) + } + }) + } +} From 75961445a18452451d901b47d80938d336a236f3 Mon Sep 17 00:00:00 2001 From: Jose Szychowski Date: Tue, 14 Oct 2025 11:12:04 -0300 Subject: [PATCH 3/9] feat: add DocumentAcceptance resource, webhook and associated roles --- .../notification/documentacceptance.yaml | 18 ++ .../notification/kustomization.yaml | 3 + .../agreement-documentacceptance-admin.yaml | 8 + .../agreement-documentacceptance-editor.yaml | 13 ++ .../agreement-documentacceptance-reader.yaml | 10 ++ config/roles/kustomization.yaml | 9 + .../v1alpha1/documentacceptance.yaml | 27 +++ internal/webhooks/agreement/v1alpha1/doc.go | 4 + .../v1alpha1/documentacceptance_webhook.go | 126 ++++++++++++++ .../documentacceptance_webhook_test.go | 159 ++++++++++++++++++ .../webhooks/documentation/v1alpha1/doc.go | 4 + pkg/apis/agreement/scheme.go | 11 ++ pkg/apis/agreement/v1alpha1/doc.go | 5 + .../v1alpha1/documentacceptance_types.go | 130 ++++++++++++++ pkg/apis/agreement/v1alpha1/register.go | 27 +++ 15 files changed, 554 insertions(+) create mode 100644 config/protected-resources/notification/documentacceptance.yaml create mode 100644 config/roles/agreement-documentacceptance-admin.yaml create mode 100644 config/roles/agreement-documentacceptance-editor.yaml create mode 100644 config/roles/agreement-documentacceptance-reader.yaml create mode 100644 config/samples/documentation/v1alpha1/documentacceptance.yaml create mode 100644 internal/webhooks/agreement/v1alpha1/doc.go create mode 100644 internal/webhooks/agreement/v1alpha1/documentacceptance_webhook.go create mode 100644 internal/webhooks/agreement/v1alpha1/documentacceptance_webhook_test.go create mode 100644 internal/webhooks/documentation/v1alpha1/doc.go create mode 100644 pkg/apis/agreement/scheme.go create mode 100644 pkg/apis/agreement/v1alpha1/doc.go create mode 100644 pkg/apis/agreement/v1alpha1/documentacceptance_types.go create mode 100644 pkg/apis/agreement/v1alpha1/register.go diff --git a/config/protected-resources/notification/documentacceptance.yaml b/config/protected-resources/notification/documentacceptance.yaml new file mode 100644 index 00000000..393c3eee --- /dev/null +++ b/config/protected-resources/notification/documentacceptance.yaml @@ -0,0 +1,18 @@ +apiVersion: iam.miloapis.com/v1alpha1 +kind: ProtectedResource +metadata: + name: agreement.miloapis.com-documentacceptance +spec: + serviceRef: + name: "agreement.miloapis.com" + kind: DocumentAcceptance + plural: documentacceptances + singular: documentacceptance + permissions: + - list + - get + - create + - update + - delete + - patch + - watch \ No newline at end of file diff --git a/config/protected-resources/notification/kustomization.yaml b/config/protected-resources/notification/kustomization.yaml index eff17914..d417338c 100644 --- a/config/protected-resources/notification/kustomization.yaml +++ b/config/protected-resources/notification/kustomization.yaml @@ -9,3 +9,6 @@ resources: - contactgroup.yaml - contactgroupmembership.yaml - contactgroupmembershipremoval.yaml + - document.yaml + - documentrevision.yaml + - documentacceptance.yaml diff --git a/config/roles/agreement-documentacceptance-admin.yaml b/config/roles/agreement-documentacceptance-admin.yaml new file mode 100644 index 00000000..dd1a609c --- /dev/null +++ b/config/roles/agreement-documentacceptance-admin.yaml @@ -0,0 +1,8 @@ +apiVersion: iam.miloapis.com/v1alpha1 +kind: Role +metadata: + name: agreement-documentacceptance-admin +spec: + launchStage: Beta + inheritedRoles: + - name: agreement-documentacceptance-editor diff --git a/config/roles/agreement-documentacceptance-editor.yaml b/config/roles/agreement-documentacceptance-editor.yaml new file mode 100644 index 00000000..fd6c7add --- /dev/null +++ b/config/roles/agreement-documentacceptance-editor.yaml @@ -0,0 +1,13 @@ +apiVersion: iam.miloapis.com/v1alpha1 +kind: Role +metadata: + name: agreement-documentacceptance-editor +spec: + launchStage: Beta + inheritedRoles: + - name: agreement-documentacceptance-reader + includedPermissions: + - agreement.miloapis.com/documentacceptances.create + - agreement.miloapis.com/documentacceptances.update + - agreement.miloapis.com/documentacceptances.patch + - agreement.miloapis.com/documentacceptances.delete diff --git a/config/roles/agreement-documentacceptance-reader.yaml b/config/roles/agreement-documentacceptance-reader.yaml new file mode 100644 index 00000000..4c5fa92d --- /dev/null +++ b/config/roles/agreement-documentacceptance-reader.yaml @@ -0,0 +1,10 @@ +apiVersion: iam.miloapis.com/v1alpha1 +kind: Role +metadata: + name: agreement-documentacceptance-reader +spec: + launchStage: Beta + includedPermissions: + - agreement.miloapis.com/documentacceptances.get + - agreement.miloapis.com/documentacceptances.list + - agreement.miloapis.com/documentacceptances.watch diff --git a/config/roles/kustomization.yaml b/config/roles/kustomization.yaml index d2a1facf..2b340904 100644 --- a/config/roles/kustomization.yaml +++ b/config/roles/kustomization.yaml @@ -46,3 +46,12 @@ resources: - iam-role-reader.yaml - iam-role-editor.yaml - iam-role-admin.yaml + - documentation-document-reader.yaml + - documentation-document-editor.yaml + - documentation-document-admin.yaml + - documentation-documentrevision-reader.yaml + - documentation-documentrevision-editor.yaml + - documentation-documentrevision-admin.yaml + - agreement-documentacceptance-reader.yaml + - agreement-documentacceptance-editor.yaml + - agreement-documentacceptance-admin.yaml diff --git a/config/samples/documentation/v1alpha1/documentacceptance.yaml b/config/samples/documentation/v1alpha1/documentacceptance.yaml new file mode 100644 index 00000000..28edd843 --- /dev/null +++ b/config/samples/documentation/v1alpha1/documentacceptance.yaml @@ -0,0 +1,27 @@ +apiVersion: documentation.miloapis.com/v1alpha1 +kind: DocumentAcceptance +metadata: + name: sample-document-acceptance + namespace: default +spec: + documentRevisionRef: + name: sample-document-revision + namespace: default + version: v1.0.3 + subjectRef: + apiGroup: resourcemanager.miloapis.com + kind: Project + name: sample-project + namespace: default + accepterRef: + apiGroup: iam.moiloapis.com + kind: User + name: john-doe + acceptanceContext: + method: web + ipAddress: "203.0.113.42" + userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_6_0)" + acceptanceLanguage: en-US + signature: + type: checkbox + timestamp: "2025-10-09T12:00:00Z" diff --git a/internal/webhooks/agreement/v1alpha1/doc.go b/internal/webhooks/agreement/v1alpha1/doc.go new file mode 100644 index 00000000..4cb53305 --- /dev/null +++ b/internal/webhooks/agreement/v1alpha1/doc.go @@ -0,0 +1,4 @@ +package v1alpha1 + +// +kubebuilder:webhookconfiguration:mutating=true,name=agreement.miloapis.com +// +kubebuilder:webhookconfiguration:mutating=false,name=agreement.miloapis.com diff --git a/internal/webhooks/agreement/v1alpha1/documentacceptance_webhook.go b/internal/webhooks/agreement/v1alpha1/documentacceptance_webhook.go new file mode 100644 index 00000000..3bb1b334 --- /dev/null +++ b/internal/webhooks/agreement/v1alpha1/documentacceptance_webhook.go @@ -0,0 +1,126 @@ +package v1alpha1 + +import ( + "context" + "fmt" + "slices" + + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/validation/field" + "sigs.k8s.io/controller-runtime/pkg/client" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + ctrl "sigs.k8s.io/controller-runtime" + + agreementv1alpha1 "go.miloapis.com/milo/pkg/apis/agreement/v1alpha1" + documentationv1alpha1 "go.miloapis.com/milo/pkg/apis/documentation/v1alpha1" + iamv1alpha1 "go.miloapis.com/milo/pkg/apis/iam/v1alpha1" +) + +var daLog = logf.Log.WithName("agreement-resource").WithName("documentacceptance") + +// SetupDocumentAcceptanceWebhooksWithManager sets up the webhooks for the DocumentAcceptance resource. +func SetupDocumentAcceptanceWebhooksWithManager(mgr ctrl.Manager) error { + daLog.Info("Setting up agreement.miloapis.com documentacceptance webhooks") + + return ctrl.NewWebhookManagedBy(mgr). + For(&agreementv1alpha1.DocumentAcceptance{}). + WithValidator(&DocumentAcceptanceValidator{ + Client: mgr.GetClient(), + }). + Complete() +} + +type DocumentAcceptanceValidator struct { + Client client.Client +} + +// +kubebuilder:webhook:path=/validate-agreement-miloapis-com-v1alpha1-documentacceptance,mutating=false,failurePolicy=fail,sideEffects=None,groups=agreement.miloapis.com,resources=documentacceptances,verbs=delete;create,versions=v1alpha1,name=vdocumentacceptance.agreement.miloapis.com,admissionReviewVersions={v1,v1beta1},serviceName=milo-controller-manager,servicePort=9443,serviceNamespace=milo-system + +func (r *DocumentAcceptanceValidator) ValidateCreate(ctx context.Context, obj runtime.Object) (admission.Warnings, error) { + da, ok := obj.(*agreementv1alpha1.DocumentAcceptance) + if !ok { + daLog.Error(fmt.Errorf("failed to cast object to DocumentAcceptance"), "failed to cast object to DocumentAcceptance") + return nil, errors.NewInternalError(fmt.Errorf("failed to cast object to DocumentAcceptance")) + } + daLog.Info("Validating DocumentAcceptance", "name", da.Name) + + var errs field.ErrorList + + // Referenced DocumentRevision must exist + documentRevision := &documentationv1alpha1.DocumentRevision{} + if err := r.Client.Get(ctx, client.ObjectKey{Namespace: da.Spec.DocumentRevisionRef.Namespace, Name: da.Spec.DocumentRevisionRef.Name}, documentRevision); err != nil { + if errors.IsNotFound(err) { + errs = append(errs, field.NotFound(field.NewPath("spec", "documentRevisionRef"), da.Spec.DocumentRevisionRef.Name)) + // Further validations cannot be done with incorrrect document revision + return nil, errors.NewInvalid(agreementv1alpha1.SchemeGroupVersion.WithKind("DocumentAcceptance").GroupKind(), da.Name, errs) + } else { + daLog.Error(err, "failed to get DocumentRevision", "namespace", da.Spec.DocumentRevisionRef.Namespace, "name", da.Spec.DocumentRevisionRef.Name) + return nil, errors.NewInternalError(err) + } + } + + // Validate correct DocumentRevision version + if da.Spec.DocumentRevisionRef.Version != documentRevision.Spec.Version { + errs = append(errs, field.Invalid(field.NewPath("spec", "documentRevisionRef", "version"), da.Spec.DocumentRevisionRef.Version, "documentRevisionRef version must match the referenced document revision version")) + } + + // Validate expected subject kind + daSubjRefKind := &documentationv1alpha1.DocumentRevisionExpectedSubjectKind{ + APIGroup: da.Spec.SubjectRef.APIGroup, + Kind: da.Spec.SubjectRef.Kind, + } + if !slices.Contains(documentRevision.Spec.ExpectedSubjectKinds, *daSubjRefKind) { + errs = append(errs, field.Invalid(field.NewPath("spec", "subjectRef"), da.Spec.SubjectRef, "subjectRef must be one of the expected subject kinds")) + } + + // Validate expected accepter kind + daAccepterRef := da.Spec.AccepterRef + daAccepterKind := &documentationv1alpha1.DocumentRevisionExpectedAccepterKind{ + APIGroup: daAccepterRef.APIGroup, + Kind: daAccepterRef.Kind, + } + if !slices.Contains(documentRevision.Spec.ExpectedAccepterKinds, *daAccepterKind) { + errs = append(errs, field.Invalid(field.NewPath("spec", "accepterRef"), daAccepterRef, "accepterRef must be one of the expected accepter kinds")) + } + + // Validate accepter reference + var accepterObj client.Object + switch daAccepterRef.Kind { + case "User": + accepterObj = &iamv1alpha1.User{} + case "MachineAccount": + accepterObj = &iamv1alpha1.MachineAccount{} + default: + // Should never happen, but just in case + errs = append(errs, field.Invalid(field.NewPath("spec", "accepterRef", "kind"), daAccepterRef.Kind, "missing backend validation for kind")) + } + if err := r.Client.Get(ctx, client.ObjectKey{Name: daAccepterRef.Name, Namespace: daAccepterRef.Namespace}, accepterObj); err != nil { + if errors.IsNotFound(err) { + errs = append(errs, field.NotFound(field.NewPath("spec", "accepterRef", "name"), daAccepterRef.Name)) + } else { + daLog.Error(err, "failed to get accepter", "namespace", daAccepterRef.Namespace, "name", daAccepterRef.Name) + return nil, errors.NewInternalError(err) + } + } + + if len(errs) > 0 { + invalidErr := errors.NewInvalid(agreementv1alpha1.SchemeGroupVersion.WithKind("DocumentAcceptance").GroupKind(), da.Name, errs) + daLog.Error(invalidErr, "invalid document acceptance") + return nil, invalidErr + } + + return nil, nil +} + +// ValidateUpdate implements webhook.Validator so a webhook will be registered for the type +func (r *DocumentAcceptanceValidator) ValidateUpdate(ctx context.Context, oldObj, newObj runtime.Object) (admission.Warnings, error) { + return nil, errors.NewMethodNotSupported(agreementv1alpha1.SchemeGroupVersion.WithResource("documentacceptances").GroupResource(), "update") +} + +// ValidateDelete implements webhook.Validator so a webhook will be registered for the type +func (r *DocumentAcceptanceValidator) ValidateDelete(ctx context.Context, obj runtime.Object) (admission.Warnings, error) { + return nil, errors.NewMethodNotSupported(agreementv1alpha1.SchemeGroupVersion.WithResource("documentacceptances").GroupResource(), "delete") +} diff --git a/internal/webhooks/agreement/v1alpha1/documentacceptance_webhook_test.go b/internal/webhooks/agreement/v1alpha1/documentacceptance_webhook_test.go new file mode 100644 index 00000000..4775a7f3 --- /dev/null +++ b/internal/webhooks/agreement/v1alpha1/documentacceptance_webhook_test.go @@ -0,0 +1,159 @@ +package v1alpha1 + +import ( + "context" + "testing" + "time" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + agreementv1alpha1 "go.miloapis.com/milo/pkg/apis/agreement/v1alpha1" + documentationv1alpha1 "go.miloapis.com/milo/pkg/apis/documentation/v1alpha1" + iamv1alpha1 "go.miloapis.com/milo/pkg/apis/iam/v1alpha1" +) + +func TestDocumentAcceptanceValidator_ValidateCreate(t *testing.T) { + scheme := runtime.NewScheme() + _ = clientgoscheme.AddToScheme(scheme) + _ = agreementv1alpha1.AddToScheme(scheme) + _ = documentationv1alpha1.AddToScheme(scheme) + _ = iamv1alpha1.AddToScheme(scheme) + + now := metav1.Now() + + // Base resources reused across tests + baseRevision := &documentationv1alpha1.DocumentRevision{ + ObjectMeta: metav1.ObjectMeta{ + Name: "tos-v1", + Namespace: "default", + }, + Spec: documentationv1alpha1.DocumentRevisionSpec{ + DocumentRef: documentationv1alpha1.DocumentReference{ + Name: "tos", + Namespace: "default", + }, + Version: "v1.0.0", + EffectiveDate: metav1.Time{Time: now.Add(24 * time.Hour)}, + Content: documentationv1alpha1.DocumentRevisionContent{ + Format: "markdown", + Data: "lorem ipsum", + }, + ChangesSummary: "initial version", + ExpectedSubjectKinds: []documentationv1alpha1.DocumentRevisionExpectedSubjectKind{{APIGroup: "resourcemanager.miloapis.com", Kind: "Organization"}}, + ExpectedAccepterKinds: []documentationv1alpha1.DocumentRevisionExpectedAccepterKind{{APIGroup: "iam.miloapis.com", Kind: "User"}}, + }, + } + + baseUser := &iamv1alpha1.User{ + ObjectMeta: metav1.ObjectMeta{ + Name: "alice", + }, + Spec: iamv1alpha1.UserSpec{Email: "alice@example.com"}, + } + + validAcceptance := &agreementv1alpha1.DocumentAcceptance{ + ObjectMeta: metav1.ObjectMeta{ + Name: "tos-acceptance", + Namespace: "default", + }, + Spec: agreementv1alpha1.DocumentAcceptanceSpec{ + DocumentRevisionRef: documentationv1alpha1.DocumentRevisionReference{ + Name: baseRevision.Name, + Namespace: baseRevision.Namespace, + Version: baseRevision.Spec.Version, + }, + SubjectRef: agreementv1alpha1.ResourceReference{ + APIGroup: "resourcemanager.miloapis.com", + Kind: "Organization", + Name: "acme", + }, + AccepterRef: agreementv1alpha1.ResourceReference{ + APIGroup: "iam.miloapis.com", + Kind: "User", + Name: baseUser.Name, + }, + AcceptanceContext: agreementv1alpha1.DocumentAcceptanceContext{Method: "web"}, + Signature: agreementv1alpha1.DocumentAcceptanceSignature{Type: "checkbox", Timestamp: now}, + }, + } + + tests := []struct { + name string + objects []runtime.Object + da *agreementv1alpha1.DocumentAcceptance + wantError bool + }{ + { + name: "valid acceptance", + objects: []runtime.Object{baseRevision.DeepCopy(), baseUser.DeepCopy()}, + da: validAcceptance.DeepCopy(), + wantError: false, + }, + { + name: "document revision not found", + objects: []runtime.Object{baseUser.DeepCopy()}, + da: validAcceptance.DeepCopy(), + wantError: true, + }, + { + name: "version mismatch", + objects: []runtime.Object{baseRevision.DeepCopy(), baseUser.DeepCopy()}, + da: func() *agreementv1alpha1.DocumentAcceptance { + v := validAcceptance.DeepCopy() + v.Spec.DocumentRevisionRef.Version = "v0.9.0" + return v + }(), + wantError: true, + }, + { + name: "unexpected subject kind", + objects: []runtime.Object{baseRevision.DeepCopy(), baseUser.DeepCopy()}, + da: func() *agreementv1alpha1.DocumentAcceptance { + v := validAcceptance.DeepCopy() + v.Spec.SubjectRef.Kind = "Project" + return v + }(), + wantError: true, + }, + { + name: "unexpected accepter kind", + objects: []runtime.Object{baseRevision.DeepCopy(), baseUser.DeepCopy()}, + da: func() *agreementv1alpha1.DocumentAcceptance { + v := validAcceptance.DeepCopy() + v.Spec.AccepterRef.Kind = "MachineAccount" + return v + }(), + wantError: true, + }, + { + name: "accepter object not found", + objects: []runtime.Object{baseRevision.DeepCopy()}, + da: validAcceptance.DeepCopy(), + wantError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := fake.NewClientBuilder().WithScheme(scheme).WithRuntimeObjects(tt.objects...).Build() + v := &DocumentAcceptanceValidator{Client: c} + _, err := v.ValidateCreate(context.TODO(), tt.da) + if tt.wantError { + if err == nil { + t.Fatalf("expected error, got nil") + } + if !apierrors.IsInvalid(err) { + t.Fatalf("expected admission invalid error, got %v", err) + } + } else { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + } + }) + } +} diff --git a/internal/webhooks/documentation/v1alpha1/doc.go b/internal/webhooks/documentation/v1alpha1/doc.go new file mode 100644 index 00000000..260f0f20 --- /dev/null +++ b/internal/webhooks/documentation/v1alpha1/doc.go @@ -0,0 +1,4 @@ +package v1alpha1 + +// +kubebuilder:webhookconfiguration:mutating=true,name=document.miloapis.com +// +kubebuilder:webhookconfiguration:mutating=false,name=document.miloapis.com diff --git a/pkg/apis/agreement/scheme.go b/pkg/apis/agreement/scheme.go new file mode 100644 index 00000000..cb344017 --- /dev/null +++ b/pkg/apis/agreement/scheme.go @@ -0,0 +1,11 @@ +package agreement + +import ( + "k8s.io/apimachinery/pkg/runtime" + + "go.miloapis.com/milo/pkg/apis/iam/v1alpha1" +) + +func Install(scheme *runtime.Scheme) { + v1alpha1.AddToScheme(scheme) +} diff --git a/pkg/apis/agreement/v1alpha1/doc.go b/pkg/apis/agreement/v1alpha1/doc.go new file mode 100644 index 00000000..25d3b3bf --- /dev/null +++ b/pkg/apis/agreement/v1alpha1/doc.go @@ -0,0 +1,5 @@ +// Package v1alpha1 contains API Schema definitions for the document agreement v1alpha1 API group +// +// +k8s:deepcopy-gen=package,register +// +groupName=agreement.miloapis.com +package v1alpha1 diff --git a/pkg/apis/agreement/v1alpha1/documentacceptance_types.go b/pkg/apis/agreement/v1alpha1/documentacceptance_types.go new file mode 100644 index 00000000..85290f73 --- /dev/null +++ b/pkg/apis/agreement/v1alpha1/documentacceptance_types.go @@ -0,0 +1,130 @@ +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + documentationmiloapiscomv1alpha1 "go.miloapis.com/milo/pkg/apis/documentation/v1alpha1" +) + +// Create conditions +const ( + // DocumentAcceptanceReadyCondition is the condition Type that tracks document acceptance status. + DocumentAcceptanceReadyCondition = "Ready" + // DocumentAcceptanceCreatedReason is used when document creation succeeds. + DocumentAcceptanceCreatedReason = "CreateSuccessful" +) + +// ResourceReference contains information that points to the Resource being referenced. +// +kubebuilder:validation:Type=object +type ResourceReference struct { + // APIGroup is the group for the resource being referenced. + // +kubebuilder:validation:Required + APIGroup string `json:"apiGroup"` + + // Kind is the type of resource being referenced. + // +kubebuilder:validation:Required + Kind string `json:"kind"` + + // Name is the name of the Resource being referenced. + // +kubebuilder:validation:Required + Name string `json:"name"` + + // Namespace is the namespace of the Resource being referenced. + // +kubebuilder:validation:Optional + Namespace string `json:"namespace,omitempty"` +} + +// DocumentAcceptanceContext contains the context of the document acceptance. +// +kubebuilder:validation:Type=object +type DocumentAcceptanceContext struct { + // Method is the method of the document acceptance. + // +kubebuilder:validation:Required + // +kubebuilder:validation:Enum=web;email;cli + Method string `json:"method"` + + // IPAddress is the IP address of the accepter. + // +kubebuilder:validation:Optional + IPAddress string `json:"ipAddress,omitempty"` + + // UserAgent is the user agent of the accepter. + // +kubebuilder:validation:Optional + UserAgent string `json:"userAgent,omitempty"` + + // AcceptanceLanguage is the language of the document acceptance. + // +kubebuilder:validation:Optional + AcceptanceLanguage string `json:"acceptanceLanguage,omitempty"` +} + +// DocumentAcceptanceSignature contains the signature of the document acceptance. +// +kubebuilder:validation:Type=object +type DocumentAcceptanceSignature struct { + // Type specifies the signature mechanism used for the document acceptance. + // +kubebuilder:validation:Required + // +kubebuilder:validation:Enum=checkbox + Type string `json:"type"` + + // Timestamp is the timestamp of the document acceptance. + // +kubebuilder:validation:Required + Timestamp metav1.Time `json:"timestamp"` +} + +// DocumentAcceptanceSpec defines the desired state of DocumentAcceptance. +// +kubebuilder:validation:Type=object +// +kubebuilder:validation:XValidation:rule="self == oldSelf",message="spec is immutable" +type DocumentAcceptanceSpec struct { + // DocumentRevisionRef is a reference to the document revision that is being accepted. + // +kubebuilder:validation:Required + DocumentRevisionRef documentationmiloapiscomv1alpha1.DocumentRevisionReference `json:"documentRevisionRef"` + + // SubjectRef is a reference to the subject that this document acceptance applies to. + // +kubebuilder:validation:Required + SubjectRef ResourceReference `json:"subjectRef"` + + // AccepterRef is a reference to the accepter that this document acceptance applies to. + // +kubebuilder:validation:Required + AccepterRef ResourceReference `json:"accepterRef"` + + // AcceptanceContext is the context of the document acceptance. + // +kubebuilder:validation:Required + AcceptanceContext DocumentAcceptanceContext `json:"acceptanceContext"` + + // Signature is the signature of the document acceptance. + // +kubebuilder:validation:Required + Signature DocumentAcceptanceSignature `json:"signature"` +} + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status + +// DocumentAcceptance is the Schema for the documentacceptances API. +// It represents a document acceptance. +// +kubebuilder:resource:scope=Namespaced +type DocumentAcceptance struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec DocumentAcceptanceSpec `json:"spec,omitempty"` + Status DocumentAcceptanceStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// DocumentAcceptanceList contains a list of DocumentAcceptance. +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type DocumentAcceptanceList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []DocumentAcceptance `json:"items"` +} + +// DocumentAcceptanceStatus defines the observed state of DocumentAcceptance. +// +kubebuilder:validation:Type=object +type DocumentAcceptanceStatus struct { + // Conditions represent the latest available observations of an object's current state. + // +kubebuilder:default={{type: "Ready", status: "Unknown", reason: "Unknown", message: "Waiting for control plane to reconcile", lastTransitionTime: "1970-01-01T00:00:00Z"}} + // +kubebuilder:validation:Optional + // +patchMergeKey=type + // +patchStrategy=merge + Conditions []metav1.Condition `json:"conditions,omitempty"` +} diff --git a/pkg/apis/agreement/v1alpha1/register.go b/pkg/apis/agreement/v1alpha1/register.go new file mode 100644 index 00000000..8659afde --- /dev/null +++ b/pkg/apis/agreement/v1alpha1/register.go @@ -0,0 +1,27 @@ +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +// SchemeGroupVersion is group version used to register these objects. +var SchemeGroupVersion = schema.GroupVersion{Group: "agreement.miloapis.com", Version: "v1alpha1"} + +var ( + // SchemeBuilder is used to add go types to the GroupVersionKind scheme + SchemeBuilder = runtime.NewSchemeBuilder(addKnownTypes) + // AddToScheme allows addition of this group to a scheme + AddToScheme = SchemeBuilder.AddToScheme +) + +// addKnownTypes adds the set of types defined in this package to the supplied scheme. +func addKnownTypes(scheme *runtime.Scheme) error { + scheme.AddKnownTypes(SchemeGroupVersion, + &DocumentAcceptance{}, + &DocumentAcceptanceList{}, + ) + metav1.AddToGroupVersion(scheme, SchemeGroupVersion) + return nil +} From fc148b61700a7b2566e9e5396385cf374f0b3ed5 Mon Sep 17 00:00:00 2001 From: Jose Szychowski Date: Tue, 14 Oct 2025 11:12:20 -0300 Subject: [PATCH 4/9] chore: auto-generate code --- .../controller-manager/controllermanager.go | 11 + ...ment.miloapis.com_documentacceptances.yaml | 230 ++++++ ...tation.miloapis.com_documentrevisions.yaml | 218 ++++++ .../documentation.miloapis.com_documents.yaml | 174 +++++ .../bases/documentation/kustomization.yaml | 4 + config/webhook/manifests.yaml | 65 ++ docs/api/agreement.md | 456 ++++++++++++ docs/api/documentation.md | 698 ++++++++++++++++++ docs/api/identity.md | 4 + .../v1alpha1/zz_generated.deepcopy.go | 157 ++++ .../v1alpha1/zz_generated.deepcopy.go | 327 ++++++++ 11 files changed, 2344 insertions(+) create mode 100644 config/crd/bases/agreement/agreement.miloapis.com_documentacceptances.yaml create mode 100644 config/crd/bases/documentation/documentation.miloapis.com_documentrevisions.yaml create mode 100644 config/crd/bases/documentation/documentation.miloapis.com_documents.yaml create mode 100644 config/crd/bases/documentation/kustomization.yaml create mode 100644 docs/api/agreement.md create mode 100644 docs/api/documentation.md create mode 100644 docs/api/identity.md create mode 100644 pkg/apis/agreement/v1alpha1/zz_generated.deepcopy.go create mode 100644 pkg/apis/documentation/v1alpha1/zz_generated.deepcopy.go diff --git a/cmd/milo/controller-manager/controllermanager.go b/cmd/milo/controller-manager/controllermanager.go index 173bd797..1fbe0ff4 100644 --- a/cmd/milo/controller-manager/controllermanager.go +++ b/cmd/milo/controller-manager/controllermanager.go @@ -77,9 +77,11 @@ import ( remoteapiservicecontroller "go.miloapis.com/milo/internal/controllers/remoteapiservice" resourcemanagercontroller "go.miloapis.com/milo/internal/controllers/resourcemanager" infracluster "go.miloapis.com/milo/internal/infra-cluster" + documentationv1alpha1webhook "go.miloapis.com/milo/internal/webhooks/documentation/v1alpha1" iamv1alpha1webhook "go.miloapis.com/milo/internal/webhooks/iam/v1alpha1" notificationv1alpha1webhook "go.miloapis.com/milo/internal/webhooks/notification/v1alpha1" resourcemanagerv1alpha1webhook "go.miloapis.com/milo/internal/webhooks/resourcemanager/v1alpha1" + documentationv1alpha1 "go.miloapis.com/milo/pkg/apis/documentation/v1alpha1" iamv1alpha1 "go.miloapis.com/milo/pkg/apis/iam/v1alpha1" infrastructurev1alpha1 "go.miloapis.com/milo/pkg/apis/infrastructure/v1alpha1" notificationv1alpha1 "go.miloapis.com/milo/pkg/apis/notification/v1alpha1" @@ -127,6 +129,7 @@ func init() { utilruntime.Must(infrastructurev1alpha1.AddToScheme(Scheme)) utilruntime.Must(iamv1alpha1.AddToScheme(Scheme)) utilruntime.Must(notificationv1alpha1.AddToScheme(Scheme)) + utilruntime.Must(documentationv1alpha1.AddToScheme(Scheme)) utilruntime.Must(apiregistrationv1.AddToScheme(Scheme)) } @@ -448,6 +451,14 @@ func Run(ctx context.Context, c *config.CompletedConfig, opts *Options) error { logger.Error(err, "Error setting up contactgroupmembershipremoval webhook") klog.FlushAndExit(klog.ExitFlushTimeout, 1) } + if err := documentationv1alpha1webhook.SetupDocumentWebhooksWithManager(ctrl); err != nil { + logger.Error(err, "Error setting up document webhook") + klog.FlushAndExit(klog.ExitFlushTimeout, 1) + } + if err := agreementv1alpha1webhook.SetupDocumentAcceptanceWebhooksWithManager(ctrl); err != nil { + logger.Error(err, "Error setting up document acceptance webhook") + klog.FlushAndExit(klog.ExitFlushTimeout, 1) + } projectCtrl := resourcemanagercontroller.ProjectController{ ControlPlaneClient: ctrl.GetClient(), diff --git a/config/crd/bases/agreement/agreement.miloapis.com_documentacceptances.yaml b/config/crd/bases/agreement/agreement.miloapis.com_documentacceptances.yaml new file mode 100644 index 00000000..45e57f72 --- /dev/null +++ b/config/crd/bases/agreement/agreement.miloapis.com_documentacceptances.yaml @@ -0,0 +1,230 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.18.0 + name: documentacceptances.agreement.miloapis.com +spec: + group: agreement.miloapis.com + names: + kind: DocumentAcceptance + listKind: DocumentAcceptanceList + plural: documentacceptances + singular: documentacceptance + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + DocumentAcceptance is the Schema for the documentacceptances API. + It represents a document acceptance. + 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: DocumentAcceptanceSpec defines the desired state of DocumentAcceptance. + properties: + acceptanceContext: + description: AcceptanceContext is the context of the document acceptance. + properties: + acceptanceLanguage: + description: AcceptanceLanguage is the language of the document + acceptance. + type: string + ipAddress: + description: IPAddress is the IP address of the accepter. + type: string + method: + description: Method is the method of the document acceptance. + enum: + - web + - email + - cli + type: string + userAgent: + description: UserAgent is the user agent of the accepter. + type: string + required: + - method + type: object + accepterRef: + description: AccepterRef is a reference to the accepter that this + document acceptance applies to. + properties: + apiGroup: + description: APIGroup is the group for the resource being referenced. + type: string + kind: + description: Kind is the type of resource being referenced. + type: string + name: + description: Name is the name of the Resource being referenced. + type: string + namespace: + description: Namespace is the namespace of the Resource being + referenced. + type: string + required: + - apiGroup + - kind + - name + type: object + documentRevisionRef: + description: DocumentRevisionRef is a reference to the document revision + that is being accepted. + properties: + name: + description: Name is the name of the DocumentRevision being referenced. + type: string + namespace: + description: Namespace of the referenced document revision. + type: string + version: + description: Version is the version of the DocumentRevision being + referenced. + pattern: ^v\d+\.\d+\.\d+$ + type: string + required: + - name + - namespace + - version + type: object + signature: + description: Signature is the signature of the document acceptance. + properties: + timestamp: + description: Timestamp is the timestamp of the document acceptance. + format: date-time + type: string + type: + description: Type specifies the signature mechanism used for the + document acceptance. + enum: + - checkbox + type: string + required: + - timestamp + - type + type: object + subjectRef: + description: SubjectRef is a reference to the subject that this document + acceptance applies to. + properties: + apiGroup: + description: APIGroup is the group for the resource being referenced. + type: string + kind: + description: Kind is the type of resource being referenced. + type: string + name: + description: Name is the name of the Resource being referenced. + type: string + namespace: + description: Namespace is the namespace of the Resource being + referenced. + type: string + required: + - apiGroup + - kind + - name + type: object + required: + - acceptanceContext + - accepterRef + - documentRevisionRef + - signature + - subjectRef + type: object + x-kubernetes-validations: + - message: spec is immutable + rule: self == oldSelf + status: + description: DocumentAcceptanceStatus defines the observed state of DocumentAcceptance. + properties: + conditions: + default: + - lastTransitionTime: "1970-01-01T00:00:00Z" + message: Waiting for control plane to reconcile + reason: Unknown + status: Unknown + type: Ready + description: Conditions represent the latest available observations + of an object's current state. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/bases/documentation/documentation.miloapis.com_documentrevisions.yaml b/config/crd/bases/documentation/documentation.miloapis.com_documentrevisions.yaml new file mode 100644 index 00000000..a6036874 --- /dev/null +++ b/config/crd/bases/documentation/documentation.miloapis.com_documentrevisions.yaml @@ -0,0 +1,218 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.18.0 + name: documentrevisions.documentation.miloapis.com +spec: + group: documentation.miloapis.com + names: + kind: DocumentRevision + listKind: DocumentRevisionList + plural: documentrevisions + singular: documentrevision + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + DocumentRevision is the Schema for the documentrevisions API. + It represents a revision of a document. + 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: DocumentRevisionSpec defines the desired state of DocumentRevision. + properties: + changesSummary: + description: ChangesSummary is the summary of the changes in the document + revision. + type: string + content: + description: Content is the content of the document revision. + properties: + data: + description: Data is the data of the document revision. + type: string + format: + description: Format is the format of the document revision. + enum: + - html + - markdown + type: string + required: + - data + - format + type: object + documentRef: + description: DocumentRef is a reference to the document that this + revision is based on. + properties: + name: + description: Name is the name of the Document being referenced. + type: string + namespace: + description: Namespace of the referenced Document. + type: string + required: + - name + - namespace + type: object + effectiveDate: + description: EffectiveDate is the date in which the document revision + starts to be effective. + format: date-time + type: string + expectedAccepterKinds: + description: ExpectedAccepterKinds is the resource kinds that are + expected to accept this revision. + items: + description: DocumentRevisionExpectedAccepterKind is the kind of + the resource that is expected to accept this revision. + properties: + apiGroup: + description: APIGroup is the group for the resource being referenced. + type: string + x-kubernetes-validations: + - message: apiGroup must be iam.miloapis.com + rule: self == 'iam.miloapis.com' + kind: + description: Kind is the type of resource being referenced. + enum: + - User + - MachineAccount + type: string + required: + - apiGroup + - kind + type: object + type: array + expectedSubjectKinds: + description: ExpectedSubjectKinds is the resource kinds that this + revision affects to. + items: + description: DocumentRevisionExpectedSubjectKind is the kind of + the resource that is expected to reference this revision. + properties: + apiGroup: + description: APIGroup is the group for the resource being referenced. + type: string + kind: + description: Kind is the type of resource being referenced. + type: string + required: + - apiGroup + - kind + type: object + type: array + version: + description: Version is the version of the document revision. + pattern: ^v\d+\.\d+\.\d+$ + type: string + required: + - changesSummary + - content + - documentRef + - effectiveDate + - expectedAccepterKinds + - expectedSubjectKinds + - version + type: object + x-kubernetes-validations: + - message: spec is immutable + rule: self == oldSelf + status: + description: DocumentRevisionStatus defines the observed state of DocumentRevision. + properties: + conditions: + default: + - lastTransitionTime: "1970-01-01T00:00:00Z" + message: Waiting for control plane to reconcile + reason: Unknown + status: Unknown + type: Ready + description: Conditions represent the latest available observations + of an object's current state. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + contentHash: + description: |- + ContentHash is the hash of the content of the document revision. + This is used to detect if the content of the document revision has changed. + type: string + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/bases/documentation/documentation.miloapis.com_documents.yaml b/config/crd/bases/documentation/documentation.miloapis.com_documents.yaml new file mode 100644 index 00000000..a9216d68 --- /dev/null +++ b/config/crd/bases/documentation/documentation.miloapis.com_documents.yaml @@ -0,0 +1,174 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.18.0 + name: documents.documentation.miloapis.com +spec: + group: documentation.miloapis.com + names: + kind: Document + listKind: DocumentList + plural: documents + singular: document + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.title + name: Title + type: string + - jsonPath: .metadata.documentMetadata.category + name: Category + type: string + - jsonPath: .metadata.documentMetadata.jurisdiction + name: Jurisdiction + type: string + - jsonPath: .status.conditions[?(@.type=='Ready')].status + name: Ready + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + Document is the Schema for the documents API. + It represents a document that can be used to create a document revision. + 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 + documentMetadata: + description: DocumentMetadata defines the metadata of the Document. + properties: + category: + description: Category is the category of the Document. + type: string + jurisdiction: + description: Jurisdiction is the jurisdiction of the Document. + type: string + required: + - category + - jurisdiction + type: object + 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: DocumentSpec defines the desired state of Document. + properties: + description: + description: Description is the description of the Document. + type: string + documentType: + description: DocumentType is the type of the document. + type: string + title: + description: Title is the title of the Document. + type: string + required: + - description + - documentType + - title + type: object + status: + description: DocumentStatus defines the observed state of Document. + properties: + conditions: + default: + - lastTransitionTime: "1970-01-01T00:00:00Z" + message: Waiting for control plane to reconcile + reason: Unknown + status: Unknown + type: Ready + description: Conditions represent the latest available observations + of an object's current state. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + latestRevisionRef: + description: LatestRevisionRef is a reference to the latest revision + of the document. + properties: + name: + type: string + namespace: + type: string + publishedAt: + format: date-time + type: string + version: + pattern: ^v\d+\.\d+\.\d+$ + type: string + type: object + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/bases/documentation/kustomization.yaml b/config/crd/bases/documentation/kustomization.yaml new file mode 100644 index 00000000..56c1e14c --- /dev/null +++ b/config/crd/bases/documentation/kustomization.yaml @@ -0,0 +1,4 @@ +resources: + - documentation.miloapis.com_documents.yaml + - documentation.miloapis.com_documentrevisions.yaml + - documentation.miloapis.com_documentacceptances.yaml \ No newline at end of file diff --git a/config/webhook/manifests.yaml b/config/webhook/manifests.yaml index 15a1742b..7ccaa9e7 100644 --- a/config/webhook/manifests.yaml +++ b/config/webhook/manifests.yaml @@ -94,6 +94,71 @@ kind: ValidatingWebhookConfiguration metadata: name: resourcemanager.miloapis.com webhooks: +- admissionReviewVersions: + - v1 + - v1beta1 + clientConfig: + service: + name: milo-controller-manager + namespace: milo-system + path: /validate-agreement-miloapis-com-v1alpha1-documentacceptance + port: 9443 + failurePolicy: Fail + name: vdocumentacceptance.agreement.miloapis.com + rules: + - apiGroups: + - agreement.miloapis.com + apiVersions: + - v1alpha1 + operations: + - DELETE + - CREATE + resources: + - documentacceptances + sideEffects: None +- admissionReviewVersions: + - v1 + - v1beta1 + clientConfig: + service: + name: milo-controller-manager + namespace: milo-system + path: /validate-documentation-miloapis-com-v1alpha1-documentation + port: 9443 + failurePolicy: Fail + name: vdocument.documentation.miloapis.com + rules: + - apiGroups: + - documentation.miloapis.com + apiVersions: + - v1alpha1 + operations: + - DELETE + resources: + - documents + sideEffects: None +- admissionReviewVersions: + - v1 + - v1beta1 + clientConfig: + service: + name: milo-controller-manager + namespace: milo-system + path: /validate-documentation-miloapis-com-v1alpha1-documentation + port: 9443 + failurePolicy: Fail + name: vdocumentrevision.documentation.miloapis.com + rules: + - apiGroups: + - documentation.miloapis.com + apiVersions: + - v1alpha1 + operations: + - DELETE + - CREATE + resources: + - documentrevisions + sideEffects: None - admissionReviewVersions: - v1 - v1beta1 diff --git a/docs/api/agreement.md b/docs/api/agreement.md new file mode 100644 index 00000000..45717dde --- /dev/null +++ b/docs/api/agreement.md @@ -0,0 +1,456 @@ +# API Reference + +Packages: + +- [agreement.miloapis.com/v1alpha1](#agreementmiloapiscomv1alpha1) + +# agreement.miloapis.com/v1alpha1 + +Resource Types: + +- [DocumentAcceptance](#documentacceptance) + + + + +## DocumentAcceptance +[↩ Parent](#agreementmiloapiscomv1alpha1 ) + + + + + + +DocumentAcceptance is the Schema for the documentacceptances API. +It represents a document acceptance. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
apiVersionstringagreement.miloapis.com/v1alpha1true
kindstringDocumentAcceptancetrue
metadataobjectRefer to the Kubernetes API documentation for the fields of the `metadata` field.true
specobject + DocumentAcceptanceSpec defines the desired state of DocumentAcceptance.
+
+ Validations:
  • self == oldSelf: spec is immutable
  • +
    false
    statusobject + DocumentAcceptanceStatus defines the observed state of DocumentAcceptance.
    +
    false
    + + +### DocumentAcceptance.spec +[↩ Parent](#documentacceptance) + + + +DocumentAcceptanceSpec defines the desired state of DocumentAcceptance. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    acceptanceContextobject + AcceptanceContext is the context of the document acceptance.
    +
    true
    accepterRefobject + AccepterRef is a reference to the accepter that this document acceptance applies to.
    +
    true
    documentRevisionRefobject + DocumentRevisionRef is a reference to the document revision that is being accepted.
    +
    true
    signatureobject + Signature is the signature of the document acceptance.
    +
    true
    subjectRefobject + SubjectRef is a reference to the subject that this document acceptance applies to.
    +
    true
    + + +### DocumentAcceptance.spec.acceptanceContext +[↩ Parent](#documentacceptancespec) + + + +AcceptanceContext is the context of the document acceptance. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    methodenum + Method is the method of the document acceptance.
    +
    + Enum: web, email, cli
    +
    true
    acceptanceLanguagestring + AcceptanceLanguage is the language of the document acceptance.
    +
    false
    ipAddressstring + IPAddress is the IP address of the accepter.
    +
    false
    userAgentstring + UserAgent is the user agent of the accepter.
    +
    false
    + + +### DocumentAcceptance.spec.accepterRef +[↩ Parent](#documentacceptancespec) + + + +AccepterRef is a reference to the accepter that this document acceptance applies to. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    apiGroupstring + APIGroup is the group for the resource being referenced.
    +
    true
    kindstring + Kind is the type of resource being referenced.
    +
    true
    namestring + Name is the name of the Resource being referenced.
    +
    true
    namespacestring + Namespace is the namespace of the Resource being referenced.
    +
    false
    + + +### DocumentAcceptance.spec.documentRevisionRef +[↩ Parent](#documentacceptancespec) + + + +DocumentRevisionRef is a reference to the document revision that is being accepted. + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    namestring + Name is the name of the DocumentRevision being referenced.
    +
    true
    namespacestring + Namespace of the referenced document revision.
    +
    true
    versionstring + Version is the version of the DocumentRevision being referenced.
    +
    true
    + + +### DocumentAcceptance.spec.signature +[↩ Parent](#documentacceptancespec) + + + +Signature is the signature of the document acceptance. + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    timestampstring + Timestamp is the timestamp of the document acceptance.
    +
    + Format: date-time
    +
    true
    typeenum + Type specifies the signature mechanism used for the document acceptance.
    +
    + Enum: checkbox
    +
    true
    + + +### DocumentAcceptance.spec.subjectRef +[↩ Parent](#documentacceptancespec) + + + +SubjectRef is a reference to the subject that this document acceptance applies to. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    apiGroupstring + APIGroup is the group for the resource being referenced.
    +
    true
    kindstring + Kind is the type of resource being referenced.
    +
    true
    namestring + Name is the name of the Resource being referenced.
    +
    true
    namespacestring + Namespace is the namespace of the Resource being referenced.
    +
    false
    + + +### DocumentAcceptance.status +[↩ Parent](#documentacceptance) + + + +DocumentAcceptanceStatus defines the observed state of DocumentAcceptance. + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    conditions[]object + Conditions represent the latest available observations of an object's current state.
    +
    + Default: [map[lastTransitionTime:1970-01-01T00:00:00Z message:Waiting for control plane to reconcile reason:Unknown status:Unknown type:Ready]]
    +
    false
    + + +### DocumentAcceptance.status.conditions[index] +[↩ Parent](#documentacceptancestatus) + + + +Condition contains details for one aspect of the current state of this API Resource. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    lastTransitionTimestring + 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
    +
    true
    messagestring + message is a human readable message indicating details about the transition. +This may be an empty string.
    +
    true
    reasonstring + 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.
    +
    true
    statusenum + status of the condition, one of True, False, Unknown.
    +
    + Enum: True, False, Unknown
    +
    true
    typestring + type of condition in CamelCase or in foo.example.com/CamelCase.
    +
    true
    observedGenerationinteger + 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
    +
    false
    diff --git a/docs/api/documentation.md b/docs/api/documentation.md new file mode 100644 index 00000000..f3597069 --- /dev/null +++ b/docs/api/documentation.md @@ -0,0 +1,698 @@ +# API Reference + +Packages: + +- [documentation.miloapis.com/v1alpha1](#documentationmiloapiscomv1alpha1) + +# documentation.miloapis.com/v1alpha1 + +Resource Types: + +- [DocumentRevision](#documentrevision) + +- [Document](#document) + + + + +## DocumentRevision +[↩ Parent](#documentationmiloapiscomv1alpha1 ) + + + + + + +DocumentRevision is the Schema for the documentrevisions API. +It represents a revision of a document. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    apiVersionstringdocumentation.miloapis.com/v1alpha1true
    kindstringDocumentRevisiontrue
    metadataobjectRefer to the Kubernetes API documentation for the fields of the `metadata` field.true
    specobject + DocumentRevisionSpec defines the desired state of DocumentRevision.
    +
    + Validations:
  • self == oldSelf: spec is immutable
  • +
    false
    statusobject + DocumentRevisionStatus defines the observed state of DocumentRevision.
    +
    false
    + + +### DocumentRevision.spec +[↩ Parent](#documentrevision) + + + +DocumentRevisionSpec defines the desired state of DocumentRevision. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    changesSummarystring + ChangesSummary is the summary of the changes in the document revision.
    +
    true
    contentobject + Content is the content of the document revision.
    +
    true
    documentRefobject + DocumentRef is a reference to the document that this revision is based on.
    +
    true
    effectiveDatestring + EffectiveDate is the date in which the document revision starts to be effective.
    +
    + Format: date-time
    +
    true
    expectedAccepterKinds[]object + ExpectedAccepterKinds is the resource kinds that are expected to accept this revision.
    +
    true
    expectedSubjectKinds[]object + ExpectedSubjectKinds is the resource kinds that this revision affects to.
    +
    true
    versionstring + Version is the version of the document revision.
    +
    true
    + + +### DocumentRevision.spec.content +[↩ Parent](#documentrevisionspec) + + + +Content is the content of the document revision. + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    datastring + Data is the data of the document revision.
    +
    true
    formatenum + Format is the format of the document revision.
    +
    + Enum: html, markdown
    +
    true
    + + +### DocumentRevision.spec.documentRef +[↩ Parent](#documentrevisionspec) + + + +DocumentRef is a reference to the document that this revision is based on. + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    namestring + Name is the name of the Document being referenced.
    +
    true
    namespacestring + Namespace of the referenced Document.
    +
    true
    + + +### DocumentRevision.spec.expectedAccepterKinds[index] +[↩ Parent](#documentrevisionspec) + + + +DocumentRevisionExpectedAccepterKind is the kind of the resource that is expected to accept this revision. + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    apiGroupstring + APIGroup is the group for the resource being referenced.
    +
    + Validations:
  • self == 'iam.miloapis.com': apiGroup must be iam.miloapis.com
  • +
    true
    kindenum + Kind is the type of resource being referenced.
    +
    + Enum: User, MachineAccount
    +
    true
    + + +### DocumentRevision.spec.expectedSubjectKinds[index] +[↩ Parent](#documentrevisionspec) + + + +DocumentRevisionExpectedSubjectKind is the kind of the resource that is expected to reference this revision. + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    apiGroupstring + APIGroup is the group for the resource being referenced.
    +
    true
    kindstring + Kind is the type of resource being referenced.
    +
    true
    + + +### DocumentRevision.status +[↩ Parent](#documentrevision) + + + +DocumentRevisionStatus defines the observed state of DocumentRevision. + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    conditions[]object + Conditions represent the latest available observations of an object's current state.
    +
    + Default: [map[lastTransitionTime:1970-01-01T00:00:00Z message:Waiting for control plane to reconcile reason:Unknown status:Unknown type:Ready]]
    +
    false
    contentHashstring + ContentHash is the hash of the content of the document revision. +This is used to detect if the content of the document revision has changed.
    +
    false
    + + +### DocumentRevision.status.conditions[index] +[↩ Parent](#documentrevisionstatus) + + + +Condition contains details for one aspect of the current state of this API Resource. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    lastTransitionTimestring + 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
    +
    true
    messagestring + message is a human readable message indicating details about the transition. +This may be an empty string.
    +
    true
    reasonstring + 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.
    +
    true
    statusenum + status of the condition, one of True, False, Unknown.
    +
    + Enum: True, False, Unknown
    +
    true
    typestring + type of condition in CamelCase or in foo.example.com/CamelCase.
    +
    true
    observedGenerationinteger + 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
    +
    false
    + +## Document +[↩ Parent](#documentationmiloapiscomv1alpha1 ) + + + + + + +Document is the Schema for the documents API. +It represents a document that can be used to create a document revision. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    apiVersionstringdocumentation.miloapis.com/v1alpha1true
    kindstringDocumenttrue
    metadataobjectRefer to the Kubernetes API documentation for the fields of the `metadata` field.true
    documentMetadataobject + DocumentMetadata defines the metadata of the Document.
    +
    false
    specobject + DocumentSpec defines the desired state of Document.
    +
    false
    statusobject + DocumentStatus defines the observed state of Document.
    +
    false
    + + +### Document.documentMetadata +[↩ Parent](#document) + + + +DocumentMetadata defines the metadata of the Document. + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    categorystring + Category is the category of the Document.
    +
    true
    jurisdictionstring + Jurisdiction is the jurisdiction of the Document.
    +
    true
    + + +### Document.spec +[↩ Parent](#document) + + + +DocumentSpec defines the desired state of Document. + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    descriptionstring + Description is the description of the Document.
    +
    true
    documentTypestring + DocumentType is the type of the document.
    +
    true
    titlestring + Title is the title of the Document.
    +
    true
    + + +### Document.status +[↩ Parent](#document) + + + +DocumentStatus defines the observed state of Document. + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    conditions[]object + Conditions represent the latest available observations of an object's current state.
    +
    + Default: [map[lastTransitionTime:1970-01-01T00:00:00Z message:Waiting for control plane to reconcile reason:Unknown status:Unknown type:Ready]]
    +
    false
    latestRevisionRefobject + LatestRevisionRef is a reference to the latest revision of the document.
    +
    false
    + + +### Document.status.conditions[index] +[↩ Parent](#documentstatus) + + + +Condition contains details for one aspect of the current state of this API Resource. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    lastTransitionTimestring + 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
    +
    true
    messagestring + message is a human readable message indicating details about the transition. +This may be an empty string.
    +
    true
    reasonstring + 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.
    +
    true
    statusenum + status of the condition, one of True, False, Unknown.
    +
    + Enum: True, False, Unknown
    +
    true
    typestring + type of condition in CamelCase or in foo.example.com/CamelCase.
    +
    true
    observedGenerationinteger + 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
    +
    false
    + + +### Document.status.latestRevisionRef +[↩ Parent](#documentstatus) + + + +LatestRevisionRef is a reference to the latest revision of the document. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    namestring +
    +
    false
    namespacestring +
    +
    false
    publishedAtstring +
    +
    + Format: date-time
    +
    false
    versionstring +
    +
    false
    diff --git a/docs/api/identity.md b/docs/api/identity.md new file mode 100644 index 00000000..8155b4bf --- /dev/null +++ b/docs/api/identity.md @@ -0,0 +1,4 @@ +# API Reference + +Packages: + diff --git a/pkg/apis/agreement/v1alpha1/zz_generated.deepcopy.go b/pkg/apis/agreement/v1alpha1/zz_generated.deepcopy.go new file mode 100644 index 00000000..fa9ce011 --- /dev/null +++ b/pkg/apis/agreement/v1alpha1/zz_generated.deepcopy.go @@ -0,0 +1,157 @@ +//go:build !ignore_autogenerated + +// Code generated by controller-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DocumentAcceptance) DeepCopyInto(out *DocumentAcceptance) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DocumentAcceptance. +func (in *DocumentAcceptance) DeepCopy() *DocumentAcceptance { + if in == nil { + return nil + } + out := new(DocumentAcceptance) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *DocumentAcceptance) 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 *DocumentAcceptanceContext) DeepCopyInto(out *DocumentAcceptanceContext) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DocumentAcceptanceContext. +func (in *DocumentAcceptanceContext) DeepCopy() *DocumentAcceptanceContext { + if in == nil { + return nil + } + out := new(DocumentAcceptanceContext) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DocumentAcceptanceList) DeepCopyInto(out *DocumentAcceptanceList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]DocumentAcceptance, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DocumentAcceptanceList. +func (in *DocumentAcceptanceList) DeepCopy() *DocumentAcceptanceList { + if in == nil { + return nil + } + out := new(DocumentAcceptanceList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *DocumentAcceptanceList) 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 *DocumentAcceptanceSignature) DeepCopyInto(out *DocumentAcceptanceSignature) { + *out = *in + in.Timestamp.DeepCopyInto(&out.Timestamp) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DocumentAcceptanceSignature. +func (in *DocumentAcceptanceSignature) DeepCopy() *DocumentAcceptanceSignature { + if in == nil { + return nil + } + out := new(DocumentAcceptanceSignature) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DocumentAcceptanceSpec) DeepCopyInto(out *DocumentAcceptanceSpec) { + *out = *in + out.DocumentRevisionRef = in.DocumentRevisionRef + out.SubjectRef = in.SubjectRef + out.AccepterRef = in.AccepterRef + out.AcceptanceContext = in.AcceptanceContext + in.Signature.DeepCopyInto(&out.Signature) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DocumentAcceptanceSpec. +func (in *DocumentAcceptanceSpec) DeepCopy() *DocumentAcceptanceSpec { + if in == nil { + return nil + } + out := new(DocumentAcceptanceSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DocumentAcceptanceStatus) DeepCopyInto(out *DocumentAcceptanceStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DocumentAcceptanceStatus. +func (in *DocumentAcceptanceStatus) DeepCopy() *DocumentAcceptanceStatus { + if in == nil { + return nil + } + out := new(DocumentAcceptanceStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ResourceReference) DeepCopyInto(out *ResourceReference) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ResourceReference. +func (in *ResourceReference) DeepCopy() *ResourceReference { + if in == nil { + return nil + } + out := new(ResourceReference) + in.DeepCopyInto(out) + return out +} diff --git a/pkg/apis/documentation/v1alpha1/zz_generated.deepcopy.go b/pkg/apis/documentation/v1alpha1/zz_generated.deepcopy.go new file mode 100644 index 00000000..259d00f6 --- /dev/null +++ b/pkg/apis/documentation/v1alpha1/zz_generated.deepcopy.go @@ -0,0 +1,327 @@ +//go:build !ignore_autogenerated + +// Code generated by controller-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Document) DeepCopyInto(out *Document) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.Spec = in.Spec + out.Metadata = in.Metadata + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Document. +func (in *Document) DeepCopy() *Document { + if in == nil { + return nil + } + out := new(Document) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *Document) 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 *DocumentList) DeepCopyInto(out *DocumentList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]Document, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DocumentList. +func (in *DocumentList) DeepCopy() *DocumentList { + if in == nil { + return nil + } + out := new(DocumentList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *DocumentList) 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 *DocumentMetadata) DeepCopyInto(out *DocumentMetadata) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DocumentMetadata. +func (in *DocumentMetadata) DeepCopy() *DocumentMetadata { + if in == nil { + return nil + } + out := new(DocumentMetadata) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DocumentReference) DeepCopyInto(out *DocumentReference) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DocumentReference. +func (in *DocumentReference) DeepCopy() *DocumentReference { + if in == nil { + return nil + } + out := new(DocumentReference) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DocumentRevision) DeepCopyInto(out *DocumentRevision) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DocumentRevision. +func (in *DocumentRevision) DeepCopy() *DocumentRevision { + if in == nil { + return nil + } + out := new(DocumentRevision) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *DocumentRevision) 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 *DocumentRevisionContent) DeepCopyInto(out *DocumentRevisionContent) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DocumentRevisionContent. +func (in *DocumentRevisionContent) DeepCopy() *DocumentRevisionContent { + if in == nil { + return nil + } + out := new(DocumentRevisionContent) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DocumentRevisionExpectedAccepterKind) DeepCopyInto(out *DocumentRevisionExpectedAccepterKind) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DocumentRevisionExpectedAccepterKind. +func (in *DocumentRevisionExpectedAccepterKind) DeepCopy() *DocumentRevisionExpectedAccepterKind { + if in == nil { + return nil + } + out := new(DocumentRevisionExpectedAccepterKind) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DocumentRevisionExpectedSubjectKind) DeepCopyInto(out *DocumentRevisionExpectedSubjectKind) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DocumentRevisionExpectedSubjectKind. +func (in *DocumentRevisionExpectedSubjectKind) DeepCopy() *DocumentRevisionExpectedSubjectKind { + if in == nil { + return nil + } + out := new(DocumentRevisionExpectedSubjectKind) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DocumentRevisionList) DeepCopyInto(out *DocumentRevisionList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]DocumentRevision, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DocumentRevisionList. +func (in *DocumentRevisionList) DeepCopy() *DocumentRevisionList { + if in == nil { + return nil + } + out := new(DocumentRevisionList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *DocumentRevisionList) 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 *DocumentRevisionReference) DeepCopyInto(out *DocumentRevisionReference) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DocumentRevisionReference. +func (in *DocumentRevisionReference) DeepCopy() *DocumentRevisionReference { + if in == nil { + return nil + } + out := new(DocumentRevisionReference) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DocumentRevisionSpec) DeepCopyInto(out *DocumentRevisionSpec) { + *out = *in + out.DocumentRef = in.DocumentRef + out.Content = in.Content + in.EffectiveDate.DeepCopyInto(&out.EffectiveDate) + if in.ExpectedSubjectKinds != nil { + in, out := &in.ExpectedSubjectKinds, &out.ExpectedSubjectKinds + *out = make([]DocumentRevisionExpectedSubjectKind, len(*in)) + copy(*out, *in) + } + if in.ExpectedAccepterKinds != nil { + in, out := &in.ExpectedAccepterKinds, &out.ExpectedAccepterKinds + *out = make([]DocumentRevisionExpectedAccepterKind, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DocumentRevisionSpec. +func (in *DocumentRevisionSpec) DeepCopy() *DocumentRevisionSpec { + if in == nil { + return nil + } + out := new(DocumentRevisionSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DocumentRevisionStatus) DeepCopyInto(out *DocumentRevisionStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DocumentRevisionStatus. +func (in *DocumentRevisionStatus) DeepCopy() *DocumentRevisionStatus { + if in == nil { + return nil + } + out := new(DocumentRevisionStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DocumentSpec) DeepCopyInto(out *DocumentSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DocumentSpec. +func (in *DocumentSpec) DeepCopy() *DocumentSpec { + if in == nil { + return nil + } + out := new(DocumentSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DocumentStatus) DeepCopyInto(out *DocumentStatus) { + *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.LatestRevisionRef != nil { + in, out := &in.LatestRevisionRef, &out.LatestRevisionRef + *out = new(LatestRevisionRef) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DocumentStatus. +func (in *DocumentStatus) DeepCopy() *DocumentStatus { + if in == nil { + return nil + } + out := new(DocumentStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LatestRevisionRef) DeepCopyInto(out *LatestRevisionRef) { + *out = *in + in.PublishedAt.DeepCopyInto(&out.PublishedAt) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LatestRevisionRef. +func (in *LatestRevisionRef) DeepCopy() *LatestRevisionRef { + if in == nil { + return nil + } + out := new(LatestRevisionRef) + in.DeepCopyInto(out) + return out +} From 987f85eace5b93ce822b09760ac0eca345b065f3 Mon Sep 17 00:00:00 2001 From: Jose Szychowski Date: Tue, 14 Oct 2025 11:13:20 -0300 Subject: [PATCH 5/9] feat: add DocumentRevision webhook setup to controller manager --- cmd/milo/controller-manager/controllermanager.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/cmd/milo/controller-manager/controllermanager.go b/cmd/milo/controller-manager/controllermanager.go index 1fbe0ff4..09f660e6 100644 --- a/cmd/milo/controller-manager/controllermanager.go +++ b/cmd/milo/controller-manager/controllermanager.go @@ -16,6 +16,7 @@ import ( "github.com/blang/semver/v4" "github.com/spf13/cobra" + agreementv1alpha1webhook "go.miloapis.com/milo/internal/webhooks/agreement/v1alpha1" coordinationv1 "k8s.io/api/coordination/v1" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/meta" @@ -455,6 +456,10 @@ func Run(ctx context.Context, c *config.CompletedConfig, opts *Options) error { logger.Error(err, "Error setting up document webhook") klog.FlushAndExit(klog.ExitFlushTimeout, 1) } + if err := documentationv1alpha1webhook.SetupDocumentRevisionWebhooksWithManager(ctrl); err != nil { + logger.Error(err, "Error setting up document revision webhook") + klog.FlushAndExit(klog.ExitFlushTimeout, 1) + } if err := agreementv1alpha1webhook.SetupDocumentAcceptanceWebhooksWithManager(ctrl); err != nil { logger.Error(err, "Error setting up document acceptance webhook") klog.FlushAndExit(klog.ExitFlushTimeout, 1) From 99e135e27c140932fb5b9ed151922310da6cf391 Mon Sep 17 00:00:00 2001 From: Jose Szychowski Date: Wed, 15 Oct 2025 10:26:55 -0300 Subject: [PATCH 6/9] fixt: add kustomization for DocumentAcceptance resource and remove from documentation kustomization --- config/crd/bases/agreement/kustomization.yaml | 3 +++ config/crd/bases/documentation/kustomization.yaml | 1 - 2 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 config/crd/bases/agreement/kustomization.yaml diff --git a/config/crd/bases/agreement/kustomization.yaml b/config/crd/bases/agreement/kustomization.yaml new file mode 100644 index 00000000..db9699a5 --- /dev/null +++ b/config/crd/bases/agreement/kustomization.yaml @@ -0,0 +1,3 @@ +resources: + - agreement.miloapis.com_documentacceptances.yaml + \ No newline at end of file diff --git a/config/crd/bases/documentation/kustomization.yaml b/config/crd/bases/documentation/kustomization.yaml index 56c1e14c..d4e279d1 100644 --- a/config/crd/bases/documentation/kustomization.yaml +++ b/config/crd/bases/documentation/kustomization.yaml @@ -1,4 +1,3 @@ resources: - documentation.miloapis.com_documents.yaml - documentation.miloapis.com_documentrevisions.yaml - - documentation.miloapis.com_documentacceptances.yaml \ No newline at end of file From ab3de60840e643c117bd41b66c2c06b0b595e103 Mon Sep 17 00:00:00 2001 From: Jose Szychowski Date: Wed, 15 Oct 2025 11:34:13 -0300 Subject: [PATCH 7/9] feat: enhance DocumentRevision validation with new resource types and new document revision constraints --- ...tation.miloapis.com_documentrevisions.yaml | 4 ++ docs/api/documentation.md | 8 ++- .../v1alpha1/documentacceptance_webhook.go | 64 +++++++++++++------ .../documentacceptance_webhook_test.go | 23 +++++-- .../v1alpha1/documentrevision_types.go | 2 + 5 files changed, 75 insertions(+), 26 deletions(-) diff --git a/config/crd/bases/documentation/documentation.miloapis.com_documentrevisions.yaml b/config/crd/bases/documentation/documentation.miloapis.com_documentrevisions.yaml index a6036874..364b5bca 100644 --- a/config/crd/bases/documentation/documentation.miloapis.com_documentrevisions.yaml +++ b/config/crd/bases/documentation/documentation.miloapis.com_documentrevisions.yaml @@ -113,9 +113,13 @@ spec: properties: apiGroup: description: APIGroup is the group for the resource being referenced. + enum: + - resourcemanager.miloapis.com type: string kind: description: Kind is the type of resource being referenced. + enum: + - Organization type: string required: - apiGroup diff --git a/docs/api/documentation.md b/docs/api/documentation.md index f3597069..8f913299 100644 --- a/docs/api/documentation.md +++ b/docs/api/documentation.md @@ -269,16 +269,20 @@ DocumentRevisionExpectedSubjectKind is the kind of the resource that is expected apiGroup - string + enum APIGroup is the group for the resource being referenced.
    +
    + Enum: resourcemanager.miloapis.com
    true kind - string + enum Kind is the type of resource being referenced.
    +
    + Enum: Organization
    true diff --git a/internal/webhooks/agreement/v1alpha1/documentacceptance_webhook.go b/internal/webhooks/agreement/v1alpha1/documentacceptance_webhook.go index 3bb1b334..af3c1595 100644 --- a/internal/webhooks/agreement/v1alpha1/documentacceptance_webhook.go +++ b/internal/webhooks/agreement/v1alpha1/documentacceptance_webhook.go @@ -17,6 +17,7 @@ import ( agreementv1alpha1 "go.miloapis.com/milo/pkg/apis/agreement/v1alpha1" documentationv1alpha1 "go.miloapis.com/milo/pkg/apis/documentation/v1alpha1" iamv1alpha1 "go.miloapis.com/milo/pkg/apis/iam/v1alpha1" + resourcemanagerv1alpha1 "go.miloapis.com/milo/pkg/apis/resourcemanager/v1alpha1" ) var daLog = logf.Log.WithName("agreement-resource").WithName("documentacceptance") @@ -68,12 +69,35 @@ func (r *DocumentAcceptanceValidator) ValidateCreate(ctx context.Context, obj ru } // Validate expected subject kind + daSubjectRef := da.Spec.SubjectRef daSubjRefKind := &documentationv1alpha1.DocumentRevisionExpectedSubjectKind{ APIGroup: da.Spec.SubjectRef.APIGroup, Kind: da.Spec.SubjectRef.Kind, } if !slices.Contains(documentRevision.Spec.ExpectedSubjectKinds, *daSubjRefKind) { errs = append(errs, field.Invalid(field.NewPath("spec", "subjectRef"), da.Spec.SubjectRef, "subjectRef must be one of the expected subject kinds")) + } else { + // If the expected kind is validated, validate the subject reference + if daSubjRefKind.APIGroup == "resourcemanager.miloapis.com" { + var subjectObj client.Object + switch daSubjRefKind.Kind { + case "Organization": + subjectObj = &resourcemanagerv1alpha1.Organization{} + default: + // Should never happen, but just in case + errs = append(errs, field.Invalid(field.NewPath("spec", "subjectRef", "kind"), daSubjRefKind.Kind, "missing backend validation for kind")) + } + if err := r.Client.Get(ctx, client.ObjectKey{Name: daSubjectRef.Name, Namespace: daSubjectRef.Namespace}, subjectObj); err != nil { + if errors.IsNotFound(err) { + errs = append(errs, field.NotFound(field.NewPath("spec", "subjectRef", "name"), daSubjectRef.Name)) + } else { + daLog.Error(err, "failed to get subject reference", "namespace", daSubjectRef.Namespace, "name", daSubjectRef.Name) + return nil, errors.NewInternalError(err) + } + } + } else { + errs = append(errs, field.Invalid(field.NewPath("spec", "subjectRef", "apiGroup"), daSubjRefKind.APIGroup, "missing backend validation for apiGroup")) + } } // Validate expected accepter kind @@ -84,25 +108,29 @@ func (r *DocumentAcceptanceValidator) ValidateCreate(ctx context.Context, obj ru } if !slices.Contains(documentRevision.Spec.ExpectedAccepterKinds, *daAccepterKind) { errs = append(errs, field.Invalid(field.NewPath("spec", "accepterRef"), daAccepterRef, "accepterRef must be one of the expected accepter kinds")) - } - - // Validate accepter reference - var accepterObj client.Object - switch daAccepterRef.Kind { - case "User": - accepterObj = &iamv1alpha1.User{} - case "MachineAccount": - accepterObj = &iamv1alpha1.MachineAccount{} - default: - // Should never happen, but just in case - errs = append(errs, field.Invalid(field.NewPath("spec", "accepterRef", "kind"), daAccepterRef.Kind, "missing backend validation for kind")) - } - if err := r.Client.Get(ctx, client.ObjectKey{Name: daAccepterRef.Name, Namespace: daAccepterRef.Namespace}, accepterObj); err != nil { - if errors.IsNotFound(err) { - errs = append(errs, field.NotFound(field.NewPath("spec", "accepterRef", "name"), daAccepterRef.Name)) + } else { + // If the expected kind is validated, validate the accepter reference + if daAccepterRef.APIGroup == "iam.miloapis.com" { + var accepterObj client.Object + switch daAccepterRef.Kind { + case "User": + accepterObj = &iamv1alpha1.User{} + case "MachineAccount": + accepterObj = &iamv1alpha1.MachineAccount{} + default: + // Should never happen, but just in case + errs = append(errs, field.Invalid(field.NewPath("spec", "accepterRef", "kind"), daAccepterRef.Kind, "missing backend validation for kind")) + } + if err := r.Client.Get(ctx, client.ObjectKey{Name: daAccepterRef.Name, Namespace: daAccepterRef.Namespace}, accepterObj); err != nil { + if errors.IsNotFound(err) { + errs = append(errs, field.NotFound(field.NewPath("spec", "accepterRef", "name"), daAccepterRef.Name)) + } else { + daLog.Error(err, "failed to get accepter", "namespace", daAccepterRef.Namespace, "name", daAccepterRef.Name) + return nil, errors.NewInternalError(err) + } + } } else { - daLog.Error(err, "failed to get accepter", "namespace", daAccepterRef.Namespace, "name", daAccepterRef.Name) - return nil, errors.NewInternalError(err) + errs = append(errs, field.Invalid(field.NewPath("spec", "accepterRef", "apiGroup"), daAccepterRef.APIGroup, "missing backend validation for apiGroup")) } } diff --git a/internal/webhooks/agreement/v1alpha1/documentacceptance_webhook_test.go b/internal/webhooks/agreement/v1alpha1/documentacceptance_webhook_test.go index 4775a7f3..b132d405 100644 --- a/internal/webhooks/agreement/v1alpha1/documentacceptance_webhook_test.go +++ b/internal/webhooks/agreement/v1alpha1/documentacceptance_webhook_test.go @@ -14,6 +14,7 @@ import ( agreementv1alpha1 "go.miloapis.com/milo/pkg/apis/agreement/v1alpha1" documentationv1alpha1 "go.miloapis.com/milo/pkg/apis/documentation/v1alpha1" iamv1alpha1 "go.miloapis.com/milo/pkg/apis/iam/v1alpha1" + resourcemanagerv1alpha1 "go.miloapis.com/milo/pkg/apis/resourcemanager/v1alpha1" ) func TestDocumentAcceptanceValidator_ValidateCreate(t *testing.T) { @@ -22,6 +23,7 @@ func TestDocumentAcceptanceValidator_ValidateCreate(t *testing.T) { _ = agreementv1alpha1.AddToScheme(scheme) _ = documentationv1alpha1.AddToScheme(scheme) _ = iamv1alpha1.AddToScheme(scheme) + _ = resourcemanagerv1alpha1.AddToScheme(scheme) now := metav1.Now() @@ -55,6 +57,15 @@ func TestDocumentAcceptanceValidator_ValidateCreate(t *testing.T) { Spec: iamv1alpha1.UserSpec{Email: "alice@example.com"}, } + baseOrg := &resourcemanagerv1alpha1.Organization{ + ObjectMeta: metav1.ObjectMeta{ + Name: "acme", + }, + Spec: resourcemanagerv1alpha1.OrganizationSpec{ + Type: "Standard", + }, + } + validAcceptance := &agreementv1alpha1.DocumentAcceptance{ ObjectMeta: metav1.ObjectMeta{ Name: "tos-acceptance", @@ -89,19 +100,19 @@ func TestDocumentAcceptanceValidator_ValidateCreate(t *testing.T) { }{ { name: "valid acceptance", - objects: []runtime.Object{baseRevision.DeepCopy(), baseUser.DeepCopy()}, + objects: []runtime.Object{baseRevision.DeepCopy(), baseUser.DeepCopy(), baseOrg.DeepCopy()}, da: validAcceptance.DeepCopy(), wantError: false, }, { name: "document revision not found", - objects: []runtime.Object{baseUser.DeepCopy()}, + objects: []runtime.Object{baseUser.DeepCopy(), baseOrg.DeepCopy()}, da: validAcceptance.DeepCopy(), wantError: true, }, { name: "version mismatch", - objects: []runtime.Object{baseRevision.DeepCopy(), baseUser.DeepCopy()}, + objects: []runtime.Object{baseRevision.DeepCopy(), baseUser.DeepCopy(), baseOrg.DeepCopy()}, da: func() *agreementv1alpha1.DocumentAcceptance { v := validAcceptance.DeepCopy() v.Spec.DocumentRevisionRef.Version = "v0.9.0" @@ -111,7 +122,7 @@ func TestDocumentAcceptanceValidator_ValidateCreate(t *testing.T) { }, { name: "unexpected subject kind", - objects: []runtime.Object{baseRevision.DeepCopy(), baseUser.DeepCopy()}, + objects: []runtime.Object{baseRevision.DeepCopy(), baseUser.DeepCopy(), baseOrg.DeepCopy()}, da: func() *agreementv1alpha1.DocumentAcceptance { v := validAcceptance.DeepCopy() v.Spec.SubjectRef.Kind = "Project" @@ -121,7 +132,7 @@ func TestDocumentAcceptanceValidator_ValidateCreate(t *testing.T) { }, { name: "unexpected accepter kind", - objects: []runtime.Object{baseRevision.DeepCopy(), baseUser.DeepCopy()}, + objects: []runtime.Object{baseRevision.DeepCopy(), baseUser.DeepCopy(), baseOrg.DeepCopy()}, da: func() *agreementv1alpha1.DocumentAcceptance { v := validAcceptance.DeepCopy() v.Spec.AccepterRef.Kind = "MachineAccount" @@ -131,7 +142,7 @@ func TestDocumentAcceptanceValidator_ValidateCreate(t *testing.T) { }, { name: "accepter object not found", - objects: []runtime.Object{baseRevision.DeepCopy()}, + objects: []runtime.Object{baseRevision.DeepCopy(), baseOrg.DeepCopy()}, da: validAcceptance.DeepCopy(), wantError: true, }, diff --git a/pkg/apis/documentation/v1alpha1/documentrevision_types.go b/pkg/apis/documentation/v1alpha1/documentrevision_types.go index c01c719b..be16f11a 100644 --- a/pkg/apis/documentation/v1alpha1/documentrevision_types.go +++ b/pkg/apis/documentation/v1alpha1/documentrevision_types.go @@ -42,10 +42,12 @@ type DocumentRevisionContent struct { type DocumentRevisionExpectedSubjectKind struct { // APIGroup is the group for the resource being referenced. // +kubebuilder:validation:Required + // +kubebuilder:validation:Enum=resourcemanager.miloapis.com APIGroup string `json:"apiGroup"` // Kind is the type of resource being referenced. // +kubebuilder:validation:Required + // +kubebuilder:validation:Enum=Organization Kind string `json:"kind"` } From 278c716e6b929f53679a6668a1d3231b622b5330 Mon Sep 17 00:00:00 2001 From: Jose Szychowski Date: Thu, 16 Oct 2025 09:50:19 -0300 Subject: [PATCH 8/9] feat: implement duplicate DocumentAcceptance validation and indexing --- .../v1alpha1/documentacceptance_webhook.go | 33 ++++++++++++++ .../documentacceptance_webhook_test.go | 43 ++++++++++++++++++- 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/internal/webhooks/agreement/v1alpha1/documentacceptance_webhook.go b/internal/webhooks/agreement/v1alpha1/documentacceptance_webhook.go index af3c1595..0e71e4d6 100644 --- a/internal/webhooks/agreement/v1alpha1/documentacceptance_webhook.go +++ b/internal/webhooks/agreement/v1alpha1/documentacceptance_webhook.go @@ -22,10 +22,29 @@ import ( var daLog = logf.Log.WithName("agreement-resource").WithName("documentacceptance") +// daIndexKey is the key used to index DocumentAcceptance by .spec.documentRevisionRef and .spec.subjectRef +const daIndexKey = "agreement.miloapis.com/documentacceptance-index" + +// buildDaIndexKey returns the composite key used for indexing DocumentAcceptance by .spec.documentRevisionRef and .spec.subjectRef +func buildDaIndexKey(da agreementv1alpha1.DocumentAcceptance) string { + return fmt.Sprintf("%s|%s|%s|%s|%s|%s|%s", + da.Spec.DocumentRevisionRef.Name, da.Spec.DocumentRevisionRef.Namespace, da.Spec.DocumentRevisionRef.Version, + da.Spec.SubjectRef.Name, da.Spec.SubjectRef.Namespace, da.Spec.SubjectRef.APIGroup, da.Spec.SubjectRef.Kind) +} + // SetupDocumentAcceptanceWebhooksWithManager sets up the webhooks for the DocumentAcceptance resource. func SetupDocumentAcceptanceWebhooksWithManager(mgr ctrl.Manager) error { daLog.Info("Setting up agreement.miloapis.com documentacceptance webhooks") + if err := mgr.GetFieldIndexer().IndexField(context.Background(), + &agreementv1alpha1.DocumentAcceptance{}, daIndexKey, + func(obj client.Object) []string { + da := obj.(*agreementv1alpha1.DocumentAcceptance) + return []string{buildDaIndexKey(*da)} + }); err != nil { + return fmt.Errorf("failed to set field index on DocumentAcceptance by .spec.documentRevisionRef and .spec.subjectRef: %w", err) + } + return ctrl.NewWebhookManagedBy(mgr). For(&agreementv1alpha1.DocumentAcceptance{}). WithValidator(&DocumentAcceptanceValidator{ @@ -50,6 +69,20 @@ func (r *DocumentAcceptanceValidator) ValidateCreate(ctx context.Context, obj ru var errs field.ErrorList + // Check if DocumentAcceptance already exists + existing := &agreementv1alpha1.DocumentAcceptanceList{} + if err := r.Client.List(ctx, existing, + client.MatchingFields{daIndexKey: buildDaIndexKey(*da)}); err != nil { + return nil, errors.NewInternalError(err) + } + if len(existing.Items) > 0 { + errs = append(errs, field.Duplicate( + field.NewPath("spec"), + "a DocumentAcceptance with the same documentRevisionRef and subjectRef already exists", + )) + return nil, errors.NewInvalid(agreementv1alpha1.SchemeGroupVersion.WithKind("DocumentAcceptance").GroupKind(), da.Name, errs) + } + // Referenced DocumentRevision must exist documentRevision := &documentationv1alpha1.DocumentRevision{} if err := r.Client.Get(ctx, client.ObjectKey{Namespace: da.Spec.DocumentRevisionRef.Namespace, Name: da.Spec.DocumentRevisionRef.Name}, documentRevision); err != nil { diff --git a/internal/webhooks/agreement/v1alpha1/documentacceptance_webhook_test.go b/internal/webhooks/agreement/v1alpha1/documentacceptance_webhook_test.go index b132d405..fbe81339 100644 --- a/internal/webhooks/agreement/v1alpha1/documentacceptance_webhook_test.go +++ b/internal/webhooks/agreement/v1alpha1/documentacceptance_webhook_test.go @@ -5,10 +5,13 @@ import ( "testing" "time" + "regexp" + apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" agreementv1alpha1 "go.miloapis.com/milo/pkg/apis/agreement/v1alpha1" @@ -97,6 +100,7 @@ func TestDocumentAcceptanceValidator_ValidateCreate(t *testing.T) { objects []runtime.Object da *agreementv1alpha1.DocumentAcceptance wantError bool + errRegex string }{ { name: "valid acceptance", @@ -109,6 +113,7 @@ func TestDocumentAcceptanceValidator_ValidateCreate(t *testing.T) { objects: []runtime.Object{baseUser.DeepCopy(), baseOrg.DeepCopy()}, da: validAcceptance.DeepCopy(), wantError: true, + errRegex: "spec.documentRevisionRef", }, { name: "version mismatch", @@ -119,6 +124,7 @@ func TestDocumentAcceptanceValidator_ValidateCreate(t *testing.T) { return v }(), wantError: true, + errRegex: "spec.documentRevisionRef.version", }, { name: "unexpected subject kind", @@ -129,6 +135,7 @@ func TestDocumentAcceptanceValidator_ValidateCreate(t *testing.T) { return v }(), wantError: true, + errRegex: "spec.subjectRef", }, { name: "unexpected accepter kind", @@ -139,18 +146,47 @@ func TestDocumentAcceptanceValidator_ValidateCreate(t *testing.T) { return v }(), wantError: true, + errRegex: "spec.accepterRef", + }, + { + name: "duplicate acceptance", + objects: func() []runtime.Object { + existing := validAcceptance.DeepCopy() + existing.ObjectMeta = metav1.ObjectMeta{ // ensure distinct name but same spec + Name: "tos-acceptance-existing", + Namespace: "default", + } + return []runtime.Object{baseRevision.DeepCopy(), baseUser.DeepCopy(), baseOrg.DeepCopy(), existing} + }(), + da: func() *agreementv1alpha1.DocumentAcceptance { + dup := validAcceptance.DeepCopy() + dup.ObjectMeta = metav1.ObjectMeta{ + Name: "tos-acceptance-dup", + Namespace: "default", + } + return dup + }(), + wantError: true, + errRegex: "same documentRevisionRef and subjectRef already exists", }, { name: "accepter object not found", objects: []runtime.Object{baseRevision.DeepCopy(), baseOrg.DeepCopy()}, da: validAcceptance.DeepCopy(), wantError: true, + errRegex: "spec.accepterRef.name", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - c := fake.NewClientBuilder().WithScheme(scheme).WithRuntimeObjects(tt.objects...).Build() + builder := fake.NewClientBuilder().WithScheme(scheme).WithRuntimeObjects(tt.objects...) + // register the same index defined in production code + builder = builder.WithIndex(&agreementv1alpha1.DocumentAcceptance{}, daIndexKey, func(obj client.Object) []string { + da := obj.(*agreementv1alpha1.DocumentAcceptance) + return []string{buildDaIndexKey(*da)} + }) + c := builder.Build() v := &DocumentAcceptanceValidator{Client: c} _, err := v.ValidateCreate(context.TODO(), tt.da) if tt.wantError { @@ -160,6 +196,11 @@ func TestDocumentAcceptanceValidator_ValidateCreate(t *testing.T) { if !apierrors.IsInvalid(err) { t.Fatalf("expected admission invalid error, got %v", err) } + if tt.errRegex != "" { + if !regexp.MustCompile(tt.errRegex).MatchString(err.Error()) { + t.Fatalf("error message %q did not match %q", err.Error(), tt.errRegex) + } + } } else { if err != nil { t.Fatalf("unexpected error: %v", err) From e805b83d3d68fa17c51eea39e7392418a46b3c80 Mon Sep 17 00:00:00 2001 From: Jose Szychowski Date: Thu, 16 Oct 2025 12:51:59 -0300 Subject: [PATCH 9/9] fix: update webhook configuration group name from 'document' to 'documentation' --- internal/webhooks/documentation/v1alpha1/doc.go | 4 ++-- pkg/apis/documentation/v1alpha1/register.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/webhooks/documentation/v1alpha1/doc.go b/internal/webhooks/documentation/v1alpha1/doc.go index 260f0f20..1b027010 100644 --- a/internal/webhooks/documentation/v1alpha1/doc.go +++ b/internal/webhooks/documentation/v1alpha1/doc.go @@ -1,4 +1,4 @@ package v1alpha1 -// +kubebuilder:webhookconfiguration:mutating=true,name=document.miloapis.com -// +kubebuilder:webhookconfiguration:mutating=false,name=document.miloapis.com +// +kubebuilder:webhookconfiguration:mutating=true,name=documentation.miloapis.com +// +kubebuilder:webhookconfiguration:mutating=false,name=documentation.miloapis.com diff --git a/pkg/apis/documentation/v1alpha1/register.go b/pkg/apis/documentation/v1alpha1/register.go index 929f924c..c3815ee3 100644 --- a/pkg/apis/documentation/v1alpha1/register.go +++ b/pkg/apis/documentation/v1alpha1/register.go @@ -7,7 +7,7 @@ import ( ) // SchemeGroupVersion is group version used to register these objects. -var SchemeGroupVersion = schema.GroupVersion{Group: "document.miloapis.com", Version: "v1alpha1"} +var SchemeGroupVersion = schema.GroupVersion{Group: "documentation.miloapis.com", Version: "v1alpha1"} var ( // SchemeBuilder is used to add go types to the GroupVersionKind scheme