Skip to content

Commit 98d9bf8

Browse files
hyperpolymathclaude
andcommitted
feat: wire Stapeln through container-mcp, add FFI state machine lifecycle
Container-mcp now integrates three layers: 1. Podman CLI tools (list, build, images, logs) 2. Zig FFI state machine (create, start, stop, remove, inspect) with formal lifecycle enforcement (None→Built→Created→Running→Stopped→Removed) 3. Stapeln API proxy (stapeln_stacks, stapeln_validate, stapeln_security, stapeln_gaps, stapeln_generate) at configurable STAPELN_URL Also links container_mcp.so in the adapter build. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 2949957 commit 98d9bf8

2 files changed

Lines changed: 223 additions & 3 deletions

File tree

Justfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -450,7 +450,7 @@ build-adapter: build
450450
#!/usr/bin/env bash
451451
set -euo pipefail
452452
echo "Building V-lang adapter..."
453-
v -cc gcc -cflags "-L$(pwd)/ffi/zig/zig-out/lib -Wl,--allow-multiple-definition" \
453+
v -cc gcc -cflags "-L$(pwd)/ffi/zig/zig-out/lib -L$(pwd)/cartridges/container-mcp/ffi/zig-out/lib -Wl,--allow-multiple-definition" \
454454
-o adapter/v/boj-server adapter/v/src/main.v
455455
echo "Built: adapter/v/boj-server ($(du -h adapter/v/boj-server | cut -f1))"
456456

adapter/v/src/main.v

Lines changed: 222 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ import time
2525
#flag -L../../ffi/zig/zig-out/lib
2626
#flag -lboj_catalogue
2727
#flag -lboj_loader
28+
#flag -L../../cartridges/container-mcp/ffi/zig-out/lib
29+
#flag -lcontainer_mcp
2830

2931
fn C.boj_catalogue_init() int
3032
fn C.boj_catalogue_deinit()
@@ -1314,9 +1316,35 @@ fn invoke_ssg(tool string, args string) http.Response {
13141316
return error_response(400, 'unknown ssg-mcp tool: "${tool}" — available: build, preview, list_engines')
13151317
}
13161318

1317-
// --- container-mcp: Podman container management ---
1319+
// --- container-mcp: Container lifecycle + Stapeln integration ---
1320+
// Combines three layers:
1321+
// 1. Podman CLI — direct container operations (list, build, images)
1322+
// 2. Zig FFI state machine — formal lifecycle tracking (slots, transitions)
1323+
// 3. Stapeln API — stack validation, security scanning, gap analysis, codegen
1324+
1325+
fn C.ctr_build(runtime int, image_name &u8) int
1326+
fn C.ctr_create(slot_idx int) int
1327+
fn C.ctr_start(slot_idx int) int
1328+
fn C.ctr_stop(slot_idx int) int
1329+
fn C.ctr_remove(slot_idx int) int
1330+
fn C.ctr_state(slot_idx int) int
1331+
fn C.ctr_can_transition(from int, to int) int
1332+
fn C.ctr_reset()
1333+
1334+
fn ctr_state_label(s int) string {
1335+
return match s {
1336+
0 { 'none' }
1337+
1 { 'built' }
1338+
2 { 'created' }
1339+
3 { 'running' }
1340+
4 { 'stopped' }
1341+
5 { 'removed' }
1342+
else { 'unknown' }
1343+
}
1344+
}
13181345

13191346
fn invoke_container(tool string, args string) http.Response {
1347+
// --- Podman CLI tools ---
13201348
if tool == 'list' {
13211349
result := os.execute('podman ps --format json 2>/dev/null')
13221350
status_str := if result.exit_code == 0 { 'ok' } else { 'error' }
@@ -1333,12 +1361,25 @@ fn invoke_container(tool string, args string) http.Response {
13331361
}
13341362
build_path := params['path'] or { '.' }
13351363
tag := params['tag'] or { 'boj-build:latest' }
1364+
runtime_name := params['runtime'] or { 'podman' }
13361365

1366+
// Allocate a state machine slot for lifecycle tracking
1367+
runtime_int := match runtime_name {
1368+
'podman' { 1 }
1369+
'nerdctl' { 2 }
1370+
'docker' { 3 }
1371+
else { 1 }
1372+
}
1373+
slot := C.ctr_build(runtime_int, tag.str)
1374+
1375+
// Actually build via CLI
13371376
result := os.execute('cd ${build_path} && podman build -t ${tag} -f Containerfile . 2>&1')
13381377
status_str := if result.exit_code == 0 { 'success' } else { 'failed' }
13391378
return json_response(json.encode({
13401379
'tool': 'build'
13411380
'tag': tag
1381+
'slot': slot.str()
1382+
'state': ctr_state_label(C.ctr_state(slot))
13421383
'exit_code': result.exit_code.str()
13431384
'output': result.output
13441385
'status': status_str
@@ -1353,7 +1394,186 @@ fn invoke_container(tool string, args string) http.Response {
13531394
'data': result.output
13541395
}))
13551396
}
1356-
return error_response(400, 'unknown container-mcp tool: "${tool}" — available: list, build, images')
1397+
1398+
// --- FFI state machine tools ---
1399+
if tool == 'create' {
1400+
params := json.decode(map[string]string, args) or {
1401+
return error_response(400, 'create requires {"slot": "0"}')
1402+
}
1403+
slot := (params['slot'] or { '0' }).int()
1404+
result := C.ctr_create(slot)
1405+
if result == -1 {
1406+
return error_response(404, 'slot ${slot} not active')
1407+
}
1408+
if result == -2 {
1409+
return error_response(409, 'cannot create from state: ${ctr_state_label(C.ctr_state(slot))}')
1410+
}
1411+
return json_response(json.encode({
1412+
'tool': 'create'
1413+
'slot': slot.str()
1414+
'state': ctr_state_label(C.ctr_state(slot))
1415+
'status': 'ok'
1416+
}))
1417+
}
1418+
if tool == 'start' {
1419+
params := json.decode(map[string]string, args) or {
1420+
return error_response(400, 'start requires {"slot": "0"}')
1421+
}
1422+
slot := (params['slot'] or { '0' }).int()
1423+
result := C.ctr_start(slot)
1424+
if result == -1 {
1425+
return error_response(404, 'slot ${slot} not active')
1426+
}
1427+
if result == -2 {
1428+
return error_response(409, 'cannot start from state: ${ctr_state_label(C.ctr_state(slot))}')
1429+
}
1430+
return json_response(json.encode({
1431+
'tool': 'start'
1432+
'slot': slot.str()
1433+
'state': ctr_state_label(C.ctr_state(slot))
1434+
'status': 'ok'
1435+
}))
1436+
}
1437+
if tool == 'stop' {
1438+
params := json.decode(map[string]string, args) or {
1439+
return error_response(400, 'stop requires {"slot": "0"}')
1440+
}
1441+
slot := (params['slot'] or { '0' }).int()
1442+
result := C.ctr_stop(slot)
1443+
if result == -1 {
1444+
return error_response(404, 'slot ${slot} not active')
1445+
}
1446+
if result == -2 {
1447+
return error_response(409, 'cannot stop from state: ${ctr_state_label(C.ctr_state(slot))}')
1448+
}
1449+
return json_response(json.encode({
1450+
'tool': 'stop'
1451+
'slot': slot.str()
1452+
'state': ctr_state_label(C.ctr_state(slot))
1453+
'status': 'ok'
1454+
}))
1455+
}
1456+
if tool == 'remove' {
1457+
params := json.decode(map[string]string, args) or {
1458+
return error_response(400, 'remove requires {"slot": "0"}')
1459+
}
1460+
slot := (params['slot'] or { '0' }).int()
1461+
result := C.ctr_remove(slot)
1462+
if result == -1 {
1463+
return error_response(404, 'slot ${slot} not active')
1464+
}
1465+
if result == -2 {
1466+
return error_response(409, 'cannot remove from state: ${ctr_state_label(C.ctr_state(slot))} — stop first')
1467+
}
1468+
return json_response(json.encode({
1469+
'tool': 'remove'
1470+
'slot': slot.str()
1471+
'state': ctr_state_label(C.ctr_state(slot))
1472+
'status': 'ok'
1473+
}))
1474+
}
1475+
if tool == 'inspect' {
1476+
params := json.decode(map[string]string, args) or {
1477+
return error_response(400, 'inspect requires {"slot": "0"}')
1478+
}
1479+
slot := (params['slot'] or { '0' }).int()
1480+
state := C.ctr_state(slot)
1481+
return json_response(json.encode({
1482+
'tool': 'inspect'
1483+
'slot': slot.str()
1484+
'state': ctr_state_label(state)
1485+
}))
1486+
}
1487+
if tool == 'logs' {
1488+
params := json.decode(map[string]string, args) or {
1489+
return error_response(400, 'logs requires {"name": "container-name"}')
1490+
}
1491+
name := params['name'] or { '' }
1492+
if name == '' {
1493+
return error_response(400, 'logs requires {"name": "container-name"}')
1494+
}
1495+
result := os.execute('podman logs ${name} 2>&1')
1496+
status_str := if result.exit_code == 0 { 'ok' } else { 'error' }
1497+
return json_response(json.encode({
1498+
'tool': 'logs'
1499+
'name': name
1500+
'status': status_str
1501+
'data': result.output
1502+
}))
1503+
}
1504+
1505+
// --- Stapeln integration tools ---
1506+
stapeln_url := os.getenv_opt('STAPELN_URL') or { 'http://localhost:4000' }
1507+
1508+
if tool == 'stapeln_stacks' {
1509+
resp := http.get('${stapeln_url}/api/stacks') or {
1510+
return error_response(502, 'stapeln not reachable at ${stapeln_url}: ${err.msg()}')
1511+
}
1512+
return json_response(json.encode({
1513+
'tool': 'stapeln_stacks'
1514+
'status': if resp.status_code == 200 { 'ok' } else { 'error' }
1515+
'data': resp.body
1516+
}))
1517+
}
1518+
if tool == 'stapeln_validate' {
1519+
params := json.decode(map[string]string, args) or {
1520+
return error_response(400, 'stapeln_validate requires {"stack_id": "1"}')
1521+
}
1522+
stack_id := params['stack_id'] or { '1' }
1523+
resp := http.post('${stapeln_url}/api/stacks/${stack_id}/validate', '') or {
1524+
return error_response(502, 'stapeln not reachable: ${err.msg()}')
1525+
}
1526+
return json_response(json.encode({
1527+
'tool': 'stapeln_validate'
1528+
'status': if resp.status_code == 200 { 'ok' } else { 'error' }
1529+
'data': resp.body
1530+
}))
1531+
}
1532+
if tool == 'stapeln_security' {
1533+
params := json.decode(map[string]string, args) or {
1534+
return error_response(400, 'stapeln_security requires {"stack_id": "1"}')
1535+
}
1536+
stack_id := params['stack_id'] or { '1' }
1537+
resp := http.post('${stapeln_url}/api/stacks/${stack_id}/security-scan', '') or {
1538+
return error_response(502, 'stapeln not reachable: ${err.msg()}')
1539+
}
1540+
return json_response(json.encode({
1541+
'tool': 'stapeln_security'
1542+
'status': if resp.status_code == 200 { 'ok' } else { 'error' }
1543+
'data': resp.body
1544+
}))
1545+
}
1546+
if tool == 'stapeln_gaps' {
1547+
params := json.decode(map[string]string, args) or {
1548+
return error_response(400, 'stapeln_gaps requires {"stack_id": "1"}')
1549+
}
1550+
stack_id := params['stack_id'] or { '1' }
1551+
resp := http.post('${stapeln_url}/api/stacks/${stack_id}/gap-analysis', '') or {
1552+
return error_response(502, 'stapeln not reachable: ${err.msg()}')
1553+
}
1554+
return json_response(json.encode({
1555+
'tool': 'stapeln_gaps'
1556+
'status': if resp.status_code == 200 { 'ok' } else { 'error' }
1557+
'data': resp.body
1558+
}))
1559+
}
1560+
if tool == 'stapeln_generate' {
1561+
params := json.decode(map[string]string, args) or {
1562+
return error_response(400, 'stapeln_generate requires {"stack_id": "1", "format": "docker_compose"}')
1563+
}
1564+
stack_id := params['stack_id'] or { '1' }
1565+
format := params['format'] or { 'all' }
1566+
resp := http.post('${stapeln_url}/api/stacks/${stack_id}/generate?format=${format}', '') or {
1567+
return error_response(502, 'stapeln not reachable: ${err.msg()}')
1568+
}
1569+
return json_response(json.encode({
1570+
'tool': 'stapeln_generate'
1571+
'format': format
1572+
'status': if resp.status_code == 200 { 'ok' } else { 'error' }
1573+
'data': resp.body
1574+
}))
1575+
}
1576+
return error_response(400, 'unknown container-mcp tool: "${tool}" — available: list, build, images, create, start, stop, remove, inspect, logs, stapeln_stacks, stapeln_validate, stapeln_security, stapeln_gaps, stapeln_generate')
13571577
}
13581578

13591579
// --- observe-mcp: Observability and feedback ---

0 commit comments

Comments
 (0)