diff --git a/cmd/milo/controller-manager/controllermanager.go b/cmd/milo/controller-manager/controllermanager.go
index 173bd797..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"
@@ -77,9 +78,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 +130,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 +452,18 @@ 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 := 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)
+ }
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/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/documentation.miloapis.com_documentrevisions.yaml b/config/crd/bases/documentation/documentation.miloapis.com_documentrevisions.yaml
new file mode 100644
index 00000000..364b5bca
--- /dev/null
+++ b/config/crd/bases/documentation/documentation.miloapis.com_documentrevisions.yaml
@@ -0,0 +1,222 @@
+---
+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.
+ enum:
+ - resourcemanager.miloapis.com
+ type: string
+ kind:
+ description: Kind is the type of resource being referenced.
+ enum:
+ - Organization
+ 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..d4e279d1
--- /dev/null
+++ b/config/crd/bases/documentation/kustomization.yaml
@@ -0,0 +1,3 @@
+resources:
+ - documentation.miloapis.com_documents.yaml
+ - documentation.miloapis.com_documentrevisions.yaml
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/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/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/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/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/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/roles/kustomization.yaml b/config/roles/kustomization.yaml
index 3e3b4e75..74b254df 100644
--- a/config/roles/kustomization.yaml
+++ b/config/roles/kustomization.yaml
@@ -46,6 +46,15 @@ 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
- organization-viewer.yaml
- organization-admin.yaml
- project-viewer.yaml
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/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/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/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.
+
+
+
+
+ | Name |
+ Type |
+ Description |
+ Required |
+
+
+
+ | apiVersion |
+ string |
+ agreement.miloapis.com/v1alpha1 |
+ true |
+
+
+ | kind |
+ string |
+ DocumentAcceptance |
+ true |
+
+
+ | metadata |
+ object |
+ Refer to the Kubernetes API documentation for the fields of the `metadata` field. |
+ true |
+
+ | spec |
+ object |
+
+ DocumentAcceptanceSpec defines the desired state of DocumentAcceptance.
+
+ Validations:self == oldSelf: spec is immutable
+ |
+ false |
+
+ | status |
+ object |
+
+ DocumentAcceptanceStatus defines the observed state of DocumentAcceptance.
+ |
+ false |
+
+
+
+
+### DocumentAcceptance.spec
+[↩ Parent](#documentacceptance)
+
+
+
+DocumentAcceptanceSpec defines the desired state of DocumentAcceptance.
+
+
+
+
+ | Name |
+ Type |
+ Description |
+ Required |
+
+
+
+ | acceptanceContext |
+ object |
+
+ AcceptanceContext is the context of the document acceptance.
+ |
+ true |
+
+ | accepterRef |
+ object |
+
+ AccepterRef is a reference to the accepter that this document acceptance applies to.
+ |
+ true |
+
+ | documentRevisionRef |
+ object |
+
+ DocumentRevisionRef is a reference to the document revision that is being accepted.
+ |
+ true |
+
+ | signature |
+ object |
+
+ Signature is the signature of the document acceptance.
+ |
+ true |
+
+ | subjectRef |
+ object |
+
+ 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.
+
+
+
+
+ | Name |
+ Type |
+ Description |
+ Required |
+
+
+
+ | method |
+ enum |
+
+ Method is the method of the document acceptance.
+
+ Enum: web, email, cli
+ |
+ true |
+
+ | acceptanceLanguage |
+ string |
+
+ AcceptanceLanguage is the language of the document acceptance.
+ |
+ false |
+
+ | ipAddress |
+ string |
+
+ IPAddress is the IP address of the accepter.
+ |
+ false |
+
+ | userAgent |
+ string |
+
+ 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.
+
+
+
+
+ | Name |
+ Type |
+ Description |
+ Required |
+
+
+
+ | apiGroup |
+ string |
+
+ APIGroup is the group for the resource being referenced.
+ |
+ true |
+
+ | kind |
+ string |
+
+ Kind is the type of resource being referenced.
+ |
+ true |
+
+ | name |
+ string |
+
+ Name is the name of the Resource being referenced.
+ |
+ true |
+
+ | namespace |
+ string |
+
+ 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.
+
+
+
+
+ | Name |
+ Type |
+ Description |
+ Required |
+
+
+
+ | name |
+ string |
+
+ Name is the name of the DocumentRevision being referenced.
+ |
+ true |
+
+ | namespace |
+ string |
+
+ Namespace of the referenced document revision.
+ |
+ true |
+
+ | version |
+ string |
+
+ Version is the version of the DocumentRevision being referenced.
+ |
+ true |
+
+
+
+
+### DocumentAcceptance.spec.signature
+[↩ Parent](#documentacceptancespec)
+
+
+
+Signature is the signature of the document acceptance.
+
+
+
+
+ | Name |
+ Type |
+ Description |
+ Required |
+
+
+
+ | timestamp |
+ string |
+
+ Timestamp is the timestamp of the document acceptance.
+
+ Format: date-time
+ |
+ true |
+
+ | type |
+ enum |
+
+ 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.
+
+
+
+
+ | Name |
+ Type |
+ Description |
+ Required |
+
+
+
+ | apiGroup |
+ string |
+
+ APIGroup is the group for the resource being referenced.
+ |
+ true |
+
+ | kind |
+ string |
+
+ Kind is the type of resource being referenced.
+ |
+ true |
+
+ | name |
+ string |
+
+ Name is the name of the Resource being referenced.
+ |
+ true |
+
+ | namespace |
+ string |
+
+ Namespace is the namespace of the Resource being referenced.
+ |
+ false |
+
+
+
+
+### DocumentAcceptance.status
+[↩ Parent](#documentacceptance)
+
+
+
+DocumentAcceptanceStatus defines the observed state of DocumentAcceptance.
+
+
+
+
+ | Name |
+ Type |
+ Description |
+ Required |
+
+
+
+ | 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.
+
+
+
+
+ | Name |
+ Type |
+ Description |
+ Required |
+
+
+
+ | lastTransitionTime |
+ string |
+
+ 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 |
+
+ | message |
+ string |
+
+ message is a human readable message indicating details about the transition.
+This may be an empty string.
+ |
+ true |
+
+ | reason |
+ string |
+
+ 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 |
+
+ | status |
+ enum |
+
+ status of the condition, one of True, False, Unknown.
+
+ Enum: True, False, Unknown
+ |
+ true |
+
+ | type |
+ string |
+
+ type of condition in CamelCase or in foo.example.com/CamelCase.
+ |
+ true |
+
+ | observedGeneration |
+ integer |
+
+ 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..8f913299
--- /dev/null
+++ b/docs/api/documentation.md
@@ -0,0 +1,702 @@
+# 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.
+
+
+
+
+ | Name |
+ Type |
+ Description |
+ Required |
+
+
+
+ | apiVersion |
+ string |
+ documentation.miloapis.com/v1alpha1 |
+ true |
+
+
+ | kind |
+ string |
+ DocumentRevision |
+ true |
+
+
+ | metadata |
+ object |
+ Refer to the Kubernetes API documentation for the fields of the `metadata` field. |
+ true |
+
+ | spec |
+ object |
+
+ DocumentRevisionSpec defines the desired state of DocumentRevision.
+
+ Validations:self == oldSelf: spec is immutable
+ |
+ false |
+
+ | status |
+ object |
+
+ DocumentRevisionStatus defines the observed state of DocumentRevision.
+ |
+ false |
+
+
+
+
+### DocumentRevision.spec
+[↩ Parent](#documentrevision)
+
+
+
+DocumentRevisionSpec defines the desired state of DocumentRevision.
+
+
+
+
+ | Name |
+ Type |
+ Description |
+ Required |
+
+
+
+ | changesSummary |
+ string |
+
+ ChangesSummary is the summary of the changes in the document revision.
+ |
+ true |
+
+ | content |
+ object |
+
+ Content is the content of the document revision.
+ |
+ true |
+
+ | documentRef |
+ object |
+
+ DocumentRef is a reference to the document that this revision is based on.
+ |
+ true |
+
+ | effectiveDate |
+ string |
+
+ 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 |
+
+ | version |
+ string |
+
+ Version is the version of the document revision.
+ |
+ true |
+
+
+
+
+### DocumentRevision.spec.content
+[↩ Parent](#documentrevisionspec)
+
+
+
+Content is the content of the document revision.
+
+
+
+
+ | Name |
+ Type |
+ Description |
+ Required |
+
+
+
+ | data |
+ string |
+
+ Data is the data of the document revision.
+ |
+ true |
+
+ | format |
+ enum |
+
+ 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.
+
+
+
+
+ | Name |
+ Type |
+ Description |
+ Required |
+
+
+
+ | name |
+ string |
+
+ Name is the name of the Document being referenced.
+ |
+ true |
+
+ | namespace |
+ string |
+
+ 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.
+
+
+
+
+ | Name |
+ Type |
+ Description |
+ Required |
+
+
+
+ | apiGroup |
+ string |
+
+ APIGroup is the group for the resource being referenced.
+
+ Validations:self == 'iam.miloapis.com': apiGroup must be iam.miloapis.com
+ |
+ true |
+
+ | kind |
+ enum |
+
+ 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.
+
+
+
+
+ | Name |
+ Type |
+ Description |
+ Required |
+
+
+
+ | apiGroup |
+ enum |
+
+ APIGroup is the group for the resource being referenced.
+
+ Enum: resourcemanager.miloapis.com
+ |
+ true |
+
+ | kind |
+ enum |
+
+ Kind is the type of resource being referenced.
+
+ Enum: Organization
+ |
+ true |
+
+
+
+
+### DocumentRevision.status
+[↩ Parent](#documentrevision)
+
+
+
+DocumentRevisionStatus defines the observed state of DocumentRevision.
+
+
+
+
+ | Name |
+ Type |
+ Description |
+ Required |
+
+
+
+ | 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 |
+
+ | contentHash |
+ string |
+
+ 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.
+
+
+
+
+ | Name |
+ Type |
+ Description |
+ Required |
+
+
+
+ | lastTransitionTime |
+ string |
+
+ 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 |
+
+ | message |
+ string |
+
+ message is a human readable message indicating details about the transition.
+This may be an empty string.
+ |
+ true |
+
+ | reason |
+ string |
+
+ 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 |
+
+ | status |
+ enum |
+
+ status of the condition, one of True, False, Unknown.
+
+ Enum: True, False, Unknown
+ |
+ true |
+
+ | type |
+ string |
+
+ type of condition in CamelCase or in foo.example.com/CamelCase.
+ |
+ true |
+
+ | observedGeneration |
+ integer |
+
+ 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.
+
+
+
+
+ | Name |
+ Type |
+ Description |
+ Required |
+
+
+
+ | apiVersion |
+ string |
+ documentation.miloapis.com/v1alpha1 |
+ true |
+
+
+ | kind |
+ string |
+ Document |
+ true |
+
+
+ | metadata |
+ object |
+ Refer to the Kubernetes API documentation for the fields of the `metadata` field. |
+ true |
+
+ | documentMetadata |
+ object |
+
+ DocumentMetadata defines the metadata of the Document.
+ |
+ false |
+
+ | spec |
+ object |
+
+ DocumentSpec defines the desired state of Document.
+ |
+ false |
+
+ | status |
+ object |
+
+ DocumentStatus defines the observed state of Document.
+ |
+ false |
+
+
+
+
+### Document.documentMetadata
+[↩ Parent](#document)
+
+
+
+DocumentMetadata defines the metadata of the Document.
+
+
+
+
+ | Name |
+ Type |
+ Description |
+ Required |
+
+
+
+ | category |
+ string |
+
+ Category is the category of the Document.
+ |
+ true |
+
+ | jurisdiction |
+ string |
+
+ Jurisdiction is the jurisdiction of the Document.
+ |
+ true |
+
+
+
+
+### Document.spec
+[↩ Parent](#document)
+
+
+
+DocumentSpec defines the desired state of Document.
+
+
+
+
+ | Name |
+ Type |
+ Description |
+ Required |
+
+
+
+ | description |
+ string |
+
+ Description is the description of the Document.
+ |
+ true |
+
+ | documentType |
+ string |
+
+ DocumentType is the type of the document.
+ |
+ true |
+
+ | title |
+ string |
+
+ Title is the title of the Document.
+ |
+ true |
+
+
+
+
+### Document.status
+[↩ Parent](#document)
+
+
+
+DocumentStatus defines the observed state of Document.
+
+
+
+
+ | Name |
+ Type |
+ Description |
+ Required |
+
+
+
+ | 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 |
+
+ | latestRevisionRef |
+ object |
+
+ 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.
+
+
+
+
+ | Name |
+ Type |
+ Description |
+ Required |
+
+
+
+ | lastTransitionTime |
+ string |
+
+ 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 |
+
+ | message |
+ string |
+
+ message is a human readable message indicating details about the transition.
+This may be an empty string.
+ |
+ true |
+
+ | reason |
+ string |
+
+ 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 |
+
+ | status |
+ enum |
+
+ status of the condition, one of True, False, Unknown.
+
+ Enum: True, False, Unknown
+ |
+ true |
+
+ | type |
+ string |
+
+ type of condition in CamelCase or in foo.example.com/CamelCase.
+ |
+ true |
+
+ | observedGeneration |
+ integer |
+
+ 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.
+
+
+
+
+ | Name |
+ Type |
+ Description |
+ Required |
+
+
+
+ | name |
+ string |
+
+
+ |
+ false |
+
+ | namespace |
+ string |
+
+
+ |
+ false |
+
+ | publishedAt |
+ string |
+
+
+
+ Format: date-time
+ |
+ false |
+
+ | version |
+ string |
+
+
+ |
+ false |
+
+
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..0e71e4d6
--- /dev/null
+++ b/internal/webhooks/agreement/v1alpha1/documentacceptance_webhook.go
@@ -0,0 +1,187 @@
+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"
+ resourcemanagerv1alpha1 "go.miloapis.com/milo/pkg/apis/resourcemanager/v1alpha1"
+)
+
+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{
+ 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
+
+ // 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 {
+ 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
+ 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
+ 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"))
+ } 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 {
+ errs = append(errs, field.Invalid(field.NewPath("spec", "accepterRef", "apiGroup"), daAccepterRef.APIGroup, "missing backend validation for apiGroup"))
+ }
+ }
+
+ 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..fbe81339
--- /dev/null
+++ b/internal/webhooks/agreement/v1alpha1/documentacceptance_webhook_test.go
@@ -0,0 +1,211 @@
+package v1alpha1
+
+import (
+ "context"
+ "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"
+ 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) {
+ scheme := runtime.NewScheme()
+ _ = clientgoscheme.AddToScheme(scheme)
+ _ = agreementv1alpha1.AddToScheme(scheme)
+ _ = documentationv1alpha1.AddToScheme(scheme)
+ _ = iamv1alpha1.AddToScheme(scheme)
+ _ = resourcemanagerv1alpha1.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"},
+ }
+
+ baseOrg := &resourcemanagerv1alpha1.Organization{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "acme",
+ },
+ Spec: resourcemanagerv1alpha1.OrganizationSpec{
+ Type: "Standard",
+ },
+ }
+
+ 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
+ errRegex string
+ }{
+ {
+ name: "valid acceptance",
+ objects: []runtime.Object{baseRevision.DeepCopy(), baseUser.DeepCopy(), baseOrg.DeepCopy()},
+ da: validAcceptance.DeepCopy(),
+ wantError: false,
+ },
+ {
+ name: "document revision not found",
+ objects: []runtime.Object{baseUser.DeepCopy(), baseOrg.DeepCopy()},
+ da: validAcceptance.DeepCopy(),
+ wantError: true,
+ errRegex: "spec.documentRevisionRef",
+ },
+ {
+ name: "version mismatch",
+ objects: []runtime.Object{baseRevision.DeepCopy(), baseUser.DeepCopy(), baseOrg.DeepCopy()},
+ da: func() *agreementv1alpha1.DocumentAcceptance {
+ v := validAcceptance.DeepCopy()
+ v.Spec.DocumentRevisionRef.Version = "v0.9.0"
+ return v
+ }(),
+ wantError: true,
+ errRegex: "spec.documentRevisionRef.version",
+ },
+ {
+ name: "unexpected subject kind",
+ objects: []runtime.Object{baseRevision.DeepCopy(), baseUser.DeepCopy(), baseOrg.DeepCopy()},
+ da: func() *agreementv1alpha1.DocumentAcceptance {
+ v := validAcceptance.DeepCopy()
+ v.Spec.SubjectRef.Kind = "Project"
+ return v
+ }(),
+ wantError: true,
+ errRegex: "spec.subjectRef",
+ },
+ {
+ name: "unexpected accepter kind",
+ objects: []runtime.Object{baseRevision.DeepCopy(), baseUser.DeepCopy(), baseOrg.DeepCopy()},
+ da: func() *agreementv1alpha1.DocumentAcceptance {
+ v := validAcceptance.DeepCopy()
+ v.Spec.AccepterRef.Kind = "MachineAccount"
+ 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) {
+ 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 {
+ if err == nil {
+ t.Fatalf("expected error, got nil")
+ }
+ 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)
+ }
+ }
+ })
+ }
+}
diff --git a/internal/webhooks/documentation/v1alpha1/doc.go b/internal/webhooks/documentation/v1alpha1/doc.go
new file mode 100644
index 00000000..1b027010
--- /dev/null
+++ b/internal/webhooks/documentation/v1alpha1/doc.go
@@ -0,0 +1,4 @@
+package v1alpha1
+
+// +kubebuilder:webhookconfiguration:mutating=true,name=documentation.miloapis.com
+// +kubebuilder:webhookconfiguration:mutating=false,name=documentation.miloapis.com
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/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/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
+}
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/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/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/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"`
+}
diff --git a/pkg/apis/documentation/v1alpha1/documentrevision_types.go b/pkg/apis/documentation/v1alpha1/documentrevision_types.go
new file mode 100644
index 00000000..be16f11a
--- /dev/null
+++ b/pkg/apis/documentation/v1alpha1/documentrevision_types.go
@@ -0,0 +1,156 @@
+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
+ // +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"`
+}
+
+// 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..c3815ee3
--- /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: "documentation.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/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
+}
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)
+ }
+ })
+ }
+}