Skip to content
Open
Show file tree
Hide file tree
Changes from all 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