fix: Refactor Kubernetes client with respect to OpenShift and Microshift (#2317)

* fix: Refactor Kubernetes client with respect to OpenShift and Microshift
* Code smells from @benoitf review
* Add support for Restricted security profile (MicroShift)

Fixes #2260

Signed-off-by: Jeff MAURY <jmaury@redhat.com>
This commit is contained in:
Jeff MAURY 2023-05-04 16:48:05 +02:00 committed by GitHub
parent 9af5b48892
commit d1699e76d7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 222 additions and 13 deletions

View file

@ -1407,6 +1407,10 @@ export class PluginSystem {
},
);
this.ipcHandle('kubernetes-client:isAPIGroupSupported', async (_listener, group): Promise<boolean> => {
return kubernetesClient.isAPIGroupSupported(group);
});
this.ipcHandle('kubernetes-client:getCurrentContextName', async (): Promise<string | undefined> => {
return kubernetesClient.getCurrentContextName();
});

View file

@ -27,8 +27,9 @@ import type {
V1Ingress,
V1ContainerState,
V1APIResource,
V1APIGroup,
} from '@kubernetes/client-node';
import { NetworkingV1Api } from '@kubernetes/client-node';
import { ApisApi, NetworkingV1Api } from '@kubernetes/client-node';
import { AppsV1Api } from '@kubernetes/client-node';
import { CustomObjectsApi } from '@kubernetes/client-node';
import { CoreV1Api, KubeConfig, Log, Watch, VersionApi } from '@kubernetes/client-node';
@ -85,9 +86,7 @@ function toPodInfo(pod: V1Pod): PodInfo {
};
}
const OPENSHIFT_CONSOLE_NAMESPACE = 'openshift-config-managed';
const OPENSHIFT_CONSOLE_CONFIG_MAP = 'console-public';
const OPENSHIFT_PROJECT_API_GROUP = 'project.openshift.io';
/**
* Handle calls to kubernetes API
@ -108,6 +107,8 @@ export class KubernetesClient {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private kubeWatcher: any | undefined;
private apiGroups = new Array<V1APIGroup>();
/*
a Cache of API resources for the cluster. This is used to compute the plural when dealing
with custom resources. The key is the apiGroup (including version) like 'networking.k8s.io/v1'
@ -214,6 +215,22 @@ export class KubernetesClient {
}
}
async fetchAPIGroups() {
this.apiGroups = [];
try {
if (this.kubeConfig) {
const result = await this.kubeConfig.makeApiClient(ApisApi).getAPIVersions();
this.apiGroups = result?.body.groups;
}
} catch (err) {
console.log(`Error while fetching API groups: ${err}`);
}
}
async isAPIGroupSupported(group: string): Promise<boolean> {
return this.apiGroups.filter(g => g.name === group).length > 0;
}
getContexts(): Context[] {
return this.kubeConfig.contexts;
}
@ -240,11 +257,11 @@ export class KubernetesClient {
let namespace;
try {
const cm = await this.readNamespacedConfigMap(OPENSHIFT_CONSOLE_CONFIG_MAP, OPENSHIFT_CONSOLE_NAMESPACE);
if (cm) {
const projectGroupSupported = await this.isAPIGroupSupported(OPENSHIFT_PROJECT_API_GROUP);
if (projectGroupSupported) {
const projects = await ctx
.makeApiClient(CustomObjectsApi)
.listClusterCustomObject('project.openshift.io', 'v1', 'projects');
.listClusterCustomObject(OPENSHIFT_PROJECT_API_GROUP, 'v1', 'projects');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if ((projects?.body as any)?.items.length > 0) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -287,6 +304,7 @@ export class KubernetesClient {
}
this.setupKubeWatcher();
this.apiResources.clear();
await this.fetchAPIGroups();
this.apiSender.send('pod-event');
}

View file

@ -1114,6 +1114,10 @@ function initExposure(): void {
},
);
contextBridge.exposeInMainWorld('kubernetesIsAPIGroupSupported', async (group: string): Promise<boolean> => {
return ipcInvoke('kubernetes-client:isAPIGroupSupported', group);
});
contextBridge.exposeInMainWorld('kubernetesCreatePod', async (namespace: string, pod: V1Pod): Promise<V1Pod> => {
return ipcInvoke('kubernetes-client:createPod', namespace, pod);
});

View file

@ -35,6 +35,7 @@ const telemetryTrackMock = vi.fn();
const kubernetesCreatePodMock = vi.fn();
const kubernetesCreateIngressMock = vi.fn();
const kubernetesCreateServiceMock = vi.fn();
const kubernetesIsAPIGroupSupported = vi.fn();
beforeEach(() => {
Object.defineProperty(window, 'generatePodmanKube', {
@ -62,6 +63,9 @@ beforeEach(() => {
Object.defineProperty(window, 'kubernetesCreateService', {
value: kubernetesCreateServiceMock,
});
Object.defineProperty(window, 'kubernetesIsAPIGroupSupported', {
value: kubernetesIsAPIGroupSupported,
});
Object.defineProperty(window, 'telemetryTrack', {
value: telemetryTrackMock,
});
@ -129,8 +133,9 @@ test('Expect to send telemetry event with OpenShift', async () => {
data: {
consoleURL: 'https://console-openshift-console.apps.cluster-1.example.com',
},
}),
await waitRender({});
});
kubernetesIsAPIGroupSupported.mockResolvedValue(true);
await waitRender({});
const createButton = screen.getByRole('button', { name: 'Deploy' });
expect(createButton).toBeInTheDocument();
expect(createButton).toBeEnabled();
@ -173,6 +178,9 @@ test('When deploying a pod, volumes should not be added (they are deleted by pod
expect(createButton).toBeInTheDocument();
expect(createButton).toBeEnabled();
const useRestricted = screen.getByTestId('useRestricted');
await fireEvent.click(useRestricted);
// Press the deploy button
await fireEvent.click(createButton);
@ -192,6 +200,41 @@ test('When deploying a pod, volumes should not be added (they are deleted by pod
);
});
test('When deploying a pod, restricted security context is added', async () => {
await waitRender({});
const createButton = screen.getByRole('button', { name: 'Deploy' });
expect(createButton).toBeInTheDocument();
expect(createButton).toBeEnabled();
// Press the deploy button
await fireEvent.click(createButton);
// Expect kubernetesCreatePod to be called with default namespace and a modified bodyPod with volumes removed
await waitFor(() =>
expect(kubernetesCreatePodMock).toBeCalledWith('default', {
metadata: { name: 'hello' },
spec: {
containers: [
{
name: 'hello',
image: 'hello-world',
securityContext: {
allowPrivilegeEscalation: false,
capabilities: {
drop: ['ALL'],
},
runAsNonRoot: true,
seccompProfile: {
type: 'RuntimeDefault',
},
},
},
],
},
}),
);
});
test('Fail to deploy ingress if service is not selected', async () => {
await waitRender({});
const createButton = screen.getByRole('button', { name: 'Deploy' });

View file

@ -7,6 +7,7 @@ import type { V1Route } from '../../../../main/src/plugin/api/openshift-types';
import type { V1NamespaceList } from '@kubernetes/client-node/dist/api';
import ErrorMessage from '../ui/ErrorMessage.svelte';
import WarningMessage from '../ui/WarningMessage.svelte';
import { ensureRestrictedSecurityContext } from '/@/lib/pod/pod-utils';
export let resourceId: string;
export let engineId: string;
@ -22,9 +23,11 @@ let deployError = '';
let deployWarning = '';
let updatePodInterval: NodeJS.Timeout;
let openshiftConsoleURL: string;
let openshiftRouteGroupSupported = false;
let deployUsingServices = true;
let deployUsingRoutes = true;
let deployUsingRestrictedSecurityContext = true;
let createdPod = undefined;
let bodyPod;
@ -62,6 +65,7 @@ onMount(async () => {
// check if there is OpenShift and then grab openshift console URL
try {
openshiftRouteGroupSupported = await window.kubernetesIsAPIGroupSupported('route.openshift.io');
const openshiftConfigMap = await window.kubernetesReadNamespacedConfigMap(
'console-public',
'openshift-config-managed',
@ -158,7 +162,7 @@ async function deployToKube() {
};
servicesToCreate.push(service);
if (openshiftConsoleURL && deployUsingRoutes) {
if (openshiftRouteGroupSupported && deployUsingRoutes) {
// Create OpenShift route object
const route = {
apiVersion: 'route.openshift.io/v1',
@ -250,7 +254,7 @@ async function deployToKube() {
useRoutes: deployUsingRoutes,
createIngress: createIngress,
};
if (openshiftConsoleURL) {
if (openshiftRouteGroupSupported) {
eventProperties['isOpenshift'] = true;
}
@ -281,6 +285,10 @@ async function deployToKube() {
});
}
if (deployUsingRestrictedSecurityContext) {
ensureRestrictedSecurityContext(bodyPod);
}
// create pod
createdPod = await window.kubernetesCreatePod(currentNamespace, bodyPod);
@ -361,8 +369,25 @@ function updateKubeResult() {
policy may prevent to use hostPort.</span>
</div>
<div class="pt-2 pb-4">
<label for="useRestricted" class="block mb-1 text-sm font-medium text-gray-300"
>Use restricted security context</label>
<input
type="checkbox"
bind:checked="{deployUsingRestrictedSecurityContext}"
name="useRestricted"
id="useRestricted"
data-testid="useRestricted"
class=""
required />
<span class="text-gray-400 text-sm ml-1"
>Update Kubernetes manifest to respect the Pod security <a
href="https://kubernetes.io/docs/concepts/security/pod-security-standards#restricted">restricted profile</a
>.</span>
</div>
<!-- Only show for non-OpenShift deployments (we use routes for OpenShift) -->
{#if !openshiftConsoleURL && deployUsingServices}
{#if !openshiftRouteGroupSupported && deployUsingServices}
<div class="pt-2 pb-4">
<label for="createIngress" class="block mb-1 text-sm font-medium text-gray-300"
>Expose service locally using Kubernetes Ingress:</label>
@ -401,7 +426,7 @@ function updateKubeResult() {
{/if}
<!-- Allow to create routes for OpenShift clusters -->
{#if openshiftConsoleURL}
{#if openshiftRouteGroupSupported}
<div class="pt-2 m-2">
<label for="routes" class="block mb-1 text-sm font-medium text-gray-400">Create OpenShift routes:</label>
<input type="checkbox" bind:checked="{deployUsingRoutes}" name="useRoutes" id="useRoutes" class="" required />

View file

@ -0,0 +1,81 @@
/**********************************************************************
* Copyright (C) 2023 Red Hat, Inc.
*
* 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.
*
* SPDX-License-Identifier: Apache-2.0
***********************************************************************/
/* eslint-disable @typescript-eslint/no-explicit-any */
import { test, expect } from 'vitest';
import { ensureRestrictedSecurityContext } from '/@/lib/pod/pod-utils';
function verifyPodSecurityContext(containers: any[], type = 'RuntimeDefault') {
containers.forEach(container => {
const securityContext = container.securityContext;
expect(securityContext).toBeDefined();
expect(securityContext.allowPrivilegeEscalation).toBeFalsy();
expect(securityContext.runAsNonRoot).toBeTruthy();
expect(securityContext.seccompProfile).toBeDefined();
expect(securityContext.seccompProfile.type).toBe(type);
expect(securityContext.capabilities).toBeDefined();
expect(securityContext.capabilities.drop).toBeDefined();
expect(securityContext.capabilities.drop).toContain('ALL');
});
}
test('Expect security context to be added to single container pod', async () => {
const pod = {
kind: 'Pod',
apiversion: 'v1',
spec: {
containers: [{ image: 'image' }],
},
};
ensureRestrictedSecurityContext(pod);
verifyPodSecurityContext(pod.spec.containers);
});
test('Expect security context to be keep seccompProfile.type', async () => {
const pod = {
kind: 'Pod',
apiversion: 'v1',
spec: {
containers: [
{
image: 'image',
securityContext: {
seccompProfile: {
type: 'Localhost',
},
},
},
],
},
};
ensureRestrictedSecurityContext(pod);
verifyPodSecurityContext(pod.spec.containers, 'Localhost');
});
test('Expect security context to be added to dual containers pod', async () => {
const pod = {
kind: 'Pod',
apiversion: 'v1',
spec: {
containers: [{ image: 'image1' }, { image: 'image2' }],
},
};
ensureRestrictedSecurityContext(pod);
verifyPodSecurityContext(pod.spec.containers);
});

View file

@ -20,6 +20,7 @@ import moment from 'moment';
import humanizeDuration from 'humanize-duration';
import type { PodInfo } from '../../../../main/src/plugin/api/pod-info';
import type { PodInfoUI } from './PodInfoUI';
export class PodUtils {
getStatus(podinfo: PodInfo): string {
return (podinfo.Status || '').toUpperCase();
@ -64,3 +65,36 @@ export class PodUtils {
};
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function ensureRestrictedSecurityContext(body: any) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
body.spec?.containers?.forEach((container: any) => {
if (!container.securityContext) {
container.securityContext = {};
}
container.securityContext.allowPrivilegeEscalation = false;
if (!container.securityContext.runAsNonRoot) {
container.securityContext.runAsNonRoot = true;
}
if (!container.securityContext.seccompProfile) {
container.securityContext.seccompProfile = {};
}
if (
!container.securityContext.seccompProfile.type ||
(container.securityContext.seccompProfile.type !== 'RuntimeDefault' &&
container.securityContext.seccompProfile.type !== 'Localhost')
) {
container.securityContext.seccompProfile.type = 'RuntimeDefault';
}
if (!container.securityContext.capabilities) {
container.securityContext.capabilities = {};
}
if (!container.securityContext.capabilities.drop) {
container.securityContext.capabilities.drop = [];
}
if (container.securityContext.capabilities.drop.indexOf('ALL') === -1) {
container.securityContext.capabilities.drop.push('ALL');
}
});
}