Skip to content
This repository was archived by the owner on Jul 14, 2025. It is now read-only.

Commit 4d7af3e

Browse files
authored
Sync cluster from workspace (#23)
1 parent 9635fe5 commit 4d7af3e

File tree

9 files changed

+339
-80
lines changed

9 files changed

+339
-80
lines changed

package.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
"onCommand:opak8s.showPolicy",
2828
"onCommand:opak8s.findFileInWorkspace",
2929
"onCommand:opak8s.deletePolicy",
30+
"onCommand:opak8s.syncFromWorkspace",
3031
"onView:extension.vsKubernetesExplorer"
3132
],
3233
"main": "./out/extension",
@@ -51,6 +52,10 @@
5152
{
5253
"command": "opak8s.deletePolicy",
5354
"title": "Delete"
55+
},
56+
{
57+
"command": "opak8s.syncFromWorkspace",
58+
"title": "Sync Policies from Workspace (💻 → ☁️)"
5459
}
5560
],
5661
"menus": {
@@ -74,6 +79,11 @@
7479
"command": "opak8s.deletePolicy",
7580
"group": "80",
7681
"when": "viewItem =~ /opak8s\\.policy/i"
82+
},
83+
{
84+
"command": "opak8s.syncFromWorkspace",
85+
"group": "10",
86+
"when": "viewItem =~ /opak8s\\.folder\\.policies/i"
7787
}
7888
],
7989
"editor/context": [

src/commands/delete-policy.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,6 @@ async function tryDeletePolicy(policy: ConfigMap, clusterExplorer: k8s.ClusterEx
3939
await vscode.window.showInformationMessage(`Deleted config map ${policy.metadata.name}`);
4040
} else {
4141
const reason = deleteResult ? deleteResult.stderr : 'unable to run kubectl';
42-
await vscode.window.showErrorMessage(`Error deleteing config map ${policy.metadata.name}: ${reason}`);
42+
await vscode.window.showErrorMessage(`Error deleting config map ${policy.metadata.name}: ${reason}`);
4343
}
4444
}

src/commands/deploy-rego.ts

Lines changed: 1 addition & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,7 @@
11
import * as vscode from 'vscode';
22
import * as k8s from 'vscode-kubernetes-tools-api';
3-
import * as path from 'path';
43
import { showUnavailable, longRunning } from '../utils/host';
5-
import { Errorable } from '../utils/errorable';
6-
import { OPA_NAMESPACE, OPA_DEV_REGO_ANNOTATION } from '../opa';
7-
import { withTempFile } from '../utils/tempfile';
4+
import { createOrUpdateConfigMapFrom, DeploymentInfo } from '../opa/deployment';
85

96
export async function deployRego(textEditor: vscode.TextEditor, edit: vscode.TextEditorEdit) {
107
const kubectl = await k8s.extension.kubectl.v1;
@@ -37,57 +34,3 @@ export async function deployRego(textEditor: vscode.TextEditor, edit: vscode.Tex
3734
await vscode.window.showErrorMessage(`Error deploying ${filePath} as config map: ${result.error[0]}`);
3835
}
3936
}
40-
41-
async function createOrUpdateConfigMapFrom(deploymentInfo: DeploymentInfo, kubectl: k8s.KubectlV1): Promise<Errorable<null>> {
42-
const createResult = await kubectl.invokeCommand(`create configmap ${deploymentInfo.configmapName} --namespace=${OPA_NAMESPACE} --from-file=${deploymentInfo.fileName}=${deploymentInfo.filePath}`);
43-
if (createResult && createResult.code === 0) {
44-
const annotateResult = await kubectl.invokeCommand(`annotate configmap ${deploymentInfo.configmapName} --namespace=${OPA_NAMESPACE} ${OPA_DEV_REGO_ANNOTATION}=true`);
45-
if (!annotateResult || annotateResult.code !== 0) {
46-
return { succeeded: false, error: ['The policy was deployed successfully but you may not be able to update it'] };
47-
}
48-
return { succeeded: true, result: null };
49-
}
50-
51-
if (createResult && createResult.stderr.includes('(AlreadyExists)')) {
52-
return await updateConfigMapFrom(deploymentInfo, kubectl);
53-
}
54-
55-
const reason = createResult ? createResult.stderr : 'Unable to run kubectl';
56-
return { succeeded: false, error: [reason] };
57-
}
58-
59-
async function updateConfigMapFrom(deploymentInfo: DeploymentInfo, kubectl: k8s.KubectlV1): Promise<Errorable<null>> {
60-
const getResult = await kubectl.invokeCommand(`get configmap ${deploymentInfo.configmapName} --namespace=${OPA_NAMESPACE} -o json`);
61-
if (!getResult || getResult.code !== 0) {
62-
const reason = getResult ? getResult.stderr : 'unable to run kubectl';
63-
return { succeeded: false, error: [reason] };
64-
}
65-
66-
const configmap = JSON.parse(getResult.stdout);
67-
68-
const hasDevFlag = configmap.metadata && configmap.metadata.annotations && configmap.metadata.annotations[OPA_DEV_REGO_ANNOTATION];
69-
if (!hasDevFlag) {
70-
// TODO: consider option to publish and be damned!
71-
return { succeeded: false, error: [`config map ${deploymentInfo.configmapName} already exists and is not managed by Visual Studio Code`] };
72-
}
73-
74-
configmap.data[deploymentInfo.fileName] = deploymentInfo.fileContent;
75-
76-
const updated = JSON.stringify(configmap);
77-
78-
const replaceResult = await withTempFile(updated, 'json', (f) =>
79-
kubectl.invokeCommand(`replace -f ${f} --namespace=${OPA_NAMESPACE}`)
80-
);
81-
if (!replaceResult || replaceResult.code !== 0) {
82-
const reason = replaceResult ? replaceResult.stderr : 'unable to run kubectl';
83-
return { succeeded: false, error: [reason] };
84-
}
85-
86-
return { succeeded: true, result: null };
87-
}
88-
89-
class DeploymentInfo {
90-
constructor(readonly filePath: string, readonly fileContent: string) {}
91-
get fileName() { return path.basename(this.filePath); }
92-
get configmapName() { return path.basename(this.filePath, '.rego'); }
93-
}

src/commands/sync-from-workspace.ts

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
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+
}

src/extension.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { PolicyBrowser } from './ui/policy-browser';
77
import { showPolicy } from './commands/show-policy';
88
import { deletePolicy } from './commands/delete-policy';
99
import { findFileInWorkspace } from './commands/find-file-in-workspace';
10+
import { syncFromWorkspace } from './commands/sync-from-workspace';
1011

1112
export async function activate(context: vscode.ExtensionContext) {
1213
const disposables = [
@@ -15,6 +16,7 @@ export async function activate(context: vscode.ExtensionContext) {
1516
vscode.commands.registerCommand('opak8s.showPolicy', showPolicy),
1617
vscode.commands.registerCommand('opak8s.findFileInWorkspace', findFileInWorkspace),
1718
vscode.commands.registerCommand('opak8s.deletePolicy', deletePolicy),
19+
vscode.commands.registerCommand('opak8s.syncFromWorkspace', syncFromWorkspace),
1820
];
1921

2022
context.subscriptions.push(...disposables);

src/opa.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,29 @@
1+
import { KubectlV1 } from "vscode-kubernetes-tools-api";
2+
import { Errorable } from "./utils/errorable";
3+
14
export const OPA_HELM_RELEASE_NAME = 'opa';
25
export const OPA_NAMESPACE = 'opa';
36
export const OPA_DEV_REGO_ANNOTATION = 'k8s-opa-vscode.hestia.cc/devrego';
47

58
const OPA_POLICY_STATUS_ANNOTATION = 'openpolicyagent.org/policy-status';
69

10+
export async function listPolicies(kubectl: KubectlV1): Promise<Errorable<ReadonlyArray<ConfigMap>>> {
11+
const sr = await kubectl.invokeCommand(`get configmap --namespace ${OPA_NAMESPACE} -o json`);
12+
if (!sr || sr.code !== 0) {
13+
const message = sr ? sr.stderr : 'Unable to run kubectl';
14+
return { succeeded: false, error: [message] };
15+
}
16+
17+
const configmaps: GetConfigMapsResponse = JSON.parse(sr.stdout);
18+
if (configmaps.items) {
19+
const policies = configmaps.items.filter((cm) => !isSystemConfigMap(cm));
20+
return { succeeded: true, result: policies };
21+
}
22+
23+
return { succeeded: true, result: [] };
24+
25+
}
26+
727
export function isSystemConfigMap(configmap: ConfigMap): boolean {
828
return configmap.metadata.name === 'opa-default-system-main';
929
}

0 commit comments

Comments
 (0)