omni/client/pkg/template/template.go
Oguz Kilcan 865e60daab
chore: bump deps, rekres and fix linters
Bump deps, rekres and fix linters.
Includes fixes for:
 - https://pkg.go.dev/vuln/GO-2026-4559
 - https://github.com/siderolabs/talos/pull/12861

Signed-off-by: Oguz Kilcan <oguz.kilcan@siderolabs.com>
2026-02-27 15:12:13 +01:00

436 lines
13 KiB
Go

// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
// Package template provides conversion of cluster templates to Omni resources.
package template
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"slices"
"github.com/cosi-project/runtime/pkg/resource"
"github.com/cosi-project/runtime/pkg/state"
"github.com/siderolabs/gen/xslices"
"go.yaml.in/yaml/v4"
"github.com/siderolabs/omni/client/pkg/omni/resources"
"github.com/siderolabs/omni/client/pkg/omni/resources/omni"
"github.com/siderolabs/omni/client/pkg/template/internal/models"
)
// Template is a cluster template.
type Template struct {
models models.List
}
// Load the template from input.
func Load(input io.Reader) (*Template, error) {
dec := yaml.NewDecoder(input)
var template Template
for {
var docNode yaml.Node
if err := dec.Decode(&docNode); err != nil {
if errors.Is(err, io.EOF) {
return &template, nil
}
return nil, fmt.Errorf("error decoding template: %w", err)
}
if docNode.Kind != yaml.DocumentNode {
return nil, fmt.Errorf("unexpected node kind %v", docNode.Kind)
}
if len(docNode.Content) != 1 {
return nil, fmt.Errorf("unexpected number of nodes %d", len(docNode.Content))
}
kind, err := findKind(docNode.Content[0])
if err != nil {
return nil, fmt.Errorf("error in document at line %d:%d: %w", docNode.Line, docNode.Column, err)
}
model, err := models.New(kind)
if err != nil {
return nil, fmt.Errorf("error in document at line %d:%d: %w", docNode.Line, docNode.Column, err)
}
// YAML decoder doesn't allow to decode with KnownFields: true from a Node
// so we do a roundtrip to bytes and back :sigh:
raw, err := yaml.Marshal(docNode.Content[0])
if err != nil {
return nil, fmt.Errorf("error marshaling document at line %d:%d: %w", docNode.Line, docNode.Column, err)
}
documentDecoder := yaml.NewDecoder(bytes.NewReader(raw))
documentDecoder.KnownFields(true)
if err = documentDecoder.Decode(model); err != nil {
return nil, fmt.Errorf("error decoding document at line %d:%d: %w", docNode.Line, docNode.Column, err)
}
template.models = append(template.models, model)
}
}
func findKind(node *yaml.Node) (string, error) {
if node.Kind != yaml.MappingNode {
return "", fmt.Errorf("unexpected node kind %v, expecting mapping", node.Kind)
}
for i := 0; i < len(node.Content); i += 2 {
key := node.Content[i]
value := node.Content[i+1]
if key.Kind != yaml.ScalarNode {
return "", fmt.Errorf("unexpected node kind %v", key.Kind)
}
if key.Value != "kind" {
continue
}
if value.Kind != yaml.ScalarNode {
return "", fmt.Errorf("unexpected value type for kind field %v", value.Kind)
}
return value.Value, nil
}
return "", fmt.Errorf("kind field not found")
}
// WithCluster creates an empty template which contains only cluster model.
//
// Such template can be used for reading a cluster status, deleting a cluster, etc.
func WithCluster(clusterName string) *Template {
return &Template{
models: models.List{
&models.Cluster{
Meta: models.Meta{
Kind: models.KindCluster,
},
Name: clusterName,
},
},
}
}
// Validate the template.
func (t *Template) Validate() error {
return t.models.Validate()
}
// Translate the template into resources.
func (t *Template) Translate() ([]resource.Resource, error) {
return t.models.Translate()
}
// ClusterName returns the name of the cluster associated with the template.
func (t *Template) ClusterName() (string, error) {
return t.models.ClusterName()
}
// actualResources returns a list of resources in the state related to the cluster template.
//
//nolint:gocognit
func (t *Template) actualResources(ctx context.Context, st state.State, expectedResources []resource.Resource) ([]resource.Resource, error) {
clusterName, err := t.models.ClusterName()
if err != nil {
return nil, err
}
var actualResources []resource.Resource
clusterResource, err := st.Get(ctx, resource.NewMetadata(resources.DefaultNamespace, omni.ClusterType, clusterName, resource.VersionUndefined))
if err != nil {
if !state.IsNotFoundError(err) {
return nil, err
}
} else {
actualResources = append(actualResources, clusterResource)
}
for _, resourceType := range []resource.Type{
omni.MachineSetType,
omni.MachineSetNodeType,
omni.ConfigPatchType,
omni.ExtensionsConfigurationType,
} {
items, err := st.List(
ctx,
resource.NewMetadata(resources.DefaultNamespace, resourceType, "", resource.VersionUndefined),
state.WithLabelQuery(resource.LabelEqual(omni.LabelCluster, clusterName)),
)
if err != nil {
return nil, err
}
filteredItems := xslices.Filter(items.Items,
func(r resource.Resource) bool {
_, programmaticallyCreatedMachineSetNode := r.Metadata().Labels().Get(omni.LabelManagedByMachineSetNodeController)
return !programmaticallyCreatedMachineSetNode && r.Metadata().Owner() == ""
},
)
actualResources = append(actualResources, filteredItems...)
if resourceType == omni.MachineSetNodeType {
for _, node := range filteredItems {
kernelArgs, getErr := st.Get(ctx, omni.NewKernelArgs(node.Metadata().ID()).Metadata())
if getErr != nil && !state.IsNotFoundError(getErr) {
return nil, getErr
}
if kernelArgs != nil {
actualResources = append(actualResources, kernelArgs)
}
}
}
}
// In the code below, we make sure that we check each expected resource from the template, in case
// it was not found by the previous queries (e.g., because it doesn't have the cluster label).
// This ensures that resources like KernelArgs are also included in the actual resources.
for _, expectedResource := range expectedResources {
alreadyAdded := slices.ContainsFunc(actualResources, func(res resource.Resource) bool {
return metadataKey(*res.Metadata()) == metadataKey(*expectedResource.Metadata())
})
if alreadyAdded {
continue
}
actual, getErr := st.Get(ctx, *expectedResource.Metadata())
if getErr != nil && !state.IsNotFoundError(getErr) {
return nil, getErr
}
if actual != nil && actual.Metadata().Owner() == "" {
actualResources = append(actualResources, actual)
}
}
return actualResources, nil
}
func splitResourcesToDelete(toDelete []resource.Resource) [][]resource.Resource {
phases := make([][]resource.Resource, 2)
for _, r := range deduplicateDeletion(toDelete) {
switch r.Metadata().Type() {
case omni.MachineSetNodeType, omni.MachineSetType:
phases[0] = append(phases[0], r)
default:
phases[1] = append(phases[1], r)
}
}
for i := range phases {
sortResources(phases[i], func(r resource.Resource) resource.Metadata { return *r.Metadata() })
}
return phases
}
// Delete returns a sync result which lists what needs to be deleted from state to remove template from the cluster.
func (t *Template) Delete(ctx context.Context, st state.State) (*SyncResult, error) {
actualResources, err := t.actualResources(ctx, st, nil)
if err != nil {
return nil, err
}
clusterName, err := t.models.ClusterName()
if err != nil {
return nil, err
}
toDelete := make([]resource.Resource, 0, len(actualResources))
toUpdate := make([]UpdateChange, 0, len(actualResources))
for _, actualResource := range actualResources {
updateChange, destroyRes, handleErr := getOperationForNoMoreExpectedResource(actualResource, clusterName)
if handleErr != nil {
return nil, handleErr
}
switch {
case updateChange != nil:
toUpdate = append(toUpdate, *updateChange)
case destroyRes != nil:
toDelete = append(toDelete, destroyRes)
}
}
syncResult := SyncResult{
Update: toUpdate,
Destroy: splitResourcesToDelete(toDelete),
}
return &syncResult, nil
}
func metadataKey(md resource.Metadata) string {
return fmt.Sprintf("%s/%s/%s", md.Namespace(), md.Type(), md.ID())
}
// Sync the template against the resource state.
func (t *Template) Sync(ctx context.Context, st state.State) (*SyncResult, error) {
clusterName, err := t.models.ClusterName()
if err != nil {
return nil, err
}
expectedResources, err := t.Translate()
if err != nil {
return nil, err
}
actualResources, err := t.actualResources(ctx, st, expectedResources)
if err != nil {
return nil, err
}
expectedResourceMap := xslices.ToMap(expectedResources, func(r resource.Resource) (string, resource.Resource) {
return metadataKey(*r.Metadata()), r
})
var (
syncResult SyncResult
toDelete []resource.Resource
)
for _, actualResource := range actualResources {
if expectedResource, ok := expectedResourceMap[metadataKey(*actualResource.Metadata())]; ok {
// copy some metadata to minimize the diff
expectedResource.Metadata().SetVersion(actualResource.Metadata().Version())
expectedResource.Metadata().SetUpdated(actualResource.Metadata().Updated())
expectedResource.Metadata().SetCreated(actualResource.Metadata().Created())
expectedResource.Metadata().Finalizers().Set(*actualResource.Metadata().Finalizers())
if !resource.Equal(actualResource, expectedResource) {
syncResult.Update = append(syncResult.Update, UpdateChange{Old: actualResource, New: expectedResource})
}
} else {
updateRes, destroyRes, handleErr := getOperationForNoMoreExpectedResource(actualResource, clusterName)
if handleErr != nil {
return nil, handleErr
}
switch {
case updateRes != nil:
syncResult.Update = append(syncResult.Update, *updateRes)
case destroyRes != nil:
toDelete = append(toDelete, destroyRes)
}
}
delete(expectedResourceMap, metadataKey(*actualResource.Metadata()))
}
for _, expectedResource := range expectedResourceMap {
if _, ok := expectedResourceMap[metadataKey(*expectedResource.Metadata())]; ok {
if err = validateNoResourceConflictOnCreate(ctx, st, expectedResource); err != nil {
return nil, err
}
syncResult.Create = append(syncResult.Create, expectedResource)
}
}
sortResources(syncResult.Create, func(r resource.Resource) resource.Metadata { return *r.Metadata() })
sortResources(syncResult.Update, func(u UpdateChange) resource.Metadata { return *u.New.Metadata() })
syncResult.Destroy = splitResourcesToDelete(toDelete)
return &syncResult, nil
}
func getOperationForNoMoreExpectedResource(actualResource resource.Resource, clusterName string) (update *UpdateChange, destroy resource.Resource, err error) {
switch actualResource.Metadata().Type() {
case omni.KernelArgsType:
_, hasManagedByTemplatesAnnotation := actualResource.Metadata().Annotations().Get(omni.ResourceManagedByClusterTemplates)
if !hasManagedByTemplatesAnnotation {
return nil, nil, nil
}
expectedKernelArgs := actualResource.DeepCopy()
expectedKernelArgs.Metadata().Annotations().Delete(omni.ResourceManagedByClusterTemplates)
return &UpdateChange{Old: actualResource, New: expectedKernelArgs}, nil, nil
default:
// check that actual resource belongs to the cluster to avoid removing resources from other clusters
if actualResource.Metadata().Type() != omni.ClusterType {
if clusterLabel, _ := actualResource.Metadata().Labels().Get(omni.LabelCluster); clusterLabel != clusterName {
return nil, nil, fmt.Errorf("resource %s belongs to cluster %q, but template is for cluster %q", resource.String(actualResource), clusterLabel, clusterName)
}
}
return nil, actualResource, nil
}
}
// validateNoResourceConflictOnCreate checks that creating expectedResource will not conflict with existing resources in the state.
func validateNoResourceConflictOnCreate(ctx context.Context, st state.State, expectedResource resource.Resource) error {
if expectedResource.Metadata().Type() == omni.KernelArgsType { // no cluster relation, nothing to check
return nil
}
actualResource, err := st.Get(ctx, *expectedResource.Metadata())
if err != nil && !state.IsNotFoundError(err) {
return err
}
if actualResource == nil { // doesn't exist, all good
return nil
}
// unexpected resource
clusterLabel, _ := actualResource.Metadata().Labels().Get(omni.LabelCluster)
return fmt.Errorf("resource %s already exists for cluster %q, cannot create a conflicting resource for a different cluster", resource.String(actualResource), clusterLabel)
}
func deduplicateDeletion(toDelete []resource.Resource) []resource.Resource {
toDeleteMap := xslices.ToMap(toDelete, func(r resource.Resource) (string, resource.Resource) {
return metadataKey(*r.Metadata()), r
})
r := xslices.Filter(toDelete, func(r resource.Resource) bool {
switch r.Metadata().Type() {
case omni.ClusterType:
return true
case omni.MachineSetNodeType:
machineSetName, ok := r.Metadata().Labels().Get(omni.LabelMachineSet)
if !ok {
return true
}
if _, ok := toDeleteMap[metadataKey(resource.NewMetadata(resources.DefaultNamespace, omni.MachineSetType, machineSetName, resource.VersionUndefined))]; ok {
return false
}
default:
clusterName, ok := r.Metadata().Labels().Get(omni.LabelCluster)
if !ok {
return true
}
if _, ok := toDeleteMap[metadataKey(resource.NewMetadata(resources.DefaultNamespace, omni.ClusterType, clusterName, resource.VersionUndefined))]; ok {
return false
}
}
return true
})
return r
}