package main import ( "context" "crypto/sha256" "encoding/base32" "encoding/json" "errors" "fmt" "reflect" "slices" "time" 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" "sigs.k8s.io/yaml" ) var ( templateGVR = schema.GroupVersionResource{Group: "argoproj.io", Version: "v1alpha1", Resource: "workflowtemplates"} configMapGVR = schema.GroupVersionResource{Version: "v1", Resource: "configmaps"} templatePending = errors.New("workflow template update is pending") ) const ( templateKey = "delivery.quorate.dev/template" candidateKey = "delivery.quorate.dev/candidate" observedKey = "delivery.quorate.dev/observed" publishedKey = "delivery.quorate.dev/published" ) type templateBundle struct { name string spec map[string]any configs []*unstructured.Unstructured } type podPatch struct { parent map[string]any body any } func (c *controller) reconcileTemplates(ctx context.Context) error { items, err := c.client.Resource(projectDeliveryGVR).Namespace(metav1.NamespaceAll).List(ctx, metav1.ListOptions{}) if err != nil { return err } seen := make(map[string]bool) var failures []error for _, item := range items.Items { delivery, err := decodeProjectDelivery(&item) if err != nil { failures = append(failures, err) continue } names := []string{delivery.Spec.WorkflowTemplateRef.Name} if delivery.Spec.Qualification != nil { names = append(names, delivery.Spec.Qualification.WorkflowTemplateRef.Name) } for _, name := range names { key := item.GetNamespace() + "/" + name if seen[key] { continue } seen[key] = true if _, err := c.readyTemplate(ctx, item.GetNamespace(), name); err != nil && !errors.Is(err, templatePending) { failures = append(failures, fmt.Errorf("template %s: %w", key, err)) } } } templates, err := c.client.Resource(templateGVR).Namespace(metav1.NamespaceAll).List(ctx, metav1.ListOptions{}) if err != nil { return errors.Join(append(failures, err)...) } for _, template := range templates.Items { annotations := template.GetAnnotations() if annotations[candidateKey] == "" || annotations[candidateKey] == annotations[publishedKey] { continue } key := template.GetNamespace() + "/" + template.GetName() if seen[key] { continue } if _, err := c.readyTemplate(ctx, template.GetNamespace(), template.GetName()); err != nil && !errors.Is(err, templatePending) { failures = append(failures, fmt.Errorf("template %s: %w", key, err)) } } return errors.Join(failures...) } func (c *controller) readyTemplate(ctx context.Context, namespace, name string) (string, error) { resource := c.client.Resource(templateGVR).Namespace(namespace) template, err := resource.Get(ctx, name, metav1.GetOptions{}) if err != nil { return "", err } bundle, err := c.captureBundle(ctx, template) if err != nil { return "", err } wanted := bundle.name annotations := template.GetAnnotations() if annotations == nil { annotations = make(map[string]string) } now := c.now().UTC() if annotations[candidateKey] != wanted { annotations[candidateKey] = wanted annotations[observedKey] = now.Format(time.RFC3339Nano) template.SetAnnotations(annotations) template, err = resource.Update(ctx, template, metav1.UpdateOptions{}) if err != nil { return "", err } } since, err := time.Parse(time.RFC3339Nano, annotations[observedKey]) if err != nil { return "", fmt.Errorf("template observation: %w", err) } if now.Before(since.Add(c.config.templateQuiet)) { return "", templatePending } if annotations[publishedKey] == wanted { return wanted, nil } active, err := c.templateActive(ctx, namespace, name) if err != nil { return "", err } if active { return "", templatePending } snapshot := &unstructured.Unstructured{Object: map[string]any{ "apiVersion": "argoproj.io/v1alpha1", "kind": "WorkflowTemplate", "spec": bundle.spec, }} snapshot.SetName(wanted) snapshot.SetNamespace(namespace) snapshot.SetLabels(template.GetLabels()) snapshot.SetAnnotations(map[string]string{templateKey: name}) created, err := resource.Create(ctx, snapshot, metav1.CreateOptions{}) if apierrors.IsAlreadyExists(err) { created, err = resource.Get(ctx, wanted, metav1.GetOptions{}) } if err != nil { return "", err } if !reflect.DeepEqual(created.Object["spec"], bundle.spec) || created.GetAnnotations()[templateKey] != name { return "", fmt.Errorf("template snapshot %q has different content", wanted) } for _, config := range bundle.configs { config.SetOwnerReferences([]metav1.OwnerReference{{ APIVersion: "argoproj.io/v1alpha1", Kind: "WorkflowTemplate", Name: wanted, UID: created.GetUID(), }}) _, err := c.client.Resource(configMapGVR).Namespace(namespace).Create(ctx, config, metav1.CreateOptions{}) if apierrors.IsAlreadyExists(err) { existing, getErr := c.client.Resource(configMapGVR).Namespace(namespace).Get(ctx, config.GetName(), metav1.GetOptions{}) if getErr != nil { return "", getErr } if existing.Object["immutable"] != true || !reflect.DeepEqual(existing.Object["data"], config.Object["data"]) || !reflect.DeepEqual(existing.Object["binaryData"], config.Object["binaryData"]) { return "", fmt.Errorf("action snapshot %q has different content", config.GetName()) } } else if err != nil { return "", err } } // Resource-version comparison prevents publishing over a newer desired template. annotations[publishedKey] = wanted template.SetAnnotations(annotations) if _, err := resource.Update(ctx, template, metav1.UpdateOptions{}); err != nil { return "", err } return wanted, nil } func (c *controller) templateActive(ctx context.Context, namespace, name string) (bool, error) { workflows, err := c.client.Resource(workflowGVR).Namespace(namespace).List(ctx, metav1.ListOptions{}) if err != nil { return false, err } for _, workflow := range workflows.Items { phase, _, err := unstructured.NestedString(workflow.Object, "status", "phase") if err != nil { return false, err } if terminalPhase(phase) { continue } ref, _, err := unstructured.NestedString(workflow.Object, "spec", "workflowTemplateRef", "name") if err != nil { return false, err } if ref == name || workflow.GetAnnotations()[templateKey] == name { return true, nil } if ref == "" { continue } bound, err := c.client.Resource(templateGVR).Namespace(namespace).Get(ctx, ref, metav1.GetOptions{}) if apierrors.IsNotFound(err) { continue } if err != nil { return false, err } if bound.GetAnnotations()[templateKey] == name { return true, nil } } return false, nil } func (c *controller) captureBundle(ctx context.Context, template *unstructured.Unstructured) (templateBundle, error) { spec, _, err := unstructured.NestedMap(template.Object, "spec") if err != nil { return templateBundle{}, err } patches, err := expandPatches(spec) if err != nil { return templateBundle{}, err } refs := make(map[string][]map[string]any) configRefs(spec, refs) names := make([]string, 0, len(refs)) for name := range refs { names = append(names, name) } slices.Sort(names) bundle := templateBundle{spec: spec} for _, name := range names { config, err := c.client.Resource(configMapGVR).Namespace(template.GetNamespace()).Get(ctx, name, metav1.GetOptions{}) if err != nil { return templateBundle{}, err } if config.GetLabels()["app.kubernetes.io/name"] != "project-delivery" { continue } body := map[string]any{} for _, field := range []string{"data", "binaryData"} { if value, found := config.Object[field]; found { body[field] = value } } snapshot := &unstructured.Unstructured{Object: body} snapshot.SetAPIVersion("v1") snapshot.SetKind("ConfigMap") snapshot.SetNamespace(template.GetNamespace()) snapshot.SetName(name) snapshot.SetLabels(config.GetLabels()) snapshot.Object["immutable"] = true bundle.configs = append(bundle.configs, snapshot) } data, err := json.Marshal(map[string]any{"name": template.GetName(), "spec": spec, "configs": bundle.configs}) if err != nil { return templateBundle{}, err } bundle.name = contentName("tpl-", data) for _, config := range bundle.configs { name := config.GetName() config.SetName(contentName("act-", []byte(bundle.name+"/"+name))) for _, ref := range refs[name] { ref["name"] = config.GetName() } } for _, patch := range patches { data, err := json.Marshal(patch.body) if err != nil { return templateBundle{}, err } patch.parent["podSpecPatch"] = string(data) } return bundle, nil } func expandPatches(value any) ([]podPatch, error) { var patches []podPatch switch value := value.(type) { case map[string]any: if text, ok := value["podSpecPatch"].(string); ok { data, err := yaml.YAMLToJSON([]byte(text)) if err != nil { return nil, fmt.Errorf("parse podSpecPatch: %w", err) } var body any if err := json.Unmarshal(data, &body); err != nil { return nil, err } value["podSpecPatch"] = body patches = append(patches, podPatch{parent: value, body: body}) } for _, child := range value { nested, err := expandPatches(child) if err != nil { return nil, err } patches = append(patches, nested...) } case []any: for _, child := range value { nested, err := expandPatches(child) if err != nil { return nil, err } patches = append(patches, nested...) } } return patches, nil } func configRefs(value any, refs map[string][]map[string]any) { switch value := value.(type) { case map[string]any: for key, child := range value { if key == "configMap" || key == "configMapRef" || key == "configMapKeyRef" { if ref, ok := child.(map[string]any); ok { if name, ok := ref["name"].(string); ok { refs[name] = append(refs[name], ref) } } } configRefs(child, refs) } case []any: for _, child := range value { configRefs(child, refs) } } } func contentName(prefix string, data []byte) string { digest := sha256.Sum256(data) return prefix + base32.NewEncoding("abcdefghijklmnopqrstuvwxyz234567").WithPadding(base32.NoPadding).EncodeToString(digest[:])[:16] }