diff --git a/README.md b/README.md index 5394930..35470f3 100644 --- a/README.md +++ b/README.md @@ -22,9 +22,10 @@ This repository is the single source of truth for the interfaces every other Ott ### Custom Resource Definitions -| Group | Kind | Scope | Purpose | -| ------------------------------- | ----------- | ------- | ----------------------------------------------------------- | -| `tenant.otterscale.io/v1alpha1` | `Workspace` | Cluster | Namespace isolation with RBAC, quotas, and network policies | +| Group | Kind | Scope | Purpose | +| -------------------------------- | ----------- | ---------- | ----------------------------------------------------------- | +| `tenant.otterscale.io/v1alpha1` | `Workspace` | Cluster | Namespace isolation with RBAC, quotas, and network policies | +| `console.otterscale.io/v1alpha1` | `Terminal` | Namespaced | A user's interactive kubectl session Pod | ## Generated Artifacts diff --git a/config/crd/bases/console.otterscale.io_terminals.yaml b/config/crd/bases/console.otterscale.io_terminals.yaml new file mode 100644 index 0000000..0699566 --- /dev/null +++ b/config/crd/bases/console.otterscale.io_terminals.yaml @@ -0,0 +1,327 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.20.1 + name: terminals.console.otterscale.io +spec: + group: console.otterscale.io + names: + categories: + - otterscale + kind: Terminal + listKind: TerminalList + plural: terminals + shortNames: + - term + singular: terminal + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.subject + name: Subject + type: string + - jsonPath: .status.phase + name: Phase + type: string + - jsonPath: .status.lastActivityTime + name: LastActivity + type: date + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + Terminal is the Schema for the terminals API. + A Terminal represents a single user's interactive kubectl session Pod, + reconciled by the console operator. + 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: Spec defines the desired behavior of the Terminal. + properties: + idleTimeoutSeconds: + description: |- + IdleTimeoutSeconds is how long to wait, after the last recorded + activity, before the controller deletes this Terminal. Zero uses the + controller's configured default. + format: int64 + minimum: 0 + type: integer + image: + description: |- + Image overrides the terminal/proxy container image. Empty uses the + controller's configured default. Changing this after the Terminal's Pod + has already been created has no effect on the running Pod (Pod + container images are immutable in Kubernetes); delete the Terminal to + have it recreated with the new image. + type: string + resources: + description: |- + Resources overrides compute resources for the terminal/proxy + containers. Empty fields use the controller's configured defaults. + Changing this after the Terminal's Pod has already been created has no + effect on the running Pod; delete the Terminal to have it recreated. + properties: + proxy: + description: |- + Proxy overrides the resources of the "proxy" sidecar container, which + holds the impersonation credential and forwards kubectl traffic. + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + + This field depends on the + DynamicResourceAllocation feature gate. + + This field is immutable. It can only be set for containers. + items: + description: ResourceClaim references one entry in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. It makes that resource available + inside a container. + type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + terminal: + description: |- + Terminal overrides the resources of the "terminal" container, where the + user's kubectl shell actually runs. + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + + This field depends on the + DynamicResourceAllocation feature gate. + + This field is immutable. It can only be set for containers. + items: + description: ResourceClaim references one entry in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. It makes that resource available + inside a container. + type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + type: object + subject: + description: |- + Subject identifies which user this terminal session belongs to. It must + be a lowercase UUID, must match the identity of the user who created, + updated, or deleted this Terminal (enforced by the validating webhook), + and metadata.name must equal "term-" followed by the first 8 characters + of Subject. + pattern: ^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$ + type: string + x-kubernetes-validations: + - message: subject is immutable + rule: self == oldSelf + required: + - subject + type: object + status: + description: Status represents the current information about the Terminal. + properties: + conditions: + description: Conditions store the status conditions of the Terminal. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + lastActivityTime: + description: |- + LastActivityTime is patched by the caller (not the controller) every + time a tab attaches/execs into this session. The controller reads it + (falling back to metadata.creationTimestamp if it was never set) to + decide when to garbage-collect an idle Terminal. + format: date-time + type: string + observedGeneration: + description: |- + ObservedGeneration is the most recent generation observed by the + controller. + format: int64 + type: integer + phase: + description: Phase summarizes the current lifecycle state of the Terminal. + enum: + - Pending + - Creating + - Ready + - Failed + type: string + podName: + description: |- + PodName is the name of the reconciled Pod, in the same namespace as + this Terminal. + type: string + podReady: + description: PodReady mirrors the Ready condition of the reconciled + Pod. + type: boolean + type: object + required: + - spec + type: object + x-kubernetes-validations: + - message: metadata.name must be term- + rule: self.metadata.name == 'term-' + self.spec.subject.substring(0,8) + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index ce01dfb..f24556b 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -3,3 +3,4 @@ # It should be run by config/default resources: - bases/tenant.otterscale.io_workspaces.yaml + - bases/console.otterscale.io_terminals.yaml diff --git a/console/v1alpha1/groupversion_info.go b/console/v1alpha1/groupversion_info.go new file mode 100644 index 0000000..4343f9f --- /dev/null +++ b/console/v1alpha1/groupversion_info.go @@ -0,0 +1,46 @@ +/* +Copyright 2026 The OtterScale Authors. + +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 v1alpha1 contains API Schema definitions for the console v1alpha1 API group. +// +kubebuilder:object:generate=true +// +groupName=console.otterscale.io +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +var ( + // GroupVersion is group version used to register these objects. + GroupVersion = schema.GroupVersion{Group: "console.otterscale.io", Version: "v1alpha1"} + + // SchemeBuilder is used to add go types to the GroupVersionKind scheme. + SchemeBuilder = runtime.NewSchemeBuilder(addKnownTypes) + + // AddToScheme adds the types in this group-version to the given scheme. + AddToScheme = SchemeBuilder.AddToScheme +) + +func addKnownTypes(s *runtime.Scheme) error { + s.AddKnownTypes(GroupVersion, + &Terminal{}, + &TerminalList{}, + ) + metav1.AddToGroupVersion(s, GroupVersion) + return nil +} diff --git a/console/v1alpha1/terminal_types.go b/console/v1alpha1/terminal_types.go new file mode 100644 index 0000000..d0af6db --- /dev/null +++ b/console/v1alpha1/terminal_types.go @@ -0,0 +1,145 @@ +/* +Copyright 2026 The OtterScale Authors. + +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 v1alpha1 + +import ( + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// TerminalResources overrides the compute resources for the containers of a +// Terminal's Pod. Empty fields fall back to the controller's configured +// defaults. +type TerminalResources struct { + // Terminal overrides the resources of the "terminal" container, where the + // user's kubectl shell actually runs. + // +optional + Terminal corev1.ResourceRequirements `json:"terminal,omitzero"` + + // Proxy overrides the resources of the "proxy" sidecar container, which + // holds the impersonation credential and forwards kubectl traffic. + // +optional + Proxy corev1.ResourceRequirements `json:"proxy,omitzero"` +} + +// TerminalSpec defines the desired state of the Terminal. +type TerminalSpec struct { + // Subject identifies which user this terminal session belongs to. It must + // be a lowercase UUID, must match the identity of the user who created, + // updated, or deleted this Terminal (enforced by the validating webhook), + // and metadata.name must equal "term-" followed by the first 8 characters + // of Subject. + // +kubebuilder:validation:Pattern=`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$` + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="subject is immutable" + // +required + Subject string `json:"subject"` + + // Image overrides the terminal/proxy container image. Empty uses the + // controller's configured default. Changing this after the Terminal's Pod + // has already been created has no effect on the running Pod (Pod + // container images are immutable in Kubernetes); delete the Terminal to + // have it recreated with the new image. + // +optional + Image string `json:"image,omitempty"` + + // IdleTimeoutSeconds is how long to wait, after the last recorded + // activity, before the controller deletes this Terminal. Zero uses the + // controller's configured default. + // +kubebuilder:validation:Minimum=0 + // +optional + IdleTimeoutSeconds int64 `json:"idleTimeoutSeconds,omitempty"` + + // Resources overrides compute resources for the terminal/proxy + // containers. Empty fields use the controller's configured defaults. + // Changing this after the Terminal's Pod has already been created has no + // effect on the running Pod; delete the Terminal to have it recreated. + // +optional + Resources TerminalResources `json:"resources,omitzero"` +} + +// TerminalStatus defines the observed state of the Terminal. +type TerminalStatus struct { + // ObservedGeneration is the most recent generation observed by the + // controller. + // +optional + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + + // Phase summarizes the current lifecycle state of the Terminal. + // +kubebuilder:validation:Enum=Pending;Creating;Ready;Failed + // +optional + Phase string `json:"phase,omitempty"` + + // PodName is the name of the reconciled Pod, in the same namespace as + // this Terminal. + // +optional + PodName string `json:"podName,omitempty"` + + // PodReady mirrors the Ready condition of the reconciled Pod. + // +optional + PodReady bool `json:"podReady,omitempty"` + + // LastActivityTime is patched by the caller (not the controller) every + // time a tab attaches/execs into this session. The controller reads it + // (falling back to metadata.creationTimestamp if it was never set) to + // decide when to garbage-collect an idle Terminal. + // +optional + LastActivityTime *metav1.Time `json:"lastActivityTime,omitempty"` + + // Conditions store the status conditions of the Terminal. + // +listType=map + // +listMapKey=type + // +optional + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:storageversion +// +kubebuilder:resource:scope=Namespaced,shortName=term,categories={otterscale} +// +kubebuilder:printcolumn:name="Subject",type=string,JSONPath=`.spec.subject` +// +kubebuilder:printcolumn:name="Phase",type=string,JSONPath=`.status.phase` +// +kubebuilder:printcolumn:name="LastActivity",type=date,JSONPath=`.status.lastActivityTime` +// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp" +// +kubebuilder:validation:XValidation:rule="self.metadata.name == 'term-' + self.spec.subject.substring(0,8)",message="metadata.name must be term-" + +// Terminal is the Schema for the terminals API. +// A Terminal represents a single user's interactive kubectl session Pod, +// reconciled by the console operator. +type Terminal struct { + metav1.TypeMeta `json:",inline"` + + // Standard object's metadata. + // +optional + metav1.ObjectMeta `json:"metadata,omitzero"` + + // Spec defines the desired behavior of the Terminal. + // +required + Spec TerminalSpec `json:"spec"` + + // Status represents the current information about the Terminal. + // +optional + Status TerminalStatus `json:"status,omitzero"` +} + +// +kubebuilder:object:root=true + +// TerminalList contains a list of Terminal resources. +type TerminalList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitzero"` + Items []Terminal `json:"items"` +} diff --git a/console/v1alpha1/zz_generated.deepcopy.go b/console/v1alpha1/zz_generated.deepcopy.go new file mode 100644 index 0000000..38f581b --- /dev/null +++ b/console/v1alpha1/zz_generated.deepcopy.go @@ -0,0 +1,144 @@ +//go:build !ignore_autogenerated + +/* +Copyright 2026 The OtterScale Authors. + +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. +*/ + +// Code generated by controller-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Terminal) DeepCopyInto(out *Terminal) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Terminal. +func (in *Terminal) DeepCopy() *Terminal { + if in == nil { + return nil + } + out := new(Terminal) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *Terminal) 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 *TerminalList) DeepCopyInto(out *TerminalList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]Terminal, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TerminalList. +func (in *TerminalList) DeepCopy() *TerminalList { + if in == nil { + return nil + } + out := new(TerminalList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *TerminalList) 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 *TerminalResources) DeepCopyInto(out *TerminalResources) { + *out = *in + in.Terminal.DeepCopyInto(&out.Terminal) + in.Proxy.DeepCopyInto(&out.Proxy) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TerminalResources. +func (in *TerminalResources) DeepCopy() *TerminalResources { + if in == nil { + return nil + } + out := new(TerminalResources) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TerminalSpec) DeepCopyInto(out *TerminalSpec) { + *out = *in + in.Resources.DeepCopyInto(&out.Resources) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TerminalSpec. +func (in *TerminalSpec) DeepCopy() *TerminalSpec { + if in == nil { + return nil + } + out := new(TerminalSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TerminalStatus) DeepCopyInto(out *TerminalStatus) { + *out = *in + if in.LastActivityTime != nil { + in, out := &in.LastActivityTime, &out.LastActivityTime + *out = (*in).DeepCopy() + } + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TerminalStatus. +func (in *TerminalStatus) DeepCopy() *TerminalStatus { + if in == nil { + return nil + } + out := new(TerminalStatus) + in.DeepCopyInto(out) + return out +}