Skip to content

Commit 9d51fc0

Browse files
committed
TFC: Implement run panel for viewing plan
1 parent 63541b7 commit 9d51fc0

File tree

6 files changed

+318
-21
lines changed

6 files changed

+318
-21
lines changed

package.json

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -493,8 +493,8 @@
493493
"icon": "$(globe)"
494494
},
495495
{
496-
"command": "terraform.cloud.run.plan.downloadLog",
497-
"title": "View raw plan log",
496+
"command": "terraform.cloud.run.viewPlan",
497+
"title": "View plan output",
498498
"icon": "$(book)"
499499
},
500500
{
@@ -559,7 +559,7 @@
559559
"when": "false"
560560
},
561561
{
562-
"command": "terraform.cloud.run.plan.downloadLog",
562+
"command": "terraform.cloud.run.viewPlan",
563563
"when": "false"
564564
},
565565
{
@@ -619,7 +619,7 @@
619619
"group": "inline"
620620
},
621621
{
622-
"command": "terraform.cloud.run.plan.downloadLog",
622+
"command": "terraform.cloud.run.viewPlan",
623623
"when": "view == terraform.cloud.runs && viewItem =~ /hasPlan/",
624624
"group": "inline"
625625
},
@@ -652,6 +652,13 @@
652652
"name": "Runs",
653653
"contextualTitle": "Terraform Cloud runs"
654654
}
655+
],
656+
"terraform-cloud-panel": [
657+
{
658+
"id": "terraform.cloud.run.plan",
659+
"name": "Plan",
660+
"contextualTitle": "Terraform Plan"
661+
}
655662
]
656663
},
657664
"viewsContainers": {
@@ -666,6 +673,13 @@
666673
"title": "HashiCorp Terraform Cloud",
667674
"icon": "assets/icons/vs_code_terraform_cloud.svg"
668675
}
676+
],
677+
"panel": [
678+
{
679+
"id": "terraform-cloud-panel",
680+
"title": "Terraform Cloud",
681+
"icon": "assets/icons/vs_code_terraform_cloud.svg"
682+
}
669683
]
670684
},
671685
"viewsWelcome": [
@@ -710,6 +724,10 @@
710724
{
711725
"view": "terraform.cloud.runs",
712726
"contents": "Select a workspace to view a list of runs"
727+
},
728+
{
729+
"view": "terraform.cloud.run.plan",
730+
"contents": "Select a run to view a plan"
713731
}
714732
]
715733
},

src/features/terraformCloud.ts

Lines changed: 13 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,
@@ -75,7 +83,7 @@ export class TerraformCloudFeature implements vscode.Disposable {
7583
treeDataProvider: workspaceDataProvider,
7684
});
7785

78-
this.context.subscriptions.push(runView, runDataProvider, workspaceDataProvider, workspaceView);
86+
this.context.subscriptions.push(runView, planView, runDataProvider, workspaceDataProvider, workspaceView);
7987

8088
workspaceView.onDidChangeSelection((event) => {
8189
if (event.selection.length <= 0) {
@@ -87,6 +95,7 @@ export class TerraformCloudFeature implements vscode.Disposable {
8795

8896
// call the TFC Run provider with the workspace
8997
runDataProvider.refresh(workspaceItem);
98+
planDataProvider.refresh();
9099
});
91100

92101
// TODO: move this as the login/organization picker is fleshed out
@@ -96,6 +105,7 @@ export class TerraformCloudFeature implements vscode.Disposable {
96105
if (e.provider.id === TerraformCloudAuthenticationProvider.providerID) {
97106
workspaceDataProvider.refresh();
98107
runDataProvider.refresh();
108+
planDataProvider.refresh();
99109
}
100110
});
101111

@@ -158,6 +168,7 @@ export class TerraformCloudFeature implements vscode.Disposable {
158168
// refresh workspaces so they pick up the change
159169
workspaceDataProvider.refresh();
160170
runDataProvider.refresh();
171+
planDataProvider.refresh();
161172
}),
162173
);
163174
}

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 ${this.activePlanId}: `;
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

@@ -225,18 +231,6 @@ export class RunTreeDataProvider implements vscode.TreeDataProvider<TFCRunTreeIt
225231
return items;
226232
}
227233

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

0 commit comments

Comments
 (0)