Codex/multi node billing ingestion (#4)
* feat: add multi-source billing ingestion * Move release traceability workflow logic into scripts * feat: release traceability --------- Co-authored-by: Haitao Pan <manbuzhe2009@qq.com>
This commit is contained in:
parent
886963606f
commit
78255baec1
53
.github/workflows/release-traceability.yml
vendored
Normal file
53
.github/workflows/release-traceability.yml
vendored
Normal file
@ -0,0 +1,53 @@
|
||||
name: release-traceability
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
service_image_ref: ${{ steps.meta.outputs.service_image_ref }}
|
||||
service_image_tag: ${{ steps.meta.outputs.service_image_tag }}
|
||||
service_image_commit: ${{ steps.meta.outputs.service_image_commit }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Derive image identity
|
||||
id: meta
|
||||
run: bash ./scripts/github-actions/resolve-service-image-ref.sh
|
||||
|
||||
- name: Build image
|
||||
env:
|
||||
SERVICE_IMAGE_REF: ${{ steps.meta.outputs.service_image_ref }}
|
||||
SERVICE_IMAGE_LATEST_REF: ghcr.io/${{ github.repository }}:latest
|
||||
run: bash ./scripts/github-actions/build-service-image.sh
|
||||
|
||||
- name: Push image
|
||||
run: bash ./scripts/github-actions/push-image-placeholder.sh
|
||||
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Deploy via playbook
|
||||
env:
|
||||
IMAGE_REF: ${{ needs.build.outputs.service_image_ref }}
|
||||
BILLING_SERVICE_IMAGE_REF: ${{ needs.build.outputs.service_image_ref }}
|
||||
BILLING_SERVICE_IMAGE_TAG: ${{ needs.build.outputs.service_image_tag }}
|
||||
BILLING_SERVICE_IMAGE_COMMIT: ${{ needs.build.outputs.service_image_commit }}
|
||||
run: bash ./scripts/github-actions/deploy-billing-service.sh
|
||||
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
needs: deploy
|
||||
steps:
|
||||
- name: Validate runtime traceability
|
||||
env:
|
||||
SERVICE_IMAGE_REF: ${{ needs.build.outputs.service_image_ref }}
|
||||
run: bash ./scripts/github-actions/validate-release-traceability.sh
|
||||
22
docs/api.md
22
docs/api.md
@ -5,6 +5,21 @@ upstream and downstream interfaces it depends on.
|
||||
|
||||
## Service endpoints
|
||||
|
||||
### `GET /api/ping`
|
||||
|
||||
Returns the runtime image identity exposed by the running container.
|
||||
|
||||
Example response:
|
||||
|
||||
```json
|
||||
{
|
||||
"image": "registry.example.com/billing-service:sha-0123456789abcdef0123456789abcdef01234567",
|
||||
"tag": "sha-0123456789abcdef0123456789abcdef01234567",
|
||||
"commit": "0123456789abcdef0123456789abcdef01234567",
|
||||
"version": "0123456789abcdef0123456789abcdef01234567"
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /healthz`
|
||||
|
||||
Returns service health derived from the most recent collect-and-rate execution.
|
||||
@ -130,6 +145,7 @@ Read-path rules:
|
||||
|
||||
Runtime environment variables used by the current implementation:
|
||||
|
||||
- `IMAGE`
|
||||
- `EXPORTER_BASE_URL`
|
||||
- `DATABASE_URL`
|
||||
- `LISTEN_ADDR`
|
||||
@ -140,6 +156,12 @@ Runtime environment variables used by the current implementation:
|
||||
- `INITIAL_INCLUDED_QUOTA_BYTES`
|
||||
- `INITIAL_BALANCE`
|
||||
|
||||
`IMAGE` rule:
|
||||
|
||||
- it must contain the full image reference used to start the container
|
||||
- `/api/ping` derives `image`, `tag`, `commit`, and `version` from this value
|
||||
- when `IMAGE` is missing or malformed, runtime metadata fields should remain empty rather than fabricated
|
||||
|
||||
`DATABASE_URL` rule:
|
||||
|
||||
- it must point to the same `account` database that `accounts.svc.plus` uses
|
||||
|
||||
27
docs/release-traceability.md
Normal file
27
docs/release-traceability.md
Normal file
@ -0,0 +1,27 @@
|
||||
# Release Traceability Contract
|
||||
|
||||
This repository now treats `IMAGE` as the single runtime source of truth for
|
||||
release identity.
|
||||
|
||||
## Runtime contract
|
||||
|
||||
- `IMAGE` must contain the full image reference used to start the container.
|
||||
- `/api/ping` returns `image`, `tag`, `commit`, and `version`.
|
||||
- `tag`, `commit`, and `version` are derived from `IMAGE`.
|
||||
- If `IMAGE` is missing or malformed, the derived fields stay empty instead of
|
||||
being fabricated.
|
||||
|
||||
## Pipeline contract
|
||||
|
||||
- Build must produce `service_image_ref` only from the full `GITHUB_SHA`.
|
||||
- Deploy must consume `service_image_ref` and pass it through as the runtime
|
||||
image identity.
|
||||
- Validate must derive `tag` and `commit` from `service_image_ref` and compare
|
||||
them against `/api/ping`.
|
||||
|
||||
## External playbook alignment
|
||||
|
||||
The external `playbooks/deploy_billing_service.yml` playbook should accept
|
||||
`IMAGE_REF` (or an equivalent full image reference variable), derive any
|
||||
repo/tag helpers from it, and inject `IMAGE=<full image ref>` into the running
|
||||
container environment.
|
||||
@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@ -19,6 +20,10 @@ type ExporterSource struct {
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
ImageRef string
|
||||
ImageTag string
|
||||
ImageCommit string
|
||||
ImageVersion string
|
||||
ExporterBaseURL string
|
||||
ExporterSources []ExporterSource
|
||||
InternalServiceToken string
|
||||
@ -42,7 +47,13 @@ type rawExporterSource struct {
|
||||
}
|
||||
|
||||
func Load() (Config, error) {
|
||||
imageRef := strings.TrimSpace(os.Getenv("IMAGE"))
|
||||
imageTag, imageCommit, imageVersion := parseImageRef(imageRef)
|
||||
cfg := Config{
|
||||
ImageRef: imageRef,
|
||||
ImageTag: imageTag,
|
||||
ImageCommit: imageCommit,
|
||||
ImageVersion: imageVersion,
|
||||
ExporterBaseURL: strings.TrimRight(strings.TrimSpace(os.Getenv("EXPORTER_BASE_URL")), "/"),
|
||||
InternalServiceToken: strings.TrimSpace(os.Getenv("INTERNAL_SERVICE_TOKEN")),
|
||||
DatabaseURL: strings.TrimSpace(os.Getenv("DATABASE_URL")),
|
||||
@ -87,6 +98,30 @@ func Load() (Config, error) {
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
var fullSHARegexp = regexp.MustCompile(`^[a-f0-9]{40}$`)
|
||||
|
||||
func parseImageRef(imageRef string) (tag, commit, version string) {
|
||||
trimmed := strings.TrimSpace(imageRef)
|
||||
if trimmed == "" {
|
||||
return "", "", ""
|
||||
}
|
||||
colon := strings.LastIndex(trimmed, ":")
|
||||
if colon < 0 || colon == len(trimmed)-1 {
|
||||
return "", "", ""
|
||||
}
|
||||
tag = trimmed[colon+1:]
|
||||
switch {
|
||||
case strings.HasPrefix(tag, "sha-") && fullSHARegexp.MatchString(strings.TrimPrefix(tag, "sha-")):
|
||||
commit = strings.TrimPrefix(tag, "sha-")
|
||||
case fullSHARegexp.MatchString(tag):
|
||||
commit = tag
|
||||
}
|
||||
if commit != "" {
|
||||
version = commit
|
||||
}
|
||||
return tag, commit, version
|
||||
}
|
||||
|
||||
func loadExporterSources(legacyBaseURL, rawJSON string) ([]ExporterSource, error) {
|
||||
if rawJSON == "" {
|
||||
if legacyBaseURL == "" {
|
||||
|
||||
@ -30,3 +30,23 @@ func TestLoadExporterSourcesFallsBackToLegacyBaseURL(t *testing.T) {
|
||||
t.Fatalf("unexpected source %#v", sources[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseImageRefWithFullShaTag(t *testing.T) {
|
||||
tag, commit, version := parseImageRef("registry.example.com/billing-service:sha-0123456789abcdef0123456789abcdef01234567")
|
||||
if tag != "sha-0123456789abcdef0123456789abcdef01234567" {
|
||||
t.Fatalf("unexpected tag %q", tag)
|
||||
}
|
||||
if commit != "0123456789abcdef0123456789abcdef01234567" {
|
||||
t.Fatalf("unexpected commit %q", commit)
|
||||
}
|
||||
if version != commit {
|
||||
t.Fatalf("expected version to equal commit, got %q vs %q", version, commit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseImageRefRejectsIncompleteSha(t *testing.T) {
|
||||
tag, commit, version := parseImageRef("registry.example.com/billing-service:sha-1234")
|
||||
if tag != "sha-1234" || commit != "" || version != "" {
|
||||
t.Fatalf("expected partial parse failure, got tag=%q commit=%q version=%q", tag, commit, version)
|
||||
}
|
||||
}
|
||||
|
||||
@ -18,6 +18,7 @@ func New(svc *service.Service) *Handler {
|
||||
|
||||
func (h *Handler) Routes() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/ping", h.ping)
|
||||
mux.HandleFunc("/healthz", h.healthz)
|
||||
mux.HandleFunc("/v1/status", h.status)
|
||||
mux.HandleFunc("/v1/jobs/collect-and-rate", h.collectAndRate)
|
||||
@ -25,6 +26,10 @@ func (h *Handler) Routes() http.Handler {
|
||||
return mux
|
||||
}
|
||||
|
||||
func (h *Handler) ping(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, h.service.Ping())
|
||||
}
|
||||
|
||||
func (h *Handler) healthz(w http.ResponseWriter, r *http.Request) {
|
||||
ok, message := h.service.Health()
|
||||
status := http.StatusOK
|
||||
|
||||
@ -111,3 +111,10 @@ type JobResult struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
SourceStatuses []SourceStatus `json:"source_statuses,omitempty"`
|
||||
}
|
||||
|
||||
type PingInfo struct {
|
||||
Image string `json:"image"`
|
||||
Tag string `json:"tag,omitempty"`
|
||||
Commit string `json:"commit,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
}
|
||||
|
||||
@ -118,6 +118,15 @@ func (s *Service) Health() (bool, string) {
|
||||
return s.lastOK, s.lastError
|
||||
}
|
||||
|
||||
func (s *Service) Ping() model.PingInfo {
|
||||
return model.PingInfo{
|
||||
Image: s.cfg.ImageRef,
|
||||
Tag: s.cfg.ImageTag,
|
||||
Commit: s.cfg.ImageCommit,
|
||||
Version: s.cfg.ImageVersion,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) collectSource(ctx context.Context, source config.ExporterSource, result *model.JobResult) (model.SourceStatus, error) {
|
||||
state, err := s.repo.GetSourceSyncState(ctx, source.SourceID)
|
||||
if err != nil {
|
||||
|
||||
@ -144,6 +144,10 @@ var _ repository.Repository = (*memoryRepo)(nil)
|
||||
|
||||
func baseConfig() config.Config {
|
||||
return config.Config{
|
||||
ImageRef: "registry.example.com/billing-service:sha-0123456789abcdef0123456789abcdef01234567",
|
||||
ImageTag: "sha-0123456789abcdef0123456789abcdef01234567",
|
||||
ImageCommit: "0123456789abcdef0123456789abcdef01234567",
|
||||
ImageVersion: "0123456789abcdef0123456789abcdef01234567",
|
||||
ExporterSources: []config.ExporterSource{{
|
||||
SourceID: "default",
|
||||
BaseURL: "https://jp-xhttp-contabo.svc.plus",
|
||||
@ -161,6 +165,14 @@ func baseConfig() config.Config {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPingReflectsImageRef(t *testing.T) {
|
||||
svc := New(baseConfig(), &fakeWindowSource{}, newMemoryRepo())
|
||||
ping := svc.Ping()
|
||||
if ping.Image != baseConfig().ImageRef || ping.Tag != baseConfig().ImageTag || ping.Commit != baseConfig().ImageCommit || ping.Version != baseConfig().ImageVersion {
|
||||
t.Fatalf("unexpected ping %#v", ping)
|
||||
}
|
||||
}
|
||||
|
||||
func singleSnapshotPage(snapshot model.Snapshot) model.SnapshotWindowPage {
|
||||
return model.SnapshotWindowPage{
|
||||
NodeID: snapshot.NodeID,
|
||||
|
||||
7
scripts/github-actions/build-service-image.sh
Normal file
7
scripts/github-actions/build-service-image.sh
Normal file
@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
docker build \
|
||||
--tag "${SERVICE_IMAGE_REF:?SERVICE_IMAGE_REF is required}" \
|
||||
--tag "${SERVICE_IMAGE_LATEST_REF:?SERVICE_IMAGE_LATEST_REF is required}" \
|
||||
.
|
||||
5
scripts/github-actions/deploy-billing-service.sh
Normal file
5
scripts/github-actions/deploy-billing-service.sh
Normal file
@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
test -n "${IMAGE_REF:?IMAGE_REF is required}"
|
||||
ansible-playbook -i inventory playbooks/deploy_billing_service.yml
|
||||
4
scripts/github-actions/push-image-placeholder.sh
Normal file
4
scripts/github-actions/push-image-placeholder.sh
Normal file
@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
echo "Push step is intentionally left as an integration point for the target registry."
|
||||
10
scripts/github-actions/resolve-service-image-ref.sh
Normal file
10
scripts/github-actions/resolve-service-image-ref.sh
Normal file
@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
full_sha="${GITHUB_SHA:?GITHUB_SHA is required}"
|
||||
tag="sha-${full_sha}"
|
||||
image_ref="ghcr.io/${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}:${tag}"
|
||||
|
||||
printf 'service_image_ref=%s\n' "${image_ref}" >> "${GITHUB_OUTPUT}"
|
||||
printf 'service_image_tag=%s\n' "${tag}" >> "${GITHUB_OUTPUT}"
|
||||
printf 'service_image_commit=%s\n' "${full_sha}" >> "${GITHUB_OUTPUT}"
|
||||
12
scripts/github-actions/validate-release-traceability.sh
Normal file
12
scripts/github-actions/validate-release-traceability.sh
Normal file
@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
service_image_ref="${SERVICE_IMAGE_REF:?SERVICE_IMAGE_REF is required}"
|
||||
tag="${service_image_ref##*:}"
|
||||
commit="${tag#sha-}"
|
||||
|
||||
curl -fsS "https://billing-service.example.com/api/ping" | jq -e \
|
||||
--arg image "${service_image_ref}" \
|
||||
--arg tag "${tag}" \
|
||||
--arg commit "${commit}" \
|
||||
'.image == $image and .tag == $tag and .commit == $commit'
|
||||
Loading…
Reference in New Issue
Block a user