diff --git a/config/platform-infra/pipelines-as-code.yaml b/config/platform-infra/pipelines-as-code.yaml new file mode 100644 index 00000000..a3f637f0 --- /dev/null +++ b/config/platform-infra/pipelines-as-code.yaml @@ -0,0 +1,76 @@ +version: 1 +kind: platform-infra-pipelines-as-code + +metadata: + id: pac-gitea-agentrun + owner: unidesk + spec: GH-1552 + relatedIssues: + - 1552 + +defaults: + targetId: JD01 + +release: + version: v0.48.0 + manifestUrl: https://github.com/tektoncd/pipelines-as-code/releases/download/v0.48.0/release.k8s.yaml + namespace: pipelines-as-code + controllerServiceName: pipelines-as-code-controller + controllerServicePort: 8080 + waitTimeoutSeconds: 55 + +targets: + - id: JD01 + route: JD01:k3s + namespace: agentrun-ci + role: active-poc + enabled: true + +gitea: + configRef: config/platform-infra/gitea.yaml#sourceAuthority.repositories.agentrun-jd01-v02 + internalBaseUrl: http://gitea-http.devops-infra.svc.cluster.local:3000 + admin: + sourceRoot: /root/unidesk + sourceRef: .env/gitea.auth + format: line-pair + usernameLine: 1 + passwordLine: 2 + apiUsername: admin + webhook: + secretRoot: /root/unidesk + secretSourceRef: .env/pipelines-as-code.webhook + events: + - push + branch: v0.2 + +repository: + name: agentrun-jd01-v02 + namespace: agentrun-ci + providerType: gitea + url: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikasTech-agentrun + cloneUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikasTech-agentrun.git + owner: mirrors + repo: pikasTech-agentrun + secretName: pac-gitea-agentrun-jd01-v02 + tokenKey: token + webhookSecretKey: webhook.secret + concurrencyLimit: 1 + params: + git_read_url: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikasTech-agentrun.git + git_write_url: http://git-mirror-write.devops-infra.svc.cluster.local:8080/pikasTech/agentrun.git + source_branch: v0.2 + gitops_branch: jd01-v0.2-gitops + source_snapshot_prefix: refs/unidesk/snapshots/gitea-actions/agentrun-v0.2 + pipeline_name: agentrun-jd01-v02-ci-image-publish + pipeline_run_prefix: agentrun-jd01-v02-ci + service_account: agentrun-jd01-v02-tekton-runner + workspace_pvc_size: 2Gi + +consumer: + node: JD01 + lane: jd01-v02 + namespace: agentrun-ci + pipeline: agentrun-jd01-v02-ci-image-publish + pipelineRunPrefix: agentrun-jd01-v02-ci + argoNamespace: argocd + argoApplication: agentrun-jd01-v02 diff --git a/scripts/src/agentrun-manifests.ts b/scripts/src/agentrun-manifests.ts index e23c8864..36c215eb 100644 --- a/scripts/src/agentrun-manifests.ts +++ b/scripts/src/agentrun-manifests.ts @@ -173,6 +173,8 @@ export function renderedObjectsDigest(objects: readonly Record[ } function agentRunPipelineManifest(spec: AgentRunLaneSpec): Record { + const build = spec.deployment.manager.imageBuild; + if (spec.ci.buildkitImage === null) throw new Error(`config/agentrun.yaml controlPlane.lanes.${spec.lane}.ci.buildkitImage is required for AgentRun Tekton image build`); return { apiVersion: "tekton.dev/v1", kind: "Pipeline", @@ -189,44 +191,280 @@ function agentRunPipelineManifest(spec: AgentRunLaneSpec): Record left.localeCompare(right)).map(([key, value]) => `${key}=${value}`)) }, + { name: "build-http-proxy", type: "string", default: build.httpProxy ?? "" }, + { name: "build-https-proxy", type: "string", default: build.httpsProxy ?? "" }, + { name: "build-no-proxy", type: "string", default: build.noProxy.join(",") }, + { name: "container-http-proxy", type: "string", default: build.buildContainerProxy.httpProxy ?? "" }, + { name: "container-https-proxy", type: "string", default: build.buildContainerProxy.httpsProxy ?? "" }, + { name: "container-no-proxy", type: "string", default: build.buildContainerProxy.noProxy.join(",") }, + { name: "env-identity-files-json", type: "string", default: JSON.stringify(build.envIdentityFiles) }, + { name: "gitops-root", type: "string", default: spec.deployment.gitopsRoot }, + { name: "artifact-catalog", type: "string", default: spec.deployment.artifactCatalogPath }, ], - workspaces: [{ name: "source" }, { name: "git-ssh" }], + workspaces: [{ name: "source" }], tasks: [ - gitopsSmokeTask(spec), + agentRunBuildPublishTask(spec), ], }, }; } -function gitopsSmokeTask(spec: AgentRunLaneSpec): Record { +function agentRunBuildPublishTask(spec: AgentRunLaneSpec): Record { return { - name: "render-smoke", + name: "build-publish", workspaces: [{ name: "source", workspace: "source" }], taskSpec: { - params: [{ name: "revision" }, { name: "tools-image" }], + params: [ + { name: "git-read-url" }, + { name: "git-write-url" }, + { name: "source-branch" }, + { name: "gitops-branch" }, + { name: "revision" }, + { name: "source-stage-ref" }, + { name: "registry-prefix" }, + { name: "tools-image" }, + { name: "buildkit-image" }, + { name: "containerfile" }, + { name: "context-dir" }, + { name: "image-repository" }, + { name: "build-network" }, + { name: "build-args-json" }, + { name: "build-http-proxy" }, + { name: "build-https-proxy" }, + { name: "build-no-proxy" }, + { name: "container-http-proxy" }, + { name: "container-https-proxy" }, + { name: "container-no-proxy" }, + { name: "env-identity-files-json" }, + { name: "gitops-root" }, + { name: "artifact-catalog" }, + ], workspaces: [{ name: "source" }], steps: [ { - name: "render-smoke", + name: "source-checkout", image: "$(params.tools-image)", - script: [ - "#!/bin/sh", - "set -eu", - "echo '{\"event\":\"agentrun-ci-render-smoke\",\"status\":\"placeholder\",\"reason\":\"unidesk-yaml-only-control-plane\",\"valuesPrinted\":false}'", - ].join("\n"), + script: agentRunTektonSourceCheckoutScript(), + }, + { + name: "probe-env-image", + image: "$(params.tools-image)", + script: agentRunTektonProbeImageScript(), + }, + { + name: "build-env-image", + image: "$(params.buildkit-image)", + env: [ + { name: "BUILDKITD_FLAGS", value: "--oci-worker-no-process-sandbox --oci-worker-net=host --allow-insecure-entitlement network.host" }, + ], + securityContext: { privileged: true, runAsUser: 1000, runAsGroup: 1000 }, + script: agentRunTektonBuildImageScript(), + }, + { + name: "publish-gitops", + image: "$(params.tools-image)", + script: agentRunTektonGitopsPublishScript(spec), }, ], }, params: [ + { name: "git-read-url", value: "$(params.git-read-url)" }, + { name: "git-write-url", value: "$(params.git-write-url)" }, + { name: "source-branch", value: "$(params.source-branch)" }, + { name: "gitops-branch", value: "$(params.gitops-branch)" }, { name: "revision", value: "$(params.revision)" }, + { name: "source-stage-ref", value: "$(params.source-stage-ref)" }, + { name: "registry-prefix", value: "$(params.registry-prefix)" }, { name: "tools-image", value: "$(params.tools-image)" }, + { name: "buildkit-image", value: "$(params.buildkit-image)" }, + { name: "containerfile", value: "$(params.containerfile)" }, + { name: "context-dir", value: "$(params.context-dir)" }, + { name: "image-repository", value: "$(params.image-repository)" }, + { name: "build-network", value: "$(params.build-network)" }, + { name: "build-args-json", value: "$(params.build-args-json)" }, + { name: "build-http-proxy", value: "$(params.build-http-proxy)" }, + { name: "build-https-proxy", value: "$(params.build-https-proxy)" }, + { name: "build-no-proxy", value: "$(params.build-no-proxy)" }, + { name: "container-http-proxy", value: "$(params.container-http-proxy)" }, + { name: "container-https-proxy", value: "$(params.container-https-proxy)" }, + { name: "container-no-proxy", value: "$(params.container-no-proxy)" }, + { name: "env-identity-files-json", value: "$(params.env-identity-files-json)" }, + { name: "gitops-root", value: "$(params.gitops-root)" }, + { name: "artifact-catalog", value: "$(params.artifact-catalog)" }, ], when: [{ input: spec.deployment.format, operator: "in", values: ["unidesk-yaml-only"] }], }; } +function agentRunTektonSourceCheckoutScript(): string { + return [ + "#!/bin/sh", + "set -eu", + "root=\"$(workspaces.source.path)\"", + "rm -rf \"$root/repo\"", + "git clone --no-checkout \"$(params.git-read-url)\" \"$root/repo\"", + "cd \"$root/repo\"", + "git fetch origin \"+$(params.source-stage-ref):refs/remotes/origin/unidesk-source-snapshot\"", + "git checkout --detach \"$(params.revision)\"", + "actual=$(git rev-parse HEAD)", + "test \"$actual\" = \"$(params.revision)\"", + "ENV_IDENTITY_FILES='$(params.env-identity-files-json)' BUILD_ARGS_JSON='$(params.build-args-json)' CONTAINER_HTTP_PROXY='$(params.container-http-proxy)' CONTAINER_HTTPS_PROXY='$(params.container-https-proxy)' CONTAINER_NO_PROXY='$(params.container-no-proxy)' node <<'NODE' > \"$root/env-identity\"", + "const { createHash } = require('node:crypto');", + "const { existsSync, lstatSync, readdirSync, readFileSync } = require('node:fs');", + "const { join } = require('node:path');", + "const files = JSON.parse(process.env.ENV_IDENTITY_FILES || '[]');", + "const buildArgs = JSON.parse(process.env.BUILD_ARGS_JSON || '[]');", + "const proxy = [`HTTP_PROXY=${process.env.CONTAINER_HTTP_PROXY || ''}`, `HTTPS_PROXY=${process.env.CONTAINER_HTTPS_PROXY || ''}`, `NO_PROXY=${process.env.CONTAINER_NO_PROXY || ''}`];", + "const skip = new Set(['.git', '.worktree', '.state', 'node_modules', 'coverage', 'tmp', '.tmp']);", + "const hash = createHash('sha256');", + "function collect(input) {", + " if (!existsSync(input)) return [{ path: input, missing: true }];", + " const stat = lstatSync(input);", + " if (stat.isFile()) return [{ path: input, missing: false }];", + " if (!stat.isDirectory()) return [{ path: input, missing: true }];", + " const out = [];", + " const stack = [input];", + " while (stack.length) {", + " const dir = stack.pop();", + " for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {", + " if (entry.isDirectory() && skip.has(entry.name)) continue;", + " const child = join(dir, entry.name);", + " if (entry.isDirectory()) stack.push(child);", + " else if (entry.isFile()) out.push({ path: child, missing: false });", + " }", + " }", + " return out.sort((a, b) => a.path.localeCompare(b.path));", + "}", + "for (const item of buildArgs) { hash.update('build-arg\\0'); hash.update(item); hash.update('\\0'); }", + "for (const item of proxy) { hash.update('build-container-proxy\\0'); hash.update(item); hash.update('\\0'); }", + "for (const file of files) for (const entry of collect(file)) { hash.update(entry.path); hash.update('\\0'); if (!entry.missing) hash.update(readFileSync(entry.path)); hash.update('\\0'); }", + "process.stdout.write(hash.digest('hex').slice(0, 24));", + "NODE", + "BUILD_ARGS='$(params.build-args-json)' node <<'NODE' > \"$root/build-args.txt\"", + "const values = JSON.parse(process.env.BUILD_ARGS || '[]');", + "for (const value of values) console.log(`build-arg:${value}`);", + "NODE", + "chmod -R a+rwX \"$root\"", + "env_identity=$(cat \"$root/env-identity\")", + "printf '{\"ok\":true,\"phase\":\"source-checkout\",\"sourceCommit\":\"%s\",\"sourceStageRef\":\"%s\",\"envIdentity\":\"%s\",\"valuesPrinted\":false}\\n' \"$(params.revision)\" \"$(params.source-stage-ref)\" \"$env_identity\"", + ].join("\n"); +} + +function agentRunTektonProbeImageScript(): string { + return [ + "#!/bin/sh", + "set -eu", + "root=\"$(workspaces.source.path)\"", + "env_identity=$(cat \"$root/env-identity\")", + "image_repository='$(params.image-repository)'", + "manifest_accept='application/vnd.oci.image.index.v1+json, application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json'", + "repo_path=${image_repository#127.0.0.1:5000/}", + "headers=$(mktemp)", + "digest=''", + "if curl -fsSI -H \"Accept: $manifest_accept\" \"http://127.0.0.1:5000/v2/$repo_path/manifests/$env_identity\" >\"$headers\"; then", + " digest=$(awk -F': ' 'tolower($1)==\"docker-content-digest\"{print $2}' \"$headers\" | tr -d '\\r' | head -n 1)", + "fi", + "rm -f \"$headers\"", + "if [ -n \"$digest\" ]; then", + " image=\"$image_repository:$env_identity\"", + " printf '{\"ok\":true,\"status\":\"reused\",\"sourceCommit\":\"%s\",\"envIdentity\":\"%s\",\"image\":\"%s\",\"digest\":\"%s\",\"repositoryDigest\":\"%s@%s\",\"valuesPrinted\":false}\\n' \"$(params.revision)\" \"$env_identity\" \"$image\" \"$digest\" \"$image_repository\" \"$digest\" > \"$root/build-result.json\"", + " touch \"$root/skip-build\"", + "else", + " printf '{\"ok\":false,\"status\":\"cache-miss\",\"sourceCommit\":\"%s\",\"envIdentity\":\"%s\",\"valuesPrinted\":false}\\n' \"$(params.revision)\" \"$env_identity\" > \"$root/build-result.json\"", + "fi", + "cat \"$root/build-result.json\"", + ].join("\n"); +} + +function agentRunTektonBuildImageScript(): string { + return [ + "#!/bin/sh", + "set -eu", + "root=\"$(workspaces.source.path)\"", + "if [ -f \"$root/skip-build\" ]; then cat \"$root/build-result.json\"; exit 0; fi", + "env_identity=$(cat \"$root/env-identity\")", + "image_repository='$(params.image-repository)'", + "image=\"$image_repository:$env_identity\"", + "context_dir='$(params.context-dir)'", + "if [ \"$context_dir\" = \".\" ]; then context_path=\"$root/repo\"; else context_path=\"$root/repo/${context_dir#./}\"; fi", + "args=\"build --allow network.host --frontend dockerfile.v0 --local context=$context_path --local dockerfile=$root/repo --opt filename=$(params.containerfile) --opt network=$(params.build-network)\"", + "add_opt() { args=\"$args --opt $1\"; }", + "if [ -n '$(params.container-http-proxy)' ]; then add_opt 'build-arg:HTTP_PROXY=$(params.container-http-proxy)'; add_opt 'build-arg:http_proxy=$(params.container-http-proxy)'; fi", + "if [ -n '$(params.container-https-proxy)' ]; then add_opt 'build-arg:HTTPS_PROXY=$(params.container-https-proxy)'; add_opt 'build-arg:https_proxy=$(params.container-https-proxy)'; fi", + "if [ -n '$(params.container-https-proxy)' ]; then add_opt 'build-arg:ALL_PROXY=$(params.container-https-proxy)'; add_opt 'build-arg:all_proxy=$(params.container-https-proxy)'; elif [ -n '$(params.container-http-proxy)' ]; then add_opt 'build-arg:ALL_PROXY=$(params.container-http-proxy)'; add_opt 'build-arg:all_proxy=$(params.container-http-proxy)'; fi", + "if [ -n '$(params.container-no-proxy)' ]; then add_opt 'build-arg:NO_PROXY=$(params.container-no-proxy)'; add_opt 'build-arg:no_proxy=$(params.container-no-proxy)'; fi", + "while IFS= read -r opt; do [ -n \"$opt\" ] && add_opt \"$opt\"; done < \"$root/build-args.txt\"", + "args=\"$args --metadata-file $root/build-metadata.json --output type=image,name=$image,push=true,registry.insecure=true\"", + "buildctl-daemonless.sh $args", + "metadata_compact=$(tr -d '\\n' < \"$root/build-metadata.json\")", + "digest=$(printf '%s' \"$metadata_compact\" | sed -n 's/.*\"containerimage.digest\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p' | head -n 1)", + "test -n \"$digest\"", + "printf '{\"ok\":true,\"status\":\"built\",\"sourceCommit\":\"%s\",\"envIdentity\":\"%s\",\"image\":\"%s\",\"digest\":\"%s\",\"repositoryDigest\":\"%s@%s\",\"valuesPrinted\":false}\\n' \"$(params.revision)\" \"$env_identity\" \"$image\" \"$digest\" \"$image_repository\" \"$digest\" > \"$root/build-result.json\"", + "cat \"$root/build-result.json\"", + ].join("\n"); +} + +function agentRunTektonGitopsPublishScript(spec: AgentRunLaneSpec): string { + const sourcePlaceholder = "__AGENTRUN_SOURCE_COMMIT__"; + const envPlaceholder = "__AGENTRUN_ENV_IDENTITY__"; + const digestPlaceholder = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const statusPlaceholder = "__AGENTRUN_IMAGE_STATUS__"; + const templateImage = agentRunImageArtifact(spec, { sourceCommit: sourcePlaceholder, envIdentity: envPlaceholder, digest: digestPlaceholder, status: statusPlaceholder }); + const templateFiles = renderAgentRunGitopsFiles(spec, { sourceCommit: sourcePlaceholder, image: templateImage }); + const templateB64 = Buffer.from(JSON.stringify(templateFiles), "utf8").toString("base64"); + return [ + "#!/bin/sh", + "set -eu", + "root=\"$(workspaces.source.path)\"", + "build_result=\"$root/build-result.json\"", + "test -s \"$build_result\"", + `templates_b64=${JSON.stringify(templateB64)}`, + "rm -rf \"$root/gitops\"", + "git clone \"$(params.git-write-url)\" \"$root/gitops\"", + "cd \"$root/gitops\"", + "git fetch origin \"$(params.gitops-branch)\" || true", + "if git rev-parse --verify \"refs/remotes/origin/$(params.gitops-branch)^{commit}\" >/dev/null 2>&1; then git checkout -B \"$(params.gitops-branch)\" \"refs/remotes/origin/$(params.gitops-branch)\"; else git checkout --orphan \"$(params.gitops-branch)\"; git rm -rf . >/dev/null 2>&1 || true; fi", + "git rm -rf --ignore-unmatch \"$(params.gitops-root)\" \"$(params.artifact-catalog)\" source.json >/dev/null 2>&1 || true", + "rm -rf \"$(params.gitops-root)\" \"$(params.artifact-catalog)\" source.json", + "TEMPLATES_B64=\"$templates_b64\" BUILD_RESULT=\"$build_result\" node <<'NODE'", + "const { mkdirSync, readFileSync, writeFileSync } = require('node:fs');", + "const { dirname } = require('node:path');", + "const templates = JSON.parse(Buffer.from(process.env.TEMPLATES_B64, 'base64').toString('utf8'));", + "const build = JSON.parse(readFileSync(process.env.BUILD_RESULT, 'utf8'));", + "const replacements = new Map([", + " ['__AGENTRUN_SOURCE_COMMIT__', build.sourceCommit],", + " ['__AGENTRUN_ENV_IDENTITY__', build.envIdentity],", + " ['sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', build.digest],", + " ['__AGENTRUN_IMAGE_STATUS__', build.status],", + "]);", + "for (const file of templates) {", + " let content = file.content;", + " for (const [from, to] of replacements) content = content.split(from).join(to);", + " mkdirSync(dirname(file.path), { recursive: true });", + " writeFileSync(file.path, content);", + "}", + "NODE", + "git add source.json \"$(params.artifact-catalog)\" \"$(params.gitops-root)\"", + "if git diff --quiet --cached; then changed=false; else changed=true; git -c user.email=agentrun@unidesk.local -c user.name='UniDesk AgentRun PaC' commit -m \"deploy: render AgentRun $(params.gitops-branch) from PaC\"; fi", + "git push -u origin \"$(params.gitops-branch)\"", + "gitops_commit=$(git rev-parse HEAD)", + "BUILD_RESULT=\"$build_result\" CHANGED=\"$changed\" GITOPS_COMMIT=\"$gitops_commit\" node <<'NODE'", + "const { readFileSync } = require('node:fs');", + "const build = JSON.parse(readFileSync(process.env.BUILD_RESULT, 'utf8'));", + "console.log(JSON.stringify({ ok: true, status: 'succeeded', phase: 'gitops-publish', changed: process.env.CHANGED === 'true', gitopsCommit: process.env.GITOPS_COMMIT, sourceCommit: build.sourceCommit, envIdentity: build.envIdentity, imageStatus: build.status, digest: build.digest, valuesPrinted: false }));", + "NODE", + ].join("\n"); +} + function agentRunArgoProjectManifest(spec: AgentRunLaneSpec): Record { return { apiVersion: "argoproj.io/v1alpha1", @@ -498,7 +736,11 @@ function agentRunArtifactCatalog(spec: AgentRunLaneSpec, sourceCommit: string, i sourceBranch: spec.source.branch, gitopsBranch: spec.gitops.branch, sourceCommitId: sourceCommit, - summary: image.status === "placeholder" ? "build=0 reuse=0 placeholder=1" : "build=1 reuse=0 placeholder=0", + summary: image.status === "placeholder" + ? "build=0 reuse=0 placeholder=1" + : image.status === "reused" + ? "build=0 reuse=1 placeholder=0" + : "build=1 reuse=0 placeholder=0", services: [image], }; } diff --git a/scripts/src/platform-infra-pipelines-as-code-remote.sh b/scripts/src/platform-infra-pipelines-as-code-remote.sh new file mode 100644 index 00000000..0f76e120 --- /dev/null +++ b/scripts/src/platform-infra-pipelines-as-code-remote.sh @@ -0,0 +1,250 @@ +#!/bin/sh +set -eu + +json_escape() { + sed 's/\\/\\\\/g; s/"/\\"/g; s/ /\\t/g' | tr '\n' ' ' +} + +json_string() { + printf '%s' "$1" | json_escape +} + +now_epoch() { + date +%s +} + +duration_seconds() { + start="$1" + end="$2" + if [ -n "$start" ] && [ -n "$end" ]; then + s=$(date -d "$start" +%s 2>/dev/null || echo "") + e=$(date -d "$end" +%s 2>/dev/null || echo "") + if [ -n "$s" ] && [ -n "$e" ]; then + echo $((e - s)) + return + fi + fi + echo null +} + +api_url() { + printf '%s/api/v1/%s' "$UNIDESK_PAC_GITEA_BASE_URL" "$1" +} + +gitea_api() { + method="$1" + path="$2" + body="${3:-}" + if [ -n "$body" ]; then + curl -fsS -u "$UNIDESK_PAC_GITEA_ADMIN_USERNAME:$UNIDESK_PAC_GITEA_ADMIN_PASSWORD" \ + -H 'Content-Type: application/json' \ + -X "$method" \ + --data "$body" \ + "$(api_url "$path")" + else + curl -fsS -u "$UNIDESK_PAC_GITEA_ADMIN_USERNAME:$UNIDESK_PAC_GITEA_ADMIN_PASSWORD" \ + -X "$method" \ + "$(api_url "$path")" + fi +} + +ensure_token() { + name="unidesk-pac-$(date +%Y%m%d%H%M%S)" + body=$(printf '{"name":"%s"}' "$name") + out=$(gitea_api POST "users/$UNIDESK_PAC_GITEA_API_USERNAME/tokens" "$body") + printf '%s' "$out" | sed -n 's/.*"sha1"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' +} + +ensure_webhook() { + hooks=$(gitea_api GET "repos/$UNIDESK_PAC_GITEA_OWNER/$UNIDESK_PAC_GITEA_REPO/hooks") + hook_id=$(printf '%s' "$hooks" | tr '{' '\n' | awk -v url="$UNIDESK_PAC_WEBHOOK_URL" 'index($0, url)>0 { if (match($0, /"id"[[:space:]]*:[[:space:]]*[0-9]+/)) { print substr($0, RSTART); exit } }' | sed -n 's/[^0-9]*\([0-9][0-9]*\).*/\1/p') + body=$(printf '{"type":"gitea","config":{"url":"%s","content_type":"json","secret":"%s"},"events":["push"],"active":true}' "$UNIDESK_PAC_WEBHOOK_URL" "$UNIDESK_PAC_WEBHOOK_SECRET") + if [ -n "$hook_id" ]; then + gitea_api PATCH "repos/$UNIDESK_PAC_GITEA_OWNER/$UNIDESK_PAC_GITEA_REPO/hooks/$hook_id" "$body" >/dev/null + else + created=$(gitea_api POST "repos/$UNIDESK_PAC_GITEA_OWNER/$UNIDESK_PAC_GITEA_REPO/hooks" "$body") + hook_id=$(printf '%s' "$created" | sed -n 's/.*"id"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p') + fi + printf '%s' "$hook_id" +} + +repository_manifest() { + cat < "$tmp" + kubectl apply --server-side --force-conflicts --field-manager="$UNIDESK_PAC_FIELD_MANAGER" -f "$tmp" >/dev/null + rm -f "$tmp" +} + +wait_ready() { + timeout="${UNIDESK_PAC_WAIT_TIMEOUT_SECONDS:-55}" + end=$(( $(now_epoch) + timeout )) + while [ "$(now_epoch)" -lt "$end" ]; do + ready=$(kubectl -n "$UNIDESK_PAC_RELEASE_NAMESPACE" get deploy "$UNIDESK_PAC_CONTROLLER_SERVICE_NAME" -o jsonpath='{.status.readyReplicas}' 2>/dev/null || true) + desired=$(kubectl -n "$UNIDESK_PAC_RELEASE_NAMESPACE" get deploy "$UNIDESK_PAC_CONTROLLER_SERVICE_NAME" -o jsonpath='{.spec.replicas}' 2>/dev/null || true) + if [ "${ready:-0}" = "${desired:-1}" ] && [ "${ready:-0}" != "0" ]; then + return 0 + fi + sleep 2 + done + return 1 +} + +apply_action() { + if [ "$UNIDESK_PAC_DRY_RUN" = "1" ]; then + printf '{"ok":true,"mode":"dry-run","mutation":false,"valuesPrinted":false}\n' + return + fi + apply_release + kubectl create ns "$UNIDESK_PAC_TARGET_NAMESPACE" --dry-run=client -o yaml | kubectl apply -f - >/dev/null + token=$(ensure_token) + test -n "$token" + kubectl -n "$UNIDESK_PAC_TARGET_NAMESPACE" create secret generic "$UNIDESK_PAC_SECRET_NAME" \ + --from-literal="$UNIDESK_PAC_TOKEN_KEY=$token" \ + --from-literal="$UNIDESK_PAC_WEBHOOK_SECRET_KEY=$UNIDESK_PAC_WEBHOOK_SECRET" \ + --dry-run=client -o yaml | kubectl apply -f - >/dev/null + repository_manifest | kubectl apply --server-side --force-conflicts --field-manager="$UNIDESK_PAC_FIELD_MANAGER" -f - >/dev/null + hook_id=$(ensure_webhook) + ready=false + if wait_ready; then ready=true; fi + printf '{"ok":%s,"mode":"confirmed","mutation":true,"controllerReady":%s,"webhook":{"present":true,"id":"%s","url":"%s"},"repository":"%s","namespace":"%s","valuesPrinted":false}\n' "$ready" "$ready" "$(json_string "$hook_id")" "$(json_string "$UNIDESK_PAC_WEBHOOK_URL")" "$(json_string "$UNIDESK_PAC_REPOSITORY_NAME")" "$(json_string "$UNIDESK_PAC_TARGET_NAMESPACE")" +} + +condition_status() { + kubectl -n "$1" get "$2" "$3" -o jsonpath='{range .status.conditions[*]}{.type}={.status}:{.reason}{";"}{end}' 2>/dev/null || true +} + +pipeline_rows() { + payload=$(kubectl -n "$UNIDESK_PAC_TARGET_NAMESPACE" get pipelinerun -o json 2>/dev/null || echo '{"items":[]}') + PIPELINE_JSON="$payload" node <<'NODE' +const input = process.env.PIPELINE_JSON || '{"items":[]}'; +const data = input ? JSON.parse(input) : { items: [] }; +function cond(item) { + const c = (item.status?.conditions || []).find((x) => x.type === 'Succeeded') || {}; + return { status: c.status || '', reason: c.reason || '' }; +} +function durationSeconds(item) { + const start = item.status?.startTime; + const done = item.status?.completionTime; + if (!start) return null; + return Math.max(0, Math.round(((done ? Date.parse(done) : Date.now()) - Date.parse(start)) / 1000)); +} +const prefix = process.env.UNIDESK_PAC_PIPELINE_RUN_PREFIX; +const rows = (data.items || []) + .filter((item) => item.metadata?.name?.startsWith(prefix)) + .sort((a, b) => Date.parse(b.metadata?.creationTimestamp || 0) - Date.parse(a.metadata?.creationTimestamp || 0)) + .slice(0, 8) + .map((item) => { + const c = cond(item); + return { + name: item.metadata.name, + status: c.status, + reason: c.reason, + createdAt: item.metadata.creationTimestamp || null, + startTime: item.status?.startTime || null, + completionTime: item.status?.completionTime || null, + durationSeconds: durationSeconds(item), + sourceCommit: item.metadata.labels?.['pipelinesascode.tekton.dev/sha'] || item.metadata.labels?.['agentrun.pikastech.local/source-commit'] || null, + }; + }); +process.stdout.write(JSON.stringify(rows)); +NODE +} + +task_rows() { + payload=$(kubectl -n "$UNIDESK_PAC_TARGET_NAMESPACE" get taskrun -o json 2>/dev/null || echo '{"items":[]}') + TASK_JSON="$payload" node <<'NODE' +const input = process.env.TASK_JSON || '{"items":[]}'; +const data = input ? JSON.parse(input) : { items: [] }; +const pr = process.env.UNIDESK_PAC_TARGET_PIPELINERUN || ''; +function cond(item) { + const c = (item.status?.conditions || []).find((x) => x.type === 'Succeeded') || {}; + return { status: c.status || '', reason: c.reason || '' }; +} +function durationSeconds(item) { + const start = item.status?.startTime; + const done = item.status?.completionTime; + if (!start) return null; + return Math.max(0, Math.round(((done ? Date.parse(done) : Date.now()) - Date.parse(start)) / 1000)); +} +const rows = (data.items || []) + .filter((item) => !pr || item.metadata?.labels?.['tekton.dev/pipelineRun'] === pr) + .sort((a, b) => Date.parse(a.metadata?.creationTimestamp || 0) - Date.parse(b.metadata?.creationTimestamp || 0)) + .slice(0, 20) + .map((item) => { + const c = cond(item); + return { name: item.metadata.name, pipelineRun: item.metadata.labels?.['tekton.dev/pipelineRun'] || null, status: c.status, reason: c.reason, startTime: item.status?.startTime || null, completionTime: item.status?.completionTime || null, durationSeconds: durationSeconds(item) }; + }); +process.stdout.write(JSON.stringify(rows)); +NODE +} + +hook_summary() { + hooks=$(gitea_api GET "repos/$UNIDESK_PAC_GITEA_OWNER/$UNIDESK_PAC_GITEA_REPO/hooks" 2>/dev/null || echo '[]') + HOOKS_JSON="$hooks" node <<'NODE' +const data = JSON.parse(process.env.HOOKS_JSON || '[]'); +const url = process.env.UNIDESK_PAC_WEBHOOK_URL; +const rows = data.filter((item) => item?.config?.url === url).map((item) => ({ id: item.id, active: item.active, type: item.type, events: item.events || [], url: item.config?.url || '' })); +process.stdout.write(JSON.stringify(rows)); +NODE +} + +status_action() { + crd=$(kubectl get crd repositories.pipelinesascode.tekton.dev -o name 2>/dev/null || true) + controller_ready=$(kubectl -n "$UNIDESK_PAC_RELEASE_NAMESPACE" get deploy "$UNIDESK_PAC_CONTROLLER_SERVICE_NAME" -o jsonpath='{.status.readyReplicas}/{.spec.replicas}' 2>/dev/null || echo "0/0") + repository_condition=$(condition_status "$UNIDESK_PAC_TARGET_NAMESPACE" repository "$UNIDESK_PAC_REPOSITORY_NAME") + pipelines=$(pipeline_rows) + latest=$(printf '%s' "$pipelines" | node -e 'const fs=require("fs"); const a=JSON.parse(fs.readFileSync(0,"utf8")||"[]"); process.stdout.write(a[0]?.name||"")') + export UNIDESK_PAC_TARGET_PIPELINERUN="$latest" + tasks=$(task_rows) + hooks=$(hook_summary) + argo=$(kubectl -n "$UNIDESK_PAC_ARGO_NAMESPACE" get application "$UNIDESK_PAC_ARGO_APPLICATION" -o json 2>/dev/null | node -e 'const fs=require("fs"); const s=fs.readFileSync(0,"utf8").trim(); if(!s){process.stdout.write("{}"); process.exit(0)} const a=JSON.parse(s); process.stdout.write(JSON.stringify({sync:a.status?.sync?.status||null, health:a.status?.health?.status||null, revision:a.status?.sync?.revision||null}))' || echo '{}') + printf '{"ok":%s,"crdPresent":%s,"controllerReady":"%s","repositoryCondition":"%s","webhooks":%s,"pipelineRuns":%s,"taskRuns":%s,"argo":%s,"valuesPrinted":false}\n' \ + "$( [ -n "$crd" ] && [ "$controller_ready" != "0/0" ] && echo true || echo false )" \ + "$( [ -n "$crd" ] && echo true || echo false )" \ + "$(json_string "$controller_ready")" \ + "$(json_string "$repository_condition")" \ + "$hooks" "$pipelines" "$tasks" "$argo" +} + +webhook_test_action() { + hooks=$(hook_summary) + hook_id=$(printf '%s' "$hooks" | node -e 'const fs=require("fs"); const a=JSON.parse(fs.readFileSync(0,"utf8")||"[]"); process.stdout.write(String(a[0]?.id||""))') + test -n "$hook_id" + gitea_api POST "repos/$UNIDESK_PAC_GITEA_OWNER/$UNIDESK_PAC_GITEA_REPO/hooks/$hook_id/tests" '{}' >/dev/null + printf '{"ok":true,"mutation":true,"webhookTestSent":true,"hookId":"%s","valuesPrinted":false}\n' "$(json_string "$hook_id")" +} + +case "$UNIDESK_PAC_ACTION" in + apply) apply_action ;; + status) status_action ;; + webhook-test) webhook_test_action ;; + *) printf '{"ok":false,"error":"unsupported-action","valuesPrinted":false}\n'; exit 2 ;; +esac diff --git a/scripts/src/platform-infra-pipelines-as-code.ts b/scripts/src/platform-infra-pipelines-as-code.ts new file mode 100644 index 00000000..9a497f8f --- /dev/null +++ b/scripts/src/platform-infra-pipelines-as-code.ts @@ -0,0 +1,741 @@ +import { createHash, randomBytes } from "node:crypto"; +import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import type { UniDeskConfig } from "./config"; +import { rootPath } from "./config"; +import type { RenderedCliResult } from "./output"; +import { + capture, + compactCapture, + createYamlFieldReader, + parseJsonOutput, + readYamlRecord, + shQuote, + sha256Fingerprint, +} from "./platform-infra-ops-library"; + +const configFile = rootPath("config", "platform-infra", "pipelines-as-code.yaml"); +const configLabel = "config/platform-infra/pipelines-as-code.yaml"; +const remoteScriptFile = rootPath("scripts", "src", "platform-infra-pipelines-as-code-remote.sh"); +const fieldManager = "unidesk-platform-infra-pipelines-as-code"; +const y = createYamlFieldReader(configLabel); + +interface PacTarget { + id: string; + route: string; + namespace: string; + role: string; + enabled: boolean; +} + +interface PacConfig { + version: number; + kind: "platform-infra-pipelines-as-code"; + metadata: { + id: string; + owner: string; + spec: string; + relatedIssues: number[]; + }; + defaults: { + targetId: string; + }; + release: { + version: string; + manifestUrl: string; + namespace: string; + controllerServiceName: string; + controllerServicePort: number; + waitTimeoutSeconds: number; + }; + targets: PacTarget[]; + gitea: { + configRef: string; + internalBaseUrl: string; + admin: { + sourceRoot: string; + sourceRef: string; + format: "line-pair"; + usernameLine: number; + passwordLine: number; + apiUsername: string; + }; + webhook: { + secretRoot: string; + secretSourceRef: string; + events: string[]; + branch: string; + }; + }; + repository: { + name: string; + namespace: string; + providerType: "gitea"; + url: string; + cloneUrl: string; + owner: string; + repo: string; + secretName: string; + tokenKey: string; + webhookSecretKey: string; + concurrencyLimit: number; + params: Record; + }; + consumer: { + node: string; + lane: string; + namespace: string; + pipeline: string; + pipelineRunPrefix: string; + argoNamespace: string; + argoApplication: string; + }; +} + +interface CommonOptions { + targetId: string | null; + full: boolean; + raw: boolean; +} + +interface ApplyOptions extends CommonOptions { + confirm: boolean; + dryRun: boolean; + wait: boolean; +} + +interface WebhookTestOptions extends CommonOptions { + confirm: boolean; +} + +interface SecretMaterial { + adminUsername: string; + adminPassword: string; + adminFingerprint: string; + webhookSecret: string; + webhookFingerprint: string; + webhookPath: string; +} + +export async function runPlatformInfraPipelinesAsCodeCommand(config: UniDeskConfig, args: string[]): Promise | RenderedCliResult> { + const [action = "plan"] = args; + if (action === "help" || action === "--help") return help(); + if (action === "plan") { + const options = parseCommonOptions(args.slice(1)); + const result = plan(options); + return options.full || options.raw ? result : renderPlan(result); + } + if (action === "apply") { + const options = parseApplyOptions(args.slice(1)); + const result = await apply(config, options); + return options.full || options.raw ? result : renderApply(result); + } + if (action === "status") { + const options = parseCommonOptions(args.slice(1)); + const result = await status(config, options); + return options.full || options.raw ? result : renderStatus(result); + } + if (action === "webhook-test") { + const options = parseWebhookTestOptions(args.slice(1)); + const result = await webhookTest(config, options); + return options.full || options.raw ? result : renderWebhookTest(result); + } + return { ok: false, error: "unsupported-platform-infra-pipelines-as-code-command", args, help: help() }; +} + +function help(): Record { + return { + command: "platform-infra pipelines-as-code plan|apply|status|webhook-test", + configTruth: configLabel, + usage: [ + "bun scripts/cli.ts platform-infra pipelines-as-code plan --target JD01", + "bun scripts/cli.ts platform-infra pipelines-as-code apply --target JD01 --dry-run", + "bun scripts/cli.ts platform-infra pipelines-as-code apply --target JD01 --confirm", + "bun scripts/cli.ts platform-infra pipelines-as-code status --target JD01 [--full|--raw]", + "bun scripts/cli.ts platform-infra pipelines-as-code webhook-test --target JD01 --confirm", + ], + boundary: "Sole CI trigger path for GH-1552: Gitea webhook -> Pipelines-as-Code -> Tekton -> Argo/k8s runtime.", + }; +} + +function readPacConfig(): PacConfig { + const root = readYamlRecord>(configFile, "platform-infra-pipelines-as-code"); + const release = y.objectField(root, "release", ""); + const gitea = y.objectField(root, "gitea", ""); + const admin = y.objectField(gitea, "admin", "gitea"); + const webhook = y.objectField(gitea, "webhook", "gitea"); + const repository = y.objectField(root, "repository", ""); + const consumer = y.objectField(root, "consumer", ""); + const parsed: PacConfig = { + version: y.integerField(root, "version", ""), + kind: "platform-infra-pipelines-as-code", + metadata: { + id: y.stringField(y.objectField(root, "metadata", ""), "id", "metadata"), + owner: y.stringField(y.objectField(root, "metadata", ""), "owner", "metadata"), + spec: y.stringField(y.objectField(root, "metadata", ""), "spec", "metadata"), + relatedIssues: y.numberArrayField(y.objectField(root, "metadata", ""), "relatedIssues", "metadata"), + }, + defaults: { + targetId: y.stringField(y.objectField(root, "defaults", ""), "targetId", "defaults"), + }, + release: { + version: y.stringField(release, "version", "release"), + manifestUrl: urlField(release, "manifestUrl", "release"), + namespace: y.kubernetesNameField(release, "namespace", "release"), + controllerServiceName: y.kubernetesNameField(release, "controllerServiceName", "release"), + controllerServicePort: y.portField(release, "controllerServicePort", "release"), + waitTimeoutSeconds: positiveInteger(release, "waitTimeoutSeconds", "release"), + }, + targets: y.arrayOfRecords(root.targets, "targets").map(parseTarget), + gitea: { + configRef: y.stringField(gitea, "configRef", "gitea"), + internalBaseUrl: urlField(gitea, "internalBaseUrl", "gitea"), + admin: { + sourceRoot: y.absolutePathField(admin, "sourceRoot", "gitea.admin"), + sourceRef: y.sourceRefField(admin, "sourceRef", "gitea.admin"), + format: y.enumField(admin, "format", "gitea.admin", ["line-pair"] as const), + usernameLine: positiveInteger(admin, "usernameLine", "gitea.admin"), + passwordLine: positiveInteger(admin, "passwordLine", "gitea.admin"), + apiUsername: y.stringField(admin, "apiUsername", "gitea.admin"), + }, + webhook: { + secretRoot: y.absolutePathField(webhook, "secretRoot", "gitea.webhook"), + secretSourceRef: y.sourceRefField(webhook, "secretSourceRef", "gitea.webhook"), + events: y.stringArrayField(webhook, "events", "gitea.webhook"), + branch: y.stringField(webhook, "branch", "gitea.webhook"), + }, + }, + repository: { + name: y.kubernetesNameField(repository, "name", "repository"), + namespace: y.kubernetesNameField(repository, "namespace", "repository"), + providerType: y.enumField(repository, "providerType", "repository", ["gitea"] as const), + url: urlField(repository, "url", "repository"), + cloneUrl: urlField(repository, "cloneUrl", "repository"), + owner: y.stringField(repository, "owner", "repository"), + repo: y.stringField(repository, "repo", "repository"), + secretName: y.kubernetesNameField(repository, "secretName", "repository"), + tokenKey: y.stringField(repository, "tokenKey", "repository"), + webhookSecretKey: y.stringField(repository, "webhookSecretKey", "repository"), + concurrencyLimit: positiveInteger(repository, "concurrencyLimit", "repository"), + params: stringRecord(y.objectField(repository, "params", "repository"), "repository.params"), + }, + consumer: { + node: y.stringField(consumer, "node", "consumer"), + lane: y.stringField(consumer, "lane", "consumer"), + namespace: y.kubernetesNameField(consumer, "namespace", "consumer"), + pipeline: y.kubernetesNameField(consumer, "pipeline", "consumer"), + pipelineRunPrefix: y.stringField(consumer, "pipelineRunPrefix", "consumer"), + argoNamespace: y.kubernetesNameField(consumer, "argoNamespace", "consumer"), + argoApplication: y.kubernetesNameField(consumer, "argoApplication", "consumer"), + }, + }; + validateConfig(parsed); + return parsed; +} + +function parseTarget(record: Record, index: number): PacTarget { + const path = `targets[${index}]`; + return { + id: y.stringField(record, "id", path), + route: y.stringField(record, "route", path), + namespace: y.kubernetesNameField(record, "namespace", path), + role: y.stringField(record, "role", path), + enabled: y.booleanField(record, "enabled", path), + }; +} + +function validateConfig(config: PacConfig): void { + if (config.version !== 1) throw new Error(`${configLabel}.version must be 1`); + resolveTarget(config, config.defaults.targetId); + if (config.repository.providerType !== "gitea") throw new Error(`${configLabel}.repository.providerType must be gitea`); + if (config.release.waitTimeoutSeconds > 55) throw new Error(`${configLabel}.release.waitTimeoutSeconds must fit the 60s trans budget`); + if (config.repository.namespace !== config.consumer.namespace) throw new Error(`${configLabel}.repository.namespace must match consumer.namespace`); + if (new URL(config.repository.cloneUrl).origin !== new URL(config.gitea.internalBaseUrl).origin) throw new Error(`${configLabel}.repository.cloneUrl must use the configured internal Gitea base URL`); + if (!config.gitea.webhook.events.includes("push")) throw new Error(`${configLabel}.gitea.webhook.events must include push`); +} + +function resolveTarget(config: PacConfig, targetId: string | null): PacTarget { + const id = targetId ?? config.defaults.targetId; + const target = config.targets.find((item) => item.id.toLowerCase() === id.toLowerCase()); + if (target === undefined) throw new Error(`unknown Pipelines-as-Code target ${id}; known targets: ${config.targets.map((item) => item.id).join(", ")}`); + if (!target.enabled) throw new Error(`Pipelines-as-Code target ${target.id} is disabled in ${configLabel}`); + return target; +} + +function plan(options: CommonOptions): Record { + const pac = readPacConfig(); + const target = resolveTarget(pac, options.targetId); + const secrets = secretSummaries(pac); + return { + ok: true, + action: "platform-infra-pipelines-as-code-plan", + mutation: false, + target: targetSummary(target), + config: configSummary(pac), + release: pac.release, + repository: repositorySummary(pac), + secrets, + policy: policyChecks(pac), + next: nextCommands(target.id), + }; +} + +async function apply(config: UniDeskConfig, options: ApplyOptions): Promise> { + const pac = readPacConfig(); + const target = resolveTarget(pac, options.targetId); + const secrets = ensureSecrets(pac, !options.dryRun, options.dryRun); + const releaseManifest = options.dryRun ? "" : await fetchReleaseManifest(pac); + const result = await capture(config, target.route, ["sh"], remoteScript("apply", pac, target, options, secrets, releaseManifest)); + const parsed = parseJsonOutput(result.stdout); + return { + ok: result.exitCode === 0 && parsed?.ok === true, + action: "platform-infra-pipelines-as-code-apply", + mutation: !options.dryRun, + mode: options.dryRun ? "dry-run" : "confirmed", + target: targetSummary(target), + config: compactConfigSummary(pac), + release: { version: pac.release.version, manifestUrl: pac.release.manifestUrl }, + repository: repositorySummary(pac), + secrets: secretSummaries(pac), + remote: parsed ?? compactCapture(result, { full: true }), + next: nextCommands(target.id), + }; +} + +async function status(config: UniDeskConfig, options: CommonOptions): Promise> { + const pac = readPacConfig(); + const target = resolveTarget(pac, options.targetId); + const secrets = ensureSecrets(pac, false); + const result = await capture(config, target.route, ["sh"], remoteScript("status", pac, target, { ...options, confirm: false, dryRun: true, wait: false }, secrets, "")); + const parsed = parseJsonOutput(result.stdout); + const summary = parsed === null ? null : statusSummary(parsed); + return { + ok: result.exitCode === 0 && summary?.ready === true, + action: "platform-infra-pipelines-as-code-status", + mutation: false, + target: targetSummary(target), + config: compactConfigSummary(pac), + summary, + remote: options.raw ? parsed : options.full ? parsed : summary ?? compactCapture(result, { full: true }), + next: nextCommands(target.id), + }; +} + +async function webhookTest(config: UniDeskConfig, options: WebhookTestOptions): Promise> { + const pac = readPacConfig(); + const target = resolveTarget(pac, options.targetId); + if (!options.confirm) return { ok: false, action: "platform-infra-pipelines-as-code-webhook-test", mutation: false, mode: "missing-confirm", error: "webhook-test requires --confirm" }; + const secrets = ensureSecrets(pac, false); + const result = await capture(config, target.route, ["sh"], remoteScript("webhook-test", pac, target, { ...options, dryRun: false, wait: false }, secrets, "")); + const parsed = parseJsonOutput(result.stdout); + return { + ok: result.exitCode === 0 && parsed?.ok === true, + action: "platform-infra-pipelines-as-code-webhook-test", + mutation: true, + target: targetSummary(target), + remote: parsed ?? compactCapture(result, { full: true }), + next: nextCommands(target.id), + }; +} + +async function fetchReleaseManifest(pac: PacConfig): Promise { + const response = await fetch(pac.release.manifestUrl); + if (!response.ok) throw new Error(`failed to fetch ${pac.release.manifestUrl}: HTTP ${response.status}`); + const text = await response.text(); + if (!text.includes("repositories.pipelinesascode.tekton.dev")) throw new Error(`${pac.release.manifestUrl} did not contain the Repository CRD`); + return text; +} + +function remoteScript(action: "apply" | "status" | "webhook-test", pac: PacConfig, target: PacTarget, options: ApplyOptions | WebhookTestOptions, secrets: SecretMaterial, releaseManifest: string): string { + const webhookUrl = `${pac.gitea.internalBaseUrl.replace(/\/+$/u, "").replace(/gitea-http\.[^.]+\.svc\.cluster\.local:3000/u, `${pac.release.controllerServiceName}.${pac.release.namespace}.svc.cluster.local:${pac.release.controllerServicePort}`)}`; + const env: Record = { + UNIDESK_PAC_ACTION: action, + UNIDESK_PAC_FIELD_MANAGER: fieldManager, + UNIDESK_PAC_TARGET_ID: target.id, + UNIDESK_PAC_TARGET_NAMESPACE: target.namespace, + UNIDESK_PAC_RELEASE_NAMESPACE: pac.release.namespace, + UNIDESK_PAC_RELEASE_MANIFEST_B64: Buffer.from(releaseManifest, "utf8").toString("base64"), + UNIDESK_PAC_CONTROLLER_SERVICE_NAME: pac.release.controllerServiceName, + UNIDESK_PAC_WAIT_TIMEOUT_SECONDS: String(pac.release.waitTimeoutSeconds), + UNIDESK_PAC_DRY_RUN: "dryRun" in options && options.dryRun ? "1" : "0", + UNIDESK_PAC_GITEA_BASE_URL: pac.gitea.internalBaseUrl.replace(/\/+$/u, ""), + UNIDESK_PAC_GITEA_ADMIN_USERNAME: secrets.adminUsername, + UNIDESK_PAC_GITEA_ADMIN_PASSWORD: secrets.adminPassword, + UNIDESK_PAC_GITEA_API_USERNAME: pac.gitea.admin.apiUsername, + UNIDESK_PAC_GITEA_OWNER: pac.repository.owner, + UNIDESK_PAC_GITEA_REPO: pac.repository.repo, + UNIDESK_PAC_WEBHOOK_URL: webhookUrl, + UNIDESK_PAC_WEBHOOK_SECRET: secrets.webhookSecret, + UNIDESK_PAC_REPOSITORY_NAME: pac.repository.name, + UNIDESK_PAC_REPOSITORY_URL: pac.repository.url, + UNIDESK_PAC_SECRET_NAME: pac.repository.secretName, + UNIDESK_PAC_TOKEN_KEY: pac.repository.tokenKey, + UNIDESK_PAC_WEBHOOK_SECRET_KEY: pac.repository.webhookSecretKey, + UNIDESK_PAC_CONCURRENCY_LIMIT: String(pac.repository.concurrencyLimit), + UNIDESK_PAC_PARAMS_JSON: JSON.stringify(pac.repository.params), + UNIDESK_PAC_PIPELINE_RUN_PREFIX: pac.consumer.pipelineRunPrefix, + UNIDESK_PAC_ARGO_NAMESPACE: pac.consumer.argoNamespace, + UNIDESK_PAC_ARGO_APPLICATION: pac.consumer.argoApplication, + }; + const exports = Object.entries(env).map(([key, value]) => `export ${key}=${shQuote(value)}`).join("\n"); + return `${exports}\n${readFileSync(remoteScriptFile, "utf8")}`; +} + +function credentialPath(root: string, sourceRef: string): string { + return sourceRef.startsWith("/") ? sourceRef : join(root, sourceRef); +} + +function parseLinePair(path: string, usernameLine: number, passwordLine: number): { username: string; password: string } { + const lines = readFileSync(path, "utf8").split(/\r?\n/u); + const username = lines[usernameLine - 1]?.trim() ?? ""; + const password = lines[passwordLine - 1]?.trim() ?? ""; + if (username.length === 0 || password.length === 0) throw new Error(`${path} must contain username on line ${usernameLine} and password on line ${passwordLine}`); + return { username, password }; +} + +function ensureSecrets(pac: PacConfig, createWebhookSecret: boolean, allowMissingWebhookSecret = false): SecretMaterial { + const adminPath = credentialPath(pac.gitea.admin.sourceRoot, pac.gitea.admin.sourceRef); + if (!existsSync(adminPath)) throw new Error(`${pac.gitea.admin.sourceRef} is missing`); + const admin = parseLinePair(adminPath, pac.gitea.admin.usernameLine, pac.gitea.admin.passwordLine); + const webhookPath = credentialPath(pac.gitea.webhook.secretRoot, pac.gitea.webhook.secretSourceRef); + if (!existsSync(webhookPath)) { + if (allowMissingWebhookSecret) { + const placeholder = "dry-run-webhook-secret-placeholder"; + return { + adminUsername: admin.username, + adminPassword: admin.password, + adminFingerprint: secretFingerprint(`${admin.username}\0${admin.password}`), + webhookSecret: placeholder, + webhookFingerprint: secretFingerprint(placeholder), + webhookPath, + }; + } + if (!createWebhookSecret) throw new Error(`${pac.gitea.webhook.secretSourceRef} is missing; run apply --confirm to create it`); + mkdirSync(dirname(webhookPath), { recursive: true }); + writeFileSync(webhookPath, `${randomBytes(32).toString("hex")}\n`, { encoding: "utf8", mode: 0o600 }); + chmodSync(webhookPath, 0o600); + } + const webhookSecret = readFileSync(webhookPath, "utf8").trim(); + if (webhookSecret.length < 32) throw new Error(`${pac.gitea.webhook.secretSourceRef} must contain at least 32 chars`); + return { + adminUsername: admin.username, + adminPassword: admin.password, + adminFingerprint: secretFingerprint(`${admin.username}\0${admin.password}`), + webhookSecret, + webhookFingerprint: secretFingerprint(webhookSecret), + webhookPath, + }; +} + +function secretSummaries(pac: PacConfig): Array> { + const adminPath = credentialPath(pac.gitea.admin.sourceRoot, pac.gitea.admin.sourceRef); + const webhookPath = credentialPath(pac.gitea.webhook.secretRoot, pac.gitea.webhook.secretSourceRef); + const admin = existsSync(adminPath) ? parseLinePair(adminPath, pac.gitea.admin.usernameLine, pac.gitea.admin.passwordLine) : null; + const webhookSecret = existsSync(webhookPath) ? readFileSync(webhookPath, "utf8").trim() : ""; + return [ + { + id: "gitea-admin", + sourceRef: pac.gitea.admin.sourceRef, + sourcePath: adminPath, + present: admin !== null, + fingerprint: admin === null ? null : secretFingerprint(`${admin.username}\0${admin.password}`), + valuesPrinted: false, + }, + { + id: "pac-webhook", + sourceRef: pac.gitea.webhook.secretSourceRef, + sourcePath: webhookPath, + present: webhookSecret.length > 0, + fingerprint: webhookSecret.length > 0 ? secretFingerprint(webhookSecret) : null, + valuesPrinted: false, + }, + ]; +} + +function statusSummary(payload: Record): Record { + const pipelineRuns = arrayRecords(payload.pipelineRuns); + const latest = pipelineRuns[0] ?? {}; + const taskRuns = arrayRecords(payload.taskRuns); + return { + ready: payload.crdPresent === true && String(payload.controllerReady ?? "0/0") !== "0/0", + crdPresent: payload.crdPresent === true, + controllerReady: payload.controllerReady, + repositoryCondition: payload.repositoryCondition, + webhookCount: arrayRecords(payload.webhooks).length, + latestPipelineRun: latest, + taskRuns, + argo: record(payload.argo), + valuesPrinted: false, + }; +} + +function policyChecks(pac: PacConfig): Array> { + return [ + { name: "single-path", ok: true, detail: "Gitea webhook -> Pipelines-as-Code -> Tekton -> Argo/k8s runtime; no Gitea Actions/act_runner/branch-follower fallback." }, + { name: "yaml-source-of-truth", ok: true, detail: `${configLabel} owns PaC release, Repository CR, Gitea webhook and AgentRun JD01 v0.2 params.` }, + { name: "gitea-internal-source", ok: pac.repository.cloneUrl.includes(".svc.cluster.local"), detail: "Tekton source clone uses the internal k3s Gitea service URL." }, + { name: "runtime-zero-docker", ok: true, detail: "Runtime starts at k8s pulling already-built images; CI build may use Tekton/BuildKit." }, + ]; +} + +function targetSummary(target: PacTarget): Record { + return { id: target.id, route: target.route, namespace: target.namespace, role: target.role }; +} + +function configSummary(pac: PacConfig): Record { + return { + path: configLabel, + metadata: pac.metadata, + release: pac.release, + gitea: { configRef: pac.gitea.configRef, internalBaseUrl: pac.gitea.internalBaseUrl, webhookBranch: pac.gitea.webhook.branch }, + repository: repositorySummary(pac), + consumer: pac.consumer, + valuesPrinted: false, + }; +} + +function compactConfigSummary(pac: PacConfig): Record { + return { + path: configLabel, + releaseVersion: pac.release.version, + repository: pac.repository.name, + providerType: pac.repository.providerType, + sourceUrl: pac.repository.cloneUrl, + consumer: `${pac.consumer.node}/${pac.consumer.lane}`, + pipeline: pac.consumer.pipeline, + valuesPrinted: false, + }; +} + +function repositorySummary(pac: PacConfig): Record { + return { + name: pac.repository.name, + namespace: pac.repository.namespace, + providerType: pac.repository.providerType, + url: pac.repository.url, + cloneUrl: pac.repository.cloneUrl, + owner: pac.repository.owner, + repo: pac.repository.repo, + secretName: pac.repository.secretName, + concurrencyLimit: pac.repository.concurrencyLimit, + params: Object.keys(pac.repository.params).sort(), + }; +} + +function nextCommands(targetId: string): Record { + return { + apply: `bun scripts/cli.ts platform-infra pipelines-as-code apply --target ${targetId} --confirm`, + status: `bun scripts/cli.ts platform-infra pipelines-as-code status --target ${targetId}`, + webhookTest: `bun scripts/cli.ts platform-infra pipelines-as-code webhook-test --target ${targetId} --confirm`, + }; +} + +function renderPlan(result: Record): RenderedCliResult { + const target = record(result.target); + const config = record(result.config); + const repository = record(result.repository); + const secrets = arrayRecords(result.secrets); + const policy = arrayRecords(result.policy); + const lines = [ + "PLATFORM-INFRA PIPELINES-AS-CODE PLAN", + ...table(["TARGET", "NAMESPACE", "RELEASE", "REPOSITORY"], [[stringValue(target.id), stringValue(target.namespace), stringValue(record(config.release).version), stringValue(repository.name)]]), + "", + "PATH", + ...table(["STAGE", "AUTHORITY", "DETAIL"], [ + ["source", "Gitea", stringValue(repository.cloneUrl)], + ["trigger", "Pipelines-as-Code", "Gitea push webhook"], + ["ci", "Tekton", stringValue(record(config.consumer).pipeline)], + ["cd", "Argo", stringValue(record(config.consumer).argoApplication)], + ]), + "", + "SECRETS", + ...table(["ID", "PRESENT", "FINGERPRINT", "VALUES"], secrets.map((item) => [stringValue(item.id), boolText(item.present), stringValue(item.fingerprint), "false"])), + "", + "POLICY", + ...table(["NAME", "OK", "DETAIL"], policy.map((item) => [stringValue(item.name), boolText(item.ok), stringValue(item.detail)])), + "", + "NEXT", + ` apply: ${stringValue(record(result.next).apply)}`, + ` status: ${stringValue(record(result.next).status)}`, + ]; + return rendered(result, "platform-infra pipelines-as-code plan", lines); +} + +function renderApply(result: Record): RenderedCliResult { + const target = record(result.target); + const remote = record(result.remote); + const lines = [ + "PLATFORM-INFRA PIPELINES-AS-CODE APPLY", + ...table(["TARGET", "MODE", "MUTATION", "OK"], [[stringValue(target.id), stringValue(result.mode), boolText(result.mutation), boolText(result.ok)]]), + "", + "REMOTE", + ...table(["CONTROLLER", "WEBHOOK", "REPOSITORY", "VALUES"], [[stringValue(remote.controllerReady), stringValue(record(remote.webhook).present), stringValue(remote.repository), "false"]]), + "", + "NEXT", + ` status: ${stringValue(record(result.next).status)}`, + ]; + return rendered(result, "platform-infra pipelines-as-code apply", lines); +} + +function renderStatus(result: Record): RenderedCliResult { + const summary = record(result.summary); + const latest = record(summary.latestPipelineRun); + const taskRuns = arrayRecords(summary.taskRuns); + const argo = record(summary.argo); + const lines = [ + "PLATFORM-INFRA PIPELINES-AS-CODE STATUS", + ...table(["READY", "CRD", "CONTROLLER", "WEBHOOKS", "REPOSITORY"], [[boolText(summary.ready), boolText(summary.crdPresent), stringValue(summary.controllerReady), stringValue(summary.webhookCount), compactLine(stringValue(summary.repositoryCondition))]]), + "", + "LATEST PIPELINERUN", + ...table(["NAME", "STATUS", "REASON", "DURATION_S", "SOURCE"], [[stringValue(latest.name), stringValue(latest.status), stringValue(latest.reason), stringValue(latest.durationSeconds), short(stringValue(latest.sourceCommit))]]), + "", + "TASKRUN DURATIONS", + ...(taskRuns.length === 0 ? ["-"] : table(["TASKRUN", "STATUS", "REASON", "DURATION_S"], taskRuns.map((item) => [short(stringValue(item.name), 56), stringValue(item.status), stringValue(item.reason), stringValue(item.durationSeconds)]))), + "", + "ARGO", + ...table(["SYNC", "HEALTH", "REVISION"], [[stringValue(argo.sync), stringValue(argo.health), short(stringValue(argo.revision))]]), + "", + "NEXT", + ` full: bun scripts/cli.ts platform-infra pipelines-as-code status --target ${stringValue(record(result.target).id)} --full`, + ]; + return rendered(result, "platform-infra pipelines-as-code status", lines); +} + +function renderWebhookTest(result: Record): RenderedCliResult { + const remote = record(result.remote); + const lines = [ + "PLATFORM-INFRA PIPELINES-AS-CODE WEBHOOK TEST", + ...table(["OK", "MUTATION", "HOOK", "SENT"], [[boolText(result.ok), boolText(result.mutation), stringValue(remote.hookId), boolText(remote.webhookTestSent)]]), + "", + "NEXT", + ` status: ${stringValue(record(result.next).status)}`, + ]; + return rendered(result, "platform-infra pipelines-as-code webhook-test", lines); +} + +function parseApplyOptions(args: string[]): ApplyOptions { + const commonArgs: string[] = []; + let confirm = false; + let dryRun = false; + let wait = false; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === "--confirm") confirm = true; + else if (arg === "--dry-run") dryRun = true; + else if (arg === "--wait") wait = true; + else { + commonArgs.push(arg); + if (arg === "--target" || arg === "--node") { + commonArgs.push(args[index + 1] ?? ""); + index += 1; + } + } + } + if (confirm && dryRun) throw new Error("pipelines-as-code apply accepts only one of --confirm or --dry-run"); + return { ...parseCommonOptions(commonArgs), confirm, dryRun: dryRun || !confirm, wait }; +} + +function parseWebhookTestOptions(args: string[]): WebhookTestOptions { + const commonArgs: string[] = []; + let confirm = false; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === "--confirm") confirm = true; + else { + commonArgs.push(arg); + if (arg === "--target" || arg === "--node") { + commonArgs.push(args[index + 1] ?? ""); + index += 1; + } + } + } + return { ...parseCommonOptions(commonArgs), confirm }; +} + +function parseCommonOptions(args: string[]): CommonOptions { + let targetId: string | null = null; + let full = false; + let raw = false; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === "--target" || arg === "--node") { + const value = args[index + 1]; + if (value === undefined || value.startsWith("--")) throw new Error(`${arg} requires a value`); + if (!/^[A-Za-z0-9._-]+$/u.test(value)) throw new Error(`${arg} must be a simple target id`); + targetId = value; + index += 1; + } else if (arg === "--full") { + full = true; + } else if (arg === "--raw") { + raw = true; + full = true; + } else { + throw new Error(`unsupported pipelines-as-code option: ${arg}`); + } + } + return { targetId, full, raw }; +} + +function positiveInteger(obj: Record, key: string, path: string): number { + const value = y.integerField(obj, key, path); + if (value < 1) throw new Error(`${configLabel}.${path}.${key} must be positive`); + return value; +} + +function urlField(obj: Record, key: string, path: string): string { + const value = y.stringField(obj, key, path); + const parsed = new URL(value); + if (!["http:", "https:"].includes(parsed.protocol) || parsed.search || parsed.hash) throw new Error(`${configLabel}.${path}.${key} must be an http(s) URL without query or hash`); + return value.replace(/\/+$/u, ""); +} + +function stringRecord(obj: Record, path: string): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(obj)) { + if (typeof value !== "string" || value.length === 0) throw new Error(`${configLabel}.${path}.${key} must be a non-empty string`); + out[key] = value; + } + return out; +} + +function secretFingerprint(value: string): string { + return sha256Fingerprint(value).slice(0, 23); +} + +function rendered(result: Record, command: string, lines: string[]): RenderedCliResult { + return { ok: result.ok !== false, command, renderedText: lines.join("\n"), contentType: "text/plain" }; +} + +function record(value: unknown): Record { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : {}; +} + +function arrayRecords(value: unknown): Record[] { + return Array.isArray(value) ? value.filter((item) => typeof item === "object" && item !== null && !Array.isArray(item)) as Record[] : []; +} + +function stringValue(value: unknown, fallback = "-"): string { + if (typeof value === "string" && value.length > 0) return value; + if (typeof value === "number" || typeof value === "boolean") return String(value); + return fallback; +} + +function boolText(value: unknown): string { + return value === true ? "true" : "false"; +} + +function short(value: string, length = 12): string { + if (value === "-" || value.length <= length) return value; + return value.slice(0, length); +} + +function compactLine(value: string): string { + const trimmed = value.replace(/\s+/gu, " ").trim(); + return trimmed.length > 0 ? trimmed.slice(0, 120) : "-"; +} + +function table(headers: string[], rows: string[][]): string[] { + const widths = headers.map((header, index) => Math.max(header.length, ...rows.map((row) => (row[index] ?? "").length))); + const format = (row: string[]) => row.map((cell, index) => cell.padEnd(widths[index])).join(" "); + return [format(headers), format(headers.map((header, index) => "-".repeat(Math.max(header.length, widths[index])))), ...rows.map(format)]; +} diff --git a/scripts/src/platform-infra/options.ts b/scripts/src/platform-infra/options.ts index 1b4847e9..d380de56 100644 --- a/scripts/src/platform-infra/options.ts +++ b/scripts/src/platform-infra/options.ts @@ -69,6 +69,10 @@ export async function runPlatformInfraCommand(config: UniDeskConfig, args: strin const { runPlatformInfraGiteaCommand } = await import("../platform-infra-gitea"); return await runPlatformInfraGiteaCommand(config, args.slice(1)); } + if (target === "pipelines-as-code" || target === "pac") { + const { runPlatformInfraPipelinesAsCodeCommand } = await import("../platform-infra-pipelines-as-code"); + return await runPlatformInfraPipelinesAsCodeCommand(config, args.slice(1)); + } if (target !== "sub2api") return unsupported(args); if (action === "plan" || action === undefined) { const planArgs = args.slice(2);