Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions pkg/cli/initconfig/cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,8 @@ func createOrUpdateMongodbIndex(ctx context.Context) {
commonrepo.NewS3StorageColl(),
commonrepo.NewServiceColl(),
commonrepo.NewProductionServiceColl(),
commonrepo.NewServiceModuleColl(),
commonrepo.NewProductionServiceModuleColl(),
commonrepo.NewStrategyColl(),
commonrepo.NewStatsColl(),
commonrepo.NewSubscriptionColl(),
Expand Down
180 changes: 180 additions & 0 deletions pkg/cli/upgradeassistant/cmd/migrate/500.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
/*
Copyright 2026 The KodeRover Authors.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package migrate

import (
"context"
"fmt"

"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo"

internalmodels "github.com/koderover/zadig/v2/pkg/cli/upgradeassistant/internal/repository/models"
internalmongodb "github.com/koderover/zadig/v2/pkg/cli/upgradeassistant/internal/repository/mongodb"
"github.com/koderover/zadig/v2/pkg/cli/upgradeassistant/internal/upgradepath"
commonmodels "github.com/koderover/zadig/v2/pkg/microservice/aslan/core/common/repository/models"
commonrepo "github.com/koderover/zadig/v2/pkg/microservice/aslan/core/common/repository/mongodb"
"github.com/koderover/zadig/v2/pkg/microservice/aslan/core/common/service/repository"
"github.com/koderover/zadig/v2/pkg/tool/log"
)

const migration500ProgressEvery = 200

// legacyServiceForMigration500 is a local-to-this-migration view of a service
// template document. We deliberately do NOT reuse commonmodels.Service here:
// that struct now has bson:"-" on Containers (5.0.0 deprecation), so a normal
// decode would silently drop the legacy "containers" field that pre-5.0.0
// documents carry — which is exactly the field this migration needs to read
// to backfill service_module.
//
// Only the fields SyncAutoServiceModules touches are declared. Everything
// else in the legacy document is ignored.
type legacyServiceForMigration500 struct {
ServiceName string `bson:"service_name"`
ProductName string `bson:"product_name"`
Revision int64 `bson:"revision"`
Type string `bson:"type"`
Containers []*commonmodels.Container `bson:"containers,omitempty"`
}

func init() {
upgradepath.RegisterHandler("4.3.0", "5.0.0", V430ToV500)
upgradepath.RegisterHandler("5.0.0", "4.3.0", V500ToV430)
}

// V430ToV500 backfills the new service_module collection from the legacy
// Service.Containers field on every existing service template revision.
//
// Phase 3 already dual-writes for new revisions persisted after the deploy.
// This migration plugs the gap for everything that existed before.
//
// Idempotent: ReplaceAutoForRevision deletes-then-inserts per (service,
// revision), so a re-run from a partial migration produces the same result.
func V430ToV500() error {
migrationInfo, err := getMigrationInfo()
if err != nil {
return fmt.Errorf("failed to get migration info from db, err: %s", err)
}

defer func() {
updateMigrationError(migrationInfo.ID, err)
}()

err = migrateServiceModule500(migrationInfo)
if err != nil {
return err
}

return nil
}

func V500ToV430() error {
// Rollback: the new collection is additive — leaving the data in place
// is safe because the legacy Service.Containers field is still populated
// and authoritative on the 4.3.0 code path. Deferring an actual cleanup
// to avoid wiping records a re-roll-forward would rebuild.
return nil
}

// migrateServiceModule500 walks both template_service and
// production_template_service collections, mirroring each Service's
// Containers slice into service_module / production_service_module as
// auto records bound to the corresponding revision.
//
// Skipped when the migration flag is already set; backfill is otherwise
// idempotent and safe to re-run on partial completion.
func migrateServiceModule500(migrationInfo *internalmodels.Migration) error {
if migrationInfo.Migration500ServiceModule {
log.Infof("migration 5.0.0: service_module backfill already completed, skipping")
return nil
}

ctx, cancel := context.WithCancel(context.Background())
defer cancel()

testCount, err := backfillServiceModulesForCollection500(ctx, commonrepo.NewServiceColl().Collection, "template_service", false)
if err != nil {
return fmt.Errorf("failed to backfill service modules from template_service, err: %s", err)
}

prodCount, err := backfillServiceModulesForCollection500(ctx, commonrepo.NewProductionServiceColl().Collection, "production_template_service", true)
if err != nil {
return fmt.Errorf("failed to backfill service modules from production_template_service, err: %s", err)
}

log.Infof("migration 5.0.0: backfilled %d test + %d production service revisions into service_module", testCount, prodCount)

return internalmongodb.NewMigrationColl().UpdateMigrationStatus(migrationInfo.ID, map[string]interface{}{
getMigrationFieldBsonTag(migrationInfo, &migrationInfo.Migration500ServiceModule): true,
})
}

// backfillServiceModulesForCollection500 streams every document in the given
// service-template collection and mirrors its Containers slice into the new
// service_module collection (production-side picked by `production`).
//
// Per-document failures are logged and skipped so a single bad record
// (corrupt yaml, dead project) doesn't halt the whole migration. The
// returned count is the number of revisions successfully mirrored.
func backfillServiceModulesForCollection500(ctx context.Context, coll *mongo.Collection, label string, production bool) (int, error) {
cursor, err := coll.Find(ctx, bson.M{})
if err != nil {
return 0, fmt.Errorf("failed to open cursor over %s: %s", label, err)
}
defer cursor.Close(ctx)

migrated := 0
skipped := 0
for cursor.Next(ctx) {
// Decode into the local legacy view (see legacyServiceForMigration500
// above) — commonmodels.Service has bson:"-" on Containers and would
// silently drop the legacy field on decode.
var legacy legacyServiceForMigration500
if decodeErr := cursor.Decode(&legacy); decodeErr != nil {
log.Warnf("migration 5.0.0: failed to decode %s document, skipping: %s", label, decodeErr)
skipped++
continue
}
svc := &commonmodels.Service{
ServiceName: legacy.ServiceName,
ProductName: legacy.ProductName,
Revision: legacy.Revision,
Type: legacy.Type,
Containers: legacy.Containers,
}
// SyncAutoServiceModules tolerates empty Containers (no-op) and
// validates required fields itself. Errors here are logged but not
// fatal — one corrupt service shouldn't block the rest.
if syncErr := repository.SyncAutoServiceModules(ctx, svc, production); syncErr != nil {
log.Warnf("migration 5.0.0: failed to sync %s %s/%s rev %d: %s",
label, svc.ProductName, svc.ServiceName, svc.Revision, syncErr)
skipped++
continue
}
migrated++
if migrated%migration500ProgressEvery == 0 {
log.Infof("migration 5.0.0: %s progress — %d revisions mirrored, %d skipped", label, migrated, skipped)
}
}
if err := cursor.Err(); err != nil {
return migrated, fmt.Errorf("cursor over %s ended in error: %s", label, err)
}
if skipped > 0 {
log.Warnf("migration 5.0.0: %s complete — %d mirrored, %d skipped (inspect warn logs above)", label, migrated, skipped)
}
return migrated, nil
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ type Migration struct {
Migration430GlobalReadOnlyRole bool `bson:"migration_430_global_read_only_role"`
Migration430ScalePermission bool `bson:"migration_430_scale_permission"`
Migration430CollaborationScalePermission bool `bson:"migration_430_collaboration_scale_permission"`
Migration500ServiceModule bool `bson:"migration_500_service_module"`
Error string `bson:"error"`
}

Expand Down
11 changes: 10 additions & 1 deletion pkg/microservice/aslan/core/build/service/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ limitations under the License.
package service

import (
"context"
"errors"
"fmt"
"strings"
Expand All @@ -29,6 +30,7 @@ import (
commonmodels "github.com/koderover/zadig/v2/pkg/microservice/aslan/core/common/repository/models"
commonrepo "github.com/koderover/zadig/v2/pkg/microservice/aslan/core/common/repository/mongodb"
commonservice "github.com/koderover/zadig/v2/pkg/microservice/aslan/core/common/service"
"github.com/koderover/zadig/v2/pkg/microservice/aslan/core/common/service/repository"
commonutil "github.com/koderover/zadig/v2/pkg/microservice/aslan/core/common/util"
"github.com/koderover/zadig/v2/pkg/setting"
"github.com/koderover/zadig/v2/pkg/shared/client/systemconfig"
Expand Down Expand Up @@ -195,7 +197,14 @@ func ListBuildModulesByServiceModule(encryptedKey, productName, envName string,
ModuleBuilds: resp,
})
} else {
for _, container := range serviceTmpl.Containers {
// Service.Containers no longer persisted — pull merged modules
// (non-production: this function targets the test service path
// per its NewServiceColl().ListMaxRevisionsByProduct call above).
resolved, _, rerr := repository.ResolveServiceModules(context.Background(), serviceTmpl.ProductName, serviceTmpl.ServiceName, false, serviceTmpl.Revision)
if rerr != nil {
return nil, e.ErrListBuildModule.AddErr(rerr)
}
for _, container := range resolved {
opt := &commonrepo.BuildListOption{
ServiceName: serviceTmpl.ServiceName,
Targets: []string{container.Name},
Expand Down
20 changes: 18 additions & 2 deletions pkg/microservice/aslan/core/build/service/target.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,14 @@ limitations under the License.
package service

import (
"context"
"fmt"

"go.uber.org/zap"

commonmodels "github.com/koderover/zadig/v2/pkg/microservice/aslan/core/common/repository/models"
commonrepo "github.com/koderover/zadig/v2/pkg/microservice/aslan/core/common/repository/mongodb"
"github.com/koderover/zadig/v2/pkg/microservice/aslan/core/common/service/repository"
"github.com/koderover/zadig/v2/pkg/setting"
e "github.com/koderover/zadig/v2/pkg/tool/errors"
)
Expand All @@ -39,7 +41,14 @@ func ListDeployTarget(productName string, log *zap.SugaredLogger) ([]*commonmode
for _, svc := range services {
switch svc.Type {
case setting.K8SDeployType, setting.HelmDeployType:
for _, container := range svc.Containers {
// Service.Containers no longer persisted — pull merged modules
// from service_module table.
resolved, _, rerr := repository.ResolveServiceModules(context.Background(), svc.ProductName, svc.ServiceName, false, svc.Revision)
if rerr != nil {
log.Errorf("failed to resolve modules for %s/%s rev %d: %s", svc.ProductName, svc.ServiceName, svc.Revision, rerr)
continue
}
for _, container := range resolved {
serviceObjects = append(serviceObjects, &commonmodels.ServiceModuleTarget{
ProductName: svc.ProductName,
ServiceWithModule: commonmodels.ServiceWithModule{
Expand Down Expand Up @@ -85,7 +94,14 @@ func ListContainers(productName string, log *zap.SugaredLogger) ([]*commonmodels
log.Errorf("ServiceTmpl.Find error: %v", err)
continue
}
for _, container := range serviceDetail.Containers {
// Service.Containers no longer persisted — read modules from
// the service_module table.
resolved, _, rerr := repository.ResolveServiceModules(context.Background(), serviceDetail.ProductName, serviceDetail.ServiceName, false, serviceDetail.Revision)
if rerr != nil {
log.Errorf("failed to resolve modules for %s/%s rev %d: %s", serviceDetail.ProductName, serviceDetail.ServiceName, serviceDetail.Revision, rerr)
continue
}
for _, container := range resolved {
containerList = append(containerList, &commonmodels.ServiceModuleTarget{
ProductName: service.ProductName,
ServiceWithModule: commonmodels.ServiceWithModule{
Expand Down
11 changes: 10 additions & 1 deletion pkg/microservice/aslan/core/common/repository/models/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,16 @@ type Service struct {
Hash string `bson:"hash256,omitempty" json:"hash256,omitempty"`
CreateTime int64 `bson:"create_time" json:"create_time"`
CreateBy string `bson:"create_by" json:"create_by"`
Containers []*Container `bson:"containers,omitempty" json:"containers,omitempty"`
// Containers is an in-memory transit field used by the parsing helpers
// (SetCurrentContainerImages, parseContainer) and JSON responses for the
// frontend. Authoritative storage lives in the service_module collection
// — read via repository.ResolveServiceModules and write via
// repository.SyncAutoServiceModules. The bson tag is `-` so MongoDB does
// not persist this field; fresh DB loads will have it nil.
//
// Deprecated: do not add new readers of this field. Use
// repository.ResolveServiceModules instead.
Containers []*Container `bson:"-" json:"containers,omitempty"`
Description string `bson:"description,omitempty" json:"description,omitempty"`
Visibility string `bson:"visibility,omitempty" json:"visibility,omitempty"` // DEPRECATED since 1.17.0
Status string `bson:"status,omitempty" json:"status,omitempty"`
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/*
Copyright 2026 The KodeRover Authors.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package models

import (
"go.mongodb.org/mongo-driver/bson/primitive"

"github.com/koderover/zadig/v2/pkg/setting"
)

// ServiceModule is a first-class record of a deployable unit ("module" /
// "container") attached to a service template. Modules can originate from:
//
// - Auto-discovery: parsed out of a service template's KubeYamls for the
// workload kinds we recognize (Deployment, StatefulSet, Job, CronJob,
// CloneSet). Auto records are bound to a specific service revision via
// RevisionBound and are re-derived every time SetCurrentContainerImages
// re-parses YAML.
//
// - Manual declaration: added through the manual-module API by users for
// workload kinds we cannot parse (CRDs, DaemonSets, Argo Rollouts, etc.).
// Manual records carry RevisionBound = 0 — they are version-agnostic and
// load for every revision of the service.
//
// Read-merge rule (see ResolveServiceModules): records are unioned by Name with
// time-of-creation precedence ("first-come-first-served" by CreateTime).
// Conflicts are recorded and surfaced to the caller so the UI can warn users.
type ServiceModule struct {
ID primitive.ObjectID `bson:"_id,omitempty" json:"id,omitempty"`
ProjectName string `bson:"project_name" json:"project_name"`
ServiceName string `bson:"service_name" json:"service_name"`

// IsManual: true if added explicitly by the user via the manual-module API.
// Manual records MUST have RevisionBound = 0.
IsManual bool `bson:"is_manual" json:"is_manual"`

// RevisionBound: the service revision this record belongs to.
// 0 -> manual / version-agnostic, loads for every revision
// >0 -> auto-discovered, scoped to that revision only
RevisionBound int64 `bson:"revision_bound" json:"revision_bound"`

// Name is the module identifier. For auto records it equals the parsed
// container's Name; for manual records it is the user-supplied name that
// $<name>-image$ placeholders in YAML resolve against.
Name string `bson:"name" json:"name"`

// Type carries the legacy Container.Type distinction (normal vs init
// container). No K8s deployment logic in this codebase branches on this
// field — it is preserved for OpenAPI parity. Manual records default to
// ContainerTypeNormal ("").
Type setting.ContainerType `bson:"type,omitempty" json:"type,omitempty"`

// Image is the resolved image URI. For auto records it is whatever the
// parsed YAML had; for manual records it is what the user typed. Either
// may be empty at creation time (an unbuilt module).
Image string `bson:"image,omitempty" json:"image,omitempty"`

// ImageName is the registry-friendly name used for build target paths and
// image webhook matching. Required for manual records (enforced at the
// service layer); for auto records it is derived from Image via
// ExtractImageName, falling back to Name.
ImageName string `bson:"image_name,omitempty" json:"image_name,omitempty"`

CreateTime int64 `bson:"create_time" json:"create_time"`
UpdateTime int64 `bson:"update_time,omitempty" json:"update_time,omitempty"`
}

func (ServiceModule) TableName() string {
return "service_module"
}
Loading
Loading