From f466a2916e477fcde0773bd8b1673f79fb2fdee0 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Fri, 30 Jan 2026 08:35:38 -0500 Subject: [PATCH] feat(03-01): implement container matching and action execution - Add Docker List for Action node to get container list - Add Match Container node with fuzzy matching (substring, prefix stripping) - Add Check Match Count Switch node to route 0/1/>1 matches - Add Build Action Command node to construct curl POST command - Add Execute Action node to call Docker API start/stop/restart - Add Parse Action Result node handling 204/304 success and error codes - Add Send Action Result node for Telegram response - Add placeholder nodes for No Match and Multiple Matches branches - Use graceful ?t=10 timeout for stop/restart actions --- n8n-workflow.json | 341 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 339 insertions(+), 2 deletions(-) diff --git a/n8n-workflow.json b/n8n-workflow.json index b4d1fee..852e818 100644 --- a/n8n-workflow.json +++ b/n8n-workflow.json @@ -187,7 +187,7 @@ "name": "Format Echo", "type": "n8n-nodes-base.code", "typeVersion": 2, - "position": [900, 600] + "position": [900, 800] }, { "parameters": { @@ -203,7 +203,7 @@ "name": "Send Echo", "type": "n8n-nodes-base.telegram", "typeVersion": 1.2, - "position": [1120, 600], + "position": [1120, 800], "credentials": { "telegramApi": { "id": "telegram-credential", @@ -220,6 +220,229 @@ "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [900, 400] + }, + { + "parameters": { + "command": "curl -s --unix-socket /var/run/docker.sock 'http://localhost/v1.47/containers/json?all=true'", + "options": {} + }, + "id": "exec-docker-list-action", + "name": "Docker List for Action", + "type": "n8n-nodes-base.executeCommand", + "typeVersion": 1, + "position": [1120, 400] + }, + { + "parameters": { + "jsCode": "// Get Docker API response and action info from Parse Action\nconst dockerOutput = $input.item.json.stdout;\nconst actionData = $('Parse Action').item.json;\nconst action = actionData.action;\nconst containerQuery = actionData.containerQuery;\nconst chatId = actionData.chatId;\n\n// Parse JSON response\nlet containers;\ntry {\n if (!dockerOutput || dockerOutput.trim() === '') {\n throw new Error('Empty response');\n }\n containers = JSON.parse(dockerOutput);\n} catch (e) {\n return [{\n json: {\n matchCount: -1,\n error: true,\n chatId: chatId,\n text: \"Can't reach Docker - check if n8n has socket access\"\n }\n }];\n}\n\n// Function to normalize container names\nfunction normalizeName(name) {\n return name\n .replace(/^\\//, '') // Remove leading slash\n .replace(/^(linuxserver[-_]|binhex[-_])/i, '') // Remove common prefixes\n .toLowerCase();\n}\n\n// Find matching containers using fuzzy matching\nconst matches = containers.filter(c => {\n const containerName = normalizeName(c.Names[0]);\n const normalized = containerQuery.toLowerCase();\n return containerName.includes(normalized) || normalized.includes(containerName);\n});\n\n// Return match results with all necessary context\nreturn [{\n json: {\n matches: matches.map(c => ({\n Id: c.Id,\n Name: c.Names[0].replace(/^\\//, ''),\n State: c.State\n })),\n matchCount: matches.length,\n action: action,\n containerQuery: containerQuery,\n chatId: chatId\n }\n}];" + }, + "id": "code-match-container", + "name": "Match Container", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [1340, 400] + }, + { + "parameters": { + "rules": { + "values": [ + { + "id": "no-match", + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict" + }, + "conditions": [ + { + "id": "count-zero", + "leftValue": "={{ $json.matchCount }}", + "rightValue": "0", + "operator": { + "type": "number", + "operation": "equals" + } + } + ], + "combinator": "and" + }, + "renameOutput": false + }, + { + "id": "single-match", + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict" + }, + "conditions": [ + { + "id": "count-one", + "leftValue": "={{ $json.matchCount }}", + "rightValue": "1", + "operator": { + "type": "number", + "operation": "equals" + } + } + ], + "combinator": "and" + }, + "renameOutput": false + }, + { + "id": "multiple-matches", + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict" + }, + "conditions": [ + { + "id": "count-gt-one", + "leftValue": "={{ $json.matchCount }}", + "rightValue": "1", + "operator": { + "type": "number", + "operation": "gt" + } + } + ], + "combinator": "and" + }, + "renameOutput": false + } + ] + }, + "options": { + "fallbackOutput": "extra" + } + }, + "id": "switch-match-count", + "name": "Check Match Count", + "type": "n8n-nodes-base.switch", + "typeVersion": 3.2, + "position": [1560, 400] + }, + { + "parameters": { + "jsCode": "// Build the curl command for the action\nconst data = $input.item.json;\nconst containerId = data.matches[0].Id;\nconst action = data.action;\nconst containerName = data.matches[0].Name;\nconst chatId = data.chatId;\n\n// stop and restart use ?t=10 for graceful timeout\nconst timeout = (action === 'stop' || action === 'restart') ? '?t=10' : '';\n\n// Build curl command that returns HTTP status code\nconst cmd = `curl -s -o /dev/null -w \"%{http_code}\" --unix-socket /var/run/docker.sock -X POST 'http://localhost/v1.47/containers/${containerId}/${action}${timeout}'`;\n\nreturn {\n json: {\n cmd: cmd,\n containerId: containerId,\n containerName: containerName,\n action: action,\n chatId: chatId\n }\n};" + }, + "id": "code-build-action-cmd", + "name": "Build Action Command", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [1780, 400] + }, + { + "parameters": { + "command": "={{ $json.cmd }}", + "options": {} + }, + "id": "exec-action", + "name": "Execute Action", + "type": "n8n-nodes-base.executeCommand", + "typeVersion": 1, + "position": [2000, 400] + }, + { + "parameters": { + "jsCode": "// Parse the HTTP status code from curl output\nconst stdout = $input.item.json.stdout;\nconst stderr = $input.item.json.stderr;\nconst actionData = $('Build Action Command').item.json;\nconst containerName = actionData.containerName;\nconst action = actionData.action;\nconst chatId = actionData.chatId;\n\n// Check for curl-level errors first\nif (stderr && stderr.trim()) {\n return {\n json: {\n success: false,\n chatId: chatId,\n text: `Failed to ${action} ${containerName}: ${stderr.trim()}`\n }\n };\n}\n\nconst statusCode = parseInt(stdout.trim());\n\n// 204: Success, 304: Already in state (also success for user)\nif (statusCode === 204 || statusCode === 304) {\n const verb = action === 'start' ? 'started' :\n action === 'stop' ? 'stopped' : 'restarted';\n return {\n json: {\n success: true,\n chatId: chatId,\n text: `${containerName} ${verb} successfully`\n }\n };\n}\n\n// Handle error codes\nlet errorMsg;\nswitch (statusCode) {\n case 404:\n errorMsg = 'Container not found';\n break;\n case 409:\n errorMsg = 'Container is in a conflicting state';\n break;\n case 500:\n errorMsg = 'Docker server error';\n break;\n default:\n errorMsg = `HTTP ${statusCode}`;\n}\n\nreturn {\n json: {\n success: false,\n chatId: chatId,\n text: `Failed to ${action} ${containerName}: ${errorMsg}`\n }\n};" + }, + "id": "code-parse-action-result", + "name": "Parse Action Result", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [2220, 400] + }, + { + "parameters": { + "resource": "message", + "operation": "sendMessage", + "chatId": "={{ $json.chatId }}", + "text": "={{ $json.text }}", + "additionalFields": { + "parse_mode": "HTML" + } + }, + "id": "telegram-send-action-result", + "name": "Send Action Result", + "type": "n8n-nodes-base.telegram", + "typeVersion": 1.2, + "position": [2440, 400], + "credentials": { + "telegramApi": { + "id": "telegram-credential", + "name": "Telegram API" + } + } + }, + { + "parameters": { + "jsCode": "// No container matched the query\nconst data = $input.item.json;\nreturn {\n json: {\n chatId: data.chatId,\n text: `No container found matching '${data.containerQuery}'`\n }\n};" + }, + "id": "code-no-match", + "name": "Format No Match", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [1780, 300] + }, + { + "parameters": { + "resource": "message", + "operation": "sendMessage", + "chatId": "={{ $json.chatId }}", + "text": "={{ $json.text }}", + "additionalFields": { + "parse_mode": "HTML" + } + }, + "id": "telegram-send-no-match", + "name": "Send No Match", + "type": "n8n-nodes-base.telegram", + "typeVersion": 1.2, + "position": [2000, 300], + "credentials": { + "telegramApi": { + "id": "telegram-credential", + "name": "Telegram API" + } + } + }, + { + "parameters": { + "jsCode": "// Multiple containers matched - placeholder for confirmation flow (Plan 03-02)\nconst data = $input.item.json;\nconst names = data.matches.map(m => m.Name).join(', ');\nreturn {\n json: {\n chatId: data.chatId,\n text: `Found ${data.matchCount} containers matching '${data.containerQuery}': ${names}\\n\\nConfirmation required. (Coming in next update)`\n }\n};" + }, + "id": "code-multiple-matches", + "name": "Format Multiple Matches", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [1780, 500] + }, + { + "parameters": { + "resource": "message", + "operation": "sendMessage", + "chatId": "={{ $json.chatId }}", + "text": "={{ $json.text }}", + "additionalFields": { + "parse_mode": "HTML" + } + }, + "id": "telegram-send-multiple", + "name": "Send Multiple Matches", + "type": "n8n-nodes-base.telegram", + "typeVersion": 1.2, + "position": [2000, 500], + "credentials": { + "telegramApi": { + "id": "telegram-credential", + "name": "Telegram API" + } + } } ], "connections": { @@ -315,6 +538,120 @@ } ] ] + }, + "Parse Action": { + "main": [ + [ + { + "node": "Docker List for Action", + "type": "main", + "index": 0 + } + ] + ] + }, + "Docker List for Action": { + "main": [ + [ + { + "node": "Match Container", + "type": "main", + "index": 0 + } + ] + ] + }, + "Match Container": { + "main": [ + [ + { + "node": "Check Match Count", + "type": "main", + "index": 0 + } + ] + ] + }, + "Check Match Count": { + "main": [ + [ + { + "node": "Format No Match", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Build Action Command", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Format Multiple Matches", + "type": "main", + "index": 0 + } + ], + [] + ] + }, + "Build Action Command": { + "main": [ + [ + { + "node": "Execute Action", + "type": "main", + "index": 0 + } + ] + ] + }, + "Execute Action": { + "main": [ + [ + { + "node": "Parse Action Result", + "type": "main", + "index": 0 + } + ] + ] + }, + "Parse Action Result": { + "main": [ + [ + { + "node": "Send Action Result", + "type": "main", + "index": 0 + } + ] + ] + }, + "Format No Match": { + "main": [ + [ + { + "node": "Send No Match", + "type": "main", + "index": 0 + } + ] + ] + }, + "Format Multiple Matches": { + "main": [ + [ + { + "node": "Send Multiple Matches", + "type": "main", + "index": 0 + } + ] + ] } }, "pinData": {},