From 20b7dc36271a0efa0d35a342283238d3a6176abe Mon Sep 17 00:00:00 2001 From: Alex Savanovich <40720931+savme@users.noreply.github.com> Date: Thu, 12 Nov 2026 15:20:52 +0100 Subject: [PATCH] feat: add execTerminal capability validation --- internal/cmd/compute/exec/exec_test.go | 12 +++++++ internal/cmd/compute/exec/session.go | 18 ++++++++++ .../instanceconsolesession_validation.go | 14 ++++++-- .../instanceconsolesession_validation_test.go | 36 +++++++++++++++++++ ui/consumer/src/lib/instance-shell.test.ts | 17 +++++++-- ui/consumer/src/lib/instance-shell.ts | 6 ++-- 6 files changed, 97 insertions(+), 6 deletions(-) diff --git a/internal/cmd/compute/exec/exec_test.go b/internal/cmd/compute/exec/exec_test.go index db1ecf61..235a2921 100644 --- a/internal/cmd/compute/exec/exec_test.go +++ b/internal/cmd/compute/exec/exec_test.go @@ -14,6 +14,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/validation/field" "k8s.io/utils/ptr" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" @@ -237,6 +238,7 @@ func TestExitFor(t *testing.T) { func TestCreateError(t *testing.T) { gr := schema.GroupResource{Group: "compute.datumapis.com", Resource: "instanceconsolesessions"} + gk := schema.GroupKind{Group: "compute.datumapis.com", Kind: "InstanceConsoleSession"} quota := k8serrors.NewForbidden(gr, "", errors.New( "You've reached your quota for this resource type (need 1, 0 available). Delete unused resources to free up capacity, or contact support to request a higher limit.")) @@ -254,6 +256,16 @@ func TestCreateError(t *testing.T) { want: "shell sessions aren't enabled for this project, or too many are open in it"}, {name: "not a quota denial", err: k8serrors.NewForbidden(gr, "", errors.New("user cannot create instanceconsolesessions")), c: newFakeClient(t), want: "user cannot create", contains: true}, + {name: "no terminals", err: k8serrors.NewInvalid(gk, "", field.ErrorList{ + field.Forbidden(field.NewPath("spec", "terminal"), `the "unikernel" runtime class does not support interactive terminals in shell sessions`), + }), c: newFakeClient(t), + want: `the "unikernel" runtime class does not support interactive terminals in shell sessions. Run the command without -t`}, + {name: "another terminal error", err: k8serrors.NewInvalid(gk, "", field.ErrorList{ + field.Invalid(field.NewPath("spec", "terminal"), true, "a terminal requires stdin"), + }), c: newFakeClient(t), want: "a terminal requires stdin", contains: true}, + {name: "another refused field", err: k8serrors.NewInvalid(gk, "", field.ErrorList{ + field.Forbidden(field.NewPath("spec", "instanceRef"), "does not support shell sessions"), + }), c: newFakeClient(t), want: "does not support shell sessions", contains: true}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { diff --git a/internal/cmd/compute/exec/session.go b/internal/cmd/compute/exec/session.go index abb58b1c..ab726706 100644 --- a/internal/cmd/compute/exec/session.go +++ b/internal/cmd/compute/exec/session.go @@ -96,6 +96,9 @@ func pickContainer(inst *computev1alpha.Instance, name string) (string, error) { // either off for the project, which has no allowance for them, or all in use. // Reading the allowance needs list on the project's AllowanceBuckets. func createError(ctx context.Context, c client.Client, err error) error { + if detail, ok := terminalRefusal(err); ok { + return fmt.Errorf("%s. Run the command without -t", detail) + } if !k8serrors.IsForbidden(err) || !strings.Contains(err.Error(), "reached your quota") { return fmt.Errorf("creating session: %w", err) } @@ -110,6 +113,21 @@ func createError(ctx context.Context, c client.Client, err error) error { } } +// terminalRefusal returns admission's reason when it forbade the session's +// terminal, which the -t flag asks for. +func terminalRefusal(err error) (string, bool) { + var status k8serrors.APIStatus + if !errors.As(err, &status) || status.Status().Details == nil { + return "", false + } + for _, cause := range status.Status().Details.Causes { + if cause.Field == "spec.terminal" && cause.Type == metav1.CauseTypeForbidden { + return strings.TrimPrefix(cause.Message, "Forbidden: "), true + } + } + return "", false +} + // waitReady watches the session until the platform says where to connect, or // ends it first. func waitReady(ctx context.Context, c client.WithWatch, s *computev1alpha.InstanceConsoleSession, timeout time.Duration) (*computev1alpha.InstanceConsoleSession, error) { diff --git a/internal/validation/instanceconsolesession_validation.go b/internal/validation/instanceconsolesession_validation.go index 1d8e5eab..9f106753 100644 --- a/internal/validation/instanceconsolesession_validation.go +++ b/internal/validation/instanceconsolesession_validation.go @@ -59,7 +59,7 @@ func ValidateInstanceConsoleSessionCreate( allErrs := field.ErrorList{} allErrs = append(allErrs, validateSessionContainer(session.Spec.ContainerName, instance, specPath.Child("containerName"))...) - allErrs = append(allErrs, validateSessionRuntimeClass(instance, opts.RuntimeClasses, refPath)...) + allErrs = append(allErrs, validateSessionRuntimeClass(session, instance, opts.RuntimeClasses, refPath, specPath.Child("terminal"))...) return allErrs } @@ -80,7 +80,12 @@ func validateSessionContainer(containerName string, instance *computev1alpha.Ins return field.ErrorList{field.NotSupported(fldPath, containerName, names)} } -func validateSessionRuntimeClass(instance *computev1alpha.Instance, catalog runtimeclass.Catalog, fldPath *field.Path) field.ErrorList { +func validateSessionRuntimeClass( + session *computev1alpha.InstanceConsoleSession, + instance *computev1alpha.Instance, + catalog runtimeclass.Catalog, + fldPath, terminalPath *field.Path, +) field.ErrorList { className := instance.Spec.Runtime.Class if className == "" { className = instance.Labels[computev1alpha.RuntimeClassLabel] @@ -99,6 +104,11 @@ func validateSessionRuntimeClass(instance *computev1alpha.Instance, catalog runt return field.ErrorList{field.Forbidden(fldPath, fmt.Sprintf( "%s does not support shell sessions", capabilities.ClassDescription()))} } + if session.Spec.Terminal && !capabilities.Supports(runtimeclass.FeatureExecTerminal) { + return field.ErrorList{field.Forbidden(terminalPath, fmt.Sprintf( + "%s does not support %s", + capabilities.ClassDescription(), runtimeclass.FeatureExecTerminal.Description()))} + } return nil } diff --git a/internal/validation/instanceconsolesession_validation_test.go b/internal/validation/instanceconsolesession_validation_test.go index a0eb8ef3..6b855401 100644 --- a/internal/validation/instanceconsolesession_validation_test.go +++ b/internal/validation/instanceconsolesession_validation_test.go @@ -20,6 +20,7 @@ const ( sessionAdmissionInstanceName = "web-0" sessionAdmissionContainer = "app" sessionAdmissionRefField = "spec.instanceRef" + sessionAdmissionExecOnly = "exec-only" ) func sessionAdmissionInstance(mutate ...func(*computev1alpha.Instance)) *computev1alpha.Instance { @@ -57,6 +58,16 @@ func sessionAdmissionCatalog() runtimeclass.Catalog { ObjectMeta: metav1.ObjectMeta{Name: "general-purpose"}, Spec: computev1alpha.RuntimeClassSpec{ Default: true, + Capabilities: computev1alpha.RuntimeClassCapabilities{ + Features: []computev1alpha.RuntimeClassFeature{ + runtimeclass.FeatureSandboxRuntime, runtimeclass.FeatureExec, runtimeclass.FeatureExecTerminal, + }, + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: sessionAdmissionExecOnly}, + Spec: computev1alpha.RuntimeClassSpec{ Capabilities: computev1alpha.RuntimeClassCapabilities{ Features: []computev1alpha.RuntimeClassFeature{runtimeclass.FeatureSandboxRuntime, runtimeclass.FeatureExec}, }, @@ -157,6 +168,31 @@ func TestValidateInstanceConsoleSessionCreate(t *testing.T) { wantField: sessionAdmissionRefField, wantMsg: `the "unikernel" runtime class does not support shell sessions`, }, + { + name: "a terminal session in a class with terminals is admitted", + session: func(s *computev1alpha.InstanceConsoleSession) { + s.Spec.Terminal = true + }, + instance: sessionAdmissionInstance(), + }, + { + name: "a terminal session in a class without terminals is refused", + session: func(s *computev1alpha.InstanceConsoleSession) { + s.Spec.Terminal = true + }, + instance: sessionAdmissionInstance(func(i *computev1alpha.Instance) { + i.Spec.Runtime.Class = sessionAdmissionExecOnly + }), + wantType: field.ErrorTypeForbidden, + wantField: "spec.terminal", + wantMsg: `the "exec-only" runtime class does not support interactive terminals in shell sessions`, + }, + { + name: "a session without a terminal in a class without terminals is admitted", + instance: sessionAdmissionInstance(func(i *computev1alpha.Instance) { + i.Spec.Runtime.Class = sessionAdmissionExecOnly + }), + }, { name: "a class missing from the catalog is refused", instance: sessionAdmissionInstance(func(i *computev1alpha.Instance) { diff --git a/ui/consumer/src/lib/instance-shell.test.ts b/ui/consumer/src/lib/instance-shell.test.ts index 981461f2..54bad63f 100644 --- a/ui/consumer/src/lib/instance-shell.test.ts +++ b/ui/consumer/src/lib/instance-shell.test.ts @@ -12,7 +12,14 @@ const classes: RuntimeClassSummary[] = toRuntimeClasses({ items: [ { metadata: { name: 'general-purpose' }, - spec: { default: true, capabilities: { features: ['sandboxRuntime', 'exec'] } }, + spec: { + default: true, + capabilities: { features: ['sandboxRuntime', 'exec', 'execTerminal'] }, + }, + }, + { + metadata: { name: 'exec-only' }, + spec: { capabilities: { features: ['sandboxRuntime', 'exec'] } }, }, { metadata: { name: 'unikernel' }, @@ -44,7 +51,7 @@ describe('canShowShell', () => { containers: ['app'], }; - test('shows with permission, an allowance, an exec class and a container', () => { + test('shows with permission, an allowance, a class with terminals and a container', () => { expect(canShowShell(shown)).toBe(true); }); @@ -66,6 +73,12 @@ describe('canShowShell', () => { ).toBe(false); }); + test('hides when the runtime class has exec but no terminals', () => { + expect( + canShowShell({ ...shown, runtimeClass: resolveRuntimeClass('exec-only', classes) }) + ).toBe(false); + }); + test('hides when the runtime class is unknown', () => { expect(canShowShell({ ...shown, runtimeClass: undefined })).toBe(false); }); diff --git a/ui/consumer/src/lib/instance-shell.ts b/ui/consumer/src/lib/instance-shell.ts index b5875fdf..b1a9eddf 100644 --- a/ui/consumer/src/lib/instance-shell.ts +++ b/ui/consumer/src/lib/instance-shell.ts @@ -1,4 +1,6 @@ -export const EXEC_FEATURE = 'exec'; +// The portal only opens shells with a terminal, so it needs a class that +// serves terminals, not just exec. +export const TERMINAL_FEATURE = 'execTerminal'; export interface RuntimeClassSummary { name: string; @@ -46,7 +48,7 @@ export function canShowShell({ clientBundled && canCreateSessions && sessionAllowance !== 0 && - !!runtimeClass?.features.includes(EXEC_FEATURE) && + !!runtimeClass?.features.includes(TERMINAL_FEATURE) && containers.length > 0 ); }