From ed9e6231087ef00e1284f8203104f90ec03df2c5 Mon Sep 17 00:00:00 2001 From: Victor Rubezhny Date: Sat, 7 Sep 2024 18:29:25 +0200 Subject: [PATCH] Use SSO account to configure sandbox in one click Uses a POC ['feat: use sso account to configure sandbox in one click #4232'](https://github.com/redhat-developer/vscode-openshift-tools/pull/4232) by @dgolovin. Allows logging in to the DevSandbox using a SSO account if Service Account pipeline token is configured, otherwise a token from the Clipboard is to be used. Co-authored-by: Denis Golovin Signed-off-by: Victor Rubezhny --- src/oc/ocWrapper.ts | 8 ++- src/openshift/cluster.ts | 80 ++++++++++++++++++++++- src/openshift/sandbox.ts | 1 + src/webview/cluster/app/sandboxView.tsx | 56 ++++++++++++---- src/webview/cluster/clusterViewLoader.ts | 83 +++++++++++++++++------- 5 files changed, 187 insertions(+), 41 deletions(-) diff --git a/src/oc/ocWrapper.ts b/src/oc/ocWrapper.ts index 9d51327fb..dbbd7e72d 100644 --- a/src/oc/ocWrapper.ts +++ b/src/oc/ocWrapper.ts @@ -9,10 +9,10 @@ import * as tmp from 'tmp'; import validator from 'validator'; import { CommandOption, CommandText } from '../base/command'; import { CliChannel, ExecutionContext } from '../cli'; +import { CliExitData } from '../util/childProcessUtil'; import { isOpenShiftCluster, KubeConfigUtils } from '../util/kubeUtils'; import { Project } from './project'; import { ClusterType, KubernetesConsole } from './types'; -import { CliExitData } from '../util/childProcessUtil'; /** * A wrapper around the `oc` CLI tool. @@ -612,8 +612,10 @@ export class Oc { const currentUser = kcu.getCurrentUser(); if (currentUser) { const projectPrefix = currentUser.name.substring(0, currentUser.name.indexOf('/')); - if (projectPrefix.length > 0) { - activeProject = fixedProjects.find((project) => project.name.includes(projectPrefix)); + const matches = projectPrefix.match(/^system:serviceaccount:([a-zA-Z-_.]+-dev):pipeline$/); + const projectName = matches ? matches[1] : projectPrefix; + if (projectName.length > 0) { + activeProject = fixedProjects.find((project) => project.name.includes(projectName)); if (activeProject) { activeProject.active = true; void Oc.Instance.setProject(activeProject.name, executionContext); diff --git a/src/openshift/cluster.ts b/src/openshift/cluster.ts index a8b87f11d..d422a05c6 100644 --- a/src/openshift/cluster.ts +++ b/src/openshift/cluster.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See LICENSE file in the project root for license information. *-----------------------------------------------------------------------------------------------*/ -import { KubernetesObject } from '@kubernetes/client-node'; +import { CoreV1Api, KubeConfig, KubernetesObject, V1Secret, V1ServiceAccount } from '@kubernetes/client-node'; import { Cluster as KcuCluster, Context as KcuContext } from '@kubernetes/client-node/dist/config_types'; import * as https from 'https'; import { ExtensionContext, QuickInputButtons, QuickPickItem, QuickPickItemButtonEvent, ThemeIcon, Uri, commands, env, window, workspace } from 'vscode'; @@ -1070,4 +1070,82 @@ export class Cluster extends OpenShiftItem { const asUrl = new URL(url); return asUrl.hostname.endsWith('openshiftapps.com'); } + + static prepareSSOInKubeConfig(proxy: string, username: string, accessToken: string): KubeConfig { + const kcu = new KubeConfig(); + const clusterProxy = { + name: 'sandbox-proxy', + server: proxy, + }; + const user = { + name: 'sso-user', + token: accessToken, + }; + const context = { + cluster: clusterProxy.name, + name: 'sandbox-proxy-context', + user: user.name, + namespace: `${username}-dev`, + }; + kcu.addCluster(clusterProxy); + kcu.addUser(user) + kcu.addContext(context); + kcu.setCurrentContext(context.name); + return kcu; + } + + static async installPipelineSecretToken(k8sApi: CoreV1Api, pipelineServiceAccount: V1ServiceAccount, username: string): Promise { + const v1Secret = { + apiVersion: 'v1', + kind: 'Secret', + metadata: { + name: `pipeline-secret-${username}-dev`, + annotations: { + 'kubernetes.io/service-account.name': pipelineServiceAccount.metadata.name, + 'kubernetes.io/service-account.uid': pipelineServiceAccount.metadata.uid + } + }, + type: 'kubernetes.io/service-account-token' + } as V1Secret + + try { + await k8sApi.createNamespacedSecret(`${username}-dev`, v1Secret); + } catch { + // Ignore + } + const newSecrets = await k8sApi.listNamespacedSecret(`${username}-dev`); + return newSecrets?.body.items.find((secret) => secret.metadata.name === `pipeline-secret-${username}-dev`); + } + + static async getPipelineServiceAccountToken(k8sApi: CoreV1Api, username: string): Promise { + try { + const serviceAccounts = await k8sApi.listNamespacedServiceAccount(`${username}-dev`); + const pipelineServiceAccount = serviceAccounts.body.items.find(serviceAccount => serviceAccount.metadata.name === 'pipeline'); + if (!pipelineServiceAccount) { + return; + } + + const secrets = await k8sApi.listNamespacedSecret(`${username}-dev`); + let pipelineTokenSecret = secrets?.body.items.find((secret) => secret.metadata.name === `pipeline-secret-${username}-dev`); + if (!pipelineTokenSecret) { + pipelineTokenSecret = await Cluster.installPipelineSecretToken(k8sApi, pipelineServiceAccount, username); + if (!pipelineTokenSecret) { + return; + } + } + return Buffer.from(pipelineTokenSecret.data.token, 'base64').toString(); + } catch { + // Ignore + } + } + + static async loginUsingPipelineServiceAccountToken(server: string, proxy: string, username: string, accessToken: string): Promise { + const kcu = Cluster.prepareSSOInKubeConfig(proxy, username, accessToken); + const k8sApi = kcu.makeApiClient(CoreV1Api); + const pipelineToken = await this.getPipelineServiceAccountToken(k8sApi, username); + if (!pipelineToken) { + return; + } + return Cluster.tokenLogin(server, true, pipelineToken); + } } diff --git a/src/openshift/sandbox.ts b/src/openshift/sandbox.ts index 563ba6323..886914bfb 100644 --- a/src/openshift/sandbox.ts +++ b/src/openshift/sandbox.ts @@ -28,6 +28,7 @@ export interface SBSignupResponse { givenName: string; status: SBStatus; username: string; + proxyURL: string; } export interface SBResponseData { diff --git a/src/webview/cluster/app/sandboxView.tsx b/src/webview/cluster/app/sandboxView.tsx index 17b755fbf..58bd15b56 100644 --- a/src/webview/cluster/app/sandboxView.tsx +++ b/src/webview/cluster/app/sandboxView.tsx @@ -56,8 +56,10 @@ export default function addSandboxView(): JSX.Element { const [currentState, setCurrentState] = React.useState({ action: 'sandboxPageDetectAuthSession', statusInfo: '', + usePipelineToken: false, consoleDashboard: '', apiEndpoint: '', + apiEndpointProxy: '', oauthTokenEndpoint: '', errorCode: undefined }); @@ -149,7 +151,9 @@ export default function addSandboxView(): JSX.Element { action: currentState.action, consoleDashboard: currentState.consoleDashboard, statusInfo: currentState.statusInfo, + usePipelineToken: false, apiEndpoint: '', + apiEndpointProxy: '', oauthTokenEndpoint: '', errorCode: undefined }); @@ -299,8 +303,10 @@ export default function addSandboxView(): JSX.Element { setCurrentState({ action: 'sandboxPageRequestVerificationCode', statusInfo: '', + usePipelineToken: false, consoleDashboard: '', apiEndpoint: '', + apiEndpointProxy: '', oauthTokenEndpoint: '', errorCode: undefined }); @@ -379,11 +385,27 @@ export default function addSandboxView(): JSX.Element { const Provisioned = () => { const handleLoginButton = () => { - postMessage('sandboxLoginUsingDataInClipboard', {apiEndpointUrl: currentState.apiEndpoint, oauthRequestTokenUrl: `${currentState.oauthTokenEndpoint}/request`}); + if (!currentState.usePipelineToken) { // Try loging in using a token from the Clipboard + postMessage('sandboxLoginUsingDataInClipboard', { + apiEndpointUrl: currentState.apiEndpoint, + oauthRequestTokenUrl: `${currentState.oauthTokenEndpoint}/request` + }); + } else { // Try loging in using a Pipeline Token + postMessage('sandboxLoginUsingPipelineToken', { + apiEndpointUrl: currentState.apiEndpoint, + oauthRequestTokenUrl: `${currentState.oauthTokenEndpoint}/request`, + username: currentState.statusInfo, + apiEndpointProxy: currentState.apiEndpointProxy, + }); + } }; const invalidToken = currentState.errorCode === 'invalidToken'; - const loginSandboxTitle = !invalidToken ? 'Login to DevSandbox OpenShift cluster with token from clipboard' : 'Token in clipboard is invalid. Select the Get Token option and copy to clipboard'; + const loginSandboxTitle = !invalidToken ? + currentState.usePipelineToken ? + 'Login to DevSandbox OpenShift cluster using a service account provided token' : + 'Login to DevSandbox OpenShift cluster with token from clipboard' : + 'Token in clipboard is invalid. Select the Get Token option and copy to clipboard'; return ( <> @@ -403,19 +425,29 @@ export default function addSandboxView(): JSX.Element { Your sandbox account has been provisioned and is ready to use. - - Next steps to connect with Developer Sandbox:

- 1. Click on Get token button. In the browser, login using DevSandbox button.

- 2. Click on Display token link and copy token to clipboard.

- 3. Switch back to IDE and press 'Login To DevSandbox' button. This will login you to DevSandbox with token from clipboard.

- 4. Once successfully logged in, start creating applications and deploy on cluster. -
+ {( !currentState.usePipelineToken ) ? ( + + Next steps to connect with Developer Sandbox:

+ 1. Click on Get token button. In the browser, login using DevSandbox button.

+ 2. Click on Display token link and copy token to clipboard.

+ 3. Switch back to IDE and press 'Login To DevSandbox' button. This will login you to DevSandbox with token from clipboard.

+ 4. Once successfully logged in, start creating applications and deploy on cluster. +
+ ) : ( + + Next steps to connect with Developer Sandbox:

+ 1. Press 'Login To DevSandbox' button. This will login you to DevSandbox using a service account provided token.

+ 2. Once successfully logged in, start creating applications and deploy on cluster. +
+ )} - - - + {( !currentState.usePipelineToken ) && ( + + + + )}
diff --git a/src/webview/cluster/clusterViewLoader.ts b/src/webview/cluster/clusterViewLoader.ts index 6f54a708f..2706ec7f7 100644 --- a/src/webview/cluster/clusterViewLoader.ts +++ b/src/webview/cluster/clusterViewLoader.ts @@ -2,19 +2,17 @@ * Copyright (c) Red Hat, Inc. All rights reserved. * Licensed under the MIT License. See LICENSE file in the project root for license information. *-----------------------------------------------------------------------------------------------*/ +import { CoreV1Api } from '@kubernetes/client-node'; import { ChildProcess, spawn } from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; -import { clearInterval } from 'timers'; import * as vscode from 'vscode'; import { CommandText } from '../../base/command'; -import { Oc } from '../../oc/ocWrapper'; import { Cluster } from '../../openshift/cluster'; import { createSandboxAPI } from '../../openshift/sandbox'; import { ExtCommandTelemetryEvent } from '../../telemetry'; import { ChildProcessUtil } from '../../util/childProcessUtil'; import { ExtensionID } from '../../util/constants'; -import { KubeConfigUtils } from '../../util/kubeUtils'; import { vsCommand } from '../../vscommand'; import { loadWebviewHtml } from '../common-ext/utils'; import { OpenShiftTerminalManager } from '../openshift-terminal/openShiftTerminal'; @@ -116,12 +114,35 @@ async function clusterEditorMessageListener (event: any ): Promise { } else { if (signupStatus.status.ready) { const oauthInfo = await sandboxAPI.getOauthServerInfo(signupStatus.apiEndpoint); + const makeCoreV1ApiClient = ((proxy: string, username: string, accessToken: string): CoreV1Api => { + const kcu = Cluster.prepareSSOInKubeConfig(proxy, username, accessToken); + const apiClient = new CoreV1Api(proxy); + apiClient.setDefaultAuthentication(kcu); + return apiClient; + }); + const pipelineAccountToken = await Cluster.getPipelineServiceAccountToken( + makeCoreV1ApiClient(signupStatus.proxyURL, signupStatus.compliantUsername, + (sessionCheck as any).idToken), + signupStatus.compliantUsername); let errCode = ''; - if (!Cluster.validateLoginToken((await vscode.env.clipboard.readText()).trim())) { - errCode = 'invalidToken'; + if (!pipelineAccountToken) { // Try loging in using a token from the Clipboard + if (!Cluster.validateLoginToken((await vscode.env.clipboard.readText()).trim())) { + errCode = 'invalidToken'; + } + } + await panel.webview.postMessage({ + action: 'sandboxPageProvisioned', + statusInfo: signupStatus.compliantUsername, + usePipelineToken: (pipelineAccountToken), + consoleDashboard: signupStatus.consoleURL, + apiEndpoint: signupStatus.apiEndpoint, + apiEndpointProxy: signupStatus.proxyURL, + oauthTokenEndpoint: oauthInfo.token_endpoint, + errorCode: errCode + }); + if (!pipelineAccountToken) { // Try loging in using a token from the Clipboard + await pollClipboard(signupStatus); } - await panel.webview.postMessage({ action: 'sandboxPageProvisioned', statusInfo: signupStatus.username, consoleDashboard: signupStatus.consoleURL, apiEndpoint: signupStatus.apiEndpoint, oauthTokenEndpoint: oauthInfo.token_endpoint, errorCode: errCode }); - await pollClipboard(signupStatus); } else { // cluster is not ready and the reason is if (signupStatus.status.verificationRequired) { @@ -189,22 +210,23 @@ async function clusterEditorMessageListener (event: any ): Promise { const result = await Cluster.loginUsingClipboardToken(event.payload.apiEndpointUrl, event.payload.oauthRequestTokenUrl); if (result) void vscode.window.showInformationMessage(`${result}`); telemetryEventLoginToSandbox.send(); - const timeout = setInterval(() => { - const currentUser = new KubeConfigUtils().getCurrentUser(); - if (currentUser) { - clearInterval(timeout); - const projectPrefix = currentUser.name.substring( - 0, - currentUser.name.indexOf('/'), - ); - void Oc.Instance.getProjects().then((projects) => { - const userProject = projects.find((project) => - project.name.includes(projectPrefix), - ); - void Oc.Instance.setProject(userProject.name); - }); - } - }, 1000); + } catch (err) { + void vscode.window.showErrorMessage(err.message); + telemetryEventLoginToSandbox.sendError('Login into Sandbox Cluster failed.'); + } + break; + } + case 'sandboxLoginUsingPipelineToken': { + const telemetryEventLoginToSandbox = new ExtCommandTelemetryEvent('openshift.explorer.addCluster.sandboxLoginUsingPipelineToken'); + try { + const result = await Cluster.loginUsingPipelineServiceAccountToken( + event.payload.apiEndpointUrl, + event.payload.apiEndpointProxy, + event.payload.username, + (sessionCheck as any).idToken + ); + if (result) void vscode.window.showInformationMessage(`${result}`); + telemetryEventLoginToSandbox.send(); } catch (err) { void vscode.window.showErrorMessage(err.message); telemetryEventLoginToSandbox.sendError('Login into Sandbox Cluster failed.'); @@ -219,16 +241,27 @@ async function clusterEditorMessageListener (event: any ): Promise { async function pollClipboard(signupStatus) { const oauthInfo = await sandboxAPI.getOauthServerInfo(signupStatus.apiEndpoint); + let firstPoll = true; while (panel) { const previousContent = (await vscode.env.clipboard.readText()).trim(); await new Promise(r => setTimeout(r, 500)); const currentContent = (await vscode.env.clipboard.readText()).trim(); - if (previousContent && previousContent !== currentContent) { + if (firstPoll || (previousContent && previousContent !== currentContent)) { + firstPoll = false; let errCode = ''; if (!Cluster.validateLoginToken(currentContent)){ errCode = 'invalidToken'; } - void panel.webview.postMessage({action: 'sandboxPageProvisioned', statusInfo: signupStatus.username, consoleDashboard: signupStatus.consoleURL, apiEndpoint: signupStatus.apiEndpoint, oauthTokenEndpoint: oauthInfo.token_endpoint, errorCode: errCode}); + void panel.webview.postMessage({ + action: 'sandboxPageProvisioned', + statusInfo: signupStatus.username, + usePipelineToken: false, + consoleDashboard: signupStatus.consoleURL, + apiEndpoint: signupStatus.apiEndpoint, + apiEndpointProxy: signupStatus.proxyURL, + oauthTokenEndpoint: oauthInfo.token_endpoint, + errorCode: errCode + }); } } }