Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
node_modules
build
dist
views/terraform-plan/dist
Tasks/**/*.js
views/**/*.js
.taskkey
configs/self.json
*.vsix
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
"loc.input.help.outputTo": "choose output to file or console. ",
"loc.input.label.fileName": "Filename",
"loc.input.help.fileName": "filename of output",
"loc.input.label.planName": "Plan Name",
"loc.input.help.planName": "Name for the terraform plan to display in the Terraform Plan tab. If not provided, a default name will be used.",
"loc.input.label.outputFormat": "Output format",
"loc.input.help.outputFormat": "choose format of console ouput for show cmd.",
"loc.input.label.environmentServiceNameAzureRM": "Azure subscription",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,8 +111,43 @@ export abstract class BaseTerraformCommandHandler {
await this.handleProvider(showCommand);

if(outputTo == "console"){
return await terraformTool.execAsync(<IExecOptions> {
cwd: showCommand.workingDirectory});
let commandOutput = await terraformTool.execSync(<IExecSyncOptions> {
cwd: showCommand.workingDirectory,
});

// If JSON format is used, attach the output for the Terraform Plan tab
if (outputFormat == "json") {
const planName = tasks.getInput("fileName") || "terraform-plan";
const attachmentType = "terraform-plan-results";

// Create a file in the task's working directory
const workDir = tasks.getVariable('System.DefaultWorkingDirectory') || '.';
// Create an absolute path for the plan file
const planFilePath = path.join(workDir, `${planName}.json`);

// Write the output to the file
tasks.writeFile(planFilePath, commandOutput.stdout);

// Debug info to help troubleshoot
console.log(`Writing plan to file: ${planFilePath}`);
console.log(`File exists: ${tasks.exist(planFilePath)}`);
console.log(`File size: ${fs.statSync(planFilePath).size} bytes`);
console.log(`First 100 chars: ${commandOutput.stdout.substring(0, 100)}...`);

// Get current task info for debugging
console.log(`Task ID: ${tasks.getVariable('SYSTEM_TASKID') || 'unknown'}`);
console.log(`Task Instance ID: ${tasks.getVariable('SYSTEM_TASKINSTANCEID') || 'unknown'}`);

// Save as attachment using the file path
console.log(`Adding attachment: type=${attachmentType}, name=${planName}, path=${planFilePath}`);
tasks.addAttachment(attachmentType, planName, planFilePath);

console.log(`Terraform plan output saved for visualization in the Terraform Plan tab`);
}

// Output to console
console.log(commandOutput.stdout);
return commandOutput.exitCode;
}else if(outputTo == "file"){
const showFilePath = path.resolve(tasks.getInput("filename"));
let commandOutput = await terraformTool.execSync(<IExecSyncOptions> {
Expand All @@ -122,7 +157,16 @@ export abstract class BaseTerraformCommandHandler {
tasks.writeFile(showFilePath, commandOutput.stdout);
tasks.setVariable('showFilePath', showFilePath, false, true);

return commandOutput;
// If JSON format is used, attach the output for the Terraform Plan tab
if (outputFormat == "json") {
const planName = tasks.getInput("fileName") || path.basename(showFilePath);
const attachmentType = "terraform-plan-results";

// Save as attachment - using the file path that was already written to
tasks.addAttachment(attachmentType, planName, showFilePath);
}

return commandOutput.exitCode;
}
}
public async output(): Promise<number> {
Expand Down Expand Up @@ -154,6 +198,41 @@ export abstract class BaseTerraformCommandHandler {
public async plan(): Promise<number> {
let serviceName = `environmentServiceName${this.getServiceProviderNameFromProviderInput()}`;
let commandOptions = tasks.getInput("commandOptions") != null ? `${tasks.getInput("commandOptions")} -detailed-exitcode`:`-detailed-exitcode`

// Check if publishPlan is provided (non-empty string means publish)
const publishPlanName = tasks.getInput("publishPlan") || "";

// If publishPlan is provided, check for -out parameter and add it if not specified
if (publishPlanName) {
// Check if -out parameter is already specified
let outParamSpecified = false;
let planOutputPath = "";

// Look for -out= in the command options (equals sign format)
const outEqualParamMatch = commandOptions.match(/-out=([^\s]+)/);
if (outEqualParamMatch && outEqualParamMatch[1]) {
outParamSpecified = true;
planOutputPath = outEqualParamMatch[1];
}

// Look for -out followed by a space and a value (space-separated format)
if (!outParamSpecified) {
const outSpaceParamMatch = commandOptions.match(/-out\s+([^\s-][^\s]*)/);
if (outSpaceParamMatch && outSpaceParamMatch[1]) {
outParamSpecified = true;
planOutputPath = outSpaceParamMatch[1];
}
}

// If -out parameter is not specified, add it
if (!outParamSpecified) {
// Generate a unique filename for the plan output
const tempPlanFile = path.join(tasks.getVariable('System.DefaultWorkingDirectory') || '.', `terraform-plan-${uuidV4()}.tfplan`);
commandOptions = `${commandOptions} -out=${tempPlanFile}`;
planOutputPath = tempPlanFile;
}
}

let planCommand = new TerraformAuthorizationCommandInitializer(
"plan",
tasks.getInput("workingDirectory"),
Expand All @@ -175,6 +254,55 @@ export abstract class BaseTerraformCommandHandler {
throw new Error(tasks.loc("TerraformPlanFailed", result));
}
tasks.setVariable('changesPresent', (result === 2).toString(), false, true);

// If publishPlan name is provided, run show command with JSON output to get the plan details
if (publishPlanName) {
try {
// Extract the plan file path from the commandOptions
let planFilePath = '';

// Look for -out= in the command options (equals sign format)
const outEqualMatch = commandOptions.match(/-out=([^\s]+)/);
if (outEqualMatch && outEqualMatch[1]) {
planFilePath = outEqualMatch[1];
} else {
// Look for -out followed by a space and a value (space-separated format)
const outSpaceMatch = commandOptions.match(/-out\s+([^\s-][^\s]*)/);
if (outSpaceMatch && outSpaceMatch[1]) {
planFilePath = outSpaceMatch[1];
}
}

if (planFilePath) {
// Run terraform show with JSON output on the plan file
let showTerraformTool = this.terraformToolHandler.createToolRunner(new TerraformBaseCommandInitializer(
"show",
planCommand.workingDirectory,
`-json ${planFilePath}`
));

let showCommandOutput = await showTerraformTool.execSync(<IExecSyncOptions> {
cwd: planCommand.workingDirectory,
});

// Create a JSON file for the plan output
const planName = publishPlanName || "terraform-plan";
const attachmentType = "terraform-plan-results";
const jsonPlanFilePath = path.join(tasks.getVariable('System.DefaultWorkingDirectory') || '.', `${planName}.json`);

// Write the output to the file
tasks.writeFile(jsonPlanFilePath, showCommandOutput.stdout);

// Save as attachment using the file path
tasks.addAttachment(attachmentType, planName, jsonPlanFilePath);
}
} catch (error) {
// Log error but don't fail the task
console.log(`Error publishing plan: ${error}`);
tasks.warning(`Failed to publish terraform plan: ${error}`);
}
}

return result;
}

Expand Down
19 changes: 14 additions & 5 deletions Tasks/TerraformTask/TerraformTaskV5/task.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@
"demands": [],
"version": {
"Major": "5",
"Minor": "257",
"Patch": "1"
"Minor": "258",
"Patch": "0"
},
"instanceNameFormat": "Terraform : $(provider)",
"execution": {
Expand Down Expand Up @@ -153,9 +153,18 @@
"name": "fileName",
"type": "string",
"label": "Output Filename",
"visibleRule": "outputTo = file",
"required": true,
"helpMarkDown": "filename of output"
"visibleRule": "outputTo = file || outputFormat = json",
"required": false,
"helpMarkDown": "Filename for the output. For JSON plan output, this will also be used as the name for the terraform plan to display in the Terraform Plan tab. If not provided, a default name will be used."
},
{
"name": "publishPlan",
"type": "string",
"label": "Publish Plan Name",
"defaultValue": "",
"visibleRule": "command = plan",
"required": false,
"helpMarkDown": "If provided, the terraform plan will be published for visualization in the Terraform Plan tab using this name. Leave empty to disable plan publishing."
},
{
"name": "environmentServiceNameAzureRM",
Expand Down
24 changes: 23 additions & 1 deletion azure-devops-extension.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"manifestVersion": 1,
"id": "custom-terraform-tasks",
"name": "Terraform",
"version": "0.1.34",
"version": "0.1.35",
"publisher": "ms-devlabs",
"targets": [
{
Expand All @@ -14,6 +14,9 @@
"categories": [
"Azure Pipelines"
],
"scopes": [
"vso.build"
],
"Tags": [
"Terraform",
"Azure",
Expand Down Expand Up @@ -72,6 +75,10 @@
{
"path": "Tasks/TerraformInstaller"
},
{
"path": "views/terraform-plan/",
"addressable": true
},
{
"path": "images/1_AWS_service_endpoint.PNG",
"addressable": true
Expand Down Expand Up @@ -369,6 +376,21 @@
}
]
}
},
{
"description": "A tab to show terraform plan output",
"id": "terraform-plan-tab",
"type": "ms.vss-build-web.build-results-tab",
"targets": [
"ms.vss-build-web.build-results-view"
],
"properties": {
"name": "Terraform Plan",
"uri": "views/terraform-plan/dist/index.html",
"supportsTasks": [
"FE504ACC-6115-40CB-89FF-191386B5E7BF"
]
}
}
]
}
20 changes: 18 additions & 2 deletions overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -349,11 +349,27 @@ The Terraform task has the following input parameters:

#### Command and Cloud Specific Inputs for the `plan`, `apply`, and `destroy` commands

- `commandOptions`: The addtiional command arguments to pass to the command. The default value is `''`.
- `commandOptions`: The additional command arguments to pass to the command. The default value is `''`.
- `customCommand`: The custom command to run if `command` is set to `custom`. The default value is `''`.
- `outputTo`: Choose whether to output to the console or a file for the `show` and `output` Terraform commands. The options are `console`, and `file`. The default value is `console`.
- `fileName`: The name of the file to output to for the `show` and `output` commands if `outputTo` is set to `file`. The default value is `''`.
- `fileName`: The name of the file to output to for the `show` and `output` commands if `outputTo` is set to `file`. For JSON plan output, this will also be used as the name for the terraform plan to display in the Terraform Plan tab. If not provided, a default name will be used. The default value is `''`.
- `outputFormat`: The output format to use for the `show` command. The options are `json`, and `default`. The default value is `default`.
- `publishPlan`: When using the `plan` command, if provided, the terraform plan will be published for visualization in the Terraform Plan tab using this name. Leave empty to disable plan publishing. The default value is `''`.

#### Terraform Plan Visualization

The task supports visualizing Terraform plans in the "Terraform Plan" tab of the build summary in two ways:

1. **Using the `show` command with JSON output**:
- Set the `command` to `show`
- Set the `outputFormat` to `json`
- Optionally provide a `fileName` to identify your plan in the tab

2. **Directly from the `plan` command**:
- Set the `command` to `plan`
- Provide a name in the `publishPlan` parameter

This provides a convenient way to view the Terraform plan directly in Azure Pipelines without needing to download and review log files. Multiple plans in the same build will be shown in a dropdown selector in the tab.

##### Azure Specific Inputs for `plan`, `apply`, and `destroy`

Expand Down
Loading