diff --git a/package.json b/package.json index 8431beecf..8c9f84f1b 100644 --- a/package.json +++ b/package.json @@ -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", @@ -580,6 +585,10 @@ "command": "terraform.cloud.run.apply.downloadLog", "when": "false" }, + { + "command": "terraform.cloud.run.viewApply", + "when": "false" + }, { "command": "terraform.cloud.organization.picker" } @@ -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" } ] @@ -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" } ] }, @@ -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" } ] }, diff --git a/src/features/terraformCloud.ts b/src/features/terraformCloud.ts index 4af764f8d..ffe433bf7 100644 --- a/src/features/terraformCloud.ts +++ b/src/features/terraformCloud.ts @@ -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; @@ -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, @@ -89,6 +103,8 @@ export class TerraformCloudFeature implements vscode.Disposable { runView, planView, planDataProvider, + applyView, + applyDataProvider, runDataProvider, workspaceDataProvider, workspaceView, @@ -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(); } }); @@ -117,6 +134,7 @@ export class TerraformCloudFeature implements vscode.Disposable { workspaceDataProvider.refresh(); runDataProvider.refresh(); planDataProvider.refresh(); + applyDataProvider.refresh(); } }); diff --git a/src/providers/tfc/applyProvider.ts b/src/providers/tfc/applyProvider.ts new file mode 100644 index 000000000..5423fa87e --- /dev/null +++ b/src/providers/tfc/applyProvider.ts @@ -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.Disposable { + private readonly didChangeTreeData = new vscode.EventEmitter(); + 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 { + return element; + } + + getChildren(element?: vscode.TreeItem | undefined): vscode.ProviderResult { + 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 { + 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 { + 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; + } +} diff --git a/src/providers/tfc/logHelpers.ts b/src/providers/tfc/logHelpers.ts new file mode 100644 index 000000000..06e332d52 --- /dev/null +++ b/src/providers/tfc/logHelpers.ts @@ -0,0 +1,86 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import * as vscode from 'vscode'; +import { Outputs, OutputChange, Diagnostic } from '../../terraformCloud/log'; +import { GetChangeActionIcon, GetDiagnosticSeverityIcon } from './helpers'; + +export interface DiagnosticSummary { + errorCount: number; + warningCount: number; +} + +export interface ItemWithChildren { + getChildren(): vscode.TreeItem[]; +} + +export function isItemWithChildren(element: object): element is ItemWithChildren { + return 'getChildren' in element; +} + +export class OutputsItem extends vscode.TreeItem implements ItemWithChildren { + constructor(private outputs: Outputs) { + const size = Object.keys(outputs).length; + super(`${size} outputs`, vscode.TreeItemCollapsibleState.Expanded); + } + + getChildren(): vscode.TreeItem[] { + const items: vscode.TreeItem[] = []; + Object.entries(this.outputs).forEach(([name, change]: [string, OutputChange]) => { + items.push(new OutputChangeItem(name, change)); + }); + return items; + } +} + +class OutputChangeItem extends vscode.TreeItem { + constructor(name: string, output: OutputChange) { + super(name, vscode.TreeItemCollapsibleState.None); + this.id = 'output/' + output.action + '/' + name; + if (output.action) { + this.iconPath = GetChangeActionIcon(output.action); + } + this.description = output.action; + if (output.sensitive) { + this.description += ' (sensitive)'; + } + } +} + +export class DiagnosticsItem extends vscode.TreeItem implements ItemWithChildren { + constructor(private diagnostics: Diagnostic[], summary: DiagnosticSummary) { + const labels: string[] = []; + if (summary.warningCount === 1) { + labels.push(`1 warning`); + } else if (summary.warningCount > 1) { + labels.push(`${summary.warningCount} warnings`); + } + if (summary.errorCount === 1) { + labels.push(`1 error`); + } else if (summary.errorCount > 1) { + labels.push(`${summary.errorCount} errors`); + } + super(labels.join(', '), vscode.TreeItemCollapsibleState.Expanded); + } + + getChildren(): vscode.TreeItem[] { + return this.diagnostics.map((diagnostic) => new DiagnosticItem(diagnostic)); + } +} + +export class DiagnosticItem extends vscode.TreeItem { + constructor(diagnostic: Diagnostic) { + super(diagnostic.summary, vscode.TreeItemCollapsibleState.None); + this.description = diagnostic.severity; + const icon = GetDiagnosticSeverityIcon(diagnostic.severity); + this.iconPath = icon; + + const tooltip = new vscode.MarkdownString(); + tooltip.supportThemeIcons = true; + tooltip.appendMarkdown(`$(${icon.id}) **${diagnostic.summary}**\n\n`); + tooltip.appendMarkdown(diagnostic.detail); + this.tooltip = tooltip; + } +} diff --git a/src/providers/tfc/planProvider.ts b/src/providers/tfc/planProvider.ts index 51a1ec8c9..94f9f72a7 100644 --- a/src/providers/tfc/planProvider.ts +++ b/src/providers/tfc/planProvider.ts @@ -9,22 +9,13 @@ import { Writable } from 'stream'; import axios from 'axios'; import TelemetryReporter from '@vscode/extension-telemetry'; -import { apiClient } from '../../terraformCloud'; import { TerraformCloudAuthenticationProvider } from '../authenticationProvider'; -import { ZodiosError, isErrorFromAlias } from '@zodios/core'; -import { apiErrorsToString } from '../../terraformCloud/errors'; +import { ZodiosError } from '@zodios/core'; import { handleAuthError, handleZodiosError } from './uiHelpers'; -import { GetDiagnosticSeverityIcon, GetChangeActionIcon, GetDriftChangeActionMessage } from './helpers'; -import { - Change, - ChangeSummary, - Diagnostic, - DriftSummary, - LogLine, - OutputChange, - Outputs, -} from '../../terraformCloud/log'; +import { GetChangeActionIcon, GetDriftChangeActionMessage } from './helpers'; +import { Change, ChangeSummary, Diagnostic, DriftSummary, LogLine, Outputs } from '../../terraformCloud/log'; import { PlanTreeItem } from './runProvider'; +import { DiagnosticSummary, DiagnosticsItem, OutputsItem, isItemWithChildren, ItemWithChildren } from './logHelpers'; export class PlanTreeDataProvider implements vscode.TreeDataProvider, vscode.Disposable { private readonly didChangeTreeData = new vscode.EventEmitter(); @@ -190,13 +181,6 @@ export class PlanTreeDataProvider implements vscode.TreeDataProvider { - items.push(new OutputChangeItem(name, change)); - }); - return items; - } -} - -class DiagnosticsItem extends vscode.TreeItem implements ItemWithChildren { - constructor(private diagnostics: Diagnostic[], summary: DiagnosticSummary) { - const labels: string[] = []; - if (summary.warningCount === 1) { - labels.push(`1 warning`); - } else if (summary.warningCount > 1) { - labels.push(`${summary.warningCount} warnings`); - } - if (summary.errorCount === 1) { - labels.push(`1 error`); - } else if (summary.errorCount > 1) { - labels.push(`${summary.errorCount} errors`); - } - super(labels.join(', '), vscode.TreeItemCollapsibleState.Expanded); - } - - getChildren(): vscode.TreeItem[] { - return this.diagnostics.map((diagnostic) => new DiagnosticItem(diagnostic)); - } -} - -class DiagnosticItem extends vscode.TreeItem { - constructor(diagnostic: Diagnostic) { - super(diagnostic.summary, vscode.TreeItemCollapsibleState.None); - this.description = diagnostic.severity; - const icon = GetDiagnosticSeverityIcon(diagnostic.severity); - this.iconPath = icon; - - const tooltip = new vscode.MarkdownString(); - tooltip.supportThemeIcons = true; - tooltip.appendMarkdown(`$(${icon.id}) **${diagnostic.summary}**\n\n`); - tooltip.appendMarkdown(diagnostic.detail); - this.tooltip = tooltip; - } -} - -interface DiagnosticSummary { - errorCount: number; - warningCount: number; -} diff --git a/src/providers/tfc/runProvider.ts b/src/providers/tfc/runProvider.ts index 7633faa8e..15d451564 100644 --- a/src/providers/tfc/runProvider.ts +++ b/src/providers/tfc/runProvider.ts @@ -20,6 +20,7 @@ import { PlanAttributes } from '../../terraformCloud/plan'; import { ApplyAttributes } from '../../terraformCloud/apply'; import { CONFIGURATION_SOURCE } from '../../terraformCloud/configurationVersion'; import { PlanTreeDataProvider } from './planProvider'; +import { ApplyTreeDataProvider } from './applyProvider'; export class RunTreeDataProvider implements vscode.TreeDataProvider, vscode.Disposable { private readonly didChangeTreeData = new vscode.EventEmitter(); @@ -31,6 +32,7 @@ export class RunTreeDataProvider implements vscode.TreeDataProvider { @@ -56,6 +58,15 @@ export class RunTreeDataProvider implements vscode.TreeDataProvider { + if (!apply.logReadUrl) { + await vscode.window.showErrorMessage(`No apply log found for ${apply.id}`); + return; + } + await vscode.commands.executeCommand('setContext', 'terraform.cloud.run.viewingApply', true); + await vscode.commands.executeCommand('terraform.cloud.run.apply.focus'); + this.applyDataProvider.refresh(apply); + }), ); } @@ -206,8 +217,8 @@ export class RunTreeDataProvider implements vscode.TreeDataProvider