Spaces:
Running
Running
fix: build usage keeper from source
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +20 -2
- .env.example +75 -0
- .gitattributes +1 -0
- .github/workflows/binary-dev-release.yml +304 -0
- .github/workflows/binary-release.yml +251 -0
- .github/workflows/ci.yml +76 -0
- .github/workflows/docker-dev-publish.yml +91 -0
- .github/workflows/docker-publish.yml +54 -0
- .gitignore +46 -0
- CONTRIBUTORS.md +7 -0
- Dockerfile +32 -12
- LICENSE +21 -0
- Makefile +16 -0
- README.en.md +223 -0
- README.md +218 -13
- cmd/server/main.go +28 -0
- docker-compose.example.yml +28 -0
- docker-entrypoint.sh +32 -0
- go.mod +43 -0
- go.sum +104 -0
- internal/api/auth.go +199 -0
- internal/api/auth_test.go +225 -0
- internal/api/errors.go +15 -0
- internal/api/health.go +13 -0
- internal/api/pricing.go +145 -0
- internal/api/pricing_test.go +144 -0
- internal/api/router.go +272 -0
- internal/api/router_test.go +321 -0
- internal/api/usage_analysis.go +126 -0
- internal/api/usage_analysis_test.go +128 -0
- internal/api/usage_credentials.go +93 -0
- internal/api/usage_events.go +245 -0
- internal/api/usage_events_test.go +295 -0
- internal/api/usage_filter.go +141 -0
- internal/api/usage_filter_test.go +209 -0
- internal/api/usage_identities.go +125 -0
- internal/api/usage_identities_test.go +144 -0
- internal/api/usage_overview.go +335 -0
- internal/api/usage_overview_test.go +196 -0
- internal/api/usage_resolution.go +17 -0
- internal/api/usage_source_resolution.go +167 -0
- internal/app/app.go +217 -0
- internal/app/app_test.go +239 -0
- internal/app/backup_runner.go +218 -0
- internal/app/backup_runner_test.go +264 -0
- internal/app/maintenance.go +98 -0
- internal/app/maintenance_test.go +107 -0
- internal/app/manual_sync.go +60 -0
- internal/app/manual_sync_test.go +100 -0
- internal/app/metadata_sync.go +74 -0
.dockerignore
CHANGED
|
@@ -1,4 +1,22 @@
|
|
|
|
|
| 1 |
.git
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
.DS_Store
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# git
|
| 2 |
.git
|
| 3 |
+
.gitignore
|
| 4 |
+
|
| 5 |
+
# local env and editor files
|
| 6 |
+
.env
|
| 7 |
+
.env.*
|
| 8 |
+
!.env.example
|
| 9 |
.DS_Store
|
| 10 |
+
|
| 11 |
+
# runtime data
|
| 12 |
+
/data/
|
| 13 |
+
*.db
|
| 14 |
+
*.db-*
|
| 15 |
+
backups/
|
| 16 |
+
|
| 17 |
+
# frontend deps and build cache
|
| 18 |
+
web/node_modules/
|
| 19 |
+
web/dist/
|
| 20 |
+
|
| 21 |
+
# docs / temp
|
| 22 |
+
.tmp-test.db
|
.env.example
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# CPA 服务基础地址。必填:是。默认值:无。
|
| 2 |
+
# Base URL of the CPA service. Required: yes. Default: none.
|
| 3 |
+
CPA_BASE_URL=http://127.0.0.1:8317
|
| 4 |
+
|
| 5 |
+
# CPA management key,用于拉取管理接口数据。必填:是。默认值:无。
|
| 6 |
+
# CPA management key used to access management endpoints. Required: yes. Default: none.
|
| 7 |
+
CPA_MANAGEMENT_KEY=replace-with-your-management-key
|
| 8 |
+
|
| 9 |
+
# 是否启用登录保护。必填:否。默认值:false。
|
| 10 |
+
# Whether to enable login protection. Required: no. Default: false.
|
| 11 |
+
AUTH_ENABLED=false
|
| 12 |
+
|
| 13 |
+
# 登录密码;仅在启用登录保护时必填。默认值:无。
|
| 14 |
+
# Login password; required only when login protection is enabled. Default: none.
|
| 15 |
+
LOGIN_PASSWORD=replace-with-your-login-password
|
| 16 |
+
|
| 17 |
+
# 登录 session 的有效时长。必填:否。默认值:168h。
|
| 18 |
+
# Lifetime of the login session. Required: no. Default: 168h.
|
| 19 |
+
AUTH_SESSION_TTL=168h
|
| 20 |
+
|
| 21 |
+
# HTTP 服务监听端口。必填:否。默认值:8080。
|
| 22 |
+
# HTTP listen port for the application. Required: no. Default: 8080.
|
| 23 |
+
APP_PORT=8080
|
| 24 |
+
|
| 25 |
+
# 留空表示部署在根路径 /;如需部署到子路径,必须以 / 开头,例如 /cpa。允许写成 /cpa/,程序会自动规范成 /cpa;不建议写成不带前导斜杠的 cpa。必填:否。默认值:空(根路径 /)。
|
| 26 |
+
# Leave empty to serve from /. For subpath deployment, the value must start with /, for example /cpa. A trailing slash like /cpa/ is accepted and normalized to /cpa; a value without the leading slash such as cpa is invalid. Required: no. Default: empty (root path /).
|
| 27 |
+
APP_BASE_PATH=
|
| 28 |
+
|
| 29 |
+
# 项目业务时区,影响 Today、按天聚合、每日 03:00 清理和日志时间。必填:否。默认值:Asia/Shanghai。
|
| 30 |
+
# Project business timezone. Affects Today, daily aggregation, daily 03:00 cleanup, and log timestamps. Required: no. Default: Asia/Shanghai.
|
| 31 |
+
TZ=Asia/Shanghai
|
| 32 |
+
|
| 33 |
+
# CPA management data stream 的 Redis/RESP TCP 地址。留空时默认使用 CPA_BASE_URL 的主机名加 8317 端口;如果通过 nginx stream 暴露到其它端口,请显式填写 host:port。
|
| 34 |
+
# Redis/RESP TCP address for the CPA management data stream. When empty, defaults to the CPA_BASE_URL hostname plus port 8317; set host:port explicitly when exposed through nginx stream on another port.
|
| 35 |
+
REDIS_QUEUE_ADDR=
|
| 36 |
+
|
| 37 |
+
# 每次 Redis LPOP 最多拉取的队列记录数。redis/auto 模式会持续 drain,非空批次会立即继续拉取。必填:否。默认值:1000。
|
| 38 |
+
# Maximum queue records per Redis LPOP. redis/auto modes continuously drain and immediately continue after a non-empty batch. Required: no. Default: 1000.
|
| 39 |
+
REDIS_QUEUE_BATCH_SIZE=1000
|
| 40 |
+
|
| 41 |
+
# Redis 队列为空时的 idle 检查间隔。必填:否。默认值:1s。
|
| 42 |
+
# Idle check interval when the Redis queue is empty. Required: no. Default: 1s.
|
| 43 |
+
REDIS_QUEUE_IDLE_INTERVAL=1s
|
| 44 |
+
|
| 45 |
+
# 请求 CPA 接口时的超时时间。必填:否。默认值:30s。
|
| 46 |
+
# Timeout for requests to the CPA service. Required: no. Default: 30s.
|
| 47 |
+
REQUEST_TIMEOUT=30s
|
| 48 |
+
|
| 49 |
+
# 应用工作目录,SQLite 数据库、日志和备份默认都写入这里。Docker 中默认工作目录为 /,因此 ./data 对应 /data;二进制通过 --env 或同目录 .env 加载时,./data 对应 env 文件同目录的 data 子目录。必填:否。默认值:./data。
|
| 50 |
+
# Application work directory. SQLite database, logs, and backups are stored here by default. In Docker the runtime working directory is /, so ./data maps to /data; for binaries loaded with --env or package-local .env, ./data maps to a data directory next to the env file. Required: no. Default: ./data.
|
| 51 |
+
WORK_DIR=./data
|
| 52 |
+
|
| 53 |
+
# 应用日志级别。必填:否。默认值:info。
|
| 54 |
+
# Application log level. Required: no. Default: info.
|
| 55 |
+
LOG_LEVEL=info
|
| 56 |
+
|
| 57 |
+
# 是否写入持久化日志文件。必填:否。默认值:true。
|
| 58 |
+
# Whether to write persistent log files. Required: no. Default: true.
|
| 59 |
+
LOG_FILE_ENABLED=true
|
| 60 |
+
|
| 61 |
+
# 日志文件保留天数;0 表示不自动清理。必填:否。默认值:7。
|
| 62 |
+
# Number of days to retain log files; 0 disables cleanup. Required: no. Default: 7.
|
| 63 |
+
LOG_RETENTION_DAYS=7
|
| 64 |
+
|
| 65 |
+
# 是否启用 SQLite 数据库备份。必填:否。默认值:true。
|
| 66 |
+
# Whether to enable SQLite database backups. Required: no. Default: true.
|
| 67 |
+
BACKUP_ENABLED=true
|
| 68 |
+
|
| 69 |
+
# 数据库备份间隔。必填:否。默认值:24h。
|
| 70 |
+
# Database backup interval. Required: no. Default: 24h.
|
| 71 |
+
BACKUP_INTERVAL=24h
|
| 72 |
+
|
| 73 |
+
# 备份文件保留天数。必填:否。默认值:7。
|
| 74 |
+
# Number of days to retain backup files. Required: no. Default: 7.
|
| 75 |
+
BACKUP_RETENTION_DAYS=7
|
.gitattributes
CHANGED
|
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
web/src/assets/cli-proxy-api-favicon.png filter=lfs diff=lfs merge=lfs -text
|
.github/workflows/binary-dev-release.yml
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Publish dev binaries
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
workflow_dispatch:
|
| 5 |
+
inputs:
|
| 6 |
+
specific_name:
|
| 7 |
+
description: 'Optional binary package name suffix; defaults to the current branch name, for example pr-123 or feature-login-test'
|
| 8 |
+
required: false
|
| 9 |
+
type: string
|
| 10 |
+
|
| 11 |
+
permissions:
|
| 12 |
+
contents: read
|
| 13 |
+
|
| 14 |
+
jobs:
|
| 15 |
+
resolve-dev-name:
|
| 16 |
+
runs-on: ubuntu-24.04
|
| 17 |
+
outputs:
|
| 18 |
+
specific_name: ${{ steps.dev_name.outputs.specific_name }}
|
| 19 |
+
steps:
|
| 20 |
+
- name: Validate branch ref
|
| 21 |
+
if: ${{ !startsWith(github.ref, 'refs/heads/') || github.ref_name == 'main' }}
|
| 22 |
+
run: |
|
| 23 |
+
echo "Manual dev binary publishing must be run from a non-main branch." >&2
|
| 24 |
+
echo "Current ref: $GITHUB_REF" >&2
|
| 25 |
+
exit 1
|
| 26 |
+
|
| 27 |
+
- name: Resolve specific binary package name
|
| 28 |
+
id: dev_name
|
| 29 |
+
env:
|
| 30 |
+
INPUT_NAME: ${{ inputs.specific_name }}
|
| 31 |
+
run: |
|
| 32 |
+
raw_name="${INPUT_NAME:-$GITHUB_REF_NAME}"
|
| 33 |
+
specific_name=$(printf '%s' "$raw_name" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9._-]+/-/g; s/^[^a-z0-9]+//; s/[._-]+$//' | cut -c1-96)
|
| 34 |
+
|
| 35 |
+
if [[ -z "$INPUT_NAME" ]]; then
|
| 36 |
+
case "$specific_name" in
|
| 37 |
+
dev|latest|sha-*)
|
| 38 |
+
specific_name=$(printf '%s-%s' "$specific_name" "${GITHUB_SHA::7}" | cut -c1-96)
|
| 39 |
+
;;
|
| 40 |
+
esac
|
| 41 |
+
fi
|
| 42 |
+
|
| 43 |
+
if [[ ! "$specific_name" =~ ^[a-z0-9][a-z0-9._-]{0,95}$ ]]; then
|
| 44 |
+
echo "Unable to generate a valid binary package name from: $raw_name" >&2
|
| 45 |
+
exit 1
|
| 46 |
+
fi
|
| 47 |
+
|
| 48 |
+
case "$specific_name" in
|
| 49 |
+
dev|latest|sha-*)
|
| 50 |
+
echo "Invalid binary package name: $specific_name is reserved." >&2
|
| 51 |
+
exit 1
|
| 52 |
+
;;
|
| 53 |
+
esac
|
| 54 |
+
|
| 55 |
+
echo "specific_name=$specific_name" >> "$GITHUB_OUTPUT"
|
| 56 |
+
echo "Using specific binary package name: $specific_name"
|
| 57 |
+
|
| 58 |
+
binary-dev-linux:
|
| 59 |
+
runs-on: ubuntu-24.04
|
| 60 |
+
needs: resolve-dev-name
|
| 61 |
+
strategy:
|
| 62 |
+
fail-fast: false
|
| 63 |
+
matrix:
|
| 64 |
+
include:
|
| 65 |
+
- goos: linux
|
| 66 |
+
goarch: amd64
|
| 67 |
+
cc: gcc
|
| 68 |
+
asset_arch: amd64
|
| 69 |
+
- goos: linux
|
| 70 |
+
goarch: arm64
|
| 71 |
+
cc: aarch64-linux-gnu-gcc
|
| 72 |
+
asset_arch: arm64
|
| 73 |
+
steps:
|
| 74 |
+
- name: Checkout repository
|
| 75 |
+
uses: actions/checkout@v6
|
| 76 |
+
|
| 77 |
+
- name: Set up Go
|
| 78 |
+
uses: actions/setup-go@v6
|
| 79 |
+
with:
|
| 80 |
+
go-version: stable
|
| 81 |
+
check-latest: true
|
| 82 |
+
cache: true
|
| 83 |
+
|
| 84 |
+
- name: Set up Node.js
|
| 85 |
+
uses: actions/setup-node@v6
|
| 86 |
+
with:
|
| 87 |
+
node-version: '24'
|
| 88 |
+
cache: npm
|
| 89 |
+
cache-dependency-path: web/package-lock.json
|
| 90 |
+
|
| 91 |
+
- name: Install build dependencies
|
| 92 |
+
run: sudo apt-get update && sudo apt-get install -y build-essential gcc-aarch64-linux-gnu
|
| 93 |
+
|
| 94 |
+
- name: Install frontend dependencies
|
| 95 |
+
run: npm --prefix ./web ci
|
| 96 |
+
|
| 97 |
+
- name: Build frontend
|
| 98 |
+
run: npm --prefix ./web run build
|
| 99 |
+
|
| 100 |
+
- name: Build binary package
|
| 101 |
+
env:
|
| 102 |
+
PACKAGE_SUFFIX: ${{ needs.resolve-dev-name.outputs.specific_name }}
|
| 103 |
+
GOOS: ${{ matrix.goos }}
|
| 104 |
+
GOARCH: ${{ matrix.goarch }}
|
| 105 |
+
CC: ${{ matrix.cc }}
|
| 106 |
+
ASSET_ARCH: ${{ matrix.asset_arch }}
|
| 107 |
+
run: |
|
| 108 |
+
set -euo pipefail
|
| 109 |
+
package_name="cpa-usage-keeper_dev_${PACKAGE_SUFFIX}_${GITHUB_SHA::7}_${GOOS}_${ASSET_ARCH}"
|
| 110 |
+
package_dir="dist/${package_name}"
|
| 111 |
+
mkdir -p "${package_dir}/data"
|
| 112 |
+
|
| 113 |
+
CGO_ENABLED=1 GOOS="${GOOS}" GOARCH="${GOARCH}" CC="${CC}" \
|
| 114 |
+
go build -trimpath -ldflags="-s -w" -o "${package_dir}/cpa-usage-keeper" ./cmd/server/main.go
|
| 115 |
+
cp .env.example README.md README.en.md LICENSE CONTRIBUTORS.md "${package_dir}/"
|
| 116 |
+
tar -czf "dist/${package_name}.tar.gz" -C dist "${package_name}"
|
| 117 |
+
|
| 118 |
+
- name: Upload binary package artifact
|
| 119 |
+
uses: actions/upload-artifact@v6
|
| 120 |
+
with:
|
| 121 |
+
name: cpa-usage-keeper-dev-${{ needs.resolve-dev-name.outputs.specific_name }}-${{ matrix.goos }}-${{ matrix.asset_arch }}
|
| 122 |
+
path: dist/cpa-usage-keeper_dev_${{ needs.resolve-dev-name.outputs.specific_name }}_*_${{ matrix.goos }}_${{ matrix.asset_arch }}.tar.gz
|
| 123 |
+
if-no-files-found: error
|
| 124 |
+
|
| 125 |
+
binary-dev-darwin:
|
| 126 |
+
runs-on: ${{ matrix.runs_on }}
|
| 127 |
+
needs: resolve-dev-name
|
| 128 |
+
strategy:
|
| 129 |
+
fail-fast: false
|
| 130 |
+
matrix:
|
| 131 |
+
include:
|
| 132 |
+
- goos: darwin
|
| 133 |
+
goarch: amd64
|
| 134 |
+
asset_arch: amd64
|
| 135 |
+
runs_on: macos-15-intel
|
| 136 |
+
- goos: darwin
|
| 137 |
+
goarch: arm64
|
| 138 |
+
asset_arch: arm64
|
| 139 |
+
runs_on: macos-15
|
| 140 |
+
steps:
|
| 141 |
+
- name: Checkout repository
|
| 142 |
+
uses: actions/checkout@v6
|
| 143 |
+
|
| 144 |
+
- name: Set up Go
|
| 145 |
+
uses: actions/setup-go@v6
|
| 146 |
+
with:
|
| 147 |
+
go-version: stable
|
| 148 |
+
check-latest: true
|
| 149 |
+
cache: true
|
| 150 |
+
|
| 151 |
+
- name: Set up Node.js
|
| 152 |
+
uses: actions/setup-node@v6
|
| 153 |
+
with:
|
| 154 |
+
node-version: '24'
|
| 155 |
+
cache: npm
|
| 156 |
+
cache-dependency-path: web/package-lock.json
|
| 157 |
+
|
| 158 |
+
- name: Install frontend dependencies
|
| 159 |
+
run: npm --prefix ./web ci
|
| 160 |
+
|
| 161 |
+
- name: Build frontend
|
| 162 |
+
run: npm --prefix ./web run build
|
| 163 |
+
|
| 164 |
+
- name: Build binary package
|
| 165 |
+
env:
|
| 166 |
+
PACKAGE_SUFFIX: ${{ needs.resolve-dev-name.outputs.specific_name }}
|
| 167 |
+
GOOS: ${{ matrix.goos }}
|
| 168 |
+
GOARCH: ${{ matrix.goarch }}
|
| 169 |
+
ASSET_ARCH: ${{ matrix.asset_arch }}
|
| 170 |
+
run: |
|
| 171 |
+
set -euo pipefail
|
| 172 |
+
package_name="cpa-usage-keeper_dev_${PACKAGE_SUFFIX}_${GITHUB_SHA::7}_${GOOS}_${ASSET_ARCH}"
|
| 173 |
+
package_dir="dist/${package_name}"
|
| 174 |
+
mkdir -p "${package_dir}/data"
|
| 175 |
+
|
| 176 |
+
CGO_ENABLED=1 GOOS="${GOOS}" GOARCH="${GOARCH}" \
|
| 177 |
+
go build -trimpath -ldflags="-s -w" -o "${package_dir}/cpa-usage-keeper" ./cmd/server/main.go
|
| 178 |
+
cp .env.example README.md README.en.md LICENSE CONTRIBUTORS.md "${package_dir}/"
|
| 179 |
+
tar -czf "dist/${package_name}.tar.gz" -C dist "${package_name}"
|
| 180 |
+
|
| 181 |
+
- name: Upload binary package artifact
|
| 182 |
+
uses: actions/upload-artifact@v6
|
| 183 |
+
with:
|
| 184 |
+
name: cpa-usage-keeper-dev-${{ needs.resolve-dev-name.outputs.specific_name }}-${{ matrix.goos }}-${{ matrix.asset_arch }}
|
| 185 |
+
path: dist/cpa-usage-keeper_dev_${{ needs.resolve-dev-name.outputs.specific_name }}_*_${{ matrix.goos }}_${{ matrix.asset_arch }}.tar.gz
|
| 186 |
+
if-no-files-found: error
|
| 187 |
+
|
| 188 |
+
binary-dev-windows:
|
| 189 |
+
runs-on: ${{ matrix.runs_on }}
|
| 190 |
+
needs: resolve-dev-name
|
| 191 |
+
strategy:
|
| 192 |
+
fail-fast: false
|
| 193 |
+
matrix:
|
| 194 |
+
include:
|
| 195 |
+
- goarch: amd64
|
| 196 |
+
asset_arch: amd64
|
| 197 |
+
runs_on: windows-2025
|
| 198 |
+
msystem: UCRT64
|
| 199 |
+
install: mingw-w64-ucrt-x86_64-gcc
|
| 200 |
+
cc: gcc
|
| 201 |
+
- goarch: arm64
|
| 202 |
+
asset_arch: arm64
|
| 203 |
+
runs_on: windows-11-arm
|
| 204 |
+
msystem: CLANGARM64
|
| 205 |
+
install: mingw-w64-clang-aarch64-gcc
|
| 206 |
+
cc: gcc
|
| 207 |
+
steps:
|
| 208 |
+
- name: Checkout repository
|
| 209 |
+
uses: actions/checkout@v6
|
| 210 |
+
|
| 211 |
+
- name: Set up Go
|
| 212 |
+
uses: actions/setup-go@v6
|
| 213 |
+
with:
|
| 214 |
+
go-version: stable
|
| 215 |
+
check-latest: true
|
| 216 |
+
cache: true
|
| 217 |
+
|
| 218 |
+
- name: Set up Node.js
|
| 219 |
+
uses: actions/setup-node@v6
|
| 220 |
+
with:
|
| 221 |
+
node-version: '24'
|
| 222 |
+
cache: npm
|
| 223 |
+
cache-dependency-path: web/package-lock.json
|
| 224 |
+
|
| 225 |
+
- name: Set up MSYS2
|
| 226 |
+
uses: msys2/setup-msys2@v2
|
| 227 |
+
with:
|
| 228 |
+
msystem: ${{ matrix.msystem }}
|
| 229 |
+
update: true
|
| 230 |
+
path-type: inherit
|
| 231 |
+
install: ${{ matrix.install }}
|
| 232 |
+
|
| 233 |
+
- name: Install frontend dependencies
|
| 234 |
+
shell: bash
|
| 235 |
+
run: npm --prefix ./web ci
|
| 236 |
+
|
| 237 |
+
- name: Build frontend
|
| 238 |
+
shell: bash
|
| 239 |
+
run: npm --prefix ./web run build
|
| 240 |
+
|
| 241 |
+
- name: Build binary package
|
| 242 |
+
shell: msys2 {0}
|
| 243 |
+
env:
|
| 244 |
+
PACKAGE_SUFFIX: ${{ needs.resolve-dev-name.outputs.specific_name }}
|
| 245 |
+
GOOS: windows
|
| 246 |
+
GOARCH: ${{ matrix.goarch }}
|
| 247 |
+
ASSET_ARCH: ${{ matrix.asset_arch }}
|
| 248 |
+
CC: ${{ matrix.cc }}
|
| 249 |
+
run: |
|
| 250 |
+
set -euo pipefail
|
| 251 |
+
package_name="cpa-usage-keeper_dev_${PACKAGE_SUFFIX}_${GITHUB_SHA::7}_${GOOS}_${ASSET_ARCH}"
|
| 252 |
+
package_dir="dist/${package_name}"
|
| 253 |
+
mkdir -p "${package_dir}/data"
|
| 254 |
+
|
| 255 |
+
CGO_ENABLED=1 GOOS="${GOOS}" GOARCH="${GOARCH}" CC="${CC}" \
|
| 256 |
+
go build -trimpath -ldflags="-s -w" -o "${package_dir}/cpa-usage-keeper.exe" ./cmd/server/main.go
|
| 257 |
+
cp .env.example README.md README.en.md LICENSE CONTRIBUTORS.md "${package_dir}/"
|
| 258 |
+
|
| 259 |
+
- name: Archive binary package
|
| 260 |
+
shell: pwsh
|
| 261 |
+
env:
|
| 262 |
+
PACKAGE_SUFFIX: ${{ needs.resolve-dev-name.outputs.specific_name }}
|
| 263 |
+
ASSET_ARCH: ${{ matrix.asset_arch }}
|
| 264 |
+
run: |
|
| 265 |
+
$shortSha = "${env:GITHUB_SHA}".Substring(0, 7)
|
| 266 |
+
$packageName = "cpa-usage-keeper_dev_${env:PACKAGE_SUFFIX}_${shortSha}_windows_${env:ASSET_ARCH}"
|
| 267 |
+
Compress-Archive -Path "dist/$packageName" -DestinationPath "dist/$packageName.zip"
|
| 268 |
+
|
| 269 |
+
- name: Upload binary package artifact
|
| 270 |
+
uses: actions/upload-artifact@v6
|
| 271 |
+
with:
|
| 272 |
+
name: cpa-usage-keeper-dev-${{ needs.resolve-dev-name.outputs.specific_name }}-windows-${{ matrix.asset_arch }}
|
| 273 |
+
path: dist/cpa-usage-keeper_dev_${{ needs.resolve-dev-name.outputs.specific_name }}_*_windows_${{ matrix.asset_arch }}.zip
|
| 274 |
+
if-no-files-found: error
|
| 275 |
+
|
| 276 |
+
publish-dev-artifacts:
|
| 277 |
+
runs-on: ubuntu-24.04
|
| 278 |
+
needs:
|
| 279 |
+
- resolve-dev-name
|
| 280 |
+
- binary-dev-linux
|
| 281 |
+
- binary-dev-darwin
|
| 282 |
+
- binary-dev-windows
|
| 283 |
+
steps:
|
| 284 |
+
- name: Download binary package artifacts
|
| 285 |
+
uses: actions/download-artifact@v6
|
| 286 |
+
with:
|
| 287 |
+
path: dist
|
| 288 |
+
merge-multiple: true
|
| 289 |
+
|
| 290 |
+
- name: Generate checksums
|
| 291 |
+
run: |
|
| 292 |
+
set -euo pipefail
|
| 293 |
+
cd dist
|
| 294 |
+
sha256sum *.tar.gz *.zip > checksums.txt
|
| 295 |
+
|
| 296 |
+
- name: Upload dev binary packages
|
| 297 |
+
uses: actions/upload-artifact@v6
|
| 298 |
+
with:
|
| 299 |
+
name: cpa-usage-keeper-dev-binaries-${{ github.run_id }}
|
| 300 |
+
path: |
|
| 301 |
+
dist/*.tar.gz
|
| 302 |
+
dist/*.zip
|
| 303 |
+
dist/checksums.txt
|
| 304 |
+
if-no-files-found: error
|
.github/workflows/binary-release.yml
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Publish release binaries
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
tags:
|
| 6 |
+
- 'v*'
|
| 7 |
+
|
| 8 |
+
permissions:
|
| 9 |
+
contents: write
|
| 10 |
+
|
| 11 |
+
jobs:
|
| 12 |
+
binary-release-linux:
|
| 13 |
+
runs-on: ubuntu-24.04
|
| 14 |
+
strategy:
|
| 15 |
+
fail-fast: false
|
| 16 |
+
matrix:
|
| 17 |
+
include:
|
| 18 |
+
- goos: linux
|
| 19 |
+
goarch: amd64
|
| 20 |
+
cc: gcc
|
| 21 |
+
asset_arch: amd64
|
| 22 |
+
- goos: linux
|
| 23 |
+
goarch: arm64
|
| 24 |
+
cc: aarch64-linux-gnu-gcc
|
| 25 |
+
asset_arch: arm64
|
| 26 |
+
steps:
|
| 27 |
+
- name: Checkout repository
|
| 28 |
+
uses: actions/checkout@v6
|
| 29 |
+
|
| 30 |
+
- name: Set up Go
|
| 31 |
+
uses: actions/setup-go@v6
|
| 32 |
+
with:
|
| 33 |
+
go-version: stable
|
| 34 |
+
check-latest: true
|
| 35 |
+
cache: true
|
| 36 |
+
|
| 37 |
+
- name: Set up Node.js
|
| 38 |
+
uses: actions/setup-node@v6
|
| 39 |
+
with:
|
| 40 |
+
node-version: '24'
|
| 41 |
+
cache: npm
|
| 42 |
+
cache-dependency-path: web/package-lock.json
|
| 43 |
+
|
| 44 |
+
- name: Install build dependencies
|
| 45 |
+
run: sudo apt-get update && sudo apt-get install -y build-essential gcc-aarch64-linux-gnu
|
| 46 |
+
|
| 47 |
+
- name: Install frontend dependencies
|
| 48 |
+
run: npm --prefix ./web ci
|
| 49 |
+
|
| 50 |
+
- name: Build frontend
|
| 51 |
+
run: npm --prefix ./web run build
|
| 52 |
+
|
| 53 |
+
- name: Build binary package
|
| 54 |
+
env:
|
| 55 |
+
VERSION: ${{ github.ref_name }}
|
| 56 |
+
GOOS: ${{ matrix.goos }}
|
| 57 |
+
GOARCH: ${{ matrix.goarch }}
|
| 58 |
+
CC: ${{ matrix.cc }}
|
| 59 |
+
ASSET_ARCH: ${{ matrix.asset_arch }}
|
| 60 |
+
run: |
|
| 61 |
+
set -euo pipefail
|
| 62 |
+
package_name="cpa-usage-keeper_${VERSION}_${GOOS}_${ASSET_ARCH}"
|
| 63 |
+
package_dir="dist/${package_name}"
|
| 64 |
+
mkdir -p "${package_dir}/data"
|
| 65 |
+
|
| 66 |
+
CGO_ENABLED=1 GOOS="${GOOS}" GOARCH="${GOARCH}" CC="${CC}" \
|
| 67 |
+
go build -trimpath -ldflags="-s -w" -o "${package_dir}/cpa-usage-keeper" ./cmd/server/main.go
|
| 68 |
+
cp .env.example README.md README.en.md LICENSE CONTRIBUTORS.md "${package_dir}/"
|
| 69 |
+
tar -czf "dist/${package_name}.tar.gz" -C dist "${package_name}"
|
| 70 |
+
|
| 71 |
+
- name: Upload binary package artifact
|
| 72 |
+
uses: actions/upload-artifact@v6
|
| 73 |
+
with:
|
| 74 |
+
name: cpa-usage-keeper-${{ github.ref_name }}-${{ matrix.goos }}-${{ matrix.asset_arch }}
|
| 75 |
+
path: dist/cpa-usage-keeper_${{ github.ref_name }}_${{ matrix.goos }}_${{ matrix.asset_arch }}.tar.gz
|
| 76 |
+
if-no-files-found: error
|
| 77 |
+
|
| 78 |
+
binary-release-darwin:
|
| 79 |
+
runs-on: ${{ matrix.runs_on }}
|
| 80 |
+
strategy:
|
| 81 |
+
fail-fast: false
|
| 82 |
+
matrix:
|
| 83 |
+
include:
|
| 84 |
+
- goos: darwin
|
| 85 |
+
goarch: amd64
|
| 86 |
+
asset_arch: amd64
|
| 87 |
+
runs_on: macos-15-intel
|
| 88 |
+
- goos: darwin
|
| 89 |
+
goarch: arm64
|
| 90 |
+
asset_arch: arm64
|
| 91 |
+
runs_on: macos-15
|
| 92 |
+
steps:
|
| 93 |
+
- name: Checkout repository
|
| 94 |
+
uses: actions/checkout@v6
|
| 95 |
+
|
| 96 |
+
- name: Set up Go
|
| 97 |
+
uses: actions/setup-go@v6
|
| 98 |
+
with:
|
| 99 |
+
go-version: stable
|
| 100 |
+
check-latest: true
|
| 101 |
+
cache: true
|
| 102 |
+
|
| 103 |
+
- name: Set up Node.js
|
| 104 |
+
uses: actions/setup-node@v6
|
| 105 |
+
with:
|
| 106 |
+
node-version: '24'
|
| 107 |
+
cache: npm
|
| 108 |
+
cache-dependency-path: web/package-lock.json
|
| 109 |
+
|
| 110 |
+
- name: Install frontend dependencies
|
| 111 |
+
run: npm --prefix ./web ci
|
| 112 |
+
|
| 113 |
+
- name: Build frontend
|
| 114 |
+
run: npm --prefix ./web run build
|
| 115 |
+
|
| 116 |
+
- name: Build binary package
|
| 117 |
+
env:
|
| 118 |
+
VERSION: ${{ github.ref_name }}
|
| 119 |
+
GOOS: ${{ matrix.goos }}
|
| 120 |
+
GOARCH: ${{ matrix.goarch }}
|
| 121 |
+
ASSET_ARCH: ${{ matrix.asset_arch }}
|
| 122 |
+
run: |
|
| 123 |
+
set -euo pipefail
|
| 124 |
+
package_name="cpa-usage-keeper_${VERSION}_${GOOS}_${ASSET_ARCH}"
|
| 125 |
+
package_dir="dist/${package_name}"
|
| 126 |
+
mkdir -p "${package_dir}/data"
|
| 127 |
+
|
| 128 |
+
CGO_ENABLED=1 GOOS="${GOOS}" GOARCH="${GOARCH}" \
|
| 129 |
+
go build -trimpath -ldflags="-s -w" -o "${package_dir}/cpa-usage-keeper" ./cmd/server/main.go
|
| 130 |
+
cp .env.example README.md README.en.md LICENSE CONTRIBUTORS.md "${package_dir}/"
|
| 131 |
+
tar -czf "dist/${package_name}.tar.gz" -C dist "${package_name}"
|
| 132 |
+
|
| 133 |
+
- name: Upload binary package artifact
|
| 134 |
+
uses: actions/upload-artifact@v6
|
| 135 |
+
with:
|
| 136 |
+
name: cpa-usage-keeper-${{ github.ref_name }}-${{ matrix.goos }}-${{ matrix.asset_arch }}
|
| 137 |
+
path: dist/cpa-usage-keeper_${{ github.ref_name }}_${{ matrix.goos }}_${{ matrix.asset_arch }}.tar.gz
|
| 138 |
+
if-no-files-found: error
|
| 139 |
+
|
| 140 |
+
binary-release-windows:
|
| 141 |
+
runs-on: ${{ matrix.runs_on }}
|
| 142 |
+
strategy:
|
| 143 |
+
fail-fast: false
|
| 144 |
+
matrix:
|
| 145 |
+
include:
|
| 146 |
+
- goarch: amd64
|
| 147 |
+
asset_arch: amd64
|
| 148 |
+
runs_on: windows-2025
|
| 149 |
+
msystem: UCRT64
|
| 150 |
+
install: mingw-w64-ucrt-x86_64-gcc
|
| 151 |
+
cc: gcc
|
| 152 |
+
- goarch: arm64
|
| 153 |
+
asset_arch: arm64
|
| 154 |
+
runs_on: windows-11-arm
|
| 155 |
+
msystem: CLANGARM64
|
| 156 |
+
install: mingw-w64-clang-aarch64-gcc
|
| 157 |
+
cc: gcc
|
| 158 |
+
steps:
|
| 159 |
+
- name: Checkout repository
|
| 160 |
+
uses: actions/checkout@v6
|
| 161 |
+
|
| 162 |
+
- name: Set up Go
|
| 163 |
+
uses: actions/setup-go@v6
|
| 164 |
+
with:
|
| 165 |
+
go-version: stable
|
| 166 |
+
check-latest: true
|
| 167 |
+
cache: true
|
| 168 |
+
|
| 169 |
+
- name: Set up Node.js
|
| 170 |
+
uses: actions/setup-node@v6
|
| 171 |
+
with:
|
| 172 |
+
node-version: '24'
|
| 173 |
+
cache: npm
|
| 174 |
+
cache-dependency-path: web/package-lock.json
|
| 175 |
+
|
| 176 |
+
- name: Set up MSYS2
|
| 177 |
+
uses: msys2/setup-msys2@v2
|
| 178 |
+
with:
|
| 179 |
+
msystem: ${{ matrix.msystem }}
|
| 180 |
+
update: true
|
| 181 |
+
path-type: inherit
|
| 182 |
+
install: ${{ matrix.install }}
|
| 183 |
+
|
| 184 |
+
- name: Install frontend dependencies
|
| 185 |
+
shell: bash
|
| 186 |
+
run: npm --prefix ./web ci
|
| 187 |
+
|
| 188 |
+
- name: Build frontend
|
| 189 |
+
shell: bash
|
| 190 |
+
run: npm --prefix ./web run build
|
| 191 |
+
|
| 192 |
+
- name: Build binary package
|
| 193 |
+
shell: msys2 {0}
|
| 194 |
+
env:
|
| 195 |
+
VERSION: ${{ github.ref_name }}
|
| 196 |
+
GOOS: windows
|
| 197 |
+
GOARCH: ${{ matrix.goarch }}
|
| 198 |
+
ASSET_ARCH: ${{ matrix.asset_arch }}
|
| 199 |
+
CC: ${{ matrix.cc }}
|
| 200 |
+
run: |
|
| 201 |
+
set -euo pipefail
|
| 202 |
+
package_name="cpa-usage-keeper_${VERSION}_${GOOS}_${ASSET_ARCH}"
|
| 203 |
+
package_dir="dist/${package_name}"
|
| 204 |
+
mkdir -p "${package_dir}/data"
|
| 205 |
+
|
| 206 |
+
CGO_ENABLED=1 GOOS="${GOOS}" GOARCH="${GOARCH}" CC="${CC}" \
|
| 207 |
+
go build -trimpath -ldflags="-s -w" -o "${package_dir}/cpa-usage-keeper.exe" ./cmd/server/main.go
|
| 208 |
+
cp .env.example README.md README.en.md LICENSE CONTRIBUTORS.md "${package_dir}/"
|
| 209 |
+
|
| 210 |
+
- name: Archive binary package
|
| 211 |
+
shell: pwsh
|
| 212 |
+
env:
|
| 213 |
+
VERSION: ${{ github.ref_name }}
|
| 214 |
+
ASSET_ARCH: ${{ matrix.asset_arch }}
|
| 215 |
+
run: |
|
| 216 |
+
$packageName = "cpa-usage-keeper_${env:VERSION}_windows_${env:ASSET_ARCH}"
|
| 217 |
+
Compress-Archive -Path "dist/$packageName" -DestinationPath "dist/$packageName.zip"
|
| 218 |
+
|
| 219 |
+
- name: Upload binary package artifact
|
| 220 |
+
uses: actions/upload-artifact@v6
|
| 221 |
+
with:
|
| 222 |
+
name: cpa-usage-keeper-${{ github.ref_name }}-windows-${{ matrix.asset_arch }}
|
| 223 |
+
path: dist/cpa-usage-keeper_${{ github.ref_name }}_windows_${{ matrix.asset_arch }}.zip
|
| 224 |
+
if-no-files-found: error
|
| 225 |
+
|
| 226 |
+
publish-release:
|
| 227 |
+
runs-on: ubuntu-24.04
|
| 228 |
+
needs:
|
| 229 |
+
- binary-release-linux
|
| 230 |
+
- binary-release-darwin
|
| 231 |
+
- binary-release-windows
|
| 232 |
+
steps:
|
| 233 |
+
- name: Download binary package artifacts
|
| 234 |
+
uses: actions/download-artifact@v6
|
| 235 |
+
with:
|
| 236 |
+
path: dist
|
| 237 |
+
merge-multiple: true
|
| 238 |
+
|
| 239 |
+
- name: Generate checksums
|
| 240 |
+
run: |
|
| 241 |
+
set -euo pipefail
|
| 242 |
+
cd dist
|
| 243 |
+
sha256sum *.tar.gz *.zip > checksums.txt
|
| 244 |
+
|
| 245 |
+
- name: Upload release assets
|
| 246 |
+
uses: softprops/action-gh-release@v3
|
| 247 |
+
with:
|
| 248 |
+
files: |
|
| 249 |
+
dist/*.tar.gz
|
| 250 |
+
dist/*.zip
|
| 251 |
+
dist/checksums.txt
|
.github/workflows/ci.yml
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: CI
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
pull_request:
|
| 5 |
+
push:
|
| 6 |
+
branches:
|
| 7 |
+
- main
|
| 8 |
+
|
| 9 |
+
permissions:
|
| 10 |
+
contents: read
|
| 11 |
+
|
| 12 |
+
jobs:
|
| 13 |
+
backend:
|
| 14 |
+
runs-on: ${{ matrix.runs-on }}
|
| 15 |
+
strategy:
|
| 16 |
+
fail-fast: false
|
| 17 |
+
matrix:
|
| 18 |
+
runs-on:
|
| 19 |
+
- ubuntu-latest
|
| 20 |
+
- macos-latest
|
| 21 |
+
- windows-latest
|
| 22 |
+
steps:
|
| 23 |
+
- name: Checkout repository
|
| 24 |
+
uses: actions/checkout@v6
|
| 25 |
+
|
| 26 |
+
- name: Set up Go
|
| 27 |
+
uses: actions/setup-go@v6
|
| 28 |
+
with:
|
| 29 |
+
go-version: '1.22'
|
| 30 |
+
cache: true
|
| 31 |
+
|
| 32 |
+
- name: Set up MSYS2
|
| 33 |
+
if: ${{ runner.os == 'Windows' }}
|
| 34 |
+
uses: msys2/setup-msys2@v2
|
| 35 |
+
with:
|
| 36 |
+
msystem: UCRT64
|
| 37 |
+
update: true
|
| 38 |
+
path-type: inherit
|
| 39 |
+
install: mingw-w64-ucrt-x86_64-gcc
|
| 40 |
+
|
| 41 |
+
- name: Run backend tests
|
| 42 |
+
if: ${{ runner.os != 'Windows' }}
|
| 43 |
+
run: go test ./cmd/... ./internal/...
|
| 44 |
+
|
| 45 |
+
- name: Run backend tests
|
| 46 |
+
if: ${{ runner.os == 'Windows' }}
|
| 47 |
+
shell: msys2 {0}
|
| 48 |
+
run: go test ./cmd/... ./internal/...
|
| 49 |
+
|
| 50 |
+
frontend:
|
| 51 |
+
runs-on: ubuntu-latest
|
| 52 |
+
steps:
|
| 53 |
+
- name: Checkout repository
|
| 54 |
+
uses: actions/checkout@v6
|
| 55 |
+
|
| 56 |
+
- name: Set up Node.js
|
| 57 |
+
uses: actions/setup-node@v6
|
| 58 |
+
with:
|
| 59 |
+
node-version: '22'
|
| 60 |
+
cache: npm
|
| 61 |
+
cache-dependency-path: web/package-lock.json
|
| 62 |
+
|
| 63 |
+
- name: Install frontend dependencies
|
| 64 |
+
run: npm --prefix ./web ci
|
| 65 |
+
|
| 66 |
+
- name: Run frontend tests
|
| 67 |
+
run: npm --prefix ./web run test
|
| 68 |
+
|
| 69 |
+
- name: Run frontend lint
|
| 70 |
+
run: npm --prefix ./web run lint
|
| 71 |
+
|
| 72 |
+
- name: Run frontend typecheck
|
| 73 |
+
run: npm --prefix ./web run typecheck
|
| 74 |
+
|
| 75 |
+
- name: Build frontend
|
| 76 |
+
run: npm --prefix ./web run build
|
.github/workflows/docker-dev-publish.yml
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Publish dev Docker image
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
workflow_dispatch:
|
| 5 |
+
inputs:
|
| 6 |
+
specific_tag:
|
| 7 |
+
description: 'Optional Docker tag to publish; defaults to the current branch name, for example pr-123 or feature-login-test'
|
| 8 |
+
required: false
|
| 9 |
+
type: string
|
| 10 |
+
|
| 11 |
+
permissions:
|
| 12 |
+
contents: read
|
| 13 |
+
packages: write
|
| 14 |
+
|
| 15 |
+
jobs:
|
| 16 |
+
docker-dev:
|
| 17 |
+
runs-on: ubuntu-latest
|
| 18 |
+
steps:
|
| 19 |
+
- name: Validate branch ref
|
| 20 |
+
if: ${{ !startsWith(github.ref, 'refs/heads/') || github.ref_name == 'main' }}
|
| 21 |
+
run: |
|
| 22 |
+
echo "Manual dev Docker publishing must be run from a non-main branch." >&2
|
| 23 |
+
echo "Current ref: $GITHUB_REF" >&2
|
| 24 |
+
exit 1
|
| 25 |
+
|
| 26 |
+
- name: Resolve specific Docker tag
|
| 27 |
+
id: dev_tag
|
| 28 |
+
env:
|
| 29 |
+
INPUT_TAG: ${{ inputs.specific_tag }}
|
| 30 |
+
run: |
|
| 31 |
+
raw_tag="${INPUT_TAG:-$GITHUB_REF_NAME}"
|
| 32 |
+
specific_tag=$(printf '%s' "$raw_tag" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9._-]+/-/g; s/^[^a-z0-9]+//; s/[._-]+$//' | cut -c1-128)
|
| 33 |
+
|
| 34 |
+
if [[ -z "$INPUT_TAG" ]]; then
|
| 35 |
+
case "$specific_tag" in
|
| 36 |
+
dev|latest|sha-*)
|
| 37 |
+
specific_tag=$(printf '%s-%s' "$specific_tag" "${GITHUB_SHA::7}" | cut -c1-128)
|
| 38 |
+
;;
|
| 39 |
+
esac
|
| 40 |
+
fi
|
| 41 |
+
|
| 42 |
+
if [[ ! "$specific_tag" =~ ^[a-z0-9][a-z0-9._-]{0,127}$ ]]; then
|
| 43 |
+
echo "Unable to generate a valid Docker tag from: $raw_tag" >&2
|
| 44 |
+
exit 1
|
| 45 |
+
fi
|
| 46 |
+
|
| 47 |
+
case "$specific_tag" in
|
| 48 |
+
dev|latest|sha-*)
|
| 49 |
+
echo "Invalid Docker tag: $specific_tag is reserved." >&2
|
| 50 |
+
exit 1
|
| 51 |
+
;;
|
| 52 |
+
esac
|
| 53 |
+
|
| 54 |
+
echo "specific_tag=$specific_tag" >> "$GITHUB_OUTPUT"
|
| 55 |
+
echo "Using specific Docker tag: $specific_tag"
|
| 56 |
+
|
| 57 |
+
- name: Checkout repository
|
| 58 |
+
uses: actions/checkout@v6
|
| 59 |
+
|
| 60 |
+
- name: Set up Docker Buildx
|
| 61 |
+
uses: docker/setup-buildx-action@v4
|
| 62 |
+
|
| 63 |
+
- name: Convert image name to lowercase
|
| 64 |
+
id: repo
|
| 65 |
+
run: echo "image=ghcr.io/${GITHUB_REPOSITORY@L}" >> "$GITHUB_OUTPUT"
|
| 66 |
+
|
| 67 |
+
- name: Generate Docker metadata
|
| 68 |
+
id: meta
|
| 69 |
+
uses: docker/metadata-action@v6
|
| 70 |
+
with:
|
| 71 |
+
images: ${{ steps.repo.outputs.image }}
|
| 72 |
+
tags: |
|
| 73 |
+
type=raw,value=dev
|
| 74 |
+
type=raw,value=${{ steps.dev_tag.outputs.specific_tag }}
|
| 75 |
+
|
| 76 |
+
- name: Log in to GitHub Container Registry
|
| 77 |
+
uses: docker/login-action@v4
|
| 78 |
+
with:
|
| 79 |
+
registry: ghcr.io
|
| 80 |
+
username: ${{ github.actor }}
|
| 81 |
+
password: ${{ secrets.GITHUB_TOKEN }}
|
| 82 |
+
|
| 83 |
+
- name: Build and push Docker image
|
| 84 |
+
uses: docker/build-push-action@v7
|
| 85 |
+
with:
|
| 86 |
+
context: .
|
| 87 |
+
file: ./Dockerfile
|
| 88 |
+
platforms: linux/amd64,linux/arm64
|
| 89 |
+
push: true
|
| 90 |
+
tags: ${{ steps.meta.outputs.tags }}
|
| 91 |
+
labels: ${{ steps.meta.outputs.labels }}
|
.github/workflows/docker-publish.yml
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Publish release Docker image
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
tags:
|
| 6 |
+
- 'v*'
|
| 7 |
+
|
| 8 |
+
permissions:
|
| 9 |
+
contents: read
|
| 10 |
+
packages: write
|
| 11 |
+
|
| 12 |
+
jobs:
|
| 13 |
+
docker:
|
| 14 |
+
runs-on: ubuntu-latest
|
| 15 |
+
steps:
|
| 16 |
+
- name: Checkout repository
|
| 17 |
+
uses: actions/checkout@v6
|
| 18 |
+
|
| 19 |
+
- name: Set up Docker Buildx
|
| 20 |
+
uses: docker/setup-buildx-action@v4
|
| 21 |
+
|
| 22 |
+
- name: Convert image name to lowercase
|
| 23 |
+
id: repo
|
| 24 |
+
run: echo "image=ghcr.io/${GITHUB_REPOSITORY@L}" >> "$GITHUB_OUTPUT"
|
| 25 |
+
|
| 26 |
+
- name: Generate Docker metadata
|
| 27 |
+
id: meta
|
| 28 |
+
uses: docker/metadata-action@v6
|
| 29 |
+
with:
|
| 30 |
+
images: ${{ steps.repo.outputs.image }}
|
| 31 |
+
tags: |
|
| 32 |
+
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
| 33 |
+
type=sha,prefix=sha-
|
| 34 |
+
type=ref,event=tag,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
| 35 |
+
type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
| 36 |
+
type=semver,pattern={{major}}.{{minor}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
| 37 |
+
type=semver,pattern={{major}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
| 38 |
+
|
| 39 |
+
- name: Log in to GitHub Container Registry
|
| 40 |
+
uses: docker/login-action@v4
|
| 41 |
+
with:
|
| 42 |
+
registry: ghcr.io
|
| 43 |
+
username: ${{ github.actor }}
|
| 44 |
+
password: ${{ secrets.GITHUB_TOKEN }}
|
| 45 |
+
|
| 46 |
+
- name: Build and push Docker image
|
| 47 |
+
uses: docker/build-push-action@v7
|
| 48 |
+
with:
|
| 49 |
+
context: .
|
| 50 |
+
file: ./Dockerfile
|
| 51 |
+
platforms: linux/amd64,linux/arm64
|
| 52 |
+
push: true
|
| 53 |
+
tags: ${{ steps.meta.outputs.tags }}
|
| 54 |
+
labels: ${{ steps.meta.outputs.labels }}
|
.gitignore
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# internal docs
|
| 2 |
+
.dev-docs/
|
| 3 |
+
.claude/
|
| 4 |
+
.worktrees/
|
| 5 |
+
|
| 6 |
+
# local environment files
|
| 7 |
+
.env
|
| 8 |
+
.env.*
|
| 9 |
+
!.env.example
|
| 10 |
+
!deploy/.env.example
|
| 11 |
+
|
| 12 |
+
# runtime data
|
| 13 |
+
/data/
|
| 14 |
+
/test/
|
| 15 |
+
*.db
|
| 16 |
+
*.db-*
|
| 17 |
+
.tmp-test.db
|
| 18 |
+
backups/
|
| 19 |
+
.runtime-test/
|
| 20 |
+
|
| 21 |
+
# frontend dependencies and build output
|
| 22 |
+
web/node_modules/
|
| 23 |
+
web/dist/
|
| 24 |
+
web/dist-ssr/
|
| 25 |
+
|
| 26 |
+
# logs
|
| 27 |
+
logs/
|
| 28 |
+
*.log
|
| 29 |
+
npm-debug.log*
|
| 30 |
+
yarn-debug.log*
|
| 31 |
+
yarn-error.log*
|
| 32 |
+
pnpm-debug.log*
|
| 33 |
+
lerna-debug.log*
|
| 34 |
+
|
| 35 |
+
# local overrides
|
| 36 |
+
*.local
|
| 37 |
+
|
| 38 |
+
# editor and OS files
|
| 39 |
+
.DS_Store
|
| 40 |
+
.vscode/
|
| 41 |
+
.idea/
|
| 42 |
+
*.suo
|
| 43 |
+
*.ntvs*
|
| 44 |
+
*.njsproj
|
| 45 |
+
*.sln
|
| 46 |
+
*.sw?
|
CONTRIBUTORS.md
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Contributors
|
| 2 |
+
|
| 3 |
+
This project is made possible by the work and ideas of the people below.
|
| 4 |
+
|
| 5 |
+
## Acknowledgements
|
| 6 |
+
|
| 7 |
+
- [luispater](https://github.com/luispater) — Author of CPA, the upstream project that this usage keeper integrates with.
|
Dockerfile
CHANGED
|
@@ -1,16 +1,36 @@
|
|
| 1 |
-
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
LOG_FILE_ENABLED=true \
|
| 10 |
-
BACKUP_ENABLED=true
|
| 11 |
|
| 12 |
-
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
CMD ["/app/cpa-usage-keeper"]
|
|
|
|
| 1 |
+
# syntax=docker/dockerfile:1
|
| 2 |
|
| 3 |
+
FROM node:22-alpine AS web-builder
|
| 4 |
+
WORKDIR /app/web
|
| 5 |
+
COPY web/package.json web/package-lock.json ./
|
| 6 |
+
RUN npm ci
|
| 7 |
+
COPY web/ ./
|
| 8 |
+
RUN npm run build
|
|
|
|
|
|
|
| 9 |
|
| 10 |
+
FROM golang:1.22-alpine AS go-builder
|
| 11 |
+
WORKDIR /app
|
| 12 |
+
RUN apk add --no-cache build-base
|
| 13 |
+
COPY go.mod go.sum ./
|
| 14 |
+
RUN go mod download
|
| 15 |
+
COPY cmd/ ./cmd/
|
| 16 |
+
COPY internal/ ./internal/
|
| 17 |
+
COPY --from=web-builder /app/web/dist ./web/dist
|
| 18 |
+
COPY web/static.go ./web/static.go
|
| 19 |
+
RUN CGO_ENABLED=1 GOOS=linux go build -o /out/cpa-usage-keeper ./cmd/server/main.go
|
| 20 |
|
| 21 |
+
FROM alpine:3.20
|
| 22 |
+
WORKDIR /
|
| 23 |
+
RUN apk add --no-cache ca-certificates tzdata su-exec \
|
| 24 |
+
&& addgroup -S app \
|
| 25 |
+
&& adduser -S -G app app \
|
| 26 |
+
&& mkdir -p /data \
|
| 27 |
+
&& chown -R app:app /data
|
| 28 |
+
COPY --from=go-builder /out/cpa-usage-keeper /app/cpa-usage-keeper
|
| 29 |
+
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
| 30 |
+
RUN sed -i 's/\r$//' /usr/local/bin/docker-entrypoint.sh \
|
| 31 |
+
&& chmod +x /usr/local/bin/docker-entrypoint.sh
|
| 32 |
+
VOLUME ["/data"]
|
| 33 |
+
EXPOSE 8080
|
| 34 |
+
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 CMD wget -q --spider "http://127.0.0.1:${APP_PORT:-8080}${APP_BASE_PATH:-}/healthz" || exit 1
|
| 35 |
+
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
| 36 |
CMD ["/app/cpa-usage-keeper"]
|
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2026 Will
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
Makefile
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.PHONY: verify verify-backend verify-frontend verify-docker
|
| 2 |
+
|
| 3 |
+
verify: verify-backend verify-frontend
|
| 4 |
+
|
| 5 |
+
verify-backend:
|
| 6 |
+
go test ./cmd/... ./internal/...
|
| 7 |
+
|
| 8 |
+
verify-frontend:
|
| 9 |
+
npm --prefix ./web ci
|
| 10 |
+
npm --prefix ./web run test
|
| 11 |
+
npm --prefix ./web run lint
|
| 12 |
+
npm --prefix ./web run typecheck
|
| 13 |
+
npm --prefix ./web run build
|
| 14 |
+
|
| 15 |
+
verify-docker:
|
| 16 |
+
docker build -t cpa-usage-keeper:ci .
|
README.en.md
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# CPA Usage Keeper
|
| 2 |
+
|
| 3 |
+
[中文说明](./README.md)
|
| 4 |
+
|
| 5 |
+
CPA Usage Keeper is a standalone CPA usage persistence and dashboard service.
|
| 6 |
+
|
| 7 |
+
It relies on [CLIProxyAPI (CPA)](https://github.com/router-for-me/CLIProxyAPI) as the backend CPA data source and adds persistent storage and statistical analysis capabilities on top of CPA. The service consumes events from the CPA Redis usage queue into SQLite, periodically pulls CPA metadata, exposes aggregation APIs, and serves a built-in web dashboard for usage, pricing, request health, and model/API statistics.
|
| 8 |
+
|
| 9 |
+

|
| 10 |
+
|
| 11 |
+
## Features
|
| 12 |
+
|
| 13 |
+
- CPA usage persistence in SQLite
|
| 14 |
+
- Aggregated usage and pricing APIs
|
| 15 |
+
- Built-in React dashboard
|
| 16 |
+
- Optional password login protection
|
| 17 |
+
- Local SQLite database backups with retention
|
| 18 |
+
- Docker / Docker Compose deployment
|
| 19 |
+
|
| 20 |
+
## Project Structure
|
| 21 |
+
|
| 22 |
+
```text
|
| 23 |
+
cmd/ Application entrypoint
|
| 24 |
+
internal/api/ HTTP routes and handlers
|
| 25 |
+
internal/app/ App wiring and startup
|
| 26 |
+
internal/auth/ In-memory session auth
|
| 27 |
+
internal/backup/ SQLite database backup management
|
| 28 |
+
internal/config/ Environment config loading
|
| 29 |
+
internal/cpa/ CPA client and types
|
| 30 |
+
internal/models/ GORM models
|
| 31 |
+
internal/poller/ Background sync loop
|
| 32 |
+
internal/repository/ SQLite access and aggregations
|
| 33 |
+
internal/service/ Sync, usage, and pricing services
|
| 34 |
+
web/ React + TypeScript frontend
|
| 35 |
+
```
|
| 36 |
+
|
| 37 |
+
## Configuration
|
| 38 |
+
|
| 39 |
+
Copy the example config:
|
| 40 |
+
|
| 41 |
+
```bash
|
| 42 |
+
cp .env.example .env
|
| 43 |
+
```
|
| 44 |
+
|
| 45 |
+
| Variable | Required | Default | Description |
|
| 46 |
+
| --- | --- | --- | --- |
|
| 47 |
+
| `CPA_BASE_URL` | Yes | - | CPA server URL |
|
| 48 |
+
| `CPA_MANAGEMENT_KEY` | Yes | - | CPA management key |
|
| 49 |
+
| `AUTH_ENABLED` | No | `false` | Enable login protection |
|
| 50 |
+
| `LOGIN_PASSWORD` | When auth is enabled | - | Login password |
|
| 51 |
+
| `AUTH_SESSION_TTL` | No | `168h` | Session lifetime |
|
| 52 |
+
| `APP_PORT` | No | `8080` | HTTP listen port |
|
| 53 |
+
| `APP_BASE_PATH` | No | root path | Subpath prefix such as `/cpa`; empty means `/` |
|
| 54 |
+
| `TZ` | No | `Asia/Shanghai` | Project business timezone; affects Today, daily aggregation, scheduled tasks, and log timestamps |
|
| 55 |
+
| `REDIS_QUEUE_ADDR` | No | `CPA_BASE_URL` hostname + `8317` | CPA Redis/RESP TCP address; set `host:port` for non-default ports |
|
| 56 |
+
| `REDIS_QUEUE_BATCH_SIZE` | No | `1000` | Maximum queue records per pull |
|
| 57 |
+
| `REDIS_QUEUE_IDLE_INTERVAL` | No | `1s` | Empty queue check interval |
|
| 58 |
+
| `REQUEST_TIMEOUT` | No | `30s` | CPA request timeout |
|
| 59 |
+
| `WORK_DIR` | No | `./data` | Application work directory; database, logs, and backups default to `app.db`, `logs/`, and `backups/` under it |
|
| 60 |
+
| `LOG_LEVEL` | No | `info` | Log level |
|
| 61 |
+
| `LOG_FILE_ENABLED` | No | `true` | Write persistent log files |
|
| 62 |
+
| `LOG_RETENTION_DAYS` | No | `7` | Log retention days; `0` disables cleanup |
|
| 63 |
+
| `BACKUP_ENABLED` | No | `true` | Enable SQLite database backups |
|
| 64 |
+
| `BACKUP_INTERVAL` | No | `24h` | Database backup interval |
|
| 65 |
+
| `BACKUP_RETENTION_DAYS` | No | `7` | Backup retention days |
|
| 66 |
+
|
| 67 |
+
`APP_BASE_PATH` must be empty or start with `/`; for example `/cpa`. `/cpa/` is normalized to `/cpa`.
|
| 68 |
+
|
| 69 |
+
Security and data notes:
|
| 70 |
+
|
| 71 |
+
- SQLite database backups store original data from the application database, and backup files are not encrypted.
|
| 72 |
+
- Browser-facing APIs redact key-like source/lookup fields or map them to stable public identifiers, but raw database values are unchanged.
|
| 73 |
+
- For public deployments, enable `AUTH_ENABLED=true` and terminate HTTPS at your reverse proxy.
|
| 74 |
+
- Login sessions are stored in process memory and become invalid after restart.
|
| 75 |
+
- Redis inbox raw messages are cleaned up automatically: successful rows are kept until the end of the current day, and failed rows are kept for 7 days.
|
| 76 |
+
|
| 77 |
+
## Development
|
| 78 |
+
|
| 79 |
+
### Prerequisites
|
| 80 |
+
|
| 81 |
+
- Go 1.22+
|
| 82 |
+
- Node.js 22+
|
| 83 |
+
- npm
|
| 84 |
+
- A running [CLIProxyAPI (CPA)](https://github.com/router-for-me/CLIProxyAPI) instance
|
| 85 |
+
|
| 86 |
+
### Run locally
|
| 87 |
+
|
| 88 |
+
1. Create your local config:
|
| 89 |
+
|
| 90 |
+
```bash
|
| 91 |
+
cp .env.example .env
|
| 92 |
+
```
|
| 93 |
+
|
| 94 |
+
2. Start the backend:
|
| 95 |
+
|
| 96 |
+
```bash
|
| 97 |
+
go run ./cmd/server/main.go
|
| 98 |
+
```
|
| 99 |
+
|
| 100 |
+
3. In another terminal, install frontend dependencies and start the dev server:
|
| 101 |
+
|
| 102 |
+
```bash
|
| 103 |
+
npm --prefix ./web ci
|
| 104 |
+
npm --prefix ./web run dev -- --host 127.0.0.1
|
| 105 |
+
```
|
| 106 |
+
|
| 107 |
+
4. Build the frontend for production:
|
| 108 |
+
|
| 109 |
+
```bash
|
| 110 |
+
npm --prefix ./web run build
|
| 111 |
+
```
|
| 112 |
+
|
| 113 |
+
### Tests
|
| 114 |
+
|
| 115 |
+
Run the full local verification baseline:
|
| 116 |
+
|
| 117 |
+
```bash
|
| 118 |
+
make verify
|
| 119 |
+
```
|
| 120 |
+
|
| 121 |
+
Or run checks individually:
|
| 122 |
+
|
| 123 |
+
```bash
|
| 124 |
+
go test ./cmd/... ./internal/...
|
| 125 |
+
npm --prefix ./web run test
|
| 126 |
+
npm --prefix ./web run lint
|
| 127 |
+
npm --prefix ./web run typecheck
|
| 128 |
+
npm --prefix ./web run build
|
| 129 |
+
```
|
| 130 |
+
|
| 131 |
+
## Docker
|
| 132 |
+
|
| 133 |
+
If CPA is already running on the host:
|
| 134 |
+
|
| 135 |
+
```bash
|
| 136 |
+
# TZ sets the container timezone; log timestamps are displayed in this timezone.
|
| 137 |
+
docker run -d \
|
| 138 |
+
--name cpa-usage-keeper \
|
| 139 |
+
--add-host=host.docker.internal:host-gateway \
|
| 140 |
+
-p 8080:8080 \
|
| 141 |
+
-v "$(pwd)/keeper/data:/data" \
|
| 142 |
+
-e TZ=Asia/Shanghai \
|
| 143 |
+
-e CPA_BASE_URL=http://host.docker.internal:8317 \
|
| 144 |
+
-e CPA_MANAGEMENT_KEY=replace-with-your-management-key \
|
| 145 |
+
-e REDIS_QUEUE_ADDR=host.docker.internal:8317 \
|
| 146 |
+
-e AUTH_ENABLED=true \
|
| 147 |
+
-e LOGIN_PASSWORD=replace-with-your-login-password \
|
| 148 |
+
ghcr.io/willxup/cpa-usage-keeper:latest
|
| 149 |
+
```
|
| 150 |
+
|
| 151 |
+
`/data` stores the SQLite database, backups, and log files. Mount it to persistent storage.
|
| 152 |
+
|
| 153 |
+
## Docker Compose
|
| 154 |
+
|
| 155 |
+
The repository includes a minimal `docker-compose.yaml` example for running CPA and CPA Usage Keeper together:
|
| 156 |
+
|
| 157 |
+
```yaml
|
| 158 |
+
services:
|
| 159 |
+
cli-proxy-api:
|
| 160 |
+
image: eceasy/cli-proxy-api:latest
|
| 161 |
+
container_name: cli-proxy-api
|
| 162 |
+
restart: unless-stopped
|
| 163 |
+
ports:
|
| 164 |
+
- "8317:8317"
|
| 165 |
+
- "1455:1455"
|
| 166 |
+
volumes:
|
| 167 |
+
- ./cpa/config.yaml:/CLIProxyAPI/config.yaml
|
| 168 |
+
- ./cpa/auths:/root/.cli-proxy-api
|
| 169 |
+
- ./cpa/logs:/CLIProxyAPI/logs
|
| 170 |
+
networks:
|
| 171 |
+
- cpa-network
|
| 172 |
+
|
| 173 |
+
cpa-usage-keeper:
|
| 174 |
+
image: ghcr.io/willxup/cpa-usage-keeper:latest
|
| 175 |
+
container_name: cpa-usage-keeper
|
| 176 |
+
restart: unless-stopped
|
| 177 |
+
depends_on:
|
| 178 |
+
- cli-proxy-api
|
| 179 |
+
ports:
|
| 180 |
+
- "8080:8080"
|
| 181 |
+
environment:
|
| 182 |
+
TZ: Asia/Shanghai # Sets the container timezone; log timestamps use this timezone.
|
| 183 |
+
CPA_BASE_URL: http://cli-proxy-api:8317
|
| 184 |
+
CPA_MANAGEMENT_KEY: replace-with-your-management-key
|
| 185 |
+
REDIS_QUEUE_ADDR: cli-proxy-api:8317
|
| 186 |
+
AUTH_ENABLED: true
|
| 187 |
+
LOGIN_PASSWORD: replace-with-your-login-password
|
| 188 |
+
volumes:
|
| 189 |
+
- ./keeper:/data
|
| 190 |
+
networks:
|
| 191 |
+
- cpa-network
|
| 192 |
+
|
| 193 |
+
networks:
|
| 194 |
+
cpa-network:
|
| 195 |
+
driver: bridge
|
| 196 |
+
```
|
| 197 |
+
|
| 198 |
+
Start:
|
| 199 |
+
|
| 200 |
+
```bash
|
| 201 |
+
docker compose up -d
|
| 202 |
+
```
|
| 203 |
+
|
| 204 |
+
Stop:
|
| 205 |
+
|
| 206 |
+
```bash
|
| 207 |
+
docker compose down
|
| 208 |
+
```
|
| 209 |
+
|
| 210 |
+
CPA files are stored under `./cpa`, and CPA Usage Keeper data is stored under `./keeper`.
|
| 211 |
+
|
| 212 |
+
## Subpath reverse proxy
|
| 213 |
+
|
| 214 |
+
When serving under `/cpa`, set `APP_BASE_PATH=/cpa` and keep the prefix in your reverse proxy:
|
| 215 |
+
|
| 216 |
+
```nginx
|
| 217 |
+
location /cpa/ {
|
| 218 |
+
proxy_pass http://127.0.0.1:8080;
|
| 219 |
+
proxy_set_header Host $host;
|
| 220 |
+
proxy_set_header X-Forwarded-Proto $scheme;
|
| 221 |
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
| 222 |
+
}
|
| 223 |
+
```
|
README.md
CHANGED
|
@@ -8,25 +8,230 @@ app_port: 8080
|
|
| 8 |
pinned: false
|
| 9 |
---
|
| 10 |
|
| 11 |
-
#
|
| 12 |
|
| 13 |
-
|
| 14 |
|
| 15 |
-
|
| 16 |
|
| 17 |
-
|
| 18 |
|
| 19 |
-
-
|
| 20 |
|
| 21 |
-
|
| 22 |
|
| 23 |
-
-
|
| 24 |
-
-
|
| 25 |
-
-
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
-
|
| 28 |
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
-
|
| 32 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
pinned: false
|
| 9 |
---
|
| 10 |
|
| 11 |
+
# CPA Usage Keeper
|
| 12 |
|
| 13 |
+
[English README](./README.en.md)
|
| 14 |
|
| 15 |
+
`CPA Usage Keeper` 是一个独立的 CPA 用量持久化与可视化服务。
|
| 16 |
|
| 17 |
+
它依赖 [CLIProxyAPI(CPA)](https://github.com/router-for-me/CLIProxyAPI) 作为后端 CPA 数据来源,目标是在 CPA 之上补充持久化存储与统计分析能力。服务会从 CPA Redis usage 队列消费事件并写入 SQLite,定时拉取 CPA metadata,暴露聚合 API,并提供内置 Web Dashboard 用于查看 usage、pricing、request health 和 model/API 维度的统计信息。
|
| 18 |
|
| 19 |
+

|
| 20 |
|
| 21 |
+
## 功能特性
|
| 22 |
|
| 23 |
+
- CPA usage 数据持久化到 SQLite
|
| 24 |
+
- usage 聚合 API 与 pricing API
|
| 25 |
+
- 内置 React Dashboard
|
| 26 |
+
- 可选密码登录保护
|
| 27 |
+
- SQLite 数据库本地备份与保留策略
|
| 28 |
+
- Docker / Docker Compose 部署
|
| 29 |
|
| 30 |
+
## 项目结构
|
| 31 |
|
| 32 |
+
```text
|
| 33 |
+
cmd/ 应用入口
|
| 34 |
+
internal/api/ HTTP 路由与处理器
|
| 35 |
+
internal/app/ 应用装配与启动
|
| 36 |
+
internal/auth/ 内存 session 鉴权
|
| 37 |
+
internal/backup/ SQLite 数据库备份管理
|
| 38 |
+
internal/config/ 环境配置加载
|
| 39 |
+
internal/cpa/ CPA 客户端与类型定义
|
| 40 |
+
internal/models/ GORM 模型
|
| 41 |
+
internal/poller/ 后台同步轮询
|
| 42 |
+
internal/repository/ SQLite 访问与聚合逻辑
|
| 43 |
+
internal/service/ 同步、usage 与 pricing 服务
|
| 44 |
+
web/ React + TypeScript 前端
|
| 45 |
+
```
|
| 46 |
|
| 47 |
+
## 配置
|
| 48 |
+
|
| 49 |
+
复制配置模板:
|
| 50 |
+
|
| 51 |
+
```bash
|
| 52 |
+
cp .env.example .env
|
| 53 |
+
```
|
| 54 |
+
|
| 55 |
+
| 变量 | 必填 | 默认值 | 说明 |
|
| 56 |
+
| --- | --- | --- | --- |
|
| 57 |
+
| `CPA_BASE_URL` | 是 | - | CPA 服务地址 |
|
| 58 |
+
| `CPA_MANAGEMENT_KEY` | 是 | - | CPA management key |
|
| 59 |
+
| `AUTH_ENABLED` | 否 | `false` | 是否启用登录保护 |
|
| 60 |
+
| `LOGIN_PASSWORD` | 鉴权启用时必填 | - | 登录密码 |
|
| 61 |
+
| `AUTH_SESSION_TTL` | 否 | `168h` | Session 生命周期 |
|
| 62 |
+
| `APP_PORT` | 否 | `8080` | HTTP 监听端口 |
|
| 63 |
+
| `APP_BASE_PATH` | 否 | 根路径 | 子路径部署前缀,例如 `/cpa`;留空表示 `/` |
|
| 64 |
+
| `TZ` | 否 | `Asia/Shanghai` | 项目业务时区,影响 Today、按天聚合、定时任务和日志时间 |
|
| 65 |
+
| `REDIS_QUEUE_ADDR` | 否 | `CPA_BASE_URL` 主机名 + `8317` | CPA Redis/RESP TCP 地址;非默认端口时填写 `host:port` |
|
| 66 |
+
| `REDIS_QUEUE_BATCH_SIZE` | 否 | `1000` | 每次最多拉取的队列记录数 |
|
| 67 |
+
| `REDIS_QUEUE_IDLE_INTERVAL` | 否 | `1s` | 队列为空时的检查间隔 |
|
| 68 |
+
| `REQUEST_TIMEOUT` | 否 | `30s` | CPA 请求超时 |
|
| 69 |
+
| `WORK_DIR` | 否 | `./data` | 应用工作目录;数据库、日志和备份默认分别写入 `app.db`、`logs/`、`backups/` |
|
| 70 |
+
| `LOG_LEVEL` | 否 | `info` | 日志级别 |
|
| 71 |
+
| `LOG_FILE_ENABLED` | 否 | `true` | 是否写入持久化日志文件 |
|
| 72 |
+
| `LOG_RETENTION_DAYS` | 否 | `7` | 日志保留天数;`0` 表示不自动清理 |
|
| 73 |
+
| `BACKUP_ENABLED` | 否 | `true` | 是否启用 SQLite 数据库备份 |
|
| 74 |
+
| `BACKUP_INTERVAL` | 否 | `24h` | 数据库备份间隔 |
|
| 75 |
+
| `BACKUP_RETENTION_DAYS` | 否 | `7` | 备份保留天数 |
|
| 76 |
+
|
| 77 |
+
`APP_BASE_PATH` 必须为空或以 `/` 开头;例如 `/cpa`,`/cpa/` 会规范为 `/cpa`。
|
| 78 |
+
|
| 79 |
+
安全与数据说明:
|
| 80 |
+
|
| 81 |
+
- SQLite 数据库备份会保存应用数据库中的原始数据,备份文件不做加密。
|
| 82 |
+
- 面向浏览器的 API 会对 key-like source/lookup 字段做脱敏或稳定公开标识映射,但不会修改数据库原始值。
|
| 83 |
+
- 公开部署建议开启 `AUTH_ENABLED=true`,并在反向代理层配置 HTTPS。
|
| 84 |
+
- 登录 session 存在服务进程内存中,服务重启后已登录 session 会失效。
|
| 85 |
+
- Redis inbox 原始消息会自动清理:成功数据保留到当天结束后清理,失败数据保留 7 天。
|
| 86 |
+
|
| 87 |
+
## 本地开发
|
| 88 |
+
|
| 89 |
+
### 前置依赖
|
| 90 |
+
|
| 91 |
+
- Go 1.22+
|
| 92 |
+
- Node.js 22+
|
| 93 |
+
- npm
|
| 94 |
+
- 已运行的 [CLIProxyAPI(CPA)](https://github.com/router-for-me/CLIProxyAPI)
|
| 95 |
+
|
| 96 |
+
### 本地启动
|
| 97 |
+
|
| 98 |
+
1. 复制本地配置:
|
| 99 |
+
|
| 100 |
+
```bash
|
| 101 |
+
cp .env.example .env
|
| 102 |
+
```
|
| 103 |
+
|
| 104 |
+
2. 启动后端:
|
| 105 |
+
|
| 106 |
+
```bash
|
| 107 |
+
go run ./cmd/server/main.go
|
| 108 |
+
```
|
| 109 |
+
|
| 110 |
+
3. 在另一个终端安装前端依赖并启动开发服务器:
|
| 111 |
+
|
| 112 |
+
```bash
|
| 113 |
+
npm --prefix ./web ci
|
| 114 |
+
npm --prefix ./web run dev -- --host 127.0.0.1
|
| 115 |
+
```
|
| 116 |
+
|
| 117 |
+
4. 构建前端生产产物:
|
| 118 |
+
|
| 119 |
+
```bash
|
| 120 |
+
npm --prefix ./web run build
|
| 121 |
+
```
|
| 122 |
+
|
| 123 |
+
### 测试
|
| 124 |
+
|
| 125 |
+
运行完整的本地验证基线:
|
| 126 |
+
|
| 127 |
+
```bash
|
| 128 |
+
make verify
|
| 129 |
+
```
|
| 130 |
+
|
| 131 |
+
也可以单独运行各项检查:
|
| 132 |
+
|
| 133 |
+
```bash
|
| 134 |
+
go test ./cmd/... ./internal/...
|
| 135 |
+
npm --prefix ./web run test
|
| 136 |
+
npm --prefix ./web run lint
|
| 137 |
+
npm --prefix ./web run typecheck
|
| 138 |
+
npm --prefix ./web run build
|
| 139 |
+
```
|
| 140 |
+
|
| 141 |
+
## Docker
|
| 142 |
+
|
| 143 |
+
如果 CPA 已在宿主机运行:
|
| 144 |
+
|
| 145 |
+
```bash
|
| 146 |
+
# TZ 设置容器时区,日志时间会按该时区显示。
|
| 147 |
+
docker run -d \
|
| 148 |
+
--name cpa-usage-keeper \
|
| 149 |
+
--add-host=host.docker.internal:host-gateway \
|
| 150 |
+
-p 8080:8080 \
|
| 151 |
+
-v "$(pwd)/keeper/data:/data" \
|
| 152 |
+
-e TZ=Asia/Shanghai \
|
| 153 |
+
-e CPA_BASE_URL=http://host.docker.internal:8317 \
|
| 154 |
+
-e CPA_MANAGEMENT_KEY=replace-with-your-management-key \
|
| 155 |
+
-e REDIS_QUEUE_ADDR=host.docker.internal:8317 \
|
| 156 |
+
-e AUTH_ENABLED=true \
|
| 157 |
+
-e LOGIN_PASSWORD=replace-with-your-login-password \
|
| 158 |
+
ghcr.io/willxup/cpa-usage-keeper:latest
|
| 159 |
+
```
|
| 160 |
+
|
| 161 |
+
`/data` 用于保存 SQLite 数据库、备份文件和日志文件,请挂载到持久化目录。
|
| 162 |
+
|
| 163 |
+
## Docker Compose
|
| 164 |
+
|
| 165 |
+
仓库提供了一个最简 `docker-compose.yaml` 示例,用于同时部署 CPA 和 CPA Usage Keeper:
|
| 166 |
+
|
| 167 |
+
```yaml
|
| 168 |
+
services:
|
| 169 |
+
cli-proxy-api:
|
| 170 |
+
image: eceasy/cli-proxy-api:latest
|
| 171 |
+
container_name: cli-proxy-api
|
| 172 |
+
restart: unless-stopped
|
| 173 |
+
ports:
|
| 174 |
+
- "8317:8317"
|
| 175 |
+
- "1455:1455"
|
| 176 |
+
volumes:
|
| 177 |
+
- ./cpa/config.yaml:/CLIProxyAPI/config.yaml
|
| 178 |
+
- ./cpa/auths:/root/.cli-proxy-api
|
| 179 |
+
- ./cpa/logs:/CLIProxyAPI/logs
|
| 180 |
+
networks:
|
| 181 |
+
- cpa-network
|
| 182 |
+
|
| 183 |
+
cpa-usage-keeper:
|
| 184 |
+
image: ghcr.io/willxup/cpa-usage-keeper:latest
|
| 185 |
+
container_name: cpa-usage-keeper
|
| 186 |
+
restart: unless-stopped
|
| 187 |
+
depends_on:
|
| 188 |
+
- cli-proxy-api
|
| 189 |
+
ports:
|
| 190 |
+
- "8080:8080"
|
| 191 |
+
environment:
|
| 192 |
+
TZ: Asia/Shanghai # 设置容器时区,日志时间会按该时区显示。
|
| 193 |
+
CPA_BASE_URL: http://cli-proxy-api:8317
|
| 194 |
+
CPA_MANAGEMENT_KEY: replace-with-your-management-key
|
| 195 |
+
REDIS_QUEUE_ADDR: cli-proxy-api:8317
|
| 196 |
+
AUTH_ENABLED: true
|
| 197 |
+
LOGIN_PASSWORD: replace-with-your-login-password
|
| 198 |
+
volumes:
|
| 199 |
+
- ./keeper:/data
|
| 200 |
+
networks:
|
| 201 |
+
- cpa-network
|
| 202 |
+
|
| 203 |
+
networks:
|
| 204 |
+
cpa-network:
|
| 205 |
+
driver: bridge
|
| 206 |
+
```
|
| 207 |
+
|
| 208 |
+
启动:
|
| 209 |
+
|
| 210 |
+
```bash
|
| 211 |
+
docker compose up -d
|
| 212 |
+
```
|
| 213 |
+
|
| 214 |
+
停止:
|
| 215 |
+
|
| 216 |
+
```bash
|
| 217 |
+
docker compose down
|
| 218 |
+
```
|
| 219 |
+
|
| 220 |
+
CPA 文件放在 `./cpa`,CPA Usage Keeper 数据放在 `./keeper`。
|
| 221 |
+
|
| 222 |
+
## 子路径反代
|
| 223 |
+
|
| 224 |
+
部署到 `/cpa` 时设置 `APP_BASE_PATH=/cpa`,并在反向代理中保留该前缀:
|
| 225 |
+
|
| 226 |
+
```nginx
|
| 227 |
+
location /cpa/ {
|
| 228 |
+
proxy_pass http://127.0.0.1:8080;
|
| 229 |
+
proxy_set_header Host $host;
|
| 230 |
+
proxy_set_header X-Forwarded-Proto $scheme;
|
| 231 |
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
| 232 |
+
}
|
| 233 |
+
```
|
| 234 |
+
|
| 235 |
+
## Daili Space deployment
|
| 236 |
+
|
| 237 |
+
This Space builds CPA Usage Keeper from source and connects to `https://pjpjq-daili.hf.space` through CPA's HTTP `/v0/management/usage-queue` fallback. Dashboard auth is enabled with `LOGIN_PASSWORD`.
|
cmd/server/main.go
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package main
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"flag"
|
| 5 |
+
"log"
|
| 6 |
+
"os"
|
| 7 |
+
|
| 8 |
+
"cpa-usage-keeper/internal/app"
|
| 9 |
+
)
|
| 10 |
+
|
| 11 |
+
func main() {
|
| 12 |
+
envFile := flag.String("env", "", "path to env file")
|
| 13 |
+
flag.Parse()
|
| 14 |
+
|
| 15 |
+
application, err := app.NewWithOptions(app.Options{EnvFile: *envFile})
|
| 16 |
+
if err != nil {
|
| 17 |
+
log.Fatalf("initialize app: %v", err)
|
| 18 |
+
}
|
| 19 |
+
defer application.Close()
|
| 20 |
+
|
| 21 |
+
if err := application.Run(); err != nil {
|
| 22 |
+
log.Printf("run app: %v", err)
|
| 23 |
+
if closeErr := application.Close(); closeErr != nil {
|
| 24 |
+
log.Printf("close app: %v", closeErr)
|
| 25 |
+
}
|
| 26 |
+
os.Exit(1)
|
| 27 |
+
}
|
| 28 |
+
}
|
docker-compose.example.yml
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
services:
|
| 2 |
+
cpa-usage-keeper:
|
| 3 |
+
image: ghcr.io/willxup/cpa-usage-keeper:latest
|
| 4 |
+
ports:
|
| 5 |
+
- "8080:8080"
|
| 6 |
+
environment:
|
| 7 |
+
CPA_BASE_URL: ${CPA_BASE_URL}
|
| 8 |
+
CPA_MANAGEMENT_KEY: ${CPA_MANAGEMENT_KEY}
|
| 9 |
+
AUTH_ENABLED: ${AUTH_ENABLED:-false}
|
| 10 |
+
LOGIN_PASSWORD: ${LOGIN_PASSWORD:-}
|
| 11 |
+
AUTH_SESSION_TTL: ${AUTH_SESSION_TTL:-168h}
|
| 12 |
+
APP_PORT: ${APP_PORT:-8080}
|
| 13 |
+
APP_BASE_PATH: ${APP_BASE_PATH:-}
|
| 14 |
+
TZ: ${TZ:-Asia/Shanghai}
|
| 15 |
+
REDIS_QUEUE_ADDR: ${REDIS_QUEUE_ADDR:-}
|
| 16 |
+
REDIS_QUEUE_BATCH_SIZE: ${REDIS_QUEUE_BATCH_SIZE:-1000}
|
| 17 |
+
REDIS_QUEUE_IDLE_INTERVAL: ${REDIS_QUEUE_IDLE_INTERVAL:-1s}
|
| 18 |
+
REQUEST_TIMEOUT: ${REQUEST_TIMEOUT:-30s}
|
| 19 |
+
WORK_DIR: ${WORK_DIR:-./data}
|
| 20 |
+
LOG_LEVEL: ${LOG_LEVEL:-info}
|
| 21 |
+
LOG_FILE_ENABLED: ${LOG_FILE_ENABLED:-true}
|
| 22 |
+
LOG_RETENTION_DAYS: ${LOG_RETENTION_DAYS:-7}
|
| 23 |
+
BACKUP_ENABLED: ${BACKUP_ENABLED:-true}
|
| 24 |
+
BACKUP_INTERVAL: ${BACKUP_INTERVAL:-24h}
|
| 25 |
+
BACKUP_RETENTION_DAYS: ${BACKUP_RETENTION_DAYS:-7}
|
| 26 |
+
volumes:
|
| 27 |
+
- ./data:/data
|
| 28 |
+
restart: unless-stopped
|
docker-entrypoint.sh
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/sh
|
| 2 |
+
set -eu
|
| 3 |
+
|
| 4 |
+
ensure_writable_dir() {
|
| 5 |
+
dir="$1"
|
| 6 |
+
if [ -z "$dir" ]; then
|
| 7 |
+
return
|
| 8 |
+
fi
|
| 9 |
+
mkdir -p "$dir"
|
| 10 |
+
chown -R app:app "$dir"
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
work_dir="${WORK_DIR:-./data}"
|
| 14 |
+
ensure_writable_dir "$work_dir"
|
| 15 |
+
|
| 16 |
+
case "${BACKUP_ENABLED:-true}" in
|
| 17 |
+
false|FALSE|False|0)
|
| 18 |
+
;;
|
| 19 |
+
*)
|
| 20 |
+
ensure_writable_dir "$work_dir/backups"
|
| 21 |
+
;;
|
| 22 |
+
esac
|
| 23 |
+
|
| 24 |
+
case "${LOG_FILE_ENABLED:-true}" in
|
| 25 |
+
false|FALSE|False|0)
|
| 26 |
+
;;
|
| 27 |
+
*)
|
| 28 |
+
ensure_writable_dir "$work_dir/logs"
|
| 29 |
+
;;
|
| 30 |
+
esac
|
| 31 |
+
|
| 32 |
+
exec su-exec app "$@"
|
go.mod
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
module cpa-usage-keeper
|
| 2 |
+
|
| 3 |
+
go 1.22
|
| 4 |
+
|
| 5 |
+
require (
|
| 6 |
+
github.com/gin-gonic/gin v1.10.0
|
| 7 |
+
github.com/joho/godotenv v1.5.1
|
| 8 |
+
github.com/sirupsen/logrus v1.9.3
|
| 9 |
+
gorm.io/driver/sqlite v1.5.7
|
| 10 |
+
gorm.io/gorm v1.25.12
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
require (
|
| 14 |
+
github.com/bytedance/sonic v1.11.6 // indirect
|
| 15 |
+
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
| 16 |
+
github.com/cloudwego/base64x v0.1.4 // indirect
|
| 17 |
+
github.com/cloudwego/iasm v0.2.0 // indirect
|
| 18 |
+
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
| 19 |
+
github.com/gin-contrib/sse v0.1.0 // indirect
|
| 20 |
+
github.com/go-playground/locales v0.14.1 // indirect
|
| 21 |
+
github.com/go-playground/universal-translator v0.18.1 // indirect
|
| 22 |
+
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
| 23 |
+
github.com/goccy/go-json v0.10.2 // indirect
|
| 24 |
+
github.com/jinzhu/inflection v1.0.0 // indirect
|
| 25 |
+
github.com/jinzhu/now v1.1.5 // indirect
|
| 26 |
+
github.com/json-iterator/go v1.1.12 // indirect
|
| 27 |
+
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
| 28 |
+
github.com/leodido/go-urn v1.4.0 // indirect
|
| 29 |
+
github.com/mattn/go-isatty v0.0.20 // indirect
|
| 30 |
+
github.com/mattn/go-sqlite3 v1.14.22 // indirect
|
| 31 |
+
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
| 32 |
+
github.com/modern-go/reflect2 v1.0.2 // indirect
|
| 33 |
+
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
| 34 |
+
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
| 35 |
+
github.com/ugorji/go/codec v1.2.12 // indirect
|
| 36 |
+
golang.org/x/arch v0.8.0 // indirect
|
| 37 |
+
golang.org/x/crypto v0.23.0 // indirect
|
| 38 |
+
golang.org/x/net v0.25.0 // indirect
|
| 39 |
+
golang.org/x/sys v0.20.0 // indirect
|
| 40 |
+
golang.org/x/text v0.15.0 // indirect
|
| 41 |
+
google.golang.org/protobuf v1.34.1 // indirect
|
| 42 |
+
gopkg.in/yaml.v3 v3.0.1 // indirect
|
| 43 |
+
)
|
go.sum
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
| 2 |
+
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
| 3 |
+
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
| 4 |
+
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
| 5 |
+
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
| 6 |
+
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
| 7 |
+
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
| 8 |
+
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
| 9 |
+
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
| 10 |
+
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
| 11 |
+
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
| 12 |
+
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
| 13 |
+
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
| 14 |
+
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
| 15 |
+
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
| 16 |
+
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
| 17 |
+
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
| 18 |
+
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
| 19 |
+
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
| 20 |
+
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
| 21 |
+
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
| 22 |
+
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
| 23 |
+
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
| 24 |
+
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
|
| 25 |
+
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
| 26 |
+
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
| 27 |
+
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
| 28 |
+
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
| 29 |
+
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
| 30 |
+
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
| 31 |
+
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
| 32 |
+
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
| 33 |
+
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
| 34 |
+
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
| 35 |
+
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
| 36 |
+
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
| 37 |
+
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
| 38 |
+
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
| 39 |
+
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
| 40 |
+
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
| 41 |
+
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
| 42 |
+
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
| 43 |
+
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
| 44 |
+
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
| 45 |
+
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
| 46 |
+
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
| 47 |
+
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
| 48 |
+
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
| 49 |
+
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
| 50 |
+
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
| 51 |
+
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
| 52 |
+
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
| 53 |
+
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
| 54 |
+
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
| 55 |
+
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
| 56 |
+
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
| 57 |
+
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
| 58 |
+
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
| 59 |
+
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
| 60 |
+
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
| 61 |
+
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
| 62 |
+
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
| 63 |
+
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
| 64 |
+
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
| 65 |
+
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
| 66 |
+
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
| 67 |
+
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
| 68 |
+
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
| 69 |
+
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
| 70 |
+
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
| 71 |
+
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
| 72 |
+
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
| 73 |
+
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
| 74 |
+
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
| 75 |
+
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
| 76 |
+
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
| 77 |
+
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
| 78 |
+
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
| 79 |
+
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
|
| 80 |
+
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
| 81 |
+
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
|
| 82 |
+
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
| 83 |
+
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
| 84 |
+
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
| 85 |
+
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
| 86 |
+
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
|
| 87 |
+
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
| 88 |
+
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
|
| 89 |
+
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
| 90 |
+
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
| 91 |
+
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
| 92 |
+
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
| 93 |
+
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
| 94 |
+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
| 95 |
+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
| 96 |
+
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
| 97 |
+
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
| 98 |
+
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
| 99 |
+
gorm.io/driver/sqlite v1.5.7 h1:8NvsrhP0ifM7LX9G4zPB97NwovUakUxc+2V2uuf3Z1I=
|
| 100 |
+
gorm.io/driver/sqlite v1.5.7/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
|
| 101 |
+
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
|
| 102 |
+
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
|
| 103 |
+
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
| 104 |
+
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
internal/api/auth.go
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package api
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"crypto/subtle"
|
| 5 |
+
"net"
|
| 6 |
+
"net/http"
|
| 7 |
+
"sync"
|
| 8 |
+
"time"
|
| 9 |
+
|
| 10 |
+
"cpa-usage-keeper/internal/auth"
|
| 11 |
+
"github.com/gin-gonic/gin"
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
const sessionCookieName = "cpa_usage_keeper_session"
|
| 15 |
+
|
| 16 |
+
const maxFailedLoginAttempts = 5
|
| 17 |
+
|
| 18 |
+
type AuthConfig struct {
|
| 19 |
+
Enabled bool
|
| 20 |
+
LoginPassword string
|
| 21 |
+
SessionTTL time.Duration
|
| 22 |
+
BasePath string
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
type authHandler struct {
|
| 26 |
+
config AuthConfig
|
| 27 |
+
sessions *auth.SessionManager
|
| 28 |
+
|
| 29 |
+
mu sync.Mutex
|
| 30 |
+
failedAttempts map[string]int
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
type loginRequest struct {
|
| 34 |
+
Password string `json:"password"`
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
type sessionResponse struct {
|
| 38 |
+
Authenticated bool `json:"authenticated"`
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
func NewAuthHandler(config AuthConfig, sessions *auth.SessionManager) *authHandler {
|
| 42 |
+
return &authHandler{config: config, sessions: sessions, failedAttempts: make(map[string]int)}
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
func (h *authHandler) registerRoutes(router gin.IRoutes) {
|
| 46 |
+
router.GET("/session", h.getSession)
|
| 47 |
+
router.POST("/login", h.login)
|
| 48 |
+
router.POST("/logout", h.logout)
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
func (h *authHandler) middleware() gin.HandlerFunc {
|
| 52 |
+
return func(c *gin.Context) {
|
| 53 |
+
if h == nil || !h.config.Enabled {
|
| 54 |
+
c.Next()
|
| 55 |
+
return
|
| 56 |
+
}
|
| 57 |
+
if h.sessions == nil {
|
| 58 |
+
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "authentication required"})
|
| 59 |
+
return
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
token, err := c.Cookie(sessionCookieName)
|
| 63 |
+
if err != nil || !h.sessions.Validate(token) {
|
| 64 |
+
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "authentication required"})
|
| 65 |
+
return
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
c.Next()
|
| 69 |
+
}
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
func (h *authHandler) getSession(c *gin.Context) {
|
| 73 |
+
if h == nil || !h.config.Enabled {
|
| 74 |
+
c.JSON(http.StatusOK, sessionResponse{Authenticated: true})
|
| 75 |
+
return
|
| 76 |
+
}
|
| 77 |
+
if h.sessions == nil {
|
| 78 |
+
c.JSON(http.StatusOK, sessionResponse{Authenticated: false})
|
| 79 |
+
return
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
token, err := c.Cookie(sessionCookieName)
|
| 83 |
+
if err != nil {
|
| 84 |
+
c.JSON(http.StatusOK, sessionResponse{Authenticated: false})
|
| 85 |
+
return
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
c.JSON(http.StatusOK, sessionResponse{Authenticated: h.sessions.Validate(token)})
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
func (h *authHandler) login(c *gin.Context) {
|
| 92 |
+
if h == nil || !h.config.Enabled {
|
| 93 |
+
c.Status(http.StatusNoContent)
|
| 94 |
+
return
|
| 95 |
+
}
|
| 96 |
+
if h.sessions == nil {
|
| 97 |
+
writeInternalError(c, "session manager is not configured", nil)
|
| 98 |
+
return
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
var request loginRequest
|
| 102 |
+
if err := c.ShouldBindJSON(&request); err != nil {
|
| 103 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
| 104 |
+
return
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
clientKey := loginClientKey(c)
|
| 108 |
+
passwordMatches := subtle.ConstantTimeCompare([]byte(request.Password), []byte(h.config.LoginPassword)) == 1
|
| 109 |
+
if h.tooManyFailedAttempts(clientKey) && !passwordMatches {
|
| 110 |
+
c.JSON(http.StatusTooManyRequests, gin.H{"error": "too many failed login attempts"})
|
| 111 |
+
return
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
if !passwordMatches {
|
| 115 |
+
h.recordFailedAttempt(clientKey)
|
| 116 |
+
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid password"})
|
| 117 |
+
return
|
| 118 |
+
}
|
| 119 |
+
h.clearFailedAttempts(clientKey)
|
| 120 |
+
|
| 121 |
+
token, expiresAt, err := h.sessions.Create()
|
| 122 |
+
if err != nil {
|
| 123 |
+
writeInternalError(c, "create auth session failed", err)
|
| 124 |
+
return
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
secure := c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https"
|
| 128 |
+
cookiePath := h.config.BasePath
|
| 129 |
+
if cookiePath == "" {
|
| 130 |
+
cookiePath = "/"
|
| 131 |
+
}
|
| 132 |
+
http.SetCookie(c.Writer, &http.Cookie{
|
| 133 |
+
Name: sessionCookieName,
|
| 134 |
+
Value: token,
|
| 135 |
+
Path: cookiePath,
|
| 136 |
+
HttpOnly: true,
|
| 137 |
+
Secure: secure,
|
| 138 |
+
SameSite: http.SameSiteLaxMode,
|
| 139 |
+
Expires: expiresAt,
|
| 140 |
+
MaxAge: int(time.Until(expiresAt).Seconds()),
|
| 141 |
+
})
|
| 142 |
+
c.Status(http.StatusNoContent)
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
func (h *authHandler) logout(c *gin.Context) {
|
| 146 |
+
if h == nil || !h.config.Enabled {
|
| 147 |
+
c.Status(http.StatusNoContent)
|
| 148 |
+
return
|
| 149 |
+
}
|
| 150 |
+
if h.sessions != nil {
|
| 151 |
+
if token, err := c.Cookie(sessionCookieName); err == nil {
|
| 152 |
+
h.sessions.Delete(token)
|
| 153 |
+
}
|
| 154 |
+
}
|
| 155 |
+
clearSessionCookie(c, h.config.BasePath)
|
| 156 |
+
c.Status(http.StatusNoContent)
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
func (h *authHandler) tooManyFailedAttempts(key string) bool {
|
| 160 |
+
h.mu.Lock()
|
| 161 |
+
defer h.mu.Unlock()
|
| 162 |
+
return h.failedAttempts[key] >= maxFailedLoginAttempts
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
func (h *authHandler) recordFailedAttempt(key string) {
|
| 166 |
+
h.mu.Lock()
|
| 167 |
+
defer h.mu.Unlock()
|
| 168 |
+
h.failedAttempts[key]++
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
func (h *authHandler) clearFailedAttempts(key string) {
|
| 172 |
+
h.mu.Lock()
|
| 173 |
+
defer h.mu.Unlock()
|
| 174 |
+
delete(h.failedAttempts, key)
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
func loginClientKey(c *gin.Context) string {
|
| 178 |
+
host, _, err := net.SplitHostPort(c.Request.RemoteAddr)
|
| 179 |
+
if err == nil && host != "" {
|
| 180 |
+
return host
|
| 181 |
+
}
|
| 182 |
+
return c.ClientIP()
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
func clearSessionCookie(c *gin.Context, basePath string) {
|
| 186 |
+
cookiePath := basePath
|
| 187 |
+
if cookiePath == "" {
|
| 188 |
+
cookiePath = "/"
|
| 189 |
+
}
|
| 190 |
+
http.SetCookie(c.Writer, &http.Cookie{
|
| 191 |
+
Name: sessionCookieName,
|
| 192 |
+
Value: "",
|
| 193 |
+
Path: cookiePath,
|
| 194 |
+
HttpOnly: true,
|
| 195 |
+
SameSite: http.SameSiteLaxMode,
|
| 196 |
+
Expires: time.Unix(0, 0),
|
| 197 |
+
MaxAge: -1,
|
| 198 |
+
})
|
| 199 |
+
}
|
internal/api/auth_test.go
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package api
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"net/http"
|
| 5 |
+
"net/http/httptest"
|
| 6 |
+
"strings"
|
| 7 |
+
"testing"
|
| 8 |
+
"time"
|
| 9 |
+
|
| 10 |
+
"cpa-usage-keeper/internal/auth"
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
func TestAuthSessionReportsAuthenticatedWhenDisabled(t *testing.T) {
|
| 14 |
+
router := NewRouter(nil, nil, nil, nil, AuthConfig{Enabled: false}, nil, "")
|
| 15 |
+
resp := httptest.NewRecorder()
|
| 16 |
+
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/session", nil)
|
| 17 |
+
|
| 18 |
+
router.ServeHTTP(resp, req)
|
| 19 |
+
|
| 20 |
+
if resp.Code != http.StatusOK || !contains(resp.Body.String(), `"authenticated":true`) {
|
| 21 |
+
t.Fatalf("unexpected response: %d %s", resp.Code, resp.Body.String())
|
| 22 |
+
}
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
func TestAuthProtectedRouteRequiresSessionWhenEnabled(t *testing.T) {
|
| 26 |
+
sessions := auth.NewSessionManager(time.Hour)
|
| 27 |
+
config := AuthConfig{Enabled: true, LoginPassword: "secret", SessionTTL: time.Hour}
|
| 28 |
+
router := NewRouter(nil, nil, nil, nil, config, NewAuthHandler(config, sessions), "")
|
| 29 |
+
resp := httptest.NewRecorder()
|
| 30 |
+
req := httptest.NewRequest(http.MethodGet, "/api/v1/usage/overview", nil)
|
| 31 |
+
|
| 32 |
+
router.ServeHTTP(resp, req)
|
| 33 |
+
|
| 34 |
+
if resp.Code != http.StatusUnauthorized {
|
| 35 |
+
t.Fatalf("expected status 401, got %d", resp.Code)
|
| 36 |
+
}
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
func TestAuthLoginSetsCookieAndUnlocksProtectedRoute(t *testing.T) {
|
| 40 |
+
sessions := auth.NewSessionManager(time.Hour)
|
| 41 |
+
config := AuthConfig{Enabled: true, LoginPassword: "secret", SessionTTL: time.Hour}
|
| 42 |
+
handler := NewAuthHandler(config, sessions)
|
| 43 |
+
router := NewRouter(nil, nil, nil, nil, config, handler, "")
|
| 44 |
+
|
| 45 |
+
loginResp := httptest.NewRecorder()
|
| 46 |
+
loginReq := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(`{"password":"secret"}`))
|
| 47 |
+
loginReq.Header.Set("Content-Type", "application/json")
|
| 48 |
+
router.ServeHTTP(loginResp, loginReq)
|
| 49 |
+
|
| 50 |
+
if loginResp.Code != http.StatusNoContent {
|
| 51 |
+
t.Fatalf("expected login status 204, got %d", loginResp.Code)
|
| 52 |
+
}
|
| 53 |
+
cookie := loginResp.Result().Cookies()
|
| 54 |
+
if len(cookie) == 0 {
|
| 55 |
+
t.Fatal("expected auth cookie to be set")
|
| 56 |
+
}
|
| 57 |
+
if cookie[0].Name != sessionCookieName {
|
| 58 |
+
t.Fatalf("expected cookie %q, got %q", sessionCookieName, cookie[0].Name)
|
| 59 |
+
}
|
| 60 |
+
if cookie[0].Path != "/" {
|
| 61 |
+
t.Fatalf("expected root cookie path '/', got %q", cookie[0].Path)
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
usageResp := httptest.NewRecorder()
|
| 65 |
+
usageReq := httptest.NewRequest(http.MethodGet, "/api/v1/usage/overview", nil)
|
| 66 |
+
usageReq.AddCookie(cookie[0])
|
| 67 |
+
router.ServeHTTP(usageResp, usageReq)
|
| 68 |
+
|
| 69 |
+
if usageResp.Code != http.StatusOK {
|
| 70 |
+
t.Fatalf("expected protected route to succeed, got %d %s", usageResp.Code, usageResp.Body.String())
|
| 71 |
+
}
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
func TestAuthLoginRejectsWrongPassword(t *testing.T) {
|
| 75 |
+
sessions := auth.NewSessionManager(time.Hour)
|
| 76 |
+
config := AuthConfig{Enabled: true, LoginPassword: "secret", SessionTTL: time.Hour}
|
| 77 |
+
router := NewRouter(nil, nil, nil, nil, config, NewAuthHandler(config, sessions), "")
|
| 78 |
+
resp := httptest.NewRecorder()
|
| 79 |
+
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(`{"password":"wrong"}`))
|
| 80 |
+
req.Header.Set("Content-Type", "application/json")
|
| 81 |
+
|
| 82 |
+
router.ServeHTTP(resp, req)
|
| 83 |
+
|
| 84 |
+
if resp.Code != http.StatusUnauthorized {
|
| 85 |
+
t.Fatalf("expected status 401, got %d", resp.Code)
|
| 86 |
+
}
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
func TestAuthLoginRateLimitsRepeatedFailures(t *testing.T) {
|
| 90 |
+
sessions := auth.NewSessionManager(time.Hour)
|
| 91 |
+
config := AuthConfig{Enabled: true, LoginPassword: "secret", SessionTTL: time.Hour}
|
| 92 |
+
router := NewRouter(nil, nil, nil, nil, config, NewAuthHandler(config, sessions), "")
|
| 93 |
+
|
| 94 |
+
for i := 0; i < 5; i++ {
|
| 95 |
+
resp := httptest.NewRecorder()
|
| 96 |
+
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(`{"password":"wrong"}`))
|
| 97 |
+
req.Header.Set("Content-Type", "application/json")
|
| 98 |
+
req.RemoteAddr = "198.51.100.1:1234"
|
| 99 |
+
router.ServeHTTP(resp, req)
|
| 100 |
+
if resp.Code != http.StatusUnauthorized {
|
| 101 |
+
t.Fatalf("expected failed attempt %d to return 401, got %d", i+1, resp.Code)
|
| 102 |
+
}
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
resp := httptest.NewRecorder()
|
| 106 |
+
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(`{"password":"wrong"}`))
|
| 107 |
+
req.Header.Set("Content-Type", "application/json")
|
| 108 |
+
req.RemoteAddr = "198.51.100.1:1234"
|
| 109 |
+
router.ServeHTTP(resp, req)
|
| 110 |
+
|
| 111 |
+
if resp.Code != http.StatusTooManyRequests {
|
| 112 |
+
t.Fatalf("expected repeated failed attempts to return 429, got %d", resp.Code)
|
| 113 |
+
}
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
func TestAuthLoginAllowsCorrectPasswordAfterRateLimitThreshold(t *testing.T) {
|
| 117 |
+
sessions := auth.NewSessionManager(time.Hour)
|
| 118 |
+
config := AuthConfig{Enabled: true, LoginPassword: "secret", SessionTTL: time.Hour}
|
| 119 |
+
router := NewRouter(nil, nil, nil, nil, config, NewAuthHandler(config, sessions), "")
|
| 120 |
+
|
| 121 |
+
for i := 0; i < 5; i++ {
|
| 122 |
+
resp := httptest.NewRecorder()
|
| 123 |
+
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(`{"password":"wrong"}`))
|
| 124 |
+
req.Header.Set("Content-Type", "application/json")
|
| 125 |
+
req.RemoteAddr = "198.51.100.2:1234"
|
| 126 |
+
router.ServeHTTP(resp, req)
|
| 127 |
+
if resp.Code != http.StatusUnauthorized {
|
| 128 |
+
t.Fatalf("expected failed attempt %d to return 401, got %d", i+1, resp.Code)
|
| 129 |
+
}
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
resp := httptest.NewRecorder()
|
| 133 |
+
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(`{"password":"secret"}`))
|
| 134 |
+
req.Header.Set("Content-Type", "application/json")
|
| 135 |
+
req.RemoteAddr = "198.51.100.2:1234"
|
| 136 |
+
router.ServeHTTP(resp, req)
|
| 137 |
+
|
| 138 |
+
if resp.Code != http.StatusNoContent {
|
| 139 |
+
t.Fatalf("expected correct password to clear failed attempts and login, got %d", resp.Code)
|
| 140 |
+
}
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
func TestAuthLogoutDeletesSessionCookie(t *testing.T) {
|
| 144 |
+
sessions := auth.NewSessionManager(time.Hour)
|
| 145 |
+
config := AuthConfig{Enabled: true, LoginPassword: "secret", SessionTTL: time.Hour}
|
| 146 |
+
handler := NewAuthHandler(config, sessions)
|
| 147 |
+
router := NewRouter(nil, nil, nil, nil, config, handler, "")
|
| 148 |
+
|
| 149 |
+
loginResp := httptest.NewRecorder()
|
| 150 |
+
loginReq := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(`{"password":"secret"}`))
|
| 151 |
+
loginReq.Header.Set("Content-Type", "application/json")
|
| 152 |
+
router.ServeHTTP(loginResp, loginReq)
|
| 153 |
+
if loginResp.Code != http.StatusNoContent {
|
| 154 |
+
t.Fatalf("expected login status 204, got %d", loginResp.Code)
|
| 155 |
+
}
|
| 156 |
+
cookies := loginResp.Result().Cookies()
|
| 157 |
+
if len(cookies) == 0 {
|
| 158 |
+
t.Fatal("expected auth cookie to be set")
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
logoutResp := httptest.NewRecorder()
|
| 162 |
+
logoutReq := httptest.NewRequest(http.MethodPost, "/api/v1/auth/logout", nil)
|
| 163 |
+
logoutReq.AddCookie(cookies[0])
|
| 164 |
+
router.ServeHTTP(logoutResp, logoutReq)
|
| 165 |
+
if logoutResp.Code != http.StatusNoContent {
|
| 166 |
+
t.Fatalf("expected logout status 204, got %d", logoutResp.Code)
|
| 167 |
+
}
|
| 168 |
+
clearCookies := logoutResp.Result().Cookies()
|
| 169 |
+
if len(clearCookies) == 0 || clearCookies[0].Name != sessionCookieName || clearCookies[0].MaxAge >= 0 {
|
| 170 |
+
t.Fatalf("expected logout to clear session cookie, got %+v", clearCookies)
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
usageResp := httptest.NewRecorder()
|
| 174 |
+
usageReq := httptest.NewRequest(http.MethodGet, "/api/v1/usage/overview", nil)
|
| 175 |
+
usageReq.AddCookie(cookies[0])
|
| 176 |
+
router.ServeHTTP(usageResp, usageReq)
|
| 177 |
+
if usageResp.Code != http.StatusUnauthorized {
|
| 178 |
+
t.Fatalf("expected logged out session to be rejected, got %d", usageResp.Code)
|
| 179 |
+
}
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
func TestSubpathAuthUsesPrefixedRoutesAndCookiePath(t *testing.T) {
|
| 183 |
+
sessions := auth.NewSessionManager(time.Hour)
|
| 184 |
+
config := AuthConfig{Enabled: true, LoginPassword: "secret", SessionTTL: time.Hour, BasePath: "/cpa"}
|
| 185 |
+
handler := NewAuthHandler(config, sessions)
|
| 186 |
+
router := NewRouter(nil, nil, nil, nil, config, handler, "/cpa")
|
| 187 |
+
|
| 188 |
+
sessionResp := httptest.NewRecorder()
|
| 189 |
+
sessionReq := httptest.NewRequest(http.MethodGet, "/cpa/api/v1/auth/session", nil)
|
| 190 |
+
router.ServeHTTP(sessionResp, sessionReq)
|
| 191 |
+
if sessionResp.Code != http.StatusOK || !contains(sessionResp.Body.String(), `"authenticated":false`) {
|
| 192 |
+
t.Fatalf("unexpected session response: %d %s", sessionResp.Code, sessionResp.Body.String())
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
loginResp := httptest.NewRecorder()
|
| 196 |
+
loginReq := httptest.NewRequest(http.MethodPost, "/cpa/api/v1/auth/login", strings.NewReader(`{"password":"secret"}`))
|
| 197 |
+
loginReq.Header.Set("Content-Type", "application/json")
|
| 198 |
+
router.ServeHTTP(loginResp, loginReq)
|
| 199 |
+
if loginResp.Code != http.StatusNoContent {
|
| 200 |
+
t.Fatalf("expected login status 204, got %d", loginResp.Code)
|
| 201 |
+
}
|
| 202 |
+
cookies := loginResp.Result().Cookies()
|
| 203 |
+
if len(cookies) == 0 {
|
| 204 |
+
t.Fatal("expected auth cookie to be set")
|
| 205 |
+
}
|
| 206 |
+
if cookies[0].Path != "/cpa" {
|
| 207 |
+
t.Fatalf("expected subpath cookie path '/cpa', got %q", cookies[0].Path)
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
usageResp := httptest.NewRecorder()
|
| 211 |
+
usageReq := httptest.NewRequest(http.MethodGet, "/cpa/api/v1/usage/overview", nil)
|
| 212 |
+
usageReq.AddCookie(cookies[0])
|
| 213 |
+
router.ServeHTTP(usageResp, usageReq)
|
| 214 |
+
if usageResp.Code != http.StatusOK {
|
| 215 |
+
t.Fatalf("expected protected route under subpath to succeed, got %d %s", usageResp.Code, usageResp.Body.String())
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
unprefixedResp := httptest.NewRecorder()
|
| 219 |
+
unprefixedReq := httptest.NewRequest(http.MethodGet, "/api/v1/usage/overview", nil)
|
| 220 |
+
unprefixedReq.AddCookie(cookies[0])
|
| 221 |
+
router.ServeHTTP(unprefixedResp, unprefixedReq)
|
| 222 |
+
if unprefixedResp.Code != http.StatusNotFound {
|
| 223 |
+
t.Fatalf("expected unprefixed route to 404, got %d", unprefixedResp.Code)
|
| 224 |
+
}
|
| 225 |
+
}
|
internal/api/errors.go
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package api
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"log/slog"
|
| 5 |
+
"net/http"
|
| 6 |
+
|
| 7 |
+
"github.com/gin-gonic/gin"
|
| 8 |
+
)
|
| 9 |
+
|
| 10 |
+
func writeInternalError(c *gin.Context, message string, err error) {
|
| 11 |
+
if err != nil {
|
| 12 |
+
slog.Error(message, "error", err)
|
| 13 |
+
}
|
| 14 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"})
|
| 15 |
+
}
|
internal/api/health.go
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package api
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"net/http"
|
| 5 |
+
|
| 6 |
+
"github.com/gin-gonic/gin"
|
| 7 |
+
)
|
| 8 |
+
|
| 9 |
+
func registerHealthRoutes(router gin.IRoutes) {
|
| 10 |
+
router.GET("/healthz", func(c *gin.Context) {
|
| 11 |
+
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
| 12 |
+
})
|
| 13 |
+
}
|
internal/api/pricing.go
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package api
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"net/http"
|
| 5 |
+
"strings"
|
| 6 |
+
|
| 7 |
+
"cpa-usage-keeper/internal/service"
|
| 8 |
+
"github.com/gin-gonic/gin"
|
| 9 |
+
)
|
| 10 |
+
|
| 11 |
+
type usedModelsResponse struct {
|
| 12 |
+
Models []string `json:"models"`
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
type pricingEntryResponse struct {
|
| 16 |
+
Model string `json:"model"`
|
| 17 |
+
PromptPricePer1M float64 `json:"prompt_price_per_1m"`
|
| 18 |
+
CompletionPricePer1M float64 `json:"completion_price_per_1m"`
|
| 19 |
+
CachePricePer1M float64 `json:"cache_price_per_1m"`
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
type pricingListResponse struct {
|
| 23 |
+
Pricing []pricingEntryResponse `json:"pricing"`
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
type updatePricingRequest struct {
|
| 27 |
+
Model string `json:"model"`
|
| 28 |
+
PromptPricePer1M float64 `json:"prompt_price_per_1m"`
|
| 29 |
+
CompletionPricePer1M float64 `json:"completion_price_per_1m"`
|
| 30 |
+
CachePricePer1M float64 `json:"cache_price_per_1m"`
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
func registerPricingRoutes(router gin.IRoutes, pricingProvider service.PricingProvider) {
|
| 34 |
+
router.GET("/models/used", func(c *gin.Context) {
|
| 35 |
+
if pricingProvider == nil {
|
| 36 |
+
c.JSON(http.StatusOK, usedModelsResponse{Models: []string{}})
|
| 37 |
+
return
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
models, err := pricingProvider.ListUsedModels(c.Request.Context())
|
| 41 |
+
if err != nil {
|
| 42 |
+
writeInternalError(c, "list used models failed", err)
|
| 43 |
+
return
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
c.JSON(http.StatusOK, usedModelsResponse{Models: models})
|
| 47 |
+
})
|
| 48 |
+
|
| 49 |
+
router.GET("/pricing", func(c *gin.Context) {
|
| 50 |
+
if pricingProvider == nil {
|
| 51 |
+
c.JSON(http.StatusOK, pricingListResponse{Pricing: []pricingEntryResponse{}})
|
| 52 |
+
return
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
settings, err := pricingProvider.ListPricing(c.Request.Context())
|
| 56 |
+
if err != nil {
|
| 57 |
+
writeInternalError(c, "list pricing failed", err)
|
| 58 |
+
return
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
response := make([]pricingEntryResponse, 0, len(settings))
|
| 62 |
+
for _, setting := range settings {
|
| 63 |
+
response = append(response, pricingEntryResponse{
|
| 64 |
+
Model: setting.Model,
|
| 65 |
+
PromptPricePer1M: setting.PromptPricePer1M,
|
| 66 |
+
CompletionPricePer1M: setting.CompletionPricePer1M,
|
| 67 |
+
CachePricePer1M: setting.CachePricePer1M,
|
| 68 |
+
})
|
| 69 |
+
}
|
| 70 |
+
c.JSON(http.StatusOK, pricingListResponse{Pricing: response})
|
| 71 |
+
})
|
| 72 |
+
|
| 73 |
+
router.PUT("/pricing", func(c *gin.Context) {
|
| 74 |
+
updatePricing(c, pricingProvider, "")
|
| 75 |
+
})
|
| 76 |
+
|
| 77 |
+
router.PUT("/pricing/:model", func(c *gin.Context) {
|
| 78 |
+
updatePricing(c, pricingProvider, c.Param("model"))
|
| 79 |
+
})
|
| 80 |
+
|
| 81 |
+
router.DELETE("/pricing", func(c *gin.Context) {
|
| 82 |
+
if pricingProvider == nil {
|
| 83 |
+
c.JSON(http.StatusNotImplemented, gin.H{"error": "pricing provider is not configured"})
|
| 84 |
+
return
|
| 85 |
+
}
|
| 86 |
+
model := strings.TrimSpace(c.Query("model"))
|
| 87 |
+
if model == "" {
|
| 88 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": "model is required"})
|
| 89 |
+
return
|
| 90 |
+
}
|
| 91 |
+
if err := pricingProvider.DeletePricing(c.Request.Context(), model); err != nil {
|
| 92 |
+
if strings.Contains(err.Error(), "required") {
|
| 93 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
| 94 |
+
return
|
| 95 |
+
}
|
| 96 |
+
writeInternalError(c, "delete pricing failed", err)
|
| 97 |
+
return
|
| 98 |
+
}
|
| 99 |
+
c.Status(http.StatusNoContent)
|
| 100 |
+
})
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
func updatePricing(c *gin.Context, pricingProvider service.PricingProvider, pathModel string) {
|
| 104 |
+
if pricingProvider == nil {
|
| 105 |
+
c.JSON(http.StatusNotImplemented, gin.H{"error": "pricing provider is not configured"})
|
| 106 |
+
return
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
var request updatePricingRequest
|
| 110 |
+
if err := c.ShouldBindJSON(&request); err != nil {
|
| 111 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
| 112 |
+
return
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
model := strings.TrimSpace(pathModel)
|
| 116 |
+
if model == "" {
|
| 117 |
+
model = strings.TrimSpace(request.Model)
|
| 118 |
+
}
|
| 119 |
+
if model == "" {
|
| 120 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": "model is required"})
|
| 121 |
+
return
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
setting, err := pricingProvider.UpdatePricing(c.Request.Context(), service.UpdatePricingInput{
|
| 125 |
+
Model: model,
|
| 126 |
+
PromptPricePer1M: request.PromptPricePer1M,
|
| 127 |
+
CompletionPricePer1M: request.CompletionPricePer1M,
|
| 128 |
+
CachePricePer1M: request.CachePricePer1M,
|
| 129 |
+
})
|
| 130 |
+
if err != nil {
|
| 131 |
+
if strings.Contains(err.Error(), "has not been used") || strings.Contains(err.Error(), "required") || strings.Contains(err.Error(), "non-negative") {
|
| 132 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
| 133 |
+
return
|
| 134 |
+
}
|
| 135 |
+
writeInternalError(c, "update pricing failed", err)
|
| 136 |
+
return
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
c.JSON(http.StatusOK, pricingEntryResponse{
|
| 140 |
+
Model: setting.Model,
|
| 141 |
+
PromptPricePer1M: setting.PromptPricePer1M,
|
| 142 |
+
CompletionPricePer1M: setting.CompletionPricePer1M,
|
| 143 |
+
CachePricePer1M: setting.CachePricePer1M,
|
| 144 |
+
})
|
| 145 |
+
}
|
internal/api/pricing_test.go
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package api
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"context"
|
| 5 |
+
"net/http"
|
| 6 |
+
"net/http/httptest"
|
| 7 |
+
"strings"
|
| 8 |
+
"testing"
|
| 9 |
+
|
| 10 |
+
"cpa-usage-keeper/internal/models"
|
| 11 |
+
"cpa-usage-keeper/internal/service"
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
type pricingStub struct {
|
| 15 |
+
usedModels []string
|
| 16 |
+
pricing []models.ModelPriceSetting
|
| 17 |
+
updated *models.ModelPriceSetting
|
| 18 |
+
lastUpdate *service.UpdatePricingInput
|
| 19 |
+
deleted string
|
| 20 |
+
err error
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
func (s pricingStub) ListUsedModels(context.Context) ([]string, error) {
|
| 24 |
+
return s.usedModels, s.err
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
func (s pricingStub) ListPricing(context.Context) ([]models.ModelPriceSetting, error) {
|
| 28 |
+
return s.pricing, s.err
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
func (s *pricingStub) UpdatePricing(_ context.Context, input service.UpdatePricingInput) (*models.ModelPriceSetting, error) {
|
| 32 |
+
s.lastUpdate = &input
|
| 33 |
+
return s.updated, s.err
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
func (s *pricingStub) DeletePricing(_ context.Context, model string) error {
|
| 37 |
+
s.deleted = model
|
| 38 |
+
return s.err
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
func TestPricingRoutesReturnEmptyResponsesWithoutProvider(t *testing.T) {
|
| 42 |
+
router := NewRouter(nil, nil, nil, nil, AuthConfig{}, nil, "")
|
| 43 |
+
|
| 44 |
+
usedReq := httptest.NewRequest(http.MethodGet, "/api/v1/models/used", nil)
|
| 45 |
+
usedResp := httptest.NewRecorder()
|
| 46 |
+
router.ServeHTTP(usedResp, usedReq)
|
| 47 |
+
if usedResp.Code != http.StatusOK || !contains(usedResp.Body.String(), `"models":[]`) {
|
| 48 |
+
t.Fatalf("unexpected used models response: %d %s", usedResp.Code, usedResp.Body.String())
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
pricingReq := httptest.NewRequest(http.MethodGet, "/api/v1/pricing", nil)
|
| 52 |
+
pricingResp := httptest.NewRecorder()
|
| 53 |
+
router.ServeHTTP(pricingResp, pricingReq)
|
| 54 |
+
if pricingResp.Code != http.StatusOK || !contains(pricingResp.Body.String(), `"pricing":[]`) {
|
| 55 |
+
t.Fatalf("unexpected pricing response: %d %s", pricingResp.Code, pricingResp.Body.String())
|
| 56 |
+
}
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
func TestPricingRoutesReturnConfiguredData(t *testing.T) {
|
| 60 |
+
router := NewRouter(nil, nil, nil, &pricingStub{
|
| 61 |
+
usedModels: []string{"claude-sonnet"},
|
| 62 |
+
pricing: []models.ModelPriceSetting{{
|
| 63 |
+
Model: "claude-sonnet",
|
| 64 |
+
PromptPricePer1M: 3,
|
| 65 |
+
CompletionPricePer1M: 15,
|
| 66 |
+
CachePricePer1M: 0.3,
|
| 67 |
+
}},
|
| 68 |
+
}, AuthConfig{}, nil, "")
|
| 69 |
+
|
| 70 |
+
usedReq := httptest.NewRequest(http.MethodGet, "/api/v1/models/used", nil)
|
| 71 |
+
usedResp := httptest.NewRecorder()
|
| 72 |
+
router.ServeHTTP(usedResp, usedReq)
|
| 73 |
+
if usedResp.Code != http.StatusOK || !contains(usedResp.Body.String(), `claude-sonnet`) {
|
| 74 |
+
t.Fatalf("unexpected used models response: %d %s", usedResp.Code, usedResp.Body.String())
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
pricingReq := httptest.NewRequest(http.MethodGet, "/api/v1/pricing", nil)
|
| 78 |
+
pricingResp := httptest.NewRecorder()
|
| 79 |
+
router.ServeHTTP(pricingResp, pricingReq)
|
| 80 |
+
if pricingResp.Code != http.StatusOK || !contains(pricingResp.Body.String(), `"prompt_price_per_1m":3`) {
|
| 81 |
+
t.Fatalf("unexpected pricing response: %d %s", pricingResp.Code, pricingResp.Body.String())
|
| 82 |
+
}
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
func TestUpdatePricingRoute(t *testing.T) {
|
| 86 |
+
provider := &pricingStub{
|
| 87 |
+
updated: &models.ModelPriceSetting{
|
| 88 |
+
Model: "claude-sonnet",
|
| 89 |
+
PromptPricePer1M: 3,
|
| 90 |
+
CompletionPricePer1M: 15,
|
| 91 |
+
CachePricePer1M: 0.3,
|
| 92 |
+
},
|
| 93 |
+
}
|
| 94 |
+
router := NewRouter(nil, nil, nil, provider, AuthConfig{}, nil, "")
|
| 95 |
+
|
| 96 |
+
req := httptest.NewRequest(http.MethodPut, "/api/v1/pricing/claude-sonnet", strings.NewReader(`{"prompt_price_per_1m":3,"completion_price_per_1m":15,"cache_price_per_1m":0.3}`))
|
| 97 |
+
req.Header.Set("Content-Type", "application/json")
|
| 98 |
+
resp := httptest.NewRecorder()
|
| 99 |
+
router.ServeHTTP(resp, req)
|
| 100 |
+
|
| 101 |
+
if resp.Code != http.StatusOK || !contains(resp.Body.String(), `"model":"claude-sonnet"`) {
|
| 102 |
+
t.Fatalf("unexpected update response: %d %s", resp.Code, resp.Body.String())
|
| 103 |
+
}
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
func TestUpdatePricingRouteAcceptsModelInBody(t *testing.T) {
|
| 107 |
+
provider := &pricingStub{
|
| 108 |
+
updated: &models.ModelPriceSetting{
|
| 109 |
+
Model: "openai/gpt-4.1",
|
| 110 |
+
PromptPricePer1M: 3,
|
| 111 |
+
CompletionPricePer1M: 15,
|
| 112 |
+
CachePricePer1M: 0.3,
|
| 113 |
+
},
|
| 114 |
+
}
|
| 115 |
+
router := NewRouter(nil, nil, nil, provider, AuthConfig{}, nil, "")
|
| 116 |
+
|
| 117 |
+
req := httptest.NewRequest(http.MethodPut, "/api/v1/pricing", strings.NewReader(`{"model":"openai/gpt-4.1","prompt_price_per_1m":3,"completion_price_per_1m":15,"cache_price_per_1m":0.3}`))
|
| 118 |
+
req.Header.Set("Content-Type", "application/json")
|
| 119 |
+
resp := httptest.NewRecorder()
|
| 120 |
+
router.ServeHTTP(resp, req)
|
| 121 |
+
|
| 122 |
+
if resp.Code != http.StatusOK || !contains(resp.Body.String(), `"model":"openai/gpt-4.1"`) {
|
| 123 |
+
t.Fatalf("unexpected update response: %d %s", resp.Code, resp.Body.String())
|
| 124 |
+
}
|
| 125 |
+
if provider.lastUpdate == nil || provider.lastUpdate.Model != "openai/gpt-4.1" {
|
| 126 |
+
t.Fatalf("expected model from body to be passed through, got %+v", provider.lastUpdate)
|
| 127 |
+
}
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
func TestDeletePricingRoute(t *testing.T) {
|
| 131 |
+
provider := &pricingStub{}
|
| 132 |
+
router := NewRouter(nil, nil, nil, provider, AuthConfig{}, nil, "")
|
| 133 |
+
|
| 134 |
+
req := httptest.NewRequest(http.MethodDelete, "/api/v1/pricing?model=openai%2Fgpt-4.1", nil)
|
| 135 |
+
resp := httptest.NewRecorder()
|
| 136 |
+
router.ServeHTTP(resp, req)
|
| 137 |
+
|
| 138 |
+
if resp.Code != http.StatusNoContent {
|
| 139 |
+
t.Fatalf("expected status 204, got %d %s", resp.Code, resp.Body.String())
|
| 140 |
+
}
|
| 141 |
+
if provider.deleted != "openai/gpt-4.1" {
|
| 142 |
+
t.Fatalf("expected model to be deleted, got %q", provider.deleted)
|
| 143 |
+
}
|
| 144 |
+
}
|
internal/api/router.go
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package api
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"bytes"
|
| 5 |
+
"context"
|
| 6 |
+
"errors"
|
| 7 |
+
"io"
|
| 8 |
+
"io/fs"
|
| 9 |
+
"log/slog"
|
| 10 |
+
"net/http"
|
| 11 |
+
"path"
|
| 12 |
+
"strconv"
|
| 13 |
+
"strings"
|
| 14 |
+
"sync"
|
| 15 |
+
"time"
|
| 16 |
+
|
| 17 |
+
"cpa-usage-keeper/internal/poller"
|
| 18 |
+
"cpa-usage-keeper/internal/service"
|
| 19 |
+
"github.com/gin-gonic/gin"
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
const appBasePathPlaceholder = "__APP_BASE_PATH__"
|
| 23 |
+
const manualSyncRateLimitWindow = time.Second
|
| 24 |
+
|
| 25 |
+
type syncLimiter struct {
|
| 26 |
+
mu sync.Mutex
|
| 27 |
+
window time.Duration
|
| 28 |
+
lastSync time.Time
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
func (l *syncLimiter) allow(now time.Time) bool {
|
| 32 |
+
l.mu.Lock()
|
| 33 |
+
defer l.mu.Unlock()
|
| 34 |
+
if !l.lastSync.IsZero() && now.Sub(l.lastSync) < l.window {
|
| 35 |
+
return false
|
| 36 |
+
}
|
| 37 |
+
l.lastSync = now
|
| 38 |
+
return true
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
type StatusProvider interface {
|
| 42 |
+
Status() poller.Status
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
type SyncRunner interface {
|
| 46 |
+
SyncNow(ctx context.Context) error
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
type syncUserMessageError interface {
|
| 50 |
+
UserMessage() string
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
func NewRouter(
|
| 54 |
+
staticFS fs.FS,
|
| 55 |
+
statusProvider StatusProvider,
|
| 56 |
+
usageProvider service.UsageProvider,
|
| 57 |
+
pricingProvider service.PricingProvider,
|
| 58 |
+
authConfig AuthConfig,
|
| 59 |
+
authHandler *authHandler,
|
| 60 |
+
basePath string,
|
| 61 |
+
usageIdentityProviders ...service.UsageIdentityProvider,
|
| 62 |
+
) *gin.Engine {
|
| 63 |
+
router := gin.New()
|
| 64 |
+
router.Use(gin.Recovery())
|
| 65 |
+
|
| 66 |
+
appGroup := router.Group(basePath)
|
| 67 |
+
registerHealthRoutes(appGroup)
|
| 68 |
+
|
| 69 |
+
apiV1 := appGroup.Group("/api/v1")
|
| 70 |
+
apiV1.GET("/ping", func(c *gin.Context) {
|
| 71 |
+
c.JSON(http.StatusOK, gin.H{"message": "ok"})
|
| 72 |
+
})
|
| 73 |
+
|
| 74 |
+
authGroup := apiV1.Group("/auth")
|
| 75 |
+
if authHandler == nil {
|
| 76 |
+
authHandler = NewAuthHandler(authConfig, nil)
|
| 77 |
+
}
|
| 78 |
+
authHandler.registerRoutes(authGroup)
|
| 79 |
+
|
| 80 |
+
var usageIdentityProvider service.UsageIdentityProvider
|
| 81 |
+
if len(usageIdentityProviders) > 0 {
|
| 82 |
+
usageIdentityProvider = usageIdentityProviders[0]
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
protected := apiV1.Group("")
|
| 86 |
+
protected.Use(authHandler.middleware())
|
| 87 |
+
registerStatusRoutes(protected, statusProvider)
|
| 88 |
+
registerSyncRoutes(protected, statusProvider, &syncLimiter{window: manualSyncRateLimitWindow})
|
| 89 |
+
registerUsageOverviewRoute(protected, usageProvider)
|
| 90 |
+
registerUsageAnalysisRoute(protected, usageProvider)
|
| 91 |
+
registerUsageEventsRoute(protected, usageProvider, usageIdentityProvider)
|
| 92 |
+
registerUsageCredentialsRoute(protected, usageProvider, usageIdentityProvider)
|
| 93 |
+
registerUsageIdentityRoutes(protected, usageIdentityProvider)
|
| 94 |
+
registerPricingRoutes(protected, pricingProvider)
|
| 95 |
+
|
| 96 |
+
if staticFS != nil {
|
| 97 |
+
if indexFile, err := staticFS.Open("index.html"); err == nil {
|
| 98 |
+
_ = indexFile.Close()
|
| 99 |
+
httpFS := http.FS(staticFS)
|
| 100 |
+
serveIndex := func(c *gin.Context) {
|
| 101 |
+
indexHTML, err := renderIndexHTML(staticFS, basePath)
|
| 102 |
+
if err != nil {
|
| 103 |
+
c.Status(http.StatusNotFound)
|
| 104 |
+
return
|
| 105 |
+
}
|
| 106 |
+
c.Data(http.StatusOK, "text/html; charset=utf-8", indexHTML)
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
appGroup.GET("/", serveIndex)
|
| 110 |
+
assetsFS, _ := fs.Sub(staticFS, "assets")
|
| 111 |
+
appGroup.StaticFS("/assets", http.FS(assetsFS))
|
| 112 |
+
router.NoRoute(func(c *gin.Context) {
|
| 113 |
+
requestPath, ok := stripBasePath(basePath, c.Request.URL.Path)
|
| 114 |
+
if !ok {
|
| 115 |
+
c.Status(http.StatusNotFound)
|
| 116 |
+
return
|
| 117 |
+
}
|
| 118 |
+
if strings.HasPrefix(requestPath, "/api/") {
|
| 119 |
+
c.Status(http.StatusNotFound)
|
| 120 |
+
return
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
if assetPath, ok := staticAssetPath(requestPath); ok {
|
| 124 |
+
if assetFile, err := staticFS.Open(assetPath); err == nil {
|
| 125 |
+
_ = assetFile.Close()
|
| 126 |
+
c.FileFromFS(assetPath, httpFS)
|
| 127 |
+
return
|
| 128 |
+
}
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
serveIndex(c)
|
| 132 |
+
})
|
| 133 |
+
}
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
return router
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
func renderIndexHTML(staticFS fs.FS, basePath string) ([]byte, error) {
|
| 140 |
+
indexFile, err := staticFS.Open("index.html")
|
| 141 |
+
if err != nil {
|
| 142 |
+
return nil, err
|
| 143 |
+
}
|
| 144 |
+
defer indexFile.Close()
|
| 145 |
+
indexHTML, err := io.ReadAll(indexFile)
|
| 146 |
+
if err != nil {
|
| 147 |
+
return nil, err
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
return bytes.ReplaceAll(
|
| 151 |
+
indexHTML,
|
| 152 |
+
[]byte(strconv.Quote(appBasePathPlaceholder)),
|
| 153 |
+
[]byte(strconv.Quote(basePath)),
|
| 154 |
+
), nil
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
func cleanURLPath(requestPath string) string {
|
| 158 |
+
cleaned := path.Clean(requestPath)
|
| 159 |
+
if cleaned == "." {
|
| 160 |
+
return "/"
|
| 161 |
+
}
|
| 162 |
+
if !strings.HasPrefix(cleaned, "/") {
|
| 163 |
+
return "/" + cleaned
|
| 164 |
+
}
|
| 165 |
+
return cleaned
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
func staticAssetPath(requestPath string) (string, bool) {
|
| 169 |
+
cleaned := cleanURLPath(requestPath)
|
| 170 |
+
if strings.Contains(cleaned, "\\") {
|
| 171 |
+
return "", false
|
| 172 |
+
}
|
| 173 |
+
relPath := strings.TrimPrefix(cleaned, "/")
|
| 174 |
+
if relPath == "" {
|
| 175 |
+
return "", false
|
| 176 |
+
}
|
| 177 |
+
return relPath, true
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
func stripBasePath(basePath, requestPath string) (string, bool) {
|
| 181 |
+
cleaned := cleanURLPath(requestPath)
|
| 182 |
+
if basePath == "" {
|
| 183 |
+
return cleaned, true
|
| 184 |
+
}
|
| 185 |
+
if cleaned == basePath {
|
| 186 |
+
return "/", true
|
| 187 |
+
}
|
| 188 |
+
if !strings.HasPrefix(cleaned, basePath+"/") {
|
| 189 |
+
return "", false
|
| 190 |
+
}
|
| 191 |
+
trimmed := strings.TrimPrefix(cleaned, basePath)
|
| 192 |
+
if trimmed == "" {
|
| 193 |
+
return "/", true
|
| 194 |
+
}
|
| 195 |
+
return trimmed, true
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
type statusResponse struct {
|
| 199 |
+
Running bool `json:"running"`
|
| 200 |
+
SyncRunning bool `json:"sync_running"`
|
| 201 |
+
Timezone string `json:"timezone"`
|
| 202 |
+
LastRunAt *time.Time `json:"last_run_at,omitempty"`
|
| 203 |
+
LastError string `json:"last_error,omitempty"`
|
| 204 |
+
LastWarning string `json:"last_warning,omitempty"`
|
| 205 |
+
LastStatus string `json:"last_status,omitempty"`
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
func registerStatusRoutes(router gin.IRoutes, statusProvider StatusProvider) {
|
| 209 |
+
router.GET("/status", func(c *gin.Context) {
|
| 210 |
+
if statusProvider == nil {
|
| 211 |
+
c.JSON(http.StatusOK, statusResponse{Timezone: time.Local.String()})
|
| 212 |
+
return
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
c.JSON(http.StatusOK, buildStatusResponse(statusProvider.Status()))
|
| 216 |
+
})
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
func manualSyncErrorMessage(err error) string {
|
| 220 |
+
var userMessage syncUserMessageError
|
| 221 |
+
if errors.As(err, &userMessage) && userMessage.UserMessage() != "" {
|
| 222 |
+
return userMessage.UserMessage()
|
| 223 |
+
}
|
| 224 |
+
return "manual sync failed"
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
func registerSyncRoutes(router gin.IRoutes, statusProvider StatusProvider, limiter *syncLimiter) {
|
| 228 |
+
router.POST("/sync", func(c *gin.Context) {
|
| 229 |
+
if limiter != nil && !limiter.allow(time.Now()) {
|
| 230 |
+
c.JSON(http.StatusTooManyRequests, gin.H{"error": "sync rate limit exceeded"})
|
| 231 |
+
return
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
syncRunner, ok := statusProvider.(SyncRunner)
|
| 235 |
+
if !ok || syncRunner == nil {
|
| 236 |
+
writeInternalError(c, "sync runner is not configured", nil)
|
| 237 |
+
return
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
if err := syncRunner.SyncNow(c.Request.Context()); err != nil {
|
| 241 |
+
if errors.Is(err, poller.ErrSyncAlreadyRunning) {
|
| 242 |
+
c.JSON(http.StatusConflict, gin.H{"error": "sync already running"})
|
| 243 |
+
return
|
| 244 |
+
}
|
| 245 |
+
slog.Error("manual sync failed", "error", err)
|
| 246 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": manualSyncErrorMessage(err)})
|
| 247 |
+
return
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
if statusProvider, ok := syncRunner.(StatusProvider); ok {
|
| 251 |
+
c.JSON(http.StatusOK, buildStatusResponse(statusProvider.Status()))
|
| 252 |
+
return
|
| 253 |
+
}
|
| 254 |
+
c.JSON(http.StatusOK, gin.H{"sync_running": false})
|
| 255 |
+
})
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
func buildStatusResponse(status poller.Status) statusResponse {
|
| 259 |
+
response := statusResponse{
|
| 260 |
+
Running: status.Running,
|
| 261 |
+
SyncRunning: status.SyncRunning,
|
| 262 |
+
Timezone: time.Local.String(),
|
| 263 |
+
LastError: status.LastError,
|
| 264 |
+
LastWarning: status.LastWarning,
|
| 265 |
+
LastStatus: status.LastStatus,
|
| 266 |
+
}
|
| 267 |
+
if !status.LastRunAt.IsZero() {
|
| 268 |
+
lastRunAt := status.LastRunAt.UTC()
|
| 269 |
+
response.LastRunAt = &lastRunAt
|
| 270 |
+
}
|
| 271 |
+
return response
|
| 272 |
+
}
|
internal/api/router_test.go
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package api
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"context"
|
| 5 |
+
"io/fs"
|
| 6 |
+
"net/http"
|
| 7 |
+
"net/http/httptest"
|
| 8 |
+
"testing"
|
| 9 |
+
"testing/fstest"
|
| 10 |
+
"time"
|
| 11 |
+
|
| 12 |
+
"cpa-usage-keeper/internal/poller"
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
func testStaticFS(t *testing.T, files map[string]string) fs.FS {
|
| 16 |
+
t.Helper()
|
| 17 |
+
staticFS := fstest.MapFS{}
|
| 18 |
+
for name, content := range files {
|
| 19 |
+
staticFS[name] = &fstest.MapFile{Data: []byte(content), Mode: 0o644}
|
| 20 |
+
}
|
| 21 |
+
return staticFS
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
type statusStub struct {
|
| 25 |
+
status poller.Status
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
type syncStatusStub struct {
|
| 29 |
+
status poller.Status
|
| 30 |
+
calls int
|
| 31 |
+
err error
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
type userFacingSyncError struct {
|
| 35 |
+
message string
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
func (e userFacingSyncError) Error() string {
|
| 39 |
+
return e.message
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
func (e userFacingSyncError) UserMessage() string {
|
| 43 |
+
return e.message
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
func (s statusStub) Status() poller.Status {
|
| 47 |
+
return s.status
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
func (s *syncStatusStub) Status() poller.Status {
|
| 51 |
+
return s.status
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
func (s *syncStatusStub) SyncNow(context.Context) error {
|
| 55 |
+
s.calls++
|
| 56 |
+
return s.err
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
func TestHealthzReturnsOK(t *testing.T) {
|
| 60 |
+
router := NewRouter(nil, nil, nil, nil, AuthConfig{}, nil, "")
|
| 61 |
+
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
| 62 |
+
resp := httptest.NewRecorder()
|
| 63 |
+
|
| 64 |
+
router.ServeHTTP(resp, req)
|
| 65 |
+
|
| 66 |
+
if resp.Code != http.StatusOK {
|
| 67 |
+
t.Fatalf("expected status 200, got %d", resp.Code)
|
| 68 |
+
}
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
func TestStatusReturnsPollerState(t *testing.T) {
|
| 72 |
+
lastRunAt := time.Date(2026, 4, 16, 12, 0, 0, 0, time.UTC)
|
| 73 |
+
router := NewRouter(nil, statusStub{status: poller.Status{
|
| 74 |
+
Running: true,
|
| 75 |
+
SyncRunning: false,
|
| 76 |
+
LastRunAt: lastRunAt,
|
| 77 |
+
LastError: "boom",
|
| 78 |
+
LastWarning: "metadata unavailable",
|
| 79 |
+
LastStatus: "completed_with_warnings",
|
| 80 |
+
}}, nil, nil, AuthConfig{}, nil, "")
|
| 81 |
+
|
| 82 |
+
req := httptest.NewRequest(http.MethodGet, "/api/v1/status", nil)
|
| 83 |
+
resp := httptest.NewRecorder()
|
| 84 |
+
router.ServeHTTP(resp, req)
|
| 85 |
+
|
| 86 |
+
if resp.Code != http.StatusOK {
|
| 87 |
+
t.Fatalf("expected status 200, got %d", resp.Code)
|
| 88 |
+
}
|
| 89 |
+
body := resp.Body.String()
|
| 90 |
+
if !(contains(body, `"running":true`) && contains(body, `"sync_running":false`) && contains(body, `"last_error":"boom"`) && contains(body, `"last_warning":"metadata unavailable"`) && contains(body, `"last_status":"completed_with_warnings"`) && contains(body, `"last_run_at":"2026-04-16T12:00:00Z"`)) {
|
| 91 |
+
t.Fatalf("unexpected response body: %s", body)
|
| 92 |
+
}
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
func TestStatusReturnsProjectTimezone(t *testing.T) {
|
| 96 |
+
previousLocal := time.Local
|
| 97 |
+
location, err := time.LoadLocation("Asia/Shanghai")
|
| 98 |
+
if err != nil {
|
| 99 |
+
t.Fatalf("load location: %v", err)
|
| 100 |
+
}
|
| 101 |
+
t.Cleanup(func() { time.Local = previousLocal })
|
| 102 |
+
time.Local = location
|
| 103 |
+
|
| 104 |
+
router := NewRouter(nil, nil, nil, nil, AuthConfig{}, nil, "")
|
| 105 |
+
req := httptest.NewRequest(http.MethodGet, "/api/v1/status", nil)
|
| 106 |
+
resp := httptest.NewRecorder()
|
| 107 |
+
router.ServeHTTP(resp, req)
|
| 108 |
+
|
| 109 |
+
if resp.Code != http.StatusOK {
|
| 110 |
+
t.Fatalf("expected status 200, got %d", resp.Code)
|
| 111 |
+
}
|
| 112 |
+
if body := resp.Body.String(); !contains(body, `"timezone":"Asia/Shanghai"`) {
|
| 113 |
+
t.Fatalf("expected status response to include project timezone, got %s", body)
|
| 114 |
+
}
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
func TestStatusReturnsEmptyStateWithoutProvider(t *testing.T) {
|
| 118 |
+
router := NewRouter(nil, nil, nil, nil, AuthConfig{}, nil, "")
|
| 119 |
+
req := httptest.NewRequest(http.MethodGet, "/api/v1/status", nil)
|
| 120 |
+
resp := httptest.NewRecorder()
|
| 121 |
+
router.ServeHTTP(resp, req)
|
| 122 |
+
|
| 123 |
+
if resp.Code != http.StatusOK {
|
| 124 |
+
t.Fatalf("expected status 200, got %d", resp.Code)
|
| 125 |
+
}
|
| 126 |
+
if body := resp.Body.String(); !contains(body, `"running":false`) || !contains(body, `"sync_running":false`) || !contains(body, `"timezone":`) {
|
| 127 |
+
t.Fatalf("unexpected response body: %s", body)
|
| 128 |
+
}
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
func TestManualSyncTriggersSyncRunner(t *testing.T) {
|
| 132 |
+
lastRunAt := time.Date(2026, 4, 16, 12, 0, 0, 0, time.UTC)
|
| 133 |
+
syncer := &syncStatusStub{status: poller.Status{Running: true, LastRunAt: lastRunAt, LastStatus: "completed"}}
|
| 134 |
+
router := NewRouter(nil, syncer, nil, nil, AuthConfig{}, nil, "")
|
| 135 |
+
req := httptest.NewRequest(http.MethodPost, "/api/v1/sync", nil)
|
| 136 |
+
resp := httptest.NewRecorder()
|
| 137 |
+
|
| 138 |
+
router.ServeHTTP(resp, req)
|
| 139 |
+
|
| 140 |
+
if resp.Code != http.StatusOK {
|
| 141 |
+
t.Fatalf("expected status 200, got %d", resp.Code)
|
| 142 |
+
}
|
| 143 |
+
if syncer.calls != 1 {
|
| 144 |
+
t.Fatalf("expected sync runner to be called once, got %d", syncer.calls)
|
| 145 |
+
}
|
| 146 |
+
body := resp.Body.String()
|
| 147 |
+
if !(contains(body, `"last_status":"completed"`) && contains(body, `"last_run_at":"2026-04-16T12:00:00Z"`)) {
|
| 148 |
+
t.Fatalf("unexpected response body: %s", body)
|
| 149 |
+
}
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
func TestManualSyncReturnsConflictWhenAlreadyRunning(t *testing.T) {
|
| 153 |
+
syncer := &syncStatusStub{err: poller.ErrSyncAlreadyRunning}
|
| 154 |
+
router := NewRouter(nil, syncer, nil, nil, AuthConfig{}, nil, "")
|
| 155 |
+
req := httptest.NewRequest(http.MethodPost, "/api/v1/sync", nil)
|
| 156 |
+
resp := httptest.NewRecorder()
|
| 157 |
+
|
| 158 |
+
router.ServeHTTP(resp, req)
|
| 159 |
+
|
| 160 |
+
if resp.Code != http.StatusConflict {
|
| 161 |
+
t.Fatalf("expected status 409, got %d", resp.Code)
|
| 162 |
+
}
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
func TestManualSyncReturnsWarningsAsError(t *testing.T) {
|
| 166 |
+
syncer := &syncStatusStub{
|
| 167 |
+
status: poller.Status{LastStatus: "completed_with_warnings", LastWarning: "metadata unavailable"},
|
| 168 |
+
err: poller.ErrSyncCompletedWithWarnings,
|
| 169 |
+
}
|
| 170 |
+
router := NewRouter(nil, syncer, nil, nil, AuthConfig{}, nil, "")
|
| 171 |
+
req := httptest.NewRequest(http.MethodPost, "/api/v1/sync", nil)
|
| 172 |
+
resp := httptest.NewRecorder()
|
| 173 |
+
|
| 174 |
+
router.ServeHTTP(resp, req)
|
| 175 |
+
|
| 176 |
+
if resp.Code != http.StatusInternalServerError {
|
| 177 |
+
t.Fatalf("expected status 500, got %d", resp.Code)
|
| 178 |
+
}
|
| 179 |
+
if body := resp.Body.String(); !contains(body, `"error":"manual sync failed"`) {
|
| 180 |
+
t.Fatalf("unexpected response body: %s", body)
|
| 181 |
+
}
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
func TestManualSyncReturnsUserFacingStageError(t *testing.T) {
|
| 185 |
+
syncer := &syncStatusStub{err: userFacingSyncError{message: "metadata sync failed"}}
|
| 186 |
+
router := NewRouter(nil, syncer, nil, nil, AuthConfig{}, nil, "")
|
| 187 |
+
req := httptest.NewRequest(http.MethodPost, "/api/v1/sync", nil)
|
| 188 |
+
resp := httptest.NewRecorder()
|
| 189 |
+
|
| 190 |
+
router.ServeHTTP(resp, req)
|
| 191 |
+
|
| 192 |
+
if resp.Code != http.StatusInternalServerError {
|
| 193 |
+
t.Fatalf("expected status 500, got %d", resp.Code)
|
| 194 |
+
}
|
| 195 |
+
if body := resp.Body.String(); !contains(body, `"error":"metadata sync failed"`) {
|
| 196 |
+
t.Fatalf("unexpected response body: %s", body)
|
| 197 |
+
}
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
func TestManualSyncRateLimitsRepeatedRequests(t *testing.T) {
|
| 201 |
+
syncer := &syncStatusStub{status: poller.Status{LastStatus: "completed"}}
|
| 202 |
+
router := NewRouter(nil, syncer, nil, nil, AuthConfig{}, nil, "")
|
| 203 |
+
|
| 204 |
+
firstResp := httptest.NewRecorder()
|
| 205 |
+
firstReq := httptest.NewRequest(http.MethodPost, "/api/v1/sync", nil)
|
| 206 |
+
router.ServeHTTP(firstResp, firstReq)
|
| 207 |
+
if firstResp.Code != http.StatusOK {
|
| 208 |
+
t.Fatalf("expected first sync to return 200, got %d", firstResp.Code)
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
secondResp := httptest.NewRecorder()
|
| 212 |
+
secondReq := httptest.NewRequest(http.MethodPost, "/api/v1/sync", nil)
|
| 213 |
+
router.ServeHTTP(secondResp, secondReq)
|
| 214 |
+
if secondResp.Code != http.StatusTooManyRequests {
|
| 215 |
+
t.Fatalf("expected second sync within one second to return 429, got %d", secondResp.Code)
|
| 216 |
+
}
|
| 217 |
+
if syncer.calls != 1 {
|
| 218 |
+
t.Fatalf("expected rate-limited sync not to call runner again, got %d calls", syncer.calls)
|
| 219 |
+
}
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
func TestSubpathRoutesOnlyServePrefixedEndpoints(t *testing.T) {
|
| 223 |
+
lastRunAt := time.Date(2026, 4, 16, 12, 0, 0, 0, time.UTC)
|
| 224 |
+
router := NewRouter(nil, statusStub{status: poller.Status{
|
| 225 |
+
Running: true,
|
| 226 |
+
LastRunAt: lastRunAt,
|
| 227 |
+
}}, nil, nil, AuthConfig{BasePath: "/cpa"}, nil, "/cpa")
|
| 228 |
+
|
| 229 |
+
for _, testCase := range []struct {
|
| 230 |
+
path string
|
| 231 |
+
statusCode int
|
| 232 |
+
}{
|
| 233 |
+
{path: "/cpa/healthz", statusCode: http.StatusOK},
|
| 234 |
+
{path: "/cpa/api/v1/status", statusCode: http.StatusOK},
|
| 235 |
+
{path: "/healthz", statusCode: http.StatusNotFound},
|
| 236 |
+
{path: "/api/v1/status", statusCode: http.StatusNotFound},
|
| 237 |
+
} {
|
| 238 |
+
resp := httptest.NewRecorder()
|
| 239 |
+
req := httptest.NewRequest(http.MethodGet, testCase.path, nil)
|
| 240 |
+
router.ServeHTTP(resp, req)
|
| 241 |
+
if resp.Code != testCase.statusCode {
|
| 242 |
+
t.Fatalf("expected %s to return %d, got %d", testCase.path, testCase.statusCode, resp.Code)
|
| 243 |
+
}
|
| 244 |
+
}
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
func TestSubpathStaticRoutesServeOnlyUnderPrefix(t *testing.T) {
|
| 248 |
+
staticFS := testStaticFS(t, map[string]string{
|
| 249 |
+
"index.html": `<html><head><script>window.__APP_BASE_PATH__ = "__APP_BASE_PATH__";</script></head><body>app</body></html>`,
|
| 250 |
+
"assets/app.js": "console.log('ok')",
|
| 251 |
+
})
|
| 252 |
+
|
| 253 |
+
router := NewRouter(staticFS, nil, nil, nil, AuthConfig{BasePath: "/cpa"}, nil, "/cpa")
|
| 254 |
+
|
| 255 |
+
for _, testCase := range []struct {
|
| 256 |
+
path string
|
| 257 |
+
statusCode int
|
| 258 |
+
contains string
|
| 259 |
+
}{
|
| 260 |
+
{path: "/cpa/", statusCode: http.StatusOK, contains: `window.__APP_BASE_PATH__ = "/cpa";`},
|
| 261 |
+
{path: "/cpa/dashboard", statusCode: http.StatusOK, contains: `window.__APP_BASE_PATH__ = "/cpa";`},
|
| 262 |
+
{path: "/cpa/assets/app.js", statusCode: http.StatusOK, contains: "console.log('ok')"},
|
| 263 |
+
{path: "/cpa/missing.html", statusCode: http.StatusOK, contains: `window.__APP_BASE_PATH__ = "/cpa";`},
|
| 264 |
+
{path: "/foo", statusCode: http.StatusNotFound},
|
| 265 |
+
{path: "/assets/app.js", statusCode: http.StatusNotFound},
|
| 266 |
+
{path: "/cpa/api/unknown", statusCode: http.StatusNotFound},
|
| 267 |
+
} {
|
| 268 |
+
resp := httptest.NewRecorder()
|
| 269 |
+
req := httptest.NewRequest(http.MethodGet, testCase.path, nil)
|
| 270 |
+
router.ServeHTTP(resp, req)
|
| 271 |
+
if resp.Code != testCase.statusCode {
|
| 272 |
+
t.Fatalf("expected %s to return %d, got %d", testCase.path, testCase.statusCode, resp.Code)
|
| 273 |
+
}
|
| 274 |
+
if testCase.contains != "" && !contains(resp.Body.String(), testCase.contains) {
|
| 275 |
+
t.Fatalf("expected %s response to contain %q, got %s", testCase.path, testCase.contains, resp.Body.String())
|
| 276 |
+
}
|
| 277 |
+
}
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
func TestCleanURLPathUsesSlashSemantics(t *testing.T) {
|
| 281 |
+
if cleaned := cleanURLPath("/cpa//dashboard/../assets/app.js"); cleaned != "/cpa/assets/app.js" {
|
| 282 |
+
t.Fatalf("expected slash-normalized URL path, got %q", cleaned)
|
| 283 |
+
}
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
func TestStaticAssetPathRejectsBackslashTraversal(t *testing.T) {
|
| 287 |
+
if _, ok := staticAssetPath(`/..\.env`); ok {
|
| 288 |
+
t.Fatal("expected backslash traversal path to be rejected")
|
| 289 |
+
}
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
func TestRootStaticRouteInjectsEmptyBasePath(t *testing.T) {
|
| 293 |
+
staticFS := testStaticFS(t, map[string]string{
|
| 294 |
+
"index.html": `<html><head><script>window.__APP_BASE_PATH__ = "__APP_BASE_PATH__";</script></head><body>app</body></html>`,
|
| 295 |
+
})
|
| 296 |
+
|
| 297 |
+
router := NewRouter(staticFS, nil, nil, nil, AuthConfig{}, nil, "")
|
| 298 |
+
resp := httptest.NewRecorder()
|
| 299 |
+
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
| 300 |
+
router.ServeHTTP(resp, req)
|
| 301 |
+
|
| 302 |
+
if resp.Code != http.StatusOK {
|
| 303 |
+
t.Fatalf("expected status 200, got %d", resp.Code)
|
| 304 |
+
}
|
| 305 |
+
if !contains(resp.Body.String(), `window.__APP_BASE_PATH__ = "";`) {
|
| 306 |
+
t.Fatalf("expected injected empty base path, got %s", resp.Body.String())
|
| 307 |
+
}
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
func contains(s, sub string) bool {
|
| 311 |
+
return len(sub) == 0 || (len(s) >= len(sub) && (func() bool { return stringContains(s, sub) })())
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
func stringContains(s, sub string) bool {
|
| 315 |
+
for i := 0; i+len(sub) <= len(s); i++ {
|
| 316 |
+
if s[i:i+len(sub)] == sub {
|
| 317 |
+
return true
|
| 318 |
+
}
|
| 319 |
+
}
|
| 320 |
+
return false
|
| 321 |
+
}
|
internal/api/usage_analysis.go
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package api
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"net/http"
|
| 5 |
+
"time"
|
| 6 |
+
|
| 7 |
+
"cpa-usage-keeper/internal/redact"
|
| 8 |
+
"cpa-usage-keeper/internal/service"
|
| 9 |
+
"github.com/gin-gonic/gin"
|
| 10 |
+
)
|
| 11 |
+
|
| 12 |
+
type usageAnalysisResponse struct {
|
| 13 |
+
APIs []usageAnalysisAPIPayload `json:"apis"`
|
| 14 |
+
Models []usageAnalysisModelPayload `json:"models"`
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
type usageAnalysisAPIPayload struct {
|
| 18 |
+
APIKey string `json:"api_key"`
|
| 19 |
+
DisplayName string `json:"display_name"`
|
| 20 |
+
TotalRequests int64 `json:"total_requests"`
|
| 21 |
+
SuccessCount int64 `json:"success_count"`
|
| 22 |
+
FailureCount int64 `json:"failure_count"`
|
| 23 |
+
InputTokens int64 `json:"input_tokens"`
|
| 24 |
+
OutputTokens int64 `json:"output_tokens"`
|
| 25 |
+
ReasoningTokens int64 `json:"reasoning_tokens"`
|
| 26 |
+
CachedTokens int64 `json:"cached_tokens"`
|
| 27 |
+
TotalTokens int64 `json:"total_tokens"`
|
| 28 |
+
Models []usageAnalysisModelPayload `json:"models"`
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
type usageAnalysisModelPayload struct {
|
| 32 |
+
Model string `json:"model"`
|
| 33 |
+
TotalRequests int64 `json:"total_requests"`
|
| 34 |
+
SuccessCount int64 `json:"success_count"`
|
| 35 |
+
FailureCount int64 `json:"failure_count"`
|
| 36 |
+
InputTokens int64 `json:"input_tokens"`
|
| 37 |
+
OutputTokens int64 `json:"output_tokens"`
|
| 38 |
+
ReasoningTokens int64 `json:"reasoning_tokens"`
|
| 39 |
+
CachedTokens int64 `json:"cached_tokens"`
|
| 40 |
+
TotalTokens int64 `json:"total_tokens"`
|
| 41 |
+
TotalLatencyMS int64 `json:"total_latency_ms"`
|
| 42 |
+
LatencySampleCount int64 `json:"latency_sample_count"`
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
func registerUsageAnalysisRoute(router gin.IRoutes, usageProvider service.UsageProvider) {
|
| 46 |
+
router.GET("/usage/analysis", func(c *gin.Context) {
|
| 47 |
+
if usageProvider == nil {
|
| 48 |
+
c.JSON(http.StatusOK, usageAnalysisResponse{APIs: []usageAnalysisAPIPayload{}, Models: []usageAnalysisModelPayload{}})
|
| 49 |
+
return
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
filter, err := parseUsageFilterQuery(c.Request, time.Now().UTC())
|
| 53 |
+
if err != nil {
|
| 54 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
| 55 |
+
return
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
analysis, err := usageProvider.GetUsageAnalysis(c.Request.Context(), filter)
|
| 59 |
+
if err != nil {
|
| 60 |
+
writeInternalError(c, "get usage analysis failed", err)
|
| 61 |
+
return
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
c.JSON(http.StatusOK, buildUsageAnalysisPayload(analysis))
|
| 65 |
+
})
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
func buildUsageAnalysisPayload(snapshot *service.UsageAnalysisSnapshot) usageAnalysisResponse {
|
| 69 |
+
if snapshot == nil {
|
| 70 |
+
return usageAnalysisResponse{APIs: []usageAnalysisAPIPayload{}, Models: []usageAnalysisModelPayload{}}
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
apis := make([]usageAnalysisAPIPayload, 0, len(snapshot.APIs))
|
| 74 |
+
for _, api := range snapshot.APIs {
|
| 75 |
+
models := make([]usageAnalysisModelPayload, 0, len(api.Models))
|
| 76 |
+
for _, model := range api.Models {
|
| 77 |
+
models = append(models, usageAnalysisModelPayload{
|
| 78 |
+
Model: model.Model,
|
| 79 |
+
TotalRequests: model.TotalRequests,
|
| 80 |
+
SuccessCount: model.SuccessCount,
|
| 81 |
+
FailureCount: model.FailureCount,
|
| 82 |
+
InputTokens: model.InputTokens,
|
| 83 |
+
OutputTokens: model.OutputTokens,
|
| 84 |
+
ReasoningTokens: model.ReasoningTokens,
|
| 85 |
+
CachedTokens: model.CachedTokens,
|
| 86 |
+
TotalTokens: model.TotalTokens,
|
| 87 |
+
TotalLatencyMS: model.TotalLatencyMS,
|
| 88 |
+
LatencySampleCount: model.LatencySampleCount,
|
| 89 |
+
})
|
| 90 |
+
}
|
| 91 |
+
apiKey := redact.APIAlias(api.APIKey)
|
| 92 |
+
displayName := redact.APIKeyDisplayName(api.APIKey)
|
| 93 |
+
apis = append(apis, usageAnalysisAPIPayload{
|
| 94 |
+
APIKey: apiKey,
|
| 95 |
+
DisplayName: displayName,
|
| 96 |
+
TotalRequests: api.TotalRequests,
|
| 97 |
+
SuccessCount: api.SuccessCount,
|
| 98 |
+
FailureCount: api.FailureCount,
|
| 99 |
+
InputTokens: api.InputTokens,
|
| 100 |
+
OutputTokens: api.OutputTokens,
|
| 101 |
+
ReasoningTokens: api.ReasoningTokens,
|
| 102 |
+
CachedTokens: api.CachedTokens,
|
| 103 |
+
TotalTokens: api.TotalTokens,
|
| 104 |
+
Models: models,
|
| 105 |
+
})
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
models := make([]usageAnalysisModelPayload, 0, len(snapshot.Models))
|
| 109 |
+
for _, model := range snapshot.Models {
|
| 110 |
+
models = append(models, usageAnalysisModelPayload{
|
| 111 |
+
Model: model.Model,
|
| 112 |
+
TotalRequests: model.TotalRequests,
|
| 113 |
+
SuccessCount: model.SuccessCount,
|
| 114 |
+
FailureCount: model.FailureCount,
|
| 115 |
+
InputTokens: model.InputTokens,
|
| 116 |
+
OutputTokens: model.OutputTokens,
|
| 117 |
+
ReasoningTokens: model.ReasoningTokens,
|
| 118 |
+
CachedTokens: model.CachedTokens,
|
| 119 |
+
TotalTokens: model.TotalTokens,
|
| 120 |
+
TotalLatencyMS: model.TotalLatencyMS,
|
| 121 |
+
LatencySampleCount: model.LatencySampleCount,
|
| 122 |
+
})
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
return usageAnalysisResponse{APIs: apis, Models: models}
|
| 126 |
+
}
|
internal/api/usage_analysis_test.go
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package api
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"context"
|
| 5 |
+
"net/http"
|
| 6 |
+
"net/http/httptest"
|
| 7 |
+
"testing"
|
| 8 |
+
"time"
|
| 9 |
+
|
| 10 |
+
"cpa-usage-keeper/internal/cpa"
|
| 11 |
+
"cpa-usage-keeper/internal/redact"
|
| 12 |
+
"cpa-usage-keeper/internal/service"
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
type usageAnalysisStub struct {
|
| 16 |
+
analysis *service.UsageAnalysisSnapshot
|
| 17 |
+
err error
|
| 18 |
+
lastFilter service.UsageFilter
|
| 19 |
+
analysisCalls int
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
func (s *usageAnalysisStub) GetUsageWithFilter(context.Context, service.UsageFilter) (*cpa.StatisticsSnapshot, error) {
|
| 23 |
+
return nil, nil
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
func (s *usageAnalysisStub) GetUsageOverview(context.Context, service.UsageFilter) (*service.UsageOverviewSnapshot, error) {
|
| 27 |
+
return nil, nil
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
func (s *usageAnalysisStub) ListUsageEvents(context.Context, service.UsageFilter) (*service.UsageEventsPage, error) {
|
| 31 |
+
return nil, nil
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
func (s *usageAnalysisStub) ListUsageEventFilterOptions(context.Context, service.UsageFilter) (*service.UsageEventFilterOptions, error) {
|
| 35 |
+
return nil, nil
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
func (s *usageAnalysisStub) ListUsageCredentialStats(context.Context, service.UsageFilter) ([]service.UsageCredentialStat, error) {
|
| 39 |
+
return nil, nil
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
func (s *usageAnalysisStub) GetUsageAnalysis(_ context.Context, filter service.UsageFilter) (*service.UsageAnalysisSnapshot, error) {
|
| 43 |
+
s.lastFilter = filter
|
| 44 |
+
s.analysisCalls++
|
| 45 |
+
return s.analysis, s.err
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
func TestUsageAnalysisReturnsAggregatedRows(t *testing.T) {
|
| 49 |
+
provider := &usageAnalysisStub{analysis: &service.UsageAnalysisSnapshot{
|
| 50 |
+
APIs: []service.UsageAnalysisAPIStat{{
|
| 51 |
+
APIKey: "provider-a",
|
| 52 |
+
DisplayName: "provider-a",
|
| 53 |
+
TotalRequests: 2,
|
| 54 |
+
SuccessCount: 1,
|
| 55 |
+
FailureCount: 1,
|
| 56 |
+
TotalTokens: 42,
|
| 57 |
+
Models: []service.UsageAnalysisModelStat{{
|
| 58 |
+
Model: "claude-sonnet",
|
| 59 |
+
TotalRequests: 2,
|
| 60 |
+
SuccessCount: 1,
|
| 61 |
+
FailureCount: 1,
|
| 62 |
+
InputTokens: 30,
|
| 63 |
+
OutputTokens: 9,
|
| 64 |
+
ReasoningTokens: 2,
|
| 65 |
+
CachedTokens: 1,
|
| 66 |
+
TotalTokens: 42,
|
| 67 |
+
TotalLatencyMS: 350,
|
| 68 |
+
LatencySampleCount: 2,
|
| 69 |
+
}},
|
| 70 |
+
}},
|
| 71 |
+
Models: []service.UsageAnalysisModelStat{{
|
| 72 |
+
Model: "claude-sonnet",
|
| 73 |
+
TotalRequests: 2,
|
| 74 |
+
SuccessCount: 1,
|
| 75 |
+
FailureCount: 1,
|
| 76 |
+
InputTokens: 30,
|
| 77 |
+
OutputTokens: 9,
|
| 78 |
+
ReasoningTokens: 2,
|
| 79 |
+
CachedTokens: 1,
|
| 80 |
+
TotalTokens: 42,
|
| 81 |
+
TotalLatencyMS: 350,
|
| 82 |
+
LatencySampleCount: 2,
|
| 83 |
+
}},
|
| 84 |
+
}}
|
| 85 |
+
router := NewRouter(nil, nil, provider, nil, AuthConfig{}, nil, "")
|
| 86 |
+
req := httptest.NewRequest(http.MethodGet, "/api/v1/usage/analysis?range=24h", nil)
|
| 87 |
+
resp := httptest.NewRecorder()
|
| 88 |
+
|
| 89 |
+
router.ServeHTTP(resp, req)
|
| 90 |
+
|
| 91 |
+
if resp.Code != http.StatusOK {
|
| 92 |
+
t.Fatalf("expected status 200, got %d", resp.Code)
|
| 93 |
+
}
|
| 94 |
+
body := resp.Body.String()
|
| 95 |
+
if !contains(body, `"apis":[`) || !contains(body, `"models":[`) {
|
| 96 |
+
t.Fatalf("unexpected response body: %s", body)
|
| 97 |
+
}
|
| 98 |
+
if !contains(body, `"display_name":"prov**er-a"`) {
|
| 99 |
+
t.Fatalf("expected display name in response body: %s", body)
|
| 100 |
+
}
|
| 101 |
+
if !contains(body, `"api_key":"`+redact.APIAlias("provider-a")+`"`) {
|
| 102 |
+
t.Fatalf("expected redacted api key alias in response body: %s", body)
|
| 103 |
+
}
|
| 104 |
+
if !contains(body, `"model":"claude-sonnet"`) || !contains(body, `"latency_sample_count":2`) || !contains(body, `"total_latency_ms":350`) {
|
| 105 |
+
t.Fatalf("expected model latency aggregates in response body: %s", body)
|
| 106 |
+
}
|
| 107 |
+
if provider.analysisCalls != 1 {
|
| 108 |
+
t.Fatalf("expected GetUsageAnalysis to be called once, got %d", provider.analysisCalls)
|
| 109 |
+
}
|
| 110 |
+
if provider.lastFilter.Range != "24h" {
|
| 111 |
+
t.Fatalf("expected range to be passed through, got %+v", provider.lastFilter)
|
| 112 |
+
}
|
| 113 |
+
if provider.lastFilter.StartTime == nil || provider.lastFilter.EndTime == nil {
|
| 114 |
+
t.Fatalf("expected resolved time bounds in filter, got %+v", provider.lastFilter)
|
| 115 |
+
}
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
func TestUsageAnalysisRequiresAuthWhenEnabled(t *testing.T) {
|
| 119 |
+
router := NewRouter(nil, nil, &usageAnalysisStub{}, nil, AuthConfig{Enabled: true, LoginPassword: "secret", SessionTTL: time.Hour}, nil, "")
|
| 120 |
+
req := httptest.NewRequest(http.MethodGet, "/api/v1/usage/analysis", nil)
|
| 121 |
+
resp := httptest.NewRecorder()
|
| 122 |
+
|
| 123 |
+
router.ServeHTTP(resp, req)
|
| 124 |
+
|
| 125 |
+
if resp.Code != http.StatusUnauthorized {
|
| 126 |
+
t.Fatalf("expected status 401, got %d", resp.Code)
|
| 127 |
+
}
|
| 128 |
+
}
|
internal/api/usage_credentials.go
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package api
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"net/http"
|
| 5 |
+
"time"
|
| 6 |
+
|
| 7 |
+
"cpa-usage-keeper/internal/service"
|
| 8 |
+
"github.com/gin-gonic/gin"
|
| 9 |
+
)
|
| 10 |
+
|
| 11 |
+
type usageCredentialsResponse struct {
|
| 12 |
+
Credentials []usageCredentialPayload `json:"credentials"`
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
type usageCredentialPayload struct {
|
| 16 |
+
Source string `json:"source"`
|
| 17 |
+
SourceType string `json:"source_type,omitempty"`
|
| 18 |
+
SourceKey string `json:"source_key,omitempty"`
|
| 19 |
+
SuccessCount int64 `json:"success_count"`
|
| 20 |
+
FailureCount int64 `json:"failure_count"`
|
| 21 |
+
TotalCount int64 `json:"total_count"`
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
func registerUsageCredentialsRoute(
|
| 25 |
+
router gin.IRoutes,
|
| 26 |
+
usageProvider service.UsageProvider,
|
| 27 |
+
usageIdentityProvider service.UsageIdentityProvider,
|
| 28 |
+
) {
|
| 29 |
+
router.GET("/usage/credentials", func(c *gin.Context) {
|
| 30 |
+
if usageProvider == nil {
|
| 31 |
+
c.JSON(http.StatusOK, usageCredentialsResponse{Credentials: []usageCredentialPayload{}})
|
| 32 |
+
return
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
filter, err := parseUsageFilterQuery(c.Request, time.Now().UTC())
|
| 36 |
+
if err != nil {
|
| 37 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
| 38 |
+
return
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
rows, err := usageProvider.ListUsageCredentialStats(c.Request.Context(), filter)
|
| 42 |
+
if err != nil {
|
| 43 |
+
writeInternalError(c, "list usage credential stats failed", err)
|
| 44 |
+
return
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
identities, err := loadUsageResolutionData(c, usageIdentityProvider)
|
| 48 |
+
if err != nil {
|
| 49 |
+
writeInternalError(c, "load usage resolution data failed", err)
|
| 50 |
+
return
|
| 51 |
+
}
|
| 52 |
+
resolver := newUsageSourceResolver(identities)
|
| 53 |
+
c.JSON(http.StatusOK, usageCredentialsResponse{Credentials: buildUsageCredentialsPayload(rows, resolver)})
|
| 54 |
+
})
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
func buildUsageCredentialsPayload(rows []service.UsageCredentialStat, resolver usageSourceResolver) []usageCredentialPayload {
|
| 58 |
+
if len(rows) == 0 {
|
| 59 |
+
return []usageCredentialPayload{}
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
buckets := make(map[string]*usageCredentialPayload, len(rows))
|
| 63 |
+
orderedKeys := make([]string, 0, len(rows))
|
| 64 |
+
for _, row := range rows {
|
| 65 |
+
resolved := resolver.resolve(row.Source, row.AuthIndex)
|
| 66 |
+
bucketKey := resolved.SourceKey
|
| 67 |
+
if bucketKey == "" {
|
| 68 |
+
bucketKey = resolved.DisplayName
|
| 69 |
+
}
|
| 70 |
+
payload, ok := buckets[bucketKey]
|
| 71 |
+
if !ok {
|
| 72 |
+
payload = &usageCredentialPayload{
|
| 73 |
+
Source: resolved.DisplayName,
|
| 74 |
+
SourceType: resolved.SourceType,
|
| 75 |
+
SourceKey: resolved.SourceKey,
|
| 76 |
+
}
|
| 77 |
+
buckets[bucketKey] = payload
|
| 78 |
+
orderedKeys = append(orderedKeys, bucketKey)
|
| 79 |
+
}
|
| 80 |
+
if row.Failed {
|
| 81 |
+
payload.FailureCount += row.RequestCount
|
| 82 |
+
} else {
|
| 83 |
+
payload.SuccessCount += row.RequestCount
|
| 84 |
+
}
|
| 85 |
+
payload.TotalCount = payload.SuccessCount + payload.FailureCount
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
result := make([]usageCredentialPayload, 0, len(orderedKeys))
|
| 89 |
+
for _, key := range orderedKeys {
|
| 90 |
+
result = append(result, *buckets[key])
|
| 91 |
+
}
|
| 92 |
+
return result
|
| 93 |
+
}
|
internal/api/usage_events.go
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package api
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"fmt"
|
| 5 |
+
"net/http"
|
| 6 |
+
"strings"
|
| 7 |
+
"time"
|
| 8 |
+
|
| 9 |
+
"cpa-usage-keeper/internal/models"
|
| 10 |
+
"cpa-usage-keeper/internal/service"
|
| 11 |
+
"github.com/gin-gonic/gin"
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
type usageEventsResponse struct {
|
| 15 |
+
Events []usageEventPayload `json:"events"`
|
| 16 |
+
Models []string `json:"models"`
|
| 17 |
+
Sources []usageSourceFilterOption `json:"sources"`
|
| 18 |
+
TotalCount int64 `json:"total_count"`
|
| 19 |
+
Page int `json:"page"`
|
| 20 |
+
PageSize int `json:"page_size"`
|
| 21 |
+
TotalPages int `json:"total_pages"`
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
type usageSourceFilterOption struct {
|
| 25 |
+
Value string `json:"value"`
|
| 26 |
+
Label string `json:"label"`
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
type usageEventFilterOptionsResponse struct {
|
| 30 |
+
Models []string `json:"models"`
|
| 31 |
+
Sources []usageSourceFilterOption `json:"sources"`
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
type usageEventPayload struct {
|
| 35 |
+
ID uint `json:"id,omitempty"`
|
| 36 |
+
Timestamp string `json:"timestamp"`
|
| 37 |
+
Model string `json:"model"`
|
| 38 |
+
Source string `json:"source"`
|
| 39 |
+
SourceRaw string `json:"source_raw,omitempty"`
|
| 40 |
+
SourceType string `json:"source_type,omitempty"`
|
| 41 |
+
SourceKey string `json:"source_key,omitempty"`
|
| 42 |
+
AuthIndex string `json:"auth_index,omitempty"`
|
| 43 |
+
Failed bool `json:"failed"`
|
| 44 |
+
LatencyMS int64 `json:"latency_ms"`
|
| 45 |
+
Tokens usageEventTokenPayload `json:"tokens"`
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
type usageEventTokenPayload struct {
|
| 49 |
+
InputTokens int64 `json:"input_tokens"`
|
| 50 |
+
OutputTokens int64 `json:"output_tokens"`
|
| 51 |
+
ReasoningTokens int64 `json:"reasoning_tokens"`
|
| 52 |
+
CachedTokens int64 `json:"cached_tokens"`
|
| 53 |
+
TotalTokens int64 `json:"total_tokens"`
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
func registerUsageEventsRoute(
|
| 57 |
+
router gin.IRoutes,
|
| 58 |
+
usageProvider service.UsageProvider,
|
| 59 |
+
usageIdentityProvider service.UsageIdentityProvider,
|
| 60 |
+
) {
|
| 61 |
+
router.GET("/usage/events/filters", func(c *gin.Context) {
|
| 62 |
+
if usageProvider == nil {
|
| 63 |
+
c.JSON(http.StatusOK, usageEventFilterOptionsResponse{Models: []string{}, Sources: []usageSourceFilterOption{}})
|
| 64 |
+
return
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
options, err := usageProvider.ListUsageEventFilterOptions(c.Request.Context(), service.UsageFilter{})
|
| 68 |
+
if err != nil {
|
| 69 |
+
writeInternalError(c, "list usage event filter options failed", err)
|
| 70 |
+
return
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
identities, err := loadUsageResolutionData(c, usageIdentityProvider)
|
| 74 |
+
if err != nil {
|
| 75 |
+
writeInternalError(c, "load usage resolution data failed", err)
|
| 76 |
+
return
|
| 77 |
+
}
|
| 78 |
+
c.JSON(http.StatusOK, usageEventFilterOptionsResponse{
|
| 79 |
+
Models: options.Models,
|
| 80 |
+
Sources: buildUsageSourceFilterOptions(options.Sources, identities),
|
| 81 |
+
})
|
| 82 |
+
})
|
| 83 |
+
|
| 84 |
+
router.GET("/usage/events", func(c *gin.Context) {
|
| 85 |
+
if usageProvider == nil {
|
| 86 |
+
c.JSON(http.StatusOK, usageEventsResponse{Events: []usageEventPayload{}, Models: []string{}, Sources: []usageSourceFilterOption{}, Page: 1, PageSize: service.DefaultUsageEventsLimit})
|
| 87 |
+
return
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
filter, err := parseUsageFilterQuery(c.Request, time.Now().UTC())
|
| 91 |
+
if err != nil {
|
| 92 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
| 93 |
+
return
|
| 94 |
+
}
|
| 95 |
+
if err := applyUsageEventsSourceFilter(&filter); err != nil {
|
| 96 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
| 97 |
+
return
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
rows, err := usageProvider.ListUsageEvents(c.Request.Context(), filter)
|
| 101 |
+
if err != nil {
|
| 102 |
+
writeInternalError(c, "list usage events failed", err)
|
| 103 |
+
return
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
identities, err := loadUsageResolutionData(c, usageIdentityProvider)
|
| 107 |
+
if err != nil {
|
| 108 |
+
writeInternalError(c, "load usage resolution data failed", err)
|
| 109 |
+
return
|
| 110 |
+
}
|
| 111 |
+
c.JSON(http.StatusOK, usageEventsResponse{
|
| 112 |
+
Events: buildUsageEventsPayload(rows.Events),
|
| 113 |
+
Models: rows.Models,
|
| 114 |
+
Sources: buildUsageSourceFilterOptions(rows.Sources, identities),
|
| 115 |
+
TotalCount: rows.TotalCount,
|
| 116 |
+
Page: rows.Page,
|
| 117 |
+
PageSize: rows.PageSize,
|
| 118 |
+
TotalPages: rows.TotalPages,
|
| 119 |
+
})
|
| 120 |
+
})
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
func applyUsageEventsSourceFilter(filter *service.UsageFilter) error {
|
| 124 |
+
if filter == nil {
|
| 125 |
+
return nil
|
| 126 |
+
}
|
| 127 |
+
source := strings.TrimSpace(filter.Source)
|
| 128 |
+
if source == "" {
|
| 129 |
+
return nil
|
| 130 |
+
}
|
| 131 |
+
if value, ok := strings.CutPrefix(source, "auth:"); ok {
|
| 132 |
+
value = strings.TrimSpace(value)
|
| 133 |
+
if value == "" {
|
| 134 |
+
return fmt.Errorf("source auth filter value is required")
|
| 135 |
+
}
|
| 136 |
+
filter.AuthType = "oauth"
|
| 137 |
+
filter.AuthIndex = value
|
| 138 |
+
filter.Source = value
|
| 139 |
+
filter.Provider = ""
|
| 140 |
+
return nil
|
| 141 |
+
}
|
| 142 |
+
if value, ok := strings.CutPrefix(source, "provider:"); ok {
|
| 143 |
+
value = strings.TrimSpace(value)
|
| 144 |
+
if value == "" {
|
| 145 |
+
return fmt.Errorf("source provider filter value is required")
|
| 146 |
+
}
|
| 147 |
+
filter.AuthType = "apikey"
|
| 148 |
+
filter.Provider = value
|
| 149 |
+
filter.Source = ""
|
| 150 |
+
filter.AuthIndex = ""
|
| 151 |
+
}
|
| 152 |
+
return nil
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
func buildUsageEventsPayload(rows []service.UsageEventRecord) []usageEventPayload {
|
| 156 |
+
if len(rows) == 0 {
|
| 157 |
+
return []usageEventPayload{}
|
| 158 |
+
}
|
| 159 |
+
payload := make([]usageEventPayload, 0, len(rows))
|
| 160 |
+
for _, row := range rows {
|
| 161 |
+
source, sourceKey := usageEventPublicSource(row)
|
| 162 |
+
payload = append(payload, usageEventPayload{
|
| 163 |
+
ID: row.ID,
|
| 164 |
+
Timestamp: row.Timestamp.UTC().Format(time.RFC3339),
|
| 165 |
+
Model: row.Model,
|
| 166 |
+
Source: source,
|
| 167 |
+
SourceKey: sourceKey,
|
| 168 |
+
AuthIndex: row.AuthIndex,
|
| 169 |
+
Failed: row.Failed,
|
| 170 |
+
LatencyMS: row.LatencyMS,
|
| 171 |
+
Tokens: usageEventTokenPayload{
|
| 172 |
+
InputTokens: row.InputTokens,
|
| 173 |
+
OutputTokens: row.OutputTokens,
|
| 174 |
+
ReasoningTokens: row.ReasoningTokens,
|
| 175 |
+
CachedTokens: row.CachedTokens,
|
| 176 |
+
TotalTokens: row.TotalTokens,
|
| 177 |
+
},
|
| 178 |
+
})
|
| 179 |
+
}
|
| 180 |
+
return payload
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
func usageEventPublicSource(row service.UsageEventRecord) (string, string) {
|
| 184 |
+
switch strings.TrimSpace(row.AuthType) {
|
| 185 |
+
case "apikey":
|
| 186 |
+
provider := strings.TrimSpace(row.Provider)
|
| 187 |
+
if provider == "" {
|
| 188 |
+
provider = "AI Provider"
|
| 189 |
+
}
|
| 190 |
+
return provider, "provider:" + provider
|
| 191 |
+
case "oauth":
|
| 192 |
+
source := firstNonEmptyString(row.Source, row.AuthIndex, "unknown")
|
| 193 |
+
return source, "auth:" + source
|
| 194 |
+
default:
|
| 195 |
+
if provider := strings.TrimSpace(row.Provider); provider != "" {
|
| 196 |
+
return provider, "provider:" + provider
|
| 197 |
+
}
|
| 198 |
+
source := firstNonEmptyString(row.Source, row.AuthIndex, "unknown")
|
| 199 |
+
return source, "auth:" + source
|
| 200 |
+
}
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
func buildUsageSourceFilterOptions(sources []string, identities []models.UsageIdentity) []usageSourceFilterOption {
|
| 204 |
+
if len(identities) == 0 {
|
| 205 |
+
return []usageSourceFilterOption{}
|
| 206 |
+
}
|
| 207 |
+
options := make([]usageSourceFilterOption, 0, len(identities))
|
| 208 |
+
seen := make(map[string]struct{}, len(identities))
|
| 209 |
+
for _, identity := range identities {
|
| 210 |
+
if identity.TotalRequests == 0 {
|
| 211 |
+
continue
|
| 212 |
+
}
|
| 213 |
+
option, ok := usageSourceFilterOptionFromIdentity(identity)
|
| 214 |
+
if !ok {
|
| 215 |
+
continue
|
| 216 |
+
}
|
| 217 |
+
if _, exists := seen[option.Value]; exists {
|
| 218 |
+
continue
|
| 219 |
+
}
|
| 220 |
+
seen[option.Value] = struct{}{}
|
| 221 |
+
options = append(options, option)
|
| 222 |
+
}
|
| 223 |
+
return options
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
func usageSourceFilterOptionFromIdentity(identity models.UsageIdentity) (usageSourceFilterOption, bool) {
|
| 227 |
+
switch identity.AuthType {
|
| 228 |
+
case models.UsageIdentityAuthTypeAuthFile:
|
| 229 |
+
value := strings.TrimSpace(identity.Identity)
|
| 230 |
+
if value == "" {
|
| 231 |
+
return usageSourceFilterOption{}, false
|
| 232 |
+
}
|
| 233 |
+
label := firstNonEmptyString(identity.Name, value)
|
| 234 |
+
return usageSourceFilterOption{Value: "auth:" + value, Label: label}, true
|
| 235 |
+
case models.UsageIdentityAuthTypeAIProvider:
|
| 236 |
+
provider := safeAIProviderDisplayValue(identity.Provider, identity.Identity, "")
|
| 237 |
+
label := firstNonEmptyString(provider, safeAIProviderDisplayValue(identity.Name, identity.Identity, ""), safeAIProviderDisplayValue(identity.Type, identity.Identity, ""))
|
| 238 |
+
if label == "" {
|
| 239 |
+
return usageSourceFilterOption{}, false
|
| 240 |
+
}
|
| 241 |
+
return usageSourceFilterOption{Value: "provider:" + label, Label: label}, true
|
| 242 |
+
default:
|
| 243 |
+
return usageSourceFilterOption{}, false
|
| 244 |
+
}
|
| 245 |
+
}
|
internal/api/usage_events_test.go
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package api
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"context"
|
| 5 |
+
"net/http"
|
| 6 |
+
"net/http/httptest"
|
| 7 |
+
"testing"
|
| 8 |
+
"time"
|
| 9 |
+
|
| 10 |
+
"cpa-usage-keeper/internal/cpa"
|
| 11 |
+
"cpa-usage-keeper/internal/models"
|
| 12 |
+
"cpa-usage-keeper/internal/service"
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
type usageEventsStub struct {
|
| 16 |
+
events []service.UsageEventRecord
|
| 17 |
+
eventsPage *service.UsageEventsPage
|
| 18 |
+
eventFilterOptions *service.UsageEventFilterOptions
|
| 19 |
+
credentialStats []service.UsageCredentialStat
|
| 20 |
+
err error
|
| 21 |
+
lastFilter service.UsageFilter
|
| 22 |
+
filterCalls int
|
| 23 |
+
filterOptionCalls int
|
| 24 |
+
credentialsCalls int
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
func (s *usageEventsStub) GetUsageWithFilter(context.Context, service.UsageFilter) (*cpa.StatisticsSnapshot, error) {
|
| 28 |
+
return nil, nil
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
func (s *usageEventsStub) GetUsageOverview(context.Context, service.UsageFilter) (*service.UsageOverviewSnapshot, error) {
|
| 32 |
+
return nil, nil
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
func (s *usageEventsStub) ListUsageEvents(_ context.Context, filter service.UsageFilter) (*service.UsageEventsPage, error) {
|
| 36 |
+
s.lastFilter = filter
|
| 37 |
+
s.filterCalls++
|
| 38 |
+
if s.eventsPage != nil {
|
| 39 |
+
return s.eventsPage, s.err
|
| 40 |
+
}
|
| 41 |
+
return &service.UsageEventsPage{Events: s.events, TotalCount: int64(len(s.events)), Page: 1, PageSize: service.DefaultUsageEventsLimit, TotalPages: 1}, s.err
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
func (s *usageEventsStub) ListUsageEventFilterOptions(_ context.Context, filter service.UsageFilter) (*service.UsageEventFilterOptions, error) {
|
| 45 |
+
s.lastFilter = filter
|
| 46 |
+
s.filterOptionCalls++
|
| 47 |
+
if s.eventFilterOptions != nil {
|
| 48 |
+
return s.eventFilterOptions, s.err
|
| 49 |
+
}
|
| 50 |
+
return &service.UsageEventFilterOptions{}, s.err
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
func (s *usageEventsStub) ListUsageCredentialStats(_ context.Context, filter service.UsageFilter) ([]service.UsageCredentialStat, error) {
|
| 54 |
+
s.lastFilter = filter
|
| 55 |
+
s.credentialsCalls++
|
| 56 |
+
return s.credentialStats, s.err
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
func (s *usageEventsStub) GetUsageAnalysis(context.Context, service.UsageFilter) (*service.UsageAnalysisSnapshot, error) {
|
| 60 |
+
return nil, s.err
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
func TestUsageEventsReturnsFilteredRows(t *testing.T) {
|
| 64 |
+
provider := &usageEventsStub{events: []service.UsageEventRecord{{
|
| 65 |
+
ID: 42,
|
| 66 |
+
Timestamp: time.Date(2026, 4, 22, 11, 0, 0, 0, time.UTC),
|
| 67 |
+
Model: "claude-sonnet",
|
| 68 |
+
AuthType: "apikey",
|
| 69 |
+
Provider: "OpenAI Mirror",
|
| 70 |
+
Source: "sk-provider-key",
|
| 71 |
+
AuthIndex: "2",
|
| 72 |
+
Failed: false,
|
| 73 |
+
LatencyMS: 321,
|
| 74 |
+
InputTokens: 10,
|
| 75 |
+
OutputTokens: 5,
|
| 76 |
+
ReasoningTokens: 2,
|
| 77 |
+
CachedTokens: 1,
|
| 78 |
+
TotalTokens: 18,
|
| 79 |
+
}}}
|
| 80 |
+
router := NewRouter(nil, nil, provider, nil, AuthConfig{}, nil, "")
|
| 81 |
+
req := httptest.NewRequest(http.MethodGet, "/api/v1/usage/events?range=24h", nil)
|
| 82 |
+
resp := httptest.NewRecorder()
|
| 83 |
+
|
| 84 |
+
router.ServeHTTP(resp, req)
|
| 85 |
+
|
| 86 |
+
if resp.Code != http.StatusOK {
|
| 87 |
+
t.Fatalf("expected status 200, got %d", resp.Code)
|
| 88 |
+
}
|
| 89 |
+
body := resp.Body.String()
|
| 90 |
+
if !contains(body, `"events":[`) || !contains(body, `"model":"claude-sonnet"`) {
|
| 91 |
+
t.Fatalf("unexpected response body: %s", body)
|
| 92 |
+
}
|
| 93 |
+
if !contains(body, `"id":42`) || !contains(body, `"total_count":1`) || !contains(body, `"page":1`) || !contains(body, `"page_size":100`) || !contains(body, `"total_pages":1`) {
|
| 94 |
+
t.Fatalf("expected pagination metadata and event id in response body: %s", body)
|
| 95 |
+
}
|
| 96 |
+
if !contains(body, `"source":"OpenAI Mirror"`) {
|
| 97 |
+
t.Fatalf("expected resolved source display in response body: %s", body)
|
| 98 |
+
}
|
| 99 |
+
if contains(body, `sk-provider-key`) || contains(body, `sk-provider-prefix`) {
|
| 100 |
+
t.Fatalf("expected raw source values to be redacted from response body: %s", body)
|
| 101 |
+
}
|
| 102 |
+
if contains(body, `"source_type"`) || !contains(body, `"source_key":"provider:OpenAI Mirror"`) {
|
| 103 |
+
t.Fatalf("expected provider source key from usage event provider only, got %s", body)
|
| 104 |
+
}
|
| 105 |
+
if !contains(body, `"auth_index":"2"`) {
|
| 106 |
+
t.Fatalf("expected auth index in response body: %s", body)
|
| 107 |
+
}
|
| 108 |
+
if provider.filterCalls != 1 {
|
| 109 |
+
t.Fatalf("expected ListUsageEvents to be called once, got %d", provider.filterCalls)
|
| 110 |
+
}
|
| 111 |
+
if provider.lastFilter.Range != "24h" {
|
| 112 |
+
t.Fatalf("expected range to be passed through, got %+v", provider.lastFilter)
|
| 113 |
+
}
|
| 114 |
+
if provider.lastFilter.Page != 1 || provider.lastFilter.PageSize != 100 || provider.lastFilter.Offset != 0 {
|
| 115 |
+
t.Fatalf("expected default pagination to be passed through, got %+v", provider.lastFilter)
|
| 116 |
+
}
|
| 117 |
+
if provider.lastFilter.StartTime == nil || provider.lastFilter.EndTime == nil {
|
| 118 |
+
t.Fatalf("expected resolved time bounds in filter, got %+v", provider.lastFilter)
|
| 119 |
+
}
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
func TestUsageEventsPassesPaginationAndProviderSourceFilter(t *testing.T) {
|
| 123 |
+
provider := &usageEventsStub{eventsPage: &service.UsageEventsPage{Events: []service.UsageEventRecord{}, TotalCount: 0, Page: 3, PageSize: 100, TotalPages: 0}}
|
| 124 |
+
router := NewRouter(nil, nil, provider, nil, AuthConfig{}, nil, "")
|
| 125 |
+
req := httptest.NewRequest(http.MethodGet, "/api/v1/usage/events?page=3&page_size=100&model=claude-sonnet&source=provider:OpenAI%20Mirror&result=failed", nil)
|
| 126 |
+
resp := httptest.NewRecorder()
|
| 127 |
+
|
| 128 |
+
router.ServeHTTP(resp, req)
|
| 129 |
+
|
| 130 |
+
if resp.Code != http.StatusOK {
|
| 131 |
+
t.Fatalf("expected status 200, got %d", resp.Code)
|
| 132 |
+
}
|
| 133 |
+
if provider.lastFilter.Page != 3 || provider.lastFilter.PageSize != 100 || provider.lastFilter.Offset != 200 {
|
| 134 |
+
t.Fatalf("expected pagination filter, got %+v", provider.lastFilter)
|
| 135 |
+
}
|
| 136 |
+
if provider.lastFilter.Model != "claude-sonnet" || provider.lastFilter.AuthType != "apikey" || provider.lastFilter.Provider != "OpenAI Mirror" || provider.lastFilter.Source != "" || provider.lastFilter.Result != "failed" {
|
| 137 |
+
t.Fatalf("expected provider source filter to be translated, got %+v", provider.lastFilter)
|
| 138 |
+
}
|
| 139 |
+
body := resp.Body.String()
|
| 140 |
+
if !contains(body, `"page":3`) || !contains(body, `"page_size":100`) || !contains(body, `"total_count":0`) || !contains(body, `"total_pages":0`) {
|
| 141 |
+
t.Fatalf("expected response pagination metadata, got %s", body)
|
| 142 |
+
}
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
func TestUsageEventsPassesAuthSourceFilter(t *testing.T) {
|
| 146 |
+
provider := &usageEventsStub{eventsPage: &service.UsageEventsPage{Events: []service.UsageEventRecord{}, TotalCount: 0, Page: 1, PageSize: 100, TotalPages: 0}}
|
| 147 |
+
router := NewRouter(nil, nil, provider, nil, AuthConfig{}, nil, "")
|
| 148 |
+
req := httptest.NewRequest(http.MethodGet, "/api/v1/usage/events?source=auth:2", nil)
|
| 149 |
+
resp := httptest.NewRecorder()
|
| 150 |
+
|
| 151 |
+
router.ServeHTTP(resp, req)
|
| 152 |
+
|
| 153 |
+
if resp.Code != http.StatusOK {
|
| 154 |
+
t.Fatalf("expected status 200, got %d", resp.Code)
|
| 155 |
+
}
|
| 156 |
+
if provider.lastFilter.AuthType != "oauth" || provider.lastFilter.AuthIndex != "2" || provider.lastFilter.Source != "2" {
|
| 157 |
+
t.Fatalf("expected auth source filter to be translated, got %+v", provider.lastFilter)
|
| 158 |
+
}
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
func TestUsageEventsRejectsEmptyPrefixedSourceFilter(t *testing.T) {
|
| 162 |
+
for _, path := range []string{"/api/v1/usage/events?source=auth:", "/api/v1/usage/events?source=provider:"} {
|
| 163 |
+
t.Run(path, func(t *testing.T) {
|
| 164 |
+
provider := &usageEventsStub{eventsPage: &service.UsageEventsPage{Events: []service.UsageEventRecord{}, TotalCount: 0, Page: 1, PageSize: 100, TotalPages: 0}}
|
| 165 |
+
router := NewRouter(nil, nil, provider, nil, AuthConfig{}, nil, "")
|
| 166 |
+
req := httptest.NewRequest(http.MethodGet, path, nil)
|
| 167 |
+
resp := httptest.NewRecorder()
|
| 168 |
+
|
| 169 |
+
router.ServeHTTP(resp, req)
|
| 170 |
+
|
| 171 |
+
if resp.Code != http.StatusBadRequest {
|
| 172 |
+
t.Fatalf("expected status 400, got %d with body %s", resp.Code, resp.Body.String())
|
| 173 |
+
}
|
| 174 |
+
if provider.filterCalls != 0 {
|
| 175 |
+
t.Fatalf("expected invalid source filter not to query events, got %d calls", provider.filterCalls)
|
| 176 |
+
}
|
| 177 |
+
})
|
| 178 |
+
}
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
func TestUsageEventsReturnsFilterOptions(t *testing.T) {
|
| 182 |
+
provider := &usageEventsStub{eventsPage: &service.UsageEventsPage{
|
| 183 |
+
Events: []service.UsageEventRecord{{
|
| 184 |
+
ID: 7, Timestamp: time.Date(2026, 4, 22, 11, 0, 0, 0, time.UTC), Model: "gpt-5", AuthType: "apikey", Provider: "Provider A", Source: "source-a", Failed: true,
|
| 185 |
+
}},
|
| 186 |
+
Models: []string{"claude-sonnet", "gpt-5"},
|
| 187 |
+
Sources: []string{"source-a", "source-b"},
|
| 188 |
+
TotalCount: 2, Page: 1, PageSize: 20, TotalPages: 1,
|
| 189 |
+
}}
|
| 190 |
+
router := NewRouter(nil, nil, provider, nil, AuthConfig{}, nil, "", usageIdentitiesStub{items: []models.UsageIdentity{{ID: 1, Name: "sk-source-prefix", AuthType: models.UsageIdentityAuthTypeAIProvider, AuthTypeName: "apikey", Identity: "source-a", Type: "openai", Provider: "Provider A", TotalRequests: 1}, {ID: 2, Name: "Provider A", AuthType: models.UsageIdentityAuthTypeAIProvider, AuthTypeName: "apikey", Identity: "source-b", Type: "openai", Provider: "Provider A", TotalRequests: 1}, {ID: 3, Name: "Auth User", AuthType: models.UsageIdentityAuthTypeAuthFile, AuthTypeName: "oauth", Identity: "auth-1", Type: "claude", Provider: "Claude", TotalRequests: 1}}})
|
| 191 |
+
req := httptest.NewRequest(http.MethodGet, "/api/v1/usage/events", nil)
|
| 192 |
+
resp := httptest.NewRecorder()
|
| 193 |
+
|
| 194 |
+
router.ServeHTTP(resp, req)
|
| 195 |
+
|
| 196 |
+
if resp.Code != http.StatusOK {
|
| 197 |
+
t.Fatalf("expected status 200, got %d", resp.Code)
|
| 198 |
+
}
|
| 199 |
+
body := resp.Body.String()
|
| 200 |
+
if !contains(body, `"models":["claude-sonnet","gpt-5"]`) {
|
| 201 |
+
t.Fatalf("expected model filter options, got %s", body)
|
| 202 |
+
}
|
| 203 |
+
if !contains(body, `"sources":[`) || !contains(body, `"value":"auth:auth-1"`) || !contains(body, `"label":"Auth User"`) || !contains(body, `"value":"provider:Provider A"`) || !contains(body, `"label":"Provider A"`) {
|
| 204 |
+
t.Fatalf("expected prefixed source filter options, got %s", body)
|
| 205 |
+
}
|
| 206 |
+
if contains(body, `"value":"source-a"`) || contains(body, `"value":"source-b"`) || contains(body, `"provider:1"`) || contains(body, `"provider:2"`) || contains(body, `sk-source-prefix`) {
|
| 207 |
+
t.Fatalf("expected raw source filter values to be redacted, got %s", body)
|
| 208 |
+
}
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
func TestUsageEventFilterOptionsReturnsStableModelsAndSources(t *testing.T) {
|
| 212 |
+
provider := &usageEventsStub{eventFilterOptions: &service.UsageEventFilterOptions{
|
| 213 |
+
Models: []string{"claude-sonnet", "gpt-5"},
|
| 214 |
+
Sources: []string{"source-a", "source-b"},
|
| 215 |
+
}}
|
| 216 |
+
router := NewRouter(nil, nil, provider, nil, AuthConfig{}, nil, "", usageIdentitiesStub{items: []models.UsageIdentity{{ID: 1, Name: "sk-source-prefix", AuthType: models.UsageIdentityAuthTypeAIProvider, AuthTypeName: "apikey", Identity: "source-a", Type: "openai", Provider: "Provider A", TotalRequests: 3}, {ID: 2, Name: "Provider A", AuthType: models.UsageIdentityAuthTypeAIProvider, AuthTypeName: "apikey", Identity: "source-b", Type: "openai", Provider: "Provider A"}, {ID: 3, Name: "Auth User", AuthType: models.UsageIdentityAuthTypeAuthFile, AuthTypeName: "oauth", Identity: "auth-1", Type: "claude", Provider: "Claude", TotalRequests: 2}, {ID: 4, Name: "Zero Request User", AuthType: models.UsageIdentityAuthTypeAuthFile, AuthTypeName: "oauth", Identity: "auth-zero", Type: "claude", Provider: "Claude"}, {ID: 5, Name: "Zero Provider", AuthType: models.UsageIdentityAuthTypeAIProvider, AuthTypeName: "apikey", Identity: "source-zero", Type: "openai", Provider: "Zero Provider"}}})
|
| 217 |
+
req := httptest.NewRequest(http.MethodGet, "/api/v1/usage/events/filters?range=24h&model=ignored&source=ignored&result=failed&page=3&page_size=20", nil)
|
| 218 |
+
resp := httptest.NewRecorder()
|
| 219 |
+
|
| 220 |
+
router.ServeHTTP(resp, req)
|
| 221 |
+
|
| 222 |
+
if resp.Code != http.StatusOK {
|
| 223 |
+
t.Fatalf("expected status 200, got %d", resp.Code)
|
| 224 |
+
}
|
| 225 |
+
if provider.filterOptionCalls != 1 || provider.filterCalls != 0 {
|
| 226 |
+
t.Fatalf("expected filter options endpoint only, events=%d filterOptions=%d", provider.filterCalls, provider.filterOptionCalls)
|
| 227 |
+
}
|
| 228 |
+
if provider.lastFilter.Range != "" || provider.lastFilter.StartTime != nil || provider.lastFilter.EndTime != nil || provider.lastFilter.Model != "" || provider.lastFilter.Source != "" || provider.lastFilter.Result != "" || provider.lastFilter.Page != 0 || provider.lastFilter.PageSize != 0 {
|
| 229 |
+
t.Fatalf("expected filters endpoint to ignore query filters, got %+v", provider.lastFilter)
|
| 230 |
+
}
|
| 231 |
+
body := resp.Body.String()
|
| 232 |
+
if !contains(body, `"models":["claude-sonnet","gpt-5"]`) {
|
| 233 |
+
t.Fatalf("expected stable model filter options, got %s", body)
|
| 234 |
+
}
|
| 235 |
+
if !contains(body, `"sources":[`) || !contains(body, `"value":"auth:auth-1"`) || !contains(body, `"label":"Auth User"`) || !contains(body, `"value":"provider:Provider A"`) || !contains(body, `"label":"Provider A"`) {
|
| 236 |
+
t.Fatalf("expected stable prefixed source filter options, got %s", body)
|
| 237 |
+
}
|
| 238 |
+
if contains(body, `"value":"source-a"`) || contains(body, `"value":"source-b"`) || contains(body, `"provider:1"`) || contains(body, `"provider:2"`) || contains(body, `sk-source-prefix`) {
|
| 239 |
+
t.Fatalf("expected raw source filter values to be redacted, got %s", body)
|
| 240 |
+
}
|
| 241 |
+
if contains(body, `Zero Request User`) || contains(body, `Zero Provider`) || contains(body, `auth-zero`) || contains(body, `source-zero`) {
|
| 242 |
+
t.Fatalf("expected zero-request source filter options to be omitted, got %s", body)
|
| 243 |
+
}
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
func TestUsageCredentialsReturnsAggregatedRows(t *testing.T) {
|
| 247 |
+
provider := &usageEventsStub{credentialStats: []service.UsageCredentialStat{{
|
| 248 |
+
Source: "sk-provider-key",
|
| 249 |
+
AuthIndex: "2",
|
| 250 |
+
Failed: false,
|
| 251 |
+
RequestCount: 3,
|
| 252 |
+
}, {
|
| 253 |
+
Source: "sk-provider-key",
|
| 254 |
+
AuthIndex: "2",
|
| 255 |
+
Failed: true,
|
| 256 |
+
RequestCount: 1,
|
| 257 |
+
}}}
|
| 258 |
+
router := NewRouter(nil, nil, provider, nil, AuthConfig{}, nil, "", usageIdentitiesStub{items: []models.UsageIdentity{{ID: 1, Name: "sk-provider-prefix", AuthType: models.UsageIdentityAuthTypeAIProvider, AuthTypeName: "apikey", Identity: "sk-provider-key", Type: "openai", Provider: "OpenAI Mirror"}}})
|
| 259 |
+
req := httptest.NewRequest(http.MethodGet, "/api/v1/usage/credentials?range=24h", nil)
|
| 260 |
+
resp := httptest.NewRecorder()
|
| 261 |
+
|
| 262 |
+
router.ServeHTTP(resp, req)
|
| 263 |
+
|
| 264 |
+
if resp.Code != http.StatusOK {
|
| 265 |
+
t.Fatalf("expected status 200, got %d", resp.Code)
|
| 266 |
+
}
|
| 267 |
+
body := resp.Body.String()
|
| 268 |
+
if !contains(body, `"credentials":[`) {
|
| 269 |
+
t.Fatalf("unexpected response body: %s", body)
|
| 270 |
+
}
|
| 271 |
+
if !contains(body, `"source":"OpenAI Mirror"`) {
|
| 272 |
+
t.Fatalf("expected resolved source display in response body: %s", body)
|
| 273 |
+
}
|
| 274 |
+
if !contains(body, `"source_type":"openai"`) {
|
| 275 |
+
t.Fatalf("expected source type in response body: %s", body)
|
| 276 |
+
}
|
| 277 |
+
if !contains(body, `"source_key":"provider:1"`) {
|
| 278 |
+
t.Fatalf("expected source key in response body: %s", body)
|
| 279 |
+
}
|
| 280 |
+
if contains(body, `sk-provider-key`) || contains(body, `sk-provider-prefix`) {
|
| 281 |
+
t.Fatalf("expected raw source values to be redacted from response body: %s", body)
|
| 282 |
+
}
|
| 283 |
+
if !contains(body, `"success_count":3`) || !contains(body, `"failure_count":1`) || !contains(body, `"total_count":4`) {
|
| 284 |
+
t.Fatalf("expected aggregated counts in response body: %s", body)
|
| 285 |
+
}
|
| 286 |
+
if provider.credentialsCalls != 1 {
|
| 287 |
+
t.Fatalf("expected ListUsageCredentialStats to be called once, got %d", provider.credentialsCalls)
|
| 288 |
+
}
|
| 289 |
+
if provider.lastFilter.Range != "24h" {
|
| 290 |
+
t.Fatalf("expected range to be passed through, got %+v", provider.lastFilter)
|
| 291 |
+
}
|
| 292 |
+
if provider.lastFilter.StartTime == nil || provider.lastFilter.EndTime == nil {
|
| 293 |
+
t.Fatalf("expected resolved time bounds in filter, got %+v", provider.lastFilter)
|
| 294 |
+
}
|
| 295 |
+
}
|
internal/api/usage_filter.go
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package api
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"fmt"
|
| 5 |
+
"net/http"
|
| 6 |
+
"strconv"
|
| 7 |
+
"strings"
|
| 8 |
+
"time"
|
| 9 |
+
|
| 10 |
+
"cpa-usage-keeper/internal/service"
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
var presetUsageRangeDurations = map[string]time.Duration{
|
| 14 |
+
"4h": 4 * time.Hour,
|
| 15 |
+
"8h": 8 * time.Hour,
|
| 16 |
+
"12h": 12 * time.Hour,
|
| 17 |
+
"24h": 24 * time.Hour,
|
| 18 |
+
"7d": 7 * 24 * time.Hour,
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
var allowedUsageEventsPageSizes = map[int]struct{}{
|
| 22 |
+
20: {},
|
| 23 |
+
50: {},
|
| 24 |
+
100: {},
|
| 25 |
+
500: {},
|
| 26 |
+
1000: {},
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
func parseUsageTimeFilterQuery(req *http.Request, anchor time.Time) (service.UsageFilter, error) {
|
| 30 |
+
filter, err := parseUsageFilterQuery(req, anchor)
|
| 31 |
+
if err != nil {
|
| 32 |
+
return service.UsageFilter{}, err
|
| 33 |
+
}
|
| 34 |
+
filter.Limit = 0
|
| 35 |
+
filter.Page = 0
|
| 36 |
+
filter.PageSize = 0
|
| 37 |
+
filter.Offset = 0
|
| 38 |
+
filter.Model = ""
|
| 39 |
+
filter.Source = ""
|
| 40 |
+
filter.AuthIndex = ""
|
| 41 |
+
filter.Result = ""
|
| 42 |
+
return filter, nil
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
func parseCustomUsageRangeBoundary(value string, endOfDay bool) (time.Time, error) {
|
| 46 |
+
if date, err := time.ParseInLocation(time.DateOnly, value, time.Local); err == nil {
|
| 47 |
+
if endOfDay {
|
| 48 |
+
return date.AddDate(0, 0, 1).Add(-time.Nanosecond), nil
|
| 49 |
+
}
|
| 50 |
+
return date, nil
|
| 51 |
+
}
|
| 52 |
+
return time.Parse(time.RFC3339, value)
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
func parseUsageFilterQuery(req *http.Request, anchor time.Time) (service.UsageFilter, error) {
|
| 56 |
+
if req == nil {
|
| 57 |
+
return service.UsageFilter{}, nil
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
rangeValue := strings.TrimSpace(req.URL.Query().Get("range"))
|
| 61 |
+
if rangeValue == "" {
|
| 62 |
+
rangeValue = "all"
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
filter := service.UsageFilter{Range: rangeValue, Limit: service.DefaultUsageEventsLimit, Page: 1, PageSize: service.DefaultUsageEventsLimit}
|
| 66 |
+
query := req.URL.Query()
|
| 67 |
+
if pageValue := strings.TrimSpace(query.Get("page")); pageValue != "" {
|
| 68 |
+
page, err := strconv.Atoi(pageValue)
|
| 69 |
+
if err != nil || page < 1 {
|
| 70 |
+
return service.UsageFilter{}, fmt.Errorf("invalid page %q", pageValue)
|
| 71 |
+
}
|
| 72 |
+
filter.Page = page
|
| 73 |
+
}
|
| 74 |
+
pageSizeValue := strings.TrimSpace(query.Get("page_size"))
|
| 75 |
+
if pageSizeValue == "" {
|
| 76 |
+
pageSizeValue = strings.TrimSpace(query.Get("limit"))
|
| 77 |
+
}
|
| 78 |
+
if pageSizeValue != "" {
|
| 79 |
+
pageSize, err := strconv.Atoi(pageSizeValue)
|
| 80 |
+
if err != nil {
|
| 81 |
+
return service.UsageFilter{}, fmt.Errorf("invalid page_size %q", pageSizeValue)
|
| 82 |
+
}
|
| 83 |
+
if _, ok := allowedUsageEventsPageSizes[pageSize]; !ok {
|
| 84 |
+
return service.UsageFilter{}, fmt.Errorf("invalid page_size %q", pageSizeValue)
|
| 85 |
+
}
|
| 86 |
+
filter.PageSize = pageSize
|
| 87 |
+
filter.Limit = pageSize
|
| 88 |
+
}
|
| 89 |
+
filter.Offset = (filter.Page - 1) * filter.PageSize
|
| 90 |
+
filter.Model = strings.TrimSpace(query.Get("model"))
|
| 91 |
+
filter.Source = strings.TrimSpace(query.Get("source"))
|
| 92 |
+
filter.AuthIndex = strings.TrimSpace(query.Get("auth_index"))
|
| 93 |
+
filter.Result = strings.TrimSpace(query.Get("result"))
|
| 94 |
+
if filter.Result != "" && filter.Result != "success" && filter.Result != "failed" {
|
| 95 |
+
return service.UsageFilter{}, fmt.Errorf("invalid result %q", filter.Result)
|
| 96 |
+
}
|
| 97 |
+
switch rangeValue {
|
| 98 |
+
case "all":
|
| 99 |
+
return filter, nil
|
| 100 |
+
case "today":
|
| 101 |
+
localAnchor := anchor.In(time.Local)
|
| 102 |
+
localStart := time.Date(localAnchor.Year(), localAnchor.Month(), localAnchor.Day(), 0, 0, 0, 0, time.Local)
|
| 103 |
+
startTime := localStart.UTC()
|
| 104 |
+
endTime := localStart.AddDate(0, 0, 1).Add(-time.Nanosecond).UTC()
|
| 105 |
+
filter.StartTime = &startTime
|
| 106 |
+
filter.EndTime = &endTime
|
| 107 |
+
return filter, nil
|
| 108 |
+
case "custom":
|
| 109 |
+
startValue := strings.TrimSpace(req.URL.Query().Get("start"))
|
| 110 |
+
endValue := strings.TrimSpace(req.URL.Query().Get("end"))
|
| 111 |
+
if startValue == "" || endValue == "" {
|
| 112 |
+
return service.UsageFilter{}, fmt.Errorf("custom range requires start and end")
|
| 113 |
+
}
|
| 114 |
+
startTime, err := parseCustomUsageRangeBoundary(startValue, false)
|
| 115 |
+
if err != nil {
|
| 116 |
+
return service.UsageFilter{}, fmt.Errorf("invalid start: %w", err)
|
| 117 |
+
}
|
| 118 |
+
endTime, err := parseCustomUsageRangeBoundary(endValue, true)
|
| 119 |
+
if err != nil {
|
| 120 |
+
return service.UsageFilter{}, fmt.Errorf("invalid end: %w", err)
|
| 121 |
+
}
|
| 122 |
+
startTime = startTime.UTC()
|
| 123 |
+
endTime = endTime.UTC()
|
| 124 |
+
if startTime.After(endTime) {
|
| 125 |
+
return service.UsageFilter{}, fmt.Errorf("custom range start must be before end")
|
| 126 |
+
}
|
| 127 |
+
filter.StartTime = &startTime
|
| 128 |
+
filter.EndTime = &endTime
|
| 129 |
+
return filter, nil
|
| 130 |
+
default:
|
| 131 |
+
duration, ok := presetUsageRangeDurations[rangeValue]
|
| 132 |
+
if !ok {
|
| 133 |
+
return service.UsageFilter{}, fmt.Errorf("unsupported usage range %q", rangeValue)
|
| 134 |
+
}
|
| 135 |
+
endTime := anchor.UTC()
|
| 136 |
+
startTime := endTime.Add(-duration)
|
| 137 |
+
filter.StartTime = &startTime
|
| 138 |
+
filter.EndTime = &endTime
|
| 139 |
+
return filter, nil
|
| 140 |
+
}
|
| 141 |
+
}
|
internal/api/usage_filter_test.go
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package api
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"net/http/httptest"
|
| 5 |
+
"testing"
|
| 6 |
+
"time"
|
| 7 |
+
)
|
| 8 |
+
|
| 9 |
+
func TestParseUsageFilterQueryPresetRange(t *testing.T) {
|
| 10 |
+
req := httptest.NewRequest("GET", "/api/v1/usage/overview?range=24h", nil)
|
| 11 |
+
anchor := time.Date(2026, 4, 22, 12, 0, 0, 0, time.UTC)
|
| 12 |
+
|
| 13 |
+
filter, err := parseUsageFilterQuery(req, anchor)
|
| 14 |
+
if err != nil {
|
| 15 |
+
t.Fatalf("parseUsageFilterQuery returned error: %v", err)
|
| 16 |
+
}
|
| 17 |
+
if filter.Range != "24h" {
|
| 18 |
+
t.Fatalf("expected range to be preserved, got %+v", filter)
|
| 19 |
+
}
|
| 20 |
+
if filter.StartTime == nil || filter.EndTime == nil {
|
| 21 |
+
t.Fatalf("expected preset range to resolve concrete times, got %+v", filter)
|
| 22 |
+
}
|
| 23 |
+
if !filter.EndTime.Equal(anchor) {
|
| 24 |
+
t.Fatalf("expected preset range end to use anchor time, got %+v", filter)
|
| 25 |
+
}
|
| 26 |
+
if !filter.StartTime.Equal(anchor.Add(-24 * time.Hour)) {
|
| 27 |
+
t.Fatalf("expected preset range start to subtract 24h, got %+v", filter)
|
| 28 |
+
}
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
func TestParseUsageFilterQueryTodayRangeUsesLocalDayBoundary(t *testing.T) {
|
| 32 |
+
previousLocal := time.Local
|
| 33 |
+
location, err := time.LoadLocation("Asia/Shanghai")
|
| 34 |
+
if err != nil {
|
| 35 |
+
t.Fatalf("load location: %v", err)
|
| 36 |
+
}
|
| 37 |
+
t.Cleanup(func() { time.Local = previousLocal })
|
| 38 |
+
time.Local = location
|
| 39 |
+
|
| 40 |
+
req := httptest.NewRequest("GET", "/api/v1/usage/overview?range=today", nil)
|
| 41 |
+
anchor := time.Date(2026, 4, 22, 12, 34, 56, 0, time.UTC)
|
| 42 |
+
|
| 43 |
+
filter, err := parseUsageFilterQuery(req, anchor)
|
| 44 |
+
if err != nil {
|
| 45 |
+
t.Fatalf("parseUsageFilterQuery returned error: %v", err)
|
| 46 |
+
}
|
| 47 |
+
if filter.Range != "today" {
|
| 48 |
+
t.Fatalf("expected today range to be preserved, got %+v", filter)
|
| 49 |
+
}
|
| 50 |
+
if filter.StartTime == nil || filter.EndTime == nil {
|
| 51 |
+
t.Fatalf("expected today range to resolve concrete times, got %+v", filter)
|
| 52 |
+
}
|
| 53 |
+
expectedStart := time.Date(2026, 4, 22, 0, 0, 0, 0, location).UTC()
|
| 54 |
+
expectedEnd := time.Date(2026, 4, 23, 0, 0, 0, 0, location).Add(-time.Nanosecond).UTC()
|
| 55 |
+
if !filter.StartTime.Equal(expectedStart) {
|
| 56 |
+
t.Fatalf("expected today start %s, got %s", expectedStart, *filter.StartTime)
|
| 57 |
+
}
|
| 58 |
+
if !filter.EndTime.Equal(expectedEnd) {
|
| 59 |
+
t.Fatalf("expected today end %s, got %s", expectedEnd, *filter.EndTime)
|
| 60 |
+
}
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
func TestParseUsageFilterQueryTodayRangeUsesLocalDSTBoundary(t *testing.T) {
|
| 64 |
+
previousLocal := time.Local
|
| 65 |
+
location, err := time.LoadLocation("America/New_York")
|
| 66 |
+
if err != nil {
|
| 67 |
+
t.Fatalf("load location: %v", err)
|
| 68 |
+
}
|
| 69 |
+
t.Cleanup(func() { time.Local = previousLocal })
|
| 70 |
+
time.Local = location
|
| 71 |
+
|
| 72 |
+
req := httptest.NewRequest("GET", "/api/v1/usage/overview?range=today", nil)
|
| 73 |
+
anchor := time.Date(2026, 3, 8, 12, 0, 0, 0, location)
|
| 74 |
+
|
| 75 |
+
filter, err := parseUsageFilterQuery(req, anchor)
|
| 76 |
+
if err != nil {
|
| 77 |
+
t.Fatalf("parseUsageFilterQuery returned error: %v", err)
|
| 78 |
+
}
|
| 79 |
+
if filter.StartTime == nil || filter.EndTime == nil {
|
| 80 |
+
t.Fatalf("expected today range to resolve concrete times, got %+v", filter)
|
| 81 |
+
}
|
| 82 |
+
expectedStart := time.Date(2026, 3, 8, 0, 0, 0, 0, location).UTC()
|
| 83 |
+
expectedEnd := time.Date(2026, 3, 9, 0, 0, 0, 0, location).Add(-time.Nanosecond).UTC()
|
| 84 |
+
if !filter.StartTime.Equal(expectedStart) {
|
| 85 |
+
t.Fatalf("expected DST today start %s, got %s", expectedStart, *filter.StartTime)
|
| 86 |
+
}
|
| 87 |
+
if !filter.EndTime.Equal(expectedEnd) {
|
| 88 |
+
t.Fatalf("expected DST today end %s, got %s", expectedEnd, *filter.EndTime)
|
| 89 |
+
}
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
func TestParseUsageFilterQueryCustomRange(t *testing.T) {
|
| 93 |
+
req := httptest.NewRequest("GET", "/api/v1/usage/overview?range=custom&start=2026-04-20T00:00:00Z&end=2026-04-21T23:59:59Z", nil)
|
| 94 |
+
|
| 95 |
+
filter, err := parseUsageFilterQuery(req, time.Time{})
|
| 96 |
+
if err != nil {
|
| 97 |
+
t.Fatalf("parseUsageFilterQuery returned error: %v", err)
|
| 98 |
+
}
|
| 99 |
+
if filter.StartTime == nil || filter.EndTime == nil {
|
| 100 |
+
t.Fatalf("expected custom range bounds, got %+v", filter)
|
| 101 |
+
}
|
| 102 |
+
if !filter.StartTime.Equal(time.Date(2026, 4, 20, 0, 0, 0, 0, time.UTC)) {
|
| 103 |
+
t.Fatalf("unexpected custom start: %+v", filter)
|
| 104 |
+
}
|
| 105 |
+
if !filter.EndTime.Equal(time.Date(2026, 4, 21, 23, 59, 59, 0, time.UTC)) {
|
| 106 |
+
t.Fatalf("unexpected custom end: %+v", filter)
|
| 107 |
+
}
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
func TestParseUsageFilterQueryCustomDateRangeUsesLocalDayBoundary(t *testing.T) {
|
| 111 |
+
previousLocal := time.Local
|
| 112 |
+
location, err := time.LoadLocation("Asia/Shanghai")
|
| 113 |
+
if err != nil {
|
| 114 |
+
t.Fatalf("load location: %v", err)
|
| 115 |
+
}
|
| 116 |
+
t.Cleanup(func() { time.Local = previousLocal })
|
| 117 |
+
time.Local = location
|
| 118 |
+
|
| 119 |
+
req := httptest.NewRequest("GET", "/api/v1/usage/overview?range=custom&start=2026-04-20&end=2026-04-21", nil)
|
| 120 |
+
|
| 121 |
+
filter, err := parseUsageFilterQuery(req, time.Time{})
|
| 122 |
+
if err != nil {
|
| 123 |
+
t.Fatalf("parseUsageFilterQuery returned error: %v", err)
|
| 124 |
+
}
|
| 125 |
+
if filter.StartTime == nil || filter.EndTime == nil {
|
| 126 |
+
t.Fatalf("expected custom date range bounds, got %+v", filter)
|
| 127 |
+
}
|
| 128 |
+
expectedStart := time.Date(2026, 4, 20, 0, 0, 0, 0, location).UTC()
|
| 129 |
+
expectedEnd := time.Date(2026, 4, 22, 0, 0, 0, 0, location).Add(-time.Nanosecond).UTC()
|
| 130 |
+
if !filter.StartTime.Equal(expectedStart) {
|
| 131 |
+
t.Fatalf("expected custom date start %s, got %s", expectedStart, *filter.StartTime)
|
| 132 |
+
}
|
| 133 |
+
if !filter.EndTime.Equal(expectedEnd) {
|
| 134 |
+
t.Fatalf("expected custom date end %s, got %s", expectedEnd, *filter.EndTime)
|
| 135 |
+
}
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
func TestParseUsageFilterQueryRejectsInvalidCustomRange(t *testing.T) {
|
| 139 |
+
req := httptest.NewRequest("GET", "/api/v1/usage/overview?range=custom&start=2026-04-21T00:00:00Z&end=2026-04-20T23:59:59Z", nil)
|
| 140 |
+
|
| 141 |
+
_, err := parseUsageFilterQuery(req, time.Time{})
|
| 142 |
+
if err == nil {
|
| 143 |
+
t.Fatal("expected invalid custom range error")
|
| 144 |
+
}
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
func TestParseUsageFilterQueryDefaultsEventsPagination(t *testing.T) {
|
| 148 |
+
req := httptest.NewRequest("GET", "/api/v1/usage/events?range=all", nil)
|
| 149 |
+
|
| 150 |
+
filter, err := parseUsageFilterQuery(req, time.Time{})
|
| 151 |
+
if err != nil {
|
| 152 |
+
t.Fatalf("parseUsageFilterQuery returned error: %v", err)
|
| 153 |
+
}
|
| 154 |
+
if filter.Page != 1 || filter.PageSize != 100 || filter.Offset != 0 {
|
| 155 |
+
t.Fatalf("expected default pagination, got %+v", filter)
|
| 156 |
+
}
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
func TestParseUsageFilterQueryAcceptsEventsPaginationAndFilters(t *testing.T) {
|
| 160 |
+
req := httptest.NewRequest("GET", "/api/v1/usage/events?page=3&page_size=100&model=%20claude-sonnet%20&source=%20source-a%20&auth_index=%202%20", nil)
|
| 161 |
+
|
| 162 |
+
filter, err := parseUsageFilterQuery(req, time.Time{})
|
| 163 |
+
if err != nil {
|
| 164 |
+
t.Fatalf("parseUsageFilterQuery returned error: %v", err)
|
| 165 |
+
}
|
| 166 |
+
if filter.Page != 3 || filter.PageSize != 100 || filter.Offset != 200 {
|
| 167 |
+
t.Fatalf("expected page 3/page size 100 offset 200, got %+v", filter)
|
| 168 |
+
}
|
| 169 |
+
if filter.Model != "claude-sonnet" || filter.Source != "source-a" || filter.AuthIndex != "2" {
|
| 170 |
+
t.Fatalf("expected trimmed server-side filters, got %+v", filter)
|
| 171 |
+
}
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
func TestParseUsageFilterQueryUsesLimitAsPageSizeAlias(t *testing.T) {
|
| 175 |
+
req := httptest.NewRequest("GET", "/api/v1/usage/events?limit=20", nil)
|
| 176 |
+
|
| 177 |
+
filter, err := parseUsageFilterQuery(req, time.Time{})
|
| 178 |
+
if err != nil {
|
| 179 |
+
t.Fatalf("parseUsageFilterQuery returned error: %v", err)
|
| 180 |
+
}
|
| 181 |
+
if filter.Page != 1 || filter.PageSize != 20 || filter.Offset != 0 {
|
| 182 |
+
t.Fatalf("expected limit alias to set page size, got %+v", filter)
|
| 183 |
+
}
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
func TestParseUsageFilterQueryPrefersPageSizeOverLimit(t *testing.T) {
|
| 187 |
+
req := httptest.NewRequest("GET", "/api/v1/usage/events?page_size=50&limit=20", nil)
|
| 188 |
+
|
| 189 |
+
filter, err := parseUsageFilterQuery(req, time.Time{})
|
| 190 |
+
if err != nil {
|
| 191 |
+
t.Fatalf("parseUsageFilterQuery returned error: %v", err)
|
| 192 |
+
}
|
| 193 |
+
if filter.PageSize != 50 {
|
| 194 |
+
t.Fatalf("expected page_size to win over limit, got %+v", filter)
|
| 195 |
+
}
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
func TestParseUsageFilterQueryRejectsInvalidEventsPagination(t *testing.T) {
|
| 199 |
+
tests := []string{
|
| 200 |
+
"/api/v1/usage/events?page=0",
|
| 201 |
+
"/api/v1/usage/events?page_size=25",
|
| 202 |
+
}
|
| 203 |
+
for _, path := range tests {
|
| 204 |
+
req := httptest.NewRequest("GET", path, nil)
|
| 205 |
+
if _, err := parseUsageFilterQuery(req, time.Time{}); err == nil {
|
| 206 |
+
t.Fatalf("expected pagination error for %s", path)
|
| 207 |
+
}
|
| 208 |
+
}
|
| 209 |
+
}
|
internal/api/usage_identities.go
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package api
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"net/http"
|
| 5 |
+
"strings"
|
| 6 |
+
"time"
|
| 7 |
+
|
| 8 |
+
"cpa-usage-keeper/internal/models"
|
| 9 |
+
"cpa-usage-keeper/internal/redact"
|
| 10 |
+
"cpa-usage-keeper/internal/service"
|
| 11 |
+
"github.com/gin-gonic/gin"
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
type usageIdentitiesResponse struct {
|
| 15 |
+
Identities []usageIdentityResponse `json:"identities"`
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
type usageIdentityResponse struct {
|
| 19 |
+
ID uint `json:"id"`
|
| 20 |
+
Name string `json:"name"`
|
| 21 |
+
AuthType models.UsageIdentityAuthType `json:"auth_type"`
|
| 22 |
+
AuthTypeName string `json:"auth_type_name"`
|
| 23 |
+
Identity string `json:"identity"`
|
| 24 |
+
Type string `json:"type"`
|
| 25 |
+
Provider string `json:"provider"`
|
| 26 |
+
TotalRequests int64 `json:"total_requests"`
|
| 27 |
+
SuccessCount int64 `json:"success_count"`
|
| 28 |
+
FailureCount int64 `json:"failure_count"`
|
| 29 |
+
InputTokens int64 `json:"input_tokens"`
|
| 30 |
+
OutputTokens int64 `json:"output_tokens"`
|
| 31 |
+
ReasoningTokens int64 `json:"reasoning_tokens"`
|
| 32 |
+
CachedTokens int64 `json:"cached_tokens"`
|
| 33 |
+
TotalTokens int64 `json:"total_tokens"`
|
| 34 |
+
LastAggregatedUsageEventID uint `json:"last_aggregated_usage_event_id"`
|
| 35 |
+
FirstUsedAt *time.Time `json:"first_used_at,omitempty"`
|
| 36 |
+
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
|
| 37 |
+
StatsUpdatedAt *time.Time `json:"stats_updated_at,omitempty"`
|
| 38 |
+
IsDeleted bool `json:"is_deleted"`
|
| 39 |
+
CreatedAt time.Time `json:"created_at"`
|
| 40 |
+
UpdatedAt time.Time `json:"updated_at"`
|
| 41 |
+
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
func registerUsageIdentityRoutes(router gin.IRoutes, usageIdentityProvider service.UsageIdentityProvider) {
|
| 45 |
+
router.GET("/usage/identities", func(c *gin.Context) {
|
| 46 |
+
if usageIdentityProvider == nil {
|
| 47 |
+
c.JSON(http.StatusOK, usageIdentitiesResponse{Identities: []usageIdentityResponse{}})
|
| 48 |
+
return
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
items, err := usageIdentityProvider.ListUsageIdentities(c.Request.Context())
|
| 52 |
+
if err != nil {
|
| 53 |
+
writeInternalError(c, "list usage identities failed", err)
|
| 54 |
+
return
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
response := make([]usageIdentityResponse, 0, len(items))
|
| 58 |
+
for _, item := range items {
|
| 59 |
+
response = append(response, mapUsageIdentityResponse(item))
|
| 60 |
+
}
|
| 61 |
+
c.JSON(http.StatusOK, usageIdentitiesResponse{Identities: response})
|
| 62 |
+
})
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
func mapUsageIdentityResponse(item models.UsageIdentity) usageIdentityResponse {
|
| 66 |
+
identity := item.Identity
|
| 67 |
+
name := item.Name
|
| 68 |
+
identityType := item.Type
|
| 69 |
+
provider := item.Provider
|
| 70 |
+
if item.AuthType == models.UsageIdentityAuthTypeAIProvider {
|
| 71 |
+
identity = redact.APIKeyDisplayName(item.Identity)
|
| 72 |
+
identityType = safeAIProviderDisplayValue(item.Type, item.Identity, item.AuthTypeName)
|
| 73 |
+
provider = safeAIProviderDisplayValue(item.Provider, item.Identity, firstNonEmptyString(identityType, identity))
|
| 74 |
+
name = safeAIProviderDisplayValue(item.Name, item.Identity, firstNonEmptyString(provider, identityType, identity))
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
return usageIdentityResponse{
|
| 78 |
+
ID: item.ID,
|
| 79 |
+
Name: name,
|
| 80 |
+
AuthType: item.AuthType,
|
| 81 |
+
AuthTypeName: item.AuthTypeName,
|
| 82 |
+
Identity: identity,
|
| 83 |
+
Type: identityType,
|
| 84 |
+
Provider: provider,
|
| 85 |
+
TotalRequests: item.TotalRequests,
|
| 86 |
+
SuccessCount: item.SuccessCount,
|
| 87 |
+
FailureCount: item.FailureCount,
|
| 88 |
+
InputTokens: item.InputTokens,
|
| 89 |
+
OutputTokens: item.OutputTokens,
|
| 90 |
+
ReasoningTokens: item.ReasoningTokens,
|
| 91 |
+
CachedTokens: item.CachedTokens,
|
| 92 |
+
TotalTokens: item.TotalTokens,
|
| 93 |
+
LastAggregatedUsageEventID: item.LastAggregatedUsageEventID,
|
| 94 |
+
FirstUsedAt: item.FirstUsedAt,
|
| 95 |
+
LastUsedAt: item.LastUsedAt,
|
| 96 |
+
StatsUpdatedAt: item.StatsUpdatedAt,
|
| 97 |
+
IsDeleted: item.IsDeleted,
|
| 98 |
+
CreatedAt: item.CreatedAt,
|
| 99 |
+
UpdatedAt: item.UpdatedAt,
|
| 100 |
+
DeletedAt: item.DeletedAt,
|
| 101 |
+
}
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
func safeAIProviderDisplayValue(value, rawIdentity, fallback string) string {
|
| 105 |
+
trimmed := strings.TrimSpace(value)
|
| 106 |
+
if trimmed == "" {
|
| 107 |
+
return fallback
|
| 108 |
+
}
|
| 109 |
+
if isSensitiveUsageIdentityValue(trimmed, rawIdentity) {
|
| 110 |
+
return fallback
|
| 111 |
+
}
|
| 112 |
+
return trimmed
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
func isSensitiveUsageIdentityValue(value, rawIdentity string) bool {
|
| 116 |
+
trimmed := strings.TrimSpace(value)
|
| 117 |
+
if trimmed == "" {
|
| 118 |
+
return false
|
| 119 |
+
}
|
| 120 |
+
if raw := strings.TrimSpace(rawIdentity); raw != "" && strings.Contains(trimmed, raw) {
|
| 121 |
+
return true
|
| 122 |
+
}
|
| 123 |
+
lower := strings.ToLower(trimmed)
|
| 124 |
+
return strings.Contains(lower, "sk-") || strings.Contains(lower, "aiza") || strings.Contains(lower, "cr_") || strings.Contains(lower, "cr-")
|
| 125 |
+
}
|
internal/api/usage_identities_test.go
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package api
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"context"
|
| 5 |
+
"net/http"
|
| 6 |
+
"net/http/httptest"
|
| 7 |
+
"testing"
|
| 8 |
+
"time"
|
| 9 |
+
|
| 10 |
+
"cpa-usage-keeper/internal/models"
|
| 11 |
+
"cpa-usage-keeper/internal/redact"
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
type usageIdentitiesStub struct {
|
| 15 |
+
items []models.UsageIdentity
|
| 16 |
+
err error
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
func (s usageIdentitiesStub) ListUsageIdentities(context.Context) ([]models.UsageIdentity, error) {
|
| 20 |
+
return s.items, s.err
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
func TestUsageIdentitiesRouteReturnsMetadataStatsAndDeletedRows(t *testing.T) {
|
| 24 |
+
firstUsedAt := time.Date(2026, 5, 4, 8, 0, 0, 0, time.UTC)
|
| 25 |
+
lastUsedAt := time.Date(2026, 5, 4, 9, 0, 0, 0, time.UTC)
|
| 26 |
+
statsUpdatedAt := time.Date(2026, 5, 4, 10, 0, 0, 0, time.UTC)
|
| 27 |
+
createdAt := time.Date(2026, 5, 3, 8, 0, 0, 0, time.UTC)
|
| 28 |
+
updatedAt := time.Date(2026, 5, 4, 10, 30, 0, 0, time.UTC)
|
| 29 |
+
deletedAt := time.Date(2026, 5, 4, 11, 0, 0, 0, time.UTC)
|
| 30 |
+
|
| 31 |
+
router := NewRouter(nil, nil, nil, nil, AuthConfig{}, nil, "", usageIdentitiesStub{items: []models.UsageIdentity{
|
| 32 |
+
{
|
| 33 |
+
ID: 1,
|
| 34 |
+
Name: "Claude Desktop",
|
| 35 |
+
AuthType: models.UsageIdentityAuthTypeAuthFile,
|
| 36 |
+
AuthTypeName: "oauth",
|
| 37 |
+
Identity: "2",
|
| 38 |
+
Type: "auth-file",
|
| 39 |
+
Provider: "anthropic",
|
| 40 |
+
TotalRequests: 10,
|
| 41 |
+
SuccessCount: 8,
|
| 42 |
+
FailureCount: 2,
|
| 43 |
+
InputTokens: 100,
|
| 44 |
+
OutputTokens: 200,
|
| 45 |
+
ReasoningTokens: 30,
|
| 46 |
+
CachedTokens: 40,
|
| 47 |
+
TotalTokens: 370,
|
| 48 |
+
LastAggregatedUsageEventID: 99,
|
| 49 |
+
FirstUsedAt: &firstUsedAt,
|
| 50 |
+
LastUsedAt: &lastUsedAt,
|
| 51 |
+
StatsUpdatedAt: &statsUpdatedAt,
|
| 52 |
+
CreatedAt: createdAt,
|
| 53 |
+
UpdatedAt: updatedAt,
|
| 54 |
+
},
|
| 55 |
+
{
|
| 56 |
+
ID: 2,
|
| 57 |
+
Name: "Deleted Provider",
|
| 58 |
+
AuthType: models.UsageIdentityAuthTypeAIProvider,
|
| 59 |
+
AuthTypeName: "apikey",
|
| 60 |
+
Identity: "sk-deleted-provider-secret",
|
| 61 |
+
Type: "openai",
|
| 62 |
+
Provider: "OpenAI",
|
| 63 |
+
IsDeleted: true,
|
| 64 |
+
DeletedAt: &deletedAt,
|
| 65 |
+
CreatedAt: createdAt,
|
| 66 |
+
UpdatedAt: updatedAt,
|
| 67 |
+
},
|
| 68 |
+
}})
|
| 69 |
+
req := httptest.NewRequest(http.MethodGet, "/api/v1/usage/identities", nil)
|
| 70 |
+
resp := httptest.NewRecorder()
|
| 71 |
+
|
| 72 |
+
router.ServeHTTP(resp, req)
|
| 73 |
+
|
| 74 |
+
body := resp.Body.String()
|
| 75 |
+
if resp.Code != http.StatusOK {
|
| 76 |
+
t.Fatalf("expected status 200, got %d: %s", resp.Code, body)
|
| 77 |
+
}
|
| 78 |
+
if !contains(body, `"identities":[`) || !contains(body, `"id":1`) || !contains(body, `"identity":"2"`) {
|
| 79 |
+
t.Fatalf("expected auth file identity row in response, got %s", body)
|
| 80 |
+
}
|
| 81 |
+
for _, expected := range []string{
|
| 82 |
+
`"name":"Claude Desktop"`,
|
| 83 |
+
`"auth_type":1`,
|
| 84 |
+
`"auth_type_name":"oauth"`,
|
| 85 |
+
`"type":"auth-file"`,
|
| 86 |
+
`"provider":"anthropic"`,
|
| 87 |
+
`"total_requests":10`,
|
| 88 |
+
`"success_count":8`,
|
| 89 |
+
`"failure_count":2`,
|
| 90 |
+
`"input_tokens":100`,
|
| 91 |
+
`"output_tokens":200`,
|
| 92 |
+
`"reasoning_tokens":30`,
|
| 93 |
+
`"cached_tokens":40`,
|
| 94 |
+
`"total_tokens":370`,
|
| 95 |
+
`"last_aggregated_usage_event_id":99`,
|
| 96 |
+
`"first_used_at":"2026-05-04T08:00:00Z"`,
|
| 97 |
+
`"last_used_at":"2026-05-04T09:00:00Z"`,
|
| 98 |
+
`"stats_updated_at":"2026-05-04T10:00:00Z"`,
|
| 99 |
+
`"is_deleted":true`,
|
| 100 |
+
`"deleted_at":"2026-05-04T11:00:00Z"`,
|
| 101 |
+
} {
|
| 102 |
+
if !contains(body, expected) {
|
| 103 |
+
t.Fatalf("expected %s in response body: %s", expected, body)
|
| 104 |
+
}
|
| 105 |
+
}
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
func TestUsageIdentitiesRouteMasksAIProviderIdentity(t *testing.T) {
|
| 109 |
+
rawLookupKey := "sk-live-secret-value"
|
| 110 |
+
rawPrefix := "sk-live-prefix"
|
| 111 |
+
maskedLookupKey := redact.APIKeyDisplayName(rawLookupKey)
|
| 112 |
+
router := NewRouter(nil, nil, nil, nil, AuthConfig{}, nil, "", usageIdentitiesStub{items: []models.UsageIdentity{
|
| 113 |
+
{ID: 1, Name: rawPrefix, AuthType: models.UsageIdentityAuthTypeAIProvider, AuthTypeName: "apikey", Identity: rawLookupKey, Type: "openai " + rawLookupKey, Provider: "OpenAI " + rawPrefix},
|
| 114 |
+
}})
|
| 115 |
+
req := httptest.NewRequest(http.MethodGet, "/api/v1/usage/identities", nil)
|
| 116 |
+
resp := httptest.NewRecorder()
|
| 117 |
+
|
| 118 |
+
router.ServeHTTP(resp, req)
|
| 119 |
+
|
| 120 |
+
body := resp.Body.String()
|
| 121 |
+
if resp.Code != http.StatusOK {
|
| 122 |
+
t.Fatalf("expected status 200, got %d: %s", resp.Code, body)
|
| 123 |
+
}
|
| 124 |
+
if contains(body, rawLookupKey) || contains(body, rawPrefix) {
|
| 125 |
+
t.Fatalf("expected raw AI provider lookup values to be hidden, got %s", body)
|
| 126 |
+
}
|
| 127 |
+
if !contains(body, `"identity":"`+maskedLookupKey+`"`) {
|
| 128 |
+
t.Fatalf("expected masked AI provider identity %q in response body: %s", maskedLookupKey, body)
|
| 129 |
+
}
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
func TestUsageIdentityReplacesLegacyMetadataRoutes(t *testing.T) {
|
| 133 |
+
router := NewRouter(nil, nil, nil, nil, AuthConfig{}, nil, "", usageIdentitiesStub{})
|
| 134 |
+
for _, path := range []string{"/api/v1/auth-files", "/api/v1/provider-metadata"} {
|
| 135 |
+
req := httptest.NewRequest(http.MethodGet, path, nil)
|
| 136 |
+
resp := httptest.NewRecorder()
|
| 137 |
+
|
| 138 |
+
router.ServeHTTP(resp, req)
|
| 139 |
+
|
| 140 |
+
if resp.Code != http.StatusNotFound {
|
| 141 |
+
t.Fatalf("expected %s to return 404, got %d: %s", path, resp.Code, resp.Body.String())
|
| 142 |
+
}
|
| 143 |
+
}
|
| 144 |
+
}
|
internal/api/usage_overview.go
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package api
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"net/http"
|
| 5 |
+
"time"
|
| 6 |
+
|
| 7 |
+
"cpa-usage-keeper/internal/cpa"
|
| 8 |
+
"cpa-usage-keeper/internal/redact"
|
| 9 |
+
"cpa-usage-keeper/internal/service"
|
| 10 |
+
"github.com/gin-gonic/gin"
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
type usageOverviewResponse struct {
|
| 14 |
+
Usage usageOverviewPayload `json:"usage"`
|
| 15 |
+
Summary usageOverviewSummary `json:"summary"`
|
| 16 |
+
Series usageOverviewSeries `json:"series"`
|
| 17 |
+
HourlySeries usageOverviewSeries `json:"hourly_series"`
|
| 18 |
+
DailySeries usageOverviewSeries `json:"daily_series"`
|
| 19 |
+
ServiceHealth usageOverviewServiceHealth `json:"service_health"`
|
| 20 |
+
Timezone string `json:"timezone"`
|
| 21 |
+
RangeStart *time.Time `json:"range_start,omitempty"`
|
| 22 |
+
RangeEnd *time.Time `json:"range_end,omitempty"`
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
type usageOverviewPayload struct {
|
| 26 |
+
TotalRequests int64 `json:"total_requests"`
|
| 27 |
+
SuccessCount int64 `json:"success_count"`
|
| 28 |
+
FailureCount int64 `json:"failure_count"`
|
| 29 |
+
TotalTokens int64 `json:"total_tokens"`
|
| 30 |
+
APIs map[string]usageOverviewAPISnapshot `json:"apis"`
|
| 31 |
+
RequestsByDay map[string]int64 `json:"requests_by_day"`
|
| 32 |
+
RequestsByHour map[string]int64 `json:"requests_by_hour"`
|
| 33 |
+
TokensByDay map[string]int64 `json:"tokens_by_day"`
|
| 34 |
+
TokensByHour map[string]int64 `json:"tokens_by_hour"`
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
type usageOverviewSummary struct {
|
| 38 |
+
RequestCount int64 `json:"request_count"`
|
| 39 |
+
TokenCount int64 `json:"token_count"`
|
| 40 |
+
WindowMinutes int64 `json:"window_minutes"`
|
| 41 |
+
RPM float64 `json:"rpm"`
|
| 42 |
+
TPM float64 `json:"tpm"`
|
| 43 |
+
TotalCost float64 `json:"total_cost"`
|
| 44 |
+
CostAvailable bool `json:"cost_available"`
|
| 45 |
+
CachedTokens int64 `json:"cached_tokens"`
|
| 46 |
+
ReasoningTokens int64 `json:"reasoning_tokens"`
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
type usageOverviewSeries struct {
|
| 50 |
+
Requests map[string]int64 `json:"requests"`
|
| 51 |
+
Tokens map[string]int64 `json:"tokens"`
|
| 52 |
+
RPM map[string]float64 `json:"rpm"`
|
| 53 |
+
TPM map[string]float64 `json:"tpm"`
|
| 54 |
+
Cost map[string]float64 `json:"cost"`
|
| 55 |
+
InputTokens map[string]int64 `json:"input_tokens"`
|
| 56 |
+
OutputTokens map[string]int64 `json:"output_tokens"`
|
| 57 |
+
CachedTokens map[string]int64 `json:"cached_tokens"`
|
| 58 |
+
ReasoningTokens map[string]int64 `json:"reasoning_tokens"`
|
| 59 |
+
Models map[string]usageOverviewSeriesLine `json:"models"`
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
type usageOverviewSeriesLine struct {
|
| 63 |
+
Requests map[string]int64 `json:"requests"`
|
| 64 |
+
Tokens map[string]int64 `json:"tokens"`
|
| 65 |
+
RPM map[string]float64 `json:"rpm"`
|
| 66 |
+
TPM map[string]float64 `json:"tpm"`
|
| 67 |
+
Cost map[string]float64 `json:"cost"`
|
| 68 |
+
InputTokens map[string]int64 `json:"input_tokens"`
|
| 69 |
+
OutputTokens map[string]int64 `json:"output_tokens"`
|
| 70 |
+
CachedTokens map[string]int64 `json:"cached_tokens"`
|
| 71 |
+
ReasoningTokens map[string]int64 `json:"reasoning_tokens"`
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
type usageOverviewServiceHealth struct {
|
| 75 |
+
TotalSuccess int64 `json:"total_success"`
|
| 76 |
+
TotalFailure int64 `json:"total_failure"`
|
| 77 |
+
SuccessRate float64 `json:"success_rate"`
|
| 78 |
+
Rows int `json:"rows"`
|
| 79 |
+
Columns int `json:"columns"`
|
| 80 |
+
BucketSeconds int64 `json:"bucket_seconds"`
|
| 81 |
+
WindowStart time.Time `json:"window_start"`
|
| 82 |
+
WindowEnd time.Time `json:"window_end"`
|
| 83 |
+
BlockDetails []usageOverviewServiceHealthBlock `json:"block_details"`
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
type usageOverviewServiceHealthBlock struct {
|
| 87 |
+
StartTime time.Time `json:"start_time"`
|
| 88 |
+
EndTime time.Time `json:"end_time"`
|
| 89 |
+
Success int64 `json:"success"`
|
| 90 |
+
Failure int64 `json:"failure"`
|
| 91 |
+
Rate float64 `json:"rate"`
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
type usageOverviewAPISnapshot struct {
|
| 95 |
+
DisplayName string `json:"display_name,omitempty"`
|
| 96 |
+
TotalRequests int64 `json:"total_requests"`
|
| 97 |
+
SuccessCount int64 `json:"success_count"`
|
| 98 |
+
FailureCount int64 `json:"failure_count"`
|
| 99 |
+
TotalTokens int64 `json:"total_tokens"`
|
| 100 |
+
Models map[string]usageOverviewModelSnapshot `json:"models"`
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
type usageOverviewModelSnapshot struct {
|
| 104 |
+
TotalRequests int64 `json:"total_requests"`
|
| 105 |
+
SuccessCount int64 `json:"success_count"`
|
| 106 |
+
FailureCount int64 `json:"failure_count"`
|
| 107 |
+
TotalTokens int64 `json:"total_tokens"`
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
func registerUsageOverviewRoute(router gin.IRoutes, usageProvider service.UsageProvider) {
|
| 111 |
+
router.GET("/usage/overview", func(c *gin.Context) {
|
| 112 |
+
if usageProvider == nil {
|
| 113 |
+
c.JSON(http.StatusOK, usageOverviewResponse{
|
| 114 |
+
Usage: buildUsageOverviewPayload(nil),
|
| 115 |
+
Summary: usageOverviewSummary{},
|
| 116 |
+
Series: emptyUsageOverviewSeries(),
|
| 117 |
+
HourlySeries: emptyUsageOverviewSeries(),
|
| 118 |
+
DailySeries: emptyUsageOverviewSeries(),
|
| 119 |
+
ServiceHealth: usageOverviewServiceHealth{BlockDetails: []usageOverviewServiceHealthBlock{}},
|
| 120 |
+
Timezone: time.Local.String(),
|
| 121 |
+
})
|
| 122 |
+
return
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
filter, err := parseUsageFilterQuery(c.Request, time.Now().UTC())
|
| 126 |
+
if err != nil {
|
| 127 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
| 128 |
+
return
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
overview, err := usageProvider.GetUsageOverview(c.Request.Context(), filter)
|
| 132 |
+
if err != nil {
|
| 133 |
+
writeInternalError(c, "get usage overview failed", err)
|
| 134 |
+
return
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
var usage *cpa.StatisticsSnapshot
|
| 138 |
+
if overview != nil {
|
| 139 |
+
usage = overview.Usage
|
| 140 |
+
}
|
| 141 |
+
redactedUsage := redact.UsageSnapshot(usage)
|
| 142 |
+
c.JSON(http.StatusOK, usageOverviewResponse{
|
| 143 |
+
Usage: buildUsageOverviewPayload(redactedUsage),
|
| 144 |
+
Summary: buildUsageOverviewSummary(overview),
|
| 145 |
+
Series: buildUsageOverviewSeries(overview),
|
| 146 |
+
HourlySeries: buildUsageOverviewHourlySeries(overview),
|
| 147 |
+
DailySeries: buildUsageOverviewDailySeries(overview),
|
| 148 |
+
ServiceHealth: buildUsageOverviewServiceHealth(overview),
|
| 149 |
+
Timezone: time.Local.String(),
|
| 150 |
+
RangeStart: filter.StartTime,
|
| 151 |
+
RangeEnd: filter.EndTime,
|
| 152 |
+
})
|
| 153 |
+
})
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
func buildUsageOverviewPayload(snapshot *cpa.StatisticsSnapshot) usageOverviewPayload {
|
| 157 |
+
if snapshot == nil {
|
| 158 |
+
return usageOverviewPayload{
|
| 159 |
+
APIs: map[string]usageOverviewAPISnapshot{},
|
| 160 |
+
RequestsByDay: map[string]int64{},
|
| 161 |
+
RequestsByHour: map[string]int64{},
|
| 162 |
+
TokensByDay: map[string]int64{},
|
| 163 |
+
TokensByHour: map[string]int64{},
|
| 164 |
+
}
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
payload := usageOverviewPayload{
|
| 168 |
+
TotalRequests: snapshot.TotalRequests,
|
| 169 |
+
SuccessCount: snapshot.SuccessCount,
|
| 170 |
+
FailureCount: snapshot.FailureCount,
|
| 171 |
+
TotalTokens: snapshot.TotalTokens,
|
| 172 |
+
RequestsByDay: cloneInt64Map(snapshot.RequestsByDay),
|
| 173 |
+
RequestsByHour: cloneInt64Map(snapshot.RequestsByHour),
|
| 174 |
+
TokensByDay: cloneInt64Map(snapshot.TokensByDay),
|
| 175 |
+
TokensByHour: cloneInt64Map(snapshot.TokensByHour),
|
| 176 |
+
APIs: map[string]usageOverviewAPISnapshot{},
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
for apiName, apiSnapshot := range snapshot.APIs {
|
| 180 |
+
payloadAPI := usageOverviewAPISnapshot{
|
| 181 |
+
DisplayName: apiSnapshot.DisplayName,
|
| 182 |
+
TotalRequests: apiSnapshot.TotalRequests,
|
| 183 |
+
SuccessCount: apiSnapshot.SuccessCount,
|
| 184 |
+
FailureCount: apiSnapshot.FailureCount,
|
| 185 |
+
TotalTokens: apiSnapshot.TotalTokens,
|
| 186 |
+
Models: map[string]usageOverviewModelSnapshot{},
|
| 187 |
+
}
|
| 188 |
+
for modelName, modelSnapshot := range apiSnapshot.Models {
|
| 189 |
+
payloadAPI.Models[modelName] = usageOverviewModelSnapshot{
|
| 190 |
+
TotalRequests: modelSnapshot.TotalRequests,
|
| 191 |
+
SuccessCount: modelSnapshot.SuccessCount,
|
| 192 |
+
FailureCount: modelSnapshot.FailureCount,
|
| 193 |
+
TotalTokens: modelSnapshot.TotalTokens,
|
| 194 |
+
}
|
| 195 |
+
}
|
| 196 |
+
payload.APIs[apiName] = payloadAPI
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
return payload
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
func buildUsageOverviewSummary(overview *service.UsageOverviewSnapshot) usageOverviewSummary {
|
| 203 |
+
if overview == nil {
|
| 204 |
+
return usageOverviewSummary{}
|
| 205 |
+
}
|
| 206 |
+
return usageOverviewSummary{
|
| 207 |
+
RequestCount: overview.Summary.RequestCount,
|
| 208 |
+
TokenCount: overview.Summary.TokenCount,
|
| 209 |
+
WindowMinutes: overview.Summary.WindowMinutes,
|
| 210 |
+
RPM: overview.Summary.RPM,
|
| 211 |
+
TPM: overview.Summary.TPM,
|
| 212 |
+
TotalCost: overview.Summary.TotalCost,
|
| 213 |
+
CostAvailable: overview.Summary.CostAvailable,
|
| 214 |
+
CachedTokens: overview.Summary.CachedTokens,
|
| 215 |
+
ReasoningTokens: overview.Summary.ReasoningTokens,
|
| 216 |
+
}
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
func emptyUsageOverviewSeries() usageOverviewSeries {
|
| 220 |
+
return usageOverviewSeries{
|
| 221 |
+
Requests: map[string]int64{},
|
| 222 |
+
Tokens: map[string]int64{},
|
| 223 |
+
RPM: map[string]float64{},
|
| 224 |
+
TPM: map[string]float64{},
|
| 225 |
+
Cost: map[string]float64{},
|
| 226 |
+
InputTokens: map[string]int64{},
|
| 227 |
+
OutputTokens: map[string]int64{},
|
| 228 |
+
CachedTokens: map[string]int64{},
|
| 229 |
+
ReasoningTokens: map[string]int64{},
|
| 230 |
+
Models: map[string]usageOverviewSeriesLine{},
|
| 231 |
+
}
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
func mapUsageOverviewSeriesLine(series service.UsageOverviewSeries) usageOverviewSeriesLine {
|
| 235 |
+
return usageOverviewSeriesLine{
|
| 236 |
+
Requests: cloneInt64Map(series.Requests),
|
| 237 |
+
Tokens: cloneInt64Map(series.Tokens),
|
| 238 |
+
RPM: cloneFloat64Map(series.RPM),
|
| 239 |
+
TPM: cloneFloat64Map(series.TPM),
|
| 240 |
+
Cost: cloneFloat64Map(series.Cost),
|
| 241 |
+
InputTokens: cloneInt64Map(series.InputTokens),
|
| 242 |
+
OutputTokens: cloneInt64Map(series.OutputTokens),
|
| 243 |
+
CachedTokens: cloneInt64Map(series.CachedTokens),
|
| 244 |
+
ReasoningTokens: cloneInt64Map(series.ReasoningTokens),
|
| 245 |
+
}
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
func mapUsageOverviewSeries(series service.UsageOverviewSeries) usageOverviewSeries {
|
| 249 |
+
models := make(map[string]usageOverviewSeriesLine, len(series.Models))
|
| 250 |
+
for model, modelSeries := range series.Models {
|
| 251 |
+
models[model] = mapUsageOverviewSeriesLine(modelSeries)
|
| 252 |
+
}
|
| 253 |
+
return usageOverviewSeries{
|
| 254 |
+
Requests: cloneInt64Map(series.Requests),
|
| 255 |
+
Tokens: cloneInt64Map(series.Tokens),
|
| 256 |
+
RPM: cloneFloat64Map(series.RPM),
|
| 257 |
+
TPM: cloneFloat64Map(series.TPM),
|
| 258 |
+
Cost: cloneFloat64Map(series.Cost),
|
| 259 |
+
InputTokens: cloneInt64Map(series.InputTokens),
|
| 260 |
+
OutputTokens: cloneInt64Map(series.OutputTokens),
|
| 261 |
+
CachedTokens: cloneInt64Map(series.CachedTokens),
|
| 262 |
+
ReasoningTokens: cloneInt64Map(series.ReasoningTokens),
|
| 263 |
+
Models: models,
|
| 264 |
+
}
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
func buildUsageOverviewSeries(overview *service.UsageOverviewSnapshot) usageOverviewSeries {
|
| 268 |
+
if overview == nil {
|
| 269 |
+
return emptyUsageOverviewSeries()
|
| 270 |
+
}
|
| 271 |
+
return mapUsageOverviewSeries(overview.Series)
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
func buildUsageOverviewHourlySeries(overview *service.UsageOverviewSnapshot) usageOverviewSeries {
|
| 275 |
+
if overview == nil {
|
| 276 |
+
return emptyUsageOverviewSeries()
|
| 277 |
+
}
|
| 278 |
+
return mapUsageOverviewSeries(overview.HourlySeries)
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
func buildUsageOverviewDailySeries(overview *service.UsageOverviewSnapshot) usageOverviewSeries {
|
| 282 |
+
if overview == nil {
|
| 283 |
+
return emptyUsageOverviewSeries()
|
| 284 |
+
}
|
| 285 |
+
return mapUsageOverviewSeries(overview.DailySeries)
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
func buildUsageOverviewServiceHealth(overview *service.UsageOverviewSnapshot) usageOverviewServiceHealth {
|
| 289 |
+
if overview == nil {
|
| 290 |
+
return usageOverviewServiceHealth{BlockDetails: []usageOverviewServiceHealthBlock{}}
|
| 291 |
+
}
|
| 292 |
+
blocks := make([]usageOverviewServiceHealthBlock, 0, len(overview.Health.BlockDetails))
|
| 293 |
+
for _, block := range overview.Health.BlockDetails {
|
| 294 |
+
blocks = append(blocks, usageOverviewServiceHealthBlock{
|
| 295 |
+
StartTime: block.StartTime,
|
| 296 |
+
EndTime: block.EndTime,
|
| 297 |
+
Success: block.Success,
|
| 298 |
+
Failure: block.Failure,
|
| 299 |
+
Rate: block.Rate,
|
| 300 |
+
})
|
| 301 |
+
}
|
| 302 |
+
return usageOverviewServiceHealth{
|
| 303 |
+
TotalSuccess: overview.Health.TotalSuccess,
|
| 304 |
+
TotalFailure: overview.Health.TotalFailure,
|
| 305 |
+
SuccessRate: overview.Health.SuccessRate,
|
| 306 |
+
Rows: overview.Health.Rows,
|
| 307 |
+
Columns: overview.Health.Columns,
|
| 308 |
+
BucketSeconds: overview.Health.BucketSeconds,
|
| 309 |
+
WindowStart: overview.Health.WindowStart,
|
| 310 |
+
WindowEnd: overview.Health.WindowEnd,
|
| 311 |
+
BlockDetails: blocks,
|
| 312 |
+
}
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
func cloneInt64Map(source map[string]int64) map[string]int64 {
|
| 316 |
+
if len(source) == 0 {
|
| 317 |
+
return map[string]int64{}
|
| 318 |
+
}
|
| 319 |
+
cloned := make(map[string]int64, len(source))
|
| 320 |
+
for key, value := range source {
|
| 321 |
+
cloned[key] = value
|
| 322 |
+
}
|
| 323 |
+
return cloned
|
| 324 |
+
}
|
| 325 |
+
|
| 326 |
+
func cloneFloat64Map(source map[string]float64) map[string]float64 {
|
| 327 |
+
if len(source) == 0 {
|
| 328 |
+
return map[string]float64{}
|
| 329 |
+
}
|
| 330 |
+
cloned := make(map[string]float64, len(source))
|
| 331 |
+
for key, value := range source {
|
| 332 |
+
cloned[key] = value
|
| 333 |
+
}
|
| 334 |
+
return cloned
|
| 335 |
+
}
|
internal/api/usage_overview_test.go
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package api
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"context"
|
| 5 |
+
"net/http"
|
| 6 |
+
"net/http/httptest"
|
| 7 |
+
"testing"
|
| 8 |
+
"time"
|
| 9 |
+
|
| 10 |
+
"cpa-usage-keeper/internal/cpa"
|
| 11 |
+
"cpa-usage-keeper/internal/service"
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
type usageFilterStub struct {
|
| 15 |
+
usage *cpa.StatisticsSnapshot
|
| 16 |
+
overview *service.UsageOverviewSnapshot
|
| 17 |
+
err error
|
| 18 |
+
lastFilter service.UsageFilter
|
| 19 |
+
filterCalls int
|
| 20 |
+
overviewCalls int
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
func (s *usageFilterStub) GetUsageWithFilter(_ context.Context, filter service.UsageFilter) (*cpa.StatisticsSnapshot, error) {
|
| 24 |
+
s.lastFilter = filter
|
| 25 |
+
s.filterCalls++
|
| 26 |
+
return s.usage, s.err
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
func (s *usageFilterStub) GetUsageOverview(_ context.Context, filter service.UsageFilter) (*service.UsageOverviewSnapshot, error) {
|
| 30 |
+
s.lastFilter = filter
|
| 31 |
+
s.overviewCalls++
|
| 32 |
+
return s.overview, s.err
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
func (s *usageFilterStub) ListUsageEvents(context.Context, service.UsageFilter) (*service.UsageEventsPage, error) {
|
| 36 |
+
return nil, s.err
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
func (s *usageFilterStub) ListUsageEventFilterOptions(context.Context, service.UsageFilter) (*service.UsageEventFilterOptions, error) {
|
| 40 |
+
return nil, s.err
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
func (s *usageFilterStub) ListUsageCredentialStats(context.Context, service.UsageFilter) ([]service.UsageCredentialStat, error) {
|
| 44 |
+
return nil, s.err
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
func (s *usageFilterStub) GetUsageAnalysis(context.Context, service.UsageFilter) (*service.UsageAnalysisSnapshot, error) {
|
| 48 |
+
return nil, s.err
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
func mustParseTime(t *testing.T, value string) time.Time {
|
| 52 |
+
t.Helper()
|
| 53 |
+
parsed, err := time.Parse(time.RFC3339, value)
|
| 54 |
+
if err != nil {
|
| 55 |
+
t.Fatalf("time.Parse returned error: %v", err)
|
| 56 |
+
}
|
| 57 |
+
return parsed
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
func TestUsageOverviewResponseIncludesResolvedRangeAndTimezone(t *testing.T) {
|
| 61 |
+
previousLocal := time.Local
|
| 62 |
+
location, err := time.LoadLocation("Asia/Shanghai")
|
| 63 |
+
if err != nil {
|
| 64 |
+
t.Fatalf("load location: %v", err)
|
| 65 |
+
}
|
| 66 |
+
t.Cleanup(func() { time.Local = previousLocal })
|
| 67 |
+
time.Local = location
|
| 68 |
+
|
| 69 |
+
provider := &usageFilterStub{overview: &service.UsageOverviewSnapshot{}}
|
| 70 |
+
router := NewRouter(nil, nil, provider, nil, AuthConfig{}, nil, "")
|
| 71 |
+
req := httptest.NewRequest(http.MethodGet, "/api/v1/usage/overview?range=custom&start=2026-04-20&end=2026-04-21", nil)
|
| 72 |
+
resp := httptest.NewRecorder()
|
| 73 |
+
|
| 74 |
+
router.ServeHTTP(resp, req)
|
| 75 |
+
|
| 76 |
+
if resp.Code != http.StatusOK {
|
| 77 |
+
t.Fatalf("expected status 200, got %d", resp.Code)
|
| 78 |
+
}
|
| 79 |
+
expectedStart := time.Date(2026, 4, 20, 0, 0, 0, 0, location).UTC().Format(time.RFC3339Nano)
|
| 80 |
+
expectedEnd := time.Date(2026, 4, 22, 0, 0, 0, 0, location).Add(-time.Nanosecond).UTC().Format(time.RFC3339Nano)
|
| 81 |
+
body := resp.Body.String()
|
| 82 |
+
if !contains(body, `"timezone":"Asia/Shanghai"`) || !contains(body, `"range_start":"`+expectedStart+`"`) || !contains(body, `"range_end":"`+expectedEnd+`"`) {
|
| 83 |
+
t.Fatalf("expected overview response to include resolved range and timezone, got %s", body)
|
| 84 |
+
}
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
func TestUsageOverviewReturnsFilteredSnapshot(t *testing.T) {
|
| 88 |
+
provider := &usageFilterStub{overview: &service.UsageOverviewSnapshot{
|
| 89 |
+
Usage: &cpa.StatisticsSnapshot{
|
| 90 |
+
TotalRequests: 1,
|
| 91 |
+
SuccessCount: 1,
|
| 92 |
+
TotalTokens: 20,
|
| 93 |
+
RequestsByHour: map[string]int64{
|
| 94 |
+
"2026-04-22T11:00:00Z": 1,
|
| 95 |
+
},
|
| 96 |
+
TokensByHour: map[string]int64{
|
| 97 |
+
"2026-04-22T11:00:00Z": 20,
|
| 98 |
+
},
|
| 99 |
+
APIs: map[string]cpa.APISnapshot{
|
| 100 |
+
"provider-a": {
|
| 101 |
+
TotalRequests: 1,
|
| 102 |
+
SuccessCount: 1,
|
| 103 |
+
TotalTokens: 20,
|
| 104 |
+
Models: map[string]cpa.ModelSnapshot{
|
| 105 |
+
"claude-sonnet": {
|
| 106 |
+
TotalRequests: 1,
|
| 107 |
+
SuccessCount: 1,
|
| 108 |
+
TotalTokens: 20,
|
| 109 |
+
},
|
| 110 |
+
},
|
| 111 |
+
},
|
| 112 |
+
},
|
| 113 |
+
},
|
| 114 |
+
Summary: service.UsageOverviewSummary{
|
| 115 |
+
RequestCount: 1,
|
| 116 |
+
TokenCount: 20,
|
| 117 |
+
WindowMinutes: 1440,
|
| 118 |
+
RPM: 1.0 / 1440.0,
|
| 119 |
+
TPM: 20.0 / 1440.0,
|
| 120 |
+
TotalCost: 0.123,
|
| 121 |
+
CostAvailable: true,
|
| 122 |
+
CachedTokens: 2,
|
| 123 |
+
ReasoningTokens: 3,
|
| 124 |
+
},
|
| 125 |
+
Series: service.UsageOverviewSeries{
|
| 126 |
+
Requests: map[string]int64{"2026-04-22T11:00:00Z": 1},
|
| 127 |
+
Tokens: map[string]int64{"2026-04-22T11:00:00Z": 20},
|
| 128 |
+
RPM: map[string]float64{"2026-04-22T11:00:00Z": 1.0 / 60.0},
|
| 129 |
+
TPM: map[string]float64{"2026-04-22T11:00:00Z": 20.0 / 60.0},
|
| 130 |
+
Cost: map[string]float64{"2026-04-22T11:00:00Z": 0.123},
|
| 131 |
+
InputTokens: map[string]int64{"2026-04-22T11:00:00Z": 11},
|
| 132 |
+
OutputTokens: map[string]int64{"2026-04-22T11:00:00Z": 7},
|
| 133 |
+
CachedTokens: map[string]int64{"2026-04-22T11:00:00Z": 2},
|
| 134 |
+
ReasoningTokens: map[string]int64{"2026-04-22T11:00:00Z": 3},
|
| 135 |
+
},
|
| 136 |
+
Health: service.UsageOverviewHealth{
|
| 137 |
+
TotalSuccess: 1,
|
| 138 |
+
TotalFailure: 0,
|
| 139 |
+
SuccessRate: 100,
|
| 140 |
+
BlockDetails: []service.UsageOverviewHealthBlock{{
|
| 141 |
+
StartTime: mustParseTime(t, "2026-04-22T11:00:00Z"),
|
| 142 |
+
EndTime: mustParseTime(t, "2026-04-22T11:15:00Z"),
|
| 143 |
+
Success: 1,
|
| 144 |
+
Failure: 0,
|
| 145 |
+
Rate: 1,
|
| 146 |
+
}},
|
| 147 |
+
},
|
| 148 |
+
}}
|
| 149 |
+
router := NewRouter(nil, nil, provider, nil, AuthConfig{}, nil, "")
|
| 150 |
+
req := httptest.NewRequest(http.MethodGet, "/api/v1/usage/overview?range=24h", nil)
|
| 151 |
+
resp := httptest.NewRecorder()
|
| 152 |
+
|
| 153 |
+
router.ServeHTTP(resp, req)
|
| 154 |
+
|
| 155 |
+
if resp.Code != http.StatusOK {
|
| 156 |
+
t.Fatalf("expected status 200, got %d", resp.Code)
|
| 157 |
+
}
|
| 158 |
+
body := resp.Body.String()
|
| 159 |
+
if !contains(body, `"usage":`) || !contains(body, `"total_requests":1`) {
|
| 160 |
+
t.Fatalf("unexpected response body: %s", body)
|
| 161 |
+
}
|
| 162 |
+
if !contains(body, `"summary":{"request_count":1,"token_count":20`) {
|
| 163 |
+
t.Fatalf("expected backend summary in response body: %s", body)
|
| 164 |
+
}
|
| 165 |
+
if !contains(body, `"cost_available":true`) {
|
| 166 |
+
t.Fatalf("expected backend cost availability in response body: %s", body)
|
| 167 |
+
}
|
| 168 |
+
if !contains(body, `"series":{"requests":{"2026-04-22T11:00:00Z":1}`) {
|
| 169 |
+
t.Fatalf("expected backend series in response body: %s", body)
|
| 170 |
+
}
|
| 171 |
+
if !contains(body, `"input_tokens":{"2026-04-22T11:00:00Z":11}`) ||
|
| 172 |
+
!contains(body, `"output_tokens":{"2026-04-22T11:00:00Z":7}`) ||
|
| 173 |
+
!contains(body, `"cached_tokens":{"2026-04-22T11:00:00Z":2}`) ||
|
| 174 |
+
!contains(body, `"reasoning_tokens":{"2026-04-22T11:00:00Z":3}`) {
|
| 175 |
+
t.Fatalf("expected token breakdown series in response body: %s", body)
|
| 176 |
+
}
|
| 177 |
+
if !contains(body, `"service_health":{"total_success":1,"total_failure":0,"success_rate":100`) ||
|
| 178 |
+
!contains(body, `"block_details":[{"start_time":"2026-04-22T11:00:00Z","end_time":"2026-04-22T11:15:00Z","success":1,"failure":0,"rate":1}]`) {
|
| 179 |
+
t.Fatalf("expected service health in response body: %s", body)
|
| 180 |
+
}
|
| 181 |
+
if contains(body, `"details":`) {
|
| 182 |
+
t.Fatalf("expected overview response to omit request details: %s", body)
|
| 183 |
+
}
|
| 184 |
+
if provider.filterCalls != 0 {
|
| 185 |
+
t.Fatalf("expected GetUsageWithFilter not to be called, got %d", provider.filterCalls)
|
| 186 |
+
}
|
| 187 |
+
if provider.overviewCalls != 1 {
|
| 188 |
+
t.Fatalf("expected GetUsageOverview to be called once, got %d", provider.overviewCalls)
|
| 189 |
+
}
|
| 190 |
+
if provider.lastFilter.Range != "24h" {
|
| 191 |
+
t.Fatalf("expected range to be passed through, got %+v", provider.lastFilter)
|
| 192 |
+
}
|
| 193 |
+
if provider.lastFilter.StartTime == nil || provider.lastFilter.EndTime == nil {
|
| 194 |
+
t.Fatalf("expected resolved time bounds in filter, got %+v", provider.lastFilter)
|
| 195 |
+
}
|
| 196 |
+
}
|
internal/api/usage_resolution.go
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package api
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"cpa-usage-keeper/internal/models"
|
| 5 |
+
"cpa-usage-keeper/internal/service"
|
| 6 |
+
"github.com/gin-gonic/gin"
|
| 7 |
+
)
|
| 8 |
+
|
| 9 |
+
func loadUsageResolutionData(
|
| 10 |
+
c *gin.Context,
|
| 11 |
+
usageIdentityProvider service.UsageIdentityProvider,
|
| 12 |
+
) ([]models.UsageIdentity, error) {
|
| 13 |
+
if usageIdentityProvider == nil {
|
| 14 |
+
return []models.UsageIdentity{}, nil
|
| 15 |
+
}
|
| 16 |
+
return usageIdentityProvider.ListUsageIdentities(c.Request.Context())
|
| 17 |
+
}
|
internal/api/usage_source_resolution.go
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package api
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"strconv"
|
| 5 |
+
"strings"
|
| 6 |
+
|
| 7 |
+
"cpa-usage-keeper/internal/models"
|
| 8 |
+
"cpa-usage-keeper/internal/redact"
|
| 9 |
+
)
|
| 10 |
+
|
| 11 |
+
type usageSourceResolver struct {
|
| 12 |
+
authIdentities map[string]models.UsageIdentity
|
| 13 |
+
providerIdentities map[string]models.UsageIdentity
|
| 14 |
+
providerRawByKey map[string]string
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
func newUsageSourceResolver(identities []models.UsageIdentity) usageSourceResolver {
|
| 18 |
+
authIdentities := make(map[string]models.UsageIdentity, len(identities))
|
| 19 |
+
providerIdentities := make(map[string]models.UsageIdentity, len(identities))
|
| 20 |
+
providerRawByKey := make(map[string]string, len(identities))
|
| 21 |
+
for _, identity := range identities {
|
| 22 |
+
key := strings.TrimSpace(identity.Identity)
|
| 23 |
+
if key == "" {
|
| 24 |
+
continue
|
| 25 |
+
}
|
| 26 |
+
switch identity.AuthType {
|
| 27 |
+
case models.UsageIdentityAuthTypeAuthFile:
|
| 28 |
+
authIdentities[key] = identity
|
| 29 |
+
case models.UsageIdentityAuthTypeAIProvider:
|
| 30 |
+
providerIdentities[key] = identity
|
| 31 |
+
resolved := usageSourceResolutionFromIdentity(identity, key)
|
| 32 |
+
if resolved.SourceKey != "" {
|
| 33 |
+
providerRawByKey[resolved.SourceKey] = key
|
| 34 |
+
}
|
| 35 |
+
}
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
return usageSourceResolver{
|
| 39 |
+
authIdentities: authIdentities,
|
| 40 |
+
providerIdentities: providerIdentities,
|
| 41 |
+
providerRawByKey: providerRawByKey,
|
| 42 |
+
}
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
type usageSourceResolution struct {
|
| 46 |
+
DisplayName string
|
| 47 |
+
SourceType string
|
| 48 |
+
SourceKey string
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
func usageSourceResolutionFromIdentity(item models.UsageIdentity, fallbackIdentity string) usageSourceResolution {
|
| 52 |
+
identityType := safeAIProviderDisplayValue(item.Type, fallbackIdentity, "")
|
| 53 |
+
displayName := firstNonEmptyString(
|
| 54 |
+
safeAIProviderDisplayValue(item.Name, fallbackIdentity, ""),
|
| 55 |
+
safeAIProviderDisplayValue(item.Provider, fallbackIdentity, ""),
|
| 56 |
+
identityType,
|
| 57 |
+
redact.APIKeyDisplayName(fallbackIdentity),
|
| 58 |
+
)
|
| 59 |
+
sourceKey := "provider:" + uintToString(item.ID)
|
| 60 |
+
if item.ID == 0 {
|
| 61 |
+
sourceKey = "provider:" + redact.APIKeyDisplayName(fallbackIdentity)
|
| 62 |
+
}
|
| 63 |
+
return usageSourceResolution{
|
| 64 |
+
DisplayName: displayName,
|
| 65 |
+
SourceType: identityType,
|
| 66 |
+
SourceKey: sourceKey,
|
| 67 |
+
}
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
func (r usageSourceResolver) rawSourceForPublicValue(value string) string {
|
| 71 |
+
trimmed := strings.TrimSpace(value)
|
| 72 |
+
if trimmed == "" {
|
| 73 |
+
return ""
|
| 74 |
+
}
|
| 75 |
+
if raw, ok := r.providerRawByKey[trimmed]; ok {
|
| 76 |
+
return raw
|
| 77 |
+
}
|
| 78 |
+
return trimmed
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
func (r usageSourceResolver) resolve(rawSource string, authIndex string) usageSourceResolution {
|
| 82 |
+
normalizedSource := strings.TrimSpace(rawSource)
|
| 83 |
+
if normalizedSource != "" {
|
| 84 |
+
if item, ok := r.providerIdentities[normalizedSource]; ok {
|
| 85 |
+
return usageSourceResolutionFromIdentity(item, normalizedSource)
|
| 86 |
+
}
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
normalizedAuthIndex := strings.TrimSpace(authIndex)
|
| 90 |
+
if normalizedAuthIndex != "" {
|
| 91 |
+
if identity, ok := r.authIdentities[normalizedAuthIndex]; ok {
|
| 92 |
+
displayName := firstNonEmptyString(identity.Name, normalizedAuthIndex)
|
| 93 |
+
return usageSourceResolution{
|
| 94 |
+
DisplayName: displayName,
|
| 95 |
+
SourceType: firstNonEmptyString(identity.Type, identity.Provider),
|
| 96 |
+
SourceKey: "auth:" + normalizedAuthIndex,
|
| 97 |
+
}
|
| 98 |
+
}
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
if normalizedSource == "" {
|
| 102 |
+
return usageSourceResolution{DisplayName: "-", SourceKey: "raw:-"}
|
| 103 |
+
}
|
| 104 |
+
if looksLikeEmail(normalizedSource) {
|
| 105 |
+
return usageSourceResolution{
|
| 106 |
+
DisplayName: normalizedSource,
|
| 107 |
+
SourceKey: "email:" + normalizedSource,
|
| 108 |
+
}
|
| 109 |
+
}
|
| 110 |
+
if inferredProvider := inferUsageProviderType(normalizedSource); inferredProvider != "" {
|
| 111 |
+
return usageSourceResolution{
|
| 112 |
+
DisplayName: inferredProvider,
|
| 113 |
+
SourceType: inferredProvider,
|
| 114 |
+
SourceKey: "provider:fallback:" + inferredProvider,
|
| 115 |
+
}
|
| 116 |
+
}
|
| 117 |
+
masked := redact.APIKeyDisplayName(normalizedSource)
|
| 118 |
+
return usageSourceResolution{
|
| 119 |
+
DisplayName: masked,
|
| 120 |
+
SourceKey: "raw:" + masked,
|
| 121 |
+
}
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
func uintToString(value uint) string {
|
| 125 |
+
return strconv.FormatUint(uint64(value), 10)
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
func firstNonEmptyString(values ...string) string {
|
| 129 |
+
for _, value := range values {
|
| 130 |
+
trimmed := strings.TrimSpace(value)
|
| 131 |
+
if trimmed != "" {
|
| 132 |
+
return trimmed
|
| 133 |
+
}
|
| 134 |
+
}
|
| 135 |
+
return ""
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
func looksLikeEmail(value string) bool {
|
| 139 |
+
trimmed := strings.TrimSpace(value)
|
| 140 |
+
if trimmed == "" {
|
| 141 |
+
return false
|
| 142 |
+
}
|
| 143 |
+
atIndex := strings.Index(trimmed, "@")
|
| 144 |
+
return atIndex > 0 && atIndex < len(trimmed)-1 && strings.Contains(trimmed[atIndex+1:], ".")
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
func inferUsageProviderType(source string) string {
|
| 148 |
+
value := strings.ToLower(strings.TrimSpace(source))
|
| 149 |
+
switch {
|
| 150 |
+
case value == "":
|
| 151 |
+
return ""
|
| 152 |
+
case strings.Contains(value, "ampcode"):
|
| 153 |
+
return "ampcode"
|
| 154 |
+
case strings.HasPrefix(value, "sk-ant-") || strings.Contains(value, "anthropic") || strings.Contains(value, "claude"):
|
| 155 |
+
return "claude"
|
| 156 |
+
case strings.HasPrefix(value, "sk-proj-") || strings.HasPrefix(value, "sk-") || strings.Contains(value, "openai") || strings.Contains(value, "gpt"):
|
| 157 |
+
return "openai"
|
| 158 |
+
case strings.HasPrefix(value, "aiza") || strings.Contains(value, "gemini"):
|
| 159 |
+
return "gemini"
|
| 160 |
+
case strings.Contains(value, "vertex"):
|
| 161 |
+
return "vertex"
|
| 162 |
+
case strings.Contains(value, "codex"):
|
| 163 |
+
return "codex"
|
| 164 |
+
default:
|
| 165 |
+
return ""
|
| 166 |
+
}
|
| 167 |
+
}
|
internal/app/app.go
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package app
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"context"
|
| 5 |
+
"errors"
|
| 6 |
+
"fmt"
|
| 7 |
+
"io"
|
| 8 |
+
"sync"
|
| 9 |
+
|
| 10 |
+
"cpa-usage-keeper/internal/api"
|
| 11 |
+
"cpa-usage-keeper/internal/auth"
|
| 12 |
+
"cpa-usage-keeper/internal/config"
|
| 13 |
+
"cpa-usage-keeper/internal/cpa"
|
| 14 |
+
"cpa-usage-keeper/internal/logging"
|
| 15 |
+
"cpa-usage-keeper/internal/poller"
|
| 16 |
+
"cpa-usage-keeper/internal/repository"
|
| 17 |
+
"cpa-usage-keeper/internal/service"
|
| 18 |
+
webui "cpa-usage-keeper/web"
|
| 19 |
+
"github.com/gin-gonic/gin"
|
| 20 |
+
"github.com/sirupsen/logrus"
|
| 21 |
+
"gorm.io/gorm"
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
type Runner interface {
|
| 25 |
+
Run(ctx context.Context) error
|
| 26 |
+
Status() poller.Status
|
| 27 |
+
SyncNow(ctx context.Context) error
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
type Options struct {
|
| 31 |
+
EnvFile string
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
type App struct {
|
| 35 |
+
Config *config.Config
|
| 36 |
+
DB *gorm.DB
|
| 37 |
+
Router *gin.Engine
|
| 38 |
+
Poller Runner
|
| 39 |
+
Maintenance *StorageCleanupRunner
|
| 40 |
+
MetadataSync *MetadataSyncRunner
|
| 41 |
+
BackupMaintenance *DatabaseBackupRunner
|
| 42 |
+
LogCloser io.Closer
|
| 43 |
+
|
| 44 |
+
backgroundCancel context.CancelFunc
|
| 45 |
+
backgroundWG sync.WaitGroup
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
func New() (*App, error) {
|
| 49 |
+
return NewWithOptions(Options{})
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
func NewWithOptions(options Options) (*App, error) {
|
| 53 |
+
cfg, err := config.Load(config.LoadOptions{EnvFile: options.EnvFile})
|
| 54 |
+
if err != nil {
|
| 55 |
+
return nil, err
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
return NewWithConfig(*cfg)
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
func NewWithConfig(cfg config.Config) (*App, error) {
|
| 62 |
+
logCloser, err := logging.Configure(cfg)
|
| 63 |
+
if err != nil {
|
| 64 |
+
return nil, err
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
db, err := repository.OpenDatabase(cfg)
|
| 68 |
+
if err != nil {
|
| 69 |
+
_ = logCloser.Close()
|
| 70 |
+
return nil, err
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
syncService := service.NewSyncService(db, cfg)
|
| 74 |
+
backgroundPoller := poller.NewRedisDrain(syncService, poller.RedisDrainConfig{
|
| 75 |
+
IdleInterval: cfg.RedisQueueIdleInterval,
|
| 76 |
+
ErrorBackoff: cfg.RedisQueueErrorBackoff,
|
| 77 |
+
})
|
| 78 |
+
var backupMaintenance *DatabaseBackupRunner
|
| 79 |
+
if cfg.BackupEnabled {
|
| 80 |
+
sqlDB, err := db.DB()
|
| 81 |
+
if err != nil {
|
| 82 |
+
_ = closeGormDB(db)
|
| 83 |
+
_ = logCloser.Close()
|
| 84 |
+
return nil, err
|
| 85 |
+
}
|
| 86 |
+
backupStore := newDatabaseBackupStore(sqlDB, cfg.BackupDir)
|
| 87 |
+
backupMaintenance = NewDatabaseBackupRunner(backupStore, backupStore, cfg.BackupInterval, cfg.BackupRetentionDays)
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
usageService := service.NewUsageService(db)
|
| 91 |
+
usageIdentityService := service.NewUsageIdentityService(db)
|
| 92 |
+
pricingModelsClient := cpa.NewClient(cfg.CPABaseURL, cfg.CPAManagementKey, cfg.RequestTimeout)
|
| 93 |
+
pricingService := service.NewPricingService(db, pricingModelsClient)
|
| 94 |
+
sessionManager := auth.NewSessionManager(cfg.AuthSessionTTL)
|
| 95 |
+
authHandler := api.NewAuthHandler(api.AuthConfig{
|
| 96 |
+
Enabled: cfg.AuthEnabled,
|
| 97 |
+
LoginPassword: cfg.LoginPassword,
|
| 98 |
+
SessionTTL: cfg.AuthSessionTTL,
|
| 99 |
+
BasePath: cfg.AppBasePath,
|
| 100 |
+
}, sessionManager)
|
| 101 |
+
|
| 102 |
+
return &App{
|
| 103 |
+
Config: &cfg,
|
| 104 |
+
DB: db,
|
| 105 |
+
Poller: backgroundPoller,
|
| 106 |
+
Maintenance: NewStorageCleanupRunner(syncService),
|
| 107 |
+
MetadataSync: NewMetadataSyncRunner(syncService, cfg.MetadataSyncInterval),
|
| 108 |
+
BackupMaintenance: backupMaintenance,
|
| 109 |
+
LogCloser: logCloser,
|
| 110 |
+
Router: api.NewRouter(
|
| 111 |
+
webui.Static,
|
| 112 |
+
newManualSyncRunner(backgroundPoller, syncService),
|
| 113 |
+
usageService,
|
| 114 |
+
pricingService,
|
| 115 |
+
api.AuthConfig{
|
| 116 |
+
Enabled: cfg.AuthEnabled,
|
| 117 |
+
LoginPassword: cfg.LoginPassword,
|
| 118 |
+
SessionTTL: cfg.AuthSessionTTL,
|
| 119 |
+
BasePath: cfg.AppBasePath,
|
| 120 |
+
},
|
| 121 |
+
authHandler,
|
| 122 |
+
cfg.AppBasePath,
|
| 123 |
+
usageIdentityService,
|
| 124 |
+
),
|
| 125 |
+
}, nil
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
func closeGormDB(db *gorm.DB) error {
|
| 129 |
+
if db == nil {
|
| 130 |
+
return nil
|
| 131 |
+
}
|
| 132 |
+
sqlDB, err := db.DB()
|
| 133 |
+
if err != nil {
|
| 134 |
+
return err
|
| 135 |
+
}
|
| 136 |
+
return sqlDB.Close()
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
func (a *App) Close() error {
|
| 140 |
+
if a == nil {
|
| 141 |
+
return nil
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
a.stopBackgroundTasks()
|
| 145 |
+
|
| 146 |
+
var closeErr error
|
| 147 |
+
if a.DB != nil {
|
| 148 |
+
closeErr = errors.Join(closeErr, closeGormDB(a.DB))
|
| 149 |
+
a.DB = nil
|
| 150 |
+
}
|
| 151 |
+
if a.LogCloser != nil {
|
| 152 |
+
closeErr = errors.Join(closeErr, a.LogCloser.Close())
|
| 153 |
+
a.LogCloser = nil
|
| 154 |
+
}
|
| 155 |
+
return closeErr
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
func (a *App) Run() error {
|
| 159 |
+
if a == nil || a.Router == nil || a.Config == nil {
|
| 160 |
+
return fmt.Errorf("application is not initialized")
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
ctx := a.startBackgroundContext()
|
| 164 |
+
defer a.stopBackgroundTasks()
|
| 165 |
+
if a.Poller != nil {
|
| 166 |
+
a.startBackgroundTask(func() {
|
| 167 |
+
if err := a.Poller.Run(ctx); err != nil {
|
| 168 |
+
logrus.Errorf("poller stopped: %v", err)
|
| 169 |
+
}
|
| 170 |
+
})
|
| 171 |
+
}
|
| 172 |
+
if a.Maintenance != nil {
|
| 173 |
+
a.startBackgroundTask(func() {
|
| 174 |
+
if err := a.Maintenance.Run(ctx); err != nil {
|
| 175 |
+
logrus.Errorf("maintenance cleanup stopped: %v", err)
|
| 176 |
+
}
|
| 177 |
+
})
|
| 178 |
+
}
|
| 179 |
+
if a.MetadataSync != nil {
|
| 180 |
+
a.startBackgroundTask(func() {
|
| 181 |
+
if err := a.MetadataSync.Run(ctx); err != nil {
|
| 182 |
+
logrus.Errorf("metadata sync stopped: %v", err)
|
| 183 |
+
}
|
| 184 |
+
})
|
| 185 |
+
}
|
| 186 |
+
if a.BackupMaintenance != nil {
|
| 187 |
+
a.startBackgroundTask(func() {
|
| 188 |
+
if err := a.BackupMaintenance.Run(ctx); err != nil {
|
| 189 |
+
logrus.Errorf("database backup stopped: %v", err)
|
| 190 |
+
}
|
| 191 |
+
})
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
return a.Router.Run(":" + a.Config.AppPort)
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
func (a *App) startBackgroundContext() context.Context {
|
| 198 |
+
ctx, cancel := context.WithCancel(context.Background())
|
| 199 |
+
a.backgroundCancel = cancel
|
| 200 |
+
return ctx
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
func (a *App) startBackgroundTask(run func()) {
|
| 204 |
+
a.backgroundWG.Add(1)
|
| 205 |
+
go func() {
|
| 206 |
+
defer a.backgroundWG.Done()
|
| 207 |
+
run()
|
| 208 |
+
}()
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
func (a *App) stopBackgroundTasks() {
|
| 212 |
+
if a.backgroundCancel != nil {
|
| 213 |
+
a.backgroundCancel()
|
| 214 |
+
a.backgroundCancel = nil
|
| 215 |
+
}
|
| 216 |
+
a.backgroundWG.Wait()
|
| 217 |
+
}
|
internal/app/app_test.go
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package app
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"bytes"
|
| 5 |
+
"context"
|
| 6 |
+
"testing"
|
| 7 |
+
"time"
|
| 8 |
+
|
| 9 |
+
"cpa-usage-keeper/internal/config"
|
| 10 |
+
"cpa-usage-keeper/internal/poller"
|
| 11 |
+
"github.com/gin-gonic/gin"
|
| 12 |
+
"github.com/sirupsen/logrus"
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
func TestAppCloseClosesDatabase(t *testing.T) {
|
| 16 |
+
app, err := NewWithConfig(testAppConfig(t))
|
| 17 |
+
if err != nil {
|
| 18 |
+
t.Fatalf("NewWithConfig returned error: %v", err)
|
| 19 |
+
}
|
| 20 |
+
sqlDB, err := app.DB.DB()
|
| 21 |
+
if err != nil {
|
| 22 |
+
t.Fatalf("load sql db: %v", err)
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
if err := app.Close(); err != nil {
|
| 26 |
+
t.Fatalf("Close returned error: %v", err)
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
if err := sqlDB.Ping(); err == nil {
|
| 30 |
+
t.Fatal("expected database ping to fail after app close")
|
| 31 |
+
}
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
func TestNewWithConfigBuildsRedisDrainAndRouter(t *testing.T) {
|
| 35 |
+
app, err := NewWithConfig(testAppConfig(t))
|
| 36 |
+
if err != nil {
|
| 37 |
+
t.Fatalf("NewWithConfig returned error: %v", err)
|
| 38 |
+
}
|
| 39 |
+
defer app.Close()
|
| 40 |
+
if app.Poller == nil {
|
| 41 |
+
t.Fatal("expected poller to be initialized")
|
| 42 |
+
}
|
| 43 |
+
if app.Router == nil {
|
| 44 |
+
t.Fatal("expected router to be initialized")
|
| 45 |
+
}
|
| 46 |
+
if app.LogCloser == nil {
|
| 47 |
+
t.Fatal("expected log closer to be initialized")
|
| 48 |
+
}
|
| 49 |
+
if app.BackupMaintenance == nil {
|
| 50 |
+
t.Fatal("expected database backup runner to be initialized")
|
| 51 |
+
}
|
| 52 |
+
if app.MetadataSync == nil {
|
| 53 |
+
t.Fatal("expected metadata sync runner to be initialized")
|
| 54 |
+
}
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
func TestNewWithConfigSkipsBackupRunnerWhenDisabled(t *testing.T) {
|
| 58 |
+
cfg := testAppConfig(t)
|
| 59 |
+
cfg.BackupEnabled = false
|
| 60 |
+
app, err := NewWithConfig(cfg)
|
| 61 |
+
if err != nil {
|
| 62 |
+
t.Fatalf("NewWithConfig returned error: %v", err)
|
| 63 |
+
}
|
| 64 |
+
defer app.Close()
|
| 65 |
+
if app.BackupMaintenance != nil {
|
| 66 |
+
t.Fatal("expected database backup runner to be skipped when backups are disabled")
|
| 67 |
+
}
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
func TestNewWithConfigSelectsRedisDrain(t *testing.T) {
|
| 71 |
+
app, err := NewWithConfig(testAppConfig(t))
|
| 72 |
+
if err != nil {
|
| 73 |
+
t.Fatalf("NewWithConfig returned error: %v", err)
|
| 74 |
+
}
|
| 75 |
+
defer app.Close()
|
| 76 |
+
if _, ok := app.Poller.(*poller.RedisDrain); !ok {
|
| 77 |
+
t.Fatalf("expected redis to use redis drain, got %T", app.Poller)
|
| 78 |
+
}
|
| 79 |
+
if app.Maintenance == nil {
|
| 80 |
+
t.Fatal("expected maintenance cleanup runner to be initialized")
|
| 81 |
+
}
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
func TestNewWithConfigCreatesIndependentMaintenanceRunner(t *testing.T) {
|
| 85 |
+
app, err := NewWithConfig(testAppConfig(t))
|
| 86 |
+
if err != nil {
|
| 87 |
+
t.Fatalf("NewWithConfig returned error: %v", err)
|
| 88 |
+
}
|
| 89 |
+
defer app.Close()
|
| 90 |
+
if app.Poller == nil {
|
| 91 |
+
t.Fatal("expected sync poller to be initialized")
|
| 92 |
+
}
|
| 93 |
+
if app.Maintenance == nil {
|
| 94 |
+
t.Fatal("expected independent maintenance runner to be initialized")
|
| 95 |
+
}
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
func TestRunStartsPollerAndMaintenanceIndependently(t *testing.T) {
|
| 99 |
+
cfg := testAppConfig(t)
|
| 100 |
+
cfg.AppPort = "invalid-port"
|
| 101 |
+
pollerStarted := make(chan struct{})
|
| 102 |
+
maintenanceStarted := make(chan struct{})
|
| 103 |
+
metadataStarted := make(chan struct{})
|
| 104 |
+
backupStarted := make(chan struct{})
|
| 105 |
+
maintenance := NewStorageCleanupRunner(&maintenanceSyncStub{})
|
| 106 |
+
maintenance.sleep = func(context.Context, time.Duration) bool {
|
| 107 |
+
close(maintenanceStarted)
|
| 108 |
+
return false
|
| 109 |
+
}
|
| 110 |
+
metadataRunner := NewMetadataSyncRunner(&metadataSyncStub{}, time.Second)
|
| 111 |
+
metadataRunner.sleep = func(context.Context, time.Duration) bool {
|
| 112 |
+
close(metadataStarted)
|
| 113 |
+
return false
|
| 114 |
+
}
|
| 115 |
+
backupRunner := NewDatabaseBackupRunner(&databaseBackupWriterStub{}, nil, time.Second, 0)
|
| 116 |
+
backupRunner.sleep = func(context.Context, time.Duration) bool {
|
| 117 |
+
close(backupStarted)
|
| 118 |
+
return false
|
| 119 |
+
}
|
| 120 |
+
app := &App{
|
| 121 |
+
Config: &cfg,
|
| 122 |
+
Router: gin.New(),
|
| 123 |
+
Poller: &appRunStub{started: pollerStarted},
|
| 124 |
+
Maintenance: maintenance,
|
| 125 |
+
MetadataSync: metadataRunner,
|
| 126 |
+
BackupMaintenance: backupRunner,
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
if err := app.Run(); err == nil {
|
| 130 |
+
t.Fatal("expected Run to return an error for invalid port")
|
| 131 |
+
}
|
| 132 |
+
select {
|
| 133 |
+
case <-pollerStarted:
|
| 134 |
+
case <-time.After(time.Second):
|
| 135 |
+
t.Fatal("expected poller runner to start")
|
| 136 |
+
}
|
| 137 |
+
select {
|
| 138 |
+
case <-maintenanceStarted:
|
| 139 |
+
case <-time.After(time.Second):
|
| 140 |
+
t.Fatal("expected maintenance runner to start")
|
| 141 |
+
}
|
| 142 |
+
select {
|
| 143 |
+
case <-metadataStarted:
|
| 144 |
+
case <-time.After(time.Second):
|
| 145 |
+
t.Fatal("expected metadata sync runner to start")
|
| 146 |
+
}
|
| 147 |
+
select {
|
| 148 |
+
case <-backupStarted:
|
| 149 |
+
case <-time.After(time.Second):
|
| 150 |
+
t.Fatal("expected database backup runner to start")
|
| 151 |
+
}
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
func TestRunCancelsBackgroundTasksWhenRouterStops(t *testing.T) {
|
| 155 |
+
cfg := testAppConfig(t)
|
| 156 |
+
cfg.AppPort = "invalid-port"
|
| 157 |
+
backupStarted := make(chan struct{})
|
| 158 |
+
backupCanceled := make(chan struct{})
|
| 159 |
+
backupRunner := NewDatabaseBackupRunner(&databaseBackupWriterStub{}, nil, time.Second, 0)
|
| 160 |
+
backupRunner.sleep = func(ctx context.Context, _ time.Duration) bool {
|
| 161 |
+
close(backupStarted)
|
| 162 |
+
<-ctx.Done()
|
| 163 |
+
close(backupCanceled)
|
| 164 |
+
return false
|
| 165 |
+
}
|
| 166 |
+
app := &App{
|
| 167 |
+
Config: &cfg,
|
| 168 |
+
Router: gin.New(),
|
| 169 |
+
BackupMaintenance: backupRunner,
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
if err := app.Run(); err == nil {
|
| 173 |
+
t.Fatal("expected Run to return an error for invalid port")
|
| 174 |
+
}
|
| 175 |
+
select {
|
| 176 |
+
case <-backupStarted:
|
| 177 |
+
case <-time.After(time.Second):
|
| 178 |
+
t.Fatal("expected database backup runner to start")
|
| 179 |
+
}
|
| 180 |
+
select {
|
| 181 |
+
case <-backupCanceled:
|
| 182 |
+
case <-time.After(time.Second):
|
| 183 |
+
t.Fatal("expected database backup runner context to be canceled")
|
| 184 |
+
}
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
type appRunStub struct {
|
| 188 |
+
started chan struct{}
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
func (s *appRunStub) Run(context.Context) error {
|
| 192 |
+
close(s.started)
|
| 193 |
+
return nil
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
func (s *appRunStub) Status() poller.Status {
|
| 197 |
+
return poller.Status{}
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
func (s *appRunStub) SyncNow(context.Context) error {
|
| 201 |
+
return nil
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
func captureAppInfoLogs(t *testing.T) *bytes.Buffer {
|
| 205 |
+
t.Helper()
|
| 206 |
+
var logs bytes.Buffer
|
| 207 |
+
previousOutput := logrus.StandardLogger().Out
|
| 208 |
+
previousFormatter := logrus.StandardLogger().Formatter
|
| 209 |
+
previousLevel := logrus.GetLevel()
|
| 210 |
+
logrus.SetOutput(&logs)
|
| 211 |
+
logrus.SetFormatter(&logrus.TextFormatter{DisableTimestamp: true})
|
| 212 |
+
logrus.SetLevel(logrus.InfoLevel)
|
| 213 |
+
t.Cleanup(func() {
|
| 214 |
+
logrus.SetOutput(previousOutput)
|
| 215 |
+
logrus.SetFormatter(previousFormatter)
|
| 216 |
+
logrus.SetLevel(previousLevel)
|
| 217 |
+
})
|
| 218 |
+
return &logs
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
func testAppConfig(t *testing.T) config.Config {
|
| 222 |
+
t.Helper()
|
| 223 |
+
return config.Config{
|
| 224 |
+
AppPort: "8080",
|
| 225 |
+
CPABaseURL: "https://cpa.example.com",
|
| 226 |
+
CPAManagementKey: "secret",
|
| 227 |
+
RedisQueueIdleInterval: time.Second,
|
| 228 |
+
RedisQueueErrorBackoff: 10 * time.Second,
|
| 229 |
+
MetadataSyncInterval: 30 * time.Second,
|
| 230 |
+
SQLitePath: t.TempDir() + "/app.db",
|
| 231 |
+
BackupEnabled: true,
|
| 232 |
+
BackupDir: t.TempDir() + "/backups",
|
| 233 |
+
BackupRetentionDays: 7,
|
| 234 |
+
RequestTimeout: 5 * time.Second,
|
| 235 |
+
LogLevel: "info",
|
| 236 |
+
LogFileEnabled: false,
|
| 237 |
+
LogRetentionDays: 7,
|
| 238 |
+
}
|
| 239 |
+
}
|
internal/app/backup_runner.go
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package app
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"context"
|
| 5 |
+
"database/sql"
|
| 6 |
+
"fmt"
|
| 7 |
+
"os"
|
| 8 |
+
"sync"
|
| 9 |
+
"time"
|
| 10 |
+
|
| 11 |
+
"cpa-usage-keeper/internal/backup"
|
| 12 |
+
"github.com/sirupsen/logrus"
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
type DatabaseBackupWriter interface {
|
| 16 |
+
WriteDatabase(context.Context, time.Time) (string, error)
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
type DatabaseBackupCleaner interface {
|
| 20 |
+
Cleanup(retentionDays int, now time.Time) (int, error)
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
type DatabaseBackupHistory interface {
|
| 24 |
+
LastBackupAt() (time.Time, bool, error)
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
type databaseBackupStore struct {
|
| 28 |
+
db *sql.DB
|
| 29 |
+
dir string
|
| 30 |
+
writer *backup.Writer
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
func newDatabaseBackupStore(db *sql.DB, dir string) *databaseBackupStore {
|
| 34 |
+
return &databaseBackupStore{db: db, dir: dir, writer: backup.NewWriter(dir)}
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
func (s *databaseBackupStore) WriteDatabase(ctx context.Context, backupAt time.Time) (string, error) {
|
| 38 |
+
return s.writer.WriteDatabase(ctx, s.db, backupAt)
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
func (s *databaseBackupStore) Cleanup(retentionDays int, now time.Time) (int, error) {
|
| 42 |
+
return s.writer.Cleanup(retentionDays, now)
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
func (s *databaseBackupStore) LastBackupAt() (time.Time, bool, error) {
|
| 46 |
+
files, err := backup.ListFiles(s.dir)
|
| 47 |
+
if err != nil {
|
| 48 |
+
return time.Time{}, false, err
|
| 49 |
+
}
|
| 50 |
+
var latest time.Time
|
| 51 |
+
for _, file := range files {
|
| 52 |
+
info, err := os.Stat(file)
|
| 53 |
+
if err != nil {
|
| 54 |
+
return time.Time{}, false, err
|
| 55 |
+
}
|
| 56 |
+
if info.ModTime().After(latest) {
|
| 57 |
+
latest = info.ModTime()
|
| 58 |
+
}
|
| 59 |
+
}
|
| 60 |
+
return latest, !latest.IsZero(), nil
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
type DatabaseBackupRunner struct {
|
| 64 |
+
writer DatabaseBackupWriter
|
| 65 |
+
cleaner DatabaseBackupCleaner
|
| 66 |
+
history DatabaseBackupHistory
|
| 67 |
+
interval time.Duration
|
| 68 |
+
retentionDays int
|
| 69 |
+
lastBackupAt time.Time
|
| 70 |
+
retryDelay time.Duration
|
| 71 |
+
retryAttempts int
|
| 72 |
+
pendingRetry bool
|
| 73 |
+
now func() time.Time
|
| 74 |
+
sleep func(context.Context, time.Duration) bool
|
| 75 |
+
|
| 76 |
+
mu sync.Mutex
|
| 77 |
+
running bool
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
func NewDatabaseBackupRunner(writer DatabaseBackupWriter, cleaner DatabaseBackupCleaner, interval time.Duration, retentionDays int) *DatabaseBackupRunner {
|
| 81 |
+
history, _ := writer.(DatabaseBackupHistory)
|
| 82 |
+
return &DatabaseBackupRunner{
|
| 83 |
+
writer: writer,
|
| 84 |
+
cleaner: cleaner,
|
| 85 |
+
history: history,
|
| 86 |
+
interval: interval,
|
| 87 |
+
retentionDays: retentionDays,
|
| 88 |
+
retryDelay: 15 * time.Minute,
|
| 89 |
+
now: time.Now,
|
| 90 |
+
sleep: maintenanceSleepContext,
|
| 91 |
+
}
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
func (r *DatabaseBackupRunner) Run(ctx context.Context) error {
|
| 95 |
+
if err := r.validate(); err != nil {
|
| 96 |
+
return err
|
| 97 |
+
}
|
| 98 |
+
logrus.Info("database backup task started")
|
| 99 |
+
r.setRunning(true)
|
| 100 |
+
defer r.setRunning(false)
|
| 101 |
+
|
| 102 |
+
for {
|
| 103 |
+
now := r.now()
|
| 104 |
+
delay := r.nextDelay(now)
|
| 105 |
+
if delay < 0 {
|
| 106 |
+
delay = 0
|
| 107 |
+
}
|
| 108 |
+
if !r.sleep(ctx, delay) {
|
| 109 |
+
return nil
|
| 110 |
+
}
|
| 111 |
+
backupAt := r.now()
|
| 112 |
+
if _, err := r.writer.WriteDatabase(ctx, backupAt); err != nil {
|
| 113 |
+
logrus.WithError(err).Error("database backup failed")
|
| 114 |
+
r.retryAttempts++
|
| 115 |
+
r.pendingRetry = r.retryAttempts <= 3
|
| 116 |
+
} else {
|
| 117 |
+
r.lastBackupAt = backupAt
|
| 118 |
+
r.retryAttempts = 0
|
| 119 |
+
r.pendingRetry = false
|
| 120 |
+
}
|
| 121 |
+
r.cleanup(backupAt)
|
| 122 |
+
}
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
func (r *DatabaseBackupRunner) nextDelay(now time.Time) time.Duration {
|
| 126 |
+
if r.pendingRetry {
|
| 127 |
+
return r.retryDelay
|
| 128 |
+
}
|
| 129 |
+
if r.usesDailySchedule() {
|
| 130 |
+
return r.nextDailyDelay(now)
|
| 131 |
+
}
|
| 132 |
+
lastBackupAt, ok := r.lastBackupAtFromHistory()
|
| 133 |
+
if !ok {
|
| 134 |
+
return 0
|
| 135 |
+
}
|
| 136 |
+
return lastBackupAt.Add(r.interval).Sub(now)
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
func (r *DatabaseBackupRunner) cleanup(now time.Time) {
|
| 140 |
+
if r.cleaner == nil || r.retentionDays <= 0 {
|
| 141 |
+
return
|
| 142 |
+
}
|
| 143 |
+
if _, err := r.cleaner.Cleanup(r.retentionDays, now); err != nil {
|
| 144 |
+
logrus.WithError(err).Error("database backup cleanup failed")
|
| 145 |
+
}
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
func (r *DatabaseBackupRunner) nextDailyDelay(now time.Time) time.Duration {
|
| 149 |
+
localNow := now.In(time.Local)
|
| 150 |
+
nextBackupAt := nextDailyBackupAt(localNow)
|
| 151 |
+
if lastBackupAt, ok := r.lastBackupAtFromHistory(); ok {
|
| 152 |
+
lastLocalBackup := lastBackupAt.In(time.Local)
|
| 153 |
+
lastBackupDay := time.Date(lastLocalBackup.Year(), lastLocalBackup.Month(), lastLocalBackup.Day(), 4, 0, 0, 0, time.Local)
|
| 154 |
+
candidate := lastBackupDay.AddDate(0, 0, r.dailyScheduleDays())
|
| 155 |
+
if localNow.Before(candidate) {
|
| 156 |
+
nextBackupAt = candidate
|
| 157 |
+
}
|
| 158 |
+
}
|
| 159 |
+
return nextBackupAt.Sub(localNow)
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
func (r *DatabaseBackupRunner) usesDailySchedule() bool {
|
| 163 |
+
return r.interval >= 24*time.Hour && r.interval%(24*time.Hour) == 0
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
func (r *DatabaseBackupRunner) dailyScheduleDays() int {
|
| 167 |
+
return int(r.interval / (24 * time.Hour))
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
func (r *DatabaseBackupRunner) lastBackupAtFromHistory() (time.Time, bool) {
|
| 171 |
+
lastBackupAt := r.lastBackupAt
|
| 172 |
+
if r.history != nil {
|
| 173 |
+
storedBackupAt, ok, err := r.history.LastBackupAt()
|
| 174 |
+
if err != nil {
|
| 175 |
+
logrus.WithError(err).Error("load last database backup time failed")
|
| 176 |
+
} else if ok && storedBackupAt.After(lastBackupAt) {
|
| 177 |
+
lastBackupAt = storedBackupAt
|
| 178 |
+
}
|
| 179 |
+
}
|
| 180 |
+
return lastBackupAt, !lastBackupAt.IsZero()
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
func nextDailyBackupAt(now time.Time) time.Time {
|
| 184 |
+
localNow := now.In(time.Local)
|
| 185 |
+
backupAt := time.Date(localNow.Year(), localNow.Month(), localNow.Day(), 4, 0, 0, 0, time.Local)
|
| 186 |
+
if !localNow.Before(backupAt) {
|
| 187 |
+
backupAt = backupAt.AddDate(0, 0, 1)
|
| 188 |
+
}
|
| 189 |
+
return backupAt
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
func (r *DatabaseBackupRunner) validate() error {
|
| 193 |
+
if r == nil {
|
| 194 |
+
return fmt.Errorf("database backup runner is nil")
|
| 195 |
+
}
|
| 196 |
+
if r.writer == nil {
|
| 197 |
+
return fmt.Errorf("database backup writer is nil")
|
| 198 |
+
}
|
| 199 |
+
if r.interval <= 0 {
|
| 200 |
+
return fmt.Errorf("database backup interval must be positive")
|
| 201 |
+
}
|
| 202 |
+
if r.retryDelay <= 0 {
|
| 203 |
+
r.retryDelay = 15 * time.Minute
|
| 204 |
+
}
|
| 205 |
+
if r.now == nil {
|
| 206 |
+
r.now = time.Now
|
| 207 |
+
}
|
| 208 |
+
if r.sleep == nil {
|
| 209 |
+
r.sleep = maintenanceSleepContext
|
| 210 |
+
}
|
| 211 |
+
return nil
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
func (r *DatabaseBackupRunner) setRunning(running bool) {
|
| 215 |
+
r.mu.Lock()
|
| 216 |
+
defer r.mu.Unlock()
|
| 217 |
+
r.running = running
|
| 218 |
+
}
|
internal/app/backup_runner_test.go
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package app
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"context"
|
| 5 |
+
"errors"
|
| 6 |
+
"strings"
|
| 7 |
+
"testing"
|
| 8 |
+
"time"
|
| 9 |
+
)
|
| 10 |
+
|
| 11 |
+
func TestNextDailyBackupAtUsesLocal0400(t *testing.T) {
|
| 12 |
+
previousLocal := time.Local
|
| 13 |
+
time.Local = time.FixedZone("Test/Local", 8*60*60)
|
| 14 |
+
t.Cleanup(func() { time.Local = previousLocal })
|
| 15 |
+
|
| 16 |
+
before := time.Date(2026, 4, 16, 3, 30, 0, 0, time.Local)
|
| 17 |
+
if got := nextDailyBackupAt(before); !got.Equal(time.Date(2026, 4, 16, 4, 0, 0, 0, time.Local)) {
|
| 18 |
+
t.Fatalf("expected same-day 04:00 backup, got %s", got)
|
| 19 |
+
}
|
| 20 |
+
after := time.Date(2026, 4, 16, 4, 1, 0, 0, time.Local)
|
| 21 |
+
if got := nextDailyBackupAt(after); !got.Equal(time.Date(2026, 4, 17, 4, 0, 0, 0, time.Local)) {
|
| 22 |
+
t.Fatalf("expected next-day 04:00 backup, got %s", got)
|
| 23 |
+
}
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
func TestDatabaseBackupRunnerRunsAtScheduledTime(t *testing.T) {
|
| 27 |
+
writer := &databaseBackupWriterStub{}
|
| 28 |
+
cleaner := &databaseBackupCleanerStub{}
|
| 29 |
+
runner := NewDatabaseBackupRunner(writer, cleaner, 24*time.Hour, 30)
|
| 30 |
+
now := time.Date(2026, 4, 16, 3, 45, 0, 0, time.Local)
|
| 31 |
+
runner.now = func() time.Time { return now }
|
| 32 |
+
sleepCalls := 0
|
| 33 |
+
runner.sleep = func(context.Context, time.Duration) bool {
|
| 34 |
+
sleepCalls++
|
| 35 |
+
if sleepCalls == 1 {
|
| 36 |
+
return true
|
| 37 |
+
}
|
| 38 |
+
return false
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
if err := runner.Run(context.Background()); err != nil {
|
| 42 |
+
t.Fatalf("Run returned error: %v", err)
|
| 43 |
+
}
|
| 44 |
+
if writer.calls != 1 {
|
| 45 |
+
t.Fatalf("expected one database backup, got %d", writer.calls)
|
| 46 |
+
}
|
| 47 |
+
if cleaner.calls != 1 {
|
| 48 |
+
t.Fatalf("expected one backup cleanup, got %d", cleaner.calls)
|
| 49 |
+
}
|
| 50 |
+
if cleaner.retentionDays != 30 {
|
| 51 |
+
t.Fatalf("expected cleanup retention 30, got %d", cleaner.retentionDays)
|
| 52 |
+
}
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
func TestDatabaseBackupRunnerWaitsUntilNext0400AfterDailyScheduleWithoutTodaysBackup(t *testing.T) {
|
| 56 |
+
writer := &databaseBackupWriterStub{}
|
| 57 |
+
runner := NewDatabaseBackupRunner(writer, nil, 24*time.Hour, 0)
|
| 58 |
+
now := time.Date(2026, 4, 16, 4, 5, 0, 0, time.Local)
|
| 59 |
+
writer.lastBackupAt = now.AddDate(0, 0, -1)
|
| 60 |
+
runner.now = func() time.Time { return now }
|
| 61 |
+
var delay time.Duration
|
| 62 |
+
runner.sleep = func(_ context.Context, d time.Duration) bool {
|
| 63 |
+
delay = d
|
| 64 |
+
return false
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
if err := runner.Run(context.Background()); err != nil {
|
| 68 |
+
t.Fatalf("Run returned error: %v", err)
|
| 69 |
+
}
|
| 70 |
+
expected := time.Date(2026, 4, 17, 4, 0, 0, 0, time.Local).Sub(now)
|
| 71 |
+
if delay != expected {
|
| 72 |
+
t.Fatalf("expected next 04:00 backup delay %s, got %s", expected, delay)
|
| 73 |
+
}
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
func TestDatabaseBackupRunnerWaitsUntilTomorrowAfterTodaysDailyBackup(t *testing.T) {
|
| 77 |
+
writer := &databaseBackupWriterStub{}
|
| 78 |
+
runner := NewDatabaseBackupRunner(writer, nil, 24*time.Hour, 0)
|
| 79 |
+
now := time.Date(2026, 4, 16, 4, 5, 0, 0, time.Local)
|
| 80 |
+
writer.lastBackupAt = time.Date(2026, 4, 16, 4, 0, 0, 0, time.Local)
|
| 81 |
+
runner.now = func() time.Time { return now }
|
| 82 |
+
var delay time.Duration
|
| 83 |
+
runner.sleep = func(_ context.Context, d time.Duration) bool {
|
| 84 |
+
delay = d
|
| 85 |
+
return false
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
if err := runner.Run(context.Background()); err != nil {
|
| 89 |
+
t.Fatalf("Run returned error: %v", err)
|
| 90 |
+
}
|
| 91 |
+
expected := time.Date(2026, 4, 17, 4, 0, 0, 0, time.Local).Sub(now)
|
| 92 |
+
if delay != expected {
|
| 93 |
+
t.Fatalf("expected next daily backup delay %s, got %s", expected, delay)
|
| 94 |
+
}
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
func TestDatabaseBackupRunnerUsesDailyScheduleForMultipleOf24Hours(t *testing.T) {
|
| 98 |
+
writer := &databaseBackupWriterStub{}
|
| 99 |
+
runner := NewDatabaseBackupRunner(writer, nil, 48*time.Hour, 0)
|
| 100 |
+
now := time.Date(2026, 4, 16, 4, 5, 0, 0, time.Local)
|
| 101 |
+
writer.lastBackupAt = time.Date(2026, 4, 15, 10, 0, 0, 0, time.Local)
|
| 102 |
+
runner.now = func() time.Time { return now }
|
| 103 |
+
var delay time.Duration
|
| 104 |
+
runner.sleep = func(_ context.Context, d time.Duration) bool {
|
| 105 |
+
delay = d
|
| 106 |
+
return false
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
if err := runner.Run(context.Background()); err != nil {
|
| 110 |
+
t.Fatalf("Run returned error: %v", err)
|
| 111 |
+
}
|
| 112 |
+
expected := time.Date(2026, 4, 17, 4, 0, 0, 0, time.Local).Sub(now)
|
| 113 |
+
if delay != expected {
|
| 114 |
+
t.Fatalf("expected next 48h daily schedule delay %s, got %s", expected, delay)
|
| 115 |
+
}
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
func TestDatabaseBackupRunnerUsesExistingBackupForIntervalSchedule(t *testing.T) {
|
| 119 |
+
writer := &databaseBackupWriterStub{}
|
| 120 |
+
runner := NewDatabaseBackupRunner(writer, nil, 10*time.Second, 0)
|
| 121 |
+
now := time.Date(2026, 4, 16, 3, 45, 0, 0, time.Local)
|
| 122 |
+
writer.lastBackupAt = now.Add(-4 * time.Second)
|
| 123 |
+
runner.now = func() time.Time { return now }
|
| 124 |
+
var delays []time.Duration
|
| 125 |
+
sleepCalls := 0
|
| 126 |
+
runner.sleep = func(_ context.Context, d time.Duration) bool {
|
| 127 |
+
sleepCalls++
|
| 128 |
+
delays = append(delays, d)
|
| 129 |
+
return sleepCalls == 1
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
if err := runner.Run(context.Background()); err != nil {
|
| 133 |
+
t.Fatalf("Run returned error: %v", err)
|
| 134 |
+
}
|
| 135 |
+
if len(delays) == 0 || delays[0] != 6*time.Second {
|
| 136 |
+
t.Fatalf("expected first interval delay 6s, got %+v", delays)
|
| 137 |
+
}
|
| 138 |
+
if writer.calls != 1 {
|
| 139 |
+
t.Fatalf("expected one database backup, got %d", writer.calls)
|
| 140 |
+
}
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
func TestDatabaseBackupRunnerRunsImmediatelyWhenIntervalBackupIsExpired(t *testing.T) {
|
| 144 |
+
writer := &databaseBackupWriterStub{}
|
| 145 |
+
runner := NewDatabaseBackupRunner(writer, nil, 10*time.Second, 0)
|
| 146 |
+
now := time.Date(2026, 4, 16, 3, 45, 0, 0, time.Local)
|
| 147 |
+
writer.lastBackupAt = now.Add(-11 * time.Second)
|
| 148 |
+
runner.now = func() time.Time { return now }
|
| 149 |
+
var delays []time.Duration
|
| 150 |
+
sleepCalls := 0
|
| 151 |
+
runner.sleep = func(_ context.Context, d time.Duration) bool {
|
| 152 |
+
sleepCalls++
|
| 153 |
+
delays = append(delays, d)
|
| 154 |
+
return sleepCalls == 1
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
if err := runner.Run(context.Background()); err != nil {
|
| 158 |
+
t.Fatalf("Run returned error: %v", err)
|
| 159 |
+
}
|
| 160 |
+
if len(delays) == 0 || delays[0] != 0 {
|
| 161 |
+
t.Fatalf("expected immediate backup for expired interval, got delays %+v", delays)
|
| 162 |
+
}
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
func TestDatabaseBackupRunnerCleansAfterBackupFailure(t *testing.T) {
|
| 166 |
+
logs := captureAppInfoLogs(t)
|
| 167 |
+
writer := &databaseBackupWriterStub{err: errors.New("disk full")}
|
| 168 |
+
cleaner := &databaseBackupCleanerStub{}
|
| 169 |
+
runner := NewDatabaseBackupRunner(writer, cleaner, time.Second, 30)
|
| 170 |
+
runner.now = func() time.Time { return time.Date(2026, 4, 16, 3, 45, 0, 0, time.Local) }
|
| 171 |
+
sleepCalls := 0
|
| 172 |
+
runner.sleep = func(context.Context, time.Duration) bool {
|
| 173 |
+
sleepCalls++
|
| 174 |
+
return sleepCalls == 1
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
if err := runner.Run(context.Background()); err != nil {
|
| 178 |
+
t.Fatalf("Run returned error: %v", err)
|
| 179 |
+
}
|
| 180 |
+
if writer.calls != 1 {
|
| 181 |
+
t.Fatalf("expected one database backup attempt, got %d", writer.calls)
|
| 182 |
+
}
|
| 183 |
+
if cleaner.calls != 1 {
|
| 184 |
+
t.Fatalf("expected cleanup after failed backup, got %d calls", cleaner.calls)
|
| 185 |
+
}
|
| 186 |
+
content := logs.String()
|
| 187 |
+
if !strings.Contains(content, "level=error") || !strings.Contains(content, "msg=\"database backup failed\"") {
|
| 188 |
+
t.Fatalf("expected database backup failure error log, got %q", content)
|
| 189 |
+
}
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
func TestDatabaseBackupRunnerRetriesDailyBackupAfterFailure(t *testing.T) {
|
| 193 |
+
writer := &databaseBackupWriterStub{err: errors.New("temporary failure")}
|
| 194 |
+
runner := NewDatabaseBackupRunner(writer, nil, 24*time.Hour, 0)
|
| 195 |
+
now := time.Date(2026, 4, 16, 3, 59, 0, 0, time.Local)
|
| 196 |
+
runner.now = func() time.Time { return now }
|
| 197 |
+
var delays []time.Duration
|
| 198 |
+
sleepCalls := 0
|
| 199 |
+
runner.sleep = func(_ context.Context, d time.Duration) bool {
|
| 200 |
+
delays = append(delays, d)
|
| 201 |
+
sleepCalls++
|
| 202 |
+
switch sleepCalls {
|
| 203 |
+
case 1:
|
| 204 |
+
now = time.Date(2026, 4, 16, 4, 0, 0, 0, time.Local)
|
| 205 |
+
case 2:
|
| 206 |
+
now = time.Date(2026, 4, 16, 4, 15, 0, 0, time.Local)
|
| 207 |
+
case 3:
|
| 208 |
+
now = time.Date(2026, 4, 16, 4, 30, 0, 0, time.Local)
|
| 209 |
+
case 4:
|
| 210 |
+
now = time.Date(2026, 4, 16, 4, 45, 0, 0, time.Local)
|
| 211 |
+
default:
|
| 212 |
+
return false
|
| 213 |
+
}
|
| 214 |
+
return true
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
if err := runner.Run(context.Background()); err != nil {
|
| 218 |
+
t.Fatalf("Run returned error: %v", err)
|
| 219 |
+
}
|
| 220 |
+
expectedDelays := []time.Duration{time.Minute, 15 * time.Minute, 15 * time.Minute, 15 * time.Minute, 23*time.Hour + 15*time.Minute}
|
| 221 |
+
if len(delays) != len(expectedDelays) {
|
| 222 |
+
t.Fatalf("expected delays %+v, got %+v", expectedDelays, delays)
|
| 223 |
+
}
|
| 224 |
+
for i, expected := range expectedDelays {
|
| 225 |
+
if delays[i] != expected {
|
| 226 |
+
t.Fatalf("expected delay %d to be %s, got %s", i, expected, delays[i])
|
| 227 |
+
}
|
| 228 |
+
}
|
| 229 |
+
if writer.calls != 4 {
|
| 230 |
+
t.Fatalf("expected initial attempt plus 3 retries, got %d attempts", writer.calls)
|
| 231 |
+
}
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
type databaseBackupWriterStub struct {
|
| 235 |
+
calls int
|
| 236 |
+
err error
|
| 237 |
+
lastBackupAt time.Time
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
func (s *databaseBackupWriterStub) WriteDatabase(_ context.Context, backupAt time.Time) (string, error) {
|
| 241 |
+
s.calls++
|
| 242 |
+
if s.err == nil {
|
| 243 |
+
s.lastBackupAt = backupAt
|
| 244 |
+
}
|
| 245 |
+
return "/tmp/database.db", s.err
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
func (s *databaseBackupWriterStub) LastBackupAt() (time.Time, bool, error) {
|
| 249 |
+
return s.lastBackupAt, !s.lastBackupAt.IsZero(), nil
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
type databaseBackupCleanerStub struct {
|
| 253 |
+
calls int
|
| 254 |
+
retentionDays int
|
| 255 |
+
now time.Time
|
| 256 |
+
err error
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
func (s *databaseBackupCleanerStub) Cleanup(retentionDays int, now time.Time) (int, error) {
|
| 260 |
+
s.calls++
|
| 261 |
+
s.retentionDays = retentionDays
|
| 262 |
+
s.now = now
|
| 263 |
+
return 0, s.err
|
| 264 |
+
}
|
internal/app/maintenance.go
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package app
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"context"
|
| 5 |
+
"fmt"
|
| 6 |
+
"sync"
|
| 7 |
+
"time"
|
| 8 |
+
|
| 9 |
+
"github.com/sirupsen/logrus"
|
| 10 |
+
)
|
| 11 |
+
|
| 12 |
+
type StorageCleanupSyncer interface {
|
| 13 |
+
CleanupStorage(ctx context.Context) error
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
type StorageCleanupRunner struct {
|
| 17 |
+
syncer StorageCleanupSyncer
|
| 18 |
+
now func() time.Time
|
| 19 |
+
sleep func(context.Context, time.Duration) bool
|
| 20 |
+
|
| 21 |
+
mu sync.Mutex
|
| 22 |
+
running bool
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
func NewStorageCleanupRunner(syncer StorageCleanupSyncer) *StorageCleanupRunner {
|
| 26 |
+
return &StorageCleanupRunner{
|
| 27 |
+
syncer: syncer,
|
| 28 |
+
now: time.Now,
|
| 29 |
+
sleep: maintenanceSleepContext,
|
| 30 |
+
}
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
// Run 每天按项目本地时区 03:00 执行一次统一存储清理,失败只记录日志,不终止后台任务。
|
| 34 |
+
func (r *StorageCleanupRunner) Run(ctx context.Context) error {
|
| 35 |
+
if err := r.validate(); err != nil {
|
| 36 |
+
return err
|
| 37 |
+
}
|
| 38 |
+
logrus.Info("storage cleanup task started")
|
| 39 |
+
r.setRunning(true)
|
| 40 |
+
defer r.setRunning(false)
|
| 41 |
+
|
| 42 |
+
for {
|
| 43 |
+
now := r.now()
|
| 44 |
+
delay := nextDailyCleanupAt(now).Sub(now)
|
| 45 |
+
if delay < 0 {
|
| 46 |
+
delay = 0
|
| 47 |
+
}
|
| 48 |
+
if !r.sleep(ctx, delay) {
|
| 49 |
+
return nil
|
| 50 |
+
}
|
| 51 |
+
if err := r.syncer.CleanupStorage(ctx); err != nil {
|
| 52 |
+
logrus.WithError(err).Error("storage cleanup failed")
|
| 53 |
+
}
|
| 54 |
+
}
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
// nextDailyCleanupAt 用 time.Local 计算下一次 03:00,因此 TZ 同时控制业务日期边界和清理触发时间。
|
| 58 |
+
func nextDailyCleanupAt(now time.Time) time.Time {
|
| 59 |
+
localNow := now.In(time.Local)
|
| 60 |
+
cleanupAt := time.Date(localNow.Year(), localNow.Month(), localNow.Day(), 3, 0, 0, 0, time.Local)
|
| 61 |
+
if !localNow.Before(cleanupAt) {
|
| 62 |
+
cleanupAt = cleanupAt.AddDate(0, 0, 1)
|
| 63 |
+
}
|
| 64 |
+
return cleanupAt
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
func (r *StorageCleanupRunner) validate() error {
|
| 68 |
+
if r == nil {
|
| 69 |
+
return fmt.Errorf("storage cleanup runner is nil")
|
| 70 |
+
}
|
| 71 |
+
if r.syncer == nil {
|
| 72 |
+
return fmt.Errorf("storage cleanup syncer is nil")
|
| 73 |
+
}
|
| 74 |
+
if r.now == nil {
|
| 75 |
+
r.now = time.Now
|
| 76 |
+
}
|
| 77 |
+
if r.sleep == nil {
|
| 78 |
+
r.sleep = maintenanceSleepContext
|
| 79 |
+
}
|
| 80 |
+
return nil
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
func maintenanceSleepContext(ctx context.Context, d time.Duration) bool {
|
| 84 |
+
timer := time.NewTimer(d)
|
| 85 |
+
defer timer.Stop()
|
| 86 |
+
select {
|
| 87 |
+
case <-ctx.Done():
|
| 88 |
+
return false
|
| 89 |
+
case <-timer.C:
|
| 90 |
+
return true
|
| 91 |
+
}
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
func (r *StorageCleanupRunner) setRunning(running bool) {
|
| 95 |
+
r.mu.Lock()
|
| 96 |
+
defer r.mu.Unlock()
|
| 97 |
+
r.running = running
|
| 98 |
+
}
|
internal/app/maintenance_test.go
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package app
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"bytes"
|
| 5 |
+
"context"
|
| 6 |
+
"strings"
|
| 7 |
+
"testing"
|
| 8 |
+
"time"
|
| 9 |
+
|
| 10 |
+
"github.com/sirupsen/logrus"
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
type maintenanceSyncStub struct {
|
| 14 |
+
cleanupCalls int
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
func (s *maintenanceSyncStub) CleanupStorage(context.Context) error {
|
| 18 |
+
s.cleanupCalls++
|
| 19 |
+
return nil
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
func TestNextDailyCleanupAtUsesLocalThreeAM(t *testing.T) {
|
| 23 |
+
previousLocal := time.Local
|
| 24 |
+
location, err := time.LoadLocation("Asia/Shanghai")
|
| 25 |
+
if err != nil {
|
| 26 |
+
t.Fatalf("load location: %v", err)
|
| 27 |
+
}
|
| 28 |
+
time.Local = location
|
| 29 |
+
t.Cleanup(func() { time.Local = previousLocal })
|
| 30 |
+
|
| 31 |
+
before := nextDailyCleanupAt(time.Date(2026, 4, 26, 18, 30, 0, 0, time.UTC))
|
| 32 |
+
if !before.Equal(time.Date(2026, 4, 26, 19, 0, 0, 0, time.UTC)) {
|
| 33 |
+
t.Fatalf("expected same local day 03:00 cleanup, got %s", before)
|
| 34 |
+
}
|
| 35 |
+
after := nextDailyCleanupAt(time.Date(2026, 4, 26, 20, 30, 0, 0, time.UTC))
|
| 36 |
+
if !after.Equal(time.Date(2026, 4, 27, 19, 0, 0, 0, time.UTC)) {
|
| 37 |
+
t.Fatalf("expected next local day 03:00 cleanup, got %s", after)
|
| 38 |
+
}
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
func TestStorageCleanupRunnerLogsTaskStart(t *testing.T) {
|
| 42 |
+
logs := captureMaintenanceInfoLogs(t)
|
| 43 |
+
syncer := &maintenanceSyncStub{}
|
| 44 |
+
runner := NewStorageCleanupRunner(syncer)
|
| 45 |
+
runner.now = func() time.Time { return time.Date(2026, 4, 26, 18, 30, 0, 0, time.UTC) }
|
| 46 |
+
runner.sleep = func(context.Context, time.Duration) bool { return false }
|
| 47 |
+
|
| 48 |
+
if err := runner.Run(context.Background()); err != nil {
|
| 49 |
+
t.Fatalf("cleanup runner returned error: %v", err)
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
content := logs.String()
|
| 53 |
+
if !strings.Contains(content, "level=info") || !strings.Contains(content, "msg=\"storage cleanup task started\"") {
|
| 54 |
+
t.Fatalf("expected storage cleanup start info log, got %q", content)
|
| 55 |
+
}
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
func TestStorageCleanupRunnerRunsAtScheduledTime(t *testing.T) {
|
| 59 |
+
syncer := &maintenanceSyncStub{}
|
| 60 |
+
runner := NewStorageCleanupRunner(syncer)
|
| 61 |
+
previousLocal := time.Local
|
| 62 |
+
location, err := time.LoadLocation("Asia/Shanghai")
|
| 63 |
+
if err != nil {
|
| 64 |
+
t.Fatalf("load location: %v", err)
|
| 65 |
+
}
|
| 66 |
+
time.Local = location
|
| 67 |
+
t.Cleanup(func() { time.Local = previousLocal })
|
| 68 |
+
runner.now = func() time.Time { return time.Date(2026, 4, 26, 18, 30, 0, 0, time.UTC) }
|
| 69 |
+
ctx, cancel := context.WithCancel(context.Background())
|
| 70 |
+
calls := 0
|
| 71 |
+
runner.sleep = func(_ context.Context, d time.Duration) bool {
|
| 72 |
+
calls++
|
| 73 |
+
if calls == 1 {
|
| 74 |
+
if d != 30*time.Minute {
|
| 75 |
+
t.Fatalf("expected cleanup sleep until local 03:00, got %s", d)
|
| 76 |
+
}
|
| 77 |
+
return true
|
| 78 |
+
}
|
| 79 |
+
cancel()
|
| 80 |
+
return false
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
if err := runner.Run(ctx); err != nil {
|
| 84 |
+
t.Fatalf("cleanup runner returned error: %v", err)
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
if syncer.cleanupCalls != 1 {
|
| 88 |
+
t.Fatalf("expected cleanup loop to run once, got %d", syncer.cleanupCalls)
|
| 89 |
+
}
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
func captureMaintenanceInfoLogs(t *testing.T) *bytes.Buffer {
|
| 93 |
+
t.Helper()
|
| 94 |
+
var logs bytes.Buffer
|
| 95 |
+
previousOutput := logrus.StandardLogger().Out
|
| 96 |
+
previousFormatter := logrus.StandardLogger().Formatter
|
| 97 |
+
previousLevel := logrus.GetLevel()
|
| 98 |
+
logrus.SetOutput(&logs)
|
| 99 |
+
logrus.SetFormatter(&logrus.TextFormatter{DisableTimestamp: true})
|
| 100 |
+
logrus.SetLevel(logrus.InfoLevel)
|
| 101 |
+
t.Cleanup(func() {
|
| 102 |
+
logrus.SetOutput(previousOutput)
|
| 103 |
+
logrus.SetFormatter(previousFormatter)
|
| 104 |
+
logrus.SetLevel(previousLevel)
|
| 105 |
+
})
|
| 106 |
+
return &logs
|
| 107 |
+
}
|
internal/app/manual_sync.go
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package app
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"context"
|
| 5 |
+
"fmt"
|
| 6 |
+
|
| 7 |
+
"cpa-usage-keeper/internal/poller"
|
| 8 |
+
)
|
| 9 |
+
|
| 10 |
+
type manualSyncRunner struct {
|
| 11 |
+
redis Runner
|
| 12 |
+
metadata MetadataSyncer
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
type manualSyncStageError struct {
|
| 16 |
+
message string
|
| 17 |
+
err error
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
func (e manualSyncStageError) Error() string {
|
| 21 |
+
return fmt.Sprintf("%s: %v", e.message, e.err)
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
func (e manualSyncStageError) Unwrap() error {
|
| 25 |
+
return e.err
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
func (e manualSyncStageError) UserMessage() string {
|
| 29 |
+
return e.message
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
func newManualSyncRunner(redis Runner, metadata MetadataSyncer) *manualSyncRunner {
|
| 33 |
+
return &manualSyncRunner{redis: redis, metadata: metadata}
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
func (r *manualSyncRunner) Status() poller.Status {
|
| 37 |
+
if r == nil || r.redis == nil {
|
| 38 |
+
return poller.Status{}
|
| 39 |
+
}
|
| 40 |
+
return r.redis.Status()
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
func (r *manualSyncRunner) SyncNow(ctx context.Context) error {
|
| 44 |
+
if r == nil {
|
| 45 |
+
return fmt.Errorf("manual sync runner is nil")
|
| 46 |
+
}
|
| 47 |
+
if r.redis == nil {
|
| 48 |
+
return fmt.Errorf("manual redis syncer is nil")
|
| 49 |
+
}
|
| 50 |
+
if err := r.redis.SyncNow(ctx); err != nil {
|
| 51 |
+
return manualSyncStageError{message: "redis sync failed", err: err}
|
| 52 |
+
}
|
| 53 |
+
if r.metadata == nil {
|
| 54 |
+
return fmt.Errorf("manual metadata syncer is nil")
|
| 55 |
+
}
|
| 56 |
+
if err := r.metadata.SyncMetadata(ctx); err != nil {
|
| 57 |
+
return manualSyncStageError{message: "metadata sync failed", err: err}
|
| 58 |
+
}
|
| 59 |
+
return nil
|
| 60 |
+
}
|
internal/app/manual_sync_test.go
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package app
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"context"
|
| 5 |
+
"errors"
|
| 6 |
+
"reflect"
|
| 7 |
+
"testing"
|
| 8 |
+
|
| 9 |
+
"cpa-usage-keeper/internal/poller"
|
| 10 |
+
)
|
| 11 |
+
|
| 12 |
+
type manualRedisSyncStub struct {
|
| 13 |
+
status poller.Status
|
| 14 |
+
err error
|
| 15 |
+
calls int
|
| 16 |
+
order *[]string
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
func (s *manualRedisSyncStub) Run(context.Context) error {
|
| 20 |
+
return nil
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
func (s *manualRedisSyncStub) Status() poller.Status {
|
| 24 |
+
return s.status
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
func (s *manualRedisSyncStub) SyncNow(context.Context) error {
|
| 28 |
+
s.calls++
|
| 29 |
+
if s.order != nil {
|
| 30 |
+
*s.order = append(*s.order, "redis")
|
| 31 |
+
}
|
| 32 |
+
return s.err
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
type manualMetadataSyncStub struct {
|
| 36 |
+
err error
|
| 37 |
+
calls int
|
| 38 |
+
order *[]string
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
func (s *manualMetadataSyncStub) SyncMetadata(context.Context) error {
|
| 42 |
+
s.calls++
|
| 43 |
+
if s.order != nil {
|
| 44 |
+
*s.order = append(*s.order, "metadata")
|
| 45 |
+
}
|
| 46 |
+
return s.err
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
func TestManualSyncRunnerRunsRedisThenMetadata(t *testing.T) {
|
| 50 |
+
var order []string
|
| 51 |
+
redis := &manualRedisSyncStub{status: poller.Status{LastStatus: "completed"}, order: &order}
|
| 52 |
+
metadata := &manualMetadataSyncStub{order: &order}
|
| 53 |
+
runner := newManualSyncRunner(redis, metadata)
|
| 54 |
+
|
| 55 |
+
if err := runner.SyncNow(context.Background()); err != nil {
|
| 56 |
+
t.Fatalf("SyncNow returned error: %v", err)
|
| 57 |
+
}
|
| 58 |
+
if !reflect.DeepEqual(order, []string{"redis", "metadata"}) {
|
| 59 |
+
t.Fatalf("expected redis then metadata sync, got %v", order)
|
| 60 |
+
}
|
| 61 |
+
if runner.Status().LastStatus != "completed" {
|
| 62 |
+
t.Fatalf("expected status to delegate to redis runner, got %+v", runner.Status())
|
| 63 |
+
}
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
func TestManualSyncRunnerReturnsRedisErrorWithoutMetadata(t *testing.T) {
|
| 67 |
+
redisErr := errors.New("redis failed")
|
| 68 |
+
redis := &manualRedisSyncStub{err: redisErr}
|
| 69 |
+
metadata := &manualMetadataSyncStub{}
|
| 70 |
+
runner := newManualSyncRunner(redis, metadata)
|
| 71 |
+
|
| 72 |
+
err := runner.SyncNow(context.Background())
|
| 73 |
+
if !errors.Is(err, redisErr) {
|
| 74 |
+
t.Fatalf("expected redis error, got %v", err)
|
| 75 |
+
}
|
| 76 |
+
if err == nil || err.Error() != "redis sync failed: redis failed" {
|
| 77 |
+
t.Fatalf("expected redis-specific sync error, got %v", err)
|
| 78 |
+
}
|
| 79 |
+
if metadata.calls != 0 {
|
| 80 |
+
t.Fatalf("expected metadata sync not to run after redis failure, got %d calls", metadata.calls)
|
| 81 |
+
}
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
func TestManualSyncRunnerReturnsMetadataError(t *testing.T) {
|
| 85 |
+
metadataErr := errors.New("metadata failed")
|
| 86 |
+
redis := &manualRedisSyncStub{}
|
| 87 |
+
metadata := &manualMetadataSyncStub{err: metadataErr}
|
| 88 |
+
runner := newManualSyncRunner(redis, metadata)
|
| 89 |
+
|
| 90 |
+
err := runner.SyncNow(context.Background())
|
| 91 |
+
if !errors.Is(err, metadataErr) {
|
| 92 |
+
t.Fatalf("expected metadata error, got %v", err)
|
| 93 |
+
}
|
| 94 |
+
if err == nil || err.Error() != "metadata sync failed: metadata failed" {
|
| 95 |
+
t.Fatalf("expected metadata-specific sync error, got %v", err)
|
| 96 |
+
}
|
| 97 |
+
if redis.calls != 1 || metadata.calls != 1 {
|
| 98 |
+
t.Fatalf("expected redis and metadata to run once, got redis=%d metadata=%d", redis.calls, metadata.calls)
|
| 99 |
+
}
|
| 100 |
+
}
|
internal/app/metadata_sync.go
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package app
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"context"
|
| 5 |
+
"fmt"
|
| 6 |
+
"sync"
|
| 7 |
+
"time"
|
| 8 |
+
|
| 9 |
+
"github.com/sirupsen/logrus"
|
| 10 |
+
)
|
| 11 |
+
|
| 12 |
+
type MetadataSyncer interface {
|
| 13 |
+
SyncMetadata(ctx context.Context) error
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
type MetadataSyncRunner struct {
|
| 17 |
+
syncer MetadataSyncer
|
| 18 |
+
interval time.Duration
|
| 19 |
+
sleep func(context.Context, time.Duration) bool
|
| 20 |
+
|
| 21 |
+
mu sync.Mutex
|
| 22 |
+
running bool
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
func NewMetadataSyncRunner(syncer MetadataSyncer, interval time.Duration) *MetadataSyncRunner {
|
| 26 |
+
return &MetadataSyncRunner{
|
| 27 |
+
syncer: syncer,
|
| 28 |
+
interval: interval,
|
| 29 |
+
sleep: maintenanceSleepContext,
|
| 30 |
+
}
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
// Run 启动独立 metadata 同步任务:启动后立即执行一次,之后按固定间隔刷新 auth files 和 provider metadata。
|
| 34 |
+
func (r *MetadataSyncRunner) Run(ctx context.Context) error {
|
| 35 |
+
if err := r.validate(); err != nil {
|
| 36 |
+
return err
|
| 37 |
+
}
|
| 38 |
+
logrus.Info("metadata sync task started")
|
| 39 |
+
r.setRunning(true)
|
| 40 |
+
defer r.setRunning(false)
|
| 41 |
+
|
| 42 |
+
delay := time.Duration(0)
|
| 43 |
+
for {
|
| 44 |
+
if !r.sleep(ctx, delay) {
|
| 45 |
+
return nil
|
| 46 |
+
}
|
| 47 |
+
if err := r.syncer.SyncMetadata(ctx); err != nil {
|
| 48 |
+
logrus.WithError(err).Error("metadata sync failed")
|
| 49 |
+
}
|
| 50 |
+
delay = r.interval
|
| 51 |
+
}
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
func (r *MetadataSyncRunner) validate() error {
|
| 55 |
+
if r == nil {
|
| 56 |
+
return fmt.Errorf("metadata sync runner is nil")
|
| 57 |
+
}
|
| 58 |
+
if r.syncer == nil {
|
| 59 |
+
return fmt.Errorf("metadata syncer is nil")
|
| 60 |
+
}
|
| 61 |
+
if r.interval <= 0 {
|
| 62 |
+
return fmt.Errorf("metadata sync interval must be positive")
|
| 63 |
+
}
|
| 64 |
+
if r.sleep == nil {
|
| 65 |
+
r.sleep = maintenanceSleepContext
|
| 66 |
+
}
|
| 67 |
+
return nil
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
func (r *MetadataSyncRunner) setRunning(running bool) {
|
| 71 |
+
r.mu.Lock()
|
| 72 |
+
defer r.mu.Unlock()
|
| 73 |
+
r.running = running
|
| 74 |
+
}
|