fix: move Gitea public edge to NC01

This commit is contained in:
pikastech
2026-07-20 17:45:03 +02:00
parent 1437048152
commit 0cfb5329c7
16 changed files with 154 additions and 313 deletions
+4 -1
View File
@@ -320,7 +320,10 @@ bun scripts/cli.ts hwlab nodes control-plane legacy-cicd --help
- 同 deliveryId 与同 payload 必须幂等,异 payload 必须返回 `409`PVC 不可用或容量满必须返回 `503` 并使 readiness=false
- inbox worker 使用 YAML 声明的有界 backoff 自动重试,重启后恢复 `accepted` / `processing`;Caddy 只可在请求尚未持久化时做小于 10 秒的 body-safe 有界重试。
- Durable inbox 只是 webhook delivery 的持久接收与重试 journal,不是业务 source/ref 的第二份真相,也不得被 PaC、Tekton、GitOps、Argo 或 runtime 当作源码/read model。最终 source authority 仍只有 Gitea authority branch 与 immutable snapshot。默认只使用 `platform-infra gitea mirror webhook status` 观察 accepted -> committed 分层。会 POST hook test 的 mutation 测试入口已经删除;连通性只能通过真正只读的 status、GET 与 readiness 观察,不能制造伪 push。平台 bootstrap 必须先落入 Git-backed YAML/controller/source,并由正常 PR merge 的自动事件验收;禁止 Gitea -> GitHub 回写、轮询/read-model fallback、Gitea Actions/act_runner fallback 或第二 source authority。
- Gitea webhook/FRPC/Caddy 自动链变更的 closeout 必须观察修复 PR 合并后的真实 delivery、rollout、只读 status/GET/readiness 证据;Secret 或 ConfigMap 声明已生成不等于 connector Pod 已加载新 proxy/path,也不得用人工 apply 或 hook test 补齐。
- Gitea webhook/public-edge 自动链变更的 closeout
- 必须观察修复 PR 合并后的真实 delivery、rollout 和只读 status/GET/readiness 证据;
- Secret、ConfigMap 或 public-edge 候选已生成不等于 connector Pod 与公网 route 已加载新配置;
- 不得用人工 apply 或 hook test 补齐。
- Sentinel 内部 publish capability 的唯一真相是 `config/platform-infra/pipelines-as-code.yaml#capabilities.sentinelInternalPublish`,当前默认关闭。Pod env、ownerReferences、ServiceAccount 与 metadata 不能证明 creator#1769 的 admission-owned provenance 落地前不得启用。`.tekton` 保持既有自动 `publish-current` 入口,禁止改成人工 publish。
- PaC PipelineRun 必须区分外层 `outer-pac-event`、内层 `inner-deterministic-publish``unknown`。只有唯一外层事件可驱动 latest/status/debug-step/closeout;内层只能作为未绑定执行观察,缺少 admission-owned 父子证据时不得按前缀推断。
- `cicd branch-follower` 对当前 PaC consumer 已退役,只保留 `status|events|logs` 与只读 `debug-step` 历史诊断。默认入口、任意 `--config` 和混合/未知 authority 都不能恢复 `apply|run-once|state-write` 等写能力,也不能猜测默认 node。
@@ -88,7 +88,11 @@ AgentRun YAML-only lane closeout 必须同时看当前 k8s git-mirror source sna
JD01/NC01 `agentrun-<node>-v02` 已迁移为单一路径:GitHub PR merge -> GitHub webhook -> Gitea controlled mirror -> immutable snapshot -> Gitea webhook -> Pipelines-as-Code -> Tekton -> GitOps/Argo -> k8s runtime。不要保留 Gitea Actions、act_runner、legacy branch-follower 或第二套 trigger fallback。CLI 必须组合 `config/platform-infra/gitea.yaml``config/platform-infra/pipelines-as-code.yaml` 精确解析 consumer;未知、歧义或 source identity drift 都必须 `mutation=false` 并 fail-closed。
Gitea 只作为受控 mirror/source authority 和 PaC webhook 源。公开 Web UI 是 `https://gitea.pikapython.com`k8s 内部 clone/read 必须走 `http://gitea-http.devops-infra.svc.cluster.local:3000/...`,不要让内部 CI、Argo 或 runtime 通过公网域名回环。需要 CI 匿名内部 read 的 mirror repo 必须在 YAML 中声明 `publicRead: true`bootstrap/apply 必须修正既有 repo/org visibility,而不只依赖 create-time 默认值。
- Gitea 只作为受控 mirror/source authority 和 PaC webhook 源
- 公开 Web UI 是 `https://gitea.hwpod.com`,由 NC01 public-edge 直连 ClusterIP
- k8s 内部 clone/read 必须走 `http://gitea-http.devops-infra.svc.cluster.local:3000/...`,不得让内部 CI、Argo 或 runtime 通过公网域名回环;
- 需要 CI 匿名内部 read 的 mirror repo 必须在 YAML 中声明 `publicRead: true`
- bootstrap/apply 必须修正既有 repo/org visibility,不能只依赖 create-time 默认值。
PaC 默认只读状态入口是:
@@ -40,7 +40,12 @@ GitHub 是唯一上游写入权威。目标 source branch 的 GitHub PR merge
- 旧人工 mutation 入口只允许在 owning YAML 精确解析为 `legacy-manual` 后从 scoped help 使用;Gitea/PaC migrated consumer 和 `unknown` authority 都不得回退到 legacy 实现。
- 包含 `gitea-actions` 的历史 snapshot prefix 只为既有 ref 保留,不代表 Gitea Actions 仍是有效触发架构。
- node 级状态入口是 `bun scripts/cli.ts cicd status --node <NODE>`consumer 级默认下钻入口是 `platform-infra pipelines-as-code status|history --target <NODE> --consumer <id>`
- 共用 GitHub 上游的多 node PaC consumer 不得共用 webhook bridge URL。每个 target node 在 `config/platform-infra/gitea.yaml#targets[].webhookSync` 声明自己的 GitHub webhook 公网路径与 FRP remote port,并由平台自动交付渲染 node-local bridge、FRPC proxy 和 PK01 Caddy 路径;禁止在 source PR 合并后人工 apply 补齐。
- Gitea 公网入口:
- 公开 Web UI 与 owning webhook path 统一由 `config/platform-infra/gitea.yaml#app.publicExposure` 声明;
- NC01 public-edge 直接连接 ClusterIP Service
- 不得为 Gitea 保留 FRPC、PK01 Caddy 或公网回环 fallback
- target 仍通过 `targets[].webhookSync.publicPath` 保持 hook 身份隔离;
- 禁止在 source PR 合并后人工 apply 补齐。
- PaC Repository CR `spec.url` 必须与 Gitea webhook payload 中的 URL 一致。`config/platform-infra/pipelines-as-code.yaml#repositories[].url` 保持公网 Gitea repository URLk8s 内网 service URL 只能写入 `cloneUrl``params.git_read_url`;否则 PaC 会记录 `cannot find a repository match` 且不会创建 PipelineRun。
- JD01 与 NC01 共用的 repository 必须传入 target-specific `node` Repository param,每个 `.tekton` PipelineRun 必须用 `pipelinesascode.tekton.dev/on-cel-expression` 过滤该参数。一个 target 的 push 不得在同一 target cluster 创建另一 target 的 PipelineRun。
@@ -211,7 +216,12 @@ GitHub webhook delivery 的持久接收与 refs 提交必须分层:
- 只有同一 delivery 的 exact-after immutable snapshot 与 authority branch 经 atomic push 后重新读取 refs 证明一致,才能进入 `committed`
- Caddy 只可对尚未成功落盘的请求做小于 10 秒、body-safe 的有界重试,不能把代理成功当作 refs committed。
当前 bridge 的权威状态链是 GitHub delivery GUID -> durable inbox record -> post-push refs proof -> live refs。HTTP `202` 后记录仍是 `accepted``refsCommitted=false`;只有 worker 完成 exact refs 复核后才成为 committed。旧 durable journal 上线前的记录可以标记 `pre-durable-bootstrap`,但不得标记 committed。`ingressRetry` 在自动 PK01 host reconciler 完成前保持关闭,不能由操作者手工补配置。
- 当前 bridge 的权威状态链
- GitHub delivery GUID -> durable inbox record -> post-push refs proof -> live refs
- HTTP `202` 后记录仍是 `accepted``refsCommitted=false`
- 只有 worker 完成 exact refs 复核后才成为 committed
- 旧 durable journal 上线前的记录可以标记 `pre-durable-bootstrap`,但不得标记 committed
- `ingressRetry` 由 owning YAML 与 NC01 public-edge 自动交付共同控制,不能由操作者手工补配置。
Durable inbox 只是 webhook delivery 的持久接收与重试 journal,不是业务 source/ref 的第二份真相,也不得被 PaC、Tekton、GitOps、Argo 或 runtime 当作源码/read model。最终 source authority 仍只有 Gitea authority branch 与 immutable snapshot。`webhook status` 默认摘要必须显示 accepted -> processing -> committed/failed 分层,并将同一 delivery 的 accepted correlation 与 committed refs proof 关联。非 `202`、缺 accepted correlation、accepted 后未 committed 或进入 failed 都是失败/stale;只能修 webhook bridge、inbox worker、受控 mirror、snapshot controller 或 owning YAML,禁止输出或执行人工 `REPAIR`、mirror sync、直接 Gitea push 等补齐命令。
+14 -28
View File
@@ -176,9 +176,6 @@ sourceAuthority:
email: platform-infra-ci@unidesk.local
cas:
maxAttempts: 3
frpc:
proxyName: platform-infra-gitea-jd01-github-webhook
remotePort: 22081
responsibilities:
- name: source-read
current: legacy-git-mirror
@@ -669,9 +666,6 @@ targets:
enabled: true
webhookSync:
publicPath: /_unidesk/github-to-gitea/nc01
frpc:
proxyName: platform-infra-gitea-nc01-github-webhook
remotePort: 22086
app:
name: gitea
@@ -687,37 +681,29 @@ app:
httpPort: 3000
sshPort: 2222
server:
domain: gitea.pikapython.com
rootUrl: https://gitea.pikapython.com/
domain: gitea.hwpod.com
rootUrl: https://gitea.hwpod.com/
sshDomain: gitea-http.devops-infra.svc.cluster.local
protocol: http
startSshServer: true
publicExposure:
enabled: true
publicBaseUrl: https://gitea.pikapython.com
secretRoot: /root/.unidesk/.state/secrets
publicBaseUrl: https://gitea.hwpod.com
upstream: http://gitea-http.devops-infra.svc.cluster.local:3000
healthPath: /api/healthz
dns:
hostname: gitea.pikapython.com
expectedA: 82.156.23.220
hostname: gitea.hwpod.com
expectedA: 152.53.229.148
resolvers: [1.1.1.1, 8.8.8.8, 223.5.5.5, 114.114.114.114]
frpc:
routes:
- path: /_unidesk/github-to-gitea/nc01*
upstream: http://gitea-github-sync.devops-infra.svc.cluster.local:8080
responseHeaderTimeout: 7s
- path: "*"
upstream: http://gitea-http.devops-infra.svc.cluster.local:3000
legacyCleanup:
deploymentName: gitea-frpc
secretName: gitea-frpc-secrets
secretKey: frpc.toml
image: fatedier/frpc:v0.68.1
serverAddr: 82.156.23.220
serverPort: 22000
proxyName: platform-infra-gitea-nc01-web
remotePort: 22080
localIP: gitea-http.devops-infra.svc.cluster.local
localPort: 3000
tokenSourceRef: platform-infra/pk01-frp.env
tokenSourceKey: FRP_TOKEN
pk01:
route: PK01
caddyConfigPath: /etc/caddy/Caddyfile
caddyServiceName: caddy
responseHeaderTimeoutSeconds: 600
database:
type: sqlite3
path: /var/lib/gitea/gitea.db
+11 -11
View File
@@ -146,7 +146,7 @@ repositories:
variables:
NODE: JD01
LANE: v02
url: https://gitea.pikapython.com/mirrors/pikasTech-agentrun
url: https://gitea.hwpod.com/mirrors/pikasTech-agentrun
- extends: templates.repositories.agentrunV02
variables:
NODE: NC01
@@ -156,7 +156,7 @@ repositories:
NODE: JD01
LANE: v03
sourceSnapshotPrefix: refs/unidesk/snapshots/gitea-actions/unidesk-master
url: https://gitea.pikapython.com/mirrors/pikasTech-unidesk
url: https://gitea.hwpod.com/mirrors/pikasTech-unidesk
- extends: templates.repositories.sentinelV03
variables:
NODE: NC01
@@ -177,7 +177,7 @@ repositories:
name: sub2rank-nc01
namespace: devops-infra
providerType: gitea
url: https://gitea.pikapython.com/mirrors/pikasTech-apistate
url: https://gitea.hwpod.com/mirrors/pikasTech-apistate
cloneUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikasTech-apistate.git
owner: mirrors
repo: pikasTech-apistate
@@ -209,7 +209,7 @@ repositories:
name: sub2rank-development-nc01
namespace: devops-infra
providerType: gitea
url: https://gitea.pikapython.com/mirrors/pikasTech-apistate-development
url: https://gitea.hwpod.com/mirrors/pikasTech-apistate-development
cloneUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikasTech-apistate-development.git
owner: mirrors
repo: pikasTech-apistate-development
@@ -241,7 +241,7 @@ repositories:
name: hwpod-home-nc01
namespace: devops-infra
providerType: gitea
url: https://gitea.pikapython.com/mirrors/pikainc-hwpod-home
url: https://gitea.hwpod.com/mirrors/pikainc-hwpod-home
cloneUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-hwpod-home.git
owner: mirrors
repo: pikainc-hwpod-home
@@ -273,7 +273,7 @@ repositories:
variables:
NODE: JD01
LANE: v03
url: https://gitea.pikapython.com/mirrors/pikasTech-HWLAB
url: https://gitea.hwpod.com/mirrors/pikasTech-HWLAB
- extends: templates.repositories.hwlabV03
variables:
NODE: NC01
@@ -282,7 +282,7 @@ repositories:
name: selfmedia-nc01
namespace: selfmedia-ci
providerType: gitea
url: https://gitea.pikapython.com/mirrors/pikainc-selfmedia
url: https://gitea.hwpod.com/mirrors/pikainc-selfmedia
cloneUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-selfmedia.git
owner: mirrors
repo: pikainc-selfmedia
@@ -317,7 +317,7 @@ repositories:
name: pikaoa-dev-nc01
namespace: pikaoa-ci
providerType: gitea
url: https://gitea.pikapython.com/mirrors/pikainc-pikaoa
url: https://gitea.hwpod.com/mirrors/pikainc-pikaoa
cloneUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-pikaoa.git
owner: mirrors
repo: pikainc-pikaoa
@@ -822,7 +822,7 @@ templates:
name: "agentrun-${nodeLower}-v02"
namespace: agentrun-ci
providerType: gitea
url: https://gitea.pikapython.com/mirrors/pikasTech-agentrun
url: https://gitea.hwpod.com/mirrors/pikasTech-agentrun
cloneUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikasTech-agentrun.git
owner: mirrors
repo: pikasTech-agentrun
@@ -846,7 +846,7 @@ templates:
name: "sentinel-${nodeLower}-v03"
namespace: devops-infra
providerType: gitea
url: https://gitea.pikapython.com/mirrors/pikasTech-unidesk
url: https://gitea.hwpod.com/mirrors/pikasTech-unidesk
cloneUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikasTech-unidesk.git
owner: mirrors
repo: pikasTech-unidesk
@@ -877,7 +877,7 @@ templates:
name: "hwlab-${nodeLower}-v03"
namespace: hwlab-ci
providerType: gitea
url: https://gitea.pikapython.com/mirrors/pikasTech-HWLAB
url: https://gitea.hwpod.com/mirrors/pikasTech-HWLAB
cloneUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikasTech-HWLAB.git
owner: mirrors
repo: pikasTech-HWLAB
+3
View File
@@ -69,6 +69,9 @@ targets:
# 共享入口包含长耗时流式 API,首响应头预算统一按 10 分钟配置。
responseHeaderTimeoutSeconds: 600
sites:
- id: gitea
configRef: config/platform-infra/gitea.yaml
path: app.publicExposure
- id: artifact-registry
configRef: config/platform-infra/k3s-clusters.yaml
path: targets.d601.k3s.registryMirror
+7 -1
View File
@@ -28,7 +28,13 @@ Network identity is part of the evidence. `127.0.0.1:5000` may mean runner-pod l
Manual hotfixes are allowed only to recover and learn the missing contract. The durable close-out is a repo-owned preflight, desired-state check, CLI behavior, or reference update, followed by the same verification from the actual runner environment. A healthy old live service does not prove CD success; success requires the formal job to pass and live commit or digest identity to match the desired state.
For node-scoped HWLAB/Gitea/PaC lanes, CI/CD closeout stops at PipelineRun success, GitOps/Argo sync and health, runtime readiness/provenance, git-mirror alignment, and the formal public HTTPS `/health` probe declared by YAML. Browser automation, `web-probe`, Playwright, DOM assertions and user-path E2E are post-deploy product validation, not CI/CD gates. FRP/Caddy public exposure failures are diagnosed by aligning YAML publicExposure, GitOps-rendered frpc config, Secret key presence, frpc login/proxy logs, PK01/Caddy loopback and target-side HTTPS probe; Secret values are never recorded in docs or issue comments.
- 对于 node-scoped HWLAB/Gitea/PaC lane
- CI/CD 收口只覆盖 PipelineRun 成功、GitOps/Argo 同步与健康、runtime readiness/provenance、git-mirror 对齐,以及 YAML 声明的正式公网 HTTPS `/health` 探针;
- 浏览器自动化、`web-probe`、Playwright、DOM 断言和用户路径 E2E 属于部署后的产品验收,不是 CI/CD 门禁;
- 公网暴露故障必须按 owning YAML 选中的 managed edge 诊断;
- Gitea 通过 NC01 public-edge 直连 ClusterIP
- 仍由 YAML 选择 FRP 的服务继续检查各自声明的 FRP/Caddy 路径;
- Secret 值不得写入文档或 issue 评论。
## Target Shape
+13 -3
View File
@@ -180,7 +180,10 @@
## Gitea 与 Pipelines-as-Code 边界
- Gitea mirror 与 Pipelines-as-Code 是 UniDesk 运维的 CI source/trigger 服务`config/platform-infra/gitea.yaml``config/platform-infra/pipelines-as-code.yaml` 分别拥有 mirror/webhook 与 PaC Repository/consumer 配置;repo URL、snapshot、webhook、public exposure、FRP/Caddy port、token sourceRef 和 PaC params 不得隐藏在代码特例中。
- Gitea mirror 与 Pipelines-as-Code 是 UniDesk 运维的 CI source/trigger 服务
- `config/platform-infra/gitea.yaml` 拥有 mirror、webhook 与 NC01 public-edge 暴露;
- `config/platform-infra/pipelines-as-code.yaml` 拥有 PaC Repository 与 consumer 配置;
- repo URL、snapshot、webhook、token sourceRef 和 PaC params 不得隐藏在代码特例中。
- Delivery authority resolver 必须组合上述两份 YAML,并按 consumer id、node、lane、upstream repository、branch 和 Gitea repository 精确关联。不得通过 URL 片段、repo 名称前缀或仓库专属 `if` 推断迁移状态。零匹配、多匹配和配置错误都必须返回 `unknown``mutation=false` 并停止生成或执行写操作。
- PaC consumer 的唯一正式触发是目标 source branch 的 GitHub PR merge。完整自动链固定为:GitHub PR merge -> GitHub webhook -> Gitea controlled mirror -> immutable snapshot -> Gitea webhook -> PaC/Pipelines-as-Code -> Tekton -> GitOps/Argo -> runtime 运行面。
- PR 合并后的交付边界:
@@ -231,8 +234,15 @@
- cache export 使用原生 `ignore-error=true` 或等价非阻塞语义;
- cache export 抖动不得在镜像已发布后制造业务构建失败或人工补跑需求。
- GitHub 始终是 upstream write authority。Bridge 只允许 GitHub -> Gitea 更新 YAML 声明的 branch 与 immutable snapshot,不得反向写 GitHub,也不得增加 polling 或第二 trigger path。
- 每个 target 必须使用 YAML 独立声明的 webhook path、bridge 与 FRP port;多个节点不得复用 hook URL。Public Gitea Web UI 与 webhook 只能通过明确 Caddy path routing 共享 hostnamek8s 内部 consumer 必须使用 ClusterIP URL。
- Secret 或 ConfigMap 改变后,Gitea connector 的显式平台配置维护必须等待 workload rollout。成功写 Secret 不能证明 `frpc` 已加载新配置;端到端交付证据仍必须来自之后的新正常 PR merge。
- Gitea 公网入口:
- 公开 Web UI 与 owning webhook path 由 `app.publicExposure.routes` 明确分流;
- NC01 public-edge 直接连接 ClusterIP Service
- Gitea 不得依赖 PK01、FRPC、Ingress、NodePort 或公网域名回环;
- k8s 内部 consumer 必须使用 ClusterIP URL。
- Secret 或 ConfigMap 改变后的收口:
- Gitea connector 的显式平台配置维护必须等待 workload rollout
- 成功写 Secret 或生成 public-edge 候选不能证明 bridge 已加载新配置;
- 端到端交付证据仍必须来自之后的新正常 PR merge。
- Shared upstream repository 必须通过 Repository CR params 与 `pipelinesascode.tekton.dev/on-cel-expression` 隔离 target-specific `.tekton`。NC01 delivery 不得在 JD01 cluster 创建 PipelineRunhistory 也不得把同一 PipelineRun 归属给多个 consumer。退役 branch-follower 的 unknown 或多 node scope 只返回 follower 自身的只读 status/events/logs 和稳定修复引用,禁止猜测 `JD01` 或其他默认 node 的 PaC status/history。
- PaC migrated lane 保持单 trigger path,禁止新增 Gitea Actions、`act_runner`、branch-follower 或脚本 fallback。`config/cicd-gitea-actions-poc.yaml` 只是归档 POC;历史 snapshot 名称不代表该路径仍在运行。
- k8s runtime 从拉取已构建 image 起保持 Docker-free。CI 可以使用 YAML 声明的 build tooling,但 runtime 不得依赖 Docker socket 或 daemon。
+51 -56
View File
@@ -1,7 +1,6 @@
import { dirname } from "node:path";
import { rootPath } from "./config";
import { createYamlFieldReader, readYamlRecord } from "./platform-infra-ops-library";
import type { PublicServiceExposure } from "./platform-infra-public-service";
import { validateGiteaWebhookTiming } from "./platform-infra-gitea-caddy";
import type { GiteaWebhookIngressRetry } from "./platform-infra-gitea-caddy";
@@ -24,9 +23,26 @@ export interface GiteaTarget {
export interface GiteaTargetWebhookSync {
publicPath: string;
frpc: {
proxyName: string;
remotePort: number;
}
export interface GiteaPublicExposure {
enabled: boolean;
publicBaseUrl: string;
upstream: string;
healthPath: string;
dns: {
hostname: string;
expectedA: string;
resolvers: string[];
};
routes: Array<{
path: string;
upstream: string;
responseHeaderTimeout?: string;
}>;
legacyCleanup: {
deploymentName: string;
secretName: string;
};
}
@@ -92,7 +108,7 @@ export interface GiteaConfig {
protocol: "http" | "https";
startSshServer: boolean;
};
publicExposure: PublicServiceExposure & { secretRoot: string };
publicExposure: GiteaPublicExposure;
database: {
type: "sqlite3";
path: string;
@@ -238,10 +254,6 @@ export interface GiteaWebhookSync {
};
};
gitOpsDelivery: GiteaWebhookGitOpsDelivery;
frpc: {
proxyName: string;
remotePort: number;
};
}
export interface GiteaWebhookGitOpsDelivery {
@@ -490,7 +502,6 @@ function parseWebhookSync(record: Record<string, unknown>, path: string): GiteaW
const candidateGate = y.objectField(bridge, "candidateGate", `${path}.bridge`);
const inbox = y.objectField(bridge, "inbox", `${path}.bridge`);
const gitOpsDelivery = y.objectField(record, "gitOpsDelivery", path);
const frpc = y.objectField(record, "frpc", path);
return {
enabled: y.booleanField(record, "enabled", path),
direction: y.enumField(record, "direction", path, ["github-to-gitea"] as const),
@@ -560,10 +571,6 @@ function parseWebhookSync(record: Record<string, unknown>, path: string): GiteaW
retry: parseWebhookRetry(y.objectField(bridge, "retry", `${path}.bridge`), `${path}.bridge.retry`),
},
gitOpsDelivery: parseWebhookGitOpsDelivery(gitOpsDelivery, `${path}.gitOpsDelivery`),
frpc: {
proxyName: y.stringField(frpc, "proxyName", `${path}.frpc`),
remotePort: y.portField(frpc, "remotePort", `${path}.frpc`),
},
};
}
@@ -612,41 +619,36 @@ function parseWebhookRetry(record: Record<string, unknown>, path: string): Gitea
};
}
function parsePublicExposure(record: Record<string, unknown>, path: string): PublicServiceExposure & { secretRoot: string } {
function parsePublicExposure(record: Record<string, unknown>, path: string): GiteaPublicExposure {
const dns = y.objectField(record, "dns", path);
const frpc = y.objectField(record, "frpc", path);
const pk01 = y.objectField(record, "pk01", path);
const legacyCleanup = y.objectField(record, "legacyCleanup", path);
const publicBaseUrl = httpsUrlField(record, "publicBaseUrl", path);
const hostname = y.hostField(dns, "hostname", `${path}.dns`);
if (new URL(publicBaseUrl).hostname !== hostname) throw new Error(`${configLabel}.${path}.dns.hostname must match publicBaseUrl`);
const routes = y.arrayOfRecords(record.routes, `${path}.routes`).map((route, index) => {
const routePath = `${path}.routes[${index}]`;
return {
path: y.stringField(route, "path", routePath),
upstream: urlField(route, "upstream", routePath),
...(route.responseHeaderTimeout === undefined
? {}
: { responseHeaderTimeout: y.stringField(route, "responseHeaderTimeout", routePath) }),
};
});
return {
enabled: y.booleanField(record, "enabled", path),
publicBaseUrl,
secretRoot: y.absolutePathField(record, "secretRoot", path),
upstream: urlField(record, "upstream", path),
healthPath: y.apiPathField(record, "healthPath", path),
dns: {
hostname,
expectedA: y.stringField(dns, "expectedA", `${path}.dns`),
resolvers: y.stringArrayField(dns, "resolvers", `${path}.dns`),
},
frpc: {
deploymentName: y.kubernetesNameField(frpc, "deploymentName", `${path}.frpc`),
secretName: y.kubernetesNameField(frpc, "secretName", `${path}.frpc`),
secretKey: y.stringField(frpc, "secretKey", `${path}.frpc`),
image: y.stringField(frpc, "image", `${path}.frpc`),
serverAddr: y.hostField(frpc, "serverAddr", `${path}.frpc`),
serverPort: y.portField(frpc, "serverPort", `${path}.frpc`),
proxyName: y.stringField(frpc, "proxyName", `${path}.frpc`),
remotePort: y.portField(frpc, "remotePort", `${path}.frpc`),
localIP: y.hostField(frpc, "localIP", `${path}.frpc`),
localPort: y.portField(frpc, "localPort", `${path}.frpc`),
tokenSourceRef: secretSourceRefField(frpc, "tokenSourceRef", `${path}.frpc`),
tokenSourceKey: y.envKeyField(frpc, "tokenSourceKey", `${path}.frpc`),
},
pk01: {
route: y.stringField(pk01, "route", `${path}.pk01`),
caddyConfigPath: y.absolutePathField(pk01, "caddyConfigPath", `${path}.pk01`),
caddyServiceName: y.stringField(pk01, "caddyServiceName", `${path}.pk01`),
responseHeaderTimeoutSeconds: y.integerField(pk01, "responseHeaderTimeoutSeconds", `${path}.pk01`),
routes,
legacyCleanup: {
deploymentName: y.kubernetesNameField(legacyCleanup, "deploymentName", `${path}.legacyCleanup`),
secretName: y.kubernetesNameField(legacyCleanup, "secretName", `${path}.legacyCleanup`),
},
};
}
@@ -745,13 +747,8 @@ function parseTarget(record: Record<string, unknown>, index: number): GiteaTarge
}
function parseTargetWebhookSync(record: Record<string, unknown>, path: string): GiteaTargetWebhookSync {
const frpc = y.objectField(record, "frpc", path);
return {
publicPath: y.apiPathField(record, "publicPath", path),
frpc: {
proxyName: y.stringField(frpc, "proxyName", `${path}.frpc`),
remotePort: y.portField(frpc, "remotePort", `${path}.frpc`),
},
};
}
@@ -811,7 +808,6 @@ function validateConfig(gitea: GiteaConfig): void {
if (gitea.sourceAuthority.webhookSync.enabled) {
const events = new Set(gitea.sourceAuthority.webhookSync.events);
if (!events.has("push")) throw new Error(`${configLabel}.sourceAuthority.webhookSync.events must include push for GitHub -> Gitea source sync`);
if (gitea.sourceAuthority.webhookSync.frpc.remotePort === gitea.app.publicExposure.frpc.remotePort) throw new Error(`${configLabel}.sourceAuthority.webhookSync.frpc.remotePort must differ from app.publicExposure.frpc.remotePort`);
const ingressRetry = gitea.sourceAuthority.webhookSync.ingressRetry;
const hookReconcile = gitea.sourceAuthority.webhookSync.hookReconcile;
const bridge = gitea.sourceAuthority.webhookSync.bridge;
@@ -865,14 +861,15 @@ function validateConfig(gitea: GiteaConfig): void {
}
}
const webhookPaths = new Set<string>();
const webhookPorts = new Set<number>();
for (const route of webhookCaddyRoutes(gitea)) {
if (webhookPaths.has(route.publicPath)) throw new Error(`${configLabel} webhook publicPath must be unique: ${route.publicPath}`);
if (webhookPorts.has(route.remotePort)) throw new Error(`${configLabel} webhook frpc.remotePort must be unique: ${route.remotePort}`);
if (route.remotePort === gitea.app.publicExposure.frpc.remotePort) throw new Error(`${configLabel} webhook frpc.remotePort must differ from app public exposure port: ${route.remotePort}`);
webhookPaths.add(route.publicPath);
webhookPorts.add(route.remotePort);
for (const publicPath of webhookPublicPaths(gitea)) {
if (webhookPaths.has(publicPath)) throw new Error(`${configLabel} webhook publicPath must be unique: ${publicPath}`);
webhookPaths.add(publicPath);
}
const ownerTarget = resolveTarget(gitea, gitea.sourceAuthority.webhookSync.hookReconcile.ownerTargetId);
const ownerSync = targetWebhookSync(gitea, ownerTarget);
const webhookRoute = gitea.app.publicExposure.routes.find((route) => route.path === `${ownerSync.publicPath}*`);
if (webhookRoute === undefined) throw new Error(`${configLabel}.app.publicExposure.routes must expose the owner webhook path ${ownerSync.publicPath}*`);
if (gitea.app.publicExposure.routes.at(-1)?.path !== "*") throw new Error(`${configLabel}.app.publicExposure.routes must end with the Gitea fallback route`);
const repoKeys = new Set<string>();
for (const repo of gitea.sourceAuthority.repositories) {
if (repoKeys.has(repo.key)) throw new Error(`${configLabel}.sourceAuthority.repositories contains duplicate key ${repo.key}`);
@@ -899,21 +896,19 @@ export function targetWebhookSync(gitea: GiteaConfig, target: GiteaTarget): Gite
return {
...base,
publicPath: target.webhookSync.publicPath,
frpc: target.webhookSync.frpc,
};
}
export function webhookCaddyRoutes(gitea: GiteaConfig): Array<{ publicPath: string; remotePort: number }> {
export function webhookPublicPaths(gitea: GiteaConfig): string[] {
const sync = gitea.sourceAuthority.webhookSync;
if (!sync.enabled) return [];
const routes = new Map<string, { publicPath: string; remotePort: number }>();
routes.set(sync.publicPath, { publicPath: sync.publicPath, remotePort: sync.frpc.remotePort });
const paths = [sync.publicPath];
for (const target of gitea.targets) {
if (!target.enabled || target.webhookSync === null) continue;
const targetSync = targetWebhookSync(gitea, target);
routes.set(targetSync.publicPath, { publicPath: targetSync.publicPath, remotePort: targetSync.frpc.remotePort });
paths.push(targetSync.publicPath);
}
return [...routes.values()];
return paths;
}
function positiveInteger(obj: Record<string, unknown>, key: string, path: string): number {
@@ -27,7 +27,6 @@ test("stdin file envelope materializes a payload larger than the current Gitea m
repositories: JSON.stringify([{ key: "sentinel", branch: "master" }]),
hookTopology: JSON.stringify({ version: 1, repositories: [] }),
statusEvaluator: "print('ok')\n",
frpcToml: "serverAddr = '127.0.0.1'\n",
};
const materializer = renderGiteaRemotePayloadMaterializer(payloads);
const result = spawnSync("sh", ["-s"], {
@@ -70,6 +69,8 @@ test("Gitea remote runner consumes materialized files instead of base64 environm
expect(source).not.toContain("UNIDESK_GITEA_STATUS_EVALUATOR_B64");
expect(source).not.toContain("UNIDESK_GITEA_REPOS_B64");
expect(source).not.toContain("UNIDESK_GITEA_FRPC_TOML_B64");
expect(source).not.toContain("UNIDESK_GITEA_FRPC_ENABLED");
expect(source).not.toContain("frpc-rollout");
expect(source).toContain("kubectl apply --server-side --force-conflicts --dry-run=server");
expect(source).toContain("target server dry-run failed; manifest apply skipped");
expect(source.indexOf("kubectl apply --server-side --force-conflicts --dry-run=server")).toBeLessThan(
@@ -5,7 +5,6 @@ export interface GiteaRemotePayloads {
repositories: string;
hookTopology: string;
statusEvaluator: string;
frpcToml: string;
}
interface GiteaRemotePayloadFile {
@@ -19,7 +18,6 @@ const payloadFiles = (payloads: GiteaRemotePayloads): GiteaRemotePayloadFile[] =
{ role: "repositories", name: "repos.json", value: payloads.repositories },
{ role: "hookTopology", name: "hook-topology.json", value: payloads.hookTopology },
{ role: "statusEvaluator", name: "platform_infra_gitea_status_evaluator.py", value: payloads.statusEvaluator },
{ role: "frpcToml", name: "frpc.toml", value: payloads.frpcToml },
];
function payloadMarker(file: GiteaRemotePayloadFile): string {
+12 -50
View File
@@ -80,26 +80,12 @@ run_apply() {
if [ "$UNIDESK_GITEA_DRY_RUN" != "1" ]; then
kubectl create namespace "$UNIDESK_GITEA_NAMESPACE" --dry-run=client -o yaml | kubectl apply --server-side --force-conflicts --field-manager="$UNIDESK_GITEA_FIELD_MANAGER" -f - >"$tmp/namespace.out" 2>"$tmp/namespace.err"
fi
if [ "$UNIDESK_GITEA_FRPC_ENABLED" = "1" ] && [ "$UNIDESK_GITEA_DRY_RUN" != "1" ]; then
kubectl -n "$UNIDESK_GITEA_NAMESPACE" create secret generic "$UNIDESK_GITEA_FRPC_SECRET_NAME" \
--from-file="$UNIDESK_GITEA_FRPC_SECRET_KEY=$tmp/frpc.toml" \
--dry-run=client -o yaml | kubectl apply --server-side --force-conflicts --field-manager="$UNIDESK_GITEA_FIELD_MANAGER" -f - >"$tmp/frpc-secret.out" 2>"$tmp/frpc-secret.err"
frpc_rc=$?
else
frpc_rc=0
: >"$tmp/frpc-secret.out"
if [ "$UNIDESK_GITEA_FRPC_ENABLED" = "1" ]; then
printf '%s\n' "dry-run: frpc secret apply skipped" >"$tmp/frpc-secret.err"
else
printf '%s\n' "frpc disabled" >"$tmp/frpc-secret.err"
fi
fi
upsert_webhook_sync_secret
webhook_secret_rc=$?
timeout "$UNIDESK_GITEA_WAIT_TIMEOUT_SECONDS" kubectl apply --server-side --force-conflicts --dry-run=server \
--field-manager="$UNIDESK_GITEA_FIELD_MANAGER" -f "$manifest" >"$tmp/server-dry-run.out" 2>"$tmp/server-dry-run.err"
server_dry_run_rc=$?
if [ "$frpc_rc" -eq 0 ] && [ "$webhook_secret_rc" -eq 0 ]; then
if [ "$webhook_secret_rc" -eq 0 ]; then
if [ "$server_dry_run_rc" -ne 0 ]; then
apply_rc=1
: >"$tmp/apply.out"
@@ -116,7 +102,7 @@ run_apply() {
else
apply_rc=1
: >"$tmp/apply.out"
printf '%s\n' "frpc or webhook secret apply failed; manifest apply skipped" >"$tmp/apply.err"
printf '%s\n' "webhook secret apply failed; manifest apply skipped" >"$tmp/apply.err"
fi
if [ "$apply_rc" -eq 0 ] && [ "$UNIDESK_GITEA_WAIT" = "1" ] && [ "$UNIDESK_GITEA_DRY_RUN" != "1" ]; then
timeout "$UNIDESK_GITEA_WAIT_TIMEOUT_SECONDS" kubectl -n "$UNIDESK_GITEA_NAMESPACE" rollout status "statefulset/$UNIDESK_GITEA_STATEFULSET_NAME" --timeout="${UNIDESK_GITEA_WAIT_TIMEOUT_SECONDS}s" >"$tmp/rollout.out" 2>"$tmp/rollout.err"
@@ -130,25 +116,7 @@ run_apply() {
printf '%s\n' "rollout wait not requested" >"$tmp/rollout.err"
fi
fi
if [ "$apply_rc" -eq 0 ] && [ "$UNIDESK_GITEA_FRPC_ENABLED" = "1" ] && [ "$UNIDESK_GITEA_DRY_RUN" != "1" ]; then
kubectl -n "$UNIDESK_GITEA_NAMESPACE" rollout restart "deployment/$UNIDESK_GITEA_FRPC_DEPLOYMENT_NAME" >"$tmp/frpc-rollout.out" 2>"$tmp/frpc-rollout.err"
frpc_rollout_rc=$?
if [ "$frpc_rollout_rc" -eq 0 ] && [ "$UNIDESK_GITEA_WAIT" = "1" ]; then
timeout "$UNIDESK_GITEA_WAIT_TIMEOUT_SECONDS" kubectl -n "$UNIDESK_GITEA_NAMESPACE" rollout status "deployment/$UNIDESK_GITEA_FRPC_DEPLOYMENT_NAME" --timeout="${UNIDESK_GITEA_WAIT_TIMEOUT_SECONDS}s" >>"$tmp/frpc-rollout.out" 2>>"$tmp/frpc-rollout.err"
frpc_rollout_rc=$?
fi
else
frpc_rollout_rc=0
: >"$tmp/frpc-rollout.out"
if [ "$UNIDESK_GITEA_DRY_RUN" = "1" ]; then
printf '%s\n' "dry-run: frpc rollout skipped" >"$tmp/frpc-rollout.err"
elif [ "$UNIDESK_GITEA_FRPC_ENABLED" = "1" ]; then
printf '%s\n' "manifest apply failed; frpc rollout skipped" >"$tmp/frpc-rollout.err"
else
printf '%s\n' "frpc disabled" >"$tmp/frpc-rollout.err"
fi
fi
if [ "$UNIDESK_GITEA_FRPC_ENABLED" = "0" ] && [ "$UNIDESK_GITEA_DRY_RUN" != "1" ]; then
if [ "$UNIDESK_GITEA_DRY_RUN" != "1" ]; then
kubectl -n "$UNIDESK_GITEA_NAMESPACE" delete deployment "$UNIDESK_GITEA_FRPC_DEPLOYMENT_NAME" --ignore-not-found >"$tmp/frpc-cleanup.out" 2>"$tmp/frpc-cleanup.err"
frpc_cleanup_deploy_rc=$?
kubectl -n "$UNIDESK_GITEA_NAMESPACE" delete secret "$UNIDESK_GITEA_FRPC_SECRET_NAME" --ignore-not-found >>"$tmp/frpc-cleanup.out" 2>>"$tmp/frpc-cleanup.err"
@@ -163,10 +131,6 @@ run_apply() {
: >"$tmp/frpc-cleanup.out"
if [ "$UNIDESK_GITEA_DRY_RUN" = "1" ]; then
printf '%s\n' "dry-run: frpc cleanup skipped" >"$tmp/frpc-cleanup.err"
elif [ "$UNIDESK_GITEA_FRPC_ENABLED" = "1" ]; then
printf '%s\n' "frpc enabled" >"$tmp/frpc-cleanup.err"
else
printf '%s\n' "frpc cleanup skipped" >"$tmp/frpc-cleanup.err"
fi
fi
if [ "$apply_rc" -eq 0 ] && [ "$UNIDESK_GITEA_WEBHOOK_SYNC_ENABLED" = "1" ] && [ "$UNIDESK_GITEA_DRY_RUN" != "1" ]; then
@@ -187,9 +151,9 @@ run_apply() {
printf '%s\n' "webhook sync disabled" >"$tmp/webhook-rollout.err"
fi
fi
python3 - "$frpc_rc" "$webhook_secret_rc" "$server_dry_run_rc" "$apply_rc" "$rollout_rc" "$frpc_rollout_rc" "$webhook_rollout_rc" "$frpc_cleanup_rc" "$tmp/frpc-secret.out" "$tmp/frpc-secret.err" "$tmp/frpc-cleanup.out" "$tmp/frpc-cleanup.err" "$tmp/webhook-secret.out" "$tmp/webhook-secret.err" "$tmp/server-dry-run.out" "$tmp/server-dry-run.err" "$tmp/apply.out" "$tmp/apply.err" "$tmp/rollout.out" "$tmp/rollout.err" "$tmp/frpc-rollout.out" "$tmp/frpc-rollout.err" "$tmp/webhook-rollout.out" "$tmp/webhook-rollout.err" <<'PY'
python3 - "$webhook_secret_rc" "$server_dry_run_rc" "$apply_rc" "$rollout_rc" "$webhook_rollout_rc" "$frpc_cleanup_rc" "$tmp/frpc-cleanup.out" "$tmp/frpc-cleanup.err" "$tmp/webhook-secret.out" "$tmp/webhook-secret.err" "$tmp/server-dry-run.out" "$tmp/server-dry-run.err" "$tmp/apply.out" "$tmp/apply.err" "$tmp/rollout.out" "$tmp/rollout.err" "$tmp/webhook-rollout.out" "$tmp/webhook-rollout.err" <<'PY'
import json, os, sys
frpc_rc, webhook_secret_rc, server_dry_run_rc, apply_rc, rollout_rc, frpc_rollout_rc, webhook_rollout_rc, frpc_cleanup_rc = [int(value) for value in sys.argv[1:9]]
webhook_secret_rc, server_dry_run_rc, apply_rc, rollout_rc, webhook_rollout_rc, frpc_cleanup_rc = [int(value) for value in sys.argv[1:7]]
def text(path, limit=3000):
try:
return open(path, encoding="utf-8", errors="replace").read()[-limit:]
@@ -197,7 +161,7 @@ def text(path, limit=3000):
return ""
dry = os.environ.get("UNIDESK_GITEA_DRY_RUN") == "1"
payload = {
"ok": all(value == 0 for value in [frpc_rc, webhook_secret_rc, server_dry_run_rc, apply_rc, rollout_rc, frpc_rollout_rc, webhook_rollout_rc, frpc_cleanup_rc]),
"ok": all(value == 0 for value in [webhook_secret_rc, server_dry_run_rc, apply_rc, rollout_rc, webhook_rollout_rc, frpc_cleanup_rc]),
"target": os.environ.get("UNIDESK_GITEA_TARGET_ID"),
"route": os.environ.get("UNIDESK_GITEA_ROUTE"),
"namespace": os.environ.get("UNIDESK_GITEA_NAMESPACE"),
@@ -210,14 +174,12 @@ payload = {
"networkPolicy": "allow-all",
},
"steps": {
"frpcSecret": {"exitCode": frpc_rc, "stdoutTail": text(sys.argv[9]), "stderrTail": text(sys.argv[10])},
"frpcCleanup": {"exitCode": frpc_cleanup_rc, "stdoutTail": text(sys.argv[11]), "stderrTail": text(sys.argv[12])},
"webhookSyncSecret": {"exitCode": webhook_secret_rc, "stdoutTail": text(sys.argv[13]), "stderrTail": text(sys.argv[14])},
"serverDryRun": {"exitCode": server_dry_run_rc, "stdoutTail": text(sys.argv[15]), "stderrTail": text(sys.argv[16])},
"apply": {"exitCode": apply_rc, "stdoutTail": text(sys.argv[17]), "stderrTail": text(sys.argv[18])},
"rollout": {"exitCode": rollout_rc, "stdoutTail": text(sys.argv[19]), "stderrTail": text(sys.argv[20])},
"frpcRollout": {"exitCode": frpc_rollout_rc, "stdoutTail": text(sys.argv[21]), "stderrTail": text(sys.argv[22])},
"webhookSyncRollout": {"exitCode": webhook_rollout_rc, "stdoutTail": text(sys.argv[23]), "stderrTail": text(sys.argv[24])},
"legacyFrpcCleanup": {"exitCode": frpc_cleanup_rc, "stdoutTail": text(sys.argv[7]), "stderrTail": text(sys.argv[8])},
"webhookSyncSecret": {"exitCode": webhook_secret_rc, "stdoutTail": text(sys.argv[9]), "stderrTail": text(sys.argv[10])},
"serverDryRun": {"exitCode": server_dry_run_rc, "stdoutTail": text(sys.argv[11]), "stderrTail": text(sys.argv[12])},
"apply": {"exitCode": apply_rc, "stdoutTail": text(sys.argv[13]), "stderrTail": text(sys.argv[14])},
"rollout": {"exitCode": rollout_rc, "stdoutTail": text(sys.argv[15]), "stderrTail": text(sys.argv[16])},
"webhookSyncRollout": {"exitCode": webhook_rollout_rc, "stdoutTail": text(sys.argv[17]), "stderrTail": text(sys.argv[18])},
},
"valuesPrinted": False,
}
+2 -8
View File
@@ -279,14 +279,11 @@ export function renderApply(result: Record<string, unknown>): RenderedCliResult
const remote = record(result.remote);
const steps = record(remote.steps);
const serverDryRunStep = record(steps.serverDryRun);
const frpcStep = record(steps.frpcSecret);
const frpcCleanupStep = record(steps.frpcCleanup);
const legacyFrpcCleanupStep = record(steps.legacyFrpcCleanup);
const webhookSecretStep = record(steps.webhookSyncSecret);
const applyStep = record(steps.apply);
const rolloutStep = record(steps.rollout);
const frpcRolloutStep = record(steps.frpcRollout);
const webhookRolloutStep = record(steps.webhookSyncRollout);
const caddy = record(result.pk01Caddy);
return rendered(result, "platform-infra gitea apply", [
"PLATFORM-INFRA GITEA APPLY",
...table(["TARGET", "NAMESPACE", "MODE", "OK"], [[stringValue(target.id), stringValue(target.namespace), stringValue(result.mode), boolText(result.ok)]]),
@@ -295,14 +292,11 @@ export function renderApply(result: Record<string, unknown>): RenderedCliResult
"STEPS",
...table(["STEP", "EXIT", "DETAIL"], [
["serverDryRun", stringValue(serverDryRunStep.exitCode), compactLine(stringValue(serverDryRunStep.stderrTail, stringValue(serverDryRunStep.stdoutTail)))],
["frpc-secret", stringValue(frpcStep.exitCode), compactLine(stringValue(frpcStep.stderrTail, stringValue(frpcStep.stdoutTail)))],
["frpc-cleanup", stringValue(frpcCleanupStep.exitCode), compactLine(stringValue(frpcCleanupStep.stderrTail, stringValue(frpcCleanupStep.stdoutTail)))],
["legacy-frpc-cleanup", stringValue(legacyFrpcCleanupStep.exitCode), compactLine(stringValue(legacyFrpcCleanupStep.stderrTail, stringValue(legacyFrpcCleanupStep.stdoutTail)))],
["webhook-secret", stringValue(webhookSecretStep.exitCode), compactLine(stringValue(webhookSecretStep.stderrTail, stringValue(webhookSecretStep.stdoutTail)))],
["apply", stringValue(applyStep.exitCode), compactLine(stringValue(applyStep.stderrTail, stringValue(applyStep.stdoutTail)))],
["rollout", stringValue(rolloutStep.exitCode), compactLine(stringValue(rolloutStep.stderrTail, stringValue(rolloutStep.stdoutTail)))],
["frpc-rollout", stringValue(frpcRolloutStep.exitCode), compactLine(stringValue(frpcRolloutStep.stderrTail, stringValue(frpcRolloutStep.stdoutTail)))],
["webhook-rollout", stringValue(webhookRolloutStep.exitCode), compactLine(stringValue(webhookRolloutStep.stderrTail, stringValue(webhookRolloutStep.stdoutTail)))],
["pk01-caddy", stringValue(caddy.exitCode ?? (caddy.ok === false ? 1 : 0)), compactLine(stringValue(caddy.error, stringValue(caddy.status, stringValue(caddy.reason))))],
]),
...remoteErrorLines(result),
"",
+16 -147
View File
@@ -7,16 +7,11 @@ import { CliInputError, type RenderedCliResult } from "./output";
import {
capture,
compactCapture,
fingerprintValues,
parseJsonOutput,
shQuote,
} from "./platform-infra-ops-library";
import { applyPk01CaddyBlock, escapeTomlString, prepareFrpcSecret, renderFrpcManifest } from "./platform-infra-public-service";
import type { PublicServiceTarget, FrpcSecretMaterial } from "./platform-infra-public-service";
import { applyPk01CaddySiteBlock, caddyManagedBlockMarkers } from "./pk01-caddy";
import { deriveGiteaWebhookAttemptBudgetMs, deriveGiteaWebhookResponseHeaderTimeoutMs, renderGiteaWebhookCaddyHandle } from "./platform-infra-gitea-caddy";
import { deriveGiteaWebhookAttemptBudgetMs, deriveGiteaWebhookResponseHeaderTimeoutMs } from "./platform-infra-gitea-caddy";
import { renderGiteaRemotePayloadMaterializer, summarizeGiteaRemotePayloads } from "./platform-infra-gitea-payload";
import { parseEnvFile, readTextFile, redactRepoPath, requiredEnvValue } from "./secrets";
import { PAC_AUTOMATIC_DELIVERY_REFERENCE } from "./cicd-delivery-authority";
import { renderPlatformInfraGiteaDesiredFragments } from "./platform-infra-gitea-desired-fragments";
import {
@@ -41,7 +36,6 @@ import {
readGiteaConfig,
resolveTarget,
targetWebhookSync,
webhookCaddyRoutes,
type GiteaAdminCredential,
type GiteaConfig,
type GiteaGithubCredential,
@@ -92,7 +86,6 @@ interface MirrorSecrets {
interface MirrorRemoteParams {
repos?: GiteaMirrorRepository[];
secrets?: MirrorSecrets;
frpcSecret?: FrpcSecretMaterial;
}
export async function runPlatformInfraGiteaCommand(config: UniDeskConfig, args: string[]): Promise<Record<string, unknown> | RenderedCliResult> {
@@ -252,19 +245,15 @@ async function apply(config: UniDeskConfig, options: ApplyOptions): Promise<Reco
const manifest = renderManifest(gitea, target);
const policy = policyChecks(gitea, target, manifest);
if (!policy.every((check) => check.ok)) return { ok: false, action: "platform-infra-gitea-apply", mode: "policy-blocked", mutation: false, policy };
const frpcSecret = prepareGiteaFrpcSecret(gitea, target);
const targetRepos = repositoriesForTarget(gitea, target);
const secrets = gitea.sourceAuthority.webhookSync.enabled && !options.dryRun
? ensureMirrorSecrets(gitea, target, targetRepos, true, true, true)
: undefined;
const remote = remoteScript("apply", gitea, target, manifest, options, { frpcSecret, secrets });
const remote = remoteScript("apply", gitea, target, manifest, options, { secrets });
const result = await capture(config, target.route, ["sh"], remote.script);
const parsed = parseJsonOutput(result.stdout);
const caddy = !options.dryRun && result.exitCode === 0 && parsed?.ok === true && caddyExposureNeeded(gitea, target)
? await applyGiteaCaddyBlock(config, gitea)
: { ok: true, skipped: true, reason: options.dryRun ? "dry-run" : "apply-not-ready" };
return {
ok: result.exitCode === 0 && parsed?.ok === true && record(caddy).ok !== false,
ok: result.exitCode === 0 && parsed?.ok === true,
action: "platform-infra-gitea-apply",
mode: options.dryRun ? "dry-run" : "confirmed",
mutation: !options.dryRun,
@@ -273,8 +262,12 @@ async function apply(config: UniDeskConfig, options: ApplyOptions): Promise<Reco
policy,
publicExposure: publicExposureSummary(gitea),
payloadTransport: remote.payloadSummary,
secrets: { frpc: frpcSecretSummary(frpcSecret), webhookSync: secrets === undefined ? { enabled: false } : webhookSecretSummary(gitea, target, secrets) },
pk01Caddy: caddy,
secrets: { webhookSync: secrets === undefined ? { enabled: false } : webhookSecretSummary(gitea, target, secrets) },
publicEdge: {
authority: "config/platform-infra/public-edge.yaml",
mutation: false,
next: `bun scripts/cli.ts platform-infra public-edge plan --target ${target.id}`,
},
remote: parsed ?? compactCapture(result, { full: true }),
next: giteaReadOnlyNextCommands(target.id),
};
@@ -739,7 +732,6 @@ ${envVars(gitea, target)}
resources:
requests:
storage: ${app.storage.config.size}
${renderFrpcManifest(publicServiceTarget(gitea, target))}
${renderGithubSyncManifest(gitea, target)}`;
}
@@ -1180,7 +1172,6 @@ function envVars(gitea: GiteaConfig, target: GiteaTarget): string {
}
function remoteScript(action: "apply" | "status" | "validate" | "mirror-preflight" | "mirror-bootstrap" | "mirror-sync" | "mirror-status" | "mirror-webhook-apply" | "mirror-webhook-status", gitea: GiteaConfig, target: GiteaTarget, manifest: string, options: ApplyOptions | MirrorOptions, params: MirrorRemoteParams = {}): { script: string; payloadSummary: Record<string, unknown> } {
const frpcExposure = targetFrpcExposure(gitea, target);
const sync = targetWebhookSync(gitea, target);
const selectedRepositories = params.repos ?? repositoriesForTarget(gitea, target);
const selectedCredentials = githubCredentialsForConsumers(gitea, target, selectedRepositories, false);
@@ -1210,10 +1201,8 @@ function remoteScript(action: "apply" | "status" | "validate" | "mirror-prefligh
.map((credential) => `${credential.gitFetchCredential.secretRef.key}=${githubTokenEnvName(credential.gitFetchCredential.secretRef.key)}`)
.join(","),
UNIDESK_GITEA_NO_PROXY: gitea.sourceAuthority.githubProxy.noProxy.join(","),
UNIDESK_GITEA_FRPC_ENABLED: frpcExposure === null ? "0" : "1",
UNIDESK_GITEA_FRPC_DEPLOYMENT_NAME: frpcExposure?.frpc.deploymentName ?? gitea.app.publicExposure.frpc.deploymentName,
UNIDESK_GITEA_FRPC_SECRET_NAME: frpcExposure?.frpc.secretName ?? gitea.app.publicExposure.frpc.secretName,
UNIDESK_GITEA_FRPC_SECRET_KEY: frpcExposure?.frpc.secretKey ?? gitea.app.publicExposure.frpc.secretKey,
UNIDESK_GITEA_FRPC_DEPLOYMENT_NAME: gitea.app.publicExposure.legacyCleanup.deploymentName,
UNIDESK_GITEA_FRPC_SECRET_NAME: gitea.app.publicExposure.legacyCleanup.secretName,
UNIDESK_GITEA_WEBHOOK_SYNC_ENABLED: gitea.sourceAuthority.webhookSync.enabled ? "1" : "0",
UNIDESK_GITEA_WEBHOOK_PUBLIC_URL: githubWebhookPublicUrl(gitea, target),
UNIDESK_GITEA_WEBHOOK_PATH: sync.publicPath,
@@ -1223,10 +1212,6 @@ function remoteScript(action: "apply" | "status" | "validate" | "mirror-prefligh
UNIDESK_GITEA_WEBHOOK_SERVICE_PORT: String(sync.bridge.httpPort),
UNIDESK_GITEA_WEBHOOK_SECRET_NAME: sync.bridge.secretName,
};
if (params.frpcSecret !== undefined) {
env.UNIDESK_GITEA_FRPC_SECRET_NAME = params.frpcSecret.secretName;
env.UNIDESK_GITEA_FRPC_SECRET_KEY = params.frpcSecret.secretKey;
}
if (params.secrets !== undefined) {
env.UNIDESK_GITEA_ADMIN_USERNAME = params.secrets.adminUsername;
env.UNIDESK_GITEA_ADMIN_PASSWORD = params.secrets.adminPassword;
@@ -1243,7 +1228,6 @@ function remoteScript(action: "apply" | "status" | "validate" | "mirror-prefligh
repositories: JSON.stringify((params.repos ?? []).map((repo) => remoteRepoSpec(repo))),
hookTopology: JSON.stringify(githubHookTopology(gitea)),
statusEvaluator: readFileSync(statusEvaluatorFile, "utf8"),
frpcToml: params.frpcSecret?.frpcToml ?? "",
};
return {
script: `${exports}\n${renderGiteaRemotePayloadMaterializer(payloads)}\n${readFileSync(remoteScriptFile, "utf8")}`,
@@ -1368,132 +1352,18 @@ function appSummary(gitea: GiteaConfig): Record<string, unknown> {
};
}
function publicServiceTarget(gitea: GiteaConfig, target: GiteaTarget): PublicServiceTarget {
return {
id: target.id,
route: target.route,
namespace: target.namespace,
replicas: 1,
publicExposure: targetPublicExposure(gitea, target),
};
}
function publicExposureEnabled(gitea: GiteaConfig, target: GiteaTarget): boolean {
return gitea.app.publicExposure.enabled && target.publicExposureEnabled;
}
function targetPublicExposure(gitea: GiteaConfig, target: GiteaTarget): GiteaConfig["app"]["publicExposure"] {
const frpcExposure = targetFrpcExposure(gitea, target);
return frpcExposure === null ? { ...gitea.app.publicExposure, enabled: false } : frpcExposure;
}
function prepareGiteaFrpcSecret(gitea: GiteaConfig, target: GiteaTarget): FrpcSecretMaterial | undefined {
const exposure = targetFrpcExposure(gitea, target);
if (exposure === null) return undefined;
const base = prepareFrpcSecret({
secretRoot: gitea.app.publicExposure.secretRoot,
exposure,
sourcePathRedactor: redactRepoPath,
parseEnvFile,
requiredEnvValue,
readTextFile,
});
const sync = targetWebhookSync(gitea, target);
if (!sync.enabled || !publicExposureEnabled(gitea, target)) return base;
const extraProxy = [
"[[proxies]]",
`name = "${escapeTomlString(sync.frpc.proxyName)}"`,
'type = "tcp"',
`localIP = "${escapeTomlString(sync.bridge.serviceName)}.${escapeTomlString(target.namespace)}.svc.cluster.local"`,
`localPort = ${sync.bridge.httpPort}`,
`remotePort = ${sync.frpc.remotePort}`,
"",
].join("\n");
const frpcToml = `${base.frpcToml.trim()}\n\n${extraProxy}`;
return {
...base,
frpcToml,
fingerprint: fingerprintValues({ frpcToml }, ["frpcToml"]),
};
}
function targetFrpcExposure(gitea: GiteaConfig, target: GiteaTarget): GiteaConfig["app"]["publicExposure"] | null {
if (publicExposureEnabled(gitea, target)) return gitea.app.publicExposure;
if (target.webhookSync === null || !gitea.sourceAuthority.webhookSync.enabled) return null;
const sync = targetWebhookSync(gitea, target);
return {
...gitea.app.publicExposure,
enabled: true,
frpc: {
...gitea.app.publicExposure.frpc,
proxyName: sync.frpc.proxyName,
remotePort: sync.frpc.remotePort,
localIP: `${sync.bridge.serviceName}.${target.namespace}.svc.cluster.local`,
localPort: sync.bridge.httpPort,
},
};
}
function caddyExposureNeeded(gitea: GiteaConfig, target: GiteaTarget): boolean {
return publicExposureEnabled(gitea, target) || (gitea.sourceAuthority.webhookSync.enabled && target.webhookSync !== null);
}
async function applyGiteaCaddyBlock(config: UniDeskConfig, gitea: GiteaConfig): Promise<Record<string, unknown>> {
const exposure = gitea.app.publicExposure;
const ingressRetry = gitea.sourceAuthority.webhookSync.ingressRetry;
const webhookHandles = webhookCaddyRoutes(gitea)
.sort((left, right) => right.publicPath.length - left.publicPath.length)
.map((route) => renderGiteaWebhookCaddyHandle(
route,
ingressRetry,
gitea.sourceAuthority.webhookSync.responseBudgetMs,
exposure.pk01.responseHeaderTimeoutSeconds,
))
.join("\n\n");
if (webhookHandles === "") return await applyPk01CaddyBlock(config, "gitea", exposure);
const siteBlock = `${exposure.dns.hostname} {
${webhookHandles}
handle {
reverse_proxy 127.0.0.1:${exposure.frpc.remotePort} {
transport http {
response_header_timeout ${exposure.pk01.responseHeaderTimeoutSeconds}s
}
}
}
}`;
return await applyPk01CaddySiteBlock(config, "gitea", exposure, siteBlock, caddyManagedBlockMarkers("gitea"));
}
function publicExposureSummary(gitea: GiteaConfig): Record<string, unknown> {
const exposure = gitea.app.publicExposure;
return {
enabled: exposure.enabled,
publicBaseUrl: exposure.publicBaseUrl,
upstream: exposure.upstream,
healthPath: exposure.healthPath,
internalReadUrlPolicy: "k3s-consumers-use-gitea-http-svc-not-public-url",
dns: exposure.dns,
frpc: {
deploymentName: exposure.frpc.deploymentName,
remotePort: exposure.frpc.remotePort,
localIP: exposure.frpc.localIP,
localPort: exposure.frpc.localPort,
tokenSourceRef: exposure.frpc.tokenSourceRef,
valuesPrinted: false,
},
pk01: exposure.pk01,
};
}
function frpcSecretSummary(secret: FrpcSecretMaterial | undefined): Record<string, unknown> {
if (secret === undefined) return { enabled: false, skipped: true, reason: "target-public-exposure-disabled", valuesPrinted: false };
return {
sourceRef: secret.sourceRef,
sourcePath: secret.sourcePath,
secretName: secret.secretName,
secretKey: secret.secretKey,
fingerprint: secret.fingerprint,
routes: exposure.routes,
authority: "nc01-public-edge",
legacyCleanup: exposure.legacyCleanup,
valuesPrinted: false,
};
}
@@ -1698,7 +1568,6 @@ function webhookSyncSummary(gitea: GiteaConfig, target: GiteaTarget): Record<str
},
bridge: sync.bridge,
gitOpsDelivery: sync.gitOpsDelivery,
frpc: sync.frpc,
valuesPrinted: false,
};
}
@@ -64,7 +64,7 @@ test("selfmedia consumers share one PaC Repository while keeping branch paramete
const config = Bun.YAML.parse(readFileSync(resolve(import.meta.dir, "../../config/platform-infra/pipelines-as-code.yaml"), "utf8")) as Record<string, unknown>;
const repositories = config.repositories as Array<Record<string, unknown>>;
const consumers = config.consumers as Array<Record<string, unknown>>;
const selfmediaRepositories = repositories.filter((item) => item.url === "https://gitea.pikapython.com/mirrors/pikainc-selfmedia");
const selfmediaRepositories = repositories.filter((item) => item.url === "https://gitea.hwpod.com/mirrors/pikainc-selfmedia");
expect(selfmediaRepositories).toHaveLength(1);
const development = consumers.find((item) => item.id === "selfmedia-nc01");
const production = consumers.find((item) => item.id === "selfmedia-production-nc01");
+1 -1
View File
@@ -520,7 +520,7 @@ export function platformInfraHelp(): unknown {
"bun scripts/cli.ts platform-infra pipelines-as-code help platform-bootstrap",
"bun scripts/cli.ts platform-infra pipelines-as-code help compatibility-diagnostics",
],
description: "Operate YAML-controlled platform-infra services such as Sub2API, LangBot, n8n, Web Terminal, WeChat archive workflows, OpenTelemetry tracing, the independent target-scoped secret plane, the target-scoped Kafka event bus, internal Gitea source authority, and read-only Gitea + Pipelines-as-Code observation for migrated consumers. Public services use PK01 Caddy/FRP or YAML-declared PK01 Caddy upstreams rather than Kubernetes Ingress, NodePort, or LoadBalancer.",
description: "Operate YAML-controlled platform-infra services such as Sub2API, LangBot, n8n, Web Terminal, WeChat archive workflows, OpenTelemetry tracing, the independent target-scoped secret plane, the target-scoped Kafka event bus, internal Gitea source authority, and read-only Gitea + Pipelines-as-Code observation for migrated consumers. Public services use their YAML-declared managed edge rather than Kubernetes Ingress, NodePort, or LoadBalancer.",
target,
codexPool: {
usage: [