From 80ca187cb3f2bcb0519ec27e13183f74df29011d Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 13 Jul 2026 07:56:37 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8E=A5=E5=85=A5=20selfmedia=20?= =?UTF-8?q?=E5=8F=97=E6=8E=A7=E4=BA=A4=E4=BB=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/platform-infra/gitea.yaml | 20 + config/platform-infra/pipelines-as-code.yaml | 76 ++++ config/secrets-distribution.yaml | 43 +++ config/selfmedia.yaml | 262 +++++++++++++ scripts/cli.ts | 9 + scripts/src/platform-infra-gitea-config.ts | 11 +- ...atform-infra-gitea-gitops-delivery.test.ts | 2 + scripts/src/platform-infra-gitea-remote.sh | 11 +- scripts/src/platform-infra-gitea.ts | 5 +- .../src/platform-infra-pac-provenance.test.ts | 30 +- ...platform-infra-pipelines-as-code-remote.sh | 193 +++++++++- ...infra-pipelines-as-code-source-artifact.ts | 46 ++- .../src/platform-infra-pipelines-as-code.ts | 98 ++++- scripts/src/secrets.ts | 202 ++++++++-- scripts/src/selfmedia-config.ts | 299 +++++++++++++++ scripts/src/selfmedia-delivery-renderer.ts | 356 ++++++++++++++++++ scripts/src/selfmedia.ts | 253 +++++++++++++ 17 files changed, 1845 insertions(+), 71 deletions(-) create mode 100644 config/selfmedia.yaml create mode 100644 scripts/src/selfmedia-config.ts create mode 100644 scripts/src/selfmedia-delivery-renderer.ts create mode 100644 scripts/src/selfmedia.ts diff --git a/config/platform-infra/gitea.yaml b/config/platform-infra/gitea.yaml index 3ecbef31..8b714a14 100644 --- a/config/platform-infra/gitea.yaml +++ b/config/platform-infra/gitea.yaml @@ -332,6 +332,26 @@ sourceAuthority: readUrl: http://git-mirror-http.devops-infra.svc.cluster.local:8080/pikasTech/HWLAB.git configRef: config/hwlab-node-lanes.yaml#lanes.v03.targets.NC01.git.readUrl disposition: replaced-by-gitea + - key: selfmedia-nc01 + targetId: NC01 + upstream: + repository: pikainc/selfmedia + cloneUrl: https://github.com/pikainc/selfmedia.git + branch: master + visibility: private + gitea: + owner: mirrors + name: pikainc-selfmedia + mirrorMode: controlled-push + publicRead: false + readUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-selfmedia.git + gitops: + branch: nc01-selfmedia-gitops + flushDisposition: gitea-writeback + snapshot: + naming: gitea-actions-immutable-source + prefix: refs/unidesk/snapshots/gitea-actions/selfmedia-master-nc01 + legacyGitMirror: null targets: - id: JD01 diff --git a/config/platform-infra/pipelines-as-code.yaml b/config/platform-infra/pipelines-as-code.yaml index 4b53e015..f733b945 100644 --- a/config/platform-infra/pipelines-as-code.yaml +++ b/config/platform-infra/pipelines-as-code.yaml @@ -130,6 +130,41 @@ repositories: variables: NODE: NC01 LANE: v03 + - id: selfmedia-nc01 + name: selfmedia-nc01 + namespace: selfmedia-ci + providerType: gitea + url: https://gitea.pikapython.com/mirrors/pikainc-selfmedia + cloneUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-selfmedia.git + owner: mirrors + repo: pikainc-selfmedia + secretName: pac-gitea-selfmedia-nc01 + tokenKey: token + webhookSecretKey: webhook.secret + concurrencyLimit: 1 + params: + git_read_url: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-selfmedia.git + source_branch: master + source_snapshot_prefix: refs/unidesk/snapshots/gitea-actions/selfmedia-master-nc01 + node: NC01 + pipeline_name: selfmedia-nc01-pac + pipeline_run_prefix: selfmedia-nc01 + service_account: selfmedia-nc01-tekton-runner + pipeline_timeout: 2h0m0s + image_repository: 127.0.0.1:5000/selfmedia/newsroom + registry_probe_base: http://127.0.0.1:5000 + gitops_read_url: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-selfmedia.git + gitops_write_url: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-selfmedia.git + gitops_branch: nc01-selfmedia-gitops + gitops_username: unidesk-admin + gitops_secret_name: pac-gitea-selfmedia-nc01 + gitops_manifest_path: deploy/gitops/nc01/resources.yaml + runtime_namespace: selfmedia + runtime_deployment: selfmedia + runtime_service: selfmedia + runtime_service_port: "4317" + health_path: /healthz + health_url: http://selfmedia.selfmedia.svc.cluster.local:4317/healthz consumers: - extends: templates.consumers.agentrunV02 variables: @@ -211,6 +246,47 @@ consumers: hostNetwork: true dnsPolicy: ClusterFirstWithHostNet fsGroup: 1000 + - id: selfmedia-nc01 + repositoryRef: selfmedia-nc01 + node: NC01 + lane: selfmedia + namespace: selfmedia-ci + pipeline: selfmedia-nc01-pac + pipelineRunPrefix: selfmedia-nc01 + argoNamespace: argocd + argoApplication: selfmedia-nc01 + closeoutGitOpsMirrorFlush: false + argoBootstrap: + project: default + repoUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-selfmedia.git + targetRevision: nc01-selfmedia-gitops + path: deploy/gitops/nc01 + destinationNamespace: selfmedia + automated: true + repositoryCredential: + secretName: argocd-repo-selfmedia-nc01 + username: unidesk-admin + deliveryProvenance: + required: true + markerValue: admission-pac-v2:selfmedia-nc01 + executionServiceAccountName: selfmedia-nc01-tekton-runner + gitOps: + repoUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-selfmedia.git + targetRevision: nc01-selfmedia-gitops + runnerServiceAccount: + name: selfmedia-nc01-tekton-runner + automountServiceAccountToken: false + roleBindingName: selfmedia-nc01-tekton-runner + sourceArtifact: + mode: embedded-pipeline-spec + renderer: selfmedia-runtime + configRef: config/selfmedia.yaml#delivery.targets.NC01 + pipelineRunPath: .tekton/selfmedia-nc01-pac.yaml + maxKeepRuns: 8 + taskRunTemplate: + hostNetwork: true + dnsPolicy: ClusterFirstWithHostNet + fsGroup: 1000 templates: repositories: agentrunV02: diff --git a/config/secrets-distribution.yaml b/config/secrets-distribution.yaml index 5c5c8143..763323c0 100644 --- a/config/secrets-distribution.yaml +++ b/config/secrets-distribution.yaml @@ -119,6 +119,25 @@ sources: - DATABASE_URL createIfMissing: enabled: false + externalFiles: + - sourceRef: ~/.env/TOKEN + type: raw-file + required: true + createIfMissing: + enabled: true + randomBase64Url: + bytes: 32 + prefix: smt_ + - sourceRef: ~/.codex/config.toml.pika + type: raw-file + required: true + createIfMissing: + enabled: false + - sourceRef: ~/.codex/auth.json.pika + type: raw-file + required: true + createIfMissing: + enabled: false targets: - id: platform-infra-g14 @@ -141,8 +160,32 @@ targets: namespace: hwlab-v03 scope: hwlab enabled: true + - id: selfmedia-nc01 + route: NC01:k3s + namespace: selfmedia + scope: selfmedia + enabled: true kubernetesSecrets: + - name: selfmedia-runtime + targetId: selfmedia-nc01 + secretName: selfmedia-runtime + type: Opaque + data: + - sourceRef: ~/.env/TOKEN + sourceKey: contents + targetKey: TOKEN + - name: selfmedia-codex + targetId: selfmedia-nc01 + secretName: selfmedia-codex + type: Opaque + data: + - sourceRef: ~/.codex/config.toml.pika + sourceKey: contents + targetKey: config.toml + - sourceRef: ~/.codex/auth.json.pika + sourceKey: contents + targetKey: auth.json - name: sub2rank-runtime targetId: sub2rank-nc01 secretName: sub2rank-secrets diff --git a/config/selfmedia.yaml b/config/selfmedia.yaml new file mode 100644 index 00000000..82dacec9 --- /dev/null +++ b/config/selfmedia.yaml @@ -0,0 +1,262 @@ +version: 1 +kind: selfmedia-platform-delivery +metadata: + id: selfmedia-factory + owner: unidesk + repository: pikainc/selfmedia + description: 自媒体工厂在 NC01 的构建、GitOps、运行时与一次性切换真相。 +defaults: + targetId: NC01 +delivery: + targets: + NC01: + node: NC01 + lane: selfmedia + route: NC01:k3s + ci: + namespace: selfmedia-ci + pipeline: selfmedia-nc01-pac + pipelineRunPrefix: selfmedia-nc01 + serviceAccountName: selfmedia-nc01-tekton-runner + serviceAccountAutomount: false + roleBindingName: selfmedia-nc01-tekton-runner + workspaceSize: 16Gi + pipelineTimeout: 2h0m0s + toolImage: 127.0.0.1:5000/hwlab/hwlab-ci-node-tools:node22-alpine-bun-v1 + buildkitImage: 127.0.0.1:5000/hwlab/buildkit:rootless + source: + repository: pikainc/selfmedia + worktreeRemote: https://github.com/pikainc/selfmedia.git + branch: master + readUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-selfmedia.git + snapshotPrefix: refs/unidesk/snapshots/gitea-actions/selfmedia-master-nc01 + build: + dockerfile: deploy/Dockerfile + imageRepository: 127.0.0.1:5000/selfmedia/newsroom + networkMode: host + proxy: + http: http://127.0.0.1:10808 + https: http://127.0.0.1:10808 + all: http://127.0.0.1:10808 + noProxy: + - localhost + - 127.0.0.1 + - "::1" + - 127.0.0.1:5000 + - localhost:5000 + - .svc + - .svc.cluster.local + - .cluster.local + - hyueapi.com + - .hyueapi.com + gitops: + readUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-selfmedia.git + writeUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-selfmedia.git + branch: nc01-selfmedia-gitops + manifestPath: deploy/gitops/nc01/resources.yaml + releaseStatePath: deploy/gitops-state/nc01/release.json + credentialSecretName: pac-gitea-selfmedia-nc01 + credentialTokenKey: token + credentialUsername: unidesk-admin + author: + name: SelfMedia NC01 CI + email: selfmedia-nc01-ci@unidesk.local + deployment: + version: selfmedia.pikapython.com/v1 + kind: SelfMediaDeployment + metadata: + name: selfmedia-nc01 + owner: unidesk + target: + id: NC01 + route: NC01:k3s + namespace: selfmedia + publicHost: 152.53.229.148 + runtime: + replicas: 1 + serviceAccountName: selfmedia + imagePullPolicy: IfNotPresent + command: + - /usr/bin/tini + - -- + - bun + - scripts/selfmedia-cli.ts + - server + - foreground + service: + name: selfmedia + type: ClusterIP + port: 4317 + targetPort: 4317 + health: + path: /healthz + startupFailureThreshold: 30 + startupPeriodSeconds: 5 + readinessPeriodSeconds: 10 + livenessPeriodSeconds: 20 + resources: + requests: + cpu: 250m + memory: 768Mi + limits: + cpu: "4" + memory: 6Gi + securityContext: + runAsUser: 10001 + runAsGroup: 10001 + fsGroup: 10001 + runAsNonRoot: true + readOnlyRootFilesystem: true + allowPrivilegeEscalation: false + seccompProfile: RuntimeDefault + dropCapabilities: + - ALL + writablePaths: + tmp: + mountPath: /tmp + sizeLimit: 2Gi + codexHome: + mountPath: /home/codex/.codex + sizeLimit: 2Gi + cache: + mountPath: /home/codex/.cache + sizeLimit: 2Gi + toolCache: + mountPath: /app/.tools/cache + sizeLimit: 1Gi + publicExposure: + enabled: true + mode: hostPort-http-edge + address: http://152.53.229.148:4317 + hostPort: 4317 + edge: + name: selfmedia-public-edge + image: caddy:2.10.2-alpine + containerPort: 8080 + upstream: selfmedia.selfmedia.svc.cluster.local:4317 + resources: + requests: + cpu: 20m + memory: 32Mi + limits: + cpu: 200m + memory: 128Mi + storage: + media: + claimName: selfmedia-data + storageClassName: local-path + accessModes: + - ReadWriteOnce + size: 40Gi + mounts: + data: + mountPath: /app/data + subPath: data + state: + mountPath: /app/.state + subPath: state + logs: + mountPath: /app/logs + subPath: logs + codexSessions: + claimName: selfmedia-codex-sessions + storageClassName: local-path + accessModes: + - ReadWriteOnce + size: 5Gi + mounts: + sessions: + mountPath: /home/codex/.codex/sessions + subPath: sessions + state: + mountPath: /home/codex/.codex/state + subPath: state + secrets: + runtime: + sourceRef: ~/.env/TOKEN + target: + secretName: selfmedia-runtime + targetKey: TOKEN + mountPath: /home/codex/.env/TOKEN + codex: + sources: + - sourceRef: ~/.codex/config.toml.pika + targetKey: config.toml + - sourceRef: ~/.codex/auth.json.pika + targetKey: auth.json + target: + secretName: selfmedia-codex + files: + - targetKey: config.toml + mountPath: /home/codex/.codex/config.toml.pika + - targetKey: auth.json + mountPath: /home/codex/.codex/auth.json.pika + networkPolicy: + enabled: true + dnsNamespace: kube-system + dnsPort: 53 + blockedCidrs: + - 10.0.0.0/8 + - 172.16.0.0/12 + - 192.168.0.0/16 + - 169.254.169.254/32 + - 152.53.229.148/32 + codex: + package: "@openai/codex" + version: 0.144.1 + executable: codex + home: /home/codex + codexHome: /home/codex/.codex + sessionsPath: /home/codex/.codex/sessions + supervisorStatePath: /home/codex/.codex/state/selfmedia-supervisor.json + resumeArgs: + - resume + - --last + sourcePath: /app + skillsPath: /home/codex/.codex/skills +cutover: + targets: + NC01: + mode: host-process-to-kubernetes + source: + workspace: /root/selfmedia + publicAddress: http://152.53.229.148:4317 + healthUrl: http://127.0.0.1:4317/healthz + stopCommand: + - bun + - scripts/selfmedia-cli.ts + - server + - stop + startCommand: + - bun + - scripts/selfmedia-cli.ts + - server + - start + dataPaths: + - data + - .state + - logs + excludes: + - .state/server.json + destination: + route: NC01:k3s + namespace: selfmedia + application: selfmedia-nc01 + publicAddress: http://152.53.229.148:4317 + healthUrl: http://152.53.229.148:4317/healthz + dataClaim: selfmedia-data + codexClaim: selfmedia-codex-sessions + prepare: + requiresFinalConfirmation: true + phases: + - 校验源服务健康、Secret presence/fingerprint 与目标 PVC + - 在线种子复制并记录文件数和字节数 + - 停止旧服务、执行最终增量并确认宿主 4317 已释放 + - 允许 Argo 首次同步并验证公网健康 + rollback: + phases: + - 暂停 selfmedia-nc01 Argo 自动同步 + - 缩容 selfmedia 与 selfmedia-public-edge + - 确认宿主 4317 已释放 + - 启动旧服务并验证原公网地址 + dataPolicy: 回滚默认不反向覆盖旧数据,需单独的数据恢复决策。 diff --git a/scripts/cli.ts b/scripts/cli.ts index 7a120349..32c9102c 100644 --- a/scripts/cli.ts +++ b/scripts/cli.ts @@ -444,6 +444,15 @@ async function main(): Promise { return; } + if (top === "selfmedia") { + const { runSelfMediaCommand } = await import("./src/selfmedia"); + const result = await runSelfMediaCommand(args.slice(1)); + const ok = (result as { ok?: unknown }).ok !== false; + emitJson(commandName, result, ok); + if (!ok) process.exitCode = 1; + return; + } + if (top === "health") { const result = runHealthCommand(args.slice(1)); const ok = (result as { ok?: unknown }).ok !== false; diff --git a/scripts/src/platform-infra-gitea-config.ts b/scripts/src/platform-infra-gitea-config.ts index c3e6436a..dcdaebb3 100644 --- a/scripts/src/platform-infra-gitea-config.ts +++ b/scripts/src/platform-infra-gitea-config.ts @@ -277,6 +277,7 @@ export interface GiteaMirrorRepository { repository: string; cloneUrl: string; branch: string; + visibility: "public" | "private"; }; gitea: { owner: string; @@ -296,7 +297,7 @@ export interface GiteaMirrorRepository { readUrl: string; configRef: string; disposition: "replaced-by-gitea" | "retained-for-gitops-flush" | "migration-readonly"; - }; + } | null; } export function readGiteaConfig(): GiteaConfig { @@ -653,7 +654,9 @@ function parseMirrorRepository(record: Record, index: number): const gitea = y.objectField(record, "gitea", path); const gitops = y.objectField(record, "gitops", path); const snapshot = y.objectField(record, "snapshot", path); - const legacyGitMirror = y.objectField(record, "legacyGitMirror", path); + const legacyGitMirror = record.legacyGitMirror === null || record.legacyGitMirror === undefined + ? null + : y.objectField(record, "legacyGitMirror", path); return { key: y.stringField(record, "key", path), targetId: y.stringField(record, "targetId", path), @@ -661,6 +664,7 @@ function parseMirrorRepository(record: Record, index: number): repository: repositoryField(upstream, "repository", `${path}.upstream`), cloneUrl: gitCloneUrlField(upstream, "cloneUrl", `${path}.upstream`), branch: y.stringField(upstream, "branch", `${path}.upstream`), + visibility: upstream.visibility === undefined ? "public" : y.enumField(upstream, "visibility", `${path}.upstream`, ["public", "private"] as const), }, gitea: { owner: y.stringField(gitea, "owner", `${path}.gitea`), @@ -676,7 +680,7 @@ function parseMirrorRepository(record: Record, index: number): snapshot: { prefix: refPrefixField(snapshot, "prefix", `${path}.snapshot`), }, - legacyGitMirror: { + legacyGitMirror: legacyGitMirror === null ? null : { readUrl: urlField(legacyGitMirror, "readUrl", `${path}.legacyGitMirror`), configRef: y.stringField(legacyGitMirror, "configRef", `${path}.legacyGitMirror`), disposition: y.enumField(legacyGitMirror, "disposition", `${path}.legacyGitMirror`, ["replaced-by-gitea", "retained-for-gitops-flush", "migration-readonly"] as const), @@ -816,6 +820,7 @@ function validateConfig(gitea: GiteaConfig): void { const readUrl = new URL(repo.gitea.readUrl); if (readUrl.hostname !== `${gitea.app.serviceName}.${resolveTarget(gitea, repo.targetId).namespace}.svc.cluster.local`) throw new Error(`${configLabel}.sourceAuthority.repositories.${repo.key}.gitea.readUrl must use the internal k3s Gitea Service DNS`); if (readUrl.hostname === new URL(gitea.app.publicExposure.publicBaseUrl).hostname) throw new Error(`${configLabel}.sourceAuthority.repositories.${repo.key}.gitea.readUrl must not use the public Web UI hostname`); + if (repo.upstream.visibility === "private" && repo.gitea.publicRead) throw new Error(`${configLabel}.sourceAuthority.repositories.${repo.key}.gitea.publicRead must be false for a private upstream`); } } diff --git a/scripts/src/platform-infra-gitea-gitops-delivery.test.ts b/scripts/src/platform-infra-gitea-gitops-delivery.test.ts index 0d58a6bc..6f60e1b6 100644 --- a/scripts/src/platform-infra-gitea-gitops-delivery.test.ts +++ b/scripts/src/platform-infra-gitea-gitops-delivery.test.ts @@ -78,6 +78,8 @@ test("owning YAML renders one child Application and the complete durable bridge "Deployment", "Role", "RoleBinding", + "Role", + "RoleBinding", "ValidatingAdmissionPolicy", "ValidatingAdmissionPolicyBinding", "ServiceAccount", diff --git a/scripts/src/platform-infra-gitea-remote.sh b/scripts/src/platform-infra-gitea-remote.sh index b5ba9dec..075a81f6 100644 --- a/scripts/src/platform-infra-gitea-remote.sh +++ b/scripts/src/platform-infra-gitea-remote.sh @@ -515,7 +515,8 @@ for i, repo in enumerate(repos): key = repo["key"] source_branch = repo["upstream"]["branch"] gitops_branch = repo["gitops"]["branch"] - branches = [source_branch] + ([] if gitops_branch == source_branch or gitops_branch == "not-a-gitops-branch" else [gitops_branch]) + sync_gitops_from_upstream = repo["gitops"].get("flushDisposition") != "gitea-writeback" + branches = [source_branch] + ([] if not sync_gitops_from_upstream or gitops_branch == source_branch or gitops_branch == "not-a-gitops-branch" else [gitops_branch]) work = f"$tmp/repo-{i}.git" gitea_write_url = base_url + urllib.parse.urlparse(repo["gitea"]["readUrl"]).path script += [ @@ -525,13 +526,14 @@ for i, repo in enumerate(repos): f"if [ \"$fetch_rc\" -eq 0 ]; then sha=$(git -C {shlex.quote(work)} rev-parse refs/remotes/github/{shlex.quote(source_branch)} 2>$tmp/{key}.revparse.err); revparse_rc=$?; printf '%s\\n' \"$sha\" >$tmp/{key}.revparse.out; else sha=''; revparse_rc=1; : >$tmp/{key}.revparse.out; printf '%s\\n' 'fetch failed; revparse skipped' >$tmp/{key}.revparse.err; fi; printf '%s' \"$revparse_rc\" >$tmp/{key}.revparse.rc", f"if [ \"$revparse_rc\" -eq 0 ]; then snapshot_ref={shlex.quote(repo['snapshot']['prefix'])}/$sha; git -C {shlex.quote(work)} -c http.extraHeader=\"$GITEA_AUTH_HEADER\" push --force {shlex.quote(gitea_write_url)} $sha:refs/heads/{shlex.quote(source_branch)} $sha:$snapshot_ref >$tmp/{key}.push.out 2>$tmp/{key}.push.err; push_rc=$?; else snapshot_ref=''; push_rc=1; : >$tmp/{key}.push.out; printf '%s\\n' 'revparse failed; push skipped' >$tmp/{key}.push.err; fi; printf '%s' \"$push_rc\" >$tmp/{key}.push.rc", ] - if gitops_branch != source_branch and gitops_branch != "not-a-gitops-branch": + if sync_gitops_from_upstream and gitops_branch != source_branch and gitops_branch != "not-a-gitops-branch": script.append(f"if [ \"$fetch_rc\" -eq 0 ]; then gitops_sha=$(git -C {shlex.quote(work)} rev-parse refs/remotes/github/{shlex.quote(gitops_branch)} 2>/dev/null || true); else gitops_sha=''; fi") script.append(f"if [ -n \"$gitops_sha\" ] && [ \"$push_rc\" -eq 0 ]; then git -C {shlex.quote(work)} -c http.extraHeader=\"$GITEA_AUTH_HEADER\" push --force {shlex.quote(gitea_write_url)} $gitops_sha:refs/heads/{shlex.quote(gitops_branch)} >>$tmp/{key}.push.out 2>>$tmp/{key}.push.err; gitops_push_rc=$?; else gitops_push_rc=0; fi; printf '%s' \"$gitops_push_rc\" >$tmp/{key}.gitops-push.rc") else: script.append(f"printf '%s' '0' >$tmp/{key}.gitops-push.rc") script.append(f"if [ \"$push_rc\" -eq 0 ]; then printf '%s %s\\n' \"$sha\" \"$snapshot_ref\" >$tmp/{key}.bundle; fi") - meta.append({"key": key, "sourceBranch": source_branch, "gitopsBranch": gitops_branch, "readUrl": repo["gitea"]["readUrl"], "writeUrlHost": urllib.parse.urlparse(gitea_write_url).netloc, "snapshotPrefix": repo["snapshot"]["prefix"], "legacyReadUrl": repo["legacyGitMirror"]["readUrl"]}) + legacy = repo.get("legacyGitMirror") or {} + meta.append({"key": key, "sourceBranch": source_branch, "gitopsBranch": gitops_branch, "gitopsUpstreamSync": sync_gitops_from_upstream, "readUrl": repo["gitea"]["readUrl"], "writeUrlHost": urllib.parse.urlparse(gitea_write_url).netloc, "snapshotPrefix": repo["snapshot"]["prefix"], "legacyReadUrl": legacy.get("readUrl"), "legacyDisposition": legacy.get("disposition", "native-gitea-only")}) open(sys.argv[2], "w", encoding="utf-8").write("\n".join(script) + "\n") json.dump(meta, open(sys.argv[3], "w", encoding="utf-8"), ensure_ascii=False, indent=2) PY @@ -621,7 +623,8 @@ for repo in repos: gitops = repo["gitops"]["branch"] gitea_probe_url = base_url + urllib.parse.urlparse(repo["gitea"]["readUrl"]).path script.append(f"git -c http.extraHeader=\"$GITEA_AUTH_HEADER\" ls-remote {shlex.quote(gitea_probe_url)} refs/heads/{shlex.quote(branch)} '{shlex.quote(repo['snapshot']['prefix'])}/*' refs/heads/{shlex.quote(gitops)} >$tmp/{key}.ls.out 2>$tmp/{key}.ls.err") - meta.append({"key": key, "branch": branch, "gitopsBranch": gitops, "readUrl": repo["gitea"]["readUrl"], "probeUrlHost": urllib.parse.urlparse(gitea_probe_url).netloc, "snapshotPrefix": repo["snapshot"]["prefix"], "legacyReadUrl": repo["legacyGitMirror"]["readUrl"], "legacyDisposition": repo["legacyGitMirror"]["disposition"]}) + legacy = repo.get("legacyGitMirror") or {} + meta.append({"key": key, "branch": branch, "gitopsBranch": gitops, "readUrl": repo["gitea"]["readUrl"], "probeUrlHost": urllib.parse.urlparse(gitea_probe_url).netloc, "snapshotPrefix": repo["snapshot"]["prefix"], "legacyReadUrl": legacy.get("readUrl"), "legacyDisposition": legacy.get("disposition", "native-gitea-only")}) open(sys.argv[2], "w", encoding="utf-8").write("\n".join(script) + "\n") json.dump(meta, open(sys.argv[3], "w", encoding="utf-8"), ensure_ascii=False) PY diff --git a/scripts/src/platform-infra-gitea.ts b/scripts/src/platform-infra-gitea.ts index ab5468ca..20b7c759 100644 --- a/scripts/src/platform-infra-gitea.ts +++ b/scripts/src/platform-infra-gitea.ts @@ -1483,13 +1483,14 @@ function repositorySummary(gitea: GiteaConfig, repo: GiteaMirrorRepository): Rec key: repo.key, upstreamRepository: repo.upstream.repository, upstreamBranch: repo.upstream.branch, + upstreamVisibility: repo.upstream.visibility, giteaOwner: repo.gitea.owner, giteaRepo: repo.gitea.name, mirrorMode: repo.gitea.mirrorMode, readUrl: repo.gitea.readUrl, snapshotPrefix: repo.snapshot.prefix, - legacyReadUrl: repo.legacyGitMirror.readUrl, - legacyDisposition: repo.legacyGitMirror.disposition, + legacyReadUrl: repo.legacyGitMirror?.readUrl ?? null, + legacyDisposition: repo.legacyGitMirror?.disposition ?? "native-gitea-only", sourceAuthority: "gitea", rootUrlMatches: repo.gitea.readUrl.startsWith(gitea.app.server.rootUrl), }; diff --git a/scripts/src/platform-infra-pac-provenance.test.ts b/scripts/src/platform-infra-pac-provenance.test.ts index e85d7ab4..7159f25c 100644 --- a/scripts/src/platform-infra-pac-provenance.test.ts +++ b/scripts/src/platform-infra-pac-provenance.test.ts @@ -59,7 +59,7 @@ test("versioned admission desired state protects controller proof, candidate ide toState: "started", }]); expect(contract.child.enabled).toBe(false); - expect(contract.consumers.map((item) => item.id)).toEqual(["agentrun-nc01-v02"]); + expect(contract.consumers.map((item) => item.id)).toEqual(["agentrun-nc01-v02", "selfmedia-nc01"]); const items = documents(renderPacAdmissionDesiredFragment("NC01")); expect(items.map((item) => item.kind)).toEqual(["ValidatingAdmissionPolicy", "ValidatingAdmissionPolicyBinding"]); const policy = items[0]; @@ -79,6 +79,7 @@ test("versioned admission desired state protects controller proof, candidate ide expect(expressions).toContain("object.metadata.name.startsWith"); expect(expressions).toContain('object.metadata.labels["tekton.dev/pipeline"]'); expect(expressions).toContain("agentrun-nc01-v02-tekton-runner"); + expect(expressions).toContain("selfmedia-nc01-tekton-runner"); expect(messages).toContain("execution ServiceAccount"); expect(expressions).toContain(contract.child.parentUidAnnotation); expect(expressions).toContain("oldObject"); @@ -117,16 +118,20 @@ test("versioned admission desired state protects controller proof, candidate ide expect(queueGuard.expression).not.toContain('== "Cancelled"'); }); -test("required consumer default RBAC has one automatic desired owner and no Tekton mutation verbs", () => { +test("required consumer default RBAC has one automatic desired owner per namespace and no Tekton mutation verbs", () => { const rbac = documents(renderPacConsumerRbacDesiredFragment("NC01")); - expect(rbac.map((item) => item.kind)).toEqual(["Role", "RoleBinding"]); - const role = rbac[0]; - expect(role.metadata.annotations["argocd.argoproj.io/sync-wave"]).toBe("-1"); - expect(role.metadata.annotations["unidesk.ai/effective-config-sha256"]).toBe(pacConsumerRbacDesiredIdentity("NC01").configSha256); - const verbs = role.rules.flatMap((rule: any) => rule.verbs); - for (const verb of ["create", "patch", "update", "delete", "deletecollection"]) expect(verbs).not.toContain(verb); + expect(rbac.map((item) => item.kind)).toEqual(["Role", "RoleBinding", "Role", "RoleBinding"]); + expect(rbac.filter((item) => item.kind === "Role").map((item) => item.metadata.namespace)).toEqual(["agentrun-ci", "selfmedia-ci"]); + for (const role of rbac.filter((item) => item.kind === "Role")) { + expect(role.metadata.annotations["argocd.argoproj.io/sync-wave"]).toBe("-1"); + expect(role.metadata.annotations["unidesk.ai/effective-config-sha256"]).toBe(pacConsumerRbacDesiredIdentity("NC01").configSha256); + const verbs = role.rules.flatMap((rule: any) => rule.verbs); + for (const verb of ["create", "patch", "update", "delete", "deletecollection"]) expect(verbs).not.toContain(verb); + } const desiredKinds = documents(renderPlatformInfraGiteaDesiredFragments("NC01")).map((item) => item.kind); expect(desiredKinds).toEqual([ + "Role", + "RoleBinding", "Role", "RoleBinding", "ValidatingAdmissionPolicy", @@ -348,6 +353,15 @@ test("history evaluates admission and default RBAC in each consumer namespace", expect(remote).toContain("'delivery plan source commit does not match the selected PipelineRun'"); expect(remote).not.toContain("'g14-ci-plan structured evidence is missing'"); expect(remote).not.toContain("for (const consumer of consumers) consumer.admissionState = admissionState"); + expect(remote).toContain("consumer_bootstrap_state()"); + expect(remote).toContain('get serviceaccount "$UNIDESK_PAC_RUNNER_SERVICE_ACCOUNT_NAME"'); + expect(remote).toContain('get rolebinding "$UNIDESK_PAC_RUNNER_ROLE_BINDING_NAME"'); + expect(remote).toContain('get secret "$UNIDESK_PAC_ARGO_REPOSITORY_SECRET_NAME"'); + expect(remote).toContain("sa.automountServiceAccountToken === false"); + expect(remote).toContain("const expectedKeys = ['password', 'url', 'username']"); + expect(remote).toContain("passwordDecoded: false"); + expect(remote).not.toContain("Buffer.from(argoSecret.data.password"); + expect(remote).not.toContain("--from-literal=password="); }); test("id-specific PaC debug keeps fixture failures but does not dump every passing check", () => { diff --git a/scripts/src/platform-infra-pipelines-as-code-remote.sh b/scripts/src/platform-infra-pipelines-as-code-remote.sh index 30d88de2..d9818316 100644 --- a/scripts/src/platform-infra-pipelines-as-code-remote.sh +++ b/scripts/src/platform-infra-pipelines-as-code-remote.sh @@ -202,6 +202,50 @@ roleRef: EOF } +pac_repository_secret_manifest() { + node -e ' +const fs = require("node:fs"); +const input = fs.readFileSync(0); +const split = input.indexOf(0); +if (split < 0) process.exit(2); +const token = input.subarray(0, split); +const webhook = input.subarray(split + 1); +const data = {}; +data[process.env.UNIDESK_PAC_TOKEN_KEY] = token.toString("base64"); +data[process.env.UNIDESK_PAC_WEBHOOK_SECRET_KEY] = webhook.toString("base64"); +process.stdout.write(JSON.stringify({ + apiVersion: "v1", + kind: "Secret", + metadata: { name: process.env.UNIDESK_PAC_SECRET_NAME, namespace: process.env.UNIDESK_PAC_TARGET_NAMESPACE }, + type: "Opaque", + data, +})); +' +} + +argo_repository_secret_manifest() { + node -e ' +const fs = require("node:fs"); +const token = fs.readFileSync(0); +const encoded = (value) => Buffer.from(value, "utf8").toString("base64"); +process.stdout.write(JSON.stringify({ + apiVersion: "v1", + kind: "Secret", + metadata: { + name: process.env.UNIDESK_PAC_ARGO_REPOSITORY_SECRET_NAME, + namespace: process.env.UNIDESK_PAC_ARGO_NAMESPACE, + labels: { "argocd.argoproj.io/secret-type": "repository" }, + }, + type: "Opaque", + data: { + url: encoded(process.env.UNIDESK_PAC_ARGO_REPOSITORY_URL || ""), + username: encoded(process.env.UNIDESK_PAC_ARGO_REPOSITORY_USERNAME || ""), + password: token.toString("base64"), + }, +})); +' +} + apply_release() { tmp=$(mktemp) printf '%s' "$UNIDESK_PAC_RELEASE_MANIFEST_B64" | base64 -d > "$tmp" @@ -238,15 +282,25 @@ apply_action() { else consumer_rbac_manifest | kubectl apply --server-side --force-conflicts --field-manager="$UNIDESK_PAC_FIELD_MANAGER" -f - >/dev/null fi + if [ -n "${UNIDESK_PAC_RUNNER_SERVICE_ACCOUNT_MANIFEST_B64:-}" ]; then + runner_tmp=$(mktemp) + printf '%s' "$UNIDESK_PAC_RUNNER_SERVICE_ACCOUNT_MANIFEST_B64" | base64 -d >"$runner_tmp" + kubectl apply --server-side --force-conflicts --field-manager="$UNIDESK_PAC_FIELD_MANAGER-runner-bootstrap" -f "$runner_tmp" >/dev/null + rm -f "$runner_tmp" + fi 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 + { printf '%s' "$token"; printf '\0'; printf '%s' "$UNIDESK_PAC_WEBHOOK_SECRET"; } \ + | pac_repository_secret_manifest \ + | kubectl apply --server-side --force-conflicts --field-manager="$UNIDESK_PAC_FIELD_MANAGER-repository-secret" -f - >/dev/null repository_manifest | kubectl apply --server-side --force-conflicts --field-manager="$UNIDESK_PAC_FIELD_MANAGER" -f - >/dev/null argo_bootstrap=false if [ -n "${UNIDESK_PAC_ARGO_BOOTSTRAP_MANIFEST_B64:-}" ]; then + if [ -n "${UNIDESK_PAC_ARGO_REPOSITORY_SECRET_NAME:-}" ]; then + printf '%s' "$token" \ + | argo_repository_secret_manifest \ + | kubectl apply --server-side --force-conflicts --field-manager="$UNIDESK_PAC_FIELD_MANAGER-argocd-repository" -f - >/dev/null + fi argo_tmp=$(mktemp) printf '%s' "$UNIDESK_PAC_ARGO_BOOTSTRAP_MANIFEST_B64" | base64 -d >"$argo_tmp" kubectl apply --server-side --force-conflicts --field-manager="$UNIDESK_PAC_FIELD_MANAGER" -f "$argo_tmp" >/dev/null @@ -1287,11 +1341,121 @@ process.stdout.write(JSON.stringify(rows)); NODE } +consumer_bootstrap_state() { + if [ -z "${UNIDESK_PAC_RUNNER_SERVICE_ACCOUNT_NAME:-}" ] && [ -z "${UNIDESK_PAC_ARGO_REPOSITORY_SECRET_NAME:-}" ]; then + printf '{"configured":false,"ready":null,"reasons":["consumer-bootstrap-not-declared"],"valuesPrinted":false}' + return + fi + sa_file=$(mktemp) + role_binding_file=$(mktemp) + argo_secret_file=$(mktemp) + if [ -n "${UNIDESK_PAC_RUNNER_SERVICE_ACCOUNT_NAME:-}" ]; then + kubectl -n "$UNIDESK_PAC_TARGET_NAMESPACE" get serviceaccount "$UNIDESK_PAC_RUNNER_SERVICE_ACCOUNT_NAME" -o json >"$sa_file" 2>/dev/null || printf '{}' >"$sa_file" + kubectl -n "$UNIDESK_PAC_TARGET_NAMESPACE" get rolebinding "$UNIDESK_PAC_RUNNER_ROLE_BINDING_NAME" -o json >"$role_binding_file" 2>/dev/null || printf '{}' >"$role_binding_file" + else + printf '{}' >"$sa_file" + printf '{}' >"$role_binding_file" + fi + if [ -n "${UNIDESK_PAC_ARGO_REPOSITORY_SECRET_NAME:-}" ]; then + kubectl -n "$UNIDESK_PAC_ARGO_NAMESPACE" get secret "$UNIDESK_PAC_ARGO_REPOSITORY_SECRET_NAME" -o json >"$argo_secret_file" 2>/dev/null || printf '{}' >"$argo_secret_file" + else + printf '{}' >"$argo_secret_file" + fi + node - "$sa_file" "$role_binding_file" "$argo_secret_file" <<'NODE' +const crypto = require('node:crypto'); +const fs = require('node:fs'); +function read(path) { + try { return JSON.parse(fs.readFileSync(path, 'utf8') || '{}'); } catch { return {}; } +} +function fingerprint(value) { + return `sha256:${crypto.createHash('sha256').update(value).digest('hex')}`; +} +const sa = read(process.argv[2]); +const roleBinding = read(process.argv[3]); +const argoSecret = read(process.argv[4]); +const namespace = process.env.UNIDESK_PAC_TARGET_NAMESPACE || ''; +const runnerName = process.env.UNIDESK_PAC_RUNNER_SERVICE_ACCOUNT_NAME || ''; +const roleBindingName = process.env.UNIDESK_PAC_RUNNER_ROLE_BINDING_NAME || ''; +const argoNamespace = process.env.UNIDESK_PAC_ARGO_NAMESPACE || ''; +const argoSecretName = process.env.UNIDESK_PAC_ARGO_REPOSITORY_SECRET_NAME || ''; +const argoUrl = process.env.UNIDESK_PAC_ARGO_REPOSITORY_URL || ''; +const reasons = []; +const runnerConfigured = runnerName.length > 0; +const saExists = !runnerConfigured || sa.metadata?.name === runnerName; +const automountDisabled = !runnerConfigured || sa.automountServiceAccountToken === false; +const subjects = Array.isArray(roleBinding.subjects) ? roleBinding.subjects : []; +const roleBindingExact = !runnerConfigured || ( + roleBinding.metadata?.name === roleBindingName + && roleBinding.metadata?.namespace === namespace + && subjects.length === 1 + && subjects[0]?.kind === 'ServiceAccount' + && subjects[0]?.name === runnerName + && subjects[0]?.namespace === namespace + && roleBinding.roleRef?.apiGroup === 'rbac.authorization.k8s.io' + && roleBinding.roleRef?.kind === 'Role' + && roleBinding.roleRef?.name === 'unidesk-pac-consumer-runner' +); +if (!saExists) reasons.push('runner-service-account-missing'); +if (!automountDisabled) reasons.push('runner-service-account-automount-not-false'); +if (!roleBindingExact) reasons.push('runner-rolebinding-drifted-or-missing'); +const argoConfigured = argoSecretName.length > 0; +const argoExists = !argoConfigured || (argoSecret.metadata?.name === argoSecretName && argoSecret.metadata?.namespace === argoNamespace); +const argoLabelReady = !argoConfigured || argoSecret.metadata?.labels?.['argocd.argoproj.io/secret-type'] === 'repository'; +const keys = Object.keys(argoSecret.data || {}).sort(); +const expectedKeys = ['password', 'url', 'username']; +const keysReady = !argoConfigured || JSON.stringify(keys) === JSON.stringify(expectedKeys); +let observedUrlFingerprint = null; +if (argoConfigured && typeof argoSecret.data?.url === 'string') { + try { observedUrlFingerprint = fingerprint(Buffer.from(argoSecret.data.url, 'base64').toString('utf8')); } catch {} +} +const expectedUrlFingerprint = argoConfigured ? fingerprint(argoUrl) : null; +const urlReady = !argoConfigured || observedUrlFingerprint === expectedUrlFingerprint; +const passwordPresent = !argoConfigured || (typeof argoSecret.data?.password === 'string' && argoSecret.data.password.length > 0); +if (!argoExists) reasons.push('argocd-repository-secret-missing'); +if (!argoLabelReady) reasons.push('argocd-repository-secret-label-drifted'); +if (!keysReady) reasons.push('argocd-repository-secret-keys-drifted'); +if (!urlReady) reasons.push('argocd-repository-secret-url-drifted'); +if (!passwordPresent) reasons.push('argocd-repository-secret-password-missing'); +process.stdout.write(JSON.stringify({ + configured: runnerConfigured || argoConfigured, + ready: reasons.length === 0, + runner: { + configured: runnerConfigured, + serviceAccount: runnerName || null, + exists: saExists, + automountServiceAccountToken: sa.metadata?.name === runnerName ? sa.automountServiceAccountToken ?? null : null, + automountDisabled, + roleBinding: roleBindingName || null, + roleBindingExact, + }, + argoRepository: { + configured: argoConfigured, + secretName: argoSecretName || null, + exists: argoExists, + labelReady: argoLabelReady, + expectedKeys, + observedKeys: keys, + keysReady, + expectedUrlFingerprint, + observedUrlFingerprint, + urlReady, + passwordPresent, + passwordDecoded: false, + }, + reasons, + valuesPrinted: false, +})); +NODE + rm -f "$sa_file" "$role_binding_file" "$argo_secret_file" +} + 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") prepare_admission_provenance_state + consumer_bootstrap=$(consumer_bootstrap_state) + bootstrap_ready=$(UNIDESK_PAC_BOOTSTRAP_STATE="$consumer_bootstrap" node -e 'const s=JSON.parse(process.env.UNIDESK_PAC_BOOTSTRAP_STATE||"{}"); process.stdout.write(s.configured===true&&s.ready!==true?"false":"true")') 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" @@ -1316,9 +1480,10 @@ NODE runtime=$(json_normalize "$(runtime_summary)") diagnostics=$(cicd_diagnostics "$pipelines" "$artifact" "$argo" "$runtime") admission_ready=$(UNIDESK_PAC_STATE="$UNIDESK_PAC_ADMISSION_STATE_JSON" node -e 'const s=JSON.parse(process.env.UNIDESK_PAC_STATE||"{}"); process.stdout.write(s.ready===true?"true":"false")') - diagnostics=$(UNIDESK_PAC_DIAGNOSTICS="$diagnostics" UNIDESK_PAC_STATE="$UNIDESK_PAC_ADMISSION_STATE_JSON" node <<'NODE' + diagnostics=$(UNIDESK_PAC_DIAGNOSTICS="$diagnostics" UNIDESK_PAC_STATE="$UNIDESK_PAC_ADMISSION_STATE_JSON" UNIDESK_PAC_BOOTSTRAP_STATE="$consumer_bootstrap" node <<'NODE' const diagnostics = JSON.parse(process.env.UNIDESK_PAC_DIAGNOSTICS || '{}'); const admissionProvenance = JSON.parse(process.env.UNIDESK_PAC_STATE || '{}'); +const consumerBootstrap = JSON.parse(process.env.UNIDESK_PAC_BOOTSTRAP_STATE || '{}'); if (process.env.UNIDESK_PAC_DELIVERY_PROVENANCE_REQUIRED === '1' && admissionProvenance.ready !== true) { process.stdout.write(JSON.stringify({ ...diagnostics, @@ -1329,16 +1494,28 @@ if (process.env.UNIDESK_PAC_DELIVERY_PROVENANCE_REQUIRED === '1' && admissionPro admissionProvenance, valuesPrinted: false, })); +} else if (consumerBootstrap.configured === true && consumerBootstrap.ready !== true) { + process.stdout.write(JSON.stringify({ + ...diagnostics, + ok: false, + code: 'pac-consumer-bootstrap-not-ready', + phase: 'consumer-bootstrap-not-ready', + hint: 'declared runner ServiceAccount/RoleBinding or Argo repository credential is missing or drifted', + admissionProvenance, + consumerBootstrap, + valuesPrinted: false, + })); } else { - process.stdout.write(JSON.stringify({ ...diagnostics, admissionProvenance, valuesPrinted: false })); + process.stdout.write(JSON.stringify({ ...diagnostics, admissionProvenance, consumerBootstrap, valuesPrinted: false })); } NODE ) - printf '{"ok":%s,"crdPresent":%s,"controllerReady":"%s","admissionProvenance":%s,"repository":{"name":"%s","repo":"%s/%s","url":"%s","condition":"%s"},"repositoryCondition":"%s","webhooks":%s,"pipelineRuns":%s,"taskRuns":%s,"artifact":%s,"argo":%s,"runtime":%s,"diagnostics":%s,"valuesPrinted":false}\n' \ - "$( [ -n "$crd" ] && [ "$controller_ready" != "0/0" ] && { [ "${UNIDESK_PAC_DELIVERY_PROVENANCE_REQUIRED:-0}" != "1" ] || [ "$admission_ready" = "true" ]; } && echo true || echo false )" \ + printf '{"ok":%s,"crdPresent":%s,"controllerReady":"%s","admissionProvenance":%s,"consumerBootstrap":%s,"repository":{"name":"%s","repo":"%s/%s","url":"%s","condition":"%s"},"repositoryCondition":"%s","webhooks":%s,"pipelineRuns":%s,"taskRuns":%s,"artifact":%s,"argo":%s,"runtime":%s,"diagnostics":%s,"valuesPrinted":false}\n' \ + "$( [ -n "$crd" ] && [ "$controller_ready" != "0/0" ] && [ "$bootstrap_ready" = "true" ] && { [ "${UNIDESK_PAC_DELIVERY_PROVENANCE_REQUIRED:-0}" != "1" ] || [ "$admission_ready" = "true" ]; } && echo true || echo false )" \ "$( [ -n "$crd" ] && echo true || echo false )" \ "$(json_string "$controller_ready")" \ "$UNIDESK_PAC_ADMISSION_STATE_JSON" \ + "$consumer_bootstrap" \ "$(json_string "$UNIDESK_PAC_REPOSITORY_NAME")" \ "$(json_string "$UNIDESK_PAC_GITEA_OWNER")" \ "$(json_string "$UNIDESK_PAC_GITEA_REPO")" \ diff --git a/scripts/src/platform-infra-pipelines-as-code-source-artifact.ts b/scripts/src/platform-infra-pipelines-as-code-source-artifact.ts index 344a1ea9..a72e8764 100644 --- a/scripts/src/platform-infra-pipelines-as-code-source-artifact.ts +++ b/scripts/src/platform-infra-pipelines-as-code-source-artifact.ts @@ -17,9 +17,10 @@ import type { RenderedCliResult } from "./output"; import { renderedCliResult } from "./agentrun/render"; import { stableJsonSha256, stableJsonValue } from "./stable-json"; import { pipelineProvenanceAnnotations, pipelineProvenanceFromManifest, withPipelineProvenanceAnnotations } from "./pipeline-provenance"; +import { renderSelfMediaDesiredPipeline, selfMediaSourceWorktreeRemote } from "./selfmedia-delivery-renderer"; export type PacSourceArtifactMode = "embedded-pipeline-spec" | "remote-pipeline-annotation"; -export type PacSourceArtifactRenderer = "agentrun-control-plane" | "hwlab-runtime-lane"; +export type PacSourceArtifactRenderer = "agentrun-control-plane" | "hwlab-runtime-lane" | "selfmedia-runtime"; export type PacSourceArtifactAction = "plan" | "check" | "write" | "status" | "verify-runtime"; export type PacSourceArtifactRuntimeAlignment = "not-requested" | "aligned" | "drifted" | "missing" | "unavailable"; @@ -441,7 +442,9 @@ function renderDesiredArtifact(binding: PacSourceArtifactBinding, sourceWorktree const sourceArtifact = binding.consumer.sourceArtifact; const rendered = sourceArtifact.renderer === "agentrun-control-plane" ? renderAgentRunDesiredPipeline(binding) - : renderHwlabDesiredPipeline(binding, sourceWorktree); + : sourceArtifact.renderer === "hwlab-runtime-lane" + ? renderHwlabDesiredPipeline(binding, sourceWorktree) + : renderSelfMediaPipeline(binding, sourceWorktree); const pipeline = sourceArtifact.renderer === "hwlab-runtime-lane" ? rendered.pipeline : withPipelineProvenanceAnnotations(rendered.pipeline, rendered.provenance); @@ -512,6 +515,19 @@ function renderHwlabDesiredPipeline(binding: PacSourceArtifactBinding, sourceWor }; } +function renderSelfMediaPipeline(binding: PacSourceArtifactBinding, sourceWorktree: string): { pipeline: Record; provenance: PacSourceArtifactProvenance } { + const rendered = renderSelfMediaDesiredPipeline(binding, sourceWorktree); + return { + pipeline: rendered.pipeline, + provenance: { + configRef: rendered.configRef, + effectiveConfigSha256: rendered.effectiveConfigSha256, + renderer: "selfmedia-runtime", + mode: "embedded-pipeline-spec", + }, + }; +} + function renderHwlabPipelineFromOwningYaml(spec: HwlabRuntimeLaneSpec, sourceWorktree: string): Record { const temporaryRoot = mkdtempSync(resolve(tmpdir(), "unidesk-pac-source-artifact-")); const temporarySource = resolve(temporaryRoot, "source"); @@ -581,9 +597,10 @@ function embeddedPipelineRun(binding: PacSourceArtifactBinding, desiredSpec: Rec name: `${binding.consumer.pipelineRunPrefix}-{{ revision }}`, namespace: binding.consumer.namespace, annotations: pipelineRunAnnotations(binding, provenance), - labels: pipelineRunLabels(binding, "agentrun"), + labels: pipelineRunLabels(binding, provenance.renderer === "selfmedia-runtime" ? "selfmedia" : "agentrun"), }, spec: { + ...(binding.repository.params.pipeline_timeout === undefined ? {} : { timeouts: { pipeline: binding.repository.params.pipeline_timeout } }), pipelineSpec: desiredSpec, taskRunTemplate: taskRunTemplate(binding), params: pipelineRunParams(binding, desiredSpec), @@ -647,16 +664,26 @@ function pipelineRunAnnotations(binding: PacSourceArtifactBinding, provenance: P }; } -function pipelineRunLabels(binding: PacSourceArtifactBinding, partOf: "agentrun" | "hwlab"): Record { - return partOf === "agentrun" - ? { +function pipelineRunLabels(binding: PacSourceArtifactBinding, partOf: "agentrun" | "hwlab" | "selfmedia"): Record { + if (partOf === "agentrun") { + return { "app.kubernetes.io/part-of": "agentrun", "agentrun.pikastech.local/lane": "v0.2", "agentrun.pikastech.local/node": binding.consumer.node, "agentrun.pikastech.local/source-commit": "{{ revision }}", "agentrun.pikastech.local/trigger": "pipelines-as-code", - } - : { + }; + } + if (partOf === "selfmedia") { + return { + "app.kubernetes.io/name": `${binding.consumer.id}-pac`, + "app.kubernetes.io/part-of": "selfmedia-factory", + "unidesk.ai/source-commit": "{{ revision }}", + "selfmedia.pikapython.com/source-commit": "{{ revision }}", + "selfmedia.pikapython.com/trigger": "pipelines-as-code", + }; + } + return { "app.kubernetes.io/name": `${binding.consumer.id}-pac`, "app.kubernetes.io/part-of": "hwlab", "unidesk.ai/source-commit": "{{ revision }}", @@ -808,6 +835,7 @@ function owningSourceRemote(binding: PacSourceArtifactBinding): string { if (binding.consumer.sourceArtifact.renderer === "agentrun-control-plane") { return resolveAgentRunLaneTarget({ node: binding.consumer.node, lane: binding.consumer.lane }).spec.source.worktreeRemote; } + if (binding.consumer.sourceArtifact.renderer === "selfmedia-runtime") return selfMediaSourceWorktreeRemote(binding.consumer.node); if (!isHwlabRuntimeLane(binding.consumer.lane)) throw new PacSourceArtifactError("owning-source-lane-unresolved"); return hwlabRuntimeLaneSpecForNode(binding.consumer.lane, binding.consumer.node).gitUrl; } @@ -923,7 +951,7 @@ function assertPipelineIdentity(pipeline: Record, binding: PacS function provenanceFromManifest(manifest: Record): PacSourceArtifactProvenance | null { const provenance = pipelineProvenanceFromManifest(manifest); if (provenance === null) return null; - if (provenance.renderer !== "agentrun-control-plane" && provenance.renderer !== "hwlab-runtime-lane") return null; + if (provenance.renderer !== "agentrun-control-plane" && provenance.renderer !== "hwlab-runtime-lane" && provenance.renderer !== "selfmedia-runtime") return null; if (provenance.mode !== "embedded-pipeline-spec" && provenance.mode !== "remote-pipeline-annotation") return null; return provenance as PacSourceArtifactProvenance; } diff --git a/scripts/src/platform-infra-pipelines-as-code.ts b/scripts/src/platform-infra-pipelines-as-code.ts index eb5b7d99..f7429ce8 100644 --- a/scripts/src/platform-infra-pipelines-as-code.ts +++ b/scripts/src/platform-infra-pipelines-as-code.ts @@ -154,6 +154,10 @@ interface PacConsumer { path: string; destinationNamespace: string; automated: boolean; + repositoryCredential: { + secretName: string; + username: string; + } | null; } | null; deliveryProvenance: { required: true; @@ -165,6 +169,11 @@ interface PacConsumer { closeoutGitOpsMirrorFlush: boolean; closeoutGitOpsMirrorLane: "v02" | "v03" | null; sourceArtifact: PacSourceArtifactSpec | null; + runnerServiceAccount: { + name: string; + automountServiceAccountToken: false; + roleBindingName: string; + } | null; } interface CommonOptions { @@ -495,6 +504,7 @@ function parseConsumer(consumer: Record, path: string, defaultR const id = typeof consumer.id === "string" && consumer.id.length > 0 ? consumer.id : `${y.stringField(consumer, "node", path)}-${y.stringField(consumer, "lane", path)}`; const argoBootstrap = consumer.argoBootstrap === undefined ? null : y.objectField(consumer, "argoBootstrap", path); const deliveryProvenance = consumer.deliveryProvenance === undefined ? null : parseConsumerDeliveryProvenance(y.objectField(consumer, "deliveryProvenance", path), `${path}.deliveryProvenance`); + const runnerServiceAccount = consumer.runnerServiceAccount === undefined ? null : y.objectField(consumer, "runnerServiceAccount", path); return { id, node: y.stringField(consumer, "node", path), @@ -511,15 +521,32 @@ function parseConsumer(consumer: Record, path: string, defaultR path: y.stringField(argoBootstrap, "path", `${path}.argoBootstrap`), destinationNamespace: y.kubernetesNameField(argoBootstrap, "destinationNamespace", `${path}.argoBootstrap`), automated: y.booleanField(argoBootstrap, "automated", `${path}.argoBootstrap`), + repositoryCredential: argoBootstrap.repositoryCredential === undefined ? null : (() => { + const credential = y.objectField(argoBootstrap, "repositoryCredential", `${path}.argoBootstrap`); + return { + secretName: y.kubernetesNameField(credential, "secretName", `${path}.argoBootstrap.repositoryCredential`), + username: y.stringField(credential, "username", `${path}.argoBootstrap.repositoryCredential`), + }; + })(), }, deliveryProvenance, repositoryRef: typeof consumer.repositoryRef === "string" && consumer.repositoryRef.length > 0 ? consumer.repositoryRef : defaultRepositoryRef, closeoutGitOpsMirrorFlush: y.booleanField(consumer, "closeoutGitOpsMirrorFlush", path), closeoutGitOpsMirrorLane: consumer.closeoutGitOpsMirrorLane === undefined ? null : y.enumField(consumer, "closeoutGitOpsMirrorLane", path, ["v02", "v03"] as const), sourceArtifact: consumer.sourceArtifact === undefined ? null : parseSourceArtifact(y.objectField(consumer, "sourceArtifact", path), `${path}.sourceArtifact`), + runnerServiceAccount: runnerServiceAccount === null ? null : { + name: y.kubernetesNameField(runnerServiceAccount, "name", `${path}.runnerServiceAccount`), + automountServiceAccountToken: parseFalse(runnerServiceAccount, "automountServiceAccountToken", `${path}.runnerServiceAccount`), + roleBindingName: y.kubernetesNameField(runnerServiceAccount, "roleBindingName", `${path}.runnerServiceAccount`), + }, }; } +function parseFalse(record: Record, key: string, path: string): false { + if (y.booleanField(record, key, path) !== false) throw new Error(`${path}.${key} must be false`); + return false; +} + function parseConsumerDeliveryProvenance(value: Record, path: string): PacConsumer["deliveryProvenance"] { const required = y.booleanField(value, "required", path); if (!required) return null; @@ -537,7 +564,7 @@ function parseConsumerDeliveryProvenance(value: Record, path: s function parseSourceArtifact(value: Record, path: string): PacSourceArtifactSpec { const mode = y.enumField(value, "mode", path, ["embedded-pipeline-spec", "remote-pipeline-annotation"] as const); - const renderer = y.enumField(value, "renderer", path, ["agentrun-control-plane", "hwlab-runtime-lane"] as const); + const renderer = y.enumField(value, "renderer", path, ["agentrun-control-plane", "hwlab-runtime-lane", "selfmedia-runtime"] as const); const pipelineRunPath = sourceArtifactPath(y.stringField(value, "pipelineRunPath", path), `${path}.pipelineRunPath`); const pipelinePath = value.pipelinePath === undefined ? null : sourceArtifactPath(y.stringField(value, "pipelinePath", path), `${path}.pipelinePath`); const taskRunTemplate = y.objectField(value, "taskRunTemplate", path); @@ -545,6 +572,7 @@ function parseSourceArtifact(value: Record, path: string): PacS if (mode === "remote-pipeline-annotation" && pipelinePath === null) throw new Error(`${path}.pipelinePath is required for remote-pipeline-annotation`); if (renderer === "agentrun-control-plane" && mode !== "embedded-pipeline-spec") throw new Error(`${path}.renderer agentrun-control-plane requires embedded-pipeline-spec`); if (renderer === "hwlab-runtime-lane" && mode !== "remote-pipeline-annotation") throw new Error(`${path}.renderer hwlab-runtime-lane requires remote-pipeline-annotation`); + if (renderer === "selfmedia-runtime" && mode !== "embedded-pipeline-spec") throw new Error(`${path}.renderer selfmedia-runtime requires embedded-pipeline-spec`); return { mode, renderer, @@ -772,6 +800,19 @@ function validateConfig(config: PacConfig): void { || consumer.argoBootstrap.targetRevision !== consumer.deliveryProvenance.gitOps.targetRevision)) { throw new Error(`${configLabel}.consumers.${consumer.id}.deliveryProvenance.gitOps must match argoBootstrap source identity`); } + if (consumer.runnerServiceAccount !== null && consumer.runnerServiceAccount.name !== consumer.deliveryProvenance.executionServiceAccountName) { + throw new Error(`${configLabel}.consumers.${consumer.id}.runnerServiceAccount.name must match deliveryProvenance.executionServiceAccountName`); + } + } + if (consumer.sourceArtifact?.renderer === "selfmedia-runtime") { + if (consumer.runnerServiceAccount === null) throw new Error(`${configLabel}.consumers.${consumer.id}.runnerServiceAccount is required for selfmedia-runtime`); + if (consumer.argoBootstrap?.repositoryCredential === null || consumer.argoBootstrap?.repositoryCredential === undefined) { + throw new Error(`${configLabel}.consumers.${consumer.id}.argoBootstrap.repositoryCredential is required for the private selfmedia repository`); + } + if (consumer.closeoutGitOpsMirrorFlush) throw new Error(`${configLabel}.consumers.${consumer.id}.closeoutGitOpsMirrorFlush must be false for Gitea writeback GitOps`); + if (repository.params.gitops_secret_name !== repository.secretName) throw new Error(`${configLabel}.repositories.${repository.id}.params.gitops_secret_name must match secretName`); + if (repository.params.gitops_username !== consumer.argoBootstrap.repositoryCredential.username) throw new Error(`${configLabel}.consumers.${consumer.id}.Argo and Tekton Gitea usernames must match`); + if (repository.params.gitops_read_url !== consumer.argoBootstrap.repoUrl) throw new Error(`${configLabel}.consumers.${consumer.id}.Argo repoUrl must match params.gitops_read_url`); } } if (!config.gitea.webhook.events.includes("push")) throw new Error(`${configLabel}.gitea.webhook.events must include push`); @@ -1227,6 +1268,9 @@ function remoteScript(action: "apply" | "status" | "history" | "debug-step", pac UNIDESK_PAC_CONSUMER_CONFIG_B64: Buffer.from(JSON.stringify(consumerConfig), "utf8").toString("base64"), UNIDESK_PAC_DELIVERY_PROVENANCE_REQUIRED: consumer.deliveryProvenance?.required === true ? "1" : "0", UNIDESK_PAC_CONSUMER_RBAC_MANIFEST_B64: Buffer.from(renderPacConsumerRbacDesiredFragment(target.id), "utf8").toString("base64"), + UNIDESK_PAC_RUNNER_SERVICE_ACCOUNT_MANIFEST_B64: Buffer.from(runnerServiceAccountManifest(consumer), "utf8").toString("base64"), + UNIDESK_PAC_RUNNER_SERVICE_ACCOUNT_NAME: consumer.runnerServiceAccount?.name ?? "", + UNIDESK_PAC_RUNNER_ROLE_BINDING_NAME: consumer.runnerServiceAccount?.roleBindingName ?? "", UNIDESK_PAC_CONSUMER_RBAC_CONFIG_SHA256: rbacIdentity.configSha256, UNIDESK_PAC_ADMISSION_POLICY_NAME: admissionIdentity.policyName, UNIDESK_PAC_ADMISSION_BINDING_NAME: admissionIdentity.bindingName, @@ -1242,6 +1286,9 @@ function remoteScript(action: "apply" | "status" | "history" | "debug-step", pac UNIDESK_PAC_ARGO_NAMESPACE: consumer.argoNamespace, UNIDESK_PAC_ARGO_APPLICATION: consumer.argoApplication, UNIDESK_PAC_ARGO_BOOTSTRAP_MANIFEST_B64: Buffer.from(argoBootstrapManifest(consumer), "utf8").toString("base64"), + UNIDESK_PAC_ARGO_REPOSITORY_SECRET_NAME: consumer.argoBootstrap?.repositoryCredential?.secretName ?? "", + UNIDESK_PAC_ARGO_REPOSITORY_USERNAME: consumer.argoBootstrap?.repositoryCredential?.username ?? "", + UNIDESK_PAC_ARGO_REPOSITORY_URL: consumer.argoBootstrap?.repoUrl ?? "", UNIDESK_PAC_PART_OF: pacPartOf(consumer.id), UNIDESK_PAC_SPEC: kubernetesLabelValue(pac.metadata.relatedIssues.includes(1555) ? "GH-1552-GH-1555" : pac.metadata.spec), }; @@ -1288,9 +1335,36 @@ function pacPartOf(consumerId: string): string { if (consumerId === "unidesk-host") return "unidesk-host"; if (consumerId.startsWith("sentinel")) return "hwlab-web-probe-sentinel"; if (consumerId.startsWith("hwlab-")) return "hwlab"; + if (consumerId.startsWith("selfmedia-")) return "selfmedia-factory"; return "agentrun"; } +function runnerServiceAccountManifest(consumer: PacConsumer): string { + const runner = consumer.runnerServiceAccount; + if (runner === null) return ""; + const labels = { + "app.kubernetes.io/managed-by": "unidesk", + "app.kubernetes.io/part-of": pacPartOf(consumer.id), + "unidesk.ai/pac-runner": consumer.id, + }; + const objects = [ + { + apiVersion: "v1", + kind: "ServiceAccount", + metadata: { name: runner.name, namespace: consumer.namespace, labels }, + automountServiceAccountToken: runner.automountServiceAccountToken, + }, + { + apiVersion: "rbac.authorization.k8s.io/v1", + kind: "RoleBinding", + metadata: { name: runner.roleBindingName, namespace: consumer.namespace, labels }, + subjects: [{ kind: "ServiceAccount", name: runner.name, namespace: consumer.namespace }], + roleRef: { apiGroup: "rbac.authorization.k8s.io", kind: "Role", name: "unidesk-pac-consumer-runner" }, + }, + ]; + return `${objects.map((item) => Bun.YAML.stringify(item).trim()).join("\n---\n")}\n`; +} + function argoBootstrapManifest(consumer: PacConsumer): string { const bootstrap = consumer.argoBootstrap; if (bootstrap === null) return ""; @@ -1411,6 +1485,7 @@ function statusSummary(payload: Record): Record): Record): Record 0, + }, valuesPrinted: false, }; } @@ -1666,6 +1747,8 @@ function compactPlanJson(result: Record): Record): Record): RenderedCliResult { const argo = record(summary.argo); const diagnostics = record(summary.diagnostics); const admissionProvenance = record(summary.admissionProvenance); + const consumerBootstrap = record(summary.consumerBootstrap); + const bootstrapRunner = record(consumerBootstrap.runner); + const bootstrapArgo = record(consumerBootstrap.argoRepository); const sourceObservation = record(summary.sourceObservation); const deliveryPlan = record(sourceObservation.plan); const repository = record(summary.repository); @@ -1970,6 +2057,13 @@ function renderStatus(result: Record): RenderedCliResult { ` admission-provenance: ready=${boolText(admissionProvenance.ready)} policy=${stringValue(admissionProvenance.policyName)} binding=${stringValue(admissionProvenance.bindingName)} active-after=${stringValue(admissionProvenance.activeAfter)} default-can-mutate=${boolText(admissionProvenance.defaultCanMutate)}`, ` admission-reasons: ${Array.isArray(admissionProvenance.reasons) ? admissionProvenance.reasons.join(",") || "-" : "-"}`, ]), + ...(consumerBootstrap.configured !== true + ? [" consumer-bootstrap: not-declared"] + : [ + ` consumer-bootstrap: ready=${boolText(consumerBootstrap.ready)} runner=${stringValue(bootstrapRunner.serviceAccount)} exists=${boolText(bootstrapRunner.exists)} automount-disabled=${boolText(bootstrapRunner.automountDisabled)} rolebinding-exact=${boolText(bootstrapRunner.roleBindingExact)}`, + ` argocd-repository-secret: name=${stringValue(bootstrapArgo.secretName)} exists=${boolText(bootstrapArgo.exists)} label=${boolText(bootstrapArgo.labelReady)} keys=${boolText(bootstrapArgo.keysReady)} url=${boolText(bootstrapArgo.urlReady)} password-present=${boolText(bootstrapArgo.passwordPresent)} password-decoded=${boolText(bootstrapArgo.passwordDecoded)}`, + ` consumer-bootstrap-reasons: ${Array.isArray(consumerBootstrap.reasons) ? consumerBootstrap.reasons.join(",") || "-" : "-"}`, + ]), "", "GITEA HOOKS", ...(webhooks.length === 0 ? ["-"] : table(["HOOK", "ACTIVE", "EVENTS", "URL"], webhooks.map((item) => [stringValue(item.id), boolText(item.active), Array.isArray(item.events) ? item.events.join(",") : stringValue(item.events), short(stringValue(item.url), 56)]))), diff --git a/scripts/src/secrets.ts b/scripts/src/secrets.ts index 6c689084..3ae1a934 100644 --- a/scripts/src/secrets.ts +++ b/scripts/src/secrets.ts @@ -1,5 +1,6 @@ -import { randomBytes } from "node:crypto"; +import { createHash, randomBytes } from "node:crypto"; import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; import { dirname, isAbsolute, join } from "node:path"; import type { UniDeskConfig } from "./config"; import { rootPath } from "./config"; @@ -39,23 +40,36 @@ interface SecretDistributionConfig { version: number; kind: "unidesk-secret-distribution"; metadata: { id: string; owner: string; relatedIssues: number[] }; - sources: { root: string; files: SourceFileConfig[] }; + sources: { root: string; files: EnvSourceFileConfig[]; externalFiles: RawSourceFileConfig[] }; targets: DistributionTarget[]; kubernetesSecrets: KubernetesSecretConfig[]; } -interface SourceFileConfig { +interface SourceCreateIfMissingConfig { + enabled: boolean; + values: Record; + randomHex: Record; + randomBase64Url: Record; +} + +interface EnvSourceFileConfig { sourceRef: string; type: "env"; requiredKeys: string[]; - createIfMissing: { - enabled: boolean; - values: Record; - randomHex: Record; - randomBase64Url: Record; - }; + required: true; + createIfMissing: SourceCreateIfMissingConfig; } +interface RawSourceFileConfig { + sourceRef: string; + type: "raw-file"; + requiredKeys: ["contents"]; + required: boolean; + createIfMissing: SourceCreateIfMissingConfig; +} + +type SourceFileConfig = EnvSourceFileConfig | RawSourceFileConfig; + interface DistributionTarget { id: string; route: string; @@ -80,8 +94,10 @@ interface SecretDataMapping { interface SourceMaterial { sourceRef: string; + type: SourceFileConfig["type"]; sourcePath: string; exists: boolean; + required: boolean; requiredKeys: string[]; presentKeys: string[]; missingKeys: string[]; @@ -190,7 +206,7 @@ function parseOptions(args: string[]): SecretsOptions { function plan(options: SecretsOptions): Record { const distribution = readSecretDistributionConfig(options.configPath); assertSelectedTargets(distribution, options); - const sources = inspectSources(distribution, false); + const sources = inspectSources(distribution, false, selectedSourceRefs(distribution, options)); const desired = desiredSecrets(distribution, options, sources); return { ok: sources.ok && desired.every((secret) => secret.missingKeys.length === 0), @@ -200,7 +216,7 @@ function plan(options: SecretsOptions): Record { localSources: sourceSummary(sources), desiredSecrets: desired.map(desiredSecretSummary), policy: { - sourceAuthority: "local YAML-declared sourceRef files under sources.root", + sourceAuthority: "local YAML-declared managed and external sourceRef files", runtimeReverseEngineering: false, valuesPrinted: false, }, @@ -233,7 +249,7 @@ async function sync(config: UniDeskConfig, options: SecretsOptions): Promise secret.missingKeys); @@ -257,7 +273,7 @@ async function sync(config: UniDeskConfig, options: SecretsOptions): Promise> { const distribution = readSecretDistributionConfig(options.configPath); assertSelectedTargets(distribution, options); - const sources = inspectSources(distribution, false); + const sources = inspectSources(distribution, false, selectedSourceRefs(distribution, options)); const desired = desiredSecrets(distribution, options, sources); const perTarget = await Promise.all(groupDesiredSecretsByTarget(desired).map(async (group) => await statusTargetSecrets(config, group.target, group.secrets, options))); return { @@ -293,6 +309,9 @@ function readSecretDistributionConfig(pathArg: string): SecretDistributionConfig sources: { root: stringField(sources, "root", `${label}.sources`), files: arrayOfRecords(sources.files, `${label}.sources.files`).map((item, index) => parseSourceFile(item, `${label}.sources.files[${index}]`)), + externalFiles: sources.externalFiles === undefined + ? [] + : arrayOfRecords(sources.externalFiles, `${label}.sources.externalFiles`).map((item, index) => parseRawSourceFile(item, `${label}.sources.externalFiles[${index}]`)), }, targets: arrayOfRecords(root.targets, `${label}.targets`).map((item, index) => parseTarget(item, `${label}.targets[${index}]`)), kubernetesSecrets: arrayOfRecords(root.kubernetesSecrets, `${label}.kubernetesSecrets`).map((item, index) => parseKubernetesSecret(item, `${label}.kubernetesSecrets[${index}]`)), @@ -301,7 +320,7 @@ function readSecretDistributionConfig(pathArg: string): SecretDistributionConfig return config; } -function parseSourceFile(record: Record, path: string): SourceFileConfig { +function parseSourceFile(record: Record, path: string): EnvSourceFileConfig { const type = stringField(record, "type", path); if (type !== "env") throw new Error(`${path}.type must be env`); const createRaw = record.createIfMissing === undefined ? {} : objectField(record, "createIfMissing", path); @@ -310,6 +329,7 @@ function parseSourceFile(record: Record, path: string): SourceF sourceRef: sourceRefField(record, "sourceRef", path), type, requiredKeys: stringArrayField(record, "requiredKeys", path).map((key, index) => envKeyValue(key, `${path}.requiredKeys[${index}]`)), + required: true, createIfMissing: { enabled: createRaw.enabled === undefined ? false : booleanField(createRaw, "enabled", `${path}.createIfMissing`), values: createRaw.values === undefined ? {} : stringMapField(createRaw, "values", `${path}.createIfMissing`), @@ -319,6 +339,35 @@ function parseSourceFile(record: Record, path: string): SourceF }; } +function parseRawSourceFile(record: Record, path: string): RawSourceFileConfig { + const type = stringField(record, "type", path); + if (type !== "raw-file") throw new Error(`${path}.type must be raw-file`); + const createRaw = objectField(record, "createIfMissing", path); + const enabled = booleanField(createRaw, "enabled", `${path}.createIfMissing`); + const randomHex = createRaw.randomHex === undefined + ? {} + : { contents: randomByteCount(createRaw.randomHex, `${path}.createIfMissing.randomHex`) }; + const randomBase64Url = createRaw.randomBase64Url === undefined + ? {} + : { contents: randomBase64UrlSpec(createRaw.randomBase64Url, `${path}.createIfMissing.randomBase64Url`) }; + if (createRaw.values !== undefined) throw new Error(`${path}.createIfMissing.values is not allowed for raw-file sources`); + const generatorCount = Object.keys(randomHex).length + Object.keys(randomBase64Url).length; + if (enabled && generatorCount !== 1) throw new Error(`${path}.createIfMissing must declare exactly one of randomHex or randomBase64Url when enabled`); + if (!enabled && generatorCount !== 0) throw new Error(`${path}.createIfMissing generators require enabled: true`); + return { + sourceRef: externalSourceRefField(record, "sourceRef", path), + type, + requiredKeys: ["contents"], + required: booleanField(record, "required", path), + createIfMissing: { + enabled, + values: {}, + randomHex, + randomBase64Url, + }, + }; +} + function parseTarget(record: Record, path: string): DistributionTarget { return { id: simpleId(stringField(record, "id", path), `${path}.id`), @@ -338,17 +387,22 @@ function parseKubernetesSecret(record: Record, path: string): K secretName: kubernetesNameField(record, "secretName", path), type, data: arrayOfRecords(record.data, `${path}.data`).map((item, index) => ({ - sourceRef: sourceRefField(item, "sourceRef", `${path}.data[${index}]`), - sourceKey: envKeyField(item, "sourceKey", `${path}.data[${index}]`), + sourceRef: declaredSourceRefField(item, "sourceRef", `${path}.data[${index}]`), + sourceKey: sourceDataKeyField(item, "sourceKey", `${path}.data[${index}]`), targetKey: kubernetesSecretKeyField(item, "targetKey", `${path}.data[${index}]`), })), }; } function validateDistributionConfig(config: SecretDistributionConfig): void { - if (config.sources.files.length === 0) throw new Error(`${config.configPath}.sources.files must not be empty`); + const sourceFiles = allSourceFiles(config); + if (sourceFiles.length === 0) throw new Error(`${config.configPath}.sources must declare files or externalFiles`); if (config.targets.length === 0) throw new Error(`${config.configPath}.targets must not be empty`); - const sources = new Map(config.sources.files.map((item) => [item.sourceRef, item])); + const sources = new Map(); + for (const source of sourceFiles) { + if (sources.has(source.sourceRef)) throw new Error(`${config.configPath}.sources declares duplicate sourceRef ${source.sourceRef}`); + sources.set(source.sourceRef, source); + } const targets = new Set(config.targets.map((item) => item.id)); const targetSecrets = new Set(); for (const secret of config.kubernetesSecrets) { @@ -360,20 +414,25 @@ function validateDistributionConfig(config: SecretDistributionConfig): void { for (const item of secret.data) { const source = sources.get(item.sourceRef); if (source === undefined) throw new Error(`${config.configPath}.kubernetesSecrets.${secret.name} references undeclared sourceRef ${item.sourceRef}`); - if (!source.requiredKeys.includes(item.sourceKey)) throw new Error(`${config.configPath}.kubernetesSecrets.${secret.name} maps ${item.sourceRef}.${item.sourceKey}, but that key is not listed in sources.files.requiredKeys`); + if (!source.requiredKeys.includes(item.sourceKey)) throw new Error(`${config.configPath}.kubernetesSecrets.${secret.name} maps undeclared source key ${item.sourceRef}.${item.sourceKey}`); if (targetKeys.has(item.targetKey)) throw new Error(`${config.configPath}.kubernetesSecrets.${secret.name} maps duplicate target key ${item.targetKey}`); targetKeys.add(item.targetKey); } } } -function inspectSources(config: SecretDistributionConfig, materialize: boolean): SourceInspection { +function allSourceFiles(config: SecretDistributionConfig): SourceFileConfig[] { + return [...config.sources.files, ...config.sources.externalFiles]; +} + +function inspectSources(config: SecretDistributionConfig, materialize: boolean, selectedRefs: Set): SourceInspection { const root = secretRoot(config); const materials = new Map(); - const entries = config.sources.files.map((source) => { - const sourcePath = join(root, source.sourceRef); + const entries = allSourceFiles(config).filter((source) => selectedRefs.has(source.sourceRef)).map((source) => { + const sourcePath = source.type === "env" ? join(root, source.sourceRef) : externalSourcePath(source.sourceRef); const exists = existsSync(sourcePath); - const existing = exists ? parseEnvFile(readFileSync(sourcePath, "utf8")) : {}; + const existingText = exists ? readFileSync(sourcePath, "utf8") : ""; + const existing = exists ? source.type === "env" ? parseEnvFile(existingText) : { contents: existingText } : {}; const next = { ...existing }; const generatedKeys: string[] = []; const unmaterializedGeneratedKeys: string[] = []; @@ -402,11 +461,30 @@ function inspectSources(config: SecretDistributionConfig, materialize: boolean): const missingBefore = source.requiredKeys.filter((key) => existing[key] === undefined || existing[key].length === 0); const missingKeys = source.requiredKeys.filter((key) => next[key] === undefined || next[key].length === 0); const action = missingKeys.length > 0 ? "blocked" : !exists ? "create" : missingBefore.length > 0 ? "update" : "none"; - if (materialize && action !== "blocked" && (action === "create" || action === "update")) writeEnvFile(sourcePath, next); + if (materialize && action !== "blocked" && (action === "create" || action === "update")) { + if (source.type === "env") writeEnvFile(sourcePath, next); + else writeRawFile(sourcePath, next.contents); + } + const materialized = materialize && action !== "blocked" && (action === "create" || action === "update"); + const presence = materialized || (exists && existingText.length > 0); + const bytes = materialized + ? source.type === "env" + ? Buffer.byteLength(readFileSync(sourcePath, "utf8"), "utf8") + : Buffer.byteLength(next.contents, "utf8") + : exists + ? Buffer.byteLength(existingText, "utf8") + : null; + const fingerprint = missingKeys.length === 0 && unmaterializedGeneratedKeys.length === 0 + ? source.type === "raw-file" + ? fingerprintRawFileContent(next.contents) + : fingerprintValues(next, source.requiredKeys) + : null; const material: SourceMaterial = { sourceRef: source.sourceRef, + type: source.type, sourcePath, exists, + required: source.required, requiredKeys: source.requiredKeys, presentKeys: source.requiredKeys.filter((key) => next[key] !== undefined && next[key].length > 0), missingKeys, @@ -414,11 +492,22 @@ function inspectSources(config: SecretDistributionConfig, materialize: boolean): generatedKeys, unmaterializedGeneratedKeys, values: next, - fingerprint: missingKeys.length === 0 && unmaterializedGeneratedKeys.length === 0 ? fingerprintValues(next, source.requiredKeys) : null, + fingerprint, }; materials.set(source.sourceRef, material); + if (source.type === "raw-file") { + return { + sourceRef: source.sourceRef, + type: source.type, + presence, + bytes, + fingerprint, + valuesPrinted: false, + }; + } return { sourceRef: source.sourceRef, + type: source.type, sourcePath: redactRepoPath(sourcePath), exists, requiredKeys: source.requiredKeys, @@ -432,7 +521,7 @@ function inspectSources(config: SecretDistributionConfig, materialize: boolean): }; }); return { - ok: entries.every((entry) => (entry.missingKeys as string[]).length === 0), + ok: Array.from(materials.values()).every((material) => !material.required || material.missingKeys.length === 0), root: redactRepoPath(root), entries, materials, @@ -489,6 +578,13 @@ function selectedTargets(config: SecretDistributionConfig, options: SecretsOptio .filter((target) => options.targetId === null || target.id === options.targetId); } +function selectedSourceRefs(config: SecretDistributionConfig, options: SecretsOptions): Set { + const targetIds = new Set(selectedTargets(config, options).map((target) => target.id)); + return new Set(config.kubernetesSecrets + .filter((secret) => targetIds.has(secret.targetId)) + .flatMap((secret) => secret.data.map((item) => item.sourceRef))); +} + async function applyTargetSecrets(config: UniDeskConfig, target: DistributionTarget, secrets: DesiredSecret[], options: SecretsOptions): Promise> { if (secrets.length === 0) return { ok: true, target: targetSummary(target), mode: "skipped-no-secrets" }; const yaml = renderSecretManifest(target, secrets); @@ -561,21 +657,21 @@ else apply_rc=1 fi python3 - "$ns_rc" "$apply_rc" "$tmp/ns.out" "$tmp/ns.err" "$tmp/apply.out" "$tmp/apply.err" <<'PY' -import base64, json, sys +import base64, json, os, sys ns_rc, apply_rc = int(sys.argv[1]), int(sys.argv[2]) -def text(path, limit=5000): +def byte_count(path): try: - return open(path, encoding="utf-8", errors="replace").read()[-limit:] + return os.path.getsize(path) except FileNotFoundError: - return "" + return 0 payload = { "ok": ns_rc == 0 and apply_rc == 0, "namespace": "${target.namespace}", "secrets": json.loads(base64.b64decode("${summaryB64}").decode("utf-8")), "valuesPrinted": False, "steps": { - "namespace": {"exitCode": ns_rc, "stdout": text(sys.argv[3]), "stderr": text(sys.argv[4])}, - "apply": {"exitCode": apply_rc, "stdout": text(sys.argv[5]), "stderr": text(sys.argv[6])}, + "namespace": {"exitCode": ns_rc, "stdoutBytes": byte_count(sys.argv[3]), "stderrBytes": byte_count(sys.argv[4]), "outputOmitted": True}, + "apply": {"exitCode": apply_rc, "stdoutBytes": byte_count(sys.argv[5]), "stderrBytes": byte_count(sys.argv[6]), "outputOmitted": True}, }, } print(json.dumps(payload, ensure_ascii=False, indent=2)) @@ -645,13 +741,17 @@ function groupDesiredSecretsByTarget(secrets: DesiredSecret[]): Array<{ target: } function configSummary(config: SecretDistributionConfig, options: SecretsOptions): Record { + const sourceRefs = selectedSourceRefs(config, options); return { path: config.configPath, metadata: config.metadata, root: redactRepoPath(secretRoot(config)), scope: options.scope, targetId: options.targetId, - sources: config.sources.files.map((item) => ({ sourceRef: item.sourceRef, requiredKeys: item.requiredKeys, createIfMissing: item.createIfMissing.enabled })), + sources: [ + ...config.sources.files.filter((item) => sourceRefs.has(item.sourceRef)).map((item) => ({ sourceRef: item.sourceRef, type: item.type, requiredKeys: item.requiredKeys, createIfMissing: item.createIfMissing.enabled })), + ...config.sources.externalFiles.filter((item) => sourceRefs.has(item.sourceRef)).map((item) => ({ sourceRef: item.sourceRef, type: item.type, required: item.required, createIfMissing: item.createIfMissing.enabled })), + ], targets: config.targets.filter((target) => (options.scope === null || target.scope === options.scope) && (options.targetId === null || target.id === options.targetId)).map(targetSummary), valuesPrinted: false, }; @@ -748,6 +848,16 @@ function writeEnvFile(path: string, values: Record): void { chmodSync(path, 0o600); } +function writeRawFile(path: string, value: string): void { + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + writeFileSync(path, value, { encoding: "utf8", mode: 0o600 }); + chmodSync(path, 0o600); +} + +function fingerprintRawFileContent(value: string): string { + return `sha256:${createHash("sha256").update(value, "utf8").digest("hex")}`; +} + function quoteEnv(value: string): string { if (/^[A-Za-z0-9_./:@%?&=+-]+$/u.test(value)) return value; return `'${value.replaceAll("'", "'\"'\"'")}'`; @@ -837,8 +947,30 @@ function sourceRefField(obj: Record, key: string, path: string) return value; } -function envKeyField(obj: Record, key: string, path: string): string { - return envKeyValue(stringField(obj, key, path), `${path}.${key}`); +function externalSourceRefField(obj: Record, key: string, path: string): string { + const value = stringField(obj, key, path); + if (value.includes("\0") || value.split("/").includes("..")) throw new Error(`${path}.${key} must not contain NUL or parent traversal`); + if (value.startsWith("~/") && value.length > 2) return value; + if (isAbsolute(value)) return value; + throw new Error(`${path}.${key} must be an absolute path or a ~/ home path`); +} + +function declaredSourceRefField(obj: Record, key: string, path: string): string { + const value = stringField(obj, key, path); + if (value.startsWith("~/") || isAbsolute(value)) return externalSourceRefField(obj, key, path); + return sourceRefField(obj, key, path); +} + +function externalSourcePath(sourceRef: string): string { + if (sourceRef.startsWith("~/")) return join(homedir(), sourceRef.slice(2)); + if (isAbsolute(sourceRef)) return sourceRef; + throw new Error(`external sourceRef ${sourceRef} must be an absolute path or a ~/ home path`); +} + +function sourceDataKeyField(obj: Record, key: string, path: string): string { + const value = stringField(obj, key, path); + if (!/^[A-Za-z_][A-Za-z0-9._-]*$/u.test(value)) throw new Error(`${path}.${key} must be a source data key`); + return value; } function envKeyValue(value: string, path: string): string { diff --git a/scripts/src/selfmedia-config.ts b/scripts/src/selfmedia-config.ts new file mode 100644 index 00000000..4f4dde63 --- /dev/null +++ b/scripts/src/selfmedia-config.ts @@ -0,0 +1,299 @@ +import { readFileSync } from "node:fs"; + +import { rootPath } from "./config"; + +export const selfMediaConfigPath = rootPath("config", "selfmedia.yaml"); +export const selfMediaConfigRef = "config/selfmedia.yaml"; + +export interface SelfMediaDeliveryTarget { + readonly id: string; + readonly node: string; + readonly lane: string; + readonly route: string; + readonly ci: { + readonly namespace: string; + readonly pipeline: string; + readonly pipelineRunPrefix: string; + readonly serviceAccountName: string; + readonly serviceAccountAutomount: false; + readonly roleBindingName: string; + readonly workspaceSize: string; + readonly pipelineTimeout: string; + readonly toolImage: string; + readonly buildkitImage: string; + }; + readonly source: { + readonly repository: string; + readonly worktreeRemote: string; + readonly branch: string; + readonly readUrl: string; + readonly snapshotPrefix: string; + }; + readonly build: Record & { + readonly dockerfile: string; + readonly imageRepository: string; + readonly networkMode: string; + readonly proxy: Record; + }; + readonly gitops: Record & { + readonly readUrl: string; + readonly writeUrl: string; + readonly branch: string; + readonly manifestPath: string; + readonly releaseStatePath: string; + readonly credentialSecretName: string; + readonly credentialTokenKey: string; + readonly credentialUsername: string; + }; + readonly deployment: Record; +} + +export interface SelfMediaCutoverTarget { + readonly id: string; + readonly mode: "host-process-to-kubernetes"; + readonly source: { + readonly workspace: string; + readonly publicAddress: string; + readonly healthUrl: string; + readonly stopCommand: readonly string[]; + readonly startCommand: readonly string[]; + readonly dataPaths: readonly string[]; + readonly excludes: readonly string[]; + }; + readonly destination: { + readonly route: string; + readonly namespace: string; + readonly application: string; + readonly publicAddress: string; + readonly healthUrl: string; + readonly dataClaim: string; + readonly codexClaim: string; + }; + readonly prepare: { + readonly requiresFinalConfirmation: true; + readonly phases: readonly string[]; + }; + readonly rollback: { + readonly phases: readonly string[]; + readonly dataPolicy: string; + }; +} + +interface SelfMediaConfig { + readonly defaultTargetId: string; + readonly deliveryTargets: Readonly>; + readonly cutoverTargets: Readonly>; +} + +export function readSelfMediaConfig(): SelfMediaConfig { + const root = record(Bun.YAML.parse(readFileSync(selfMediaConfigPath, "utf8")), selfMediaConfigRef); + if (integer(root.version, "version") !== 1) throw new Error(`${selfMediaConfigRef}.version must be 1`); + if (text(root.kind, "kind") !== "selfmedia-platform-delivery") throw new Error(`${selfMediaConfigRef}.kind must be selfmedia-platform-delivery`); + const defaults = record(root.defaults, "defaults"); + const delivery = record(root.delivery, "delivery"); + const cutover = record(root.cutover, "cutover"); + const deliveryRecords = record(delivery.targets, "delivery.targets"); + const cutoverRecords = record(cutover.targets, "cutover.targets"); + const deliveryTargets = Object.fromEntries(Object.entries(deliveryRecords).map(([id, value]) => [id, parseDeliveryTarget(id, record(value, `delivery.targets.${id}`))])); + const cutoverTargets = Object.fromEntries(Object.entries(cutoverRecords).map(([id, value]) => [id, parseCutoverTarget(id, record(value, `cutover.targets.${id}`))])); + const defaultTargetId = text(defaults.targetId, "defaults.targetId"); + if (deliveryTargets[defaultTargetId] === undefined || cutoverTargets[defaultTargetId] === undefined) { + throw new Error(`${selfMediaConfigRef}.defaults.targetId must resolve in delivery.targets and cutover.targets`); + } + return { defaultTargetId, deliveryTargets, cutoverTargets }; +} + +export function resolveSelfMediaDeliveryTarget(targetId?: string | null): SelfMediaDeliveryTarget { + const config = readSelfMediaConfig(); + const id = targetId ?? config.defaultTargetId; + const target = config.deliveryTargets[id]; + if (target === undefined) throw new Error(`unknown selfmedia delivery target ${id}; known targets: ${Object.keys(config.deliveryTargets).join(", ")}`); + return target; +} + +export function resolveSelfMediaCutoverTarget(targetId?: string | null): SelfMediaCutoverTarget { + const config = readSelfMediaConfig(); + const id = targetId ?? config.defaultTargetId; + const target = config.cutoverTargets[id]; + if (target === undefined) throw new Error(`unknown selfmedia cutover target ${id}; known targets: ${Object.keys(config.cutoverTargets).join(", ")}`); + return target; +} + +export function selfMediaDeliveryConfigRef(targetId: string): string { + return `${selfMediaConfigRef}#delivery.targets.${targetId}`; +} + +export function selfMediaEffectiveDeployment(target: SelfMediaDeliveryTarget): Record { + return { + ...structuredClone(target.deployment), + delivery: { + enabled: true, + source: { + branch: target.source.branch, + snapshotPrefix: target.source.snapshotPrefix, + readUrl: target.source.readUrl, + }, + build: structuredClone(target.build), + gitops: structuredClone(target.gitops), + }, + }; +} + +function parseDeliveryTarget(id: string, value: Record): SelfMediaDeliveryTarget { + const path = `delivery.targets.${id}`; + const ci = record(value.ci, `${path}.ci`); + const source = record(value.source, `${path}.source`); + const build = record(value.build, `${path}.build`); + const proxy = record(build.proxy, `${path}.build.proxy`); + const gitops = record(value.gitops, `${path}.gitops`); + const deployment = record(value.deployment, `${path}.deployment`); + const target = record(deployment.target, `${path}.deployment.target`); + const publicExposure = record(deployment.publicExposure, `${path}.deployment.publicExposure`); + const noProxy = stringArray(proxy.noProxy, `${path}.build.proxy.noProxy`); + if (!noProxy.includes("hyueapi.com") || !noProxy.includes(".hyueapi.com")) throw new Error(`${path}.build.proxy.noProxy must preserve hyueapi.com and .hyueapi.com`); + if (text(value.node, `${path}.node`) !== id) throw new Error(`${path}.node must match target id ${id}`); + if (text(target.id, `${path}.deployment.target.id`) !== id) throw new Error(`${path}.deployment.target.id must match ${id}`); + if (text(source.repository, `${path}.source.repository`) !== "pikainc/selfmedia") throw new Error(`${path}.source.repository must be pikainc/selfmedia`); + if (!text(source.snapshotPrefix, `${path}.source.snapshotPrefix`).startsWith("refs/unidesk/snapshots/")) throw new Error(`${path}.source.snapshotPrefix must be immutable`); + if (boolean(ci.serviceAccountAutomount, `${path}.ci.serviceAccountAutomount`) !== false) throw new Error(`${path}.ci.serviceAccountAutomount must be false`); + if (boolean(publicExposure.enabled, `${path}.deployment.publicExposure.enabled`) !== true) throw new Error(`${path}.deployment.publicExposure.enabled must be true`); + if (integer(publicExposure.hostPort, `${path}.deployment.publicExposure.hostPort`) !== 4317) throw new Error(`${path}.deployment.publicExposure.hostPort must be 4317`); + const gitopsWriteUrl = text(gitops.writeUrl, `${path}.gitops.writeUrl`); + if (!gitopsWriteUrl.startsWith("http://gitea-http.")) throw new Error(`${path}.gitops.writeUrl must use internal Gitea authority`); + return { + id, + node: id, + lane: text(value.lane, `${path}.lane`), + route: text(value.route, `${path}.route`), + ci: { + namespace: kubernetesName(ci.namespace, `${path}.ci.namespace`), + pipeline: kubernetesName(ci.pipeline, `${path}.ci.pipeline`), + pipelineRunPrefix: kubernetesName(ci.pipelineRunPrefix, `${path}.ci.pipelineRunPrefix`), + serviceAccountName: kubernetesName(ci.serviceAccountName, `${path}.ci.serviceAccountName`), + serviceAccountAutomount: false, + roleBindingName: kubernetesName(ci.roleBindingName, `${path}.ci.roleBindingName`), + workspaceSize: text(ci.workspaceSize, `${path}.ci.workspaceSize`), + pipelineTimeout: text(ci.pipelineTimeout, `${path}.ci.pipelineTimeout`), + toolImage: text(ci.toolImage, `${path}.ci.toolImage`), + buildkitImage: text(ci.buildkitImage, `${path}.ci.buildkitImage`), + }, + source: { + repository: "pikainc/selfmedia", + worktreeRemote: url(source.worktreeRemote, `${path}.source.worktreeRemote`), + branch: text(source.branch, `${path}.source.branch`), + readUrl: url(source.readUrl, `${path}.source.readUrl`), + snapshotPrefix: text(source.snapshotPrefix, `${path}.source.snapshotPrefix`), + }, + build: { + ...structuredClone(build), + dockerfile: relativePath(build.dockerfile, `${path}.build.dockerfile`), + imageRepository: text(build.imageRepository, `${path}.build.imageRepository`), + networkMode: text(build.networkMode, `${path}.build.networkMode`), + proxy: structuredClone(proxy), + }, + gitops: { + ...structuredClone(gitops), + readUrl: url(gitops.readUrl, `${path}.gitops.readUrl`), + writeUrl: url(gitops.writeUrl, `${path}.gitops.writeUrl`), + branch: text(gitops.branch, `${path}.gitops.branch`), + manifestPath: relativePath(gitops.manifestPath, `${path}.gitops.manifestPath`), + releaseStatePath: relativePath(gitops.releaseStatePath, `${path}.gitops.releaseStatePath`), + credentialSecretName: kubernetesName(gitops.credentialSecretName, `${path}.gitops.credentialSecretName`), + credentialTokenKey: text(gitops.credentialTokenKey, `${path}.gitops.credentialTokenKey`), + credentialUsername: text(gitops.credentialUsername, `${path}.gitops.credentialUsername`), + }, + deployment: structuredClone(deployment), + }; +} + +function parseCutoverTarget(id: string, value: Record): SelfMediaCutoverTarget { + const path = `cutover.targets.${id}`; + const source = record(value.source, `${path}.source`); + const destination = record(value.destination, `${path}.destination`); + const prepare = record(value.prepare, `${path}.prepare`); + const rollback = record(value.rollback, `${path}.rollback`); + const mode = text(value.mode, `${path}.mode`); + if (mode !== "host-process-to-kubernetes") throw new Error(`${path}.mode must be host-process-to-kubernetes`); + if (boolean(prepare.requiresFinalConfirmation, `${path}.prepare.requiresFinalConfirmation`) !== true) throw new Error(`${path}.prepare.requiresFinalConfirmation must be true`); + return { + id, + mode, + source: { + workspace: absolutePath(source.workspace, `${path}.source.workspace`), + publicAddress: url(source.publicAddress, `${path}.source.publicAddress`), + healthUrl: url(source.healthUrl, `${path}.source.healthUrl`), + stopCommand: stringArray(source.stopCommand, `${path}.source.stopCommand`), + startCommand: stringArray(source.startCommand, `${path}.source.startCommand`), + dataPaths: stringArray(source.dataPaths, `${path}.source.dataPaths`).map((item, index) => relativePath(item, `${path}.source.dataPaths[${index}]`)), + excludes: stringArray(source.excludes, `${path}.source.excludes`).map((item, index) => relativePath(item, `${path}.source.excludes[${index}]`)), + }, + destination: { + route: text(destination.route, `${path}.destination.route`), + namespace: kubernetesName(destination.namespace, `${path}.destination.namespace`), + application: kubernetesName(destination.application, `${path}.destination.application`), + publicAddress: url(destination.publicAddress, `${path}.destination.publicAddress`), + healthUrl: url(destination.healthUrl, `${path}.destination.healthUrl`), + dataClaim: kubernetesName(destination.dataClaim, `${path}.destination.dataClaim`), + codexClaim: kubernetesName(destination.codexClaim, `${path}.destination.codexClaim`), + }, + prepare: { + requiresFinalConfirmation: true, + phases: stringArray(prepare.phases, `${path}.prepare.phases`), + }, + rollback: { + phases: stringArray(rollback.phases, `${path}.rollback.phases`), + dataPolicy: text(rollback.dataPolicy, `${path}.rollback.dataPolicy`), + }, + }; +} + +function record(value: unknown, path: string): Record { + if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error(`${selfMediaConfigRef}.${path} must be an object`); + return value as Record; +} + +function text(value: unknown, path: string): string { + if (typeof value !== "string" || value.length === 0 || value.includes("\n")) throw new Error(`${selfMediaConfigRef}.${path} must be a non-empty single-line string`); + return value; +} + +function integer(value: unknown, path: string): number { + if (!Number.isInteger(value)) throw new Error(`${selfMediaConfigRef}.${path} must be an integer`); + return value as number; +} + +function boolean(value: unknown, path: string): boolean { + if (typeof value !== "boolean") throw new Error(`${selfMediaConfigRef}.${path} must be a boolean`); + return value; +} + +function stringArray(value: unknown, path: string): string[] { + if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || item.length === 0 || item.includes("\n"))) throw new Error(`${selfMediaConfigRef}.${path} must be an array of non-empty single-line strings`); + return [...value] as string[]; +} + +function kubernetesName(value: unknown, path: string): string { + const result = text(value, path); + if (!/^[a-z0-9](?:[-a-z0-9]*[a-z0-9])?$/u.test(result) || result.length > 63) throw new Error(`${selfMediaConfigRef}.${path} must be a Kubernetes name`); + return result; +} + +function absolutePath(value: unknown, path: string): string { + const result = text(value, path); + if (!result.startsWith("/") || result.split("/").includes("..")) throw new Error(`${selfMediaConfigRef}.${path} must be an absolute path without ..`); + return result; +} + +function relativePath(value: unknown, path: string): string { + const result = text(value, path); + if (result.startsWith("/") || result.split("/").includes("..")) throw new Error(`${selfMediaConfigRef}.${path} must be a relative path without ..`); + return result; +} + +function url(value: unknown, path: string): string { + const result = text(value, path); + const parsed = new URL(result); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error(`${selfMediaConfigRef}.${path} must use http or https`); + if (parsed.username.length > 0 || parsed.password.length > 0) throw new Error(`${selfMediaConfigRef}.${path} must not contain credentials`); + return result; +} diff --git a/scripts/src/selfmedia-delivery-renderer.ts b/scripts/src/selfmedia-delivery-renderer.ts new file mode 100644 index 00000000..acffbfe9 --- /dev/null +++ b/scripts/src/selfmedia-delivery-renderer.ts @@ -0,0 +1,356 @@ +import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { spawnSync } from "node:child_process"; + +import { + resolveSelfMediaDeliveryTarget, + selfMediaDeliveryConfigRef, + selfMediaEffectiveDeployment, + type SelfMediaDeliveryTarget, +} from "./selfmedia-config"; +import { stableJsonSha256 } from "./stable-json"; + +export interface SelfMediaRendererBinding { + readonly consumer: { + readonly id: string; + readonly node: string; + readonly lane: string; + readonly namespace: string; + readonly pipeline: string; + readonly pipelineRunPrefix: string; + readonly sourceArtifact: { + readonly configRef: string; + readonly mode: string; + readonly renderer: string; + }; + }; + readonly repository: { + readonly id: string; + readonly params: Readonly>; + }; +} + +export interface SelfMediaRenderedPipeline { + readonly pipeline: Record; + readonly configRef: string; + readonly effectiveConfigSha256: string; + readonly sourceWorktreeRemote: string; +} + +export function renderSelfMediaDesiredPipeline(binding: SelfMediaRendererBinding, sourceWorktree: string): SelfMediaRenderedPipeline { + const target = resolveSelfMediaDeliveryTarget(binding.consumer.node); + const configRef = selfMediaDeliveryConfigRef(target.id); + if (binding.consumer.sourceArtifact.renderer !== "selfmedia-runtime" || binding.consumer.sourceArtifact.mode !== "embedded-pipeline-spec") { + throw new Error("selfmedia-runtime requires embedded-pipeline-spec"); + } + if (binding.consumer.sourceArtifact.configRef !== configRef) { + throw new Error(`sourceArtifact.configRef must equal resolved owning selector ${configRef}; observed ${binding.consumer.sourceArtifact.configRef}`); + } + assertBinding(target, binding); + assertServiceRepositoryContract(sourceWorktree); + const effectiveDeployment = selfMediaEffectiveDeployment(target); + validateRuntimeRender(sourceWorktree, effectiveDeployment); + const effectiveDeploymentB64 = Buffer.from(`${Bun.YAML.stringify(effectiveDeployment).trim()}\n`, "utf8").toString("base64"); + return { + pipeline: pipeline(target, effectiveDeploymentB64), + configRef, + effectiveConfigSha256: stableJsonSha256(target), + sourceWorktreeRemote: target.source.worktreeRemote, + }; +} + +export function selfMediaSourceWorktreeRemote(targetId: string): string { + return resolveSelfMediaDeliveryTarget(targetId).source.worktreeRemote; +} + +function pipeline(target: SelfMediaDeliveryTarget, effectiveDeploymentB64: string): Record { + const params = [ + "revision", + "source-snapshot-prefix", + "git-read-url", + "gitops-read-url", + "gitops-write-url", + "gitops-username", + "gitops-secret-name", + ]; + const taskParams = params.map((name) => ({ name, type: "string" })); + const taskParamBindings = params.map((name) => ({ name, value: `$(params.${name})` })); + return { + apiVersion: "tekton.dev/v1", + kind: "Pipeline", + metadata: { + name: target.ci.pipeline, + namespace: target.ci.namespace, + labels: { + "app.kubernetes.io/name": target.ci.pipeline, + "app.kubernetes.io/part-of": "selfmedia-factory", + "app.kubernetes.io/managed-by": "unidesk", + }, + }, + spec: { + params: taskParams, + workspaces: [], + tasks: [{ + name: "build-and-publish", + params: taskParamBindings, + taskSpec: { + params: taskParams, + volumes: [ + { name: "workspace", emptyDir: { sizeLimit: target.ci.workspaceSize } }, + { name: "buildkit-state", emptyDir: { sizeLimit: "12Gi" } }, + { name: "tmp", emptyDir: { sizeLimit: "4Gi" } }, + { + name: "gitops-token", + secret: { + secretName: "$(params.gitops-secret-name)", + defaultMode: 288, + items: [{ key: target.gitops.credentialTokenKey, path: "token" }], + }, + }, + ], + steps: [ + sourceStep(target, effectiveDeploymentB64), + prepareBuildkitStep(target), + imageBuildStep(target), + gitOpsPublishStep(target), + ], + }, + }], + }, + }; +} + +function sourceStep(target: SelfMediaDeliveryTarget, effectiveDeploymentB64: string): Record { + const proxy = target.build.proxy; + return { + name: "immutable-source", + image: target.ci.toolImage, + imagePullPolicy: "IfNotPresent", + workingDir: "/workspace", + env: [ + { name: "HTTP_PROXY", value: requiredString(proxy.http, "build.proxy.http") }, + { name: "HTTPS_PROXY", value: requiredString(proxy.https, "build.proxy.https") }, + { name: "ALL_PROXY", value: requiredString(proxy.all, "build.proxy.all") }, + { name: "NO_PROXY", value: requiredStringArray(proxy.noProxy, "build.proxy.noProxy").join(",") }, + ], + script: `#!/bin/sh +set -eu +source_commit="$(params.revision)" +snapshot_prefix="$(params.source-snapshot-prefix)" +rm -rf /workspace/source /workspace/release +askpass=/tmp/selfmedia-source-git-askpass +cat >"$askpass" <<'ASKPASS' +#!/bin/sh +case "$1" in + *Username*) printf '%s' "$SELFMEDIA_GIT_USERNAME" ;; + *Password*) cat /var/run/selfmedia-gitops/token ;; + *) exit 1 ;; +esac +ASKPASS +chmod 700 "$askpass" +export SELFMEDIA_GIT_USERNAME="$(params.gitops-username)" +export GIT_ASKPASS="$askpass" +export GIT_TERMINAL_PROMPT=0 +git clone --filter=blob:none --no-checkout "$(params.git-read-url)" /workspace/source +cd /workspace/source +git fetch --depth=1 --filter=blob:none origin "+$snapshot_prefix/$source_commit:refs/remotes/origin/selfmedia-source-snapshot" +git checkout --detach "$source_commit" +test "$(git rev-parse HEAD)" = "$source_commit" +rm -f "$askpass" +printf '%s' '${effectiveDeploymentB64}' | base64 -d > /workspace/effective-deployment.yaml +bun install --frozen-lockfile +bun deploy/scripts/prepare-build.ts \\ + --config /workspace/effective-deployment.yaml \\ + --source-root /workspace/source \\ + --source-commit "$source_commit" \\ + --source-snapshot-prefix "$snapshot_prefix" \\ + --output-dir /workspace/release +`, + securityContext: nonRootSecurity(), + volumeMounts: [ + { name: "workspace", mountPath: "/workspace" }, + { name: "tmp", mountPath: "/tmp" }, + { name: "gitops-token", mountPath: "/var/run/selfmedia-gitops", readOnly: true }, + ], + }; +} + +function prepareBuildkitStep(target: SelfMediaDeliveryTarget): Record { + return { + name: "prepare-buildkit-state", + image: target.ci.toolImage, + imagePullPolicy: "IfNotPresent", + script: "#!/bin/sh\nset -eu\nmkdir -p /home/user/.local/share/buildkit\nchown -R 1000:1000 /home/user/.local/share/buildkit\n", + securityContext: { runAsUser: 0, runAsGroup: 0 }, + volumeMounts: [{ name: "buildkit-state", mountPath: "/home/user/.local/share/buildkit" }], + }; +} + +function imageBuildStep(target: SelfMediaDeliveryTarget): Record { + return { + name: "image-build", + image: target.ci.buildkitImage, + imagePullPolicy: "IfNotPresent", + workingDir: "/workspace", + env: [ + { name: "SOURCE_COMMIT", value: "$(params.revision)" }, + { name: "BUILDKITD_FLAGS", value: "--oci-worker-no-process-sandbox --oci-worker-net=host --allow-insecure-entitlement network.host" }, + ], + script: "#!/bin/sh\nset -eu\nexec /bin/sh /workspace/source/deploy/scripts/build-image.sh\n", + securityContext: { privileged: true, runAsUser: 1000, runAsGroup: 1000 }, + volumeMounts: [ + { name: "workspace", mountPath: "/workspace" }, + { name: "buildkit-state", mountPath: "/home/user/.local/share/buildkit" }, + { name: "tmp", mountPath: "/tmp" }, + ], + }; +} + +function gitOpsPublishStep(target: SelfMediaDeliveryTarget): Record { + return { + name: "gitops-publish", + image: target.ci.toolImage, + imagePullPolicy: "IfNotPresent", + workingDir: "/workspace", + env: [{ name: "SOURCE_COMMIT", value: "$(params.revision)" }], + script: `#!/bin/sh +set -eu +askpass=/tmp/selfmedia-git-askpass +cat >"$askpass" <<'ASKPASS' +#!/bin/sh +case "$1" in + *Username*) printf '%s' "$SELFMEDIA_GIT_USERNAME" ;; + *Password*) cat /var/run/selfmedia-gitops/token ;; + *) exit 1 ;; +esac +ASKPASS +chmod 700 "$askpass" +export SELFMEDIA_GIT_USERNAME="$(params.gitops-username)" +export GIT_ASKPASS="$askpass" +export GIT_TERMINAL_PROMPT=0 +bun /workspace/source/deploy/scripts/publish-gitops.ts \\ + --config /workspace/effective-deployment.yaml \\ + --source-root /workspace/source \\ + --metadata /workspace/build-metadata.json \\ + --source-commit "$SOURCE_COMMIT" \\ + --worktree /workspace/selfmedia-gitops +rm -f "$askpass" +`, + securityContext: nonRootSecurity(), + volumeMounts: [ + { name: "workspace", mountPath: "/workspace" }, + { name: "tmp", mountPath: "/tmp" }, + { name: "gitops-token", mountPath: "/var/run/selfmedia-gitops", readOnly: true }, + ], + }; +} + +function assertBinding(target: SelfMediaDeliveryTarget, binding: SelfMediaRendererBinding): void { + const expected: Record = { + node: target.node, + lane: target.lane, + namespace: target.ci.namespace, + pipeline: target.ci.pipeline, + pipelineRunPrefix: target.ci.pipelineRunPrefix, + }; + const observed: Record = { + node: binding.consumer.node, + lane: binding.consumer.lane, + namespace: binding.consumer.namespace, + pipeline: binding.consumer.pipeline, + pipelineRunPrefix: binding.consumer.pipelineRunPrefix, + }; + for (const [key, value] of Object.entries(expected)) { + if (observed[key] !== value) throw new Error(`PaC ${binding.consumer.id} ${key}=${observed[key]} does not match selfmedia owner ${value}`); + } + const requiredParams: Readonly> = { + node: target.node, + source_branch: target.source.branch, + source_snapshot_prefix: target.source.snapshotPrefix, + git_read_url: target.source.readUrl, + gitops_read_url: target.gitops.readUrl, + gitops_write_url: target.gitops.writeUrl, + gitops_branch: target.gitops.branch, + gitops_username: target.gitops.credentialUsername, + gitops_secret_name: target.gitops.credentialSecretName, + pipeline_name: target.ci.pipeline, + pipeline_run_prefix: target.ci.pipelineRunPrefix, + service_account: target.ci.serviceAccountName, + image_repository: target.build.imageRepository, + pipeline_timeout: target.ci.pipelineTimeout, + }; + for (const [key, value] of Object.entries(requiredParams)) { + if (binding.repository.params[key] !== value) throw new Error(`PaC ${binding.consumer.id} repository params.${key} must match selfmedia owner`); + } +} + +function assertServiceRepositoryContract(sourceWorktree: string): void { + const required = [ + "deploy/Dockerfile", + "deploy/scripts/prepare-build.ts", + "deploy/scripts/build-image.sh", + "deploy/scripts/publish-gitops.ts", + "deploy/scripts/render-runtime.ts", + ]; + for (const file of required) { + if (!existsSync(resolve(sourceWorktree, file))) throw new Error(`selfmedia source repository is missing renderer contract file ${file}`); + } +} + +function validateRuntimeRender(sourceWorktree: string, effectiveDeployment: Record): void { + const temporary = mkdtempSync(resolve(tmpdir(), "unidesk-selfmedia-render-")); + const configPath = resolve(temporary, "effective-deployment.yaml"); + try { + writeFileSync(configPath, `${Bun.YAML.stringify(effectiveDeployment).trim()}\n`, "utf8"); + const result = spawnSync("bun", [ + "deploy/scripts/render-runtime.ts", + "--config", configPath, + "--image", `127.0.0.1:5000/selfmedia/newsroom@sha256:${"a".repeat(64)}`, + "--source-commit", "b".repeat(40), + ], { + cwd: sourceWorktree, + encoding: "utf8", + maxBuffer: 16 * 1024 * 1024, + timeout: 60_000, + }); + if (result.error !== undefined) throw result.error; + if (result.status !== 0) { + const evidence = `${result.stderr || result.stdout || "render-runtime failed"}`.trim().slice(-2000); + throw new Error(`selfmedia effective deployment failed service renderer validation: ${evidence}`); + } + const output = result.stdout; + const required = ["kind: Deployment", "kind: Service", "kind: PersistentVolumeClaim", "kind: NetworkPolicy", "hostPort: 4317", "automountServiceAccountToken: false"]; + for (const marker of required) { + if (!output.includes(marker)) throw new Error(`selfmedia service renderer output is missing ${marker}`); + } + const forbidden = ["kind: Secret", "kind: Role\n", "kind: RoleBinding", "kind: ClusterRole", "kind: ClusterRoleBinding"]; + for (const marker of forbidden) { + if (output.includes(marker)) throw new Error(`selfmedia service renderer output contains forbidden ${marker.trim()}`); + } + if ((output.match(/kind: PersistentVolumeClaim/gu) ?? []).length !== 2) throw new Error("selfmedia service renderer must produce exactly two PVCs"); + if ((output.match(/kind: NetworkPolicy/gu) ?? []).length < 3) throw new Error("selfmedia service renderer must produce at least three NetworkPolicies"); + } finally { + rmSync(temporary, { recursive: true, force: true }); + } +} + +function nonRootSecurity(): Record { + return { + runAsUser: 1000, + runAsGroup: 1000, + runAsNonRoot: true, + allowPrivilegeEscalation: false, + capabilities: { drop: ["ALL"] }, + }; +} + +function requiredString(value: unknown, path: string): string { + if (typeof value !== "string" || value.length === 0) throw new Error(`${path} must be a non-empty string`); + return value; +} + +function requiredStringArray(value: unknown, path: string): string[] { + if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || item.length === 0)) throw new Error(`${path} must be an array of non-empty strings`); + return value as string[]; +} diff --git a/scripts/src/selfmedia.ts b/scripts/src/selfmedia.ts new file mode 100644 index 00000000..148a91fb --- /dev/null +++ b/scripts/src/selfmedia.ts @@ -0,0 +1,253 @@ +import { createHash } from "node:crypto"; +import { createReadStream, existsSync, lstatSync, readdirSync } from "node:fs"; +import { relative, resolve, sep } from "node:path"; + +import { resolveSelfMediaCutoverTarget, resolveSelfMediaDeliveryTarget, selfMediaConfigRef, type SelfMediaCutoverTarget } from "./selfmedia-config"; + +type CutoverAction = "plan" | "prepare" | "status" | "rollback"; + +interface CutoverOptions { + readonly targetId: string | null; + readonly dryRun: boolean; + readonly confirm: boolean; +} + +interface SourceDataSummary { + readonly available: boolean; + readonly files: number; + readonly bytes: number; + readonly digest: string | null; + readonly digestBasis: "relative-path,size,sha256-content"; + readonly missingPaths: readonly string[]; + readonly excludedPaths: readonly string[]; + readonly valuesPrinted: false; +} + +export async function runSelfMediaCommand(args: string[]): Promise> { + if (args.length === 0 || args.includes("--help") || args.includes("-h") || args[0] === "help") return help(); + if (args[0] !== "cutover") return { ok: false, error: "unsupported-selfmedia-command", args, help: help() }; + const action = args[1]; + if (action !== "plan" && action !== "prepare" && action !== "status" && action !== "rollback") { + return { ok: false, error: "unsupported-selfmedia-cutover-command", args, help: help() }; + } + const options = parseOptions(args.slice(2)); + const target = resolveSelfMediaCutoverTarget(options.targetId); + const delivery = resolveSelfMediaDeliveryTarget(target.id); + if (action === "plan") return cutoverPlan(target, delivery.route); + if (action === "status") return await cutoverStatus(target, delivery.route); + if (action === "prepare") return await cutoverPrepare(target, delivery.route, options); + return cutoverRollback(target, delivery.route, options); +} + +function help(): Record { + return { + ok: true, + command: "selfmedia cutover plan|prepare|status|rollback", + configTruth: selfMediaConfigRef, + usage: [ + "bun scripts/cli.ts selfmedia cutover plan --target NC01", + "bun scripts/cli.ts selfmedia cutover prepare --target NC01 --dry-run", + "bun scripts/cli.ts selfmedia cutover status --target NC01", + "bun scripts/cli.ts selfmedia cutover rollback --target NC01 --dry-run", + ], + boundary: "当前入口只做配置校验、源数据摘要和切换计划;不停止服务、不复制数据、不访问 k8s、不切换端口。", + valuesPrinted: false, + }; +} + +function parseOptions(args: string[]): CutoverOptions { + let targetId: string | null = null; + let dryRun = false; + let confirm = false; + for (let index = 0; index < args.length; index += 1) { + const token = args[index]; + if (token === "--target") { + const value = args[index + 1]; + if (value === undefined || value.startsWith("--")) throw new Error("--target requires a value"); + targetId = value; + index += 1; + continue; + } + if (token === "--dry-run") { + dryRun = true; + continue; + } + if (token === "--confirm") { + confirm = true; + continue; + } + if (token === "--json") continue; + throw new Error(`unsupported selfmedia cutover option ${token}`); + } + if (dryRun && confirm) throw new Error("--dry-run and --confirm are mutually exclusive"); + return { targetId, dryRun, confirm }; +} + +function cutoverPlan(target: SelfMediaCutoverTarget, deliveryRoute: string): Record { + return { + ok: true, + action: "selfmedia-cutover-plan", + target: target.id, + mode: target.mode, + mutation: false, + configTruth: `${selfMediaConfigRef}#cutover.targets.${target.id}`, + routeAligned: target.destination.route === deliveryRoute, + source: { + workspace: target.source.workspace, + healthUrl: target.source.healthUrl, + dataPaths: target.source.dataPaths, + excludes: target.source.excludes, + }, + destination: target.destination, + phases: target.prepare.phases, + rollback: { + phases: target.rollback.phases, + dataPolicy: target.rollback.dataPolicy, + }, + safety: { + requiresFinalConfirmation: target.prepare.requiresFinalConfirmation, + oldLifecycleStateExcluded: target.source.excludes.includes(".state/server.json"), + runtimeExecutorAvailable: false, + }, + next: `bun scripts/cli.ts selfmedia cutover prepare --target ${target.id} --dry-run`, + valuesPrinted: false, + }; +} + +async function cutoverStatus(target: SelfMediaCutoverTarget, deliveryRoute: string): Promise> { + const sourceData = await summarizeSourceData(target); + return { + ok: sourceData.available, + action: "selfmedia-cutover-status", + target: target.id, + mutation: false, + configReady: target.destination.route === deliveryRoute && target.source.excludes.includes(".state/server.json"), + sourceWorkspacePresent: existsSync(target.source.workspace), + sourceData, + runtimeObservation: { + status: "not-requested", + reason: "read-only CLI does not contact the host service, k8s, Argo, or public endpoint", + }, + next: sourceData.available + ? `bun scripts/cli.ts selfmedia cutover prepare --target ${target.id} --dry-run` + : "Restore or select the YAML-declared source workspace before preparing cutover.", + valuesPrinted: false, + }; +} + +async function cutoverPrepare(target: SelfMediaCutoverTarget, deliveryRoute: string, options: CutoverOptions): Promise> { + const sourceData = await summarizeSourceData(target); + const confirmedButUnsupported = options.confirm; + return { + ok: sourceData.available && !confirmedButUnsupported, + action: "selfmedia-cutover-prepare", + target: target.id, + mode: options.dryRun || !options.confirm ? "dry-run" : "blocked-confirmed", + mutation: false, + configReady: target.destination.route === deliveryRoute && target.source.excludes.includes(".state/server.json"), + sourceData, + proposedPhases: target.prepare.phases, + portHandoff: { + port: 4317, + oldServiceStopCommand: target.source.stopCommand, + destinationHealthUrl: target.destination.healthUrl, + executed: false, + }, + blocked: confirmedButUnsupported ? { + code: "selfmedia-cutover-runtime-executor-not-implemented", + reason: "本次交付只建立 YAML 所有权和只读/干运行入口,禁止从此命令执行真实切换。", + } : null, + next: confirmedButUnsupported + ? "由后续受控执行器实现 PVC seed、最终增量、端口释放、Argo 同步和原入口验收后再开放 --confirm。" + : `bun scripts/cli.ts selfmedia cutover status --target ${target.id}`, + valuesPrinted: false, + }; +} + +function cutoverRollback(target: SelfMediaCutoverTarget, deliveryRoute: string, options: CutoverOptions): Record { + const confirmedButUnsupported = options.confirm; + return { + ok: !confirmedButUnsupported, + action: "selfmedia-cutover-rollback", + target: target.id, + mode: options.dryRun || !options.confirm ? "dry-run" : "blocked-confirmed", + mutation: false, + configReady: target.destination.route === deliveryRoute, + proposedPhases: target.rollback.phases, + dataPolicy: target.rollback.dataPolicy, + blocked: confirmedButUnsupported ? { + code: "selfmedia-cutover-runtime-executor-not-implemented", + reason: "回滚命令当前只输出计划,不操作 Argo、Deployment、宿主进程或数据。", + } : null, + valuesPrinted: false, + }; +} + +async function summarizeSourceData(target: SelfMediaCutoverTarget): Promise { + const root = resolve(target.source.workspace); + const missingPaths: string[] = []; + const files: { absolute: string; relative: string; size: number }[] = []; + for (const declared of target.source.dataPaths) { + const absolute = resolve(root, declared); + if (!inside(root, absolute) || !existsSync(absolute)) { + missingPaths.push(declared); + continue; + } + collectFiles(root, absolute, target.source.excludes, files); + } + files.sort((left, right) => left.relative.localeCompare(right.relative)); + const digest = createHash("sha256"); + let bytes = 0; + for (const file of files) { + const contentDigest = await hashFile(file.absolute); + bytes += file.size; + digest.update(file.relative); + digest.update("\0"); + digest.update(String(file.size)); + digest.update("\0"); + digest.update(contentDigest); + digest.update("\n"); + } + return { + available: missingPaths.length === 0, + files: files.length, + bytes, + digest: missingPaths.length === 0 ? `sha256:${digest.digest("hex")}` : null, + digestBasis: "relative-path,size,sha256-content", + missingPaths, + excludedPaths: target.source.excludes, + valuesPrinted: false, + }; +} + +function collectFiles(root: string, current: string, excludes: readonly string[], output: { absolute: string; relative: string; size: number }[]): void { + const currentRelative = relative(root, current).split(sep).join("/"); + if (isExcluded(currentRelative, excludes)) return; + const stat = lstatSync(current); + if (stat.isSymbolicLink()) return; + if (stat.isFile()) { + output.push({ absolute: current, relative: currentRelative, size: stat.size }); + return; + } + if (!stat.isDirectory()) return; + for (const entry of readdirSync(current).sort()) collectFiles(root, resolve(current, entry), excludes, output); +} + +function isExcluded(path: string, excludes: readonly string[]): boolean { + return excludes.some((excluded) => path === excluded || path.startsWith(`${excluded}/`)); +} + +function inside(root: string, candidate: string): boolean { + return candidate === root || candidate.startsWith(`${root}${sep}`); +} + +async function hashFile(path: string): Promise { + const digest = createHash("sha256"); + await new Promise((resolvePromise, reject) => { + const stream = createReadStream(path); + stream.on("data", (chunk) => digest.update(chunk)); + stream.on("error", reject); + stream.on("end", resolvePromise); + }); + return digest.digest("hex"); +}