Ctrl K
ComunidadGuías…

pkg/k8score/workload_images.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package k8score

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"strings"

	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/schema"
	"k8s.io/apimachinery/pkg/types"

	"github.com/skyhook-io/radar/pkg/rollouts"
)

var (
	ErrUnsupportedImageWorkload = errors.New("image updates are not supported for this workload kind")
	ErrInvalidImageUpdate       = errors.New("invalid image update")
	ErrImageWorkloadTerminating = errors.New("workload is terminating")
)

const (
	containerTypeRegular = "container"
	containerTypeInit    = "initContainer"
)

type WorkloadContainerImage struct {
	Type  string `json:"type"`
	Name  string `json:"name"`
	Image string `json:"image"`
}

type WorkloadImageTarget struct {
	Group     string `json:"group"`
	Resource  string `json:"resource"`
	Kind      string `json:"kind"`
	Namespace string `json:"namespace"`
	Name      string `json:"name"`
}

type WorkloadUpdateBehavior struct {
	Type        string `json:"type"`
	Partition   *int64 `json:"partition,omitempty"`
	AutoPromote *bool  `json:"autoPromote,omitempty"`
	Gated       *bool  `json:"gated,omitempty"`
}

type WorkloadImageInventory struct {
	Target     WorkloadImageTarget      `json:"target"`
	Containers []WorkloadContainerImage `json:"containers"`
	Behavior   WorkloadUpdateBehavior   `json:"behavior"`
}

type WorkloadImageUpdate struct {
	Type          string `json:"type"`
	Name          string `json:"name"`
	PreviousImage string `json:"previousImage"`
	Image         string `json:"image"`
}

type SetWorkloadImagesResult struct {
	WorkloadImageInventory
	Object *unstructured.Unstructured `json:"object"`
}

type workloadImageRoot struct {
	gvr          schema.GroupVersionResource
	templatePath []string
}

var workloadImageRoots = map[string]workloadImageRoot{
	"deployments": {
		gvr:          schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "deployments"},
		templatePath: []string{"spec", "template"},
	},
	"statefulsets": {
		gvr:          schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "statefulsets"},
		templatePath: []string{"spec", "template"},
	},
	"daemonsets": {
		gvr:          schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "daemonsets"},
		templatePath: []string{"spec", "template"},
	},
	"rollouts": {
		gvr:          rollouts.GVR,
		templatePath: []string{"spec", "template"},
	},
}

type resolvedWorkloadImageTarget struct {
	normalizedKind string
	root           *unstructured.Unstructured
	target         *unstructured.Unstructured
	targetGVR      schema.GroupVersionResource
	targetName     string
	templatePath   []string
}

type imageLocation struct {
	typeName string
	name     string
	index    int
}

func (m *WorkloadManager) GetWorkloadImages(ctx context.Context, kind, namespace, name string) (*WorkloadImageInventory, error) {
	resolved, err := m.resolveWorkloadImageTarget(ctx, kind, namespace, name)
	if err != nil {
		return nil, err
	}
	return resolved.inventory()
}

func (m *WorkloadManager) SetWorkloadImages(ctx context.Context, kind, namespace, name string, updates []WorkloadImageUpdate) (*SetWorkloadImagesResult, error) {
	resolved, err := m.resolveWorkloadImageTarget(ctx, kind, namespace, name)
	if err != nil {
		return nil, err
	}
	if err := validateImageUpdates(updates); err != nil {
		return nil, err
	}

	patch, locations, err := buildImagePatch(resolved.target, resolved.targetGVR.GroupResource(), resolved.templatePath, updates)
	if err != nil {
		return nil, err
	}
	updated, err := m.patchWorkloadImages(ctx, resolved, patch, locations, updates)
	if err != nil {
		return nil, err
	}

	resolved.target = StripUnstructuredFields(updated)
	inventory, err := resolved.inventory()
	if err != nil {
		return nil, err
	}
	return &SetWorkloadImagesResult{WorkloadImageInventory: *inventory, Object: resolved.target}, nil
}

func (m *WorkloadManager) resolveWorkloadImageTarget(ctx context.Context, kind, namespace, name string) (*resolvedWorkloadImageTarget, error) {
	if m.dynClient == nil {
		return nil, fmt.Errorf("dynamic client not initialized")
	}
	normalizedKind := NormalizeWorkloadKind(strings.ToLower(kind))
	rootInfo, ok := workloadImageRoots[normalizedKind]
	if !ok {
		return nil, fmt.Errorf("%w: %s", ErrUnsupportedImageWorkload, kind)
	}

	root, err := m.dynClient.Resource(rootInfo.gvr).Namespace(namespace).Get(ctx, name, metav1.GetOptions{})
	if err != nil {
		return nil, fmt.Errorf("failed to get workload: %w", err)
	}
	if !root.GetDeletionTimestamp().IsZero() {
		return nil, fmt.Errorf("%s %s/%s: %w", root.GetKind(), namespace, name, ErrImageWorkloadTerminating)
	}

	resolved := &resolvedWorkloadImageTarget{
		normalizedKind: normalizedKind,
		root:           root,
		target:         root,
		targetGVR:      rootInfo.gvr,
		targetName:     name,
		templatePath:   rootInfo.templatePath,
	}
	if normalizedKind != "rollouts" {
		return resolved, nil
	}

	target, err := rollouts.ResolveTemplateTarget(root)
	if err != nil {
		return nil, err
	}
	resolved.targetGVR = target.GVR
	resolved.targetName = target.Name
	resolved.templatePath = target.TemplatePath
	if target.GVR == rollouts.GVR && target.Name == name {
		return resolved, nil
	}

	resolved.target, err = m.dynClient.Resource(target.GVR).Namespace(namespace).Get(ctx, target.Name, metav1.GetOptions{})
	if err != nil {
		return nil, fmt.Errorf("failed to get workload %s/%s referenced by Rollout %s: %w", namespace, target.Name, name, err)
	}
	if !resolved.target.GetDeletionTimestamp().IsZero() {
		return nil, fmt.Errorf("%s %s/%s referenced by Rollout %s: %w", resolved.target.GetKind(), namespace, target.Name, name, ErrImageWorkloadTerminating)
	}
	return resolved, nil
}

func (r *resolvedWorkloadImageTarget) inventory() (*WorkloadImageInventory, error) {
	containers, err := inventoryContainerImages(r.target, r.templatePath)
	if err != nil {
		return nil, err
	}
	return &WorkloadImageInventory{
		Target: WorkloadImageTarget{
			Group:     r.targetGVR.Group,
			Resource:  r.targetGVR.Resource,
			Kind:      r.target.GetKind(),
			Namespace: r.target.GetNamespace(),
			Name:      r.targetName,
		},
		Containers: containers,
		Behavior:   classifyWorkloadUpdateBehavior(r.normalizedKind, r.root),
	}, nil
}

func inventoryContainerImages(obj *unstructured.Unstructured, templatePath []string) ([]WorkloadContainerImage, error) {
	var images []WorkloadContainerImage
	for _, set := range []struct {
		typeName string
		field    string
	}{
		{typeName: containerTypeRegular, field: "containers"},
		{typeName: containerTypeInit, field: "initContainers"},
	} {
		path := append(append([]string{}, templatePath...), "spec", set.field)
		containers, found, err := unstructured.NestedSlice(obj.Object, path...)
		if err != nil {
			return nil, fmt.Errorf("%w: %s is malformed", ErrInvalidImageUpdate, strings.Join(path, "."))
		}
		if !found {
			if set.typeName == containerTypeRegular {
				return nil, fmt.Errorf("%w: workload has no containers", ErrInvalidImageUpdate)
			}
			continue
		}
		for _, raw := range containers {
			container, ok := raw.(map[string]any)
			if !ok {
				return nil, fmt.Errorf("%w: %s contains a malformed entry", ErrInvalidImageUpdate, strings.Join(path, "."))
			}
			name, nameOK := container["name"].(string)
			image, imageOK := container["image"].(string)
			if !nameOK || name == "" || !imageOK || image == "" {
				return nil, fmt.Errorf("%w: every container must have a name and image", ErrInvalidImageUpdate)
			}
			images = append(images, WorkloadContainerImage{Type: set.typeName, Name: name, Image: image})
		}
	}
	return images, nil
}

func validateImageUpdates(updates []WorkloadImageUpdate) error {
	if len(updates) == 0 {
		return fmt.Errorf("%w: at least one changed image is required", ErrInvalidImageUpdate)
	}
	if len(updates) > 64 {
		return fmt.Errorf("%w: at most 64 images can be changed at once", ErrInvalidImageUpdate)
	}
	seen := make(map[string]struct{}, len(updates))
	for _, update := range updates {
		if update.Type != containerTypeRegular && update.Type != containerTypeInit {
			return fmt.Errorf("%w: unknown container type %q", ErrInvalidImageUpdate, update.Type)
		}
		if update.Name == "" || update.PreviousImage == "" || update.Image == "" {
			return fmt.Errorf("%w: container name, previous image, and new image are required", ErrInvalidImageUpdate)
		}
		if update.Image == update.PreviousImage {
			return fmt.Errorf("%w: image for %s is unchanged", ErrInvalidImageUpdate, update.Name)
		}
		key := update.Type + "\x00" + update.Name
		if _, exists := seen[key]; exists {
			return fmt.Errorf("%w: duplicate update for %s", ErrInvalidImageUpdate, update.Name)
		}
		seen[key] = struct{}{}
	}
	return nil
}

func buildImagePatch(obj *unstructured.Unstructured, resource schema.GroupResource, templatePath []string, updates []WorkloadImageUpdate) ([]byte, []imageLocation, error) {
	operations := make([]map[string]any, 0, len(updates)*3)
	locations := make([]imageLocation, 0, len(updates))
	for _, update := range updates {
		field := "containers"
		if update.Type == containerTypeInit {
			field = "initContainers"
		}
		path := append(append([]string{}, templatePath...), "spec", field)
		containers, found, err := unstructured.NestedSlice(obj.Object, path...)
		if err != nil || !found {
			return nil, nil, missingImageConflict(resource, obj, update)
		}

		index := -1
		currentImage := ""
		for i, raw := range containers {
			container, ok := raw.(map[string]any)
			if !ok {
				continue
			}
			if container["name"] == update.Name {
				index = i
				currentImage, _ = container["image"].(string)
				break
			}
		}
		if index < 0 {
			return nil, nil, missingImageConflict(resource, obj, update)
		}
		if currentImage != update.PreviousImage {
			return nil, nil, imageConflict(resource, obj, update, currentImage)
		}

		basePath := "/" + strings.Join(path, "/") + fmt.Sprintf("/%d", index)
		operations = append(operations,
			map[string]any{"op": "test", "path": basePath + "/name", "value": update.Name},
			map[string]any{"op": "test", "path": basePath + "/image", "value": update.PreviousImage},
			map[string]any{"op": "replace", "path": basePath + "/image", "value": update.Image},
		)
		locations = append(locations, imageLocation{typeName: update.Type, name: update.Name, index: index})
	}
	patch, err := json.Marshal(operations)
	if err != nil {
		return nil, nil, fmt.Errorf("failed to build image patch: %w", err)
	}
	return patch, locations, nil
}

func (m *WorkloadManager) patchWorkloadImages(ctx context.Context, resolved *resolvedWorkloadImageTarget, patch []byte, locations []imageLocation, updates []WorkloadImageUpdate) (*unstructured.Unstructured, error) {
	ri := m.dynClient.Resource(resolved.targetGVR).Namespace(resolved.target.GetNamespace())
	updated, err := ri.Patch(ctx, resolved.targetName, types.JSONPatchType, patch, metav1.PatchOptions{})
	if err == nil || !apierrors.IsInvalid(err) {
		if err != nil {
			return nil, fmt.Errorf("failed to update workload images: %w", err)
		}
		return updated, nil
	}

	latest, getErr := ri.Get(ctx, resolved.targetName, metav1.GetOptions{})
	if getErr != nil {
		return nil, fmt.Errorf("failed to re-read workload after rejected image patch: %w", getErr)
	}
	if !latest.GetDeletionTimestamp().IsZero() {
		return nil, fmt.Errorf("%s %s/%s: %w", latest.GetKind(), latest.GetNamespace(), latest.GetName(), ErrImageWorkloadTerminating)
	}
	retryPatch, retryLocations, classifyErr := buildImagePatch(latest, resolved.targetGVR.GroupResource(), resolved.templatePath, updates)
	if classifyErr != nil {
		return nil, classifyErr
	}
	if sameImageLocations(locations, retryLocations) {
		return nil, fmt.Errorf("failed to update workload images: %w", err)
	}

	updated, retryErr := ri.Patch(ctx, resolved.targetName, types.JSONPatchType, retryPatch, metav1.PatchOptions{})
	if retryErr != nil {
		if apierrors.IsInvalid(retryErr) {
			final, finalGetErr := ri.Get(ctx, resolved.targetName, metav1.GetOptions{})
			if finalGetErr != nil {
				return nil, fmt.Errorf("failed to re-read workload after retried image patch: %w", finalGetErr)
			}
			if !final.GetDeletionTimestamp().IsZero() {
				return nil, fmt.Errorf("%s %s/%s: %w", final.GetKind(), final.GetNamespace(), final.GetName(), ErrImageWorkloadTerminating)
			}
			if _, _, finalClassifyErr := buildImagePatch(final, resolved.targetGVR.GroupResource(), resolved.templatePath, updates); apierrors.IsConflict(finalClassifyErr) {
				return nil, finalClassifyErr
			}
		}
		return nil, fmt.Errorf("failed to update workload images: %w", retryErr)
	}
	return updated, nil
}

func sameImageLocations(left, right []imageLocation) bool {
	if len(left) != len(right) {
		return false
	}
	for i := range left {
		if left[i] != right[i] {
			return false
		}
	}
	return true
}

func imageConflict(resource schema.GroupResource, obj *unstructured.Unstructured, update WorkloadImageUpdate, currentImage string) error {
	return apierrors.NewConflict(
		resource,
		obj.GetName(),
		fmt.Errorf("image for %s %q changed from %q to %q; review the latest images before applying", update.Type, update.Name, update.PreviousImage, currentImage),
	)
}

func missingImageConflict(resource schema.GroupResource, obj *unstructured.Unstructured, update WorkloadImageUpdate) error {
	return apierrors.NewConflict(
		resource,
		obj.GetName(),
		fmt.Errorf("%s %q no longer exists; review the latest images before applying", update.Type, update.Name),
	)
}

func classifyWorkloadUpdateBehavior(normalizedKind string, root *unstructured.Unstructured) WorkloadUpdateBehavior {
	switch normalizedKind {
	case "deployments":
		paused, _, _ := unstructured.NestedBool(root.Object, "spec", "paused")
		if paused {
			return WorkloadUpdateBehavior{Type: "paused"}
		}
		strategy, _, _ := unstructured.NestedString(root.Object, "spec", "strategy", "type")
		if strategy == "Recreate" {
			return WorkloadUpdateBehavior{Type: "recreate"}
		}
		return WorkloadUpdateBehavior{Type: "rolling"}
	case "daemonsets":
		strategy, _, _ := unstructured.NestedString(root.Object, "spec", "updateStrategy", "type")
		if strategy == "OnDelete" {
			return WorkloadUpdateBehavior{Type: "onDelete"}
		}
		return WorkloadUpdateBehavior{Type: "rolling"}
	case "statefulsets":
		strategy, _, _ := unstructured.NestedString(root.Object, "spec", "updateStrategy", "type")
		if strategy == "OnDelete" {
			return WorkloadUpdateBehavior{Type: "onDelete"}
		}
		partition, found, _ := unstructured.NestedInt64(root.Object, "spec", "updateStrategy", "rollingUpdate", "partition")
		if found && partition > 0 {
			return WorkloadUpdateBehavior{Type: "partitioned", Partition: &partition}
		}
		return WorkloadUpdateBehavior{Type: "rolling"}
	case "rollouts":
		paused, _, _ := unstructured.NestedBool(root.Object, "spec", "paused")
		if paused {
			return WorkloadUpdateBehavior{Type: "paused"}
		}
		switch rollouts.StrategyOf(root) {
		case rollouts.StrategyCanary:
			steps, _, _ := unstructured.NestedSlice(root.Object, "spec", "strategy", "canary", "steps")
			gated := len(steps) > 0
			return WorkloadUpdateBehavior{Type: string(rollouts.StrategyCanary), Gated: &gated}
		case rollouts.StrategyBlueGreen:
			autoPromote, found, _ := unstructured.NestedBool(root.Object, "spec", "strategy", "blueGreen", "autoPromotionEnabled")
			if !found {
				autoPromote = true
			}
			return WorkloadUpdateBehavior{Type: string(rollouts.StrategyBlueGreen), AutoPromote: &autoPromote}
		}
		return WorkloadUpdateBehavior{Type: "rolling"}
	default:
		return WorkloadUpdateBehavior{Type: "rolling"}
	}
}

func WorkloadImageTargetForRollout(ro *unstructured.Unstructured) (group, resource string, needsGet bool, supported bool) {
	target, err := rollouts.ResolveTemplateTarget(ro)
	if err != nil {
		return "", "", false, false
	}
	return target.GVR.Group, target.GVR.Resource, target.GVR != rollouts.GVR || target.Name != ro.GetName(), true
}