Files
unraid-docker-manager/.planning/phases/03-container-actions/03-04-PLAN.md
T
Lucas Berger 893412f405 docs(03): create phase plan
Phase 03: Container Actions
- 4 plans in 4 waves (sequential due to shared workflow file)
- Ready for execution
2026-01-29 21:48:58 -05:00

430 lines
13 KiB
Markdown

---
phase: 03-container-actions
plan: 04
type: execute
wave: 4
depends_on: ["03-01"]
files_modified: [n8n-workflow.json]
autonomous: true
must_haves:
truths:
- "User can update a container by name (pull new image, recreate)"
- "Update detects if image actually changed"
- "Version change shown when detectable from image labels"
- "No notification if image was already up to date"
artifacts:
- path: "n8n-workflow.json"
provides: "Container update workflow (pull + recreate)"
contains: "Update Container"
key_links:
- from: "Route Message switch"
to: "Update branch"
via: "update <name> pattern"
pattern: "update"
- from: "Docker inspect"
to: "Docker create"
via: "Config extraction and recreation"
pattern: "containers/create"
---
<objective>
Implement container update action (pull new image + recreate container with same config).
Purpose: Allow users to update containers via "update plex" command. The workflow pulls the latest image, compares digests to detect changes, and recreates the container with the same configuration. Per CONTEXT.md, only notify if an actual update occurred.
Output: Extended n8n workflow with full container update flow.
</objective>
<execution_context>
@/home/luc/.claude/get-shit-done/workflows/execute-plan.md
@/home/luc/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/03-container-actions/03-CONTEXT.md
@.planning/phases/03-container-actions/03-RESEARCH.md
@.planning/phases/03-container-actions/03-01-SUMMARY.md
@n8n-workflow.json
</context>
<tasks>
<task type="auto">
<name>Task 1: Add update command routing and container matching</name>
<files>n8n-workflow.json</files>
<action>
Extend the "Route Message" Switch node to handle update commands:
1. **Add Update Route** (to existing Switch node):
- Pattern: message contains "update" (case-insensitive)
- Route to new "Update Branch"
2. **Parse Update Command** (Code node):
```javascript
const text = $json.message.text.toLowerCase().trim();
const match = text.match(/^update\s+(.+)$/i);
if (!match) {
return { json: { error: 'Invalid update format', chatId: $json.message.chat.id } };
}
return {
json: {
containerQuery: match[1].trim(),
chatId: $json.message.chat.id,
messageId: $json.message.message_id
}
};
```
3. **Match Container** (reuse existing matching pattern):
- Docker List Containers (Execute Command)
- Fuzzy match logic (Code node)
- For updates: only single match supported (no batch update confirmation)
- If 0 matches: "No container found" (can reuse suggestion flow from 03-02 if available)
- If >1 matches: "Update requires exact container name. Found: sonarr, radarr, lidarr"
- If 1 match: proceed to update flow
4. **Multiple Match Handler** (for update only):
```javascript
const matches = $json.matches;
const names = matches.map(m => m.Names[0].replace(/^\//, '')).join(', ');
return {
json: {
message: `Update requires an exact container name.\n\nFound ${matches.length} matches: ${names}`,
chatId: $json.chatId
}
};
```
Then Send Message node.
</action>
<verify>
1. "update plex" (single match) → should proceed to update flow (may fail at execution, but routing works)
2. "update arr" (multiple matches) → should show "requires exact name" message
3. "update nonexistent" → should show "no container found"
</verify>
<done>Update commands route correctly, single matches proceed to update flow</done>
</task>
<task type="auto">
<name>Task 2: Implement image pull and change detection</name>
<files>n8n-workflow.json</files>
<action>
After single-match routing, implement the update steps:
1. **Inspect Container** (Execute Command node):
```javascript
const containerId = $json.matches[0].Id;
return {
json: {
cmd: `curl -s --unix-socket /var/run/docker.sock 'http://localhost/v1.47/containers/${containerId}/json'`,
containerId,
containerName: $json.matches[0].Names[0].replace(/^\//, ''),
chatId: $json.chatId
}
};
```
2. **Parse Container Config** (Code node):
Parse the inspect output and extract what we need:
```javascript
const inspect = JSON.parse($json.stdout);
const imageName = inspect.Config.Image;
const currentImageId = inspect.Image;
// Extract version from labels if available
const labels = inspect.Config.Labels || {};
const currentVersion = labels['org.opencontainers.image.version']
|| labels['version']
|| currentImageId.substring(7, 19);
return {
json: {
imageName,
currentImageId,
currentVersion,
containerConfig: inspect.Config,
hostConfig: inspect.HostConfig,
networkSettings: inspect.NetworkSettings,
containerName: $json.containerName,
containerId: $json.containerId,
chatId: $json.chatId
}
};
```
3. **Pull Image** (Execute Command node):
```javascript
const imageName = $json.imageName;
return {
json: {
cmd: `curl -s --unix-socket /var/run/docker.sock -X POST 'http://localhost/v1.47/images/create?fromImage=${encodeURIComponent(imageName)}'`,
...($json) // Preserve all context
}
};
```
4. **Inspect New Image** (Execute Command node):
```javascript
const imageName = $json.imageName;
return {
json: {
cmd: `curl -s --unix-socket /var/run/docker.sock 'http://localhost/v1.47/images/${encodeURIComponent(imageName)}/json'`,
...($json)
}
};
```
5. **Compare Digests** (Code node):
```javascript
// Parse new image inspect
const newImage = JSON.parse($json.stdout);
const newImageId = newImage.Id;
const currentImageId = $('Parse Container Config').first().json.currentImageId;
if (currentImageId === newImageId) {
// No update needed - stay silent per CONTEXT.md
return { json: { needsUpdate: false, chatId: $json.chatId } };
}
// Extract new version
const labels = newImage.Config?.Labels || {};
const newVersion = labels['org.opencontainers.image.version']
|| labels['version']
|| newImageId.substring(7, 19);
return {
json: {
needsUpdate: true,
currentImageId,
newImageId,
currentVersion: $('Parse Container Config').first().json.currentVersion,
newVersion,
containerConfig: $('Parse Container Config').first().json.containerConfig,
hostConfig: $('Parse Container Config').first().json.hostConfig,
networkSettings: $('Parse Container Config').first().json.networkSettings,
containerName: $('Parse Container Config').first().json.containerName,
containerId: $('Parse Container Config').first().json.containerId,
chatId: $json.chatId
}
};
```
6. **Check If Update Needed** (IF node):
- Condition: `{{ $json.needsUpdate }}` equals true
- True: proceed to recreate
- False: do nothing (silent, no message)
</action>
<verify>
1. "update [container]" with no new image → no message sent (silent)
2. Check workflow logs to confirm pull was attempted and digests compared
</verify>
<done>Image pull works, change detection compares digests correctly</done>
</task>
<task type="auto">
<name>Task 3: Implement container recreation workflow</name>
<files>n8n-workflow.json</files>
<action>
When update is needed, stop old container, remove it, create new one, start it:
1. **Stop Container** (Execute Command node):
```javascript
const containerId = $json.containerId;
return {
json: {
cmd: `curl -s -o /dev/null -w "%{http_code}" --unix-socket /var/run/docker.sock -X POST 'http://localhost/v1.47/containers/${containerId}/stop?t=10'`,
...($json)
}
};
```
2. **Verify Stopped** (Code node):
```javascript
const statusCode = parseInt($json.stdout.trim());
if (statusCode !== 204 && statusCode !== 304) {
return {
json: {
error: true,
message: `Failed to stop container: HTTP ${statusCode}`,
chatId: $json.chatId
}
};
}
return { json: { ...$json, stopped: true } };
```
3. **Remove Container** (Execute Command node):
```javascript
const containerId = $json.containerId;
return {
json: {
cmd: `curl -s -o /dev/null -w "%{http_code}" --unix-socket /var/run/docker.sock -X DELETE 'http://localhost/v1.47/containers/${containerId}'`,
...($json)
}
};
```
4. **Build Create Body** (Code node):
Build the container creation request from saved config:
```javascript
const config = $json.containerConfig;
const hostConfig = $json.hostConfig;
const networkSettings = $json.networkSettings;
// Build NetworkingConfig from NetworkSettings
const networks = {};
for (const [name, netConfig] of Object.entries(networkSettings.Networks || {})) {
networks[name] = {
IPAMConfig: netConfig.IPAMConfig,
Links: netConfig.Links,
Aliases: netConfig.Aliases
};
}
const createBody = {
...config,
HostConfig: hostConfig,
NetworkingConfig: {
EndpointsConfig: networks
}
};
// Remove fields that shouldn't be in create request
delete createBody.Hostname; // Let Docker assign
delete createBody.Domainname;
return {
json: {
createBody: JSON.stringify(createBody),
containerName: $json.containerName,
currentVersion: $json.currentVersion,
newVersion: $json.newVersion,
chatId: $json.chatId
}
};
```
5. **Create Container** (Execute Command node):
```javascript
const containerName = $json.containerName;
const createBody = $json.createBody;
// Write body to temp file to avoid shell escaping issues
// Or use curl's -d option with proper escaping
return {
json: {
cmd: `echo '${createBody.replace(/'/g, "'\\''")}' | curl -s -X POST --unix-socket /var/run/docker.sock -H "Content-Type: application/json" -d @- 'http://localhost/v1.47/containers/create?name=${encodeURIComponent(containerName)}'`,
...($json)
}
};
```
Alternative approach if shell escaping is problematic:
```javascript
// Use a Code node with HTTP request instead of Execute Command
// n8n Code nodes can make HTTP requests directly
```
6. **Parse Create Response** (Code node):
```javascript
let response;
try {
response = JSON.parse($json.stdout);
} catch (e) {
return { json: { error: true, message: `Create failed: ${$json.stdout}`, chatId: $json.chatId } };
}
if (response.message) {
// Error response
return { json: { error: true, message: `Create failed: ${response.message}`, chatId: $json.chatId } };
}
return {
json: {
newContainerId: response.Id,
currentVersion: $json.currentVersion,
newVersion: $json.newVersion,
containerName: $json.containerName,
chatId: $json.chatId
}
};
```
7. **Start New Container** (Execute Command node):
```javascript
const newContainerId = $json.newContainerId;
return {
json: {
cmd: `curl -s -o /dev/null -w "%{http_code}" --unix-socket /var/run/docker.sock -X POST 'http://localhost/v1.47/containers/${newContainerId}/start'`,
...($json)
}
};
```
8. **Send Update Result** (Telegram Send Message):
```javascript
const { containerName, currentVersion, newVersion } = $json;
const message = `<b>${containerName}</b> updated: ${currentVersion} → ${newVersion}`;
return { json: { text: message, chatId: $json.chatId } };
```
</action>
<verify>
1. "update [container]" when update available → container recreated, version change message sent
2. Container restarts successfully with same ports, volumes, networks
3. Check container is running after update
</verify>
<done>Container recreation workflow works, preserves configuration, reports version change</done>
</task>
</tasks>
<verification>
End-to-end update verification:
1. "update plex" (when update available):
- Image pulled
- Container stops
- Container removed
- New container created with same config
- Container starts
- Message: "plex updated: v1.32.0 → v1.32.1"
2. "update plex" (when already up to date):
- Image pulled
- Digests compared
- No further action
- No message sent (silent per CONTEXT.md)
3. "update arr" (multiple matches):
- Message: "Update requires exact container name..."
4. "update nonexistent":
- Message: "No container found..."
5. Post-update verification:
- Container running
- Same ports mapped
- Same volumes mounted
- Same network connections
Import updated workflow and test with a real container that has an update available.
</verification>
<success_criteria>
- Update command parses container name correctly
- Image pull succeeds
- Digest comparison detects changes accurately
- Container recreation preserves Config, HostConfig, Networks
- Version change displayed when detectable
- Silent when no update available
- All error cases report diagnostic details
- Container runs correctly after update
</success_criteria>
<output>
After completion, create `.planning/phases/03-container-actions/03-04-SUMMARY.md`
</output>