diff --git a/.github/scripts/utils/preferred-image-ref.sh b/.github/scripts/utils/preferred-image-ref.sh new file mode 100644 index 0000000..c2bb290 --- /dev/null +++ b/.github/scripts/utils/preferred-image-ref.sh @@ -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" diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 583525e..f48d87c 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -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 diff --git a/ansible/roles/accounts_compose_deploy/defaults/main.yml b/ansible/roles/accounts_compose_deploy/defaults/main.yml index 53380cc..f59e6a5 100644 --- a/ansible/roles/accounts_compose_deploy/defaults/main.yml +++ b/ansible/roles/accounts_compose_deploy/defaults/main.yml @@ -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: {} - diff --git a/api/accounting_test.go b/api/accounting_test.go index 849b8ce..8023313 100644 --- a/api/accounting_test.go +++ b/api/accounting_test.go @@ -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) { diff --git a/api/agent_server.go b/api/agent_server.go index 55db934..e71139c 100644 --- a/api/agent_server.go +++ b/api/agent_server.go @@ -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, }) } diff --git a/api/api.go b/api/api.go index e20a835..f9e82e1 100644 --- a/api/api.go +++ b/api/api.go @@ -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) diff --git a/api/api_test.go b/api/api_test.go index 300beaa..a108488 100644 --- a/api/api_test.go +++ b/api/api_test.go @@ -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) diff --git a/scripts/github-actions/resolve-pipeline-flags.sh b/scripts/github-actions/resolve-pipeline-flags.sh index 1011d04..b082bb3 100755 --- a/scripts/github-actions/resolve-pipeline-flags.sh +++ b/scripts/github-actions/resolve-pipeline-flags.sh @@ -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 diff --git a/scripts/github-actions/validate-deploy.sh b/scripts/github-actions/validate-deploy.sh index e9a4e06..128ddd1 100644 --- a/scripts/github-actions/validate-deploy.sh +++ b/scripts/github-actions/validate-deploy.sh @@ -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 \ - --silent \ - --show-error \ - --fail \ - --location \ - --max-time 20 \ - "${BASE_URL}/healthz" | grep -q '"status":"ok"' +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}/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 diff --git a/sql/20260401_accounting_control_plane.sql b/sql/20260401_accounting_control_plane.sql index 01f1135..979b9f2 100644 --- a/sql/20260401_accounting_control_plane.sql +++ b/sql/20260401_accounting_control_plane.sql @@ -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,