package osupgrade import ( "fmt" "sort" "strconv" "strings" monov1alpha1 "example.com/monok8s/pkg/apis/monok8s/v1alpha1" "example.com/monok8s/pkg/catalog" ) type Version struct { Major int Minor int Patch int Raw string } func ParseVersion(s string) (Version, error) { raw := strings.TrimSpace(s) if raw == "" { return Version{}, fmt.Errorf("empty version") } raw = strings.TrimPrefix(raw, "v") parts := strings.Split(raw, ".") if len(parts) != 3 { return Version{}, fmt.Errorf("invalid version %q: expected vMAJOR.MINOR.PATCH", s) } maj, err := strconv.Atoi(parts[0]) if err != nil { return Version{}, fmt.Errorf("parse major from %q: %w", s, err) } min, err := strconv.Atoi(parts[1]) if err != nil { return Version{}, fmt.Errorf("parse minor from %q: %w", s, err) } patch, err := strconv.Atoi(parts[2]) if err != nil { return Version{}, fmt.Errorf("parse patch from %q: %w", s, err) } return Version{ Major: maj, Minor: min, Patch: patch, Raw: fmt.Sprintf("v%d.%d.%d", maj, min, patch), }, nil } func (v Version) String() string { return v.Raw } func (v Version) Compare(o Version) int { if v.Major != o.Major { if v.Major < o.Major { return -1 } return 1 } if v.Minor != o.Minor { if v.Minor < o.Minor { return -1 } return 1 } if v.Patch != o.Patch { if v.Patch < o.Patch { return -1 } return 1 } return 0 } func (v Version) SameMinor(o Version) bool { return v.Major == o.Major && v.Minor == o.Minor } type Plan struct { ResolvedTarget string Path []catalog.CatalogImage } func PlanUpgrade( current string, osu *monov1alpha1.OSUpgrade, cat *catalog.VersionCatalog, ) (*Plan, error) { target, err := resolveTarget(osu.Spec.DesiredVersion, cat) if err != nil { return nil, err } if isBlocked(target, cat.Blocked) { return nil, fmt.Errorf("target %s is blocked", target) } imagesByVersion := make(map[string]catalog.CatalogImage, len(cat.Images)) installable := make([]string, 0, len(cat.Images)) for _, img := range cat.Images { if img.Version == "" { continue } if isBlocked(img.Version, cat.Blocked) { continue } if _, exists := imagesByVersion[img.Version]; exists { return nil, fmt.Errorf("duplicate image entry for version %s", img.Version) } imagesByVersion[img.Version] = img installable = append(installable, img.Version) } versionPath, err := calculatePath(current, target, installable) if err != nil { return nil, err } path := make([]catalog.CatalogImage, 0, len(versionPath)) for _, v := range versionPath { img, ok := imagesByVersion[v] if !ok { return nil, fmt.Errorf("internal error: no image for planned version %s", v) } path = append(path, img) } return &Plan{ ResolvedTarget: target, Path: path, }, nil } func installableVersions(cat *catalog.VersionCatalog) []string { out := make([]string, 0, len(cat.Images)) for _, img := range cat.Images { if img.Version == "" { continue } if isBlocked(img.Version, cat.Blocked) { continue } out = append(out, img.Version) } return out } func resolveTarget(desired string, cat *catalog.VersionCatalog) (string, error) { if desired == "stable" { if cat.Stable == "" { return "", fmt.Errorf("catalog missing stable") } return cat.Stable, nil } for _, img := range cat.Images { if img.Version == desired { return desired, nil } } return "", fmt.Errorf("desired version %s not in catalog", desired) } func calculatePath(current, target string, available []string) ([]string, error) { cur, err := ParseVersion(current) if err != nil { return nil, fmt.Errorf("parse current version: %w", err) } tgt, err := ParseVersion(target) if err != nil { return nil, fmt.Errorf("parse target version: %w", err) } if cur.Compare(tgt) == 0 { return nil, nil } if cur.Compare(tgt) > 0 { return nil, fmt.Errorf("downgrade not supported: current=%s target=%s", cur, tgt) } if cur.Major != tgt.Major { return nil, fmt.Errorf("cross-major upgrade not supported: %s -> %s", cur, tgt) } versions, err := parseAndSortVersions(available) if err != nil { return nil, err } if !containsVersion(versions, tgt) { return nil, fmt.Errorf("target version %s not found in available versions", tgt) } var path []Version seen := map[string]struct{}{} add := func(v Version) { if v.Compare(cur) <= 0 { return } if _, ok := seen[v.String()]; ok { return } seen[v.String()] = struct{}{} path = append(path, v) } // Same minor: jump directly to target patch. if cur.SameMinor(tgt) { add(tgt) return versionsToStrings(path), nil } // Step 1: finish current minor by moving to the latest patch available there. if latestCurMinor, ok := latestPatchInMinor(versions, cur.Major, cur.Minor, cur); ok { add(latestCurMinor) } // Step 2: walk each intermediate minor using the lowest available patch in that minor. for minor := cur.Minor + 1; minor < tgt.Minor; minor++ { bridge, ok := lowestPatchInMinor(versions, cur.Major, minor) if !ok { return nil, fmt.Errorf("no available bridge version for v%d.%d.x", cur.Major, minor) } add(bridge) } // Step 3: final target. add(tgt) return versionsToStrings(path), nil } func parseAndSortVersions(raw []string) ([]Version, error) { out := make([]Version, 0, len(raw)) seen := map[string]struct{}{} for _, s := range raw { v, err := ParseVersion(s) if err != nil { return nil, fmt.Errorf("parse catalog version %q: %w", s, err) } if _, ok := seen[v.String()]; ok { continue } seen[v.String()] = struct{}{} out = append(out, v) } sort.Slice(out, func(i, j int) bool { return out[i].Compare(out[j]) < 0 }) return out, nil } func containsRawVersion(versions []string, want string) bool { for _, v := range versions { if strings.TrimSpace(v) == strings.TrimSpace(want) { return true } } return false } func containsVersion(versions []Version, want Version) bool { for _, v := range versions { if v.Compare(want) == 0 { return true } } return false } func isBlocked(version string, blocked []string) bool { for _, v := range blocked { if strings.TrimSpace(v) == strings.TrimSpace(version) { return true } } return false } func latestPatchInMinor(versions []Version, major, minor int, gt Version) (Version, bool) { var found Version ok := false for _, v := range versions { if v.Major != major || v.Minor != minor { continue } if v.Compare(gt) <= 0 { continue } if !ok || found.Compare(v) < 0 { found = v ok = true } } return found, ok } func lowestPatchInMinor(versions []Version, major, minor int) (Version, bool) { for _, v := range versions { if v.Major == major && v.Minor == minor { return v, true } } return Version{}, false } func versionsToStrings(vs []Version) []string { out := make([]string, 0, len(vs)) for _, v := range vs { out = append(out, v.String()) } return out }