759 lines
23 KiB
Go
759 lines
23 KiB
Go
|
|
package main
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"crypto/sha256"
|
||
|
|
"encoding/base32"
|
||
|
|
"encoding/json"
|
||
|
|
"errors"
|
||
|
|
"fmt"
|
||
|
|
"log/slog"
|
||
|
|
"slices"
|
||
|
|
"sort"
|
||
|
|
"strings"
|
||
|
|
"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"
|
||
|
|
"k8s.io/client-go/dynamic"
|
||
|
|
"k8s.io/client-go/util/retry"
|
||
|
|
)
|
||
|
|
|
||
|
|
var (
|
||
|
|
projectDeliveryGVR = schema.GroupVersionResource{Group: "delivery.quorate.dev", Version: "v1alpha1", Resource: "projectdeliveries"}
|
||
|
|
workflowGVR = schema.GroupVersionResource{Group: "argoproj.io", Version: "v1alpha1", Resource: "workflows"}
|
||
|
|
)
|
||
|
|
|
||
|
|
type controllerConfig struct {
|
||
|
|
pollInterval time.Duration
|
||
|
|
bucketInterval time.Duration
|
||
|
|
requestTimeout time.Duration
|
||
|
|
templateQuiet time.Duration
|
||
|
|
}
|
||
|
|
|
||
|
|
type controller struct {
|
||
|
|
client dynamic.Interface
|
||
|
|
source sourceReader
|
||
|
|
now func() time.Time
|
||
|
|
config controllerConfig
|
||
|
|
qualCursor string
|
||
|
|
}
|
||
|
|
|
||
|
|
func (c *controller) run(ctx context.Context) error {
|
||
|
|
ticker := time.NewTicker(c.config.pollInterval)
|
||
|
|
defer ticker.Stop()
|
||
|
|
for {
|
||
|
|
c.reconcileAll(ctx)
|
||
|
|
select {
|
||
|
|
case <-ctx.Done():
|
||
|
|
return ctx.Err()
|
||
|
|
case <-ticker.C:
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (c *controller) reconcileAll(ctx context.Context) {
|
||
|
|
if err := c.releaseOrphans(ctx); err != nil {
|
||
|
|
slog.Error("release orphan records", "error", err)
|
||
|
|
}
|
||
|
|
if err := c.reconcileTemplates(ctx); err != nil {
|
||
|
|
slog.Error("reconcile templates", "error", err)
|
||
|
|
}
|
||
|
|
if err := c.qualifyAll(ctx); err != nil {
|
||
|
|
slog.Error("reconcile qualification", "error", err)
|
||
|
|
}
|
||
|
|
items, err := c.client.Resource(projectDeliveryGVR).Namespace(metav1.NamespaceAll).List(ctx, metav1.ListOptions{})
|
||
|
|
if err != nil {
|
||
|
|
slog.Error("list ProjectDeliveries", "error", err)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
for index := range items.Items {
|
||
|
|
item := &items.Items[index]
|
||
|
|
if err := c.reconcileKey(ctx, item.GetNamespace(), item.GetName()); err != nil {
|
||
|
|
slog.Error("reconcile ProjectDelivery", "namespace", item.GetNamespace(), "name", item.GetName(), "error", err)
|
||
|
|
c.recordError(ctx, item.GetNamespace(), item.GetName(), err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (c *controller) reconcileKey(ctx context.Context, namespace, name string) error {
|
||
|
|
return retry.RetryOnConflict(retry.DefaultRetry, func() error {
|
||
|
|
object, err := c.client.Resource(projectDeliveryGVR).Namespace(namespace).Get(ctx, name, metav1.GetOptions{})
|
||
|
|
if apierrors.IsNotFound(err) {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
return c.reconcile(ctx, object)
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
func (c *controller) reconcile(ctx context.Context, object *unstructured.Unstructured) error {
|
||
|
|
delivery, err := decodeProjectDelivery(object)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if delivery.Metadata.DeletionTimestamp != nil {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
if err := validateTargets(delivery.Spec.Targets); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
recovered, err := c.recoverBuilds(ctx, &delivery)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
active, err := c.resumeAttempts(ctx, &delivery)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if active || recovered {
|
||
|
|
return c.updateStatus(ctx, object, &delivery)
|
||
|
|
}
|
||
|
|
retried, err := c.reconcileRetry(ctx, &delivery)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
var reconcileErr error
|
||
|
|
switch {
|
||
|
|
case retried:
|
||
|
|
case delivery.Spec.Source.Continuous != nil:
|
||
|
|
refs, err := c.readRefs(ctx, delivery.Spec.Source.Repository)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("read Forgejo refs: %w", err)
|
||
|
|
}
|
||
|
|
reconcileErr = c.reconcileContinuous(ctx, &delivery, refs)
|
||
|
|
case delivery.Spec.Source.Point != nil:
|
||
|
|
reconcileErr = c.reconcilePoint(ctx, &delivery)
|
||
|
|
default:
|
||
|
|
reconcileErr = errors.New("delivery source has no mode")
|
||
|
|
}
|
||
|
|
if reconcileErr != nil {
|
||
|
|
return reconcileErr
|
||
|
|
}
|
||
|
|
delivery.Status.ObservedGeneration = delivery.Metadata.Generation
|
||
|
|
return c.updateStatus(ctx, object, &delivery)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (c *controller) resumeAttempts(ctx context.Context, delivery *projectDelivery) (bool, error) {
|
||
|
|
active := false
|
||
|
|
if status := delivery.Status.Continuous; status != nil && status.Attempt != nil && !terminalPhase(status.Attempt.Phase) {
|
||
|
|
if err := c.advanceAttempt(ctx, delivery, status.Attempt); err != nil {
|
||
|
|
return false, err
|
||
|
|
}
|
||
|
|
recordDelivered(status)
|
||
|
|
delivery.Status.Message = "build attempt: " + phaseOrPending(status.Attempt.Phase)
|
||
|
|
active = true
|
||
|
|
}
|
||
|
|
for index := range delivery.Status.Points {
|
||
|
|
attempt := &delivery.Status.Points[index]
|
||
|
|
if terminalPhase(attempt.Phase) {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
if err := c.advanceAttempt(ctx, delivery, attempt); err != nil {
|
||
|
|
return false, err
|
||
|
|
}
|
||
|
|
delivery.Status.Message = "point attempt: " + phaseOrPending(attempt.Phase)
|
||
|
|
active = true
|
||
|
|
}
|
||
|
|
return active, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (c *controller) reconcileRetry(ctx context.Context, delivery *projectDelivery) (bool, error) {
|
||
|
|
request := delivery.Metadata.Annotations[retryAnnotation]
|
||
|
|
if request == "" || request == delivery.Status.LastHandledRetry {
|
||
|
|
return false, nil
|
||
|
|
}
|
||
|
|
target, continuous := retryTarget(delivery, request)
|
||
|
|
if target == nil {
|
||
|
|
return false, fmt.Errorf("retry Workflow %q is not a recorded attempt", request)
|
||
|
|
}
|
||
|
|
if otherAttemptActive(delivery, request) {
|
||
|
|
return false, nil
|
||
|
|
}
|
||
|
|
if !terminalPhase(target.Phase) {
|
||
|
|
return false, nil
|
||
|
|
}
|
||
|
|
successor := attemptStatus{
|
||
|
|
Ref: target.Ref,
|
||
|
|
Revision: target.Revision,
|
||
|
|
SourceIdentity: target.SourceIdentity,
|
||
|
|
RetryOf: request,
|
||
|
|
Phase: "Pending",
|
||
|
|
}
|
||
|
|
if err := c.advanceAttempt(ctx, delivery, &successor); err != nil {
|
||
|
|
return false, err
|
||
|
|
}
|
||
|
|
if successor.WorkflowName == "" {
|
||
|
|
delivery.Status.Message = templatePending.Error()
|
||
|
|
return true, nil
|
||
|
|
}
|
||
|
|
if continuous {
|
||
|
|
rememberAttempt(delivery.Status.Continuous, &successor)
|
||
|
|
target = delivery.Status.Continuous.Attempt
|
||
|
|
} else {
|
||
|
|
delivery.Status.Points = append(delivery.Status.Points, successor)
|
||
|
|
target = &delivery.Status.Points[len(delivery.Status.Points)-1]
|
||
|
|
}
|
||
|
|
delivery.Status.LastHandledRetry = request
|
||
|
|
delivery.Status.Message = "retry attempt: " + phaseOrPending(target.Phase)
|
||
|
|
return true, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func retryTarget(delivery *projectDelivery, workflow string) (*attemptStatus, bool) {
|
||
|
|
if delivery.Status.Continuous != nil {
|
||
|
|
attempt := delivery.Status.Continuous.Attempt
|
||
|
|
if attempt != nil && attempt.WorkflowName == workflow {
|
||
|
|
return attempt, true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
for index := range delivery.Status.Points {
|
||
|
|
attempt := &delivery.Status.Points[index]
|
||
|
|
if attempt.WorkflowName == workflow {
|
||
|
|
return attempt, false
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return nil, false
|
||
|
|
}
|
||
|
|
|
||
|
|
func otherAttemptActive(delivery *projectDelivery, excluded string) bool {
|
||
|
|
if delivery.Status.Continuous != nil {
|
||
|
|
attempt := delivery.Status.Continuous.Attempt
|
||
|
|
if attempt != nil && attempt.WorkflowName != excluded && !terminalPhase(attempt.Phase) {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
for index := range delivery.Status.Points {
|
||
|
|
attempt := &delivery.Status.Points[index]
|
||
|
|
if attempt.WorkflowName != excluded && !terminalPhase(attempt.Phase) {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
|
||
|
|
func (c *controller) reconcileContinuous(
|
||
|
|
ctx context.Context,
|
||
|
|
delivery *projectDelivery,
|
||
|
|
refs map[string]string,
|
||
|
|
) error {
|
||
|
|
ref := delivery.Spec.Source.Continuous.Ref
|
||
|
|
revision, found := refs[ref]
|
||
|
|
if !found {
|
||
|
|
return fmt.Errorf("continuous ref %q is absent", ref)
|
||
|
|
}
|
||
|
|
if delivery.Status.Continuous == nil {
|
||
|
|
delivery.Status.Continuous = &continuousStatus{}
|
||
|
|
}
|
||
|
|
status := delivery.Status.Continuous
|
||
|
|
sourceIdentity, err := c.sourceIdentity(ctx, delivery, revision)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("read continuous path identity: %w", err)
|
||
|
|
}
|
||
|
|
filtered := len(delivery.Spec.Source.Continuous.Paths) != 0
|
||
|
|
if consumedIdentity(status, sourceIdentity) {
|
||
|
|
clearCandidate(status)
|
||
|
|
delivery.Status.Message = "continuous source already attempted"
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
if deliveredMatches(status.Delivered, sourceIdentity) ||
|
||
|
|
status.Delivered == nil && status.Attempt != nil && status.Attempt.Phase == "Succeeded" &&
|
||
|
|
attemptMatches(status.Attempt, sourceIdentity, revision, filtered) {
|
||
|
|
clearCandidate(status)
|
||
|
|
delivery.Status.Message = "continuous source already delivered"
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
if status.Attempt != nil && terminalPhase(status.Attempt.Phase) &&
|
||
|
|
attemptMatches(status.Attempt, sourceIdentity, revision, filtered) {
|
||
|
|
delivery.Status.Message = "continuous source already attempted"
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
now := c.now().UTC()
|
||
|
|
bucket := now.Truncate(c.config.bucketInterval).Format(time.RFC3339)
|
||
|
|
if status.CandidateIdentity != sourceIdentity {
|
||
|
|
status.CandidateRevision = revision
|
||
|
|
status.CandidateIdentity = sourceIdentity
|
||
|
|
status.CandidateBuckets = []string{bucket}
|
||
|
|
status.CandidateSince = &now
|
||
|
|
if delivery.Spec.Source.Continuous.Debounce == "" {
|
||
|
|
delivery.Status.Message = "observed continuous candidate 1/3"
|
||
|
|
} else {
|
||
|
|
delivery.Status.Message = "observed continuous candidate"
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
status.CandidateRevision = revision
|
||
|
|
if configured := delivery.Spec.Source.Continuous.Debounce; configured != "" {
|
||
|
|
debounce, err := time.ParseDuration(configured)
|
||
|
|
if err != nil || debounce <= 0 {
|
||
|
|
return fmt.Errorf("invalid continuous debounce %q", configured)
|
||
|
|
}
|
||
|
|
if status.CandidateSince == nil {
|
||
|
|
status.CandidateSince = &now
|
||
|
|
delivery.Status.Message = "observed continuous candidate"
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
if now.Before(status.CandidateSince.Add(debounce)) {
|
||
|
|
delivery.Status.Message = "continuous candidate debouncing"
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
if !contains(status.CandidateBuckets, bucket) && len(status.CandidateBuckets) < 3 {
|
||
|
|
status.CandidateBuckets = append(status.CandidateBuckets, bucket)
|
||
|
|
}
|
||
|
|
if len(status.CandidateBuckets) < 3 {
|
||
|
|
delivery.Status.Message = fmt.Sprintf("observed continuous candidate %d/3", len(status.CandidateBuckets))
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
}
|
||
|
|
latest, err := c.readRefs(ctx, delivery.Spec.Source.Repository)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("reread continuous ref: %w", err)
|
||
|
|
}
|
||
|
|
latestRevision, found := latest[ref]
|
||
|
|
if !found {
|
||
|
|
return fmt.Errorf("continuous ref %q is absent after debounce", ref)
|
||
|
|
}
|
||
|
|
latestIdentity, err := c.sourceIdentity(ctx, delivery, latestRevision)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("reread continuous path identity: %w", err)
|
||
|
|
}
|
||
|
|
if consumedIdentity(status, latestIdentity) {
|
||
|
|
clearCandidate(status)
|
||
|
|
delivery.Status.Message = "continuous source returned to attempted state"
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
if deliveredMatches(status.Delivered, latestIdentity) {
|
||
|
|
clearCandidate(status)
|
||
|
|
delivery.Status.Message = "continuous source returned to delivered state"
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
if latestIdentity != sourceIdentity {
|
||
|
|
status.CandidateRevision = latestRevision
|
||
|
|
status.CandidateIdentity = latestIdentity
|
||
|
|
status.CandidateBuckets = nil
|
||
|
|
status.CandidateSince = nil
|
||
|
|
delivery.Status.Message = "continuous source changed before Workflow creation"
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
revision = latestRevision
|
||
|
|
status.CandidateRevision = revision
|
||
|
|
attempt := &attemptStatus{
|
||
|
|
Ref: ref,
|
||
|
|
Revision: revision,
|
||
|
|
SourceIdentity: sourceIdentity,
|
||
|
|
Phase: "Pending",
|
||
|
|
}
|
||
|
|
if err := c.advanceAttempt(ctx, delivery, attempt); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if attempt.WorkflowName == "" {
|
||
|
|
delivery.Status.Message = templatePending.Error()
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
rememberAttempt(status, attempt)
|
||
|
|
recordDelivered(status)
|
||
|
|
delivery.Status.Message = "build attempt: " + phaseOrPending(attempt.Phase)
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (c *controller) reconcilePoint(
|
||
|
|
ctx context.Context,
|
||
|
|
delivery *projectDelivery,
|
||
|
|
) error {
|
||
|
|
for _, revision := range delivery.Spec.Source.Point.Revisions {
|
||
|
|
if pointAttempted(delivery.Status.Points, revision) {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
attempt := attemptStatus{
|
||
|
|
Revision: revision,
|
||
|
|
Phase: "Pending",
|
||
|
|
}
|
||
|
|
if err := c.advanceAttempt(ctx, delivery, &attempt); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if attempt.WorkflowName == "" {
|
||
|
|
delivery.Status.Message = templatePending.Error()
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
delivery.Status.Points = append(delivery.Status.Points, attempt)
|
||
|
|
delivery.Status.Message = "point attempt: " + phaseOrPending(attempt.Phase)
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
delivery.Status.Message = "point revisions already attempted"
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (c *controller) advanceAttempt(
|
||
|
|
ctx context.Context,
|
||
|
|
delivery *projectDelivery,
|
||
|
|
attempt *attemptStatus,
|
||
|
|
) error {
|
||
|
|
if attempt.Input == nil {
|
||
|
|
targets, err := json.Marshal(delivery.Spec.Targets)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
attempt.Input = map[string]string{
|
||
|
|
"source-repository": delivery.Spec.Source.Repository,
|
||
|
|
"source-ref": attempt.Ref,
|
||
|
|
"source-revision": attempt.Revision,
|
||
|
|
"targets": string(targets),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
name := attempt.WorkflowName
|
||
|
|
if name == "" {
|
||
|
|
name = attemptName(delivery, attempt)
|
||
|
|
if err := c.ensureWorkflow(ctx, delivery, name, delivery.Spec.WorkflowTemplateRef.Name, "build", attempt.Input, map[string]string{sourceKey: attempt.SourceIdentity, retryKey: attempt.RetryOf}); err != nil {
|
||
|
|
if errors.Is(err, templatePending) {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
}
|
||
|
|
state, err := c.workflow(ctx, delivery.Metadata.Namespace, name)
|
||
|
|
if apierrors.IsNotFound(err) && attempt.WorkflowName != "" {
|
||
|
|
attempt.Phase = "Error"
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
attempt.WorkflowName = name
|
||
|
|
attempt.CreatedAt = state.CreatedAt
|
||
|
|
attempt.Input = state.Input
|
||
|
|
attempt.Ref = state.Input["source-ref"]
|
||
|
|
attempt.Revision = state.Input["source-revision"]
|
||
|
|
attempt.Phase = phaseOrPending(state.Phase)
|
||
|
|
if result, found := state.Outputs["delivery-result"]; found {
|
||
|
|
attempt.Result = opaqueResult(result)
|
||
|
|
}
|
||
|
|
observeQualBuild(delivery, attempt)
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (c *controller) ensureWorkflow(
|
||
|
|
ctx context.Context,
|
||
|
|
delivery *projectDelivery,
|
||
|
|
name, templateName, purpose string,
|
||
|
|
parameters map[string]string,
|
||
|
|
capture map[string]string,
|
||
|
|
) error {
|
||
|
|
resource := c.client.Resource(workflowGVR).Namespace(delivery.Metadata.Namespace)
|
||
|
|
if _, err := resource.Get(ctx, name, metav1.GetOptions{}); err == nil {
|
||
|
|
return nil
|
||
|
|
} else if !apierrors.IsNotFound(err) {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
published, err := c.readyTemplate(ctx, delivery.Metadata.Namespace, templateName)
|
||
|
|
if errors.Is(err, templatePending) {
|
||
|
|
if _, getErr := resource.Get(ctx, name, metav1.GetOptions{}); getErr == nil {
|
||
|
|
return nil
|
||
|
|
} else if !apierrors.IsNotFound(getErr) {
|
||
|
|
return getErr
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
metadata := map[string]any{
|
||
|
|
"name": name,
|
||
|
|
"namespace": delivery.Metadata.Namespace,
|
||
|
|
"annotations": map[string]any{templateKey: templateName},
|
||
|
|
"finalizers": []any{recordKey},
|
||
|
|
"labels": map[string]any{
|
||
|
|
"app.kubernetes.io/name": "project-delivery",
|
||
|
|
"app.kubernetes.io/instance": delivery.Metadata.Name,
|
||
|
|
"delivery.quorate.dev/purpose": purpose,
|
||
|
|
recordKey: "pending",
|
||
|
|
},
|
||
|
|
"ownerReferences": []any{map[string]any{
|
||
|
|
"apiVersion": "delivery.quorate.dev/v1alpha1",
|
||
|
|
"kind": "ProjectDelivery",
|
||
|
|
"name": delivery.Metadata.Name,
|
||
|
|
"uid": delivery.Metadata.UID,
|
||
|
|
"controller": true,
|
||
|
|
"blockOwnerDeletion": false,
|
||
|
|
}},
|
||
|
|
}
|
||
|
|
for key, value := range capture {
|
||
|
|
metadata["annotations"].(map[string]any)[key] = value
|
||
|
|
}
|
||
|
|
names := make([]string, 0, len(parameters))
|
||
|
|
for parameter := range parameters {
|
||
|
|
names = append(names, parameter)
|
||
|
|
}
|
||
|
|
sort.Strings(names)
|
||
|
|
arguments := make([]any, 0, len(names))
|
||
|
|
for _, parameter := range names {
|
||
|
|
arguments = append(arguments, map[string]any{"name": parameter, "value": parameters[parameter]})
|
||
|
|
}
|
||
|
|
workflow := &unstructured.Unstructured{Object: map[string]any{
|
||
|
|
"apiVersion": "argoproj.io/v1alpha1",
|
||
|
|
"kind": "Workflow",
|
||
|
|
"metadata": metadata,
|
||
|
|
"spec": map[string]any{
|
||
|
|
"workflowTemplateRef": map[string]any{"name": published},
|
||
|
|
"arguments": map[string]any{"parameters": arguments},
|
||
|
|
"synchronization": map[string]any{
|
||
|
|
"mutexes": []any{map[string]any{"name": templateName}},
|
||
|
|
},
|
||
|
|
},
|
||
|
|
}}
|
||
|
|
_, err = resource.Create(ctx, workflow, metav1.CreateOptions{})
|
||
|
|
if apierrors.IsAlreadyExists(err) {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
func (c *controller) workflow(ctx context.Context, namespace, name string) (workflowState, error) {
|
||
|
|
object, err := c.client.Resource(workflowGVR).Namespace(namespace).Get(ctx, name, metav1.GetOptions{})
|
||
|
|
if err != nil {
|
||
|
|
return workflowState{}, err
|
||
|
|
}
|
||
|
|
phase, _, _ := unstructured.NestedString(object.Object, "status", "phase")
|
||
|
|
outputs := map[string]string{}
|
||
|
|
parameters, _, _ := unstructured.NestedSlice(object.Object, "status", "outputs", "parameters")
|
||
|
|
rootParameters, _, _ := unstructured.NestedSlice(object.Object, "status", "nodes", name, "outputs", "parameters")
|
||
|
|
parameters = append(parameters, rootParameters...)
|
||
|
|
for _, item := range parameters {
|
||
|
|
parameter, ok := item.(map[string]any)
|
||
|
|
if !ok {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
name, _ := parameter["name"].(string)
|
||
|
|
value, _ := parameter["value"].(string)
|
||
|
|
if name != "" {
|
||
|
|
outputs[name] = value
|
||
|
|
}
|
||
|
|
}
|
||
|
|
input := map[string]string{}
|
||
|
|
arguments, _, _ := unstructured.NestedSlice(object.Object, "spec", "arguments", "parameters")
|
||
|
|
for _, item := range arguments {
|
||
|
|
parameter := item.(map[string]any)
|
||
|
|
name, _ := parameter["name"].(string)
|
||
|
|
value, _ := parameter["value"].(string)
|
||
|
|
input[name] = value
|
||
|
|
}
|
||
|
|
var startedAt *time.Time
|
||
|
|
if value, _, _ := unstructured.NestedString(object.Object, "status", "startedAt"); value != "" {
|
||
|
|
timestamp, err := time.Parse(time.RFC3339, value)
|
||
|
|
if err != nil {
|
||
|
|
return workflowState{}, err
|
||
|
|
}
|
||
|
|
if !timestamp.IsZero() {
|
||
|
|
startedAt = ×tamp
|
||
|
|
}
|
||
|
|
}
|
||
|
|
ownerUID := ""
|
||
|
|
for _, owner := range object.GetOwnerReferences() {
|
||
|
|
if owner.Kind == "ProjectDelivery" && owner.APIVersion == "delivery.quorate.dev/v1alpha1" {
|
||
|
|
ownerUID = string(owner.UID)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
createdAt := object.GetCreationTimestamp()
|
||
|
|
return workflowState{Phase: phase, Outputs: outputs, Input: input, StartedAt: startedAt, OwnerUID: ownerUID, CreatedAt: &createdAt.Time}, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func consumedIdentity(status *continuousStatus, identity string) bool {
|
||
|
|
return status.Attempt != nil && status.Attempt.History != nil && slices.Contains(status.Attempt.History.Identities, identity)
|
||
|
|
}
|
||
|
|
|
||
|
|
func rememberAttempt(status *continuousStatus, attempt *attemptStatus) {
|
||
|
|
history := &attemptHistory{}
|
||
|
|
if previous := status.Attempt; previous != nil {
|
||
|
|
if previous.History != nil {
|
||
|
|
history.Workflows = slices.Clone(previous.History.Workflows)
|
||
|
|
history.Identities = slices.Clone(previous.History.Identities)
|
||
|
|
}
|
||
|
|
addHistory(history, previous)
|
||
|
|
}
|
||
|
|
addHistory(history, attempt)
|
||
|
|
attempt.History = history
|
||
|
|
status.Attempt = attempt
|
||
|
|
}
|
||
|
|
|
||
|
|
func addHistory(history *attemptHistory, attempt *attemptStatus) {
|
||
|
|
if attempt.WorkflowName != "" && !slices.Contains(history.Workflows, attempt.WorkflowName) {
|
||
|
|
history.Workflows = append(history.Workflows, attempt.WorkflowName)
|
||
|
|
}
|
||
|
|
if attempt.SourceIdentity != "" && !slices.Contains(history.Identities, attempt.SourceIdentity) {
|
||
|
|
history.Identities = append(history.Identities, attempt.SourceIdentity)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (c *controller) updateStatus(ctx context.Context, object *unstructured.Unstructured, delivery *projectDelivery) error {
|
||
|
|
status, err := toMap(delivery.Status)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
copy := object.DeepCopy()
|
||
|
|
copy.Object["status"] = status
|
||
|
|
_, err = c.client.Resource(projectDeliveryGVR).Namespace(copy.GetNamespace()).UpdateStatus(ctx, copy, metav1.UpdateOptions{})
|
||
|
|
if apierrors.IsNotFound(err) {
|
||
|
|
return c.releaseOrphans(ctx)
|
||
|
|
}
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
return c.releaseRecords(ctx, delivery)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (c *controller) recordError(ctx context.Context, namespace, name string, reconcileError error) {
|
||
|
|
object, err := c.client.Resource(projectDeliveryGVR).Namespace(namespace).Get(ctx, name, metav1.GetOptions{})
|
||
|
|
if err != nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
delivery, err := decodeProjectDelivery(object)
|
||
|
|
if err != nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
delivery.Status.Message = reconcileError.Error()
|
||
|
|
delivery.Status.ObservedGeneration = delivery.Metadata.Generation
|
||
|
|
_ = c.updateStatus(ctx, object, &delivery)
|
||
|
|
}
|
||
|
|
|
||
|
|
func decodeProjectDelivery(object *unstructured.Unstructured) (projectDelivery, error) {
|
||
|
|
content, err := json.Marshal(object.Object)
|
||
|
|
if err != nil {
|
||
|
|
return projectDelivery{}, err
|
||
|
|
}
|
||
|
|
var delivery projectDelivery
|
||
|
|
if err := json.Unmarshal(content, &delivery); err != nil {
|
||
|
|
return projectDelivery{}, err
|
||
|
|
}
|
||
|
|
return delivery, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func validateTargets(targets []json.RawMessage) error {
|
||
|
|
seen := make(map[string]struct{}, len(targets))
|
||
|
|
for index, target := range targets {
|
||
|
|
key := string(target)
|
||
|
|
if _, found := seen[key]; found {
|
||
|
|
return fmt.Errorf("duplicate target at index %d", index)
|
||
|
|
}
|
||
|
|
seen[key] = struct{}{}
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func toMap(value any) (map[string]any, error) {
|
||
|
|
content, err := json.Marshal(value)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
var result map[string]any
|
||
|
|
err = json.Unmarshal(content, &result)
|
||
|
|
return result, err
|
||
|
|
}
|
||
|
|
|
||
|
|
func workflowName(purpose string, fields ...string) string {
|
||
|
|
digest := sha256.Sum256([]byte("v1\x00" + purpose + "\x00" + strings.Join(fields, "\x00")))
|
||
|
|
encoded := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(digest[:20])
|
||
|
|
return "pd-" + purpose + "-" + strings.ToLower(encoded)
|
||
|
|
}
|
||
|
|
|
||
|
|
func attemptName(delivery *projectDelivery, attempt *attemptStatus) string {
|
||
|
|
sourceMode := ""
|
||
|
|
identity := attempt.Revision
|
||
|
|
if attempt.Ref != "" {
|
||
|
|
sourceMode = "continuous"
|
||
|
|
if attempt.SourceIdentity != "" {
|
||
|
|
identity = attempt.SourceIdentity
|
||
|
|
}
|
||
|
|
}
|
||
|
|
fields := []string{delivery.Metadata.UID, sourceMode, identity}
|
||
|
|
if attempt.RetryOf != "" {
|
||
|
|
fields = append(fields, attempt.RetryOf)
|
||
|
|
}
|
||
|
|
return workflowName("build", fields...)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (c *controller) readRefs(ctx context.Context, repository string) (map[string]string, error) {
|
||
|
|
requestContext, cancel := context.WithTimeout(ctx, c.config.requestTimeout)
|
||
|
|
defer cancel()
|
||
|
|
return c.source.Refs(requestContext, repository)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (c *controller) sourceIdentity(
|
||
|
|
ctx context.Context,
|
||
|
|
delivery *projectDelivery,
|
||
|
|
revision string,
|
||
|
|
) (string, error) {
|
||
|
|
continuous := delivery.Spec.Source.Continuous
|
||
|
|
paths := append([]string(nil), continuous.Paths...)
|
||
|
|
sort.Strings(paths)
|
||
|
|
if len(paths) == 0 {
|
||
|
|
return revision, nil
|
||
|
|
}
|
||
|
|
requestContext, cancel := context.WithTimeout(ctx, c.config.requestTimeout)
|
||
|
|
defer cancel()
|
||
|
|
value, err := c.source.PathIdentity(requestContext, delivery.Spec.Source.Repository, revision, paths)
|
||
|
|
if err != nil {
|
||
|
|
return "", err
|
||
|
|
}
|
||
|
|
return value, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func recordDelivered(status *continuousStatus) {
|
||
|
|
attempt := status.Attempt
|
||
|
|
if attempt == nil || attempt.Phase != "Succeeded" || attempt.SourceIdentity == "" {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
status.Delivered = &deliveredSource{
|
||
|
|
Revision: attempt.Revision,
|
||
|
|
Identity: attempt.SourceIdentity,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func deliveredMatches(delivered *deliveredSource, sourceIdentity string) bool {
|
||
|
|
return delivered != nil && delivered.Identity == sourceIdentity
|
||
|
|
}
|
||
|
|
|
||
|
|
func attemptMatches(attempt *attemptStatus, sourceIdentity, revision string, filtered bool) bool {
|
||
|
|
if attempt.SourceIdentity != "" {
|
||
|
|
return attempt.SourceIdentity == sourceIdentity
|
||
|
|
}
|
||
|
|
return !filtered && attempt.Revision == revision
|
||
|
|
}
|
||
|
|
|
||
|
|
func clearCandidate(status *continuousStatus) {
|
||
|
|
status.CandidateRevision = ""
|
||
|
|
status.CandidateIdentity = ""
|
||
|
|
status.CandidateBuckets = nil
|
||
|
|
status.CandidateSince = nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func contains(values []string, expected string) bool {
|
||
|
|
for _, value := range values {
|
||
|
|
if value == expected {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
|
||
|
|
func terminalPhase(phase string) bool {
|
||
|
|
return phase == "Succeeded" || phase == "Failed" || phase == "Error"
|
||
|
|
}
|
||
|
|
|
||
|
|
func pointAttempted(attempts []attemptStatus, revision string) bool {
|
||
|
|
for _, attempt := range attempts {
|
||
|
|
if attempt.Revision == revision {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
|
||
|
|
func phaseOrPending(phase string) string {
|
||
|
|
if phase == "" {
|
||
|
|
return "Pending"
|
||
|
|
}
|
||
|
|
return phase
|
||
|
|
}
|