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. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
apiVersionstringagreement.miloapis.com/v1alpha1true
kindstringDocumentAcceptancetrue
metadataobjectRefer to the Kubernetes API documentation for the fields of the `metadata` field.true
specobject + DocumentAcceptanceSpec defines the desired state of DocumentAcceptance.
+
+ Validations:
  • self == oldSelf: spec is immutable
  • +
    false
    statusobject + DocumentAcceptanceStatus defines the observed state of DocumentAcceptance.
    +
    false
    + + +### DocumentAcceptance.spec +[↩ Parent](#documentacceptance) + + + +DocumentAcceptanceSpec defines the desired state of DocumentAcceptance. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    acceptanceContextobject + AcceptanceContext is the context of the document acceptance.
    +
    true
    accepterRefobject + AccepterRef is a reference to the accepter that this document acceptance applies to.
    +
    true
    documentRevisionRefobject + DocumentRevisionRef is a reference to the document revision that is being accepted.
    +
    true
    signatureobject + Signature is the signature of the document acceptance.
    +
    true
    subjectRefobject + SubjectRef is a reference to the subject that this document acceptance applies to.
    +
    true
    + + +### DocumentAcceptance.spec.acceptanceContext +[↩ Parent](#documentacceptancespec) + + + +AcceptanceContext is the context of the document acceptance. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    methodenum + Method is the method of the document acceptance.
    +
    + Enum: web, email, cli
    +
    true
    acceptanceLanguagestring + AcceptanceLanguage is the language of the document acceptance.
    +
    false
    ipAddressstring + IPAddress is the IP address of the accepter.
    +
    false
    userAgentstring + UserAgent is the user agent of the accepter.
    +
    false
    + + +### DocumentAcceptance.spec.accepterRef +[↩ Parent](#documentacceptancespec) + + + +AccepterRef is a reference to the accepter that this document acceptance applies to. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    apiGroupstring + APIGroup is the group for the resource being referenced.
    +
    true
    kindstring + Kind is the type of resource being referenced.
    +
    true
    namestring + Name is the name of the Resource being referenced.
    +
    true
    namespacestring + Namespace is the namespace of the Resource being referenced.
    +
    false
    + + +### DocumentAcceptance.spec.documentRevisionRef +[↩ Parent](#documentacceptancespec) + + + +DocumentRevisionRef is a reference to the document revision that is being accepted. + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    namestring + Name is the name of the DocumentRevision being referenced.
    +
    true
    namespacestring + Namespace of the referenced document revision.
    +
    true
    versionstring + Version is the version of the DocumentRevision being referenced.
    +
    true
    + + +### DocumentAcceptance.spec.signature +[↩ Parent](#documentacceptancespec) + + + +Signature is the signature of the document acceptance. + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    timestampstring + Timestamp is the timestamp of the document acceptance.
    +
    + Format: date-time
    +
    true
    typeenum + Type specifies the signature mechanism used for the document acceptance.
    +
    + Enum: checkbox
    +
    true
    + + +### DocumentAcceptance.spec.subjectRef +[↩ Parent](#documentacceptancespec) + + + +SubjectRef is a reference to the subject that this document acceptance applies to. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    apiGroupstring + APIGroup is the group for the resource being referenced.
    +
    true
    kindstring + Kind is the type of resource being referenced.
    +
    true
    namestring + Name is the name of the Resource being referenced.
    +
    true
    namespacestring + Namespace is the namespace of the Resource being referenced.
    +
    false
    + + +### DocumentAcceptance.status +[↩ Parent](#documentacceptance) + + + +DocumentAcceptanceStatus defines the observed state of DocumentAcceptance. + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    conditions[]object + Conditions represent the latest available observations of an object's current state.
    +
    + Default: [map[lastTransitionTime:1970-01-01T00:00:00Z message:Waiting for control plane to reconcile reason:Unknown status:Unknown type:Ready]]
    +
    false
    + + +### DocumentAcceptance.status.conditions[index] +[↩ Parent](#documentacceptancestatus) + + + +Condition contains details for one aspect of the current state of this API Resource. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    lastTransitionTimestring + lastTransitionTime is the last time the condition transitioned from one status to another. +This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
    +
    + Format: date-time
    +
    true
    messagestring + message is a human readable message indicating details about the transition. +This may be an empty string.
    +
    true
    reasonstring + reason contains a programmatic identifier indicating the reason for the condition's last transition. +Producers of specific condition types may define expected values and meanings for this field, +and whether the values are considered a guaranteed API. +The value should be a CamelCase string. +This field may not be empty.
    +
    true
    statusenum + status of the condition, one of True, False, Unknown.
    +
    + Enum: True, False, Unknown
    +
    true
    typestring + type of condition in CamelCase or in foo.example.com/CamelCase.
    +
    true
    observedGenerationinteger + observedGeneration represents the .metadata.generation that the condition was set based upon. +For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date +with respect to the current state of the instance.
    +
    + Format: int64
    + Minimum: 0
    +
    false
    diff --git a/docs/api/documentation.md b/docs/api/documentation.md new file mode 100644 index 00000000..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. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    apiVersionstringdocumentation.miloapis.com/v1alpha1true
    kindstringDocumentRevisiontrue
    metadataobjectRefer to the Kubernetes API documentation for the fields of the `metadata` field.true
    specobject + DocumentRevisionSpec defines the desired state of DocumentRevision.
    +
    + Validations:
  • self == oldSelf: spec is immutable
  • +
    false
    statusobject + DocumentRevisionStatus defines the observed state of DocumentRevision.
    +
    false
    + + +### DocumentRevision.spec +[↩ Parent](#documentrevision) + + + +DocumentRevisionSpec defines the desired state of DocumentRevision. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    changesSummarystring + ChangesSummary is the summary of the changes in the document revision.
    +
    true
    contentobject + Content is the content of the document revision.
    +
    true
    documentRefobject + DocumentRef is a reference to the document that this revision is based on.
    +
    true
    effectiveDatestring + EffectiveDate is the date in which the document revision starts to be effective.
    +
    + Format: date-time
    +
    true
    expectedAccepterKinds[]object + ExpectedAccepterKinds is the resource kinds that are expected to accept this revision.
    +
    true
    expectedSubjectKinds[]object + ExpectedSubjectKinds is the resource kinds that this revision affects to.
    +
    true
    versionstring + Version is the version of the document revision.
    +
    true
    + + +### DocumentRevision.spec.content +[↩ Parent](#documentrevisionspec) + + + +Content is the content of the document revision. + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    datastring + Data is the data of the document revision.
    +
    true
    formatenum + Format is the format of the document revision.
    +
    + Enum: html, markdown
    +
    true
    + + +### DocumentRevision.spec.documentRef +[↩ Parent](#documentrevisionspec) + + + +DocumentRef is a reference to the document that this revision is based on. + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    namestring + Name is the name of the Document being referenced.
    +
    true
    namespacestring + Namespace of the referenced Document.
    +
    true
    + + +### DocumentRevision.spec.expectedAccepterKinds[index] +[↩ Parent](#documentrevisionspec) + + + +DocumentRevisionExpectedAccepterKind is the kind of the resource that is expected to accept this revision. + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    apiGroupstring + APIGroup is the group for the resource being referenced.
    +
    + Validations:
  • self == 'iam.miloapis.com': apiGroup must be iam.miloapis.com
  • +
    true
    kindenum + Kind is the type of resource being referenced.
    +
    + Enum: User, MachineAccount
    +
    true
    + + +### DocumentRevision.spec.expectedSubjectKinds[index] +[↩ Parent](#documentrevisionspec) + + + +DocumentRevisionExpectedSubjectKind is the kind of the resource that is expected to reference this revision. + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    apiGroupenum + APIGroup is the group for the resource being referenced.
    +
    + Enum: resourcemanager.miloapis.com
    +
    true
    kindenum + Kind is the type of resource being referenced.
    +
    + Enum: Organization
    +
    true
    + + +### DocumentRevision.status +[↩ Parent](#documentrevision) + + + +DocumentRevisionStatus defines the observed state of DocumentRevision. + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    conditions[]object + Conditions represent the latest available observations of an object's current state.
    +
    + Default: [map[lastTransitionTime:1970-01-01T00:00:00Z message:Waiting for control plane to reconcile reason:Unknown status:Unknown type:Ready]]
    +
    false
    contentHashstring + ContentHash is the hash of the content of the document revision. +This is used to detect if the content of the document revision has changed.
    +
    false
    + + +### DocumentRevision.status.conditions[index] +[↩ Parent](#documentrevisionstatus) + + + +Condition contains details for one aspect of the current state of this API Resource. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    lastTransitionTimestring + lastTransitionTime is the last time the condition transitioned from one status to another. +This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
    +
    + Format: date-time
    +
    true
    messagestring + message is a human readable message indicating details about the transition. +This may be an empty string.
    +
    true
    reasonstring + reason contains a programmatic identifier indicating the reason for the condition's last transition. +Producers of specific condition types may define expected values and meanings for this field, +and whether the values are considered a guaranteed API. +The value should be a CamelCase string. +This field may not be empty.
    +
    true
    statusenum + status of the condition, one of True, False, Unknown.
    +
    + Enum: True, False, Unknown
    +
    true
    typestring + type of condition in CamelCase or in foo.example.com/CamelCase.
    +
    true
    observedGenerationinteger + observedGeneration represents the .metadata.generation that the condition was set based upon. +For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date +with respect to the current state of the instance.
    +
    + Format: int64
    + Minimum: 0
    +
    false
    + +## Document +[↩ Parent](#documentationmiloapiscomv1alpha1 ) + + + + + + +Document is the Schema for the documents API. +It represents a document that can be used to create a document revision. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    apiVersionstringdocumentation.miloapis.com/v1alpha1true
    kindstringDocumenttrue
    metadataobjectRefer to the Kubernetes API documentation for the fields of the `metadata` field.true
    documentMetadataobject + DocumentMetadata defines the metadata of the Document.
    +
    false
    specobject + DocumentSpec defines the desired state of Document.
    +
    false
    statusobject + DocumentStatus defines the observed state of Document.
    +
    false
    + + +### Document.documentMetadata +[↩ Parent](#document) + + + +DocumentMetadata defines the metadata of the Document. + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    categorystring + Category is the category of the Document.
    +
    true
    jurisdictionstring + Jurisdiction is the jurisdiction of the Document.
    +
    true
    + + +### Document.spec +[↩ Parent](#document) + + + +DocumentSpec defines the desired state of Document. + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    descriptionstring + Description is the description of the Document.
    +
    true
    documentTypestring + DocumentType is the type of the document.
    +
    true
    titlestring + Title is the title of the Document.
    +
    true
    + + +### Document.status +[↩ Parent](#document) + + + +DocumentStatus defines the observed state of Document. + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    conditions[]object + Conditions represent the latest available observations of an object's current state.
    +
    + Default: [map[lastTransitionTime:1970-01-01T00:00:00Z message:Waiting for control plane to reconcile reason:Unknown status:Unknown type:Ready]]
    +
    false
    latestRevisionRefobject + LatestRevisionRef is a reference to the latest revision of the document.
    +
    false
    + + +### Document.status.conditions[index] +[↩ Parent](#documentstatus) + + + +Condition contains details for one aspect of the current state of this API Resource. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    lastTransitionTimestring + lastTransitionTime is the last time the condition transitioned from one status to another. +This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
    +
    + Format: date-time
    +
    true
    messagestring + message is a human readable message indicating details about the transition. +This may be an empty string.
    +
    true
    reasonstring + reason contains a programmatic identifier indicating the reason for the condition's last transition. +Producers of specific condition types may define expected values and meanings for this field, +and whether the values are considered a guaranteed API. +The value should be a CamelCase string. +This field may not be empty.
    +
    true
    statusenum + status of the condition, one of True, False, Unknown.
    +
    + Enum: True, False, Unknown
    +
    true
    typestring + type of condition in CamelCase or in foo.example.com/CamelCase.
    +
    true
    observedGenerationinteger + observedGeneration represents the .metadata.generation that the condition was set based upon. +For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date +with respect to the current state of the instance.
    +
    + Format: int64
    + Minimum: 0
    +
    false
    + + +### Document.status.latestRevisionRef +[↩ Parent](#documentstatus) + + + +LatestRevisionRef is a reference to the latest revision of the document. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    namestring +
    +
    false
    namespacestring +
    +
    false
    publishedAtstring +
    +
    + Format: date-time
    +
    false
    versionstring +
    +
    false
    diff --git a/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) + } + }) + } +}