merge: codex/multi-node-billing-ingestion

This commit is contained in:
Haitao Pan 2026-04-12 13:43:25 +08:00
commit b6f85af12e
10 changed files with 202 additions and 30 deletions

View File

@ -0,0 +1,21 @@
#!/usr/bin/env bash
set -euo pipefail
tags="$1"
preferred=""
while IFS= read -r line; do
if [[ "$line" == *":latest" ]]; then
continue
fi
if [[ -n "$line" ]]; then
preferred="$line"
break
fi
done <<< "$tags"
if [[ -z "$preferred" ]]; then
preferred="$(echo "$tags" | head -n 1)"
fi
echo "$preferred"

View File

@ -46,7 +46,6 @@ jobs:
outputs:
target_host: ${{ steps.flags.outputs.target_host }}
run_apply: ${{ steps.flags.outputs.run_apply }}
image_tag: ${{ steps.flags.outputs.image_tag }}
push_image: ${{ steps.flags.outputs.push_image }}
push_latest: ${{ steps.flags.outputs.push_latest }}
steps:
@ -62,7 +61,6 @@ jobs:
DEFAULT_TARGET_HOST: ${{ env.DEFAULT_TARGET_HOST }}
INPUT_TARGET_HOST: ${{ inputs.target_host }}
INPUT_RUN_APPLY: ${{ inputs.run_apply }}
INPUT_IMAGE_TAG: ${{ inputs.image_tag }}
INPUT_PUSH_IMAGE: ${{ inputs.push_image }}
INPUT_PUSH_LATEST: ${{ inputs.push_latest }}
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
@ -81,7 +79,7 @@ jobs:
SERVICE_IMAGE_NAME: accounts
outputs:
service_image_repo: ${{ steps.service_image.outputs.repo }}
service_preferred_tag: ${{ steps.service_preferred.outputs.tag }}
service_image_ref: ${{ steps.service_ref.outputs.image_ref }}
steps:
- name: Check Out Repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
@ -110,16 +108,15 @@ jobs:
with:
images: ${{ steps.service_image.outputs.repo }}
tags: |
type=raw,value=${{ needs.prep.outputs.image_tag }},enable=${{ needs.prep.outputs.image_tag != '' }}
type=sha,format=short,enable=${{ needs.prep.outputs.image_tag == '' }}
type=sha,format=long
type=raw,value=latest,enable=${{ needs.prep.outputs.push_latest == 'true' || github.ref == 'refs/heads/main' }}
- name: Resolve Service Preferred Tag
id: service_preferred
- name: Resolve Service Image Ref
id: service_ref
run: |
set -euo pipefail
tag="$(bash .github/scripts/utils/preferred-tag.sh "${{ steps.service_meta.outputs.tags }}")"
echo "tag=${tag}" >> "$GITHUB_OUTPUT"
image_ref="$(bash .github/scripts/utils/preferred-image-ref.sh "${{ steps.service_meta.outputs.tags }}")"
echo "image_ref=${image_ref}" >> "$GITHUB_OUTPUT"
- name: Build And Push Service Image
uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6.15.0
@ -140,7 +137,7 @@ jobs:
runs-on: ubuntu-latest
outputs:
image: ${{ needs.build.outputs.service_image_repo }}
preferred_tag: ${{ needs.build.outputs.service_preferred_tag }}
image_ref: ${{ needs.build.outputs.service_image_ref }}
run_apply: ${{ needs.prep.outputs.run_apply }}
pushed: "true"
steps:
@ -159,8 +156,8 @@ jobs:
id: deploy_image_tag
run: |
set -euo pipefail
tag="${{ needs.build.outputs.service_preferred_tag }}"
echo "value=${tag##*:}" >> "$GITHUB_OUTPUT"
image_ref="${{ needs.build.outputs.service_image_ref }}"
echo "value=${image_ref##*:}" >> "$GITHUB_OUTPUT"
- name: Set Up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.0.0
@ -209,6 +206,7 @@ jobs:
validate:
name: Validate
needs:
- build
- deploy
if: ${{ always() && needs.deploy.result == 'success' && needs.deploy.outputs.pushed == 'true' && needs.deploy.outputs.run_apply == 'true' }}
runs-on: ubuntu-latest
@ -217,4 +215,4 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Validate Deployed Accounts Service
run: bash ./scripts/github-actions/validate-deploy.sh https://accounts.svc.plus
run: bash ./scripts/github-actions/validate-deploy.sh "${{ needs.build.outputs.service_image_ref }}" https://accounts.svc.plus

View File

@ -5,6 +5,7 @@ service_compose_container_port: 8080
service_compose_git_short_commit: "{{ lookup('ansible.builtin.env', 'GIT_SHORT_COMMIT') | default('manual', true) }}"
service_compose_env_common:
CONFIG_TEMPLATE: /app/config/account.cloudrun.yaml
IMAGE: "{{ service_compose_image }}"
service_compose_deploy_targets:
- name: prod
deploy_subdomain_prefix: accounts
@ -18,4 +19,3 @@ service_compose_deploy_targets:
- accounts-preview.svc.plus
host_port: 18081
env: {}

View File

@ -6,6 +6,7 @@ import (
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
@ -15,7 +16,7 @@ import (
"account/internal/store"
)
func TestAgentUsersUseAccountUUIDAsStatsEmail(t *testing.T) {
func TestAgentUsersUseAccountEmailAsStatsEmail(t *testing.T) {
gin.SetMode(gin.TestMode)
st := store.NewMemoryStore()
@ -74,12 +75,12 @@ func TestAgentUsersUseAccountUUIDAsStatsEmail(t *testing.T) {
}
for _, client := range payload.Clients {
if client.Email == user.ID {
if client.Email == strings.ToLower(strings.TrimSpace(user.Email)) {
return
}
}
t.Fatalf("expected stats email %q in payload, got %#v", user.ID, payload.Clients)
t.Fatalf("expected stats email %q in payload, got %#v", user.Email, payload.Clients)
}
func TestAccountUsageAndPolicyEndpoints(t *testing.T) {

View File

@ -76,7 +76,7 @@ func (h *handler) listAgentUsers(c *gin.Context) {
if id != "" {
clients = append(clients, xrayconfig.Client{
ID: id,
Email: strings.TrimSpace(sandboxUser.ID),
Email: strings.ToLower(strings.TrimSpace(sandboxUser.Email)),
Flow: xrayconfig.DefaultFlow,
})
}
@ -92,7 +92,7 @@ func (h *handler) listAgentUsers(c *gin.Context) {
}
clients = append(clients, xrayconfig.Client{
ID: id,
Email: strings.TrimSpace(u.ID),
Email: strings.ToLower(strings.TrimSpace(u.Email)),
Flow: xrayconfig.DefaultFlow,
})
}

View File

@ -13,6 +13,7 @@ import (
"math/big"
"net/http"
"net/url"
"os"
"reflect"
"strings"
"sync"
@ -44,6 +45,13 @@ const defaultBridgeBootstrapTarget = "https://xworkmate-bridge.svc.plus"
const sessionCookieName = "xc_session"
type imageVersionInfo struct {
ImageRef string `json:"image_ref"`
Tag string `json:"tag,omitempty"`
Commit string `json:"commit,omitempty"`
Version string `json:"version,omitempty"`
}
type session struct {
userID string
expiresAt time.Time
@ -326,6 +334,17 @@ func RegisterRoutes(r *gin.Engine, opts ...Option) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
})
r.GET("/api/ping", func(c *gin.Context) {
info := parseImageVersionInfo(os.Getenv("IMAGE"))
c.JSON(http.StatusOK, gin.H{
"status": "ok",
"image": info.ImageRef,
"tag": info.Tag,
"commit": info.Commit,
"version": info.Version,
})
})
authGroup := r.Group("/api/auth")
authGroup.POST("/register", h.register)
@ -3039,6 +3058,52 @@ func (h *handler) isRootAccount(user *store.User) bool {
return store.IsRootRole(user.Role) && strings.EqualFold(strings.TrimSpace(user.Email), store.RootAdminEmail)
}
func parseImageVersionInfo(imageRef string) imageVersionInfo {
ref := strings.TrimSpace(imageRef)
info := imageVersionInfo{ImageRef: ref}
if ref == "" {
return info
}
if idx := strings.LastIndex(ref, "@"); idx >= 0 {
ref = ref[:idx]
}
tag := ref
if idx := strings.LastIndex(tag, ":"); idx >= 0 && idx > strings.LastIndex(tag, "/") {
tag = tag[idx+1:]
}
tag = strings.TrimSpace(tag)
info.Tag = tag
switch {
case isHexCommit(tag):
info.Commit = tag
info.Version = tag
case strings.HasPrefix(tag, "v") && len(tag) > 1:
info.Version = tag
default:
info.Version = tag
}
return info
}
func isHexCommit(value string) bool {
if len(value) < 7 || len(value) > 40 {
return false
}
for _, r := range value {
switch {
case r >= '0' && r <= '9':
case r >= 'a' && r <= 'f':
default:
return false
}
}
return true
}
func respondError(c *gin.Context, status int, code, message string) {
if status >= 500 {
slog.Error("api_error", "status", status, "code", code, "message", message, "path", c.Request.URL.Path, "method", c.Request.Method)

View File

@ -318,10 +318,10 @@ func TestAgentServerUsers_DefaultSyncIncludesSandboxAndRegularUsers(t *testing.T
seenSandbox := false
seenNormal := false
for _, c := range payload.Clients {
if c.Email == sandbox.ID && strings.TrimSpace(c.ID) != "" {
if c.Email == strings.ToLower(strings.TrimSpace(sandbox.Email)) && strings.TrimSpace(c.ID) != "" {
seenSandbox = true
}
if c.Email == normal.ID && strings.TrimSpace(c.ID) != "" {
if c.Email == strings.ToLower(strings.TrimSpace(normal.Email)) && strings.TrimSpace(c.ID) != "" {
seenNormal = true
}
}
@ -1494,6 +1494,39 @@ func TestHealthzEndpoint(t *testing.T) {
}
}
func TestPingEndpointDerivesVersionFromImageEnv(t *testing.T) {
t.Setenv("IMAGE", "ghcr.io/example/accounts:abcdef1234567890abcdef1234567890abcdef12")
gin.SetMode(gin.TestMode)
router := gin.New()
RegisterRoutes(router)
req := httptest.NewRequest(http.MethodGet, "/api/ping", nil)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected ping endpoint to return 200, got %d", rr.Code)
}
var resp map[string]string
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to decode ping response: %v", err)
}
if got := resp["image"]; got != "ghcr.io/example/accounts:abcdef1234567890abcdef1234567890abcdef12" {
t.Fatalf("expected image ref from env, got %q", got)
}
if got := resp["tag"]; got != "abcdef1234567890abcdef1234567890abcdef12" {
t.Fatalf("expected tag derived from image ref, got %q", got)
}
if got := resp["commit"]; got != "abcdef1234567890abcdef1234567890abcdef12" {
t.Fatalf("expected commit derived from image ref, got %q", got)
}
if got := resp["version"]; got != "abcdef1234567890abcdef1234567890abcdef12" {
t.Fatalf("expected version derived from image ref, got %q", got)
}
}
func TestPasswordResetFlow(t *testing.T) {
gin.SetMode(gin.TestMode)

View File

@ -20,7 +20,6 @@ fi
if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then
TARGET_HOST="${INPUT_TARGET_HOST:-${TARGET_HOST}}"
[[ "${INPUT_RUN_APPLY:-true}" == "true" ]] && RUN_APPLY=true || RUN_APPLY=false
IMAGE_TAG="${INPUT_IMAGE_TAG:-}"
[[ "${INPUT_PUSH_IMAGE:-true}" == "true" ]] && PUSH_IMAGE=true || PUSH_IMAGE=false
[[ "${INPUT_PUSH_LATEST:-false}" == "true" ]] && PUSH_LATEST=true || PUSH_LATEST=false
[[ "${INPUT_RUN_BASE_IMAGES:-false}" == "true" ]] && RUN_BASE_IMAGES=true || RUN_BASE_IMAGES=false

View File

@ -1,12 +1,58 @@
#!/usr/bin/env bash
set -euo pipefail
BASE_URL="${1:-https://accounts.svc.plus}"
IMAGE_REF="${1:?image_ref is required}"
BASE_URL="${2:-https://accounts.svc.plus}"
curl \
image_ref="$(printf '%s' "${IMAGE_REF}" | tr -d '\n' | xargs)"
if [[ -z "${image_ref}" ]]; then
echo "image_ref is required" >&2
exit 1
fi
image_no_digest="${image_ref%@*}"
tag="${image_no_digest##*:}"
if [[ "${image_no_digest}" == "${tag}" ]]; then
tag=""
fi
commit=""
version="${tag}"
if [[ "${tag}" =~ ^[0-9a-f]{7,40}$ ]]; then
commit="${tag}"
fi
ping_json="$(
curl \
--silent \
--show-error \
--fail \
--location \
--max-time 20 \
"${BASE_URL}/healthz" | grep -q '"status":"ok"'
"${BASE_URL}/api/ping"
)"
PING_JSON="${ping_json}" python3 - "${image_ref}" "${tag}" "${commit}" "${version}" <<'PY'
import json
import os
import sys
image_ref, tag, commit, version = sys.argv[1:5]
payload = json.loads(os.environ["PING_JSON"])
if payload.get("status") != "ok":
raise SystemExit("ping status not ok")
if payload.get("image") != image_ref:
raise SystemExit(f"expected image {image_ref!r}, got {payload.get('image')!r}")
if tag and payload.get("tag") != tag:
raise SystemExit(f"expected tag {tag!r}, got {payload.get('tag')!r}")
if commit and payload.get("commit") != commit:
raise SystemExit(f"expected commit {commit!r}, got {payload.get('commit')!r}")
if version and payload.get("version") != version:
raise SystemExit(f"expected version {version!r}, got {payload.get('version')!r}")
PY

View File

@ -67,6 +67,15 @@ CREATE TABLE IF NOT EXISTS public.account_billing_profiles (
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS public.billing_source_sync_state (
source_id TEXT PRIMARY KEY,
last_completed_until TIMESTAMPTZ NULL,
last_attempted_at TIMESTAMPTZ NULL,
last_succeeded_at TIMESTAMPTZ NULL,
last_error TEXT NOT NULL DEFAULT '',
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS public.account_policy_snapshots (
account_uuid UUID PRIMARY KEY REFERENCES public.users(uuid) ON DELETE CASCADE,
policy_version TEXT NOT NULL,