328 lines
6.8 KiB
Go
328 lines
6.8 KiB
Go
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
|
|
}
|