feat: expose runtime build version
This commit is contained in:
@@ -15,6 +15,7 @@ Implemented:
|
||||
- external-message idempotency;
|
||||
- independent PostgreSQL session/turn storage;
|
||||
- authenticated internal `POST /v1/qa` ingress endpoint.
|
||||
- unauthenticated read-only `GET /version` runtime build identity endpoint.
|
||||
- DingTalk Stream text ingress (official SDK) wired into `cmd/dobotai`.
|
||||
- v1 knowledge coverage decisions, durable support cases, and optional DingTalk support-group notifications.
|
||||
|
||||
@@ -74,6 +75,17 @@ Health check:
|
||||
curl http://127.0.0.1:8090/healthz
|
||||
```
|
||||
|
||||
Runtime version check:
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:8090/version
|
||||
```
|
||||
|
||||
`scripts/deploy-local.sh` builds from its own worktree (or the explicit
|
||||
`DOBOTAI_SOURCE_ROOT`), injects `version`, `commit_sha`, and `build_time`, and
|
||||
accepts the deployment only when `/version` reports the exact source commit.
|
||||
It refuses dirty worktrees so the reported SHA cannot hide uncommitted source.
|
||||
|
||||
Internal QA request:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -25,6 +25,20 @@ import (
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/weknora"
|
||||
)
|
||||
|
||||
var (
|
||||
version = "dev"
|
||||
commitSHA = "unknown"
|
||||
buildTime = "unknown"
|
||||
)
|
||||
|
||||
func runtimeBuildInfo() httpapi.BuildInfo {
|
||||
return httpapi.BuildInfo{
|
||||
Version: version,
|
||||
CommitSHA: commitSHA,
|
||||
BuildTime: buildTime,
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
@@ -74,6 +88,7 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
auditOptions.BuildInfo = runtimeBuildInfo()
|
||||
api, err := httpapi.NewWithOptions(qaService, os.Getenv("DOBOTAI_INGRESS_TOKEN"), auditOptions)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
|
||||
@@ -21,6 +21,20 @@ func (r *auditRepoStub) GetTurn(context.Context, string, string) (audit.TurnDeta
|
||||
return audit.TurnDetail{}, audit.ErrNotFound
|
||||
}
|
||||
|
||||
func TestRuntimeBuildInfoUsesLinkerVariables(t *testing.T) {
|
||||
oldVersion, oldCommitSHA, oldBuildTime := version, commitSHA, buildTime
|
||||
t.Cleanup(func() {
|
||||
version, commitSHA, buildTime = oldVersion, oldCommitSHA, oldBuildTime
|
||||
})
|
||||
version = "2.1.0"
|
||||
commitSHA = "abc123def456"
|
||||
buildTime = "2026-07-27T12:00:00Z"
|
||||
got := runtimeBuildInfo()
|
||||
if got.Version != version || got.CommitSHA != commitSHA || got.BuildTime != buildTime {
|
||||
t.Fatalf("build info=%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAuditOptionsDisabledIsBackwardCompatible(t *testing.T) {
|
||||
opts, err := buildAuditOptions(nil, "", "", "0.0.0.0:8090")
|
||||
if err != nil {
|
||||
|
||||
@@ -25,9 +25,16 @@ type AuditService interface {
|
||||
GetTurn(context.Context, string) (audit.TurnDetail, error)
|
||||
}
|
||||
|
||||
type BuildInfo struct {
|
||||
Version string `json:"version"`
|
||||
CommitSHA string `json:"commit_sha"`
|
||||
BuildTime string `json:"build_time"`
|
||||
}
|
||||
|
||||
type Options struct {
|
||||
AuditService AuditService
|
||||
AdminToken string
|
||||
BuildInfo BuildInfo
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
@@ -35,6 +42,7 @@ type Server struct {
|
||||
ingressToken string
|
||||
auditService AuditService
|
||||
adminToken string
|
||||
buildInfo BuildInfo
|
||||
mux *http.ServeMux
|
||||
}
|
||||
|
||||
@@ -58,13 +66,25 @@ func NewWithOptions(service QAService, ingressToken string, opts Options) (*Serv
|
||||
if hasAdminToken && constantTimeTokenEqual(adminToken, ingressToken) {
|
||||
return nil, errors.New("DOBOTAI_ADMIN_TOKEN must differ from DOBOTAI_INGRESS_TOKEN")
|
||||
}
|
||||
buildInfo := opts.BuildInfo
|
||||
if buildInfo.Version == "" {
|
||||
buildInfo.Version = "dev"
|
||||
}
|
||||
if buildInfo.CommitSHA == "" {
|
||||
buildInfo.CommitSHA = "unknown"
|
||||
}
|
||||
if buildInfo.BuildTime == "" {
|
||||
buildInfo.BuildTime = "unknown"
|
||||
}
|
||||
|
||||
s := &Server{
|
||||
service: service, ingressToken: ingressToken,
|
||||
auditService: opts.AuditService, adminToken: adminToken,
|
||||
mux: http.NewServeMux(),
|
||||
buildInfo: buildInfo,
|
||||
mux: http.NewServeMux(),
|
||||
}
|
||||
s.mux.HandleFunc("GET /healthz", s.health)
|
||||
s.mux.HandleFunc("GET /version", s.version)
|
||||
s.mux.HandleFunc("POST /v1/qa", s.authorizeToken(s.ingressToken, s.ask))
|
||||
if hasAuditService {
|
||||
s.mux.HandleFunc("GET /v1/admin/audit/turns", s.authorizeToken(s.adminToken, s.listAuditTurns))
|
||||
@@ -102,6 +122,11 @@ func (s *Server) health(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) version(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
writeJSON(w, http.StatusOK, s.buildInfo)
|
||||
}
|
||||
|
||||
func (s *Server) ask(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
|
||||
defer r.Body.Close()
|
||||
|
||||
@@ -25,6 +25,46 @@ func (f *captureQA) Ask(_ context.Context, request core.QARequest) (core.QAResul
|
||||
return core.QAResult{TurnID: "turn-capture", Answer: "answer"}, nil
|
||||
}
|
||||
|
||||
func TestVersionEndpointReturnsBuildMetadataWithoutAuthentication(t *testing.T) {
|
||||
server, err := NewWithOptions(fakeQA{}, "ingress-secret", Options{
|
||||
BuildInfo: BuildInfo{
|
||||
Version: "2.1.0",
|
||||
CommitSHA: "abc123def456",
|
||||
BuildTime: "2026-07-27T12:00:00Z",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodGet, "/version", nil)
|
||||
result := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(result, request)
|
||||
if result.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", result.Code, result.Body.String())
|
||||
}
|
||||
if got := result.Header().Get("Cache-Control"); got != "no-store" {
|
||||
t.Fatalf("Cache-Control=%q, want no-store", got)
|
||||
}
|
||||
want := `{"version":"2.1.0","commit_sha":"abc123def456","build_time":"2026-07-27T12:00:00Z"}`
|
||||
if got := strings.TrimSpace(result.Body.String()); got != want {
|
||||
t.Fatalf("body=%s, want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionEndpointUsesSafeDefaults(t *testing.T) {
|
||||
server, err := New(fakeQA{}, "ingress-secret")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodGet, "/version", nil)
|
||||
result := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(result, request)
|
||||
want := `{"version":"dev","commit_sha":"unknown","build_time":"unknown"}`
|
||||
if got := strings.TrimSpace(result.Body.String()); result.Code != http.StatusOK || got != want {
|
||||
t.Fatalf("status=%d body=%s, want %s", result.Code, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQAEndpointRequiresTokenAndReturnsResult(t *testing.T) {
|
||||
server, err := New(fakeQA{}, "ingress-secret")
|
||||
if err != nil {
|
||||
|
||||
+20
-4
@@ -1,18 +1,30 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
root="/home/zheromiku/dobotai-platform"
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
root="${DOBOTAI_SOURCE_ROOT:-$(cd "$script_dir/.." && pwd)}"
|
||||
go_bin="${GO_BIN:-/home/zheromiku/.local/share/go1.26.5/bin/go}"
|
||||
bin_dir="$root/bin"
|
||||
current="$bin_dir/dobotai"
|
||||
next="$bin_dir/dobotai.new"
|
||||
previous="$bin_dir/dobotai.prev"
|
||||
service="dobotai.service"
|
||||
version_url="http://127.0.0.1:8090/version"
|
||||
|
||||
if [[ -n "$(git -C "$root" status --porcelain)" ]]; then
|
||||
echo "DobotAI source worktree is dirty; refusing unverifiable build" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
commit_sha="$(git -C "$root" rev-parse HEAD)"
|
||||
version="${DOBOTAI_VERSION:-dev}"
|
||||
build_time="$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
|
||||
ldflags="-X main.version=$version -X main.commitSHA=$commit_sha -X main.buildTime=$build_time"
|
||||
|
||||
mkdir -p "$bin_dir"
|
||||
cd "$root"
|
||||
|
||||
"$go_bin" build -trimpath -o "$next" ./cmd/dobotai
|
||||
"$go_bin" build -trimpath -ldflags "$ldflags" -o "$next" ./cmd/dobotai
|
||||
chmod 0755 "$next"
|
||||
|
||||
if [[ -f "$current" ]]; then
|
||||
@@ -24,8 +36,12 @@ systemctl --user restart "$service"
|
||||
|
||||
for _ in $(seq 1 30); do
|
||||
if curl --noproxy '*' --fail --silent --max-time 2 http://127.0.0.1:8090/healthz >/dev/null; then
|
||||
echo "DOBOTAI_DEPLOY_OK"
|
||||
exit 0
|
||||
deployed_sha="$(curl --noproxy '*' --fail --silent --max-time 2 "$version_url" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("commit_sha", ""))' 2>/dev/null || true)"
|
||||
if [[ "$deployed_sha" == "$commit_sha" ]]; then
|
||||
echo "DOBOTAI_DEPLOY_OK"
|
||||
echo "DOBOTAI_COMMIT_SHA=$commit_sha"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package scripts
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDeployLocalBuildsItsSelectedSourceAndVerifiesVersion(t *testing.T) {
|
||||
data, err := os.ReadFile("deploy-local.sh")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
source := string(data)
|
||||
if strings.Contains(source, `root="/home/zheromiku/dobotai-platform"`) {
|
||||
t.Fatal("deploy script must not hard-code the production checkout")
|
||||
}
|
||||
for _, fragment := range []string{
|
||||
`BASH_SOURCE[0]`,
|
||||
`DOBOTAI_SOURCE_ROOT`,
|
||||
`git -C "$root" status --porcelain`,
|
||||
`git -C "$root" rev-parse HEAD`,
|
||||
`main.version`,
|
||||
`main.commitSHA`,
|
||||
`main.buildTime`,
|
||||
`http://127.0.0.1:8090/version`,
|
||||
} {
|
||||
if !strings.Contains(source, fragment) {
|
||||
t.Errorf("deploy script missing %q", fragment)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user