From f539710b861237ae05fdfb493ac3be965b903ca4 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 16 Jul 2026 09:01:06 +0200 Subject: [PATCH] =?UTF-8?q?chore:=20=E5=88=9D=E5=A7=8B=E5=8C=96=20ApiState?= =?UTF-8?q?=20=E6=9C=8D=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 --- .dockerignore | 5 + .gitignore | 10 + .tekton/sub2rank-nc01-pac.yaml | 483 +++++++++++++++++++++++++++++++++ Dockerfile | 15 + bun.lock | 35 +++ config/sub2rank.yaml | 83 ++++++ package.json | 20 ++ scripts/ci/build-image.sh | 48 ++++ scripts/ci/publish-gitops.mjs | 195 +++++++++++++ scripts/src/cli.ts | 136 ++++++++++ scripts/sub2rank-cli.ts | 4 + src/admin-http-client.ts | 34 +++ src/bootstrap.ts | 27 ++ src/config.ts | 240 ++++++++++++++++ src/http.ts | 63 +++++ src/lottery-service.ts | 225 +++++++++++++++ src/secrets.ts | 33 +++ src/server.ts | 33 +++ src/store.ts | 147 ++++++++++ src/sub2api-client.ts | 94 +++++++ src/types.ts | 45 +++ static/app.js | 164 +++++++++++ static/index.html | 87 ++++++ static/styles.css | 146 ++++++++++ tsconfig.json | 11 + 25 files changed, 2383 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 .tekton/sub2rank-nc01-pac.yaml create mode 100644 Dockerfile create mode 100644 bun.lock create mode 100644 config/sub2rank.yaml create mode 100644 package.json create mode 100755 scripts/ci/build-image.sh create mode 100755 scripts/ci/publish-gitops.mjs create mode 100644 scripts/src/cli.ts create mode 100644 scripts/sub2rank-cli.ts create mode 100644 src/admin-http-client.ts create mode 100644 src/bootstrap.ts create mode 100644 src/config.ts create mode 100644 src/http.ts create mode 100644 src/lottery-service.ts create mode 100644 src/secrets.ts create mode 100644 src/server.ts create mode 100644 src/store.ts create mode 100644 src/sub2api-client.ts create mode 100644 src/types.ts create mode 100644 static/app.js create mode 100644 static/index.html create mode 100644 static/styles.css create mode 100644 tsconfig.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e47a3b1 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +.git +.state +node_modules +scripts +*.log diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..457dbcf --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +node_modules/ +.worktree/ +.state/ +*.sqlite +*.sqlite-shm +*.sqlite-wal +dist/ +.env +*.log +logs/ diff --git a/.tekton/sub2rank-nc01-pac.yaml b/.tekton/sub2rank-nc01-pac.yaml new file mode 100644 index 0000000..8c87aac --- /dev/null +++ b/.tekton/sub2rank-nc01-pac.yaml @@ -0,0 +1,483 @@ +apiVersion: tekton.dev/v1 +kind: PipelineRun +metadata: + name: "sub2rank-nc01-{{ revision }}" + namespace: devops-infra + annotations: + pipelinesascode.tekton.dev/on-event: "[push]" + pipelinesascode.tekton.dev/on-target-branch: "[master]" + pipelinesascode.tekton.dev/on-cel-expression: "event == 'push' && target_branch == 'master' && node == 'NC01'" + pipelinesascode.tekton.dev/max-keep-runs: "8" + unidesk.ai/pac-admission-provenance: admission-pac-v2:platform-infra-sub2rank-nc01 + unidesk.ai/owning-config-ref: "config/platform-infra/sub2rank.yaml#delivery" + unidesk.ai/effective-config-sha256: sha256:e4d95e74410816956a8f848eb379bf73d26bab130db498fd69a442c0a1725e5d + 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-sub2rank.git + - name: source-branch + type: string + default: master + - 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-nc01/resources.yaml + - name: gitops-release-state-path + type: string + default: deploy/gitops-state/platform-infra/sub2rank-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: YXBpVmVyc2lvbjogdjEKa2luZDogQ29uZmlnTWFwCm1ldGFkYXRhOgogIG5hbWU6IHN1YjJyYW5rLWNvbmZpZwogIG5hbWVzcGFjZTogcGxhdGZvcm0taW5mcmEKICBsYWJlbHM6CiAgICBhcHAua3ViZXJuZXRlcy5pby9uYW1lOiBzdWIycmFuawogICAgYXBwLmt1YmVybmV0ZXMuaW8vcGFydC1vZjogcGxhdGZvcm0taW5mcmEKICAgIGFwcC5rdWJlcm5ldGVzLmlvL21hbmFnZWQtYnk6IHVuaWRlc2sKYmluYXJ5RGF0YToKICBzdWIycmFuay55YW1sOiAiX19TVUIyUkFOS19DT05GSUdfQkFTRTY0X18iCi0tLQphcGlWZXJzaW9uOiB2MQpraW5kOiBQZXJzaXN0ZW50Vm9sdW1lQ2xhaW0KbWV0YWRhdGE6CiAgbmFtZTogc3ViMnJhbmstZGF0YQogIG5hbWVzcGFjZTogcGxhdGZvcm0taW5mcmEKICBsYWJlbHM6CiAgICBhcHAua3ViZXJuZXRlcy5pby9uYW1lOiBzdWIycmFuawogICAgYXBwLmt1YmVybmV0ZXMuaW8vcGFydC1vZjogcGxhdGZvcm0taW5mcmEKICAgIGFwcC5rdWJlcm5ldGVzLmlvL21hbmFnZWQtYnk6IHVuaWRlc2sKc3BlYzoKICBhY2Nlc3NNb2RlczoKICAgIC0gUmVhZFdyaXRlT25jZQogIHJlc291cmNlczoKICAgIHJlcXVlc3RzOgogICAgICBzdG9yYWdlOiAxR2kKLS0tCmFwaVZlcnNpb246IHYxCmtpbmQ6IFNlcnZpY2UKbWV0YWRhdGE6CiAgbmFtZTogc3ViMnJhbmsKICBuYW1lc3BhY2U6IHBsYXRmb3JtLWluZnJhCiAgbGFiZWxzOgogICAgYXBwLmt1YmVybmV0ZXMuaW8vbmFtZTogc3ViMnJhbmsKICAgIGFwcC5rdWJlcm5ldGVzLmlvL3BhcnQtb2Y6IHBsYXRmb3JtLWluZnJhCiAgICBhcHAua3ViZXJuZXRlcy5pby9tYW5hZ2VkLWJ5OiB1bmlkZXNrCnNwZWM6CiAgdHlwZTogQ2x1c3RlcklQCiAgc2VsZWN0b3I6CiAgICBhcHAua3ViZXJuZXRlcy5pby9uYW1lOiBzdWIycmFuawogIHBvcnRzOgogICAgLSBuYW1lOiBodHRwCiAgICAgIHBvcnQ6IDgwODAKICAgICAgdGFyZ2V0UG9ydDogaHR0cAotLS0KYXBpVmVyc2lvbjogYXBwcy92MQpraW5kOiBEZXBsb3ltZW50Cm1ldGFkYXRhOgogIG5hbWU6IHN1YjJyYW5rCiAgbmFtZXNwYWNlOiBwbGF0Zm9ybS1pbmZyYQogIGxhYmVsczoKICAgIGFwcC5rdWJlcm5ldGVzLmlvL25hbWU6IHN1YjJyYW5rCiAgICBhcHAua3ViZXJuZXRlcy5pby9wYXJ0LW9mOiBwbGF0Zm9ybS1pbmZyYQogICAgYXBwLmt1YmVybmV0ZXMuaW8vbWFuYWdlZC1ieTogdW5pZGVzawpzcGVjOgogIHJlcGxpY2FzOiAxCiAgc3RyYXRlZ3k6CiAgICB0eXBlOiBSZWNyZWF0ZQogIHNlbGVjdG9yOgogICAgbWF0Y2hMYWJlbHM6CiAgICAgIGFwcC5rdWJlcm5ldGVzLmlvL25hbWU6IHN1YjJyYW5rCiAgdGVtcGxhdGU6CiAgICBtZXRhZGF0YToKICAgICAgbGFiZWxzOgogICAgICAgIGFwcC5rdWJlcm5ldGVzLmlvL25hbWU6IHN1YjJyYW5rCiAgICAgICAgYXBwLmt1YmVybmV0ZXMuaW8vcGFydC1vZjogcGxhdGZvcm0taW5mcmEKICAgICAgYW5ub3RhdGlvbnM6CiAgICAgICAgdW5pZGVzay5haS9zdWIycmFuay1jb25maWctc2hhMjU2OiAiX19TVUIyUkFOS19DT05GSUdfU0hBMjU2X18iCiAgICAgICAgdW5pZGVzay5haS9zdWIycmFuay1kZXBsb3ltZW50LWhhc2g6ICJmNmZhMWMwMjZjMjEwMzc2IgogICAgICAgIHVuaWRlc2suYWkvc291cmNlLWNvbW1pdDogIl9fU1VCMlJBTktfU09VUkNFX0NPTU1JVF9fIgogICAgICAgIHVuaWRlc2suYWkvcHVibGljLWJhc2UtdXJsOiAiaHR0cHM6Ly9yYW5rLnBpa2FweXRob24uY29tIgogICAgc3BlYzoKICAgICAgc2VjdXJpdHlDb250ZXh0OgogICAgICAgIGZzR3JvdXA6IDEwMDAKICAgICAgY29udGFpbmVyczoKICAgICAgICAtIG5hbWU6IHN1YjJyYW5rCiAgICAgICAgICBpbWFnZTogIl9fU1VCMlJBTktfSU1BR0VfUkVGX18iCiAgICAgICAgICBpbWFnZVB1bGxQb2xpY3k6IElmTm90UHJlc2VudAogICAgICAgICAgY29tbWFuZDoKICAgICAgICAgICAgLSAiYnVuIgogICAgICAgICAgICAtICJzcmMvc2VydmVyLnRzIgogICAgICAgICAgICAtICItLWNvbmZpZyIKICAgICAgICAgICAgLSAiL2V0Yy9zdWIycmFuay9zdWIycmFuay55YW1sIgogICAgICAgICAgICAtICItLXJ1bnRpbWUiCiAgICAgICAgICAgIC0gIms4cyIKICAgICAgICAgIHBvcnRzOgogICAgICAgICAgICAtIG5hbWU6IGh0dHAKICAgICAgICAgICAgICBjb250YWluZXJQb3J0OiA4MDgwCiAgICAgICAgICBlbnY6CiAgICAgICAgICAgIC0gbmFtZTogU1VCMlJBTktfQURNSU5fVE9LRU4KICAgICAgICAgICAgICB2YWx1ZUZyb206CiAgICAgICAgICAgICAgICBzZWNyZXRLZXlSZWY6CiAgICAgICAgICAgICAgICAgIG5hbWU6IHN1YjJyYW5rLXNlY3JldHMKICAgICAgICAgICAgICAgICAga2V5OiBTVUIyUkFOS19BRE1JTl9UT0tFTgogICAgICAgICAgICAtIG5hbWU6IFNVQjJBUElfQURNSU5fRU1BSUwKICAgICAgICAgICAgICB2YWx1ZUZyb206CiAgICAgICAgICAgICAgICBzZWNyZXRLZXlSZWY6CiAgICAgICAgICAgICAgICAgIG5hbWU6IHN1YjJyYW5rLXNlY3JldHMKICAgICAgICAgICAgICAgICAga2V5OiBTVUIyQVBJX0FETUlOX0VNQUlMCiAgICAgICAgICAgIC0gbmFtZTogU1VCMkFQSV9BRE1JTl9QQVNTV09SRAogICAgICAgICAgICAgIHZhbHVlRnJvbToKICAgICAgICAgICAgICAgIHNlY3JldEtleVJlZjoKICAgICAgICAgICAgICAgICAgbmFtZTogc3ViMnJhbmstc2VjcmV0cwogICAgICAgICAgICAgICAgICBrZXk6IFNVQjJBUElfQURNSU5fUEFTU1dPUkQKICAgICAgICAgIHJlYWRpbmVzc1Byb2JlOgogICAgICAgICAgICBodHRwR2V0OgogICAgICAgICAgICAgIHBhdGg6IC9oZWFsdGgKICAgICAgICAgICAgICBwb3J0OiBodHRwCiAgICAgICAgICAgIGluaXRpYWxEZWxheVNlY29uZHM6IDMKICAgICAgICAgICAgcGVyaW9kU2Vjb25kczogMTAKICAgICAgICAgICAgdGltZW91dFNlY29uZHM6IDMKICAgICAgICAgICAgZmFpbHVyZVRocmVzaG9sZDogNgogICAgICAgICAgbGl2ZW5lc3NQcm9iZToKICAgICAgICAgICAgaHR0cEdldDoKICAgICAgICAgICAgICBwYXRoOiAvaGVhbHRoCiAgICAgICAgICAgICAgcG9ydDogaHR0cAogICAgICAgICAgICBpbml0aWFsRGVsYXlTZWNvbmRzOiAzCiAgICAgICAgICAgIHBlcmlvZFNlY29uZHM6IDEwCiAgICAgICAgICAgIHRpbWVvdXRTZWNvbmRzOiAzCiAgICAgICAgICAgIGZhaWx1cmVUaHJlc2hvbGQ6IDYKICAgICAgICAgIHZvbHVtZU1vdW50czoKICAgICAgICAgICAgLSBuYW1lOiBhcHAtY29uZmlnCiAgICAgICAgICAgICAgbW91bnRQYXRoOiAvZXRjL3N1YjJyYW5rL3N1YjJyYW5rLnlhbWwKICAgICAgICAgICAgICBzdWJQYXRoOiBzdWIycmFuay55YW1sCiAgICAgICAgICAgICAgcmVhZE9ubHk6IHRydWUKICAgICAgICAgICAgLSBuYW1lOiBkYXRhCiAgICAgICAgICAgICAgbW91bnRQYXRoOiAvdmFyL2xpYi9zdWIycmFuawogICAgICB2b2x1bWVzOgogICAgICAgIC0gbmFtZTogYXBwLWNvbmZpZwogICAgICAgICAgY29uZmlnTWFwOgogICAgICAgICAgICBuYW1lOiBzdWIycmFuay1jb25maWcKICAgICAgICAtIG5hbWU6IGRhdGEKICAgICAgICAgIHBlcnNpc3RlbnRWb2x1bWVDbGFpbToKICAgICAgICAgICAgY2xhaW1OYW1lOiBzdWIycmFuay1kYXRhCi0tLQphcGlWZXJzaW9uOiB2MQpraW5kOiBDb25maWdNYXAKbWV0YWRhdGE6CiAgbmFtZTogc3ViMnJhbmstZnJwYy1jb25maWcKICBuYW1lc3BhY2U6IHBsYXRmb3JtLWluZnJhCiAgbGFiZWxzOgogICAgYXBwLmt1YmVybmV0ZXMuaW8vbmFtZTogc3ViMnJhbmstZnJwYwogICAgYXBwLmt1YmVybmV0ZXMuaW8vY29tcG9uZW50OiB0dW5uZWwKICAgIGFwcC5rdWJlcm5ldGVzLmlvL3BhcnQtb2Y6IHBsYXRmb3JtLWluZnJhCiAgICBhcHAua3ViZXJuZXRlcy5pby9tYW5hZ2VkLWJ5OiB1bmlkZXNrCmRhdGE6CiAgZnJwYy50b21sOiB8CiAgICBzZXJ2ZXJBZGRyID0gIjgyLjE1Ni4yMy4yMjAiCiAgICBzZXJ2ZXJQb3J0ID0gMjIwMDAKICAgIGxvZ2luRmFpbEV4aXQgPSB0cnVlCiAgICBhdXRoLnRva2VuID0gInt7IC5FbnZzLkZSUF9UT0tFTiB9fSIKICAgIAogICAgW1twcm94aWVzXV0KICAgIG5hbWUgPSAicGxhdGZvcm0taW5mcmEtc3ViMnJhbmstbmMwMS13ZWIiCiAgICB0eXBlID0gInRjcCIKICAgIGxvY2FsSVAgPSAic3ViMnJhbmsucGxhdGZvcm0taW5mcmEuc3ZjLmNsdXN0ZXIubG9jYWwiCiAgICBsb2NhbFBvcnQgPSA4MDgwCiAgICByZW1vdGVQb3J0ID0gMjIwOTUKICAgIAotLS0KYXBpVmVyc2lvbjogYXBwcy92MQpraW5kOiBEZXBsb3ltZW50Cm1ldGFkYXRhOgogIG5hbWU6IHN1YjJyYW5rLWZycGMKICBuYW1lc3BhY2U6IHBsYXRmb3JtLWluZnJhCiAgbGFiZWxzOgogICAgYXBwLmt1YmVybmV0ZXMuaW8vbmFtZTogc3ViMnJhbmstZnJwYwogICAgYXBwLmt1YmVybmV0ZXMuaW8vY29tcG9uZW50OiB0dW5uZWwKICAgIGFwcC5rdWJlcm5ldGVzLmlvL3BhcnQtb2Y6IHBsYXRmb3JtLWluZnJhCiAgICBhcHAua3ViZXJuZXRlcy5pby9tYW5hZ2VkLWJ5OiB1bmlkZXNrCiAgICB1bmlkZXNrLmFpL3J1bnRpbWUtbm9kZTogTkMwMQogICAgdW5pZGVzay5haS9wdWJsaWMtaG9zdG5hbWU6IHJhbmsucGlrYXB5dGhvbi5jb20Kc3BlYzoKICByZXBsaWNhczogMQogIHN0cmF0ZWd5OgogICAgdHlwZTogUmVjcmVhdGUKICBzZWxlY3RvcjoKICAgIG1hdGNoTGFiZWxzOgogICAgICBhcHAua3ViZXJuZXRlcy5pby9uYW1lOiBzdWIycmFuay1mcnBjCiAgICAgIGFwcC5rdWJlcm5ldGVzLmlvL2NvbXBvbmVudDogdHVubmVsCiAgdGVtcGxhdGU6CiAgICBtZXRhZGF0YToKICAgICAgbGFiZWxzOgogICAgICAgIGFwcC5rdWJlcm5ldGVzLmlvL25hbWU6IHN1YjJyYW5rLWZycGMKICAgICAgICBhcHAua3ViZXJuZXRlcy5pby9jb21wb25lbnQ6IHR1bm5lbAogICAgICAgIGFwcC5rdWJlcm5ldGVzLmlvL3BhcnQtb2Y6IHBsYXRmb3JtLWluZnJhCiAgICAgIGFubm90YXRpb25zOgogICAgICAgIHVuaWRlc2suYWkvcHVibGljLWJhc2UtdXJsOiAiaHR0cHM6Ly9yYW5rLnBpa2FweXRob24uY29tIgogICAgICAgIHVuaWRlc2suYWkvZnJwLXNlcnZlcjogIjgyLjE1Ni4yMy4yMjA6MjIwMDAiCiAgICAgICAgdW5pZGVzay5haS9mcnAtcmVtb3RlLXBvcnQ6ICIyMjA5NSIKICAgICAgICB1bmlkZXNrLmFpL2ZycGMtY29uZmlnLXNoYTI1NjogIjFkOGY1OGE2ZjU4MjQ3ZGEzMzJkNDBkZWE1YzFkN2I3YzY4NDU1YTU5OTVhYTNiMWQ5NjE1NzA2ZWMxYzk0ZTYiCiAgICBzcGVjOgogICAgICBjb250YWluZXJzOgogICAgICAgIC0gbmFtZTogZnJwYwogICAgICAgICAgaW1hZ2U6IDEyNy4wLjAuMTo1MDAwL2h3bGFiL2ZycGM6djAuNjguMQogICAgICAgICAgaW1hZ2VQdWxsUG9saWN5OiBJZk5vdFByZXNlbnQKICAgICAgICAgIGFyZ3M6CiAgICAgICAgICAgIC0gLWMKICAgICAgICAgICAgLSAvZXRjL2ZycC9mcnBjLnRvbWwKICAgICAgICAgIGVudjoKICAgICAgICAgICAgLSBuYW1lOiBGUlBfVE9LRU4KICAgICAgICAgICAgICB2YWx1ZUZyb206CiAgICAgICAgICAgICAgICBzZWNyZXRLZXlSZWY6CiAgICAgICAgICAgICAgICAgIG5hbWU6IHN1YjJyYW5rLXNlY3JldHMKICAgICAgICAgICAgICAgICAga2V5OiBGUlBfVE9LRU4KICAgICAgICAgIHZvbHVtZU1vdW50czoKICAgICAgICAgICAgLSBuYW1lOiBmcnBjLWNvbmZpZwogICAgICAgICAgICAgIG1vdW50UGF0aDogL2V0Yy9mcnAvZnJwYy50b21sCiAgICAgICAgICAgICAgc3ViUGF0aDogZnJwYy50b21sCiAgICAgICAgICAgICAgcmVhZE9ubHk6IHRydWUKICAgICAgdm9sdW1lczoKICAgICAgICAtIG5hbWU6IGZycGMtY29uZmlnCiAgICAgICAgICBjb25maWdNYXA6CiAgICAgICAgICAgIG5hbWU6IHN1YjJyYW5rLWZycGMtY29uZmlnCg== + 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/sub2rank-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-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-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: YXBpVmVyc2lvbjogdjEKa2luZDogQ29uZmlnTWFwCm1ldGFkYXRhOgogIG5hbWU6IHN1YjJyYW5rLWNvbmZpZwogIG5hbWVzcGFjZTogcGxhdGZvcm0taW5mcmEKICBsYWJlbHM6CiAgICBhcHAua3ViZXJuZXRlcy5pby9uYW1lOiBzdWIycmFuawogICAgYXBwLmt1YmVybmV0ZXMuaW8vcGFydC1vZjogcGxhdGZvcm0taW5mcmEKICAgIGFwcC5rdWJlcm5ldGVzLmlvL21hbmFnZWQtYnk6IHVuaWRlc2sKYmluYXJ5RGF0YToKICBzdWIycmFuay55YW1sOiAiX19TVUIyUkFOS19DT05GSUdfQkFTRTY0X18iCi0tLQphcGlWZXJzaW9uOiB2MQpraW5kOiBQZXJzaXN0ZW50Vm9sdW1lQ2xhaW0KbWV0YWRhdGE6CiAgbmFtZTogc3ViMnJhbmstZGF0YQogIG5hbWVzcGFjZTogcGxhdGZvcm0taW5mcmEKICBsYWJlbHM6CiAgICBhcHAua3ViZXJuZXRlcy5pby9uYW1lOiBzdWIycmFuawogICAgYXBwLmt1YmVybmV0ZXMuaW8vcGFydC1vZjogcGxhdGZvcm0taW5mcmEKICAgIGFwcC5rdWJlcm5ldGVzLmlvL21hbmFnZWQtYnk6IHVuaWRlc2sKc3BlYzoKICBhY2Nlc3NNb2RlczoKICAgIC0gUmVhZFdyaXRlT25jZQogIHJlc291cmNlczoKICAgIHJlcXVlc3RzOgogICAgICBzdG9yYWdlOiAxR2kKLS0tCmFwaVZlcnNpb246IHYxCmtpbmQ6IFNlcnZpY2UKbWV0YWRhdGE6CiAgbmFtZTogc3ViMnJhbmsKICBuYW1lc3BhY2U6IHBsYXRmb3JtLWluZnJhCiAgbGFiZWxzOgogICAgYXBwLmt1YmVybmV0ZXMuaW8vbmFtZTogc3ViMnJhbmsKICAgIGFwcC5rdWJlcm5ldGVzLmlvL3BhcnQtb2Y6IHBsYXRmb3JtLWluZnJhCiAgICBhcHAua3ViZXJuZXRlcy5pby9tYW5hZ2VkLWJ5OiB1bmlkZXNrCnNwZWM6CiAgdHlwZTogQ2x1c3RlcklQCiAgc2VsZWN0b3I6CiAgICBhcHAua3ViZXJuZXRlcy5pby9uYW1lOiBzdWIycmFuawogIHBvcnRzOgogICAgLSBuYW1lOiBodHRwCiAgICAgIHBvcnQ6IDgwODAKICAgICAgdGFyZ2V0UG9ydDogaHR0cAotLS0KYXBpVmVyc2lvbjogYXBwcy92MQpraW5kOiBEZXBsb3ltZW50Cm1ldGFkYXRhOgogIG5hbWU6IHN1YjJyYW5rCiAgbmFtZXNwYWNlOiBwbGF0Zm9ybS1pbmZyYQogIGxhYmVsczoKICAgIGFwcC5rdWJlcm5ldGVzLmlvL25hbWU6IHN1YjJyYW5rCiAgICBhcHAua3ViZXJuZXRlcy5pby9wYXJ0LW9mOiBwbGF0Zm9ybS1pbmZyYQogICAgYXBwLmt1YmVybmV0ZXMuaW8vbWFuYWdlZC1ieTogdW5pZGVzawpzcGVjOgogIHJlcGxpY2FzOiAxCiAgc3RyYXRlZ3k6CiAgICB0eXBlOiBSZWNyZWF0ZQogIHNlbGVjdG9yOgogICAgbWF0Y2hMYWJlbHM6CiAgICAgIGFwcC5rdWJlcm5ldGVzLmlvL25hbWU6IHN1YjJyYW5rCiAgdGVtcGxhdGU6CiAgICBtZXRhZGF0YToKICAgICAgbGFiZWxzOgogICAgICAgIGFwcC5rdWJlcm5ldGVzLmlvL25hbWU6IHN1YjJyYW5rCiAgICAgICAgYXBwLmt1YmVybmV0ZXMuaW8vcGFydC1vZjogcGxhdGZvcm0taW5mcmEKICAgICAgYW5ub3RhdGlvbnM6CiAgICAgICAgdW5pZGVzay5haS9zdWIycmFuay1jb25maWctc2hhMjU2OiAiX19TVUIyUkFOS19DT05GSUdfU0hBMjU2X18iCiAgICAgICAgdW5pZGVzay5haS9zdWIycmFuay1kZXBsb3ltZW50LWhhc2g6ICJmNmZhMWMwMjZjMjEwMzc2IgogICAgICAgIHVuaWRlc2suYWkvc291cmNlLWNvbW1pdDogIl9fU1VCMlJBTktfU09VUkNFX0NPTU1JVF9fIgogICAgICAgIHVuaWRlc2suYWkvcHVibGljLWJhc2UtdXJsOiAiaHR0cHM6Ly9yYW5rLnBpa2FweXRob24uY29tIgogICAgc3BlYzoKICAgICAgc2VjdXJpdHlDb250ZXh0OgogICAgICAgIGZzR3JvdXA6IDEwMDAKICAgICAgY29udGFpbmVyczoKICAgICAgICAtIG5hbWU6IHN1YjJyYW5rCiAgICAgICAgICBpbWFnZTogIl9fU1VCMlJBTktfSU1BR0VfUkVGX18iCiAgICAgICAgICBpbWFnZVB1bGxQb2xpY3k6IElmTm90UHJlc2VudAogICAgICAgICAgY29tbWFuZDoKICAgICAgICAgICAgLSAiYnVuIgogICAgICAgICAgICAtICJzcmMvc2VydmVyLnRzIgogICAgICAgICAgICAtICItLWNvbmZpZyIKICAgICAgICAgICAgLSAiL2V0Yy9zdWIycmFuay9zdWIycmFuay55YW1sIgogICAgICAgICAgICAtICItLXJ1bnRpbWUiCiAgICAgICAgICAgIC0gIms4cyIKICAgICAgICAgIHBvcnRzOgogICAgICAgICAgICAtIG5hbWU6IGh0dHAKICAgICAgICAgICAgICBjb250YWluZXJQb3J0OiA4MDgwCiAgICAgICAgICBlbnY6CiAgICAgICAgICAgIC0gbmFtZTogU1VCMlJBTktfQURNSU5fVE9LRU4KICAgICAgICAgICAgICB2YWx1ZUZyb206CiAgICAgICAgICAgICAgICBzZWNyZXRLZXlSZWY6CiAgICAgICAgICAgICAgICAgIG5hbWU6IHN1YjJyYW5rLXNlY3JldHMKICAgICAgICAgICAgICAgICAga2V5OiBTVUIyUkFOS19BRE1JTl9UT0tFTgogICAgICAgICAgICAtIG5hbWU6IFNVQjJBUElfQURNSU5fRU1BSUwKICAgICAgICAgICAgICB2YWx1ZUZyb206CiAgICAgICAgICAgICAgICBzZWNyZXRLZXlSZWY6CiAgICAgICAgICAgICAgICAgIG5hbWU6IHN1YjJyYW5rLXNlY3JldHMKICAgICAgICAgICAgICAgICAga2V5OiBTVUIyQVBJX0FETUlOX0VNQUlMCiAgICAgICAgICAgIC0gbmFtZTogU1VCMkFQSV9BRE1JTl9QQVNTV09SRAogICAgICAgICAgICAgIHZhbHVlRnJvbToKICAgICAgICAgICAgICAgIHNlY3JldEtleVJlZjoKICAgICAgICAgICAgICAgICAgbmFtZTogc3ViMnJhbmstc2VjcmV0cwogICAgICAgICAgICAgICAgICBrZXk6IFNVQjJBUElfQURNSU5fUEFTU1dPUkQKICAgICAgICAgIHJlYWRpbmVzc1Byb2JlOgogICAgICAgICAgICBodHRwR2V0OgogICAgICAgICAgICAgIHBhdGg6IC9oZWFsdGgKICAgICAgICAgICAgICBwb3J0OiBodHRwCiAgICAgICAgICAgIGluaXRpYWxEZWxheVNlY29uZHM6IDMKICAgICAgICAgICAgcGVyaW9kU2Vjb25kczogMTAKICAgICAgICAgICAgdGltZW91dFNlY29uZHM6IDMKICAgICAgICAgICAgZmFpbHVyZVRocmVzaG9sZDogNgogICAgICAgICAgbGl2ZW5lc3NQcm9iZToKICAgICAgICAgICAgaHR0cEdldDoKICAgICAgICAgICAgICBwYXRoOiAvaGVhbHRoCiAgICAgICAgICAgICAgcG9ydDogaHR0cAogICAgICAgICAgICBpbml0aWFsRGVsYXlTZWNvbmRzOiAzCiAgICAgICAgICAgIHBlcmlvZFNlY29uZHM6IDEwCiAgICAgICAgICAgIHRpbWVvdXRTZWNvbmRzOiAzCiAgICAgICAgICAgIGZhaWx1cmVUaHJlc2hvbGQ6IDYKICAgICAgICAgIHZvbHVtZU1vdW50czoKICAgICAgICAgICAgLSBuYW1lOiBhcHAtY29uZmlnCiAgICAgICAgICAgICAgbW91bnRQYXRoOiAvZXRjL3N1YjJyYW5rL3N1YjJyYW5rLnlhbWwKICAgICAgICAgICAgICBzdWJQYXRoOiBzdWIycmFuay55YW1sCiAgICAgICAgICAgICAgcmVhZE9ubHk6IHRydWUKICAgICAgICAgICAgLSBuYW1lOiBkYXRhCiAgICAgICAgICAgICAgbW91bnRQYXRoOiAvdmFyL2xpYi9zdWIycmFuawogICAgICB2b2x1bWVzOgogICAgICAgIC0gbmFtZTogYXBwLWNvbmZpZwogICAgICAgICAgY29uZmlnTWFwOgogICAgICAgICAgICBuYW1lOiBzdWIycmFuay1jb25maWcKICAgICAgICAtIG5hbWU6IGRhdGEKICAgICAgICAgIHBlcnNpc3RlbnRWb2x1bWVDbGFpbToKICAgICAgICAgICAgY2xhaW1OYW1lOiBzdWIycmFuay1kYXRhCi0tLQphcGlWZXJzaW9uOiB2MQpraW5kOiBDb25maWdNYXAKbWV0YWRhdGE6CiAgbmFtZTogc3ViMnJhbmstZnJwYy1jb25maWcKICBuYW1lc3BhY2U6IHBsYXRmb3JtLWluZnJhCiAgbGFiZWxzOgogICAgYXBwLmt1YmVybmV0ZXMuaW8vbmFtZTogc3ViMnJhbmstZnJwYwogICAgYXBwLmt1YmVybmV0ZXMuaW8vY29tcG9uZW50OiB0dW5uZWwKICAgIGFwcC5rdWJlcm5ldGVzLmlvL3BhcnQtb2Y6IHBsYXRmb3JtLWluZnJhCiAgICBhcHAua3ViZXJuZXRlcy5pby9tYW5hZ2VkLWJ5OiB1bmlkZXNrCmRhdGE6CiAgZnJwYy50b21sOiB8CiAgICBzZXJ2ZXJBZGRyID0gIjgyLjE1Ni4yMy4yMjAiCiAgICBzZXJ2ZXJQb3J0ID0gMjIwMDAKICAgIGxvZ2luRmFpbEV4aXQgPSB0cnVlCiAgICBhdXRoLnRva2VuID0gInt7IC5FbnZzLkZSUF9UT0tFTiB9fSIKICAgIAogICAgW1twcm94aWVzXV0KICAgIG5hbWUgPSAicGxhdGZvcm0taW5mcmEtc3ViMnJhbmstbmMwMS13ZWIiCiAgICB0eXBlID0gInRjcCIKICAgIGxvY2FsSVAgPSAic3ViMnJhbmsucGxhdGZvcm0taW5mcmEuc3ZjLmNsdXN0ZXIubG9jYWwiCiAgICBsb2NhbFBvcnQgPSA4MDgwCiAgICByZW1vdGVQb3J0ID0gMjIwOTUKICAgIAotLS0KYXBpVmVyc2lvbjogYXBwcy92MQpraW5kOiBEZXBsb3ltZW50Cm1ldGFkYXRhOgogIG5hbWU6IHN1YjJyYW5rLWZycGMKICBuYW1lc3BhY2U6IHBsYXRmb3JtLWluZnJhCiAgbGFiZWxzOgogICAgYXBwLmt1YmVybmV0ZXMuaW8vbmFtZTogc3ViMnJhbmstZnJwYwogICAgYXBwLmt1YmVybmV0ZXMuaW8vY29tcG9uZW50OiB0dW5uZWwKICAgIGFwcC5rdWJlcm5ldGVzLmlvL3BhcnQtb2Y6IHBsYXRmb3JtLWluZnJhCiAgICBhcHAua3ViZXJuZXRlcy5pby9tYW5hZ2VkLWJ5OiB1bmlkZXNrCiAgICB1bmlkZXNrLmFpL3J1bnRpbWUtbm9kZTogTkMwMQogICAgdW5pZGVzay5haS9wdWJsaWMtaG9zdG5hbWU6IHJhbmsucGlrYXB5dGhvbi5jb20Kc3BlYzoKICByZXBsaWNhczogMQogIHN0cmF0ZWd5OgogICAgdHlwZTogUmVjcmVhdGUKICBzZWxlY3RvcjoKICAgIG1hdGNoTGFiZWxzOgogICAgICBhcHAua3ViZXJuZXRlcy5pby9uYW1lOiBzdWIycmFuay1mcnBjCiAgICAgIGFwcC5rdWJlcm5ldGVzLmlvL2NvbXBvbmVudDogdHVubmVsCiAgdGVtcGxhdGU6CiAgICBtZXRhZGF0YToKICAgICAgbGFiZWxzOgogICAgICAgIGFwcC5rdWJlcm5ldGVzLmlvL25hbWU6IHN1YjJyYW5rLWZycGMKICAgICAgICBhcHAua3ViZXJuZXRlcy5pby9jb21wb25lbnQ6IHR1bm5lbAogICAgICAgIGFwcC5rdWJlcm5ldGVzLmlvL3BhcnQtb2Y6IHBsYXRmb3JtLWluZnJhCiAgICAgIGFubm90YXRpb25zOgogICAgICAgIHVuaWRlc2suYWkvcHVibGljLWJhc2UtdXJsOiAiaHR0cHM6Ly9yYW5rLnBpa2FweXRob24uY29tIgogICAgICAgIHVuaWRlc2suYWkvZnJwLXNlcnZlcjogIjgyLjE1Ni4yMy4yMjA6MjIwMDAiCiAgICAgICAgdW5pZGVzay5haS9mcnAtcmVtb3RlLXBvcnQ6ICIyMjA5NSIKICAgICAgICB1bmlkZXNrLmFpL2ZycGMtY29uZmlnLXNoYTI1NjogIjFkOGY1OGE2ZjU4MjQ3ZGEzMzJkNDBkZWE1YzFkN2I3YzY4NDU1YTU5OTVhYTNiMWQ5NjE1NzA2ZWMxYzk0ZTYiCiAgICBzcGVjOgogICAgICBjb250YWluZXJzOgogICAgICAgIC0gbmFtZTogZnJwYwogICAgICAgICAgaW1hZ2U6IDEyNy4wLjAuMTo1MDAwL2h3bGFiL2ZycGM6djAuNjguMQogICAgICAgICAgaW1hZ2VQdWxsUG9saWN5OiBJZk5vdFByZXNlbnQKICAgICAgICAgIGFyZ3M6CiAgICAgICAgICAgIC0gLWMKICAgICAgICAgICAgLSAvZXRjL2ZycC9mcnBjLnRvbWwKICAgICAgICAgIGVudjoKICAgICAgICAgICAgLSBuYW1lOiBGUlBfVE9LRU4KICAgICAgICAgICAgICB2YWx1ZUZyb206CiAgICAgICAgICAgICAgICBzZWNyZXRLZXlSZWY6CiAgICAgICAgICAgICAgICAgIG5hbWU6IHN1YjJyYW5rLXNlY3JldHMKICAgICAgICAgICAgICAgICAga2V5OiBGUlBfVE9LRU4KICAgICAgICAgIHZvbHVtZU1vdW50czoKICAgICAgICAgICAgLSBuYW1lOiBmcnBjLWNvbmZpZwogICAgICAgICAgICAgIG1vdW50UGF0aDogL2V0Yy9mcnAvZnJwYy50b21sCiAgICAgICAgICAgICAgc3ViUGF0aDogZnJwYy50b21sCiAgICAgICAgICAgICAgcmVhZE9ubHk6IHRydWUKICAgICAgdm9sdW1lczoKICAgICAgICAtIG5hbWU6IGZycGMtY29uZmlnCiAgICAgICAgICBjb25maWdNYXA6CiAgICAgICAgICAgIG5hbWU6IHN1YjJyYW5rLWZycGMtY29uZmlnCg== + workspaces: + - name: source + volumeClaimTemplate: + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 4Gi diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4f14e9e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM oven/bun:1.2.21-slim + +WORKDIR /app + +COPY package.json bun.lock ./ +RUN bun install --frozen-lockfile --production + +COPY config ./config +COPY src ./src +COPY static ./static + +ENV NODE_ENV=production +EXPOSE 8080 + +CMD ["bun", "src/server.ts", "--config", "config/sub2rank.yaml", "--runtime", "k8s"] diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..48481b4 --- /dev/null +++ b/bun.lock @@ -0,0 +1,35 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "sub2rank", + "dependencies": { + "luxon": "^3.7.2", + "yaml": "^2.8.1", + }, + "devDependencies": { + "@types/bun": "^1.2.21", + "@types/luxon": "^3.7.1", + "typescript": "^5.9.2", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + + "@types/luxon": ["@types/luxon@3.7.2", "", {}, "sha512-gW+Oib+vUtGJBtNC8V9Reww0oIpusw+4m81uncg9REGZAJfqOQHfo/nkabnc7w0QReXyPqjrbWMJk6NuAkiX3Q=="], + + "@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="], + + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + + "luxon": ["luxon@3.7.2", "", {}, "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + + "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], + } +} diff --git a/config/sub2rank.yaml b/config/sub2rank.yaml new file mode 100644 index 0000000..8b48b55 --- /dev/null +++ b/config/sub2rank.yaml @@ -0,0 +1,83 @@ +apiVersion: sub2rank.pikapython.com/v1 +kind: Sub2Rank + +metadata: + name: sub2rank + owner: unidesk + +sub2api: + baseUrl: https://api.pikapython.com/api/v1 + requestTimeoutMs: 20000 + pageSize: 100 + adminCredentials: + sourceRef: platform-infra/sub2api.env + emailKey: ADMIN_EMAIL + passwordKey: ADMIN_PASSWORD + +lottery: + timezone: Asia/Shanghai + initialDrawCount: 1 + dailyGrant: + hour: 6 + minute: 0 + count: 1 + eligibility: + activeWithinHours: 24 + statuses: + - active + excludedRoles: + - admin + excludedIdentities: + - zhuo + - huiguo + identityFields: + - username + - email + - emailLocalPart + prize: + amountUsd: 100 + automaticCredit: + enabled: false + mode: dry-run + notesPrefix: Sub2Rank 每日抽奖奖金 + creditTest: + targetIdentifier: admin + identityFields: + - username + - emailLocalPart + amountUsd: 0.01 + notes: Sub2Rank CLI 管理员充值链路测试 + +ranking: + timezone: Asia/Shanghai + windowDays: 1 + sourceLimit: 100 + displayLimit: 20 + +records: + publicLimit: 20 + +runtime: + secretsRoot: /root/.unidesk/.state/secrets + secretSourcePaths: + platform-infra/sub2api.env: /root/.unidesk/.state/secrets/platform-infra/sub2api.env + platform-infra/sub2rank.env: /root/.unidesk/.state/secrets/platform-infra/sub2rank.env + defaultCliTarget: local + cliTargets: + local: + mode: embedded + databasePath: .state/sub2rank.sqlite + production: + mode: http + baseUrl: https://rank.pikapython.com + adminToken: + sourceRef: platform-infra/sub2rank.env + sourceKey: SUB2RANK_ADMIN_TOKEN + serverTargets: + k8s: + listenHost: 0.0.0.0 + listenPort: 8080 + databasePath: /var/lib/sub2rank/sub2rank.sqlite + adminTokenEnv: SUB2RANK_ADMIN_TOKEN + sub2apiAdminEmailEnv: SUB2API_ADMIN_EMAIL + sub2apiAdminPasswordEnv: SUB2API_ADMIN_PASSWORD diff --git a/package.json b/package.json new file mode 100644 index 0000000..5e1aa97 --- /dev/null +++ b/package.json @@ -0,0 +1,20 @@ +{ + "name": "sub2rank", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "cli": "bun scripts/sub2rank-cli.ts --config config/sub2rank.yaml", + "server": "bun src/server.ts --config config/sub2rank.yaml --runtime k8s", + "check": "bunx tsc --noEmit" + }, + "dependencies": { + "luxon": "^3.7.2", + "yaml": "^2.8.1" + }, + "devDependencies": { + "@types/bun": "^1.2.21", + "@types/luxon": "^3.7.1", + "typescript": "^5.9.2" + } +} diff --git a/scripts/ci/build-image.sh b/scripts/ci/build-image.sh new file mode 100755 index 0000000..bb33890 --- /dev/null +++ b/scripts/ci/build-image.sh @@ -0,0 +1,48 @@ +#!/bin/sh +set -eu + +source_root=${SOURCE_ROOT:?SOURCE_ROOT is required} +source_commit=${SOURCE_COMMIT:?SOURCE_COMMIT is required} +image_repository=${IMAGE_REPOSITORY:?IMAGE_REPOSITORY is required} +dockerfile=${DOCKERFILE:?DOCKERFILE is required} +build_network=${BUILD_NETWORK:?BUILD_NETWORK is required} +metadata_file=${BUILD_METADATA_FILE:?BUILD_METADATA_FILE is required} +result_file=${BUILD_RESULT_FILE:?BUILD_RESULT_FILE is required} + +case "$source_commit" in + *[!0-9a-f]*|'') printf '%s\n' 'SOURCE_COMMIT must be a full lowercase Git SHA' >&2; exit 2 ;; +esac +[ "${#source_commit}" -eq 40 ] || { printf '%s\n' 'SOURCE_COMMIT must be 40 characters' >&2; exit 2; } +[ "$build_network" = host ] || { printf '%s\n' 'BUILD_NETWORK must be host' >&2; exit 2; } +case "$dockerfile" in + /*|*../*|*/..|..) printf '%s\n' 'DOCKERFILE must be a safe relative path' >&2; exit 2 ;; +esac +[ -f "$source_root/$dockerfile" ] || { printf '%s\n' 'Dockerfile is missing' >&2; exit 2; } + +image_ref="$image_repository:$(printf '%s' "$source_commit" | cut -c1-12)" +rm -f "$metadata_file" "$result_file" + +buildctl-daemonless.sh build \ + --allow network.host \ + --frontend dockerfile.v0 \ + --local context="$source_root" \ + --local dockerfile="$source_root" \ + --opt "filename=$dockerfile" \ + --opt "network=$build_network" \ + --opt "build-arg:HTTP_PROXY=${HTTP_PROXY:-}" \ + --opt "build-arg:HTTPS_PROXY=${HTTPS_PROXY:-}" \ + --opt "build-arg:ALL_PROXY=${ALL_PROXY:-}" \ + --opt "build-arg:NO_PROXY=${NO_PROXY:-}" \ + --metadata-file "$metadata_file" \ + --output "type=image,name=$image_ref,push=true,registry.insecure=true" + +[ -s "$metadata_file" ] || { printf '%s\n' 'BuildKit metadata is missing' >&2; exit 2; } +digest=$(tr -d '\n' < "$metadata_file" | sed -n 's/.*"containerimage.digest"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1) +case "$digest" in + sha256:????????????????????????????????????????????????????????????????) ;; + *) printf '%s\n' 'BuildKit metadata digest is invalid' >&2; exit 2 ;; +esac + +printf '{"ok":true,"phase":"image-build","status":"built","imageStatus":"built","sourceCommit":"%s","imageRef":"%s","digest":"%s","digestRef":"%s@%s","valuesPrinted":false}\n' \ + "$source_commit" "$image_ref" "$digest" "$image_repository" "$digest" > "$result_file" +cat "$result_file" diff --git a/scripts/ci/publish-gitops.mjs b/scripts/ci/publish-gitops.mjs new file mode 100755 index 0000000..09908f3 --- /dev/null +++ b/scripts/ci/publish-gitops.mjs @@ -0,0 +1,195 @@ +#!/usr/bin/env bun +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { parseAllDocuments } from "yaml"; + +const placeholders = { + image: "__SUB2RANK_IMAGE_REF__", + configBase64: "__SUB2RANK_CONFIG_BASE64__", + configSha256: "__SUB2RANK_CONFIG_SHA256__", + sourceCommit: "__SUB2RANK_SOURCE_COMMIT__", +}; + +function option(name) { + const index = process.argv.indexOf(name); + if (index === -1) return null; + const value = process.argv[index + 1]; + if (value === undefined || value.length === 0 || value.startsWith("--")) throw new Error(`${name} requires a value`); + return value; +} + +function required(value, path) { + if (typeof value !== "string" || value.length === 0 || value.includes("\n")) throw new Error(`${path} must be a non-empty single-line string`); + return value; +} + +function record(value, path) { + if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} must be an object`); + return value; +} + +function safePath(value, path) { + const result = required(value, path); + if (result.startsWith("/") || result.split(/[\\/]/u).includes("..") || !/^[A-Za-z0-9._/-]+$/u.test(result)) throw new Error(`${path} must be a safe relative path`); + return result; +} + +function run(command, args, cwd, allowFailure = false) { + const result = spawnSync(command, args, { cwd, encoding: "utf8", maxBuffer: 4 * 1024 * 1024 }); + if (result.error !== undefined) throw result.error; + if (result.status !== 0 && !allowFailure) { + const detail = `${result.stderr || result.stdout || "command failed"}`.trim().slice(-2000); + throw new Error(`${command} failed (${result.status}): ${detail}`); + } + return result; +} + +function sha256(value) { + return createHash("sha256").update(value).digest("hex"); +} + +function replaceAllRequired(template, replacements) { + let output = template; + for (const [from, to] of replacements) { + if (!output.includes(from)) throw new Error(`manifest template is missing ${from}`); + output = output.split(from).join(to); + } + for (const placeholder of Object.values(placeholders)) { + if (output.includes(placeholder)) throw new Error(`manifest template retained ${placeholder}`); + } + return output; +} + +function validateManifest(manifest, expected) { + const documents = parseAllDocuments(manifest); + const errors = documents.flatMap((document) => document.errors); + if (errors.length > 0) throw new Error(`rendered manifest is invalid YAML: ${errors[0].message}`); + const objects = documents.map((document) => document.toJSON()).filter((value) => value !== null).map((value, index) => record(value, `manifest[${index}]`)); + const deployment = objects.find((item) => item.kind === "Deployment" && item.metadata?.name === "sub2rank"); + const service = objects.find((item) => item.kind === "Service" && item.metadata?.name === "sub2rank"); + const configMap = objects.find((item) => item.kind === "ConfigMap" && item.metadata?.name === "sub2rank-config"); + if (deployment === undefined || service === undefined || configMap === undefined) throw new Error("rendered manifest must contain the Sub2Rank Deployment, Service and ConfigMap"); + if (deployment.spec?.template?.spec?.containers?.[0]?.image !== expected.digestRef) throw new Error("rendered Deployment image is not the expected digest reference"); + if (deployment.spec?.template?.metadata?.annotations?.["unidesk.ai/source-commit"] !== expected.sourceCommit) throw new Error("rendered Deployment source commit annotation is incorrect"); + if (deployment.spec?.template?.metadata?.annotations?.["unidesk.ai/sub2rank-config-sha256"] !== expected.configSha256) throw new Error("rendered Deployment config SHA annotation is incorrect"); + if (configMap.binaryData?.["sub2rank.yaml"] !== expected.configBase64) throw new Error("rendered ConfigMap does not contain the exact source config"); +} + +function main() { + const sourceRoot = resolve(required(option("--source-root"), "--source-root")); + const sourceCommit = required(option("--source-commit"), "--source-commit"); + const configPath = safePath(required(option("--config-path"), "--config-path"), "--config-path"); + const metadataPath = resolve(required(option("--metadata"), "--metadata")); + const templateB64 = required(option("--manifest-template-b64"), "--manifest-template-b64"); + const imageRepository = required(option("--image-repository"), "--image-repository"); + const readUrl = required(option("--gitops-read-url"), "--gitops-read-url"); + const writeUrl = required(option("--gitops-write-url"), "--gitops-write-url"); + const branch = required(option("--gitops-branch"), "--gitops-branch"); + const manifestPath = safePath(required(option("--manifest-path"), "--manifest-path"), "--manifest-path"); + const releaseStatePath = safePath(required(option("--release-state-path"), "--release-state-path"), "--release-state-path"); + const maxPushAttempts = Number(required(option("--max-push-attempts"), "--max-push-attempts")); + const authorName = required(option("--author-name"), "--author-name"); + const authorEmail = required(option("--author-email"), "--author-email"); + const worktree = resolve(required(option("--worktree"), "--worktree")); + if (!/^[0-9a-f]{40}$/u.test(sourceCommit)) throw new Error("--source-commit must be a full lowercase Git SHA"); + if (!Number.isInteger(maxPushAttempts) || maxPushAttempts < 1 || maxPushAttempts > 5) throw new Error("--max-push-attempts must be an integer from 1 to 5"); + if (run("git", ["rev-parse", "HEAD"], sourceRoot).stdout.trim() !== sourceCommit) throw new Error("source checkout does not match --source-commit"); + + const configText = readFileSync(resolve(sourceRoot, configPath), "utf8"); + const appConfig = record(Bun.YAML.parse(configText), configPath); + if (appConfig.kind !== "Sub2Rank") throw new Error(`${configPath}.kind must be Sub2Rank`); + const lottery = record(appConfig.lottery, `${configPath}.lottery`); + const automaticCredit = record(lottery.automaticCredit, `${configPath}.lottery.automaticCredit`); + if (typeof automaticCredit.enabled !== "boolean" || !new Set(["dry-run", "live"]).has(automaticCredit.mode)) throw new Error(`${configPath}.lottery.automaticCredit must declare enabled and mode`); + const configBase64 = Buffer.from(configText, "utf8").toString("base64"); + const configSha256 = sha256(configText); + + const metadata = record(JSON.parse(readFileSync(metadataPath, "utf8")), metadataPath); + const digest = required(metadata["containerimage.digest"], `${metadataPath}.containerimage.digest`); + if (!/^sha256:[0-9a-f]{64}$/u.test(digest)) throw new Error("BuildKit metadata digest is invalid"); + const digestRef = `${imageRepository}@${digest}`; + const template = Buffer.from(templateB64, "base64").toString("utf8"); + const manifest = replaceAllRequired(template, [ + [placeholders.image, digestRef], + [placeholders.configBase64, configBase64], + [placeholders.configSha256, configSha256], + [placeholders.sourceCommit, sourceCommit], + ]); + validateManifest(manifest, { digestRef, sourceCommit, configSha256, configBase64 }); + + rmSync(worktree, { recursive: true, force: true }); + run("git", ["clone", "--no-checkout", readUrl, worktree], sourceRoot); + const fetched = run("git", ["fetch", "origin", branch], worktree, true).status === 0; + if (fetched) run("git", ["checkout", "-B", branch, `origin/${branch}`], worktree); + else { + run("git", ["checkout", "--orphan", branch], worktree); + run("git", ["rm", "-rf", "."], worktree, true); + } + + const targetPath = resolve(worktree, manifestPath); + const statePath = resolve(worktree, releaseStatePath); + if (!targetPath.startsWith(`${worktree}/`) || !statePath.startsWith(`${worktree}/`)) throw new Error("GitOps output path escaped the worktree"); + mkdirSync(dirname(targetPath), { recursive: true }); + writeFileSync(targetPath, manifest, "utf8"); + mkdirSync(dirname(statePath), { recursive: true }); + writeFileSync(statePath, `${JSON.stringify({ + version: 1, + kind: "Sub2RankReleaseState", + service: "sub2rank", + sourceCommit, + configSha256, + digest, + digestRef, + manifestPath, + automaticCredit: { enabled: automaticCredit.enabled, mode: automaticCredit.mode }, + }, null, 2)}\n`, "utf8"); + + run("git", ["config", "user.name", authorName], worktree); + run("git", ["config", "user.email", authorEmail], worktree); + run("git", ["add", "--", manifestPath, releaseStatePath], worktree); + const changed = run("git", ["diff", "--cached", "--quiet"], worktree, true).status !== 0; + if (changed) run("git", ["commit", "-m", `sub2rank: deploy ${sourceCommit.slice(0, 12)}`], worktree); + run("git", ["remote", "set-url", "origin", writeUrl], worktree); + + let pushAttempts = 0; + if (changed) { + let pushed = false; + for (let attempt = 1; attempt <= maxPushAttempts; attempt += 1) { + pushAttempts = attempt; + if (run("git", ["push", "origin", `HEAD:refs/heads/${branch}`], worktree, true).status === 0) { + pushed = true; + break; + } + run("git", ["fetch", "origin", branch], worktree); + const rebase = run("git", ["rebase", `origin/${branch}`], worktree, true); + if (rebase.status !== 0) { + run("git", ["rebase", "--abort"], worktree, true); + throw new Error(`GitOps rebase failed after push attempt ${attempt}`); + } + } + if (!pushed) throw new Error(`GitOps push failed after ${maxPushAttempts} attempts`); + } + const gitopsCommit = run("git", ["rev-parse", "HEAD"], worktree).stdout.trim(); + const remoteHead = run("git", ["ls-remote", writeUrl, `refs/heads/${branch}`], worktree).stdout.trim().split(/\s+/u)[0] ?? ""; + if (remoteHead !== gitopsCommit) throw new Error("GitOps remote branch did not converge to the published commit"); + process.stdout.write(`${JSON.stringify({ + ok: true, + phase: "gitops-publish", + status: "built", + imageStatus: "built", + sourceCommit, + runtimeSourceCommit: sourceCommit, + configSha256, + automaticCredit: { enabled: automaticCredit.enabled, mode: automaticCredit.mode }, + digest, + digestRef, + gitopsCommit, + changed, + pushAttempts, + valuesPrinted: false, + })}\n`); +} + +if (!process.execArgv.includes("--check")) main(); diff --git a/scripts/src/cli.ts b/scripts/src/cli.ts new file mode 100644 index 0000000..24e7be7 --- /dev/null +++ b/scripts/src/cli.ts @@ -0,0 +1,136 @@ +import { AdminHttpClient } from "../../src/admin-http-client"; +import { createEmbeddedContext } from "../../src/bootstrap"; +import { loadConfig, type EmbeddedCliTarget, type HttpCliTarget } from "../../src/config"; + +interface Parsed { + configPath: string; + targetId: string | null; + command: string[]; + confirm: boolean; + includeRecords: boolean; + id: string | null; + limit: number | null; + draws: number | null; +} + +function value(args: string[], name: string): string | null { + const index = args.indexOf(name); + if (index < 0) return null; + const result = args[index + 1]; + if (!result || result.startsWith("--")) throw new Error(`${name} requires a value`); + return result; +} + +function parseArgs(args: string[]): Parsed { + const configPath = value(args, "--config"); + if (!configPath) throw new Error("--config is required"); + const optionNames = new Set(["--config", "--target", "--id", "--limit", "--draws"]); + const flags = new Set(["--confirm", "--include-records"]); + const command: string[] = []; + for (let index = 0; index < args.length; index += 1) { + const item = args[index]!; + if (optionNames.has(item)) { index += 1; continue; } + if (flags.has(item)) continue; + if (item.startsWith("--")) throw new Error(`unknown option ${item}`); + command.push(item); + } + const integer = (name: string): number | null => { + const raw = value(args, name); + if (raw === null) return null; + const parsed = Number(raw); + if (!Number.isInteger(parsed) || parsed < 0) throw new Error(`${name} must be a non-negative integer`); + return parsed; + }; + return { + configPath, + targetId: value(args, "--target"), + command, + confirm: args.includes("--confirm"), + includeRecords: args.includes("--include-records"), + id: value(args, "--id"), + limit: integer("--limit"), + draws: integer("--draws"), + }; +} + +function help(): Record { + return { + ok: true, + usage: "bun scripts/sub2rank-cli.ts --config config/sub2rank.yaml [--target local|production] ", + commands: [ + "config validate", + "backend check", + "lottery status", + "lottery draw [--confirm]", + "lottery reset [--draws N] [--include-records] --confirm", + "records list [--limit N]", + "records delete --id --confirm", + "credit test [--confirm]", + ], + }; +} + +async function embedded(parsed: Parsed, config: ReturnType, target: EmbeddedCliTarget): Promise { + const context = createEmbeddedContext(config, target); + try { + const [group, action] = parsed.command; + if (group === "backend" && action === "check") return await context.service.status(true); + if (group === "lottery" && action === "status") return await context.service.status(false); + if (group === "lottery" && action === "draw") return parsed.confirm ? { ok: true, record: await context.service.draw() } : { ok: true, mutation: false, action: "lottery-draw", hint: "add --confirm to execute" }; + if (group === "lottery" && action === "reset") { + const draws = parsed.draws ?? config.lottery.initialDrawCount; + return parsed.confirm ? context.service.reset(draws, parsed.includeRecords) : { ok: true, mutation: false, action: "lottery-reset", draws, includeRecords: parsed.includeRecords, hint: "add --confirm to execute" }; + } + if (group === "records" && action === "list") return { ok: true, records: context.service.listRecords(parsed.limit ?? config.records.publicLimit) }; + if (group === "records" && action === "delete") { + if (!parsed.id) throw new Error("records delete requires --id"); + return parsed.confirm ? context.service.deleteRecord(parsed.id) : { ok: true, mutation: false, action: "record-delete", id: parsed.id, hint: "add --confirm to execute" }; + } + if (group === "credit" && action === "test") return await context.service.creditTest(parsed.confirm); + throw new Error(`unknown command: ${parsed.command.join(" ")}`); + } finally { + context.close(); + } +} + +async function remote(parsed: Parsed, config: ReturnType, target: HttpCliTarget): Promise { + const client = new AdminHttpClient(config, target); + const [group, action] = parsed.command; + if (group === "backend" && action === "check") return await client.backendCheck(); + if (group === "lottery" && action === "status") return await client.status(); + if (group === "lottery" && action === "draw") return parsed.confirm ? await client.draw() : { ok: true, mutation: false, action: "lottery-draw", hint: "add --confirm to execute" }; + if (group === "lottery" && action === "reset") { + const draws = parsed.draws ?? config.lottery.initialDrawCount; + return parsed.confirm ? await client.reset(draws, parsed.includeRecords) : { ok: true, mutation: false, action: "lottery-reset", draws, includeRecords: parsed.includeRecords, hint: "add --confirm to execute" }; + } + if (group === "records" && action === "list") return await client.records(parsed.limit ?? config.records.publicLimit); + if (group === "records" && action === "delete") { + if (!parsed.id) throw new Error("records delete requires --id"); + return parsed.confirm ? await client.deleteRecord(parsed.id) : { ok: true, mutation: false, action: "record-delete", id: parsed.id, hint: "add --confirm to execute" }; + } + if (group === "credit" && action === "test") return await client.creditTest(parsed.confirm); + throw new Error(`unknown command: ${parsed.command.join(" ")}`); +} + +export async function runCli(args: string[]): Promise { + try { + if (args.includes("--help") || args.length === 0) { + console.log(JSON.stringify(help(), null, 2)); + return; + } + const parsed = parseArgs(args); + const config = loadConfig(parsed.configPath); + if (parsed.command.join(" ") === "config validate") { + console.log(JSON.stringify({ ok: true, configPath: config.configPath, kind: config.kind, automaticCreditEnabled: config.lottery.automaticCredit.enabled, excludedIdentities: config.lottery.eligibility.excludedIdentities }, null, 2)); + return; + } + const targetId = parsed.targetId ?? config.runtime.defaultCliTarget; + const target = config.runtime.cliTargets[targetId]; + if (!target) throw new Error(`runtime.cliTargets.${targetId} does not exist`); + const result = target.mode === "embedded" ? await embedded(parsed, config, target) : await remote(parsed, config, target); + console.log(JSON.stringify({ target: targetId, ...result as Record }, null, 2)); + } catch (error) { + console.error(JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) }, null, 2)); + process.exitCode = 1; + } +} diff --git a/scripts/sub2rank-cli.ts b/scripts/sub2rank-cli.ts new file mode 100644 index 0000000..ba3a71e --- /dev/null +++ b/scripts/sub2rank-cli.ts @@ -0,0 +1,4 @@ +#!/usr/bin/env bun +import { runCli } from "./src/cli"; + +await runCli(process.argv.slice(2)); diff --git a/src/admin-http-client.ts b/src/admin-http-client.ts new file mode 100644 index 0000000..2dc91f1 --- /dev/null +++ b/src/admin-http-client.ts @@ -0,0 +1,34 @@ +import type { AppConfig, HttpCliTarget } from "./config"; +import { readSecret } from "./secrets"; + +export class AdminHttpClient { + private readonly token: string; + + constructor(private readonly config: AppConfig, private readonly target: HttpCliTarget) { + this.token = readSecret(config, target.adminToken); + } + + private async request(path: string, init: RequestInit = {}): Promise { + const headers = new Headers(init.headers); + headers.set("authorization", `Bearer ${this.token}`); + if (init.body) headers.set("content-type", "application/json"); + const response = await fetch(`${this.target.baseUrl.replace(/\/$/u, "")}${path}`, { + ...init, + headers, + signal: AbortSignal.timeout(this.config.sub2api.requestTimeoutMs), + }); + const payload = await response.json().catch(() => null) as { ok?: boolean; error?: string } | null; + if (!response.ok || !payload) throw new Error(payload?.error ?? `Sub2Rank admin API failed with HTTP ${response.status}`); + return payload as T; + } + + status(): Promise> { return this.request("/api/admin/status"); } + backendCheck(): Promise> { return this.request("/api/admin/backend-check"); } + draw(): Promise> { return this.request("/api/admin/draw", { method: "POST" }); } + reset(draws: number, includeRecords: boolean): Promise> { + return this.request("/api/admin/reset", { method: "POST", body: JSON.stringify({ draws, includeRecords }) }); + } + records(limit: number): Promise> { return this.request(`/api/admin/records?limit=${limit}`); } + deleteRecord(id: string): Promise> { return this.request(`/api/admin/records/${encodeURIComponent(id)}`, { method: "DELETE" }); } + creditTest(execute: boolean): Promise> { return this.request("/api/admin/credit-test", { method: "POST", body: JSON.stringify({ execute }) }); } +} diff --git a/src/bootstrap.ts b/src/bootstrap.ts new file mode 100644 index 0000000..67d9301 --- /dev/null +++ b/src/bootstrap.ts @@ -0,0 +1,27 @@ +import type { AppConfig, EmbeddedCliTarget, ServerTarget } from "./config"; +import { resolveDataPath } from "./config"; +import { LotteryService } from "./lottery-service"; +import { readSub2ApiCredentials } from "./secrets"; +import { LotteryStore } from "./store"; +import { Sub2ApiClient } from "./sub2api-client"; + +export interface AppContext { + service: LotteryService; + store: LotteryStore; + close(): void; +} + +export function createEmbeddedContext(config: AppConfig, target: EmbeddedCliTarget): AppContext { + const store = new LotteryStore(config, resolveDataPath(config, target.databasePath)); + const client = new Sub2ApiClient(config, readSub2ApiCredentials(config)); + return { service: new LotteryService(config, store, client), store, close: () => store.close() }; +} + +export function createServerContext(config: AppConfig, target: ServerTarget): AppContext { + const email = process.env[target.sub2apiAdminEmailEnv]; + const password = process.env[target.sub2apiAdminPasswordEnv]; + if (!email || !password) throw new Error(`server target requires env ${target.sub2apiAdminEmailEnv} and ${target.sub2apiAdminPasswordEnv}`); + const store = new LotteryStore(config, resolveDataPath(config, target.databasePath)); + const client = new Sub2ApiClient(config, { email, password }); + return { service: new LotteryService(config, store, client), store, close: () => store.close() }; +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..618b267 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,240 @@ +import { readFileSync } from "node:fs"; +import { dirname, isAbsolute, resolve } from "node:path"; +import { DateTime } from "luxon"; +import { parse } from "yaml"; + +export type IdentityField = "username" | "email" | "emailLocalPart"; + +export interface SecretRef { + sourceRef: string; + sourceKey: string; +} + +export interface AppConfig { + apiVersion: string; + kind: string; + metadata: { name: string; owner: string }; + sub2api: { + baseUrl: string; + requestTimeoutMs: number; + pageSize: number; + adminCredentials: { sourceRef: string; emailKey: string; passwordKey: string }; + }; + lottery: { + timezone: string; + initialDrawCount: number; + dailyGrant: { hour: number; minute: number; count: number }; + eligibility: { + activeWithinHours: number; + statuses: string[]; + excludedRoles: string[]; + excludedIdentities: string[]; + identityFields: IdentityField[]; + }; + prize: { amountUsd: number }; + automaticCredit: { enabled: boolean; mode: "dry-run" | "live"; notesPrefix: string }; + creditTest: { + targetIdentifier: string; + identityFields: IdentityField[]; + amountUsd: number; + notes: string; + }; + }; + ranking: { timezone: string; windowDays: number; sourceLimit: number; displayLimit: number }; + records: { publicLimit: number }; + runtime: { + secretsRoot: string; + secretSourcePaths: Record; + defaultCliTarget: string; + cliTargets: Record; + serverTargets: Record; + }; + configPath: string; + rootDirectory: string; +} + +export interface EmbeddedCliTarget { + mode: "embedded"; + databasePath: string; +} + +export interface HttpCliTarget { + mode: "http"; + baseUrl: string; + adminToken: SecretRef; +} + +export interface ServerTarget { + listenHost: string; + listenPort: number; + databasePath: string; + adminTokenEnv: string; + sub2apiAdminEmailEnv: string; + sub2apiAdminPasswordEnv: string; +} + +type ObjectValue = Record; + +function object(value: unknown, path: string): ObjectValue { + if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error(`${path} must be an object`); + return value as ObjectValue; +} + +function stringValue(parent: ObjectValue, key: string, path: string): string { + const value = parent[key]; + if (typeof value !== "string" || value.trim() === "") throw new Error(`${path}.${key} must be a non-empty string`); + return value; +} + +function numberValue(parent: ObjectValue, key: string, path: string, minimum = 0): number { + const value = parent[key]; + if (typeof value !== "number" || !Number.isFinite(value) || value < minimum) throw new Error(`${path}.${key} must be a number >= ${minimum}`); + return value; +} + +function integerValue(parent: ObjectValue, key: string, path: string, minimum = 0, maximum = Number.MAX_SAFE_INTEGER): number { + const value = parent[key]; + if (!Number.isInteger(value) || Number(value) < minimum || Number(value) > maximum) throw new Error(`${path}.${key} must be an integer between ${minimum} and ${maximum}`); + return Number(value); +} + +function timezoneValue(parent: ObjectValue, key: string, path: string): string { + const value = stringValue(parent, key, path); + if (!DateTime.now().setZone(value).isValid) throw new Error(`${path}.${key} must be a valid IANA timezone`); + return value; +} + +function booleanValue(parent: ObjectValue, key: string, path: string): boolean { + const value = parent[key]; + if (typeof value !== "boolean") throw new Error(`${path}.${key} must be boolean`); + return value; +} + +function strings(parent: ObjectValue, key: string, path: string): string[] { + const value = parent[key]; + if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || item.trim() === "")) throw new Error(`${path}.${key} must be a string array`); + return value as string[]; +} + +function identityFields(parent: ObjectValue, key: string, path: string): IdentityField[] { + const values = strings(parent, key, path); + const supported = new Set(["username", "email", "emailLocalPart"]); + if (values.some((value) => !supported.has(value))) throw new Error(`${path}.${key} contains an unsupported identity field`); + return values as IdentityField[]; +} + +function secretRef(value: unknown, path: string): SecretRef { + const raw = object(value, path); + return { sourceRef: stringValue(raw, "sourceRef", path), sourceKey: stringValue(raw, "sourceKey", path) }; +} + +export function loadConfig(path: string): AppConfig { + const configPath = resolve(path); + const rootDirectory = resolve(dirname(configPath), ".."); + const raw = object(parse(readFileSync(configPath, "utf8")), "config"); + const metadata = object(raw.metadata, "metadata"); + const sub2api = object(raw.sub2api, "sub2api"); + const adminCredentials = object(sub2api.adminCredentials, "sub2api.adminCredentials"); + const lottery = object(raw.lottery, "lottery"); + const dailyGrant = object(lottery.dailyGrant, "lottery.dailyGrant"); + const eligibility = object(lottery.eligibility, "lottery.eligibility"); + const prize = object(lottery.prize, "lottery.prize"); + const automaticCredit = object(lottery.automaticCredit, "lottery.automaticCredit"); + const creditTest = object(lottery.creditTest, "lottery.creditTest"); + const ranking = object(raw.ranking, "ranking"); + const records = object(raw.records, "records"); + const runtime = object(raw.runtime, "runtime"); + const secretSourcePathsRaw = object(runtime.secretSourcePaths, "runtime.secretSourcePaths"); + const cliTargetsRaw = object(runtime.cliTargets, "runtime.cliTargets"); + const serverTargetsRaw = object(runtime.serverTargets, "runtime.serverTargets"); + const cliTargets: Record = {}; + for (const [id, value] of Object.entries(cliTargetsRaw)) { + const target = object(value, `runtime.cliTargets.${id}`); + const mode = stringValue(target, "mode", `runtime.cliTargets.${id}`); + if (mode === "embedded") cliTargets[id] = { mode, databasePath: stringValue(target, "databasePath", `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 throw new Error(`runtime.cliTargets.${id}.mode must be embedded or http`); + } + const serverTargets: Record = {}; + for (const [id, value] of Object.entries(serverTargetsRaw)) { + const target = object(value, `runtime.serverTargets.${id}`); + serverTargets[id] = { + listenHost: stringValue(target, "listenHost", `runtime.serverTargets.${id}`), + listenPort: numberValue(target, "listenPort", `runtime.serverTargets.${id}`, 1), + databasePath: stringValue(target, "databasePath", `runtime.serverTargets.${id}`), + adminTokenEnv: stringValue(target, "adminTokenEnv", `runtime.serverTargets.${id}`), + sub2apiAdminEmailEnv: stringValue(target, "sub2apiAdminEmailEnv", `runtime.serverTargets.${id}`), + sub2apiAdminPasswordEnv: stringValue(target, "sub2apiAdminPasswordEnv", `runtime.serverTargets.${id}`), + }; + } + const automaticMode = stringValue(automaticCredit, "mode", "lottery.automaticCredit"); + if (automaticMode !== "dry-run" && automaticMode !== "live") throw new Error("lottery.automaticCredit.mode must be dry-run or live"); + const defaultCliTarget = stringValue(runtime, "defaultCliTarget", "runtime"); + if (!cliTargets[defaultCliTarget]) throw new Error(`runtime.defaultCliTarget references missing target ${defaultCliTarget}`); + return { + apiVersion: stringValue(raw, "apiVersion", "config"), + kind: stringValue(raw, "kind", "config"), + metadata: { name: stringValue(metadata, "name", "metadata"), owner: stringValue(metadata, "owner", "metadata") }, + sub2api: { + baseUrl: stringValue(sub2api, "baseUrl", "sub2api").replace(/\/$/u, ""), + requestTimeoutMs: integerValue(sub2api, "requestTimeoutMs", "sub2api", 1), + pageSize: integerValue(sub2api, "pageSize", "sub2api", 1, 100), + adminCredentials: { + sourceRef: stringValue(adminCredentials, "sourceRef", "sub2api.adminCredentials"), + emailKey: stringValue(adminCredentials, "emailKey", "sub2api.adminCredentials"), + passwordKey: stringValue(adminCredentials, "passwordKey", "sub2api.adminCredentials"), + }, + }, + lottery: { + timezone: timezoneValue(lottery, "timezone", "lottery"), + initialDrawCount: integerValue(lottery, "initialDrawCount", "lottery"), + dailyGrant: { + hour: integerValue(dailyGrant, "hour", "lottery.dailyGrant", 0, 23), + minute: integerValue(dailyGrant, "minute", "lottery.dailyGrant", 0, 59), + count: integerValue(dailyGrant, "count", "lottery.dailyGrant", 1), + }, + eligibility: { + activeWithinHours: integerValue(eligibility, "activeWithinHours", "lottery.eligibility", 1), + statuses: strings(eligibility, "statuses", "lottery.eligibility"), + excludedRoles: strings(eligibility, "excludedRoles", "lottery.eligibility"), + excludedIdentities: strings(eligibility, "excludedIdentities", "lottery.eligibility"), + identityFields: identityFields(eligibility, "identityFields", "lottery.eligibility"), + }, + prize: { amountUsd: numberValue(prize, "amountUsd", "lottery.prize", 0.01) }, + automaticCredit: { + enabled: booleanValue(automaticCredit, "enabled", "lottery.automaticCredit"), + mode: automaticMode, + notesPrefix: stringValue(automaticCredit, "notesPrefix", "lottery.automaticCredit"), + }, + creditTest: { + targetIdentifier: stringValue(creditTest, "targetIdentifier", "lottery.creditTest"), + identityFields: identityFields(creditTest, "identityFields", "lottery.creditTest"), + amountUsd: numberValue(creditTest, "amountUsd", "lottery.creditTest", 0.01), + notes: stringValue(creditTest, "notes", "lottery.creditTest"), + }, + }, + ranking: { + timezone: timezoneValue(ranking, "timezone", "ranking"), + windowDays: integerValue(ranking, "windowDays", "ranking", 1), + sourceLimit: integerValue(ranking, "sourceLimit", "ranking", 1), + displayLimit: integerValue(ranking, "displayLimit", "ranking", 1), + }, + records: { publicLimit: integerValue(records, "publicLimit", "records", 1) }, + runtime: { + secretsRoot: stringValue(runtime, "secretsRoot", "runtime"), + secretSourcePaths: Object.fromEntries(Object.entries(secretSourcePathsRaw).map(([ref, value]) => { + if (typeof value !== "string" || value.trim() === "") throw new Error(`runtime.secretSourcePaths.${ref} must be a non-empty string`); + return [ref, value]; + })), + defaultCliTarget, + cliTargets, + serverTargets, + }, + configPath, + rootDirectory, + }; +} + +export function resolveDataPath(config: AppConfig, value: string): string { + return isAbsolute(value) ? value : resolve(config.rootDirectory, value); +} diff --git a/src/http.ts b/src/http.ts new file mode 100644 index 0000000..42c5496 --- /dev/null +++ b/src/http.ts @@ -0,0 +1,63 @@ +import type { LotteryService } from "./lottery-service"; +import { resolve } from "node:path"; + +const staticRoot = resolve(import.meta.dir, "../static"); + +function json(data: unknown, status = 200): Response { + return Response.json(data, { status, headers: { "cache-control": "no-store" } }); +} + +async function body(request: Request): Promise> { + return await request.json().catch(() => ({})) as Record; +} + +function errorResponse(error: unknown, publicRequest: boolean): Response { + const message = error instanceof Error ? error.message : String(error); + const status = /does not exist|no draw chance|no eligible/u.test(message) ? 409 : 500; + if (status >= 500) console.error(JSON.stringify({ ok: false, path: publicRequest ? "public" : "admin", error: message })); + return json({ ok: false, error: publicRequest && status >= 500 ? "开奖服务暂时不可用,请稍后重试" : message }, status); +} + +async function staticFile(name: string, contentType: string): Promise { + const file = Bun.file(resolve(staticRoot, name)); + if (!(await file.exists())) return json({ ok: false, error: "not found" }, 404); + return new Response(file, { headers: { "content-type": contentType, "cache-control": name === "index.html" ? "no-cache" : "public, max-age=300" } }); +} + +export function createHandler(service: LotteryService, adminToken: string): (request: Request) => Promise { + return async (request) => { + const url = new URL(request.url); + try { + if (request.method === "GET" && url.pathname === "/health") return json({ ok: true, service: "sub2rank" }); + if ((request.method === "GET" || request.method === "HEAD") && url.pathname === "/") return await staticFile("index.html", "text/html; charset=utf-8"); + if ((request.method === "GET" || request.method === "HEAD") && url.pathname === "/styles.css") return await staticFile("styles.css", "text/css; charset=utf-8"); + if ((request.method === "GET" || request.method === "HEAD") && url.pathname === "/app.js") return await staticFile("app.js", "text/javascript; charset=utf-8"); + if (request.method === "GET" && url.pathname === "/api/public/state") return json(await service.publicState()); + if (request.method === "POST" && url.pathname === "/api/public/draw") return json({ ok: true, record: await service.publicDraw() }); + if (!url.pathname.startsWith("/api/admin/")) return json({ ok: false, error: "not found" }, 404); + if (request.headers.get("authorization") !== `Bearer ${adminToken}`) return json({ ok: false, error: "unauthorized" }, 401); + if (request.method === "GET" && url.pathname === "/api/admin/status") return json(await service.status(false)); + if (request.method === "GET" && url.pathname === "/api/admin/backend-check") return json(await service.status(true)); + if (request.method === "POST" && url.pathname === "/api/admin/draw") return json({ ok: true, record: await service.draw() }); + if (request.method === "POST" && url.pathname === "/api/admin/reset") { + const input = await body(request); + if (!Number.isInteger(input.draws) || Number(input.draws) < 0 || typeof input.includeRecords !== "boolean") return json({ ok: false, error: "draws must be a non-negative integer and includeRecords must be boolean" }, 400); + return json(service.reset(Number(input.draws), input.includeRecords)); + } + if (request.method === "GET" && url.pathname === "/api/admin/records") { + const limit = Number(url.searchParams.get("limit")); + if (!Number.isInteger(limit) || limit < 1) return json({ ok: false, error: "limit must be a positive integer" }, 400); + return json({ ok: true, records: service.listRecords(limit) }); + } + if (request.method === "DELETE" && url.pathname.startsWith("/api/admin/records/")) return json(service.deleteRecord(decodeURIComponent(url.pathname.slice("/api/admin/records/".length)))); + if (request.method === "POST" && url.pathname === "/api/admin/credit-test") { + const input = await body(request); + if (typeof input.execute !== "boolean") return json({ ok: false, error: "execute must be boolean" }, 400); + return json(await service.creditTest(input.execute)); + } + return json({ ok: false, error: "not found" }, 404); + } catch (error) { + return errorResponse(error, url.pathname.startsWith("/api/public/")); + } + }; +} diff --git a/src/lottery-service.ts b/src/lottery-service.ts new file mode 100644 index 0000000..335ebcc --- /dev/null +++ b/src/lottery-service.ts @@ -0,0 +1,225 @@ +import { DateTime } from "luxon"; +import { createHash, randomInt, randomUUID } from "node:crypto"; +import type { AppConfig, IdentityField } from "./config"; +import { LotteryStore } from "./store"; +import { Sub2ApiClient } from "./sub2api-client"; +import type { DrawRecord, PublicDrawRecord, PublicRankingRow, Sub2ApiUser } from "./types"; + +class Mutex { + private tail = Promise.resolve(); + + async run(operation: () => Promise): Promise { + const previous = this.tail; + let release!: () => void; + this.tail = new Promise((resolve) => { release = resolve; }); + await previous; + try { + return await operation(); + } finally { + release(); + } + } +} + +function normalized(value: string): string { + return value.trim().toLocaleLowerCase("en-US"); +} + +function identityValues(user: Sub2ApiUser, fields: IdentityField[]): string[] { + return fields.map((field) => { + if (field === "username") return normalized(user.username ?? ""); + if (field === "email") return normalized(user.email ?? ""); + return normalized((user.email ?? "").split("@", 1)[0] ?? ""); + }).filter(Boolean); +} + +function maskEmail(email: string): string { + const [local, domain] = email.split("@", 2); + if (!domain) return "匿名用户"; + const visible = local.slice(0, Math.min(2, local.length)); + return `${visible}${"*".repeat(Math.max(2, Math.min(5, local.length - visible.length)))}@${domain}`; +} + +function displayName(user: Sub2ApiUser): string { + return user.username.trim() || maskEmail(user.email); +} + +export class LotteryService { + private readonly mutex = new Mutex(); + + constructor( + readonly config: AppConfig, + private readonly store: LotteryStore, + private readonly sub2api: Sub2ApiClient, + ) {} + + private excluded(user: Sub2ApiUser): boolean { + const eligibility = this.config.lottery.eligibility; + const roles = new Set(eligibility.excludedRoles.map(normalized)); + const statuses = new Set(eligibility.statuses.map(normalized)); + const identities = new Set(eligibility.excludedIdentities.map(normalized)); + return roles.has(normalized(user.role)) || !statuses.has(normalized(user.status)) || identityValues(user, eligibility.identityFields).some((value) => identities.has(value)); + } + + async eligibleUsers(now = new Date()): Promise { + const cutoff = now.getTime() - this.config.lottery.eligibility.activeWithinHours * 60 * 60 * 1000; + return (await this.sub2api.listUsers()).filter((user) => { + if (this.excluded(user) || !user.last_active_at) return false; + const activeAt = Date.parse(user.last_active_at); + return Number.isFinite(activeAt) && activeAt >= cutoff && activeAt <= now.getTime(); + }); + } + + async ranking(now = new Date()): Promise<{ rows: PublicRankingRow[]; totals: { actualCost: number; requests: number; tokens: number }; startDate: string; endDate: string }> { + const local = DateTime.fromJSDate(now, { zone: this.config.ranking.timezone }); + const startDate = local.minus({ days: this.config.ranking.windowDays - 1 }).toISODate(); + const endDate = local.toISODate(); + if (!startDate || !endDate) throw new Error("failed to calculate ranking date range"); + const [source, users] = await Promise.all([this.sub2api.getUsageRanking(startDate, endDate), this.sub2api.listUsers()]); + const userMap = new Map(users.map((user) => [user.id, user])); + const filtered = source.ranking + .map((row) => ({ row, user: userMap.get(row.user_id) })) + .filter((item): item is { row: typeof item.row; user: Sub2ApiUser } => Boolean(item.user) && !this.excluded(item.user!)); + const rows = filtered + .slice(0, this.config.ranking.displayLimit) + .map(({ row, user }, index) => ({ + rank: index + 1, + displayName: displayName(user), + actualCost: row.actual_cost, + requests: row.requests, + tokens: row.tokens, + })); + return { + rows, + totals: filtered.reduce((totals, { row }) => ({ + actualCost: totals.actualCost + row.actual_cost, + requests: totals.requests + row.requests, + tokens: totals.tokens + row.tokens, + }), { actualCost: 0, requests: 0, tokens: 0 }), + startDate, + endDate, + }; + } + + private publicRecord(record: DrawRecord): PublicDrawRecord { + return { + id: record.id, + drawnAt: record.drawnAt, + eligibleCount: record.eligibleCount, + winnerDisplayName: record.winnerDisplayName, + prizeAmountUsd: record.prizeAmountUsd, + creditStatus: record.creditStatus, + }; + } + + async publicState(): Promise> { + const state = await this.status(true); + return { + ...state, + records: this.store.listRecords(this.config.records.publicLimit).map((record) => this.publicRecord(record)), + rankingWindowDays: this.config.ranking.windowDays, + }; + } + + async publicDraw(): Promise { + return this.publicRecord(await this.draw()); + } + + async status(includeLiveData = false): Promise> { + const grants = this.store.reconcileGrants(); + const result: Record = { + ok: true, + remainingDraws: grants.remainingDraws, + nextGrantAt: grants.nextGrantAt, + dailyGrantCount: this.config.lottery.dailyGrant.count, + prizeAmountUsd: this.config.lottery.prize.amountUsd, + activeWithinHours: this.config.lottery.eligibility.activeWithinHours, + automaticCredit: { + enabled: this.config.lottery.automaticCredit.enabled, + mode: this.config.lottery.automaticCredit.mode, + }, + records: this.store.listRecords(this.config.records.publicLimit), + }; + if (includeLiveData) { + const [eligible, ranking] = await Promise.all([this.eligibleUsers(), this.ranking()]); + result.eligibleUserCount = eligible.length; + result.ranking = ranking; + } + return result; + } + + async draw(): Promise { + return await this.mutex.run(async () => { + const state = this.store.reconcileGrants(); + if (state.remainingDraws < 1) throw new Error("no draw chance is available"); + const users = await this.eligibleUsers(); + if (users.length === 0) throw new Error("no eligible user was active in the configured window"); + const winner = users[randomInt(users.length)]!; + const record: DrawRecord = { + id: randomUUID(), + drawnAt: new Date().toISOString(), + eligibleCount: users.length, + winnerUserId: winner.id, + winnerDisplayName: displayName(winner), + prizeAmountUsd: this.config.lottery.prize.amountUsd, + creditStatus: this.config.lottery.automaticCredit.enabled ? (this.config.lottery.automaticCredit.mode === "dry-run" ? "dry_run" : "pending") : "disabled", + creditMessage: this.config.lottery.automaticCredit.enabled ? (this.config.lottery.automaticCredit.mode === "dry-run" ? "YAML dry-run:未修改账户额度" : "等待自动充值") : "YAML 开关关闭:未修改账户额度", + }; + this.store.createDraw(record); + if (this.config.lottery.automaticCredit.enabled && this.config.lottery.automaticCredit.mode === "live") { + try { + await this.sub2api.addBalance(winner.id, record.prizeAmountUsd, `${this.config.lottery.automaticCredit.notesPrefix} ${record.id}`); + record.creditStatus = "succeeded"; + record.creditMessage = "Sub2API 余额增加成功"; + } catch (error) { + record.creditStatus = "failed"; + record.creditMessage = error instanceof Error ? error.message : String(error); + } + this.store.updateCredit(record.id, record.creditStatus, record.creditMessage); + } + return record; + }); + } + + reset(draws: number, includeRecords: boolean): Record { + const result = this.store.resetData(draws, includeRecords); + return { ok: true, ...result, includeRecords }; + } + + listRecords(limit: number): DrawRecord[] { + return this.store.listRecords(limit); + } + + deleteRecord(id: string): Record { + const deleted = this.store.deleteRecord(id); + if (!deleted) throw new Error(`draw record ${id} does not exist`); + return { ok: true, deletedId: id }; + } + + async creditTest(execute: boolean): Promise> { + const test = this.config.lottery.creditTest; + const users = await this.sub2api.listUsers(); + const target = users.find((user) => normalized(user.role) === "admin" && identityValues(user, test.identityFields).includes(normalized(test.targetIdentifier))); + if (!target) throw new Error("configured admin credit-test target was not found"); + const plan = { + ok: true, + execute, + targetUserId: target.id, + targetDisplayName: displayName(target), + role: target.role, + amountUsd: test.amountUsd, + automaticCreditEnabled: this.config.lottery.automaticCredit.enabled, + }; + if (!execute) return plan; + const before = target.balance; + const updated = await this.sub2api.addBalance(target.id, test.amountUsd, test.notes); + const observedDeltaUsd = Number((updated.balance - before).toFixed(8)); + if (Math.abs(observedDeltaUsd - test.amountUsd) > 0.00000001) throw new Error(`admin credit test delta mismatch: expected ${test.amountUsd}, observed ${observedDeltaUsd}`); + return { + ...plan, + credited: true, + observedDeltaUsd, + operationFingerprint: createHash("sha256").update(`${target.id}:${test.amountUsd}:${test.notes}`).digest("hex").slice(0, 16), + }; + } +} diff --git a/src/secrets.ts b/src/secrets.ts new file mode 100644 index 0000000..8cc7e54 --- /dev/null +++ b/src/secrets.ts @@ -0,0 +1,33 @@ +import { readFileSync } from "node:fs"; +import type { AppConfig, SecretRef } from "./config"; + +function parseEnv(text: string): Map { + const values = new Map(); + for (const line of text.split(/\r?\n/u)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const index = trimmed.indexOf("="); + if (index < 1) continue; + const key = trimmed.slice(0, index).trim(); + let value = trimmed.slice(index + 1).trim(); + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) value = value.slice(1, -1); + values.set(key, value); + } + return values; +} + +export function readSecret(config: AppConfig, ref: SecretRef): string { + const path = config.runtime.secretSourcePaths[ref.sourceRef]; + if (!path) throw new Error(`runtime.secretSourcePaths is missing sourceRef ${ref.sourceRef}`); + const value = parseEnv(readFileSync(path, "utf8")).get(ref.sourceKey); + if (!value) throw new Error(`secret source ${ref.sourceRef} is missing key ${ref.sourceKey}`); + return value; +} + +export function readSub2ApiCredentials(config: AppConfig): { email: string; password: string } { + const sourceRef = config.sub2api.adminCredentials.sourceRef; + return { + email: readSecret(config, { sourceRef, sourceKey: config.sub2api.adminCredentials.emailKey }), + password: readSecret(config, { sourceRef, sourceKey: config.sub2api.adminCredentials.passwordKey }), + }; +} diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..4a12bbf --- /dev/null +++ b/src/server.ts @@ -0,0 +1,33 @@ +import { loadConfig } from "./config"; +import { createServerContext } from "./bootstrap"; +import { createHandler } from "./http"; + +function option(name: string): string { + const index = process.argv.indexOf(name); + const value = index >= 0 ? process.argv[index + 1] : undefined; + if (!value) throw new Error(`${name} is required`); + return value; +} + +const config = loadConfig(option("--config")); +const runtimeId = option("--runtime"); +const target = config.runtime.serverTargets[runtimeId]; +if (!target) throw new Error(`runtime.serverTargets.${runtimeId} does not exist`); +const adminToken = process.env[target.adminTokenEnv]; +if (!adminToken) throw new Error(`server target requires env ${target.adminTokenEnv}`); +const context = createServerContext(config, target); +const server = Bun.serve({ + hostname: target.listenHost, + port: target.listenPort, + fetch: createHandler(context.service, adminToken), +}); + +console.log(JSON.stringify({ ok: true, service: config.metadata.name, runtime: runtimeId, listen: server.url.toString(), automaticCreditEnabled: config.lottery.automaticCredit.enabled })); + +for (const signal of ["SIGINT", "SIGTERM"] as const) { + process.on(signal, () => { + context.close(); + server.stop(true); + process.exit(0); + }); +} diff --git a/src/store.ts b/src/store.ts new file mode 100644 index 0000000..b90823c --- /dev/null +++ b/src/store.ts @@ -0,0 +1,147 @@ +import { Database } from "bun:sqlite"; +import { dirname } from "node:path"; +import { mkdirSync } from "node:fs"; +import { DateTime } from "luxon"; +import type { AppConfig } from "./config"; +import type { DrawRecord } from "./types"; + +interface StoredRecordRow { + id: string; + drawn_at: string; + eligible_count: number; + winner_user_id: number; + winner_display_name: string; + prize_amount_usd: number; + credit_status: DrawRecord["creditStatus"]; + credit_message: string | null; +} + +export class LotteryStore { + private readonly db: Database; + + constructor(private readonly config: AppConfig, path: string) { + mkdirSync(dirname(path), { recursive: true }); + this.db = new Database(path, { create: true, strict: true }); + this.db.exec("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000;"); + this.db.exec(` + CREATE TABLE IF NOT EXISTS meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS draw_records ( + id TEXT PRIMARY KEY, + drawn_at TEXT NOT NULL, + eligible_count INTEGER NOT NULL, + winner_user_id INTEGER NOT NULL, + winner_display_name TEXT NOT NULL, + prize_amount_usd REAL NOT NULL, + credit_status TEXT NOT NULL, + credit_message TEXT + ); + CREATE INDEX IF NOT EXISTS draw_records_drawn_at ON draw_records(drawn_at DESC); + `); + } + + close(): void { + this.db.close(); + } + + private meta(key: string): string | null { + const row = this.db.query<{ value: string }, [string]>("SELECT value FROM meta WHERE key = ?").get(key); + return row?.value ?? null; + } + + private setMeta(key: string, value: string): void { + this.db.query("INSERT INTO meta(key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value").run(key, value); + } + + private latestGrant(now: Date): DateTime { + const zone = this.config.lottery.timezone; + const local = DateTime.fromJSDate(now, { zone }); + let grant = local.set({ hour: this.config.lottery.dailyGrant.hour, minute: this.config.lottery.dailyGrant.minute, second: 0, millisecond: 0 }); + if (grant > local) grant = grant.minus({ days: 1 }); + return grant; + } + + reconcileGrants(now = new Date()): { remainingDraws: number; nextGrantAt: string; added: number } { + return this.db.transaction(() => { + const latest = this.latestGrant(now); + const latestKey = latest.toISODate(); + if (!latestKey) throw new Error("failed to calculate latest grant key"); + const previousKey = this.meta("last_grant_key"); + let remaining = Number(this.meta("remaining_draws") ?? "0"); + let added = 0; + if (previousKey === null) { + remaining = this.config.lottery.initialDrawCount; + this.setMeta("last_grant_key", latestKey); + this.setMeta("remaining_draws", String(remaining)); + } else { + const previous = DateTime.fromISO(previousKey, { zone: this.config.lottery.timezone }).startOf("day"); + const days = Math.max(0, Math.round(latest.startOf("day").diff(previous, "days").days)); + added = days * this.config.lottery.dailyGrant.count; + if (added > 0) { + remaining += added; + this.setMeta("last_grant_key", latestKey); + this.setMeta("remaining_draws", String(remaining)); + } + } + const nextGrantAt = latest.plus({ days: 1 }).toUTC().toISO(); + if (!nextGrantAt) throw new Error("failed to calculate next grant time"); + return { remainingDraws: remaining, nextGrantAt, added }; + })(); + } + + createDraw(record: DrawRecord): void { + this.db.transaction(() => { + const remaining = Number(this.meta("remaining_draws") ?? "0"); + if (remaining < 1) throw new Error("no draw chance is available"); + this.db.query(`INSERT INTO draw_records( + id, drawn_at, eligible_count, winner_user_id, winner_display_name, prize_amount_usd, credit_status, credit_message + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run( + record.id, + record.drawnAt, + record.eligibleCount, + record.winnerUserId, + record.winnerDisplayName, + record.prizeAmountUsd, + record.creditStatus, + record.creditMessage, + ); + this.setMeta("remaining_draws", String(remaining - 1)); + })(); + } + + updateCredit(id: string, status: DrawRecord["creditStatus"], message: string | null): void { + const result = this.db.query("UPDATE draw_records SET credit_status = ?, credit_message = ? WHERE id = ?").run(status, message, id); + if (result.changes !== 1) throw new Error(`draw record ${id} does not exist`); + } + + listRecords(limit: number): DrawRecord[] { + const rows = this.db.query("SELECT * FROM draw_records ORDER BY drawn_at DESC LIMIT ?").all(limit); + return rows.map((row) => ({ + id: row.id, + drawnAt: row.drawn_at, + eligibleCount: row.eligible_count, + winnerUserId: row.winner_user_id, + winnerDisplayName: row.winner_display_name, + prizeAmountUsd: row.prize_amount_usd, + creditStatus: row.credit_status, + creditMessage: row.credit_message, + })); + } + + deleteRecord(id: string): boolean { + return this.db.query("DELETE FROM draw_records WHERE id = ?").run(id).changes === 1; + } + + resetData(draws: number, includeRecords: boolean, now = new Date()): { remainingDraws: number; recordsDeleted: number } { + return this.db.transaction(() => { + const deleted = includeRecords ? this.db.query("DELETE FROM draw_records").run().changes : 0; + const latestKey = this.latestGrant(now).toISODate(); + if (!latestKey) throw new Error("failed to calculate reset grant key"); + this.setMeta("last_grant_key", latestKey); + this.setMeta("remaining_draws", String(draws)); + return { remainingDraws: draws, recordsDeleted: deleted }; + })(); + } +} diff --git a/src/sub2api-client.ts b/src/sub2api-client.ts new file mode 100644 index 0000000..947c209 --- /dev/null +++ b/src/sub2api-client.ts @@ -0,0 +1,94 @@ +import type { AppConfig } from "./config"; +import type { RankingSourceRow, Sub2ApiUser } from "./types"; + +interface Envelope { + code: number; + message: string; + data: T; +} + +interface Paginated { + items: T[]; + total: number; + page: number; + page_size: number; + pages: number; +} + +export class Sub2ApiClient { + private token: string | null = null; + private tokenExpiresAt = 0; + + constructor( + private readonly config: AppConfig, + private readonly credentials: { email: string; password: string }, + ) {} + + private async request(path: string, init: RequestInit = {}, authenticate = true): Promise { + const headers = new Headers(init.headers); + headers.set("accept", "application/json"); + if (init.body) headers.set("content-type", "application/json"); + if (authenticate) headers.set("authorization", `Bearer ${await this.accessToken()}`); + const response = await fetch(`${this.config.sub2api.baseUrl}${path}`, { + ...init, + headers, + signal: AbortSignal.timeout(this.config.sub2api.requestTimeoutMs), + }); + const payload = (await response.json().catch(() => null)) as Envelope | null; + if (!response.ok || !payload || payload.code !== 0) throw new Error(`Sub2API ${init.method ?? "GET"} ${path} failed: HTTP ${response.status} ${payload?.message ?? "invalid response"}`); + return payload.data; + } + + private async accessToken(): Promise { + if (this.token && Date.now() < this.tokenExpiresAt) return this.token; + const data = await this.request<{ access_token: string; expires_in?: number }>("/auth/login", { + method: "POST", + body: JSON.stringify(this.credentials), + }, false); + if (!data.access_token) throw new Error("Sub2API login response is missing access_token"); + this.token = data.access_token; + const lifetimeSeconds = data.expires_in && data.expires_in > 120 ? data.expires_in - 60 : 300; + this.tokenExpiresAt = Date.now() + lifetimeSeconds * 1000; + return this.token; + } + + async listUsers(): Promise { + for (let attempt = 1; attempt <= 3; attempt += 1) { + const users = new Map(); + let expectedTotal = 0; + let page = 1; + for (;;) { + const params = new URLSearchParams({ + page: String(page), + page_size: String(this.config.sub2api.pageSize), + sort_by: "id", + sort_order: "asc", + }); + const data = await this.request>(`/admin/users?${params}`); + expectedTotal = data.total; + for (const user of data.items) users.set(user.id, user); + if (page >= data.pages) break; + page += 1; + } + if (users.size === expectedTotal) return [...users.values()].sort((left, right) => left.id - right.id); + } + throw new Error("Sub2API user list changed during pagination; retry the operation"); + } + + async getUsageRanking(startDate: string, endDate: string): Promise<{ ranking: RankingSourceRow[]; total_actual_cost: number; total_requests: number; total_tokens: number }> { + const params = new URLSearchParams({ + start_date: startDate, + end_date: endDate, + timezone: this.config.ranking.timezone, + limit: String(this.config.ranking.sourceLimit), + }); + return await this.request(`/admin/dashboard/users-ranking?${params}`); + } + + async addBalance(userId: number, amountUsd: number, notes: string): Promise { + return await this.request(`/admin/users/${userId}/balance`, { + method: "POST", + body: JSON.stringify({ balance: amountUsd, operation: "add", notes }), + }); + } +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..6542233 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,45 @@ +export interface Sub2ApiUser { + id: number; + email: string; + username: string; + role: string; + status: string; + balance: number; + last_active_at?: string | null; +} + +export interface RankingSourceRow { + user_id: number; + email: string; + actual_cost: number; + requests: number; + tokens: number; +} + +export interface PublicRankingRow { + rank: number; + displayName: string; + actualCost: number; + requests: number; + tokens: number; +} + +export interface PublicDrawRecord { + id: string; + drawnAt: string; + eligibleCount: number; + winnerDisplayName: string; + prizeAmountUsd: number; + creditStatus: DrawRecord["creditStatus"]; +} + +export interface DrawRecord { + id: string; + drawnAt: string; + eligibleCount: number; + winnerUserId: number; + winnerDisplayName: string; + prizeAmountUsd: number; + creditStatus: "disabled" | "dry_run" | "pending" | "succeeded" | "failed"; + creditMessage: string | null; +} diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..eb6982d --- /dev/null +++ b/static/app.js @@ -0,0 +1,164 @@ +const $ = (selector) => document.querySelector(selector) +const fields = (name) => document.querySelectorAll(`[data-field="${name}"]`) +const setField = (name, value) => fields(name).forEach((node) => { node.textContent = String(value) }) + +const button = $('#draw-button') +const stage = $('.draw-stage') +const statusLine = $('#draw-status') +const rankingBody = $('#ranking-body') +const recordList = $('#record-list') +const creditMode = $('#credit-mode') +const dialog = $('#winner-dialog') + +let state = null +let drawing = false + +function money(value, digits = 2) { + return Number(value).toLocaleString('en-US', { minimumFractionDigits: digits, maximumFractionDigits: digits }) +} + +function compact(value) { + return new Intl.NumberFormat('zh-CN', { notation: 'compact', maximumFractionDigits: 1 }).format(Number(value)) +} + +function localTime(value) { + return new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }).format(new Date(value)) +} + +function renderRanking(ranking) { + const rows = ranking?.rows ?? [] + if (!rows.length) { + rankingBody.innerHTML = '当前窗口暂无用量' + return + } + rankingBody.innerHTML = rows.map((row) => ` + ${String(row.rank).padStart(2, '0')} + ${escapeHtml(row.displayName)} + $${money(row.actualCost, 3)} + ${compact(row.requests)} 次${compact(row.tokens)} tokens + `).join('') +} + +function renderRecords(records) { + if (!records?.length) { + recordList.innerHTML = '
  • 第一位幸运用户,会是谁?
  • ' + return + } + recordList.innerHTML = records.map((record, index) => `
  • + #${String(index + 1).padStart(2, '0')} + ${escapeHtml(record.winnerDisplayName)}${localTime(record.drawnAt)} · ${record.eligibleCount} 人等概率 + $${money(record.prizeAmountUsd, 0)}${creditLabel(record.creditStatus)} +
  • `).join('') +} + +function creditLabel(status) { + return ({ succeeded: '已充值', dry_run: '模拟充值', disabled: '充值未开启', pending: '充值待确认', failed: '充值失败' })[status] ?? status +} + +function escapeHtml(value) { + const node = document.createElement('span') + node.textContent = String(value) + return node.innerHTML +} + +function render(next) { + state = next + setField('active-hours', next.activeWithinHours) + setField('prize', money(next.prizeAmountUsd, 0)) + setField('remaining', next.remainingDraws) + setField('eligible', next.eligibleUserCount ?? '—') + setField('daily-grant', next.dailyGrantCount) + setField('next-grant', localTime(next.nextGrantAt)) + setField('ranking-label', next.rankingWindowDays === 1 ? '今日' : `近 ${next.rankingWindowDays} 日`) + setField('ranking-total', `$${money(next.ranking?.totals?.actualCost ?? 0)}`) + renderRanking(next.ranking) + renderRecords(next.records) + const autoEnabled = next.automaticCredit?.enabled === true + creditMode.textContent = autoEnabled ? (next.automaticCredit.mode === 'live' ? '自动充值已开启' : '自动充值模拟中') : '自动充值暂未开启' + button.disabled = drawing || Number(next.remainingDraws) < 1 || Number(next.eligibleUserCount) < 1 + statusLine.textContent = Number(next.remainingDraws) < 1 ? '今天的机会已用完,明早 06:00 再来' : `${next.eligibleUserCount} 名候选人已就位` +} + +async function requestJson(url, options = {}) { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 12000) + try { + const response = await fetch(url, { ...options, signal: controller.signal }) + const raw = await response.text() + let data = null + try { data = raw ? JSON.parse(raw) : null } catch { /* handled below */ } + if (!response.ok || !data?.ok) throw new Error(data?.error ?? `服务暂不可用(HTTP ${response.status})`) + return data + } catch (error) { + if (error instanceof DOMException && error.name === 'AbortError') throw new Error('请求超时,请稍后重试') + throw error + } finally { + clearTimeout(timeout) + } +} + +async function loadState() { + const data = await requestJson('/api/public/state', { cache: 'no-store' }) + render(data) +} + +function showStateError(error) { + const message = error instanceof Error ? error.message : String(error) + statusLine.textContent = `${message},正在重试…` + rankingBody.innerHTML = '服务暂时不可用,正在重试…' + creditMode.textContent = '连接中断' + button.disabled = true +} + +function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)) } + +async function draw() { + if (drawing) return + drawing = true + button.disabled = true + stage.classList.add('is-drawing') + const phrases = ['锁定活跃用户…', '打乱候选顺序…', '正在碰撞好运…', '马上揭晓!'] + let phraseIndex = 0 + const ticker = setInterval(() => { + statusLine.textContent = phrases[phraseIndex % phrases.length] + phraseIndex += 1 + }, 620) + try { + const request = requestJson('/api/public/draw', { method: 'POST', headers: { 'content-type': 'application/json' } }) + const [, data] = await Promise.all([sleep(2900), request]) + $('#winner-name').textContent = data.record.winnerDisplayName + $('#winner-prize').textContent = money(data.record.prizeAmountUsd, 0) + $('#winner-meta').textContent = `${data.record.eligibleCount} 人等概率 · ${creditLabel(data.record.creditStatus)}` + dialog.showModal() + await loadState() + } catch (error) { + statusLine.textContent = error instanceof Error ? error.message : String(error) + await loadState().catch((stateError) => { + state = null + showStateError(stateError) + setTimeout(boot, 5000) + }) + } finally { + clearInterval(ticker) + drawing = false + stage.classList.remove('is-drawing') + button.disabled = !state || Number(state.remainingDraws) < 1 || Number(state.eligibleUserCount) < 1 + } +} + +button.addEventListener('click', draw) +$('#winner-close').addEventListener('click', () => dialog.close()) +dialog.addEventListener('click', (event) => { if (event.target === dialog) dialog.close() }) + +async function boot() { + try { await loadState() } catch (error) { + showStateError(error) + setTimeout(boot, 5000) + } +} + +boot() + +setInterval(() => { + if (!drawing && !document.hidden) loadState().catch(showStateError) +}, 30000) diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..31e9d6c --- /dev/null +++ b/static/index.html @@ -0,0 +1,87 @@ + + + + + + + Sub2Rank 幸运额度站 + + + + + + + +
    + + S2 + SUB2RANK幸运额度站 + +
    PK01 LIVE
    +

    最近 24 小时活跃用户 · 等概率

    +
    + +
    +
    +
    +

    DAILY CREDIT DRAW / 每日额度抽奖

    +

    今天,
    好运值多少钱?

    +

    任何人都可以按下按钮。开奖瞬间从符合条件的活跃用户中等概率抽出一位。

    +
    +
    本期奖金
    $
    +
    剩余次数
    +
    候选人数
    +
    +
    + +
    + + +
    +
    ONE PRESSONE WINNER
    + +
    正在连接开奖台…
    +
    +

    下次增加 次:

    +
    +
    + +
    +
    +
    +

    01 / USAGE

    用量排行榜

    +
    今日$—
    +
    +
    + + + +
    名次用户用量请求 / Token
    正在载入排行榜…
    +
    +
    + +
    +
    +

    02 / ARCHIVE

    开奖记录

    + 充值状态读取中 +
    +
    1. 暂无开奖记录
    +
    +
    +
    + + + +

    TODAY'S LUCKY USER

    +

    恭喜

    +

    获得 $ 额度

    +

    + +
    + + + + diff --git a/static/styles.css b/static/styles.css new file mode 100644 index 0000000..a6c4921 --- /dev/null +++ b/static/styles.css @@ -0,0 +1,146 @@ +:root { + --ink: #11110f; + --paper: #f0eadb; + --acid: #dfff00; + --signal: #ff4d2e; + --cyan: #60e8df; + --line: rgba(240, 234, 219, 0.19); + --muted: #a8a496; + --serif: "Bodoni 72", "Noto Serif SC", "Songti SC", Georgia, serif; + --sans: "Avenir Next Condensed", "DIN Condensed", "Noto Sans SC", sans-serif; +} + +* { box-sizing: border-box; } +html { background: var(--ink); color: var(--paper); scroll-behavior: smooth; } +body { margin: 0; min-width: 320px; font-family: var(--sans); background: + linear-gradient(90deg, transparent 49.9%, rgba(255,255,255,.026) 50%, transparent 50.1%) 0 0 / 7.5rem 7.5rem, + linear-gradient(rgba(255,255,255,.026) 49.9%, transparent 50.1%) 0 0 / 7.5rem 7.5rem, + radial-gradient(circle at 78% 24%, rgba(255,77,46,.11), transparent 31rem), var(--ink); } +.noise { position: fixed; inset: 0; opacity: .07; pointer-events: none; z-index: 20; background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.5'/%3E%3C/svg%3E"); } + +.topbar { min-height: 5.6rem; display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 1rem; padding: 1rem clamp(1.2rem, 4vw, 4.5rem); border-bottom: 1px solid var(--line); } +.brand { display: flex; align-items: center; gap: .75rem; color: inherit; text-decoration: none; width: fit-content; } +.brand-mark { display: grid; place-items: center; width: 2.55rem; aspect-ratio: 1; background: var(--acid); color: var(--ink); font: 900 1rem/1 var(--sans); clip-path: polygon(50% 0, 100% 20%, 90% 100%, 10% 100%, 0 20%); } +.brand b { display: block; letter-spacing: .16em; font-size: .86rem; } +.brand small { color: var(--muted); font-size: .72rem; } +.live-sign { border: 1px solid var(--line); border-radius: 99px; padding: .48rem .8rem; font-size: .68rem; letter-spacing: .16em; } +.live-sign i { display: inline-block; width: .46rem; height: .46rem; background: var(--signal); border-radius: 50%; margin-right: .42rem; box-shadow: 0 0 0 .25rem rgba(255,77,46,.15); animation: pulse 1.6s infinite; } +.rule-copy { justify-self: end; color: var(--muted); font-size: .78rem; margin: 0; } + +main { width: min(1500px, 100%); margin: 0 auto; padding: 0 clamp(1.2rem, 4vw, 4.5rem) 4rem; } +.hero { min-height: 43rem; display: grid; grid-template-columns: minmax(0, 1.02fr) minmax(25rem, .98fr); align-items: center; gap: clamp(2rem, 7vw, 8rem); border-bottom: 1px solid var(--line); } +.eyebrow, .panel-index { color: var(--acid); letter-spacing: .22em; font-size: .7rem; font-weight: 800; } +h1 { max-width: 14ch; font: 400 clamp(3.8rem, 5.7vw, 7.1rem)/.88 var(--serif); letter-spacing: -.065em; margin: 1rem 0 1.8rem; } +h1 em { color: var(--acid); font-weight: 400; } +.hero-note { max-width: 37rem; color: var(--muted); font: 400 clamp(.9rem, 1.3vw, 1.05rem)/1.75 var(--sans); } +.stats-strip { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); margin: 3rem 0 0; border-block: 1px solid var(--line); } +.stats-strip div { padding: 1.2rem 1rem 1.2rem 0; } +.stats-strip div + div { border-left: 1px solid var(--line); padding-left: 1.2rem; } +.stats-strip dt { color: var(--muted); font-size: .7rem; letter-spacing: .1em; } +.stats-strip dd { margin: .35rem 0 0; font: 500 clamp(1.8rem, 3vw, 2.6rem)/1 var(--serif); } +.stats-strip small, .currency { font: 700 .75rem/1 var(--sans); color: var(--acid); } + +.draw-stage { min-height: 36rem; display: grid; place-items: center; position: relative; isolation: isolate; overflow: clip; } +.draw-machine { width: clamp(18rem, 31vw, 29rem); aspect-ratio: 1; border-radius: 50%; display: grid; place-items: center; position: relative; background: radial-gradient(circle, #373732 0 45%, #171715 46% 58%, #292924 59% 60%, #121210 61%); box-shadow: inset 0 0 2rem #000, 0 3rem 6rem rgba(0,0,0,.55); } +.draw-machine::before { content: ""; position: absolute; inset: 5%; border-radius: 50%; border: 1px dashed rgba(223,255,0,.38); animation: rotate 24s linear infinite; } +.machine-label { position: absolute; inset: 1.5rem 0 auto; display: flex; justify-content: center; gap: 1.3rem; color: var(--muted); letter-spacing: .18em; font-size: .5rem; } +.draw-button { width: 50%; aspect-ratio: 1; border: 0; border-radius: 50%; position: relative; overflow: hidden; cursor: pointer; color: #fff5e9; background: radial-gradient(circle at 36% 26%, #ff9a70, var(--signal) 37%, #c52912 70%); box-shadow: 0 .8rem 0 #76180d, 0 1.4rem 3rem rgba(255,77,46,.32), inset 0 .25rem .35rem rgba(255,255,255,.45); transition: transform .18s, filter .18s, box-shadow .18s; z-index: 2; } +.draw-button:not(:disabled):hover { transform: translateY(-.25rem) scale(1.025); filter: saturate(1.2); } +.draw-button:focus-visible, .winner-dialog button:focus-visible, .brand:focus-visible { outline: .22rem solid var(--cyan); outline-offset: .3rem; } +.draw-button:not(:disabled):active { transform: translateY(.45rem) scale(.98); box-shadow: 0 .25rem 0 #76180d, 0 .6rem 1.6rem rgba(255,77,46,.28), inset 0 .25rem .35rem rgba(255,255,255,.35); } +.draw-button:disabled { cursor: not-allowed; filter: grayscale(.65) brightness(.65); } +.button-glare { position: absolute; inset: 7% 18% 56%; border-radius: 50%; background: rgba(255,255,255,.24); filter: blur(.35rem); } +.button-copy { position: relative; display: grid; gap: .25rem; } +.button-copy b { font-size: clamp(1.35rem, 2.4vw, 2.2rem); letter-spacing: .04em; } +.button-copy small { font-size: .52rem; letter-spacing: .18em; } +.machine-status { position: absolute; bottom: 18%; z-index: 3; width: 70%; text-align: center; color: var(--paper); font-size: .72rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.orbit { position: absolute; border-radius: 50%; pointer-events: none; } +.orbit-one { width: 88%; aspect-ratio: 1; border: 1px solid rgba(96,232,223,.25); transform: rotate(-14deg) scaleY(.38); } +.orbit-two { width: 104%; aspect-ratio: 1; border: 1px solid rgba(223,255,0,.2); transform: rotate(19deg) scaleY(.52); } +.next-grant { position: absolute; bottom: 1.1rem; color: var(--muted); font-size: .72rem; letter-spacing: .04em; } +.next-grant b { color: var(--acid); } +.draw-stage.is-drawing .draw-machine { animation: machineCharge 2.8s cubic-bezier(.32,.02,.26,1); } +.draw-stage.is-drawing .draw-button { animation: buttonCharge .38s alternate infinite; } +.draw-stage.is-drawing .orbit-one { animation: orbitDash .7s linear infinite; border-color: var(--acid); } +.draw-stage.is-drawing .orbit-two { animation: orbitDash .95s linear infinite reverse; border-color: var(--signal); } + +.board-grid { display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(22rem, .65fr); border-inline: 1px solid var(--line); } +.panel { min-width: 0; padding: clamp(1.3rem, 3vw, 2.4rem); } +.panel + .panel { border-left: 1px solid var(--line); } +.panel-head { min-height: 5.5rem; display: flex; justify-content: space-between; align-items: start; gap: 1rem; border-bottom: 1px solid var(--line); } +.panel-head h2 { margin: .3rem 0 1.2rem; font: 400 clamp(1.8rem, 3.2vw, 2.8rem)/1 var(--serif); } +.panel-index { margin: 0; } +.range-chip, .credit-mode { text-align: right; color: var(--muted); font-size: .67rem; letter-spacing: .08em; } +.range-chip span, .range-chip b { display: block; } +.range-chip b { color: var(--paper); font: 400 1.35rem/1.4 var(--serif); } +.credit-mode { border: 1px solid var(--line); padding: .5rem .65rem; } +.table-wrap { overflow-x: auto; } +table { width: 100%; border-collapse: collapse; } +th { color: var(--muted); font-size: .62rem; letter-spacing: .1em; text-align: left; font-weight: 500; padding: .9rem .5rem; } +td { border-top: 1px solid rgba(240,234,219,.1); padding: 1rem .5rem; font-size: .82rem; } +td:first-child { width: 4rem; color: var(--acid); font: 400 1.6rem/1 var(--serif); } +td:nth-child(3) { font: 400 1.1rem/1 var(--serif); } +.usage-detail { display: block; color: var(--muted); font-size: .63rem; margin-top: .3rem; } +tbody tr { transition: background .2s, transform .2s; } +tbody tr:hover { background: rgba(223,255,0,.04); transform: translateX(.2rem); } +.record-list { list-style: none; margin: 0; padding: 0; } +.record-list li { display: grid; grid-template-columns: 2.6rem 1fr auto; gap: .8rem; align-items: center; padding: 1rem 0; border-bottom: 1px solid rgba(240,234,219,.1); } +.record-badge { display: grid; place-items: center; width: 2.5rem; aspect-ratio: 1; border-radius: 50%; border: 1px solid var(--acid); color: var(--acid); font: 700 .6rem/1 var(--sans); } +.record-main b { display: block; font: 400 1.05rem/1.25 var(--serif); } +.record-main small, .record-prize small { color: var(--muted); font-size: .61rem; } +.record-prize { text-align: right; font: 400 1.25rem/1 var(--serif); } +.empty { color: var(--muted) !important; text-align: center !important; padding: 2.6rem 1rem !important; font: .75rem/1.5 var(--sans) !important; } +.record-list li.empty { display: block; } + +.winner-dialog { width: min(38rem, calc(100% - 2rem)); border: 1px solid rgba(223,255,0,.5); color: var(--paper); background: #171714; padding: clamp(2rem, 6vw, 4.5rem); text-align: center; overflow: hidden; box-shadow: 0 2rem 8rem #000; } +.winner-dialog::backdrop { background: rgba(5,5,4,.82); backdrop-filter: blur(.7rem); } +.winner-dialog[open] { animation: reveal .65s cubic-bezier(.18,.85,.3,1.15); } +.winner-kicker { color: var(--acid); font-size: .68rem; letter-spacing: .2em; } +.winner-dialog h2 { font: 400 clamp(2.3rem, 7vw, 4rem)/1 var(--serif); margin: 1rem 0; } +.winner-dialog h2 span { display: block; color: var(--acid); font-size: clamp(1.65rem, 5vw, 3.3rem); overflow-wrap: anywhere; margin-top: .3rem; } +.winner-prize { color: var(--muted); } +.winner-prize b { display: block; color: var(--signal); font: 400 clamp(3.4rem, 10vw, 6rem)/1 var(--serif); margin-top: .5rem; } +.winner-meta { color: var(--muted); font-size: .72rem; min-height: 1em; } +.winner-dialog button { margin-top: 1.5rem; padding: .8rem 1.5rem; border: 0; background: var(--acid); color: var(--ink); font-weight: 800; cursor: pointer; } +.burst { position: absolute; inset: 50%; width: 1px; height: 1px; box-shadow: 0 -12rem 0 1px var(--acid), 8rem -8rem 0 1px var(--signal), 12rem 0 0 1px var(--cyan), 8rem 8rem 0 1px var(--acid), 0 12rem 0 1px var(--signal), -8rem 8rem 0 1px var(--cyan), -12rem 0 0 1px var(--acid), -8rem -8rem 0 1px var(--signal); animation: burst 1.4s ease-out both; } +footer { display: flex; justify-content: space-between; padding: 1.2rem clamp(1.2rem, 4vw, 4.5rem); border-top: 1px solid var(--line); color: var(--muted); font-size: .6rem; letter-spacing: .12em; } + +@keyframes pulse { 50% { opacity: .45; } } +@keyframes rotate { to { transform: rotate(360deg); } } +@keyframes orbitDash { to { rotate: 360deg; } } +@keyframes buttonCharge { to { filter: brightness(1.35) saturate(1.4); transform: scale(1.035); } } +@keyframes machineCharge { 20% { transform: scale(.98); } 55% { transform: scale(1.035); filter: hue-rotate(20deg); } 100% { transform: scale(1); } } +@keyframes reveal { from { opacity: 0; transform: translateY(2rem) scale(.88) rotate(-1deg); } } +@keyframes burst { from { transform: scale(.2) rotate(0); opacity: 1; } to { transform: scale(1.4) rotate(50deg); opacity: 0; } } + +@media (max-width: 960px) { + .topbar { grid-template-columns: 1fr auto; } + .rule-copy { display: none; } + .hero { grid-template-columns: 1fr; padding-block: 4rem 2rem; } + .hero-copy { text-align: center; } + h1, .hero-note { margin-inline: auto; } + .stats-strip { text-align: left; } + .draw-stage { min-height: 31rem; } + .board-grid { grid-template-columns: 1fr; } + .panel + .panel { border-left: 0; border-top: 1px solid var(--line); } +} + +@media (max-width: 560px) { + .topbar { min-height: 4.6rem; padding: .8rem 1rem; } + .live-sign { font-size: .55rem; } + main { padding-inline: 1rem; } + h1 { font-size: 3.4rem; } + .stats-strip dd { font-size: 1.7rem; } + .draw-stage { min-height: 26rem; } + .draw-machine { width: min(21rem, 92vw); } + .board-grid { border-inline: 0; } + .panel { padding-inline: .5rem; } + th:last-child, td:last-child { display: none; } + .record-list li { grid-template-columns: 2.6rem 1fr; } + .record-prize { grid-column: 2; text-align: left; } + footer { gap: 1rem; } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; scroll-behavior: auto !important; } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..25c2250 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noEmit": true, + "types": ["bun"] + }, + "include": ["src/**/*.ts", "scripts/**/*.ts"] +}