Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions api/v1alpha1/taskspawner_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -916,6 +916,56 @@ type TaskTemplate struct {
UpstreamRepo string `json:"upstreamRepo,omitempty"`
}

// TerminalTaskPhase is a TaskPhase restricted to terminal values.
// +kubebuilder:validation:Enum=Succeeded;Failed
type TerminalTaskPhase string

// NotificationHook defines an outbound notification destination triggered
// when a spawned Task reaches a terminal phase.
type NotificationHook struct {
// Name identifies this hook for logging and status reporting.
// +kubebuilder:validation:Required
// +kubebuilder:validation:MinLength=1
Name string `json:"name"`

// Phases specifies which terminal phases trigger this hook.
// Defaults to both Succeeded and Failed.
// +optional
Phases []TerminalTaskPhase `json:"phases,omitempty"`

// Webhook sends an HTTP POST with task details to the given URL.
// +kubebuilder:validation:Required
Webhook WebhookNotification `json:"webhook"`
}

// WebhookNotification configures an HTTP webhook notification.
// Note: The URL is stored in a Task annotation for dispatch. Do not embed
// secrets in the URL path; use SecretRef for authentication instead.
type WebhookNotification struct {
// URL is the webhook endpoint. Must use HTTPS. This value is stored
// in the spawned Task's annotations and is visible to anyone with
// read access to the Task resource. Do not use URLs with embedded
// tokens; use secretRef for authentication.
// +kubebuilder:validation:Required
// +kubebuilder:validation:Pattern="^https://[^@/]+(/.*)?$"
URL string `json:"url"`

// SecretRef optionally references a Secret whose keys are sent as HTTP
// headers. For example, a Secret with keys "Authorization" and "X-Api-Key"
// will set both as request headers with their respective values.
// +optional
SecretRef *SecretReference `json:"secretRef,omitempty"`
}

// OnCompletion configures outbound notifications when spawned Tasks
// reach terminal phases.
type OnCompletion struct {
// Hooks is a list of notification destinations.
// +kubebuilder:validation:MinItems=1
// +kubebuilder:validation:MaxItems=8
Hooks []NotificationHook `json:"hooks"`
}

// TaskSpawnerSpec defines the desired state of TaskSpawner.
// +kubebuilder:validation:XValidation:rule="!(has(self.when.githubIssues) || has(self.when.githubPullRequests) || has(self.when.githubWebhook) || has(self.when.linearWebhook)) || has(self.taskTemplate.workspaceRef)",message="taskTemplate.workspaceRef is required when using githubIssues, githubPullRequests, githubWebhook, or linearWebhook source"
type TaskSpawnerSpec struct {
Expand Down Expand Up @@ -956,6 +1006,11 @@ type TaskSpawnerSpec struct {
// +optional
// +kubebuilder:validation:Minimum=0
MaxTotalTasks *int32 `json:"maxTotalTasks,omitempty"`

// OnCompletion configures outbound notifications when spawned Tasks
// reach terminal phases (Succeeded or Failed).
// +optional
OnCompletion *OnCompletion `json:"onCompletion,omitempty"`
}

// TaskSpawnerStatus defines the observed state of TaskSpawner.
Expand Down
68 changes: 68 additions & 0 deletions api/v1alpha1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

48 changes: 48 additions & 0 deletions cmd/kelos-spawner/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package main

import (
"context"
"encoding/json"
"flag"
"fmt"
"net/http"
Expand Down Expand Up @@ -415,6 +416,15 @@ func runCycleWithSourceCore(ctx context.Context, cl client.Client, key types.Nam
}
}

// Stamp onCompletion hooks config into task annotation for the
// reporting loop to dispatch without looking up the TaskSpawner.
if hookJSON := onCompletionAnnotation(&ts); hookJSON != "" {
if task.Annotations == nil {
task.Annotations = make(map[string]string)
}
task.Annotations[reporting.AnnotationOnCompletion] = hookJSON
}

// Propagate upstream repo for fork workflows. Explicit template
// value takes precedence; otherwise derive from the source repo
// override (githubIssues.repo or githubPullRequests.repo).
Expand Down Expand Up @@ -819,3 +829,41 @@ func parsePollInterval(s string) time.Duration {
}
return d
}

// onCompletionAnnotation returns the serialized onCompletion hooks config
// to stamp onto spawned Tasks, or empty string when not configured.
func onCompletionAnnotation(ts *kelosv1alpha1.TaskSpawner) string {
Comment thread
j-bennet marked this conversation as resolved.
if ts.Spec.OnCompletion == nil || len(ts.Spec.OnCompletion.Hooks) == 0 {
return ""
}
data, err := json.Marshal(ts.Spec.OnCompletion.Hooks)
if err != nil {
ctrl.Log.WithName("spawner").Error(err, "Serializing onCompletion hooks")
return ""
}
return string(data)
}

// runWebhookReportingCycle lists all Tasks owned by the given TaskSpawner and
// dispatches pending webhook notifications for those in terminal phases.
func runWebhookReportingCycle(ctx context.Context, cl client.Client, key types.NamespacedName, httpClient *http.Client) error {
var taskList kelosv1alpha1.TaskList
if err := cl.List(ctx, &taskList, client.InNamespace(key.Namespace), client.MatchingLabels{"kelos.dev/taskspawner": key.Name}); err != nil {
return fmt.Errorf("listing tasks for webhook reporting: %w", err)
}

reporter := &reporting.WebhookReporter{
Client: cl,
HTTPClient: httpClient,
SecretReader: &reporting.KubeSecretReader{
Client: cl,
},
}

for i := range taskList.Items {
if err := reporter.ReportWebhooks(ctx, &taskList.Items[i]); err != nil {
ctrl.Log.WithName("spawner").Error(err, "Webhook reporting", "task", taskList.Items[i].Name)
}
}
return nil
}
6 changes: 6 additions & 0 deletions cmd/kelos-spawner/reconciler.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,12 @@ func (r *spawnerReconciler) SetupWithManager(mgr ctrl.Manager) error {
}

func runOnce(ctx context.Context, cl client.Client, key types.NamespacedName, cfg spawnerRuntimeConfig) (time.Duration, error) {
// Run webhook reporting first — it operates on already-terminal tasks
// and must not be blocked by source-polling or GitHub reporting failures.
if err := runWebhookReportingCycle(ctx, cl, key, cfg.HTTPClient); err != nil {
ctrl.Log.WithName("spawner").Error(err, "Webhook reporting cycle failed")
}

if err := runCycleWithProxy(ctx, cl, key, cfg.GitHubOwner, cfg.GitHubRepo, cfg.GHProxyURL, cfg.GitHubAPIBaseURL, cfg.TokenResolver, cfg.JiraBaseURL, cfg.JiraProject, cfg.JiraJQL, cfg.HTTPClient); err != nil {
return 0, err
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,72 @@ spec:
format: int32
minimum: 0
type: integer
onCompletion:
description: |-
OnCompletion configures outbound notifications when spawned Tasks
reach terminal phases (Succeeded or Failed).
properties:
hooks:
description: Hooks is a list of notification destinations.
items:
description: |-
NotificationHook defines an outbound notification destination triggered
when a spawned Task reaches a terminal phase.
properties:
name:
description: Name identifies this hook for logging and status
reporting.
minLength: 1
type: string
phases:
description: |-
Phases specifies which terminal phases trigger this hook.
Defaults to both Succeeded and Failed.
items:
description: TerminalTaskPhase is a TaskPhase restricted
to terminal values.
enum:
- Succeeded
- Failed
type: string
type: array
webhook:
description: Webhook sends an HTTP POST with task details
to the given URL.
properties:
secretRef:
description: |-
SecretRef optionally references a Secret whose keys are sent as HTTP
headers. For example, a Secret with keys "Authorization" and "X-Api-Key"
will set both as request headers with their respective values.
properties:
name:
description: Name is the name of the secret.
type: string
required:
- name
type: object
url:
description: |-
URL is the webhook endpoint. Must use HTTPS. This value is stored
in the spawned Task's annotations and is visible to anyone with
read access to the Task resource. Do not use URLs with embedded
tokens; use secretRef for authentication.
pattern: ^https://[^@/]+(/.*)?$
type: string
required:
- url
type: object
required:
- name
- webhook
type: object
maxItems: 8
minItems: 1
type: array
required:
- hooks
type: object
pollInterval:
default: 5m
description: |-
Expand Down
66 changes: 66 additions & 0 deletions internal/manifests/install-crd.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7310,6 +7310,72 @@ spec:
format: int32
minimum: 0
type: integer
onCompletion:
description: |-
OnCompletion configures outbound notifications when spawned Tasks
reach terminal phases (Succeeded or Failed).
properties:
hooks:
description: Hooks is a list of notification destinations.
items:
description: |-
NotificationHook defines an outbound notification destination triggered
when a spawned Task reaches a terminal phase.
properties:
name:
description: Name identifies this hook for logging and status
reporting.
minLength: 1
type: string
phases:
description: |-
Phases specifies which terminal phases trigger this hook.
Defaults to both Succeeded and Failed.
items:
description: TerminalTaskPhase is a TaskPhase restricted
to terminal values.
enum:
- Succeeded
- Failed
type: string
type: array
webhook:
description: Webhook sends an HTTP POST with task details
to the given URL.
properties:
secretRef:
description: |-
SecretRef optionally references a Secret whose keys are sent as HTTP
headers. For example, a Secret with keys "Authorization" and "X-Api-Key"
will set both as request headers with their respective values.
properties:
name:
description: Name is the name of the secret.
type: string
required:
- name
type: object
url:
description: |-
URL is the webhook endpoint. Must use HTTPS. This value is stored
in the spawned Task's annotations and is visible to anyone with
read access to the Task resource. Do not use URLs with embedded
tokens; use secretRef for authentication.
pattern: ^https://[^@/]+(/.*)?$
type: string
required:
- url
type: object
required:
- name
- webhook
type: object
maxItems: 8
minItems: 1
type: array
required:
- hooks
type: object
pollInterval:
default: 5m
description: |-
Expand Down
Loading
Loading