diff --git a/build/downloader.ts b/build/downloader.ts index b73d9726..dfe012af 100644 --- a/build/downloader.ts +++ b/build/downloader.ts @@ -49,7 +49,7 @@ function getExtensionInfo(): ExtensionInfo { // eslint-disable-next-line @typescript-eslint/no-var-requires const pjson = require('../package.json'); return { - name: pjson.name, + name: 'terraform', extensionVersion: pjson.version, languageServerVersion: pjson.langServer.version, syntaxVersion: pjson.syntax.version, diff --git a/docs/tfc/choose_org_view.png b/docs/tfc/choose_org_view.png deleted file mode 100644 index d927ed57..00000000 Binary files a/docs/tfc/choose_org_view.png and /dev/null differ diff --git a/docs/tfc/log_out.png b/docs/tfc/log_out.png deleted file mode 100644 index e712925a..00000000 Binary files a/docs/tfc/log_out.png and /dev/null differ diff --git a/docs/tfc/login_view.gif b/docs/tfc/login_view.gif deleted file mode 100644 index 727a9585..00000000 Binary files a/docs/tfc/login_view.gif and /dev/null differ diff --git a/docs/tfc/plan_apply_view.gif b/docs/tfc/plan_apply_view.gif deleted file mode 100644 index b8a52f7a..00000000 Binary files a/docs/tfc/plan_apply_view.gif and /dev/null differ diff --git a/docs/tfc/workspace_run_view.gif b/docs/tfc/workspace_run_view.gif deleted file mode 100644 index 8cc5af97..00000000 Binary files a/docs/tfc/workspace_run_view.gif and /dev/null differ diff --git a/docs/tfc/workspace_view.gif b/docs/tfc/workspace_view.gif deleted file mode 100644 index cd9eecab..00000000 Binary files a/docs/tfc/workspace_view.gif and /dev/null differ diff --git a/package.json b/package.json index de2df32a..980dc205 100644 --- a/package.json +++ b/package.json @@ -548,7 +548,7 @@ "viewsContainers": { "activitybar": [ { - "id": "opentofu", + "id": "terraform", "title": "OpenTofu", "icon": "assets/icons/vs_code_terraform.svg" } diff --git a/src/commands/terraform.ts b/src/commands/terraform.ts index b5c42c4c..901e6f07 100644 --- a/src/commands/terraform.ts +++ b/src/commands/terraform.ts @@ -3,7 +3,6 @@ * SPDX-License-Identifier: MPL-2.0 */ -import TelemetryReporter from '@vscode/extension-telemetry'; import * as vscode from 'vscode'; import { LanguageClient } from 'vscode-languageclient/node'; import * as terraform from '../terraform'; @@ -11,22 +10,22 @@ import * as terraform from '../terraform'; export class TerraformCommands implements vscode.Disposable { private commands: vscode.Disposable[]; - constructor(private client: LanguageClient, private reporter: TelemetryReporter) { + constructor(private client: LanguageClient) { this.commands = [ vscode.commands.registerCommand('terraform.init', async () => { - await terraform.initAskUserCommand(this.client, this.reporter); + await terraform.initAskUserCommand(this.client); }), vscode.commands.registerCommand('terraform.initCurrent', async () => { - await terraform.initCurrentOpenFileCommand(this.client, this.reporter); + await terraform.initCurrentOpenFileCommand(this.client); }), vscode.commands.registerCommand('terraform.apply', async () => { - await terraform.command('apply', this.client, this.reporter, true); + await terraform.command('apply', this.client, true); }), vscode.commands.registerCommand('terraform.plan', async () => { - await terraform.command('plan', this.client, this.reporter, true); + await terraform.command('plan', this.client, true); }), vscode.commands.registerCommand('terraform.validate', async () => { - await terraform.command('validate', this.client, this.reporter); + await terraform.command('validate', this.client); }), ]; } diff --git a/src/extension.ts b/src/extension.ts index 06c66ac3..43e05064 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -4,7 +4,6 @@ */ import * as vscode from 'vscode'; -import TelemetryReporter from '@vscode/extension-telemetry'; import { CloseAction, DocumentSelector, @@ -23,7 +22,6 @@ import { ModuleCallsDataProvider } from './providers/moduleCalls'; import { ModuleProvidersDataProvider } from './providers/moduleProviders'; import { ServerPath } from './utils/serverPath'; import { config, handleLanguageClientStartError } from './utils/vscode'; -import { TelemetryFeature } from './features/telemetry'; import { ShowReferencesFeature } from './features/showReferences'; import { CustomSemanticTokens } from './features/semanticTokens'; import { ModuleProvidersFeature } from './features/moduleProviders'; @@ -34,7 +32,6 @@ import { getInitializationOptions } from './settings'; import { TerraformLSCommands } from './commands/terraformls'; import { TerraformCommands } from './commands/terraform'; import * as lsStatus from './status/language'; -import { TerraformCloudFeature } from './features/terraformCloud'; const id = 'terraform'; const brand = `HashiCorp Terraform`; @@ -43,32 +40,23 @@ const documentSelector: DocumentSelector = [ { scheme: 'file', language: 'terraform-vars' }, ]; const outputChannel = vscode.window.createOutputChannel(brand); -const tfcOutputChannel = vscode.window.createOutputChannel('HashiCorp Terraform Cloud'); -let reporter: TelemetryReporter; let client: LanguageClient; let initializationError: ResponseError | undefined = undefined; let crashCount = 0; export async function activate(context: vscode.ExtensionContext): Promise { const manifest = context.extension.packageJSON; - reporter = new TelemetryReporter(context.extension.id, manifest.version, manifest.appInsightsKey); - context.subscriptions.push(reporter); // always register commands needed to control terraform-ls context.subscriptions.push(new TerraformLSCommands()); - context.subscriptions.push(new TerraformCloudFeature(context, reporter, tfcOutputChannel)); - if (config('terraform').get('languageServer.enable') === false) { - reporter.sendTelemetryEvent('disabledTerraformLS'); return; } const lsPath = new ServerPath(context); - if (lsPath.hasCustomBinPath()) { - reporter.sendTelemetryEvent('usePathToBinary'); - } + const serverOptions = await getServerOptions(lsPath, outputChannel); const initializationOptions = getInitializationOptions(); @@ -90,8 +78,6 @@ export async function activate(context: vscode.ExtensionContext): Promise initializationFailedHandler: (error: ResponseError | Error | any) => { initializationError = error; - reporter.sendTelemetryException(error); - let msg = 'Failure to start terraform-ls. Please check your configuration settings and reload this window'; const serverArgs = config('terraform').get('languageServer.args', []); @@ -193,28 +179,26 @@ export async function activate(context: vscode.ExtensionContext): Promise break; case State.Stopped: lsStatus.setLanguageServerStopped(); - reporter.sendTelemetryEvent('stopClient'); break; } }); client.registerFeatures([ - new LanguageStatusFeature(client, reporter, outputChannel), + new LanguageStatusFeature(client, outputChannel), new CustomSemanticTokens(client, manifest), - new ModuleProvidersFeature(client, new ModuleProvidersDataProvider(context, client, reporter)), - new ModuleCallsFeature(client, new ModuleCallsDataProvider(context, client, reporter)), - new TelemetryFeature(client, reporter), + new ModuleProvidersFeature(client, new ModuleProvidersDataProvider(context, client)), + new ModuleCallsFeature(client, new ModuleCallsDataProvider(context, client)), new ShowReferencesFeature(client), - new TerraformVersionFeature(client, reporter, outputChannel), + new TerraformVersionFeature(client, outputChannel), ]); // these need the LS to function, so are only registered if enabled - context.subscriptions.push(new GenerateBugReportCommand(context), new TerraformCommands(client, reporter)); + context.subscriptions.push(new GenerateBugReportCommand(context), new TerraformCommands(client)); try { await client.start(); } catch (error) { - await handleLanguageClientStartError(error, context, reporter); + await handleLanguageClientStartError(error, context); } } @@ -224,12 +208,10 @@ export async function deactivate(): Promise { } catch (error) { if (error instanceof Error) { outputChannel.appendLine(error.message); - reporter.sendTelemetryException(error); vscode.window.showErrorMessage(error.message); lsStatus.setLanguageServerState(error.message, false, vscode.LanguageStatusSeverity.Error); } else if (typeof error === 'string') { outputChannel.appendLine(error); - reporter.sendTelemetryException(new Error(error)); vscode.window.showErrorMessage(error); lsStatus.setLanguageServerState(error, false, vscode.LanguageStatusSeverity.Error); } diff --git a/src/features/languageStatus.ts b/src/features/languageStatus.ts index 0a9d0772..7d7e9e3c 100644 --- a/src/features/languageStatus.ts +++ b/src/features/languageStatus.ts @@ -4,7 +4,6 @@ */ import * as vscode from 'vscode'; -import TelemetryReporter from '@vscode/extension-telemetry'; import { BaseLanguageClient, ClientCapabilities, FeatureState, StaticFeature } from 'vscode-languageclient'; import { ExperimentalClientCapabilities } from './types'; @@ -13,11 +12,7 @@ import * as lsStatus from '../status/language'; export class LanguageStatusFeature implements StaticFeature { private disposables: vscode.Disposable[] = []; - constructor( - private client: BaseLanguageClient, - private reporter: TelemetryReporter, - private outputChannel: vscode.OutputChannel, - ) {} + constructor(private client: BaseLanguageClient, private outputChannel: vscode.OutputChannel) {} getState(): FeatureState { return { @@ -32,7 +27,6 @@ export class LanguageStatusFeature implements StaticFeature { } public initialize(): void { - this.reporter.sendTelemetryEvent('startClient'); this.outputChannel.appendLine('Started client'); const initializeResult = this.client.initializeResult; diff --git a/src/features/telemetry.ts b/src/features/telemetry.ts deleted file mode 100644 index 8d094a26..00000000 --- a/src/features/telemetry.ts +++ /dev/null @@ -1,58 +0,0 @@ -/** - * Copyright (c) HashiCorp, Inc. - * SPDX-License-Identifier: MPL-2.0 - */ - -import * as vscode from 'vscode'; -import TelemetryReporter from '@vscode/extension-telemetry'; -import { BaseLanguageClient, ClientCapabilities, FeatureState, StaticFeature } from 'vscode-languageclient'; - -import { ExperimentalClientCapabilities } from './types'; - -const TELEMETRY_VERSION = 1; - -type TelemetryEvent = { - v: number; - name: string; - properties: { [key: string]: unknown }; -}; - -export class TelemetryFeature implements StaticFeature { - private disposables: vscode.Disposable[] = []; - - constructor(private client: BaseLanguageClient, private reporter: TelemetryReporter) {} - - getState(): FeatureState { - return { - kind: 'static', - }; - } - - public fillClientCapabilities(capabilities: ClientCapabilities & ExperimentalClientCapabilities): void { - if (!capabilities['experimental']) { - capabilities['experimental'] = {}; - } - capabilities['experimental']['telemetryVersion'] = TELEMETRY_VERSION; - } - - public initialize(): void { - if (vscode.env.isTelemetryEnabled === false) { - return; - } - - this.disposables.push( - this.client.onTelemetry((event: TelemetryEvent) => { - if (event.v !== TELEMETRY_VERSION) { - console.log(`unsupported telemetry event: ${event}`); - return; - } - - this.reporter.sendRawTelemetryEvent(event.name, event.properties); - }), - ); - } - - public dispose(): void { - this.disposables.forEach((d: vscode.Disposable) => d.dispose()); - } -} diff --git a/src/features/terraformCloud.ts b/src/features/terraformCloud.ts deleted file mode 100644 index a38ab1be..00000000 --- a/src/features/terraformCloud.ts +++ /dev/null @@ -1,251 +0,0 @@ -/** - * Copyright (c) HashiCorp, Inc. - * SPDX-License-Identifier: MPL-2.0 - */ - -import * as vscode from 'vscode'; -import TelemetryReporter from '@vscode/extension-telemetry'; - -import { WorkspaceTreeDataProvider, WorkspaceTreeItem } from '../providers/tfc/workspaceProvider'; -import { RunTreeDataProvider } from '../providers/tfc/runProvider'; -import { PlanTreeDataProvider } from '../providers/tfc/planProvider'; -import { TerraformCloudAuthenticationProvider } from '../providers/authenticationProvider'; -import { - CreateOrganizationItem, - OrganizationAPIResource, - RefreshOrganizationItem, -} from '../providers/tfc/organizationPicker'; -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; - - constructor( - private context: vscode.ExtensionContext, - private reporter: TelemetryReporter, - outputChannel: vscode.OutputChannel, - ) { - const authProvider = new TerraformCloudAuthenticationProvider( - context.secrets, - context, - this.reporter, - outputChannel, - ); - this.context.subscriptions.push( - vscode.workspace.registerTextDocumentContentProvider('vscode-terraform', new PlanLogContentProvider()), - ); - this.statusBar = new OrganizationStatusBar(context); - - authProvider.onDidChangeSessions(async (event) => { - if (event && event.added && event.added.length > 0) { - await vscode.commands.executeCommand('terraform.cloud.organization.picker'); - this.statusBar.show(); - } - if (event && event.removed && event.removed.length > 0) { - this.statusBar.reset(); - } - }); - - context.subscriptions.push( - vscode.authentication.registerAuthenticationProvider( - TerraformCloudAuthenticationProvider.providerID, - TerraformCloudAuthenticationProvider.providerLabel, - authProvider, - { supportsMultipleAccounts: false }, - ), - ); - - const planDataProvider = new PlanTreeDataProvider(this.context, this.reporter, outputChannel); - const planView = vscode.window.createTreeView('terraform.cloud.run.plan', { - canSelectMany: false, - showCollapseAll: true, - treeDataProvider: 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, - treeDataProvider: runDataProvider, - }); - - const workspaceDataProvider = new WorkspaceTreeDataProvider( - this.context, - runDataProvider, - this.reporter, - outputChannel, - ); - const workspaceView = vscode.window.createTreeView('terraform.cloud.workspaces', { - canSelectMany: false, - showCollapseAll: true, - treeDataProvider: workspaceDataProvider, - }); - const organization = this.context.workspaceState.get('terraform.cloud.organization', ''); - workspaceView.title = organization !== '' ? `Workspaces - (${organization})` : 'Workspaces'; - - this.context.subscriptions.push( - runView, - planView, - planDataProvider, - applyView, - applyDataProvider, - runDataProvider, - workspaceDataProvider, - workspaceView, - ); - - workspaceView.onDidChangeSelection((event) => { - if (event.selection.length <= 0) { - return; - } - - // we don't allow multi-select yet so this will always be one - const item = event.selection[0]; - if (item instanceof WorkspaceTreeItem) { - // call the TFC Run provider with the workspace - runDataProvider.refresh(item); - planDataProvider.refresh(); - applyDataProvider.refresh(); - } - }); - - // TODO: move this as the login/organization picker is fleshed out - // where it can handle things better - vscode.authentication.onDidChangeSessions((e) => { - // Refresh the workspace list if the user changes session - if (e.provider.id === TerraformCloudAuthenticationProvider.providerID) { - workspaceDataProvider.reset(); - workspaceDataProvider.refresh(); - runDataProvider.refresh(); - planDataProvider.refresh(); - applyDataProvider.refresh(); - } - }); - - workspaceView.onDidChangeVisibility(async (event) => { - if (event.visible) { - // the view is visible so show the status bar - this.statusBar.show(); - await vscode.commands.executeCommand('setContext', 'terraform.cloud.views.visible', true); - } else { - // hide statusbar because user isn't looking at our views - this.statusBar.hide(); - await vscode.commands.executeCommand('setContext', 'terraform.cloud.views.visible', false); - } - }); - - this.context.subscriptions.push( - vscode.commands.registerCommand('terraform.cloud.workspaces.picker', async () => { - this.reporter.sendTelemetryEvent('tfc-new-workspace'); - const organization = this.context.workspaceState.get('terraform.cloud.organization', ''); - if (organization === '') { - return []; - } - const terraformCloudURL = `${TerraformCloudWebUrl}/${organization}/workspaces/new`; - await vscode.env.openExternal(vscode.Uri.parse(terraformCloudURL)); - }), - vscode.commands.registerCommand('terraform.cloud.organization.picker', async () => { - this.reporter.sendTelemetryEvent('tfc-pick-organization'); - - const organizationAPIResource = new OrganizationAPIResource(outputChannel, reporter); - const organizationQuickPick = new APIQuickPick(organizationAPIResource); - let choice: vscode.QuickPickItem | undefined; - - // eslint-disable-next-line no-constant-condition - while (true) { - choice = await organizationQuickPick.pick(false); - - if (choice === undefined) { - // user exited without answering, so don't do anything - return; - } else if (choice instanceof CreateOrganizationItem) { - this.reporter.sendTelemetryEvent('tfc-pick-organization-create'); - - // open the browser an re-run the loop - choice.open(); - continue; - } else if (choice instanceof RefreshOrganizationItem) { - this.reporter.sendTelemetryEvent('tfc-pick-organization-refresh'); - // re-run the loop - continue; - } - - break; - } - - // user chose an organization so update the statusbar and make sure its visible - organizationQuickPick.hide(); - this.statusBar.show(choice.label); - workspaceView.title = `Workspace - (${choice.label})`; - - // project filter should be cleared on org change - await vscode.commands.executeCommand('terraform.cloud.workspaces.resetProjectFilter'); - // filter reset will refresh workspaces - }), - ); - } - - dispose() { - this.statusBar.dispose(); - } -} - -export class OrganizationStatusBar implements vscode.Disposable { - private organizationStatusBar = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 0); - - constructor(private context: vscode.ExtensionContext) { - this.organizationStatusBar.name = 'TFCOrganizationBar'; - this.organizationStatusBar.command = { - command: 'terraform.cloud.organization.picker', - title: 'Choose your Terraform Cloud Organization', - }; - } - - dispose() { - this.organizationStatusBar.dispose(); - } - - public async show(organization?: string) { - if (organization) { - await this.context.workspaceState.update('terraform.cloud.organization', organization); - } else { - organization = this.context.workspaceState.get('terraform.cloud.organization', ''); - } - - if (organization) { - this.organizationStatusBar.text = `$(account) TFC - ${organization}`; - await vscode.commands.executeCommand('setContext', 'terraform.cloud.organizationsChosen', true); - } else { - await vscode.commands.executeCommand('setContext', 'terraform.cloud.organizationsChosen', false); - } - - this.organizationStatusBar.show(); - } - - public async reset() { - await vscode.commands.executeCommand('setContext', 'terraform.cloud.organizationsChosen', false); - await this.context.workspaceState.update('terraform.cloud.organization', undefined); - this.organizationStatusBar.text = ''; - this.organizationStatusBar.hide(); - } - - public hide() { - this.organizationStatusBar.hide(); - } -} diff --git a/src/features/terraformVersion.ts b/src/features/terraformVersion.ts index e688e718..7be54221 100644 --- a/src/features/terraformVersion.ts +++ b/src/features/terraformVersion.ts @@ -9,7 +9,6 @@ import { ClientCapabilities, FeatureState, ServerCapabilities, StaticFeature } f import { getActiveTextEditor } from '../utils/vscode'; import { ExperimentalClientCapabilities } from './types'; import { Utils } from 'vscode-uri'; -import TelemetryReporter from '@vscode/extension-telemetry'; import { LanguageClient } from 'vscode-languageclient/node'; import * as lsStatus from '../status/language'; import * as versionStatus from '../status/installedVersion'; @@ -20,11 +19,7 @@ export class TerraformVersionFeature implements StaticFeature { private clientTerraformVersionCommandId = 'client.refreshTerraformVersion'; - constructor( - private client: LanguageClient, - private reporter: TelemetryReporter, - private outputChannel: vscode.OutputChannel, - ) {} + constructor(private client: LanguageClient, private outputChannel: vscode.OutputChannel) {} getState(): FeatureState { return { @@ -57,7 +52,7 @@ export class TerraformVersionFeature implements StaticFeature { lsStatus.setLanguageServerBusy(); - const response = await terraform.terraformVersion(moduleDir.toString(), this.client, this.reporter); + const response = await terraform.terraformVersion(moduleDir.toString(), this.client); versionStatus.setVersion(response.discovered_version || 'unknown'); requiredVersionStatus.setVersion(response.required_version || 'any'); diff --git a/src/providers/authenticationProvider.ts b/src/providers/authenticationProvider.ts deleted file mode 100644 index 20bba380..00000000 --- a/src/providers/authenticationProvider.ts +++ /dev/null @@ -1,363 +0,0 @@ -/** - * Copyright (c) HashiCorp, Inc. - * SPDX-License-Identifier: MPL-2.0 - */ - -import * as os from 'os'; -import * as path from 'path'; -import * as vscode from 'vscode'; -import TelemetryReporter from '@vscode/extension-telemetry'; -import { earlyApiClient, TerraformCloudHost, TerraformCloudWebUrl } from '../terraformCloud'; -import { isErrorFromAlias, ZodiosError } from '@zodios/core'; -import { apiErrorsToString } from '../terraformCloud/errors'; -import { handleZodiosError } from './tfc/uiHelpers'; - -class TerraformCloudSession implements vscode.AuthenticationSession { - // This id isn't used for anything yet, so we set it to a constant - readonly id = TerraformCloudAuthenticationProvider.providerID; - - // In the future, we may use the UAT permissions as scopes - // but right now have no use fior them, so we have an empty array here. - readonly scopes = []; - - /** - * - * @param accessToken The personal access token to use for authentication - * @param account The user account for the specified token - */ - constructor(public readonly accessToken: string, public account: vscode.AuthenticationSessionAccountInformation) {} -} - -class InvalidToken extends Error { - constructor() { - super('Invalid token'); - } -} - -class TerraformCloudSessionHandler { - constructor( - private outputChannel: vscode.OutputChannel, - private reporter: TelemetryReporter, - private readonly secretStorage: vscode.SecretStorage, - private readonly sessionKey: string, - ) {} - - async get(): Promise { - const rawSession = await this.secretStorage.get(this.sessionKey); - if (!rawSession) { - return undefined; - } - const session: TerraformCloudSession = JSON.parse(rawSession); - return session; - } - - async store(token: string): Promise { - try { - const user = await earlyApiClient.getAccount({ - headers: { - authorization: `Bearer ${token}`, - }, - }); - - const session = new TerraformCloudSession(token, { - label: user.data.attributes.username, - id: user.data.id, - }); - - await this.secretStorage.store(this.sessionKey, JSON.stringify(session)); - return session; - } catch (error) { - if (error instanceof ZodiosError) { - handleZodiosError(error, 'Failed to process user details', this.outputChannel, this.reporter); - throw error; - } - - if (isErrorFromAlias(earlyApiClient.api, 'getAccount', error)) { - if ((error.response.status as number) === 401) { - throw new InvalidToken(); - } - this.reporter.sendTelemetryException(error); - throw new Error(`Failed to login: ${apiErrorsToString(error.response.data.errors)}`); - } else if (error instanceof Error) { - this.reporter.sendTelemetryException(error); - } - - throw error; - } - } - - async delete(): Promise { - return this.secretStorage.delete(this.sessionKey); - } -} -export class TerraformCloudAuthenticationProvider implements vscode.AuthenticationProvider, vscode.Disposable { - static providerLabel = 'HashiCorp Terraform Cloud'; - static providerID = 'HashiCorpTerraformCloud'; - private sessionKey = 'HashiCorpTerraformCloudSession'; - private logger: vscode.LogOutputChannel; - private sessionHandler: TerraformCloudSessionHandler; - // this property is used to determine if the session has been changed in another window of VS Code - // it's a promise, so we can set in the constructor where we can't execute async code - private sessionPromise: Promise; - private disposable: vscode.Disposable | undefined; - - private _onDidChangeSessions = - new vscode.EventEmitter(); - - constructor( - private readonly secretStorage: vscode.SecretStorage, - private readonly ctx: vscode.ExtensionContext, - private reporter: TelemetryReporter, - private outputChannel: vscode.OutputChannel, - ) { - this.logger = vscode.window.createOutputChannel('HashiCorp Authentication', { log: true }); - this.sessionHandler = new TerraformCloudSessionHandler( - this.outputChannel, - this.reporter, - this.secretStorage, - this.sessionKey, - ); - ctx.subscriptions.push( - vscode.commands.registerCommand('terraform.cloud.login', async () => { - const session = await vscode.authentication.getSession(TerraformCloudAuthenticationProvider.providerID, [], { - createIfNone: true, - }); - vscode.window.showInformationMessage(`Hello ${session.account.label}`); - }), - ); - - this.sessionPromise = this.sessionHandler.get(); - this.disposable = vscode.Disposable.from( - this.secretStorage.onDidChange((e) => { - if (e.key === this.sessionKey) { - this.checkForUpdates(); - } - }), - ); - } - - get onDidChangeSessions(): vscode.Event { - // Expose our internal event emitter to the outer world - return this._onDidChangeSessions.event; - } - - // This function is called first when `vscode.authentication.getSessions` is called. - // eslint-disable-next-line @typescript-eslint/no-unused-vars - async getSessions(scopes?: string[] | undefined): Promise { - try { - const session = await this.sessionPromise; - if (session) { - this.logger.info('Successfully fetched Terraform Cloud session'); - await vscode.commands.executeCommand('setContext', 'terraform.cloud.signed-in', true); - return [session]; - } else { - return []; - } - } catch (error) { - if (error instanceof Error) { - vscode.window.showErrorMessage(error.message); - } else if (typeof error === 'string') { - vscode.window.showErrorMessage(error); - } - return []; - } - } - - // This function is called after `this.getSessions` is called and only when: - // - `this.getSessions` returns nothing but `createIfNone` was set to `true` in `vscode.authentication.getSessions` - // - `vscode.authentication.getSessions` was called with `forceNewSession: true` - // - The end user initiates the "silent" auth flow via the Accounts menu - async createSession(_scopes: readonly string[]): Promise { - // Prompt for the UAT. - const token = await this.promptForToken(); - if (!token) { - this.logger.error('User did not provide a token'); - throw new Error('Token is required'); - } - - try { - const session = await this.sessionHandler.store(token); - this.reporter.sendTelemetryEvent('tfc-login-success'); - this.logger.info('Successfully logged in to Terraform Cloud'); - - await vscode.commands.executeCommand('setContext', 'terraform.cloud.signed-in', true); - - // Notify VSCode's UI - this._onDidChangeSessions.fire({ added: [session], removed: [], changed: [] }); - - return session; - } catch (error) { - if (error instanceof InvalidToken) { - this.reporter.sendTelemetryEvent('tfc-login-fail', { reason: 'Invalid token' }); - vscode.window.showErrorMessage(`${error.message}. Please try again`); - return this.createSession(_scopes); - } else if (error instanceof Error) { - vscode.window.showErrorMessage(error.message); - this.reporter.sendTelemetryException(error); - this.logger.error(error.message); - } else if (typeof error === 'string') { - vscode.window.showErrorMessage(error); - this.reporter.sendTelemetryException(new Error(error)); - this.logger.error(error); - } - - throw error; - } - } - - // This function is called when the end user signs out of the account. - // eslint-disable-next-line @typescript-eslint/no-unused-vars - async removeSession(_sessionId: string): Promise { - const session = await this.sessionPromise; - if (!session) { - return; - } - - this.reporter.sendTelemetryEvent('tfc-logout'); - this.logger.info('Removing current session'); - await this.sessionHandler.delete(); - - await vscode.commands.executeCommand('setContext', 'terraform.cloud.signed-in', false); - - // Notify VSCode's UI - this._onDidChangeSessions.fire({ added: [], removed: [session], changed: [] }); - } - - // This is a crucial function that handles whether or not the session has changed in - // a different window of VS Code and sends the necessary event if it has. - private async checkForUpdates(): Promise { - const previousSession = await this.sessionPromise; - this.sessionPromise = this.sessionHandler.get(); - const session = await this.sessionPromise; - - const added: vscode.AuthenticationSession[] = []; - const removed: vscode.AuthenticationSession[] = []; - const changed: vscode.AuthenticationSession[] = []; - - if (session?.accessToken && !previousSession?.accessToken) { - this.logger.info('Session added'); - added.push(session); - } else if (!session?.accessToken && previousSession?.accessToken) { - this.logger.info('Session removed'); - removed.push(previousSession); - } else if (session && previousSession && session.accessToken !== previousSession.accessToken) { - this.logger.info('Session changed'); - changed.push(session); - } else { - return; - } - - this._onDidChangeSessions.fire({ added: added, removed: removed, changed: changed }); - } - - private async promptForToken(): Promise { - const choice = await vscode.window.showQuickPick( - [ - { - label: 'Stored user token', - detail: 'Use a token stored in the Terraform CLI configuration file', - }, - { - label: 'Existing user token', - detail: 'Enter a token manually', - }, - { - label: 'Generate a user token', - detail: 'Open the Terraform Cloud website to generate a new token', - }, - ], - { - canPickMany: false, - ignoreFocusOut: true, - placeHolder: 'Choose a method to enter a Terraform Cloud user token', - title: 'HashiCorp Terraform Cloud Authentication', - }, - ); - if (choice === undefined) { - return undefined; - } - - const terraformCloudURL = `${TerraformCloudWebUrl}/settings/tokens?source=vscode-terraform`; - let token: string | undefined; - switch (choice.label) { - case 'Generate a user token': - this.reporter.sendTelemetryEvent('tfc-login', { method: 'browser' }); - await vscode.env.openExternal(vscode.Uri.parse(terraformCloudURL)); - // Prompt for the UAT. - token = await vscode.window.showInputBox({ - ignoreFocusOut: true, - placeHolder: 'User access token', - prompt: 'Enter a Terraform Cloud user access token', - password: true, - }); - break; - case 'Existing user token': - this.reporter.sendTelemetryEvent('tfc-login', { method: 'existing' }); - // Prompt for the UAT. - token = await vscode.window.showInputBox({ - ignoreFocusOut: true, - placeHolder: 'User access token', - prompt: 'Enter a Terraform Cloud user access token', - password: true, - }); - break; - case 'Stored user token': - this.reporter.sendTelemetryEvent('tfc-login', { method: 'stored' }); - token = await this.getTerraformCLIToken(); - break; - default: - break; - } - - return token; - } - - private async getTerraformCLIToken() { - // detect if stored auth token is present - // ~/.terraform.d/credentials.tfrc.json - const credFilePath = path.join(os.homedir(), '.terraform.d', 'credentials.tfrc.json'); - if ((await this.pathExists(credFilePath)) === false) { - vscode.window.showErrorMessage( - 'Terraform credential file not found. Please login using the Terraform CLI and try again.', - ); - return undefined; - } - - // read and marshall json file - let text: string; - try { - const content = await vscode.workspace.fs.readFile(vscode.Uri.file(credFilePath)); - text = Buffer.from(content).toString('utf8'); - } catch (error) { - vscode.window.showErrorMessage( - 'Failed to read configuration file. Please login using the Terraform CLI and try again', - ); - return undefined; - } - - // find app.terraform.io token - try { - const data = JSON.parse(text); - const cred = data.credentials[TerraformCloudHost]; - return cred.token; - } catch (error) { - vscode.window.showErrorMessage( - `No token found for ${TerraformCloudHost}. Please login using the Terraform CLI and try again`, - ); - return undefined; - } - } - - private async pathExists(filePath: string): Promise { - try { - await vscode.workspace.fs.stat(vscode.Uri.file(filePath)); - return true; - } catch (error) { - return false; - } - } - - dispose() { - this.disposable?.dispose(); - } -} diff --git a/src/providers/moduleCalls.ts b/src/providers/moduleCalls.ts index 07d6d842..ce6c2da9 100644 --- a/src/providers/moduleCalls.ts +++ b/src/providers/moduleCalls.ts @@ -3,7 +3,6 @@ * SPDX-License-Identifier: MPL-2.0 */ -import TelemetryReporter from '@vscode/extension-telemetry'; import * as path from 'path'; import * as terraform from '../terraform'; import * as vscode from 'vscode'; @@ -65,7 +64,7 @@ export class ModuleCallsDataProvider implements vscode.TreeDataProvider(); public readonly onDidChangeTreeData = this.didChangeTreeData.event; - constructor(ctx: vscode.ExtensionContext, private client: LanguageClient, private reporter: TelemetryReporter) { + constructor(ctx: vscode.ExtensionContext, private client: LanguageClient) { ctx.subscriptions.push( vscode.commands.registerCommand('terraform.providers.refreshList', () => this.refresh()), vscode.window.onDidChangeActiveTextEditor(async (event: vscode.TextEditor | undefined) => { @@ -90,7 +89,7 @@ export class ModuleProvidersDataProvider 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/contentProvider.ts b/src/providers/tfc/contentProvider.ts deleted file mode 100644 index da3c4a2d..00000000 --- a/src/providers/tfc/contentProvider.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** - * Copyright (c) HashiCorp, Inc. - * SPDX-License-Identifier: MPL-2.0 - */ - -import axios from 'axios'; -import * as vscode from 'vscode'; - -import { apiClient } from '../../terraformCloud'; -import stripAnsi from './helpers'; - -export class PlanLogContentProvider implements vscode.TextDocumentContentProvider { - onDidChangeEmitter = new vscode.EventEmitter(); - onDidChange = this.onDidChangeEmitter.event; - - async provideTextDocumentContent(uri: vscode.Uri): Promise { - try { - const logUrl = await this.getLogUrl(uri); - if (!logUrl) { - throw new Error('Unable to parse log URL'); - } - - const result = await axios.get(logUrl, { headers: { Accept: 'text/plain' } }); - return this.parseLog(result.data); - } catch (error) { - if (error instanceof Error) { - await vscode.window.showErrorMessage('Failed to load log:', error.message); - } else if (typeof error === 'string') { - await vscode.window.showErrorMessage('Failed to load log:', error); - } - - console.error(error); - return ''; - } - } - - private parseLog(text: string) { - text = stripAnsi(text); // strip ansi escape codes - text = text.replace('', '').replace('', ''); // remove control characters - - return text; - } - - private async getLogUrl(uri: vscode.Uri) { - const id = uri.path.replace('/', ''); - - switch (uri.authority) { - case 'plan': - return await this.getPlanLogUrl(id); - case 'apply': - return await this.getApplyLogUrl(id); - } - } - - private async getPlanLogUrl(id: string) { - const plan = await apiClient.getPlan({ - params: { - plan_id: id, - }, - }); - - return plan.data.attributes['log-read-url']; - } - - private async getApplyLogUrl(id: string) { - const apply = await apiClient.getApply({ - params: { - apply_id: id, - }, - }); - - return apply.data.attributes['log-read-url']; - } -} diff --git a/src/providers/tfc/helpers.ts b/src/providers/tfc/helpers.ts deleted file mode 100644 index d8b9717d..00000000 --- a/src/providers/tfc/helpers.ts +++ /dev/null @@ -1,245 +0,0 @@ -/** - * Copyright (c) HashiCorp, Inc. - * SPDX-License-Identifier: MPL-2.0 - */ - -import * as vscode from 'vscode'; -import { ChangeAction, DiagnosticSeverity } from '../../terraformCloud/log'; - -export function GetPlanApplyStatusIcon(status?: string): vscode.ThemeIcon { - switch (status) { - case 'pending': - return new vscode.ThemeIcon('debug-pause', new vscode.ThemeColor('charts.gray')); - case 'managed_queued': - return new vscode.ThemeIcon('debug-pause', new vscode.ThemeColor('charts.gray')); - case 'queued': - return new vscode.ThemeIcon('debug-pause', new vscode.ThemeColor('charts.gray')); - case 'running': - return new vscode.ThemeIcon('run-status-running', new vscode.ThemeColor('charts.gray')); - case 'errored': - return new vscode.ThemeIcon('error', new vscode.ThemeColor('charts.red')); - case 'canceled': - return new vscode.ThemeIcon('error', new vscode.ThemeColor('charts.gray')); - case 'finished': - return new vscode.ThemeIcon('pass', new vscode.ThemeColor('charts.green')); - case 'unreachable': - return new vscode.ThemeIcon('dash'); - } - - return new vscode.ThemeIcon('dash'); -} - -export function GetRunStatusIcon(status?: string): vscode.ThemeIcon { - switch (status) { - // in progress - case 'pending': - return new vscode.ThemeIcon('debug-pause', new vscode.ThemeColor('charts.gray')); - case 'fetching': - return new vscode.ThemeIcon('sync~spin', new vscode.ThemeColor('charts.gray')); - case 'pre_plan_running': - return new vscode.ThemeIcon('run-status-running', new vscode.ThemeColor('charts.gray')); - case 'queuing': - return new vscode.ThemeIcon('debug-pause', new vscode.ThemeColor('charts.gray')); - case 'planning': - return new vscode.ThemeIcon('run-status-running', new vscode.ThemeColor('charts.gray')); - case 'cost_estimating': - return new vscode.ThemeIcon('sync~spin', new vscode.ThemeColor('charts.gray')); - case 'policy_checking': - return new vscode.ThemeIcon('sync~spin', new vscode.ThemeColor('charts.gray')); - case 'apply_queued': - return new vscode.ThemeIcon('debug-pause', new vscode.ThemeColor('charts.gray')); - case 'applying': - return new vscode.ThemeIcon('sync~spin', new vscode.ThemeColor('charts.gray')); - case 'post_plan_running': - return new vscode.ThemeIcon('run-status-running', new vscode.ThemeColor('charts.gray')); - case 'plan_queued': - return new vscode.ThemeIcon('debug-pause'); - case 'fetching_completed': - return new vscode.ThemeIcon('pass', new vscode.ThemeColor('charts.green')); - case 'pre_plan_completed': - return new vscode.ThemeIcon('pass', new vscode.ThemeColor('charts.green')); - case 'planned': - return new vscode.ThemeIcon('warning', new vscode.ThemeColor('charts.yellow')); - case 'cost_estimated': - return new vscode.ThemeIcon('info', new vscode.ThemeColor('charts.orange')); - case 'policy_override': - return new vscode.ThemeIcon('warning', new vscode.ThemeColor('charts.yellow')); - case 'policy_checked': - return new vscode.ThemeIcon('pass', new vscode.ThemeColor('charts.green')); - case 'confirmed': - return new vscode.ThemeIcon('pass', new vscode.ThemeColor('charts.green')); - case 'post_plan_completed': - return new vscode.ThemeIcon('pass', new vscode.ThemeColor('charts.green')); - case 'planned_and_finished': - return new vscode.ThemeIcon('pass-filled', new vscode.ThemeColor('charts.green')); - case 'policy_soft_failed': - return new vscode.ThemeIcon('warning', new vscode.ThemeColor('charts.yellow')); - case 'applied': - return new vscode.ThemeIcon('pass-filled', new vscode.ThemeColor('charts.green')); - case 'discarded': - return new vscode.ThemeIcon('close', new vscode.ThemeColor('charts.gray')); - case 'canceled': - return new vscode.ThemeIcon('error', new vscode.ThemeColor('charts.gray')); - case 'force_canceled': - return new vscode.ThemeIcon('error'); - case 'errored': - return new vscode.ThemeIcon('error', new vscode.ThemeColor('charts.red')); - } - - return new vscode.ThemeIcon('dash'); -} - -export function GetRunStatusMessage(status?: string): string { - switch (status) { - // in progress - case 'pending': - return 'Pending'; - case 'fetching': - return 'Fetching'; - case 'queuing': - return 'Queuing'; - case 'planning': - return 'Planning'; - case 'cost_estimating': - return 'Estimating costs'; - case 'policy_checking': - return 'Checking policies'; - case 'apply_queued': - return 'Apply queued'; - case 'applying': - return 'Applying'; - case 'post_plan_running': - return 'Tasks - post-plan (running)'; - case 'plan_queued': - return 'Plan queued'; - case 'fetching_completed': - return 'Fetching completed'; - case 'pre_plan_running': - return 'Tasks - pre-plan (running)'; - case 'pre_plan_completed': - return 'Tasks - pre-plan (passed)'; - case 'planned': - return 'Planned'; - case 'cost_estimated': - return 'Cost estimated'; - case 'policy_override': - return 'Policy override'; - case 'policy_checked': - return 'Policy checked'; - case 'confirmed': - return 'Confirmed'; - case 'post_plan_completed': - return 'Tasks - post-plan (passed)'; - case 'planned_and_finished': - return 'Planned and finished'; - case 'policy_soft_failed': - return 'Policy Soft Failure'; - case 'applied': - return 'Applied'; - case 'discarded': - return 'Discarded'; - case 'canceled': - return 'Canceled'; - case 'force_canceled': - return 'Force canceled'; - case 'errored': - return 'Errored'; - } - - return 'No runs available'; -} - -export function GetChangeActionIcon(action: ChangeAction): vscode.ThemeIcon { - switch (action) { - case 'create': - return new vscode.ThemeIcon('diff-added', new vscode.ThemeColor('charts.green')); - case 'delete': - return new vscode.ThemeIcon('diff-removed', new vscode.ThemeColor('charts.red')); - case 'update': - return new vscode.ThemeIcon('diff-modified', new vscode.ThemeColor('charts.orange')); - case 'move': - return new vscode.ThemeIcon('diff-renamed', new vscode.ThemeColor('charts.orange')); - case 'replace': - return new vscode.ThemeIcon('arrow-swap', new vscode.ThemeColor('charts.orange')); - case 'read': - return new vscode.ThemeIcon('git-fetch', new vscode.ThemeColor('charts.blue')); - case 'import': - return new vscode.ThemeIcon('export', new vscode.ThemeColor('charts.blue')); - case 'noop': - return new vscode.ThemeIcon('diff-ignored', new vscode.ThemeColor('charts.grey')); - } -} - -export function GetDriftChangeActionMessage(action: ChangeAction): string { - switch (action) { - case 'update': - return 'updated'; - case 'delete': - return 'deleted'; - default: - // Other actions are not defined for drifts - return 'unknown'; - } -} - -export function GetDiagnosticSeverityIcon(severity: DiagnosticSeverity): vscode.ThemeIcon { - switch (severity) { - case 'warning': - return new vscode.ThemeIcon('warning', new vscode.ThemeColor('charts.orange')); - case 'error': - return new vscode.ThemeIcon('error', new vscode.ThemeColor('charts.red')); - } -} - -export function RelativeTimeFormat(d: Date): string { - const SECONDS_IN_MINUTE = 60; - const SECONDS_IN_HOUR = SECONDS_IN_MINUTE * 60; - const SECONDS_IN_DAY = SECONDS_IN_HOUR * 24; - const SECONDS_IN_WEEK = SECONDS_IN_DAY * 7; - const SECONDS_IN_MONTH = SECONDS_IN_DAY * 30; - const SECONDS_IN_YEAR = SECONDS_IN_DAY * 365; - - const rtf = new Intl.RelativeTimeFormat('en', { style: 'long', numeric: 'auto' }); - const nowSeconds = Date.now() / 1000; - const seconds = d.getTime() / 1000; - const diffSeconds = nowSeconds - seconds; - - if (diffSeconds < SECONDS_IN_MINUTE) { - return rtf.format(-diffSeconds, 'second'); - } - if (diffSeconds < SECONDS_IN_HOUR) { - const minutes = Math.round(diffSeconds / SECONDS_IN_MINUTE); - return rtf.format(-minutes, 'minute'); - } - if (diffSeconds < SECONDS_IN_DAY) { - const hours = Math.round(diffSeconds / SECONDS_IN_HOUR); - return rtf.format(-hours, 'hour'); - } - if (diffSeconds < SECONDS_IN_WEEK) { - const days = Math.round(diffSeconds / SECONDS_IN_DAY); - return rtf.format(-days, 'day'); - } - if (diffSeconds < SECONDS_IN_MONTH) { - const weeks = Math.round(diffSeconds / SECONDS_IN_WEEK); - return rtf.format(-weeks, 'week'); - } - if (diffSeconds < SECONDS_IN_YEAR) { - const months = Math.round(diffSeconds / SECONDS_IN_MONTH); - return rtf.format(-months, 'month'); - } - const years = Math.round(diffSeconds / SECONDS_IN_YEAR); - return rtf.format(-years, 'year'); -} - -// See https://github.com/chalk/ansi-regex/blob/02fa893d619d3da85411acc8fd4e2eea0e95a9d9/index.js#L2 -const ansiRegex = new RegExp( - [ - '[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]+)*|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)', - '(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))', - ].join('|'), - 'g', -); - -export default function stripAnsi(text: string) { - return text.replace(ansiRegex, ''); -} diff --git a/src/providers/tfc/logHelpers.ts b/src/providers/tfc/logHelpers.ts deleted file mode 100644 index 06e332d5..00000000 --- a/src/providers/tfc/logHelpers.ts +++ /dev/null @@ -1,86 +0,0 @@ -/** - * 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/organizationPicker.ts b/src/providers/tfc/organizationPicker.ts deleted file mode 100644 index 222dab22..00000000 --- a/src/providers/tfc/organizationPicker.ts +++ /dev/null @@ -1,113 +0,0 @@ -/** - * Copyright (c) HashiCorp, Inc. - * SPDX-License-Identifier: MPL-2.0 - */ - -import * as vscode from 'vscode'; -import { TerraformCloudWebUrl, apiClient } from '../../terraformCloud'; -import { APIResource, handleAuthError, handleZodiosError } from './uiHelpers'; -import { Organization } from '../../terraformCloud/organization'; -import { ZodiosError, isErrorFromAlias } from '@zodios/core'; -import axios from 'axios'; -import { apiErrorsToString } from '../../terraformCloud/errors'; -import TelemetryReporter from '@vscode/extension-telemetry'; - -export class CreateOrganizationItem implements vscode.QuickPickItem { - get label() { - return '$(add) Create new organization'; - } - get detail() { - return 'Open the browser to create a new organization'; - } - async open() { - await vscode.env.openExternal(vscode.Uri.parse(`${TerraformCloudWebUrl}/organizations/new`)); - } - get alwaysShow() { - return true; - } -} - -export class RefreshOrganizationItem implements vscode.QuickPickItem { - get label() { - return '$(refresh) Refresh organizations'; - } - get detail() { - return 'Refetch all organizations'; - } - get alwaysShow() { - return true; - } -} - -class OrganizationItem implements vscode.QuickPickItem { - constructor(protected organization: Organization) {} - get label() { - return this.organization.attributes.name; - } -} - -export class OrganizationAPIResource implements APIResource { - name = 'organizations'; - title = 'Welcome to Terraform Cloud'; - placeholder = 'Choose an organization (type to search)'; - ignoreFocusOut = true; - - constructor(private outputChannel: vscode.OutputChannel, private reporter: TelemetryReporter) {} - - private async createOrganizationItems(search?: string): Promise { - const organizations = await apiClient.listOrganizations({ - // Include query parameter only if search argument is passed - ...(search && { - queries: { - q: search, - }, - }), - }); - - if (organizations.data.length <= 0) { - await vscode.commands.executeCommand('setContext', 'terraform.cloud.organizationsExist', false); - } else { - await vscode.commands.executeCommand('setContext', 'terraform.cloud.organizationsExist', true); - } - - return organizations.data.map((organization) => new OrganizationItem(organization)); - } - - async fetchItems(query?: string): Promise { - const createItem = new CreateOrganizationItem(); - const refreshItem = new RefreshOrganizationItem(); - const picks: vscode.QuickPickItem[] = [ - createItem, - refreshItem, - { label: '', kind: vscode.QuickPickItemKind.Separator }, - ]; - - try { - picks.push(...(await this.createOrganizationItems(query))); - } catch (error) { - let message = 'Failed to fetch organizations'; - - if (error instanceof ZodiosError) { - handleZodiosError(error, message, this.outputChannel, this.reporter); - return picks; - } - - if (axios.isAxiosError(error) && error.response?.status === 401) { - handleAuthError(); - return picks; - } else if (isErrorFromAlias(apiClient.api, 'listOrganizations', error)) { - message += apiErrorsToString(error.response.data.errors); - this.reporter.sendTelemetryException(error); - } else if (error instanceof Error) { - message += error.message; - this.reporter.sendTelemetryException(error); - } else if (typeof error === 'string') { - message += error; - } - - picks.push({ label: `$(error) ${message}`, alwaysShow: true }); - } - - return picks; - } -} diff --git a/src/providers/tfc/planProvider.ts b/src/providers/tfc/planProvider.ts deleted file mode 100644 index 94f9f72a..00000000 --- a/src/providers/tfc/planProvider.ts +++ /dev/null @@ -1,309 +0,0 @@ -/** - * 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, 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(); - public readonly onDidChangeTreeData = this.didChangeTreeData.event; - private plan: PlanTreeItem | undefined; - - constructor( - private ctx: vscode.ExtensionContext, - private reporter: TelemetryReporter, - private outputChannel: vscode.OutputChannel, - ) { - this.ctx.subscriptions.push( - vscode.commands.registerCommand('terraform.cloud.run.plan.refresh', () => { - this.reporter.sendTelemetryEvent('tfc-run-plan-refresh'); - this.refresh(this.plan); - }), - ); - } - - refresh(plan?: PlanTreeItem): void { - this.plan = plan; - this.didChangeTreeData.fire(); - } - - getTreeItem(element: vscode.TreeItem): vscode.TreeItem | Thenable { - return element; - } - - getChildren(element?: vscode.TreeItem | undefined): vscode.ProviderResult { - if (!this.plan) { - return []; - } - - if (!element) { - try { - return this.getRootChildren(this.plan); - } catch (error) { - return []; - } - } - - if (isItemWithChildren(element)) { - return element.getChildren(); - } - } - - private async getRootChildren(plan: PlanTreeItem): Promise { - const planLog = await this.getPlanFromUrl(plan); - - const items: vscode.TreeItem[] = []; - if (planLog && planLog.plannedChanges) { - items.push(new PlannedChangesItem(planLog.plannedChanges, planLog.changeSummary)); - } - if (planLog && planLog.driftChanges) { - items.push(new DriftChangesItem(planLog.driftChanges, planLog.driftSummary)); - } - if (planLog && planLog.outputs) { - items.push(new OutputsItem(planLog.outputs)); - } - if (planLog && planLog.diagnostics && planLog.diagnosticSummary && planLog.diagnostics.length > 0) { - items.push(new DiagnosticsItem(planLog.diagnostics, planLog.diagnosticSummary)); - } - return items; - } - - private async getPlanFromUrl(plan: PlanTreeItem): Promise { - const session = await vscode.authentication.getSession(TerraformCloudAuthenticationProvider.providerID, [], { - createIfNone: false, - }); - - if (session === undefined) { - return; - } - - try { - const result = await axios.get(plan.logReadUrl, { - headers: { Accept: 'text/plain' }, - responseType: 'stream', - }); - const lineStream = readline.createInterface({ - input: result.data, - output: new Writable(), - }); - - const planLog: PlanLog = {}; - - for await (const line of lineStream) { - try { - const logLine: LogLine = JSON.parse(line); - - if (logLine.type === 'planned_change' && logLine.change) { - if (!planLog.plannedChanges) { - planLog.plannedChanges = []; - } - planLog.plannedChanges.push(logLine.change); - continue; - } - if (logLine.type === 'resource_drift' && logLine.change) { - if (!planLog.driftChanges) { - planLog.driftChanges = []; - } - if (!planLog.driftSummary) { - planLog.driftSummary = { - changed: 0, - deleted: 0, - }; - } - planLog.driftChanges.push(logLine.change); - if (logLine.change.action === 'update') { - planLog.driftSummary.changed += 1; - } - if (logLine.change.action === 'delete') { - planLog.driftSummary.deleted += 1; - } - continue; - } - if (logLine.type === 'change_summary' && logLine.changes) { - planLog.changeSummary = logLine.changes; - continue; - } - if (logLine.type === 'outputs' && logLine.outputs) { - planLog.outputs = logLine.outputs; - continue; - } - if (logLine.type === 'diagnostic' && logLine.diagnostic) { - if (!planLog.diagnostics) { - planLog.diagnostics = []; - } - if (!planLog.diagnosticSummary) { - planLog.diagnosticSummary = { - errorCount: 0, - warningCount: 0, - }; - } - planLog.diagnostics.push(logLine.diagnostic); - if (logLine.diagnostic.severity === 'warning') { - planLog.diagnosticSummary.warningCount += 1; - } - if (logLine.diagnostic.severity === 'error') { - planLog.diagnosticSummary.errorCount += 1; - } - continue; - } - - // TODO: logLine.type=test_* - } catch (e) { - // skip any non-JSON lines, like Terraform version output - continue; - } - } - - return planLog; - } catch (error) { - let message = `Failed to obtain plan from ${plan.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 PlanLog { - plannedChanges?: Change[]; - changeSummary?: ChangeSummary; - driftChanges?: Change[]; - driftSummary?: DriftSummary; - outputs?: Outputs; - diagnostics?: Diagnostic[]; - diagnosticSummary?: DiagnosticSummary; -} - -class PlannedChangesItem extends vscode.TreeItem implements ItemWithChildren { - constructor(private plannedChanges: Change[], summary?: ChangeSummary) { - let label = 'Planned changes'; - if (summary) { - const labels: string[] = []; - if (summary.import > 0) { - labels.push(`${summary.import} to import`); - } - if (summary.add > 0) { - labels.push(`${summary.add} to add`); - } - if (summary.change > 0) { - labels.push(`${summary.change} to change`); - } - if (summary.remove > 0) { - labels.push(`${summary.remove} to destroy`); - } - if (labels.length > 0) { - label = `Planned changes: ${labels.join(', ')}`; - } - } - super(label, vscode.TreeItemCollapsibleState.Expanded); - } - - getChildren(): vscode.TreeItem[] { - return this.plannedChanges.map((change) => new PlannedChangeItem(change)); - } -} - -class PlannedChangeItem extends vscode.TreeItem { - constructor(public change: Change) { - let label = change.resource.addr; - if (change.previous_resource) { - label = `${change.previous_resource.addr} → ${change.resource.addr}`; - } - - super(label, vscode.TreeItemCollapsibleState.None); - this.id = change.action + '/' + change.resource.addr; - this.iconPath = GetChangeActionIcon(change.action); - this.description = change.action; - - const tooltip = new vscode.MarkdownString(); - if (change.previous_resource) { - tooltip.appendMarkdown( - `\`${change.previous_resource.addr}\` planned to _${change.action}_ to \`${change.resource.addr}\``, - ); - } else if (change.importing) { - tooltip.appendMarkdown( - `Planned to _${change.action}_ \`${change.resource.addr}\` (id=\`${change.importing.id}\`)`, - ); - } else { - tooltip.appendMarkdown(`Planned to _${change.action}_ \`${change.resource.addr}\``); - } - this.tooltip = tooltip; - } -} - -class DriftChangesItem extends vscode.TreeItem implements ItemWithChildren { - constructor(private driftChanges: Change[], summary?: DriftSummary) { - let label = `Drifted resources`; - if (summary) { - const details = []; - if (summary.changed > 0) { - details.push(`${summary.changed} changed`); - } - if (summary.deleted > 0) { - details.push(`${summary.deleted} deleted`); - } - label = `Drifted resources: ${details.join(', ')}`; - } - - super(label, vscode.TreeItemCollapsibleState.Expanded); - } - - getChildren(): vscode.TreeItem[] { - return this.driftChanges.map((change) => new DriftChangeItem(change)); - } -} - -class DriftChangeItem extends vscode.TreeItem { - constructor(public change: Change) { - let label = change.resource.addr; - if (change.previous_resource) { - label = `${change.previous_resource.addr} → ${change.resource.addr}`; - } - - super(label, vscode.TreeItemCollapsibleState.None); - this.id = 'drift/' + change.action + '/' + change.resource.addr; - this.iconPath = GetChangeActionIcon(change.action); - const message = GetDriftChangeActionMessage(change.action); - this.description = message; - this.tooltip = new vscode.MarkdownString(`\`${change.resource.addr}\` _${message}_`); - } -} diff --git a/src/providers/tfc/runProvider.ts b/src/providers/tfc/runProvider.ts deleted file mode 100644 index 15d45156..00000000 --- a/src/providers/tfc/runProvider.ts +++ /dev/null @@ -1,431 +0,0 @@ -/** - * Copyright (c) HashiCorp, Inc. - * SPDX-License-Identifier: MPL-2.0 - */ - -import * as vscode from 'vscode'; -import axios from 'axios'; -import * as semver from 'semver'; -import TelemetryReporter from '@vscode/extension-telemetry'; - -import { TerraformCloudWebUrl, apiClient } from '../../terraformCloud'; -import { TerraformCloudAuthenticationProvider } from '../authenticationProvider'; -import { RUN_SOURCE, RunAttributes, TRIGGER_REASON } from '../../terraformCloud/run'; -import { WorkspaceTreeItem } from './workspaceProvider'; -import { GetPlanApplyStatusIcon, GetRunStatusIcon, GetRunStatusMessage, RelativeTimeFormat } from './helpers'; -import { ZodiosError, isErrorFromAlias } from '@zodios/core'; -import { apiErrorsToString } from '../../terraformCloud/errors'; -import { handleAuthError, handleZodiosError } from './uiHelpers'; -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(); - public readonly onDidChangeTreeData = this.didChangeTreeData.event; - private activeWorkspace: WorkspaceTreeItem | undefined; - - constructor( - private ctx: vscode.ExtensionContext, - private reporter: TelemetryReporter, - private outputChannel: vscode.OutputChannel, - private planDataProvider: PlanTreeDataProvider, - private applyDataProvider: ApplyTreeDataProvider, - ) { - this.ctx.subscriptions.push( - vscode.commands.registerCommand('terraform.cloud.run.plan.downloadLog', async (run: PlanTreeItem) => { - this.downloadPlanLog(run); - }), - vscode.commands.registerCommand('terraform.cloud.run.apply.downloadLog', async (run: ApplyTreeItem) => - this.downloadApplyLog(run), - ), - vscode.commands.registerCommand('terraform.cloud.runs.refresh', () => { - this.reporter.sendTelemetryEvent('tfc-runs-refresh'); - this.refresh(this.activeWorkspace); - }), - vscode.commands.registerCommand('terraform.cloud.run.viewInBrowser', (run: RunTreeItem) => { - this.reporter.sendTelemetryEvent('tfc-runs-viewInBrowser'); - vscode.env.openExternal(run.websiteUri); - }), - vscode.commands.registerCommand('terraform.cloud.run.viewPlan', async (plan: PlanTreeItem) => { - if (!plan.logReadUrl) { - await vscode.window.showErrorMessage(`No plan found for ${plan.id}`); - return; - } - await vscode.commands.executeCommand('setContext', 'terraform.cloud.run.viewingPlan', true); - await vscode.commands.executeCommand('terraform.cloud.run.plan.focus'); - this.planDataProvider.refresh(plan); - }), - vscode.commands.registerCommand('terraform.cloud.run.viewApply', async (apply: ApplyTreeItem) => { - 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); - }), - ); - } - - refresh(workspaceItem?: WorkspaceTreeItem): void { - this.activeWorkspace = workspaceItem; - this.didChangeTreeData.fire(); - } - - getTreeItem(element: TFCRunTreeItem): vscode.TreeItem | Thenable { - return element; - } - - getChildren(element?: TFCRunTreeItem | undefined): vscode.ProviderResult { - if (!this.activeWorkspace || !(this.activeWorkspace instanceof WorkspaceTreeItem)) { - return []; - } - - if (element) { - const items = this.getRunDetails(element); - return items; - } - - try { - return this.getRuns(this.activeWorkspace); - } catch (error) { - return []; - } - } - - async resolveTreeItem(item: vscode.TreeItem, element: TFCRunTreeItem): Promise { - if (element instanceof RunTreeItem) { - item.tooltip = await runMarkdown(element); - } - return item; - } - - private async getRuns(workspace: WorkspaceTreeItem): Promise { - const organization = this.ctx.workspaceState.get('terraform.cloud.organization', ''); - if (organization === '') { - return []; - } - - const session = await vscode.authentication.getSession(TerraformCloudAuthenticationProvider.providerID, [], { - createIfNone: false, - }); - - if (session === undefined) { - return []; - } - - if (!this.activeWorkspace) { - return []; - } - - try { - const runs = await apiClient.listRuns({ - params: { workspace_id: workspace.id }, - queries: { - 'page[size]': 100, - }, - }); - - this.reporter.sendTelemetryEvent('tfc-fetch-runs', undefined, { - totalCount: runs.meta.pagination['total-count'], - }); - - if (runs.data.length === 0) { - return [ - { - label: `No runs found for ${this.activeWorkspace.attributes.name}`, - tooltip: `No runs found for ${this.activeWorkspace.attributes.name}`, - contextValue: 'empty', - }, - ]; - } - - const items: RunTreeItem[] = []; - for (let index = 0; index < runs.data.length; index++) { - const run = runs.data[index]; - const runItem = new RunTreeItem(run.id, run.attributes, this.activeWorkspace); - - runItem.contextValue = 'isRun'; - runItem.organizationName = organization; - - runItem.configurationVersionId = run.relationships['configuration-version']?.data?.id; - runItem.createdByUserId = run.relationships['created-by']?.data?.id; - - runItem.planId = run.relationships.plan?.data?.id; - runItem.applyId = run.relationships.apply?.data?.id; - if (runItem.planId || runItem.applyId) { - runItem.collapsibleState = vscode.TreeItemCollapsibleState.Collapsed; - } - - items.push(runItem); - } - - return items; - } catch (error) { - let message = `Failed to list runs in ${this.activeWorkspace.attributes.name} (${workspace.id}): `; - - 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.response?.status === 404) { - vscode.window.showWarningMessage( - `Workspace ${this.activeWorkspace.attributes.name} (${workspace.id}) not found, please pick another one`, - ); - return []; - } - - if (isErrorFromAlias(apiClient.api, 'listRuns', error)) { - message += apiErrorsToString(error.response.data.errors); - vscode.window.showErrorMessage(message); - this.reporter.sendTelemetryException(error); - 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 []; - } - } - - private async getRunDetails(element: TFCRunTreeItem) { - const items = []; - const root = element as RunTreeItem; - if (root.planId) { - const plan = await apiClient.getPlan({ params: { plan_id: root.planId } }); - if (plan) { - const status = plan.data.attributes.status; - const label = status === 'unreachable' ? 'Plan will not run' : `Plan ${status}`; - const item = new PlanTreeItem(root.planId, label, plan.data.attributes); - if (status === 'unreachable' || status === 'pending') { - item.label = label; - } else { - if (this.isJsonExpected(plan.data.attributes, root.attributes['terraform-version'])) { - item.contextValue = 'hasStructuredPlan'; - } else { - item.contextValue = 'hasRawPlan'; - } - } - items.push(item); - } - } - - if (root.applyId) { - const apply = await apiClient.getApply({ params: { apply_id: root.applyId } }); - if (apply) { - const status = apply.data.attributes.status; - const label = status === 'unreachable' ? 'Apply will not run' : `Apply ${status}`; - const item = new ApplyTreeItem(root.applyId, label, apply.data.attributes); - if (status === 'unreachable' || status === 'pending') { - item.label = label; - } else { - if (this.isJsonExpected(apply.data.attributes, root.attributes['terraform-version'])) { - item.contextValue = 'hasStructuredApply'; - } else { - item.contextValue = 'hasRawApply'; - } - } - items.push(item); - } - } - - return items; - } - - private isJsonExpected(attributes: PlanAttributes | ApplyAttributes, terraformVersion: string): boolean { - const jsonSupportedVersion = '> 0.15.2'; - if (!semver.satisfies(terraformVersion, jsonSupportedVersion)) { - return false; - } - return attributes['structured-run-output-enabled']; - } - - private async downloadPlanLog(run: PlanTreeItem) { - if (!run.id) { - await vscode.window.showErrorMessage(`No plan found for ${run.id}`); - return; - } - - const doc = await vscode.workspace.openTextDocument(run.documentUri); - return await vscode.window.showTextDocument(doc, { - preview: false, - }); - } - - private async downloadApplyLog(run: ApplyTreeItem) { - if (!run.id) { - await vscode.window.showErrorMessage(`No apply found for ${run.id}`); - return; - } - - const doc = await vscode.workspace.openTextDocument(run.documentUri); - return await vscode.window.showTextDocument(doc, { - preview: false, - }); - } - - dispose() { - // - } -} - -export type TFCRunTreeItem = RunTreeItem | PlanTreeItem | ApplyTreeItem | vscode.TreeItem; - -export class RunTreeItem extends vscode.TreeItem { - public organizationName?: string; - public configurationVersionId?: string; - public createdByUserId?: string; - - public planAttributes?: PlanAttributes; - public planId?: string; - - public applyAttributes?: ApplyAttributes; - public applyId?: string; - - constructor(public id: string, public attributes: RunAttributes, public workspace: WorkspaceTreeItem) { - super(attributes.message, vscode.TreeItemCollapsibleState.None); - this.id = id; - - this.workspace = workspace; - this.iconPath = GetRunStatusIcon(attributes.status); - this.description = `${attributes['trigger-reason']} ${attributes['created-at']}`; - } - - public get websiteUri(): vscode.Uri { - return vscode.Uri.parse( - `${TerraformCloudWebUrl}/${this.organizationName}/workspaces/${this.workspace.attributes.name}/runs/${this.id}`, - ); - } -} - -export class PlanTreeItem extends vscode.TreeItem { - public logReadUrl = ''; - - constructor(public id: string, public label: string, public attributes: PlanAttributes) { - super(label); - this.iconPath = GetPlanApplyStatusIcon(attributes.status); - if (attributes) { - this.logReadUrl = attributes['log-read-url'] ?? ''; - } - } - - public get documentUri(): vscode.Uri { - return vscode.Uri.parse(`vscode-terraform://plan/${this.id}`); - } -} - -export class ApplyTreeItem extends vscode.TreeItem { - public logReadUrl = ''; - constructor(public id: string, public label: string, public attributes: ApplyAttributes) { - super(label); - this.iconPath = GetPlanApplyStatusIcon(attributes.status); - if (attributes) { - this.logReadUrl = attributes['log-read-url'] ?? ''; - } - } - - public get documentUri(): vscode.Uri { - return vscode.Uri.parse(`vscode-terraform://apply/${this.id}`); - } -} - -async function runMarkdown(item: RunTreeItem) { - const markdown: vscode.MarkdownString = new vscode.MarkdownString(); - - // to allow image resizing - markdown.supportHtml = true; - markdown.supportThemeIcons = true; - - const configurationVersion = item.configurationVersionId - ? await apiClient.getConfigurationVersion({ - params: { - configuration_id: item.configurationVersionId, - }, - }) - : undefined; - const ingress = configurationVersion?.data.relationships['ingress-attributes']?.data?.id - ? await apiClient.getIngressAttributes({ - params: { - configuration_id: configurationVersion.data.id, - }, - }) - : undefined; - - const createdAtTime = RelativeTimeFormat(item.attributes['created-at']); - - if (item.createdByUserId) { - const user = await apiClient.getUser({ - params: { - user_id: item.createdByUserId, - }, - }); - - markdown.appendMarkdown( - ` **${user.data.attributes.username}**`, - ); - } else if (ingress) { - markdown.appendMarkdown( - ` **${ingress.data.attributes['sender-username']}**`, - ); - } - - const triggerReason = TRIGGER_REASON[item.attributes['trigger-reason']]; - const icon = GetRunStatusIcon(item.attributes.status); - const msg = GetRunStatusMessage(item.attributes.status); - - markdown.appendMarkdown(` ${triggerReason} from ${RUN_SOURCE[item.attributes.source]} ${createdAtTime}`); - markdown.appendMarkdown(` - ------ -_____ -| | | --:|-- -| **Run ID** | \`${item.id}\` | -| **Status** | $(${icon.id}) ${msg} | -`); - if (ingress && configurationVersion && configurationVersion.data.attributes.source) { - // Blind shortening like this may not be appropriate - // due to hash collisions but we just mimic what TFC does here - // which is fairly safe since it's just UI/text, not URL. - const shortCommitSha = ingress.data.attributes['commit-sha'].slice(0, 8); - - const cfgSource = CONFIGURATION_SOURCE[configurationVersion.data.attributes.source]; - markdown.appendMarkdown(`| **Configuration** | From ${cfgSource} by ${ingress.data.attributes['sender-username']} **Branch** ${ - ingress.data.attributes.branch - } **Repo** [${ingress.data.attributes.identifier}](${ingress.data.attributes['clone-url']}) | -| **Commit** | [${shortCommitSha}](${ingress.data.attributes['commit-url']}): ${ - ingress.data.attributes['commit-message'].split('\n')[0] - } | -`); - } else { - markdown.appendMarkdown(`| **Configuration** | From ${item.attributes.source} | -`); - } - - markdown.appendMarkdown(`| **Trigger** | ${triggerReason} | -| **Execution Mode** | ${item.workspace.attributes['execution-mode']} | -`); - return markdown; -} diff --git a/src/providers/tfc/uiHelpers.ts b/src/providers/tfc/uiHelpers.ts deleted file mode 100644 index 630eb257..00000000 --- a/src/providers/tfc/uiHelpers.ts +++ /dev/null @@ -1,96 +0,0 @@ -/** - * Copyright (c) HashiCorp, Inc. - * SPDX-License-Identifier: MPL-2.0 - */ - -import { ZodiosError } from '@zodios/core'; -import * as vscode from 'vscode'; -import { TerraformCloudAuthenticationProvider } from '../authenticationProvider'; -import TelemetryReporter from '@vscode/extension-telemetry'; - -export interface APIResource { - readonly name: string; - - readonly title: string; - readonly placeholder: string; - readonly ignoreFocusOut?: boolean; - - fetchItems(query?: string): Promise; -} - -export class APIQuickPick { - private quickPick: vscode.QuickPick; - private fetchTimerKey: NodeJS.Timeout | undefined; - - constructor(private resource: APIResource) { - this.quickPick = vscode.window.createQuickPick(); - this.quickPick.title = resource.title; - this.quickPick.placeholder = resource.placeholder; - this.quickPick.onDidChangeValue(this.onDidChangeValue, this); - this.quickPick.ignoreFocusOut = resource.ignoreFocusOut ?? false; - } - - private onDidChangeValue() { - clearTimeout(this.fetchTimerKey); - // Only starts fetching after a user stopped typing for 300ms - this.fetchTimerKey = setTimeout(() => this.fetchResource.apply(this), 300); - } - - private async fetchResource() { - this.quickPick.busy = true; - this.quickPick.show(); - - this.quickPick.items = await this.resource.fetchItems(this.quickPick.value); - - this.quickPick.busy = false; - } - - async pick(autoHide = true) { - await this.fetchResource(); - - const result = await new Promise((c) => { - this.quickPick.onDidAccept(() => c(this.quickPick.selectedItems[0])); - this.quickPick.onDidHide(() => c(undefined)); - this.quickPick.show(); - }); - - if (autoHide) { - this.quickPick.hide(); - } - - return result; - } - - hide() { - this.quickPick.hide(); - } -} - -export async function handleZodiosError( - error: ZodiosError, - msgPrefix: string, - outputChannel: vscode.OutputChannel, - reporter: TelemetryReporter, -) { - reporter.sendTelemetryException(error); - outputChannel.append(JSON.stringify({ cause: error.cause }, undefined, 2)); - const chosenItem = await vscode.window.showErrorMessage( - `${msgPrefix} Response validation failed. Please report this as a bug.`, - 'Report bug', - ); - if (chosenItem === 'Report bug') { - outputChannel.show(true); - vscode.commands.executeCommand('terraform.generateBugReport'); - return; - } -} - -export async function handleAuthError() { - // TODO: clear org - await vscode.authentication.getSession(TerraformCloudAuthenticationProvider.providerID, [], { - createIfNone: false, - forceNewSession: { - detail: 'Your token is invalid or has expired. Please generate a new token', - }, - }); -} diff --git a/src/providers/tfc/workspaceFilters.ts b/src/providers/tfc/workspaceFilters.ts deleted file mode 100644 index 4826a103..00000000 --- a/src/providers/tfc/workspaceFilters.ts +++ /dev/null @@ -1,96 +0,0 @@ -/** - * Copyright (c) HashiCorp, Inc. - * SPDX-License-Identifier: MPL-2.0 - */ - -import * as vscode from 'vscode'; -import { apiClient } from '../../terraformCloud'; -import { Project } from '../../terraformCloud/project'; -import { APIResource, handleAuthError, handleZodiosError } from './uiHelpers'; -import TelemetryReporter from '@vscode/extension-telemetry'; -import { ZodiosError, isErrorFromAlias } from '@zodios/core'; -import axios from 'axios'; -import { apiErrorsToString } from '../../terraformCloud/errors'; - -export class ResetProjectItem implements vscode.QuickPickItem { - get label() { - return '$(clear-all) Clear project filter. Show all workspaces'; - } - get description() { - return ''; - } - get alwaysShow() { - return true; - } -} - -class ProjectItem implements vscode.QuickPickItem { - constructor(protected project: Project) {} - get label() { - return this.project.attributes.name; - } - get description() { - return this.project.id; - } -} - -export class ProjectsAPIResource implements APIResource { - name = 'projects'; - title = 'Filter workspaces'; - placeholder = 'Select a project (type to search)'; - - constructor( - private organizationName: string, - private outputChannel: vscode.OutputChannel, - private reporter: TelemetryReporter, - ) {} - - private async createProjectItems(organization: string, search?: string): Promise { - const projects = await apiClient.listProjects({ - params: { - organization_name: organization, - }, - // Include query parameter only if search argument is passed - ...(search && { - queries: { - q: search, - }, - }), - }); - - return projects.data.map((project) => new ProjectItem(project)); - } - - async fetchItems(query?: string): Promise { - const resetProjectItem = new ResetProjectItem(); - const picks: vscode.QuickPickItem[] = [resetProjectItem, { label: '', kind: vscode.QuickPickItemKind.Separator }]; - - try { - picks.push(...(await this.createProjectItems(this.organizationName, query))); - } catch (error) { - let message = 'Failed to fetch projects'; - - if (error instanceof ZodiosError) { - handleZodiosError(error, message, this.outputChannel, this.reporter); - return picks; - } - - if (axios.isAxiosError(error) && error.response?.status === 401) { - handleAuthError(); - return picks; - } else if (isErrorFromAlias(apiClient.api, 'listProjects', error)) { - message += apiErrorsToString(error.response.data.errors); - this.reporter.sendTelemetryException(error); - } else if (error instanceof Error) { - message += error.message; - this.reporter.sendTelemetryException(error); - } else if (typeof error === 'string') { - message += error; - } - - picks.push({ label: `$(error) ${message}`, alwaysShow: true }); - } - - return picks; - } -} diff --git a/src/providers/tfc/workspaceProvider.ts b/src/providers/tfc/workspaceProvider.ts deleted file mode 100644 index 8d8c239e..00000000 --- a/src/providers/tfc/workspaceProvider.ts +++ /dev/null @@ -1,353 +0,0 @@ -/** - * Copyright (c) HashiCorp, Inc. - * SPDX-License-Identifier: MPL-2.0 - */ - -import * as vscode from 'vscode'; -import TelemetryReporter from '@vscode/extension-telemetry'; - -import { RunTreeDataProvider } from './runProvider'; -import { apiClient, TerraformCloudWebUrl } from '../../terraformCloud'; -import { TerraformCloudAuthenticationProvider } from '../authenticationProvider'; -import { ProjectsAPIResource, ResetProjectItem } from './workspaceFilters'; -import { GetRunStatusIcon, GetRunStatusMessage, RelativeTimeFormat } from './helpers'; -import { WorkspaceAttributes } from '../../terraformCloud/workspace'; -import { RunAttributes } from '../../terraformCloud/run'; -import { APIQuickPick, handleAuthError, handleZodiosError } from './uiHelpers'; -import { isErrorFromAlias, ZodiosError } from '@zodios/core'; -import axios from 'axios'; -import { apiErrorsToString } from '../../terraformCloud/errors'; - -export class WorkspaceTreeDataProvider implements vscode.TreeDataProvider, vscode.Disposable { - private readonly didChangeTreeData = new vscode.EventEmitter(); - public readonly onDidChangeTreeData = this.didChangeTreeData.event; - private projectFilter: string | undefined; - private pageSize = 50; - private cache: vscode.TreeItem[] = []; - private nextPage: number | null = null; - - constructor( - private ctx: vscode.ExtensionContext, - private runDataProvider: RunTreeDataProvider, - private reporter: TelemetryReporter, - private outputChannel: vscode.OutputChannel, - ) { - this.ctx.subscriptions.push( - vscode.commands.registerCommand('terraform.cloud.workspaces.refresh', (workspaceItem: WorkspaceTreeItem) => { - this.reporter.sendTelemetryEvent('tfc-workspaces-refresh'); - this.reset(); - this.refresh(); - this.runDataProvider.refresh(workspaceItem); - }), - vscode.commands.registerCommand('terraform.cloud.workspaces.resetProjectFilter', () => { - this.reporter.sendTelemetryEvent('tfc-workspaces-filter-reset'); - this.projectFilter = undefined; - this.reset(); - this.refresh(); - this.runDataProvider.refresh(); - }), - vscode.commands.registerCommand( - 'terraform.cloud.workspaces.viewInBrowser', - (workspaceItem: WorkspaceTreeItem) => { - this.reporter.sendTelemetryEvent('tfc-workspaces-viewInBrowser'); - const workspaceURL = `${TerraformCloudWebUrl}/${workspaceItem.organization}/workspaces/${workspaceItem.attributes.name}`; - vscode.env.openExternal(vscode.Uri.parse(workspaceURL)); - }, - ), - vscode.commands.registerCommand('terraform.cloud.organization.viewInBrowser', () => { - this.reporter.sendTelemetryEvent('tfc-organization-viewInBrowser'); - const organization = this.ctx.workspaceState.get('terraform.cloud.organization', ''); - const orgURL = `${TerraformCloudWebUrl}/${organization}`; - vscode.env.openExternal(vscode.Uri.parse(orgURL)); - }), - vscode.commands.registerCommand('terraform.cloud.workspaces.filterByProject', () => { - this.reporter.sendTelemetryEvent('tfc-workspaces-filter'); - this.filterByProject(); - }), - vscode.commands.registerCommand('terraform.cloud.workspaces.loadMore', async () => { - this.reporter.sendTelemetryEvent('tfc-workspaces-loadMore'); - this.refresh(); - this.runDataProvider.refresh(); - }), - ); - } - - refresh(): void { - this.didChangeTreeData.fire(); - } - - // This resets the internal cache, e.g. after logout - reset(): void { - this.nextPage = null; - this.cache = []; - } - - async filterByProject(): Promise { - const session = await vscode.authentication.getSession(TerraformCloudAuthenticationProvider.providerID, [], { - createIfNone: false, - }); - - if (session === undefined) { - return; - } - - const organization = this.ctx.workspaceState.get('terraform.cloud.organization', ''); - const projectAPIResource = new ProjectsAPIResource(organization, this.outputChannel, this.reporter); - const projectQuickPick = new APIQuickPick(projectAPIResource); - const project = await projectQuickPick.pick(); - - if (project === undefined || project instanceof ResetProjectItem) { - this.projectFilter = undefined; - await vscode.commands.executeCommand('setContext', 'terraform.cloud.projectFilterUsed', false); - } else { - this.projectFilter = project.description; - await vscode.commands.executeCommand('setContext', 'terraform.cloud.projectFilterUsed', true); - } - this.reset(); - this.refresh(); - this.runDataProvider.refresh(); - } - - getTreeItem(element: vscode.TreeItem): vscode.TreeItem | Thenable { - return element; - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - getChildren(element?: any): vscode.ProviderResult { - if (element) { - return [element]; - } - - return this.buildChildren(); - } - - private async buildChildren() { - try { - this.cache = [...this.cache, ...(await this.getWorkspaces())]; - } catch (error) { - return []; - } - - const items = this.cache.slice(0); - if (this.nextPage !== null) { - items.push(new LoadMoreTreeItem()); - } - - return items; - } - - private async getWorkspaces(): Promise { - const organization = this.ctx.workspaceState.get('terraform.cloud.organization', ''); - if (organization === '') { - return []; - } - - const session = await vscode.authentication.getSession(TerraformCloudAuthenticationProvider.providerID, [], { - createIfNone: false, - }); - - if (session === undefined) { - return []; - } - - try { - const workspaceResponse = await apiClient.listWorkspaces({ - params: { - organization_name: organization, - }, - queries: { - include: ['current_run'], - // Include query parameter only if project filter is set - ...(this.projectFilter && { 'filter[project][id]': this.projectFilter }), - 'page[size]': this.pageSize, - 'page[number]': this.nextPage ?? 1, - sort: '-current-run.created-at', - }, - }); - this.nextPage = workspaceResponse.meta.pagination['next-page']; - - this.reporter.sendTelemetryEvent('tfc-fetch-workspaces', undefined, { - totalCount: workspaceResponse.meta.pagination['total-count'], - }); - - // TODO? we could skip this request if a project filter is set, - // but with the addition of more filters, we could still get - // projects from different workspaces - const projectResponse = await apiClient.listProjects({ - params: { - organization_name: organization, - }, - }); - - // We can imply organization existence based on 200 OK (i.e. not 404) here - vscode.commands.executeCommand('setContext', 'terraform.cloud.organizationsExist', true); - - const workspaces = workspaceResponse.data; - if (workspaces.length <= 0) { - await vscode.commands.executeCommand('setContext', 'terraform.cloud.workspacesExist', false); - - // check if the user has pending invitation to the org - // as that may be a reason for zero workspaces - const memberships = await apiClient.listOrganizationMemberships({}); - const pendingMembership = memberships.data.filter( - (membership) => - membership.relationships.organization.data.id === organization && - membership.attributes.status === 'invited', - ); - if (pendingMembership.length > 0) { - await vscode.commands.executeCommand('setContext', 'terraform.cloud.pendingOrgMembership', true); - } else { - await vscode.commands.executeCommand('setContext', 'terraform.cloud.pendingOrgMembership', false); - } - - return []; - } else { - await vscode.commands.executeCommand('setContext', 'terraform.cloud.workspacesExist', true); - await vscode.commands.executeCommand('setContext', 'terraform.cloud.pendingOrgMembership', false); - } - const projects = projectResponse.data; - - const items: WorkspaceTreeItem[] = []; - for (let index = 0; index < workspaces.length; index++) { - const workspace = workspaces[index]; - - const project = projects.find((p) => p.id === workspace.relationships['project']?.data?.id); - const projectName = project ? project.attributes.name : ''; - - const lastRunId = workspace.relationships['latest-run']?.data?.id; - const lastestRun = workspaceResponse.included - ? workspaceResponse.included.find((run) => run.id === lastRunId) - : undefined; - const link = vscode.Uri.joinPath(vscode.Uri.parse(TerraformCloudWebUrl), workspace.links['self-html']); - - items.push( - new WorkspaceTreeItem( - workspace.attributes, - workspace.id, - projectName, - organization, - link, - lastestRun?.attributes, - ), - ); - } - - return items; - } catch (error) { - let message = `Failed to list workspaces in ${organization}: `; - - 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.response?.status === 404) { - vscode.commands.executeCommand('setContext', 'terraform.cloud.organizationsExist', false); - vscode.window.showWarningMessage(`Organization '${organization}' not found, please pick another one`); - vscode.commands.executeCommand('terraform.cloud.organization.picker'); - return []; - } - - if ( - isErrorFromAlias(apiClient.api, 'listWorkspaces', error) || - isErrorFromAlias(apiClient.api, 'listProjects', error) - ) { - message += apiErrorsToString(error.response.data.errors); - vscode.window.showErrorMessage(message); - this.reporter.sendTelemetryException(error); - 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() { - // - } -} - -export class LoadMoreTreeItem extends vscode.TreeItem { - constructor() { - super('Load more...', vscode.TreeItemCollapsibleState.None); - - this.iconPath = new vscode.ThemeIcon('ellipsis', new vscode.ThemeColor('charts.gray')); - this.command = { - command: 'terraform.cloud.workspaces.loadMore', - title: 'Load more', - }; - } -} - -export class WorkspaceTreeItem extends vscode.TreeItem { - /** - * @param name The Workspace Name - * @param id This is the workspaceID as well as the unique ID for the treeitem - * @param projectName The name of the project this workspace is in - */ - constructor( - public attributes: WorkspaceAttributes, - public id: string, - public projectName: string, - public organization: string, - public weblink: vscode.Uri, - public lastRun?: RunAttributes, - ) { - super(attributes.name, vscode.TreeItemCollapsibleState.None); - - this.description = `[${this.projectName}]`; - this.iconPath = GetRunStatusIcon(this.lastRun?.status); - this.contextValue = 'hasLink'; - - const lockedTxt = this.attributes.locked ? '$(lock) Locked' : '$(unlock) Unlocked'; - const vscText = - this.attributes['vcs-repo-identifier'] && this.attributes['vcs-repo'] - ? `$(source-control) [${this.attributes['vcs-repo-identifier']}](${this.attributes['vcs-repo']['repository-http-url']})` - : ''; - - const statusMsg = GetRunStatusMessage(this.lastRun?.status); - const updatedAt = RelativeTimeFormat(this.attributes['updated-at']); - const text = ` -## $(${this.iconPath.id}) [${this.attributes.name}](${this.weblink}) - -#### ID: *${this.id}* - -Run Status: $(${this.iconPath.id}) ${statusMsg} - -${lockedTxt} -___ -| | | ---|-- -| **Resources** | ${this.attributes['resource-count']}| -| **Terraform Version** | ${this.attributes['terraform-version']}| -| **Updated** | ${updatedAt}| - -___ -| | | ---|-- -| ${vscText} | | -| **$(zap) Execution Mode** | ${this.attributes['execution-mode']}| -| **$(gear) Auto Apply** | ${updatedAt}| -`; - - this.tooltip = new vscode.MarkdownString(text, true); - } -} diff --git a/src/terraform.ts b/src/terraform.ts index f1d76335..d5ef966c 100644 --- a/src/terraform.ts +++ b/src/terraform.ts @@ -3,7 +3,6 @@ * SPDX-License-Identifier: MPL-2.0 */ -import TelemetryReporter from '@vscode/extension-telemetry'; import * as vscode from 'vscode'; import { ExecuteCommandParams, ExecuteCommandRequest, LanguageClient } from 'vscode-languageclient/node'; import { Utils } from 'vscode-uri'; @@ -55,55 +54,39 @@ export interface TerraformInfoResponse { } /* eslint-enable @typescript-eslint/naming-convention */ -export async function terraformVersion( - moduleUri: string, - client: LanguageClient, - reporter: TelemetryReporter, -): Promise { +export async function terraformVersion(moduleUri: string, client: LanguageClient): Promise { const command = 'terraform-ls.module.terraform'; - const response = await execWorkspaceLSCommand(command, moduleUri, client, reporter); + const response = await execWorkspaceLSCommand(command, moduleUri, client); return response; } -export async function moduleCallers( - moduleUri: string, - client: LanguageClient, - reporter: TelemetryReporter, -): Promise { +export async function moduleCallers(moduleUri: string, client: LanguageClient): Promise { const command = 'terraform-ls.module.callers'; - const response = await execWorkspaceLSCommand(command, moduleUri, client, reporter); + const response = await execWorkspaceLSCommand(command, moduleUri, client); return response; } -export async function moduleCalls( - moduleUri: string, - client: LanguageClient, - reporter: TelemetryReporter, -): Promise { +export async function moduleCalls(moduleUri: string, client: LanguageClient): Promise { const command = 'terraform-ls.module.calls'; - const response = await execWorkspaceLSCommand(command, moduleUri, client, reporter); + const response = await execWorkspaceLSCommand(command, moduleUri, client); return response; } -export async function moduleProviders( - moduleUri: string, - client: LanguageClient, - reporter: TelemetryReporter, -): Promise { +export async function moduleProviders(moduleUri: string, client: LanguageClient): Promise { const command = 'terraform-ls.module.providers'; - const response = await execWorkspaceLSCommand(command, moduleUri, client, reporter); + const response = await execWorkspaceLSCommand(command, moduleUri, client); return response; } -export async function initAskUserCommand(client: LanguageClient, reporter: TelemetryReporter) { +export async function initAskUserCommand(client: LanguageClient) { try { const workspaceFolders = vscode.workspace.workspaceFolders; const selected = await vscode.window.showOpenDialog({ @@ -121,7 +104,7 @@ export async function initAskUserCommand(client: LanguageClient, reporter: Telem const moduleUri = selected[0]; const command = `terraform-ls.terraform.init`; - return execWorkspaceLSCommand(command, moduleUri.toString(), client, reporter); + return execWorkspaceLSCommand(command, moduleUri.toString(), client); } catch (error) { if (error instanceof Error) { vscode.window.showErrorMessage(error.message); @@ -131,9 +114,9 @@ export async function initAskUserCommand(client: LanguageClient, reporter: Telem } } -export async function initCurrentOpenFileCommand(client: LanguageClient, reporter: TelemetryReporter) { +export async function initCurrentOpenFileCommand(client: LanguageClient) { try { - await terraformCommand('initCurrent', client, reporter); + await terraformCommand('initCurrent', client); } catch (error) { if (error instanceof Error) { vscode.window.showErrorMessage(error.message); @@ -143,9 +126,9 @@ export async function initCurrentOpenFileCommand(client: LanguageClient, reporte } } -export async function command(command: string, client: LanguageClient, reporter: TelemetryReporter, useShell = false) { +export async function command(command: string, client: LanguageClient, useShell = false) { try { - await terraformCommand(command, client, reporter, useShell); + await terraformCommand(command, client, useShell); } catch (error) { if (error instanceof Error) { vscode.window.showErrorMessage(error.message); @@ -155,12 +138,7 @@ export async function command(command: string, client: LanguageClient, reporter: } } -async function terraformCommand( - command: string, - client: LanguageClient, - reporter: TelemetryReporter, - useShell = false, -): Promise { +async function terraformCommand(command: string, client: LanguageClient, useShell = false): Promise { const textEditor = getActiveTextEditor(); if (textEditor === undefined) { vscode.window.showErrorMessage(`Open a Terraform module file and then run terraform ${command} again`); @@ -168,7 +146,7 @@ async function terraformCommand( } const moduleUri = Utils.dirname(textEditor.document.uri); - const response = await moduleCallers(moduleUri.toString(), client, reporter); + const response = await moduleCallers(moduleUri.toString(), client); const selectedModule = await getSelectedModule(moduleUri, response.callers); if (selectedModule === undefined) { @@ -192,26 +170,19 @@ async function terraformCommand( terminal.sendText(terraformCommand); terminal.show(); - reporter.sendTelemetryEvent('execShellCommand', { command: command }); return; } const fullCommand = `terraform-ls.terraform.${command}`; - return execWorkspaceLSCommand(fullCommand, selectedModule, client, reporter); + return execWorkspaceLSCommand(fullCommand, selectedModule, client); } -async function execWorkspaceLSCommand( - command: string, - moduleUri: string, - client: LanguageClient, - reporter: TelemetryReporter, -): Promise { +async function execWorkspaceLSCommand(command: string, moduleUri: string, client: LanguageClient): Promise { // record whether we use terraform.init or terraform.initcurrent vscode commands // this is hacky, but better than propagating down another parameter just to handle // which init command we used if (command === 'terraform-ls.terraform.initCurrent') { - reporter.sendTelemetryEvent('execWorkspaceCommand', { command: command }); // need to change to terraform-ls command after detection command = 'terraform-ls.terraform.init'; } diff --git a/src/test/integration/index.ts b/src/test/integration/index.ts index 0a119870..631e7987 100644 --- a/src/test/integration/index.ts +++ b/src/test/integration/index.ts @@ -6,8 +6,6 @@ import * as path from 'path'; import * as Mocha from 'mocha'; import { glob } from 'glob'; -import { server } from './mocks/server'; -import { apiClient, tokenPluginId } from '../../terraformCloud'; export async function run(): Promise { // Create the mocha test @@ -17,14 +15,6 @@ export async function run(): Promise { }); // integration tests require long activation time mocha.timeout(100000); - // Establish API mocking before all tests. - mocha.globalSetup(() => { - apiClient.eject(tokenPluginId); - - server.listen(); - }); - // Clean up after the tests are finished. - mocha.globalTeardown(() => server.close()); // const testsRoot = path.resolve(__dirname, '..'); const testsRoot = path.resolve(__dirname); diff --git a/src/test/integration/mocks/server.ts b/src/test/integration/mocks/server.ts deleted file mode 100644 index f7e180cf..00000000 --- a/src/test/integration/mocks/server.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** - * Copyright (c) HashiCorp, Inc. - * SPDX-License-Identifier: MPL-2.0 - */ - -import { ZodiosPathsByMethod, ZodiosResponseByPath } from '@zodios/core/lib/zodios.types'; -import { ResponseResolver, rest, RestContext, RestRequest } from 'msw'; -import { setupServer } from 'msw/node'; -import { TerraformCloudAPIUrl, apiClient } from '../../../terraformCloud'; - -type Api = typeof apiClient.api; - -export function mockGet>( - path: Path, - resolver: ResponseResolver>>, -) { - return rest.get(`${TerraformCloudAPIUrl}${path}`, resolver); -} - -const handlers = [ - mockGet('/account/details', (_, res, ctx) => { - return res( - ctx.status(200), - ctx.json({ - data: { - id: 'user-1', - type: 'user', - attributes: { - username: 'user', - email: 'user@example.com', - }, - }, - }), - ); - }), -]; - -// This configures a request mocking server with the given request handlers. -export const server = setupServer(...handlers); diff --git a/src/utils/vscode.ts b/src/utils/vscode.ts index 457d3c47..c30e329e 100644 --- a/src/utils/vscode.ts +++ b/src/utils/vscode.ts @@ -3,7 +3,6 @@ * SPDX-License-Identifier: MPL-2.0 */ -import TelemetryReporter from '@vscode/extension-telemetry'; import * as vscode from 'vscode'; import { InitializeError, ResponseError } from 'vscode-languageclient'; @@ -76,21 +75,15 @@ function isInitializeError(error: unknown): error is ResponseError).data?.retry !== undefined; } -export async function handleLanguageClientStartError( - error: unknown, - ctx: vscode.ExtensionContext, - reporter: TelemetryReporter, -) { +export async function handleLanguageClientStartError(error: unknown, ctx: vscode.ExtensionContext) { let message = 'Unknown Error'; if (isInitializeError(error)) { // handled in initializationFailedHandler return; } else if (error instanceof Error) { message = error.message; - reporter.sendTelemetryException(error); } else if (typeof error === 'string') { message = error; - reporter.sendTelemetryException(new Error(error)); } if (message === 'INVALID_URI_WSL') { @@ -121,15 +114,12 @@ export async function handleLanguageClientStartError( switch (choice.title) { case 'Suppress': - reporter.sendTelemetryEvent('disableWSLNotification'); ctx.globalState.update('terraform.disableWSLNotification', true); break; case 'Reopen Folder in WSL': - reporter.sendTelemetryEvent('reopenInWSL'); await vscode.commands.executeCommand('remote-wsl.reopenInWSL'); break; case 'More Info': - reporter.sendTelemetryEvent('wslMoreInfo'); await vscode.commands.executeCommand( 'vscode.open', vscode.Uri.parse( diff --git a/src/web/extension.ts b/src/web/extension.ts index 5e2659c6..2fa1a7f5 100644 --- a/src/web/extension.ts +++ b/src/web/extension.ts @@ -3,24 +3,17 @@ * SPDX-License-Identifier: MPL-2.0 */ -import TelemetryReporter from '@vscode/extension-telemetry'; import * as vscode from 'vscode'; const brand = `HashiCorp Terraform`; const outputChannel = vscode.window.createOutputChannel(brand); -let reporter: TelemetryReporter; export function activate(context: vscode.ExtensionContext) { - const manifest = context.extension.packageJSON; - reporter = new TelemetryReporter(context.extension.id, manifest.version, manifest.appInsightsKey); - context.subscriptions.push(reporter); context.subscriptions.push(outputChannel); - reporter.sendTelemetryEvent('startExtension'); outputChannel.appendLine(`Started: Terraform ${vscode.env.appHost}`); } export function deactivate() { - reporter.sendTelemetryEvent('stopExtension'); outputChannel.appendLine(`Stopped: Terraform ${vscode.env.appHost}`); }