diff --git a/changelog.d/+namespace-selection.added.md b/changelog.d/+namespace-selection.added.md new file mode 100644 index 00000000..efbd59fa --- /dev/null +++ b/changelog.d/+namespace-selection.added.md @@ -0,0 +1 @@ +mirrord target quick pick now allows for switching between Kubernetes namespaces. diff --git a/src/api.ts b/src/api.ts index 727ab603..58f31751 100644 --- a/src/api.ts +++ b/src/api.ts @@ -6,6 +6,7 @@ import { NotificationBuilder } from './notification'; import { MirrordStatus } from './status'; import { EnvVars, VerifiedConfig } from './config'; import { PathLike } from 'fs'; +import { UserSelection } from './targetQuickPick'; /** * Key to access the feedback counter (see `tickFeedbackCounter`) from the global user config. @@ -27,18 +28,6 @@ const DISCORD_COUNTER = 'mirrord-discord-counter'; */ const DISCORD_COUNTER_PROMPT_AFTER = 10; -const TARGET_TYPE_DISPLAY: Record = { - pod: 'Pod', - deployment: 'Deployment', - rollout: 'Rollout', -}; - -// Option in the target selector that represents no target. -const TARGETLESS_TARGET: TargetQuickPick = { - label: "No Target (\"targetless\")", - type: 'targetless' -}; - /** * Level of the notification, different levels map to different notification boxes. */ @@ -122,77 +111,52 @@ function handleIdeMessage(message: IdeMessage) { } } -type TargetQuickPick = vscode.QuickPickItem & ( - { type: 'targetless' } | - { type: 'target' | 'page', value: string } -); - -export class Targets { - private activePage: string; - - private readonly inner: Record; - readonly length: number; - - constructor(targets: string[], lastTarget?: string) { - this.length = targets.length; - - this.inner = targets.reduce((acc, value) => { - const targetType = value.split('/')[0]; - const target: TargetQuickPick = { - label: value, - type: 'target', - value - }; - - if (Array.isArray(acc[targetType])) { - acc[targetType]!.push(target); - } else { - acc[targetType] = [target]; - } - - return acc; - }, {} as Targets['inner']); - - - const types = Object.keys(this.inner); - const lastPage = lastTarget?.split("/")?.[0] ?? ''; - - if (types.includes(lastPage)) { - this.activePage = lastPage; - } else { - this.activePage = types[0] ?? ''; - } - } - - private quickPickSelects(): TargetQuickPick[] { - return Object.keys(this.inner) - .filter((value) => value !== this.activePage) - .map((value) => ({ - label: `Show ${TARGET_TYPE_DISPLAY[value] ?? value}s`, - type: 'page', - value - })); - } - +/** + * A mirrord target found in the cluster. + */ +export type FoundTarget = { + /** + * The path of this target, as in the mirrord config. + */ + path: string; + /** + * Whether this target is available. + */ + available: boolean; +}; - quickPickItems(): TargetQuickPick[] { - return [ - ...(this.inner[this.activePage] ?? []), - TARGETLESS_TARGET, - ...this.quickPickSelects() - ]; - } +/** + * The new format of `mirrord ls`, including target availability and namespaces info. + */ +export type MirrordLsOutput = { + /** + * The targets found in the current namespace. + */ + targets: FoundTarget[]; + /** + * The namespace where the lookup was done. + * + * If the CLI does not support listing namespaces, this is undefined. + */ + // eslint-disable-next-line @typescript-eslint/naming-convention + current_namespace?: string; + /** + * All namespaces visible to the user. + * + * If the CLI does not support listing namespaces, this is undefined. + */ + namespaces?: string[]; +}; - switchPage(nextPage: TargetQuickPick) { - if (nextPage.type === 'page') { - this.activePage = nextPage.value; - } - } +/** + * Checks whether the JSON value is in the @see MirrordLsOutput format. + * + * @param output JSON parsed from `mirrord ls` stdout + */ +function isRichMirrordLsOutput(output: any): output is MirrordLsOutput { + return "targets" in output && "current_namespace" in output && "namespaces" in output; } -/// Key used to store the last selected target in the persistent state. -export const LAST_TARGET_KEY = "mirrord-last-target"; - // Display error message with help export function mirrordFailure(error: string) { new NotificationBuilder() @@ -239,7 +203,7 @@ export class MirrordExecution { /** * Sets up the args that are going to be passed to the mirrord cli. */ -const makeMirrordArgs = (target: string | null, configFilePath: PathLike | null, userExecutable: PathLike | null): readonly string[] => { +const makeMirrordArgs = (target: string | undefined, configFilePath: PathLike | null, userExecutable: PathLike | null): readonly string[] => { let args = ["ext"]; if (target) { @@ -280,7 +244,10 @@ export class MirrordAPI { "MIRRORD_PROGRESS_MODE": "json", // to have "advanced" progress in IDE // eslint-disable-next-line @typescript-eslint/naming-convention - "MIRRORD_PROGRESS_SUPPORT_IDE": "true" + "MIRRORD_PROGRESS_SUPPORT_IDE": "true", + // to have namespaces in the `mirrord ls` output + // eslint-disable-next-line @typescript-eslint/naming-convention + "MIRRORD_LS_RICH_OUTPUT": "true" }; } @@ -345,35 +312,41 @@ export class MirrordAPI { async getBinaryVersion(): Promise { const stdout = await this.exec(["--version"], {}); // parse mirrord x.y.z - return stdout.split(" ")[1].trim(); + return stdout.split(" ")[1]?.trim(); } /** - * Uses `mirrord ls` to get a list of all targets. - * Targets come sorted, with an exception of the last used target being the first on the list. + * Uses `mirrord ls` to get lists of targets and namespaces. + * + * Note that old CLI versions return only targets. + * + * @see MirrordLsOutput */ - async listTargets(configPath: string | null | undefined): Promise { + async listTargets(configPath: string | null | undefined, configEnv: EnvVars, namespace?: string): Promise { const args = ['ls']; if (configPath) { args.push('-f', configPath); } - const stdout = await this.exec(args, {}); - - const targets: string[] = JSON.parse(stdout); + if (namespace !== undefined) { + args.push('-n', namespace); + } - let lastTarget: string | undefined = globalContext.workspaceState.get(LAST_TARGET_KEY) - || globalContext.globalState.get(LAST_TARGET_KEY); + const stdout = await this.exec(args, configEnv); - if (lastTarget !== undefined) { - const idx = targets.indexOf(lastTarget); - if (idx !== -1) { - targets.splice(idx, 1); - targets.unshift(lastTarget); - } + const targets = JSON.parse(stdout); + let mirrordLsOutput: MirrordLsOutput; + if (isRichMirrordLsOutput(targets)) { + mirrordLsOutput = targets; + } else { + mirrordLsOutput = { + targets: (targets as string[]).map(path => { + return {path, available: true }; + }), + }; } - return new Targets(targets, lastTarget); + return mirrordLsOutput; } /** @@ -398,7 +371,7 @@ export class MirrordAPI { * * Has 60 seconds timeout */ - async binaryExecute(target: string | null, configFile: string | null, executable: string | null, configEnv: EnvVars): Promise { + async binaryExecute(target: UserSelection, configFile: string | null, executable: string | null, configEnv: EnvVars): Promise { tickMirrordForTeamsCounter(); tickFeedbackCounter(); tickDiscordCounter(); @@ -414,9 +387,16 @@ export class MirrordAPI { reject("timeout"); }, 120 * 1000); - const args = makeMirrordArgs(target, configFile, executable); + const args = makeMirrordArgs(target.path ?? "targetless", configFile, executable); + let env: EnvVars; + if (target.namespace) { + // eslint-disable-next-line @typescript-eslint/naming-convention + env = { MIRRORD_TARGET_NAMESPACE: target.namespace, ...configEnv }; + } else { + env = configEnv; + } - const child = this.spawnCliWithArgsAndEnv(args, configEnv); + const child = this.spawnCliWithArgsAndEnv(args, env); let stderrData = ""; child.stderr.on("data", (data) => stderrData += data.toString()); diff --git a/src/debugger.ts b/src/debugger.ts index 4c4576f1..d7c70276 100644 --- a/src/debugger.ts +++ b/src/debugger.ts @@ -1,13 +1,14 @@ import * as vscode from 'vscode'; import { globalContext } from './extension'; import { isTargetSet, MirrordConfigManager } from './config'; -import { LAST_TARGET_KEY, MirrordAPI, mirrordFailure, MirrordExecution } from './api'; +import { MirrordAPI, mirrordFailure, MirrordExecution } from './api'; import { updateTelemetries } from './versionCheck'; import { getMirrordBinary } from './binaryManager'; import { platform } from 'node:os'; import { NotificationBuilder } from './notification'; import { setOperatorUsed } from './mirrordForTeams'; import fs from 'fs'; +import { TargetQuickPick, UserSelection } from './targetQuickPick'; const DYLD_ENV_VAR_NAME = "DYLD_INSERT_LIBRARIES"; @@ -109,60 +110,24 @@ async function main( let mirrordApi = new MirrordAPI(cliPath); config.env ||= {}; - let target = null; + let target: UserSelection = {}; let configPath = await MirrordConfigManager.getInstance().resolveMirrordConfig(folder, config); const verifiedConfig = await mirrordApi.verifyConfig(configPath, config.env); // If target wasn't specified in the config file (or there's no config file), let user choose pod from dropdown if (!configPath || (verifiedConfig && !isTargetSet(verifiedConfig))) { - let targets; + const getTargets = async (namespace?: string) => { + return mirrordApi.listTargets(configPath?.path, config.env, namespace); + }; + try { - targets = await mirrordApi.listTargets(configPath?.path); + const quickPick = await TargetQuickPick.new(getTargets); + target = await quickPick.showAndGet(); } catch (err) { mirrordFailure(`mirrord failed to list targets: ${err}`); return null; } - if (targets.length === 0) { - new NotificationBuilder() - .withMessage( - "No mirrord target available in the configured namespace. " + - "You can run targetless, or set a different target namespace or kubeconfig in the mirrord configuration file.", - ) - .info(); - } - - let selected = false; - - while (!selected) { - let targetPick = await vscode.window.showQuickPick(targets.quickPickItems(), { - placeHolder: 'Select a target path to mirror' - }); - - if (targetPick) { - if (targetPick.type === 'page') { - targets.switchPage(targetPick); - - continue; - } - - if (targetPick.type !== 'targetless') { - target = targetPick.value; - } - - globalContext.globalState.update(LAST_TARGET_KEY, target); - globalContext.workspaceState.update(LAST_TARGET_KEY, target); - } - - selected = true; - } - - if (!target) { - new NotificationBuilder() - .withMessage("mirrord running targetless") - .withDisableAction("promptTargetless") - .info(); - } } if (config.type === "go") { diff --git a/src/targetQuickPick.ts b/src/targetQuickPick.ts new file mode 100644 index 00000000..9e81435f --- /dev/null +++ b/src/targetQuickPick.ts @@ -0,0 +1,320 @@ +import * as vscode from 'vscode'; +import { MirrordLsOutput } from './api'; +import { globalContext } from './extension'; +import { NotificationBuilder } from './notification'; + +/// Key used to store the last selected target in the persistent state. +const LAST_TARGET_KEY = "mirrord-last-target"; + +/** + * A page in the @see TargetQuickPick. + */ +type TargetQuickPickPage = { + /** + * Label to display in the widget. + */ + label: string, + /** + * Prefix of targets visible on this page, mirrord config format. + * + * undefined **only** for namespace selection page. + */ + targetType?: string, +}; + +/** + * Namespace selection page in the @see TargetQuickPick. + */ +const NAMESPACE_SELECTION_PAGE: TargetQuickPickPage = { + label: 'Select Another Namespace', +}; + +/** + * Target selection pages in the @see TargetQuickPick. + */ +const TARGET_SELECTION_PAGES: (TargetQuickPickPage & {targetType: string})[] = [ + { + label: 'Show Deployments', + targetType: 'deployment', + }, + { + label: 'Show Rollouts', + targetType: 'rollout', + }, + { + label: 'Show Pods', + targetType: 'pod', + }, +]; + +/** + * An item in the @see TargetQuickPick. + */ +type TargetQuickPickItem = vscode.QuickPickItem & ( + { type: 'target', value?: string } | // select target + { type: 'namespace', value: string } | // switch to another namespace + { type: 'page', value: TargetQuickPickPage } // switch to another page (e.g select pod -> select deployment) +); + +/** + * The item in the @see TargetQuickPick that represents the targetless mode. + */ +const TARGETLESS_ITEM: TargetQuickPickItem = { + type: 'target', + label: 'No Target (\"targetless\")', +}; + +/** + * A function used by @see TargetQuickPick to invoke `mirrord ls` in the given namespace. + */ +export type TargetFetcher = (namespace?: string) => Thenable; + +/** + * Describes what the user has selected with the @see TargetQuickPick. + */ +export type UserSelection = { + /** + * Selected target. + * + * undefined if targetless. + */ + path?: string, + /** + * Selected namespace. + * + * undefined if the CLI does not support listing namespaces. + */ + namespace?: string, +}; + +/** + * A quick pick allowing the user to select the target and, if the CLI supports listing namepaces, switch the namespace. + */ +export class TargetQuickPick { + /** + * Output of the last `mirrord ls` invocation. + * + * Should contain only targets that are available and supported by this widget (deployments, rollouts and pods). + */ + private lsOutput: MirrordLsOutput; + /** + * The page we are currently displaying. + */ + private activePage?: TargetQuickPickPage; + /** + * Target that was selected most recently by the user. + * + * This target, if present in @see lsOutput, is put first on its page. + * Also, determines initial page. + */ + private readonly lastTarget?: string; + /** + * Function used to invoke `mirrord ls` and get its output. + * + * Should return only targets that are available and supported by this widget (deployments, rollouts and pods). + */ + private readonly getTargets: TargetFetcher; + + private constructor(getTargets: TargetFetcher, lsOutput: MirrordLsOutput) { + this.lastTarget = globalContext.workspaceState.get(LAST_TARGET_KEY) || globalContext.globalState.get(LAST_TARGET_KEY); + this.lsOutput = lsOutput; + this.getTargets = getTargets; + } + + /** + * Creates a new instance of this quick pick. + * + * This quick pick can be executed using @see showAndGet. + */ + static async new(getTargets: (namespace?: string) => Thenable): Promise { + const getFilteredTargets = async (namespace?: string) => { + const output = await getTargets(namespace); + output.targets = output.targets.filter(t => { + if (!t.available) { + return false; + } + const targetType = t.path.split('/')[0]; + return TARGET_SELECTION_PAGES.find(p => p.targetType === targetType) !== undefined; + }); + return output; + }; + + const lsOutput = await getFilteredTargets(); + + return new TargetQuickPick(getFilteredTargets, lsOutput); + } + + /** + * Returns whether @see lsOutput has at least one target of this type. + */ + private hasTargetOfType(targetType: string): boolean { + return this.lsOutput.targets.find(t => t.path.startsWith(`${targetType}/`)) !== undefined; + } + + /** + * Returns a default page to display. undefined if @see lsOutput contains no targets. + */ + private getDefaultPage(): TargetQuickPickPage | undefined { + let page: TargetQuickPickPage | undefined; + + const lastTargetType = this.lastTarget?.split('/')[0]; + if (lastTargetType !== undefined && this.hasTargetOfType(lastTargetType)) { + page = TARGET_SELECTION_PAGES.find(p => p.targetType === lastTargetType); + } + + if (page === undefined) { + page = this + .lsOutput + .targets + .map(t => { + const targetType = t.path.split('/')[0] ?? ''; + return TARGET_SELECTION_PAGES.find(p => p.targetType === targetType); + }) + .find(p => p !== undefined); + } + + return page; + } + + /** + * Prepares a placeholder and items for the quick pick. + */ + private prepareQuickPick(): [string, TargetQuickPickItem[]] { + if (this.activePage === undefined) { + this.activePage = this.getDefaultPage(); + } + + let items: TargetQuickPickItem[]; + let placeholder: string; + + if (this.activePage === undefined) { + placeholder = "No available targets"; + if (this.lsOutput.current_namespace !== undefined) { + placeholder += ` in ${this.lsOutput.current_namespace}`; + } + + items = [TARGETLESS_ITEM]; + + if (this.lsOutput.namespaces !== undefined) { + items.push({ + type: 'page', + value: NAMESPACE_SELECTION_PAGE, + label: NAMESPACE_SELECTION_PAGE.label, + }); + } + } else if (this.activePage.targetType === undefined) { + placeholder = "Select another namespace"; + if (this.lsOutput.current_namespace !== undefined) { + placeholder += ` (current: ${this.lsOutput.current_namespace})`; + } + + items = this + .lsOutput + .namespaces + ?.filter(ns => ns !== this.lsOutput.current_namespace) + .map(ns => { + return { + type: 'namespace', + value: ns, + label: ns, + }; + }) ?? []; + + TARGET_SELECTION_PAGES + .filter(p => this.hasTargetOfType(p.targetType)) + .forEach(p => { + items.push({ + type: 'page', + value: p, + label: p.label, + }); + }); + } else { + placeholder = "Select a target"; + if (this.lsOutput.current_namespace !== undefined) { + placeholder += ` from ${this.lsOutput.current_namespace}`; + } + + items = this + .lsOutput + .targets + .filter(t => t.path.startsWith(`${this.activePage?.targetType}/`)) + .map(t => { + return { + type: 'target', + value: t.path, + label: t.path, + }; + }); + + if (this.lastTarget !== undefined) { + const idx = items.findIndex(i => i.value === this.lastTarget); + if (idx !== -1) { + const removed = items.splice(idx, 1); + items = removed.concat(items); + } + } + + items.push(TARGETLESS_ITEM); + + TARGET_SELECTION_PAGES + .filter(p => (p.targetType !== this.activePage?.targetType) && this.hasTargetOfType(p.targetType)) + .forEach(p => { + items.push({ + type: 'page', + value: p, + label: p.label, + }); + }); + + if (this.lsOutput.namespaces !== undefined) { + items.push({ + type: 'page', + value: NAMESPACE_SELECTION_PAGE, + label: NAMESPACE_SELECTION_PAGE.label, + }); + } + } + + return [placeholder, items]; + } + + /** + * Shows the quick pick and returns user selection. + * + * If the user selected nothing, returns targetless. + */ + async showAndGet(): Promise { + while (true) { + const [placeHolder, items] = this.prepareQuickPick(); + const newSelection = await vscode.window.showQuickPick(items, { placeHolder }); + + switch (newSelection?.type) { + case 'target': + if (newSelection.value !== undefined) { + globalContext.globalState.update(LAST_TARGET_KEY, newSelection.value); + globalContext.workspaceState.update(LAST_TARGET_KEY, newSelection.value); + } + + return { path: newSelection.value, namespace: this.lsOutput.current_namespace }; + + case 'namespace': + this.lsOutput = await this.getTargets(newSelection.value); + this.activePage = undefined; + break; + + case 'page': + this.activePage = newSelection.value; + break; + + case undefined: + new NotificationBuilder() + .withMessage("mirrord running targetless") + .withDisableAction("promptTargetless") + .info(); + + return { namespace: this.lsOutput.current_namespace }; + } + } + } +} diff --git a/tsconfig.json b/tsconfig.json index bcbee93c..94f88d55 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,11 +9,12 @@ "sourceMap": true, "rootDir": "src", "esModuleInterop": true, // Without this, will report errors in k8s client node modules https://github.com/kubernetes-client/javascript/issues/751#issuecomment-986953203 - "strict": true /* enable all strict type-checking options */ + "strict": true, /* enable all strict type-checking options */ /* Additional Checks */ // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ // "noUnusedParameters": true, /* Report errors on unused parameters. */ + "noUncheckedIndexedAccess": true, }, "exclude": [ "node_modules",