Merge pull request 'feat: report delivery status to developers' (#44) from feat/developer-delivery-status into main
Reviewed-on: #44
This commit is contained in:
commit
7789c80f25
|
|
@ -20,6 +20,7 @@ dlv version
|
||||||
- `maidn app onboard --config <private-config> --from <app-checkout>` validates a clean configured checkout and adds its `.tekton` delivery contract.
|
- `maidn app onboard --config <private-config> --from <app-checkout>` validates a clean configured checkout and adds its `.tekton` delivery contract.
|
||||||
|
|
||||||
See `docs/operations.md` for the authorized operating and verification runbook.
|
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
|
## Forgejo setup
|
||||||
|
|
||||||
|
|
|
||||||
19
docs/delivery-feedback.md
Normal file
19
docs/delivery-feedback.md
Normal file
|
|
@ -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.
|
||||||
|
|
@ -320,12 +320,284 @@ spec:
|
||||||
1) ;;
|
1) ;;
|
||||||
*) fail ;;
|
*) fail ;;
|
||||||
esac
|
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
|
unset forgejo_auth
|
||||||
else
|
else
|
||||||
git push origin "$MANIFESTS_BRANCH"
|
git push origin "$MANIFESTS_BRANCH"
|
||||||
fi
|
fi
|
||||||
---
|
---
|
||||||
apiVersion: tekton.dev/v1
|
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 = "<!-- maidn-delivery-status -->"
|
||||||
|
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
|
kind: Pipeline
|
||||||
metadata:
|
metadata:
|
||||||
name: {{ .AppName }}
|
name: {{ .AppName }}
|
||||||
|
|
@ -464,6 +736,48 @@ spec:
|
||||||
value: $(params.git-revision)
|
value: $(params.git-revision)
|
||||||
- name: environment
|
- name: environment
|
||||||
value: staging
|
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
|
- name: promote-production
|
||||||
runAfter: [push]
|
runAfter: [push]
|
||||||
when:
|
when:
|
||||||
|
|
@ -513,3 +827,34 @@ spec:
|
||||||
value: $(params.pr-number)
|
value: $(params.pr-number)
|
||||||
- name: app-repository
|
- name: app-repository
|
||||||
value: {{ quote .AppRepository }}
|
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)
|
||||||
|
|
|
||||||
|
|
@ -510,9 +510,10 @@ func writePreviewDeliveryConfig(dir string, cfg config.Config) error {
|
||||||
Kind: "ConfigMap",
|
Kind: "ConfigMap",
|
||||||
Metadata: map[string]string{"name": "maidn-preview-delivery-config", "namespace": "tekton-pipelines"},
|
Metadata: map[string]string{"name": "maidn-preview-delivery-config", "namespace": "tekton-pipelines"},
|
||||||
Data: map[string]string{
|
Data: map[string]string{
|
||||||
"forgejo-origin": origin,
|
"forgejo-origin": origin,
|
||||||
"manifests-url": manifestsURL,
|
"manifests-url": manifestsURL,
|
||||||
"manifests-branch": cfg.Flux.Branch,
|
"manifests-branch": cfg.Flux.Branch,
|
||||||
|
"tekton-dashboard-url": cfg.Delivery.TektonDashboardURL,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -581,7 +582,7 @@ type appDeliveryTemplateConfig struct {
|
||||||
ManifestsBranch string
|
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.
|
// GenerateAppDelivery writes the source-owned Tekton delivery contract for an app checkout.
|
||||||
func GenerateAppDelivery(dir string, cfg config.Config) error {
|
func GenerateAppDelivery(dir string, cfg config.Config) error {
|
||||||
|
|
|
||||||
|
|
@ -171,12 +171,12 @@ func TestWritePreviewDeliveryConfigIsTrustedAndNonSecret(t *testing.T) {
|
||||||
if err := os.WriteFile(filepath.Join(dir, "kustomization.yaml"), []byte("resources:\n"), 0644); err != nil {
|
if err := os.WriteFile(filepath.Join(dir, "kustomization.yaml"), []byte("resources:\n"), 0644); err != nil {
|
||||||
t.Fatal(err)
|
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 {
|
if err := writePreviewDeliveryConfig(dir, cfg); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
content, err := os.ReadFile(filepath.Join(dir, "preview-delivery-config.yaml"))
|
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)
|
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) {
|
if _, err := os.Stat(filepath.Join(dir, "maidn-preview-delivery-config.yaml")); !os.IsNotExist(err) {
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,8 @@ func TestGenerateAppDeliveryReplacesOnlyKnownGeneratedFiles(t *testing.T) {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
pipeline, err := os.ReadFile(filepath.Join(tektonDir, "pipeline.yaml"))
|
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)
|
t.Fatalf("target-specific pipeline = %q, %v", pipeline, err)
|
||||||
}
|
}
|
||||||
decoder := yaml.NewDecoder(bytes.NewReader(pipeline))
|
decoder := yaml.NewDecoder(bytes.NewReader(pipeline))
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,7 @@ func WriteRedacted(path string, cfg Config) error {
|
||||||
redacted.Templates.TektonCatalogRepoURL = RedactURL(redacted.Templates.TektonCatalogRepoURL)
|
redacted.Templates.TektonCatalogRepoURL = RedactURL(redacted.Templates.TektonCatalogRepoURL)
|
||||||
redacted.Delivery.AppRepoURL = RedactURL(redacted.Delivery.AppRepoURL)
|
redacted.Delivery.AppRepoURL = RedactURL(redacted.Delivery.AppRepoURL)
|
||||||
redacted.Delivery.ImageRepository = RedactURL(redacted.Delivery.ImageRepository)
|
redacted.Delivery.ImageRepository = RedactURL(redacted.Delivery.ImageRepository)
|
||||||
|
redacted.Delivery.TektonDashboardURL = RedactURL(redacted.Delivery.TektonDashboardURL)
|
||||||
data, err := yaml.Marshal(redacted)
|
data, err := yaml.Marshal(redacted)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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 == "" {
|
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")
|
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) {
|
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")
|
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) {
|
if cfg.Delivery.ProductionBranch == cfg.Delivery.AppRepoRef || !validDeliveryBranch(cfg.Delivery.ProductionBranch) {
|
||||||
return errors.New("delivery productionBranch must be a valid branch distinct from appRepoRef")
|
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 {
|
if err := validateDeliveryRepositoryOrigin(cfg.Git.BaseURL, cfg.Delivery.AppRepoURL); err != nil {
|
||||||
return err
|
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, "?#") {
|
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")
|
return errors.New("delivery webhookHostname must be a hostname and webhookPath must be an absolute path")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
func TestResolveAppOnboardingRequiresTargetOwner(t *testing.T) {
|
||||||
cfg := validConfig(t)
|
cfg := validConfig(t)
|
||||||
cfg.Git.Owner = "test-org-2"
|
cfg.Git.Owner = "test-org-2"
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ type DeliveryConfig struct {
|
||||||
ImageRepository string `yaml:"imageRepository"`
|
ImageRepository string `yaml:"imageRepository"`
|
||||||
BuildOutputDirectory string `yaml:"buildOutputDirectory"`
|
BuildOutputDirectory string `yaml:"buildOutputDirectory"`
|
||||||
BuildConfiguration string `yaml:"buildConfiguration"`
|
BuildConfiguration string `yaml:"buildConfiguration"`
|
||||||
|
TektonDashboardURL string `yaml:"tektonDashboardUrl,omitempty"`
|
||||||
WebhookHostname string `yaml:"webhookHostname"`
|
WebhookHostname string `yaml:"webhookHostname"`
|
||||||
WebhookPath string `yaml:"webhookPath"`
|
WebhookPath string `yaml:"webhookPath"`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue