diff --git a/src/api/terraformCloud/index.ts b/src/api/terraformCloud/index.ts index 5e925318b0..3c98cee368 100644 --- a/src/api/terraformCloud/index.ts +++ b/src/api/terraformCloud/index.ts @@ -5,7 +5,7 @@ import * as vscode from 'vscode'; import { Zodios, ZodiosPlugin } from '@zodios/core'; -import { pluginToken, pluginHeader } from '@zodios/plugins'; +import { pluginToken, pluginHeader, pluginBaseURL } from '@zodios/plugins'; import { TerraformCloudAuthenticationProvider } from '../../providers/tfc/authenticationProvider'; import { accountEndpoints } from './account'; import { organizationEndpoints } from './organization'; @@ -17,11 +17,12 @@ import { applyEndpoints } from './apply'; import { userEndpoints } from './user'; import { configurationVersionEndpoints } from './configurationVersion'; import { ingressAttributesEndpoints } from './ingressAttribute'; +import { pingEndpoints } from './instance'; -export const TerraformCloudHost = 'app.terraform.io'; +export let TerraformCloudHost = 'app.terraform.io'; -export const TerraformCloudAPIUrl = `https://${TerraformCloudHost}/api/v2`; -export const TerraformCloudWebUrl = `https://${TerraformCloudHost}/app`; +export let TerraformCloudAPIUrl = `https://${TerraformCloudHost}/api/v2`; +export let TerraformCloudWebUrl = `https://${TerraformCloudHost}/app`; const jsonHeader = pluginHeader('Content-Type', async () => 'application/vnd.api+json'); @@ -41,6 +42,25 @@ function pluginLogger(): ZodiosPlugin { }; } +function responseHeaderLogger(): ZodiosPlugin { + return { + response: async (api, config, response) => { + console.log('Response appname:', response.headers['tfp-appname']); + console.log('Response api-version:', response.headers['tfp-api-version']); + + response.data = { + appName: response.headers['tfp-appname'], + apiVersion: response.headers['tfp-api-version'], + ...response.data, + }; + + return response; + }, + }; +} + +export let pingClient = new Zodios(TerraformCloudAPIUrl, pingEndpoints); + export const earlyApiClient = new Zodios(TerraformCloudAPIUrl, accountEndpoints); earlyApiClient.use(jsonHeader); earlyApiClient.use(userAgentHeader); @@ -73,3 +93,31 @@ export const tokenPluginId = apiClient.use( }, }), ); + +export function setupPingClient(hostname: string) { + // Hostname setup + const url = `https://${hostname}/api/v2`; + + pingClient = new Zodios(url, pingEndpoints); + pingClient.use(jsonHeader); + pingClient.use(userAgentHeader); + pingClient.use(pluginLogger()); + pingClient.use(responseHeaderLogger()); +} + +export function earlySetupForHostname(hostname: string) { + // Hostname setup + TerraformCloudHost = hostname; + TerraformCloudAPIUrl = `https://${TerraformCloudHost}/api/v2`; + TerraformCloudWebUrl = `https://${TerraformCloudHost}/app`; + // EarlyApiClient setup + earlyApiClient.use(pluginBaseURL(TerraformCloudAPIUrl)); +} + +export function apiSetupForHostName(hostname: string) { + TerraformCloudHost = hostname; + TerraformCloudAPIUrl = `https://${TerraformCloudHost}/api/v2`; + TerraformCloudWebUrl = `https://${TerraformCloudHost}/app`; + // ApiClient setup + apiClient.use(pluginBaseURL(TerraformCloudAPIUrl)); +} diff --git a/src/api/terraformCloud/instance.ts b/src/api/terraformCloud/instance.ts new file mode 100644 index 0000000000..377c2fd912 --- /dev/null +++ b/src/api/terraformCloud/instance.ts @@ -0,0 +1,33 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import { makeApi } from '@zodios/core'; +import { z } from 'zod'; +import { errors } from './errors'; + +const hcpInstance = z.object({ + appName: z.string().nullish(), + apiVersion: z.string().nullish(), +}); +export type HCPInstance = z.infer; + +const details = z.object({ + data: hcpInstance, +}); +export type HCPInstanceDetails = z.infer; + +export const pingEndpoints = makeApi([ + { + method: 'get', + path: '/ping', + alias: 'ping', + description: 'Get instance details', + response: z.object({ + appName: z.string().nullish(), + apiVersion: z.string().nullish(), + }), + errors, + }, +]); diff --git a/src/providers/tfc/authenticationProvider.ts b/src/providers/tfc/authenticationProvider.ts index 6a84d5e844..ba50c1b545 100644 --- a/src/providers/tfc/authenticationProvider.ts +++ b/src/providers/tfc/authenticationProvider.ts @@ -7,7 +7,15 @@ 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 '../../api/terraformCloud'; +import { + apiSetupForHostName, + earlyApiClient, + earlySetupForHostname, + pingClient, + setupPingClient, + TerraformCloudHost, + TerraformCloudWebUrl, +} from '../../api/terraformCloud'; import { isErrorFromAlias, ZodiosError } from '@zodios/core'; import { apiErrorsToString } from '../../api/terraformCloud/errors'; import { handleZodiosError } from './uiHelpers'; @@ -25,7 +33,15 @@ class TerraformCloudSession implements vscode.AuthenticationSession { * @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) {} + constructor( + public readonly accessToken: string, + public readonly hostName: string, + public account: vscode.AuthenticationSessionAccountInformation, + ) {} +} + +interface TerraformCloudToken { + token: string; } class InvalidToken extends Error { @@ -51,7 +67,7 @@ class TerraformCloudSessionHandler { return session; } - async store(token: string): Promise { + async store(hostname: string, token: string): Promise { try { const user = await earlyApiClient.getAccount({ headers: { @@ -59,12 +75,13 @@ class TerraformCloudSessionHandler { }, }); - const session = new TerraformCloudSession(token, { + const session = new TerraformCloudSession(token, hostname, { label: user.data.attributes.username, id: user.data.id, }); await this.secretStorage.store(this.sessionKey, JSON.stringify(session)); + apiSetupForHostName(session.hostName); return session; } catch (error) { if (error instanceof ZodiosError) { @@ -90,6 +107,7 @@ class TerraformCloudSessionHandler { return this.secretStorage.delete(this.sessionKey); } } + export class TerraformCloudAuthenticationProvider implements vscode.AuthenticationProvider, vscode.Disposable { static providerLabel = 'HashiCorp Cloud Platform Terraform'; // These are IDs and session keys that are used to identify the provider and the session in VS Code secret storage @@ -100,7 +118,7 @@ export class TerraformCloudAuthenticationProvider implements vscode.Authenticati 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 sessionPromise: Promise; private disposable: vscode.Disposable | undefined; private _onDidChangeSessions = @@ -113,13 +131,15 @@ export class TerraformCloudAuthenticationProvider implements vscode.Authenticati 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( + + this.ctx.subscriptions.push( vscode.commands.registerCommand('terraform.cloud.login', async () => { const session = await vscode.authentication.getSession(TerraformCloudAuthenticationProvider.providerID, [], { createIfNone: true, @@ -129,6 +149,7 @@ export class TerraformCloudAuthenticationProvider implements vscode.Authenticati ); this.sessionPromise = this.sessionHandler.get(); + this.disposable = vscode.Disposable.from( this.secretStorage.onDidChange((e) => { if (e.key === this.sessionKey) { @@ -147,20 +168,38 @@ export class TerraformCloudAuthenticationProvider implements vscode.Authenticati // 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 HCP Terraform session'); - await vscode.commands.executeCommand('setContext', 'terraform.cloud.signed-in', true); - return [session]; - } else { + let session = await this.sessionPromise; + if (!session) { + // no session stored, create a new one return []; } + + if (session.hostName === '' || session.hostName === undefined) { + // we have a valid session but the hostname is not set + // this is most likely an old session that needs to be updated + // if hostname is undefined, we need to set it to the default + session = await this.sessionHandler.store(TerraformCloudHost, session.accessToken); + } + + // setup the API client for getting the user info + earlySetupForHostname(session.hostName); + // setup the API client for the session + apiSetupForHostName(session.hostName); + + this.logger.info('Successfully fetched HCP Terraform session'); + await vscode.commands.executeCommand('setContext', 'terraform.cloud.signed-in', true); + + return [session]; } catch (error) { + let message = 'Failed to get HCP Terraform session'; if (error instanceof Error) { - vscode.window.showErrorMessage(error.message); + message = error.message; } else if (typeof error === 'string') { - vscode.window.showErrorMessage(error); + message = error; } + + this.logger.info(message); + vscode.window.showErrorMessage(message); return []; } } @@ -170,15 +209,24 @@ export class TerraformCloudAuthenticationProvider implements vscode.Authenticati // - `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 { + const tfcHostname = await this.promptForTFCHostname(); + if (!tfcHostname) { + this.logger.error('User did not provide a TFC instance'); + throw new Error('TFC instance is required'); + } + earlySetupForHostname(tfcHostname); // 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'); + // throw new InvalidToken(); + this.reporter.sendTelemetryEvent('tfc-login-fail', { reason: 'Invalid token' }); + vscode.window.showErrorMessage(`Invalid token. Please try again`); + return this.createSession(_scopes); } try { - const session = await this.sessionHandler.store(token); + const session = await this.sessionHandler.store(tfcHostname, token); this.reporter.sendTelemetryEvent('tfc-login-success'); this.logger.info('Successfully logged in to HCP Terraform'); @@ -252,6 +300,75 @@ export class TerraformCloudAuthenticationProvider implements vscode.Authenticati this._onDidChangeSessions.fire({ added: added, removed: removed, changed: changed }); } + private async promptForTFCHostname(): Promise { + const hostnames = []; + + try { + // Retrieve existing Hostnames from TFC credentials file + const tfcCredentials = await this.getTerraformCLICredentials(); + for (const key of tfcCredentials.keys()) { + setupPingClient(key); + const instance = await pingClient.ping(); + if (!instance) { + continue; + } + + hostnames.push({ + detail: key, + label: `${instance.appName} instance`, + }); + } + } catch (error) { + // If there is an error, it's likely that the credential file is not present + // or there is an issue with the file itself + // In this case, we'll just continue with just the default hostname + this.logger.info('Terraform credential file not yet initialized.'); + const defaultHostName = { + detail: 'app.terraform.io', + label: 'Default HCP Terraform instance', + }; + hostnames.push(defaultHostName); + } + + // Add the new hostname option no matter if there is a credential file or not + // so the user can select a new hostname if they want to + const newHostSelection = { + label: 'New Instance', + detail: 'Connect to a new HCP Terraform or Terraform enterprise instance', + }; + hostnames.push(newHostSelection); + + const choice = await vscode.window.showQuickPick(hostnames, { + canPickMany: false, + ignoreFocusOut: true, + placeHolder: 'Choose the HCP Terraform or Terraform Enterprise instance to connect to', + title: 'HashiCorp Authentication', + }); + + if (choice === undefined) { + return undefined; + } + + let hostname: string | undefined; + switch (choice.label) { + case newHostSelection.label: + // Prompt for the TFC hostname. + hostname = await vscode.window.showInputBox({ + ignoreFocusOut: true, + placeHolder: 'app.terraform.io', + value: 'app.terraform.io', + prompt: 'Enter a HCP Terraform hostname', + password: false, + }); + break; + default: + hostname = choice.detail; + break; + } + + return hostname; + } + private async promptForToken(): Promise { const choice = await vscode.window.showQuickPick( [ @@ -314,7 +431,7 @@ export class TerraformCloudAuthenticationProvider implements vscode.Authenticati return token; } - private async getTerraformCLIToken() { + private async getTerraformCLICredentials(): Promise> { // detect if stored auth token is present // On windows: // ~/AppData/Roaming/terraform.d/credentials.tfrc.json @@ -325,34 +442,43 @@ export class TerraformCloudAuthenticationProvider implements vscode.Authenticati ? path.join(os.homedir(), 'AppData', 'Roaming', 'terraform.d', 'credentials.tfrc.json') : path.join(os.homedir(), '.terraform.d', 'credentials.tfrc.json'); if ((await this.pathExists(credFilePath)) === false) { - vscode.window.showErrorMessage( + throw new TerraformCredentialFileError( 'Terraform credential file not found. Please login using the Terraform CLI and try again.', ); - return undefined; } - // read and marshall json file + const content = await vscode.workspace.fs.readFile(vscode.Uri.file(credFilePath)); 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( + throw new TerraformCredentialFileError( 'Failed to read configuration file. Please login using the Terraform CLI and try again', ); - return undefined; } - // find app.terraform.io token + // Parse credential file try { const data = JSON.parse(text); - const cred = data.credentials[TerraformCloudHost]; - return cred.token; + return new Map(Object.entries(data.credentials)); } catch (error) { - vscode.window.showErrorMessage( - `No token found for ${TerraformCloudHost}. Please login using the Terraform CLI and try again`, + throw new TerraformCredentialFileError( + `Failed to parse configuration file. Please login using the Terraform CLI and try again`, ); - return undefined; + } + } + + private async getTerraformCLIToken(): Promise { + let creds: Map; + + try { + creds = await this.getTerraformCLICredentials(); + return creds.get(TerraformCloudHost)?.token; + } catch (error) { + if (error instanceof TerraformCredentialFileError) { + vscode.window.showErrorMessage(error.message); + return undefined; + } } } @@ -369,3 +495,10 @@ export class TerraformCloudAuthenticationProvider implements vscode.Authenticati this.disposable?.dispose(); } } + +class TerraformCredentialFileError extends Error { + constructor(message: string) { + super(message); + this.name = 'TerraformCredentialFileError'; + } +}