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
30 changes: 30 additions & 0 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"name": "infra-diff",
"image": "mcr.microsoft.com/devcontainers/typescript-node:22",
"features": {
"ghcr.io/devcontainers/features/docker-outside-of-docker:1": {},
"ghcr.io/devcontainers-contrib/features/terraform-asdf:2": {
"version": "1.13.4"
Copy link

Copilot AI Nov 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Terraform version is duplicated here. It should be read from the .terraform-version file to maintain consistency. The devcontainer feature supports using a version file reference instead of hardcoding the version.

Suggested change
"version": "1.13.4"
"version": "file:.terraform-version"

Copilot uses AI. Check for mistakes.
}
},
"customizations": {
"vscode": {
"extensions": [
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"biomejs.biome",
"hashicorp.terraform"
],
"settings": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "biomejs.biome"
}
}
},
"postCreateCommand": "npm install",
"forwardPorts": [50000],
"mounts": [
"source=/var/run/docker.sock,target=/var/run/docker.sock,type=bind"
],
"remoteUser": "node"
}
7 changes: 7 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,10 @@ updates:
schedule:
interval: "weekly"
open-pull-requests-limit: 5

# Monitor Terraform
- package-ecosystem: "terraform"
directory: "/e2e/terraform"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
16 changes: 15 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,23 @@ jobs:
- run: npm test
- run: npm run test:e2e:file-reading

test-terraform-integration:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version-file: ".nvmrc"
- uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2
with:
terraform_version: 1.13.4
Copy link

Copilot AI Nov 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Terraform version is hardcoded as "1.13.4" here, but it should be read from the .terraform-version file to maintain a single source of truth. Consider using terraform_version_file: ".terraform-version" instead of terraform_version: 1.13.4 to avoid version drift.

Suggested change
terraform_version: 1.13.4
terraform_version_file: ".terraform-version"

Copilot uses AI. Check for mistakes.
- run: npm ci
- name: Run Terraform integration tests
run: npm run test:e2e:terraform

validate-action:
runs-on: ubuntu-latest
needs: [lint, test]
needs: [lint, test, test-terraform-integration]
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
Expand Down
1 change: 1 addition & 0 deletions .terraform-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
1.13.4
15 changes: 15 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
version: '3.8'
Copy link

Copilot AI Nov 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The 'version' field in docker-compose.yml is deprecated as of Docker Compose v1.27.0+ and is no longer required. Consider removing this line as modern versions of Docker Compose ignore it.

Suggested change
version: '3.8'

Copilot uses AI. Check for mistakes.

services:
moto:
image: motoserver/moto:5.0.0
container_name: infra-diff-moto-test
ports:
- "50000:5000"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:5000"]
interval: 5s
timeout: 3s
retries: 10
start_period: 10s
253 changes: 253 additions & 0 deletions e2e/terraform-integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,253 @@
import { existsSync, rmSync, unlinkSync } from "node:fs";
import * as fs from "node:fs/promises";
import * as path from "node:path";
import {
GenericContainer,
Network,
type StartedNetwork,
type StartedTestContainer,
} from "testcontainers";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { ParsePlanUseCase } from "../src/domain/usecases/ParsePlanUseCase";
import { ReadPlanFileUseCase } from "../src/domain/usecases/ReadPlanFileUseCase";
import { FilesystemAdapter } from "../src/infrastructure/adapters/FilesystemAdapter";

describe("E2E: Terraform Integration with moto", () => {
const terraformDir = path.join(process.cwd(), "e2e", "terraform");
const planFileRelative = "plan.bin";
const planFile = path.join(terraformDir, planFileRelative);
const planJsonFileRelative = "plan.json";
const planJsonFile = path.join(terraformDir, planJsonFileRelative);
let motoContainer: StartedTestContainer;
let terraformContainer: StartedTestContainer;
let network: StartedNetwork;
const motoContainerName = "moto-server";

// Read Terraform version from .terraform-version file
const getTerraformVersion = async (): Promise<string> => {
const versionFilePath = path.join(process.cwd(), ".terraform-version");
const versionContent = await fs.readFile(versionFilePath, "utf-8");
return versionContent.trim();
};

beforeAll(async () => {
try {
// Get Terraform version from .terraform-version file
const terraformVersion = await getTerraformVersion();
console.log(`Using Terraform version: ${terraformVersion}`);

// Create a shared network for containers
console.log("Creating Docker network...");
network = await new Network().start();

// Start moto server in the network
console.log("Starting moto server...");
motoContainer = await new GenericContainer("motoserver/moto:5.0.0")
.withNetwork(network)
.withNetworkAliases(motoContainerName)
.withExposedPorts(5000)
.withStartupTimeout(120000)
.start();

const motoPort = motoContainer.getFirstMappedPort();
console.log(`Moto server is ready on port ${motoPort}`);
Copy link

Copilot AI Nov 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using console.log for test output violates the operational convention stated in the coding guidelines: 'ensure all application logs are structured as JSON objects, except when logging to the console in GitHub Actions.' Since this is a test file, consider using the test framework's logging capabilities or a structured logger.

Suggested change
console.log(`Moto server is ready on port ${motoPort}`);
console.log(JSON.stringify({ event: "moto_server_ready", port: motoPort }));

Copilot uses AI. Check for mistakes.

// Start Terraform container in the same network
console.log("Starting Terraform container...");
terraformContainer = await new GenericContainer(
`hashicorp/terraform:${terraformVersion}`,
Comment on lines +57 to +58
Copy link

Copilot AI Nov 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The version string used in this template literal comes from reading the .terraform-version file, but there's no validation that the version format is correct or that the Docker image tag exists. Consider adding validation or error handling if the version is malformed or the image doesn't exist with that tag.

Copilot uses AI. Check for mistakes.
)
.withNetwork(network)
.withBindMounts([
{
source: terraformDir,
target: "/terraform",
mode: "rw",
},
])
.withWorkingDir("/terraform")
.withEntrypoint(["/bin/sh", "-c", "exec sleep infinity"])
.withEnvironment({
AWS_ACCESS_KEY_ID: "testing",
AWS_SECRET_ACCESS_KEY: "testing",
AWS_SECURITY_TOKEN: "testing",
AWS_SESSION_TOKEN: "testing",
AWS_EC2_METADATA_DISABLED: "true",
})
.withStartupTimeout(60000)
.start();

console.log("Terraform container started");

// Initialize Terraform
console.log("Initializing Terraform...");
Copy link

Copilot AI Nov 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using console.log for test output violates the operational convention stated in the coding guidelines: 'ensure all application logs are structured as JSON objects, except when logging to the console in GitHub Actions.' Since this is a test file, consider using the test framework's logging capabilities or a structured logger.

Suggested change
console.log("Initializing Terraform...");
console.log(JSON.stringify({ message: "Initializing Terraform..." }));

Copilot uses AI. Check for mistakes.
const initResult = await terraformContainer.exec(["terraform", "init"]);
console.log("Init exit code:", initResult.exitCode);
if (initResult.exitCode !== 0) {
console.error("Init output:", initResult.output);
throw new Error(`Terraform init failed: ${initResult.output}`);
}

// Generate plan using moto endpoint accessible from container
console.log("Generating Terraform plan...");
Copy link

Copilot AI Nov 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using console.log for test output violates the operational convention stated in the coding guidelines: 'ensure all application logs are structured as JSON objects, except when logging to the console in GitHub Actions.' Since this is a test file, consider using the test framework's logging capabilities or a structured logger.

Suggested change
console.log("Generating Terraform plan...");
console.log(JSON.stringify({ event: "Generating Terraform plan" }));

Copilot uses AI. Check for mistakes.
const planResult = await terraformContainer.exec([
"terraform",
"plan",
`-out=${planFileRelative}`,
"-var",
`moto_endpoint=http://${motoContainerName}:5000`,
"-no-color",
]);
console.log("Plan exit code:", planResult.exitCode);
if (planResult.exitCode !== 0) {
console.error("Plan output:", planResult.output);
throw new Error(`Terraform plan failed: ${planResult.output}`);
}

// Convert plan to JSON
console.log("Converting plan to JSON...");
Copy link

Copilot AI Nov 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using console.log for test output violates the operational convention stated in the coding guidelines: 'ensure all application logs are structured as JSON objects, except when logging to the console in GitHub Actions.' Since this is a test file, consider using the test framework's logging capabilities or a structured logger.

Copilot uses AI. Check for mistakes.
const showResult = await terraformContainer.exec([
"terraform",
"show",
"-json",
planFileRelative,
]);
console.log("Show exit code:", showResult.exitCode);
if (showResult.exitCode !== 0) {
console.error("Show output:", showResult.output);
throw new Error(`Terraform show failed: ${showResult.output}`);
}

// Write the JSON output to file
await fs.writeFile(planJsonFile, showResult.output);
} catch (error) {
console.error("Setup failed:", error);
Copy link

Copilot AI Nov 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using console.error violates the operational convention stated in the coding guidelines: 'ensure all application logs are structured as JSON objects, except when logging to the console in GitHub Actions.' Since this is a test file, consider using the test framework's logging capabilities or a structured logger.

Copilot uses AI. Check for mistakes.
throw error;
}
}, 120000); // 2 minute timeout for setup

afterAll(async () => {
// Cleanup
console.log("Cleaning up...");
Copy link

Copilot AI Nov 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using console.log for test output violates the operational convention stated in the coding guidelines: 'ensure all application logs are structured as JSON objects, except when logging to the console in GitHub Actions.' Since this is a test file, consider using the test framework's logging capabilities or a structured logger.

Suggested change
console.log("Cleaning up...");
console.log(JSON.stringify({ event: "cleanup", message: "Cleaning up..." }));

Copilot uses AI. Check for mistakes.

// Have terraform container clean up its own files with proper permissions
try {
if (terraformContainer) {
console.log("Cleaning up terraform files from container...");
await terraformContainer.exec([
"sh",
"-c",
"rm -rf /terraform/.terraform /terraform/plan.bin /terraform/plan.json /terraform/.terraform.lock.hcl",
]);
console.log("Terraform files cleaned from container");
}
} catch (error) {
console.warn("Failed to cleanup from container:", error);
}

// Stop and remove terraform container
try {
if (terraformContainer) {
await terraformContainer.stop();
console.log("Terraform container stopped");
}
} catch (error) {
console.error("Failed to stop terraform container:", error);
}

// Stop and remove moto container
try {
if (motoContainer) {
await motoContainer.stop();
console.log("Moto container stopped");
}
} catch (error) {
console.error("Failed to stop moto container:", error);
}

// Stop and remove network
try {
if (network) {
await network.stop();
console.log("Network stopped");
}
} catch (error) {
console.error("Failed to stop network:", error);
}

// Remove plan files from host
try {
if (existsSync(planFile)) {
unlinkSync(planFile);
}
if (existsSync(planJsonFile)) {
unlinkSync(planJsonFile);
}
} catch (error) {
console.error("Failed to cleanup plan files:", error);
Copy link

Copilot AI Nov 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using console.error violates the operational convention stated in the coding guidelines: 'ensure all application logs are structured as JSON objects, except when logging to the console in GitHub Actions.' Since this is a test file, consider using the test framework's logging capabilities or a structured logger.

Suggested change
console.error("Failed to cleanup plan files:", error);
console.log(JSON.stringify({ level: "error", message: "Failed to cleanup plan files", error: error instanceof Error ? error.message : String(error) }));

Copilot uses AI. Check for mistakes.
}

// Remove .terraform directory
try {
const terraformStateDir = path.join(terraformDir, ".terraform");
if (existsSync(terraformStateDir)) {
rmSync(terraformStateDir, { recursive: true, force: true });
console.log(".terraform directory cleaned up");
}
} catch (error) {
// Log error but don't fail - permissions issues in CI can be expected
console.warn("Failed to cleanup .terraform directory:", error);
}

// Remove lock file
try {
const lockFile = path.join(terraformDir, ".terraform.lock.hcl");
if (existsSync(lockFile)) {
unlinkSync(lockFile);
}
} catch (error) {
console.error("Failed to cleanup lock file:", error);
Copy link

Copilot AI Nov 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using console.error violates the operational convention stated in the coding guidelines: 'ensure all application logs are structured as JSON objects, except when logging to the console in GitHub Actions.' Since this is a test file, consider using the test framework's logging capabilities or a structured logger.

Copilot uses AI. Check for mistakes.
}
Comment on lines +129 to +210
Copy link

Copilot AI Nov 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The cleanup logic in the afterAll hook has multiple try-catch blocks that swallow errors. While some failures during cleanup are acceptable (as noted in comments), the current approach makes it difficult to distinguish between expected and unexpected failures. Consider consolidating cleanup logic and using more specific error handling to ensure critical cleanup steps don't fail silently.

Copilot uses AI. Check for mistakes.
}, 60000); // 1 minute timeout for cleanup

it("should parse real Terraform plan output", async () => {
// Verify plan JSON file was created
expect(existsSync(planJsonFile)).toBe(true);

// Read the plan file
const fileReader = new FilesystemAdapter();
const readUseCase = new ReadPlanFileUseCase(fileReader);
const readResult = await readUseCase.execute(planJsonFile);

expect(readResult.path).toBe(planJsonFile);
expect(readResult.content).toBeTruthy();
expect(readResult.content.length).toBeGreaterThan(0);

// Parse the plan
const parseUseCase = new ParsePlanUseCase();
const plan = await parseUseCase.parse(readResult.content);

// Verify plan structure
expect(plan).toBeDefined();
expect(plan.formatVersion).toBeTruthy();
expect(plan.terraformVersion).toBeTruthy();
expect(plan.resourceChanges).toBeDefined();
expect(Array.isArray(plan.resourceChanges)).toBe(true);

// Verify we have the expected SQS queue resource change
expect(plan.resourceChanges.length).toBeGreaterThan(0);

const sqsQueue = plan.resourceChanges.find(
(rc) => rc.type === "aws_sqs_queue" && rc.name === "queue",
);

expect(sqsQueue).toBeDefined();
expect(sqsQueue?.address).toBe("aws_sqs_queue.queue");
expect(sqsQueue?.actions).toContain("create");

// Verify the queue has expected configuration
expect(sqsQueue?.after).toBeDefined();
expect(sqsQueue?.after?.name).toBe("test-queue");
expect(sqsQueue?.after?.tags).toBeDefined();
});
});
3 changes: 3 additions & 0 deletions e2e/terraform/backend.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
terraform {
backend "local" {}
}
12 changes: 12 additions & 0 deletions e2e/terraform/provider.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
provider "aws" {
region = "us-east-1"
skip_credentials_validation = true
skip_metadata_api_check = true
skip_requesting_account_id = true

endpoints {
sts = var.moto_endpoint
s3 = var.moto_endpoint
sqs = var.moto_endpoint
}
}
7 changes: 7 additions & 0 deletions e2e/terraform/sqs.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
resource "aws_sqs_queue" "queue" {
name = "test-queue"
tags = {
github = "https://github.com/zpratt/infra-diff.git"
random = 1234
}
}
5 changes: 5 additions & 0 deletions e2e/terraform/variables.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
variable "moto_endpoint" {
type = string
description = "The endpoint for the moto server"
default = "http://localhost:5000"
}
16 changes: 16 additions & 0 deletions features/end-to-end-with-terraform.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# End to End with Terraform

Goal: Demonstrate how Infra Diff can be used in an end-to-end workflow with Terraform and moto to provide a fake of the AWS API. The goal is to produce a real binary terraform plan, convert it to json, and then run infra-diff against that json plan.
Copy link

Copilot AI Nov 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The feature description mentions using "moto to provide a fake of the AWS API" and creating infrastructure on AWS, but then describes using moto to "mock" the AWS API. The terminology is inconsistent. Consider using consistent terminology (either "mock" or "fake") throughout the document to avoid confusion.

Suggested change
Goal: Demonstrate how Infra Diff can be used in an end-to-end workflow with Terraform and moto to provide a fake of the AWS API. The goal is to produce a real binary terraform plan, convert it to json, and then run infra-diff against that json plan.
Goal: Demonstrate how Infra Diff can be used in an end-to-end workflow with Terraform and moto to mock the AWS API. The goal is to produce a real binary terraform plan, convert it to json, and then run infra-diff against that json plan.

Copilot uses AI. Check for mistakes.

## Context

In this workflow, we will use Terraform to create a simple infrastructure setup on AWS. We will use moto to mock the AWS API, allowing us to generate a binary terraform plan without needing access to a real AWS account. This plan will then be converted to JSON format and analyzed using Infra Diff to identify any potential changes or issues. The test itself should be implemented in typescript, using the infra-diff library to run the analysis programmatically, treating the production code as if it is a complete black box. We should run moto in a docker container to ensure a clean and isolated environment for the test. Everything that is created should be runnable locally with a single command and should also be included in our CI pipeline to ensure consistent results across different environments. I've added example terraform fixtures in the e2e/terraform directory to get started. Ensure you examine the e2e/terraform/provider.tf file to see how to configure terraform to point at the moto server.

## Requirements

- Ensure the pipeline uses setup-terraform action to install terraform
- Ensure we're testing with the latest version of terraform
Copy link

Copilot AI Nov 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The requirement states "Ensure we're testing with the latest version of terraform," but version 1.13.4 is specified. This creates ambiguity - should the version be pinned (as is done) or should it actually use the latest version? Consider clarifying this requirement to reflect the actual implementation (using a pinned version for consistency) rather than "latest."

Suggested change
- Ensure we're testing with the latest version of terraform
- Ensure we're testing with a pinned version of terraform (currently 1.13.4) for consistency and reproducibility

Copilot uses AI. Check for mistakes.
- Use moto server in a docker container to mock AWS API
- When running locally, create a devcontainer environment that we can use to run the tests with a specific version of node and terraform.
- Write the test in typescript using infra-diff library to analyze the terraform plan json output.
- Ensure the entire setup can be run with a single command both locally and in CI.
Loading
Loading