diff --git a/PROJECT b/PROJECT index 69358d1f..ceb2a5f5 100644 --- a/PROJECT +++ b/PROJECT @@ -11,7 +11,7 @@ resources: domain: roboscale.io group: robot kind: Robot - path: github.com/robolaunch/robot-operator/api/v1alpha1 + path: github.com/robolaunch/robot-operator/pkg/api/roboscale.io/v1alpha1 version: v1alpha1 webhooks: defaulting: true @@ -24,7 +24,7 @@ resources: domain: roboscale.io group: robot kind: DiscoveryServer - path: github.com/robolaunch/robot-operator/api/v1alpha1 + path: github.com/robolaunch/robot-operator/pkg/api/roboscale.io/v1alpha1 version: v1alpha1 webhooks: defaulting: true @@ -37,7 +37,7 @@ resources: domain: roboscale.io group: robot kind: ROSBridge - path: github.com/robolaunch/robot-operator/api/v1alpha1 + path: github.com/robolaunch/robot-operator/pkg/api/roboscale.io/v1alpha1 version: v1alpha1 - api: crdVersion: v1 @@ -46,7 +46,7 @@ resources: domain: roboscale.io group: robot kind: BuildManager - path: github.com/robolaunch/robot-operator/api/v1alpha1 + path: github.com/robolaunch/robot-operator/pkg/api/roboscale.io/v1alpha1 version: v1alpha1 webhooks: defaulting: true @@ -59,7 +59,7 @@ resources: domain: roboscale.io group: robot kind: LaunchManager - path: github.com/robolaunch/robot-operator/api/v1alpha1 + path: github.com/robolaunch/robot-operator/pkg/api/roboscale.io/v1alpha1 version: v1alpha1 webhooks: defaulting: true @@ -72,7 +72,7 @@ resources: domain: roboscale.io group: robot kind: RobotDevSuite - path: github.com/robolaunch/robot-operator/api/v1alpha1 + path: github.com/robolaunch/robot-operator/pkg/api/roboscale.io/v1alpha1 version: v1alpha1 webhooks: defaulting: true @@ -85,7 +85,7 @@ resources: domain: roboscale.io group: robot kind: RobotVDI - path: github.com/robolaunch/robot-operator/api/v1alpha1 + path: github.com/robolaunch/robot-operator/pkg/api/roboscale.io/v1alpha1 version: v1alpha1 webhooks: defaulting: true @@ -98,7 +98,7 @@ resources: domain: roboscale.io group: robot kind: RobotIDE - path: github.com/robolaunch/robot-operator/api/v1alpha1 + path: github.com/robolaunch/robot-operator/pkg/api/roboscale.io/v1alpha1 version: v1alpha1 webhooks: defaulting: true @@ -110,7 +110,7 @@ resources: domain: roboscale.io group: robot kind: RobotArtifact - path: github.com/robolaunch/robot-operator/api/v1alpha1 + path: github.com/robolaunch/robot-operator/pkg/api/roboscale.io/v1alpha1 version: v1alpha1 - api: crdVersion: v1 @@ -119,7 +119,7 @@ resources: domain: roboscale.io group: robot kind: WorkspaceManager - path: github.com/robolaunch/robot-operator/api/v1alpha1 + path: github.com/robolaunch/robot-operator/pkg/api/roboscale.io/v1alpha1 version: v1alpha1 webhooks: defaulting: true @@ -132,7 +132,7 @@ resources: domain: roboscale.io group: robot kind: MetricsExporter - path: github.com/robolaunch/robot-operator/api/v1alpha1 + path: github.com/robolaunch/robot-operator/pkg/api/roboscale.io/v1alpha1 version: v1alpha1 - api: crdVersion: v1 @@ -141,6 +141,19 @@ resources: domain: roboscale.io group: robot kind: RelayServer - path: github.com/robolaunch/robot-operator/api/v1alpha1 + path: github.com/robolaunch/robot-operator/pkg/api/roboscale.io/v1alpha1 version: v1alpha1 +- api: + crdVersion: v1 + namespaced: true + controller: true + domain: roboscale.io + group: robot + kind: Notebook + path: github.com/robolaunch/robot-operator/pkg/api/roboscale.io/v1alpha1 + version: v1alpha1 + webhooks: + defaulting: true + validation: true + webhookVersion: v1 version: "3" diff --git a/config/crd/bases/robot.roboscale.io_notebooks.yaml b/config/crd/bases/robot.roboscale.io_notebooks.yaml new file mode 100644 index 00000000..fa46087a --- /dev/null +++ b/config/crd/bases/robot.roboscale.io_notebooks.yaml @@ -0,0 +1,468 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.9.2 + creationTimestamp: null + name: notebooks.robot.roboscale.io +spec: + group: robot.roboscale.io + names: + kind: Notebook + listKind: NotebookList + plural: notebooks + singular: notebook + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: Notebook is the Schema for the notebooks API. + 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: NotebookSpec defines the desired state of Notebook. + properties: + display: + description: Notebook connects an X11 socket if it's set to `true` + and a target Notebook resource is set in labels with key `robolaunch.io/target-vdi`. + Applications that requires GUI can be executed such as VLC. + type: boolean + ingress: + description: '[*alpha*] Notebook will create an Ingress resource if + `true`.' + type: boolean + privileged: + description: If `true`, containers of Notebook will be privileged + containers. It can be used in physical instances where it's necessary + to access I/O devices on the host machine. Not recommended to activate + this field on cloud instances. + type: boolean + resources: + description: Resource limitations of Notebook. + properties: + cpu: + description: CPU resource limit. + pattern: ^([0-9])+(m)$ + type: string + gpuCore: + description: GPU core number that will be allocated. + type: integer + gpuInstance: + default: nvidia.com/gpu + description: GPU instance that will be allocated. eg. nvidia.com/mig-1g.5gb. + Defaults to "nvidia.com/gpu". + type: string + memory: + description: Memory resource limit. + pattern: ^([0-9])+(Mi|Gi)$ + type: string + type: object + serviceType: + default: NodePort + description: Service type of Notebook. `ClusterIP` and `NodePort` + is supported. + enum: + - ClusterIP + - NodePort + type: string + type: object + status: + description: NotebookStatus defines the observed state of Notebook. + properties: + configMapStatus: + description: Config map status. It's used to add background apps. + properties: + created: + description: Shows if the owned resource is created. + type: boolean + phase: + description: Phase of the owned resource. + type: string + reference: + description: Reference to the owned resource. + properties: + apiVersion: + description: API version of the referent. + type: string + fieldPath: + description: 'If referring to a piece of an object instead + of an entire object, this string should contain a valid + JSON/Go field access statement, such as desiredState.manifest.containers[2]. + For example, if the object reference is to a container within + a pod, this would take on a value like: "spec.containers{name}" + (where "name" refers to the name of the container that triggered + the event) or if no container name is specified "spec.containers[2]" + (container with index 2 in this pod). This syntax is chosen + only to have some well-defined way of referencing a part + of an object. TODO: this design is not final and this field + is subject to change in the future.' + type: string + kind: + description: 'Kind of the referent. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' + type: string + name: + description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names' + type: string + namespace: + description: 'Namespace of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces/' + type: string + resourceVersion: + description: 'Specific resourceVersion to which this reference + is made, if any. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency' + type: string + uid: + description: 'UID of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#uids' + type: string + type: object + x-kubernetes-map-type: atomic + required: + - created + type: object + customPortIngressStatus: + description: Status of Notebook ingress for custom ports service. + Created only if the robot has an additional config with key `IDE_CUSTOM_PORT_RANGE` + and `.spec.ingress` is `true`. + properties: + created: + description: Shows if the owned resource is created. + type: boolean + phase: + description: Phase of the owned resource. + type: string + reference: + description: Reference to the owned resource. + properties: + apiVersion: + description: API version of the referent. + type: string + fieldPath: + description: 'If referring to a piece of an object instead + of an entire object, this string should contain a valid + JSON/Go field access statement, such as desiredState.manifest.containers[2]. + For example, if the object reference is to a container within + a pod, this would take on a value like: "spec.containers{name}" + (where "name" refers to the name of the container that triggered + the event) or if no container name is specified "spec.containers[2]" + (container with index 2 in this pod). This syntax is chosen + only to have some well-defined way of referencing a part + of an object. TODO: this design is not final and this field + is subject to change in the future.' + type: string + kind: + description: 'Kind of the referent. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' + type: string + name: + description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names' + type: string + namespace: + description: 'Namespace of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces/' + type: string + resourceVersion: + description: 'Specific resourceVersion to which this reference + is made, if any. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency' + type: string + uid: + description: 'UID of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#uids' + type: string + type: object + x-kubernetes-map-type: atomic + required: + - created + type: object + customPortServiceStatus: + description: Status of Notebook service for custom ports. Created + only if the robot has an additional config with key `IDE_CUSTOM_PORT_RANGE`. + properties: + resource: + description: Generic status for any owned resource. + properties: + created: + description: Shows if the owned resource is created. + type: boolean + phase: + description: Phase of the owned resource. + type: string + reference: + description: Reference to the owned resource. + properties: + apiVersion: + description: API version of the referent. + type: string + fieldPath: + description: 'If referring to a piece of an object instead + of an entire object, this string should contain a valid + JSON/Go field access statement, such as desiredState.manifest.containers[2]. + For example, if the object reference is to a container + within a pod, this would take on a value like: "spec.containers{name}" + (where "name" refers to the name of the container that + triggered the event) or if no container name is specified + "spec.containers[2]" (container with index 2 in this + pod). This syntax is chosen only to have some well-defined + way of referencing a part of an object. TODO: this design + is not final and this field is subject to change in + the future.' + type: string + kind: + description: 'Kind of the referent. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' + type: string + name: + description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names' + type: string + namespace: + description: 'Namespace of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces/' + type: string + resourceVersion: + description: 'Specific resourceVersion to which this reference + is made, if any. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency' + type: string + uid: + description: 'UID of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#uids' + type: string + type: object + x-kubernetes-map-type: atomic + required: + - created + type: object + urls: + additionalProperties: + type: string + description: Connection URL. + type: object + type: object + ingressStatus: + description: Status of Notebook Ingress. + properties: + created: + description: Shows if the owned resource is created. + type: boolean + phase: + description: Phase of the owned resource. + type: string + reference: + description: Reference to the owned resource. + properties: + apiVersion: + description: API version of the referent. + type: string + fieldPath: + description: 'If referring to a piece of an object instead + of an entire object, this string should contain a valid + JSON/Go field access statement, such as desiredState.manifest.containers[2]. + For example, if the object reference is to a container within + a pod, this would take on a value like: "spec.containers{name}" + (where "name" refers to the name of the container that triggered + the event) or if no container name is specified "spec.containers[2]" + (container with index 2 in this pod). This syntax is chosen + only to have some well-defined way of referencing a part + of an object. TODO: this design is not final and this field + is subject to change in the future.' + type: string + kind: + description: 'Kind of the referent. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' + type: string + name: + description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names' + type: string + namespace: + description: 'Namespace of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces/' + type: string + resourceVersion: + description: 'Specific resourceVersion to which this reference + is made, if any. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency' + type: string + uid: + description: 'UID of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#uids' + type: string + type: object + x-kubernetes-map-type: atomic + required: + - created + type: object + phase: + description: Phase of Notebook. + type: string + podStatus: + description: Status of Notebook pod. + properties: + ip: + description: IP of the pod. + type: string + resource: + description: Generic status for any owned resource. + properties: + created: + description: Shows if the owned resource is created. + type: boolean + phase: + description: Phase of the owned resource. + type: string + reference: + description: Reference to the owned resource. + properties: + apiVersion: + description: API version of the referent. + type: string + fieldPath: + description: 'If referring to a piece of an object instead + of an entire object, this string should contain a valid + JSON/Go field access statement, such as desiredState.manifest.containers[2]. + For example, if the object reference is to a container + within a pod, this would take on a value like: "spec.containers{name}" + (where "name" refers to the name of the container that + triggered the event) or if no container name is specified + "spec.containers[2]" (container with index 2 in this + pod). This syntax is chosen only to have some well-defined + way of referencing a part of an object. TODO: this design + is not final and this field is subject to change in + the future.' + type: string + kind: + description: 'Kind of the referent. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' + type: string + name: + description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names' + type: string + namespace: + description: 'Namespace of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces/' + type: string + resourceVersion: + description: 'Specific resourceVersion to which this reference + is made, if any. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency' + type: string + uid: + description: 'UID of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#uids' + type: string + type: object + x-kubernetes-map-type: atomic + required: + - created + type: object + type: object + serviceExportStatus: + description: Status of Notebook ServiceExport. Created only if the + instance type is Physical Instance. + properties: + created: + description: Shows if the owned resource is created. + type: boolean + phase: + description: Phase of the owned resource. + type: string + reference: + description: Reference to the owned resource. + properties: + apiVersion: + description: API version of the referent. + type: string + fieldPath: + description: 'If referring to a piece of an object instead + of an entire object, this string should contain a valid + JSON/Go field access statement, such as desiredState.manifest.containers[2]. + For example, if the object reference is to a container within + a pod, this would take on a value like: "spec.containers{name}" + (where "name" refers to the name of the container that triggered + the event) or if no container name is specified "spec.containers[2]" + (container with index 2 in this pod). This syntax is chosen + only to have some well-defined way of referencing a part + of an object. TODO: this design is not final and this field + is subject to change in the future.' + type: string + kind: + description: 'Kind of the referent. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' + type: string + name: + description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names' + type: string + namespace: + description: 'Namespace of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces/' + type: string + resourceVersion: + description: 'Specific resourceVersion to which this reference + is made, if any. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency' + type: string + uid: + description: 'UID of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#uids' + type: string + type: object + x-kubernetes-map-type: atomic + required: + - created + type: object + serviceStatus: + description: Status of Notebook service. + properties: + resource: + description: Generic status for any owned resource. + properties: + created: + description: Shows if the owned resource is created. + type: boolean + phase: + description: Phase of the owned resource. + type: string + reference: + description: Reference to the owned resource. + properties: + apiVersion: + description: API version of the referent. + type: string + fieldPath: + description: 'If referring to a piece of an object instead + of an entire object, this string should contain a valid + JSON/Go field access statement, such as desiredState.manifest.containers[2]. + For example, if the object reference is to a container + within a pod, this would take on a value like: "spec.containers{name}" + (where "name" refers to the name of the container that + triggered the event) or if no container name is specified + "spec.containers[2]" (container with index 2 in this + pod). This syntax is chosen only to have some well-defined + way of referencing a part of an object. TODO: this design + is not final and this field is subject to change in + the future.' + type: string + kind: + description: 'Kind of the referent. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' + type: string + name: + description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names' + type: string + namespace: + description: 'Namespace of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces/' + type: string + resourceVersion: + description: 'Specific resourceVersion to which this reference + is made, if any. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency' + type: string + uid: + description: 'UID of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#uids' + type: string + type: object + x-kubernetes-map-type: atomic + required: + - created + type: object + urls: + additionalProperties: + type: string + description: Connection URL. + type: object + type: object + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/bases/robot.roboscale.io_robotartifacts.yaml b/config/crd/bases/robot.roboscale.io_robotartifacts.yaml index d252836c..4d13d768 100644 --- a/config/crd/bases/robot.roboscale.io_robotartifacts.yaml +++ b/config/crd/bases/robot.roboscale.io_robotartifacts.yaml @@ -256,6 +256,59 @@ spec: description: If `true`, a Cloud IDE will be provisioned inside development suite. type: boolean + notebookEnabled: + description: If `true`, a Notebook will be provisioned inside + development suite. + type: boolean + notebookTemplate: + description: Configurational parameters of Notebook. Applied if + `.spec.notebookEnabled` is set to `true`. + properties: + display: + description: Notebook connects an X11 socket if it's set to + `true` and a target Notebook resource is set in labels with + key `robolaunch.io/target-vdi`. Applications that requires + GUI can be executed such as VLC. + type: boolean + ingress: + description: '[*alpha*] Notebook will create an Ingress resource + if `true`.' + type: boolean + privileged: + description: If `true`, containers of Notebook will be privileged + containers. It can be used in physical instances where it's + necessary to access I/O devices on the host machine. Not + recommended to activate this field on cloud instances. + type: boolean + resources: + description: Resource limitations of Notebook. + properties: + cpu: + description: CPU resource limit. + pattern: ^([0-9])+(m)$ + type: string + gpuCore: + description: GPU core number that will be allocated. + type: integer + gpuInstance: + default: nvidia.com/gpu + description: GPU instance that will be allocated. eg. + nvidia.com/mig-1g.5gb. Defaults to "nvidia.com/gpu". + type: string + memory: + description: Memory resource limit. + pattern: ^([0-9])+(Mi|Gi)$ + type: string + type: object + serviceType: + default: NodePort + description: Service type of Notebook. `ClusterIP` and `NodePort` + is supported. + enum: + - ClusterIP + - NodePort + type: string + type: object remoteIDEEnabled: description: If `true`, a relay server for remote Cloud IDE will be provisioned inside development suite. diff --git a/config/crd/bases/robot.roboscale.io_robotdevsuites.yaml b/config/crd/bases/robot.roboscale.io_robotdevsuites.yaml index 2a0da749..1242fbb5 100644 --- a/config/crd/bases/robot.roboscale.io_robotdevsuites.yaml +++ b/config/crd/bases/robot.roboscale.io_robotdevsuites.yaml @@ -53,6 +53,58 @@ spec: description: If `true`, a Cloud IDE will be provisioned inside development suite. type: boolean + notebookEnabled: + description: If `true`, a Notebook will be provisioned inside development + suite. + type: boolean + notebookTemplate: + description: Configurational parameters of Notebook. Applied if `.spec.notebookEnabled` + is set to `true`. + properties: + display: + description: Notebook connects an X11 socket if it's set to `true` + and a target Notebook resource is set in labels with key `robolaunch.io/target-vdi`. + Applications that requires GUI can be executed such as VLC. + type: boolean + ingress: + description: '[*alpha*] Notebook will create an Ingress resource + if `true`.' + type: boolean + privileged: + description: If `true`, containers of Notebook will be privileged + containers. It can be used in physical instances where it's + necessary to access I/O devices on the host machine. Not recommended + to activate this field on cloud instances. + type: boolean + resources: + description: Resource limitations of Notebook. + properties: + cpu: + description: CPU resource limit. + pattern: ^([0-9])+(m)$ + type: string + gpuCore: + description: GPU core number that will be allocated. + type: integer + gpuInstance: + default: nvidia.com/gpu + description: GPU instance that will be allocated. eg. nvidia.com/mig-1g.5gb. + Defaults to "nvidia.com/gpu". + type: string + memory: + description: Memory resource limit. + pattern: ^([0-9])+(Mi|Gi)$ + type: string + type: object + serviceType: + default: NodePort + description: Service type of Notebook. `ClusterIP` and `NodePort` + is supported. + enum: + - ClusterIP + - NodePort + type: string + type: object remoteIDEEnabled: description: If `true`, a relay server for remote Cloud IDE will be provisioned inside development suite. @@ -221,6 +273,66 @@ spec: description: '[*alpha*] Indicates if RobotDevSuite is attached to a Robot and actively provisioned it''s resources.' type: boolean + notebookStatus: + description: Status of Notebook. + properties: + connections: + additionalProperties: + type: string + description: Address of the robot service that can be reached + from outside. + type: object + resource: + description: Generic status for any owned resource. + properties: + created: + description: Shows if the owned resource is created. + type: boolean + phase: + description: Phase of the owned resource. + type: string + reference: + description: Reference to the owned resource. + properties: + apiVersion: + description: API version of the referent. + type: string + fieldPath: + description: 'If referring to a piece of an object instead + of an entire object, this string should contain a valid + JSON/Go field access statement, such as desiredState.manifest.containers[2]. + For example, if the object reference is to a container + within a pod, this would take on a value like: "spec.containers{name}" + (where "name" refers to the name of the container that + triggered the event) or if no container name is specified + "spec.containers[2]" (container with index 2 in this + pod). This syntax is chosen only to have some well-defined + way of referencing a part of an object. TODO: this design + is not final and this field is subject to change in + the future.' + type: string + kind: + description: 'Kind of the referent. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' + type: string + name: + description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names' + type: string + namespace: + description: 'Namespace of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces/' + type: string + resourceVersion: + description: 'Specific resourceVersion to which this reference + is made, if any. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency' + type: string + uid: + description: 'UID of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#uids' + type: string + type: object + x-kubernetes-map-type: atomic + required: + - created + type: object + type: object phase: description: Phase of RobotDevSuite. type: string diff --git a/config/crd/bases/robot.roboscale.io_robots.yaml b/config/crd/bases/robot.roboscale.io_robots.yaml index befa32e0..4f065b0c 100644 --- a/config/crd/bases/robot.roboscale.io_robots.yaml +++ b/config/crd/bases/robot.roboscale.io_robots.yaml @@ -277,6 +277,59 @@ spec: description: If `true`, a Cloud IDE will be provisioned inside development suite. type: boolean + notebookEnabled: + description: If `true`, a Notebook will be provisioned inside + development suite. + type: boolean + notebookTemplate: + description: Configurational parameters of Notebook. Applied if + `.spec.notebookEnabled` is set to `true`. + properties: + display: + description: Notebook connects an X11 socket if it's set to + `true` and a target Notebook resource is set in labels with + key `robolaunch.io/target-vdi`. Applications that requires + GUI can be executed such as VLC. + type: boolean + ingress: + description: '[*alpha*] Notebook will create an Ingress resource + if `true`.' + type: boolean + privileged: + description: If `true`, containers of Notebook will be privileged + containers. It can be used in physical instances where it's + necessary to access I/O devices on the host machine. Not + recommended to activate this field on cloud instances. + type: boolean + resources: + description: Resource limitations of Notebook. + properties: + cpu: + description: CPU resource limit. + pattern: ^([0-9])+(m)$ + type: string + gpuCore: + description: GPU core number that will be allocated. + type: integer + gpuInstance: + default: nvidia.com/gpu + description: GPU instance that will be allocated. eg. + nvidia.com/mig-1g.5gb. Defaults to "nvidia.com/gpu". + type: string + memory: + description: Memory resource limit. + pattern: ^([0-9])+(Mi|Gi)$ + type: string + type: object + serviceType: + default: NodePort + description: Service type of Notebook. `ClusterIP` and `NodePort` + is supported. + enum: + - ClusterIP + - NodePort + type: string + type: object remoteIDEEnabled: description: If `true`, a relay server for remote Cloud IDE will be provisioned inside development suite. @@ -943,6 +996,74 @@ spec: description: '[*alpha*] Indicates if RobotDevSuite is attached to a Robot and actively provisioned it''s resources.' type: boolean + notebookStatus: + description: Status of Notebook. + properties: + connections: + additionalProperties: + type: string + description: Address of the robot service that can be + reached from outside. + type: object + resource: + description: Generic status for any owned resource. + properties: + created: + description: Shows if the owned resource is created. + type: boolean + phase: + description: Phase of the owned resource. + type: string + reference: + description: Reference to the owned resource. + properties: + apiVersion: + description: API version of the referent. + type: string + fieldPath: + description: 'If referring to a piece of an + object instead of an entire object, this string + should contain a valid JSON/Go field access + statement, such as desiredState.manifest.containers[2]. + For example, if the object reference is to + a container within a pod, this would take + on a value like: "spec.containers{name}" (where + "name" refers to the name of the container + that triggered the event) or if no container + name is specified "spec.containers[2]" (container + with index 2 in this pod). This syntax is + chosen only to have some well-defined way + of referencing a part of an object. TODO: + this design is not final and this field is + subject to change in the future.' + type: string + kind: + description: 'Kind of the referent. More info: + https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' + type: string + name: + description: 'Name of the referent. More info: + https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names' + type: string + namespace: + description: 'Namespace of the referent. More + info: https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces/' + type: string + resourceVersion: + description: 'Specific resourceVersion to which + this reference is made, if any. More info: + https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency' + type: string + uid: + description: 'UID of the referent. More info: + https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#uids' + type: string + type: object + x-kubernetes-map-type: atomic + required: + - created + type: object + type: object phase: description: Phase of RobotDevSuite. type: string @@ -1961,6 +2082,73 @@ spec: description: '[*alpha*] Indicates if RobotDevSuite is attached to a Robot and actively provisioned it''s resources.' type: boolean + notebookStatus: + description: Status of Notebook. + properties: + connections: + additionalProperties: + type: string + description: Address of the robot service that can be + reached from outside. + type: object + resource: + description: Generic status for any owned resource. + properties: + created: + description: Shows if the owned resource is created. + type: boolean + phase: + description: Phase of the owned resource. + type: string + reference: + description: Reference to the owned resource. + properties: + apiVersion: + description: API version of the referent. + type: string + fieldPath: + description: 'If referring to a piece of an object + instead of an entire object, this string should + contain a valid JSON/Go field access statement, + such as desiredState.manifest.containers[2]. + For example, if the object reference is to a + container within a pod, this would take on a + value like: "spec.containers{name}" (where "name" + refers to the name of the container that triggered + the event) or if no container name is specified + "spec.containers[2]" (container with index 2 + in this pod). This syntax is chosen only to + have some well-defined way of referencing a + part of an object. TODO: this design is not + final and this field is subject to change in + the future.' + type: string + kind: + description: 'Kind of the referent. More info: + https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' + type: string + name: + description: 'Name of the referent. More info: + https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names' + type: string + namespace: + description: 'Namespace of the referent. More + info: https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces/' + type: string + resourceVersion: + description: 'Specific resourceVersion to which + this reference is made, if any. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency' + type: string + uid: + description: 'UID of the referent. More info: + https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#uids' + type: string + type: object + x-kubernetes-map-type: atomic + required: + - created + type: object + type: object phase: description: Phase of RobotDevSuite. type: string diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index ef30f435..8b13d63c 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -17,6 +17,7 @@ resources: - bases/multicluster.x-k8s.io_serviceimports.yaml - bases/robot.roboscale.io_metricsexporters.yaml - bases/robot.roboscale.io_relayservers.yaml +- bases/robot.roboscale.io_notebooks.yaml #+kubebuilder:scaffold:crdkustomizeresource patchesStrategicMerge: @@ -35,6 +36,7 @@ patchesStrategicMerge: - patches/webhook_in_workspacemanagers.yaml #- patches/webhook_in_metricsexporters.yaml - patches/webhook_in_relayservers.yaml +#- patches/webhook_in_notebooks.yaml #+kubebuilder:scaffold:crdkustomizewebhookpatch # [CERTMANAGER] To enable cert-manager, uncomment all the sections with [CERTMANAGER] prefix. @@ -52,6 +54,7 @@ patchesStrategicMerge: - patches/cainjection_in_workspacemanagers.yaml #- patches/cainjection_in_metricsexporters.yaml # - patches/cainjection_in_relayservers.yaml +#- patches/cainjection_in_notebooks.yaml #+kubebuilder:scaffold:crdkustomizecainjectionpatch # the following config is for teaching kustomize how to do kustomization for CRDs. diff --git a/config/crd/patches/cainjection_in_notebooks.yaml b/config/crd/patches/cainjection_in_notebooks.yaml new file mode 100644 index 00000000..09727be3 --- /dev/null +++ b/config/crd/patches/cainjection_in_notebooks.yaml @@ -0,0 +1,7 @@ +# The following patch adds a directive for certmanager to inject CA into the CRD +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + cert-manager.io/inject-ca-from: $(CERTIFICATE_NAMESPACE)/$(CERTIFICATE_NAME) + name: notebooks.robot.roboscale.io diff --git a/config/crd/patches/webhook_in_notebooks.yaml b/config/crd/patches/webhook_in_notebooks.yaml new file mode 100644 index 00000000..0dbc6ce5 --- /dev/null +++ b/config/crd/patches/webhook_in_notebooks.yaml @@ -0,0 +1,16 @@ +# The following patch enables a conversion webhook for the CRD +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: notebooks.robot.roboscale.io +spec: + conversion: + strategy: Webhook + webhook: + clientConfig: + service: + namespace: system + name: webhook-service + path: /convert + conversionReviewVersions: + - v1 diff --git a/config/rbac/notebook_editor_role.yaml b/config/rbac/notebook_editor_role.yaml new file mode 100644 index 00000000..15201b94 --- /dev/null +++ b/config/rbac/notebook_editor_role.yaml @@ -0,0 +1,31 @@ +# permissions for end users to edit notebooks. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: clusterrole + app.kubernetes.io/instance: notebook-editor-role + app.kubernetes.io/component: rbac + app.kubernetes.io/created-by: robot-operator + app.kubernetes.io/part-of: robot-operator + app.kubernetes.io/managed-by: kustomize + name: notebook-editor-role +rules: +- apiGroups: + - robot.roboscale.io + resources: + - notebooks + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - robot.roboscale.io + resources: + - notebooks/status + verbs: + - get diff --git a/config/rbac/notebook_viewer_role.yaml b/config/rbac/notebook_viewer_role.yaml new file mode 100644 index 00000000..db71029a --- /dev/null +++ b/config/rbac/notebook_viewer_role.yaml @@ -0,0 +1,27 @@ +# permissions for end users to view notebooks. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: clusterrole + app.kubernetes.io/instance: notebook-viewer-role + app.kubernetes.io/component: rbac + app.kubernetes.io/created-by: robot-operator + app.kubernetes.io/part-of: robot-operator + app.kubernetes.io/managed-by: kustomize + name: notebook-viewer-role +rules: +- apiGroups: + - robot.roboscale.io + resources: + - notebooks + verbs: + - get + - list + - watch +- apiGroups: + - robot.roboscale.io + resources: + - notebooks/status + verbs: + - get diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 72aba824..b575508e 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -253,6 +253,32 @@ rules: - get - patch - update +- apiGroups: + - robot.roboscale.io + resources: + - notebooks + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - robot.roboscale.io + resources: + - notebooks/finalizers + verbs: + - update +- apiGroups: + - robot.roboscale.io + resources: + - notebooks/status + verbs: + - get + - patch + - update - apiGroups: - robot.roboscale.io resources: diff --git a/config/samples/robot_v1alpha1_notebook.yaml b/config/samples/robot_v1alpha1_notebook.yaml new file mode 100644 index 00000000..1ad24f4e --- /dev/null +++ b/config/samples/robot_v1alpha1_notebook.yaml @@ -0,0 +1,12 @@ +apiVersion: robot.roboscale.io/v1alpha1 +kind: Notebook +metadata: + labels: + app.kubernetes.io/name: notebook + app.kubernetes.io/instance: notebook-sample + app.kubernetes.io/part-of: robot-operator + app.kuberentes.io/managed-by: kustomize + app.kubernetes.io/created-by: robot-operator + name: notebook-sample +spec: + # TODO(user): Add fields here diff --git a/config/webhook/manifests.yaml b/config/webhook/manifests.yaml index 006cd4b5..cea89b1a 100644 --- a/config/webhook/manifests.yaml +++ b/config/webhook/manifests.yaml @@ -5,6 +5,26 @@ metadata: creationTimestamp: null name: mutating-webhook-configuration webhooks: +- admissionReviewVersions: + - v1 + clientConfig: + service: + name: webhook-service + namespace: system + path: /mutate-robot-roboscale-io-v1alpha1-notebook + failurePolicy: Fail + name: mnotebook.kb.io + rules: + - apiGroups: + - robot.roboscale.io + apiVersions: + - v1alpha1 + operations: + - CREATE + - UPDATE + resources: + - notebooks + sideEffects: None - admissionReviewVersions: - v1 clientConfig: @@ -172,6 +192,26 @@ metadata: creationTimestamp: null name: validating-webhook-configuration webhooks: +- admissionReviewVersions: + - v1 + clientConfig: + service: + name: webhook-service + namespace: system + path: /validate-robot-roboscale-io-v1alpha1-notebook + failurePolicy: Fail + name: vnotebook.kb.io + rules: + - apiGroups: + - robot.roboscale.io + apiVersions: + - v1alpha1 + operations: + - CREATE + - UPDATE + resources: + - notebooks + sideEffects: None - admissionReviewVersions: - v1 clientConfig: diff --git a/internal/resources/notebook.go b/internal/resources/notebook.go new file mode 100644 index 00000000..8cfe610c --- /dev/null +++ b/internal/resources/notebook.go @@ -0,0 +1,391 @@ +package resources + +import ( + "fmt" + "path/filepath" + "strconv" + "strings" + + "github.com/robolaunch/robot-operator/internal" + "github.com/robolaunch/robot-operator/internal/configure" + "github.com/robolaunch/robot-operator/internal/label" + mcsv1alpha1 "github.com/robolaunch/robot-operator/pkg/api/external/apis/mcsv1alpha1/v1alpha1" + robotv1alpha1 "github.com/robolaunch/robot-operator/pkg/api/roboscale.io/v1alpha1" + corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/intstr" +) + +const ( + NOTEBOOK_PORT_NAME = "notebook" + NOTEBOOK_PORT = 8888 +) + +func getNotebookSelector(notebook robotv1alpha1.Notebook) map[string]string { + return map[string]string{ + "notebook": notebook.Name, + } +} + +func GetNotebookPod(notebook *robotv1alpha1.Notebook, podNamespacedName *types.NamespacedName, robot robotv1alpha1.Robot, robotVDI robotv1alpha1.RobotVDI, node corev1.Node, cm corev1.ConfigMap) *corev1.Pod { + + podCfg := configure.PodConfigInjector{} + containerCfg := configure.ContainerConfigInjector{} + + var cmdBuilder strings.Builder + cmdBuilder.WriteString(configure.GetGrantPermissionCmd(robot)) + cmdBuilder.WriteString("supervisord -c " + filepath.Join("/etc", "robolaunch", "services", "notebook.conf")) + + labels := getNotebookSelector(*notebook) + for k, v := range notebook.Labels { + labels[k] = v + } + + nbContainer := corev1.Container{ + Name: "notebook", + Image: robot.Status.Image, + Command: internal.Bash(cmdBuilder.String()), + Env: []corev1.EnvVar{ + internal.Env("NOTEBOOK_PORT", strconv.Itoa(NOTEBOOK_PORT)), + internal.Env("FILE_BROWSER_PORT", strconv.Itoa(internal.FILE_BROWSER_PORT)), + internal.Env("FILE_BROWSER_SERVICE", "notebook"), + internal.Env("FILE_BROWSER_BASE_URL", robotv1alpha1.GetRobotServicePath(robot, "/file-browser/notebook")), + internal.Env("ROBOT_NAMESPACE", robot.Namespace), + internal.Env("ROBOT_NAME", robot.Name), + internal.Env("WORKSPACES_PATH", robot.Spec.WorkspaceManagerTemplate.WorkspacesPath), + internal.Env("TERM", "xterm-256color"), + }, + Ports: []corev1.ContainerPort{ + { + Name: NOTEBOOK_PORT_NAME, + ContainerPort: NOTEBOOK_PORT, + }, + { + Name: internal.FILE_BROWSER_PORT_NAME, + ContainerPort: internal.FILE_BROWSER_PORT, + }, + }, + Resources: corev1.ResourceRequirements{ + Limits: getResourceLimits(notebook.Spec.Resources), + }, + SecurityContext: &corev1.SecurityContext{ + Privileged: ¬ebook.Spec.Privileged, + }, + } + + containerCfg.InjectVolumeMountConfiguration(&nbContainer, robot, "") + // add custom ports defined by user + if ports, ok := robot.Spec.AdditionalConfigs[internal.NOTEBOOK_CUSTOM_PORT_RANGE_KEY]; ok { + containerCfg.InjectCustomPortConfiguration(&nbContainer, ports) + } + + nbPod := corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: podNamespacedName.Name, + Namespace: podNamespacedName.Namespace, + Labels: labels, + }, + Spec: corev1.PodSpec{ + // HostNetwork: notebook.Spec.Privileged, + Containers: []corev1.Container{ + nbContainer, + }, + RestartPolicy: corev1.RestartPolicyNever, + }, + } + + podCfg.InjectImagePullPolicy(&nbPod) + podCfg.SchedulePod(&nbPod, notebook) + podCfg.InjectVolumeConfiguration(&nbPod, robot) + podCfg.InjectBackgroundConfigFiles(&nbPod, cm) + podCfg.InjectGenericEnvironmentVariables(&nbPod, robot) + podCfg.InjectRuntimeClass(&nbPod, robot, node) + podCfg.InjectTimezone(&nbPod, node) + if notebook.Spec.Display && label.GetTargetRobotVDI(notebook) != "" { + // TODO: Add control for validating robot VDI + podCfg.InjectDisplayConfiguration(&nbPod, robotVDI) + } + + if robot.Spec.Type == robotv1alpha1.TypeRobot { + podCfg.InjectGenericRobotEnvironmentVariables(&nbPod, robot) + podCfg.InjectRMWImplementationConfiguration(&nbPod, robot) + podCfg.InjectROSDomainID(&nbPod, robot.Spec.RobotConfig.DomainID) + podCfg.InjectDiscoveryServerConnection(&nbPod, robot.Status.DiscoveryServerStatus.Status.ConnectionInfo) + } + + return &nbPod +} + +func GetNotebookService(notebook *robotv1alpha1.Notebook, svcNamespacedName *types.NamespacedName) *corev1.Service { + + cfg := configure.ServiceConfigInjector{} + + serviceSpec := corev1.ServiceSpec{ + Type: notebook.Spec.ServiceType, + Selector: getNotebookSelector(*notebook), + Ports: []corev1.ServicePort{ + { + Port: NOTEBOOK_PORT, + TargetPort: intstr.IntOrString{ + IntVal: NOTEBOOK_PORT, + }, + Protocol: corev1.ProtocolTCP, + Name: NOTEBOOK_PORT_NAME, + }, + { + Port: internal.FILE_BROWSER_PORT, + TargetPort: intstr.IntOrString{ + IntVal: internal.FILE_BROWSER_PORT, + }, + Protocol: corev1.ProtocolTCP, + Name: internal.FILE_BROWSER_PORT_NAME, + }, + }, + } + + service := corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: svcNamespacedName.Name, + Namespace: svcNamespacedName.Namespace, + }, + Spec: serviceSpec, + } + + if label.GetInstanceType(notebook) == label.InstanceTypePhysicalInstance { + // apply ONLY if the resource is on physical instance + cfg.InjectRemoteConfigurations(&service) + } + + return &service +} + +func GetNotebookIngress(notebook *robotv1alpha1.Notebook, ingressNamespacedName *types.NamespacedName, robot robotv1alpha1.Robot) *networkingv1.Ingress { + + tenancy := label.GetTenancy(&robot) + + rootDNSConfig := robot.Spec.RootDNSConfig + secretName := robot.Spec.TLSSecretReference.Name + + annotations := map[string]string{ + internal.INGRESS_AUTH_URL_KEY: fmt.Sprintf(internal.INGRESS_AUTH_URL_VAL, tenancy.CloudInstance, rootDNSConfig.Host), + internal.INGRESS_AUTH_SIGNIN_KEY: fmt.Sprintf(internal.INGRESS_AUTH_SIGNIN_VAL, tenancy.CloudInstance, rootDNSConfig.Host), + internal.INGRESS_AUTH_RESPONSE_HEADERS_KEY: internal.INGRESS_AUTH_RESPONSE_HEADERS_VAL, + internal.INGRESS_CONFIGURATION_SNIPPET_KEY: internal.INGRESS_IDE_CONFIGURATION_SNIPPET_VAL, + internal.INGRESS_CERT_MANAGER_KEY: internal.INGRESS_CERT_MANAGER_VAL, + internal.INGRESS_NGINX_PROXY_BUFFER_SIZE_KEY: internal.INGRESS_NGINX_PROXY_BUFFER_SIZE_VAL, + internal.INGRESS_NGINX_REWRITE_TARGET_KEY: internal.INGRESS_NGINX_REWRITE_TARGET_VAL, + internal.INGRESS_PROXY_READ_TIMEOUT_KEY: internal.INGRESS_PROXY_READ_TIMEOUT_VAL, + } + + pathTypePrefix := networkingv1.PathTypePrefix + ingressClassNameNginx := "nginx" + + ingressSpec := networkingv1.IngressSpec{ + TLS: []networkingv1.IngressTLS{ + { + Hosts: []string{ + tenancy.CloudInstance + "." + rootDNSConfig.Host, + }, + SecretName: secretName, + }, + }, + Rules: []networkingv1.IngressRule{ + { + Host: tenancy.CloudInstance + "." + rootDNSConfig.Host, + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + { + Path: robotv1alpha1.GetRobotServicePath(robot, "/notebook") + "(/|$)(.*)", + PathType: &pathTypePrefix, + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: notebook.GetNotebookServiceMetadata().Name, + Port: networkingv1.ServiceBackendPort{ + Number: NOTEBOOK_PORT, + }, + }, + }, + }, + { + Path: robotv1alpha1.GetRobotServicePath(robot, "/file-browser/notebook") + "(/|$)(.*)", + PathType: &pathTypePrefix, + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: notebook.GetNotebookServiceMetadata().Name, + Port: networkingv1.ServiceBackendPort{ + Number: internal.FILE_BROWSER_PORT, + }, + }, + }, + }, + }, + }, + }, + }, + }, + IngressClassName: &ingressClassNameNginx, + } + + ingress := &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: ingressNamespacedName.Name, + Namespace: ingressNamespacedName.Namespace, + Annotations: annotations, + }, + Spec: ingressSpec, + } + + return ingress +} + +func GetNotebookServiceExport(notebook *robotv1alpha1.Notebook, svcExportNamespacedName *types.NamespacedName) *mcsv1alpha1.ServiceExport { + + serviceExport := mcsv1alpha1.ServiceExport{ + ObjectMeta: metav1.ObjectMeta{ + Name: svcExportNamespacedName.Name, + Namespace: svcExportNamespacedName.Namespace, + }, + } + + return &serviceExport +} + +func GetNotebookCustomService(notebook *robotv1alpha1.Notebook, svcNamespacedName *types.NamespacedName, robot robotv1alpha1.Robot) *corev1.Service { + + var ports []corev1.ServicePort + + if portsStr, ok := robot.Spec.AdditionalConfigs[internal.NOTEBOOK_CUSTOM_PORT_RANGE_KEY]; ok { + portsSlice := strings.Split(portsStr.Value, "/") + for _, p := range portsSlice { + portInfo := strings.Split(p, "-") + portName := portInfo[0] + fwdStr := strings.Split(portInfo[1], ":") + nodePortVal, _ := strconv.ParseInt(fwdStr[0], 10, 64) + containerPortVal, _ := strconv.ParseInt(fwdStr[1], 10, 64) + ports = append(ports, corev1.ServicePort{ + Port: int32(containerPortVal), + TargetPort: intstr.IntOrString{ + IntVal: int32(containerPortVal), + }, + NodePort: int32(nodePortVal), + Protocol: corev1.ProtocolTCP, + Name: portName, + }) + } + } + + serviceSpec := corev1.ServiceSpec{ + Type: corev1.ServiceTypeNodePort, // notebook.Spec.ServiceType, + Selector: getNotebookSelector(*notebook), + Ports: ports, + } + + service := corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: svcNamespacedName.Name, + Namespace: svcNamespacedName.Namespace, + }, + Spec: serviceSpec, + } + + return &service +} + +func GetNotebookCustomIngress(notebook *robotv1alpha1.Notebook, ingressNamespacedName *types.NamespacedName, robot robotv1alpha1.Robot) *networkingv1.Ingress { + + tenancy := label.GetTenancy(&robot) + + rootDNSConfig := robot.Spec.RootDNSConfig + secretName := robot.Spec.TLSSecretReference.Name + + annotations := map[string]string{ + internal.INGRESS_AUTH_URL_KEY: fmt.Sprintf(internal.INGRESS_AUTH_URL_VAL, tenancy.CloudInstance, rootDNSConfig.Host), + internal.INGRESS_AUTH_SIGNIN_KEY: fmt.Sprintf(internal.INGRESS_AUTH_SIGNIN_VAL, tenancy.CloudInstance, rootDNSConfig.Host), + internal.INGRESS_AUTH_RESPONSE_HEADERS_KEY: internal.INGRESS_AUTH_RESPONSE_HEADERS_VAL, + internal.INGRESS_CONFIGURATION_SNIPPET_KEY: internal.INGRESS_IDE_CONFIGURATION_SNIPPET_VAL, + internal.INGRESS_CERT_MANAGER_KEY: internal.INGRESS_CERT_MANAGER_VAL, + internal.INGRESS_NGINX_PROXY_BUFFER_SIZE_KEY: internal.INGRESS_NGINX_PROXY_BUFFER_SIZE_VAL, + internal.INGRESS_NGINX_REWRITE_TARGET_KEY: internal.INGRESS_NGINX_REWRITE_TARGET_VAL, + internal.INGRESS_PROXY_READ_TIMEOUT_KEY: internal.INGRESS_PROXY_READ_TIMEOUT_VAL, + } + + pathTypePrefix := networkingv1.PathTypePrefix + ingressClassNameNginx := "nginx" + + var ingressPaths []networkingv1.HTTPIngressPath + + if portsStr, ok := robot.Spec.AdditionalConfigs[internal.NOTEBOOK_CUSTOM_PORT_RANGE_KEY]; ok { + portsSlice := strings.Split(portsStr.Value, "/") + for _, p := range portsSlice { + portInfo := strings.Split(p, "-") + portName := portInfo[0] + fwdStr := strings.Split(portInfo[1], ":") + // nodePortVal, _ := strconv.ParseInt(fwdStr[0], 10, 64) + containerPortVal, _ := strconv.ParseInt(fwdStr[1], 10, 64) + ingressPaths = append(ingressPaths, networkingv1.HTTPIngressPath{ + Path: robotv1alpha1.GetRobotServicePath(robot, "/custom/notebook/"+portName) + "(/|$)(.*)", + PathType: &pathTypePrefix, + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: notebook.GetNotebookCustomServiceMetadata().Name, + Port: networkingv1.ServiceBackendPort{ + Number: int32(containerPortVal), + }, + }, + }, + }) + } + } + + ingressSpec := networkingv1.IngressSpec{ + TLS: []networkingv1.IngressTLS{ + { + Hosts: []string{ + tenancy.CloudInstance + "." + rootDNSConfig.Host, + }, + SecretName: secretName, + }, + }, + Rules: []networkingv1.IngressRule{ + { + Host: tenancy.CloudInstance + "." + rootDNSConfig.Host, + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: ingressPaths, + }, + }, + }, + }, + IngressClassName: &ingressClassNameNginx, + } + + ingress := &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: ingressNamespacedName.Name, + Namespace: ingressNamespacedName.Namespace, + Annotations: annotations, + }, + Spec: ingressSpec, + } + + return ingress +} + +func GetNotebookConfigMap(notebook *robotv1alpha1.Notebook, cmNamespacedName *types.NamespacedName) *corev1.ConfigMap { + + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: cmNamespacedName.Name, + Namespace: cmNamespacedName.Namespace, + }, + Data: map[string]string{ + "custom.conf": internal.CUSTOM_SUPERVISORD_CONFIG, + "custom.sh": internal.CUSTOM_BACKGROUND_SCRIPT, + }, + } + + return cm +} diff --git a/internal/resources/robot_dev_suite.go b/internal/resources/robot_dev_suite.go index 72199d07..289449d2 100644 --- a/internal/resources/robot_dev_suite.go +++ b/internal/resources/robot_dev_suite.go @@ -39,6 +39,24 @@ func GetRobotIDE(robotDevSuite *robotv1alpha1.RobotDevSuite, robotIDENamespacedN return &robotIDE } +func GetNotebook(robotDevSuite *robotv1alpha1.RobotDevSuite, notebookNamespacedName *types.NamespacedName) *robotv1alpha1.Notebook { + + notebook := robotv1alpha1.Notebook{ + ObjectMeta: metav1.ObjectMeta{ + Name: notebookNamespacedName.Name, + Namespace: notebookNamespacedName.Namespace, + Labels: robotDevSuite.Labels, + }, + Spec: robotDevSuite.Spec.NotebookTemplate, + } + + if robotDevSuite.Spec.VDIEnabled { + notebook.Labels[internal.TARGET_VDI_LABEL_KEY] = robotDevSuite.GetRobotVDIMetadata().Name + } + + return ¬ebook +} + func GetRemoteIDERelayServer(robotDevSuite *robotv1alpha1.RobotDevSuite, relayServerNamespacedName *types.NamespacedName) *robotv1alpha1.RelayServer { relayServer := robotv1alpha1.RelayServer{ diff --git a/internal/shared.go b/internal/shared.go index 36c0cce7..25bd3d5e 100644 --- a/internal/shared.go +++ b/internal/shared.go @@ -97,10 +97,21 @@ const ( CONFIGMAP_IDE_POSTFIX = "" ) +// Notebook owned resources' postfixes +const ( + SVC_NOTEBOOK_POSTFIX = "" + POD_NOTEBOOK_POSTFIX = "" + INGRESS_NOTEBOOK_POSTFIX = "" + CUSTOM_PORT_SVC_NOTEBOOK_POSTFIX = "-custom" + CUSTOM_PORT_INGRESS_NOTEBOOK_POSTFIX = "-custom" + CONFIGMAP_NOTEBOOK_POSTFIX = "" +) + // RobotDevSuite owned resources' postfixes const ( ROBOT_VDI_POSTFIX = "-vdi" ROBOT_IDE_POSTFIX = "-ide" + NOTEBOOK_POSTFIX = "-notebook" REMOTE_IDE_RELAY_SERVER_POSTFIX = "-relay" ) @@ -127,11 +138,12 @@ const ( ) const ( - GRANT_PERMISSION_KEY = "GRANT_PERMISSION" - PERSISTENT_DIRS_KEY = "PERSISTENT_DIRS" - HOST_DIRS_KEY = "HOST_DIRS" - IDE_CUSTOM_PORT_RANGE_KEY = "IDE_CUSTOM_PORT_RANGE" - VDI_CUSTOM_PORT_RANGE_KEY = "VDI_CUSTOM_PORT_RANGE" + GRANT_PERMISSION_KEY = "GRANT_PERMISSION" + PERSISTENT_DIRS_KEY = "PERSISTENT_DIRS" + HOST_DIRS_KEY = "HOST_DIRS" + IDE_CUSTOM_PORT_RANGE_KEY = "IDE_CUSTOM_PORT_RANGE" + VDI_CUSTOM_PORT_RANGE_KEY = "VDI_CUSTOM_PORT_RANGE" + NOTEBOOK_CUSTOM_PORT_RANGE_KEY = "NOTEBOOK_CUSTOM_PORT_RANGE" ) // regex diff --git a/main.go b/main.go index e782d556..09e0f045 100644 --- a/main.go +++ b/main.go @@ -45,6 +45,7 @@ import ( relayServer "github.com/robolaunch/robot-operator/pkg/controllers/robot/relay_server" rosBridge "github.com/robolaunch/robot-operator/pkg/controllers/robot/ros_bridge" robotDevSuite "github.com/robolaunch/robot-operator/pkg/controllers/robot_dev_suite" + "github.com/robolaunch/robot-operator/pkg/controllers/robot_dev_suite/notebook" robotIDE "github.com/robolaunch/robot-operator/pkg/controllers/robot_dev_suite/robot_ide" robotVDI "github.com/robolaunch/robot-operator/pkg/controllers/robot_dev_suite/robot_vdi" workspaceManager "github.com/robolaunch/robot-operator/pkg/controllers/workspace_manager" @@ -288,6 +289,18 @@ func startDevCRDsAndWebhooks(mgr manager.Manager, dynamicClient dynamic.Interfac setupLog.Error(err, "unable to create webhook", "webhook", "RobotIDE") os.Exit(1) } + + if err := (¬ebook.NotebookReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "Notebook") + os.Exit(1) + } + if err := (&robotv1alpha1.Notebook{}).SetupWebhookWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create webhook", "webhook", "Notebook") + os.Exit(1) + } } // This function starts Observer CRDs' controllers and webhooks. Here are the CRDs: diff --git a/pkg/api/roboscale.io/v1alpha1/dev_helpers.go b/pkg/api/roboscale.io/v1alpha1/dev_helpers.go index 71599bb7..a902e9d1 100644 --- a/pkg/api/roboscale.io/v1alpha1/dev_helpers.go +++ b/pkg/api/roboscale.io/v1alpha1/dev_helpers.go @@ -24,6 +24,13 @@ func (robotDevSuite *RobotDevSuite) GetRobotIDEMetadata() *types.NamespacedName } } +func (robotDevSuite *RobotDevSuite) GetNotebookMetadata() *types.NamespacedName { + return &types.NamespacedName{ + Namespace: robotDevSuite.Namespace, + Name: robotDevSuite.Name + internal.NOTEBOOK_POSTFIX, + } +} + func (robotDevSuite *RobotDevSuite) GetRemoteIDERelayServerMetadata() *types.NamespacedName { return &types.NamespacedName{ Namespace: robotDevSuite.Namespace, @@ -148,3 +155,68 @@ func (robotvdi *RobotVDI) GetRobotVDICustomIngressMetadata() *types.NamespacedNa Name: robotvdi.Name + internal.CUSTOM_PORT_INGRESS_IDE_POSTFIX, } } + +// ******************************** +// Notebook helpers +// ******************************** + +func (notebook *Notebook) GetNotebookPodMetadata() *types.NamespacedName { + return &types.NamespacedName{ + Namespace: notebook.Namespace, + Name: notebook.Name + internal.POD_NOTEBOOK_POSTFIX, + } +} + +func (notebook *Notebook) GetNotebookServiceMetadata() *types.NamespacedName { + instanceType := label.GetInstanceType(notebook) + if instanceType == label.InstanceTypeCloudInstance { + return &types.NamespacedName{ + Namespace: notebook.Namespace, + Name: notebook.Name + internal.SVC_NOTEBOOK_POSTFIX, + } + } else { + + tenancy := label.GetTenancy(notebook) + + return &types.NamespacedName{ + Namespace: notebook.Namespace, + Name: notebook.Name + internal.SVC_NOTEBOOK_POSTFIX + "-" + tenancy.PhysicalInstance, + } + } +} + +func (notebook *Notebook) GetNotebookServiceExportMetadata() *types.NamespacedName { + return &types.NamespacedName{ + Namespace: notebook.Namespace, + Name: notebook.GetNotebookServiceMetadata().Name, + } + +} + +func (notebook *Notebook) GetNotebookIngressMetadata() *types.NamespacedName { + return &types.NamespacedName{ + Namespace: notebook.Namespace, + Name: notebook.Name + internal.INGRESS_NOTEBOOK_POSTFIX, + } +} + +func (notebook *Notebook) GetNotebookCustomServiceMetadata() *types.NamespacedName { + return &types.NamespacedName{ + Namespace: notebook.Namespace, + Name: notebook.Name + internal.CUSTOM_PORT_SVC_NOTEBOOK_POSTFIX, + } +} + +func (notebook *Notebook) GetNotebookCustomIngressMetadata() *types.NamespacedName { + return &types.NamespacedName{ + Namespace: notebook.Namespace, + Name: notebook.Name + internal.CUSTOM_PORT_INGRESS_NOTEBOOK_POSTFIX, + } +} + +func (notebook *Notebook) GetNotebookConfigMapMetadata() *types.NamespacedName { + return &types.NamespacedName{ + Namespace: notebook.Namespace, + Name: notebook.Name + internal.CONFIGMAP_NOTEBOOK_POSTFIX, + } +} diff --git a/pkg/api/roboscale.io/v1alpha1/dev_types.go b/pkg/api/roboscale.io/v1alpha1/dev_types.go index ab2d8202..0a909570 100644 --- a/pkg/api/roboscale.io/v1alpha1/dev_types.go +++ b/pkg/api/roboscale.io/v1alpha1/dev_types.go @@ -25,6 +25,7 @@ func init() { SchemeBuilder.Register(&RobotDevSuite{}, &RobotDevSuiteList{}) SchemeBuilder.Register(&RobotIDE{}, &RobotIDEList{}) SchemeBuilder.Register(&RobotVDI{}, &RobotVDIList{}) + SchemeBuilder.Register(&Notebook{}, &NotebookList{}) } //+genclient @@ -113,6 +114,28 @@ type RobotVDIList struct { Items []RobotVDI `json:"items"` } +//+genclient +//+kubebuilder:object:root=true +//+kubebuilder:subresource:status + +// Notebook is the Schema for the notebooks API. +type Notebook struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec NotebookSpec `json:"spec,omitempty"` + Status NotebookStatus `json:"status,omitempty"` +} + +//+kubebuilder:object:root=true + +// NotebookList contains a list of Notebook. +type NotebookList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []Notebook `json:"items"` +} + // ******************************** // RobotDevSuite types // ******************************** @@ -127,6 +150,10 @@ type RobotDevSuiteSpec struct { IDEEnabled bool `json:"ideEnabled,omitempty"` // Configurational parameters of RobotIDE. Applied if `.spec.ideEnabled` is set to `true`. RobotIDETemplate RobotIDESpec `json:"robotIDETemplate,omitempty"` + // If `true`, a Notebook will be provisioned inside development suite. + NotebookEnabled bool `json:"notebookEnabled,omitempty"` + // Configurational parameters of Notebook. Applied if `.spec.notebookEnabled` is set to `true`. + NotebookTemplate NotebookSpec `json:"notebookTemplate,omitempty"` // If `true`, a relay server for remote Cloud IDE will be provisioned inside development suite. RemoteIDEEnabled bool `json:"remoteIDEEnabled,omitempty"` // Configurational parameters of remote IDE. Applied if `.spec.remoteIDEEnabled` is set to `true`. @@ -141,6 +168,8 @@ type RobotDevSuiteStatus struct { RobotVDIStatus OwnedRobotServiceStatus `json:"robotVDIStatus,omitempty"` // Status of RobotIDE. RobotIDEStatus OwnedRobotServiceStatus `json:"robotIDEStatus,omitempty"` + // Status of Notebook. + NotebookStatus OwnedRobotServiceStatus `json:"notebookStatus,omitempty"` // Status of remote Cloud IDE RelayServer. Created only if the instance type is Physical Instance. RemoteIDERelayServerStatus OwnedRobotServiceStatus `json:"remoteIDERelayServerStatus,omitempty"` // [*alpha*] Indicates if RobotDevSuite is attached to a Robot and actively provisioned it's resources. @@ -261,3 +290,47 @@ type RobotVDIStatus struct { // Status of Cloud IDE ingress for custom ports service. Created only if the robot has an additional config with key `IDE_CUSTOM_PORT_RANGE` and `.spec.ingress` is `true`. CustomPortIngressStatus OwnedResourceStatus `json:"customPortIngressStatus,omitempty"` } + +// ******************************** +// Notebook types +// ******************************** + +// NotebookSpec defines the desired state of Notebook. +type NotebookSpec struct { + // Resource limitations of Notebook. + Resources Resources `json:"resources,omitempty"` + // Service type of Notebook. `ClusterIP` and `NodePort` is supported. + // +kubebuilder:validation:Enum=ClusterIP;NodePort + // +kubebuilder:default="NodePort" + ServiceType corev1.ServiceType `json:"serviceType,omitempty"` + // If `true`, containers of Notebook will be privileged containers. + // It can be used in physical instances where it's necessary to access + // I/O devices on the host machine. + // Not recommended to activate this field on cloud instances. + Privileged bool `json:"privileged,omitempty"` + // Notebook connects an X11 socket if it's set to `true` and a target Notebook resource is set in labels with key `robolaunch.io/target-vdi`. + // Applications that requires GUI can be executed such as VLC. + Display bool `json:"display,omitempty"` + // [*alpha*] Notebook will create an Ingress resource if `true`. + Ingress bool `json:"ingress,omitempty"` +} + +// NotebookStatus defines the observed state of Notebook. +type NotebookStatus struct { + // Phase of Notebook. + Phase NotebookPhase `json:"phase,omitempty"` + // Status of Notebook pod. + PodStatus OwnedPodStatus `json:"podStatus,omitempty"` + // Status of Notebook service. + ServiceStatus OwnedServiceStatus `json:"serviceStatus,omitempty"` + // Status of Notebook Ingress. + IngressStatus OwnedResourceStatus `json:"ingressStatus,omitempty"` + // Status of Notebook ServiceExport. Created only if the instance type is Physical Instance. + ServiceExportStatus OwnedResourceStatus `json:"serviceExportStatus,omitempty"` + // Status of Notebook service for custom ports. Created only if the robot has an additional config with key `IDE_CUSTOM_PORT_RANGE`. + CustomPortServiceStatus OwnedServiceStatus `json:"customPortServiceStatus,omitempty"` + // Status of Notebook ingress for custom ports service. Created only if the robot has an additional config with key `IDE_CUSTOM_PORT_RANGE` and `.spec.ingress` is `true`. + CustomPortIngressStatus OwnedResourceStatus `json:"customPortIngressStatus,omitempty"` + // Config map status. It's used to add background apps. + ConfigMapStatus OwnedResourceStatus `json:"configMapStatus,omitempty"` +} diff --git a/pkg/api/roboscale.io/v1alpha1/dev_webhook.go b/pkg/api/roboscale.io/v1alpha1/dev_webhook.go index 10beedd6..429cbcd8 100644 --- a/pkg/api/roboscale.io/v1alpha1/dev_webhook.go +++ b/pkg/api/roboscale.io/v1alpha1/dev_webhook.go @@ -11,6 +11,61 @@ import ( "sigs.k8s.io/controller-runtime/pkg/webhook" ) +// ******************************** +// Notebook webhooks +// ******************************** + +// log is for logging in this package. +var notebooklog = logf.Log.WithName("notebook-resource") + +func (r *Notebook) SetupWebhookWithManager(mgr ctrl.Manager) error { + return ctrl.NewWebhookManagedBy(mgr). + For(r). + Complete() +} + +// TODO(user): EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN! + +//+kubebuilder:webhook:path=/mutate-robot-roboscale-io-v1alpha1-notebook,mutating=true,failurePolicy=fail,sideEffects=None,groups=robot.roboscale.io,resources=notebooks,verbs=create;update,versions=v1alpha1,name=mnotebook.kb.io,admissionReviewVersions=v1 + +var _ webhook.Defaulter = &Notebook{} + +// Default implements webhook.Defaulter so a webhook will be registered for the type +func (r *Notebook) Default() { + notebooklog.Info("default", "name", r.Name) + + // TODO(user): fill in your defaulting logic. +} + +// TODO(user): change verbs to "verbs=create;update;delete" if you want to enable deletion validation. +//+kubebuilder:webhook:path=/validate-robot-roboscale-io-v1alpha1-notebook,mutating=false,failurePolicy=fail,sideEffects=None,groups=robot.roboscale.io,resources=notebooks,verbs=create;update,versions=v1alpha1,name=vnotebook.kb.io,admissionReviewVersions=v1 + +var _ webhook.Validator = &Notebook{} + +// ValidateCreate implements webhook.Validator so a webhook will be registered for the type +func (r *Notebook) ValidateCreate() error { + notebooklog.Info("validate create", "name", r.Name) + + // TODO(user): fill in your validation logic upon object creation. + return nil +} + +// ValidateUpdate implements webhook.Validator so a webhook will be registered for the type +func (r *Notebook) ValidateUpdate(old runtime.Object) error { + notebooklog.Info("validate update", "name", r.Name) + + // TODO(user): fill in your validation logic upon object update. + return nil +} + +// ValidateDelete implements webhook.Validator so a webhook will be registered for the type +func (r *Notebook) ValidateDelete() error { + notebooklog.Info("validate delete", "name", r.Name) + + // TODO(user): fill in your validation logic upon object deletion. + return nil +} + // ******************************** // RobotIDE webhooks // ******************************** diff --git a/pkg/api/roboscale.io/v1alpha1/manager_webhook.go b/pkg/api/roboscale.io/v1alpha1/manager_webhook.go index 55221cd9..3f625c4e 100644 --- a/pkg/api/roboscale.io/v1alpha1/manager_webhook.go +++ b/pkg/api/roboscale.io/v1alpha1/manager_webhook.go @@ -101,6 +101,7 @@ func (r *WorkspaceManager) checkTargetRobotLabel() error { return nil } +/* func (r *WorkspaceManager) setRepositoryInfo() error { for k1, ws := range r.Spec.Workspaces { @@ -113,7 +114,7 @@ func (r *WorkspaceManager) setRepositoryInfo() error { repo.Owner = userOrOrg repo.Repo = repoName - lastCommitHash, err := getLastCommitHash(repo) + lastCommitHash, _ := getLastCommitHash(repo) repo.Hash = lastCommitHash ws.Repositories[k2] = repo @@ -122,9 +123,10 @@ func (r *WorkspaceManager) setRepositoryInfo() error { } return nil - } +*/ + func (r *WorkspaceManager) setWorkspacesPath() { if reflect.DeepEqual(r.Spec.WorkspacesPath, "") { r.Spec.WorkspacesPath = defaultWorkspacePath diff --git a/pkg/api/roboscale.io/v1alpha1/phases.go b/pkg/api/roboscale.io/v1alpha1/phases.go index c9b39083..c3747cb2 100644 --- a/pkg/api/roboscale.io/v1alpha1/phases.go +++ b/pkg/api/roboscale.io/v1alpha1/phases.go @@ -95,6 +95,7 @@ const ( RobotDevSuitePhaseRobotNotFound RobotDevSuitePhase = "RobotNotFound" RobotDevSuitePhaseCreatingRobotVDI RobotDevSuitePhase = "CreatingRobotVDI" RobotDevSuitePhaseCreatingRobotIDE RobotDevSuitePhase = "CreatingRobotIDE" + RobotDevSuitePhaseCreatingNotebook RobotDevSuitePhase = "CreatingNotebook" RobotDevSuitePhaseCreatingRelayServerForRemoteIDE RobotDevSuitePhase = "CreatingRelayServerForRemoteIDE" RobotDevSuitePhaseRunning RobotDevSuitePhase = "Running" RobotDevSuitePhaseDeactivating RobotDevSuitePhase = "Deactivating" @@ -125,3 +126,15 @@ const ( RobotVDIPhaseCreatingCustomPortIngress RobotVDIPhase = "CreatingCustomPortIngress" RobotVDIPhaseRunning RobotVDIPhase = "Running" ) + +type NotebookPhase string + +const ( + NotebookPhaseCreatingService NotebookPhase = "CreatingService" + NotebookPhaseCreatingPod NotebookPhase = "CreatingPod" + NotebookPhaseCreatingIngress NotebookPhase = "CreatingIngress" + NotebookPhaseCreatingCustomPortService NotebookPhase = "CreatingCustomPortService" + NotebookPhaseCreatingCustomPortIngress NotebookPhase = "CreatingCustomPortIngress" + NotebookPhaseCreatingConfigMap NotebookPhase = "CreatingConfigMap" + NotebookPhaseRunning NotebookPhase = "Running" +) diff --git a/pkg/api/roboscale.io/v1alpha1/zz_generated.deepcopy.go b/pkg/api/roboscale.io/v1alpha1/zz_generated.deepcopy.go index c40eae78..34d2e672 100644 --- a/pkg/api/roboscale.io/v1alpha1/zz_generated.deepcopy.go +++ b/pkg/api/roboscale.io/v1alpha1/zz_generated.deepcopy.go @@ -897,6 +897,103 @@ func (in *NetworkMetrics) DeepCopy() *NetworkMetrics { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Notebook) DeepCopyInto(out *Notebook) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.Spec = in.Spec + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Notebook. +func (in *Notebook) DeepCopy() *Notebook { + if in == nil { + return nil + } + out := new(Notebook) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *Notebook) 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 *NotebookList) DeepCopyInto(out *NotebookList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]Notebook, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NotebookList. +func (in *NotebookList) DeepCopy() *NotebookList { + if in == nil { + return nil + } + out := new(NotebookList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *NotebookList) 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 *NotebookSpec) DeepCopyInto(out *NotebookSpec) { + *out = *in + out.Resources = in.Resources +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NotebookSpec. +func (in *NotebookSpec) DeepCopy() *NotebookSpec { + if in == nil { + return nil + } + out := new(NotebookSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *NotebookStatus) DeepCopyInto(out *NotebookStatus) { + *out = *in + out.PodStatus = in.PodStatus + in.ServiceStatus.DeepCopyInto(&out.ServiceStatus) + out.IngressStatus = in.IngressStatus + out.ServiceExportStatus = in.ServiceExportStatus + in.CustomPortServiceStatus.DeepCopyInto(&out.CustomPortServiceStatus) + out.CustomPortIngressStatus = in.CustomPortIngressStatus + out.ConfigMapStatus = in.ConfigMapStatus +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NotebookStatus. +func (in *NotebookStatus) DeepCopy() *NotebookStatus { + if in == nil { + return nil + } + out := new(NotebookStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *OwnedPodStatus) DeepCopyInto(out *OwnedPodStatus) { *out = *in @@ -1414,6 +1511,7 @@ func (in *RobotDevSuiteSpec) DeepCopyInto(out *RobotDevSuiteSpec) { *out = *in out.RobotVDITemplate = in.RobotVDITemplate out.RobotIDETemplate = in.RobotIDETemplate + out.NotebookTemplate = in.NotebookTemplate out.RemoteIDERelayServerTemplate = in.RemoteIDERelayServerTemplate } @@ -1432,6 +1530,7 @@ func (in *RobotDevSuiteStatus) DeepCopyInto(out *RobotDevSuiteStatus) { *out = *in in.RobotVDIStatus.DeepCopyInto(&out.RobotVDIStatus) in.RobotIDEStatus.DeepCopyInto(&out.RobotIDEStatus) + in.NotebookStatus.DeepCopyInto(&out.NotebookStatus) in.RemoteIDERelayServerStatus.DeepCopyInto(&out.RemoteIDERelayServerStatus) } diff --git a/pkg/controllers/robot_dev_suite/check.go b/pkg/controllers/robot_dev_suite/check.go index 8b92feaa..ac1fe7fd 100644 --- a/pkg/controllers/robot_dev_suite/check.go +++ b/pkg/controllers/robot_dev_suite/check.go @@ -91,6 +91,47 @@ func (r *RobotDevSuiteReconciler) reconcileCheckRobotIDE(ctx context.Context, in return nil } +func (r *RobotDevSuiteReconciler) reconcileCheckNotebook(ctx context.Context, instance *robotv1alpha1.RobotDevSuite) error { + + notebookQuery := &robotv1alpha1.Notebook{} + err := r.Get(ctx, *instance.GetNotebookMetadata(), notebookQuery) + if err != nil { + if errors.IsNotFound(err) { + instance.Status.NotebookStatus = robotv1alpha1.OwnedRobotServiceStatus{} + } else { + return err + } + } else { + + if instance.Spec.NotebookEnabled { + + if !reflect.DeepEqual(instance.Spec.NotebookTemplate, notebookQuery.Spec) { + notebookQuery.Spec = instance.Spec.NotebookTemplate + err = r.Update(ctx, notebookQuery) + if err != nil { + return err + } + } + + instance.Status.NotebookStatus.Resource.Created = true + reference.SetReference(&instance.Status.NotebookStatus.Resource.Reference, notebookQuery.TypeMeta, notebookQuery.ObjectMeta) + instance.Status.NotebookStatus.Resource.Phase = string(notebookQuery.Status.Phase) + instance.Status.NotebookStatus.Connections = notebookQuery.Status.ServiceStatus.URLs + + } else { + + err := r.Delete(ctx, notebookQuery) + if err != nil { + return err + } + + } + + } + + return nil +} + func (r *RobotDevSuiteReconciler) reconcileCheckRemoteIDERelayServer(ctx context.Context, instance *robotv1alpha1.RobotDevSuite) error { remoteIDERelayServerQuery := &robotv1alpha1.RelayServer{} diff --git a/pkg/controllers/robot_dev_suite/create.go b/pkg/controllers/robot_dev_suite/create.go index fd0e5008..a4722a68 100644 --- a/pkg/controllers/robot_dev_suite/create.go +++ b/pkg/controllers/robot_dev_suite/create.go @@ -51,6 +51,27 @@ func (r *RobotDevSuiteReconciler) reconcileCreateRobotIDE(ctx context.Context, i return nil } +func (r *RobotDevSuiteReconciler) reconcileCreateNotebook(ctx context.Context, instance *robotv1alpha1.RobotDevSuite) error { + + notebook := resources.GetNotebook(instance, instance.GetNotebookMetadata()) + + err := ctrl.SetControllerReference(instance, notebook, r.Scheme) + if err != nil { + return err + } + + err = r.Create(ctx, notebook) + if err != nil && errors.IsAlreadyExists(err) { + return nil + } else if err != nil { + return err + } + + logger.Info("STATUS: Notebook is created.") + + return nil +} + func (r *RobotDevSuiteReconciler) reconcileCreateRemoteIDERelayServer(ctx context.Context, instance *robotv1alpha1.RobotDevSuite) error { remoteIDERelayServer := resources.GetRemoteIDERelayServer(instance, instance.GetRemoteIDERelayServerMetadata()) diff --git a/pkg/controllers/robot_dev_suite/delete.go b/pkg/controllers/robot_dev_suite/delete.go index 2567670e..8ad97825 100644 --- a/pkg/controllers/robot_dev_suite/delete.go +++ b/pkg/controllers/robot_dev_suite/delete.go @@ -84,6 +84,43 @@ func (r *RobotDevSuiteReconciler) reconcileDeleteRobotIDE(ctx context.Context, i return nil } +func (r *RobotDevSuiteReconciler) reconcileDeleteNotebook(ctx context.Context, instance *robotv1alpha1.RobotDevSuite) error { + + notebookQuery := &robotv1alpha1.Notebook{} + err := r.Get(ctx, *instance.GetNotebookMetadata(), notebookQuery) + if err != nil { + if errors.IsNotFound(err) { + instance.Status.NotebookStatus = robotv1alpha1.OwnedRobotServiceStatus{} + } else { + return err + } + } else { + + propagationPolicy := v1.DeletePropagationForeground + err := r.Delete(ctx, notebookQuery, &client.DeleteOptions{ + PropagationPolicy: &propagationPolicy, + }) + if err != nil { + return err + } + + // watch until it's deleted + deleted := false + for !deleted { + notebookQuery := &robotv1alpha1.Notebook{} + err := r.Get(ctx, *instance.GetNotebookMetadata(), notebookQuery) + if err != nil && errors.IsNotFound(err) { + deleted = true + } + time.Sleep(time.Second * 1) + } + + instance.Status.NotebookStatus = robotv1alpha1.OwnedRobotServiceStatus{} + } + + return nil +} + func (r *RobotDevSuiteReconciler) reconcileDeleteRemoteIDERelayServer(ctx context.Context, instance *robotv1alpha1.RobotDevSuite) error { remoteIDERelayServerQuery := &robotv1alpha1.RelayServer{} diff --git a/pkg/controllers/robot_dev_suite/handle.go b/pkg/controllers/robot_dev_suite/handle.go index bf0a7414..0a21cf03 100644 --- a/pkg/controllers/robot_dev_suite/handle.go +++ b/pkg/controllers/robot_dev_suite/handle.go @@ -69,6 +69,36 @@ func (r *RobotDevSuiteReconciler) reconcileHandleRobotIDE(ctx context.Context, i return nil } +func (r *RobotDevSuiteReconciler) reconcileHandleNotebook(ctx context.Context, instance *robotv1alpha1.RobotDevSuite) error { + + if instance.Spec.NotebookEnabled { + if !instance.Status.NotebookStatus.Resource.Created { + instance.Status.Phase = robotv1alpha1.RobotDevSuitePhaseCreatingNotebook + err := r.reconcileCreateNotebook(ctx, instance) + if err != nil { + return err + } + instance.Status.NotebookStatus.Resource.Created = true + + return &robotErr.CreatingResourceError{ + ResourceKind: "Notebook", + ResourceName: instance.GetNotebookMetadata().Name, + ResourceNamespace: instance.GetNotebookMetadata().Namespace, + } + } + + if instance.Status.NotebookStatus.Resource.Phase != string(robotv1alpha1.NotebookPhaseRunning) { + return &robotErr.WaitingForResourceError{ + ResourceKind: "Notebook", + ResourceName: instance.GetNotebookMetadata().Name, + ResourceNamespace: instance.GetNotebookMetadata().Namespace, + } + } + } + + return nil +} + func (r *RobotDevSuiteReconciler) reconcileHandleRemoteIDE(ctx context.Context, instance *robotv1alpha1.RobotDevSuite) error { if instance.Spec.RemoteIDEEnabled && label.GetInstanceType(instance) == label.InstanceTypeCloudInstance { diff --git a/pkg/controllers/robot_dev_suite/notebook/check.go b/pkg/controllers/robot_dev_suite/notebook/check.go new file mode 100644 index 00000000..20d00397 --- /dev/null +++ b/pkg/controllers/robot_dev_suite/notebook/check.go @@ -0,0 +1,183 @@ +package notebook + +import ( + "context" + "strconv" + + "github.com/robolaunch/robot-operator/internal" + "github.com/robolaunch/robot-operator/internal/handle" + "github.com/robolaunch/robot-operator/internal/reference" + mcsv1alpha1 "github.com/robolaunch/robot-operator/pkg/api/external/apis/mcsv1alpha1/v1alpha1" + robotv1alpha1 "github.com/robolaunch/robot-operator/pkg/api/roboscale.io/v1alpha1" + corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" + "k8s.io/apimachinery/pkg/api/errors" +) + +func (r *NotebookReconciler) reconcileCheckService(ctx context.Context, instance *robotv1alpha1.Notebook) error { + + serviceQuery := &corev1.Service{} + err := r.Get(ctx, *instance.GetNotebookServiceMetadata(), serviceQuery) + if err != nil { + if errors.IsNotFound(err) { + instance.Status.ServiceStatus = robotv1alpha1.OwnedServiceStatus{} + } else { + return err + } + } else { + robot, err := r.reconcileGetTargetRobot(ctx, instance) + if err != nil { + return err + } + + instance.Status.ServiceStatus.Resource.Created = true + reference.SetReference(&instance.Status.ServiceStatus.Resource.Reference, serviceQuery.TypeMeta, serviceQuery.ObjectMeta) + if instance.Spec.Ingress { + instance.Status.ServiceStatus.URLs = map[string]string{} + instance.Status.ServiceStatus.URLs["code-server"] = robotv1alpha1.GetRobotServiceDNS(*robot, "https://", "/notebook/") + instance.Status.ServiceStatus.URLs["code-server-file-browser"] = robotv1alpha1.GetRobotServiceDNS(*robot, "https://", "/file-browser/notebook/") + } else if instance.Spec.ServiceType == corev1.ServiceTypeNodePort { + instance.Status.ServiceStatus.URLs = map[string]string{} + instance.Status.ServiceStatus.URLs["code-server"] = robotv1alpha1.GetRobotServiceDNSWithNodePort(*robot, "http://", strconv.Itoa(int(serviceQuery.Spec.Ports[0].NodePort))) + instance.Status.ServiceStatus.URLs["code-server-file-browser"] = robotv1alpha1.GetRobotServiceDNSWithNodePort(*robot, "http://", strconv.Itoa(int(serviceQuery.Spec.Ports[1].NodePort))) + } + } + + return nil +} + +func (r *NotebookReconciler) reconcileCheckPod(ctx context.Context, instance *robotv1alpha1.Notebook) error { + + podQuery := &corev1.Pod{} + err := r.Get(ctx, *instance.GetNotebookPodMetadata(), podQuery) + if err != nil { + if errors.IsNotFound(err) { + instance.Status.PodStatus = robotv1alpha1.OwnedPodStatus{} + } else { + return err + } + } else { + + err = handle.HandlePod(ctx, r, *podQuery) + if err != nil { + return err + } + + instance.Status.PodStatus.Resource.Created = true + reference.SetReference(&instance.Status.PodStatus.Resource.Reference, podQuery.TypeMeta, podQuery.ObjectMeta) + instance.Status.PodStatus.Resource.Phase = string(podQuery.Status.Phase) + instance.Status.PodStatus.IP = podQuery.Status.PodIP + } + + return nil +} + +func (r *NotebookReconciler) reconcileCheckIngress(ctx context.Context, instance *robotv1alpha1.Notebook) error { + + if instance.Spec.Ingress { + ingressQuery := &networkingv1.Ingress{} + err := r.Get(ctx, *instance.GetNotebookIngressMetadata(), ingressQuery) + if err != nil { + if errors.IsNotFound(err) { + instance.Status.IngressStatus = robotv1alpha1.OwnedResourceStatus{} + } else { + return err + } + } else { + instance.Status.IngressStatus.Created = true + reference.SetReference(&instance.Status.IngressStatus.Reference, ingressQuery.TypeMeta, ingressQuery.ObjectMeta) + } + } + + return nil +} + +func (r *NotebookReconciler) reconcileCheckServiceExport(ctx context.Context, instance *robotv1alpha1.Notebook) error { + + serviceExportQuery := &mcsv1alpha1.ServiceExport{} + err := r.Get(ctx, *instance.GetNotebookServiceExportMetadata(), serviceExportQuery) + if err != nil { + if errors.IsNotFound(err) { + instance.Status.ServiceExportStatus = robotv1alpha1.OwnedResourceStatus{} + } else { + return err + } + } else { + instance.Status.ServiceExportStatus.Created = true + reference.SetReference(&instance.Status.ServiceExportStatus.Reference, serviceExportQuery.TypeMeta, serviceExportQuery.ObjectMeta) + } + + return nil +} + +func (r *NotebookReconciler) reconcileCheckCustomService(ctx context.Context, instance *robotv1alpha1.Notebook) error { + + robot, err := r.reconcileGetTargetRobot(ctx, instance) + if err != nil { + return err + } + + if config, ok := robot.Spec.AdditionalConfigs[internal.NOTEBOOK_CUSTOM_PORT_RANGE_KEY]; ok && config.ConfigType == robotv1alpha1.AdditionalConfigTypeOperator { + customSvcQuery := &corev1.Service{} + err := r.Get(ctx, *instance.GetNotebookCustomServiceMetadata(), customSvcQuery) + if err != nil { + if errors.IsNotFound(err) { + instance.Status.CustomPortServiceStatus = robotv1alpha1.OwnedServiceStatus{} + } else { + return err + } + } else { + instance.Status.CustomPortServiceStatus.Resource.Created = true + reference.SetReference(&instance.Status.CustomPortServiceStatus.Resource.Reference, customSvcQuery.TypeMeta, customSvcQuery.ObjectMeta) + } + } + + return nil +} + +func (r *NotebookReconciler) reconcileCheckCustomIngress(ctx context.Context, instance *robotv1alpha1.Notebook) error { + + if instance.Spec.Ingress { + + robot, err := r.reconcileGetTargetRobot(ctx, instance) + if err != nil { + return err + } + + if config, ok := robot.Spec.AdditionalConfigs[internal.NOTEBOOK_CUSTOM_PORT_RANGE_KEY]; ok && config.ConfigType == robotv1alpha1.AdditionalConfigTypeOperator { + customIngressQuery := &networkingv1.Ingress{} + err := r.Get(ctx, *instance.GetNotebookCustomIngressMetadata(), customIngressQuery) + if err != nil { + if errors.IsNotFound(err) { + instance.Status.CustomPortIngressStatus = robotv1alpha1.OwnedResourceStatus{} + } else { + return err + } + } else { + instance.Status.CustomPortIngressStatus.Created = true + reference.SetReference(&instance.Status.CustomPortIngressStatus.Reference, customIngressQuery.TypeMeta, customIngressQuery.ObjectMeta) + } + } + + } + + return nil +} + +func (r *NotebookReconciler) reconcileCheckConfigMap(ctx context.Context, instance *robotv1alpha1.Notebook) error { + + cmQuery := &corev1.ConfigMap{} + err := r.Get(ctx, *instance.GetNotebookConfigMapMetadata(), cmQuery) + if err != nil { + if errors.IsNotFound(err) { + instance.Status.ConfigMapStatus = robotv1alpha1.OwnedResourceStatus{} + } else { + return err + } + } else { + instance.Status.ConfigMapStatus.Created = true + reference.SetReference(&instance.Status.ConfigMapStatus.Reference, cmQuery.TypeMeta, cmQuery.ObjectMeta) + } + + return nil +} diff --git a/pkg/controllers/robot_dev_suite/notebook/create.go b/pkg/controllers/robot_dev_suite/notebook/create.go new file mode 100644 index 00000000..3ad46bf9 --- /dev/null +++ b/pkg/controllers/robot_dev_suite/notebook/create.go @@ -0,0 +1,198 @@ +package notebook + +import ( + "context" + + "github.com/robolaunch/robot-operator/internal/label" + "github.com/robolaunch/robot-operator/internal/resources" + robotv1alpha1 "github.com/robolaunch/robot-operator/pkg/api/roboscale.io/v1alpha1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + ctrl "sigs.k8s.io/controller-runtime" +) + +func (r *NotebookReconciler) reconcileCreateService(ctx context.Context, instance *robotv1alpha1.Notebook) error { + + nbService := resources.GetNotebookService(instance, instance.GetNotebookServiceMetadata()) + + err := ctrl.SetControllerReference(instance, nbService, r.Scheme) + if err != nil { + return err + } + + err = r.Create(ctx, nbService) + if err != nil && errors.IsAlreadyExists(err) { + return nil + } else if err != nil { + return err + } + + logger.Info("STATUS: Notebook service is created.") + + return nil +} + +func (r *NotebookReconciler) reconcileCreatePod(ctx context.Context, instance *robotv1alpha1.Notebook) error { + + robot, err := r.reconcileGetTargetRobot(ctx, instance) + if err != nil { + return err + } + + robotVDI := &robotv1alpha1.RobotVDI{} + if label.GetTargetRobotVDI(instance) != "" { + robotVDI, err = r.reconcileGetTargetRobotVDI(ctx, instance) + if err != nil { + return err + } + } + + activeNode, err := r.reconcileCheckNode(ctx, robot) + if err != nil { + return err + } + + cm := corev1.ConfigMap{} + err = r.Get(ctx, *instance.GetNotebookConfigMapMetadata(), &cm) + if err != nil { + return err + } + + nbPod := resources.GetNotebookPod(instance, instance.GetNotebookPodMetadata(), *robot, *robotVDI, *activeNode, cm) + + err = ctrl.SetControllerReference(instance, nbPod, r.Scheme) + if err != nil { + return err + } + + err = r.Create(ctx, nbPod) + if err != nil && errors.IsAlreadyExists(err) { + return nil + } else if err != nil { + return err + } + + logger.Info("STATUS: Notebook pod is created.") + + return nil +} + +func (r *NotebookReconciler) reconcileCreateIngress(ctx context.Context, instance *robotv1alpha1.Notebook) error { + + robot, err := r.reconcileGetTargetRobot(ctx, instance) + if err != nil { + return err + } + + nbIngress := resources.GetNotebookIngress(instance, instance.GetNotebookIngressMetadata(), *robot) + + err = ctrl.SetControllerReference(instance, nbIngress, r.Scheme) + if err != nil { + return err + } + + err = r.Create(ctx, nbIngress) + if err != nil && errors.IsAlreadyExists(err) { + return nil + } else if err != nil { + return err + } + + logger.Info("STATUS: Notebook ingress is created.") + + return nil +} + +func (r *NotebookReconciler) reconcileCreateServiceExport(ctx context.Context, instance *robotv1alpha1.Notebook) error { + + nbServiceExport := resources.GetNotebookServiceExport(instance, instance.GetNotebookServiceExportMetadata()) + + err := ctrl.SetControllerReference(instance, nbServiceExport, r.Scheme) + if err != nil { + return err + } + + err = r.Create(ctx, nbServiceExport) + if err != nil && errors.IsAlreadyExists(err) { + return nil + } else if err != nil { + return err + } + + logger.Info("STATUS: Notebook service export is created.") + + return nil +} + +func (r *NotebookReconciler) reconcileCreateCustomService(ctx context.Context, instance *robotv1alpha1.Notebook) error { + + robot, err := r.reconcileGetTargetRobot(ctx, instance) + if err != nil { + return err + } + + nbService := resources.GetNotebookCustomService(instance, instance.GetNotebookCustomServiceMetadata(), *robot) + + err = ctrl.SetControllerReference(instance, nbService, r.Scheme) + if err != nil { + return err + } + + err = r.Create(ctx, nbService) + if err != nil && errors.IsAlreadyExists(err) { + return nil + } else if err != nil { + return err + } + + logger.Info("STATUS: Notebook custom service is created.") + + return nil +} + +func (r *NotebookReconciler) reconcileCreateCustomIngress(ctx context.Context, instance *robotv1alpha1.Notebook) error { + + robot, err := r.reconcileGetTargetRobot(ctx, instance) + if err != nil { + return err + } + + nbIngress := resources.GetNotebookCustomIngress(instance, instance.GetNotebookCustomIngressMetadata(), *robot) + + err = ctrl.SetControllerReference(instance, nbIngress, r.Scheme) + if err != nil { + return err + } + + err = r.Create(ctx, nbIngress) + if err != nil && errors.IsAlreadyExists(err) { + return nil + } else if err != nil { + return err + } + + logger.Info("STATUS: Notebook custom ingress is created.") + + return nil +} + +func (r *NotebookReconciler) reconcileCreateConfigMap(ctx context.Context, instance *robotv1alpha1.Notebook) error { + + nbCm := resources.GetNotebookConfigMap(instance, instance.GetNotebookConfigMapMetadata()) + + err := ctrl.SetControllerReference(instance, nbCm, r.Scheme) + if err != nil { + return err + } + + err = r.Create(ctx, nbCm) + if err != nil && errors.IsAlreadyExists(err) { + return nil + } else if err != nil { + return err + } + + logger.Info("STATUS: Notebook config map is created.") + + return nil +} diff --git a/pkg/controllers/robot_dev_suite/notebook/handle.go b/pkg/controllers/robot_dev_suite/notebook/handle.go new file mode 100644 index 00000000..65aefeb1 --- /dev/null +++ b/pkg/controllers/robot_dev_suite/notebook/handle.go @@ -0,0 +1,179 @@ +package notebook + +import ( + "context" + + "github.com/robolaunch/robot-operator/internal" + robotErr "github.com/robolaunch/robot-operator/internal/error" + "github.com/robolaunch/robot-operator/internal/label" + robotv1alpha1 "github.com/robolaunch/robot-operator/pkg/api/roboscale.io/v1alpha1" + corev1 "k8s.io/api/core/v1" +) + +func (r *NotebookReconciler) reconcileHandleService(ctx context.Context, instance *robotv1alpha1.Notebook) error { + + if !instance.Status.ServiceStatus.Resource.Created { + instance.Status.Phase = robotv1alpha1.NotebookPhaseCreatingService + err := r.reconcileCreateService(ctx, instance) + if err != nil { + return err + } + instance.Status.ServiceStatus.Resource.Created = true + + return &robotErr.CreatingResourceError{ + ResourceKind: "Service", + ResourceName: instance.GetNotebookServiceMetadata().Name, + ResourceNamespace: instance.GetNotebookServiceMetadata().Namespace, + } + } + + return nil +} + +func (r *NotebookReconciler) reconcileHandlePod(ctx context.Context, instance *robotv1alpha1.Notebook) error { + + if !instance.Status.PodStatus.Resource.Created { + instance.Status.Phase = robotv1alpha1.NotebookPhaseCreatingPod + err := r.reconcileCreatePod(ctx, instance) + if err != nil { + return err + } + instance.Status.PodStatus.Resource.Created = true + + return &robotErr.CreatingResourceError{ + ResourceKind: "Pod", + ResourceName: instance.GetNotebookPodMetadata().Name, + ResourceNamespace: instance.GetNotebookPodMetadata().Namespace, + } + } + + if instance.Status.PodStatus.Resource.Phase != string(corev1.PodRunning) { + return &robotErr.WaitingForResourceError{ + ResourceKind: "Pod", + ResourceName: instance.GetNotebookPodMetadata().Name, + ResourceNamespace: instance.GetNotebookPodMetadata().Namespace, + } + } + + return nil +} + +func (r *NotebookReconciler) reconcileHandleIngress(ctx context.Context, instance *robotv1alpha1.Notebook) error { + + if instance.Spec.Ingress { + if !instance.Status.IngressStatus.Created { + instance.Status.Phase = robotv1alpha1.NotebookPhaseCreatingIngress + err := r.reconcileCreateIngress(ctx, instance) + if err != nil { + return err + } + instance.Status.IngressStatus.Created = true + + return &robotErr.CreatingResourceError{ + ResourceKind: "Ingress", + ResourceName: instance.GetNotebookIngressMetadata().Name, + ResourceNamespace: instance.GetNotebookIngressMetadata().Namespace, + } + } + } + + return nil +} + +func (r *NotebookReconciler) reconcileHandleServiceExport(ctx context.Context, instance *robotv1alpha1.Notebook) error { + + if label.GetInstanceType(instance) == label.InstanceTypePhysicalInstance { + if !instance.Status.ServiceExportStatus.Created { + err := r.reconcileCreateServiceExport(ctx, instance) + if err != nil { + return err + } + instance.Status.ServiceExportStatus.Created = true + + return &robotErr.CreatingResourceError{ + ResourceKind: "ServiceExport", + ResourceName: instance.GetNotebookServiceExportMetadata().Name, + ResourceNamespace: instance.GetNotebookServiceExportMetadata().Namespace, + } + } + } + + return nil +} + +func (r *NotebookReconciler) reconcileHandleCustomService(ctx context.Context, instance *robotv1alpha1.Notebook) error { + + robot, err := r.reconcileGetTargetRobot(ctx, instance) + if err != nil { + return err + } + + if config, ok := robot.Spec.AdditionalConfigs[internal.NOTEBOOK_CUSTOM_PORT_RANGE_KEY]; ok && config.ConfigType == robotv1alpha1.AdditionalConfigTypeOperator { + if !instance.Status.CustomPortServiceStatus.Resource.Created { + instance.Status.Phase = robotv1alpha1.NotebookPhaseCreatingCustomPortService + err := r.reconcileCreateCustomService(ctx, instance) + if err != nil { + return err + } + instance.Status.CustomPortServiceStatus.Resource.Created = true + + return &robotErr.CreatingResourceError{ + ResourceKind: "Service", + ResourceName: instance.GetNotebookCustomServiceMetadata().Name, + ResourceNamespace: instance.GetNotebookCustomServiceMetadata().Namespace, + } + } + } + + return nil +} + +func (r *NotebookReconciler) reconcileHandleCustomIngress(ctx context.Context, instance *robotv1alpha1.Notebook) error { + + if instance.Spec.Ingress { + + robot, err := r.reconcileGetTargetRobot(ctx, instance) + if err != nil { + return err + } + + if config, ok := robot.Spec.AdditionalConfigs[internal.NOTEBOOK_CUSTOM_PORT_RANGE_KEY]; ok && config.ConfigType == robotv1alpha1.AdditionalConfigTypeOperator { + if !instance.Status.CustomPortIngressStatus.Created { + instance.Status.Phase = robotv1alpha1.NotebookPhaseCreatingCustomPortIngress + err := r.reconcileCreateCustomIngress(ctx, instance) + if err != nil { + return err + } + instance.Status.CustomPortIngressStatus.Created = true + + return &robotErr.CreatingResourceError{ + ResourceKind: "Ingress", + ResourceName: instance.GetNotebookCustomIngressMetadata().Name, + ResourceNamespace: instance.GetNotebookCustomIngressMetadata().Namespace, + } + } + } + } + + return nil +} + +func (r *NotebookReconciler) reconcileHandleConfigMap(ctx context.Context, instance *robotv1alpha1.Notebook) error { + + if !instance.Status.ConfigMapStatus.Created { + instance.Status.Phase = robotv1alpha1.NotebookPhaseCreatingConfigMap + err := r.reconcileCreateConfigMap(ctx, instance) + if err != nil { + return err + } + instance.Status.ConfigMapStatus.Created = true + + return &robotErr.CreatingResourceError{ + ResourceKind: "ConfigMap", + ResourceName: instance.GetNotebookConfigMapMetadata().Name, + ResourceNamespace: instance.GetNotebookConfigMapMetadata().Namespace, + } + } + + return nil +} diff --git a/pkg/controllers/robot_dev_suite/notebook/helpers.go b/pkg/controllers/robot_dev_suite/notebook/helpers.go new file mode 100644 index 00000000..fb0add87 --- /dev/null +++ b/pkg/controllers/robot_dev_suite/notebook/helpers.go @@ -0,0 +1,110 @@ +package notebook + +import ( + "context" + + robotErr "github.com/robolaunch/robot-operator/internal/error" + "github.com/robolaunch/robot-operator/internal/label" + robotv1alpha1 "github.com/robolaunch/robot-operator/pkg/api/roboscale.io/v1alpha1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/selection" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/util/retry" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func (r *NotebookReconciler) reconcileGetInstance(ctx context.Context, meta types.NamespacedName) (*robotv1alpha1.Notebook, error) { + instance := &robotv1alpha1.Notebook{} + err := r.Get(ctx, meta, instance) + if err != nil { + return &robotv1alpha1.Notebook{}, err + } + + return instance, nil +} + +func (r *NotebookReconciler) reconcileUpdateInstanceStatus(ctx context.Context, instance *robotv1alpha1.Notebook) error { + return retry.RetryOnConflict(retry.DefaultRetry, func() error { + instanceLV := &robotv1alpha1.Notebook{} + err := r.Get(ctx, types.NamespacedName{ + Name: instance.Name, + Namespace: instance.Namespace, + }, instanceLV) + + if err == nil { + instance.ResourceVersion = instanceLV.ResourceVersion + } + + err1 := r.Status().Update(ctx, instance) + return err1 + }) +} + +func (r *NotebookReconciler) reconcileGetTargetRobot(ctx context.Context, instance *robotv1alpha1.Notebook) (*robotv1alpha1.Robot, error) { + robot := &robotv1alpha1.Robot{} + err := r.Get(ctx, types.NamespacedName{ + Namespace: instance.Namespace, + Name: label.GetTargetRobot(instance), + }, robot) + if err != nil { + return nil, err + } + + return robot, nil +} + +func (r *NotebookReconciler) reconcileGetTargetRobotVDI(ctx context.Context, instance *robotv1alpha1.Notebook) (*robotv1alpha1.RobotVDI, error) { + robotVDI := &robotv1alpha1.RobotVDI{} + err := r.Get(ctx, types.NamespacedName{ + Namespace: instance.Namespace, + Name: label.GetTargetRobotVDI(instance), + }, robotVDI) + if err != nil { + return nil, err + } + + return robotVDI, nil +} + +func (r *NotebookReconciler) reconcileCheckNode(ctx context.Context, instance *robotv1alpha1.Robot) (*corev1.Node, error) { + + tenancyMap := label.GetTenancyMap(instance) + + requirements := []labels.Requirement{} + for k, v := range tenancyMap { + newReq, err := labels.NewRequirement(k, selection.In, []string{v}) + if err != nil { + return nil, err + } + requirements = append(requirements, *newReq) + } + + nodeSelector := labels.NewSelector().Add(requirements...) + + nodes := &corev1.NodeList{} + err := r.List(ctx, nodes, &client.ListOptions{ + LabelSelector: nodeSelector, + }) + if err != nil { + return nil, err + } + + if len(nodes.Items) == 0 { + return nil, &robotErr.NodeNotFoundError{ + ResourceKind: instance.Kind, + ResourceName: instance.Name, + ResourceNamespace: instance.Namespace, + } + } else if len(nodes.Items) > 1 { + return nil, &robotErr.MultipleNodeFoundError{ + ResourceKind: instance.Kind, + ResourceName: instance.Name, + ResourceNamespace: instance.Namespace, + } + } + + instance.Status.NodeName = nodes.Items[0].Name + + return &nodes.Items[0], nil +} diff --git a/pkg/controllers/robot_dev_suite/notebook/notebook_controller.go b/pkg/controllers/robot_dev_suite/notebook/notebook_controller.go new file mode 100644 index 00000000..ac6269de --- /dev/null +++ b/pkg/controllers/robot_dev_suite/notebook/notebook_controller.go @@ -0,0 +1,188 @@ +/* +Copyright 2022. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package notebook + +import ( + "context" + + corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" + + "github.com/go-logr/logr" + robotErr "github.com/robolaunch/robot-operator/internal/error" + "github.com/robolaunch/robot-operator/internal/label" + mcsv1alpha1 "github.com/robolaunch/robot-operator/pkg/api/external/apis/mcsv1alpha1/v1alpha1" + robotv1alpha1 "github.com/robolaunch/robot-operator/pkg/api/roboscale.io/v1alpha1" +) + +// NotebookReconciler reconciles a Notebook object +type NotebookReconciler struct { + client.Client + Scheme *runtime.Scheme +} + +//+kubebuilder:rbac:groups=robot.roboscale.io,resources=notebooks,verbs=get;list;watch;create;update;patch;delete +//+kubebuilder:rbac:groups=robot.roboscale.io,resources=notebooks/status,verbs=get;update;patch +//+kubebuilder:rbac:groups=robot.roboscale.io,resources=notebooks/finalizers,verbs=update + +//+kubebuilder:rbac:groups=core,resources=pods,verbs=get;list;watch;create;update;patch;delete +//+kubebuilder:rbac:groups=core,resources=services,verbs=get;list;watch;create;update;patch;delete +//+kubebuilder:rbac:groups=networking.k8s.io,resources=ingresses,verbs=get;list;watch;create;update;patch;delete +//+kubebuilder:rbac:groups=multicluster.x-k8s.io,resources=serviceexports,verbs=get;list;watch;create;update;patch;delete + +var logger logr.Logger + +func (r *NotebookReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + logger = log.FromContext(ctx) + + var result ctrl.Result = ctrl.Result{} + + instance, err := r.reconcileGetInstance(ctx, req.NamespacedName) + if err != nil { + if errors.IsNotFound(err) { + return ctrl.Result{}, nil + } + return ctrl.Result{}, err + } + + if !instance.DeletionTimestamp.IsZero() { + return ctrl.Result{}, nil + } + + err = r.reconcileCheckStatus(ctx, instance, &result) + if err != nil { + return result, err + } + + err = r.reconcileUpdateInstanceStatus(ctx, instance) + if err != nil { + return ctrl.Result{}, err + } + + err = r.reconcileCheckResources(ctx, instance) + if err != nil { + return ctrl.Result{}, err + } + + err = r.reconcileUpdateInstanceStatus(ctx, instance) + if err != nil { + return ctrl.Result{}, err + } + + return ctrl.Result{}, nil +} + +func (r *NotebookReconciler) reconcileCheckStatus(ctx context.Context, instance *robotv1alpha1.Notebook, result *ctrl.Result) error { + + err := r.reconcileHandleConfigMap(ctx, instance) + if err != nil { + return robotErr.CheckCreatingOrWaitingError(result, err) + } + + err = r.reconcileHandleService(ctx, instance) + if err != nil { + return robotErr.CheckCreatingOrWaitingError(result, err) + } + + err = r.reconcileHandlePod(ctx, instance) + if err != nil { + return robotErr.CheckCreatingOrWaitingError(result, err) + } + + err = r.reconcileHandleIngress(ctx, instance) + if err != nil { + return robotErr.CheckCreatingOrWaitingError(result, err) + } + + err = r.reconcileHandleServiceExport(ctx, instance) + if err != nil { + return robotErr.CheckCreatingOrWaitingError(result, err) + } + + err = r.reconcileHandleCustomService(ctx, instance) + if err != nil { + return robotErr.CheckCreatingOrWaitingError(result, err) + } + + err = r.reconcileHandleCustomIngress(ctx, instance) + if err != nil { + return robotErr.CheckCreatingOrWaitingError(result, err) + } + + instance.Status.Phase = robotv1alpha1.NotebookPhaseRunning + + return nil +} + +func (r *NotebookReconciler) reconcileCheckResources(ctx context.Context, instance *robotv1alpha1.Notebook) error { + + err := r.reconcileCheckConfigMap(ctx, instance) + if err != nil { + return err + } + + err = r.reconcileCheckService(ctx, instance) + if err != nil { + return err + } + + err = r.reconcileCheckPod(ctx, instance) + if err != nil { + return err + } + + err = r.reconcileCheckIngress(ctx, instance) + if err != nil { + return err + } + + if label.GetInstanceType(instance) == label.InstanceTypePhysicalInstance { + err = r.reconcileCheckServiceExport(ctx, instance) + if err != nil { + return err + } + } + + err = r.reconcileCheckCustomService(ctx, instance) + if err != nil { + return err + } + + err = r.reconcileCheckCustomIngress(ctx, instance) + if err != nil { + return err + } + + return nil +} + +// SetupWithManager sets up the controller with the Manager. +func (r *NotebookReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&robotv1alpha1.Notebook{}). + Owns(&corev1.Pod{}). + Owns(&corev1.Service{}). + Owns(&networkingv1.Ingress{}). + Owns(&mcsv1alpha1.ServiceExport{}). + Owns(&corev1.ConfigMap{}). + Complete(r) +} diff --git a/pkg/controllers/robot_dev_suite/notebook/suite_test.go b/pkg/controllers/robot_dev_suite/notebook/suite_test.go new file mode 100644 index 00000000..fefaf229 --- /dev/null +++ b/pkg/controllers/robot_dev_suite/notebook/suite_test.go @@ -0,0 +1,80 @@ +/* +Copyright 2022. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package notebook + +import ( + "path/filepath" + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/envtest" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + + robotv1alpha1 "github.com/robolaunch/robot-operator/pkg/api/roboscale.io/v1alpha1" + //+kubebuilder:scaffold:imports +) + +// These tests use Ginkgo (BDD-style Go testing framework). Refer to +// http://onsi.github.io/ginkgo/ to learn more about Ginkgo. + +var cfg *rest.Config +var k8sClient client.Client +var testEnv *envtest.Environment + +func TestAPIs(t *testing.T) { + RegisterFailHandler(Fail) + + RunSpecs(t, "Controller Suite") +} + +var _ = BeforeSuite(func() { + logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) + + By("bootstrapping test environment") + testEnv = &envtest.Environment{ + CRDDirectoryPaths: []string{filepath.Join("..", "config", "crd", "bases")}, + ErrorIfCRDPathMissing: true, + } + + var err error + // cfg is defined in this file globally. + cfg, err = testEnv.Start() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg).NotTo(BeNil()) + + err = robotv1alpha1.AddToScheme(scheme.Scheme) + Expect(err).NotTo(HaveOccurred()) + + //+kubebuilder:scaffold:scheme + + k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme}) + Expect(err).NotTo(HaveOccurred()) + Expect(k8sClient).NotTo(BeNil()) + +}) + +var _ = AfterSuite(func() { + By("tearing down the test environment") + err := testEnv.Stop() + Expect(err).NotTo(HaveOccurred()) +}) diff --git a/pkg/controllers/robot_dev_suite/robotdevsuite_controller.go b/pkg/controllers/robot_dev_suite/robotdevsuite_controller.go index 31a2d429..df9daef4 100644 --- a/pkg/controllers/robot_dev_suite/robotdevsuite_controller.go +++ b/pkg/controllers/robot_dev_suite/robotdevsuite_controller.go @@ -52,6 +52,7 @@ type RobotDevSuiteReconciler struct { //+kubebuilder:rbac:groups=robot.roboscale.io,resources=robotdevsuites/finalizers,verbs=update //+kubebuilder:rbac:groups=robot.roboscale.io,resources=robotides,verbs=get;list;watch;create;update;patch;delete +//+kubebuilder:rbac:groups=robot.roboscale.io,resources=notebooks,verbs=get;list;watch;create;update;patch;delete //+kubebuilder:rbac:groups=robot.roboscale.io,resources=robotvdis,verbs=get;list;watch;create;update;patch;delete //+kubebuilder:rbac:groups=robot.roboscale.io,resources=relayservers,verbs=get;list;watch;create;update;patch;delete @@ -132,6 +133,11 @@ func (r *RobotDevSuiteReconciler) reconcileCheckStatus(ctx context.Context, inst return robotErr.CheckCreatingOrWaitingError(result, err) } + err = r.reconcileHandleNotebook(ctx, instance) + if err != nil { + return robotErr.CheckCreatingOrWaitingError(result, err) + } + err = r.reconcileHandleRemoteIDE(ctx, instance) if err != nil { return robotErr.CheckCreatingOrWaitingError(result, err) @@ -148,6 +154,11 @@ func (r *RobotDevSuiteReconciler) reconcileCheckStatus(ctx context.Context, inst return err } + err = r.reconcileDeleteNotebook(ctx, instance) + if err != nil { + return err + } + err = r.reconcileDeleteRobotVDI(ctx, instance) if err != nil { return err @@ -177,6 +188,11 @@ func (r *RobotDevSuiteReconciler) reconcileCheckResources(ctx context.Context, i return err } + err = r.reconcileCheckNotebook(ctx, instance) + if err != nil { + return err + } + err = r.reconcileCheckRemoteIDERelayServer(ctx, instance) if err != nil { return err @@ -191,6 +207,7 @@ func (r *RobotDevSuiteReconciler) SetupWithManager(mgr ctrl.Manager) error { For(&robotv1alpha1.RobotDevSuite{}). Owns(&robotv1alpha1.RobotVDI{}). Owns(&robotv1alpha1.RobotIDE{}). + Owns(&robotv1alpha1.Notebook{}). Owns(&robotv1alpha1.RelayServer{}). Watches( &source.Kind{Type: &robotv1alpha1.Robot{}},