merge: 接入 selfmedia 受控交付
Pipelines as Code CI / hwlab-web-probe-sentinel-nc01- Success
Pipelines as Code CI / platform-infra-gitea-nc01- Success
Pipelines as Code CI / unidesk-host- Success

This commit is contained in:
Codex
2026-07-13 07:57:43 +02:00
17 changed files with 1845 additions and 71 deletions
+20
View File
@@ -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
@@ -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:
+43
View File
@@ -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
+262
View File
@@ -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: 回滚默认不反向覆盖旧数据,需单独的数据恢复决策。
+9
View File
@@ -444,6 +444,15 @@ async function main(): Promise<void> {
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;
+8 -3
View File
@@ -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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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`);
}
}
@@ -78,6 +78,8 @@ test("owning YAML renders one child Application and the complete durable bridge
"Deployment",
"Role",
"RoleBinding",
"Role",
"RoleBinding",
"ValidatingAdmissionPolicy",
"ValidatingAdmissionPolicyBinding",
"ServiceAccount",
+7 -4
View File
@@ -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
+3 -2
View File
@@ -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),
};
@@ -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", () => {
@@ -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")" \
@@ -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<string, unknown>; 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<string, unknown> {
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<string, string> {
return partOf === "agentrun"
? {
function pipelineRunLabels(binding: PacSourceArtifactBinding, partOf: "agentrun" | "hwlab" | "selfmedia"): Record<string, string> {
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<string, unknown>, binding: PacS
function provenanceFromManifest(manifest: Record<string, unknown>): 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;
}
@@ -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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, path: string): PacConsumer["deliveryProvenance"] {
const required = y.booleanField(value, "required", path);
if (!required) return null;
@@ -537,7 +564,7 @@ function parseConsumerDeliveryProvenance(value: Record<string, unknown>, path: s
function parseSourceArtifact(value: Record<string, unknown>, 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<string, unknown>, 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<string, unknown>): Record<string, unknown
const runtime = record(payload.runtime);
const diagnostics = record(payload.diagnostics);
const admissionProvenance = record(payload.admissionProvenance);
const consumerBootstrap = record(payload.consumerBootstrap);
const webhooks = arrayRecords(payload.webhooks);
const rawArtifact = record(payload.artifact);
const sourceObservation = record(rawArtifact.sourceObservation);
@@ -1427,7 +1502,7 @@ function statusSummary(payload: Record<string, unknown>): Record<string, unknown
};
const pipelineGate = pipelineRunGate(latest);
return {
ready: payload.crdPresent === true && String(payload.controllerReady ?? "0/0") !== "0/0" && pipelineGate.ok && diagnostics.ok !== false,
ready: payload.crdPresent === true && String(payload.controllerReady ?? "0/0") !== "0/0" && pipelineGate.ok && diagnostics.ok !== false && consumerBootstrap.ready !== false,
crdPresent: payload.crdPresent === true,
controllerReady: payload.controllerReady,
repositoryCondition: payload.repositoryCondition,
@@ -1444,6 +1519,7 @@ function statusSummary(payload: Record<string, unknown>): Record<string, unknown
runtime,
diagnostics,
admissionProvenance,
consumerBootstrap,
valuesPrinted: false,
};
}
@@ -1615,6 +1691,11 @@ function consumerObservationSummary(consumer: PacConsumer): Record<string, unkno
argoNamespace: consumer.argoNamespace,
argoApplication: consumer.argoApplication,
repositoryRef: consumer.repositoryRef,
runnerServiceAccount: consumer.runnerServiceAccount,
argoRepositoryCredential: consumer.argoBootstrap?.repositoryCredential === null || consumer.argoBootstrap?.repositoryCredential === undefined ? null : {
secretName: consumer.argoBootstrap.repositoryCredential.secretName,
usernameConfigured: consumer.argoBootstrap.repositoryCredential.username.length > 0,
},
valuesPrinted: false,
};
}
@@ -1666,6 +1747,8 @@ function compactPlanJson(result: Record<string, unknown>): Record<string, unknow
argoNamespace: consumer.argoNamespace,
argoApplication: consumer.argoApplication,
repositoryRef: consumer.repositoryRef,
runnerServiceAccount: consumer.runnerServiceAccount,
argoRepositoryCredential: consumer.argoRepositoryCredential,
},
secrets: result.secrets,
policy: result.policy,
@@ -1729,6 +1812,7 @@ function compactStatusSummary(summary: Record<string, unknown>): Record<string,
},
pipelineRunGate: summary.pipelineRunGate,
admissionProvenance: summary.admissionProvenance,
consumerBootstrap: summary.consumerBootstrap,
sourceObservation: summary.sourceObservation,
artifact: {
imageStatus: artifact.imageStatus,
@@ -1950,6 +2034,9 @@ function renderStatus(result: Record<string, unknown>): 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<string, unknown>): 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)]))),
+167 -35
View File
@@ -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<string, string>;
randomHex: Record<string, number>;
randomBase64Url: Record<string, { bytes: number; prefix: string }>;
}
interface EnvSourceFileConfig {
sourceRef: string;
type: "env";
requiredKeys: string[];
createIfMissing: {
enabled: boolean;
values: Record<string, string>;
randomHex: Record<string, number>;
randomBase64Url: Record<string, { bytes: number; prefix: string }>;
};
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<string, unknown> {
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<string, unknown> {
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<Rec
statusCommand: `bun scripts/cli.ts job status ${job.id} --tail-bytes 12000`,
};
}
const sources = inspectSources(distribution, true);
const sources = inspectSources(distribution, true, selectedSourceRefs(distribution, options));
if (!sources.ok) return { ok: false, action: "secrets-sync", mode: "blocked-local-sources", mutation: true, config: configSummary(distribution, options), localSources: sourceSummary(sources) };
const desired = desiredSecrets(distribution, options, sources);
const missing = desired.flatMap((secret) => secret.missingKeys);
@@ -257,7 +273,7 @@ async function sync(config: UniDeskConfig, options: SecretsOptions): Promise<Rec
async function status(config: UniDeskConfig, options: SecretsOptions): Promise<Record<string, unknown>> {
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<string, unknown>, path: string): SourceFileConfig {
function parseSourceFile(record: Record<string, unknown>, 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<string, unknown>, 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<string, unknown>, path: string): SourceF
};
}
function parseRawSourceFile(record: Record<string, unknown>, 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<string, unknown>, path: string): DistributionTarget {
return {
id: simpleId(stringField(record, "id", path), `${path}.id`),
@@ -338,17 +387,22 @@ function parseKubernetesSecret(record: Record<string, unknown>, 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<string, SourceFileConfig>();
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<string>();
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<string>): SourceInspection {
const root = secretRoot(config);
const materials = new Map<string, SourceMaterial>();
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<string> {
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<Record<string, unknown>> {
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<string, unknown> {
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<string, string>): 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<string, unknown>, key: string, path: string)
return value;
}
function envKeyField(obj: Record<string, unknown>, key: string, path: string): string {
return envKeyValue(stringField(obj, key, path), `${path}.${key}`);
function externalSourceRefField(obj: Record<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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 {
+299
View File
@@ -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<string, unknown> & {
readonly dockerfile: string;
readonly imageRepository: string;
readonly networkMode: string;
readonly proxy: Record<string, unknown>;
};
readonly gitops: Record<string, unknown> & {
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<string, unknown>;
}
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<Record<string, SelfMediaDeliveryTarget>>;
readonly cutoverTargets: Readonly<Record<string, SelfMediaCutoverTarget>>;
}
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<string, unknown> {
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<string, unknown>): 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<string, unknown>): 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<string, unknown> {
if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error(`${selfMediaConfigRef}.${path} must be an object`);
return value as Record<string, unknown>;
}
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;
}
+356
View File
@@ -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<Record<string, string>>;
};
}
export interface SelfMediaRenderedPipeline {
readonly pipeline: Record<string, unknown>;
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<string, unknown> {
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<string, unknown> {
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<string, unknown> {
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<string, unknown> {
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<string, unknown> {
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<string, string> = {
node: target.node,
lane: target.lane,
namespace: target.ci.namespace,
pipeline: target.ci.pipeline,
pipelineRunPrefix: target.ci.pipelineRunPrefix,
};
const observed: Record<string, string> = {
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<Record<string, string>> = {
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<string, unknown>): 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<string, unknown> {
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[];
}
+253
View File
@@ -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<Record<string, unknown>> {
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<string, unknown> {
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<string, unknown> {
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<Record<string, unknown>> {
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<Record<string, unknown>> {
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<string, unknown> {
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<SourceDataSummary> {
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<string> {
const digest = createHash("sha256");
await new Promise<void>((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");
}