ci: canonical docker-build (#2)

* feat: add /v1/o11y/* rewrite middleware for standard API path convention

Add URL rewrite middleware that intercepts /v1/o11y/* requests and
rewrites them to /api/* internally, allowing external clients to use
the /<version>/<service>/<path> convention while keeping all existing
internal route registrations unchanged.

* chore: sync latest

* ci: migrate to canonical hanzoai/.github/docker-build.yml reusable
This commit is contained in:
2026-04-23 19:24:27 -07:00
committed by GitHub
parent 1791c57181
commit ebc234a065
9 changed files with 160 additions and 79 deletions
+20 -79
View File
@@ -1,84 +1,25 @@
name: Build & Push to GHCR
name: Docker
on:
workflow_dispatch:
push:
branches: [main, test, dev]
tags: [v*]
workflow_dispatch:
env:
REGISTRY: ghcr.io
IMAGE_NAME: hanzoai/o11y
permissions:
contents: read
packages: write
jobs:
build-frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Build frontend
working-directory: frontend
run: |
pnpm install --frozen-lockfile
pnpm run build
- uses: actions/upload-artifact@v4
with:
name: frontend-build
path: frontend/build/
build-and-push:
needs: build-frontend
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: frontend-build
path: frontend/build/
- uses: actions/setup-go@v5
with:
go-version: '1.26'
- name: Build Go binary
run: |
VERSION=$(git describe --tags --always)
HASH=$(git rev-parse --short HEAD)
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
BRANCH=$(git rev-parse --abbrev-ref HEAD)
LDFLAGS="-X github.com/hanzoai/o11y/pkg/version.version=$VERSION"
LDFLAGS="$LDFLAGS -X github.com/hanzoai/o11y/pkg/version.hash=$HASH"
LDFLAGS="$LDFLAGS -X github.com/hanzoai/o11y/pkg/version.time=$TIMESTAMP"
LDFLAGS="$LDFLAGS -X github.com/hanzoai/o11y/pkg/version.branch=$BRANCH"
mkdir -p target/linux-amd64
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "$LDFLAGS" -o target/linux-amd64/o11y ./cmd/server/
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
type=sha,prefix=
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
file: cmd/server/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
OS=linux
TARGETARCH=amd64
docker:
uses: hanzoai/.github/.github/workflows/docker-build.yml@main
with:
image: ghcr.io/hanzoai/o11y
pre-build-command: |
cd frontend && corepack enable && pnpm install --frozen-lockfile && pnpm run build
cd ..
VERSION=$(git describe --tags --always)
HASH=$(git rev-parse --short HEAD)
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
BRANCH=$(git rev-parse --abbrev-ref HEAD)
LDFLAGS="-X github.com/hanzoai/o11y/pkg/version.version=$VERSION -X github.com/hanzoai/o11y/pkg/version.hash=$HASH -X github.com/hanzoai/o11y/pkg/version.time=$TIMESTAMP -X github.com/hanzoai/o11y/pkg/version.branch=$BRANCH"
mkdir -p target/linux-amd64
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "$LDFLAGS" -o target/linux-amd64/o11y ./cmd/server/
secrets: inherit
+7
View File
@@ -0,0 +1,7 @@
name: Workflow Sanity
on:
pull_request:
paths: ['.github/workflows/**']
jobs:
sanity:
uses: hanzoai/.github/.github/workflows/workflow-sanity.yml@main
Symlink
+1
View File
@@ -0,0 +1 @@
LLM.md
Symlink
+1
View File
@@ -0,0 +1 @@
LLM.md
+7
View File
@@ -0,0 +1,7 @@
# o11y — AI Assistant Context
<h1 align="center" style="border-bottom: none">
<a href="https://o11y.hanzo.ai" target="_blank">
<img alt="Hanzo O11y" src="https://github.com/user-attachments/assets/ef9a33f7-12d7-4c94-8908-0a02b22f0c18" width="100" height="100">
</a>
<br>Hanzo O11y
+2
View File
@@ -204,6 +204,8 @@ github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.14.1 h1:FBMC0zVz5XUmE4z9wF4Jey0An5FueFvOsTKKKtwIl7w=
github.com/bytedance/sonic v1.14.1/go.mod h1:gi6uhQLMbTdeP0muCnrjHLeCUPyb70ujhnNlhOylAFc=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cactus/go-statsd-client/statsd v0.0.0-20200423205355-cb0885a1018c/go.mod h1:l/bIBLeOl9eX+wxJAzxS4TveKRtAqlyDpHjhkfO0MEI=
+33
View File
@@ -0,0 +1,33 @@
package middleware
import (
"net/http"
"strings"
)
// Rewrite rewrites request URL paths that match a given prefix to a target
// prefix. This allows exposing routes under a new prefix (e.g. /v1/o11y/*)
// while keeping all internal route registrations unchanged (/api/*).
type Rewrite struct {
from string
to string
}
// NewRewrite creates a middleware that rewrites paths starting with `from` to
// start with `to` instead. Both prefixes should include trailing context if
// needed (e.g. "/v1/o11y" rewrites to "/api").
func NewRewrite(from, to string) *Rewrite {
return &Rewrite{from: from, to: to}
}
func (rw *Rewrite) Wrap(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, rw.from) {
r.URL.Path = rw.to + strings.TrimPrefix(r.URL.Path, rw.from)
if r.URL.RawPath != "" {
r.URL.RawPath = rw.to + strings.TrimPrefix(r.URL.RawPath, rw.from)
}
}
next.ServeHTTP(w, r)
})
}
+85
View File
@@ -0,0 +1,85 @@
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestRewrite(t *testing.T) {
tests := []struct {
name string
from string
to string
path string
wantPath string
}{
{
name: "rewrites matching prefix",
from: "/v1/o11y",
to: "/api",
path: "/v1/o11y/v1/dashboards",
wantPath: "/api/v1/dashboards",
},
{
name: "passes through non-matching path",
from: "/v1/o11y",
to: "/api",
path: "/api/v1/dashboards",
wantPath: "/api/v1/dashboards",
},
{
name: "rewrites exact prefix",
from: "/v1/o11y",
to: "/api",
path: "/v1/o11y",
wantPath: "/api",
},
{
name: "no rewrite for partial match",
from: "/v1/o11y",
to: "/api",
path: "/v1/o11yxyz",
wantPath: "/api" + "xyz",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var gotPath string
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
})
rw := NewRewrite(tt.from, tt.to)
handler := rw.Wrap(inner)
req := httptest.NewRequest(http.MethodGet, tt.path, nil)
handler.ServeHTTP(httptest.NewRecorder(), req)
if gotPath != tt.wantPath {
t.Errorf("got path %q, want %q", gotPath, tt.wantPath)
}
})
}
}
func TestRewrite_RawPath(t *testing.T) {
var gotPath, gotRawPath string
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotRawPath = r.URL.RawPath
})
rw := NewRewrite("/v1/o11y", "/api")
handler := rw.Wrap(inner)
req := httptest.NewRequest(http.MethodGet, "/v1/o11y/v1/query%20range", nil)
// httptest.NewRequest sets both Path and RawPath for encoded URLs
req.URL.Path = "/v1/o11y/v1/query range"
req.URL.RawPath = "/v1/o11y/v1/query%20range"
handler.ServeHTTP(httptest.NewRecorder(), req)
if gotPath != "/api/v1/query range" {
t.Errorf("got path %q, want %q", gotPath, "/api/v1/query range")
}
if gotRawPath != "/api/v1/query%20range" {
t.Errorf("got raw path %q, want %q", gotRawPath, "/api/v1/query%20range")
}
}
+4
View File
@@ -186,6 +186,10 @@ func (s Server) HealthCheckStatus() chan healthcheck.Status {
func (s *Server) createPublicServer(api *APIHandler, web web.Web) (*http.Server, error) {
r := NewRouter()
// Rewrite /v1/o11y/* to /api/* so external clients use the
// /<version>/<service>/<path> convention while internal routes stay unchanged.
r.Use(middleware.NewRewrite("/v1/o11y", "/api").Wrap)
r.Use(otelmux.Middleware(
"apiserver",
otelmux.WithMeterProvider(s.o11y.Instrumentation.MeterProvider()),