209 lines
6.6 KiB
Go
209 lines
6.6 KiB
Go
package libkubectl
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
|
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
|
"k8s.io/apimachinery/pkg/api/meta"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
|
"k8s.io/apimachinery/pkg/types"
|
|
"k8s.io/apimachinery/pkg/util/yaml"
|
|
"k8s.io/client-go/discovery"
|
|
"k8s.io/client-go/dynamic"
|
|
"k8s.io/client-go/restmapper"
|
|
)
|
|
|
|
// ApplyDynamic applies Kubernetes resources using the dynamic client with Server-Side Apply.
|
|
// This implementation is more performant than the kubectl library approach and provides
|
|
// better conflict resolution through Server-Side Apply (SSA).
|
|
func (c *Client) ApplyDynamic(ctx context.Context, manifests []string) (string, error) {
|
|
// Create REST config from the factory
|
|
restConfig, err := c.factory.ToRESTConfig()
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to create REST config: %w", err)
|
|
}
|
|
|
|
// Create dynamic client
|
|
dynamicClient, err := dynamic.NewForConfig(restConfig)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to create dynamic client: %w", err)
|
|
}
|
|
|
|
// Create discovery client for resource mapping
|
|
discoveryClient, err := discovery.NewDiscoveryClientForConfig(restConfig)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to create discovery client: %w", err)
|
|
}
|
|
|
|
// Create REST mapper
|
|
groupResources, err := restmapper.GetAPIGroupResources(discoveryClient)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to get API group resources: %w", err)
|
|
}
|
|
mapper := restmapper.NewDiscoveryRESTMapper(groupResources)
|
|
|
|
// Get the namespace configured on the client (from form/API payload)
|
|
// The second return value indicates if the namespace was explicitly set
|
|
configuredNamespace, wasExplicitlySet, err := c.factory.ToRawKubeConfigLoader().Namespace()
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to get configured namespace: %w", err)
|
|
}
|
|
// Only treat as configured if it was explicitly set (not just defaulted from kubeconfig)
|
|
if !wasExplicitlySet {
|
|
configuredNamespace = ""
|
|
}
|
|
|
|
var results []string
|
|
var processErr error
|
|
|
|
// Process each manifest
|
|
for _, manifest := range manifests {
|
|
manifest = strings.TrimSpace(manifest)
|
|
if manifest == "" {
|
|
continue
|
|
}
|
|
|
|
var content string
|
|
// Check if manifest is a file path
|
|
if isManifestFile(manifest) {
|
|
data, err := os.ReadFile(manifest)
|
|
if err != nil {
|
|
processErr = errors.Join(processErr, fmt.Errorf("failed to read file %s: %w", manifest, err))
|
|
continue
|
|
}
|
|
content = string(data)
|
|
} else {
|
|
content = manifest
|
|
}
|
|
|
|
// Split by document separator if multiple resources in one manifest
|
|
resources := strings.SplitSeq(content, "\n---\n")
|
|
|
|
for resource := range resources {
|
|
resource = strings.TrimSpace(resource)
|
|
if resource == "" {
|
|
continue
|
|
}
|
|
|
|
result, err := c.applyResource(ctx, dynamicClient, mapper, []byte(resource), configuredNamespace)
|
|
if err != nil {
|
|
processErr = errors.Join(processErr, fmt.Errorf("failed to apply resource: %w", err))
|
|
continue
|
|
}
|
|
results = append(results, result)
|
|
}
|
|
}
|
|
|
|
// Build output message
|
|
output := strings.Join(results, "\n")
|
|
|
|
if processErr != nil {
|
|
if len(results) == 0 {
|
|
return "", fmt.Errorf("failed to apply resources: %s", processErr.Error())
|
|
}
|
|
return output, fmt.Errorf("partially applied resources with errors: %s", processErr.Error())
|
|
}
|
|
|
|
return output, nil
|
|
}
|
|
|
|
// applyResource applies a single resource using Server-Side Apply.
|
|
// configuredNamespace is the namespace set via form/API (empty string means "use manifest namespace").
|
|
func (c *Client) applyResource(ctx context.Context, dynamicClient dynamic.Interface, mapper meta.RESTMapper, resourceYAML []byte, configuredNamespace string) (string, error) {
|
|
obj := &unstructured.Unstructured{}
|
|
decoder := yaml.NewYAMLOrJSONDecoder(strings.NewReader(string(resourceYAML)), 4096)
|
|
if err := decoder.Decode(obj); err != nil {
|
|
return "", fmt.Errorf("failed to decode YAML: %w", err)
|
|
}
|
|
|
|
// Skip empty objects
|
|
if obj.Object == nil {
|
|
return "", nil
|
|
}
|
|
|
|
// Get GVK (GroupVersionKind) from the object
|
|
gvk := obj.GroupVersionKind()
|
|
if gvk.Empty() {
|
|
return "", errors.New("unable to determine resource type")
|
|
}
|
|
|
|
// Map GVK to GVR (GroupVersionResource)
|
|
mapping, err := mapper.RESTMapping(gvk.GroupKind(), gvk.Version)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to map resource type %s: %w", gvk.String(), err)
|
|
}
|
|
|
|
name := obj.GetName()
|
|
|
|
// Get the dynamic resource client
|
|
var resourceClient dynamic.ResourceInterface
|
|
var namespace string
|
|
|
|
if mapping.Scope.Name() == meta.RESTScopeNameNamespace {
|
|
namespace, err = resolveNamespace(configuredNamespace, obj.GetNamespace())
|
|
if err != nil {
|
|
return "", fmt.Errorf("namespace conflict for %s %q: %w", gvk.Kind, name, err)
|
|
}
|
|
obj.SetNamespace(namespace)
|
|
resourceClient = dynamicClient.Resource(mapping.Resource).Namespace(namespace)
|
|
} else {
|
|
resourceClient = dynamicClient.Resource(mapping.Resource)
|
|
}
|
|
|
|
// Convert object to JSON for Server-Side Apply
|
|
data, err := obj.MarshalJSON()
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to marshal object to JSON: %w", err)
|
|
}
|
|
|
|
// Apply using Server-Side Apply (Patch). If the resource does not exist (404),
|
|
// fall back to Create so restoration can create Deployments and other resources
|
|
// that were removed (e.g. by Helm uninstall).
|
|
patchOptions := metav1.PatchOptions{
|
|
FieldManager: "portainer",
|
|
Force: new(true),
|
|
}
|
|
|
|
_, err = resourceClient.Patch(
|
|
ctx,
|
|
name,
|
|
types.ApplyPatchType,
|
|
data,
|
|
patchOptions,
|
|
)
|
|
if err != nil {
|
|
if apierrors.IsNotFound(err) {
|
|
_, createErr := resourceClient.Create(ctx, obj, metav1.CreateOptions{})
|
|
if createErr != nil {
|
|
return "", fmt.Errorf("failed to create %s %s/%s: %w", gvk.Kind, namespace, name, createErr)
|
|
}
|
|
} else {
|
|
return "", fmt.Errorf("failed to apply %s %s/%s: %w", gvk.Kind, namespace, name, err)
|
|
}
|
|
}
|
|
|
|
// Format output message
|
|
resourceType := strings.ToLower(gvk.Kind)
|
|
return fmt.Sprintf("%s/%s configured", resourceType, name), nil
|
|
}
|
|
|
|
// resolveNamespace determines the namespace for a resource
|
|
func resolveNamespace(configuredNamespace, manifestNamespace string) (string, error) {
|
|
// If both namespaces are set and don't match return an error (to match the behavior where the kubectl client (from the form/API) has a different namespace than the manifest)
|
|
if configuredNamespace != "" && manifestNamespace != "" && configuredNamespace != manifestNamespace {
|
|
return "", fmt.Errorf("the namespace %q from the manifest does not match the namespace %q set from the form/API", manifestNamespace, configuredNamespace)
|
|
}
|
|
if configuredNamespace != "" {
|
|
return configuredNamespace, nil
|
|
}
|
|
if manifestNamespace != "" {
|
|
return manifestNamespace, nil
|
|
}
|
|
return "default", nil
|
|
}
|