From 9cb593ffc0f79704150cbd6197ac71c85d8b56b458567d65488470d373c44b63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=96=9F=E9=85=8C=20=E9=B5=AC=E5=85=84?= Date: Fri, 3 Apr 2026 08:57:27 +0800 Subject: [PATCH] OSUpgrade: Version Planning --- clitools/makefile | 2 +- .../monok8s/v1alpha1/groupversion_info.go | 1 + .../pkg/apis/monok8s/v1alpha1/osupgrade.go | 35 +- clitools/pkg/catalog/resolver.go | 113 ++++++ clitools/pkg/catalog/types.go | 13 + clitools/pkg/cmd/agent/agent.go | 183 ++++++++-- clitools/pkg/controller/osupgrade/handler.go | 80 +++++ clitools/pkg/controller/osupgrade/planner.go | 327 ++++++++++++++++++ clitools/pkg/controller/osupgrade/progress.go | 265 ++++++++++++++ clitools/pkg/templates/templates.go | 4 +- 10 files changed, 985 insertions(+), 38 deletions(-) create mode 100644 clitools/pkg/catalog/resolver.go create mode 100644 clitools/pkg/catalog/types.go create mode 100644 clitools/pkg/controller/osupgrade/handler.go create mode 100644 clitools/pkg/controller/osupgrade/planner.go create mode 100644 clitools/pkg/controller/osupgrade/progress.go diff --git a/clitools/makefile b/clitools/makefile index 96e8870..10e2719 100644 --- a/clitools/makefile +++ b/clitools/makefile @@ -2,7 +2,7 @@ VERSION ?= dev # Target kube version -KUBE_VERSION ?= v1.35.1 +KUBE_VERSION ?= v1.34.1 GIT_REV := $(shell git rev-parse HEAD) diff --git a/clitools/pkg/apis/monok8s/v1alpha1/groupversion_info.go b/clitools/pkg/apis/monok8s/v1alpha1/groupversion_info.go index 77b6cbe..5966af6 100644 --- a/clitools/pkg/apis/monok8s/v1alpha1/groupversion_info.go +++ b/clitools/pkg/apis/monok8s/v1alpha1/groupversion_info.go @@ -17,6 +17,7 @@ var ( Label = "monok8s.io/label" Annotation = "monok8s.io/annotation" ControlAgentKey = "monok8s.io/control-agent" + CatalogURL = "https://example.com/monok8s.io/v1alpha1/catalog.yaml" ) var ( diff --git a/clitools/pkg/apis/monok8s/v1alpha1/osupgrade.go b/clitools/pkg/apis/monok8s/v1alpha1/osupgrade.go index a86c88c..9c5a248 100644 --- a/clitools/pkg/apis/monok8s/v1alpha1/osupgrade.go +++ b/clitools/pkg/apis/monok8s/v1alpha1/osupgrade.go @@ -56,12 +56,17 @@ type OSUpgradeSpec struct { // User request, can be "stable" or an explicit version like "v1.35.3". DesiredVersion string `json:"desiredVersion,omitempty" yaml:"desiredVersion,omitempty"` - ImageURL string `json:"imageURL,omitempty" yaml:"imageURL,omitempty"` - Checksum string `json:"checksum,omitempty" yaml:"checksum,omitempty"` + Catalog *VersionCatalogSource `json:"catalog,omitempty"` NodeSelector *metav1.LabelSelector `json:"nodeSelector,omitempty" yaml:"nodeSelector,omitempty"` } +type VersionCatalogSource struct { + URL string `json:"url,omitempty"` + Inline string `json:"inline,omitempty"` + ConfigMap string `json:"configMapRef,omitempty"` +} + type OSUpgradeStatus struct { Phase OSUpgradePhase `json:"phase,omitempty" yaml:"phase,omitempty"` ResolvedVersion string `json:"resolvedVersion,omitempty" yaml:"resolvedVersion,omitempty"` @@ -115,16 +120,22 @@ type OSUpgradeSourceRef struct { } type OSUpgradeProgressStatus struct { - CurrentVersion string `json:"currentVersion,omitempty" yaml:"currentVersion,omitempty"` - TargetVersion string `json:"targetVersion,omitempty" yaml:"targetVersion,omitempty"` - Phase OSUpgradeProgressPhase `json:"phase,omitempty" yaml:"phase,omitempty"` - StartedAt *metav1.Time `json:"startedAt,omitempty" yaml:"startedAt,omitempty"` - CompletedAt *metav1.Time `json:"completedAt,omitempty" yaml:"completedAt,omitempty"` - LastUpdatedAt *metav1.Time `json:"lastUpdatedAt,omitempty" yaml:"lastUpdatedAt,omitempty"` - RetryCount int32 `json:"retryCount,omitempty" yaml:"retryCount,omitempty"` - InactivePartition string `json:"inactivePartition,omitempty" yaml:"inactivePartition,omitempty"` - FailureReason string `json:"failureReason,omitempty" yaml:"failureReason,omitempty"` - Message string `json:"message,omitempty" yaml:"message,omitempty"` + CurrentVersion string `json:"currentVersion,omitempty" yaml:"currentVersion,omitempty"` + TargetVersion string `json:"targetVersion,omitempty" yaml:"targetVersion,omitempty"` + Phase OSUpgradeProgressPhase `json:"phase,omitempty" yaml:"phase,omitempty"` + + StartedAt *metav1.Time `json:"startedAt,omitempty" yaml:"startedAt,omitempty"` + CompletedAt *metav1.Time `json:"completedAt,omitempty" yaml:"completedAt,omitempty"` + LastUpdatedAt *metav1.Time `json:"lastUpdatedAt,omitempty" yaml:"lastUpdatedAt,omitempty"` + RetryCount int32 `json:"retryCount,omitempty" yaml:"retryCount,omitempty"` + InactivePartition string `json:"inactivePartition,omitempty" yaml:"inactivePartition,omitempty"` + FailureReason string `json:"failureReason,omitempty" yaml:"failureReason,omitempty"` + Message string `json:"message,omitempty" yaml:"message,omitempty"` + + PlannedPath []string `json:"plannedPath,omitempty"` + CurrentStep int32 `json:"currentStep,omitempty"` + CurrentFrom string `json:"currentFrom,omitempty"` + CurrentTo string `json:"currentTo,omitempty"` } func (in *OSUpgrade) DeepCopyObject() runtime.Object { diff --git a/clitools/pkg/catalog/resolver.go b/clitools/pkg/catalog/resolver.go new file mode 100644 index 0000000..f9e347d --- /dev/null +++ b/clitools/pkg/catalog/resolver.go @@ -0,0 +1,113 @@ +package catalog + +import ( + "context" + "fmt" + "io" + "net/http" + "os" + + "sigs.k8s.io/yaml" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + + monov1alpha1 "example.com/monok8s/pkg/apis/monok8s/v1alpha1" +) + +const ( + DefaultCachePath = "/var/lib/monok8s/catalog.yaml" +) + +// ResolveCatalog resolves catalog using priority: +// Inline > ConfigMap > URL > cached +func ResolveCatalog( + ctx context.Context, + kubeClient kubernetes.Interface, + namespace string, + src *monov1alpha1.VersionCatalogSource, +) (*VersionCatalog, error) { + + // 1. Inline + if src != nil && src.Inline != "" { + return parseCatalog([]byte(src.Inline)) + } + + // 2. ConfigMap + if src != nil && src.ConfigMap != "" { + cm, err := kubeClient.CoreV1().ConfigMaps(namespace).Get(ctx, src.ConfigMap, metav1.GetOptions{}) + if err != nil { + return nil, fmt.Errorf("get catalog configmap: %w", err) + } + + data, ok := cm.Data["catalog.yaml"] + if !ok { + return nil, fmt.Errorf("configmap %s missing key catalog.yaml", src.ConfigMap) + } + + return parseCatalog([]byte(data)) + } + + // 3. URL + if src != nil && src.URL != "" { + cat, err := fetchCatalog(src.URL) + if err == nil { + _ = os.WriteFile(DefaultCachePath, mustMarshal(cat), 0644) + return cat, nil + } + + // fallback to cache + if cached, err2 := loadCached(); err2 == nil { + return cached, nil + } + + return nil, fmt.Errorf("fetch catalog failed and no cache: %w", err) + } + + // 4. cached fallback + if cached, err := loadCached(); err == nil { + return cached, nil + } + + return nil, fmt.Errorf("no catalog source available") +} + +func fetchCatalog(url string) (*VersionCatalog, error) { + resp, err := http.Get(url) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("http %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + return parseCatalog(body) +} + +func loadCached() (*VersionCatalog, error) { + data, err := os.ReadFile(DefaultCachePath) + if err != nil { + return nil, err + } + return parseCatalog(data) +} + +func parseCatalog(data []byte) (*VersionCatalog, error) { + var c VersionCatalog + if err := yaml.Unmarshal(data, &c); err != nil { + return nil, fmt.Errorf("parse catalog: %w", err) + } + return &c, nil +} + +func mustMarshal(c *VersionCatalog) []byte { + b, _ := yaml.Marshal(c) + return b +} diff --git a/clitools/pkg/catalog/types.go b/clitools/pkg/catalog/types.go new file mode 100644 index 0000000..e78f0a9 --- /dev/null +++ b/clitools/pkg/catalog/types.go @@ -0,0 +1,13 @@ +package catalog + +type VersionCatalog struct { + Stable string `json:"stable" yaml:"stable"` + Images []CatalogImage `json:"images" yaml:"images"` + Blocked []string `json:"blocked,omitempty" yaml:"blocked,omitempty"` +} + +type CatalogImage struct { + Version string `json:"version" yaml:"version"` + URL string `json:"url" yaml:"url"` + Checksum string `json:"checksum,omitempty" yaml:"checksum,omitempty"` +} diff --git a/clitools/pkg/cmd/agent/agent.go b/clitools/pkg/cmd/agent/agent.go index ab8c736..edd050c 100644 --- a/clitools/pkg/cmd/agent/agent.go +++ b/clitools/pkg/cmd/agent/agent.go @@ -7,63 +7,198 @@ import ( "github.com/spf13/cobra" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/cli-runtime/pkg/genericclioptions" "k8s.io/klog/v2" monov1alpha1 "example.com/monok8s/pkg/apis/monok8s/v1alpha1" mkscmd "example.com/monok8s/pkg/cmd" + osupgradeController "example.com/monok8s/pkg/controller/osupgrade" "example.com/monok8s/pkg/kube" "example.com/monok8s/pkg/templates" ) +const defaultPollInterval = 15 * time.Second + +var runtimeDefaultUnstructuredConverter = runtime.DefaultUnstructuredConverter + func NewCmdAgent(flags *genericclioptions.ConfigFlags) *cobra.Command { var namespace string var envFile string + var pollInterval time.Duration cmd := &cobra.Command{ Use: "agent --env-file path", - Short: "Watch OSUpgrade resources and do nothing for now", + Short: "Watch OSUpgrade resources and process matching upgrades for this node", RunE: func(cmd *cobra.Command, _ []string) error { - - var cfg *monov1alpha1.MonoKSConfig // or value, depending on your API + if envFile == "" { + return fmt.Errorf("--env-file is required") + } if err := mkscmd.LoadEnvFile(envFile); err != nil { return fmt.Errorf("load env file %q: %w", envFile, err) } + vals := templates.LoadTemplateValuesFromEnv() rendered := templates.DefaultMonoKSConfig(vals) - cfg = &rendered + cfg := &rendered - klog.InfoS("starting agent", "node", cfg.Spec.NodeName, "envFile", envFile) + if cfg.Spec.NodeName == "" { + return fmt.Errorf("node name is empty in rendered config") + } + + klog.InfoS("starting agent", + "node", cfg.Spec.NodeName, + "namespace", namespace, + "envFile", envFile, + "pollInterval", pollInterval, + ) clients, err := kube.NewClients(flags) if err != nil { - return err + return fmt.Errorf("create kube clients: %w", err) } - gvr := schema.GroupVersionResource{Group: monov1alpha1.Group, Version: monov1alpha1.Version, Resource: "osupgrades"} + ctx := cmd.Context() - for { - list, err := clients.Dynamic.Resource(gvr).Namespace(namespace).List(ctx, metav1.ListOptions{}) - if err != nil { - return err - } - klog.InfoS("agent tick", "namespace", namespace, "items", len(list.Items)) - for _, item := range list.Items { - klog.InfoS("observed osupgrade", "name", item.GetName(), "resourceVersion", item.GetResourceVersion()) - } - select { - case <-ctx.Done(): - return ctx.Err() - case <-time.After(15 * time.Second): - } - } + return runPollLoop(ctx, clients, namespace, cfg.Spec.NodeName, pollInterval) }, } + cmd.Flags().StringVar(&namespace, "namespace", "kube-system", "namespace to watch") cmd.Flags().StringVar(&envFile, "env-file", "", "path to env file containing MKS_* variables") + cmd.Flags().DurationVar(&pollInterval, "poll-interval", defaultPollInterval, "poll interval for OSUpgrade resources") + return cmd } -var _ = context.Background -var _ = fmt.Sprintf +func runPollLoop(ctx context.Context, clients *kube.Clients, namespace, nodeName string, interval time.Duration) error { + gvr := schema.GroupVersionResource{ + Group: monov1alpha1.Group, + Version: monov1alpha1.Version, + Resource: "osupgrades", + } + + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + if err := pollOnce(ctx, clients, gvr, namespace, nodeName); err != nil { + klog.ErrorS(err, "poll failed", "namespace", namespace, "node", nodeName) + } + + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + } + } +} + +func pollOnce( + ctx context.Context, + clients *kube.Clients, + gvr schema.GroupVersionResource, + namespace string, + nodeName string, +) error { + list, err := clients.Dynamic.Resource(gvr).Namespace(namespace).List(ctx, metav1.ListOptions{}) + if err != nil { + return fmt.Errorf("list osupgrades: %w", err) + } + + klog.InfoS("agent tick", "namespace", namespace, "items", len(list.Items), "node", nodeName) + + nodeLabels := labels.Set{ + "kubernetes.io/hostname": nodeName, + "monok8s.io/node-name": nodeName, + "monok8s.io/control-agent": "true", + } + + for i := range list.Items { + item := &list.Items[i] + + osu, err := decodeOSUpgrade(item) + if err != nil { + klog.ErrorS(err, "failed to decode osupgrade", + "name", item.GetName(), + "resourceVersion", item.GetResourceVersion(), + ) + continue + } + + if !matchesNode(osu, nodeName, nodeLabels) { + klog.V(2).InfoS("skipping osupgrade; not targeted to this node", + "name", osu.Name, + "node", nodeName, + ) + continue + } + + klog.InfoS("matched osupgrade", + "name", osu.Name, + "node", nodeName, + "desiredVersion", osu.Spec.DesiredVersion, + "phase", statusPhase(osu.Status), + "resourceVersion", osu.ResourceVersion, + ) + + if err := osupgradeController.HandleOSUpgrade(ctx, clients, namespace, nodeName, osu); err != nil { + klog.ErrorS(err, "failed to handle osupgrade", + "name", osu.Name, + "node", nodeName, + ) + continue + } + } + + return nil +} + +func decodeOSUpgrade(item *unstructured.Unstructured) (*monov1alpha1.OSUpgrade, error) { + var osu monov1alpha1.OSUpgrade + if err := runtimeDefaultUnstructuredConverter.FromUnstructured(item.Object, &osu); err != nil { + return nil, fmt.Errorf("convert unstructured to OSUpgrade: %w", err) + } + return &osu, nil +} + +func matchesNode(osu *monov1alpha1.OSUpgrade, nodeName string, nodeLabels labels.Set) bool { + if osu == nil { + return false + } + + sel := osu.Spec.NodeSelector + if sel == nil { + // No selector means "match all nodes". + return true + } + + selector, err := metav1.LabelSelectorAsSelector(sel) + if err != nil { + klog.ErrorS(err, "invalid node selector on osupgrade", "name", osu.Name) + return false + } + + if selector.Empty() { + return true + } + + if selector.Matches(nodeLabels) { + return true + } + + // Cheap fallback in case you temporarily target by explicit hostname-ish labels only. + return nodeName != "" && selector.Matches(labels.Set{ + "kubernetes.io/hostname": nodeName, + }) +} + +func statusPhase(st *monov1alpha1.OSUpgradeStatus) string { + if st == nil { + return "" + } + return string(st.Phase) +} diff --git a/clitools/pkg/controller/osupgrade/handler.go b/clitools/pkg/controller/osupgrade/handler.go new file mode 100644 index 0000000..85378c5 --- /dev/null +++ b/clitools/pkg/controller/osupgrade/handler.go @@ -0,0 +1,80 @@ +package osupgrade + +import ( + "context" + "fmt" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/klog/v2" + + monov1alpha1 "example.com/monok8s/pkg/apis/monok8s/v1alpha1" + "example.com/monok8s/pkg/buildinfo" + "example.com/monok8s/pkg/catalog" + "example.com/monok8s/pkg/kube" +) + +func HandleOSUpgrade( + ctx context.Context, + clients *kube.Clients, + namespace string, + nodeName string, + osu *monov1alpha1.OSUpgrade, +) error { + osup, err := ensureProgressHeartbeat(ctx, clients, namespace, nodeName, osu) + if err != nil { + return err + } + + klog.InfoS("handling osupgrade", + "name", osu.Name, + "node", nodeName, + "desiredVersion", osu.Spec.DesiredVersion, + ) + + kata, err := catalog.ResolveCatalog(ctx, clients.Kubernetes, namespace, osu.Spec.Catalog) + if err != nil { + return failProgress(ctx, clients, osup, "resolve catalog", err) + } + + plan, err := PlanUpgrade(buildinfo.KubeVersion, osu, kata) + if err != nil { + return failProgress(ctx, clients, osup, "plan upgrade", err) + } + + if len(plan.Path) == 0 { + osup.Status.CurrentVersion = buildinfo.KubeVersion + osup.Status.TargetVersion = buildinfo.KubeVersion + if err := markProgressCompleted(ctx, clients, osup, "already at target version"); err != nil { + return err + } + klog.InfoS("osupgrade already satisfied", + "name", osu.Name, + "node", nodeName, + "target", plan.ResolvedTarget, + ) + return nil + } + + first := plan.Path[0] + + osup.Status.TargetVersion = plan.ResolvedTarget + osup.Status.Phase = monov1alpha1.OSUpgradeProgressPhaseDownloading + osup.Status.Message = fmt.Sprintf("downloading image: %s", first.URL) + now := metav1.Now() + osup.Status.LastUpdatedAt = &now + + if _, err := updateProgressStatus(ctx, clients, osup_gvr, osup); err != nil { + return fmt.Errorf("update progress status: %w", err) + } + + klog.InfoS("planned osupgrade", + "name", osu.Name, + "node", nodeName, + "resolvedTarget", plan.ResolvedTarget, + "steps", len(plan.Path), + "firstVersion", first.Version, + "firstURL", first.URL, + ) + + return nil +} diff --git a/clitools/pkg/controller/osupgrade/planner.go b/clitools/pkg/controller/osupgrade/planner.go new file mode 100644 index 0000000..29b52a4 --- /dev/null +++ b/clitools/pkg/controller/osupgrade/planner.go @@ -0,0 +1,327 @@ +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 +} diff --git a/clitools/pkg/controller/osupgrade/progress.go b/clitools/pkg/controller/osupgrade/progress.go new file mode 100644 index 0000000..93f4588 --- /dev/null +++ b/clitools/pkg/controller/osupgrade/progress.go @@ -0,0 +1,265 @@ +package osupgrade + +import ( + "context" + "fmt" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/klog/v2" + + monov1alpha1 "example.com/monok8s/pkg/apis/monok8s/v1alpha1" + "example.com/monok8s/pkg/buildinfo" + "example.com/monok8s/pkg/kube" +) + +var ( + unstructuredConverter = runtime.DefaultUnstructuredConverter + + osup_gvr = schema.GroupVersionResource{ + Group: monov1alpha1.Group, + Version: monov1alpha1.Version, + Resource: "osupgradeprogresses", + } +) + +func ensureProgressHeartbeat( + ctx context.Context, + clients *kube.Clients, + namespace string, + nodeName string, + osu *monov1alpha1.OSUpgrade, +) (*monov1alpha1.OSUpgradeProgress, error) { + + name := fmt.Sprintf("%s-%s", osu.Name, nodeName) + now := metav1.Now() + + currentVersion := buildinfo.KubeVersion + targetVersion := "" + + if osu.Status != nil { + targetVersion = osu.Status.ResolvedVersion + } + + progress := &monov1alpha1.OSUpgradeProgress{ + TypeMeta: metav1.TypeMeta{ + APIVersion: monov1alpha1.APIVersion, + Kind: "OSUpgradeProgress", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Spec: monov1alpha1.OSUpgradeProgressSpec{ + NodeName: nodeName, + SourceRef: monov1alpha1.OSUpgradeSourceRef{ + Name: osu.Name, + }, + }, + Status: &monov1alpha1.OSUpgradeProgressStatus{ + CurrentVersion: currentVersion, + TargetVersion: targetVersion, + Phase: monov1alpha1.OSUpgradeProgressPhasePending, + LastUpdatedAt: &now, + Message: "acknowledged", + }, + } + + created, err := createProgress(ctx, clients, osup_gvr, progress) + if err == nil { + klog.InfoS("created osupgradeprogress", "name", created.Name, "namespace", created.Namespace) + return created, nil + } + if !apierrors.IsAlreadyExists(err) { + return nil, fmt.Errorf("create OSUpgradeProgress %s/%s: %w", namespace, name, err) + } + + existing, err := getProgress(ctx, clients, osup_gvr, namespace, name) + if err != nil { + return nil, fmt.Errorf("get existing OSUpgradeProgress %s/%s: %w", namespace, name, err) + } + + // Spec should remain aligned with the source and node. + existing.Spec.NodeName = nodeName + existing.Spec.SourceRef.Name = osu.Name + + if existing, err = updateProgressSpec(ctx, clients, osup_gvr, existing); err != nil { + return nil, fmt.Errorf("update OSUpgradeProgress spec %s/%s: %w", namespace, name, err) + } + + if existing.Status == nil { + existing.Status = &monov1alpha1.OSUpgradeProgressStatus{} + } + + existing.Status.CurrentVersion = currentVersion + existing.Status.TargetVersion = targetVersion + existing.Status.LastUpdatedAt = &now + + // Only set phase/message if they are still empty, so later real state machine + // updates are not clobbered by the heartbeat. + if existing.Status.Phase == "" { + existing.Status.Phase = monov1alpha1.OSUpgradeProgressPhasePending + } + + if existing, err = updateProgressStatus(ctx, clients, osup_gvr, existing); err != nil { + return nil, fmt.Errorf("update OSUpgradeProgress status %s/%s: %w", namespace, name, err) + } + + klog.InfoS("updated osupgradeprogress", "name", existing.Name, "namespace", existing.Namespace) + return existing, nil +} + +func createProgress( + ctx context.Context, + clients *kube.Clients, + gvr schema.GroupVersionResource, + progress *monov1alpha1.OSUpgradeProgress, +) (*monov1alpha1.OSUpgradeProgress, error) { + obj, err := toUnstructured(progress) + if err != nil { + return nil, err + } + + created, err := clients.Dynamic. + Resource(gvr). + Namespace(progress.Namespace). + Create(ctx, obj, metav1.CreateOptions{}) + if err != nil { + return nil, err + } + + return fromUnstructuredProgress(created) +} + +func getProgress( + ctx context.Context, + clients *kube.Clients, + gvr schema.GroupVersionResource, + namespace, name string, +) (*monov1alpha1.OSUpgradeProgress, error) { + got, err := clients.Dynamic. + Resource(gvr). + Namespace(namespace). + Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return nil, err + } + + return fromUnstructuredProgress(got) +} + +func updateProgressSpec( + ctx context.Context, + clients *kube.Clients, + gvr schema.GroupVersionResource, + progress *monov1alpha1.OSUpgradeProgress, +) (*monov1alpha1.OSUpgradeProgress, error) { + obj, err := toUnstructured(progress) + if err != nil { + return nil, err + } + + updated, err := clients.Dynamic. + Resource(gvr). + Namespace(progress.Namespace). + Update(ctx, obj, metav1.UpdateOptions{}) + if err != nil { + return nil, err + } + + return fromUnstructuredProgress(updated) +} + +func updateProgressStatus( + ctx context.Context, + clients *kube.Clients, + gvr schema.GroupVersionResource, + progress *monov1alpha1.OSUpgradeProgress, +) (*monov1alpha1.OSUpgradeProgress, error) { + obj, err := toUnstructured(progress) + if err != nil { + return nil, err + } + + updated, err := clients.Dynamic. + Resource(gvr). + Namespace(progress.Namespace). + UpdateStatus(ctx, obj, metav1.UpdateOptions{}) + if err != nil { + return nil, err + } + + return fromUnstructuredProgress(updated) +} + +func failProgress( + ctx context.Context, + clients *kube.Clients, + osup *monov1alpha1.OSUpgradeProgress, + action string, + cause error, +) error { + now := metav1.Now() + + if osup.Status == nil { + osup.Status = &monov1alpha1.OSUpgradeProgressStatus{} + } + + osup.Status.LastUpdatedAt = &now + osup.Status.Message = fmt.Sprintf("%s: %v", action, cause) + osup.Status.Phase = monov1alpha1.OSUpgradeProgressPhaseFailed + + if _, err := updateProgressStatus(ctx, clients, osup_gvr, osup); err != nil { + klog.ErrorS(err, "failed to update osupgradeprogress status after error", + "action", action, + "name", osup.Name, + "namespace", osup.Namespace, + ) + } + + return fmt.Errorf("%s: %w", action, cause) +} + +func markProgressCompleted( + ctx context.Context, + clients *kube.Clients, + osup *monov1alpha1.OSUpgradeProgress, + message string, +) error { + now := metav1.Now() + + if osup.Status == nil { + osup.Status = &monov1alpha1.OSUpgradeProgressStatus{} + } + + osup.Status.Phase = monov1alpha1.OSUpgradeProgressPhaseCompleted + osup.Status.Message = message + osup.Status.LastUpdatedAt = &now + osup.Status.CompletedAt = &now + + _, err := updateProgressStatus(ctx, clients, osup_gvr, osup) + if err != nil { + return fmt.Errorf("mark progress completed: %w", err) + } + + return nil +} + +func toUnstructured(progress *monov1alpha1.OSUpgradeProgress) (*unstructured.Unstructured, error) { + m, err := unstructuredConverter.ToUnstructured(progress) + if err != nil { + return nil, fmt.Errorf("convert OSUpgradeProgress to unstructured: %w", err) + } + return &unstructured.Unstructured{Object: m}, nil +} + +func fromUnstructuredProgress(obj *unstructured.Unstructured) (*monov1alpha1.OSUpgradeProgress, error) { + var progress monov1alpha1.OSUpgradeProgress + if err := unstructuredConverter.FromUnstructured(obj.Object, &progress); err != nil { + return nil, fmt.Errorf("convert unstructured to OSUpgradeProgress: %w", err) + } + return &progress, nil +} diff --git a/clitools/pkg/templates/templates.go b/clitools/pkg/templates/templates.go index ac3878f..0436941 100644 --- a/clitools/pkg/templates/templates.go +++ b/clitools/pkg/templates/templates.go @@ -77,12 +77,14 @@ func DefaultOSUpgrade(v TemplateValues) monov1alpha1.OSUpgrade { }, Spec: monov1alpha1.OSUpgradeSpec{ DesiredVersion: v.KubernetesVersion, - ImageURL: "https://example.invalid/images/monok8s-v0.0.1.img.zst", NodeSelector: &metav1.LabelSelector{ MatchLabels: map[string]string{ "kubernetes.io/hostname": firstNonEmpty(v.NodeName, v.Hostname), }, }, + Catalog: &monov1alpha1.VersionCatalogSource{ + URL: monov1alpha1.CatalogURL, + }, }, } }