feat: Support batch upgrades for container images (#9915)

This commit is contained in:
ssongliu 2025-08-08 20:29:00 +08:00 committed by GitHub
parent c49c16cbb5
commit b156ef9476
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 206 additions and 53 deletions

View file

@ -57,7 +57,7 @@ func (b *BaseApi) LoadContainerUsers(c *gin.Context) {
// @Summary List containers
// @Accept json
// @Produce json
// @Success 200 {array} string
// @Success 200 {array} dto.ContainerOptions
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /containers/list [post]
@ -65,6 +65,23 @@ func (b *BaseApi) ListContainer(c *gin.Context) {
helper.SuccessWithData(c, containerService.List())
}
// @Tags Container
// @Summary List containers by image
// @Accept json
// @Produce json
// @Success 200 {array} dto.ContainerOptions
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /containers/list/byimage [post]
func (b *BaseApi) ListContainerByImage(c *gin.Context) {
var req dto.OperationWithName
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
helper.SuccessWithData(c, containerService.ListByImage(req.Name))
}
// @Tags Container
// @Summary Load containers status
// @Accept json

View file

@ -107,10 +107,10 @@ type ContainerCreateByCommand struct {
}
type ContainerUpgrade struct {
TaskID string `json:"taskID"`
Name string `json:"name" validate:"required"`
Image string `json:"image" validate:"required"`
ForcePull bool `json:"forcePull"`
TaskID string `json:"taskID"`
Names []string `json:"names" validate:"required"`
Image string `json:"image" validate:"required"`
ForcePull bool `json:"forcePull"`
}
type ContainerListStats struct {

View file

@ -54,6 +54,7 @@ type ContainerService struct{}
type IContainerService interface {
Page(req dto.PageContainer) (int64, interface{}, error)
List() []dto.ContainerOptions
ListByImage(imageName string) []dto.ContainerOptions
LoadStatus() (dto.ContainerStatus, error)
PageNetwork(req dto.SearchWithPage) (int64, interface{}, error)
ListNetwork() ([]dto.Options, error)
@ -246,6 +247,33 @@ func (u *ContainerService) List() []dto.ContainerOptions {
return options
}
func (u *ContainerService) ListByImage(imageName string) []dto.ContainerOptions {
var options []dto.ContainerOptions
client, err := docker.NewDockerClient()
if err != nil {
global.LOG.Errorf("load docker client for contianer list failed, err: %v", err)
return nil
}
defer client.Close()
containers, err := client.ContainerList(context.Background(), container.ListOptions{All: true})
if err != nil {
global.LOG.Errorf("load container list failed, err: %v", err)
return nil
}
for _, container := range containers {
if container.Image != imageName {
continue
}
for _, name := range container.Names {
if len(name) != 0 {
options = append(options, dto.ContainerOptions{Name: strings.TrimPrefix(name, "/"), State: container.State})
}
}
}
return options
}
func (u *ContainerService) LoadStatus() (dto.ContainerStatus, error) {
var data dto.ContainerStatus
client, err := docker.NewDockerClient()
@ -683,6 +711,12 @@ func (u *ContainerService) ContainerUpdate(req dto.ContainerOperate) error {
return err
}
if len(oldContainer.Config.Entrypoint) != 0 {
if oldContainer.Config.Entrypoint[0] == "/docker-entrypoint.sh" {
oldContainer.Config.Entrypoint = []string{}
}
}
taskItem, err := task.NewTaskWithOps(req.Name, task.TaskUpdate, task.TaskScopeContainer, req.TaskID, 1)
if err != nil {
global.LOG.Errorf("new task for create container failed, err: %v", err)
@ -745,17 +779,14 @@ func (u *ContainerService) ContainerUpgrade(req dto.ContainerUpgrade) error {
}
defer client.Close()
ctx := context.Background()
oldContainer, err := client.ContainerInspect(ctx, req.Name)
if err != nil {
return err
}
taskItem, err := task.NewTaskWithOps(req.Name, task.TaskUpgrade, task.TaskScopeContainer, req.TaskID, 1)
taskItem, err := task.NewTaskWithOps(req.Image, task.TaskUpgrade, task.TaskScopeImage, req.TaskID, 1)
if err != nil {
global.LOG.Errorf("new task for create container failed, err: %v", err)
return err
}
go func() {
taskItem.AddSubTask(i18n.GetWithName("ContainerImagePull", req.Image), func(t *task.Task) error {
taskItem.LogStart(i18n.GetWithName("ContainerImagePull", req.Image))
if !checkImageExist(client, req.Image) || req.ForcePull {
if err := pullImages(taskItem, client, req.Image); err != nil {
if !req.ForcePull {
@ -766,38 +797,58 @@ func (u *ContainerService) ContainerUpgrade(req dto.ContainerUpgrade) error {
}
return nil
}, nil)
taskItem.AddSubTask(i18n.GetWithName("ContainerCreate", req.Name), func(t *task.Task) error {
config := oldContainer.Config
config.Image = req.Image
hostConf := oldContainer.HostConfig
var networkConf network.NetworkingConfig
if oldContainer.NetworkSettings != nil {
for networkKey := range oldContainer.NetworkSettings.Networks {
networkConf.EndpointsConfig = map[string]*network.EndpointSettings{networkKey: {}}
break
for _, item := range req.Names {
var oldContainer container.InspectResponse
taskItem.AddSubTask(i18n.GetWithName("ContainerLoadInfo", item), func(t *task.Task) error {
taskItem.Logf("----------------- %s -----------------", item)
oldContainer, err = client.ContainerInspect(ctx, item)
if err != nil {
return err
}
}
err := client.ContainerRemove(ctx, req.Name, container.RemoveOptions{Force: true})
taskItem.LogWithStatus(i18n.GetWithName("ContainerRemoveOld", req.Name), err)
if err != nil {
return err
}
inspected, err := client.ImageInspect(ctx, req.Image)
if err != nil {
return fmt.Errorf("inspect image failed, err: %v", err)
}
if isDynamicImage(inspected) {
oldContainer.Config.Entrypoint = nil
oldContainer.Config.Cmd = nil
}
return nil
}, nil)
con, err := client.ContainerCreate(ctx, config, hostConf, &networkConf, &v1.Platform{}, req.Name)
if err != nil {
taskItem.Log(i18n.GetMsgByKey("ContainerRecreate"))
reCreateAfterUpdate(req.Name, client, oldContainer.Config, oldContainer.HostConfig, oldContainer.NetworkSettings)
return fmt.Errorf("upgrade container failed, err: %v", err)
}
err = client.ContainerStart(ctx, con.ID, container.StartOptions{})
taskItem.LogWithStatus(i18n.GetMsgByKey("ContainerStartCheck"), err)
if err != nil {
return fmt.Errorf("upgrade successful but start failed, err: %v", err)
}
return nil
}, nil)
taskItem.AddSubTask(i18n.GetWithName("ContainerCreate", item), func(t *task.Task) error {
oldContainer.Config.Cmd = nil
config := oldContainer.Config
config.Image = req.Image
hostConf := oldContainer.HostConfig
var networkConf network.NetworkingConfig
if oldContainer.NetworkSettings != nil {
for networkKey := range oldContainer.NetworkSettings.Networks {
networkConf.EndpointsConfig = map[string]*network.EndpointSettings{networkKey: {}}
break
}
}
err := client.ContainerRemove(ctx, item, container.RemoveOptions{Force: true})
taskItem.LogWithStatus(i18n.GetWithName("ContainerRemoveOld", item), err)
if err != nil {
return err
}
con, err := client.ContainerCreate(ctx, config, hostConf, &networkConf, &v1.Platform{}, item)
if err != nil {
taskItem.Log(i18n.GetMsgByKey("ContainerRecreate"))
reCreateAfterUpdate(item, client, oldContainer.Config, oldContainer.HostConfig, oldContainer.NetworkSettings)
return fmt.Errorf("upgrade container failed, err: %v", err)
}
err = client.ContainerStart(ctx, con.ID, container.StartOptions{})
taskItem.LogWithStatus(i18n.GetMsgByKey("ContainerStartCheck"), err)
if err != nil {
return fmt.Errorf("upgrade successful but start failed, err: %v", err)
}
return nil
}, nil)
}
if err := taskItem.Execute(); err != nil {
global.LOG.Error(err.Error())
}
@ -1692,3 +1743,23 @@ func loadContainerPortForInfo(itemPorts []container.Port) []dto.PortHelper {
}
return exposedPorts
}
func isDynamicImage(inspected image.InspectResponse) bool {
if len(inspected.Config.Entrypoint) > 0 {
entrypointStr := strings.Join(inspected.Config.Entrypoint, " ")
if strings.Contains(entrypointStr, "entrypoint") {
return true
}
}
dirs := []string{"/docker-entrypoint.d", "/docker-entrypoint-initdb.d"}
for _, dir := range dirs {
for _, layer := range inspected.RootFS.Layers {
if strings.Contains(layer, dir) {
return true
}
}
}
return false
}

View file

@ -21,6 +21,7 @@ func (s *ContainerRouter) InitRouter(Router *gin.RouterGroup) {
baRouter.POST("/info", baseApi.ContainerInfo)
baRouter.POST("/search", baseApi.SearchContainer)
baRouter.POST("/list", baseApi.ListContainer)
baRouter.POST("/list/byimage", baseApi.ListContainerByImage)
baRouter.GET("/status", baseApi.LoadContainerStatus)
baRouter.GET("/list/stats", baseApi.ContainerListStats)
baRouter.GET("/search/log", baseApi.ContainerStreamLogs)

View file

@ -45,6 +45,10 @@ export namespace Container {
imageSize: number;
}
export interface ContainerOption {
name: string;
state: string;
}
export interface ResourceLimit {
cpu: number;
memory: number;
@ -86,7 +90,7 @@ export namespace Container {
}
export interface ContainerUpgrade {
taskID: string;
name: string;
names: Array<string>;
image: string;
forcePull: boolean;
}

View file

@ -7,7 +7,10 @@ export const searchContainer = (params: Container.ContainerSearch) => {
return http.post<ResPage<Container.ContainerInfo>>(`/containers/search`, params, TimeoutEnum.T_40S);
};
export const listContainer = () => {
return http.post<Array<Container.ContainerInfo>>(`/containers/list`, {});
return http.post<Array<Container.ContainerOption>>(`/containers/list`, {});
};
export const listContainerByImage = (image: string) => {
return http.post<Array<Container.ContainerOption>>(`/containers/list/byimage`, { name: image });
};
export const loadContainerUsers = (name: string) => {
return http.post<Array<string>>(`/containers/users`, { name: name });

View file

@ -805,6 +805,8 @@ const message = {
upgradeWarning2:
'The upgrade operation requires rebuilding the container, any unpersisted data will be lost. Do you wish to continue?',
oldImage: 'Current image',
sameImageContainer: 'Same-image containers',
sameImageHelper: 'Containers using the same image can be batch upgraded after selection',
targetImage: 'Target image',
imageLoadErr: 'No image name detected for the container',
appHelper: 'The container comes from the app store, and upgrading may make the service unavailable.',

View file

@ -783,6 +783,8 @@ const message = {
upgradeWarning2: 'アップグレード操作ではコンテナを再構築する必要があります続けたいですか',
oldImage: '現在の画像',
targetImage: 'ターゲット画像',
sameImageContainer: '同一イメージコンテナ',
sameImageHelper: '同一イメージを使用するコンテナは選択後一括アップグレード可能',
imageLoadErr: 'コンテナの画像名は検出されません',
appHelper:
'このコンテナはアプリストアから取得されたものでありアップグレードによってサービスが利用不可になる可能性があります',

View file

@ -777,6 +777,8 @@ const message = {
upgradeWarning2:
'업그레이드 작업은 컨테이너를 재빌드해야 하며, 비지속적인 데이터가 손실됩니다. 계속하시겠습니까?',
oldImage: '현재 이미지',
sameImageContainer: '동일 이미지 컨테이너',
sameImageHelper: '동일한 이미지를 사용하는 컨테이너는 선택 일괄 업그레이드 가능',
targetImage: '대상 이미지',
imageLoadErr: '컨테이너에 대한 이미지 이름이 감지되지 않았습니다.',
appHelper: ' 컨테이너는 스토어에서 왔으며 업그레이드 서비스가 중단될 있습니다.',

View file

@ -798,6 +798,8 @@ const message = {
upgradeWarning2:
'Operasi peningkatan memerlukan pembinaan semula kontena, sebarang data yang tidak disimpan akan hilang. Adakah anda mahu meneruskan?',
oldImage: 'Imej semasa',
sameImageContainer: 'Kontena imej sama',
sameImageHelper: 'Kontena yang menggunakan imej sama boleh dinaik taraf secara berkumpulan setelah dipilih',
targetImage: 'Imej sasaran',
imageLoadErr: 'Tiada nama imej dikesan untuk kontena',
appHelper:

View file

@ -794,6 +794,8 @@ const message = {
upgradeWarning2:
'A operação de upgrade requer a reconstrução do contêiner, e qualquer dado não persistente será perdido. Deseja continuar?',
oldImage: 'Imagem atual',
sameImageContainer: 'Contêineres com mesma imagem',
sameImageHelper: 'Contêineres usando a mesma imagem podem ser atualizados em lote após seleção',
targetImage: 'Imagem alvo',
imageLoadErr: 'Nenhum nome de imagem detectado para o contêiner',
appHelper: 'O contêiner vem da loja de aplicativos, e o upgrade pode tornar o serviço indisponível.',

View file

@ -793,6 +793,8 @@ const message = {
upgradeWarning2:
'Операция обновления требует пересборки контейнера, все несохраненные данные будут потеряны. Хотите продолжить?',
oldImage: 'Текущий образ',
sameImageContainer: 'Контейнеры с одинаковым образом',
sameImageHelper: 'Контейнеры, использующие один образ, можно массово обновить после выбора',
targetImage: 'Целевой образ',
imageLoadErr: 'Не обнаружено имя образа для контейнера',
appHelper: 'Контейнер происходит из магазина приложений, и обновление может сделать сервис недоступным.',

View file

@ -815,6 +815,8 @@ const message = {
upgradeWarning2:
'Yükseltme işlemi konteynerin yeniden oluşturulmasını gerektirir, kalıcı olmayan tüm veriler kaybedilecektir. Devam etmek istiyor musunuz?',
oldImage: 'Mevcut imaj',
sameImageContainer: 'Aynı imajlı konteynerler',
sameImageHelper: 'Aynı imajı kullanan konteynerlar seçilerek toplu şekilde güncellenebilir',
targetImage: 'Hedef imaj',
imageLoadErr: 'Konteyner için imaj adı algılanmadı',
appHelper: 'Konteyner uygulama mağazasından geliyor ve yükseltme hizmeti kullanılamaz hale getirebilir.',

View file

@ -770,6 +770,8 @@ const message = {
upgradeHelper: '倉庫名稱/鏡像名稱:鏡像版本',
upgradeWarning2: '升級操作需要重建容器任何未持久化的數據將會丟失是否繼續',
oldImage: '當前鏡像',
sameImageContainer: '同鏡像容器',
sameImageHelper: '同鏡像容器可勾選後批量升級',
targetImage: '目標鏡像',
imageLoadErr: '未檢測到容器的鏡像名稱',
appHelper: '該容器來源於應用商店升級可能導致該服務不可用',

View file

@ -769,6 +769,8 @@ const message = {
upgradeHelper: '仓库名称/镜像名称:镜像版本',
upgradeWarning2: '升级操作需要重建容器任何未持久化的数据将会丢失是否继续',
oldImage: '当前镜像',
sameImageContainer: '同镜像容器',
sameImageHelper: '同镜像容器可勾选后批量升级',
targetImage: '目标镜像',
imageLoadErr: '未检测到容器的镜像名称',
appHelper: '该容器来源于应用商店升级可能导致该服务不可用',

View file

@ -1,11 +1,5 @@
<template>
<DrawerPro
v-model="drawerVisible"
:header="$t('commons.button.upgrade')"
@close="handleClose"
:resource="form.name"
size="large"
>
<DrawerPro v-model="drawerVisible" :header="$t('commons.button.upgrade')" @close="handleClose" size="large">
<el-alert
:title="$t('container.appHelper')"
v-if="form.fromApp"
@ -20,6 +14,24 @@
</el-tooltip>
<el-tag v-else>{{ form.oldImageName }}</el-tag>
</el-form-item>
<el-form-item :label="$t('container.sameImageContainer')" v-if="containerOptions.length > 1">
<div class="w-full">
<el-checkbox v-model="checkAll" :indeterminate="isIndeterminate" @change="handleCheckAllChange">
{{ $t('commons.table.all') }}
</el-checkbox>
</div>
<el-checkbox-group v-model="form.names" @change="handleCheckedChange">
<el-checkbox
v-for="item in containerOptions"
:key="item.name"
:label="item.name"
:value="item.name"
>
{{ item.name }}
</el-checkbox>
</el-checkbox-group>
<span class="input-help">{{ $t('container.sameImageHelper') }}</span>
</el-form-item>
<el-form-item prop="newImageName" :rules="Rules.imageName">
<template #label>
{{ $t('container.targetImage') }}
@ -52,20 +64,20 @@
</template>
<script lang="ts" setup>
import { upgradeContainer } from '@/api/modules/container';
import { listContainerByImage, upgradeContainer } from '@/api/modules/container';
import { Rules } from '@/global/form-rules';
import TaskLog from '@/components/log/task/index.vue';
import i18n from '@/lang';
import { MsgSuccess } from '@/utils/message';
import { newUUID } from '@/utils/util';
import { ElForm } from 'element-plus';
import { CheckboxValueType, ElForm } from 'element-plus';
import { reactive, ref } from 'vue';
const loading = ref(false);
const taskLogRef = ref();
const form = reactive({
name: '',
names: [],
oldImageName: '',
newImageName: '',
hasName: true,
@ -79,13 +91,18 @@ const formRef = ref<FormInstance>();
const drawerVisible = ref<boolean>(false);
type FormInstance = InstanceType<typeof ElForm>;
const containerOptions = ref([]);
const isIndeterminate = ref();
const checkAll = ref();
interface DialogProps {
container: string;
image: string;
fromApp: boolean;
}
const acceptParams = (props: DialogProps): void => {
form.name = props.container;
form.names = [props.container];
isIndeterminate.value = true;
form.oldImageName = props.image;
form.fromApp = props.fromApp;
form.hasName = props.image.indexOf('sha256:') === -1;
@ -94,10 +111,32 @@ const acceptParams = (props: DialogProps): void => {
} else {
form.newImageName = '';
}
loadContainers();
drawerVisible.value = true;
};
const emit = defineEmits<{ (e: 'search'): void }>();
const loadContainers = async () => {
const res = await listContainerByImage(form.oldImageName);
containerOptions.value = res.data || [];
};
const handleCheckAllChange = (val: CheckboxValueType) => {
form.names = [];
if (!val) {
isIndeterminate.value = false;
return;
}
for (const item of containerOptions.value) {
form.names.push(item.name);
}
};
const handleCheckedChange = (value: CheckboxValueType[]) => {
const checkedCount = value.length;
checkAll.value = checkedCount === containerOptions.value.length;
isIndeterminate.value = checkedCount > 0 && checkedCount < containerOptions.value.length;
};
const onSubmit = async (formEl: FormInstance | undefined) => {
if (!formEl) return;
formEl.validate(async (valid) => {
@ -109,7 +148,7 @@ const onSubmit = async (formEl: FormInstance | undefined) => {
let taskID = newUUID();
let param = {
taskID: taskID,
name: form.name,
names: form.names,
image: form.newImageName,
forcePull: form.forcePull,
};