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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,14 @@ What's provider-specific is only the curation layer, shipped as **provider packs
AWS ships first. Adding a GCP/Azure/other pack is pure data and makes a great
first PR.

## OpenTofu

stackcanvas works with [OpenTofu](https://opentofu.org) as a drop-in replacement
for Terraform: it looks for a `terraform` binary on `PATH` first, then falls
back to `tofu`. Override the choice with `--tf-bin <path>` on `stackcanvas serve`,
or set `STACKCANVAS_TF_BIN` (e.g. in your MCP client's `.mcp.json` `env` block)
to pin it — both take precedence over auto-detection.

## Tools

| Tool | Purpose |
Expand Down
2 changes: 2 additions & 0 deletions packages/mcp/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ async function main(): Promise<void> {
const dir = arg('dir') ?? process.cwd()
const fixture = arg('fixture')
const port = arg('port')
const tfBin = arg('tf-bin')
if (port !== undefined && Number.isNaN(Number(port))) {
console.error('invalid --port')
process.exit(1)
Expand All @@ -36,6 +37,7 @@ async function main(): Promise<void> {
uiDist,
port: port ? Number(port) : undefined,
runTerraformShow: fixture ? async () => readFileSync(fixture, 'utf8') : undefined,
tfBinary: tfBin,
telemetry,
})
const { url } = await server.start()
Expand Down
3 changes: 2 additions & 1 deletion packages/mcp/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@ export function createMcpServer(deps: McpDeps = {}): McpServer {
}
return ok(`Canvas running at ${url}. The graph live-updates as tfstate changes. `
+ 'Run `terraform plan -out=tfplan && terraform show -json tfplan > .stackcanvas/plan.json` '
+ 'to show the plan diff, then call await_canvas_intent to receive user edits.')
+ '(or `tofu plan …` with OpenTofu) to show the plan diff, then call await_canvas_intent '
+ 'to receive user edits.')
})

mcp.registerTool('load_plan', {
Expand Down
23 changes: 23 additions & 0 deletions packages/server/src/canvas-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,3 +248,26 @@ test('a refreshOnStart:false provider is never refreshed by start()/refreshGraph
live.pushSnapshot({ origin: 'fake-live', graph: fakeGraph('fake_thing.x'), stale: null })
expect(server.getGraph().nodes.some(n => n.id === 'fake_thing.x')).toBe(true)
})

// --- P2-12 OpenTofu binary resolution tests (issue #26) — new portRangeStart
// base per the distinct-base convention. tfBinary is passed explicit (used
// verbatim by resolveTfBinary, no probe), so this stays hermetic regardless
// of whether terraform/tofu are actually installed on the host. ---

test('tfBinary option flows through to TerraformProvider.binaryUsed and the /api/meta label', async () => {
server = new CanvasServer({ dir: makeDir(), tfBinary: 'sc-test-fake-binary', portRangeStart: 22680 })
const { url } = await server.start()
const meta = await (await fetch(`${url}/api/meta`)).json()
const tf = meta.providers.find((p: { origin: string }) => p.origin === 'terraform')
expect(tf.label).toBe(`Terraform (${server.dir}) via sc-test-fake-binary`)
})

test('without tfBinary and an injected runTerraformShow, the label carries no binary suffix (unchanged)', async () => {
server = new CanvasServer({
dir: makeDir(), runTerraformShow: async () => stateFixture, portRangeStart: 22680,
})
const { url } = await server.start()
const meta = await (await fetch(`${url}/api/meta`)).json()
const tf = meta.providers.find((p: { origin: string }) => p.origin === 'terraform')
expect(tf.label).toBe(`Terraform (${server.dir})`)
})
14 changes: 9 additions & 5 deletions packages/server/src/canvas-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
import { findPort } from './find-port.js'
import { IntentQueue } from './intent-queue.js'
import { nodesBucket, TelemetryClient } from './telemetry.js'
import { TerraformProvider, type TerraformShowRunner } from './providers/terraform.js'
import { binaryKind, TerraformProvider, type TerraformShowRunner } from './providers/terraform.js'

const intentSchema = z.object({
add: z.array(z.object({
Expand Down Expand Up @@ -46,6 +46,9 @@ export interface CanvasServerOptions {
/** First port findPort probes when no fixed port is given (default 4680).
* Tests use distinct bases so parallel suites never contend. */
portRangeStart?: number
/** Explicit terraform/tofu binary; forwarded to TerraformProvider (used
* verbatim, skipping PATH detection — see resolveTfBinary). */
tfBinary?: string
/** Injectable for tests; defaults to a TelemetryClient reading/writing
* ~/.stackcanvas/config.json (or STACKCANVAS_CONFIG_DIR if set). */
telemetry?: TelemetryClient
Expand Down Expand Up @@ -83,7 +86,7 @@ export class CanvasServer {
this.fixedPort = opts.port
this.portRangeStart = opts.portRangeStart ?? 4680
this.telemetry = opts.telemetry ?? new TelemetryClient({ appVersion: '0.1.0' })
this.tf = new TerraformProvider({ dir: opts.dir, runShow: opts.runTerraformShow })
this.tf = new TerraformProvider({ dir: opts.dir, runShow: opts.runTerraformShow, binary: opts.tfBinary })
this.providers = [this.tf]
this.extraProviders = opts.extraProviders ?? []
this.subscribe((graph, stale) => this.broadcast({ type: 'graph', graph, stale }))
Expand Down Expand Up @@ -329,12 +332,13 @@ export class CanvasServer {
// One canvas_opened per successful start() — covers both `stackcanvas
// serve` and the MCP open_canvas path (which calls start() once per new
// canvas and never re-calls it for a reused one, since start() throws on
// a second call). tf_bin degrades to 'unknown' until resolveTfBinary /
// TerraformProvider ships with the OpenTofu-detection increment.
// a second call). tf_bin reads TerraformProvider.binaryUsed (resolved by
// tf.init()/refreshGraph() above); it degrades to 'unknown' whenever
// binaryUsed is null — no binary detected, or runShow was injected.
this.telemetry.emit({
event: 'canvas_opened',
nodes_bucket: nodesBucket(this.graph.nodes.length),
tf_bin: 'unknown',
tf_bin: binaryKind(this.tf.binaryUsed),
})
// Pure sugar over addProvider(): a refreshOnStart provider is refreshed
// via addProvider's own conditional refresh, a refreshOnStart:false one
Expand Down
4 changes: 2 additions & 2 deletions packages/server/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
export { CanvasServer } from './canvas-server.js'
export type { CanvasServerOptions } from './canvas-server.js'
export { TerraformProvider, defaultRunner } from './providers/terraform.js'
export type { TerraformProviderOptions, TerraformShowRunner } from './providers/terraform.js'
export { binaryKind, createShowRunner, defaultRunner, resolveTfBinary, TerraformProvider } from './providers/terraform.js'
export type { ResolveTfBinaryOptions, TerraformProviderOptions, TerraformShowRunner } from './providers/terraform.js'
export { IntentQueue } from './intent-queue.js'
export { nodesBucket, TELEMETRY_SCHEMA_VERSION, TelemetryClient } from './telemetry.js'
export type {
Expand Down
148 changes: 146 additions & 2 deletions packages/server/src/providers/terraform.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, expect, test, vi } from 'vitest'
import type { ProviderSnapshot } from '@stackcanvas/core'
import { defaultRunner, TerraformProvider } from './terraform.js'
import { binaryKind, defaultRunner, resolveTfBinary, TerraformProvider } from './terraform.js'

const stateFixture = readFileSync(
new URL('../../../core/test/fixtures/state.json', import.meta.url), 'utf8',
Expand Down Expand Up @@ -157,3 +157,147 @@ test('dispose() is idempotent — calling it twice resolves cleanly', async () =
await provider.dispose()
await expect(provider.dispose()).resolves.toBeUndefined()
})

// ---------------------------------------------------------------------------
// resolveTfBinary / createShowRunner / binaryKind (P2-12, issue #26)
// ---------------------------------------------------------------------------

afterEach(() => {
vi.unstubAllEnvs()
})

test('resolveTfBinary: explicit argument wins over everything, without probing', async () => {
const probe = vi.fn(async () => true)
const bin = await resolveTfBinary('/custom/path/mytf', { probe })
expect(bin).toBe('/custom/path/mytf')
expect(probe).not.toHaveBeenCalled()
})

test('resolveTfBinary: STACKCANVAS_TF_BIN env wins over PATH probing', async () => {
vi.stubEnv('STACKCANVAS_TF_BIN', 'my-tofu-fork')
const probe = vi.fn(async () => true)
const bin = await resolveTfBinary(undefined, { probe })
expect(bin).toBe('my-tofu-fork')
expect(probe).not.toHaveBeenCalled()
})

test('resolveTfBinary: explicit argument wins over STACKCANVAS_TF_BIN too', async () => {
vi.stubEnv('STACKCANVAS_TF_BIN', 'env-bin')
const probe = vi.fn(async () => true)
const bin = await resolveTfBinary('explicit-bin', { probe })
expect(bin).toBe('explicit-bin')
expect(probe).not.toHaveBeenCalled()
})

test('resolveTfBinary: PATH fallback probes terraform before tofu, stopping on first pass', async () => {
const calls: string[] = []
const probe = vi.fn(async (bin: string) => { calls.push(bin); return bin === 'terraform' })
const bin = await resolveTfBinary(undefined, { probe })
expect(bin).toBe('terraform')
expect(calls).toEqual(['terraform']) // tofu never probed once terraform passes
})

test('resolveTfBinary: falls through to tofu when the terraform probe fails silently', async () => {
const calls: string[] = []
const probe = vi.fn(async (bin: string) => { calls.push(bin); return bin === 'tofu' })
const bin = await resolveTfBinary(undefined, { probe })
expect(bin).toBe('tofu')
expect(calls).toEqual(['terraform', 'tofu'])
})

test('resolveTfBinary: returns null when neither terraform nor tofu probes succeed', async () => {
const probe = vi.fn(async () => false)
const bin = await resolveTfBinary(undefined, { probe })
expect(bin).toBeNull()
})

test('resolveTfBinary performs a fresh probe on every call — no internal caching, so a later '
+ 'call recovers once a binary becomes available (re-probe recovery)', async () => {
let installed = false
const probe = vi.fn(async (bin: string) => installed && bin === 'terraform')
expect(await resolveTfBinary(undefined, { probe })).toBeNull()
installed = true
expect(await resolveTfBinary(undefined, { probe })).toBe('terraform')
})

test('binaryKind maps a resolved binary to terraform/tofu/unknown by basename', () => {
expect(binaryKind('terraform')).toBe('terraform')
expect(binaryKind('/usr/local/bin/terraform')).toBe('terraform')
expect(binaryKind('terraform.exe')).toBe('terraform')
expect(binaryKind('tofu')).toBe('tofu')
expect(binaryKind('/opt/homebrew/bin/tofu')).toBe('tofu')
expect(binaryKind('some-custom-wrapper')).toBe('unknown')
expect(binaryKind(null)).toBe('unknown')
})

// ---------------------------------------------------------------------------
// TerraformProvider binary detection wiring (P2-12) — hermetic PATH swap via
// real (fake) executables, so it exercises the real execFile probe/runner
// wiring without depending on terraform/tofu actually being installed.
// ---------------------------------------------------------------------------

function writeFakeBinary(dir: string, name: string): void {
const path = join(dir, name)
// Responds to `version` (the resolveTfBinary probe) and `show -json ...`
// (the runner) with just enough to satisfy parseState. Uses only shell
// builtins (echo/if/exit) — no external PATH-resolved commands — so it
// works even when PATH is stubbed down to just this fake-bin dir.
writeFileSync(path, [
'#!/bin/sh',
'if [ "$1" = "version" ]; then exit 0; fi',
'echo \'{"format_version":"1.0","values":{"root_module":{"resources":[]}}}\'',
'',
].join('\n'))
chmodSync(path, 0o755)
}

test('TerraformProvider: no injected runShow and no binary on PATH resolves to '
+ 'stale + binaryUsed null', async () => {
const dir = makeDir()
const emptyPathDir = mkdtempSync(join(tmpdir(), 'sc-emptypath-'))
vi.stubEnv('PATH', emptyPathDir) // hermetic: real terraform/tofu on this machine is unreachable
provider = new TerraformProvider({ dir })
const snap = await provider.refresh()
expect(provider.binaryUsed).toBeNull()
expect(snap.stale).toBe('No terraform or tofu binary found in PATH. Install one or set STACKCANVAS_TF_BIN.')
expect(provider.label).toBe(`Terraform (${dir})`)
}, 15000)

test('TerraformProvider: re-probes and recovers once a binary appears on PATH, no restart needed', async () => {
const dir = makeDir()
const emptyPathDir = mkdtempSync(join(tmpdir(), 'sc-emptypath-'))
vi.stubEnv('PATH', emptyPathDir)
provider = new TerraformProvider({ dir })
const first = await provider.refresh()
expect(first.stale).not.toBeNull()
expect(provider.binaryUsed).toBeNull()

const fakeBinDir = mkdtempSync(join(tmpdir(), 'sc-fakebin-'))
writeFakeBinary(fakeBinDir, 'terraform')
vi.stubEnv('PATH', fakeBinDir)

const second = await provider.refresh()
expect(second.stale).toBeNull()
expect(provider.binaryUsed).toBe('terraform')
expect(provider.label).toBe(`Terraform (${dir}) via terraform`)
}, 15000)

test('TerraformProvider: an injected runShow skips probing entirely — binaryUsed stays null', async () => {
const dir = makeDir()
provider = new TerraformProvider({ dir, runShow: async () => stateFixture })
await provider.refresh()
expect(provider.binaryUsed).toBeNull()
expect(provider.label).toBe(`Terraform (${dir})`)
})

test('TerraformProvider: an explicit binary option is used verbatim, skipping probing', async () => {
const dir = makeDir()
const fakeBinDir = mkdtempSync(join(tmpdir(), 'sc-fakebin-'))
writeFakeBinary(fakeBinDir, 'tofu')
vi.stubEnv('PATH', fakeBinDir)
provider = new TerraformProvider({ dir, binary: 'tofu' })
const snap = await provider.refresh()
expect(provider.binaryUsed).toBe('tofu')
expect(snap.stale).toBeNull()
expect(provider.label).toBe(`Terraform (${dir}) via tofu`)
})
Loading
Loading