Skip to content

Commit

Permalink
Sync cluster from workspace (#23)
Browse files Browse the repository at this point in the history
itowlson authored Dec 6, 2019
1 parent 9635fe5 commit 4d7af3e
Showing 9 changed files with 339 additions and 80 deletions.
10 changes: 10 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -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": [
2 changes: 1 addition & 1 deletion src/commands/delete-policy.ts
Original file line number Diff line number Diff line change
@@ -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}`);
}
}
59 changes: 1 addition & 58 deletions src/commands/deploy-rego.ts
Original file line number Diff line number Diff line change
@@ -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<Errorable<null>> {
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<Errorable<null>> {
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'); }
}
223 changes: 223 additions & 0 deletions src/commands/sync-from-workspace.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
// 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<boolean> {
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<RegoFile>;
readonly overwriteDevRego: ReadonlyArray<RegoFile>;
readonly overwriteNonDevRego: ReadonlyArray<RegoFile>;
readonly delete: ReadonlyArray<string>;
}

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<Errorable<SyncActions>> {
// 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<ConfigMap>, 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<string>, 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<Errorable<null>> {
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<Errorable<null>> {
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<Errorable<null>> {
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<null>[], clusterExplorer: k8s.ClusterExplorerV1): Promise<void> {
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`);
}
2 changes: 2 additions & 0 deletions src/extension.ts
Original file line number Diff line number Diff line change
@@ -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);
20 changes: 20 additions & 0 deletions src/opa.ts
Original file line number Diff line number Diff line change
@@ -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<Errorable<ReadonlyArray<ConfigMap>>> {
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';
}
Loading

0 comments on commit 4d7af3e

Please sign in to comment.