docs(03): create phase plan
Phase 03: Container Actions - 4 plans in 4 waves (sequential due to shared workflow file) - Ready for execution
This commit is contained in:
@@ -0,0 +1,429 @@
|
||||
---
|
||||
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>
|
||||
Reference in New Issue
Block a user