diff --git a/README.md b/README.md index 9ad4abf..671c28e 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ dlv version - `maidn app onboard --config --from ` validates a clean configured checkout and adds its `.tekton` delivery contract. See `docs/operations.md` for the authorized operating and verification runbook. +App authors: see `docs/delivery-feedback.md` for preview feedback and the scoped Forgejo token contract. ## Forgejo setup diff --git a/docs/delivery-feedback.md b/docs/delivery-feedback.md new file mode 100644 index 0000000..88bdc5e --- /dev/null +++ b/docs/delivery-feedback.md @@ -0,0 +1,19 @@ +# Delivery feedback + +`maidn app onboard` adds source-owned Tekton tasks that update one marked +Forgejo pull-request comment. The comment contains only the verified preview +URL, a redacted task-status summary, and the PipelineRun name. Set the optional +`delivery.tektonDashboardUrl` to a credential-free HTTPS Tekton Dashboard +origin to add a PipelineRun link. + +Before enabling delivery feedback, store a separate Forgejo token at +`cicd/forgejo-delivery-status.token` in encrypted operational secrets. Scope it +only to the onboarded application repositories and to creating/updating issue +comments; do not reuse the Git clone/push token. The generated task never +prints the token or Forgejo API responses. + +Preview and staging feedback waits up to ten minutes for the app Deployment +and HTTPRoute, then performs a bounded HTTPS check. A production event reports +the manifest-repository promotion PR; it does not claim a production deploy. +The chart must name both resources after `delivery.appName`; the HTTPRoute's +first hostname must be the public HTTPS preview/staging URL. diff --git a/internal/assets/templates/delivery-pipeline.yaml.tmpl b/internal/assets/templates/delivery-pipeline.yaml.tmpl index 5931293..9e23fca 100644 --- a/internal/assets/templates/delivery-pipeline.yaml.tmpl +++ b/internal/assets/templates/delivery-pipeline.yaml.tmpl @@ -320,12 +320,284 @@ spec: 1) ;; *) fail ;; esac + promotion_pr_number=$(grep -o '"number"[[:space:]]*:[[:space:]]*[0-9][0-9]*' "$pr_response" | sed -n '1s/.*:[[:space:]]*//p') + valid_pr_number "$promotion_pr_number" + printf 'Promotion PR opened or updated: %s/%s/%s/pulls/%s\n' "$FORGEJO_BASE_URL" "$FORGEJO_OWNER" "$MANIFESTS_REPO" "$promotion_pr_number" unset forgejo_auth else git push origin "$MANIFESTS_BRANCH" fi --- apiVersion: tekton.dev/v1 +kind: Task +metadata: + name: {{ .AppName }}-wait-delivery + namespace: tekton-pipelines +spec: + params: + - name: app-name + - name: environment + - name: image + - name: tag + - name: pr-number + default: "" + results: + - name: preview-url + description: Verified preview or staging HTTPRoute URL. + stepTemplate: + env: + - name: HOME + value: /tmp + securityContext: + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + allowPrivilegeEscalation: false + capabilities: + drop: [ALL] + seccompProfile: + type: RuntimeDefault + steps: + - name: wait-for-traffic + image: alpine/k8s:1.33.4 + env: + - name: APP_NAME + value: $(params.app-name) + - name: ENVIRONMENT + value: $(params.environment) + - name: PR_NUMBER + value: $(params.pr-number) + - name: IMAGE + value: $(params.image) + - name: TAG + value: $(params.tag) + - name: RESULT_PATH + value: $(results.preview-url.path) + script: | + #!/bin/sh + set -eu + fail() { exit 1; } + valid_app() { case "$1" in ''|*[!a-z0-9-]*|-*|*-) fail ;; esac; [ "${#1}" -le 47 ] || fail; } + valid_pr() { case "$1" in [1-9]*) ;; *) fail ;; esac; case "$1" in *[!0-9]*) fail ;; esac; } + valid_image() { case "$1" in ''|/*|*/|*..*|*//*|*[!A-Za-z0-9._/:-]*) fail ;; esac; } + valid_tag() { [ "${#1}" -eq 40 ] || fail; case "$1" in *[!0-9a-fA-F]*) fail ;; esac; } + valid_host() { case "$1" in ''|.*|*.) fail ;; esac; case "$1" in *[!A-Za-z0-9.-]*) fail ;; esac; } + valid_app "$APP_NAME" + valid_image "$IMAGE" + valid_tag "$TAG" + case "$ENVIRONMENT" in + preview) + valid_pr "$PR_NUMBER" + namespace="$APP_NAME-pr-$PR_NUMBER" + ;; + staging) namespace=staging ;; + *) fail ;; + esac + attempts=120 + while :; do + deployed_image=$(kubectl -n "$namespace" get "deployment/$APP_NAME" -o jsonpath='{.spec.template.spec.containers[0].image}' 2>/dev/null || true) + [ "$deployed_image" = "$IMAGE:$TAG" ] && break + attempts=$((attempts - 1)) + [ "$attempts" -gt 0 ] || fail + sleep 5 + done + kubectl -n "$namespace" rollout status "deployment/$APP_NAME" --timeout=600s + kubectl -n "$namespace" wait --for=condition=Available "deployment/$APP_NAME" --timeout=600s + kubectl -n "$namespace" wait --for=jsonpath='{.status.parents[0].conditions[?(@.type=="Accepted")].status}'=True "httproute/$APP_NAME" --timeout=600s + host=$(kubectl -n "$namespace" get "httproute/$APP_NAME" -o jsonpath='{.spec.hostnames[0]}') + valid_host "$host" + url="https://$host" + attempts=12 + while ! wget -q --spider --timeout=10 "$url" >/dev/null 2>&1; do + attempts=$((attempts - 1)) + [ "$attempts" -gt 0 ] || fail + sleep 5 + done + printf '%s' "$url" > "$RESULT_PATH" +--- +apiVersion: tekton.dev/v1 +kind: Task +metadata: + name: {{ .AppName }}-report-delivery + namespace: tekton-pipelines +spec: + params: + - name: app-name + - name: app-repository + - name: pr-number + - name: event-action + - name: pipeline-run + - name: clone-status + - name: build-status + - name: push-status + - name: update-status + - name: readiness-status + - name: cleanup-status + volumes: + - name: delivery-status + emptyDir: {} + stepTemplate: + securityContext: + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + allowPrivilegeEscalation: false + capabilities: + drop: [ALL] + seccompProfile: + type: RuntimeDefault + steps: + - name: read-preview-url + image: alpine/k8s:1.33.4 + env: + - name: APP_NAME + value: $(params.app-name) + - name: PR_NUMBER + value: $(params.pr-number) + - name: EVENT_ACTION + value: $(params.event-action) + - name: READINESS_STATUS + value: $(params.readiness-status) + volumeMounts: + - name: delivery-status + mountPath: /delivery + script: | + #!/bin/sh + set -eu + fail() { exit 1; } + valid_app() { case "$1" in ''|*[!a-z0-9-]*|-*|*-) fail ;; esac; [ "${#1}" -le 47 ] || fail; } + valid_pr() { case "$1" in [1-9]*) ;; *) fail ;; esac; case "$1" in *[!0-9]*) fail ;; esac; } + valid_host() { case "$1" in ''|.*|*.) fail ;; esac; case "$1" in *[!A-Za-z0-9.-]*) fail ;; esac; } + [ "$EVENT_ACTION" = closed ] && exit 0 + [ "$READINESS_STATUS" = Succeeded ] || exit 0 + valid_app "$APP_NAME" + valid_pr "$PR_NUMBER" + host=$(kubectl -n "$APP_NAME-pr-$PR_NUMBER" get "httproute/$APP_NAME" -o jsonpath='{.spec.hostnames[0]}') + valid_host "$host" + printf 'https://%s' "$host" > /delivery/preview-url + - name: update-pr-comment + image: python:3.13-alpine + env: + - name: FORGEJO_BASE_URL + value: {{ quote .ForgejoBaseURL }} + - name: APP_NAME + value: $(params.app-name) + - name: APP_REPOSITORY + value: $(params.app-repository) + - name: PR_NUMBER + value: $(params.pr-number) + - name: EVENT_ACTION + value: $(params.event-action) + - name: PIPELINE_RUN + value: $(params.pipeline-run) + - name: CLONE_STATUS + value: $(params.clone-status) + - name: BUILD_STATUS + value: $(params.build-status) + - name: PUSH_STATUS + value: $(params.push-status) + - name: UPDATE_STATUS + value: $(params.update-status) + - name: READINESS_STATUS + value: $(params.readiness-status) + - name: CLEANUP_STATUS + value: $(params.cleanup-status) + - name: FORGEJO_DELIVERY_TOKEN + valueFrom: + secretKeyRef: + name: forgejo-delivery-status + key: token + - name: TEKTON_DASHBOARD_URL + valueFrom: + configMapKeyRef: + name: maidn-preview-delivery-config + key: tekton-dashboard-url + optional: true + volumeMounts: + - name: delivery-status + mountPath: /delivery + script: | + import json + import os + import re + import sys + from pathlib import Path + from urllib.parse import quote, urlsplit + from urllib.request import Request, urlopen + + marker = "" + statuses = {"Succeeded", "Failed", "None", "Skipped", "Cancelled", "Unknown", "Pending"} + + def fail(): + raise ValueError + + def origin(value): + parsed = urlsplit(value) + if parsed.scheme != "https" or not parsed.netloc or parsed.username or parsed.password or parsed.path or parsed.query or parsed.fragment: + fail() + if not re.fullmatch(r"[A-Za-z0-9.-]+(?::[0-9]{1,5})?", parsed.netloc): + fail() + return value + + def status(name): + value = os.environ.get(name, "Unknown") + return value if value in statuses else "Unknown" + + def request(method, endpoint, payload=None): + data = None if payload is None else json.dumps(payload).encode() + req = Request(endpoint, data=data, method=method) + req.add_header("Authorization", "token " + token) + req.add_header("Content-Type", "application/json") + with urlopen(req, timeout=15) as response: + return json.load(response) if response.length != 0 else None + + try: + base = origin(os.environ["FORGEJO_BASE_URL"]) + app = os.environ["APP_NAME"] + repository = os.environ["APP_REPOSITORY"] + pr = os.environ["PR_NUMBER"] + run = os.environ["PIPELINE_RUN"] + action = os.environ["EVENT_ACTION"] + token = os.environ["FORGEJO_DELIVERY_TOKEN"] + if not re.fullmatch(r"[a-z0-9](?:[a-z0-9-]{0,45}[a-z0-9])?", app) or not re.fullmatch(r"[1-9][0-9]{0,8}", pr) or not re.fullmatch(r"[a-z0-9](?:[a-z0-9.-]{0,61}[a-z0-9])?", run) or action not in {"opened", "reopened", "synchronize", "closed"} or not token: + fail() + owner, repo = repository.split("/", 1) + if not all(re.fullmatch(r"[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?", part) and ".." not in part for part in (owner, repo)): + fail() + task_status = {name: status(name) for name in ("CLONE_STATUS", "BUILD_STATUS", "PUSH_STATUS", "UPDATE_STATUS", "READINESS_STATUS", "CLEANUP_STATUS")} + if action == "closed": + delivery = "closed" + preview = "Preview cleanup requested." + elif task_status["READINESS_STATUS"] == "Succeeded" and task_status["UPDATE_STATUS"] == "Succeeded": + delivery = "ready" + preview_url = Path("/delivery/preview-url").read_text() if Path("/delivery/preview-url").is_file() else "" + parsed_preview = urlsplit(preview_url) + if parsed_preview.scheme != "https" or not re.fullmatch(r"[A-Za-z0-9.-]+", parsed_preview.netloc) or parsed_preview.path or parsed_preview.query or parsed_preview.fragment: + fail() + preview = "Preview: " + preview_url + else: + delivery = "failed" + preview = "Preview unavailable." + dashboard = os.environ.get("TEKTON_DASHBOARD_URL", "").strip() + run_text = "PipelineRun: `" + run + "`" + if dashboard: + dashboard = origin(dashboard.rstrip("/")) + run_text += " ([details](" + dashboard + "/#/pipelineruns/tekton-pipelines/" + quote(run, safe="") + "))" + summary = ", ".join(name.removesuffix("_STATUS").lower() + "=" + value for name, value in task_status.items()) + body = "\n".join((marker, "## Maidn delivery", "Status: **" + delivery + "**", preview, run_text, "Summary: " + summary)) + endpoint = base + "/api/v1/repos/" + quote(owner, safe="") + "/" + quote(repo, safe="") + "/issues/" + pr + "/comments" + comments = request("GET", endpoint + "?limit=100") + matches = [comment for comment in comments if marker in comment.get("body", "")] + if len(matches) > 1: + fail() + if matches: + request("PATCH", base + "/api/v1/repos/" + quote(owner, safe="") + "/" + quote(repo, safe="") + "/issues/comments/" + str(matches[0]["id"]), {"body": body}) + else: + request("POST", endpoint, {"body": body}) + except Exception: + sys.exit("delivery status update failed") +--- +apiVersion: tekton.dev/v1 kind: Pipeline metadata: name: {{ .AppName }} @@ -464,6 +736,48 @@ spec: value: $(params.git-revision) - name: environment value: staging + - name: wait-preview + runAfter: [update-preview] + when: + - input: $(params.event-type) + operator: in + values: [pull_request] + - input: $(params.event-action) + operator: in + values: [opened, reopened, synchronize] + taskRef: + name: {{ .AppName }}-wait-delivery + params: + - name: app-name + value: {{ quote .AppName }} + - name: environment + value: preview + - name: image + value: $(params.image) + - name: tag + value: $(params.git-revision) + - name: pr-number + value: $(params.pr-number) + - name: wait-staging + runAfter: [update-staging] + when: + - input: $(params.event-type) + operator: in + values: [push] + - input: $(params.branch) + operator: in + values: [{{ quote .AppRepoRef }}] + taskRef: + name: {{ .AppName }}-wait-delivery + params: + - name: app-name + value: {{ quote .AppName }} + - name: environment + value: staging + - name: image + value: $(params.image) + - name: tag + value: $(params.git-revision) - name: promote-production runAfter: [push] when: @@ -513,3 +827,34 @@ spec: value: $(params.pr-number) - name: app-repository value: {{ quote .AppRepository }} + finally: + - name: report-delivery + when: + - input: $(params.event-type) + operator: in + values: [pull_request] + taskRef: + name: {{ .AppName }}-report-delivery + params: + - name: app-name + value: {{ quote .AppName }} + - name: app-repository + value: {{ quote .AppRepository }} + - name: pr-number + value: $(params.pr-number) + - name: event-action + value: $(params.event-action) + - name: pipeline-run + value: $(context.pipelineRun.name) + - name: clone-status + value: $(tasks.clone.status) + - name: build-status + value: $(tasks.build-layer.status) + - name: push-status + value: $(tasks.push.status) + - name: update-status + value: $(tasks.update-preview.status) + - name: readiness-status + value: $(tasks.wait-preview.status) + - name: cleanup-status + value: $(tasks.cleanup-preview.status) diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go index 02d7882..3cb1485 100644 --- a/internal/bootstrap/bootstrap.go +++ b/internal/bootstrap/bootstrap.go @@ -510,9 +510,10 @@ func writePreviewDeliveryConfig(dir string, cfg config.Config) error { Kind: "ConfigMap", Metadata: map[string]string{"name": "maidn-preview-delivery-config", "namespace": "tekton-pipelines"}, Data: map[string]string{ - "forgejo-origin": origin, - "manifests-url": manifestsURL, - "manifests-branch": cfg.Flux.Branch, + "forgejo-origin": origin, + "manifests-url": manifestsURL, + "manifests-branch": cfg.Flux.Branch, + "tekton-dashboard-url": cfg.Delivery.TektonDashboardURL, }, }) if err != nil { @@ -581,7 +582,7 @@ type appDeliveryTemplateConfig struct { ManifestsBranch string } -var deliveryAppName = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`) +var deliveryAppName = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,45}[a-z0-9])?$`) // GenerateAppDelivery writes the source-owned Tekton delivery contract for an app checkout. func GenerateAppDelivery(dir string, cfg config.Config) error { diff --git a/internal/bootstrap/bootstrap_test.go b/internal/bootstrap/bootstrap_test.go index ff227f9..e2a30a7 100644 --- a/internal/bootstrap/bootstrap_test.go +++ b/internal/bootstrap/bootstrap_test.go @@ -171,12 +171,12 @@ func TestWritePreviewDeliveryConfigIsTrustedAndNonSecret(t *testing.T) { if err := os.WriteFile(filepath.Join(dir, "kustomization.yaml"), []byte("resources:\n"), 0644); err != nil { t.Fatal(err) } - cfg := config.Config{Git: config.GitConfig{BaseURL: "https://git.example.test", Owner: "platform", Token: "secret"}, Flux: config.FluxConfig{ManifestsRepo: "manifests", Branch: "main"}, Delivery: config.DeliveryConfig{AppName: "dynamic-app"}} + cfg := config.Config{Git: config.GitConfig{BaseURL: "https://git.example.test", Owner: "platform", Token: "secret"}, Flux: config.FluxConfig{ManifestsRepo: "manifests", Branch: "main"}, Delivery: config.DeliveryConfig{AppName: "dynamic-app", TektonDashboardURL: "https://tekton.example.test"}} if err := writePreviewDeliveryConfig(dir, cfg); err != nil { t.Fatal(err) } content, err := os.ReadFile(filepath.Join(dir, "preview-delivery-config.yaml")) - if err != nil || !strings.Contains(string(content), "forgejo-origin: https://git.example.test") || strings.Contains(string(content), "forgejo-base-url") || !strings.Contains(string(content), "manifests-url: https://git.example.test/platform/manifests.git") || strings.Contains(string(content), "secret") || strings.Contains(string(content), "dynamic-app") { + if err != nil || !strings.Contains(string(content), "forgejo-origin: https://git.example.test") || strings.Contains(string(content), "forgejo-base-url") || !strings.Contains(string(content), "manifests-url: https://git.example.test/platform/manifests.git") || !strings.Contains(string(content), "tekton-dashboard-url: https://tekton.example.test") || strings.Contains(string(content), "secret") || strings.Contains(string(content), "dynamic-app") { t.Fatalf("preview delivery config is not trusted and non-secret: %q, %v", content, err) } if _, err := os.Stat(filepath.Join(dir, "maidn-preview-delivery-config.yaml")); !os.IsNotExist(err) { diff --git a/internal/bootstrap/onboard_test.go b/internal/bootstrap/onboard_test.go index 97f5c22..ddd680d 100644 --- a/internal/bootstrap/onboard_test.go +++ b/internal/bootstrap/onboard_test.go @@ -52,7 +52,8 @@ func TestGenerateAppDeliveryReplacesOnlyKnownGeneratedFiles(t *testing.T) { t.Fatal(err) } pipeline, err := os.ReadFile(filepath.Join(tektonDir, "pipeline.yaml")) - if err != nil || !strings.Contains(string(pipeline), "https://git.example.test/test-org-2/web-ui.git") || !strings.Contains(string(pipeline), "name: HOME\n value: /tekton/home") || !strings.Contains(string(pipeline), "grep -qxF \" namespace: staging\"") || !strings.Contains(string(pipeline), "namespace: production") || strings.Contains(string(pipeline), "taskRunTemplate:") { + normalized := strings.ReplaceAll(string(pipeline), "\r\n", "\n") + if err != nil || !strings.Contains(normalized, "https://git.example.test/test-org-2/web-ui.git") || !strings.Contains(normalized, "name: HOME\n value: /tekton/home") || !strings.Contains(normalized, "grep -qxF \" namespace: staging\"") || !strings.Contains(normalized, "namespace: production") || !strings.Contains(normalized, "name: web-ui-wait-delivery") || !strings.Contains(normalized, "name: web-ui-report-delivery") || !strings.Contains(normalized, "forgejo-delivery-status") || !strings.Contains(normalized, "$(context.pipelineRun.name)") || !strings.Contains(normalized, "Promotion PR opened or updated") || strings.Contains(normalized, "taskRunTemplate:") { t.Fatalf("target-specific pipeline = %q, %v", pipeline, err) } decoder := yaml.NewDecoder(bytes.NewReader(pipeline)) diff --git a/internal/config/config.go b/internal/config/config.go index 09d1431..52c9eb5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -66,6 +66,7 @@ func WriteRedacted(path string, cfg Config) error { redacted.Templates.TektonCatalogRepoURL = RedactURL(redacted.Templates.TektonCatalogRepoURL) redacted.Delivery.AppRepoURL = RedactURL(redacted.Delivery.AppRepoURL) redacted.Delivery.ImageRepository = RedactURL(redacted.Delivery.ImageRepository) + redacted.Delivery.TektonDashboardURL = RedactURL(redacted.Delivery.TektonDashboardURL) data, err := yaml.Marshal(redacted) if err != nil { return err @@ -528,8 +529,8 @@ func ValidateDelivery(cfg Config) error { if cfg.Delivery.AppName == "" || cfg.Delivery.AppRepoURL == "" || cfg.Delivery.AppRepoRef == "" || cfg.Delivery.ProductionBranch == "" || cfg.Delivery.ImageRepository == "" || cfg.Delivery.BuildOutputDirectory == "" || cfg.Delivery.BuildConfiguration == "" || cfg.Delivery.WebhookHostname == "" || cfg.Delivery.WebhookPath == "" { return errors.New("delivery appName, appRepoUrl, appRepoRef, productionBranch, imageRepository, buildOutputDirectory, buildConfiguration, webhookHostname, and webhookPath are required") } - if !regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`).MatchString(cfg.Delivery.AppName) { - return errors.New("delivery appName must be a lowercase DNS label") + if !regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,45}[a-z0-9])?$`).MatchString(cfg.Delivery.AppName) { + return errors.New("delivery appName must be a lowercase DNS label of at most 47 characters") } if cfg.Delivery.ProductionBranch == cfg.Delivery.AppRepoRef || !validDeliveryBranch(cfg.Delivery.ProductionBranch) { return errors.New("delivery productionBranch must be a valid branch distinct from appRepoRef") @@ -540,6 +541,11 @@ func ValidateDelivery(cfg Config) error { if err := validateDeliveryRepositoryOrigin(cfg.Git.BaseURL, cfg.Delivery.AppRepoURL); err != nil { return err } + if cfg.Delivery.TektonDashboardURL != "" { + if err := validateForgejoOrigin(cfg.Delivery.TektonDashboardURL); err != nil { + return errors.New("delivery tektonDashboardUrl must be a credential-free HTTPS origin") + } + } if strings.ContainsAny(cfg.Delivery.WebhookHostname, "/:@?#") || !strings.HasPrefix(cfg.Delivery.WebhookPath, "/") || strings.ContainsAny(cfg.Delivery.WebhookPath, "?#") { return errors.New("delivery webhookHostname must be a hostname and webhookPath must be an absolute path") } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index d07f9d8..54e29e2 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -91,6 +91,14 @@ func TestValidateDeliveryRequiresCompleteConfig(t *testing.T) { } } +func TestValidateDeliveryRejectsUnsafeTektonDashboardURL(t *testing.T) { + cfg := validConfig(t) + cfg.Delivery.TektonDashboardURL = "https://token@example.test" + if err := ValidateDelivery(cfg); err == nil || !strings.Contains(err.Error(), "tektonDashboardUrl") { + t.Fatalf("ValidateDelivery() accepted credential-bearing dashboard URL: %v", err) + } +} + func TestResolveAppOnboardingRequiresTargetOwner(t *testing.T) { cfg := validConfig(t) cfg.Git.Owner = "test-org-2" diff --git a/internal/config/types.go b/internal/config/types.go index 3168d07..c2342b0 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -46,6 +46,7 @@ type DeliveryConfig struct { ImageRepository string `yaml:"imageRepository"` BuildOutputDirectory string `yaml:"buildOutputDirectory"` BuildConfiguration string `yaml:"buildConfiguration"` + TektonDashboardURL string `yaml:"tektonDashboardUrl,omitempty"` WebhookHostname string `yaml:"webhookHostname"` WebhookPath string `yaml:"webhookPath"` }