Skip to content

Commit f917e89

Browse files
authored
Merge pull request #18 from LiteyukiStudio/codex/fix-cluster-resource-drift
fix 🐛: 修复集群资源漂移与删除清理
2 parents 6694c4f + 045805d commit f917e89

36 files changed

Lines changed: 1874 additions & 310 deletions

‎internal/api/application_bridge.go‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package api
22

33
import (
44
"context"
5+
"errors"
56

67
"github.com/LiteyukiStudio/devops/internal/api/applicationapi"
78
"github.com/LiteyukiStudio/devops/internal/api/buildapi"
@@ -11,6 +12,7 @@ import (
1112
kubeprovider "github.com/LiteyukiStudio/devops/internal/provider/kubernetes"
1213
"github.com/LiteyukiStudio/devops/internal/tasks"
1314
"github.com/gin-gonic/gin"
15+
"github.com/hibiken/asynq"
1416
"gorm.io/gorm"
1517
)
1618

@@ -149,5 +151,5 @@ func (host applicationHost) EnqueueApplicationDelete(ctx context.Context, app mo
149151
_, err := host.handlers.taskClient.EnqueueApplicationDelete(ctx, tasks.ApplicationDeletePayload{
150152
ApplicationID: app.ID, ProjectID: app.ProjectID, ActorID: actorID, DeleteData: deleteData,
151153
})
152-
return err == nil
154+
return err == nil || errors.Is(err, asynq.ErrDuplicateTask)
153155
}

‎internal/api/handlers_core_test.go‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ func TestHighGrowthListHandlersCannotBypassPaginationContract(t *testing.T) {
152152
t.Run(contract.function, func(t *testing.T) {
153153
function := parseAPIFunction(t, contract.file, contract.function)
154154
calls := calledFunctions(function.Body)
155-
if !calls["paginatedResponse"] {
155+
if !calls["paginatedResponse"] && !calls["clusterResourcePaginatedResponse"] {
156156
t.Fatalf("%s must return the shared pagination envelope", contract.function)
157157
}
158158
sortNormalized := calls["paginationFromQueryWithSort"] || calls["normalizeClusterResourceSortBy"] || calls["gitRepositoryPagination"]
@@ -163,7 +163,7 @@ func TestHighGrowthListHandlersCannotBypassPaginationContract(t *testing.T) {
163163
calls["buildRunPageQuery"] || calls["containerImagePageQuery"] ||
164164
calls["deploymentTargetPageQuery"] || calls["projectPageQuery"] ||
165165
calls["ListRepositories"] || calls["SearchPublicRepositories"] ||
166-
calls["ListManagedResourcesPage"] || calls["ListManagedResourceEventsPage"]
166+
calls["clusterResourcePaginatedResponse"] || calls["ListManagedResourceEventsPage"]
167167
if !bounded {
168168
t.Fatalf("%s must apply LIMIT/OFFSET or bounded slice pagination", contract.function)
169169
}

‎internal/api/handlers_tasks_test.go‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,19 @@ func TestEnqueueResourceCleanupPassesActor(t *testing.T) {
7474
}
7575
}
7676

77+
func TestEnqueueApplicationDeleteTreatsExistingTaskAsQueued(t *testing.T) {
78+
fake := &fakeBuildTaskEnqueuer{applicationDeleteError: asynq.ErrDuplicateTask}
79+
h := &Handlers{taskClient: fake}
80+
81+
app := model.Application{ID: "app_1", ProjectID: "prj_1"}
82+
if !((applicationHost{domainHost{handlers: h}}).EnqueueApplicationDelete(context.Background(), app, "usr_operator", false)) {
83+
t.Fatal("existing application deletion task must be treated as queued")
84+
}
85+
if fake.applicationDeletePayload.ApplicationID != app.ID || fake.applicationDeletePayload.ProjectID != app.ProjectID {
86+
t.Fatalf("application deletion payload = %#v", fake.applicationDeletePayload)
87+
}
88+
}
89+
7790
func TestRollbackReleaseFromTargetUsesPreviousSuccessfulRelease(t *testing.T) {
7891
source := model.Release{
7992
ID: "rel_current",
@@ -205,6 +218,7 @@ type fakeBuildTaskEnqueuer struct {
205218
gatewayPayload tasks.GatewayApplyPayload
206219
notificationPayload tasks.NotificationDeliverPayload
207220
applicationDeletePayload tasks.ApplicationDeletePayload
221+
applicationDeleteError error
208222
resourceCleanupPayload tasks.ResourceCleanupPayload
209223
}
210224

@@ -230,7 +244,7 @@ func (f *fakeBuildTaskEnqueuer) EnqueueNotificationDeliver(_ context.Context, pa
230244

231245
func (f *fakeBuildTaskEnqueuer) EnqueueApplicationDelete(_ context.Context, payload tasks.ApplicationDeletePayload) (*asynq.TaskInfo, error) {
232246
f.applicationDeletePayload = payload
233-
return &asynq.TaskInfo{}, nil
247+
return &asynq.TaskInfo{}, f.applicationDeleteError
234248
}
235249

236250
func (f *fakeBuildTaskEnqueuer) EnqueueResourceCleanup(_ context.Context, payload tasks.ResourceCleanupPayload) (*asynq.TaskInfo, error) {
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package runtimeapi
2+
3+
import (
4+
"testing"
5+
6+
"github.com/LiteyukiStudio/devops/internal/model"
7+
"github.com/LiteyukiStudio/devops/internal/testdb"
8+
"gorm.io/gorm"
9+
)
10+
11+
func TestRuntimeClusterReferenceCountsCoverImplicitTargetsAndProjectVolumes(t *testing.T) {
12+
db := testdb.Open(t, testdb.Options{
13+
SchemaPrefix: "runtime_cluster_delete_refs_test",
14+
Migrate: func(db *gorm.DB) error {
15+
return db.AutoMigrate(&model.Project{}, &model.RuntimeCluster{}, &model.DeploymentTarget{}, &model.ProjectVolume{})
16+
},
17+
})
18+
projects := []model.Project{
19+
{ID: "prj_one", Identifier: "one", Name: "One"},
20+
{ID: "prj_two", Identifier: "two", Name: "Two"},
21+
{ID: "prj_three", Identifier: "three", Name: "Three"},
22+
}
23+
if err := db.Create(&projects).Error; err != nil {
24+
t.Fatalf("create projects: %v", err)
25+
}
26+
defaultCluster := model.RuntimeCluster{ID: "clu_default", Name: "Default", Scope: "global", IsDefault: true, DeleteStatus: "active"}
27+
otherCluster := model.RuntimeCluster{ID: "clu_other", Name: "Other", Scope: "global", DeleteStatus: "active"}
28+
if err := db.Create(&[]model.RuntimeCluster{defaultCluster, otherCluster}).Error; err != nil {
29+
t.Fatalf("create runtime clusters: %v", err)
30+
}
31+
targets := []model.DeploymentTarget{
32+
{ID: "dplt_implicit", ProjectID: "prj_one", ApplicationID: "app_one", Name: "Implicit", Stage: "prod"},
33+
{ID: "dplt_default", ProjectID: "prj_two", ApplicationID: "app_two", Name: "Default", Stage: "prod", ClusterID: defaultCluster.ID},
34+
{ID: "dplt_other", ProjectID: "prj_three", ApplicationID: "app_three", Name: "Other", Stage: "prod", ClusterID: otherCluster.ID},
35+
}
36+
if err := db.Create(&targets).Error; err != nil {
37+
t.Fatalf("create deployment targets: %v", err)
38+
}
39+
volumes := []model.ProjectVolume{
40+
projectVolumeReferenceFixture("pvol_default", "prj_one", defaultCluster.ID),
41+
projectVolumeReferenceFixture("pvol_other", "prj_two", otherCluster.ID),
42+
}
43+
if err := db.Create(&volumes).Error; err != nil {
44+
t.Fatalf("create project volumes: %v", err)
45+
}
46+
47+
targetCount, volumeCount, err := runtimeClusterReferenceCounts(db, defaultCluster)
48+
if err != nil {
49+
t.Fatalf("default runtime cluster reference counts: %v", err)
50+
}
51+
if targetCount != 2 || volumeCount != 1 {
52+
t.Fatalf("default cluster references = targets:%d volumes:%d, want 2 and 1", targetCount, volumeCount)
53+
}
54+
targetCount, volumeCount, err = runtimeClusterReferenceCounts(db, otherCluster)
55+
if err != nil {
56+
t.Fatalf("other runtime cluster reference counts: %v", err)
57+
}
58+
if targetCount != 1 || volumeCount != 1 {
59+
t.Fatalf("other cluster references = targets:%d volumes:%d, want 1 and 1", targetCount, volumeCount)
60+
}
61+
}
62+
63+
func projectVolumeReferenceFixture(id, projectID, clusterID string) model.ProjectVolume {
64+
return model.ProjectVolume{
65+
ID: id, ProjectID: projectID, DisplayName: id, ClusterID: clusterID,
66+
Namespace: "ns-" + projectID, ClaimName: id, OwnershipMode: model.ProjectVolumeOwnershipManaged,
67+
SourceKind: model.ProjectVolumeSourceBlank, LifecycleState: model.ProjectVolumeLifecycleReady,
68+
CapacityRequest: "1Gi", CapacityBytes: 1024 * 1024 * 1024,
69+
AccessMode: model.ProjectVolumeAccessReadWriteOnce, VolumeMode: model.ProjectVolumeModeFilesystem,
70+
CreatedBy: "usr_test",
71+
}
72+
}

‎internal/api/runtimeapi/runtime_cluster_handlers.go‎

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package runtimeapi
22

33
import (
44
"context"
5+
"errors"
56
"net/http"
67
"strings"
78
"time"
@@ -227,13 +228,17 @@ func (h *Handlers) DeleteRuntimeCluster(ctx *gin.Context) {
227228
if !h.canManageScopedResourceByID(ctx, user, cluster.Scope, cluster.OwnerRef, scopedResourceRuntimeCluster, cluster.ID, "无权维护该运行集群") {
228229
return
229230
}
230-
var targetCount int64
231-
if err := h.dbFor(ctx).Model(&model.DeploymentTarget{}).Where("cluster_id = ?", cluster.ID).Count(&targetCount).Error; err != nil {
231+
targetCount, volumeCount, err := runtimeClusterReferenceCounts(h.dbFor(ctx), cluster)
232+
if err != nil {
232233
writeError(ctx, http.StatusInternalServerError, err.Error())
233234
return
234235
}
235236
if targetCount > 0 {
236-
writeError(ctx, http.StatusConflict, "运行集群仍被部署配置引用,请先迁移或删除相关部署配置")
237+
writeErrorCode(ctx, http.StatusConflict, "runtime_cluster.in_use", "runtime cluster is referenced by active deployment targets")
238+
return
239+
}
240+
if volumeCount > 0 {
241+
writeErrorCode(ctx, http.StatusConflict, "runtime_cluster.in_use", "runtime cluster is referenced by active project volumes")
237242
return
238243
}
239244
if !h.deleteStatusCanStart(cluster.DeleteStatus) {
@@ -260,6 +265,39 @@ func (h *Handlers) DeleteRuntimeCluster(ctx *gin.Context) {
260265
ctx.Status(http.StatusNoContent)
261266
}
262267

268+
func runtimeClusterReferenceCounts(db *gorm.DB, cluster model.RuntimeCluster) (int64, int64, error) {
269+
targetCount, volumeCount, err := runtimecluster.ActiveProjectReferenceCounts(db, cluster.ID)
270+
if err != nil {
271+
return 0, 0, err
272+
}
273+
if cluster.Scope == "global" {
274+
var defaultCluster model.RuntimeCluster
275+
err = runtimecluster.ActiveScope(db).
276+
Where("scope = ? and is_default = ?", "global", true).
277+
First(&defaultCluster).Error
278+
if errors.Is(err, gorm.ErrRecordNotFound) {
279+
err = runtimecluster.ActiveScope(db).
280+
Where("scope = ?", "global").
281+
Order("created_at asc").
282+
First(&defaultCluster).Error
283+
}
284+
if err != nil {
285+
return 0, 0, err
286+
}
287+
if defaultCluster.ID == cluster.ID {
288+
var implicitTargetCount int64
289+
if err := db.Model(&model.DeploymentTarget{}).
290+
Joins("join projects on projects.id = deployment_targets.project_id and projects.deleted_at is null").
291+
Where("cluster_id = ''").
292+
Count(&implicitTargetCount).Error; err != nil {
293+
return 0, 0, err
294+
}
295+
targetCount += implicitTargetCount
296+
}
297+
}
298+
return targetCount, volumeCount, nil
299+
}
300+
263301
func (h *Handlers) TestRuntimeCluster(ctx *gin.Context) {
264302
markLiveObservationResponse(ctx)
265303
user, ok := h.currentUser(ctx)

‎internal/api/runtimeapi/runtime_cluster_resource_handlers.go‎

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,6 @@ func (h *Handlers) ListRuntimeClusterResources(ctx *gin.Context) {
3737
return
3838
}
3939
pagination := paginationFromQuery(ctx)
40-
if pagination.Page != 1 {
41-
writeErrorCode(ctx, http.StatusBadRequest, "pagination.cursor_required", "runtime cluster resources only support the first bounded page")
42-
return
43-
}
4440
kubeconfig := h.secrets.ResolveContext(ctx.Request.Context(), cluster.KubeconfigRef)
4541
if strings.TrimSpace(kubeconfig) == "" {
4642
writeError(ctx, http.StatusBadRequest, "运行集群缺少 kubeconfig,无法读取资源")
@@ -57,7 +53,6 @@ func (h *Handlers) ListRuntimeClusterResources(ctx *gin.Context) {
5753
ProjectID: strings.TrimSpace(ctx.Query("projectId")),
5854
ApplicationID: strings.TrimSpace(ctx.Query("applicationId")),
5955
DeploymentTargetID: strings.TrimSpace(ctx.Query("deploymentTargetId")),
60-
Limit: int64(pagination.PageSize),
6156
}
6257
if !validRuntimeResourceCategory(options.Kind) {
6358
writeRuntimeResourceArgumentError(ctx, "cluster.resource_category_invalid", "resourceCategory", runtimeResourceCategories)
@@ -68,12 +63,12 @@ func (h *Handlers) ListRuntimeClusterResources(ctx *gin.Context) {
6863
}
6964
requestCtx, cancel := context.WithTimeout(ctx.Request.Context(), 10*time.Second)
7065
defer cancel()
71-
page, err := client.ListManagedResourcesPage(requestCtx, options)
66+
items, err := client.ListManagedResources(requestCtx, options)
7267
if err != nil {
7368
writeError(ctx, http.StatusBadGateway, "集群资源读取失败,请检查集群连接和权限")
7469
return
7570
}
76-
items := h.filterClusterResourceSnapshots(ctx, user, page.Items, visibility, options.ProjectID)
71+
items = h.filterClusterResourceSnapshots(ctx, user, items, visibility, options.ProjectID)
7772
responses, err := h.clusterResourceResponses(items, ctx.Request.Context())
7873
if err != nil {
7974
writeError(ctx, http.StatusInternalServerError, err.Error())
@@ -83,13 +78,7 @@ func (h *Handlers) ListRuntimeClusterResources(ctx *gin.Context) {
8378
responses = groupWorkloadPodResponses(responses)
8479
}
8580
pagination.SortBy = normalizeClusterResourceSortBy(pagination.SortBy)
86-
sortClusterResourceResponses(responses, pagination)
87-
pageItems := paginateSlice(responses, pagination)
88-
// A resource category fans out to several Kubernetes kinds, each with an
89-
// independent continue token. A numeric global page cannot preserve the
90-
// requested cross-kind sort without draining every kind. Keep this endpoint
91-
// to one bounded page and do not advertise inaccessible remaining pages.
92-
ctx.JSON(http.StatusOK, paginatedResponse(pageItems, int64(len(pageItems)), pagination))
81+
ctx.JSON(http.StatusOK, clusterResourcePaginatedResponse(responses, pagination))
9382
}
9483

9584
func (h *Handlers) GetRuntimeClusterResourceYAML(ctx *gin.Context) {

‎internal/api/runtimeapi/runtime_cluster_resource_responses.go‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"strings"
77
"time"
88

9+
transportapi "github.com/LiteyukiStudio/devops/internal/api/transport"
910
"github.com/LiteyukiStudio/devops/internal/model"
1011
kubeprovider "github.com/LiteyukiStudio/devops/internal/provider/kubernetes"
1112
)
@@ -178,6 +179,11 @@ func sortClusterResourceResponses(items []clusterResourceResponse, pagination pa
178179
})
179180
}
180181

182+
func clusterResourcePaginatedResponse(items []clusterResourceResponse, pagination paginationParams) transportapi.PaginatedResponseBody[clusterResourceResponse] {
183+
sortClusterResourceResponses(items, pagination)
184+
return paginatedResponse(paginateSlice(items, pagination), int64(len(items)), pagination)
185+
}
186+
181187
func groupWorkloadPodResponses(items []clusterResourceResponse) []clusterResourceResponse {
182188
deployments := make([]clusterResourceResponse, 0, len(items))
183189
pods := make([]clusterResourceResponse, 0)

‎internal/api/runtimeapi/runtime_cluster_resource_responses_test.go‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
package runtimeapi
22

3-
import "testing"
3+
import (
4+
"fmt"
5+
"testing"
6+
)
47

58
func TestGroupWorkloadPodResponses(t *testing.T) {
69
items := []clusterResourceResponse{
@@ -27,3 +30,24 @@ func TestGroupWorkloadPodResponses(t *testing.T) {
2730
t.Fatalf("expected unmatched pod to stay top-level, got %s/%s", grouped[2].Kind, grouped[2].Name)
2831
}
2932
}
33+
34+
func TestClusterResourcePaginatedResponseUsesCompleteSortedSet(t *testing.T) {
35+
filteredItems := make([]clusterResourceResponse, 0, 25)
36+
for index := 24; index >= 0; index-- {
37+
filteredItems = append(filteredItems, clusterResourceResponse{Name: fmt.Sprintf("resource-%02d", index)})
38+
}
39+
40+
response := clusterResourcePaginatedResponse(filteredItems, paginationParams{
41+
Page: 2,
42+
PageSize: 10,
43+
SortBy: "name",
44+
SortOrder: "asc",
45+
})
46+
47+
if response.Page != 2 || response.Total != 25 || response.TotalPages != 3 {
48+
t.Fatalf("pagination = page %d, total %d, totalPages %d", response.Page, response.Total, response.TotalPages)
49+
}
50+
if len(response.Items) != 10 || response.Items[0].Name != "resource-10" || response.Items[9].Name != "resource-19" {
51+
t.Fatalf("second page = %#v", response.Items)
52+
}
53+
}

‎internal/database/fresh_database_migration_test.go‎

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -21,16 +21,21 @@ import (
2121
"gorm.io/gorm/schema"
2222
)
2323

24-
const baselineMigrationVersion = 103
24+
const (
25+
baselineMigrationVersion = 103
26+
latestMigrationVersion = 104
27+
)
2528

26-
func TestEmbeddedMigrationsContainSingleBaseline(t *testing.T) {
29+
func TestEmbeddedMigrationsContainExpectedFiles(t *testing.T) {
2730
entries, err := sqlmigrations.FS.ReadDir(".")
2831
if err != nil {
2932
t.Fatalf("read embedded migrations: %v", err)
3033
}
31-
baselineFiles := map[string]bool{
32-
"000103_baseline.up.sql": false,
33-
"000103_baseline.down.sql": false,
34+
migrationFiles := map[string]bool{
35+
"000103_baseline.up.sql": false,
36+
"000103_baseline.down.sql": false,
37+
"000104_bridge_retained_volumes.up.sql": false,
38+
"000104_bridge_retained_volumes.down.sql": false,
3439
}
3540
migrationCount := 0
3641
for _, entry := range entries {
@@ -39,17 +44,17 @@ func TestEmbeddedMigrationsContainSingleBaseline(t *testing.T) {
3944
continue
4045
}
4146
migrationCount++
42-
if _, ok := baselineFiles[name]; !ok {
47+
if _, ok := migrationFiles[name]; !ok {
4348
t.Fatalf("unexpected embedded migration %s", name)
4449
}
45-
baselineFiles[name] = true
50+
migrationFiles[name] = true
4651
}
47-
if migrationCount != len(baselineFiles) {
48-
t.Fatalf("embedded migration file count = %d, want %d", migrationCount, len(baselineFiles))
52+
if migrationCount != len(migrationFiles) {
53+
t.Fatalf("embedded migration file count = %d, want %d", migrationCount, len(migrationFiles))
4954
}
50-
for name, found := range baselineFiles {
55+
for name, found := range migrationFiles {
5156
if !found {
52-
t.Fatalf("baseline is missing %s", name)
57+
t.Fatalf("embedded migrations are missing %s", name)
5358
}
5459
}
5560
}
@@ -195,8 +200,9 @@ WHERE namespace.nspname = current_schema()
195200
}
196201
assertRunnerMigrationVersion(t, runner, baselineMigrationVersion)
197202
if err := MigrateContext(context.Background(), testDB); err != nil {
198-
t.Fatalf("repeat migration after baseline bootstrap: %v", err)
203+
t.Fatalf("apply migrations after baseline bootstrap: %v", err)
199204
}
205+
assertRunnerMigrationVersion(t, runner, latestMigrationVersion)
200206

201207
assertFreshMigrationState(t, testDB)
202208
assertStableModelMigrationCoverage(t, testDB)
@@ -262,8 +268,8 @@ func assertFreshMigrationState(t *testing.T, db *gorm.DB) {
262268
if migrationState.Dirty {
263269
t.Fatalf("fresh database migration is dirty at version %d", migrationState.Version)
264270
}
265-
if migrationState.Version != baselineMigrationVersion {
266-
t.Fatalf("migration version = %d, want %d", migrationState.Version, baselineMigrationVersion)
271+
if migrationState.Version != latestMigrationVersion {
272+
t.Fatalf("migration version = %d, want %d", migrationState.Version, latestMigrationVersion)
267273
}
268274
assertSlimmingMigrationRemovals(t, db)
269275
assertBaselineConstraintsAndDefaults(t, db)

0 commit comments

Comments
 (0)