Skip to content

Commit

Permalink
Merge pull request #1857 from hashicorp/fix-authentication-with-terra…
Browse files Browse the repository at this point in the history
…form-enterprise

feat(cli): support custom Terraform Enterprise instances
  • Loading branch information
DanielMSchmidt authored Jun 8, 2022
2 parents 5408d26 + 7b7eada commit 2fb3107
Show file tree
Hide file tree
Showing 10 changed files with 124 additions and 66 deletions.
64 changes: 36 additions & 28 deletions packages/cdktf-cli/bin/cmds/handlers.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import chalk from "chalk";
import * as fs from "fs-extra";
import React from "react";
import yargs from "yargs";
import { convert as hcl2cdkConvert } from "@cdktf/hcl2cdk";
import {
readSchema,
Expand Down Expand Up @@ -47,6 +46,7 @@ import {
ProviderConstraint,
} from "../../lib/dependencies/dependency-manager";
import { CdktfConfig, ProviderDependencySpec } from "../../lib/cdktf-config";
import { logger } from "../../lib/logging";

const chalkColour = new chalk.Instance();
const config = cfg.readConfigSync();
Expand Down Expand Up @@ -82,7 +82,10 @@ export async function convert({ language, provider }: any) {
)
);

const input = await readStreamAsString(process.stdin);
const input = await readStreamAsString(
process.stdin,
"No stdin was passed, please use it like this: cat main.tf | cdktf convert > imported.ts"
);
let output;
try {
const { all, stats } = await hcl2cdkConvert(input, {
Expand Down Expand Up @@ -233,42 +236,47 @@ export async function list(argv: any) {
await renderInk(React.createElement(List, { outDir, synthCommand: command }));
}

export async function login(argv: any) {
export async function login(argv: { tfeHostname: string }) {
await terraformCheck();
await displayVersionMessage();

const args = argv as yargs.Arguments;
if (args["_"].length > 1) {
console.error(
chalkColour`{redBright ERROR: 'cdktf login' command cannot have more than one argument.}\n`
async function showUserDetails(authToken: string) {
// Get user details if token is set
const userAccount = await terraformCloudClient.getAccountDetails(
argv.tfeHostname,
authToken
);
yargs.showHelp();
process.exit(1);
if (userAccount) {
const username = userAccount.data.attributes.username;
console.log(
chalkColour`\n{greenBright cdktf has successfully configured Terraform Cloud credentials!}`
);
console.log(chalkColour`\nWelcome {bold ${username}}!`);
} else {
throw Errors.Usage(`Configured Terraform Cloud token is invalid.`);
}
}

const terraformLogin = new TerraformLogin();
const token = await terraformLogin.askToLogin();
if (token == "") {
console.error(
chalkColour`{redBright ERROR: couldn't configure Terraform Cloud credentials.}\n`
);
process.exit(1);
const terraformLogin = new TerraformLogin(argv.tfeHostname);
let token = "";
try {
token = await readStreamAsString(process.stdin, "No stdin was passed");
} catch (e) {
logger.debug(`No TTY stream passed to login`);
}

// Get user details if token is set
const userAccount = await terraformCloudClient.getAccountDetails(token);
if (userAccount) {
const username = userAccount.data.attributes.username;
console.log(
chalkColour`\n{greenBright cdktf has successfully configured Terraform Cloud credentials!}`
);
console.log(chalkColour`\nWelcome {bold ${username}}!`);
// If we get a token through stdin, we don't need to ask for credentials, we just validate and set it
// This is useful for programmatically authenticating, e.g. a CI server
if (token) {
await terraformLogin.saveTerraformCredentials(token.replace(/\n/g, ""));
} else {
console.error(
chalkColour`{redBright ERROR: couldn't configure Terraform Cloud credentials.}\n`
);
process.exit(1);
token = await terraformLogin.askToLogin();
if (token === "") {
throw Errors.Usage(`No Terraform Cloud token was provided.`);
}
}

await showUserDetails(token);
}

export async function synth(argv: any) {
Expand Down
7 changes: 5 additions & 2 deletions packages/cdktf-cli/bin/cmds/helper/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export function checkForEmptyDirectory(dir: string) {
process.exit(1);
}
}

const tfeHostname = "app.terraform.io";
type Options = {
local?: boolean;
template?: string;
Expand All @@ -69,7 +69,8 @@ export async function runInit(argv: Options) {
// We ask the user to login to Terraform Cloud and set a token
// If the user chooses not to use Terraform Cloud, we continue
// without a token and set up the project.
const terraformLogin = new TerraformLogin();

const terraformLogin = new TerraformLogin(tfeHostname);
token = await terraformLogin.askToLogin();
} else {
console.log(chalkColour`{yellow Note: By supplying '--local' option you have chosen local storage mode for storing the state of your stack.
Expand Down Expand Up @@ -112,6 +113,7 @@ This means that your Terraform state file will be stored locally on disk in a fi
);
try {
await terraformCloudClient.createWorkspace(
tfeHostname,
projectInfo.OrganizationName,
projectInfo.WorkspaceName,
token
Expand Down Expand Up @@ -267,6 +269,7 @@ async function gatherInfo(
chalkColour`\nWe will now set up {blueBright Terraform Cloud} for your project.\n`
);
const organizationNames = await terraformCloudClient.getOrganizationNames(
tfeHostname,
token
);
const organizationData = organizationNames.data;
Expand Down
19 changes: 12 additions & 7 deletions packages/cdktf-cli/bin/cmds/helper/terraform-cloud-client.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import https = require("https");
import { format } from "url";

const BASE_URL = `https://app.terraform.io/api/v2/`;

const SUCCESS_STATUS_CODES = [200, 201];

export interface Attributes {
Expand Down Expand Up @@ -102,17 +100,21 @@ async function post(url: string, token: string, data: string) {
});
}

export async function getAccountDetails(token: string) {
return (await get(`${BASE_URL}/account/details`, token)) as Account;
export async function getAccountDetails(tfeHostname: string, token: string) {
return (await get(
`https://${tfeHostname}/api/v2//account/details`,
token
)) as Account;
}

export async function createWorkspace(
tfeHostname: string,
organizationName: string,
workspaceName: string,
token: string
) {
await post(
`${BASE_URL}/organizations/${organizationName}/workspaces`,
`https://${tfeHostname}/api/v2//organizations/${organizationName}/workspaces`,
token,
JSON.stringify({
data: {
Expand All @@ -126,6 +128,9 @@ export async function createWorkspace(
);
}

export async function getOrganizationNames(token: string) {
return (await get(`${BASE_URL}/organizations`, token)) as Organization;
export async function getOrganizationNames(tfeHostname: string, token: string) {
return (await get(
`https://${tfeHostname}/api/v2//organizations`,
token
)) as Organization;
}
31 changes: 21 additions & 10 deletions packages/cdktf-cli/bin/cmds/helper/terraform-login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,27 +7,31 @@ import * as terraformCloudClient from "./terraform-cloud-client";
const chalkColour = new chalk.Instance();
const homedir = require("os").homedir();
const terraformCredentialsFilePath = `${homedir}/.terraform.d/credentials.tfrc.json`;
const terraformLoginURL = `https://app.terraform.io/app/settings/tokens?source=terraform-login`;

export interface Hostname {
token: string;
}

export interface Credentials {
"app.terraform.io": Hostname;
[tfeHostname: string]: Hostname;
}

export interface TerraformCredentialsFile {
credentials: Credentials;
}

export class TerraformLogin {
constructor(private readonly tfeHostname: string) {}

private get terraformLoginURL(): string {
return `https://${this.tfeHostname}/app/settings/tokens?source=terraform-login`;
}
public async askToContinue(): Promise<boolean> {
// Describe the command
console.log(chalkColour`{greenBright Welcome to CDK for Terraform!}
By default, cdktf allows you to manage the state of your stacks using Terraform Cloud for free.
cdktf will request an API token for app.terraform.io using your browser.
cdktf will request an API token for ${this.tfeHostname} using your browser.
If login is successful, cdktf will store the token in plain text in
the following file for use by subsequent Terraform commands:
Expand Down Expand Up @@ -58,24 +62,31 @@ the following file for use by subsequent Terraform commands:
openBrowser() {
console.log(`\nopening webpage using your browser.....\n`);
console.log(chalkColour`If the web browser didn't open the window automatically, you can go to the following url:
{whiteBright ${terraformLoginURL}}\n`);
return open.default(terraformLoginURL);
{whiteBright ${this.terraformLoginURL}}\n`);
return open.default(this.terraformLoginURL);
}

public async askForToken() {
const { token } = await inquirer.prompt([
{
name: "token",
message: "Token for app.terraform.io 🔑",
message: `Token for ${this.tfeHostname} 🔑`,
type: "password",
},
]);
return token;
}

public async saveTerraformCredentials(token: string) {
const terraformCredentials = await this.getTerraformCredentialsFile();
const credentialsFileJSON = JSON.stringify(
{ credentials: { "app.terraform.io": { token: token } } },
{
...terraformCredentials,
credentials: {
...terraformCredentials.credentials,
[this.tfeHostname]: { token: token },
},
},
undefined,
2
);
Expand All @@ -95,8 +106,8 @@ the following file for use by subsequent Terraform commands:

public async getTokenFromTerraformCredentialsFile(): Promise<string> {
const terraformCredentials = await this.getTerraformCredentialsFile();
if ("app.terraform.io" in terraformCredentials.credentials) {
return terraformCredentials.credentials["app.terraform.io"].token;
if (this.tfeHostname in terraformCredentials.credentials) {
return terraformCredentials.credentials[this.tfeHostname].token;
}

return "";
Expand All @@ -113,7 +124,7 @@ the following file for use by subsequent Terraform commands:

public async isTokenValid(token: string): Promise<boolean> {
try {
await terraformCloudClient.getAccountDetails(token);
await terraformCloudClient.getAccountDetails(this.tfeHostname, token);
return true;
} catch (e) {
if ((e as any).statusCode === 401) {
Expand Down
7 changes: 3 additions & 4 deletions packages/cdktf-cli/bin/cmds/helper/utilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,12 @@ export const requireHandlers = () => {
};

export function readStreamAsString(
stream: typeof process.stdin
stream: typeof process.stdin,
noTTYErrorMessage: string
): Promise<string> {
return new Promise((ok, ko) => {
if (stream.isTTY) {
ko(
"No stdin was passed, please use it like this: cat main.tf | cdktf convert > imported.ts"
);
ko(noTTYErrorMessage);
} else {
let string = "";
stream.on("data", (data) => (string += data.toString()));
Expand Down
23 changes: 21 additions & 2 deletions packages/cdktf-cli/bin/cmds/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,27 @@ import { BaseCommand } from "./helper/base-command";
class Command extends BaseCommand {
public readonly command = "login";
public readonly describe =
"Retrieves an API token to connect to Terraform Cloud.";
public readonly builder = (args: yargs.Argv) => args.showHelpOnFail(true);
"Retrieves an API token to connect to Terraform Cloud or Terraform Enterprise.";
public readonly builder = (args: yargs.Argv) =>
args
.showHelpOnFail(true)
.option("tfe-hostname", {
string: true,
requiresArg: true,
describe:
"The Terraform Enterprise hostname to authenticate against. If you use Terraform Cloud you can leave this on the default.",
default: "app.terraform.io",
})
.example("cdktf login", "Takes you through the interactive login process")
.example(
"cdktf login --tfe-hostname tfe.my-company.com",
"Takes you through the interactive login process on your companies Terraform Enterprise instance."
)
.example(
"cat my-token.txt | cdktf login",
"Uses a locally stored token directly, instead of going through the interactive login process"
)
.strict();

public async handleCommand(argv: any) {
Errors.setScope("login");
Expand Down
11 changes: 7 additions & 4 deletions packages/cdktf-cli/lib/models/terraform-cloud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,10 @@ export class TerraformCloud implements Terraform {
this.token = configFile.credentials[this.hostname].token;
}

this.client = new TerraformCloudClient.TerraformCloud(this.token);
this.client = new TerraformCloudClient.TerraformCloud(
this.token,
this.hostname
);

this.abortSignal.addEventListener("abort", () => {
this.removeRun("cancel");
Expand Down Expand Up @@ -231,7 +234,7 @@ export class TerraformCloud implements Terraform {
throw new Error("Please create a ConfigurationVersion before planning");
const sendLog = this.createTerraformLogHandler("plan");
const workspace = await this.workspace();
const workspaceUrl = `https://app.terraform.io/app/${this.organizationName}/workspaces/${this.workspaceName}`;
const workspaceUrl = `https://${this.hostname}/app/${this.organizationName}/workspaces/${this.workspaceName}`;

if (
workspace.attributes.locked &&
Expand Down Expand Up @@ -284,7 +287,7 @@ export class TerraformCloud implements Terraform {
},
},
});
const url = `https://app.terraform.io/app/${this.organizationName}/workspaces/${this.workspaceName}/runs/${result.id}`;
const url = `https://${this.hostname}/app/${this.organizationName}/workspaces/${this.workspaceName}/runs/${result.id}`;
sendLog(`Created speculative Terraform Cloud run: ${url}`);

const pendingStates = ["pending", "plan_queued", "planning"];
Expand Down Expand Up @@ -494,7 +497,7 @@ export class TerraformCloud implements Terraform {
return;
}

const url = `https://app.terraform.io/app/${this.organizationName}/workspaces/${this.workspaceName}/runs/${this.run.id}`;
const url = `https://${this.hostname}/app/${this.organizationName}/workspaces/${this.workspaceName}/runs/${this.run.id}`;
logger.info(`${action}ing run ${url}`);
this.client.Runs.action(action, this.run.id)
.then(() => {
Expand Down
2 changes: 1 addition & 1 deletion packages/cdktf-cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@
"@cdktf/provider-generator": "0.0.0",
"@npmcli/ci-detect": "^1.4.0",
"@skorfmann/ink-confirm-input": "^3.0.0",
"@skorfmann/terraform-cloud": "^1.12.0",
"@skorfmann/terraform-cloud": "^1.14.0",
"@types/archiver": "^5.3.1",
"@types/cross-spawn": "^6.0.2",
"@types/detect-port": "^1.3.2",
Expand Down
18 changes: 14 additions & 4 deletions website/docs/cdktf/cli-reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -390,14 +390,24 @@ $ cdktf login --help
```
cdktf login
Retrieves an API token to connect to Terraform Cloud.
Retrieves an API token to connect to Terraform Cloud or Terraform Enterprise.
Options:
--version Show version number [boolean]
--log-level Which log level should be written. Only supported via setting the env CDKTF_LOG_LEVEL [string]
-h, --help Show help [boolean]
--version Show version number [boolean]
--disable-plugin-cache-env Dont set TF_PLUGIN_CACHE_DIR automatically. This is useful when the plugin cache is configured differently. Supported using the env CDKTF_DISABLE_PLUGIN_CACHE_ENV.
[boolean] [default: false]
--log-level Which log level should be written. Only supported via setting the env CDKTF_LOG_LEVEL [string]
--tfe-hostname The Terraform Enterprise hostname to authenticate against. If you use Terraform Cloud you can leave this on the default. [string] [default: "app.terraform.io"]
-h, --help Show help [boolean]
Examples:
cdktf login Takes you through the interactive login process
cdktf login --tfe-hostname tfe.my-company.com Takes you through the interactive login process on your companies Terraform Enterprise instance.
cat my-token.txt | cdktf login Uses a locally stored token directly, instead of going through the interactive login process
```

Please note that we currently expect custom TFE instances to be using the `https` protocol.

**Examples**

Fetch an API token from Terraform Cloud.
Expand Down
Loading

0 comments on commit 2fb3107

Please sign in to comment.