Skip to content

Commit

Permalink
TFC: Implement apply panel for structured output (#1647)
Browse files Browse the repository at this point in the history
  • Loading branch information
radeksimko authored Dec 12, 2023
1 parent a0d800c commit 08a525c
Show file tree
Hide file tree
Showing 8 changed files with 406 additions and 109 deletions.
25 changes: 24 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -507,6 +507,11 @@
"title": "View raw apply log",
"icon": "$(output)"
},
{
"command": "terraform.cloud.run.viewApply",
"title": "View apply output",
"icon": "$(output)"
},
{
"command": "terraform.cloud.organization.picker",
"title": "HashiCorp Terraform Cloud: Pick Organization",
Expand Down Expand Up @@ -580,6 +585,10 @@
"command": "terraform.cloud.run.apply.downloadLog",
"when": "false"
},
{
"command": "terraform.cloud.run.viewApply",
"when": "false"
},
{
"command": "terraform.cloud.organization.picker"
}
Expand Down Expand Up @@ -652,7 +661,12 @@
},
{
"command": "terraform.cloud.run.apply.downloadLog",
"when": "view == terraform.cloud.runs && viewItem =~ /hasApply/",
"when": "view == terraform.cloud.runs && viewItem =~ /hasRawApply/",
"group": "inline"
},
{
"command": "terraform.cloud.run.viewApply",
"when": "view == terraform.cloud.runs && viewItem =~ /hasStructuredApply/",
"group": "inline"
}
]
Expand Down Expand Up @@ -685,6 +699,11 @@
"id": "terraform.cloud.run.plan",
"name": "Plan",
"when": "terraform.cloud.run.viewingPlan"
},
{
"id": "terraform.cloud.run.apply",
"name": "Apply",
"when": "terraform.cloud.run.viewingApply"
}
]
},
Expand Down Expand Up @@ -755,6 +774,10 @@
{
"view": "terraform.cloud.run.plan",
"contents": "Select a run to view a plan"
},
{
"view": "terraform.cloud.run.apply",
"contents": "Select a run to view an apply"
}
]
},
Expand Down
20 changes: 19 additions & 1 deletion src/features/terraformCloud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
import { APIQuickPick } from '../providers/tfc/uiHelpers';
import { TerraformCloudWebUrl } from '../terraformCloud';
import { PlanLogContentProvider } from '../providers/tfc/contentProvider';
import { ApplyTreeDataProvider } from '../providers/tfc/applyProvider';

export class TerraformCloudFeature implements vscode.Disposable {
private statusBar: OrganizationStatusBar;
Expand Down Expand Up @@ -64,7 +65,20 @@ export class TerraformCloudFeature implements vscode.Disposable {
treeDataProvider: planDataProvider,
});

const runDataProvider = new RunTreeDataProvider(this.context, this.reporter, outputChannel, planDataProvider);
const applyDataProvider = new ApplyTreeDataProvider(this.context, this.reporter, outputChannel);
const applyView = vscode.window.createTreeView('terraform.cloud.run.apply', {
canSelectMany: false,
showCollapseAll: true,
treeDataProvider: applyDataProvider,
});

const runDataProvider = new RunTreeDataProvider(
this.context,
this.reporter,
outputChannel,
planDataProvider,
applyDataProvider,
);
const runView = vscode.window.createTreeView('terraform.cloud.runs', {
canSelectMany: false,
showCollapseAll: true,
Expand All @@ -89,6 +103,8 @@ export class TerraformCloudFeature implements vscode.Disposable {
runView,
planView,
planDataProvider,
applyView,
applyDataProvider,
runDataProvider,
workspaceDataProvider,
workspaceView,
Expand All @@ -105,6 +121,7 @@ export class TerraformCloudFeature implements vscode.Disposable {
// call the TFC Run provider with the workspace
runDataProvider.refresh(item);
planDataProvider.refresh();
applyDataProvider.refresh();
}
});

Expand All @@ -117,6 +134,7 @@ export class TerraformCloudFeature implements vscode.Disposable {
workspaceDataProvider.refresh();
runDataProvider.refresh();
planDataProvider.refresh();
applyDataProvider.refresh();
}
});

Expand Down
238 changes: 238 additions & 0 deletions src/providers/tfc/applyProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/

import * as vscode from 'vscode';
import * as readline from 'readline';
import { Writable } from 'stream';
import axios from 'axios';
import TelemetryReporter from '@vscode/extension-telemetry';

import { TerraformCloudAuthenticationProvider } from '../authenticationProvider';
import { ZodiosError } from '@zodios/core';
import { handleAuthError, handleZodiosError } from './uiHelpers';
import { GetChangeActionIcon } from './helpers';
import { AppliedChange, ChangeSummary, Diagnostic, LogLine, Outputs } from '../../terraformCloud/log';
import { ApplyTreeItem } from './runProvider';
import { OutputsItem, DiagnosticsItem, DiagnosticSummary, ItemWithChildren, isItemWithChildren } from './logHelpers';

export class ApplyTreeDataProvider implements vscode.TreeDataProvider<vscode.TreeItem>, vscode.Disposable {
private readonly didChangeTreeData = new vscode.EventEmitter<void | vscode.TreeItem>();
public readonly onDidChangeTreeData = this.didChangeTreeData.event;
private apply: ApplyTreeItem | undefined;

constructor(
private ctx: vscode.ExtensionContext,
private reporter: TelemetryReporter,
private outputChannel: vscode.OutputChannel,
) {
this.ctx.subscriptions.push(
vscode.commands.registerCommand('terraform.cloud.run.apply.refresh', () => {
this.reporter.sendTelemetryEvent('tfc-run-apply-refresh');
this.refresh(this.apply);
}),
);
}

refresh(apply?: ApplyTreeItem): void {
this.apply = apply;
this.didChangeTreeData.fire();
}

getTreeItem(element: vscode.TreeItem): vscode.TreeItem | Thenable<vscode.TreeItem> {
return element;
}

getChildren(element?: vscode.TreeItem | undefined): vscode.ProviderResult<vscode.TreeItem[]> {
if (!this.apply) {
return [];
}

if (!element) {
try {
return this.getRootChildren(this.apply);
} catch (error) {
return [];
}
}

if (isItemWithChildren(element)) {
return element.getChildren();
}
}

private async getRootChildren(apply: ApplyTreeItem): Promise<vscode.TreeItem[]> {
const applyLog = await this.getApplyFromUrl(apply);

const items: vscode.TreeItem[] = [];
if (applyLog && applyLog.appliedChanges) {
items.push(new AppliedChangesItem(applyLog.appliedChanges, applyLog.changeSummary));
}
if (applyLog && applyLog.outputs && Object.keys(applyLog.outputs).length > 0) {
items.push(new OutputsItem(applyLog.outputs));
}
if (applyLog && applyLog.diagnostics && applyLog.diagnosticSummary && applyLog.diagnostics.length > 0) {
items.push(new DiagnosticsItem(applyLog.diagnostics, applyLog.diagnosticSummary));
}
return items;
}

private async getApplyFromUrl(apply: ApplyTreeItem): Promise<ApplyLog | undefined> {
const session = await vscode.authentication.getSession(TerraformCloudAuthenticationProvider.providerID, [], {
createIfNone: false,
});

if (session === undefined) {
return;
}

try {
const result = await axios.get(apply.logReadUrl, {
headers: { Accept: 'text/plain' },
responseType: 'stream',
});
const lineStream = readline.createInterface({
input: result.data,
output: new Writable(),
});

const applyLog: ApplyLog = {};

for await (const line of lineStream) {
try {
const logLine: LogLine = JSON.parse(line);

if (logLine.type === 'apply_complete' && logLine.hook) {
if (!applyLog.appliedChanges) {
applyLog.appliedChanges = [];
}
applyLog.appliedChanges.push(logLine.hook);
continue;
}
if (logLine.type === 'change_summary' && logLine.changes) {
applyLog.changeSummary = logLine.changes;
continue;
}
if (logLine.type === 'outputs' && logLine.outputs) {
applyLog.outputs = logLine.outputs;
continue;
}
if (logLine.type === 'diagnostic' && logLine.diagnostic) {
if (!applyLog.diagnostics) {
applyLog.diagnostics = [];
}
if (!applyLog.diagnosticSummary) {
applyLog.diagnosticSummary = {
errorCount: 0,
warningCount: 0,
};
}
applyLog.diagnostics.push(logLine.diagnostic);
if (logLine.diagnostic.severity === 'warning') {
applyLog.diagnosticSummary.warningCount += 1;
}
if (logLine.diagnostic.severity === 'error') {
applyLog.diagnosticSummary.errorCount += 1;
}
continue;
}

// TODO: logLine.type=test_*
} catch (e) {
// skip any non-JSON lines, like Terraform version output
continue;
}
}

return applyLog;
} catch (error) {
let message = `Failed to obtain apply log from ${apply.logReadUrl}: `;

if (error instanceof ZodiosError) {
handleZodiosError(error, message, this.outputChannel, this.reporter);
return;
}

if (axios.isAxiosError(error)) {
if (error.response?.status === 401) {
handleAuthError();
return;
}
}

if (error instanceof Error) {
message += error.message;
vscode.window.showErrorMessage(message);
this.reporter.sendTelemetryException(error);
return;
}

if (typeof error === 'string') {
message += error;
}
vscode.window.showErrorMessage(message);
return;
}
}

dispose() {
//
}
}

interface ApplyLog {
appliedChanges?: AppliedChange[];
changeSummary?: ChangeSummary;
outputs?: Outputs;
diagnostics?: Diagnostic[];
diagnosticSummary?: DiagnosticSummary;
}

class AppliedChangesItem extends vscode.TreeItem implements ItemWithChildren {
constructor(private appliedChanges: AppliedChange[], summary?: ChangeSummary) {
let label = 'Applied changes';
if (summary) {
const labels: string[] = [];
if (summary.import > 0) {
labels.push(`${summary.import} imported`);
}
if (summary.add > 0) {
labels.push(`${summary.add} added`);
}
if (summary.change > 0) {
labels.push(`${summary.change} changed`);
}
if (summary.remove > 0) {
labels.push(`${summary.remove} destroyed`);
}
if (labels.length > 0) {
label = `Applied changes: ${labels.join(', ')}`;
}
}
super(label, vscode.TreeItemCollapsibleState.Expanded);
}

getChildren(): vscode.TreeItem[] {
return this.appliedChanges.map((change) => new AppliedChangeItem(change));
}
}

class AppliedChangeItem extends vscode.TreeItem {
constructor(public change: AppliedChange) {
const label = change.resource.addr;

super(label, vscode.TreeItemCollapsibleState.None);
this.id = change.action + '/' + change.resource.addr;
this.iconPath = GetChangeActionIcon(change.action);

this.description = change.action;
if (change.id_key && change.id_value) {
this.description = `${change.id_key}=${change.id_value}`;
}

const tooltip = new vscode.MarkdownString();
tooltip.appendMarkdown(`_${change.action}_ \`${change.resource.addr}\``);
this.tooltip = tooltip;
}
}
Loading

0 comments on commit 08a525c

Please sign in to comment.