@@ -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
2931fn C.boj_catalogue_init () int
3032fn 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
13191346fn 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