From 5435632b63e140bb7093cf3b0bf0322b49c36551 Mon Sep 17 00:00:00 2001 From: Convez <72307937+Convez@users.noreply.github.com> Date: Wed, 26 Jun 2024 15:44:26 +0200 Subject: [PATCH] Allow custom HCP instance selection (#1678) This presents a new login workflow when the editor is opened without a stored HCP session which allows the user to select from the existing instances stored in the Terraform CLI credentials file or create a new instance. This allows users to select custom HCP Terraform or Terraform Enterprise instances in addition to the official HCP Terraform site (app.terraform.io). Once an instance is selected, the normal token quickpick is presented to choose the method of retrieving the authentication token. In order to "remember" the custom instance selected by the user, the hostname is stored in the secret store when the user logs in. This ensures that the hostname is available when the user logs in again. This is necessary because the hostname is used to construct the API URL and the Web URL for a given TFE or HCP Terraform instance. This also handles sessions created before this version without a hostname to connect to. It will set the hostname to the default Terraform Cloud API URL if it is undefined. The order of hostname options in the instance quick pick shows existing host names (if any) first then an option to create a new one. This makes it easier for users to select an existing hostname if they have one, as that is most likely the more common workflow. It also adds the ability to identify TFE instances by making a call to the `/ping` endpoint. This will allow the user to see the name of the instance they are connecting to in the quickpick menu. There is still some hardcoding of 'HCP Terraform' where 'Terraform Enterprise' should be in the code that needs to be updated to be more generic, but that can be handled in a separate set of work. --------- Co-authored-by: James Pogran --- src/api/terraformCloud/index.ts | 56 +++++- src/api/terraformCloud/instance.ts | 33 ++++ src/providers/tfc/authenticationProvider.ts | 191 +++++++++++++++++--- 3 files changed, 247 insertions(+), 33 deletions(-) create mode 100644 src/api/terraformCloud/instance.ts 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'; + } +}