diff --git a/README.md b/README.md index 87d1e0b..cf0cbc3 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/cmd/dobotai/main.go b/cmd/dobotai/main.go index c65bd7f..ec7d62c 100644 --- a/cmd/dobotai/main.go +++ b/cmd/dobotai/main.go @@ -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) diff --git a/cmd/dobotai/main_test.go b/cmd/dobotai/main_test.go index a78875a..31af20e 100644 --- a/cmd/dobotai/main_test.go +++ b/cmd/dobotai/main_test.go @@ -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 { diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 0355a06..0bb21b6 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -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() diff --git a/internal/httpapi/server_test.go b/internal/httpapi/server_test.go index 65b5a53..a063614 100644 --- a/internal/httpapi/server_test.go +++ b/internal/httpapi/server_test.go @@ -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 { diff --git a/scripts/deploy-local.sh b/scripts/deploy-local.sh index f61808f..b7bc240 100755 --- a/scripts/deploy-local.sh +++ b/scripts/deploy-local.sh @@ -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 diff --git a/scripts/deploy_local_test.go b/scripts/deploy_local_test.go new file mode 100644 index 0000000..7cc6372 --- /dev/null +++ b/scripts/deploy_local_test.go @@ -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) + } + } +}