feat(16-02): replace container lookup with Unraid GraphQL API

- Replace 'Get All Containers' Docker API call with GraphQL query
- Add GraphQL Response Normalizer to transform Unraid format to Docker contract
- Add Container ID Registry update on every container lookup
- Update Resolve Container ID to output unraidId (PrefixedID) for mutations
- Wire: Query All Containers -> Normalizer -> Registry Update -> Resolve Container ID
This commit is contained in:
Lucas Berger
2026-02-09 10:21:50 -05:00
parent f84d433b25
commit abb98c0186
+83 -16
View File
@@ -80,23 +80,41 @@
},
{
"parameters": {
"url": "http://docker-socket-proxy:2375/v1.47/containers/json?all=true",
"method": "POST",
"url": "={{ $env.UNRAID_HOST }}/graphql",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "x-api-key",
"value": "={{ $env.UNRAID_API_KEY }}"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={\"query\": \"query { docker { containers { id names state image } } }\"}",
"options": {
"timeout": 5000
"timeout": 15000
}
},
"id": "http-get-containers",
"name": "Get All Containers",
"name": "Query All Containers",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
600,
400
]
],
"onError": "continueRegularOutput"
},
{
"parameters": {
"jsCode": "// Find container by name and resolve ID\nconst triggerData = $('When executed by another workflow').item.json;\nconst containerName = triggerData.containerName;\nconst containers = $input.all();\n\n// Normalize function to strip leading slash\nconst normalizeName = (name) => name.replace(/^\\//, '').toLowerCase();\nconst searchName = normalizeName(containerName);\n\n// Find matching container\nlet matched = null;\nfor (const item of containers) {\n const c = item.json;\n if (c.Names && c.Names.length > 0) {\n const cName = normalizeName(c.Names[0]);\n if (cName === searchName || cName.includes(searchName)) {\n matched = c;\n break;\n }\n }\n}\n\nif (!matched) {\n // Return error - container not found\n return {\n json: {\n ...triggerData,\n containerId: '',\n error: `Container '${containerName}' not found`\n }\n };\n}\n\nreturn {\n json: {\n ...triggerData,\n containerId: matched.Id\n }\n};"
"jsCode": "// Find container by name and resolve ID\nconst triggerData = $('When executed by another workflow').item.json;\nconst containerName = triggerData.containerName;\nconst containers = $input.all();\n\n// Normalize function to strip leading slash\nconst normalizeName = (name) => name.replace(/^\\//, '').toLowerCase();\nconst searchName = normalizeName(containerName);\n\n// Find matching container\nlet matched = null;\nfor (const item of containers) {\n const c = item.json;\n if (c.Names && c.Names.length > 0) {\n const cName = normalizeName(c.Names[0]);\n if (cName === searchName || cName.includes(searchName)) {\n matched = c;\n break;\n }\n }\n}\n\nif (!matched) {\n // Return error - container not found\n return {\n json: {\n ...triggerData,\n containerId: '',\n error: `Container '${containerName}' not found`\n }\n };\n}\n\nreturn {\n json: {\n ...triggerData,\n containerId: matched.Id,\n unraidId: matched.Id // Add PrefixedID for downstream mutations\n }\n};"
},
"id": "code-resolve-id",
"name": "Resolve Container ID",
@@ -287,6 +305,33 @@
1380,
400
]
},
{
"parameters": {
"jsCode": "// GraphQL Response Normalizer - Transform Unraid GraphQL to Docker API contract\n// Input: $input.item.json = raw GraphQL response\n// Output: Array of normalized containers\n\nconst response = $input.item.json;\n\n// Validation: Check for GraphQL errors\nif (response.errors && response.errors.length > 0) {\n const messages = response.errors.map(e => e.message).join('; ');\n throw new Error(`GraphQL error: ${messages}`);\n}\n\n// Validation: Check response structure\nif (!response.data?.docker?.containers) {\n throw new Error('Invalid GraphQL response structure: missing data.docker.containers');\n}\n\n// State mapping: Unraid UPPERCASE -> Docker lowercase\nconst stateMap = {\n 'RUNNING': 'running',\n 'STOPPED': 'exited', // Docker convention: stopped = exited\n 'PAUSED': 'paused'\n};\n\nfunction normalizeState(unraidState) {\n return stateMap[unraidState] || unraidState.toLowerCase();\n}\n\n// Transform each container\nconst containers = response.data.docker.containers;\nconst normalized = containers.map(container => {\n const dockerState = normalizeState(container.state);\n \n return {\n // Core fields matching Docker API contract\n Id: container.id, // Keep full PrefixedID (registry handles translation)\n Names: container.names, // Already has '/' prefix (Phase 14 verified)\n State: dockerState, // Normalized lowercase state\n Status: dockerState, // Docker has separate Status field\n Image: '', // Not available in basic query\n \n // Debug field: preserve original Unraid ID\n _unraidId: container.id\n };\n});\n\n// Return as array of items (n8n multi-item output format)\nreturn normalized.map(container => ({ json: container }));\n"
},
"id": "code-graphql-normalizer",
"name": "GraphQL Response Normalizer",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
660,
400
]
},
{
"parameters": {
"mode": "runOnceForAllItems",
"jsCode": "// Update Container ID Registry with fresh container data\n// Input: Array of containers from Normalizer\n// Output: Pass through all containers unchanged\n\nconst containers = $input.all().map(item => item.json);\n\n// Get static data registry\nconst registry = $getWorkflowStaticData('global');\nif (!registry._containerIdMap) {\n registry._containerIdMap = JSON.stringify({});\n}\n\nconst containerMap = {};\n\nfor (const container of containers) {\n // Extract container name: strip leading '/', lowercase\n const rawName = container.Names[0];\n const name = rawName.startsWith('/') ? rawName.substring(1).toLowerCase() : rawName.toLowerCase();\n \n // Map name -> {name, unraidId}\n // The container.Id field IS the PrefixedID (129-char format)\n containerMap[name] = {\n name: name,\n unraidId: container.Id\n };\n}\n\n// Store timestamp\nregistry._lastRefresh = Date.now();\n\n// Serialize (top-level assignment - this is what n8n persists)\nregistry._containerIdMap = JSON.stringify(containerMap);\n\n// Pass through all containers unchanged (multi-item output)\nreturn containers.map(c => ({ json: c }));\n"
},
"id": "code-update-registry",
"name": "Update Container ID Registry",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
720,
400
]
}
],
"connections": {
@@ -319,17 +364,6 @@
]
]
},
"Get All Containers": {
"main": [
[
{
"node": "Resolve Container ID",
"type": "main",
"index": 0
}
]
]
},
"Resolve Container ID": {
"main": [
[
@@ -398,6 +432,39 @@
}
]
]
},
"Query All Containers": {
"main": [
[
{
"node": "GraphQL Response Normalizer",
"type": "main",
"index": 0
}
]
]
},
"GraphQL Response Normalizer": {
"main": [
[
{
"node": "Update Container ID Registry",
"type": "main",
"index": 0
}
]
]
},
"Update Container ID Registry": {
"main": [
[
{
"node": "Resolve Container ID",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {