|
| 1 | +import * as vscode from 'vscode'; |
| 2 | +import * as k8s from 'vscode-kubernetes-tools-api'; |
| 3 | +import { showUnavailable, longRunning } from '../utils/host'; |
| 4 | +import { listPolicies, ConfigMap, policyIsDevRego, OPA_NAMESPACE } from '../opa'; |
| 5 | +import { failed, Errorable, Failed, succeeded } from '../utils/errorable'; |
| 6 | +import { partition } from '../utils/array'; |
| 7 | +import { basename } from 'path'; |
| 8 | +import { DeploymentInfo, createOrUpdateConfigMapFrom, updateConfigMapFrom } from '../opa/deployment'; |
| 9 | + |
| 10 | +export async function syncFromWorkspace() { |
| 11 | + const clusterExplorer = await k8s.extension.clusterExplorer.v1; |
| 12 | + const kubectl = await k8s.extension.kubectl.v1; |
| 13 | + if (!clusterExplorer.available) { |
| 14 | + await showUnavailable(clusterExplorer.reason); |
| 15 | + return; |
| 16 | + } else if (!kubectl.available) { |
| 17 | + await showUnavailable(kubectl.reason); |
| 18 | + return; |
| 19 | + } |
| 20 | + |
| 21 | + await trySyncFromWorkspace(clusterExplorer.api, kubectl.api); |
| 22 | +} |
| 23 | + |
| 24 | +async function trySyncFromWorkspace(clusterExplorer: k8s.ClusterExplorerV1, kubectl: k8s.KubectlV1): Promise<void> { |
| 25 | + // Strategy: |
| 26 | + // * Compare the files and the cluster and work out what needs to be done. |
| 27 | + // * Render the list of actions needed to sync as a multi-select quick picker. |
| 28 | + // * If the user confirms any sync actions, go ahead and perform them. |
| 29 | + // * Display the outcome. |
| 30 | + |
| 31 | + const allSaved = await saveRegoFiles(); |
| 32 | + if (!allSaved) { |
| 33 | + await vscode.window.showErrorMessage("Some .rego files have changes which couldn't be saved. Please save all .rego files and try again."); |
| 34 | + return; |
| 35 | + } |
| 36 | + |
| 37 | + const plan = await longRunning('Working out sync actions...', () => syncActions(kubectl)); |
| 38 | + if (failed(plan)) { |
| 39 | + await vscode.window.showErrorMessage(plan.error[0]); |
| 40 | + return; |
| 41 | + } |
| 42 | + |
| 43 | + const actions = plan.result; |
| 44 | + |
| 45 | + const actionQuickPicks = createQuickPicks(actions); |
| 46 | + if (actionQuickPicks.length === 0) { |
| 47 | + await vscode.window.showInformationMessage('Cluster and workspace are already in sync'); |
| 48 | + return; |
| 49 | + } |
| 50 | + |
| 51 | + const selectedActionQuickPicks = await vscode.window.showQuickPick(actionQuickPicks, { canPickMany: true }); |
| 52 | + if (!selectedActionQuickPicks || selectedActionQuickPicks.length === 0) { |
| 53 | + return; |
| 54 | + } |
| 55 | + |
| 56 | + const selectedActionPromises = selectedActionQuickPicks.map((a) => runAction(kubectl, a)); |
| 57 | + const actionResults = await longRunning('Syncing the cluster from the workspace...', () => |
| 58 | + Promise.all(selectedActionPromises) |
| 59 | + ); |
| 60 | + |
| 61 | + await displaySyncResult(actionResults, clusterExplorer); |
| 62 | +} |
| 63 | + |
| 64 | +async function saveRegoFiles(): Promise<boolean> { |
| 65 | + const dirtyRegoFiles = vscode.workspace.textDocuments.filter((d) => d.languageId === 'rego' && d.isDirty); |
| 66 | + const savePromises = dirtyRegoFiles.map((f) => f.save()); |
| 67 | + const saveResults = await Promise.all(savePromises); |
| 68 | + if (saveResults.some((r) => !r)) { |
| 69 | + return false; |
| 70 | + } |
| 71 | + return true; |
| 72 | +} |
| 73 | + |
| 74 | +interface RegoFile { |
| 75 | + readonly uri: vscode.Uri; |
| 76 | + readonly content: string; |
| 77 | +} |
| 78 | + |
| 79 | +interface SyncActions { |
| 80 | + readonly deploy: ReadonlyArray<RegoFile>; |
| 81 | + readonly overwriteDevRego: ReadonlyArray<RegoFile>; |
| 82 | + readonly overwriteNonDevRego: ReadonlyArray<RegoFile>; |
| 83 | + readonly delete: ReadonlyArray<string>; |
| 84 | +} |
| 85 | + |
| 86 | +type ActionQuickPickItem = vscode.QuickPickItem & ({ |
| 87 | + readonly value: RegoFile; |
| 88 | + readonly action: 'deploy'; |
| 89 | + readonly isCreate: boolean; |
| 90 | +} | { |
| 91 | + readonly value: string; |
| 92 | + readonly action: 'delete'; |
| 93 | +}); |
| 94 | + |
| 95 | +async function syncActions(kubectl: k8s.KubectlV1): Promise<Errorable<SyncActions>> { |
| 96 | + // Strategy: |
| 97 | + // * Find all the .rego files in the workspace |
| 98 | + // * Look at all the configmaps in the cluster (except system ones) |
| 99 | + // * Sort the .rego files into buckets: |
| 100 | + // * No matching policy. We can deploy these fearlessly. |
| 101 | + // * Matching policy that the extension didn't deploy. We can deploy these with caution. |
| 102 | + // * Matching policy whose content is the same as the file. We can skip these. |
| 103 | + // * Matching policy whose content is out of sync with the file. We can deploy these fearlessly, too. |
| 104 | + // * Pick out all the MANAGED configmaps in the cluster which DON'T have matching files. |
| 105 | + // We propose to delete these. |
| 106 | + |
| 107 | + const regoUris = await vscode.workspace.findFiles('**/*.rego'); |
| 108 | + const regoFiles = await Promise.all(regoUris.map(async (u) => ({ uri: u, content: (await vscode.workspace.openTextDocument(u)).getText() }))); |
| 109 | + const clusterPolicies = await listPolicies(kubectl); |
| 110 | + |
| 111 | + const nonFileRegoUris = regoUris.filter((u) => u.scheme !== 'file'); |
| 112 | + if (nonFileRegoUris.length > 0) { |
| 113 | + const message = nonFileRegoUris.map((u) => u.toString()).join(', '); |
| 114 | + return { succeeded: false, error: [`Workspace contains .rego documents that aren't files. Save all .rego documents to the file system and try again. (${message})`] }; |
| 115 | + } |
| 116 | + |
| 117 | + if (failed(clusterPolicies)) { |
| 118 | + return { succeeded: false, error: [`Failed to get current policies: ${clusterPolicies.error[0]}`] }; |
| 119 | + } |
| 120 | + |
| 121 | + const localRegoFiles = regoUris.map((u) => vscode.workspace.asRelativePath(u)); |
| 122 | + |
| 123 | + const fileActions = partition(regoFiles, (f) => deploymentAction(clusterPolicies.result, f)); |
| 124 | + const filesToDeploy = fileActions.get('no-overwrite') || []; |
| 125 | + const filesOverwritingDevRego = fileActions.get('overwrite-dev') || []; |
| 126 | + const filesOverwritingNonDevRego = fileActions.get('overwrite-nondev') || []; |
| 127 | + const policiesToDelete = clusterPolicies.result |
| 128 | + .filter((p) => policyIsDevRego(p) && !hasMatchingRegoFile(localRegoFiles, p)) |
| 129 | + .map((p) => p.metadata.name); |
| 130 | + |
| 131 | + return { |
| 132 | + succeeded: true, |
| 133 | + result: { |
| 134 | + deploy: filesToDeploy, |
| 135 | + overwriteDevRego: filesOverwritingDevRego, |
| 136 | + overwriteNonDevRego: filesOverwritingNonDevRego, |
| 137 | + delete: policiesToDelete |
| 138 | + } |
| 139 | + }; |
| 140 | +} |
| 141 | + |
| 142 | +function deploymentAction(policies: ReadonlyArray<ConfigMap>, regoFile: RegoFile): 'no-overwrite' | 'overwrite-dev' | 'overwrite-nondev' | 'skip' { |
| 143 | + const policyName = basename(regoFile.uri.fsPath, '.rego'); // TODO: deduplicate - seems like DeploymentInfo might do this for us? |
| 144 | + const matchingPolicy = policies.find((p) => p.metadata.name === policyName); |
| 145 | + if (!matchingPolicy) { |
| 146 | + return 'no-overwrite'; |
| 147 | + } |
| 148 | + if (!policyIsDevRego(matchingPolicy)) { |
| 149 | + return 'overwrite-nondev'; // it's kind of opaque to us so let's not try to sniff content |
| 150 | + } |
| 151 | + const policyKeys = Object.keys(matchingPolicy.data); |
| 152 | + if (policyKeys.length !== 1) { |
| 153 | + return 'overwrite-nondev'; // shouldn't happen so something fishy is going on - claims to be managed but has been fiddled with |
| 154 | + } |
| 155 | + const policyContent = matchingPolicy.data[policyKeys[0]]; |
| 156 | + return policyContent === regoFile.content ? 'skip' : 'overwrite-dev'; |
| 157 | +} |
| 158 | + |
| 159 | +function hasMatchingRegoFile(regoFiles: ReadonlyArray<string>, policy: ConfigMap): boolean { |
| 160 | + return regoFiles.some((f) => basename(f, '.rego') === policy.metadata.name); |
| 161 | +} |
| 162 | + |
| 163 | +function createQuickPicks(actions: SyncActions) { |
| 164 | + const deployQuickPicks = actions.deploy.map((f) => deployQuickPick(f, 'deploy to cluster', true, true)); |
| 165 | + const overwriteDevRegoQuickPicks = actions.overwriteDevRego.map((f) => deployQuickPick(f, 'deploy to cluster (overwriting existing)', true, false)); |
| 166 | + const overwriteNonDevRegoQuickPicks = actions.overwriteNonDevRego.map((f) => deployQuickPick(f, 'deploy to cluster (overwriting existing not deployed by VS Code)', false, false)); |
| 167 | + const deleteQuickPicks: ActionQuickPickItem[] = actions.delete.map((p) => ({ label: `${p}: delete from cluster`, picked: true, value: p, action: 'delete' })); |
| 168 | + const actionQuickPicks = deployQuickPicks.concat(overwriteDevRegoQuickPicks).concat(overwriteNonDevRegoQuickPicks).concat(deleteQuickPicks); |
| 169 | + return actionQuickPicks; |
| 170 | +} |
| 171 | + |
| 172 | +function deployQuickPick(file: RegoFile, actionDescription: string, picked: boolean, isCreate: boolean): ActionQuickPickItem { |
| 173 | + const displayFileName = vscode.workspace.asRelativePath(file.uri); |
| 174 | + return {label: `${displayFileName}: ${actionDescription}`, picked: picked, value: file, action: 'deploy', isCreate: isCreate }; |
| 175 | +} |
| 176 | + |
| 177 | +function runAction(kubectl: k8s.KubectlV1, action: ActionQuickPickItem): Promise<Errorable<null>> { |
| 178 | + switch (action.action) { |
| 179 | + case 'deploy': return runDeployAction(kubectl, action.value, action.isCreate); |
| 180 | + case 'delete': return runDeleteAction(kubectl, action.value); |
| 181 | + } |
| 182 | +} |
| 183 | + |
| 184 | +async function runDeployAction(kubectl: k8s.KubectlV1, regoFile: RegoFile, isCreate: boolean): Promise<Errorable<null>> { |
| 185 | + const regoFilePath = regoFile.uri.fsPath; |
| 186 | + const regoFileContent = regoFile.content; |
| 187 | + const deploymentInfo = new DeploymentInfo(regoFilePath, regoFileContent); |
| 188 | + const deployResult = isCreate ? |
| 189 | + await createOrUpdateConfigMapFrom(deploymentInfo, kubectl) : |
| 190 | + await updateConfigMapFrom(deploymentInfo, kubectl); |
| 191 | + if (failed(deployResult)) { |
| 192 | + return { succeeded: false, error: [`deploying ${vscode.workspace.asRelativePath(regoFile.uri)} (${deployResult.error[0]})`] }; |
| 193 | + } |
| 194 | + return deployResult; |
| 195 | +} |
| 196 | + |
| 197 | +async function runDeleteAction(kubectl: k8s.KubectlV1, policyName: string): Promise<Errorable<null>> { |
| 198 | + const sr = await kubectl.invokeCommand(`delete configmap ${policyName} --namespace=${OPA_NAMESPACE}`); |
| 199 | + |
| 200 | + if (sr && sr.code === 0) { |
| 201 | + return { succeeded: true, result: null }; |
| 202 | + } else { |
| 203 | + const reason = sr ? sr.stderr : 'unable to run kubectl'; |
| 204 | + return { succeeded: false, error: [`deleting config map ${policyName} (${reason})`] }; |
| 205 | + } |
| 206 | + |
| 207 | +} |
| 208 | + |
| 209 | +async function displaySyncResult(actionResults: Errorable<null>[], clusterExplorer: k8s.ClusterExplorerV1): Promise<void> { |
| 210 | + const failures = actionResults.filter((r) => failed(r)) as Failed[]; |
| 211 | + const successCount = actionResults.filter((r) => succeeded(r)).length; |
| 212 | + if (failures.length > 0) { |
| 213 | + const successCountInfo = successCount > 0 ? `. (${successCount} other update(s) succeeded.)` : ''; |
| 214 | + await vscode.window.showErrorMessage(`${failures.length} update(s) failed: ${failures.map((f) => f.error[0]).join(', ')}${successCountInfo}`); |
| 215 | + return; |
| 216 | + } |
| 217 | + |
| 218 | + if (successCount > 0) { |
| 219 | + clusterExplorer.refresh(); |
| 220 | + } |
| 221 | + |
| 222 | + await vscode.window.showInformationMessage(`Synced the cluster from the workspace`); |
| 223 | +} |
0 commit comments