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
This commit is contained in:
Lucas Berger
2026-01-30 08:35:38 -05:00
parent 4848e7db16
commit f466a2916e
+339 -2
View File
@@ -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: `<b>${containerName}</b> ${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 '<b>${data.containerQuery}</b>'`\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 '<b>${data.containerQuery}</b>': ${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": {},