diff --git a/n8n-container-update.json b/n8n-container-update.json new file mode 100644 index 0000000..1ff3d97 --- /dev/null +++ b/n8n-container-update.json @@ -0,0 +1,965 @@ +{ + "name": "Container Update", + "nodes": [ + { + "parameters": { + "inputSource": "passthrough", + "schema": { + "schemaType": "fromFields", + "fields": [ + { + "fieldName": "containerId", + "fieldType": "string" + }, + { + "fieldName": "containerName", + "fieldType": "string" + }, + { + "fieldName": "chatId", + "fieldType": "number" + }, + { + "fieldName": "messageId", + "fieldType": "number" + }, + { + "fieldName": "responseMode", + "fieldType": "string" + } + ] + } + }, + "id": "sub-trigger", + "name": "When executed by another workflow", + "type": "n8n-nodes-base.executeWorkflowTrigger", + "typeVersion": 1.1, + "position": [ + 240, + 300 + ] + }, + { + "parameters": { + "method": "GET", + "url": "=http://docker-socket-proxy:2375/containers/{{ $json.containerId }}/json", + "options": { + "timeout": 5000 + } + }, + "id": "http-inspect-container", + "name": "Inspect Container", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 460, + 300 + ] + }, + { + "parameters": { + "jsCode": "// Parse container config and prepare for pull\nconst inspectData = $input.item.json;\nconst triggerData = $('When executed by another workflow').item.json;\nconst containerId = triggerData.containerId;\nconst containerName = triggerData.containerName;\nconst chatId = triggerData.chatId;\nconst messageId = triggerData.messageId;\nconst responseMode = triggerData.responseMode;\n\n// Extract image info\nlet imageName = inspectData.Config.Image;\nconst currentImageId = inspectData.Image;\n\n// CRITICAL: Ensure image has a tag, otherwise Docker pulls ALL tags!\nif (imageName && !imageName.includes(':') && !imageName.includes('@')) {\n imageName = imageName + ':latest';\n}\n\n// Extract config for recreation\nconst containerConfig = inspectData.Config;\nconst hostConfig = inspectData.HostConfig;\nconst networkSettings = inspectData.NetworkSettings;\n\n// Get current version from labels or image digest\nconst labels = containerConfig.Labels || {};\nconst currentVersion = labels['org.opencontainers.image.version']\n || labels['version']\n || currentImageId.substring(7, 19);\n\nreturn {\n json: {\n containerId,\n containerName,\n chatId,\n messageId,\n responseMode,\n imageName,\n currentImageId,\n currentVersion,\n containerConfig,\n hostConfig,\n networkSettings\n }\n};" + }, + "id": "code-parse-config", + "name": "Parse Container Config", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 680, + 300 + ] + }, + { + "parameters": { + "command": "=curl -s --max-time 600 -X POST 'http://docker-socket-proxy:2375/v1.47/images/create?fromImage={{ encodeURIComponent($json.imageName) }}' | tail -c 10000", + "options": { + "timeout": 660 + } + }, + "id": "exec-pull-image", + "name": "Pull Image", + "type": "n8n-nodes-base.executeCommand", + "typeVersion": 1, + "position": [ + 900, + 300 + ] + }, + { + "parameters": { + "jsCode": "// Check pull response for errors\nconst stdout = $input.item.json.stdout || '';\nconst prevData = $('Parse Container Config').item.json;\n\n// Docker pull streams JSON objects, check for error messages\nif (stdout.includes('\"message\"') && (stdout.includes('toomanyrequests') || stdout.includes('error') || stdout.includes('denied'))) {\n // Extract error message\n let errorMsg = 'Pull failed';\n try {\n const match = stdout.match(/\"message\"\\s*:\\s*\"([^\"]+)\"/);\n if (match) errorMsg = match[1];\n } catch (e) {}\n \n return {\n json: {\n pullError: true,\n errorMessage: errorMsg.substring(0, 100),\n ...prevData\n }\n };\n}\n\n// Success - pass through data\nreturn {\n json: {\n pullError: false,\n ...prevData\n }\n};" + }, + "id": "code-check-pull", + "name": "Check Pull Response", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1120, + 300 + ] + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict" + }, + "conditions": [ + { + "id": "pull-success", + "leftValue": "={{ $json.pullError }}", + "rightValue": false, + "operator": { + "type": "boolean", + "operation": "equals" + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "id": "if-pull-success", + "name": "Check Pull Success", + "type": "n8n-nodes-base.if", + "typeVersion": 2, + "position": [ + 1340, + 300 + ] + }, + { + "parameters": { + "method": "GET", + "url": "=http://docker-socket-proxy:2375/v1.47/images/{{ encodeURIComponent($json.imageName) }}/json", + "options": { + "timeout": 5000 + } + }, + "id": "http-inspect-new-image", + "name": "Inspect New Image", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 1560, + 200 + ] + }, + { + "parameters": { + "jsCode": "// Compare old and new image IDs\nconst newImage = $input.item.json;\nconst prevData = $('Check Pull Success').item.json;\nconst currentImageId = prevData.currentImageId;\n\nconst newImageId = newImage.Id;\n\nif (currentImageId === newImageId) {\n // No update needed\n return {\n json: {\n needsUpdate: false,\n ...prevData\n }\n };\n}\n\n// Extract new version from labels\nconst labels = newImage.Config?.Labels || {};\nconst newVersion = labels['org.opencontainers.image.version']\n || labels['version']\n || newImageId.substring(7, 19);\n\nreturn {\n json: {\n needsUpdate: true,\n newImageId,\n newVersion,\n ...prevData\n }\n};" + }, + "id": "code-compare-digests", + "name": "Compare Digests", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1780, + 200 + ] + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict" + }, + "conditions": [ + { + "id": "needs-update", + "leftValue": "={{ $json.needsUpdate }}", + "rightValue": true, + "operator": { + "type": "boolean", + "operation": "equals" + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "id": "if-needs-update", + "name": "Check If Update Needed", + "type": "n8n-nodes-base.if", + "typeVersion": 2, + "position": [ + 2000, + 200 + ] + }, + { + "parameters": { + "method": "POST", + "url": "=http://docker-socket-proxy:2375/v1.47/containers/{{ $json.containerId }}/stop?t=10", + "options": { + "timeout": 15000 + } + }, + "id": "http-stop-container", + "name": "Stop Container", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 2220, + 100 + ], + "onError": "continueRegularOutput" + }, + { + "parameters": { + "method": "DELETE", + "url": "=http://docker-socket-proxy:2375/v1.47/containers/{{ $('Check If Update Needed').item.json.containerId }}", + "options": { + "timeout": 5000 + } + }, + "id": "http-remove-container", + "name": "Remove Container", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 2440, + 100 + ], + "onError": "continueRegularOutput" + }, + { + "parameters": { + "jsCode": "// Build container create request body from saved config\nconst prevData = $('Check If Update Needed').item.json;\nconst config = prevData.containerConfig;\nconst hostConfig = prevData.hostConfig;\nconst networkSettings = prevData.networkSettings;\nconst containerName = prevData.containerName;\n\n// Build NetworkingConfig from NetworkSettings\nconst networks = {};\nfor (const [name, netConfig] of Object.entries(networkSettings.Networks || {})) {\n networks[name] = {\n IPAMConfig: netConfig.IPAMConfig,\n Links: netConfig.Links,\n Aliases: netConfig.Aliases\n };\n}\n\nconst createBody = {\n ...config,\n HostConfig: hostConfig,\n NetworkingConfig: {\n EndpointsConfig: networks\n }\n};\n\n// Remove fields that shouldn't be in create request\ndelete createBody.Hostname;\ndelete createBody.Domainname;\n\nreturn {\n json: {\n createBody,\n containerName,\n ...prevData\n }\n};" + }, + "id": "code-build-create-body", + "name": "Build Create Body", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 2660, + 100 + ] + }, + { + "parameters": { + "method": "POST", + "url": "=http://docker-socket-proxy:2375/v1.47/containers/create?name={{ encodeURIComponent($json.containerName) }}", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify($json.createBody) }}", + "options": { + "timeout": 5000 + } + }, + "id": "http-create-container", + "name": "Create Container", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 2880, + 100 + ] + }, + { + "parameters": { + "jsCode": "// Parse create response and extract new container ID\nconst response = $input.item.json;\nconst prevData = $('Build Create Body').item.json;\n\nif (response.message) {\n // Error response from Docker\n return {\n json: {\n createError: true,\n errorMessage: response.message,\n ...prevData\n }\n };\n}\n\nreturn {\n json: {\n createError: false,\n newContainerId: response.Id,\n ...prevData\n }\n};" + }, + "id": "code-parse-create", + "name": "Parse Create Response", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 3100, + 100 + ] + }, + { + "parameters": { + "method": "POST", + "url": "=http://docker-socket-proxy:2375/v1.47/containers/{{ $json.newContainerId }}/start", + "options": { + "timeout": 5000 + } + }, + "id": "http-start-container", + "name": "Start Container", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 3320, + 100 + ], + "onError": "continueRegularOutput" + }, + { + "parameters": { + "jsCode": "// Format update success result and clean up old image\nconst prevData = $('Parse Create Response').item.json;\nconst containerName = prevData.containerName;\nconst currentVersion = prevData.currentVersion;\nconst newVersion = prevData.newVersion;\nconst currentImageId = prevData.currentImageId;\nconst chatId = prevData.chatId;\nconst messageId = prevData.messageId;\nconst responseMode = prevData.responseMode;\n\nconst message = `${containerName} updated: ${currentVersion} \\u2192 ${newVersion}`;\n\nreturn {\n json: {\n success: true,\n updated: true,\n message,\n oldDigest: currentVersion,\n newDigest: newVersion,\n currentImageId,\n chatId,\n messageId,\n responseMode,\n containerName\n }\n};" + }, + "id": "code-format-success", + "name": "Format Update Success", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 3540, + 100 + ] + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict" + }, + "conditions": [ + { + "id": "is-inline", + "leftValue": "={{ $json.responseMode }}", + "rightValue": "inline", + "operator": { + "type": "string", + "operation": "equals" + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "id": "if-response-mode-success", + "name": "Check Response Mode (Success)", + "type": "n8n-nodes-base.if", + "typeVersion": 2, + "position": [ + 3760, + 100 + ] + }, + { + "parameters": { + "method": "POST", + "url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN }}/editMessageText", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ chat_id: $json.chatId, message_id: $json.messageId, text: '\\u2705 ' + $json.message, parse_mode: 'HTML', reply_markup: { inline_keyboard: [[{ text: '\\u25C0\\uFE0F Back to Containers', callback_data: 'list:0' }]] } }) }}", + "options": {} + }, + "id": "http-send-inline-success", + "name": "Send Inline Success", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 3980, + 0 + ] + }, + { + "parameters": { + "resource": "message", + "operation": "sendMessage", + "chatId": "={{ $json.chatId }}", + "text": "={{ $json.message }}", + "additionalFields": { + "parse_mode": "HTML" + } + }, + "id": "telegram-send-text-success", + "name": "Send Text Success", + "type": "n8n-nodes-base.telegram", + "typeVersion": 1.2, + "position": [ + 3980, + 200 + ], + "credentials": { + "telegramApi": { + "id": "I0xTTiASl7C1NZhJ", + "name": "Telegram account" + } + } + }, + { + "parameters": { + "method": "DELETE", + "url": "=http://docker-socket-proxy:2375/v1.47/images/{{ $('Format Update Success').item.json.currentImageId }}?force=false", + "options": { + "timeout": 5000 + } + }, + "id": "http-remove-old-image-success", + "name": "Remove Old Image (Success)", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 4200, + 100 + ], + "onError": "continueRegularOutput" + }, + { + "parameters": { + "jsCode": "// Return final success result\nconst data = $('Format Update Success').item.json;\nreturn {\n json: {\n success: true,\n updated: true,\n message: data.message,\n oldDigest: data.oldDigest,\n newDigest: data.newDigest\n }\n};" + }, + "id": "code-return-success", + "name": "Return Success", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 4420, + 100 + ] + }, + { + "parameters": { + "jsCode": "// Format 'already up to date' result\nconst prevData = $('Check If Update Needed').item.json;\nconst containerName = prevData.containerName;\nconst chatId = prevData.chatId;\nconst messageId = prevData.messageId;\nconst responseMode = prevData.responseMode;\n\nconst message = `${containerName} is already up to date`;\n\nreturn {\n json: {\n success: true,\n updated: false,\n message,\n chatId,\n messageId,\n responseMode,\n containerName\n }\n};" + }, + "id": "code-format-no-update", + "name": "Format No Update Needed", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 2220, + 300 + ] + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict" + }, + "conditions": [ + { + "id": "is-inline-no-update", + "leftValue": "={{ $json.responseMode }}", + "rightValue": "inline", + "operator": { + "type": "string", + "operation": "equals" + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "id": "if-response-mode-no-update", + "name": "Check Response Mode (No Update)", + "type": "n8n-nodes-base.if", + "typeVersion": 2, + "position": [ + 2440, + 300 + ] + }, + { + "parameters": { + "method": "POST", + "url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN }}/editMessageText", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ chat_id: $json.chatId, message_id: $json.messageId, text: '\\u2705 ' + $json.message, parse_mode: 'HTML', reply_markup: { inline_keyboard: [[{ text: '\\u25C0\\uFE0F Back to Containers', callback_data: 'list:0' }]] } }) }}", + "options": {} + }, + "id": "http-send-inline-no-update", + "name": "Send Inline No Update", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 2660, + 200 + ] + }, + { + "parameters": { + "resource": "message", + "operation": "sendMessage", + "chatId": "={{ $json.chatId }}", + "text": "={{ $json.message }}", + "additionalFields": { + "parse_mode": "HTML" + } + }, + "id": "telegram-send-text-no-update", + "name": "Send Text No Update", + "type": "n8n-nodes-base.telegram", + "typeVersion": 1.2, + "position": [ + 2660, + 400 + ], + "credentials": { + "telegramApi": { + "id": "I0xTTiASl7C1NZhJ", + "name": "Telegram account" + } + } + }, + { + "parameters": { + "jsCode": "// Return no update result\nconst data = $('Format No Update Needed').item.json;\nreturn {\n json: {\n success: true,\n updated: false,\n message: data.message\n }\n};" + }, + "id": "code-return-no-update", + "name": "Return No Update", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 2880, + 300 + ] + }, + { + "parameters": { + "jsCode": "// Format pull error result\nconst prevData = $('Check Pull Success').item.json;\nconst containerName = prevData.containerName;\nconst errorMessage = prevData.errorMessage;\nconst chatId = prevData.chatId;\nconst messageId = prevData.messageId;\nconst responseMode = prevData.responseMode;\n\nconst message = `Failed to update ${containerName}: ${errorMessage}`;\n\nreturn {\n json: {\n success: false,\n updated: false,\n message,\n chatId,\n messageId,\n responseMode,\n containerName\n }\n};" + }, + "id": "code-format-pull-error", + "name": "Format Pull Error", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1560, + 400 + ] + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict" + }, + "conditions": [ + { + "id": "is-inline-error", + "leftValue": "={{ $json.responseMode }}", + "rightValue": "inline", + "operator": { + "type": "string", + "operation": "equals" + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "id": "if-response-mode-error", + "name": "Check Response Mode (Error)", + "type": "n8n-nodes-base.if", + "typeVersion": 2, + "position": [ + 1780, + 400 + ] + }, + { + "parameters": { + "method": "POST", + "url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN }}/editMessageText", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ chat_id: $json.chatId, message_id: $json.messageId, text: '\\u274C ' + $json.message, parse_mode: 'HTML', reply_markup: { inline_keyboard: [[{ text: '\\u25C0\\uFE0F Back to Containers', callback_data: 'list:0' }]] } }) }}", + "options": {} + }, + "id": "http-send-inline-error", + "name": "Send Inline Error", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 2000, + 300 + ] + }, + { + "parameters": { + "resource": "message", + "operation": "sendMessage", + "chatId": "={{ $json.chatId }}", + "text": "={{ $json.message }}", + "additionalFields": { + "parse_mode": "HTML" + } + }, + "id": "telegram-send-text-error", + "name": "Send Text Error", + "type": "n8n-nodes-base.telegram", + "typeVersion": 1.2, + "position": [ + 2000, + 500 + ], + "credentials": { + "telegramApi": { + "id": "I0xTTiASl7C1NZhJ", + "name": "Telegram account" + } + } + }, + { + "parameters": { + "jsCode": "// Return error result\nconst data = $('Format Pull Error').item.json;\nreturn {\n json: {\n success: false,\n updated: false,\n message: data.message\n }\n};" + }, + "id": "code-return-error", + "name": "Return Error", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 2220, + 400 + ] + } + ], + "connections": { + "When executed by another workflow": { + "main": [ + [ + { + "node": "Inspect Container", + "type": "main", + "index": 0 + } + ] + ] + }, + "Inspect Container": { + "main": [ + [ + { + "node": "Parse Container Config", + "type": "main", + "index": 0 + } + ] + ] + }, + "Parse Container Config": { + "main": [ + [ + { + "node": "Pull Image", + "type": "main", + "index": 0 + } + ] + ] + }, + "Pull Image": { + "main": [ + [ + { + "node": "Check Pull Response", + "type": "main", + "index": 0 + } + ] + ] + }, + "Check Pull Response": { + "main": [ + [ + { + "node": "Check Pull Success", + "type": "main", + "index": 0 + } + ] + ] + }, + "Check Pull Success": { + "main": [ + [ + { + "node": "Inspect New Image", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Format Pull Error", + "type": "main", + "index": 0 + } + ] + ] + }, + "Inspect New Image": { + "main": [ + [ + { + "node": "Compare Digests", + "type": "main", + "index": 0 + } + ] + ] + }, + "Compare Digests": { + "main": [ + [ + { + "node": "Check If Update Needed", + "type": "main", + "index": 0 + } + ] + ] + }, + "Check If Update Needed": { + "main": [ + [ + { + "node": "Stop Container", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Format No Update Needed", + "type": "main", + "index": 0 + } + ] + ] + }, + "Stop Container": { + "main": [ + [ + { + "node": "Remove Container", + "type": "main", + "index": 0 + } + ] + ] + }, + "Remove Container": { + "main": [ + [ + { + "node": "Build Create Body", + "type": "main", + "index": 0 + } + ] + ] + }, + "Build Create Body": { + "main": [ + [ + { + "node": "Create Container", + "type": "main", + "index": 0 + } + ] + ] + }, + "Create Container": { + "main": [ + [ + { + "node": "Parse Create Response", + "type": "main", + "index": 0 + } + ] + ] + }, + "Parse Create Response": { + "main": [ + [ + { + "node": "Start Container", + "type": "main", + "index": 0 + } + ] + ] + }, + "Start Container": { + "main": [ + [ + { + "node": "Format Update Success", + "type": "main", + "index": 0 + } + ] + ] + }, + "Format Update Success": { + "main": [ + [ + { + "node": "Check Response Mode (Success)", + "type": "main", + "index": 0 + } + ] + ] + }, + "Check Response Mode (Success)": { + "main": [ + [ + { + "node": "Send Inline Success", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Send Text Success", + "type": "main", + "index": 0 + } + ] + ] + }, + "Send Inline Success": { + "main": [ + [ + { + "node": "Remove Old Image (Success)", + "type": "main", + "index": 0 + } + ] + ] + }, + "Send Text Success": { + "main": [ + [ + { + "node": "Remove Old Image (Success)", + "type": "main", + "index": 0 + } + ] + ] + }, + "Remove Old Image (Success)": { + "main": [ + [ + { + "node": "Return Success", + "type": "main", + "index": 0 + } + ] + ] + }, + "Format No Update Needed": { + "main": [ + [ + { + "node": "Check Response Mode (No Update)", + "type": "main", + "index": 0 + } + ] + ] + }, + "Check Response Mode (No Update)": { + "main": [ + [ + { + "node": "Send Inline No Update", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Send Text No Update", + "type": "main", + "index": 0 + } + ] + ] + }, + "Send Inline No Update": { + "main": [ + [ + { + "node": "Return No Update", + "type": "main", + "index": 0 + } + ] + ] + }, + "Send Text No Update": { + "main": [ + [ + { + "node": "Return No Update", + "type": "main", + "index": 0 + } + ] + ] + }, + "Format Pull Error": { + "main": [ + [ + { + "node": "Check Response Mode (Error)", + "type": "main", + "index": 0 + } + ] + ] + }, + "Check Response Mode (Error)": { + "main": [ + [ + { + "node": "Send Inline Error", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Send Text Error", + "type": "main", + "index": 0 + } + ] + ] + }, + "Send Inline Error": { + "main": [ + [ + { + "node": "Return Error", + "type": "main", + "index": 0 + } + ] + ] + }, + "Send Text Error": { + "main": [ + [ + { + "node": "Return Error", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1" + } +}