diff --git a/agent/app/api/v2/container.go b/agent/app/api/v2/container.go index 3b4718249..d8f43c678 100644 --- a/agent/app/api/v2/container.go +++ b/agent/app/api/v2/container.go @@ -965,6 +965,24 @@ func (b *BaseApi) ContainerStreamLogs(c *gin.Context) { containerService.StreamLogs(c, streamLog) } +// CleanNetworks submits a network cleanup task. +// @Tags Container Network +// @Summary Submit unused network cleanup task +// @Produce json +// @Success 200 {object} dto.NetworkCleanupTask +// @Security ApiKeyAuth +// @Security Timestamp +// @Router /containers/network/clean [post] +// @x-panel-log {"bodyKeys":[],"paramKeys":[],"BeforeFunctions":[],"formatZH":"清理未使用的容器网络","formatEN":"Clean unused container networks"} +func (b *BaseApi) CleanNetworks(c *gin.Context) { + result, err := containerService.CleanNetworks() + if err != nil { + helper.InternalServer(c, err) + return + } + helper.SuccessWithData(c, result) +} + func (b *BaseApi) CleanNetworks(c *gin.Context) { result, err := containerService.CleanNetworks() if err != nil { diff --git a/agent/app/service/container_network_cleanup_test.go b/agent/app/service/container_network_cleanup_test.go new file mode 100644 index 000000000..89373f340 --- /dev/null +++ b/agent/app/service/container_network_cleanup_test.go @@ -0,0 +1,113 @@ +package service + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/1Panel-dev/1Panel/agent/app/model" + "github.com/1Panel-dev/1Panel/agent/app/task" + "github.com/1Panel-dev/1Panel/agent/constant" + "github.com/1Panel-dev/1Panel/agent/global" + "github.com/1Panel-dev/1Panel/agent/i18n" + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/network" + "github.com/docker/docker/errdefs" + "github.com/glebarez/sqlite" + "gorm.io/gorm" +) + +type networkTaskClient struct{ fail bool } + +func (f networkTaskClient) NetworkList(context.Context, network.ListOptions) ([]network.Inspect, error) { + return []network.Inspect{{ID: "reserved", Name: "1panel-network", Scope: "local"}, {ID: "connected", Name: "busy", Scope: "local"}, {ID: "unused", Name: "free", Scope: "local"}, {ID: "race", Name: "race", Scope: "local"}}, nil +} +func (f networkTaskClient) ContainerList(context.Context, container.ListOptions) ([]container.Summary, error) { + return nil, nil +} +func (f networkTaskClient) NetworkInspect(_ context.Context, id string, _ network.InspectOptions) (network.Inspect, error) { + n := network.Inspect{} + if id == "connected" { + n.Containers = map[string]network.EndpointResource{"container-id": {}} + } + return n, nil +} +func (f networkTaskClient) NetworkRemove(_ context.Context, id string) error { + if id == "race" { + return errdefs.Conflict(errors.New("has active endpoints")) + } + if id != "unused" { + return errors.New("unexpected removal") + } + if f.fail { + return errors.New("remove failed") + } + return nil +} + +func TestNetworkCleanupTaskPersistsLogsAndStatus(t *testing.T) { + oldDB, oldTaskDB, oldDir, oldI18n := global.DB, global.TaskDB, global.Dir, global.I18n + t.Cleanup(func() { global.DB = oldDB; global.TaskDB = oldTaskDB; global.Dir = oldDir; global.I18n = oldI18n }) + dir := t.TempDir() + db, err := gorm.Open(sqlite.Open(filepath.Join(dir, "tasks.db")), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + sqlDB, err := db.DB() + if err != nil { + t.Fatal(err) + } + defer sqlDB.Close() + if err := db.AutoMigrate(&model.Task{}); err != nil { + t.Fatal(err) + } + global.DB = nil + global.TaskDB = db + global.Dir.TaskDir = dir + i18n.Init() + for _, fail := range []bool{false, true} { + name := "success" + wantStatus := constant.StatusSuccess + if fail { + name = "partial failure" + wantStatus = constant.StatusFailed + } + t.Run(name, func(t *testing.T) { + item, err := task.NewTaskWithOps("Network", task.TaskClean, task.TaskScopeContainer, "", 0) + if err != nil { + t.Fatal(err) + } + item.AddSubTask("Clean", func(t *task.Task) error { return executeNetworkCleanup(t, networkTaskClient{fail: fail}) }, nil) + err = item.Execute() + if (err != nil) != fail { + t.Fatalf("unexpected execution error: %v", err) + } + var saved model.Task + if err := db.First(&saved, "id = ?", item.TaskID).Error; err != nil { + t.Fatal(err) + } + if saved.Status != wantStatus { + t.Fatalf("status %s, want %s", saved.Status, wantStatus) + } + content, err := os.ReadFile(saved.LogFile) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{"[busy] (connected): containers connected", "[race] (race): containers connected", "[1panel-network] (reserved): reserved network", "Network cleanup finished:", "[TASK-END]"} { + if !strings.Contains(string(content), want) { + t.Fatalf("missing %q in log: %s", want, content) + } + } + want := "Deleted network [free]" + if fail { + want = "Failed to remove network [free]" + } + if !strings.Contains(string(content), want) { + t.Fatalf("missing %q", want) + } + }) + } +} diff --git a/agent/utils/docker/network_cleanup_test.go b/agent/utils/docker/network_cleanup_test.go new file mode 100644 index 000000000..46e605c69 --- /dev/null +++ b/agent/utils/docker/network_cleanup_test.go @@ -0,0 +1,72 @@ +package docker + +import ( + "context" + "errors" + "reflect" + "testing" + + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/network" + "github.com/docker/docker/errdefs" +) + +type cleanupFake struct { + networks []network.Inspect + containers []container.Summary + inspected map[string]network.Inspect + inspectErrors, removeErrors map[string]error + listError error + removed []string +} + +func (f *cleanupFake) NetworkList(context.Context, network.ListOptions) ([]network.Inspect, error) { + return f.networks, nil +} +func (f *cleanupFake) ContainerList(_ context.Context, o container.ListOptions) ([]container.Summary, error) { + if !o.All { + panic("must include stopped containers") + } + return f.containers, f.listError +} +func (f *cleanupFake) NetworkInspect(_ context.Context, id string, _ network.InspectOptions) (network.Inspect, error) { + return f.inspected[id], f.inspectErrors[id] +} +func (f *cleanupFake) NetworkRemove(_ context.Context, id string) error { + f.removed = append(f.removed, id) + return f.removeErrors[id] +} + +func TestNetworkCleanupProtectsAndReports(t *testing.T) { + f := &cleanupFake{inspected: map[string]network.Inspect{"attached": {Containers: map[string]network.EndpointResource{"container": {}}}}, inspectErrors: map[string]error{"unknown": errors.New("inspect failure")}, removeErrors: map[string]error{"race": errdefs.Conflict(errors.New("has active endpoints")), "gone": errdefs.NotFound(errors.New("gone")), "failed": errors.New("denied")}} + for _, name := range []string{"none", "host", "bridge", "1panel-network", "configured", "attached", "stopped", "free", "unknown", "race", "gone", "failed"} { + f.networks = append(f.networks, network.Inspect{ID: name, Name: name, Scope: "local"}) + } + // Compose ownership labels do not protect an otherwise unused network. + for i := range f.networks { + if f.networks[i].Name == "configured" { + f.networks[i].Labels = map[string]string{"com.docker.compose.project": "demo", "com.docker.compose.network": "default"} + } + } + f.containers = []container.Summary{{State: "exited", NetworkSettings: &container.NetworkSettingsSummary{Networks: map[string]*network.EndpointSettings{"stopped": {NetworkID: "stopped"}}}}} + report, err := CleanUnusedNetworks(context.Background(), f) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(f.removed, []string{"configured", "failed", "free", "gone", "race"}) { + t.Fatal(f.removed) + } + if len(report.Deleted) != 2 || report.Deleted[0].Name != "configured" || report.Deleted[1].Name != "free" || len(report.Failed) != 2 || len(report.Skipped) != 8 { + t.Fatalf("%+v", report) + } +} + +func TestNetworkCleanupDiscoveryFailureDeletesNothing(t *testing.T) { + f := &cleanupFake{networks: []network.Inspect{{ID: "free", Name: "free", Scope: "local"}}, listError: errors.New("cannot list containers")} + if _, err := CleanUnusedNetworks(context.Background(), f); err == nil { + t.Fatal("expected error") + } + if len(f.removed) > 0 { + t.Fatal(f.removed) + } +}