ci: add PaC-driven AgentRun JD01 v02 pipeline
This commit is contained in:
@@ -173,6 +173,8 @@ export function renderedObjectsDigest(objects: readonly Record<string, unknown>[
|
||||
}
|
||||
|
||||
function agentRunPipelineManifest(spec: AgentRunLaneSpec): Record<string, unknown> {
|
||||
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<string, unknow
|
||||
{ name: "source-branch", type: "string", default: spec.source.branch },
|
||||
{ name: "gitops-branch", type: "string", default: spec.gitops.branch },
|
||||
{ name: "revision", type: "string" },
|
||||
{ name: "source-stage-ref", type: "string" },
|
||||
{ name: "registry-prefix", type: "string", default: spec.ci.registryPrefix },
|
||||
{ name: "tools-image", type: "string", default: spec.ci.toolsImage },
|
||||
{ name: "buildkit-image", type: "string", default: spec.ci.buildkitImage },
|
||||
{ name: "containerfile", type: "string", default: build.containerfile },
|
||||
{ name: "context-dir", type: "string", default: build.context },
|
||||
{ name: "image-repository", type: "string", default: `${spec.ci.registryPrefix}/${build.repository}` },
|
||||
{ name: "build-network", type: "string", default: build.network },
|
||||
{ name: "build-args-json", type: "string", default: JSON.stringify(Object.entries(build.buildArgs).sort(([left], [right]) => 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<string, unknown> {
|
||||
function agentRunBuildPublishTask(spec: AgentRunLaneSpec): Record<string, unknown> {
|
||||
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<string, unknown> {
|
||||
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],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 <<EOF
|
||||
apiVersion: pipelinesascode.tekton.dev/v1alpha1
|
||||
kind: Repository
|
||||
metadata:
|
||||
name: $UNIDESK_PAC_REPOSITORY_NAME
|
||||
namespace: $UNIDESK_PAC_TARGET_NAMESPACE
|
||||
labels:
|
||||
app.kubernetes.io/managed-by: unidesk
|
||||
app.kubernetes.io/part-of: agentrun
|
||||
unidesk.ai/spec: GH-1552
|
||||
spec:
|
||||
url: $UNIDESK_PAC_REPOSITORY_URL
|
||||
git_provider:
|
||||
type: gitea
|
||||
url: $UNIDESK_PAC_GITEA_BASE_URL
|
||||
secret:
|
||||
name: $UNIDESK_PAC_SECRET_NAME
|
||||
key: $UNIDESK_PAC_TOKEN_KEY
|
||||
webhook_secret:
|
||||
name: $UNIDESK_PAC_SECRET_NAME
|
||||
key: $UNIDESK_PAC_WEBHOOK_SECRET_KEY
|
||||
concurrency_limit: $UNIDESK_PAC_CONCURRENCY_LIMIT
|
||||
params:
|
||||
$(printf '%s' "$UNIDESK_PAC_PARAMS_JSON" | node -e 'const fs=require("fs"); const p=JSON.parse(fs.readFileSync(0,"utf8")); for (const [name,value] of Object.entries(p)) console.log(` - name: ${name}\n value: ${JSON.stringify(String(value))}`);')
|
||||
EOF
|
||||
}
|
||||
|
||||
apply_release() {
|
||||
tmp=$(mktemp)
|
||||
printf '%s' "$UNIDESK_PAC_RELEASE_MANIFEST_B64" | base64 -d > "$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
|
||||
@@ -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<string, string>;
|
||||
};
|
||||
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<Record<string, unknown> | 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<string, unknown> {
|
||||
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<Record<string, unknown>>(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<string, unknown>, 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<string, unknown> {
|
||||
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<Record<string, unknown>> {
|
||||
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<Record<string, unknown>> {
|
||||
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<Record<string, unknown>> {
|
||||
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<string> {
|
||||
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<string, string> = {
|
||||
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<Record<string, unknown>> {
|
||||
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<string, unknown>): Record<string, unknown> {
|
||||
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<Record<string, unknown>> {
|
||||
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<string, unknown> {
|
||||
return { id: target.id, route: target.route, namespace: target.namespace, role: target.role };
|
||||
}
|
||||
|
||||
function configSummary(pac: PacConfig): Record<string, unknown> {
|
||||
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<string, unknown> {
|
||||
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<string, unknown> {
|
||||
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<string, string> {
|
||||
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<string, unknown>): 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<string, unknown>): 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<string, unknown>): 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<string, unknown>): 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, path: string): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
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<string, unknown>, command: string, lines: string[]): RenderedCliResult {
|
||||
return { ok: result.ok !== false, command, renderedText: lines.join("\n"), contentType: "text/plain" };
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
function arrayRecords(value: unknown): Record<string, unknown>[] {
|
||||
return Array.isArray(value) ? value.filter((item) => typeof item === "object" && item !== null && !Array.isArray(item)) as Record<string, unknown>[] : [];
|
||||
}
|
||||
|
||||
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)];
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user