Skip to content

Commit 0e55ed9

Browse files
committed
TFC: Implement run panel for viewing plan
1 parent 97743b9 commit 0e55ed9

File tree

6 files changed

+318
-22
lines changed

6 files changed

+318
-22
lines changed

package.json

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -493,9 +493,9 @@
493493
"icon": "$(globe)"
494494
},
495495
{
496-
"command": "terraform.cloud.run.plan.downloadLog",
497-
"title": "View raw plan log",
498-
"icon": "$(output)"
496+
"command": "terraform.cloud.run.viewPlan",
497+
"title": "View plan output",
498+
"icon": "$(book)"
499499
},
500500
{
501501
"command": "terraform.cloud.run.apply.downloadLog",
@@ -564,7 +564,7 @@
564564
"when": "false"
565565
},
566566
{
567-
"command": "terraform.cloud.run.plan.downloadLog",
567+
"command": "terraform.cloud.run.viewPlan",
568568
"when": "false"
569569
},
570570
{
@@ -632,7 +632,7 @@
632632
"group": "inline"
633633
},
634634
{
635-
"command": "terraform.cloud.run.plan.downloadLog",
635+
"command": "terraform.cloud.run.viewPlan",
636636
"when": "view == terraform.cloud.runs && viewItem =~ /hasPlan/",
637637
"group": "inline"
638638
},
@@ -665,6 +665,13 @@
665665
"name": "Runs",
666666
"contextualTitle": "Terraform Cloud runs"
667667
}
668+
],
669+
"terraform-cloud-panel": [
670+
{
671+
"id": "terraform.cloud.run.plan",
672+
"name": "Plan",
673+
"contextualTitle": "Terraform Plan"
674+
}
668675
]
669676
},
670677
"viewsContainers": {
@@ -679,6 +686,13 @@
679686
"title": "HashiCorp Terraform Cloud",
680687
"icon": "assets/icons/vs_code_terraform_cloud.svg"
681688
}
689+
],
690+
"panel": [
691+
{
692+
"id": "terraform-cloud-panel",
693+
"title": "Terraform Cloud",
694+
"icon": "assets/icons/vs_code_terraform_cloud.svg"
695+
}
682696
]
683697
},
684698
"viewsWelcome": [
@@ -723,6 +737,10 @@
723737
{
724738
"view": "terraform.cloud.runs",
725739
"contents": "Select a workspace to view a list of runs"
740+
},
741+
{
742+
"view": "terraform.cloud.run.plan",
743+
"contents": "Select a run to view a plan"
726744
}
727745
]
728746
},

src/features/terraformCloud.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import TelemetryReporter from '@vscode/extension-telemetry';
88

99
import { WorkspaceTreeDataProvider, WorkspaceTreeItem } from '../providers/tfc/workspaceProvider';
1010
import { RunTreeDataProvider } from '../providers/tfc/runProvider';
11+
import { PlanTreeDataProvider } from '../providers/tfc/planProvider';
1112
import { TerraformCloudAuthenticationProvider } from '../providers/authenticationProvider';
1213
import {
1314
CreateOrganizationItem,
@@ -56,7 +57,14 @@ export class TerraformCloudFeature implements vscode.Disposable {
5657
),
5758
);
5859

59-
const runDataProvider = new RunTreeDataProvider(this.context, this.reporter, outputChannel);
60+
const planDataProvider = new PlanTreeDataProvider(this.context, this.reporter, outputChannel);
61+
const planView = vscode.window.createTreeView('terraform.cloud.run.plan', {
62+
canSelectMany: false,
63+
showCollapseAll: true,
64+
treeDataProvider: planDataProvider,
65+
});
66+
67+
const runDataProvider = new RunTreeDataProvider(this.context, this.reporter, outputChannel, planDataProvider);
6068
const runView = vscode.window.createTreeView('terraform.cloud.runs', {
6169
canSelectMany: false,
6270
showCollapseAll: true,
@@ -77,7 +85,7 @@ export class TerraformCloudFeature implements vscode.Disposable {
7785
const organization = this.context.workspaceState.get('terraform.cloud.organization', '');
7886
workspaceView.title = organization !== '' ? `Workspaces - (${organization})` : 'Workspaces';
7987

80-
this.context.subscriptions.push(runView, runDataProvider, workspaceDataProvider, workspaceView);
88+
this.context.subscriptions.push(runView, planView, runDataProvider, workspaceDataProvider, workspaceView);
8189

8290
workspaceView.onDidChangeSelection((event) => {
8391
if (event.selection.length <= 0) {
@@ -89,6 +97,7 @@ export class TerraformCloudFeature implements vscode.Disposable {
8997
if (item instanceof WorkspaceTreeItem) {
9098
// call the TFC Run provider with the workspace
9199
runDataProvider.refresh(item);
100+
planDataProvider.refresh();
92101
}
93102
});
94103

@@ -100,6 +109,7 @@ export class TerraformCloudFeature implements vscode.Disposable {
100109
workspaceDataProvider.reset();
101110
workspaceDataProvider.refresh();
102111
runDataProvider.refresh();
112+
planDataProvider.refresh();
103113
}
104114
});
105115

src/providers/tfc/helpers.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
*/
55

66
import * as vscode from 'vscode';
7+
import { ChangeAction } from '../../terraformCloud/log';
78

89
export function GetPlanApplyStatusIcon(status?: string): vscode.ThemeIcon {
910
switch (status) {
@@ -148,6 +149,25 @@ export function GetRunStatusMessage(status?: string): string {
148149
return 'No runs available';
149150
}
150151

152+
export function GetPlanActionIcon(action: ChangeAction): vscode.ThemeIcon {
153+
switch (action) {
154+
case 'create':
155+
return new vscode.ThemeIcon('diff-added', new vscode.ThemeColor('charts.green'));
156+
case 'delete':
157+
return new vscode.ThemeIcon('diff-removed', new vscode.ThemeColor('charts.red'));
158+
case 'update':
159+
return new vscode.ThemeIcon('diff-modified', new vscode.ThemeColor('charts.orange'));
160+
case 'move':
161+
return new vscode.ThemeIcon('diff-renamed', new vscode.ThemeColor('charts.orange'));
162+
case 'replace':
163+
return new vscode.ThemeIcon('diff-renamed', new vscode.ThemeColor('charts.orange'));
164+
case 'read':
165+
return new vscode.ThemeIcon('export', new vscode.ThemeColor('charts.blue'));
166+
case 'noop':
167+
return new vscode.ThemeIcon('diff-ignored', new vscode.ThemeColor('charts.grey'));
168+
}
169+
}
170+
151171
export function RelativeTimeFormat(d: Date): string {
152172
const SECONDS_IN_MINUTE = 60;
153173
const SECONDS_IN_HOUR = SECONDS_IN_MINUTE * 60;

src/providers/tfc/planProvider.ts

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
/**
2+
* Copyright (c) HashiCorp, Inc.
3+
* SPDX-License-Identifier: MPL-2.0
4+
*/
5+
6+
import * as vscode from 'vscode';
7+
import * as readline from 'readline';
8+
import { Writable } from 'stream';
9+
import axios from 'axios';
10+
import TelemetryReporter from '@vscode/extension-telemetry';
11+
12+
import { apiClient } from '../../terraformCloud';
13+
import { TerraformCloudAuthenticationProvider } from '../authenticationProvider';
14+
import { ZodiosError, isErrorFromAlias } from '@zodios/core';
15+
import { apiErrorsToString } from '../../terraformCloud/errors';
16+
import { handleAuthError, handleZodiosError } from './uiHelpers';
17+
import { GetPlanActionIcon } from './helpers';
18+
import { ChangeAction, LogLine, Resource } from '../../terraformCloud/log';
19+
20+
export class PlanTreeDataProvider implements vscode.TreeDataProvider<vscode.TreeItem>, vscode.Disposable {
21+
private readonly didChangeTreeData = new vscode.EventEmitter<void | vscode.TreeItem>();
22+
public readonly onDidChangeTreeData = this.didChangeTreeData.event;
23+
private activePlanId: string | undefined;
24+
25+
constructor(
26+
private ctx: vscode.ExtensionContext,
27+
private reporter: TelemetryReporter,
28+
private outputChannel: vscode.OutputChannel,
29+
) {
30+
this.ctx.subscriptions.push(
31+
vscode.commands.registerCommand('terraform.cloud.run.plan.refresh', () => {
32+
this.reporter.sendTelemetryEvent('tfc-run-plan-refresh');
33+
this.refresh(this.activePlanId);
34+
}),
35+
);
36+
}
37+
38+
refresh(planId?: string): void {
39+
this.activePlanId = planId;
40+
this.didChangeTreeData.fire();
41+
}
42+
43+
getTreeItem(element: vscode.TreeItem): vscode.TreeItem | Thenable<vscode.TreeItem> {
44+
return element;
45+
}
46+
47+
getChildren(element?: vscode.TreeItem | undefined): vscode.ProviderResult<vscode.TreeItem[]> {
48+
if (element) {
49+
return [element];
50+
}
51+
if (!this.activePlanId) {
52+
return [];
53+
}
54+
55+
try {
56+
return this.getPlan(this.activePlanId);
57+
} catch (error) {
58+
return [];
59+
}
60+
}
61+
62+
private async getPlan(planId: string): Promise<vscode.TreeItem[]> {
63+
const session = await vscode.authentication.getSession(TerraformCloudAuthenticationProvider.providerID, [], {
64+
createIfNone: false,
65+
});
66+
67+
if (session === undefined) {
68+
return [];
69+
}
70+
71+
if (!this.activePlanId) {
72+
return [];
73+
}
74+
75+
try {
76+
const plan = await apiClient.getPlan({
77+
params: {
78+
plan_id: planId,
79+
},
80+
});
81+
const planLogUrl = plan.data.attributes['log-read-url'];
82+
83+
const result = await axios.get(planLogUrl, {
84+
headers: { Accept: 'text/plain' },
85+
responseType: 'stream',
86+
});
87+
const lineStream = readline.createInterface({
88+
input: result.data,
89+
output: new Writable(),
90+
});
91+
92+
const items: PlannedChange[] = [];
93+
94+
for await (const line of lineStream) {
95+
try {
96+
const logLine: LogLine = JSON.parse(line);
97+
98+
// TODO: non-JSON output?
99+
100+
// TODO: resource_drift
101+
// TODO: import?
102+
// TODO: test_*
103+
104+
if (logLine.type === 'planned_change' && logLine.change) {
105+
const runItem = new PlannedChange(logLine.change.action, logLine.change.resource);
106+
items.push(runItem);
107+
}
108+
} catch (e) {
109+
// skip any non-JSON lines, like Terraform version output
110+
continue;
111+
}
112+
}
113+
114+
return items;
115+
} catch (error) {
116+
let message = `Failed to obtain plan ${planId}: `;
117+
118+
if (error instanceof ZodiosError) {
119+
handleZodiosError(error, message, this.outputChannel, this.reporter);
120+
return [];
121+
}
122+
123+
if (axios.isAxiosError(error)) {
124+
if (error.response?.status === 401) {
125+
handleAuthError();
126+
return [];
127+
}
128+
129+
if (isErrorFromAlias(apiClient.api, 'listRuns', error)) {
130+
message += apiErrorsToString(error.response.data.errors);
131+
vscode.window.showErrorMessage(message);
132+
this.reporter.sendTelemetryException(error);
133+
return [];
134+
}
135+
}
136+
137+
if (error instanceof Error) {
138+
message += error.message;
139+
vscode.window.showErrorMessage(message);
140+
this.reporter.sendTelemetryException(error);
141+
return [];
142+
}
143+
144+
if (typeof error === 'string') {
145+
message += error;
146+
}
147+
vscode.window.showErrorMessage(message);
148+
return [];
149+
}
150+
}
151+
152+
dispose() {
153+
//
154+
}
155+
}
156+
157+
export class PlannedChange extends vscode.TreeItem {
158+
constructor(public action: ChangeAction, resource: Resource) {
159+
// TODO: nesting by module?
160+
super(resource.addr, vscode.TreeItemCollapsibleState.None);
161+
this.id = action + '/' + resource.addr;
162+
this.iconPath = GetPlanActionIcon(action);
163+
this.description = action;
164+
}
165+
}

src/providers/tfc/runProvider.ts

Lines changed: 9 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { handleAuthError, handleZodiosError } from './uiHelpers';
1818
import { PlanAttributes } from '../../terraformCloud/plan';
1919
import { ApplyAttributes } from '../../terraformCloud/apply';
2020
import { CONFIGURATION_SOURCE } from '../../terraformCloud/configurationVersion';
21+
import { PlanTreeDataProvider } from './planProvider';
2122

2223
export class RunTreeDataProvider implements vscode.TreeDataProvider<TFCRunTreeItem>, vscode.Disposable {
2324
private readonly didChangeTreeData = new vscode.EventEmitter<void | TFCRunTreeItem>();
@@ -28,11 +29,9 @@ export class RunTreeDataProvider implements vscode.TreeDataProvider<TFCRunTreeIt
2829
private ctx: vscode.ExtensionContext,
2930
private reporter: TelemetryReporter,
3031
private outputChannel: vscode.OutputChannel,
32+
private planDataProvider: PlanTreeDataProvider,
3133
) {
3234
this.ctx.subscriptions.push(
33-
vscode.commands.registerCommand('terraform.cloud.run.plan.downloadLog', async (run: PlanTreeItem) => {
34-
this.downloadPlanLog(run);
35-
}),
3635
vscode.commands.registerCommand('terraform.cloud.run.apply.downloadLog', async (run: ApplyTreeItem) =>
3736
this.downloadApplyLog(run),
3837
),
@@ -44,6 +43,13 @@ export class RunTreeDataProvider implements vscode.TreeDataProvider<TFCRunTreeIt
4443
this.reporter.sendTelemetryEvent('tfc-runs-viewInBrowser');
4544
vscode.env.openExternal(run.websiteUri);
4645
}),
46+
vscode.commands.registerCommand('terraform.cloud.run.viewPlan', async (run: PlanTreeItem) => {
47+
if (!run.id) {
48+
await vscode.window.showErrorMessage(`No plan found for ${run.id}`);
49+
return;
50+
}
51+
this.planDataProvider.refresh(run.id);
52+
}),
4753
);
4854
}
4955

@@ -227,18 +233,6 @@ export class RunTreeDataProvider implements vscode.TreeDataProvider<TFCRunTreeIt
227233
return items;
228234
}
229235

230-
private async downloadPlanLog(run: PlanTreeItem) {
231-
if (!run.id) {
232-
await vscode.window.showErrorMessage(`No plan found for ${run.id}`);
233-
return;
234-
}
235-
236-
const doc = await vscode.workspace.openTextDocument(run.documentUri);
237-
return await vscode.window.showTextDocument(doc, {
238-
preview: false,
239-
});
240-
}
241-
242236
private async downloadApplyLog(run: ApplyTreeItem) {
243237
if (!run.id) {
244238
await vscode.window.showErrorMessage(`No apply found for ${run.id}`);

0 commit comments

Comments
 (0)