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
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ Terraform plans are notoriously difficult to review in pull requests:
## Features

- 📄 **Convert Terraform plans to Markdown** - Generate clean, readable reports from `terraform show -json` output
- ☁️ **Native HCP Terraform input** - Fetch plan JSON directly from HCP Terraform using `--hcp-run-id`
- 🔍 **Static analysis integration** - Display security and quality findings from Checkov, Trivy, TFLint, and Semgrep (SARIF 2.1.0 format) directly in reports
- ✅ **Validated markdown output** - Comprehensive testing ensures GitHub/Azure DevOps compatibility
- 🔒 **Sensitive value masking** - Sensitive values are masked by default for security
Expand Down Expand Up @@ -213,6 +214,25 @@ docker run -v $(pwd):/data oocx/tfplan2md /data/plan.json
dotnet run --project src/Oocx.TfPlan2Md -- plan.json
```

### From HCP Terraform run ID

```bash
# Requires TFE_TOKEN in environment
tfplan2md --hcp-run-id run-abc123

# Optional: override HCP address for Terraform Enterprise/private installs
TFE_ADDRESS="https://tfe.example.com" tfplan2md --hcp-run-id run-abc123
```

Authentication and endpoint behavior:

- `TFE_TOKEN` is required when `--hcp-run-id` is used.
- Use a user/team token with workspace admin access for `TFE_TOKEN` (`organization` tokens are not accepted for plan JSON output endpoints).
- `TFE_ADDRESS` is optional; default is `https://app.terraform.io`.
- `TFE_ADDRESS` must be an absolute `https://` URL.
- Input modes are mutually exclusive: use either `--hcp-run-id`, positional `plan.json`, or stdin.
- The fetched JSON is passed into the existing parser/render pipeline unchanged.

### With output file

```bash
Expand All @@ -231,6 +251,7 @@ terraform show -json plan.tfplan | docker run -i oocx/tfplan2md --template summa
|--------|-------------|
| `--output`, `-o <file>` | Write output to a file instead of stdout |
| `--template`, `-t <name>` | Use a built-in template by name (`default`, `summary`) |
| `--hcp-run-id <id>` | Fetch plan JSON from HCP Terraform by run ID (requires `TFE_TOKEN`, optional `TFE_ADDRESS`) |
| `--report-title <text>` | Override the level-1 heading in the generated report |
| `--render-target <github\|azuredevops>` | Target platform for rendering: `github` (simple diff) or `azuredevops` (inline diff, default) |
| `--details <auto\|open\|closed>` | Control resource details display: `auto` (expand resources with findings, default), `open` (expand all), `closed` (collapse all) |
Expand Down Expand Up @@ -647,6 +668,8 @@ The demo includes:

See [examples/comprehensive-demo/README.md](examples/comprehensive-demo/README.md) for details.

For native HCP Terraform run ID input usage, see [examples/hcp-run-id/README.md](examples/hcp-run-id/README.md).

## Built-in Templates

Select a built-in report template with the `--template` flag:
Expand Down
23 changes: 23 additions & 0 deletions docs/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -699,6 +699,28 @@ tfplan2md plan.json --report-title "Drift Detection Results"
```bash
tfplan2md plan.json
```
- **HCP Terraform run ID**: Fetch Terraform plan JSON directly from HCP Terraform
```bash
# Requires TFE_TOKEN
tfplan2md --hcp-run-id run-abc123

# Optional Terraform Enterprise/private address override
TFE_ADDRESS="https://tfe.example.com" tfplan2md --hcp-run-id run-abc123
```

### HCP Terraform Authentication

When using `--hcp-run-id`, tfplan2md reads authentication and endpoint settings from environment variables:

- `TFE_TOKEN` (**required**) - Bearer token for HCP Terraform/Terraform Enterprise API requests.
- Use a user/team token with workspace admin access; organization tokens are not accepted for plan JSON output endpoints.
- `TFE_ADDRESS` (optional) - Base URL for API requests; defaults to `https://app.terraform.io` and must be an absolute `https://` URL.

Behavior:

- `--hcp-run-id` cannot be combined with positional input file arguments.
- Missing `TFE_TOKEN` returns a clear actionable error.
- Non-success API responses and malformed payloads return explicit errors.

## Output

Expand Down Expand Up @@ -1032,6 +1054,7 @@ Simple single-command interface with flags:
|------|-------------|
| `--output <file>` | Write output to a file instead of stdout |
| `--template <name>` | Use a built-in template by name (`default`, `summary`) |
| `--hcp-run-id <id>` | Fetch plan JSON from HCP Terraform by run ID (requires `TFE_TOKEN`, optional `TFE_ADDRESS`) |
| `--report-title <text>` | Override the report's level-1 heading |
| `--render-target <github\|azuredevops>` | Target platform for rendering: `github` (simple diff) or `azuredevops` (inline diff, default) |
| `--principal-mapping <file>` | Map Azure principal IDs to names using a JSON file |
Expand Down
30 changes: 30 additions & 0 deletions examples/hcp-run-id/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# HCP Run-ID Example

This example shows how to generate a tfplan2md report directly from an HCP Terraform run ID.

## Prerequisites

- `TFE_TOKEN` set to a user or team token with workspace admin access.
- Optional `TFE_ADDRESS` override for Terraform Enterprise (default is `https://app.terraform.io`).

## Usage

```bash
# HCP Terraform (default address)
TFE_TOKEN="<your-token>" \
dotnet run --project src/Oocx.TfPlan2Md/Oocx.TfPlan2Md.csproj -- \
--hcp-run-id "run-abc123" \
--output artifacts/hcp-run-id-example.md

# Terraform Enterprise (custom address)
TFE_TOKEN="<your-token>" \
TFE_ADDRESS="https://tfe.example.com" \
dotnet run --project src/Oocx.TfPlan2Md/Oocx.TfPlan2Md.csproj -- \
--hcp-run-id "run-abc123" \
--output artifacts/hcp-run-id-example.md
```

## Notes

- Input modes are mutually exclusive: use either `--hcp-run-id`, a positional `plan.json` file, or stdin.
- If `TFE_TOKEN` is missing, tfplan2md exits with an actionable error.
29 changes: 29 additions & 0 deletions src/Oocx.TfPlan2Md/CLI/CliParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ internal record CliOptions
/// </summary>
public string? InputFile { get; init; }

/// <summary>
/// Gets the HCP Terraform run id used to fetch plan JSON as input.
/// </summary>
public string? HcpRunId { get; init; }

/// <summary>
/// Gets the list of code analysis SARIF file patterns provided via CLI.
/// Related feature: docs/features/056-static-analysis-integration/specification.md.
Expand Down Expand Up @@ -129,6 +134,7 @@ internal static class CliParser
public static CliOptions Parse(string[] args)
{
string? inputFile = null;
string? hcpRunId = null;
string? outputFile = null;
string? templatePath = null;
string? principalMappingFile = null;
Expand Down Expand Up @@ -200,6 +206,22 @@ public static CliOptions Parse(string[] args)
{
throw new CliParseException("--output requires a file path argument.");
}
break;
case "--hcp-run-id":
if (i + 1 < args.Length)
{
if (inputFile is not null)
{
throw new CliParseException("--hcp-run-id cannot be combined with an input file argument.");
}

hcpRunId = args[++i];
}
else
{
throw new CliParseException("--hcp-run-id requires a value.");
}

break;
case "--template" or "-t":
if (i + 1 < args.Length)
Expand Down Expand Up @@ -289,6 +311,12 @@ public static CliOptions Parse(string[] args)
{
throw new CliParseException($"Unexpected argument: {arg}. Only one input file can be specified.");
}

if (hcpRunId is not null)
{
throw new CliParseException("Input file argument cannot be combined with --hcp-run-id.");
}

// Positional argument is the input file
inputFile = arg;
break;
Expand All @@ -298,6 +326,7 @@ public static CliOptions Parse(string[] args)
return new CliOptions
{
InputFile = inputFile,
HcpRunId = hcpRunId,
OutputFile = outputFile,
TemplatePath = templatePath,
ShowSensitive = showSensitive,
Expand Down
6 changes: 5 additions & 1 deletion src/Oocx.TfPlan2Md/CLI/HelpTextProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ public static string GetHelpText()
("-t, --template <name>", "Use a built-in template by name (default or summary)."),
("--report-title <title>", "Override the report title (level-1 heading) with a custom value."),
("-p, --principal-mapping <file>", "Map principal IDs to names using a JSON file."),
("--hcp-run-id <id>", "Fetch plan JSON from HCP Terraform using a run ID (requires TFE_TOKEN user/team token with workspace admin access; optional TFE_ADDRESS)."),
("--render-target <github|azuredevops>", "Target platform for rendering (default: azuredevops). When azuredevops and --output is used, emits ##vso[task.setvariable variable=tfplan2md_haschanges]true/false."),
("--details <auto|open|closed>", "Control resource details display (default: auto)."),
("--code-analysis-results <pattern>", "SARIF file pattern for static analysis findings (repeatable)."),
Expand All @@ -49,6 +50,9 @@ public static string GetHelpText()
"# With principal mapping",
"tfplan2md --principal-mapping principals.json plan.json",
string.Empty,
"# From HCP Terraform run ID",
"tfplan2md --hcp-run-id run-abc123",
string.Empty,
"# With code analysis (auto-expand resources with findings)",
"tfplan2md --code-analysis-results checkov.sarif plan.json",
string.Empty,
Expand All @@ -74,7 +78,7 @@ public static string GetHelpText()
sb.AppendLine();
sb.AppendLine("Arguments:");
sb.AppendLine(" input-file Path to the Terraform plan JSON file.");
sb.AppendLine(" If omitted, reads from stdin.");
sb.AppendLine(" If omitted, reads from stdin (unless --hcp-run-id is provided).");
sb.AppendLine();
sb.AppendLine("Options:");
foreach (var (option, description) in options)
Expand Down
184 changes: 184 additions & 0 deletions src/Oocx.TfPlan2Md/Input/HcpTerraformPlanInput.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
using System.Diagnostics.CodeAnalysis;
using System.Net;
using System.Net.Http.Headers;
using System.Text.Json;

namespace Oocx.TfPlan2Md.Input;

/// <summary>
/// Fetches Terraform plan JSON from HCP Terraform using a run id.
/// </summary>
[SuppressMessage("Design", "CA1506:Avoid excessive class coupling", Justification = "Input adapter composes HTTP, URI validation, and JSON parsing concerns for a single external boundary.")]
internal sealed class HcpTerraformPlanInput(HttpClient httpClient)
{
/// <summary>
/// Default HCP Terraform address used when <c>TFE_ADDRESS</c> is not set.
/// </summary>
private const string DefaultTfeAddress = "https://app.terraform.io";

/// <summary>
/// HTTP client used to call HCP Terraform API endpoints.
/// </summary>
private readonly HttpClient _httpClient = httpClient;

/// <summary>
/// Resolves plan JSON for an HCP Terraform run id.
/// </summary>
/// <param name="runId">HCP Terraform run identifier.</param>
/// <param name="cancellationToken">Cancellation signal for the HTTP workflow.</param>
/// <returns>Terraform plan JSON text suitable for the existing parser pipeline.</returns>
public async Task<string> GetPlanJsonAsync(string runId, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(runId))
{
throw new InvalidOperationException("HCP run ID is required when using --hcp-run-id.");
}

var token = Environment.GetEnvironmentVariable("TFE_TOKEN");
if (string.IsNullOrWhiteSpace(token))
{
throw new InvalidOperationException("TFE_TOKEN is required for HCP run-id input.");
}

var baseAddressRaw = Environment.GetEnvironmentVariable("TFE_ADDRESS");
var baseAddress = string.IsNullOrWhiteSpace(baseAddressRaw) ? DefaultTfeAddress : baseAddressRaw;
var normalizedBaseAddress = NormalizeAndValidateBaseAddress(baseAddress);

var planId = await GetPlanIdAsync(normalizedBaseAddress, runId, token, cancellationToken);
return await GetPlanJsonByIdAsync(normalizedBaseAddress, planId, token, cancellationToken);
}

/// <summary>
/// Retrieves the plan id for a run from HCP Terraform API.
/// </summary>
/// <param name="baseAddress">Base TFE address.</param>
/// <param name="runId">Run identifier.</param>
/// <param name="token">Bearer token value.</param>
/// <param name="cancellationToken">Cancellation signal.</param>
/// <returns>Plan id resolved from the run record.</returns>
private async Task<string> GetPlanIdAsync(
string baseAddress,
string runId,
string token,
CancellationToken cancellationToken)
{
var escapedRunId = Uri.EscapeDataString(runId);
var requestUri = BuildApiUri(baseAddress, $"/api/v2/runs/{escapedRunId}");

using var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);

using var response = await _httpClient.SendAsync(request, cancellationToken);
if (!response.IsSuccessStatusCode)
{
throw new InvalidOperationException(
$"HCP Terraform run lookup failed ({(int)response.StatusCode} {response.ReasonPhrase}).");
}

var payload = await response.Content.ReadAsStringAsync(cancellationToken);

try
{
using var document = JsonDocument.Parse(payload);
var root = document.RootElement;

if (root.TryGetProperty("data", out var data)
&& data.TryGetProperty("relationships", out var relationships)
&& relationships.TryGetProperty("plan", out var plan)
&& plan.TryGetProperty("data", out var planData)
&& planData.TryGetProperty("id", out var id)
&& id.ValueKind == JsonValueKind.String)
{
var planId = id.GetString();
if (!string.IsNullOrWhiteSpace(planId))
{
return planId;
}
}

throw new InvalidOperationException("HCP Terraform run response did not include relationships.plan.data.id.");
}
catch (JsonException ex)
{
throw new InvalidOperationException("Malformed HCP Terraform run response payload.", ex);
}
}

/// <summary>
/// Retrieves plan JSON by plan id from HCP Terraform API.
/// </summary>
/// <param name="baseAddress">Base TFE address.</param>
/// <param name="planId">Plan identifier resolved from run lookup.</param>
/// <param name="token">Bearer token value.</param>
/// <param name="cancellationToken">Cancellation signal.</param>
/// <returns>Validated Terraform plan JSON string.</returns>
private async Task<string> GetPlanJsonByIdAsync(
string baseAddress,
string planId,
string token,
CancellationToken cancellationToken)
{
var escapedPlanId = Uri.EscapeDataString(planId);
var requestUri = BuildApiUri(baseAddress, $"/api/v2/plans/{escapedPlanId}/json-output");

using var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);

using var response = await _httpClient.SendAsync(request, cancellationToken);
if (response.StatusCode == HttpStatusCode.NoContent)
{
throw new InvalidOperationException(
"HCP Terraform plan JSON is not available yet (run is still in progress). Please retry when planning is complete.");
}

if (!response.IsSuccessStatusCode)
{
throw new InvalidOperationException(
$"HCP Terraform plan JSON lookup failed ({(int)response.StatusCode} {response.ReasonPhrase}).");
}

var payload = await response.Content.ReadAsStringAsync(cancellationToken);

try
{
using var document = JsonDocument.Parse(payload);
return document.RootElement.GetRawText();
}
catch (JsonException ex)
{
throw new InvalidOperationException("Malformed plan JSON received from HCP Terraform.", ex);
}
}

/// <summary>
/// Builds an absolute API URI from base address and API path.
/// </summary>
/// <param name="baseAddress">Configured HCP Terraform address.</param>
/// <param name="apiPath">API path beginning with slash.</param>
/// <returns>Absolute URI for API invocation.</returns>
private static Uri BuildApiUri(string baseAddress, string apiPath)
{
var normalized = baseAddress.TrimEnd('/');
return new Uri($"{normalized}{apiPath}", UriKind.Absolute);
}

/// <summary>
/// Validates and normalizes the configured TFE base address.
/// </summary>
/// <param name="baseAddress">Raw base address from configuration or default value.</param>
/// <returns>Normalized absolute HTTPS base address string.</returns>
private static string NormalizeAndValidateBaseAddress(string baseAddress)
{
if (!Uri.TryCreate(baseAddress, UriKind.Absolute, out var uri))
{
throw new InvalidOperationException("TFE_ADDRESS must be a valid absolute HTTPS URL.");
}

if (!string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("TFE_ADDRESS must use https.");
}

return uri.ToString();
}
}
Loading