penguin/monok8s

k8s image for Mono Gateway Dev Kit

clitools/pkg/controller/osupgrade/planner.go

raw ยท 7429 bytes

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 latest available patch in that minor.
	for minor := cur.Minor + 1; minor < tgt.Minor; minor++ {
		bridge, ok := latestAnyPatchInMinor(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 latestAnyPatchInMinor(versions []Version, major, minor int) (Version, bool) {
	var found Version
	ok := false

	for _, v := range versions {
		if v.Major != major || v.Minor != minor {
			continue
		}
		if !ok || found.Compare(v) < 0 {
			found = v
			ok = true
		}
	}

	return found, ok
}

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 plannedPath(plan *Plan) []string {
	ppath := []string{}
	for _, img := range plan.Path {
		ppath = append(ppath, img.Version)
	}
	return ppath
}

func versionsToStrings(vs []Version) []string {
	out := make([]string, 0, len(vs))
	for _, v := range vs {
		out = append(out, v.String())
	}
	return out
}