From 4d7af3e58d555f07a7ed05ea5571c63bd00333fa Mon Sep 17 00:00:00 2001 From: itowlson Date: Fri, 6 Dec 2019 16:20:35 +1300 Subject: [PATCH] Sync cluster from workspace (#23) --- package.json | 10 ++ src/commands/delete-policy.ts | 2 +- src/commands/deploy-rego.ts | 59 +------- src/commands/sync-from-workspace.ts | 223 ++++++++++++++++++++++++++++ src/extension.ts | 2 + src/opa.ts | 20 +++ src/opa/deployment.ts | 59 ++++++++ src/ui/policy-browser.ts | 32 ++-- src/utils/array.ts | 12 ++ 9 files changed, 339 insertions(+), 80 deletions(-) create mode 100644 src/commands/sync-from-workspace.ts create mode 100644 src/opa/deployment.ts diff --git a/package.json b/package.json index db58f58..f408bcc 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "onCommand:opak8s.showPolicy", "onCommand:opak8s.findFileInWorkspace", "onCommand:opak8s.deletePolicy", + "onCommand:opak8s.syncFromWorkspace", "onView:extension.vsKubernetesExplorer" ], "main": "./out/extension", @@ -51,6 +52,10 @@ { "command": "opak8s.deletePolicy", "title": "Delete" + }, + { + "command": "opak8s.syncFromWorkspace", + "title": "Sync Policies from Workspace (💻 → ☁️)" } ], "menus": { @@ -74,6 +79,11 @@ "command": "opak8s.deletePolicy", "group": "80", "when": "viewItem =~ /opak8s\\.policy/i" + }, + { + "command": "opak8s.syncFromWorkspace", + "group": "10", + "when": "viewItem =~ /opak8s\\.folder\\.policies/i" } ], "editor/context": [ diff --git a/src/commands/delete-policy.ts b/src/commands/delete-policy.ts index cd49291..dd13e19 100644 --- a/src/commands/delete-policy.ts +++ b/src/commands/delete-policy.ts @@ -39,6 +39,6 @@ async function tryDeletePolicy(policy: ConfigMap, clusterExplorer: k8s.ClusterEx await vscode.window.showInformationMessage(`Deleted config map ${policy.metadata.name}`); } else { const reason = deleteResult ? deleteResult.stderr : 'unable to run kubectl'; - await vscode.window.showErrorMessage(`Error deleteing config map ${policy.metadata.name}: ${reason}`); + await vscode.window.showErrorMessage(`Error deleting config map ${policy.metadata.name}: ${reason}`); } } diff --git a/src/commands/deploy-rego.ts b/src/commands/deploy-rego.ts index 5d50405..033e2e1 100644 --- a/src/commands/deploy-rego.ts +++ b/src/commands/deploy-rego.ts @@ -1,10 +1,7 @@ import * as vscode from 'vscode'; import * as k8s from 'vscode-kubernetes-tools-api'; -import * as path from 'path'; import { showUnavailable, longRunning } from '../utils/host'; -import { Errorable } from '../utils/errorable'; -import { OPA_NAMESPACE, OPA_DEV_REGO_ANNOTATION } from '../opa'; -import { withTempFile } from '../utils/tempfile'; +import { createOrUpdateConfigMapFrom, DeploymentInfo } from '../opa/deployment'; export async function deployRego(textEditor: vscode.TextEditor, edit: vscode.TextEditorEdit) { const kubectl = await k8s.extension.kubectl.v1; @@ -37,57 +34,3 @@ export async function deployRego(textEditor: vscode.TextEditor, edit: vscode.Tex await vscode.window.showErrorMessage(`Error deploying ${filePath} as config map: ${result.error[0]}`); } } - -async function createOrUpdateConfigMapFrom(deploymentInfo: DeploymentInfo, kubectl: k8s.KubectlV1): Promise> { - const createResult = await kubectl.invokeCommand(`create configmap ${deploymentInfo.configmapName} --namespace=${OPA_NAMESPACE} --from-file=${deploymentInfo.fileName}=${deploymentInfo.filePath}`); - if (createResult && createResult.code === 0) { - const annotateResult = await kubectl.invokeCommand(`annotate configmap ${deploymentInfo.configmapName} --namespace=${OPA_NAMESPACE} ${OPA_DEV_REGO_ANNOTATION}=true`); - if (!annotateResult || annotateResult.code !== 0) { - return { succeeded: false, error: ['The policy was deployed successfully but you may not be able to update it'] }; - } - return { succeeded: true, result: null }; - } - - if (createResult && createResult.stderr.includes('(AlreadyExists)')) { - return await updateConfigMapFrom(deploymentInfo, kubectl); - } - - const reason = createResult ? createResult.stderr : 'Unable to run kubectl'; - return { succeeded: false, error: [reason] }; -} - -async function updateConfigMapFrom(deploymentInfo: DeploymentInfo, kubectl: k8s.KubectlV1): Promise> { - const getResult = await kubectl.invokeCommand(`get configmap ${deploymentInfo.configmapName} --namespace=${OPA_NAMESPACE} -o json`); - if (!getResult || getResult.code !== 0) { - const reason = getResult ? getResult.stderr : 'unable to run kubectl'; - return { succeeded: false, error: [reason] }; - } - - const configmap = JSON.parse(getResult.stdout); - - const hasDevFlag = configmap.metadata && configmap.metadata.annotations && configmap.metadata.annotations[OPA_DEV_REGO_ANNOTATION]; - if (!hasDevFlag) { - // TODO: consider option to publish and be damned! - return { succeeded: false, error: [`config map ${deploymentInfo.configmapName} already exists and is not managed by Visual Studio Code`] }; - } - - configmap.data[deploymentInfo.fileName] = deploymentInfo.fileContent; - - const updated = JSON.stringify(configmap); - - const replaceResult = await withTempFile(updated, 'json', (f) => - kubectl.invokeCommand(`replace -f ${f} --namespace=${OPA_NAMESPACE}`) - ); - if (!replaceResult || replaceResult.code !== 0) { - const reason = replaceResult ? replaceResult.stderr : 'unable to run kubectl'; - return { succeeded: false, error: [reason] }; - } - - return { succeeded: true, result: null }; -} - -class DeploymentInfo { - constructor(readonly filePath: string, readonly fileContent: string) {} - get fileName() { return path.basename(this.filePath); } - get configmapName() { return path.basename(this.filePath, '.rego'); } -} diff --git a/src/commands/sync-from-workspace.ts b/src/commands/sync-from-workspace.ts new file mode 100644 index 0000000..5b08ca5 --- /dev/null +++ b/src/commands/sync-from-workspace.ts @@ -0,0 +1,223 @@ +import * as vscode from 'vscode'; +import * as k8s from 'vscode-kubernetes-tools-api'; +import { showUnavailable, longRunning } from '../utils/host'; +import { listPolicies, ConfigMap, policyIsDevRego, OPA_NAMESPACE } from '../opa'; +import { failed, Errorable, Failed, succeeded } from '../utils/errorable'; +import { partition } from '../utils/array'; +import { basename } from 'path'; +import { DeploymentInfo, createOrUpdateConfigMapFrom, updateConfigMapFrom } from '../opa/deployment'; + +export async function syncFromWorkspace() { + const clusterExplorer = await k8s.extension.clusterExplorer.v1; + const kubectl = await k8s.extension.kubectl.v1; + if (!clusterExplorer.available) { + await showUnavailable(clusterExplorer.reason); + return; + } else if (!kubectl.available) { + await showUnavailable(kubectl.reason); + return; + } + + await trySyncFromWorkspace(clusterExplorer.api, kubectl.api); +} + +async function trySyncFromWorkspace(clusterExplorer: k8s.ClusterExplorerV1, kubectl: k8s.KubectlV1): Promise { + // Strategy: + // * Compare the files and the cluster and work out what needs to be done. + // * Render the list of actions needed to sync as a multi-select quick picker. + // * If the user confirms any sync actions, go ahead and perform them. + // * Display the outcome. + + const allSaved = await saveRegoFiles(); + if (!allSaved) { + await vscode.window.showErrorMessage("Some .rego files have changes which couldn't be saved. Please save all .rego files and try again."); + return; + } + + const plan = await longRunning('Working out sync actions...', () => syncActions(kubectl)); + if (failed(plan)) { + await vscode.window.showErrorMessage(plan.error[0]); + return; + } + + const actions = plan.result; + + const actionQuickPicks = createQuickPicks(actions); + if (actionQuickPicks.length === 0) { + await vscode.window.showInformationMessage('Cluster and workspace are already in sync'); + return; + } + + const selectedActionQuickPicks = await vscode.window.showQuickPick(actionQuickPicks, { canPickMany: true }); + if (!selectedActionQuickPicks || selectedActionQuickPicks.length === 0) { + return; + } + + const selectedActionPromises = selectedActionQuickPicks.map((a) => runAction(kubectl, a)); + const actionResults = await longRunning('Syncing the cluster from the workspace...', () => + Promise.all(selectedActionPromises) + ); + + await displaySyncResult(actionResults, clusterExplorer); +} + +async function saveRegoFiles(): Promise { + const dirtyRegoFiles = vscode.workspace.textDocuments.filter((d) => d.languageId === 'rego' && d.isDirty); + const savePromises = dirtyRegoFiles.map((f) => f.save()); + const saveResults = await Promise.all(savePromises); + if (saveResults.some((r) => !r)) { + return false; + } + return true; +} + +interface RegoFile { + readonly uri: vscode.Uri; + readonly content: string; +} + +interface SyncActions { + readonly deploy: ReadonlyArray; + readonly overwriteDevRego: ReadonlyArray; + readonly overwriteNonDevRego: ReadonlyArray; + readonly delete: ReadonlyArray; +} + +type ActionQuickPickItem = vscode.QuickPickItem & ({ + readonly value: RegoFile; + readonly action: 'deploy'; + readonly isCreate: boolean; +} | { + readonly value: string; + readonly action: 'delete'; +}); + +async function syncActions(kubectl: k8s.KubectlV1): Promise> { + // Strategy: + // * Find all the .rego files in the workspace + // * Look at all the configmaps in the cluster (except system ones) + // * Sort the .rego files into buckets: + // * No matching policy. We can deploy these fearlessly. + // * Matching policy that the extension didn't deploy. We can deploy these with caution. + // * Matching policy whose content is the same as the file. We can skip these. + // * Matching policy whose content is out of sync with the file. We can deploy these fearlessly, too. + // * Pick out all the MANAGED configmaps in the cluster which DON'T have matching files. + // We propose to delete these. + + const regoUris = await vscode.workspace.findFiles('**/*.rego'); + const regoFiles = await Promise.all(regoUris.map(async (u) => ({ uri: u, content: (await vscode.workspace.openTextDocument(u)).getText() }))); + const clusterPolicies = await listPolicies(kubectl); + + const nonFileRegoUris = regoUris.filter((u) => u.scheme !== 'file'); + if (nonFileRegoUris.length > 0) { + const message = nonFileRegoUris.map((u) => u.toString()).join(', '); + return { succeeded: false, error: [`Workspace contains .rego documents that aren't files. Save all .rego documents to the file system and try again. (${message})`] }; + } + + if (failed(clusterPolicies)) { + return { succeeded: false, error: [`Failed to get current policies: ${clusterPolicies.error[0]}`] }; + } + + const localRegoFiles = regoUris.map((u) => vscode.workspace.asRelativePath(u)); + + const fileActions = partition(regoFiles, (f) => deploymentAction(clusterPolicies.result, f)); + const filesToDeploy = fileActions.get('no-overwrite') || []; + const filesOverwritingDevRego = fileActions.get('overwrite-dev') || []; + const filesOverwritingNonDevRego = fileActions.get('overwrite-nondev') || []; + const policiesToDelete = clusterPolicies.result + .filter((p) => policyIsDevRego(p) && !hasMatchingRegoFile(localRegoFiles, p)) + .map((p) => p.metadata.name); + + return { + succeeded: true, + result: { + deploy: filesToDeploy, + overwriteDevRego: filesOverwritingDevRego, + overwriteNonDevRego: filesOverwritingNonDevRego, + delete: policiesToDelete + } + }; +} + +function deploymentAction(policies: ReadonlyArray, regoFile: RegoFile): 'no-overwrite' | 'overwrite-dev' | 'overwrite-nondev' | 'skip' { + const policyName = basename(regoFile.uri.fsPath, '.rego'); // TODO: deduplicate - seems like DeploymentInfo might do this for us? + const matchingPolicy = policies.find((p) => p.metadata.name === policyName); + if (!matchingPolicy) { + return 'no-overwrite'; + } + if (!policyIsDevRego(matchingPolicy)) { + return 'overwrite-nondev'; // it's kind of opaque to us so let's not try to sniff content + } + const policyKeys = Object.keys(matchingPolicy.data); + if (policyKeys.length !== 1) { + return 'overwrite-nondev'; // shouldn't happen so something fishy is going on - claims to be managed but has been fiddled with + } + const policyContent = matchingPolicy.data[policyKeys[0]]; + return policyContent === regoFile.content ? 'skip' : 'overwrite-dev'; +} + +function hasMatchingRegoFile(regoFiles: ReadonlyArray, policy: ConfigMap): boolean { + return regoFiles.some((f) => basename(f, '.rego') === policy.metadata.name); +} + +function createQuickPicks(actions: SyncActions) { + const deployQuickPicks = actions.deploy.map((f) => deployQuickPick(f, 'deploy to cluster', true, true)); + const overwriteDevRegoQuickPicks = actions.overwriteDevRego.map((f) => deployQuickPick(f, 'deploy to cluster (overwriting existing)', true, false)); + const overwriteNonDevRegoQuickPicks = actions.overwriteNonDevRego.map((f) => deployQuickPick(f, 'deploy to cluster (overwriting existing not deployed by VS Code)', false, false)); + const deleteQuickPicks: ActionQuickPickItem[] = actions.delete.map((p) => ({ label: `${p}: delete from cluster`, picked: true, value: p, action: 'delete' })); + const actionQuickPicks = deployQuickPicks.concat(overwriteDevRegoQuickPicks).concat(overwriteNonDevRegoQuickPicks).concat(deleteQuickPicks); + return actionQuickPicks; +} + +function deployQuickPick(file: RegoFile, actionDescription: string, picked: boolean, isCreate: boolean): ActionQuickPickItem { + const displayFileName = vscode.workspace.asRelativePath(file.uri); + return {label: `${displayFileName}: ${actionDescription}`, picked: picked, value: file, action: 'deploy', isCreate: isCreate }; +} + +function runAction(kubectl: k8s.KubectlV1, action: ActionQuickPickItem): Promise> { + switch (action.action) { + case 'deploy': return runDeployAction(kubectl, action.value, action.isCreate); + case 'delete': return runDeleteAction(kubectl, action.value); + } +} + +async function runDeployAction(kubectl: k8s.KubectlV1, regoFile: RegoFile, isCreate: boolean): Promise> { + const regoFilePath = regoFile.uri.fsPath; + const regoFileContent = regoFile.content; + const deploymentInfo = new DeploymentInfo(regoFilePath, regoFileContent); + const deployResult = isCreate ? + await createOrUpdateConfigMapFrom(deploymentInfo, kubectl) : + await updateConfigMapFrom(deploymentInfo, kubectl); + if (failed(deployResult)) { + return { succeeded: false, error: [`deploying ${vscode.workspace.asRelativePath(regoFile.uri)} (${deployResult.error[0]})`] }; + } + return deployResult; +} + +async function runDeleteAction(kubectl: k8s.KubectlV1, policyName: string): Promise> { + const sr = await kubectl.invokeCommand(`delete configmap ${policyName} --namespace=${OPA_NAMESPACE}`); + + if (sr && sr.code === 0) { + return { succeeded: true, result: null }; + } else { + const reason = sr ? sr.stderr : 'unable to run kubectl'; + return { succeeded: false, error: [`deleting config map ${policyName} (${reason})`] }; + } + +} + +async function displaySyncResult(actionResults: Errorable[], clusterExplorer: k8s.ClusterExplorerV1): Promise { + const failures = actionResults.filter((r) => failed(r)) as Failed[]; + const successCount = actionResults.filter((r) => succeeded(r)).length; + if (failures.length > 0) { + const successCountInfo = successCount > 0 ? `. (${successCount} other update(s) succeeded.)` : ''; + await vscode.window.showErrorMessage(`${failures.length} update(s) failed: ${failures.map((f) => f.error[0]).join(', ')}${successCountInfo}`); + return; + } + + if (successCount > 0) { + clusterExplorer.refresh(); + } + + await vscode.window.showInformationMessage(`Synced the cluster from the workspace`); +} diff --git a/src/extension.ts b/src/extension.ts index 78058f9..a71758e 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -7,6 +7,7 @@ import { PolicyBrowser } from './ui/policy-browser'; import { showPolicy } from './commands/show-policy'; import { deletePolicy } from './commands/delete-policy'; import { findFileInWorkspace } from './commands/find-file-in-workspace'; +import { syncFromWorkspace } from './commands/sync-from-workspace'; export async function activate(context: vscode.ExtensionContext) { const disposables = [ @@ -15,6 +16,7 @@ export async function activate(context: vscode.ExtensionContext) { vscode.commands.registerCommand('opak8s.showPolicy', showPolicy), vscode.commands.registerCommand('opak8s.findFileInWorkspace', findFileInWorkspace), vscode.commands.registerCommand('opak8s.deletePolicy', deletePolicy), + vscode.commands.registerCommand('opak8s.syncFromWorkspace', syncFromWorkspace), ]; context.subscriptions.push(...disposables); diff --git a/src/opa.ts b/src/opa.ts index 5324013..e806325 100644 --- a/src/opa.ts +++ b/src/opa.ts @@ -1,9 +1,29 @@ +import { KubectlV1 } from "vscode-kubernetes-tools-api"; +import { Errorable } from "./utils/errorable"; + export const OPA_HELM_RELEASE_NAME = 'opa'; export const OPA_NAMESPACE = 'opa'; export const OPA_DEV_REGO_ANNOTATION = 'k8s-opa-vscode.hestia.cc/devrego'; const OPA_POLICY_STATUS_ANNOTATION = 'openpolicyagent.org/policy-status'; +export async function listPolicies(kubectl: KubectlV1): Promise>> { + const sr = await kubectl.invokeCommand(`get configmap --namespace ${OPA_NAMESPACE} -o json`); + if (!sr || sr.code !== 0) { + const message = sr ? sr.stderr : 'Unable to run kubectl'; + return { succeeded: false, error: [message] }; + } + + const configmaps: GetConfigMapsResponse = JSON.parse(sr.stdout); + if (configmaps.items) { + const policies = configmaps.items.filter((cm) => !isSystemConfigMap(cm)); + return { succeeded: true, result: policies }; + } + + return { succeeded: true, result: [] }; + +} + export function isSystemConfigMap(configmap: ConfigMap): boolean { return configmap.metadata.name === 'opa-default-system-main'; } diff --git a/src/opa/deployment.ts b/src/opa/deployment.ts new file mode 100644 index 0000000..0c78f6b --- /dev/null +++ b/src/opa/deployment.ts @@ -0,0 +1,59 @@ +import * as k8s from 'vscode-kubernetes-tools-api'; +import * as path from 'path'; +import { Errorable } from '../utils/errorable'; +import { OPA_NAMESPACE, OPA_DEV_REGO_ANNOTATION } from '../opa'; +import { withTempFile } from '../utils/tempfile'; + +export async function createOrUpdateConfigMapFrom(deploymentInfo: DeploymentInfo, kubectl: k8s.KubectlV1): Promise> { + const createResult = await kubectl.invokeCommand(`create configmap ${deploymentInfo.configmapName} --namespace=${OPA_NAMESPACE} --from-file=${deploymentInfo.fileName}=${deploymentInfo.filePath}`); + if (createResult && createResult.code === 0) { + const annotateResult = await kubectl.invokeCommand(`annotate configmap ${deploymentInfo.configmapName} --namespace=${OPA_NAMESPACE} ${OPA_DEV_REGO_ANNOTATION}=true`); + if (!annotateResult || annotateResult.code !== 0) { + return { succeeded: false, error: ['The policy was deployed successfully but you may not be able to update it'] }; + } + return { succeeded: true, result: null }; + } + + if (createResult && createResult.stderr.includes('(AlreadyExists)')) { + return await updateConfigMapFrom(deploymentInfo, kubectl); + } + + const reason = createResult ? createResult.stderr : 'Unable to run kubectl'; + return { succeeded: false, error: [reason] }; +} + +export async function updateConfigMapFrom(deploymentInfo: DeploymentInfo, kubectl: k8s.KubectlV1): Promise> { + const getResult = await kubectl.invokeCommand(`get configmap ${deploymentInfo.configmapName} --namespace=${OPA_NAMESPACE} -o json`); + if (!getResult || getResult.code !== 0) { + const reason = getResult ? getResult.stderr : 'unable to run kubectl'; + return { succeeded: false, error: [reason] }; + } + + const configmap = JSON.parse(getResult.stdout); + + const hasDevFlag = configmap.metadata && configmap.metadata.annotations && configmap.metadata.annotations[OPA_DEV_REGO_ANNOTATION]; + if (!hasDevFlag) { + // TODO: consider option to publish and be damned! + return { succeeded: false, error: [`config map ${deploymentInfo.configmapName} already exists and is not managed by Visual Studio Code`] }; + } + + configmap.data[deploymentInfo.fileName] = deploymentInfo.fileContent; + + const updated = JSON.stringify(configmap); + + const replaceResult = await withTempFile(updated, 'json', (f) => + kubectl.invokeCommand(`replace -f ${f} --namespace=${OPA_NAMESPACE}`) + ); + if (!replaceResult || replaceResult.code !== 0) { + const reason = replaceResult ? replaceResult.stderr : 'unable to run kubectl'; + return { succeeded: false, error: [reason] }; + } + + return { succeeded: true, result: null }; +} + +export class DeploymentInfo { + constructor(readonly filePath: string, readonly fileContent: string) {} + get fileName() { return path.basename(this.filePath); } + get configmapName() { return path.basename(this.filePath, '.rego'); } +} diff --git a/src/ui/policy-browser.ts b/src/ui/policy-browser.ts index 849392f..d1bc278 100644 --- a/src/ui/policy-browser.ts +++ b/src/ui/policy-browser.ts @@ -1,7 +1,8 @@ import * as vscode from 'vscode'; import * as k8s from 'vscode-kubernetes-tools-api'; -import { isSystemConfigMap, OPA_NAMESPACE, GetConfigMapsResponse, ConfigMap, policyStatus, PolicyStatus, policyIsDevRego, policyError } from '../opa'; +import { ConfigMap, policyStatus, PolicyStatus, policyIsDevRego, policyError, listPolicies } from '../opa'; import { definedOf } from '../utils/array'; +import { failed } from '../utils/errorable'; const OPA_TREE_NODE_KEY = 'opak8s_tree_node_8357fa36-5ade-4b9b-b0f4-ac07d6460c5c'; @@ -70,22 +71,17 @@ class PoliciesFolderNode implements k8s.ClusterExplorerV1.Node, PolicyBrowser.Po readonly [OPA_TREE_NODE_KEY] = true; readonly nodeType = 'folder.policies'; async getChildren(): Promise { - const sr = await this.kubectl.invokeCommand(`get configmap --namespace ${OPA_NAMESPACE} -o json`); - if (!sr || sr.code !== 0) { - return [new ErrorNode(sr)]; + const policies = await listPolicies(this.kubectl); + if (failed(policies)) { + return [new ErrorNode(policies.error[0])]; } - const configmaps: GetConfigMapsResponse = JSON.parse(sr.stdout); - if (configmaps.items) { - return configmaps.items - .filter((cm) => !isSystemConfigMap(cm)) - .map((cm) => new PolicyNode(cm, this.extensionContext)); - } - - return []; + return policies.result.map((cm) => new PolicyNode(cm, this.extensionContext)); } getTreeItem(): vscode.TreeItem { - return new vscode.TreeItem('OPA Policies', vscode.TreeItemCollapsibleState.Collapsed); + const treeItem = new vscode.TreeItem('OPA Policies', vscode.TreeItemCollapsibleState.Collapsed); + treeItem.contextValue = 'opak8s.folder.policies'; + return treeItem; } } @@ -126,21 +122,15 @@ class PolicyNode implements k8s.ClusterExplorerV1.Node, PolicyBrowser.PolicyNode } class ErrorNode implements k8s.ClusterExplorerV1.Node { - constructor(private readonly sr: k8s.KubectlV1.ShellResult | undefined) { } + constructor(private readonly message: string) { } async getChildren(): Promise { return []; } getTreeItem(): vscode.TreeItem { const treeItem = new vscode.TreeItem('Error'); - treeItem.tooltip = this.tooltip(); + treeItem.tooltip = this.message; return treeItem; } - private tooltip(): string { - if (!this.sr) { - return 'Unable to run kubectl'; - } - return this.sr.stderr; - } } function policyIcon(policy: ConfigMap): string { diff --git a/src/utils/array.ts b/src/utils/array.ts index f50fded..86183a2 100644 --- a/src/utils/array.ts +++ b/src/utils/array.ts @@ -1,3 +1,15 @@ export function definedOf(...items: (T | undefined)[]): T[] { return items.filter((i) => i !== undefined).map((i) => i!); } + +export function partition(items: T[], fn: (item: T) => K): Map { + const partition = new Map(); + for (const item of items) { + const key = fn(item); + if (!partition.get(key)) { + partition.set(key, Array.of()); + } + partition.get(key)!.push(item); + } + return partition; +}