diff --git a/cmd/milo/controller-manager/controllermanager.go b/cmd/milo/controller-manager/controllermanager.go index 09f660e6..62b94c39 100644 --- a/cmd/milo/controller-manager/controllermanager.go +++ b/cmd/milo/controller-manager/controllermanager.go @@ -74,6 +74,7 @@ import ( // Datum webhook and API type imports controlplane "go.miloapis.com/milo/internal/control-plane" + documentationcontroller "go.miloapis.com/milo/internal/controllers/documentation" iamcontroller "go.miloapis.com/milo/internal/controllers/iam" remoteapiservicecontroller "go.miloapis.com/milo/internal/controllers/remoteapiservice" resourcemanagercontroller "go.miloapis.com/milo/internal/controllers/resourcemanager" @@ -82,6 +83,7 @@ import ( 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" + 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" infrastructurev1alpha1 "go.miloapis.com/milo/pkg/apis/infrastructure/v1alpha1" @@ -132,6 +134,7 @@ func init() { utilruntime.Must(notificationv1alpha1.AddToScheme(Scheme)) utilruntime.Must(documentationv1alpha1.AddToScheme(Scheme)) utilruntime.Must(apiregistrationv1.AddToScheme(Scheme)) + utilruntime.Must(agreementv1alpha1.AddToScheme(Scheme)) } const ( @@ -506,6 +509,22 @@ func Run(ctx context.Context, c *config.CompletedConfig, opts *Options) error { klog.FlushAndExit(klog.ExitFlushTimeout, 1) } + documentCtrl := documentationcontroller.DocumentController{ + Client: ctrl.GetClient(), + } + if err := documentCtrl.SetupWithManager(ctrl); err != nil { + logger.Error(err, "Error setting up document controller") + klog.FlushAndExit(klog.ExitFlushTimeout, 1) + } + + documentRevisionCtrl := documentationcontroller.DocumentRevisionController{ + Client: ctrl.GetClient(), + } + if err := documentRevisionCtrl.SetupWithManager(ctrl); err != nil { + logger.Error(err, "Error setting up document revision controller") + klog.FlushAndExit(klog.ExitFlushTimeout, 1) + } + userInvitationCtrl := iamcontroller.UserInvitationController{ Client: ctrl.GetClient(), SystemNamespace: SystemNamespace, diff --git a/config/controller-manager/overlays/core-control-plane/rbac/role.yaml b/config/controller-manager/overlays/core-control-plane/rbac/role.yaml index acfa9d3c..2e1a4ff6 100644 --- a/config/controller-manager/overlays/core-control-plane/rbac/role.yaml +++ b/config/controller-manager/overlays/core-control-plane/rbac/role.yaml @@ -31,6 +31,23 @@ rules: verbs: - patch - update +- apiGroups: + - documentation.miloapis.com + resources: + - documentrevisions + - documents + verbs: + - get + - list + - watch +- apiGroups: + - documentation.miloapis.com + resources: + - documentrevisions/status + - documents/status + verbs: + - patch + - update - apiGroups: - gateway.networking.k8s.io resources: diff --git a/config/crd/overlays/core-control-plane/kustomization.yaml b/config/crd/overlays/core-control-plane/kustomization.yaml index 8ae8e5bf..aafb11d1 100644 --- a/config/crd/overlays/core-control-plane/kustomization.yaml +++ b/config/crd/overlays/core-control-plane/kustomization.yaml @@ -2,3 +2,5 @@ resources: - ../../bases/iam/ - ../../bases/resourcemanager/ - ../../bases/notification/ +- ../../bases/agreement/ +- ../../bases/documentation/ diff --git a/config/webhook/manifests.yaml b/config/webhook/manifests.yaml index 7ccaa9e7..a21b6b11 100644 --- a/config/webhook/manifests.yaml +++ b/config/webhook/manifests.yaml @@ -123,7 +123,7 @@ webhooks: service: name: milo-controller-manager namespace: milo-system - path: /validate-documentation-miloapis-com-v1alpha1-documentation + path: /validate-documentation-miloapis-com-v1alpha1-document port: 9443 failurePolicy: Fail name: vdocument.documentation.miloapis.com @@ -144,7 +144,7 @@ webhooks: service: name: milo-controller-manager namespace: milo-system - path: /validate-documentation-miloapis-com-v1alpha1-documentation + path: /validate-documentation-miloapis-com-v1alpha1-documentrevision port: 9443 failurePolicy: Fail name: vdocumentrevision.documentation.miloapis.com diff --git a/internal/controllers/documentation/document_controller.go b/internal/controllers/documentation/document_controller.go new file mode 100644 index 00000000..89ed5629 --- /dev/null +++ b/internal/controllers/documentation/document_controller.go @@ -0,0 +1,188 @@ +package documents + +import ( + "context" + "fmt" + + "k8s.io/apimachinery/pkg/api/equality" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/finalizer" + "sigs.k8s.io/controller-runtime/pkg/handler" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/predicate" + + docversion "go.miloapis.com/milo/pkg/version" + + documentationv1alpha1 "go.miloapis.com/milo/pkg/apis/documentation/v1alpha1" +) + +const ( + documentRefNamespacedKey = "documentation.miloapis.com/documentnamespacedkey" +) + +func buildDocumentRevisionByDocumentIndexKey(docRef documentationv1alpha1.DocumentReference) string { + return fmt.Sprintf("%s|%s", docRef.Name, docRef.Namespace) +} + +// DocumentController reconciles a Document object +type DocumentController struct { + Client client.Client + Finalizers finalizer.Finalizers +} + +// +kubebuilder:rbac:groups=documentation.miloapis.com,resources=documents,verbs=get;list;watch +// +kubebuilder:rbac:groups=documentation.miloapis.com,resources=documents/status,verbs=update;patch +// +kubebuilder:rbac:groups=documentation.miloapis.com,resources=documentrevisions,verbs=get;list;watch + +func (r *DocumentController) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + log := logf.FromContext(ctx).WithValues("controller", "DocumentController", "trigger", req.NamespacedName) + log.Info("Starting reconciliation", "namespacedName", req.String(), "name", req.Name, "namespace", req.Namespace) + + // Get document + var document documentationv1alpha1.Document + if err := r.Client.Get(ctx, req.NamespacedName, &document); err != nil { + if errors.IsNotFound(err) { + log.Info("Document not found. Probably deleted.") + return ctrl.Result{}, nil + } + log.Error(err, "failed to get document") + return ctrl.Result{}, err + } + oldStatus := document.Status.DeepCopy() + + // Get document revisions + var documentRevisions documentationv1alpha1.DocumentRevisionList + if err := r.Client.List(ctx, &documentRevisions, + client.MatchingFields{ + documentRefNamespacedKey: buildDocumentRevisionByDocumentIndexKey( + documentationv1alpha1.DocumentReference{Name: document.Name, Namespace: document.Namespace})}); err != nil { + log.Error(err, "failed to get document revisions") + return ctrl.Result{}, err + } + // Verify if there is at least one revision + revisionFound := false + if len(documentRevisions.Items) > 0 { + revisionFound = true + } + + // Update document status + meta.SetStatusCondition(&document.Status.Conditions, metav1.Condition{ + Type: "Ready", + Status: metav1.ConditionTrue, + Reason: "Reconciled", + Message: "Document reconciled", + ObservedGeneration: document.Generation, + }) + if revisionFound { + log.Info("Revision found. Updating latest revision status reference") + latestRevision, err := GetLatestDocumentRevision(documentRevisions) + if err != nil { + log.Error(err, "failed to get latest document revision") + return ctrl.Result{}, err + } + // Update latest revision status reference + document.Status.LatestRevisionRef = &documentationv1alpha1.LatestRevisionRef{ + Name: latestRevision.Name, + Namespace: latestRevision.Namespace, + Version: latestRevision.Spec.Version, + PublishedAt: latestRevision.Spec.EffectiveDate, + } + } else { + log.Info("No revision found. Updating latest revision status reference to nil") + document.Status.LatestRevisionRef = nil + } + // Update document status onlyif it changed + if !equality.Semantic.DeepEqual(oldStatus, &document.Status) { + if err := r.Client.Status().Update(ctx, &document); err != nil { + log.Error(err, "Failed to update document status") + return ctrl.Result{}, fmt.Errorf("failed to update document status: %w", err) + } + } else { + log.V(1).Info("Document status unchanged, skipping update") + } + + log.Info("Document reconciled") + + return ctrl.Result{}, nil +} + +// GetLatestDocumentRevision returns the latest document revision from the list of document revisions. +func GetLatestDocumentRevision(documentRevisions documentationv1alpha1.DocumentRevisionList) (documentationv1alpha1.DocumentRevision, error) { + latestRevision := documentRevisions.Items[0] + for _, revision := range documentRevisions.Items { + isHigher, err := docversion.IsVersionHigher(revision.Spec.Version, latestRevision.Spec.Version) + if err != nil { + return documentationv1alpha1.DocumentRevision{}, err + } + if isHigher { + latestRevision = revision + } + } + + return latestRevision, nil +} + +// enqueueDocumentForDocumentRevisionCreate enqueues the referenced document for the document revision create event. +// This is used to ensure that the document status is updated when a document revision is created. +func (r *DocumentController) enqueueDocumentForDocumentRevisionCreate(ctx context.Context, obj client.Object) []ctrl.Request { + log := logf.FromContext(ctx).WithValues("controller", "DocumentController", "trigger", obj.GetName()) + log.Info("Enqueuing document for document revision create") + dr, ok := obj.(*documentationv1alpha1.DocumentRevision) + if !ok { + log.Error(fmt.Errorf("failed to cast object to DocumentRevision"), "failed to cast object to DocumentRevision") + return nil + } + + referencedDocument := &documentationv1alpha1.Document{} + if err := r.Client.Get(ctx, client.ObjectKey{Namespace: dr.Spec.DocumentRef.Namespace, Name: dr.Spec.DocumentRef.Name}, referencedDocument); err != nil { + // Document must exists, as webhook validates it + log.Error(err, "failed to get referenced document", "namespace", dr.Spec.DocumentRef.Namespace, "name", dr.Spec.DocumentRef.Name) + return nil + } + log.V(1).Info("Referenced document found. Enqueuing document", "namespace", referencedDocument.Namespace, "name", referencedDocument.Name) + + return []ctrl.Request{ + { + NamespacedName: types.NamespacedName{ + Name: referencedDocument.Name, + Namespace: referencedDocument.Namespace, + }, + }, + } +} + +func (r *DocumentController) SetupWithManager(mgr ctrl.Manager) error { + // Index DocumentRevision by documentref namespaced key for efficient lookups + if err := mgr.GetFieldIndexer().IndexField(context.Background(), + &documentationv1alpha1.DocumentRevision{}, documentRefNamespacedKey, func(obj client.Object) []string { + dr, ok := obj.(*documentationv1alpha1.DocumentRevision) + if !ok { + return nil + } + return []string{buildDocumentRevisionByDocumentIndexKey(dr.Spec.DocumentRef)} + }); err != nil { + return fmt.Errorf("failed to set field index on DocumentRevision: %w", err) + } + + return ctrl.NewControllerManagedBy(mgr). + For(&documentationv1alpha1.Document{}). + Watches( + &documentationv1alpha1.DocumentRevision{}, + handler.EnqueueRequestsFromMapFunc(r.enqueueDocumentForDocumentRevisionCreate), + builder.WithPredicates(predicate.Funcs{ + CreateFunc: func(e event.CreateEvent) bool { return true }, + DeleteFunc: func(e event.DeleteEvent) bool { return false }, + GenericFunc: func(e event.GenericEvent) bool { return false }, + UpdateFunc: func(e event.UpdateEvent) bool { return false }, + }), + ). + Named("document"). + Complete(r) +} diff --git a/internal/controllers/documentation/document_controller_test.go b/internal/controllers/documentation/document_controller_test.go new file mode 100644 index 00000000..7009a32d --- /dev/null +++ b/internal/controllers/documentation/document_controller_test.go @@ -0,0 +1,151 @@ +package documents + +import ( + "context" + "testing" + "time" + + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/finalizer" + + documentationv1alpha1 "go.miloapis.com/milo/pkg/apis/documentation/v1alpha1" +) + +func TestDocumentController_Reconcile_LatestRevisionRefUpdated(t *testing.T) { + scheme := runtime.NewScheme() + if err := clientgoscheme.AddToScheme(scheme); err != nil { + t.Fatalf("failed to add client-go scheme: %v", err) + } + if err := documentationv1alpha1.AddToScheme(scheme); err != nil { + t.Fatalf("failed to add documentation scheme: %v", err) + } + + // Build fake client that supports status subresource updates + fakeClient := fake.NewClientBuilder().WithScheme(scheme). + WithStatusSubresource(&documentationv1alpha1.Document{}). + WithIndex(&documentationv1alpha1.DocumentRevision{}, documentRefNamespacedKey, func(obj client.Object) []string { + dr, ok := obj.(*documentationv1alpha1.DocumentRevision) + if !ok { + return nil + } + return []string{buildDocumentRevisionByDocumentIndexKey(dr.Spec.DocumentRef)} + }). + Build() + + ctx := context.TODO() + + // Create a sample Document in the fake cluster + doc := &documentationv1alpha1.Document{ + ObjectMeta: metav1.ObjectMeta{ + Name: "sample-document", + Namespace: "default", + }, + Spec: documentationv1alpha1.DocumentSpec{ + Title: "TOS", + Description: "Terms of Service", + DocumentType: "tos", + }, + Metadata: documentationv1alpha1.DocumentMetadata{ + Category: "legal", + Jurisdiction: "us", + }, + } + if err := fakeClient.Create(ctx, doc); err != nil { + t.Fatalf("failed to create document: %v", err) + } + + r := &DocumentController{ + Client: fakeClient, + Finalizers: finalizer.NewFinalizers(), + } + + req := ctrl.Request{NamespacedName: client.ObjectKey{Name: doc.Name, Namespace: doc.Namespace}} + + // First reconcile: no revisions exist yet -> LatestRevisionRef should stay nil + if _, err := r.Reconcile(ctx, req); err != nil { + t.Fatalf("reconcile failed: %v", err) + } + if err := fakeClient.Get(ctx, req.NamespacedName, doc); err != nil { + t.Fatalf("failed to get document after reconcile: %v", err) + } + if doc.Status.LatestRevisionRef != nil { + t.Fatalf("expected LatestRevisionRef to be nil, got %v", doc.Status.LatestRevisionRef) + } + + // Verify Ready condition is present and set to True with reason Reconciled + cond := meta.FindStatusCondition(doc.Status.Conditions, "Ready") + if cond == nil { + t.Fatalf("expected Ready condition to be present") + } + if cond.Status != metav1.ConditionTrue || cond.Reason != "Reconciled" { + t.Fatalf("unexpected Ready condition: %+v", cond) + } + + // Create first DocumentRevision v1.0.0 + rev1 := &documentationv1alpha1.DocumentRevision{ + ObjectMeta: metav1.ObjectMeta{ + Name: "sample-document-v1", + Namespace: "default", + }, + Spec: documentationv1alpha1.DocumentRevisionSpec{ + DocumentRef: documentationv1alpha1.DocumentReference{Name: doc.Name, Namespace: doc.Namespace}, + Version: "v1.0.0", + EffectiveDate: metav1.Time{Time: time.Now()}, + Content: documentationv1alpha1.DocumentRevisionContent{Format: "markdown", Data: "initial content"}, + ChangesSummary: "initial version", + ExpectedSubjectKinds: []documentationv1alpha1.DocumentRevisionExpectedSubjectKind{{APIGroup: "", Kind: ""}}, + ExpectedAccepterKinds: []documentationv1alpha1.DocumentRevisionExpectedAccepterKind{{APIGroup: "iam.miloapis.com", Kind: "User"}}, + }, + } + if err := fakeClient.Create(ctx, rev1); err != nil { + t.Fatalf("failed to create document revision 1: %v", err) + } + + // Reconcile again -> LatestRevisionRef should be updated to v1.0.0 + if _, err := r.Reconcile(ctx, req); err != nil { + t.Fatalf("reconcile failed: %v", err) + } + if err := fakeClient.Get(ctx, req.NamespacedName, doc); err != nil { + t.Fatalf("failed to get document after reconcile 2: %v", err) + } + if doc.Status.LatestRevisionRef == nil || string(doc.Status.LatestRevisionRef.Version) != "v1.0.0" { + t.Fatalf("expected LatestRevisionRef version v1.0.0, got %v", doc.Status.LatestRevisionRef) + } + + // Create second DocumentRevision v1.1.0 (higher) + rev2 := &documentationv1alpha1.DocumentRevision{ + ObjectMeta: metav1.ObjectMeta{ + Name: "sample-document-v2", + Namespace: "default", + }, + Spec: documentationv1alpha1.DocumentRevisionSpec{ + DocumentRef: documentationv1alpha1.DocumentReference{Name: doc.Name, Namespace: doc.Namespace}, + Version: "v1.1.0", + EffectiveDate: metav1.Time{Time: time.Now()}, + Content: documentationv1alpha1.DocumentRevisionContent{Format: "markdown", Data: "updated content"}, + ChangesSummary: "update", + ExpectedSubjectKinds: []documentationv1alpha1.DocumentRevisionExpectedSubjectKind{{APIGroup: "", Kind: ""}}, + ExpectedAccepterKinds: []documentationv1alpha1.DocumentRevisionExpectedAccepterKind{{APIGroup: "iam.miloapis.com", Kind: "User"}}, + }, + } + if err := fakeClient.Create(ctx, rev2); err != nil { + t.Fatalf("failed to create document revision 2: %v", err) + } + + // Reconcile again -> LatestRevisionRef should be updated to v1.1.0 + if _, err := r.Reconcile(ctx, req); err != nil { + t.Fatalf("reconcile failed: %v", err) + } + if err := fakeClient.Get(ctx, req.NamespacedName, doc); err != nil { + t.Fatalf("failed to get document after reconcile 3: %v", err) + } + if doc.Status.LatestRevisionRef == nil || string(doc.Status.LatestRevisionRef.Version) != "v1.1.0" { + t.Fatalf("expected LatestRevisionRef version v1.1.0, got %v", doc.Status.LatestRevisionRef.Version) + } +} diff --git a/internal/controllers/documentation/documentrevision_controller.go b/internal/controllers/documentation/documentrevision_controller.go new file mode 100644 index 00000000..d57ddc28 --- /dev/null +++ b/internal/controllers/documentation/documentrevision_controller.go @@ -0,0 +1,72 @@ +package documents + +import ( + "context" + "fmt" + + documentationv1alpha1 "go.miloapis.com/milo/pkg/apis/documentation/v1alpha1" + "go.miloapis.com/milo/pkg/util/hash" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + logf "sigs.k8s.io/controller-runtime/pkg/log" +) + +// DocumentRevisionController reconciles a DocumentRevision object +type DocumentRevisionController struct { + Client client.Client +} + +// +kubebuilder:rbac:groups=documentation.miloapis.com,resources=documentrevisions,verbs=get;list;watch +// +kubebuilder:rbac:groups=documentation.miloapis.com,resources=documentrevisions/status,verbs=update;patch + +func (r *DocumentRevisionController) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + log := logf.FromContext(ctx).WithValues("controller", "DocumentRevisionController", "trigger", req.NamespacedName) + log.Info("Starting reconciliation", "namespacedName", req.String(), "name", req.Name, "namespace", req.Namespace) + + // Get document revision + var documentRevision documentationv1alpha1.DocumentRevision + if err := r.Client.Get(ctx, req.NamespacedName, &documentRevision); err != nil { + if errors.IsNotFound(err) { + log.Info("Document revision not found. Probably deleted.") + return ctrl.Result{}, nil + } + log.Error(err, "failed to get document revision") + return ctrl.Result{}, err + } + + // DocumentRevision does not allows updates to its status, as the hash is used + // to detect if the content of the document revision has changed. + if meta.IsStatusConditionTrue(documentRevision.Status.Conditions, "Ready") { + log.Info("Document revision already reconciled") + return ctrl.Result{}, nil + } + + documentRevision.Status.ContentHash = hash.SHA256Hex(documentRevision.Spec.Content.Data) + + // Update document revision status + meta.SetStatusCondition(&documentRevision.Status.Conditions, metav1.Condition{ + Type: "Ready", + Status: metav1.ConditionTrue, + Reason: "Reconciled", + Message: "Document revision reconciled", + ObservedGeneration: documentRevision.Generation, + }) + if err := r.Client.Status().Update(ctx, &documentRevision); err != nil { + log.Error(err, "Failed to update document revision status") + return ctrl.Result{}, fmt.Errorf("failed to update document revision status: %w", err) + } + + log.Info("Document revision reconciled") + + return ctrl.Result{}, nil +} + +func (r *DocumentRevisionController) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&documentationv1alpha1.DocumentRevision{}). + Named("documentrevision"). + Complete(r) +} diff --git a/internal/controllers/documentation/documentrevision_controller_test.go b/internal/controllers/documentation/documentrevision_controller_test.go new file mode 100644 index 00000000..0891d7ef --- /dev/null +++ b/internal/controllers/documentation/documentrevision_controller_test.go @@ -0,0 +1,98 @@ +package documents + +import ( + "context" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + documentationv1alpha1 "go.miloapis.com/milo/pkg/apis/documentation/v1alpha1" + "go.miloapis.com/milo/pkg/util/hash" +) + +func TestDocumentRevisionController_Reconcile_HashBehaviour(t *testing.T) { + scheme := runtime.NewScheme() + if err := clientgoscheme.AddToScheme(scheme); err != nil { + t.Fatalf("failed to add client-go scheme: %v", err) + } + if err := documentationv1alpha1.AddToScheme(scheme); err != nil { + t.Fatalf("failed to add documentation scheme: %v", err) + } + + // Build fake client that supports status subresource updates + fakeClient := fake.NewClientBuilder().WithScheme(scheme). + WithStatusSubresource(&documentationv1alpha1.DocumentRevision{}). + Build() + + ctx := context.TODO() + + // Create a sample Document in the fake cluster (needed to satisfy reference, controller doesn't use it) + doc := &documentationv1alpha1.Document{ + ObjectMeta: metav1.ObjectMeta{Name: "sample-document", Namespace: "default"}, + Spec: documentationv1alpha1.DocumentSpec{ + Title: "TOS", Description: "Terms", DocumentType: "tos", + }, + Metadata: documentationv1alpha1.DocumentMetadata{Category: "legal", Jurisdiction: "us"}, + } + if err := fakeClient.Create(ctx, doc); err != nil { + t.Fatalf("failed to create document: %v", err) + } + + // Initial content + initialContent := "initial content" + + rev := &documentationv1alpha1.DocumentRevision{ + ObjectMeta: metav1.ObjectMeta{Name: "sample-document-v1", Namespace: "default"}, + Spec: documentationv1alpha1.DocumentRevisionSpec{ + DocumentRef: documentationv1alpha1.DocumentReference{Name: doc.Name, Namespace: doc.Namespace}, + Version: "v1.0.0", + EffectiveDate: metav1.Now(), + Content: documentationv1alpha1.DocumentRevisionContent{Format: "markdown", Data: initialContent}, + ChangesSummary: "initial", + ExpectedSubjectKinds: []documentationv1alpha1.DocumentRevisionExpectedSubjectKind{{APIGroup: "", Kind: ""}}, + ExpectedAccepterKinds: []documentationv1alpha1.DocumentRevisionExpectedAccepterKind{{APIGroup: "iam.miloapis.com", Kind: "User"}}, + }, + } + if err := fakeClient.Create(ctx, rev); err != nil { + t.Fatalf("failed to create document revision: %v", err) + } + + r := &DocumentRevisionController{Client: fakeClient} + + req := ctrl.Request{NamespacedName: client.ObjectKey{Name: rev.Name, Namespace: rev.Namespace}} + + // First reconcile should calculate and persist hash + if _, err := r.Reconcile(ctx, req); err != nil { + t.Fatalf("reconcile failed: %v", err) + } + if err := fakeClient.Get(ctx, req.NamespacedName, rev); err != nil { + t.Fatalf("failed to get revision after reconcile: %v", err) + } + expectedHash := hash.SHA256Hex(initialContent) + if rev.Status.ContentHash != expectedHash { + t.Fatalf("expected content hash %s, got %s", expectedHash, rev.Status.ContentHash) + } + + // Modify spec content (controller should ignore because Ready=True) + updatedContent := "modified content" + rev.Spec.Content.Data = updatedContent + if err := fakeClient.Update(ctx, rev); err != nil { + t.Fatalf("failed to update revision content: %v", err) + } + + // Reconcile again - hash should remain unchanged + if _, err := r.Reconcile(ctx, req); err != nil { + t.Fatalf("reconcile 2 failed: %v", err) + } + if err := fakeClient.Get(ctx, req.NamespacedName, rev); err != nil { + t.Fatalf("failed to get revision after reconcile 2: %v", err) + } + if rev.Status.ContentHash != expectedHash { + t.Fatalf("content hash changed unexpectedly: expected %s got %s", expectedHash, rev.Status.ContentHash) + } +} diff --git a/internal/webhooks/documentation/v1alpha1/document_webhook.go b/internal/webhooks/documentation/v1alpha1/document_webhook.go index 7a631420..9840676c 100644 --- a/internal/webhooks/documentation/v1alpha1/document_webhook.go +++ b/internal/webhooks/documentation/v1alpha1/document_webhook.go @@ -29,7 +29,7 @@ func SetupDocumentWebhooksWithManager(mgr ctrl.Manager) error { 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 +// +kubebuilder:webhook:path=/validate-documentation-miloapis-com-v1alpha1-document,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 diff --git a/internal/webhooks/documentation/v1alpha1/documentrevision_webhook.go b/internal/webhooks/documentation/v1alpha1/documentrevision_webhook.go index 0ff2d1f6..b6097b72 100644 --- a/internal/webhooks/documentation/v1alpha1/documentrevision_webhook.go +++ b/internal/webhooks/documentation/v1alpha1/documentrevision_webhook.go @@ -33,7 +33,7 @@ func SetupDocumentRevisionWebhooksWithManager(mgr ctrl.Manager) error { 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 +// +kubebuilder:webhook:path=/validate-documentation-miloapis-com-v1alpha1-documentrevision,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 diff --git a/internal/webhooks/iam/v1alpha1/userinvitation_webhook.go b/internal/webhooks/iam/v1alpha1/userinvitation_webhook.go index f03f5828..df1a6a91 100644 --- a/internal/webhooks/iam/v1alpha1/userinvitation_webhook.go +++ b/internal/webhooks/iam/v1alpha1/userinvitation_webhook.go @@ -26,7 +26,9 @@ func SetupUserInvitationWebhooksWithManager(mgr ctrl.Manager, systemNamespace st return ctrl.NewWebhookManagedBy(mgr). For(&iamv1alpha1.UserInvitation{}). - WithDefaulter(&UserInvitationMutator{}). + WithDefaulter(&UserInvitationMutator{ + client: mgr.GetClient(), + }). WithValidator(&UserInvitationValidator{ client: mgr.GetClient(), systemNamespace: systemNamespace, @@ -37,7 +39,9 @@ func SetupUserInvitationWebhooksWithManager(mgr ctrl.Manager, systemNamespace st // +kubebuilder:webhook:path=/mutate-iam-miloapis-com-v1alpha1-userinvitation,mutating=true,failurePolicy=fail,sideEffects=None,groups=iam.miloapis.com,resources=userinvitations,verbs=create,versions=v1alpha1,name=muserinvitation.iam.miloapis.com,admissionReviewVersions={v1,v1beta1},serviceName=milo-controller-manager,servicePort=9443,serviceNamespace=milo-system // UserInvitationMutator sets default values for UserInvitation resources. -type UserInvitationMutator struct{} +type UserInvitationMutator struct { + client client.Client +} // Default sets the InvitedBy field to the requesting user if not already set. func (m *UserInvitationMutator) Default(ctx context.Context, obj runtime.Object) error { @@ -52,8 +56,14 @@ func (m *UserInvitationMutator) Default(ctx context.Context, obj runtime.Object) return fmt.Errorf("failed to get request from context: %w", err) } + inviterUser := &iamv1alpha1.User{} + if err := m.client.Get(ctx, client.ObjectKey{Name: string(req.UserInfo.UID)}, inviterUser); err != nil { + userinvitationlog.Error(err, "failed to get user '%s' from iam.miloapis.com API", string(req.UserInfo.UID)) + return errors.NewInternalError(fmt.Errorf("failed to get user '%s' from iam.miloapis.com API: %w", string(req.UserInfo.UID), err)) + } + ui.Spec.InvitedBy = iamv1alpha1.UserReference{ - Name: req.UserInfo.Username, + Name: inviterUser.Name, } return nil diff --git a/pkg/util/hash/hash.go b/pkg/util/hash/hash.go new file mode 100644 index 00000000..94e500ce --- /dev/null +++ b/pkg/util/hash/hash.go @@ -0,0 +1,12 @@ +package hash + +import ( + "crypto/sha256" + "encoding/hex" +) + +// SHA256Hex returns the hex-encoded SHA-256 hash of the given string. +func SHA256Hex(data string) string { + h := sha256.Sum256([]byte(data)) + return hex.EncodeToString(h[:]) +}