fix(repo):统一发布健康与只读质量门禁
bat-rust / Build and test Rust (push) Canceled after 0s
bat-rust / Build and test Go API (push) Canceled after 0s

This commit is contained in:
2026-09-13 22:09:44 +08:00
parent 32fc64fa83
commit c17904ee1c
25 changed files with 869 additions and 184 deletions
+297 -17
View File
@@ -32,6 +32,89 @@ func fixtureRoot(t *testing.T) string {
return abs
}
func copyFixtureRoot(t *testing.T) string {
t.Helper()
source := fixtureRoot(t)
target := filepath.Join(t.TempDir(), "release")
if err := filepath.Walk(source, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(source, path)
if err != nil {
return err
}
destination := filepath.Join(target, rel)
if info.IsDir() {
return os.MkdirAll(destination, info.Mode().Perm())
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
return os.WriteFile(destination, data, info.Mode().Perm())
}); err != nil {
t.Fatal(err)
}
return target
}
func fixtureRPCBackend(t *testing.T, root string) *fakeBackend {
t.Helper()
idx, err := LoadIndexFromResourceRoot(root)
if err != nil {
t.Fatal(err)
}
manifestEntries := make([]backendrpc.ResourceManifestEntry, 0, len(idx.Entries))
for _, entry := range idx.Entries {
size := entry.Bytes
manifestEntries = append(manifestEntries, backendrpc.ResourceManifestEntry{
URL: entry.URL,
Destination: entry.RelativePath,
Bytes: &size,
BLAKE3: entry.BLAKE3,
})
}
catalog, err := json.Marshal(map[string]any{
"available": true,
"status": "published",
"version": map[string]any{
"id": "official-fixture",
"resource_root": root,
},
})
if err != nil {
t.Fatal(err)
}
return &fakeBackend{
status: &backendrpc.DaemonStatusReport{Status: "ok", Running: true, RPCAvailable: true},
doctor: &backendrpc.DoctorReport{Healthy: true, Status: "ok"},
catalog: catalog,
manifest: &backendrpc.ResourceManifestPage{
Available: true,
ResourceRoot: root,
ManifestVersion: 1,
TotalEntries: len(manifestEntries),
Entries: manifestEntries,
},
releaseStatus: &backendrpc.ReleaseStatusReport{
Status: "ready",
StatusCode: "distribution.ready",
OfficialCurrentReleaseID: "official-fixture",
DefaultDistributionChannel: "official",
OfficialDistributionReady: true,
Releases: []backendrpc.ReleaseSummary{{
Channel: "official",
ID: "official-fixture",
Current: true,
DistributionIntegrityStatus: "valid",
}},
},
}
}
const fixtureCurrentCDNPath = "/prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes"
func TestLoadIndexFromResourceRoot(t *testing.T) {
idx, err := LoadIndexFromResourceRoot(fixtureRoot(t))
if err != nil {
@@ -89,6 +172,188 @@ func TestReleaseSummaryRequiresCompleteManifest(t *testing.T) {
}
}
func TestCurrentCDNRequiresRustWholeReleaseHealth(t *testing.T) {
tests := []struct {
name string
mutate func(t *testing.T, root string)
}{
{
name: "missing entry",
mutate: func(t *testing.T, root string) {
t.Helper()
if err := os.Remove(filepath.Join(root, "prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.hash")); err != nil {
t.Fatal(err)
}
},
},
{
name: "size mismatch",
mutate: func(t *testing.T, root string) {
t.Helper()
if err := os.WriteFile(filepath.Join(root, "prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.hash"), []byte("too-large"), 0o644); err != nil {
t.Fatal(err)
}
},
},
{
name: "same-size corruption",
mutate: func(t *testing.T, root string) {
t.Helper()
if err := os.WriteFile(filepath.Join(root, "prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.hash"), []byte("CORRUPTED!"), 0o644); err != nil {
t.Fatal(err)
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
root := copyFixtureRoot(t)
tt.mutate(t, root)
backend := fixtureRPCBackend(t, root)
backend.releaseStatus = &backendrpc.ReleaseStatusReport{
Status: "blocked",
StatusCode: "distribution.blocked",
OfficialCurrentReleaseID: "official-fixture",
DefaultDistributionChannel: "official",
OfficialDistributionReady: false,
Releases: []backendrpc.ReleaseSummary{{
Channel: "official",
ID: "official-fixture",
Current: true,
DistributionIntegrityStatus: "invalid",
Diagnostics: []string{"fixture integrity failure"},
}},
}
cfg := DefaultConfig()
cfg.RequireIndexed = false
cfg.RefreshInterval = 0
if err := cfg.Normalize(); err != nil {
t.Fatal(err)
}
server := NewServer(cfg, backend, log.New(io.Discard, "", 0))
if err := server.Refresh(context.Background()); err != nil {
t.Fatal(err)
}
summary := server.index().Summary()
if summary.Ready || summary.Distribution.Ready ||
summary.Distribution.StatusCode != "distribution.blocked" {
t.Fatalf("summary=%+v", summary)
}
for _, path := range []string{"/readyz", "/v1/bootstrap"} {
recorder := httptest.NewRecorder()
server.Handler().ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, path, nil))
if recorder.Code != http.StatusServiceUnavailable {
t.Fatalf("%s status=%d body=%s", path, recorder.Code, recorder.Body.String())
}
}
recorder := httptest.NewRecorder()
server.Handler().ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, fixtureCurrentCDNPath, nil))
if recorder.Code != http.StatusServiceUnavailable {
t.Fatalf("current CDN status=%d body=%s", recorder.Code, recorder.Body.String())
}
if recorder.Header().Get("ETag") != "" || recorder.Header().Get("Cache-Control") != "" {
t.Fatalf("unhealthy CDN headers=%v", recorder.Header())
}
})
}
}
func TestRefreshCurrentReleaseHealthTransitionsAndClearsFailure(t *testing.T) {
root := copyFixtureRoot(t)
backend := fixtureRPCBackend(t, root)
cfg := DefaultConfig()
cfg.RefreshInterval = 0
if err := cfg.Normalize(); err != nil {
t.Fatal(err)
}
server := NewServer(cfg, backend, log.New(io.Discard, "", 0))
if err := server.Refresh(context.Background()); err != nil {
t.Fatal(err)
}
if !server.index().Summary().Ready {
t.Fatal("initial release is not ready")
}
get := func() *httptest.ResponseRecorder {
recorder := httptest.NewRecorder()
server.Handler().ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, fixtureCurrentCDNPath, nil))
return recorder
}
if recorder := get(); recorder.Code != http.StatusOK || recorder.Body.String() != "TABLE_CATALOG_FIXTURE" {
t.Fatalf("healthy CDN status=%d body=%q", recorder.Code, recorder.Body.String())
}
// The bytes stay the same size, but Rust's next release.status result
// revokes whole-release distribution authorization.
if err := os.WriteFile(filepath.Join(root, "prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.hash"), []byte("CORRUPTED!"), 0o644); err != nil {
t.Fatal(err)
}
backend.releaseStatus = &backendrpc.ReleaseStatusReport{
Status: "blocked",
StatusCode: "distribution.blocked",
OfficialCurrentReleaseID: "official-fixture",
DefaultDistributionChannel: "official",
Releases: []backendrpc.ReleaseSummary{{
Channel: "official",
ID: "official-fixture",
Current: true,
DistributionIntegrityStatus: "invalid",
}},
}
if err := server.Refresh(context.Background()); err != nil {
t.Fatal(err)
}
if server.index().Summary().Ready || get().Code != http.StatusServiceUnavailable {
t.Fatalf("unhealthy refresh summary=%+v", server.index().Summary())
}
backend.releaseStatusErr = errors.New("release.status transport failure")
if err := server.Refresh(context.Background()); err == nil {
t.Fatal("expected refresh failure")
}
if summary := server.index().Summary(); summary.Ready || summary.ResourceRoot != "" {
t.Fatalf("failed refresh retained snapshot=%+v", summary)
}
healthRecorder := httptest.NewRecorder()
server.Handler().ServeHTTP(healthRecorder, httptest.NewRequest(http.MethodGet, "/healthz", nil))
var health map[string]any
if err := json.Unmarshal(healthRecorder.Body.Bytes(), &health); err != nil {
t.Fatal(err)
}
refresh := health["refresh"].(map[string]any)
if refresh["last_error"] == "" {
t.Fatalf("refresh diagnostics=%v", refresh)
}
if err := os.WriteFile(filepath.Join(root, "prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.hash"), []byte("1234567890"), 0o644); err != nil {
t.Fatal(err)
}
backend.releaseStatusErr = nil
backend.releaseStatus = &backendrpc.ReleaseStatusReport{
Status: "ready",
StatusCode: "distribution.ready",
OfficialCurrentReleaseID: "official-fixture",
DefaultDistributionChannel: "official",
OfficialDistributionReady: true,
Releases: []backendrpc.ReleaseSummary{{
Channel: "official",
ID: "official-fixture",
Current: true,
DistributionIntegrityStatus: "valid",
}},
}
if err := server.Refresh(context.Background()); err != nil {
t.Fatal(err)
}
if summary := server.index().Summary(); !summary.Ready || !summary.Distribution.Ready {
t.Fatalf("recovered summary=%+v", summary)
}
if recorder := get(); recorder.Code != http.StatusOK || recorder.Body.String() != "TABLE_CATALOG_FIXTURE" {
t.Fatalf("recovered CDN status=%d body=%q", recorder.Code, recorder.Body.String())
}
}
func TestSplitCDNPathRejectsEscape(t *testing.T) {
if _, _, err := SplitCDNPath("/prod-clientpatch.bluearchiveyostar.com/../etc/passwd"); err == nil {
t.Fatal("expected error")
@@ -553,18 +818,21 @@ func TestServerInfoRewritesAddressablesOnly(t *testing.T) {
}
type fakeBackend struct {
statusCalls int
doctorCalls int
status *backendrpc.DaemonStatusReport
doctor *backendrpc.DoctorReport
catalog json.RawMessage
resource *backendrpc.ResourceState
manifest *backendrpc.ResourceManifestPage
daemonLogs *backendrpc.LogsReport
taskList *backendrpc.TaskList
taskStatus *backendrpc.TaskRecord
taskLogs *backendrpc.TaskLogs
taskCancel *backendrpc.TaskCancelResult
statusCalls int
doctorCalls int
releaseStatusCalls int
status *backendrpc.DaemonStatusReport
doctor *backendrpc.DoctorReport
releaseStatus *backendrpc.ReleaseStatusReport
releaseStatusErr error
catalog json.RawMessage
resource *backendrpc.ResourceState
manifest *backendrpc.ResourceManifestPage
daemonLogs *backendrpc.LogsReport
taskList *backendrpc.TaskList
taskStatus *backendrpc.TaskRecord
taskLogs *backendrpc.TaskLogs
taskCancel *backendrpc.TaskCancelResult
}
func (f *fakeBackend) DaemonStatus(ctx context.Context) (*backendrpc.DaemonStatusReport, error) {
@@ -573,11 +841,23 @@ func (f *fakeBackend) DaemonStatus(ctx context.Context) (*backendrpc.DaemonStatu
}
func (f *fakeBackend) DaemonDoctor(ctx context.Context) (*backendrpc.DoctorReport, error) {
f.doctorCalls++
if f.statusCalls == 0 {
// status must be called first in real DiscoverAndIndex; this is asserted by call order.
}
return f.doctor, nil
}
func (f *fakeBackend) ReleaseStatus(ctx context.Context) (*backendrpc.ReleaseStatusReport, error) {
f.releaseStatusCalls++
if f.releaseStatusErr != nil {
return nil, f.releaseStatusErr
}
if f.releaseStatus != nil {
return f.releaseStatus, nil
}
return &backendrpc.ReleaseStatusReport{
Status: "ready",
StatusCode: "distribution.ready",
DefaultDistributionChannel: "official",
OfficialDistributionReady: true,
}, nil
}
func (f *fakeBackend) ResourceState(ctx context.Context) (*backendrpc.ResourceState, error) {
if f.resource != nil {
return f.resource, nil
@@ -1243,8 +1523,8 @@ func TestDiscoverCallsStatusBeforeDoctor(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if fb.statusCalls != 1 || fb.doctorCalls != 1 {
t.Fatalf("status=%d doctor=%d", fb.statusCalls, fb.doctorCalls)
if fb.statusCalls != 1 || fb.doctorCalls != 1 || fb.releaseStatusCalls != 1 {
t.Fatalf("status=%d doctor=%d release_status=%d", fb.statusCalls, fb.doctorCalls, fb.releaseStatusCalls)
}
if !result.RPCAvailable || result.Index == nil || !result.Index.Summary().Ready {
t.Fatalf("result=%+v summary=%+v", result, result.Index.Summary())
+16 -14
View File
@@ -50,16 +50,20 @@ func (s *Server) serveCDN(w http.ResponseWriter, r *http.Request) {
http.Error(w, "resource root not ready", http.StatusServiceUnavailable)
return
}
resourceRoot = idx.ResourceRoot
if s.cfg.RequireIndexed {
entry, hasEntry = idx.Lookup(rel)
}
}
if explicitRelease || s.cfg.RequireIndexed {
if !hasEntry || !entry.Present || !entry.SizeMatch {
http.NotFound(w, r)
summary := idx.Summary()
if !summary.Ready {
// The cached health fact covers the whole current release. A
// locally present target is not enough to serve it as a healthy
// immutable artifact.
http.Error(w, "current release is not distributable", http.StatusServiceUnavailable)
return
}
resourceRoot = idx.ResourceRoot
entry, hasEntry = idx.Lookup(rel)
}
if !hasEntry || !entry.Present || !entry.SizeMatch {
http.NotFound(w, r)
return
}
abs, err := ResolveUnderRoot(resourceRoot, rel, true)
@@ -76,11 +80,9 @@ func (s *Server) serveCDN(w http.ResponseWriter, r *http.Request) {
http.Error(w, "size mismatch with release index", http.StatusConflict)
return
}
if !explicitRelease && s.cfg.RequireIndexed && s.cfg.VerifySize {
if hasEntry && entry.Bytes > 0 && uint64(info.Size()) != entry.Bytes {
http.Error(w, "size mismatch with release index", http.StatusConflict)
return
}
if !explicitRelease && entry.Bytes > 0 && uint64(info.Size()) != entry.Bytes {
http.Error(w, "size mismatch with release index", http.StatusConflict)
return
}
file, err := os.Open(abs)
@@ -88,7 +90,7 @@ func (s *Server) serveCDN(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
defer file.Close()
defer func() { _ = file.Close() }()
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
w.Header().Set("ETag", cdnETag(entry, hasEntry, info))
+1
View File
@@ -111,6 +111,7 @@ func (s *Server) launcherBootstrapBody(sum ReleaseSummary) LauncherBootstrapResp
},
Resource: LauncherResource{
Release: sum.Snapshot,
Distribution: sum.Distribution,
ServerInfoURL: s.serverInfoURL(),
ClientPatchBaseURL: s.clientPatchBaseURL(),
},
+7 -7
View File
@@ -27,25 +27,25 @@ paths:
summary: Release readiness
responses:
"200":
description: A distributable release is available.
description: A release authorized by Rust release.status and fully represented by the local read snapshot is available.
"503":
description: No distributable release is available.
description: The Rust whole-release distribution health fact or the local read snapshot is not distributable.
/v1/bootstrap:
get:
summary: Startup resource bootstrap
responses:
"200":
description: Resource bootstrap response.
description: Resource bootstrap response with the same distribution health used by readiness and current CDN serving.
"503":
description: Release is not ready.
description: The current release is not distributable.
/v1/launcher/bootstrap:
get:
summary: Launcher-shaped resource bootstrap
responses:
"200":
description: Launcher bootstrap response.
description: Launcher bootstrap response with the current release distribution health.
"503":
description: Release is not ready.
description: The current release is not distributable.
/api/launcher/game/config:
get:
summary: Resource-only launcher game config compatibility
@@ -78,7 +78,7 @@ paths:
summary: Current release summary
responses:
"200":
description: Release summary.
description: Release summary including Rust-owned whole-release distribution health.
/v1/releases:
get:
summary: Rust-owned official and localized release history
+68 -23
View File
@@ -55,17 +55,33 @@ type GameMainConfigSummary struct {
DefaultConnectionGroup string `json:"default_connection_group,omitempty"`
}
// DistributionHealth is the release-level authorization used by read paths.
//
// In RPC mode Ready is copied from Rust's release.status
// official_distribution_ready fact. The local manifest checks only establish
// that this process has a usable read snapshot; they do not replace Rust's
// release verifier.
type DistributionHealth struct {
Ready bool `json:"ready"`
Source string `json:"source"`
Status string `json:"status,omitempty"`
StatusCode string `json:"status_code,omitempty"`
IntegrityStatus string `json:"integrity_status,omitempty"`
Diagnostics []string `json:"diagnostics,omitempty"`
}
// ReleaseIndex is the in-memory view of a published resource root.
type ReleaseIndex struct {
mu sync.RWMutex
ResourceRoot string `json:"resource_root"`
Source string `json:"source"` // "rpc" | "resource_root" | "empty"
RPCAvailable bool `json:"rpc_available"`
DoctorHealthy *bool `json:"doctor_healthy,omitempty"`
Snapshot *SnapshotSummary `json:"snapshot,omitempty"`
ManifestVersion int `json:"manifest_version,omitempty"`
Entries []ResourceEntry `json:"entries"`
ResourceRoot string `json:"resource_root"`
Source string `json:"source"` // "rpc" | "resource_root" | "empty"
RPCAvailable bool `json:"rpc_available"`
DoctorHealthy *bool `json:"doctor_healthy,omitempty"`
Distribution DistributionHealth `json:"distribution"`
Snapshot *SnapshotSummary `json:"snapshot,omitempty"`
ManifestVersion int `json:"manifest_version,omitempty"`
Entries []ResourceEntry `json:"entries"`
// byRel maps relative path (host/path...) to entry index.
byRel map[string]int
// MissingOnDisk lists relative paths present in the index but absent on disk.
@@ -74,16 +90,17 @@ type ReleaseIndex struct {
// Summary returns a JSON-serializable overview without the full entry list.
type ReleaseSummary struct {
ResourceRoot string `json:"resource_root"`
Source string `json:"source"`
RPCAvailable bool `json:"rpc_available"`
DoctorHealthy *bool `json:"doctor_healthy,omitempty"`
Snapshot *SnapshotSummary `json:"snapshot,omitempty"`
ManifestVersion int `json:"manifest_version,omitempty"`
EntryCount int `json:"entry_count"`
PresentCount int `json:"present_count"`
MissingCount int `json:"missing_count"`
Ready bool `json:"ready"`
ResourceRoot string `json:"resource_root"`
Source string `json:"source"`
RPCAvailable bool `json:"rpc_available"`
DoctorHealthy *bool `json:"doctor_healthy,omitempty"`
Distribution DistributionHealth `json:"distribution"`
Snapshot *SnapshotSummary `json:"snapshot,omitempty"`
ManifestVersion int `json:"manifest_version,omitempty"`
EntryCount int `json:"entry_count"`
PresentCount int `json:"present_count"`
MissingCount int `json:"missing_count"`
Ready bool `json:"ready"`
}
// Summary builds a compact release overview.
@@ -96,20 +113,32 @@ func (idx *ReleaseIndex) Summary() ReleaseSummary {
present++
}
}
distribution := idx.Distribution
distribution.Diagnostics = append([]string(nil), idx.Distribution.Diagnostics...)
localComplete := idx.ResourceRoot != "" && len(idx.Entries) > 0 && present == len(idx.Entries)
// Hand-built indexes are retained for compatibility with local tests and
// diagnostics. Any index explicitly sourced from RPC must carry the Rust
// health fact; an RPC index without it is never considered distributable.
if distribution.Source == "" {
distribution.Ready = localComplete && idx.Source != "rpc" && idx.Source != "rpc+local_manifest"
} else {
distribution.Ready = distribution.Ready && localComplete
}
return ReleaseSummary{
ResourceRoot: idx.ResourceRoot,
Source: idx.Source,
RPCAvailable: idx.RPCAvailable,
DoctorHealthy: idx.DoctorHealthy,
Distribution: distribution,
Snapshot: idx.Snapshot,
ManifestVersion: idx.ManifestVersion,
EntryCount: len(idx.Entries),
PresentCount: present,
MissingCount: len(idx.MissingOnDisk),
// A release is distributable only when every manifest entry is present
// and has the expected size. Serving a partial release can leave clients
// with an apparently valid bootstrap and an unrecoverable download set.
Ready: idx.ResourceRoot != "" && len(idx.Entries) > 0 && present == len(idx.Entries),
// A release is distributable only when Rust authorizes it and every
// entry in this process's read snapshot is usable. Serving a partial
// release can leave clients with an unrecoverable download set.
Ready: distribution.Ready,
}
}
@@ -158,6 +187,7 @@ func BuildIndexFromManifestEntries(
snapshot *SnapshotSummary,
manifestVersion int,
entries []manifestEntry,
distribution DistributionHealth,
) (*ReleaseIndex, error) {
rootAbs, err := filepath.Abs(resourceRoot)
if err != nil {
@@ -168,7 +198,8 @@ func BuildIndexFromManifestEntries(
Source: source,
RPCAvailable: rpcAvailable,
DoctorHealthy: doctorHealthy,
Snapshot: snapshot,
Distribution: distribution,
Snapshot: snapshotWithDistributionHealth(snapshot, distribution),
ManifestVersion: manifestVersion,
byRel: make(map[string]int),
}
@@ -258,7 +289,21 @@ func LoadIndexFromResourceRoot(resourceRoot string) (*ReleaseIndex, error) {
}
}
}
return BuildIndexFromManifestEntries(rootAbs, "resource_root", false, nil, snapshot, manifest.Version, entries)
return BuildIndexFromManifestEntries(
rootAbs,
"resource_root",
false,
nil,
snapshot,
manifest.Version,
entries,
DistributionHealth{
Ready: true,
Source: "resource_root_override",
Status: "ready",
StatusCode: "resource_root.ready",
},
)
}
type manifestEntry struct {
+15 -13
View File
@@ -51,16 +51,17 @@ type BootstrapAPI struct {
}
type BootstrapResource struct {
Release *SnapshotSummary `json:"release,omitempty"`
ResourceRoot string `json:"resource_root"`
Source string `json:"source"`
ManifestVersion int `json:"manifest_version,omitempty"`
EntryCount int `json:"entry_count"`
PresentCount int `json:"present_count"`
MissingCount int `json:"missing_count"`
ServerInfoURL string `json:"server_info_url"`
ClientPatchBaseURL string `json:"client_patch_base_url"`
AddressablesCatalogURLRoot string `json:"addressables_catalog_url_root,omitempty"`
Release *SnapshotSummary `json:"release,omitempty"`
ResourceRoot string `json:"resource_root"`
Source string `json:"source"`
Distribution DistributionHealth `json:"distribution"`
ManifestVersion int `json:"manifest_version,omitempty"`
EntryCount int `json:"entry_count"`
PresentCount int `json:"present_count"`
MissingCount int `json:"missing_count"`
ServerInfoURL string `json:"server_info_url"`
ClientPatchBaseURL string `json:"client_patch_base_url"`
AddressablesCatalogURLRoot string `json:"addressables_catalog_url_root,omitempty"`
}
type BootstrapPolicy struct {
@@ -113,9 +114,10 @@ type LauncherPolicy struct {
}
type LauncherResource struct {
Release *SnapshotSummary `json:"release,omitempty"`
ServerInfoURL string `json:"server_info_url"`
ClientPatchBaseURL string `json:"client_patch_base_url"`
Release *SnapshotSummary `json:"release,omitempty"`
Distribution DistributionHealth `json:"distribution"`
ServerInfoURL string `json:"server_info_url"`
ClientPatchBaseURL string `json:"client_patch_base_url"`
}
type LauncherEndpointSet struct {
+152 -28
View File
@@ -113,10 +113,17 @@ type LocalizedBackend interface {
LocalizedRollback(ctx context.Context, params backendrpc.LocalizedRollbackParams) (json.RawMessage, error)
}
// ReleaseStatusBackend exposes the Rust-owned release health fact used during
// discovery. It is kept separate so lightweight test/diagnostic backends do
// not have to implement the administrative release surface.
type ReleaseStatusBackend interface {
ReleaseStatus(ctx context.Context) (*backendrpc.ReleaseStatusReport, error)
}
// ReleaseBackend exposes Rust-owned dual-release queries, distribution
// selection and the explicit cleanup operation.
type ReleaseBackend interface {
ReleaseStatus(ctx context.Context) (*backendrpc.ReleaseStatusReport, error)
ReleaseStatusBackend
ReleaseList(ctx context.Context, params backendrpc.ReleaseListParams) (*backendrpc.ReleaseListReport, error)
ReleaseDistribution(ctx context.Context, params backendrpc.ReleaseDistributionParams) (*backendrpc.ReleaseDistributionPage, error)
ReleaseCleanup(ctx context.Context, params backendrpc.ReleaseCleanupParams) (*backendrpc.ReleaseCleanupReport, error)
@@ -303,14 +310,18 @@ type DiscoverResult struct {
DoctorHealthy *bool
Status *backendrpc.DaemonStatusReport
Doctor *backendrpc.DoctorReport
ReleaseStatus *backendrpc.ReleaseStatusReport
Distribution DistributionHealth
Snapshot *SnapshotSummary
ResourceRoot string
Index *ReleaseIndex
Warnings []string
}
// DiscoverAndIndex contacts the daemon (status first, then doctor) and builds
// a release index from paginated resource.manifest plus on-disk checks.
// DiscoverAndIndex contacts the daemon (status first, then doctor, then
// release.status) and builds a release index from paginated resource.manifest
// plus on-disk checks. Rust's release.status is the only release-level
// integrity authorization used for the production RPC path.
//
// If resourceRootOverride is non-empty, it wins over RPC-reported roots after
// RPC health probes (still preferred for production to call status/doctor).
@@ -326,6 +337,7 @@ func DiscoverAndIndex(ctx context.Context, backend Backend, resourceRootOverride
return nil, err
}
out.ResourceRoot = idx.ResourceRoot
out.Distribution = idx.Distribution
out.Index = idx
return out, nil
}
@@ -340,6 +352,7 @@ func DiscoverAndIndex(ctx context.Context, backend Backend, resourceRootOverride
return out, fmt.Errorf("daemon.status failed (%v) and resource-root load failed: %w", err, loadErr)
}
out.ResourceRoot = idx.ResourceRoot
out.Distribution = idx.Distribution
out.Index = idx
return out, nil
}
@@ -359,6 +372,45 @@ func DiscoverAndIndex(ctx context.Context, backend Backend, resourceRootOverride
out.DoctorHealthy = &h
}
// An explicit root is a fixture/emergency read-only override. Keep it
// outside the production RPC release-health contract, while still probing
// daemon status and doctor first.
if resourceRootOverride != "" {
idx, loadErr := LoadIndexFromResourceRoot(resourceRootOverride)
if loadErr != nil {
return out, fmt.Errorf("resource-root override load failed: %w", loadErr)
}
idx.Source = "resource_root"
idx.RPCAvailable = true
idx.DoctorHealthy = out.DoctorHealthy
out.ResourceRoot = idx.ResourceRoot
out.Snapshot = idx.Snapshot
out.Distribution = idx.Distribution
out.Index = idx
return out, nil
}
// 3) release.status is the Rust-owned whole-release distribution gate.
releaseStatusBackend, ok := backend.(ReleaseStatusBackend)
if !ok {
out.Warnings = append(out.Warnings, "release.status: backend does not expose Rust release health")
return emptyRPCResult(out, nil, "Rust release health is unavailable"),
fmt.Errorf("rust release health is unavailable")
}
releaseStatus, err := releaseStatusBackend.ReleaseStatus(ctx)
if err != nil {
out.Warnings = append(out.Warnings, fmt.Sprintf("release.status: %v", err))
return emptyRPCResult(out, nil, "Rust release health query failed"),
fmt.Errorf("release.status failed: %w", err)
}
if releaseStatus == nil {
out.Warnings = append(out.Warnings, "release.status: empty response")
return emptyRPCResult(out, nil, "Rust release health query returned no response"),
fmt.Errorf("release.status returned an empty response")
}
out.ReleaseStatus = releaseStatus
out.Distribution = rustDistributionHealth(releaseStatus)
// Catalog / resource discovery
var snapshot *SnapshotSummary
var resourceRoot string
@@ -375,6 +427,20 @@ func DiscoverAndIndex(ctx context.Context, backend Backend, resourceRootOverride
resourceRoot = root
}
}
if snapshot != nil &&
snapshot.VersionID != "" &&
out.ReleaseStatus.OfficialCurrentReleaseID != "" &&
snapshot.VersionID != out.ReleaseStatus.OfficialCurrentReleaseID {
return emptyRPCResult(
out,
snapshot,
fmt.Sprintf(
"release.status current ID %q does not match catalog current ID %q",
out.ReleaseStatus.OfficialCurrentReleaseID,
snapshot.VersionID,
),
), nil
}
if catalogAvailabilityKnown && !catalogAvailable {
return emptyRPCResult(out, snapshot, "catalog.status available=false; no published release"), nil
}
@@ -393,12 +459,13 @@ func DiscoverAndIndex(ctx context.Context, backend Backend, resourceRootOverride
resourceRoot = resourceRootOverride
}
if resourceRoot == "" {
out.Snapshot = snapshot
out.Snapshot = snapshotWithDistributionHealth(snapshot, out.Distribution)
out.Index = &ReleaseIndex{
Source: "rpc",
RPCAvailable: true,
DoctorHealthy: out.DoctorHealthy,
Snapshot: snapshot,
Distribution: out.Distribution,
Snapshot: snapshotWithDistributionHealth(snapshot, out.Distribution),
byRel: map[string]int{},
}
out.Warnings = append(out.Warnings, "no resource root from RPC; set --resource-root or publish a version")
@@ -421,28 +488,14 @@ func DiscoverAndIndex(ctx context.Context, backend Backend, resourceRootOverride
entries, manifestVersion, rootFromManifest, err := fetchAllManifestEntries(ctx, backend)
if err != nil {
out.Warnings = append(out.Warnings, fmt.Sprintf("resource.manifest: %v", err))
// Fallback: load local manifest file under root.
idx, loadErr := LoadIndexFromResourceRoot(resourceRoot)
if loadErr != nil {
if resourceRootOverride == "" {
return emptyRPCResult(
out,
snapshot,
fmt.Sprintf("published release cannot be indexed: %v", loadErr),
), nil
}
return out, fmt.Errorf("manifest RPC and local load failed: rpc=%v local=%w", err, loadErr)
}
idx.Source = "rpc+local_manifest"
idx.RPCAvailable = true
idx.DoctorHealthy = out.DoctorHealthy
if snapshot != nil {
idx.Snapshot = snapshot
}
out.ResourceRoot = idx.ResourceRoot
out.Snapshot = idx.Snapshot
out.Index = idx
return out, nil
// Without the RPC manifest there is no evidence that the local
// snapshot matches the Rust health fact. Do not pair a fresh health
// result with a potentially stale on-disk manifest.
return emptyRPCResult(
out,
snapshot,
fmt.Sprintf("published release cannot be indexed: %v", err),
), nil
}
if rootFromManifest != "" {
resourceRoot = rootFromManifest
@@ -459,6 +512,7 @@ func DiscoverAndIndex(ctx context.Context, backend Backend, resourceRootOverride
snapshot,
manifestVersion,
entries,
out.Distribution,
)
if err != nil {
if resourceRootOverride == "" {
@@ -471,18 +525,25 @@ func DiscoverAndIndex(ctx context.Context, backend Backend, resourceRootOverride
return out, err
}
out.ResourceRoot = idx.ResourceRoot
out.Snapshot = snapshot
out.Snapshot = idx.Snapshot
out.Index = idx
return out, nil
}
func emptyRPCResult(out *DiscoverResult, snapshot *SnapshotSummary, warning string) *DiscoverResult {
distribution := out.Distribution
if distribution.Source == "" {
distribution = unavailableRustDistributionHealth()
out.Distribution = distribution
}
snapshot = snapshotWithDistributionHealth(snapshot, distribution)
out.ResourceRoot = ""
out.Snapshot = snapshot
out.Index = &ReleaseIndex{
Source: "rpc",
RPCAvailable: true,
DoctorHealthy: out.DoctorHealthy,
Distribution: distribution,
Snapshot: snapshot,
byRel: map[string]int{},
}
@@ -492,6 +553,69 @@ func emptyRPCResult(out *DiscoverResult, snapshot *SnapshotSummary, warning stri
return out
}
func rustDistributionHealth(report *backendrpc.ReleaseStatusReport) DistributionHealth {
health := DistributionHealth{
Source: "rust_release_status",
Status: "blocked",
StatusCode: "distribution.blocked",
IntegrityStatus: "unknown",
}
if report == nil {
return health
}
health.Ready = report.OfficialDistributionReady
health.Status = report.Status
health.StatusCode = report.StatusCode
if health.Status == "" {
if health.Ready {
health.Status = "ready"
} else {
health.Status = "blocked"
}
}
if health.StatusCode == "" {
if health.Ready {
health.StatusCode = "distribution.ready"
} else {
health.StatusCode = "distribution.blocked"
}
}
for _, release := range report.Releases {
if release.Channel == "official" && release.Current {
health.IntegrityStatus = release.DistributionIntegrityStatus
health.Diagnostics = append([]string(nil), release.Diagnostics...)
break
}
}
if health.IntegrityStatus == "valid" && !health.Ready {
health.IntegrityStatus = "invalid"
}
return health
}
func unavailableRustDistributionHealth() DistributionHealth {
return DistributionHealth{
Source: "rust_release_status",
Status: "unavailable",
StatusCode: "distribution.health_unavailable",
IntegrityStatus: "unknown",
}
}
func snapshotWithDistributionHealth(snapshot *SnapshotSummary, health DistributionHealth) *SnapshotSummary {
if snapshot == nil {
return nil
}
updated := *snapshot
if health.Status != "" {
updated.DistributionStatus = health.Status
}
if health.StatusCode != "" {
updated.DistributionStatusCode = health.StatusCode
}
return &updated
}
func parseCatalogStatus(raw json.RawMessage) (*SnapshotSummary, string, bool) {
if len(raw) == 0 || string(raw) == "null" {
return nil, "", false
+45 -10
View File
@@ -3,6 +3,7 @@ package api
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"strconv"
@@ -18,9 +19,10 @@ type Server struct {
logger *log.Logger
limiter *tokenBucketLimiter
mu sync.RWMutex
idx *ReleaseIndex
meta DiscoverResult
refreshMu sync.Mutex
mu sync.RWMutex
idx *ReleaseIndex
meta DiscoverResult
refreshInProgress bool
lastRefreshStart time.Time
@@ -95,10 +97,46 @@ func (s *Server) Handler() http.Handler {
// Refresh rebuilds the release index via RPC (and optional resource-root override).
func (s *Server) Refresh(ctx context.Context) error {
// Serialize refreshes so an older, slower RPC response cannot replace a
// newer snapshot and so refresh diagnostics describe one attempt at a time.
s.refreshMu.Lock()
defer s.refreshMu.Unlock()
started := s.beginRefresh()
result, err := DiscoverAndIndex(ctx, s.backend, s.cfg.ResourceRoot)
if err != nil {
s.finishRefresh(started, err, nil)
s.mu.Lock()
warnings := []string(nil)
if result != nil {
s.meta = *result
warnings = append(warnings, result.Warnings...)
} else {
s.meta = DiscoverResult{}
}
warnings = append(warnings, fmt.Sprintf("refresh: %v", err))
s.idx = &ReleaseIndex{
Source: "empty",
Distribution: unavailableRustDistributionHealth(),
byRel: map[string]int{},
}
s.meta.Index = s.idx
s.meta.ResourceRoot = ""
s.meta.Distribution = s.idx.Distribution
s.finishRefreshLocked(started, err, warnings)
s.mu.Unlock()
return err
}
if result == nil || result.Index == nil {
err := fmt.Errorf("refresh returned no release index")
s.mu.Lock()
s.idx = &ReleaseIndex{
Source: "empty",
Distribution: unavailableRustDistributionHealth(),
byRel: map[string]int{},
}
s.meta = DiscoverResult{Index: s.idx, Distribution: s.idx.Distribution}
s.finishRefreshLocked(started, err, []string{err.Error()})
s.mu.Unlock()
return err
}
s.mu.Lock()
@@ -229,6 +267,7 @@ func (s *Server) handleBootstrap(w http.ResponseWriter, r *http.Request) {
Release: sum.Snapshot,
ResourceRoot: sum.ResourceRoot,
Source: sum.Source,
Distribution: sum.Distribution,
ManifestVersion: sum.ManifestVersion,
EntryCount: sum.EntryCount,
PresentCount: sum.PresentCount,
@@ -287,6 +326,7 @@ func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) {
"missing_count": sum.MissingCount,
"source": sum.Source,
"warnings": meta.Warnings,
"distribution": sum.Distribution,
// Database/redis are reserved config surface for a normal API process.
"database_configured": s.cfg.DatabaseURL != "",
"redis_configured": s.cfg.RedisURL != "",
@@ -316,6 +356,7 @@ func (s *Server) handleReadyz(w http.ResponseWriter, r *http.Request) {
"present_count": sum.PresentCount,
"missing_count": sum.MissingCount,
"source": sum.Source,
"distribution": sum.Distribution,
"refresh": s.refreshSnapshot(),
}
if r.Method == http.MethodHead {
@@ -430,12 +471,6 @@ func (s *Server) beginRefresh() time.Time {
return now
}
func (s *Server) finishRefresh(started time.Time, err error, warnings []string) {
s.mu.Lock()
defer s.mu.Unlock()
s.finishRefreshLocked(started, err, warnings)
}
func (s *Server) finishRefreshLocked(started time.Time, err error, warnings []string) {
now := time.Now()
s.refreshInProgress = false