Skip to content

Commit 284822c

Browse files
amir20claude
andauthored
feat: add container update action with image pull and recreate 🚀 (#4588)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 475a163 commit 284822c

43 files changed

Lines changed: 1149 additions & 119 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.claude/agent-memory/bug-hunter/MEMORY.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,10 @@
2828
- Subscription fields use atomic types: TriggerCount (atomic.Int64), LastTriggeredAt (atomic.Pointer)
2929
- MetricCooldowns uses xsync.Map for per-container cooldown tracking
3030
- sendSem (semaphore.Weighted=5) limits concurrent notification sends
31+
32+
### Container Update (feat/container-update)
33+
34+
- **progressCh close contract**: Docker and Agent implementations close progressCh via defer; K8s does NOT, causing handler hang
35+
- **NetworkSettings nil risk**: `docker.InspectResponse.NetworkSettings` is a pointer; `ContainerCreate` doesn't nil-check before accessing `.Networks`
36+
- **Destructive recreate**: stop->remove->create->start has no rollback if create fails after remove
37+
- **SSE parsing in frontend**: Uses manual ReadableStream reader, not EventSource; no AbortController cleanup on unmount

assets/components.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ declare module 'vue' {
2727
'Carbon:star': typeof import('~icons/carbon/star')['default']
2828
'Carbon:starFilled': typeof import('~icons/carbon/star-filled')['default']
2929
'Carbon:stopFilledAlt': typeof import('~icons/carbon/stop-filled-alt')['default']
30+
'Carbon:upgrade': typeof import('~icons/carbon/upgrade')['default']
3031
'Carbon:warning': typeof import('~icons/carbon/warning')['default']
3132
Carousel: typeof import('./components/common/Carousel.vue')['default']
3233
CarouselItem: typeof import('./components/common/CarouselItem.vue')['default']

assets/components/ContainerViewer/ContainerActionsToolbar.vue

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,17 @@
154154
{{ $t("toolbar.restart") }}
155155
</button>
156156
</li>
157+
<li>
158+
<button @click="update()" :disabled="actionStates.update">
159+
<carbon:upgrade
160+
:class="{
161+
'animate-spin': actionStates.update,
162+
'text-secondary': actionStates.update,
163+
}"
164+
/>
165+
{{ container.isSwarm ? $t("toolbar.update-service") : $t("toolbar.update") }}
166+
</button>
167+
</li>
157168
</template>
158169

159170
<template v-if="enableShell && !historical">
@@ -190,7 +201,7 @@ const showDrawer = useDrawer();
190201
191202
const { container, historical = false } = defineProps<{ container: Container; historical?: boolean }>();
192203
const clear = defineEmit();
193-
const { actionStates, start, stop, restart } = useContainerActions(toRef(() => container));
204+
const { actionStates, start, stop, restart, update } = useContainerActions(toRef(() => container));
194205
195206
const router = useRouter();
196207
const { copy, copied, isSupported } = useClipboard();

assets/composable/containerActions.ts

Lines changed: 101 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,25 +2,27 @@ import { Container } from "@/models/Container";
22

33
type ContainerActions = "start" | "stop" | "restart";
44
export const useContainerActions = (container: Ref<Container>) => {
5-
const { showToast } = useToast();
5+
const { showToast, removeToast } = useToast();
6+
const { t } = useI18n();
67

78
const actionStates = reactive({
89
stop: false,
910
restart: false,
1011
start: false,
12+
update: false,
1113
});
1214

1315
async function actionHandler(action: ContainerActions) {
1416
const actionUrl = `/api/hosts/${container.value.host}/containers/${container.value.id}/actions/${action}`;
1517

1618
const errors = {
17-
404: "container not found",
18-
500: "unable to complete action",
19-
400: "invalid action",
19+
404: t("error.container-not-found"),
20+
500: t("error.unable-to-complete-action"),
21+
400: t("error.invalid-action"),
2022
} as Record<number, string>;
2123

22-
const defaultError = "something went wrong";
23-
const toastTitle = "Action Failed";
24+
const defaultError = t("error.something-went-wrong");
25+
const toastTitle = t("error.action-failed");
2426

2527
actionStates[action] = true;
2628

@@ -37,10 +39,103 @@ export const useContainerActions = (container: Ref<Container>) => {
3739
actionStates[action] = false;
3840
}
3941

42+
async function update() {
43+
const updateUrl = `/api/hosts/${container.value.host}/containers/${container.value.id}/actions/update`;
44+
const toastId = "container-update";
45+
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
46+
47+
actionStates.update = true;
48+
49+
showToast(
50+
{
51+
id: toastId,
52+
title: t("toolbar.update"),
53+
message: t("toolbar.update-pulling"),
54+
type: "info",
55+
},
56+
{ once: true },
57+
);
58+
59+
try {
60+
const response = await fetch(withBase(updateUrl), { method: "POST" });
61+
if (!response.ok) {
62+
removeToast(toastId);
63+
showToast({ type: "error", message: t("error.unable-to-update"), title: t("error.update-failed") });
64+
return;
65+
}
66+
67+
reader = response.body?.getReader();
68+
if (!reader) return;
69+
70+
const decoder = new TextDecoder();
71+
let buffer = "";
72+
73+
while (true) {
74+
const { done, value } = await reader.read();
75+
if (done) break;
76+
77+
buffer += decoder.decode(value, { stream: true });
78+
const lines = buffer.split("\n\n");
79+
buffer = lines.pop() ?? "";
80+
81+
for (const chunk of lines) {
82+
const dataLine = chunk.split("\n").find((l) => l.startsWith("data: "));
83+
if (!dataLine) continue;
84+
85+
const data = JSON.parse(dataLine.slice(6));
86+
87+
switch (data.status) {
88+
case "pulling":
89+
break;
90+
case "recreating":
91+
removeToast(toastId);
92+
showToast(
93+
{
94+
id: toastId,
95+
title: t("toolbar.update"),
96+
message: t("toolbar.update-recreating"),
97+
type: "info",
98+
},
99+
{ once: true },
100+
);
101+
break;
102+
case "done":
103+
case "up-to-date":
104+
removeToast(toastId);
105+
showToast(
106+
{
107+
title: t("toolbar.update"),
108+
message: t(`toolbar.update-${data.status}`),
109+
type: "info",
110+
},
111+
{ expire: 3000 },
112+
);
113+
break;
114+
case "error":
115+
removeToast(toastId);
116+
showToast({
117+
type: "error",
118+
message: data.error || t("error.unknown-error"),
119+
title: t("error.update-failed"),
120+
});
121+
break;
122+
}
123+
}
124+
}
125+
} catch (error) {
126+
removeToast(toastId);
127+
showToast({ type: "error", message: t("error.something-went-wrong"), title: t("error.update-failed") });
128+
} finally {
129+
reader?.cancel();
130+
actionStates.update = false;
131+
}
132+
}
133+
40134
return {
41135
actionStates,
42136
start: () => actionHandler("start"),
43137
stop: () => actionHandler("stop"),
44138
restart: () => actionHandler("restart"),
139+
update,
45140
};
46141
};

internal/agent/client.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,33 @@ func (c *Client) ContainerAction(ctx context.Context, containerId string, action
367367
return err
368368
}
369369

370+
func (c *Client) UpdateContainer(ctx context.Context, containerID string, progressCh chan<- container.UpdateProgress) error {
371+
defer close(progressCh)
372+
373+
stream, err := c.client.UpdateContainer(ctx, &pb.UpdateContainerRequest{ContainerId: containerID})
374+
if err != nil {
375+
return err
376+
}
377+
378+
for {
379+
progress, err := stream.Recv()
380+
if err == io.EOF {
381+
return nil
382+
}
383+
if err != nil {
384+
return err
385+
}
386+
387+
progressCh <- container.UpdateProgress{
388+
Status: progress.Status,
389+
Layer: progress.Layer,
390+
Current: progress.Current,
391+
Total: progress.Total,
392+
Error: progress.Error,
393+
}
394+
}
395+
}
396+
370397
func (c *Client) ContainerAttach(ctx context.Context, containerId string) (*container.ExecSession, error) {
371398
stream, err := c.client.ContainerAttach(ctx)
372399
if err != nil {

internal/agent/client_test.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,11 @@ func (m *MockedClientService) Exec(ctx context.Context, c container.Container, c
100100
return args.Error(0)
101101
}
102102

103+
func (m *MockedClientService) UpdateContainer(ctx context.Context, c container.Container, progressCh chan<- container.UpdateProgress) error {
104+
args := m.Called(ctx, c, progressCh)
105+
return args.Error(0)
106+
}
107+
103108
var wantedContainer = container.Container{}
104109

105110
func init() {

0 commit comments

Comments
 (0)