diff --git a/.dockerignore b/.dockerignore index e47a3b1..6d6ca3b 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,5 +1,4 @@ .git .state node_modules -scripts *.log diff --git a/.tekton/sub2rank-development-nc01-pac.yaml b/.tekton/sub2rank-development-nc01-pac.yaml new file mode 100644 index 0000000..c66e4d1 --- /dev/null +++ b/.tekton/sub2rank-development-nc01-pac.yaml @@ -0,0 +1,483 @@ +apiVersion: tekton.dev/v1 +kind: PipelineRun +metadata: + name: "sub2rank-development-nc01-{{ revision }}" + namespace: devops-infra + annotations: + pipelinesascode.tekton.dev/on-event: "[push]" + pipelinesascode.tekton.dev/on-target-branch: "[development]" + pipelinesascode.tekton.dev/on-cel-expression: "event == 'push' && target_branch == 'development' && node == 'NC01'" + pipelinesascode.tekton.dev/max-keep-runs: "8" + unidesk.ai/pac-admission-provenance: admission-pac-v2:platform-infra-sub2rank-development-nc01 + unidesk.ai/owning-config-ref: "config/platform-infra/sub2rank.yaml#delivery.profiles.development" + unidesk.ai/effective-config-sha256: sha256:074719afc0d3f5a0cc371df88d65cbe6341ee4ef3378818ce7c5dde6d191412f + unidesk.ai/source-artifact-renderer: sub2rank-platform-service + unidesk.ai/source-artifact-mode: embedded-pipeline-spec + labels: + app.kubernetes.io/name: sub2rank + app.kubernetes.io/part-of: platform-infra + unidesk.ai/node: NC01 + unidesk.ai/source-commit: "{{ revision }}" + unidesk.ai/trigger: pipelines-as-code +spec: + timeouts: + pipeline: 600s + pipelineSpec: + params: + - name: git-read-url + type: string + default: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikasTech-apistate-development.git + - name: source-branch + type: string + default: development + - name: revision + type: string + - name: source-stage-ref + type: string + - name: config-path + type: string + default: config/sub2rank.yaml + - name: dockerfile + type: string + default: Dockerfile + - name: image-repository + type: string + default: 127.0.0.1:5000/sub2rank/sub2rank + - name: tools-image + type: string + default: 127.0.0.1:5000/hwlab/hwlab-ci-node-tools:node22-alpine-bun-v1 + - name: buildkit-image + type: string + default: 127.0.0.1:5000/hwlab/buildkit:rootless + - name: build-network + type: string + default: host + - name: build-http-proxy + type: string + default: http://127.0.0.1:10808 + - name: build-https-proxy + type: string + default: http://127.0.0.1:10808 + - name: build-all-proxy + type: string + default: http://127.0.0.1:10808 + - name: build-no-proxy + type: string + default: "localhost,127.0.0.1,::1,127.0.0.1:5000,localhost:5000,.svc,.svc.cluster.local,.cluster.local,hyueapi.com,.hyueapi.com" + - name: gitops-read-url + type: string + default: http://git-mirror-http.devops-infra.svc.cluster.local:8080/pikasTech/unidesk.git + - name: gitops-write-url + type: string + default: http://git-mirror-write.devops-infra.svc.cluster.local:8080/pikasTech/unidesk.git + - name: gitops-branch + type: string + default: unidesk-host-gitops + - name: gitops-manifest-path + type: string + default: deploy/gitops/platform-infra/sub2rank-development-nc01/resources.yaml + - name: gitops-release-state-path + type: string + default: deploy/gitops-state/platform-infra/sub2rank-development-nc01.json + - name: gitops-max-push-attempts + type: string + default: "3" + - name: gitops-author-name + type: string + default: UniDesk Sub2Rank CI + - name: gitops-author-email + type: string + default: sub2rank-ci@unidesk.local + - name: manifest-template-b64 + type: string + default: YXBpVmVyc2lvbjogdjEKa2luZDogTmFtZXNwYWNlCm1ldGFkYXRhOgogIG5hbWU6IHBsYXRmb3JtLWluZnJhLWRldmVsb3BtZW50CiAgbGFiZWxzOgogICAgYXBwLmt1YmVybmV0ZXMuaW8vbWFuYWdlZC1ieTogdW5pZGVzawotLS0KYXBpVmVyc2lvbjogbmV0d29ya2luZy5rOHMuaW8vdjEKa2luZDogTmV0d29ya1BvbGljeQptZXRhZGF0YToKICBuYW1lOiBhbGxvdy1hbGwKICBuYW1lc3BhY2U6IHBsYXRmb3JtLWluZnJhLWRldmVsb3BtZW50CnNwZWM6CiAgcG9kU2VsZWN0b3I6IHt9CiAgcG9saWN5VHlwZXM6CiAgICAtIEluZ3Jlc3MKICAgIC0gRWdyZXNzCiAgaW5ncmVzczoKICAgIC0ge30KICBlZ3Jlc3M6CiAgICAtIHt9Ci0tLQphcGlWZXJzaW9uOiB2MQpraW5kOiBDb25maWdNYXAKbWV0YWRhdGE6CiAgbmFtZTogc3ViMnJhbmstY29uZmlnCiAgbmFtZXNwYWNlOiBwbGF0Zm9ybS1pbmZyYS1kZXZlbG9wbWVudAogIGxhYmVsczoKICAgIGFwcC5rdWJlcm5ldGVzLmlvL25hbWU6IHN1YjJyYW5rCiAgICBhcHAua3ViZXJuZXRlcy5pby9wYXJ0LW9mOiBwbGF0Zm9ybS1pbmZyYQogICAgYXBwLmt1YmVybmV0ZXMuaW8vbWFuYWdlZC1ieTogdW5pZGVzawpiaW5hcnlEYXRhOgogIHN1YjJyYW5rLnlhbWw6ICJfX1NVQjJSQU5LX0NPTkZJR19CQVNFNjRfXyIKLS0tCmFwaVZlcnNpb246IHYxCmtpbmQ6IFBlcnNpc3RlbnRWb2x1bWVDbGFpbQptZXRhZGF0YToKICBuYW1lOiBzdWIycmFuay1kYXRhCiAgbmFtZXNwYWNlOiBwbGF0Zm9ybS1pbmZyYS1kZXZlbG9wbWVudAogIGxhYmVsczoKICAgIGFwcC5rdWJlcm5ldGVzLmlvL25hbWU6IHN1YjJyYW5rCiAgICBhcHAua3ViZXJuZXRlcy5pby9wYXJ0LW9mOiBwbGF0Zm9ybS1pbmZyYQogICAgYXBwLmt1YmVybmV0ZXMuaW8vbWFuYWdlZC1ieTogdW5pZGVzawpzcGVjOgogIGFjY2Vzc01vZGVzOgogICAgLSBSZWFkV3JpdGVPbmNlCiAgcmVzb3VyY2VzOgogICAgcmVxdWVzdHM6CiAgICAgIHN0b3JhZ2U6IDFHaQotLS0KYXBpVmVyc2lvbjogdjEKa2luZDogU2VydmljZQptZXRhZGF0YToKICBuYW1lOiBzdWIycmFuawogIG5hbWVzcGFjZTogcGxhdGZvcm0taW5mcmEtZGV2ZWxvcG1lbnQKICBsYWJlbHM6CiAgICBhcHAua3ViZXJuZXRlcy5pby9uYW1lOiBzdWIycmFuawogICAgYXBwLmt1YmVybmV0ZXMuaW8vcGFydC1vZjogcGxhdGZvcm0taW5mcmEKICAgIGFwcC5rdWJlcm5ldGVzLmlvL21hbmFnZWQtYnk6IHVuaWRlc2sKc3BlYzoKICB0eXBlOiBDbHVzdGVySVAKICBzZWxlY3RvcjoKICAgIGFwcC5rdWJlcm5ldGVzLmlvL25hbWU6IHN1YjJyYW5rCiAgcG9ydHM6CiAgICAtIG5hbWU6IGh0dHAKICAgICAgcG9ydDogODA4MAogICAgICB0YXJnZXRQb3J0OiBodHRwCi0tLQphcGlWZXJzaW9uOiBhcHBzL3YxCmtpbmQ6IERlcGxveW1lbnQKbWV0YWRhdGE6CiAgbmFtZTogc3ViMnJhbmsKICBuYW1lc3BhY2U6IHBsYXRmb3JtLWluZnJhLWRldmVsb3BtZW50CiAgbGFiZWxzOgogICAgYXBwLmt1YmVybmV0ZXMuaW8vbmFtZTogc3ViMnJhbmsKICAgIGFwcC5rdWJlcm5ldGVzLmlvL3BhcnQtb2Y6IHBsYXRmb3JtLWluZnJhCiAgICBhcHAua3ViZXJuZXRlcy5pby9tYW5hZ2VkLWJ5OiB1bmlkZXNrCnNwZWM6CiAgcmVwbGljYXM6IDEKICBzdHJhdGVneToKICAgIHR5cGU6IFJlY3JlYXRlCiAgc2VsZWN0b3I6CiAgICBtYXRjaExhYmVsczoKICAgICAgYXBwLmt1YmVybmV0ZXMuaW8vbmFtZTogc3ViMnJhbmsKICB0ZW1wbGF0ZToKICAgIG1ldGFkYXRhOgogICAgICBsYWJlbHM6CiAgICAgICAgYXBwLmt1YmVybmV0ZXMuaW8vbmFtZTogc3ViMnJhbmsKICAgICAgICBhcHAua3ViZXJuZXRlcy5pby9wYXJ0LW9mOiBwbGF0Zm9ybS1pbmZyYQogICAgICBhbm5vdGF0aW9uczoKICAgICAgICB1bmlkZXNrLmFpL3N1YjJyYW5rLWNvbmZpZy1zaGEyNTY6ICJfX1NVQjJSQU5LX0NPTkZJR19TSEEyNTZfXyIKICAgICAgICB1bmlkZXNrLmFpL3N1YjJyYW5rLWRlcGxveW1lbnQtaGFzaDogIjY5ZTc2OTE1MTIwMDc4ODMiCiAgICAgICAgdW5pZGVzay5haS9zb3VyY2UtY29tbWl0OiAiX19TVUIyUkFOS19TT1VSQ0VfQ09NTUlUX18iCiAgICAgICAgdW5pZGVzay5haS9wdWJsaWMtYmFzZS11cmw6ICJodHRwczovL2FwaXN0YXRlLWRldmVsb3BtZW50LmludmFsaWQiCiAgICBzcGVjOgogICAgICBzZWN1cml0eUNvbnRleHQ6CiAgICAgICAgZnNHcm91cDogMTAwMAogICAgICBjb250YWluZXJzOgogICAgICAgIC0gbmFtZTogc3ViMnJhbmsKICAgICAgICAgIGltYWdlOiAiX19TVUIyUkFOS19JTUFHRV9SRUZfXyIKICAgICAgICAgIGltYWdlUHVsbFBvbGljeTogSWZOb3RQcmVzZW50CiAgICAgICAgICBjb21tYW5kOgogICAgICAgICAgICAtICJidW4iCiAgICAgICAgICAgIC0gInNyYy9hcGkudHMiCiAgICAgICAgICAgIC0gIi0tY29uZmlnIgogICAgICAgICAgICAtICIvZXRjL3N1YjJyYW5rL3N1YjJyYW5rLnlhbWwiCiAgICAgICAgICAgIC0gIi0tcnVudGltZSIKICAgICAgICAgICAgLSAiZGV2ZWxvcG1lbnQiCiAgICAgICAgICBwb3J0czoKICAgICAgICAgICAgLSBuYW1lOiBodHRwCiAgICAgICAgICAgICAgY29udGFpbmVyUG9ydDogODA4MAogICAgICAgICAgZW52OgogICAgICAgICAgICAtIG5hbWU6IEFQSVNUQVRFX1dFQl9QQVNTV09SRAogICAgICAgICAgICAgIHZhbHVlRnJvbToKICAgICAgICAgICAgICAgIHNlY3JldEtleVJlZjoKICAgICAgICAgICAgICAgICAgbmFtZTogc3ViMnJhbmstc2VjcmV0cwogICAgICAgICAgICAgICAgICBrZXk6IEFQSVNUQVRFX1dFQl9QQVNTV09SRAogICAgICAgICAgICAtIG5hbWU6IEFQSVNUQVRFX0FQSV9LRVkKICAgICAgICAgICAgICB2YWx1ZUZyb206CiAgICAgICAgICAgICAgICBzZWNyZXRLZXlSZWY6CiAgICAgICAgICAgICAgICAgIG5hbWU6IHN1YjJyYW5rLXNlY3JldHMKICAgICAgICAgICAgICAgICAga2V5OiBBUElTVEFURV9BUElfS0VZCiAgICAgICAgICAgIC0gbmFtZTogQVBJU1RBVEVfU0VTU0lPTl9TRUNSRVQKICAgICAgICAgICAgICB2YWx1ZUZyb206CiAgICAgICAgICAgICAgICBzZWNyZXRLZXlSZWY6CiAgICAgICAgICAgICAgICAgIG5hbWU6IHN1YjJyYW5rLXNlY3JldHMKICAgICAgICAgICAgICAgICAga2V5OiBBUElTVEFURV9TRVNTSU9OX1NFQ1JFVAogICAgICAgICAgICAtIG5hbWU6IFNVQjJBUElfQURNSU5fRU1BSUwKICAgICAgICAgICAgICB2YWx1ZUZyb206CiAgICAgICAgICAgICAgICBzZWNyZXRLZXlSZWY6CiAgICAgICAgICAgICAgICAgIG5hbWU6IHN1YjJyYW5rLXNlY3JldHMKICAgICAgICAgICAgICAgICAga2V5OiBTVUIyQVBJX0FETUlOX0VNQUlMCiAgICAgICAgICAgIC0gbmFtZTogU1VCMkFQSV9BRE1JTl9QQVNTV09SRAogICAgICAgICAgICAgIHZhbHVlRnJvbToKICAgICAgICAgICAgICAgIHNlY3JldEtleVJlZjoKICAgICAgICAgICAgICAgICAgbmFtZTogc3ViMnJhbmstc2VjcmV0cwogICAgICAgICAgICAgICAgICBrZXk6IFNVQjJBUElfQURNSU5fUEFTU1dPUkQKICAgICAgICAgICAgLSBuYW1lOiBBUElTVEFURV9URU1QT1JBTF9BRERSRVNTCiAgICAgICAgICAgICAgdmFsdWU6ICJ0ZW1wb3JhbC1mcm9udGVuZC50ZW1wb3JhbC5zdmMuY2x1c3Rlci5sb2NhbDo3MjMzIgogICAgICAgICAgcmVhZGluZXNzUHJvYmU6CiAgICAgICAgICAgIGh0dHBHZXQ6CiAgICAgICAgICAgICAgcGF0aDogL2hlYWx0aAogICAgICAgICAgICAgIHBvcnQ6IGh0dHAKICAgICAgICAgICAgaW5pdGlhbERlbGF5U2Vjb25kczogMwogICAgICAgICAgICBwZXJpb2RTZWNvbmRzOiAxMAogICAgICAgICAgICB0aW1lb3V0U2Vjb25kczogMwogICAgICAgICAgICBmYWlsdXJlVGhyZXNob2xkOiA2CiAgICAgICAgICBsaXZlbmVzc1Byb2JlOgogICAgICAgICAgICBodHRwR2V0OgogICAgICAgICAgICAgIHBhdGg6IC9oZWFsdGgKICAgICAgICAgICAgICBwb3J0OiBodHRwCiAgICAgICAgICAgIGluaXRpYWxEZWxheVNlY29uZHM6IDMKICAgICAgICAgICAgcGVyaW9kU2Vjb25kczogMTAKICAgICAgICAgICAgdGltZW91dFNlY29uZHM6IDMKICAgICAgICAgICAgZmFpbHVyZVRocmVzaG9sZDogNgogICAgICAgICAgdm9sdW1lTW91bnRzOgogICAgICAgICAgICAtIG5hbWU6IGFwcC1jb25maWcKICAgICAgICAgICAgICBtb3VudFBhdGg6IC9ldGMvc3ViMnJhbmsvc3ViMnJhbmsueWFtbAogICAgICAgICAgICAgIHN1YlBhdGg6IHN1YjJyYW5rLnlhbWwKICAgICAgICAgICAgICByZWFkT25seTogdHJ1ZQogICAgICAgICAgICAtIG5hbWU6IGRhdGEKICAgICAgICAgICAgICBtb3VudFBhdGg6IC92YXIvbGliL3N1YjJyYW5rCiAgICAgICAgICAgIC0gbmFtZTogaG9zdC1zb3VyY2UtMAogICAgICAgICAgICAgIG1vdW50UGF0aDogL29wdC91bmlkZXNrCiAgICAgICAgICAgICAgcmVhZE9ubHk6IHRydWUKICAgICAgdm9sdW1lczoKICAgICAgICAtIG5hbWU6IGFwcC1jb25maWcKICAgICAgICAgIGNvbmZpZ01hcDoKICAgICAgICAgICAgbmFtZTogc3ViMnJhbmstY29uZmlnCiAgICAgICAgLSBuYW1lOiBkYXRhCiAgICAgICAgICBwZXJzaXN0ZW50Vm9sdW1lQ2xhaW06CiAgICAgICAgICAgIGNsYWltTmFtZTogc3ViMnJhbmstZGF0YQogICAgICAgIC0gbmFtZTogaG9zdC1zb3VyY2UtMAogICAgICAgICAgaG9zdFBhdGg6CiAgICAgICAgICAgIHBhdGg6IC9yb290L3VuaWRlc2sKICAgICAgICAgICAgdHlwZTogRGlyZWN0b3J5Ci0tLQphcGlWZXJzaW9uOiBhcHBzL3YxCmtpbmQ6IERlcGxveW1lbnQKbWV0YWRhdGE6CiAgbmFtZTogc3ViMnJhbmstd29ya2VyCiAgbmFtZXNwYWNlOiBwbGF0Zm9ybS1pbmZyYS1kZXZlbG9wbWVudAogIGxhYmVsczoKICAgIGFwcC5rdWJlcm5ldGVzLmlvL25hbWU6IHN1YjJyYW5rLXdvcmtlcgogICAgYXBwLmt1YmVybmV0ZXMuaW8vcGFydC1vZjogcGxhdGZvcm0taW5mcmEKICAgIGFwcC5rdWJlcm5ldGVzLmlvL21hbmFnZWQtYnk6IHVuaWRlc2sKc3BlYzoKICByZXBsaWNhczogMQogIHN0cmF0ZWd5OgogICAgdHlwZTogUmVjcmVhdGUKICBzZWxlY3RvcjoKICAgIG1hdGNoTGFiZWxzOgogICAgICBhcHAua3ViZXJuZXRlcy5pby9uYW1lOiBzdWIycmFuay13b3JrZXIKICB0ZW1wbGF0ZToKICAgIG1ldGFkYXRhOgogICAgICBsYWJlbHM6CiAgICAgICAgYXBwLmt1YmVybmV0ZXMuaW8vbmFtZTogc3ViMnJhbmstd29ya2VyCiAgICAgICAgYXBwLmt1YmVybmV0ZXMuaW8vcGFydC1vZjogcGxhdGZvcm0taW5mcmEKICAgICAgYW5ub3RhdGlvbnM6CiAgICAgICAgdW5pZGVzay5haS9zdWIycmFuay1jb25maWctc2hhMjU2OiAiX19TVUIyUkFOS19DT05GSUdfU0hBMjU2X18iCiAgICAgICAgdW5pZGVzay5haS9zdWIycmFuay1kZXBsb3ltZW50LWhhc2g6ICI2OWU3NjkxNTEyMDA3ODgzIgogICAgICAgIHVuaWRlc2suYWkvc291cmNlLWNvbW1pdDogIl9fU1VCMlJBTktfU09VUkNFX0NPTU1JVF9fIgogICAgc3BlYzoKICAgICAgc2VjdXJpdHlDb250ZXh0OgogICAgICAgIGZzR3JvdXA6IDEwMDAKICAgICAgY29udGFpbmVyczoKICAgICAgICAtIG5hbWU6IHN1YjJyYW5rLXdvcmtlcgogICAgICAgICAgaW1hZ2U6ICJfX1NVQjJSQU5LX0lNQUdFX1JFRl9fIgogICAgICAgICAgaW1hZ2VQdWxsUG9saWN5OiBJZk5vdFByZXNlbnQKICAgICAgICAgIGNvbW1hbmQ6CiAgICAgICAgICAgIC0gImJ1biIKICAgICAgICAgICAgLSAic3JjL3dvcmtlci50cyIKICAgICAgICAgICAgLSAiLS1jb25maWciCiAgICAgICAgICAgIC0gIi9ldGMvc3ViMnJhbmsvc3ViMnJhbmsueWFtbCIKICAgICAgICAgICAgLSAiLS1ydW50aW1lIgogICAgICAgICAgICAtICJkZXZlbG9wbWVudCIKICAgICAgICAgIHBvcnRzOgogICAgICAgICAgICAtIG5hbWU6IGhlYWx0aAogICAgICAgICAgICAgIGNvbnRhaW5lclBvcnQ6IDgwODEKICAgICAgICAgIGVudjoKICAgICAgICAgICAgLSBuYW1lOiBBUElTVEFURV9XRUJfUEFTU1dPUkQKICAgICAgICAgICAgICB2YWx1ZUZyb206CiAgICAgICAgICAgICAgICBzZWNyZXRLZXlSZWY6CiAgICAgICAgICAgICAgICAgIG5hbWU6IHN1YjJyYW5rLXNlY3JldHMKICAgICAgICAgICAgICAgICAga2V5OiBBUElTVEFURV9XRUJfUEFTU1dPUkQKICAgICAgICAgICAgLSBuYW1lOiBBUElTVEFURV9BUElfS0VZCiAgICAgICAgICAgICAgdmFsdWVGcm9tOgogICAgICAgICAgICAgICAgc2VjcmV0S2V5UmVmOgogICAgICAgICAgICAgICAgICBuYW1lOiBzdWIycmFuay1zZWNyZXRzCiAgICAgICAgICAgICAgICAgIGtleTogQVBJU1RBVEVfQVBJX0tFWQogICAgICAgICAgICAtIG5hbWU6IEFQSVNUQVRFX1NFU1NJT05fU0VDUkVUCiAgICAgICAgICAgICAgdmFsdWVGcm9tOgogICAgICAgICAgICAgICAgc2VjcmV0S2V5UmVmOgogICAgICAgICAgICAgICAgICBuYW1lOiBzdWIycmFuay1zZWNyZXRzCiAgICAgICAgICAgICAgICAgIGtleTogQVBJU1RBVEVfU0VTU0lPTl9TRUNSRVQKICAgICAgICAgICAgLSBuYW1lOiBTVUIyQVBJX0FETUlOX0VNQUlMCiAgICAgICAgICAgICAgdmFsdWVGcm9tOgogICAgICAgICAgICAgICAgc2VjcmV0S2V5UmVmOgogICAgICAgICAgICAgICAgICBuYW1lOiBzdWIycmFuay1zZWNyZXRzCiAgICAgICAgICAgICAgICAgIGtleTogU1VCMkFQSV9BRE1JTl9FTUFJTAogICAgICAgICAgICAtIG5hbWU6IFNVQjJBUElfQURNSU5fUEFTU1dPUkQKICAgICAgICAgICAgICB2YWx1ZUZyb206CiAgICAgICAgICAgICAgICBzZWNyZXRLZXlSZWY6CiAgICAgICAgICAgICAgICAgIG5hbWU6IHN1YjJyYW5rLXNlY3JldHMKICAgICAgICAgICAgICAgICAga2V5OiBTVUIyQVBJX0FETUlOX1BBU1NXT1JECiAgICAgICAgICAgIC0gbmFtZTogQVBJU1RBVEVfVEVNUE9SQUxfQUREUkVTUwogICAgICAgICAgICAgIHZhbHVlOiAidGVtcG9yYWwtZnJvbnRlbmQudGVtcG9yYWwuc3ZjLmNsdXN0ZXIubG9jYWw6NzIzMyIKICAgICAgICAgIHJlYWRpbmVzc1Byb2JlOgogICAgICAgICAgICBodHRwR2V0OgogICAgICAgICAgICAgIHBhdGg6IC9oZWFsdGgKICAgICAgICAgICAgICBwb3J0OiBoZWFsdGgKICAgICAgICAgICAgaW5pdGlhbERlbGF5U2Vjb25kczogMwogICAgICAgICAgICBwZXJpb2RTZWNvbmRzOiAxMAogICAgICAgICAgICB0aW1lb3V0U2Vjb25kczogMwogICAgICAgICAgICBmYWlsdXJlVGhyZXNob2xkOiA2CiAgICAgICAgICBsaXZlbmVzc1Byb2JlOgogICAgICAgICAgICBodHRwR2V0OgogICAgICAgICAgICAgIHBhdGg6IC9oZWFsdGgKICAgICAgICAgICAgICBwb3J0OiBoZWFsdGgKICAgICAgICAgICAgaW5pdGlhbERlbGF5U2Vjb25kczogMwogICAgICAgICAgICBwZXJpb2RTZWNvbmRzOiAxMAogICAgICAgICAgICB0aW1lb3V0U2Vjb25kczogMwogICAgICAgICAgICBmYWlsdXJlVGhyZXNob2xkOiA2CiAgICAgICAgICB2b2x1bWVNb3VudHM6CiAgICAgICAgICAgIC0gbmFtZTogYXBwLWNvbmZpZwogICAgICAgICAgICAgIG1vdW50UGF0aDogL2V0Yy9zdWIycmFuay9zdWIycmFuay55YW1sCiAgICAgICAgICAgICAgc3ViUGF0aDogc3ViMnJhbmsueWFtbAogICAgICAgICAgICAgIHJlYWRPbmx5OiB0cnVlCiAgICAgICAgICAgIC0gbmFtZTogZGF0YQogICAgICAgICAgICAgIG1vdW50UGF0aDogL3Zhci9saWIvc3ViMnJhbmsKICAgICAgICAgICAgLSBuYW1lOiBob3N0LXNvdXJjZS0wCiAgICAgICAgICAgICAgbW91bnRQYXRoOiAvb3B0L3VuaWRlc2sKICAgICAgICAgICAgICByZWFkT25seTogdHJ1ZQogICAgICB2b2x1bWVzOgogICAgICAgIC0gbmFtZTogYXBwLWNvbmZpZwogICAgICAgICAgY29uZmlnTWFwOgogICAgICAgICAgICBuYW1lOiBzdWIycmFuay1jb25maWcKICAgICAgICAtIG5hbWU6IGRhdGEKICAgICAgICAgIHBlcnNpc3RlbnRWb2x1bWVDbGFpbToKICAgICAgICAgICAgY2xhaW1OYW1lOiBzdWIycmFuay1kYXRhCiAgICAgICAgLSBuYW1lOiBob3N0LXNvdXJjZS0wCiAgICAgICAgICBob3N0UGF0aDoKICAgICAgICAgICAgcGF0aDogL3Jvb3QvdW5pZGVzawogICAgICAgICAgICB0eXBlOiBEaXJlY3RvcnkK + workspaces: + - name: source + tasks: + - name: source-validate + workspaces: + - name: source + workspace: source + taskSpec: + params: + - name: git-read-url + - name: source-branch + - name: revision + - name: source-stage-ref + - name: config-path + - name: dockerfile + - name: image-repository + - name: tools-image + - name: buildkit-image + - name: build-network + - name: build-http-proxy + - name: build-https-proxy + - name: build-all-proxy + - name: build-no-proxy + - name: gitops-read-url + - name: gitops-write-url + - name: gitops-branch + - name: gitops-manifest-path + - name: gitops-release-state-path + - name: gitops-max-push-attempts + - name: gitops-author-name + - name: gitops-author-email + - name: manifest-template-b64 + workspaces: + - name: source + steps: + - name: checkout-and-cli-validate + image: $(params.tools-image) + imagePullPolicy: IfNotPresent + env: + - name: HTTP_PROXY + value: $(params.build-http-proxy) + - name: http_proxy + value: $(params.build-http-proxy) + - name: HTTPS_PROXY + value: $(params.build-https-proxy) + - name: https_proxy + value: $(params.build-https-proxy) + - name: ALL_PROXY + value: $(params.build-all-proxy) + - name: all_proxy + value: $(params.build-all-proxy) + - name: NO_PROXY + value: $(params.build-no-proxy) + - name: no_proxy + value: $(params.build-no-proxy) + script: "#!/bin/sh\nset -eu\nroot=\"$(workspaces.source.path)\"\nrm -rf \"$root/repo\"\ngit clone --filter=blob:none --no-checkout \"$(params.git-read-url)\" \"$root/repo\"\ncd \"$root/repo\"\ngit fetch --depth=1 --filter=blob:none origin \"+$(params.source-stage-ref):refs/remotes/origin/sub2rank-source-snapshot\"\ngit checkout --detach \"$(params.revision)\"\ntest \"$(git rev-parse HEAD)\" = \"$(params.revision)\"\nbun install --frozen-lockfile\nbun scripts/apistate-cli.ts --config \"$(params.config-path)\" config validate\nchmod -R a+rX,g+rwX \"$root/repo\"\nprintf '{\"ok\":true,\"phase\":\"source-validate\",\"sourceCommit\":\"%s\",\"configPath\":\"%s\",\"valuesPrinted\":false}\\n' \"$(params.revision)\" \"$(params.config-path)\"" + params: + - name: git-read-url + value: $(params.git-read-url) + - name: source-branch + value: $(params.source-branch) + - name: revision + value: $(params.revision) + - name: source-stage-ref + value: $(params.source-stage-ref) + - name: config-path + value: $(params.config-path) + - name: dockerfile + value: $(params.dockerfile) + - name: image-repository + value: $(params.image-repository) + - name: tools-image + value: $(params.tools-image) + - name: buildkit-image + value: $(params.buildkit-image) + - name: build-network + value: $(params.build-network) + - name: build-http-proxy + value: $(params.build-http-proxy) + - name: build-https-proxy + value: $(params.build-https-proxy) + - name: build-all-proxy + value: $(params.build-all-proxy) + - name: build-no-proxy + value: $(params.build-no-proxy) + - name: gitops-read-url + value: $(params.gitops-read-url) + - name: gitops-write-url + value: $(params.gitops-write-url) + - name: gitops-branch + value: $(params.gitops-branch) + - name: gitops-manifest-path + value: $(params.gitops-manifest-path) + - name: gitops-release-state-path + value: $(params.gitops-release-state-path) + - name: gitops-max-push-attempts + value: $(params.gitops-max-push-attempts) + - name: gitops-author-name + value: $(params.gitops-author-name) + - name: gitops-author-email + value: $(params.gitops-author-email) + - name: manifest-template-b64 + value: $(params.manifest-template-b64) + - name: image-build + runAfter: + - source-validate + workspaces: + - name: source + workspace: source + taskSpec: + params: + - name: git-read-url + - name: source-branch + - name: revision + - name: source-stage-ref + - name: config-path + - name: dockerfile + - name: image-repository + - name: tools-image + - name: buildkit-image + - name: build-network + - name: build-http-proxy + - name: build-https-proxy + - name: build-all-proxy + - name: build-no-proxy + - name: gitops-read-url + - name: gitops-write-url + - name: gitops-branch + - name: gitops-manifest-path + - name: gitops-release-state-path + - name: gitops-max-push-attempts + - name: gitops-author-name + - name: gitops-author-email + - name: manifest-template-b64 + workspaces: + - name: source + steps: + - name: build-and-push + image: $(params.buildkit-image) + imagePullPolicy: IfNotPresent + env: + - name: HTTP_PROXY + value: $(params.build-http-proxy) + - name: http_proxy + value: $(params.build-http-proxy) + - name: HTTPS_PROXY + value: $(params.build-https-proxy) + - name: https_proxy + value: $(params.build-https-proxy) + - name: ALL_PROXY + value: $(params.build-all-proxy) + - name: all_proxy + value: $(params.build-all-proxy) + - name: NO_PROXY + value: $(params.build-no-proxy) + - name: no_proxy + value: $(params.build-no-proxy) + - name: BUILDKITD_FLAGS + value: "--oci-worker-no-process-sandbox --oci-worker-net=host --allow-insecure-entitlement network.host" + - name: SOURCE_ROOT + value: $(workspaces.source.path)/repo + - name: SOURCE_COMMIT + value: $(params.revision) + - name: IMAGE_REPOSITORY + value: $(params.image-repository) + - name: DOCKERFILE + value: $(params.dockerfile) + - name: BUILD_NETWORK + value: $(params.build-network) + - name: BUILD_METADATA_FILE + value: $(workspaces.source.path)/build-metadata.json + - name: BUILD_RESULT_FILE + value: $(workspaces.source.path)/build-result.json + securityContext: + privileged: true + runAsUser: 1000 + runAsGroup: 1000 + script: "#!/bin/sh\nset -eu\nexec \"$(workspaces.source.path)/repo/scripts/ci/build-image.sh\"" + params: + - name: git-read-url + value: $(params.git-read-url) + - name: source-branch + value: $(params.source-branch) + - name: revision + value: $(params.revision) + - name: source-stage-ref + value: $(params.source-stage-ref) + - name: config-path + value: $(params.config-path) + - name: dockerfile + value: $(params.dockerfile) + - name: image-repository + value: $(params.image-repository) + - name: tools-image + value: $(params.tools-image) + - name: buildkit-image + value: $(params.buildkit-image) + - name: build-network + value: $(params.build-network) + - name: build-http-proxy + value: $(params.build-http-proxy) + - name: build-https-proxy + value: $(params.build-https-proxy) + - name: build-all-proxy + value: $(params.build-all-proxy) + - name: build-no-proxy + value: $(params.build-no-proxy) + - name: gitops-read-url + value: $(params.gitops-read-url) + - name: gitops-write-url + value: $(params.gitops-write-url) + - name: gitops-branch + value: $(params.gitops-branch) + - name: gitops-manifest-path + value: $(params.gitops-manifest-path) + - name: gitops-release-state-path + value: $(params.gitops-release-state-path) + - name: gitops-max-push-attempts + value: $(params.gitops-max-push-attempts) + - name: gitops-author-name + value: $(params.gitops-author-name) + - name: gitops-author-email + value: $(params.gitops-author-email) + - name: manifest-template-b64 + value: $(params.manifest-template-b64) + - name: gitops-publish + runAfter: + - image-build + workspaces: + - name: source + workspace: source + taskSpec: + params: + - name: git-read-url + - name: source-branch + - name: revision + - name: source-stage-ref + - name: config-path + - name: dockerfile + - name: image-repository + - name: tools-image + - name: buildkit-image + - name: build-network + - name: build-http-proxy + - name: build-https-proxy + - name: build-all-proxy + - name: build-no-proxy + - name: gitops-read-url + - name: gitops-write-url + - name: gitops-branch + - name: gitops-manifest-path + - name: gitops-release-state-path + - name: gitops-max-push-attempts + - name: gitops-author-name + - name: gitops-author-email + - name: manifest-template-b64 + workspaces: + - name: source + steps: + - name: publish-digest-manifest + image: $(params.tools-image) + imagePullPolicy: IfNotPresent + env: + - name: HTTP_PROXY + value: $(params.build-http-proxy) + - name: http_proxy + value: $(params.build-http-proxy) + - name: HTTPS_PROXY + value: $(params.build-https-proxy) + - name: https_proxy + value: $(params.build-https-proxy) + - name: ALL_PROXY + value: $(params.build-all-proxy) + - name: all_proxy + value: $(params.build-all-proxy) + - name: NO_PROXY + value: $(params.build-no-proxy) + - name: no_proxy + value: $(params.build-no-proxy) + script: "#!/bin/sh\nset -eu\nroot=\"$(workspaces.source.path)\"\nexec bun \"$root/repo/scripts/ci/publish-gitops.mjs\" \\\n --source-root \"$root/repo\" \\\n --source-commit \"$(params.revision)\" \\\n --config-path \"$(params.config-path)\" \\\n --metadata \"$root/build-metadata.json\" \\\n --manifest-template-b64 \"$(params.manifest-template-b64)\" \\\n --image-repository \"$(params.image-repository)\" \\\n --gitops-read-url \"$(params.gitops-read-url)\" \\\n --gitops-write-url \"$(params.gitops-write-url)\" \\\n --gitops-branch \"$(params.gitops-branch)\" \\\n --manifest-path \"$(params.gitops-manifest-path)\" \\\n --release-state-path \"$(params.gitops-release-state-path)\" \\\n --max-push-attempts \"$(params.gitops-max-push-attempts)\" \\\n --author-name \"$(params.gitops-author-name)\" \\\n --author-email \"$(params.gitops-author-email)\" \\\n --worktree \"$root/gitops\"" + params: + - name: git-read-url + value: $(params.git-read-url) + - name: source-branch + value: $(params.source-branch) + - name: revision + value: $(params.revision) + - name: source-stage-ref + value: $(params.source-stage-ref) + - name: config-path + value: $(params.config-path) + - name: dockerfile + value: $(params.dockerfile) + - name: image-repository + value: $(params.image-repository) + - name: tools-image + value: $(params.tools-image) + - name: buildkit-image + value: $(params.buildkit-image) + - name: build-network + value: $(params.build-network) + - name: build-http-proxy + value: $(params.build-http-proxy) + - name: build-https-proxy + value: $(params.build-https-proxy) + - name: build-all-proxy + value: $(params.build-all-proxy) + - name: build-no-proxy + value: $(params.build-no-proxy) + - name: gitops-read-url + value: $(params.gitops-read-url) + - name: gitops-write-url + value: $(params.gitops-write-url) + - name: gitops-branch + value: $(params.gitops-branch) + - name: gitops-manifest-path + value: $(params.gitops-manifest-path) + - name: gitops-release-state-path + value: $(params.gitops-release-state-path) + - name: gitops-max-push-attempts + value: $(params.gitops-max-push-attempts) + - name: gitops-author-name + value: $(params.gitops-author-name) + - name: gitops-author-email + value: $(params.gitops-author-email) + - name: manifest-template-b64 + value: $(params.manifest-template-b64) + taskRunTemplate: + serviceAccountName: sub2rank-development-nc01-tekton-runner + podTemplate: + hostNetwork: true + dnsPolicy: ClusterFirstWithHostNet + securityContext: + fsGroup: 1000 + params: + - name: git-read-url + value: "{{ git_read_url }}" + - name: source-branch + value: "{{ source_branch }}" + - name: revision + value: "{{ revision }}" + - name: source-stage-ref + value: "{{ source_snapshot_prefix }}/{{ revision }}" + - name: config-path + value: config/sub2rank.yaml + - name: dockerfile + value: Dockerfile + - name: image-repository + value: "{{ image_repository }}" + - name: tools-image + value: 127.0.0.1:5000/hwlab/hwlab-ci-node-tools:node22-alpine-bun-v1 + - name: buildkit-image + value: 127.0.0.1:5000/hwlab/buildkit:rootless + - name: build-network + value: host + - name: build-http-proxy + value: http://127.0.0.1:10808 + - name: build-https-proxy + value: http://127.0.0.1:10808 + - name: build-all-proxy + value: http://127.0.0.1:10808 + - name: build-no-proxy + value: "localhost,127.0.0.1,::1,127.0.0.1:5000,localhost:5000,.svc,.svc.cluster.local,.cluster.local,hyueapi.com,.hyueapi.com" + - name: gitops-read-url + value: http://git-mirror-http.devops-infra.svc.cluster.local:8080/pikasTech/unidesk.git + - name: gitops-write-url + value: http://git-mirror-write.devops-infra.svc.cluster.local:8080/pikasTech/unidesk.git + - name: gitops-branch + value: "{{ gitops_branch }}" + - name: gitops-manifest-path + value: "{{ gitops_manifest_path }}" + - name: gitops-release-state-path + value: deploy/gitops-state/platform-infra/sub2rank-development-nc01.json + - name: gitops-max-push-attempts + value: "3" + - name: gitops-author-name + value: UniDesk Sub2Rank CI + - name: gitops-author-email + value: sub2rank-ci@unidesk.local + - name: manifest-template-b64 + value: YXBpVmVyc2lvbjogdjEKa2luZDogTmFtZXNwYWNlCm1ldGFkYXRhOgogIG5hbWU6IHBsYXRmb3JtLWluZnJhLWRldmVsb3BtZW50CiAgbGFiZWxzOgogICAgYXBwLmt1YmVybmV0ZXMuaW8vbWFuYWdlZC1ieTogdW5pZGVzawotLS0KYXBpVmVyc2lvbjogbmV0d29ya2luZy5rOHMuaW8vdjEKa2luZDogTmV0d29ya1BvbGljeQptZXRhZGF0YToKICBuYW1lOiBhbGxvdy1hbGwKICBuYW1lc3BhY2U6IHBsYXRmb3JtLWluZnJhLWRldmVsb3BtZW50CnNwZWM6CiAgcG9kU2VsZWN0b3I6IHt9CiAgcG9saWN5VHlwZXM6CiAgICAtIEluZ3Jlc3MKICAgIC0gRWdyZXNzCiAgaW5ncmVzczoKICAgIC0ge30KICBlZ3Jlc3M6CiAgICAtIHt9Ci0tLQphcGlWZXJzaW9uOiB2MQpraW5kOiBDb25maWdNYXAKbWV0YWRhdGE6CiAgbmFtZTogc3ViMnJhbmstY29uZmlnCiAgbmFtZXNwYWNlOiBwbGF0Zm9ybS1pbmZyYS1kZXZlbG9wbWVudAogIGxhYmVsczoKICAgIGFwcC5rdWJlcm5ldGVzLmlvL25hbWU6IHN1YjJyYW5rCiAgICBhcHAua3ViZXJuZXRlcy5pby9wYXJ0LW9mOiBwbGF0Zm9ybS1pbmZyYQogICAgYXBwLmt1YmVybmV0ZXMuaW8vbWFuYWdlZC1ieTogdW5pZGVzawpiaW5hcnlEYXRhOgogIHN1YjJyYW5rLnlhbWw6ICJfX1NVQjJSQU5LX0NPTkZJR19CQVNFNjRfXyIKLS0tCmFwaVZlcnNpb246IHYxCmtpbmQ6IFBlcnNpc3RlbnRWb2x1bWVDbGFpbQptZXRhZGF0YToKICBuYW1lOiBzdWIycmFuay1kYXRhCiAgbmFtZXNwYWNlOiBwbGF0Zm9ybS1pbmZyYS1kZXZlbG9wbWVudAogIGxhYmVsczoKICAgIGFwcC5rdWJlcm5ldGVzLmlvL25hbWU6IHN1YjJyYW5rCiAgICBhcHAua3ViZXJuZXRlcy5pby9wYXJ0LW9mOiBwbGF0Zm9ybS1pbmZyYQogICAgYXBwLmt1YmVybmV0ZXMuaW8vbWFuYWdlZC1ieTogdW5pZGVzawpzcGVjOgogIGFjY2Vzc01vZGVzOgogICAgLSBSZWFkV3JpdGVPbmNlCiAgcmVzb3VyY2VzOgogICAgcmVxdWVzdHM6CiAgICAgIHN0b3JhZ2U6IDFHaQotLS0KYXBpVmVyc2lvbjogdjEKa2luZDogU2VydmljZQptZXRhZGF0YToKICBuYW1lOiBzdWIycmFuawogIG5hbWVzcGFjZTogcGxhdGZvcm0taW5mcmEtZGV2ZWxvcG1lbnQKICBsYWJlbHM6CiAgICBhcHAua3ViZXJuZXRlcy5pby9uYW1lOiBzdWIycmFuawogICAgYXBwLmt1YmVybmV0ZXMuaW8vcGFydC1vZjogcGxhdGZvcm0taW5mcmEKICAgIGFwcC5rdWJlcm5ldGVzLmlvL21hbmFnZWQtYnk6IHVuaWRlc2sKc3BlYzoKICB0eXBlOiBDbHVzdGVySVAKICBzZWxlY3RvcjoKICAgIGFwcC5rdWJlcm5ldGVzLmlvL25hbWU6IHN1YjJyYW5rCiAgcG9ydHM6CiAgICAtIG5hbWU6IGh0dHAKICAgICAgcG9ydDogODA4MAogICAgICB0YXJnZXRQb3J0OiBodHRwCi0tLQphcGlWZXJzaW9uOiBhcHBzL3YxCmtpbmQ6IERlcGxveW1lbnQKbWV0YWRhdGE6CiAgbmFtZTogc3ViMnJhbmsKICBuYW1lc3BhY2U6IHBsYXRmb3JtLWluZnJhLWRldmVsb3BtZW50CiAgbGFiZWxzOgogICAgYXBwLmt1YmVybmV0ZXMuaW8vbmFtZTogc3ViMnJhbmsKICAgIGFwcC5rdWJlcm5ldGVzLmlvL3BhcnQtb2Y6IHBsYXRmb3JtLWluZnJhCiAgICBhcHAua3ViZXJuZXRlcy5pby9tYW5hZ2VkLWJ5OiB1bmlkZXNrCnNwZWM6CiAgcmVwbGljYXM6IDEKICBzdHJhdGVneToKICAgIHR5cGU6IFJlY3JlYXRlCiAgc2VsZWN0b3I6CiAgICBtYXRjaExhYmVsczoKICAgICAgYXBwLmt1YmVybmV0ZXMuaW8vbmFtZTogc3ViMnJhbmsKICB0ZW1wbGF0ZToKICAgIG1ldGFkYXRhOgogICAgICBsYWJlbHM6CiAgICAgICAgYXBwLmt1YmVybmV0ZXMuaW8vbmFtZTogc3ViMnJhbmsKICAgICAgICBhcHAua3ViZXJuZXRlcy5pby9wYXJ0LW9mOiBwbGF0Zm9ybS1pbmZyYQogICAgICBhbm5vdGF0aW9uczoKICAgICAgICB1bmlkZXNrLmFpL3N1YjJyYW5rLWNvbmZpZy1zaGEyNTY6ICJfX1NVQjJSQU5LX0NPTkZJR19TSEEyNTZfXyIKICAgICAgICB1bmlkZXNrLmFpL3N1YjJyYW5rLWRlcGxveW1lbnQtaGFzaDogIjY5ZTc2OTE1MTIwMDc4ODMiCiAgICAgICAgdW5pZGVzay5haS9zb3VyY2UtY29tbWl0OiAiX19TVUIyUkFOS19TT1VSQ0VfQ09NTUlUX18iCiAgICAgICAgdW5pZGVzay5haS9wdWJsaWMtYmFzZS11cmw6ICJodHRwczovL2FwaXN0YXRlLWRldmVsb3BtZW50LmludmFsaWQiCiAgICBzcGVjOgogICAgICBzZWN1cml0eUNvbnRleHQ6CiAgICAgICAgZnNHcm91cDogMTAwMAogICAgICBjb250YWluZXJzOgogICAgICAgIC0gbmFtZTogc3ViMnJhbmsKICAgICAgICAgIGltYWdlOiAiX19TVUIyUkFOS19JTUFHRV9SRUZfXyIKICAgICAgICAgIGltYWdlUHVsbFBvbGljeTogSWZOb3RQcmVzZW50CiAgICAgICAgICBjb21tYW5kOgogICAgICAgICAgICAtICJidW4iCiAgICAgICAgICAgIC0gInNyYy9hcGkudHMiCiAgICAgICAgICAgIC0gIi0tY29uZmlnIgogICAgICAgICAgICAtICIvZXRjL3N1YjJyYW5rL3N1YjJyYW5rLnlhbWwiCiAgICAgICAgICAgIC0gIi0tcnVudGltZSIKICAgICAgICAgICAgLSAiZGV2ZWxvcG1lbnQiCiAgICAgICAgICBwb3J0czoKICAgICAgICAgICAgLSBuYW1lOiBodHRwCiAgICAgICAgICAgICAgY29udGFpbmVyUG9ydDogODA4MAogICAgICAgICAgZW52OgogICAgICAgICAgICAtIG5hbWU6IEFQSVNUQVRFX1dFQl9QQVNTV09SRAogICAgICAgICAgICAgIHZhbHVlRnJvbToKICAgICAgICAgICAgICAgIHNlY3JldEtleVJlZjoKICAgICAgICAgICAgICAgICAgbmFtZTogc3ViMnJhbmstc2VjcmV0cwogICAgICAgICAgICAgICAgICBrZXk6IEFQSVNUQVRFX1dFQl9QQVNTV09SRAogICAgICAgICAgICAtIG5hbWU6IEFQSVNUQVRFX0FQSV9LRVkKICAgICAgICAgICAgICB2YWx1ZUZyb206CiAgICAgICAgICAgICAgICBzZWNyZXRLZXlSZWY6CiAgICAgICAgICAgICAgICAgIG5hbWU6IHN1YjJyYW5rLXNlY3JldHMKICAgICAgICAgICAgICAgICAga2V5OiBBUElTVEFURV9BUElfS0VZCiAgICAgICAgICAgIC0gbmFtZTogQVBJU1RBVEVfU0VTU0lPTl9TRUNSRVQKICAgICAgICAgICAgICB2YWx1ZUZyb206CiAgICAgICAgICAgICAgICBzZWNyZXRLZXlSZWY6CiAgICAgICAgICAgICAgICAgIG5hbWU6IHN1YjJyYW5rLXNlY3JldHMKICAgICAgICAgICAgICAgICAga2V5OiBBUElTVEFURV9TRVNTSU9OX1NFQ1JFVAogICAgICAgICAgICAtIG5hbWU6IFNVQjJBUElfQURNSU5fRU1BSUwKICAgICAgICAgICAgICB2YWx1ZUZyb206CiAgICAgICAgICAgICAgICBzZWNyZXRLZXlSZWY6CiAgICAgICAgICAgICAgICAgIG5hbWU6IHN1YjJyYW5rLXNlY3JldHMKICAgICAgICAgICAgICAgICAga2V5OiBTVUIyQVBJX0FETUlOX0VNQUlMCiAgICAgICAgICAgIC0gbmFtZTogU1VCMkFQSV9BRE1JTl9QQVNTV09SRAogICAgICAgICAgICAgIHZhbHVlRnJvbToKICAgICAgICAgICAgICAgIHNlY3JldEtleVJlZjoKICAgICAgICAgICAgICAgICAgbmFtZTogc3ViMnJhbmstc2VjcmV0cwogICAgICAgICAgICAgICAgICBrZXk6IFNVQjJBUElfQURNSU5fUEFTU1dPUkQKICAgICAgICAgICAgLSBuYW1lOiBBUElTVEFURV9URU1QT1JBTF9BRERSRVNTCiAgICAgICAgICAgICAgdmFsdWU6ICJ0ZW1wb3JhbC1mcm9udGVuZC50ZW1wb3JhbC5zdmMuY2x1c3Rlci5sb2NhbDo3MjMzIgogICAgICAgICAgcmVhZGluZXNzUHJvYmU6CiAgICAgICAgICAgIGh0dHBHZXQ6CiAgICAgICAgICAgICAgcGF0aDogL2hlYWx0aAogICAgICAgICAgICAgIHBvcnQ6IGh0dHAKICAgICAgICAgICAgaW5pdGlhbERlbGF5U2Vjb25kczogMwogICAgICAgICAgICBwZXJpb2RTZWNvbmRzOiAxMAogICAgICAgICAgICB0aW1lb3V0U2Vjb25kczogMwogICAgICAgICAgICBmYWlsdXJlVGhyZXNob2xkOiA2CiAgICAgICAgICBsaXZlbmVzc1Byb2JlOgogICAgICAgICAgICBodHRwR2V0OgogICAgICAgICAgICAgIHBhdGg6IC9oZWFsdGgKICAgICAgICAgICAgICBwb3J0OiBodHRwCiAgICAgICAgICAgIGluaXRpYWxEZWxheVNlY29uZHM6IDMKICAgICAgICAgICAgcGVyaW9kU2Vjb25kczogMTAKICAgICAgICAgICAgdGltZW91dFNlY29uZHM6IDMKICAgICAgICAgICAgZmFpbHVyZVRocmVzaG9sZDogNgogICAgICAgICAgdm9sdW1lTW91bnRzOgogICAgICAgICAgICAtIG5hbWU6IGFwcC1jb25maWcKICAgICAgICAgICAgICBtb3VudFBhdGg6IC9ldGMvc3ViMnJhbmsvc3ViMnJhbmsueWFtbAogICAgICAgICAgICAgIHN1YlBhdGg6IHN1YjJyYW5rLnlhbWwKICAgICAgICAgICAgICByZWFkT25seTogdHJ1ZQogICAgICAgICAgICAtIG5hbWU6IGRhdGEKICAgICAgICAgICAgICBtb3VudFBhdGg6IC92YXIvbGliL3N1YjJyYW5rCiAgICAgICAgICAgIC0gbmFtZTogaG9zdC1zb3VyY2UtMAogICAgICAgICAgICAgIG1vdW50UGF0aDogL29wdC91bmlkZXNrCiAgICAgICAgICAgICAgcmVhZE9ubHk6IHRydWUKICAgICAgdm9sdW1lczoKICAgICAgICAtIG5hbWU6IGFwcC1jb25maWcKICAgICAgICAgIGNvbmZpZ01hcDoKICAgICAgICAgICAgbmFtZTogc3ViMnJhbmstY29uZmlnCiAgICAgICAgLSBuYW1lOiBkYXRhCiAgICAgICAgICBwZXJzaXN0ZW50Vm9sdW1lQ2xhaW06CiAgICAgICAgICAgIGNsYWltTmFtZTogc3ViMnJhbmstZGF0YQogICAgICAgIC0gbmFtZTogaG9zdC1zb3VyY2UtMAogICAgICAgICAgaG9zdFBhdGg6CiAgICAgICAgICAgIHBhdGg6IC9yb290L3VuaWRlc2sKICAgICAgICAgICAgdHlwZTogRGlyZWN0b3J5Ci0tLQphcGlWZXJzaW9uOiBhcHBzL3YxCmtpbmQ6IERlcGxveW1lbnQKbWV0YWRhdGE6CiAgbmFtZTogc3ViMnJhbmstd29ya2VyCiAgbmFtZXNwYWNlOiBwbGF0Zm9ybS1pbmZyYS1kZXZlbG9wbWVudAogIGxhYmVsczoKICAgIGFwcC5rdWJlcm5ldGVzLmlvL25hbWU6IHN1YjJyYW5rLXdvcmtlcgogICAgYXBwLmt1YmVybmV0ZXMuaW8vcGFydC1vZjogcGxhdGZvcm0taW5mcmEKICAgIGFwcC5rdWJlcm5ldGVzLmlvL21hbmFnZWQtYnk6IHVuaWRlc2sKc3BlYzoKICByZXBsaWNhczogMQogIHN0cmF0ZWd5OgogICAgdHlwZTogUmVjcmVhdGUKICBzZWxlY3RvcjoKICAgIG1hdGNoTGFiZWxzOgogICAgICBhcHAua3ViZXJuZXRlcy5pby9uYW1lOiBzdWIycmFuay13b3JrZXIKICB0ZW1wbGF0ZToKICAgIG1ldGFkYXRhOgogICAgICBsYWJlbHM6CiAgICAgICAgYXBwLmt1YmVybmV0ZXMuaW8vbmFtZTogc3ViMnJhbmstd29ya2VyCiAgICAgICAgYXBwLmt1YmVybmV0ZXMuaW8vcGFydC1vZjogcGxhdGZvcm0taW5mcmEKICAgICAgYW5ub3RhdGlvbnM6CiAgICAgICAgdW5pZGVzay5haS9zdWIycmFuay1jb25maWctc2hhMjU2OiAiX19TVUIyUkFOS19DT05GSUdfU0hBMjU2X18iCiAgICAgICAgdW5pZGVzay5haS9zdWIycmFuay1kZXBsb3ltZW50LWhhc2g6ICI2OWU3NjkxNTEyMDA3ODgzIgogICAgICAgIHVuaWRlc2suYWkvc291cmNlLWNvbW1pdDogIl9fU1VCMlJBTktfU09VUkNFX0NPTU1JVF9fIgogICAgc3BlYzoKICAgICAgc2VjdXJpdHlDb250ZXh0OgogICAgICAgIGZzR3JvdXA6IDEwMDAKICAgICAgY29udGFpbmVyczoKICAgICAgICAtIG5hbWU6IHN1YjJyYW5rLXdvcmtlcgogICAgICAgICAgaW1hZ2U6ICJfX1NVQjJSQU5LX0lNQUdFX1JFRl9fIgogICAgICAgICAgaW1hZ2VQdWxsUG9saWN5OiBJZk5vdFByZXNlbnQKICAgICAgICAgIGNvbW1hbmQ6CiAgICAgICAgICAgIC0gImJ1biIKICAgICAgICAgICAgLSAic3JjL3dvcmtlci50cyIKICAgICAgICAgICAgLSAiLS1jb25maWciCiAgICAgICAgICAgIC0gIi9ldGMvc3ViMnJhbmsvc3ViMnJhbmsueWFtbCIKICAgICAgICAgICAgLSAiLS1ydW50aW1lIgogICAgICAgICAgICAtICJkZXZlbG9wbWVudCIKICAgICAgICAgIHBvcnRzOgogICAgICAgICAgICAtIG5hbWU6IGhlYWx0aAogICAgICAgICAgICAgIGNvbnRhaW5lclBvcnQ6IDgwODEKICAgICAgICAgIGVudjoKICAgICAgICAgICAgLSBuYW1lOiBBUElTVEFURV9XRUJfUEFTU1dPUkQKICAgICAgICAgICAgICB2YWx1ZUZyb206CiAgICAgICAgICAgICAgICBzZWNyZXRLZXlSZWY6CiAgICAgICAgICAgICAgICAgIG5hbWU6IHN1YjJyYW5rLXNlY3JldHMKICAgICAgICAgICAgICAgICAga2V5OiBBUElTVEFURV9XRUJfUEFTU1dPUkQKICAgICAgICAgICAgLSBuYW1lOiBBUElTVEFURV9BUElfS0VZCiAgICAgICAgICAgICAgdmFsdWVGcm9tOgogICAgICAgICAgICAgICAgc2VjcmV0S2V5UmVmOgogICAgICAgICAgICAgICAgICBuYW1lOiBzdWIycmFuay1zZWNyZXRzCiAgICAgICAgICAgICAgICAgIGtleTogQVBJU1RBVEVfQVBJX0tFWQogICAgICAgICAgICAtIG5hbWU6IEFQSVNUQVRFX1NFU1NJT05fU0VDUkVUCiAgICAgICAgICAgICAgdmFsdWVGcm9tOgogICAgICAgICAgICAgICAgc2VjcmV0S2V5UmVmOgogICAgICAgICAgICAgICAgICBuYW1lOiBzdWIycmFuay1zZWNyZXRzCiAgICAgICAgICAgICAgICAgIGtleTogQVBJU1RBVEVfU0VTU0lPTl9TRUNSRVQKICAgICAgICAgICAgLSBuYW1lOiBTVUIyQVBJX0FETUlOX0VNQUlMCiAgICAgICAgICAgICAgdmFsdWVGcm9tOgogICAgICAgICAgICAgICAgc2VjcmV0S2V5UmVmOgogICAgICAgICAgICAgICAgICBuYW1lOiBzdWIycmFuay1zZWNyZXRzCiAgICAgICAgICAgICAgICAgIGtleTogU1VCMkFQSV9BRE1JTl9FTUFJTAogICAgICAgICAgICAtIG5hbWU6IFNVQjJBUElfQURNSU5fUEFTU1dPUkQKICAgICAgICAgICAgICB2YWx1ZUZyb206CiAgICAgICAgICAgICAgICBzZWNyZXRLZXlSZWY6CiAgICAgICAgICAgICAgICAgIG5hbWU6IHN1YjJyYW5rLXNlY3JldHMKICAgICAgICAgICAgICAgICAga2V5OiBTVUIyQVBJX0FETUlOX1BBU1NXT1JECiAgICAgICAgICAgIC0gbmFtZTogQVBJU1RBVEVfVEVNUE9SQUxfQUREUkVTUwogICAgICAgICAgICAgIHZhbHVlOiAidGVtcG9yYWwtZnJvbnRlbmQudGVtcG9yYWwuc3ZjLmNsdXN0ZXIubG9jYWw6NzIzMyIKICAgICAgICAgIHJlYWRpbmVzc1Byb2JlOgogICAgICAgICAgICBodHRwR2V0OgogICAgICAgICAgICAgIHBhdGg6IC9oZWFsdGgKICAgICAgICAgICAgICBwb3J0OiBoZWFsdGgKICAgICAgICAgICAgaW5pdGlhbERlbGF5U2Vjb25kczogMwogICAgICAgICAgICBwZXJpb2RTZWNvbmRzOiAxMAogICAgICAgICAgICB0aW1lb3V0U2Vjb25kczogMwogICAgICAgICAgICBmYWlsdXJlVGhyZXNob2xkOiA2CiAgICAgICAgICBsaXZlbmVzc1Byb2JlOgogICAgICAgICAgICBodHRwR2V0OgogICAgICAgICAgICAgIHBhdGg6IC9oZWFsdGgKICAgICAgICAgICAgICBwb3J0OiBoZWFsdGgKICAgICAgICAgICAgaW5pdGlhbERlbGF5U2Vjb25kczogMwogICAgICAgICAgICBwZXJpb2RTZWNvbmRzOiAxMAogICAgICAgICAgICB0aW1lb3V0U2Vjb25kczogMwogICAgICAgICAgICBmYWlsdXJlVGhyZXNob2xkOiA2CiAgICAgICAgICB2b2x1bWVNb3VudHM6CiAgICAgICAgICAgIC0gbmFtZTogYXBwLWNvbmZpZwogICAgICAgICAgICAgIG1vdW50UGF0aDogL2V0Yy9zdWIycmFuay9zdWIycmFuay55YW1sCiAgICAgICAgICAgICAgc3ViUGF0aDogc3ViMnJhbmsueWFtbAogICAgICAgICAgICAgIHJlYWRPbmx5OiB0cnVlCiAgICAgICAgICAgIC0gbmFtZTogZGF0YQogICAgICAgICAgICAgIG1vdW50UGF0aDogL3Zhci9saWIvc3ViMnJhbmsKICAgICAgICAgICAgLSBuYW1lOiBob3N0LXNvdXJjZS0wCiAgICAgICAgICAgICAgbW91bnRQYXRoOiAvb3B0L3VuaWRlc2sKICAgICAgICAgICAgICByZWFkT25seTogdHJ1ZQogICAgICB2b2x1bWVzOgogICAgICAgIC0gbmFtZTogYXBwLWNvbmZpZwogICAgICAgICAgY29uZmlnTWFwOgogICAgICAgICAgICBuYW1lOiBzdWIycmFuay1jb25maWcKICAgICAgICAtIG5hbWU6IGRhdGEKICAgICAgICAgIHBlcnNpc3RlbnRWb2x1bWVDbGFpbToKICAgICAgICAgICAgY2xhaW1OYW1lOiBzdWIycmFuay1kYXRhCiAgICAgICAgLSBuYW1lOiBob3N0LXNvdXJjZS0wCiAgICAgICAgICBob3N0UGF0aDoKICAgICAgICAgICAgcGF0aDogL3Jvb3QvdW5pZGVzawogICAgICAgICAgICB0eXBlOiBEaXJlY3RvcnkK + workspaces: + - name: source + volumeClaimTemplate: + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 4Gi diff --git a/Dockerfile b/Dockerfile index ee2ccd6..3ac9ada 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,6 +9,7 @@ RUN apt-get update \ COPY package.json bun.lock ./ RUN bun install --frozen-lockfile --production COPY config ./config +COPY scripts ./scripts COPY src ./src COPY static ./static diff --git a/config/sub2rank.yaml b/config/sub2rank.yaml index 00d8abe..3c93246 100644 --- a/config/sub2rank.yaml +++ b/config/sub2rank.yaml @@ -98,12 +98,18 @@ runtime: databasePath: .state/sub2rank.sqlite scoreCachePath: .state/account-scores.json monitorWorkDir: /root/unidesk + temporalTaskQueue: apistate-native production: mode: http baseUrl: https://apistate.hwpod.com adminToken: sourceRef: platform-infra/apistate.env sourceKey: APISTATE_API_KEY + development: + mode: http + baseUrl: http://sub2rank.platform-infra-development.svc.cluster.local:8080 + adminToken: + envKey: APISTATE_API_KEY native-api: mode: http baseUrl: http://127.0.0.1:8080 @@ -171,6 +177,8 @@ runtime: databasePath: .state/sub2rank.sqlite scoreCachePath: .state/account-scores.json monitorWorkDir: /root/unidesk + temporalTaskQueue: apistate-native + scoreScheduleWorkflowId: apistate-native-score-refresh-schedule adminTokenEnv: APISTATE_API_KEY sub2apiAdminEmailEnv: SUB2API_ADMIN_EMAIL sub2apiAdminPasswordEnv: SUB2API_ADMIN_PASSWORD @@ -189,6 +197,28 @@ runtime: databasePath: /var/lib/sub2rank/sub2rank.sqlite scoreCachePath: /var/lib/sub2rank/account-scores.json monitorWorkDir: /opt/unidesk + temporalTaskQueue: apistate + scoreScheduleWorkflowId: apistate-score-refresh-schedule + adminTokenEnv: APISTATE_API_KEY + sub2apiAdminEmailEnv: SUB2API_ADMIN_EMAIL + sub2apiAdminPasswordEnv: SUB2API_ADMIN_PASSWORD + webPasswordEnv: APISTATE_WEB_PASSWORD + apiKeyEnv: APISTATE_API_KEY + sessionSecretEnv: APISTATE_SESSION_SECRET + development: + listenHost: 0.0.0.0 + listenPort: 8080 + workerHealthHost: 0.0.0.0 + workerHealthPort: 8081 + webListenHost: 127.0.0.1 + webListenPort: 5173 + webApiBaseUrl: http://127.0.0.1:8080 + secureCookies: false + databasePath: /var/lib/sub2rank/sub2rank.sqlite + scoreCachePath: /var/lib/sub2rank/account-scores.json + monitorWorkDir: /opt/unidesk + temporalTaskQueue: apistate-development + scoreScheduleWorkflowId: apistate-development-score-refresh-schedule adminTokenEnv: APISTATE_API_KEY sub2apiAdminEmailEnv: SUB2API_ADMIN_EMAIL sub2apiAdminPasswordEnv: SUB2API_ADMIN_PASSWORD diff --git a/scripts/src/cli.ts b/scripts/src/cli.ts index 6e7e60b..10a55e4 100644 --- a/scripts/src/cli.ts +++ b/scripts/src/cli.ts @@ -160,7 +160,7 @@ function isAppCommand(value: AppCommand | Record): value is App async function embedded(parsed: Parsed, config: ReturnType, target: EmbeddedCliTarget): Promise { if (parsed.command.join(" ") === "scores refresh" || parsed.command.join(" ") === "workflow status") { - const temporal = await TemporalGateway.connect(config); + const temporal = await TemporalGateway.connect(config, { taskQueue: target.temporalTaskQueue }); try { if (parsed.command[0] === "scores") return await temporal.submit({ kind: "scores.refresh" }); if (!parsed.id) throw new Error("workflow status requires --id"); @@ -174,7 +174,7 @@ async function embedded(parsed: Parsed, config: ReturnType, t const context = createEmbeddedContext(config, target); let temporal: TemporalGateway | null = null; try { - if (usesWorkflow(command)) temporal = await TemporalGateway.connect(config); + if (usesWorkflow(command)) temporal = await TemporalGateway.connect(config, { taskQueue: target.temporalTaskQueue }); return await new ApplicationDispatcher({ lottery: context.service, scores: context.monitor }, temporal).dispatch(command); } finally { if (temporal) await temporal.close(); @@ -206,11 +206,15 @@ async function remote(parsed: Parsed, config: ReturnType, tar if (group === "credit" && action === "test") return await client.creditTest(parsed.confirm); if (group === "api" && action === "smoke") { const [status, scores, ranking, lottery] = await Promise.all([client.serviceStatus(), client.scores(), client.ranking(), client.lottery()]); - const refreshed = await client.refreshScores(); + const refreshed = await client.workflowSubmit({ kind: "scores.refresh" }); return { ok: status.ok === true && scores.ok === true && refreshed.ok === true && ranking.ok === true && lottery.ok === true, action: "apistate-api-smoke", - checks: { status: status.ok === true, scores: scores.ok === true, refresh: refreshed.ok === true && refreshed.snapshotOk === true, ranking: ranking.ok === true, lottery: lottery.ok === true }, + checks: { status: status.ok === true, scores: scores.ok === true, refreshSubmitted: refreshed.ok === true, ranking: ranking.ok === true, lottery: lottery.ok === true }, + workflowId: refreshed.workflowId, + runId: refreshed.runId, + state: refreshed.state, + next: "workflow status --id ", valuesPrinted: false, }; } @@ -244,7 +248,10 @@ export async function runCli(args: string[]): Promise { const config = loadConfig(parsed.configPath); if (parsed.command.join(" ") === "config validate") return emit({ ok: true, configPath: config.configPath, kind: config.kind, service: config.metadata.name, - temporalNamespace: config.temporal.namespace, temporalTaskQueue: config.temporal.taskQueue, + temporalNamespace: config.temporal.namespace, + temporalTaskQueue: config.temporal.taskQueue, + cliTargets: Object.fromEntries(Object.entries(config.runtime.cliTargets).map(([id, target]) => [id, target.mode === "embedded" ? { mode: target.mode, temporalTaskQueue: target.temporalTaskQueue } : { mode: target.mode }])), + serverTargets: Object.fromEntries(Object.entries(config.runtime.serverTargets).map(([id, target]) => [id, { temporalTaskQueue: target.temporalTaskQueue, scoreScheduleWorkflowId: target.scoreScheduleWorkflowId }])), refreshIntervalMinutes: config.monitor.refreshIntervalMinutes, scoreWindow: config.monitor.scoreWindow, automaticCreditEnabled: config.lottery.automaticCredit.enabled, valuesPrinted: false, }, parsed.json); diff --git a/src/account-score-native.test.ts b/src/account-score-native.test.ts new file mode 100644 index 0000000..bb7ce79 --- /dev/null +++ b/src/account-score-native.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, test } from "bun:test"; +import { aggregateNativeGroupScore, collectNativeScores } from "./account-score-native"; +import type { Sub2ApiClient } from "./sub2api-client"; + +const availableOps = { status: "available" as const, data: {}, reason: null }; + +describe("aggregateNativeGroupScore", () => { + test("scores upstream failures and excludes client input locally", () => { + const group = { id: 2, name: "pool", platform: "openai", status: "active" }; + const account = { id: 15, name: "primary 0.02", platform: "openai", status: "active", schedulable: true, priority: 1 }; + const usage = Array.from({ length: 20 }, (_, index) => ({ + id: index + 1, + account_id: 15, + group_id: 2, + model: "gpt-test", + stream: true, + input_tokens: 100, + output_tokens: 20, + actual_cost: 0.1, + duration_ms: 20_000, + first_token_ms: 5_000 + index, + created_at: "2026-07-17T09:00:00Z", + })); + const result = aggregateNativeGroupScore({ + group, + accounts: [account], + usage, + requestErrors: [ + { id: 1, request_id: "req-fail", account_id: 15, status_code: 502, phase: "upstream", type: "upstream_error", message: "Upstream service temporarily unavailable" }, + { id: 2, request_id: "req-input", account_id: 15, status_code: 400, phase: "client", type: "invalid_request", message: "Input must be a list" }, + ], + systemLogs: [ + { id: 10, created_at: "2026-07-17T09:01:00Z", message: "openai.upstream_failover_switching", request_id: "req-fail", account_id: 15, extra: { group_id: 2 } }, + { id: 11, created_at: "2026-07-17T09:01:01Z", message: "http request completed", request_id: "req-fail", account_id: 15, extra: { group_id: 2, status_code: 200 } }, + ], + overview: { + request_count_total: 22, + error_count_total: 1, + upstream_error_count_excl_429_529: 1, + upstream_429_count: 0, + upstream_529_count: 0, + error_rate: 1 / 22, + upstream_error_rate: 1 / 22, + ttft: { p99_ms: 5019 }, + }, + availability: availableOps, + concurrency: availableOps, + }); + const row = result.accounts[0]!; + expect(row.successRequests).toBe(20); + expect(row.failureRequests).toBe(1); + expect(row.scoreableUpstreamErrorRequests).toBe(1); + expect(row.excludedNonUpstreamErrorRequests).toBe(1); + expect(row.failoverRequests).toBe(1); + expect(row.failoverRecovered).toBe(1); + expect((row.usage as Record).tokenCount).toBe(2400); + expect((row.usage as Record).upstreamCostCny).toBe(0.04); + expect(result.collection.mode).toBe("nc01-native-api-local-aggregation"); + }); + + test("does not attribute another group policy event", () => { + const result = aggregateNativeGroupScore({ + group: { id: 2, name: "pool", platform: "openai", status: "active" }, + accounts: [{ id: 15, name: "primary 0.0", platform: "openai", status: "active", schedulable: true, priority: 1 }], + usage: [], + requestErrors: [], + systemLogs: [{ id: 1, created_at: "2026-07-17T09:00:00Z", message: "openai.forward_failed", request_id: "req-other", account_id: 15, extra: { group_id: 3 } }], + overview: {}, + availability: { + status: "available", + data: { + group: { "2": { total_accounts: 1, available_count: 0, rate_limit_count: 0, error_count: 1 } }, + account: { "15": { account_id: 15, group_id: 2, is_available: false } }, + }, + reason: null, + }, + concurrency: { status: "available", data: { group: { "2": { current_in_use: 0, max_capacity: 10, waiting_in_queue: 0 } } }, reason: null }, + }); + expect(result.accounts[0]!.forwardFailedRequests).toBe(0); + expect(result.accounts[0]!.currentlyAvailable).toBe(false); + expect(result.group.unavailableAccountCount).toBe(1); + expect(result.group.maxCapacity).toBe(10); + }); + + test("collects groups sequentially and deduplicates marker results", async () => { + const calls: string[] = []; + const fake = { + async listGroups() { + calls.push("groups"); + return [ + { id: 2, name: "pool", platform: "openai", status: "active" }, + { id: 3, name: "self", platform: "openai", status: "active" }, + ]; + }, + async listGroupAccounts(groupId: number) { calls.push(`accounts:${groupId}`); return []; }, + async getOpsOverview(groupId: number) { calls.push(`overview:${groupId}`); return {}; }, + async getOpsAccountAvailability(groupId: number) { calls.push(`availability:${groupId}`); return {}; }, + async getOpsConcurrency(groupId: number) { calls.push(`concurrency:${groupId}`); return {}; }, + async listGroupUsage(groupId: number) { calls.push(`usage:${groupId}`); return []; }, + async listRequestErrors(groupId: number) { calls.push(`errors:${groupId}`); return []; }, + } as unknown as Sub2ApiClient; + const events = { + async collect() { + calls.push("events"); + return { + events: [{ id: 1, created_at: "2026-07-17T09:00:00Z", message: "account_temp_unschedulable", account_id: 15 }], + evidence: { source: "test", eventCount: 1 }, + }; + }, + }; + const result = await collectNativeScores(fake, events, "8h", new Date("2026-07-17T10:00:00Z")); + expect(result.groups).toHaveLength(2); + expect(calls.filter((call) => call === "events")).toHaveLength(1); + expect(calls.indexOf("accounts:3")).toBeGreaterThan(calls.indexOf("errors:2")); + const collection = result.collection.groups as Array>; + expect(collection[0]!.policyEventRows).toBe(1); + }); +}); diff --git a/src/account-score-native.ts b/src/account-score-native.ts new file mode 100644 index 0000000..36235d2 --- /dev/null +++ b/src/account-score-native.ts @@ -0,0 +1,358 @@ +import type { + Sub2ApiAccount, + Sub2ApiGroup, + Sub2ApiRequestError, + Sub2ApiSystemLog, + Sub2ApiUsageRow, +} from "./sub2api-client"; +import { Sub2ApiClient } from "./sub2api-client"; +import type { RuntimePolicyEventSource } from "./runtime-policy-events"; + +type Row = Record; + +export interface NativeGroupScoreInput { + group: Sub2ApiGroup; + accounts: Sub2ApiAccount[]; + usage: Sub2ApiUsageRow[]; + requestErrors: Sub2ApiRequestError[]; + systemLogs: Sub2ApiSystemLog[]; + overview: Record; + availability: NativeOpsResult; + concurrency: NativeOpsResult; +} + +interface NativeOpsResult { + status: "available" | "unavailable"; + data: Row; + reason: string | null; +} + +const policyMarkers = { + temp: "account_temp_unschedulable", + failover: "openai.upstream_failover_switching", + forward: "openai.forward_failed", + retryOpenAI: "openai.pool_mode_same_account_retry", + retryGateway: "gateway.failover_same_account_retry", + completed: "http request completed", +} as const; + +export const nativeScoreSystemLogMarkers = Object.values(policyMarkers); + +function windowStart(window: string, now: Date): Date { + const match = window.match(/^([1-9][0-9]*)(m|h|d)$/u); + if (!match) throw new Error(`unsupported score window: ${window}`); + const amount = Number(match[1]); + const unitMs = match[2] === "m" ? 60_000 : match[2] === "h" ? 3_600_000 : 86_400_000; + return new Date(now.getTime() - amount * unitMs); +} + +export async function collectNativeScores(client: Sub2ApiClient, eventSource: RuntimePolicyEventSource, window: string, now = new Date()): Promise<{ groups: Row[]; accounts: Row[]; collection: Row }> { + const start = windowStart(window, now); + const groups = await client.listGroups(); + const policyEvents = await eventSource.collect(window); + const groupRows: Row[] = []; + const accountRows: Row[] = []; + const collectionRows: Row[] = []; + for (const group of groups) { + // Keep database-heavy reads sequential on the two-core Sub2API host. + const accounts = await client.listGroupAccounts(group.id, group.platform); + const overview = await client.getOpsOverview(group.id, group.platform, start); + const availability = await optionalOps(() => client.getOpsAccountAvailability(group.id, group.platform)); + const concurrency = await optionalOps(() => client.getOpsConcurrency(group.id, group.platform)); + const usage = await client.listGroupUsage(group.id, start, now); + const requestErrors = await client.listRequestErrors(group.id, group.platform, start); + const result = aggregateNativeGroupScore({ group, accounts, usage, requestErrors, systemLogs: policyEvents.events, overview, availability, concurrency }); + groupRows.push(result.group); + accountRows.push(...result.accounts.map((account) => ({ groupId: group.id, groupName: group.name, platform: group.platform, ...account }))); + collectionRows.push({ groupId: group.id, groupName: group.name, ...result.collection }); + } + return { + groups: groupRows, + accounts: accountRows, + collection: { + mode: "nc01-native-api-local-aggregation", + window, + startAt: start.toISOString(), + endAt: now.toISOString(), + groupCount: groups.length, + groups: collectionRows, + policyEvents: policyEvents.evidence, + }, + }; +} + +async function optionalOps(operation: () => Promise): Promise { + try { + return { status: "available", data: await operation(), reason: null }; + } catch (error) { + return { status: "unavailable", data: {}, reason: error instanceof Error ? error.message : String(error) }; + } +} + +function numeric(value: unknown): number | null { + const parsed = Number(value); + return value !== null && value !== undefined && Number.isFinite(parsed) ? parsed : null; +} + +function record(value: unknown): Row { + return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Row : {}; +} + +function percentile(values: number[], fraction: number): number | null { + if (values.length === 0) return null; + const ordered = [...values].sort((left, right) => left - right); + const position = (ordered.length - 1) * fraction; + const lower = Math.floor(position); + const upper = Math.min(lower + 1, ordered.length - 1); + return Math.round(ordered[lower]! + (ordered[upper]! - ordered[lower]!) * (position - lower)); +} + +function costRate(name: string): number | null { + const match = name.match(/(\d+(?:\.\d+)?)$/u); + return match ? Number(match[1]) : null; +} + +function extra(log: Sub2ApiSystemLog): Row { + return log.extra && typeof log.extra === "object" ? log.extra : {}; +} + +function accountId(log: Sub2ApiSystemLog): number | null { + return numeric(log.account_id) ?? numeric(extra(log).account_id); +} + +function requestId(log: Sub2ApiSystemLog): string | null { + const value = log.request_id ?? extra(log).request_id; + return typeof value === "string" && value ? value : null; +} + +function groupMatches(log: Sub2ApiSystemLog, groupId: number): boolean { + const value = numeric(extra(log).group_id); + return value === null || value === groupId; +} + +function customerErrorAttribution(row: Sub2ApiRequestError): { scoreable: boolean; reason: string } { + const message = String(row.message ?? row.error_message ?? "").toLowerCase(); + if (message.includes("context window") || message.includes("context_length_exceeded")) return { scoreable: false, reason: "context-window" }; + if (message.includes("input must be a list")) return { scoreable: false, reason: "invalid-client-input" }; + if (message.includes("not supported by any configured account") || message.includes("no available channel for model")) return { scoreable: false, reason: "model-route" }; + const phase = String(row.phase ?? "").toLowerCase(); + if (["internal", "client", "business"].includes(phase)) return { scoreable: false, reason: "non-upstream-phase" }; + if (row.account_id === null || row.account_id === undefined) return { scoreable: false, reason: "no-account-attribution" }; + const category = String(row.type ?? "").toLowerCase(); + if (phase === "upstream" || category.includes("upstream")) return { scoreable: true, reason: "explicit-upstream" }; + const stable = ["upstream service temporarily unavailable", "upstream request failed", "bad gateway", "gateway timeout", "error code: 502", "error code: 503", "error code: 504", "error code: 524"]; + return stable.some((marker) => message.includes(marker)) + ? { scoreable: true, reason: "stable-upstream-message" } + : { scoreable: false, reason: "unattributed-customer-error" }; +} + +function grade(score: number | null, comparable: boolean, attempts: number): string { + if (score === null || (!comparable && !(score < 60 && attempts >= 10))) return "insufficient"; + return score >= 90 ? "A" : score >= 80 ? "B" : score >= 70 ? "C" : score >= 60 ? "D" : "E"; +} + +export function aggregateNativeGroupScore(input: NativeGroupScoreInput): { group: Row; accounts: Row[]; collection: Row } { + const usageByAccount = new Map(); + for (const row of input.usage) { + if (row.account_id === null) continue; + usageByAccount.set(row.account_id, [...(usageByAccount.get(row.account_id) ?? []), row]); + } + + const scoreableByAccount = new Map>(); + const excludedByAccount = new Map>(); + const customerErrorCount = new Map>(); + for (const row of input.requestErrors) { + if (row.account_id === null || row.account_id === undefined || !row.request_id) continue; + const id = row.account_id; + customerErrorCount.set(id, new Set([...(customerErrorCount.get(id) ?? []), row.request_id])); + const attribution = customerErrorAttribution(row); + if (attribution.scoreable) scoreableByAccount.set(id, new Set([...(scoreableByAccount.get(id) ?? []), row.request_id])); + else { + const reasons = excludedByAccount.get(id) ?? new Map(); + reasons.set(attribution.reason, (reasons.get(attribution.reason) ?? 0) + 1); + excludedByAccount.set(id, reasons); + } + } + + const finalStatus = new Map(); + const failover = new Map>(); + const forward = new Map>(); + const temp = new Map(); + const retries = new Map(); + const upstreamStatuses = new Map>(); + for (const log of input.systemLogs) { + if (!groupMatches(log, input.group.id)) continue; + const id = accountId(log); + const request = requestId(log); + const message = log.message ?? ""; + if (message.includes(policyMarkers.completed) && request) { + const status = numeric(extra(log).status_code); + if (status !== null) finalStatus.set(request, status); + } + if (id === null) continue; + if (message.includes(policyMarkers.temp)) temp.set(id, (temp.get(id) ?? 0) + 1); + if ((message.includes(policyMarkers.retryOpenAI) || message.includes(policyMarkers.retryGateway))) retries.set(id, (retries.get(id) ?? 0) + 1); + const eventGroupId = numeric(extra(log).group_id); + if (request && eventGroupId === input.group.id && message.includes(policyMarkers.failover)) { + failover.set(id, new Set([...(failover.get(id) ?? []), request])); + const status = numeric(extra(log).upstream_status); + if (status !== null) { + const buckets = upstreamStatuses.get(id) ?? new Map(); + buckets.set(status, (buckets.get(status) ?? 0) + 1); + upstreamStatuses.set(id, buckets); + } + } + if (request && eventGroupId === input.group.id && message.includes(policyMarkers.forward)) forward.set(id, new Set([...(forward.get(id) ?? []), request])); + } + + const availabilityAccounts = Object.values(record(input.availability.data.account)).map(record); + const availabilityByAccount = new Map(); + for (const value of availabilityAccounts) { + const id = numeric(value.account_id); + if (id !== null && numeric(value.group_id) === input.group.id) availabilityByAccount.set(id, value); + } + const availabilityGroups = record(input.availability.data.group); + const availabilityGroup = record(availabilityGroups[String(input.group.id)] ?? availabilityGroups[input.group.id]); + const concurrencyGroups = record(input.concurrency.data.group); + const concurrencyGroup = record(concurrencyGroups[String(input.group.id)] ?? concurrencyGroups[input.group.id]); + + const accountRows = input.accounts.map((account): Row => { + const usages = usageByAccount.get(account.id) ?? []; + const streams = usages.filter((row) => row.stream); + const ttft = streams.flatMap((row) => row.first_token_ms === null ? [] : [row.first_token_ms]); + const durations = usages.flatMap((row) => row.duration_ms === null ? [] : [row.duration_ms]); + const scoreable = scoreableByAccount.get(account.id) ?? new Set(); + const failed = new Set([...(failover.get(account.id) ?? []), ...(forward.get(account.id) ?? []), ...scoreable]); + const successRequests = usages.length; + const failureRequests = failed.size; + const attempts = successRequests + failureRequests; + const failureRate = attempts > 0 ? Math.round(failureRequests / attempts * 1_000_000) / 1_000_000 : null; + const ttftP95Ms = percentile(ttft, 0.95); + const reliability = failureRate === null ? null : Math.round(60 * (1 - Math.min(Math.max(failureRate, 0), 0.2) / 0.2) * 100) / 100; + const latency = ttft.length < 5 || ttftP95Ms === null ? null : Math.round(25 * (1 - Math.min(Math.max(ttftP95Ms - 10_000, 0), 170_000) / 170_000) * 100) / 100; + const nativeAvailability = availabilityByAccount.get(account.id)?.is_available; + const currentlyAvailable = typeof nativeAvailability === "boolean" + ? nativeAvailability + : account.status === "active" && account.schedulable !== false; + const availability = currentlyAvailable ? 15 : account.status === "active" ? 8 : 0; + const availableWeight = (reliability === null ? 0 : 60) + (latency === null ? 0 : 25) + 15; + const score = attempts > 0 ? Math.round(((reliability ?? 0) + (latency ?? 0) + availability) / availableWeight * 1_000) / 10 : null; + const comparable = attempts >= 10 && ttft.length >= 5; + const accountGrade = grade(score, comparable, attempts); + const failoverIds = failover.get(account.id) ?? new Set(); + const failoverRecovered = [...failoverIds].filter((id) => (finalStatus.get(id) ?? 999) < 400).length; + const failoverFailed = [...failoverIds].filter((id) => (finalStatus.get(id) ?? 0) >= 400).length; + const models = new Map(); + for (const usage of usages) models.set(usage.model || "unknown", (models.get(usage.model || "unknown") ?? 0) + 1); + const amount = usages.reduce((sum, row) => sum + (numeric(row.actual_cost) ?? 0), 0); + const rate = costRate(account.name); + const reasons = [ + ...(failureRate !== null && failureRate >= 0.1 ? ["failure-rate>=10%"] : failureRate !== null && failureRate >= 0.03 ? ["failure-rate>=3%"] : []), + ...(failoverIds.size > 0 ? ["upstream-failover-triggered"] : []), + ...(!currentlyAvailable ? ["currently-unavailable"] : []), + ...(ttft.length < 5 ? ["ttft-evidence-insufficient"] : []), + ...(attempts < 10 ? ["request-evidence-insufficient"] : []), + ]; + return { + accountId: account.id, + accountName: account.name, + status: account.status, + schedulable: account.schedulable, + currentlyAvailable, + priority: account.priority, + priorityOrder: "lower-is-higher", + score, + grade: accountGrade, + assessment: ({ A: "preferred", B: "healthy", C: "watch", D: "degraded", E: "poor" } as Row)[accountGrade] ?? "insufficient-evidence", + confidence: attempts >= 50 && ttft.length >= 20 ? "high" : attempts >= 10 && ttft.length >= 5 ? "medium" : "low", + scoreComparable: comparable, + observedAttempts: attempts, + successRequests, + failureRequests, + failureRate, + streamSuccessRequests: streams.length, + firstTokenSamples: ttft.length, + firstTokenCoverage: streams.length > 0 ? Math.round(ttft.length / streams.length * 1_000_000) / 1_000_000 : null, + ttftP50Ms: percentile(ttft, 0.5), + ttftP95Ms, + ttftP99Ms: percentile(ttft, 0.99), + ttftMaxMs: ttft.length ? Math.max(...ttft) : null, + durationP95Ms: percentile(durations, 0.95), + modelBuckets: [...models].sort((left, right) => right[1] - left[1]).slice(0, 8).map(([model, count]) => ({ model, count })), + customerErrorRequests: customerErrorCount.get(account.id)?.size ?? 0, + scoreableUpstreamErrorRequests: scoreable.size, + excludedNonUpstreamErrorRequests: [...(excludedByAccount.get(account.id)?.values() ?? [])].reduce((sum, value) => sum + value, 0), + excludedReasonBuckets: [...(excludedByAccount.get(account.id) ?? [])].map(([reason, count]) => ({ reason, count })), + usage: { + requestCount: usages.length, + tokenCount: usages.reduce((sum, row) => sum + row.input_tokens + row.output_tokens, 0), + apiAmountUsd: Math.round(amount * 100_000_000) / 100_000_000, + costRateCnyPerApiUsd: rate, + upstreamCostCny: rate === null ? null : Math.round(amount * rate * 100_000_000) / 100_000_000, + }, + failoverRequests: failoverIds.size, + failoverRecovered, + failoverFailed, + failoverOutcomeMissing: failoverIds.size - failoverRecovered - failoverFailed, + sameAccountRetryEvents: retries.get(account.id) ?? 0, + tempUnschedulableEvents: temp.get(account.id) ?? 0, + forwardFailedRequests: forward.get(account.id)?.size ?? 0, + upstreamStatusBuckets: [...(upstreamStatuses.get(account.id) ?? [])].sort((left, right) => left[0] - right[0]).map(([statusCode, count]) => ({ statusCode, count })), + scoreComponents: { reliability, latency, availability, availableWeight }, + reasons, + usageStatus: "available", + usageReason: null, + }; + }); + + const overview = input.overview; + const requestCount = numeric(overview.request_count_total) ?? 0; + const errorCount = numeric(overview.error_count_total) ?? 0; + const upstreamErrorCount = [overview.upstream_error_count_excl_429_529, overview.upstream_429_count, overview.upstream_529_count] + .reduce((sum: number, value) => sum + (numeric(value) ?? 0), 0); + const ttftOverview = overview.ttft && typeof overview.ttft === "object" ? overview.ttft as Row : {}; + const durationOverview = overview.duration && typeof overview.duration === "object" ? overview.duration as Row : {}; + const unavailableAccountCount = numeric(availabilityGroup.total_accounts) !== null && numeric(availabilityGroup.available_count) !== null + ? Math.max(0, Number(availabilityGroup.total_accounts) - Number(availabilityGroup.available_count)) + : [...availabilityByAccount.values()].filter((value) => value.is_available === false).length; + return { + group: { + groupId: input.group.id, + groupName: input.group.name, + platform: input.group.platform, + status: input.group.status, + requestCount, + errorCount, + errorRate: numeric(overview.error_rate), + upstreamErrorCount, + upstreamErrorRate: numeric(overview.upstream_error_rate), + businessLimitedCount: numeric(overview.business_limited_count) ?? 0, + ttftP99Ms: numeric(ttftOverview.p99_ms), + durationP99Ms: numeric(durationOverview.p99_ms), + totalAccounts: numeric(availabilityGroup.total_accounts), + availableCount: numeric(availabilityGroup.available_count), + rateLimitCount: numeric(availabilityGroup.rate_limit_count), + errorAccountCount: numeric(availabilityGroup.error_count), + unavailableAccountCount, + currentInUse: numeric(concurrencyGroup.current_in_use), + maxCapacity: numeric(concurrencyGroup.max_capacity), + waitingInQueue: numeric(concurrencyGroup.waiting_in_queue), + needsAttention: errorCount > 0 || upstreamErrorCount > 0 || unavailableAccountCount > 0, + opsStatus: { + overview: "available", + accountAvailability: input.availability.status, + concurrency: input.concurrency.status, + }, + }, + accounts: accountRows, + collection: { + mode: "nc01-native-api-local-aggregation", + accountCount: input.accounts.length, + usageRows: input.usage.length, + requestErrorRows: input.requestErrors.length, + policyEventRows: input.systemLogs.length, + accountAvailabilityStatus: input.availability.status, + concurrencyStatus: input.concurrency.status, + }, + }; +} diff --git a/src/account-score-service.ts b/src/account-score-service.ts index 1658e71..839b072 100644 --- a/src/account-score-service.ts +++ b/src/account-score-service.ts @@ -1,7 +1,10 @@ -import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs"; -import { dirname, resolve } from "node:path"; +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; import { mergeAccountScores } from "./account-score-aggregation"; +import { collectNativeScores } from "./account-score-native"; import type { AppConfig } from "./config"; +import type { Sub2ApiClient } from "./sub2api-client"; +import type { RuntimePolicyEventSource } from "./runtime-policy-events"; interface ScoreSnapshot { ok: boolean; @@ -14,6 +17,7 @@ interface ScoreSnapshot { accounts: Array>; error: string | null; source: string; + collection?: Record; } function record(value: unknown): Record | null { @@ -24,31 +28,17 @@ function records(value: unknown): Array> { return Array.isArray(value) ? value.map(record).filter((item): item is Record => item !== null) : []; } -function nested(root: unknown, ...keys: string[]): unknown { - let value = root; - for (const key of keys) value = record(value)?.[key]; - return value; -} - -async function pooled(values: T[], concurrency: number, operation: (value: T) => Promise): Promise { - const results = new Array(values.length); - let cursor = 0; - await Promise.all(Array.from({ length: Math.min(concurrency, values.length) }, async () => { - for (;;) { - const index = cursor++; - if (index >= values.length) return; - results[index] = await operation(values[index]!); - } - })); - return results; -} - export class AccountScoreService { private timer: ReturnType | null = null; private inFlight: Promise | null = null; private snapshot: ScoreSnapshot; - constructor(private readonly config: AppConfig, private readonly cachePath: string, private readonly cliWorkDir: string) { + constructor( + private readonly config: AppConfig, + private readonly cachePath: string, + private readonly sub2api: Sub2ApiClient, + private readonly policyEvents: RuntimePolicyEventSource, + ) { this.snapshot = this.readCache(); } @@ -86,24 +76,9 @@ export class AccountScoreService { const startedAt = new Date(); this.snapshot = { ...this.snapshot, status: "refreshing", refreshStartedAt: startedAt.toISOString(), error: null }; try { - const overview = await this.invoke(["--all-groups"]); - const groups = records(nested(overview, "data", "parsed", "allGroups", "groups")); - const details = await pooled(groups, 2, async (group) => { - const id = String(group.groupId ?? ""); - if (!id) return { group, accounts: [] as Array> }; - const payload = await this.invoke(["--group", id]); - return { - group, - accounts: records(nested(payload, "data", "parsed", "errors", "nativeOps", "accountQuality", "accounts")), - }; - }); + const collected = await collectNativeScores(this.sub2api, this.policyEvents, this.config.monitor.scoreWindow); const refreshedAt = new Date(); - const accounts = mergeAccountScores(details.flatMap(({ group, accounts }) => accounts.map((account): Record => ({ - groupId: group.groupId ?? null, - groupName: group.groupName ?? null, - platform: group.platform ?? null, - ...account, - })))); + const accounts = mergeAccountScores(collected.accounts); this.snapshot = { ok: true, status: "ready", @@ -111,10 +86,11 @@ export class AccountScoreService { refreshStartedAt: startedAt.toISOString(), nextRefreshAt: new Date(refreshedAt.getTime() + this.config.monitor.refreshIntervalMinutes * 60_000).toISOString(), window: this.config.monitor.scoreWindow, - groups, + groups: collected.groups, accounts, error: null, - source: "unidesk-sub2api-runtime-errors", + source: "sub2api-native-admin-api-local-aggregation", + collection: collected.collection, }; this.writeCache(this.snapshot); return this.snapshot; @@ -131,50 +107,6 @@ export class AccountScoreService { } } - private async invoke(scope: string[]): Promise> { - const args = [ - resolve(this.cliWorkDir, this.config.monitor.cli.entrypoint), - "platform-infra", "sub2api", "codex-pool", "runtime", "errors", - "--target", this.config.monitor.target, - "--since", this.config.monitor.scoreWindow, - ...scope, - "--raw", - ]; - const process = Bun.spawn([this.config.monitor.cli.executable, ...args], { - cwd: this.cliWorkDir, - stdout: "pipe", - stderr: "pipe", - env: { ...Bun.env, UNIDESK_MAIN_SERVER_IP: this.config.monitor.cli.mainServerHost }, - }); - let timedOut = false; - const timeout = setTimeout(() => { - timedOut = true; - process.kill(); - }, this.config.monitor.cli.timeoutMs); - const [stdout, stderr, exitCode] = await Promise.all([ - new Response(process.stdout).text(), - new Response(process.stderr).text(), - process.exited, - ]).finally(() => clearTimeout(timeout)); - if (timedOut) throw new Error(`评分 CLI 在 ${this.config.monitor.cli.timeoutMs}ms 后超时`); - if (exitCode !== 0) throw new Error(`评分 CLI 退出码 ${exitCode}: ${stderr.trim().slice(-600)}`); - let payload = record(JSON.parse(stdout)); - if (!payload || payload.ok !== true) throw new Error("评分 CLI 返回无效结果"); - if (nested(payload, "data", "outputTruncated")) { - const dumpPath = nested(payload, "data", "dump", "path"); - if (typeof dumpPath !== "string" || !dumpPath.startsWith("/tmp/unidesk-cli-output/")) { - throw new Error("评分 CLI 渐进披露结果缺少受保护 dump"); - } - try { - payload = record(JSON.parse(readFileSync(dumpPath, "utf8"))); - } finally { - try { unlinkSync(dumpPath); } catch { /* The CLI may clean up its own temporary output. */ } - } - if (!payload || payload.ok !== true) throw new Error("评分 CLI dump 返回无效结果"); - } - return payload; - } - private readCache(): ScoreSnapshot { if (existsSync(this.cachePath)) { try { @@ -194,7 +126,7 @@ export class AccountScoreService { groups: [], accounts: [], error: null, - source: "unidesk-sub2api-runtime-errors", + source: "sub2api-native-admin-api-local-aggregation", }; } diff --git a/src/admin-http-client.ts b/src/admin-http-client.ts index 4512410..7983af8 100644 --- a/src/admin-http-client.ts +++ b/src/admin-http-client.ts @@ -5,7 +5,13 @@ export class AdminHttpClient { private readonly token: string; constructor(private readonly config: AppConfig, private readonly target: HttpCliTarget) { - this.token = readSecret(config, target.adminToken); + if ("envKey" in target.adminToken) { + const value = process.env[target.adminToken.envKey]; + if (!value) throw new Error(`HTTP CLI target requires env ${target.adminToken.envKey}`); + this.token = value; + } else { + this.token = readSecret(config, target.adminToken); + } } private async request(path: string, init: RequestInit = {}, timeoutMs = this.config.sub2api.requestTimeoutMs): Promise { @@ -33,9 +39,6 @@ export class AdminHttpClient { creditTest(execute: boolean): Promise> { return this.request("/api/admin/credit-test", { method: "POST", body: JSON.stringify({ execute }) }); } serviceStatus(): Promise> { return this.request("/api/status"); } scores(): Promise> { return this.request("/api/scores"); } - refreshScores(): Promise> { - return this.request("/api/scores/refresh", { method: "POST", body: "{}" }, this.config.monitor.cli.timeoutMs + 5_000); - } ranking(): Promise> { return this.request("/api/ranking"); } lottery(): Promise> { return this.request("/api/lottery"); } workflowSubmit(command: Record): Promise> { diff --git a/src/api.ts b/src/api.ts index 5beeb35..d0b71d5 100644 --- a/src/api.ts +++ b/src/api.ts @@ -13,7 +13,7 @@ const adminToken = process.env[target.adminTokenEnv]; if (!adminToken) throw new Error(`server target requires env ${target.adminTokenEnv}`); const context = createServerContext(config, target); -const temporal = await TemporalGateway.connect(config); +const temporal = await TemporalGateway.connect(config, { taskQueue: target.temporalTaskQueue, scoreScheduleWorkflowId: target.scoreScheduleWorkflowId }); const dispatcher = new ApplicationDispatcher({ lottery: context.service, scores: context.monitor }, temporal); const server = Bun.serve({ hostname: target.listenHost, @@ -27,7 +27,7 @@ console.log(JSON.stringify({ runtime: runtimeId, listen: server.url.toString(), temporalNamespace: config.temporal.namespace, - temporalTaskQueue: config.temporal.taskQueue, + temporalTaskQueue: target.temporalTaskQueue, automaticCreditEnabled: config.lottery.automaticCredit.enabled, valuesPrinted: false, })); diff --git a/src/bootstrap.ts b/src/bootstrap.ts index bca1ca0..e7b8be0 100644 --- a/src/bootstrap.ts +++ b/src/bootstrap.ts @@ -6,6 +6,7 @@ import { LotteryStore } from "./store"; import { Sub2ApiClient } from "./sub2api-client"; import { AccountScoreService } from "./account-score-service"; import type { WebAuthSecrets } from "./web-auth"; +import { UniDeskRuntimePolicyEventSource } from "./runtime-policy-events"; export interface AppContext { service: LotteryService; @@ -18,7 +19,7 @@ export interface AppContext { export function createEmbeddedContext(config: AppConfig, target: EmbeddedCliTarget): AppContext { const store = new LotteryStore(config, resolveDataPath(config, target.databasePath)); const client = new Sub2ApiClient(config, readSub2ApiCredentials(config)); - const monitor = new AccountScoreService(config, resolveDataPath(config, target.scoreCachePath), target.monitorWorkDir); + const monitor = new AccountScoreService(config, resolveDataPath(config, target.scoreCachePath), client, new UniDeskRuntimePolicyEventSource(config, target.monitorWorkDir)); return { service: new LotteryService(config, store, client), store, @@ -39,7 +40,7 @@ export function createServerContext(config: AppConfig, target: ServerTarget): Ap } const store = new LotteryStore(config, resolveDataPath(config, target.databasePath)); const client = new Sub2ApiClient(config, { email, password }); - const monitor = new AccountScoreService(config, resolveDataPath(config, target.scoreCachePath), target.monitorWorkDir); + const monitor = new AccountScoreService(config, resolveDataPath(config, target.scoreCachePath), client, new UniDeskRuntimePolicyEventSource(config, target.monitorWorkDir)); return { service: new LotteryService(config, store, client), store, diff --git a/src/config.ts b/src/config.ts index 61e9a20..3a65f90 100644 --- a/src/config.ts +++ b/src/config.ts @@ -10,6 +10,10 @@ export interface SecretRef { sourceKey: string; } +export interface EnvSecretRef { + envKey: string; +} + export interface AppConfig { apiVersion: string; kind: string; @@ -81,12 +85,13 @@ export interface EmbeddedCliTarget { databasePath: string; scoreCachePath: string; monitorWorkDir: string; + temporalTaskQueue: string; } export interface HttpCliTarget { mode: "http"; baseUrl: string; - adminToken: SecretRef; + adminToken: SecretRef | EnvSecretRef; } export interface ServerTarget { @@ -101,6 +106,8 @@ export interface ServerTarget { databasePath: string; scoreCachePath: string; monitorWorkDir: string; + temporalTaskQueue: string; + scoreScheduleWorkflowId: string; adminTokenEnv: string; sub2apiAdminEmailEnv: string; sub2apiAdminPasswordEnv: string; @@ -172,6 +179,15 @@ function secretRef(value: unknown, path: string): SecretRef { return { sourceRef: stringValue(raw, "sourceRef", path), sourceKey: stringValue(raw, "sourceKey", path) }; } +function cliTokenRef(value: unknown, path: string): SecretRef | EnvSecretRef { + const raw = object(value, path); + if (typeof raw.envKey === "string") { + if (raw.sourceRef !== undefined || raw.sourceKey !== undefined) throw new Error(`${path} must use either envKey or sourceRef/sourceKey`); + return { envKey: stringValue(raw, "envKey", path) }; + } + return secretRef(raw, path); +} + function nativeFile(parent: ObjectValue, key: string, path: string): string { const value = stringValue(parent, key, path); if (value.includes("/") || value.includes("\\") || value === "." || value === "..") throw new Error(`${path}.${key} must be a filename`); @@ -214,8 +230,9 @@ export function loadConfig(path: string): AppConfig { databasePath: stringValue(target, "databasePath", `runtime.cliTargets.${id}`), scoreCachePath: stringValue(target, "scoreCachePath", `runtime.cliTargets.${id}`), monitorWorkDir: stringValue(target, "monitorWorkDir", `runtime.cliTargets.${id}`), + temporalTaskQueue: stringValue(target, "temporalTaskQueue", `runtime.cliTargets.${id}`), }; - else if (mode === "http") cliTargets[id] = { mode, baseUrl: stringValue(target, "baseUrl", `runtime.cliTargets.${id}`), adminToken: secretRef(target.adminToken, `runtime.cliTargets.${id}.adminToken`) }; + else if (mode === "http") cliTargets[id] = { mode, baseUrl: stringValue(target, "baseUrl", `runtime.cliTargets.${id}`), adminToken: cliTokenRef(target.adminToken, `runtime.cliTargets.${id}.adminToken`) }; else throw new Error(`runtime.cliTargets.${id}.mode must be embedded or http`); } const serverTargets: Record = {}; @@ -233,6 +250,8 @@ export function loadConfig(path: string): AppConfig { databasePath: stringValue(target, "databasePath", `runtime.serverTargets.${id}`), scoreCachePath: stringValue(target, "scoreCachePath", `runtime.serverTargets.${id}`), monitorWorkDir: stringValue(target, "monitorWorkDir", `runtime.serverTargets.${id}`), + temporalTaskQueue: stringValue(target, "temporalTaskQueue", `runtime.serverTargets.${id}`), + scoreScheduleWorkflowId: stringValue(target, "scoreScheduleWorkflowId", `runtime.serverTargets.${id}`), adminTokenEnv: stringValue(target, "adminTokenEnv", `runtime.serverTargets.${id}`), sub2apiAdminEmailEnv: stringValue(target, "sub2apiAdminEmailEnv", `runtime.serverTargets.${id}`), sub2apiAdminPasswordEnv: stringValue(target, "sub2apiAdminPasswordEnv", `runtime.serverTargets.${id}`), diff --git a/src/runtime-policy-events.ts b/src/runtime-policy-events.ts new file mode 100644 index 0000000..8087003 --- /dev/null +++ b/src/runtime-policy-events.ts @@ -0,0 +1,111 @@ +import { existsSync, readFileSync, unlinkSync } from "node:fs"; +import { resolve } from "node:path"; +import type { AppConfig } from "./config"; +import type { Sub2ApiSystemLog } from "./sub2api-client"; + +type Row = Record; + +export interface RuntimePolicyEventBatch { + events: Sub2ApiSystemLog[]; + evidence: Row; +} + +export interface RuntimePolicyEventSource { + collect(window: string): Promise; +} + +function record(value: unknown): Row | null { + return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Row : null; +} + +function numberValue(value: unknown): number | null { + const parsed = Number(value); + return value !== null && value !== undefined && Number.isFinite(parsed) ? parsed : null; +} + +function text(value: unknown): string | null { + return typeof value === "string" && value.length > 0 ? value : null; +} + +function readCliPayload(stdout: string): Row { + let payload = record(JSON.parse(stdout)); + if (!payload) throw new Error("runtime events CLI returned an invalid payload"); + const outerData = record(payload.data); + if (outerData?.outputTruncated === true) { + const dumpPath = text(record(outerData.dump)?.path); + if (!dumpPath || !dumpPath.startsWith("/tmp/unidesk-cli-output/") || !existsSync(dumpPath)) { + throw new Error("runtime events CLI did not expose its protected raw result"); + } + try { + payload = record(JSON.parse(readFileSync(dumpPath, "utf8"))); + } finally { + try { unlinkSync(dumpPath); } catch { /* The CLI may clean up its own result. */ } + } + if (!payload) throw new Error("runtime events CLI raw result is invalid"); + } + const response = record(payload.data); + const parsed = record(response?.parsed); + if (payload.ok !== true || response?.ok !== true || parsed?.ok !== true) { + throw new Error(`runtime events CLI failed: ${text(parsed?.error) ?? "unknown error"}`); + } + return parsed; +} + +export class UniDeskRuntimePolicyEventSource implements RuntimePolicyEventSource { + constructor(private readonly config: AppConfig, private readonly workDir: string) {} + + async collect(window: string): Promise { + const args = [ + resolve(this.workDir, this.config.monitor.cli.entrypoint), + "platform-infra", "sub2api", "codex-pool", "runtime", "events", + "--target", this.config.monitor.target, + "--since", window, + "--raw", + ]; + const child = Bun.spawn([this.config.monitor.cli.executable, ...args], { + cwd: this.workDir, + stdout: "pipe", + stderr: "pipe", + env: { ...Bun.env, UNIDESK_MAIN_SERVER_IP: this.config.monitor.cli.mainServerHost }, + }); + let timedOut = false; + const timeout = setTimeout(() => { + timedOut = true; + child.kill(); + }, this.config.monitor.cli.timeoutMs); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]).finally(() => clearTimeout(timeout)); + if (timedOut) throw new Error(`runtime events CLI timed out after ${this.config.monitor.cli.timeoutMs}ms`); + if (exitCode !== 0) throw new Error(`runtime events CLI exited ${exitCode}: ${stderr.trim().slice(-600)}`); + const payload = readCliPayload(stdout); + const rows = Array.isArray(payload.events) ? payload.events.map(record).filter((item): item is Row => item !== null) : []; + const events = rows.map((row, index): Sub2ApiSystemLog => ({ + id: index + 1, + created_at: text(row.createdAt) ?? "", + message: text(row.marker) ?? "", + request_id: text(row.requestId) ?? undefined, + account_id: numberValue(row.accountId), + extra: { + group_id: numberValue(row.groupId), + status_code: numberValue(row.statusCode), + upstream_status: numberValue(row.upstreamStatus), + path: text(row.path), + }, + })); + return { + events, + evidence: { + source: payload.source, + window: payload.window, + tail: payload.tail, + scannedLineCount: payload.scannedLineCount, + policyEventCount: payload.policyEventCount, + completionEventCount: payload.completionEventCount, + eventCount: payload.eventCount, + }, + }; + } +} diff --git a/src/sub2api-client.ts b/src/sub2api-client.ts index 947c209..9bb47da 100644 --- a/src/sub2api-client.ts +++ b/src/sub2api-client.ts @@ -7,7 +7,7 @@ interface Envelope { data: T; } -interface Paginated { +export interface Paginated { items: T[]; total: number; page: number; @@ -15,6 +15,56 @@ interface Paginated { pages: number; } +export interface Sub2ApiGroup { + id: number; + name: string; + platform: string; + status: string; +} + +export interface Sub2ApiAccount { + id: number; + name: string; + platform: string; + status: string; + schedulable?: boolean; + priority?: number; +} + +export interface Sub2ApiUsageRow { + id: number; + account_id: number | null; + group_id: number | null; + model: string; + stream: boolean; + input_tokens: number; + output_tokens: number; + actual_cost: number; + duration_ms: number | null; + first_token_ms: number | null; + created_at: string; +} + +export interface Sub2ApiRequestError { + id: number; + request_id?: string; + account_id?: number | null; + status_code?: number; + phase?: string; + type?: string; + message?: string; + error_message?: string; +} + +export interface Sub2ApiSystemLog { + id: number; + created_at: string; + message: string; + request_id?: string; + account_id?: number | null; + extra?: Record; +} + export class Sub2ApiClient { private token: string | null = null; private tokenExpiresAt = 0; @@ -85,6 +135,85 @@ export class Sub2ApiClient { return await this.request(`/admin/dashboard/users-ranking?${params}`); } + async listGroups(): Promise { + return await this.request("/admin/groups/all"); + } + + async listGroupAccounts(groupId: number, platform: string): Promise { + const params = new URLSearchParams({ + page: "1", + page_size: "1000", + group: String(groupId), + platform, + sort_by: "id", + sort_order: "asc", + }); + return (await this.request>(`/admin/accounts?${params}`)).items; + } + + async listGroupUsage(groupId: number, start: Date, end: Date): Promise { + return await this.paginate("/admin/usage", { + group_id: String(groupId), + start_date: start.toISOString().slice(0, 10), + end_date: end.toISOString().slice(0, 10), + timezone: "UTC", + sort_by: "created_at", + sort_order: "desc", + }, 1000, (row) => { + const createdAt = Date.parse(row.created_at); + return Number.isFinite(createdAt) && createdAt >= start.getTime() && createdAt <= end.getTime(); + }); + } + + async listRequestErrors(groupId: number, platform: string, start: Date): Promise { + return await this.paginate("/admin/ops/request-errors", { + group_id: String(groupId), + platform, + start_time: start.toISOString(), + view: "all", + }, 500); + } + + async listSystemLogs(platform: string, start: Date, marker: string): Promise { + return await this.paginate("/admin/ops/system-logs", { + platform, + start_time: start.toISOString(), + q: marker, + }, 200); + } + + async getOpsOverview(groupId: number, platform: string, start: Date): Promise> { + const params = new URLSearchParams({ + group_id: String(groupId), + platform, + start_time: start.toISOString(), + query_mode: "raw", + }); + return await this.request(`/admin/ops/dashboard/overview?${params}`); + } + + async getOpsAccountAvailability(groupId: number, platform: string): Promise> { + const params = new URLSearchParams({ group_id: String(groupId), platform }); + return await this.request(`/admin/ops/account-availability?${params}`); + } + + async getOpsConcurrency(groupId: number, platform: string): Promise> { + const params = new URLSearchParams({ group_id: String(groupId), platform }); + return await this.request(`/admin/ops/concurrency?${params}`); + } + + private async paginate(path: string, query: Record, pageSize: number, keep: (row: T) => boolean = () => true): Promise { + const rows: T[] = []; + let page = 1; + for (;;) { + const params = new URLSearchParams({ ...query, page: String(page), page_size: String(pageSize) }); + const data = await this.request>(`${path}?${params}`); + rows.push(...data.items.filter(keep)); + if (page >= data.pages) return rows; + page += 1; + } + } + async addBalance(userId: number, amountUsd: number, notes: string): Promise { return await this.request(`/admin/users/${userId}/balance`, { method: "POST", diff --git a/src/temporal-client.ts b/src/temporal-client.ts index 8fbfb9c..54f9464 100644 --- a/src/temporal-client.ts +++ b/src/temporal-client.ts @@ -14,11 +14,15 @@ export class TemporalGateway { private readonly connection: Connection, private readonly client: Client, private readonly config: AppConfig, + private readonly runtime: { taskQueue: string; scoreScheduleWorkflowId: string }, ) {} - static async connect(config: AppConfig): Promise { + static async connect(config: AppConfig, runtime: { taskQueue: string; scoreScheduleWorkflowId?: string }): Promise { const connection = await Connection.connect({ address: temporalAddress(config) }); - return new TemporalGateway(connection, new Client({ connection, namespace: config.temporal.namespace }), config); + return new TemporalGateway(connection, new Client({ connection, namespace: config.temporal.namespace }), config, { + taskQueue: runtime.taskQueue, + scoreScheduleWorkflowId: runtime.scoreScheduleWorkflowId ?? config.temporal.scoreScheduleWorkflowId, + }); } async execute(command: AppCommand): Promise { @@ -29,7 +33,7 @@ export class TemporalGateway { async submit(command: AppCommand): Promise<{ ok: true; workflowId: string; runId: string; state: "submitted" }> { const operation: OperationRequest = { operationId: randomUUID(), command }; const handle = await this.client.workflow.start("operationWorkflow", { - taskQueue: this.config.temporal.taskQueue, + taskQueue: this.runtime.taskQueue, workflowId: `apistate-${command.kind.replaceAll(".", "-")}-${operation.operationId}`, workflowExecutionTimeout: this.config.temporal.workflowExecutionTimeout, args: [{ @@ -56,10 +60,10 @@ export class TemporalGateway { } async ensureScoreSchedule(): Promise<{ started: boolean; workflowId: string }> { - const workflowId = this.config.temporal.scoreScheduleWorkflowId; + const workflowId = this.runtime.scoreScheduleWorkflowId; try { await this.client.workflow.start("scoreRefreshScheduleWorkflow", { - taskQueue: this.config.temporal.taskQueue, + taskQueue: this.runtime.taskQueue, workflowId, args: [{ intervalMs: this.config.monitor.refreshIntervalMinutes * 60_000, diff --git a/src/worker.ts b/src/worker.ts index aefcddc..777bb77 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -16,11 +16,11 @@ const connection = await NativeConnection.connect({ address: temporalAddress(con const worker = await Worker.create({ connection, namespace: config.temporal.namespace, - taskQueue: config.temporal.taskQueue, + taskQueue: target.temporalTaskQueue, workflowsPath: fileURLToPath(new URL("./workflows.ts", import.meta.url)), activities: createActivities({ lottery: context.service, scores: context.monitor }), }); -const temporal = await TemporalGateway.connect(config); +const temporal = await TemporalGateway.connect(config, { taskQueue: target.temporalTaskQueue, scoreScheduleWorkflowId: target.scoreScheduleWorkflowId }); const schedule = await temporal.ensureScoreSchedule(); let state: "ready" | "stopping" = "ready"; const health = Bun.serve({ @@ -31,7 +31,7 @@ const health = Bun.serve({ component: "apistate-worker", state, namespace: config.temporal.namespace, - taskQueue: config.temporal.taskQueue, + taskQueue: target.temporalTaskQueue, schedule, }, { status: state === "ready" ? 200 : 503 }), }); @@ -42,7 +42,7 @@ console.log(JSON.stringify({ runtime: runtimeId, health: health.url.toString(), temporalNamespace: config.temporal.namespace, - temporalTaskQueue: config.temporal.taskQueue, + temporalTaskQueue: target.temporalTaskQueue, schedule, valuesPrinted: false, }));