Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions internal/cmd/compute/exec/exec_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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."))

Expand All @@ -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) {
Expand Down
18 changes: 18 additions & 0 deletions internal/cmd/compute/exec/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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) {
Expand Down
14 changes: 12 additions & 2 deletions internal/validation/instanceconsolesession_validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -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]
Expand All @@ -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
}

Expand Down
36 changes: 36 additions & 0 deletions internal/validation/instanceconsolesession_validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const (
sessionAdmissionInstanceName = "web-0"
sessionAdmissionContainer = "app"
sessionAdmissionRefField = "spec.instanceRef"
sessionAdmissionExecOnly = "exec-only"
)

func sessionAdmissionInstance(mutate ...func(*computev1alpha.Instance)) *computev1alpha.Instance {
Expand Down Expand Up @@ -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},
},
Expand Down Expand Up @@ -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) {
Expand Down
17 changes: 15 additions & 2 deletions ui/consumer/src/lib/instance-shell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down Expand Up @@ -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);
});

Expand All @@ -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);
});
Expand Down
6 changes: 4 additions & 2 deletions ui/consumer/src/lib/instance-shell.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -46,7 +48,7 @@ export function canShowShell({
clientBundled &&
canCreateSessions &&
sessionAllowance !== 0 &&
!!runtimeClass?.features.includes(EXEC_FEATURE) &&
!!runtimeClass?.features.includes(TERMINAL_FEATURE) &&
containers.length > 0
);
}
Expand Down
Loading