Skip to content
Merged
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
15 changes: 14 additions & 1 deletion .github/workflows/greenops-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ on:
- 'cli.ts'
- 'factors.json'
- 'dist/index.cjs'
- 'fixtures/**'
- '.github/workflows/greenops-e2e.yml'

jobs:
Expand All @@ -20,8 +21,20 @@ jobs:
steps:
- uses: actions/checkout@v4

- name: Run GreenOps against fixture plan
- name: Run GreenOps — AWS fixture
uses: ./
with:
plan-file: fixtures/tfplan.e2e.json
github-token: ${{ secrets.GITHUB_TOKEN }}

- name: Run GreenOps — Azure fixture
uses: ./
with:
plan-file: fixtures/tfplan.azure.e2e.json
github-token: ${{ secrets.GITHUB_TOKEN }}

- name: Run GreenOps — GCP fixture
uses: ./
with:
plan-file: fixtures/tfplan.gcp.e2e.json
github-token: ${{ secrets.GITHUB_TOKEN }}
14 changes: 7 additions & 7 deletions dist/index.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -2986,11 +2986,11 @@ function formatTable(result2) {
return out + `No compatible infrastructure detected.
`;
}
out += `\u250C${"\u2500".repeat(38)}\u252C${"\u2500".repeat(13)}\u252C${"\u2500".repeat(13)}\u252C${"\u2500".repeat(11)}\u252C${"\u2500".repeat(11)}\u252C${"\u2500".repeat(9)}\u252C${"\u2500".repeat(13)}\u2510
out += `\u250C${"\u2500".repeat(38)}\u252C${"\u2500".repeat(20)}\u252C${"\u2500".repeat(16)}\u252C${"\u2500".repeat(11)}\u252C${"\u2500".repeat(11)}\u252C${"\u2500".repeat(9)}\u252C${"\u2500".repeat(13)}\u2510
`;
out += `\u2502 ${truncate("Resource", 36)} \u2502 ${truncate("Instance", 11)} \u2502 ${truncate("Region", 11)} \u2502 ${truncate("Scope 2", 9)} \u2502 ${truncate("Scope 3", 9)} \u2502 ${truncate("Water", 7)} \u2502 ${truncate("Action", 11)} \u2502
out += `\u2502 ${truncate("Resource", 36)} \u2502 ${truncate("Instance", 18)} \u2502 ${truncate("Region", 14)} \u2502 ${truncate("Scope 2", 9)} \u2502 ${truncate("Scope 3", 9)} \u2502 ${truncate("Water", 7)} \u2502 ${truncate("Action", 11)} \u2502
`;
out += `\u251C${"\u2500".repeat(38)}\u253C${"\u2500".repeat(13)}\u253C${"\u2500".repeat(13)}\u253C${"\u2500".repeat(11)}\u253C${"\u2500".repeat(11)}\u253C${"\u2500".repeat(9)}\u253C${"\u2500".repeat(13)}\u2524
out += `\u251C${"\u2500".repeat(38)}\u253C${"\u2500".repeat(20)}\u253C${"\u2500".repeat(16)}\u253C${"\u2500".repeat(11)}\u253C${"\u2500".repeat(11)}\u253C${"\u2500".repeat(9)}\u253C${"\u2500".repeat(13)}\u2524
`;
const analysed = result2.resources.filter((r) => r.baseline.confidence !== "LOW_ASSUMED_DEFAULT");
const unsupportedResources = result2.resources.filter((r) => r.baseline.confidence === "LOW_ASSUMED_DEFAULT");
Expand All @@ -2999,18 +2999,18 @@ function formatTable(result2) {
const scope3 = formatGrams(r.baseline.embodiedCo2eGramsPerMonth);
const water = formatWater2(r.baseline.waterLitresPerMonth);
const action = r.recommendation ? `\x1B[33mUPGRADE\x1B[0m` : `\x1B[32mOK\x1B[0m`;
out += `\u2502 ${truncate(r.input.resourceId, 36)} \u2502 ${truncate(r.input.instanceType, 11)} \u2502 ${truncate(r.input.region, 11)} \u2502 ${truncate(scope2, 9)} \u2502 ${truncate(scope3, 9)} \u2502 ${truncate(water, 7)} \u2502 ${truncate(action, 11)} \u2502
out += `\u2502 ${truncate(r.input.resourceId, 36)} \u2502 ${truncate(r.input.instanceType, 18)} \u2502 ${truncate(r.input.region, 14)} \u2502 ${truncate(scope2, 9)} \u2502 ${truncate(scope3, 9)} \u2502 ${truncate(water, 7)} \u2502 ${truncate(action, 11)} \u2502
`;
}
for (const s of result2.skipped) {
out += `\u2502 \x1B[90m${truncate(s.resourceId, 36)}\x1B[0m \u2502 \x1B[90m${truncate("---", 11)}\x1B[0m \u2502 \x1B[90m${truncate("---", 11)}\x1B[0m \u2502 \x1B[90m${truncate("---", 9)}\x1B[0m \u2502 \x1B[90m${truncate("---", 9)}\x1B[0m \u2502 \x1B[90m${truncate("---", 7)}\x1B[0m \u2502 \x1B[33m${truncate("\u26A0 SKIPPED", 11)}\x1B[0m \u2502
out += `\u2502 \x1B[90m${truncate(s.resourceId, 36)}\x1B[0m \u2502 \x1B[90m${truncate("---", 18)}\x1B[0m \u2502 \x1B[90m${truncate("---", 14)}\x1B[0m \u2502 \x1B[90m${truncate("---", 9)}\x1B[0m \u2502 \x1B[90m${truncate("---", 9)}\x1B[0m \u2502 \x1B[90m${truncate("---", 7)}\x1B[0m \u2502 \x1B[33m${truncate("\u26A0 SKIPPED", 11)}\x1B[0m \u2502
`;
}
for (const r of unsupportedResources) {
out += `\u2502 \x1B[90m${truncate(r.input.resourceId, 36)}\x1B[0m \u2502 \x1B[90m${truncate(r.input.instanceType, 11)}\x1B[0m \u2502 \x1B[90m${truncate(r.input.region, 11)}\x1B[0m \u2502 \x1B[90m${truncate("---", 9)}\x1B[0m \u2502 \x1B[90m${truncate("---", 9)}\x1B[0m \u2502 \x1B[90m${truncate("---", 7)}\x1B[0m \u2502 \x1B[33m${truncate("\u26A0 UNKNOWN", 11)}\x1B[0m \u2502
out += `\u2502 \x1B[90m${truncate(r.input.resourceId, 36)}\x1B[0m \u2502 \x1B[90m${truncate(r.input.instanceType, 18)}\x1B[0m \u2502 \x1B[90m${truncate(r.input.region, 14)}\x1B[0m \u2502 \x1B[90m${truncate("---", 9)}\x1B[0m \u2502 \x1B[90m${truncate("---", 9)}\x1B[0m \u2502 \x1B[90m${truncate("---", 7)}\x1B[0m \u2502 \x1B[33m${truncate("\u26A0 UNKNOWN", 11)}\x1B[0m \u2502
`;
}
out += `\u2514${"\u2500".repeat(38)}\u2534${"\u2500".repeat(13)}\u2534${"\u2500".repeat(13)}\u2534${"\u2500".repeat(11)}\u2534${"\u2500".repeat(11)}\u2534${"\u2500".repeat(9)}\u2534${"\u2500".repeat(13)}\u2518
out += `\u2514${"\u2500".repeat(38)}\u2534${"\u2500".repeat(20)}\u2534${"\u2500".repeat(16)}\u2534${"\u2500".repeat(11)}\u2534${"\u2500".repeat(11)}\u2534${"\u2500".repeat(9)}\u2534${"\u2500".repeat(13)}\u2518

`;
out += `Scope 2: ${formatGrams(result2.totals.currentCo2eGramsPerMonth)} | Scope 3: ${formatGrams(result2.totals.currentEmbodiedCo2eGramsPerMonth)} | Lifecycle: ${formatGrams(result2.totals.currentLifecycleCo2eGramsPerMonth)}
Expand Down
134 changes: 134 additions & 0 deletions fixtures/tfplan.azure.e2e.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
{
"format_version": "1.2",
"terraform_version": "1.7.5",
"planned_values": {
"root_module": {
"resources": [
{
"address": "azurerm_linux_virtual_machine.api",
"mode": "managed",
"type": "azurerm_linux_virtual_machine",
"name": "api",
"provider_name": "registry.terraform.io/hashicorp/azurerm",
"schema_version": 0,
"values": {
"admin_username": "adminuser",
"location": "eastus",
"name": "greenops-e2e-api",
"resource_group_name": "greenops-e2e-rg",
"size": "Standard_D2s_v3",
"tags": { "environment": "e2e" }
}
},
{
"address": "azurerm_linux_virtual_machine.worker",
"mode": "managed",
"type": "azurerm_linux_virtual_machine",
"name": "worker",
"provider_name": "registry.terraform.io/hashicorp/azurerm",
"schema_version": 0,
"values": {
"admin_username": "adminuser",
"location": "swedencentral",
"name": "greenops-e2e-worker",
"resource_group_name": "greenops-e2e-rg",
"size": "Standard_D4s_v3",
"tags": { "environment": "e2e" }
}
}
]
}
},
"resource_changes": [
{
"address": "azurerm_linux_virtual_machine.api",
"mode": "managed",
"type": "azurerm_linux_virtual_machine",
"name": "api",
"provider_name": "registry.terraform.io/hashicorp/azurerm",
"change": {
"actions": ["create"],
"before": null,
"after": {
"admin_username": "adminuser",
"location": "eastus",
"name": "greenops-e2e-api",
"resource_group_name": "greenops-e2e-rg",
"size": "Standard_D2s_v3",
"tags": { "environment": "e2e" }
},
"after_unknown": { "id": true, "private_ip_address": true, "public_ip_address": true, "virtual_machine_id": true },
"before_sensitive": false,
"after_sensitive": { "admin_password": true }
}
},
{
"address": "azurerm_linux_virtual_machine.worker",
"mode": "managed",
"type": "azurerm_linux_virtual_machine",
"name": "worker",
"provider_name": "registry.terraform.io/hashicorp/azurerm",
"change": {
"actions": ["create"],
"before": null,
"after": {
"admin_username": "adminuser",
"location": "swedencentral",
"name": "greenops-e2e-worker",
"resource_group_name": "greenops-e2e-rg",
"size": "Standard_D4s_v3",
"tags": { "environment": "e2e" }
},
"after_unknown": { "id": true, "private_ip_address": true, "public_ip_address": true, "virtual_machine_id": true },
"before_sensitive": false,
"after_sensitive": { "admin_password": true }
}
}
],
"configuration": {
"provider_config": {
"azurerm": {
"name": "azurerm",
"full_name": "registry.terraform.io/hashicorp/azurerm",
"version_constraint": "~> 3.0",
"expressions": {}
}
},
"root_module": {
"resources": [
{
"address": "azurerm_linux_virtual_machine.api",
"mode": "managed",
"type": "azurerm_linux_virtual_machine",
"name": "api",
"provider_config_key": "azurerm",
"expressions": {
"admin_username": { "constant_value": "adminuser" },
"location": { "constant_value": "eastus" },
"name": { "constant_value": "greenops-e2e-api" },
"resource_group_name": { "constant_value": "greenops-e2e-rg" },
"size": { "constant_value": "Standard_D2s_v3" }
},
"schema_version": 0
},
{
"address": "azurerm_linux_virtual_machine.worker",
"mode": "managed",
"type": "azurerm_linux_virtual_machine",
"name": "worker",
"provider_config_key": "azurerm",
"expressions": {
"admin_username": { "constant_value": "adminuser" },
"location": { "constant_value": "swedencentral" },
"name": { "constant_value": "greenops-e2e-worker" },
"resource_group_name": { "constant_value": "greenops-e2e-rg" },
"size": { "constant_value": "Standard_D4s_v3" }
},
"schema_version": 0
}
]
}
},
"timestamp": "2026-03-28T12:00:00Z",
"errored": false
}
129 changes: 129 additions & 0 deletions fixtures/tfplan.gcp.e2e.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
{
"format_version": "1.2",
"terraform_version": "1.7.5",
"planned_values": {
"root_module": {
"resources": [
{
"address": "google_compute_instance.api",
"mode": "managed",
"type": "google_compute_instance",
"name": "api",
"provider_name": "registry.terraform.io/hashicorp/google",
"schema_version": 6,
"values": {
"name": "greenops-e2e-api",
"machine_type": "n2-standard-2",
"zone": "us-central1-a",
"tags": ["e2e"],
"labels": { "environment": "e2e" }
}
},
{
"address": "google_compute_instance.worker",
"mode": "managed",
"type": "google_compute_instance",
"name": "worker",
"provider_name": "registry.terraform.io/hashicorp/google",
"schema_version": 6,
"values": {
"name": "greenops-e2e-worker",
"machine_type": "t2a-standard-2",
"zone": "europe-north1-a",
"tags": ["e2e"],
"labels": { "environment": "e2e" }
}
}
]
}
},
"resource_changes": [
{
"address": "google_compute_instance.api",
"mode": "managed",
"type": "google_compute_instance",
"name": "api",
"provider_name": "registry.terraform.io/hashicorp/google",
"change": {
"actions": ["create"],
"before": null,
"after": {
"name": "greenops-e2e-api",
"machine_type": "n2-standard-2",
"zone": "us-central1-a",
"tags": ["e2e"],
"labels": { "environment": "e2e" }
},
"after_unknown": { "id": true, "instance_id": true, "network_interface": true, "self_link": true },
"before_sensitive": false,
"after_sensitive": {}
}
},
{
"address": "google_compute_instance.worker",
"mode": "managed",
"type": "google_compute_instance",
"name": "worker",
"provider_name": "registry.terraform.io/hashicorp/google",
"change": {
"actions": ["create"],
"before": null,
"after": {
"name": "greenops-e2e-worker",
"machine_type": "t2a-standard-2",
"zone": "europe-north1-a",
"tags": ["e2e"],
"labels": { "environment": "e2e" }
},
"after_unknown": { "id": true, "instance_id": true, "network_interface": true, "self_link": true },
"before_sensitive": false,
"after_sensitive": {}
}
}
],
"configuration": {
"provider_config": {
"google": {
"name": "google",
"full_name": "registry.terraform.io/hashicorp/google",
"version_constraint": "~> 5.0",
"expressions": {
"project": { "constant_value": "greenops-e2e-project" },
"region": { "constant_value": "us-central1" }
}
}
},
"root_module": {
"resources": [
{
"address": "google_compute_instance.api",
"mode": "managed",
"type": "google_compute_instance",
"name": "api",
"provider_config_key": "google",
"expressions": {
"name": { "constant_value": "greenops-e2e-api" },
"machine_type": { "constant_value": "n2-standard-2" },
"zone": { "constant_value": "us-central1-a" }
},
"schema_version": 6
},
{
"address": "google_compute_instance.worker",
"mode": "managed",
"type": "google_compute_instance",
"name": "worker",
"provider_config_key": "google",
"expressions": {
"name": { "constant_value": "greenops-e2e-worker" },
"machine_type": { "constant_value": "t2a-standard-2" },
"zone": { "constant_value": "europe-north1-a" }
},
"schema_version": 6
}
]
}
},
"timestamp": "2026-03-28T12:00:00Z",
"errored": false
}
14 changes: 14 additions & 0 deletions formatters/table.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,20 @@ describe('formatTable', () => {
assert.ok(table.includes('SKIPPED'), 'Should show SKIPPED for skipped resources');
});

it('shows Azure instance names without truncation (Standard_D2s_v3 fits in 18-char column)', () => {
const result = makeMockResult({
resources: [{
input: { resourceId: 'azurerm_linux_virtual_machine.api', instanceType: 'Standard_D2s_v3', region: 'eastus', provider: 'azure' as const },
baseline: makeMockBaseline(),
recommendation: null,
}],
totals: makeMockTotals({ currentCo2eGramsPerMonth: 1000 }),
});
const table = formatTable(result);
assert.ok(table.includes('Standard_D2s_v3'), 'Standard_D2s_v3 should not be truncated in the 18-char column');
assert.ok(!table.includes('Standard_...'), 'Should not truncate Standard_ names to Standard_...');
});

it('shows UNKNOWN marker for LOW_ASSUMED_DEFAULT resources instead of OK/UPGRADE', () => {
const result = makeMockResult({
resources: [
Expand Down
14 changes: 7 additions & 7 deletions formatters/table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@ export function formatTable(result: PlanAnalysisResult): string {
return out + `No compatible infrastructure detected.\n`;
}

out += `┌${'─'.repeat(38)}┬${'─'.repeat(13)}┬${'─'.repeat(13)}┬${'─'.repeat(11)}┬${'─'.repeat(11)}┬${'─'.repeat(9)}┬${'─'.repeat(13)}┐\n`;
out += `│ ${truncate('Resource', 36)} │ ${truncate('Instance', 11)} │ ${truncate('Region', 11)} │ ${truncate('Scope 2', 9)} │ ${truncate('Scope 3', 9)} │ ${truncate('Water', 7)} │ ${truncate('Action', 11)} │\n`;
out += `├${'─'.repeat(38)}┼${'─'.repeat(13)}┼${'─'.repeat(13)}┼${'─'.repeat(11)}┼${'─'.repeat(11)}┼${'─'.repeat(9)}┼${'─'.repeat(13)}┤\n`;
out += `┌${'─'.repeat(38)}┬${'─'.repeat(20)}┬${'─'.repeat(16)}┬${'─'.repeat(11)}┬${'─'.repeat(11)}┬${'─'.repeat(9)}┬${'─'.repeat(13)}┐\n`;
out += `│ ${truncate('Resource', 36)} │ ${truncate('Instance', 18)} │ ${truncate('Region', 14)} │ ${truncate('Scope 2', 9)} │ ${truncate('Scope 3', 9)} │ ${truncate('Water', 7)} │ ${truncate('Action', 11)} │\n`;
out += `├${'─'.repeat(38)}┼${'─'.repeat(20)}┼${'─'.repeat(16)}┼${'─'.repeat(11)}┼${'─'.repeat(11)}┼${'─'.repeat(9)}┼${'─'.repeat(13)}┤\n`;

// Separate analysed resources from LOW_ASSUMED_DEFAULT (unsupported instance/region)
const analysed = result.resources.filter(r => r.baseline.confidence !== 'LOW_ASSUMED_DEFAULT');
Expand All @@ -32,17 +32,17 @@ export function formatTable(result: PlanAnalysisResult): string {
const scope3 = formatGrams(r.baseline.embodiedCo2eGramsPerMonth);
const water = formatWater(r.baseline.waterLitresPerMonth);
const action = r.recommendation ? `\x1b[33mUPGRADE\x1b[0m` : `\x1b[32mOK\x1b[0m`;
out += `│ ${truncate(r.input.resourceId, 36)} │ ${truncate(r.input.instanceType, 11)} │ ${truncate(r.input.region, 11)} │ ${truncate(scope2, 9)} │ ${truncate(scope3, 9)} │ ${truncate(water, 7)} │ ${truncate(action, 11)} │\n`;
out += `│ ${truncate(r.input.resourceId, 36)} │ ${truncate(r.input.instanceType, 18)} │ ${truncate(r.input.region, 14)} │ ${truncate(scope2, 9)} │ ${truncate(scope3, 9)} │ ${truncate(water, 7)} │ ${truncate(action, 11)} │\n`;
}
// Skipped: known_after_apply and other runtime-unresolvable resources
for (const s of result.skipped) {
out += `│ \x1b[90m${truncate(s.resourceId, 36)}\x1b[0m │ \x1b[90m${truncate('---', 11)}\x1b[0m │ \x1b[90m${truncate('---', 11)}\x1b[0m │ \x1b[90m${truncate('---', 9)}\x1b[0m │ \x1b[90m${truncate('---', 9)}\x1b[0m │ \x1b[90m${truncate('---', 7)}\x1b[0m │ \x1b[33m${truncate('⚠ SKIPPED', 11)}\x1b[0m │\n`;
out += `│ \x1b[90m${truncate(s.resourceId, 36)}\x1b[0m │ \x1b[90m${truncate('---', 18)}\x1b[0m │ \x1b[90m${truncate('---', 14)}\x1b[0m │ \x1b[90m${truncate('---', 9)}\x1b[0m │ \x1b[90m${truncate('---', 9)}\x1b[0m │ \x1b[90m${truncate('---', 7)}\x1b[0m │ \x1b[33m${truncate('⚠ SKIPPED', 11)}\x1b[0m │\n`;
}
// Skipped: unsupported instance types not in the ledger
for (const r of unsupportedResources) {
out += `│ \x1b[90m${truncate(r.input.resourceId, 36)}\x1b[0m │ \x1b[90m${truncate(r.input.instanceType, 11)}\x1b[0m │ \x1b[90m${truncate(r.input.region, 11)}\x1b[0m │ \x1b[90m${truncate('---', 9)}\x1b[0m │ \x1b[90m${truncate('---', 9)}\x1b[0m │ \x1b[90m${truncate('---', 7)}\x1b[0m │ \x1b[33m${truncate('⚠ UNKNOWN', 11)}\x1b[0m │\n`;
out += `│ \x1b[90m${truncate(r.input.resourceId, 36)}\x1b[0m │ \x1b[90m${truncate(r.input.instanceType, 18)}\x1b[0m │ \x1b[90m${truncate(r.input.region, 14)}\x1b[0m │ \x1b[90m${truncate('---', 9)}\x1b[0m │ \x1b[90m${truncate('---', 9)}\x1b[0m │ \x1b[90m${truncate('---', 7)}\x1b[0m │ \x1b[33m${truncate('⚠ UNKNOWN', 11)}\x1b[0m │\n`;
}
out += `└${'─'.repeat(38)}┴${'─'.repeat(13)}┴${'─'.repeat(13)}┴${'─'.repeat(11)}┴${'─'.repeat(11)}┴${'─'.repeat(9)}┴${'─'.repeat(13)}┘\n\n`;
out += `└${'─'.repeat(38)}┴${'─'.repeat(20)}┴${'─'.repeat(16)}┴${'─'.repeat(11)}┴${'─'.repeat(11)}┴${'─'.repeat(9)}┴${'─'.repeat(13)}┘\n\n`;

out += `Scope 2: ${formatGrams(result.totals.currentCo2eGramsPerMonth)} | Scope 3: ${formatGrams(result.totals.currentEmbodiedCo2eGramsPerMonth)} | Lifecycle: ${formatGrams(result.totals.currentLifecycleCo2eGramsPerMonth)}\n`;
out += `Water: ${formatWater(result.totals.currentWaterLitresPerMonth)} | Cost: $${result.totals.currentCostUsdPerMonth.toFixed(2)}/month\n`;
Expand Down
Loading