merge: 接入 selfmedia 受控交付
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: 回滚默认不反向覆盖旧数据,需单独的数据恢复决策。
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user