mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-18 07:24:55 +08:00
feat(release):完成双 release 运维闭环
This commit is contained in:
@@ -85,6 +85,7 @@ func (s *Server) handleAdminIndex(w http.ResponseWriter, r *http.Request) {
|
||||
"/admin/control/translation-glossary-delete",
|
||||
"/admin/control/localized-publish",
|
||||
"/admin/control/localized-rollback",
|
||||
"/admin/control/release-cleanup",
|
||||
},
|
||||
}
|
||||
if r.Method == http.MethodHead {
|
||||
@@ -144,6 +145,10 @@ func (s *Server) handleAdminControl(w http.ResponseWriter, r *http.Request) {
|
||||
s.handleAdminLocalizedRollback(w, r)
|
||||
return
|
||||
}
|
||||
if action == "release-cleanup" {
|
||||
s.handleAdminReleaseCleanup(w, r)
|
||||
return
|
||||
}
|
||||
request, ok := decodeAdminControlRequest(w, r)
|
||||
if !ok {
|
||||
return
|
||||
@@ -619,6 +624,34 @@ func (s *Server) handleAdminLocalizedStatus(w http.ResponseWriter, r *http.Reque
|
||||
writeNoStoreJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminReleaseCleanup(w http.ResponseWriter, r *http.Request) {
|
||||
backend, ok := s.backend.(ReleaseBackend)
|
||||
if !ok || backend == nil {
|
||||
writeErrorJSON(w, http.StatusServiceUnavailable, "release_backend_unavailable", "Rust bat release backend is unavailable")
|
||||
return
|
||||
}
|
||||
var params backendrpc.ReleaseCleanupParams
|
||||
if !decodeAdminTranslationJSON(w, r, ¶ms) {
|
||||
return
|
||||
}
|
||||
if params.Execute && strings.TrimSpace(params.PlanID) == "" {
|
||||
writeErrorJSON(w, http.StatusBadRequest, "invalid_release_cleanup_params", "execute cleanup requires plan_id from a dry run")
|
||||
return
|
||||
}
|
||||
result, err := backend.ReleaseCleanup(r.Context(), params)
|
||||
if err != nil {
|
||||
s.writeControlBackendError(w, "release-cleanup", err)
|
||||
return
|
||||
}
|
||||
writeNoStoreJSON(w, http.StatusAccepted, AdminControlResponse{
|
||||
Service: "bat-api",
|
||||
Action: "release-cleanup",
|
||||
RPCMethod: "release.cleanup",
|
||||
Status: "accepted",
|
||||
Result: result,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminDiagnostics(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
|
||||
|
||||
+36
-9
@@ -14,11 +14,6 @@ func (s *Server) serveCDN(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
idx := s.index()
|
||||
if idx == nil || idx.ResourceRoot == "" {
|
||||
http.Error(w, "resource root not ready", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
_, rel, err := SplitCDNPath(r.URL.Path)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
@@ -27,15 +22,47 @@ func (s *Server) serveCDN(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var entry ResourceEntry
|
||||
var hasEntry bool
|
||||
if s.cfg.RequireIndexed {
|
||||
entry, hasEntry = idx.Lookup(rel)
|
||||
resourceRoot := ""
|
||||
explicitRelease := false
|
||||
channel := r.URL.Query().Get("channel")
|
||||
releaseID := r.URL.Query().Get("release_id")
|
||||
if channel != "" || releaseID != "" {
|
||||
explicitRelease = true
|
||||
channel, releaseID, selectorErr := releaseSelector(r)
|
||||
if selectorErr != nil {
|
||||
http.Error(w, selectorErr.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
selected, selectErr := s.loadReleaseDistribution(r, channel, releaseID)
|
||||
if selectErr != nil {
|
||||
http.Error(w, selectErr.Error(), http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
if selected == nil || !selected.Available || selected.ResourceRoot == "" {
|
||||
http.Error(w, "selected release is not distributable", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
resourceRoot = selected.ResourceRoot
|
||||
entry, hasEntry = releaseDistributionEntry(selected, rel)
|
||||
} else {
|
||||
idx := s.index()
|
||||
if idx == nil || idx.ResourceRoot == "" {
|
||||
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)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
abs, err := ResolveUnderRoot(idx.ResourceRoot, rel, true)
|
||||
abs, err := ResolveUnderRoot(resourceRoot, rel, true)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
@@ -45,7 +72,7 @@ func (s *Server) serveCDN(w http.ResponseWriter, r *http.Request) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if s.cfg.RequireIndexed && s.cfg.VerifySize {
|
||||
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
|
||||
|
||||
+73
-1
@@ -79,6 +79,52 @@ paths:
|
||||
responses:
|
||||
"200":
|
||||
description: Release summary.
|
||||
/v1/releases:
|
||||
get:
|
||||
summary: Rust-owned official and localized release history
|
||||
parameters:
|
||||
- name: channel
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
enum: [official, localized]
|
||||
responses:
|
||||
"200":
|
||||
description: Release history and manifest/artifact integrity summaries.
|
||||
"503":
|
||||
description: Rust bat release backend is unavailable.
|
||||
/v1/distribution:
|
||||
get:
|
||||
summary: Select a verified official or localized release for distribution
|
||||
parameters:
|
||||
- name: channel
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
enum: [official, localized]
|
||||
default: official
|
||||
- name: release_id
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
- name: offset
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 0
|
||||
- name: limit
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 1000
|
||||
responses:
|
||||
"200":
|
||||
description: Rust-verified selected release and resource manifest page.
|
||||
"409":
|
||||
description: Selected release is missing, stale, damaged, or not distributable.
|
||||
"503":
|
||||
description: Rust bat release backend is unavailable.
|
||||
/v1/resources:
|
||||
get:
|
||||
summary: Paginated resource manifest entries
|
||||
@@ -551,6 +597,32 @@ paths:
|
||||
description: Missing or invalid admin token.
|
||||
"503":
|
||||
description: Rust bat localized backend is unavailable.
|
||||
/admin/releases/status:
|
||||
get:
|
||||
summary: Read the unified Rust-owned release status view
|
||||
responses:
|
||||
"200":
|
||||
description: Official/localized current relation and integrity status.
|
||||
"401":
|
||||
description: Missing or invalid admin token.
|
||||
"503":
|
||||
description: Rust bat release backend is unavailable.
|
||||
/admin/releases:
|
||||
get:
|
||||
summary: Read Rust-owned historical release summaries
|
||||
parameters:
|
||||
- name: channel
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
enum: [official, localized]
|
||||
responses:
|
||||
"200":
|
||||
description: Historical release summaries.
|
||||
"401":
|
||||
description: Missing or invalid admin token.
|
||||
"503":
|
||||
description: Rust bat release backend is unavailable.
|
||||
/admin/control/{action}:
|
||||
post:
|
||||
summary: Forward an allowlisted control or schedule action to Rust bat
|
||||
@@ -560,7 +632,7 @@ paths:
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
enum: [reload, refresh, restart, sync, verify, repair, catalog-refresh, schedule-add, schedule-update, schedule-remove, schedule-run, task-cancel, translation-task-update, translation-worker-run, translation-proofread, translation-memory-confirm, translation-glossary-add, translation-glossary-update, translation-glossary-approve, translation-glossary-deprecate, translation-glossary-delete, localized-publish, localized-rollback]
|
||||
enum: [reload, refresh, restart, sync, verify, repair, catalog-refresh, schedule-add, schedule-update, schedule-remove, schedule-run, task-cancel, translation-task-update, translation-worker-run, translation-proofread, translation-memory-confirm, translation-glossary-add, translation-glossary-update, translation-glossary-approve, translation-glossary-deprecate, translation-glossary-delete, localized-publish, localized-rollback, release-cleanup]
|
||||
requestBody:
|
||||
required: false
|
||||
content:
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"bat-api/internal/backendrpc"
|
||||
)
|
||||
|
||||
func (s *Server) handleReleaseList(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
|
||||
return
|
||||
}
|
||||
backend, ok := s.backend.(ReleaseBackend)
|
||||
if !ok || backend == nil {
|
||||
writeErrorJSON(w, http.StatusServiceUnavailable, "release_backend_unavailable", "Rust bat release backend is unavailable")
|
||||
return
|
||||
}
|
||||
channel := r.URL.Query().Get("channel")
|
||||
result, err := backend.ReleaseList(r.Context(), backendrpc.ReleaseListParams{Channel: channel})
|
||||
if err != nil {
|
||||
s.writeControlBackendError(w, "release-list", err)
|
||||
return
|
||||
}
|
||||
writeNoStoreJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (s *Server) handleReleaseDistribution(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
|
||||
return
|
||||
}
|
||||
channel, releaseID, err := releaseSelector(r)
|
||||
if err != nil {
|
||||
writeErrorJSON(w, http.StatusBadRequest, "invalid_release_selector", err.Error())
|
||||
return
|
||||
}
|
||||
params, err := releaseDistributionParams(r, channel, releaseID)
|
||||
if err != nil {
|
||||
writeErrorJSON(w, http.StatusBadRequest, "invalid_release_query", err.Error())
|
||||
return
|
||||
}
|
||||
page, err := s.requestReleaseDistribution(r, params)
|
||||
if err != nil {
|
||||
writeErrorJSON(w, http.StatusServiceUnavailable, "release_backend_unavailable", err.Error())
|
||||
return
|
||||
}
|
||||
if page == nil {
|
||||
writeErrorJSON(w, http.StatusServiceUnavailable, "release_backend_unavailable", "Rust release distribution returned no result")
|
||||
return
|
||||
}
|
||||
status := http.StatusOK
|
||||
if !page.Available {
|
||||
status = http.StatusConflict
|
||||
}
|
||||
writeNoStoreJSON(w, status, page)
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminReleaseStatus(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
|
||||
return
|
||||
}
|
||||
if !s.requireAdminToken(w, r) {
|
||||
return
|
||||
}
|
||||
backend, ok := s.backend.(ReleaseBackend)
|
||||
if !ok || backend == nil {
|
||||
writeErrorJSON(w, http.StatusServiceUnavailable, "release_backend_unavailable", "Rust bat release backend is unavailable")
|
||||
return
|
||||
}
|
||||
result, err := backend.ReleaseStatus(r.Context())
|
||||
if err != nil {
|
||||
s.writeControlBackendError(w, "release-status", err)
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodHead {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
writeNoStoreJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminReleaseList(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
|
||||
return
|
||||
}
|
||||
if !s.requireAdminToken(w, r) {
|
||||
return
|
||||
}
|
||||
backend, ok := s.backend.(ReleaseBackend)
|
||||
if !ok || backend == nil {
|
||||
writeErrorJSON(w, http.StatusServiceUnavailable, "release_backend_unavailable", "Rust bat release backend is unavailable")
|
||||
return
|
||||
}
|
||||
result, err := backend.ReleaseList(r.Context(), backendrpc.ReleaseListParams{
|
||||
Channel: r.URL.Query().Get("channel"),
|
||||
})
|
||||
if err != nil {
|
||||
s.writeControlBackendError(w, "release-list", err)
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodHead {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
writeNoStoreJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func releaseSelector(r *http.Request) (string, string, error) {
|
||||
channel := strings.TrimSpace(r.URL.Query().Get("channel"))
|
||||
releaseID := strings.TrimSpace(r.URL.Query().Get("release_id"))
|
||||
if channel == "" {
|
||||
channel = "official"
|
||||
}
|
||||
if channel != "official" && channel != "localized" {
|
||||
return "", "", &releaseSelectorError{message: "channel must be official or localized"}
|
||||
}
|
||||
if releaseID == "." || releaseID == ".." ||
|
||||
strings.Contains(releaseID, "/") ||
|
||||
strings.Contains(releaseID, "\\") ||
|
||||
strings.Contains(releaseID, ":") ||
|
||||
strings.ContainsRune(releaseID, 0) {
|
||||
return "", "", &releaseSelectorError{message: "release_id contains an unsafe path character"}
|
||||
}
|
||||
return channel, releaseID, nil
|
||||
}
|
||||
|
||||
type releaseSelectorError struct {
|
||||
message string
|
||||
}
|
||||
|
||||
func (e *releaseSelectorError) Error() string {
|
||||
return e.message
|
||||
}
|
||||
|
||||
func (s *Server) loadReleaseDistribution(r *http.Request, channel, releaseID string) (*backendrpc.ReleaseDistributionPage, error) {
|
||||
return s.loadReleaseDistributionFrom(r, backendrpc.ReleaseDistributionParams{
|
||||
Channel: channel,
|
||||
ReleaseID: releaseID,
|
||||
Offset: 0,
|
||||
Limit: 1000,
|
||||
})
|
||||
}
|
||||
|
||||
func releaseDistributionParams(r *http.Request, channel, releaseID string) (backendrpc.ReleaseDistributionParams, error) {
|
||||
params := backendrpc.ReleaseDistributionParams{
|
||||
Channel: channel,
|
||||
ReleaseID: releaseID,
|
||||
}
|
||||
query := r.URL.Query()
|
||||
if raw := strings.TrimSpace(query.Get("offset")); raw != "" {
|
||||
offset, err := strconv.ParseUint(raw, 10, 64)
|
||||
if err != nil || uint64(int(^uint(0)>>1)) < offset {
|
||||
return backendrpc.ReleaseDistributionParams{}, &releaseSelectorError{
|
||||
message: "offset must be a non-negative integer",
|
||||
}
|
||||
}
|
||||
params.Offset = int(offset)
|
||||
}
|
||||
if raw := strings.TrimSpace(query.Get("limit")); raw != "" {
|
||||
limit, err := strconv.ParseUint(raw, 10, 64)
|
||||
if err != nil || limit == 0 || limit > 1000 {
|
||||
return backendrpc.ReleaseDistributionParams{}, &releaseSelectorError{
|
||||
message: "limit must be in 1..=1000",
|
||||
}
|
||||
}
|
||||
params.Limit = int(limit)
|
||||
}
|
||||
return params, nil
|
||||
}
|
||||
|
||||
func (s *Server) requestReleaseDistribution(r *http.Request, params backendrpc.ReleaseDistributionParams) (*backendrpc.ReleaseDistributionPage, error) {
|
||||
backend, ok := s.backend.(ReleaseBackend)
|
||||
if !ok || backend == nil {
|
||||
return nil, &releaseSelectorError{message: "Rust bat release backend is unavailable"}
|
||||
}
|
||||
return backend.ReleaseDistribution(r.Context(), params)
|
||||
}
|
||||
|
||||
func (s *Server) loadReleaseDistributionFrom(r *http.Request, params backendrpc.ReleaseDistributionParams) (*backendrpc.ReleaseDistributionPage, error) {
|
||||
backend, ok := s.backend.(ReleaseBackend)
|
||||
if !ok || backend == nil {
|
||||
return nil, &releaseSelectorError{message: "Rust bat release backend is unavailable"}
|
||||
}
|
||||
pageSize := 1000
|
||||
result, err := backend.ReleaseDistribution(r.Context(), params)
|
||||
if err != nil || result == nil || !result.Available || result.Total <= len(result.Entries) {
|
||||
return result, err
|
||||
}
|
||||
all := append([]backendrpc.ReleaseDistributionEntry(nil), result.Entries...)
|
||||
for offset := len(all); offset < result.Total; {
|
||||
next, nextErr := backend.ReleaseDistribution(r.Context(), backendrpc.ReleaseDistributionParams{
|
||||
Channel: params.Channel,
|
||||
ReleaseID: params.ReleaseID,
|
||||
Offset: offset,
|
||||
Limit: pageSize,
|
||||
})
|
||||
if nextErr != nil {
|
||||
return nil, nextErr
|
||||
}
|
||||
if next == nil || !next.Available || len(next.Entries) == 0 {
|
||||
return nil, &releaseSelectorError{message: "Rust release distribution page is incomplete"}
|
||||
}
|
||||
all = append(all, next.Entries...)
|
||||
offset = len(all)
|
||||
if len(all) > result.Total {
|
||||
all = all[:result.Total]
|
||||
break
|
||||
}
|
||||
}
|
||||
result.Entries = all
|
||||
result.Offset = 0
|
||||
result.Limit = len(all)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func releaseDistributionEntry(page *backendrpc.ReleaseDistributionPage, rel string) (ResourceEntry, bool) {
|
||||
rel = strings.TrimPrefix(strings.ReplaceAll(rel, "\\", "/"), "/")
|
||||
for _, entry := range page.Entries {
|
||||
destination := strings.TrimPrefix(strings.ReplaceAll(entry.Destination, "\\", "/"), "/")
|
||||
if destination == rel {
|
||||
return ResourceEntry{
|
||||
URL: entry.URL,
|
||||
RelativePath: destination,
|
||||
Bytes: entry.Bytes,
|
||||
BLAKE3: entry.BLAKE3,
|
||||
Present: true,
|
||||
SizeMatch: true,
|
||||
}, true
|
||||
}
|
||||
}
|
||||
return ResourceEntry{}, false
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"bat-api/internal/backendrpc"
|
||||
)
|
||||
|
||||
type releaseBackendStub struct {
|
||||
*fakeBackend
|
||||
root string
|
||||
available bool
|
||||
distributionParams []backendrpc.ReleaseDistributionParams
|
||||
}
|
||||
|
||||
func (b *controlBackend) ReleaseStatus(context.Context) (*backendrpc.ReleaseStatusReport, error) {
|
||||
b.calls = append(b.calls, "release.status")
|
||||
return &backendrpc.ReleaseStatusReport{Status: "ready", StatusCode: "distribution.ready"}, nil
|
||||
}
|
||||
|
||||
func (b *controlBackend) ReleaseList(context.Context, backendrpc.ReleaseListParams) (*backendrpc.ReleaseListReport, error) {
|
||||
b.calls = append(b.calls, "release.list")
|
||||
return &backendrpc.ReleaseListReport{Status: "ready", StatusCode: "distribution.ready"}, nil
|
||||
}
|
||||
|
||||
func (b *controlBackend) ReleaseDistribution(context.Context, backendrpc.ReleaseDistributionParams) (*backendrpc.ReleaseDistributionPage, error) {
|
||||
b.calls = append(b.calls, "release.distribution")
|
||||
return &backendrpc.ReleaseDistributionPage{Available: false, StatusCode: "distribution.blocked"}, nil
|
||||
}
|
||||
|
||||
func (b *controlBackend) ReleaseCleanup(context.Context, backendrpc.ReleaseCleanupParams) (*backendrpc.ReleaseCleanupReport, error) {
|
||||
b.calls = append(b.calls, "release.cleanup")
|
||||
return &backendrpc.ReleaseCleanupReport{PlanID: "plan-1"}, nil
|
||||
}
|
||||
|
||||
func (b *releaseBackendStub) ReleaseStatus(context.Context) (*backendrpc.ReleaseStatusReport, error) {
|
||||
return &backendrpc.ReleaseStatusReport{
|
||||
Status: "ready",
|
||||
StatusCode: "distribution.ready",
|
||||
OfficialCurrentReleaseID: "official-1",
|
||||
DefaultDistributionChannel: "official",
|
||||
OfficialDistributionReady: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (b *releaseBackendStub) ReleaseList(context.Context, backendrpc.ReleaseListParams) (*backendrpc.ReleaseListReport, error) {
|
||||
return &backendrpc.ReleaseListReport{
|
||||
Status: "ready",
|
||||
StatusCode: "distribution.ready",
|
||||
Releases: []backendrpc.ReleaseSummary{{
|
||||
Channel: "localized",
|
||||
ID: "localized-1",
|
||||
ManifestContractStatus: "valid",
|
||||
ArtifactIntegrityStatus: "valid",
|
||||
DistributionIntegrityStatus: "valid",
|
||||
}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (b *releaseBackendStub) ReleaseDistribution(_ context.Context, params backendrpc.ReleaseDistributionParams) (*backendrpc.ReleaseDistributionPage, error) {
|
||||
b.distributionParams = append(b.distributionParams, params)
|
||||
return &backendrpc.ReleaseDistributionPage{
|
||||
Available: b.available,
|
||||
Channel: params.Channel,
|
||||
ReleaseID: params.ReleaseID,
|
||||
ResourceRoot: b.root,
|
||||
Status: "ready",
|
||||
StatusCode: "distribution.ready",
|
||||
ArtifactIntegrityStatus: "valid",
|
||||
Total: 1,
|
||||
Limit: 1000,
|
||||
Entries: []backendrpc.ReleaseDistributionEntry{{
|
||||
URL: "https://prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes",
|
||||
Destination: "prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes",
|
||||
Bytes: 21,
|
||||
BLAKE3: "not-used-by-http-index",
|
||||
}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (*releaseBackendStub) ReleaseCleanup(context.Context, backendrpc.ReleaseCleanupParams) (*backendrpc.ReleaseCleanupReport, error) {
|
||||
return &backendrpc.ReleaseCleanupReport{PlanID: "plan-1"}, nil
|
||||
}
|
||||
|
||||
func TestReleaseHTTPForwardsTypedSelectionAndDoesNotFallback(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
if err := cfg.Normalize(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
backend := &releaseBackendStub{fakeBackend: &fakeBackend{}, root: fixtureRoot(t), available: true}
|
||||
server := NewServer(cfg, backend, nil)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/v1/releases?channel=localized", nil))
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("release list status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
|
||||
recorder = httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/v1/distribution?channel=localized&release_id=localized-1&offset=2&limit=10", nil))
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("distribution status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if len(backend.distributionParams) != 1 ||
|
||||
backend.distributionParams[0].Channel != "localized" ||
|
||||
backend.distributionParams[0].ReleaseID != "localized-1" ||
|
||||
backend.distributionParams[0].Offset != 2 ||
|
||||
backend.distributionParams[0].Limit != 10 {
|
||||
t.Fatalf("distribution params=%#v", backend.distributionParams)
|
||||
}
|
||||
|
||||
recorder = httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes?channel=localized&release_id=localized-1", nil))
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("localized CDN status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if recorder.Body.String() != "TABLE_CATALOG_FIXTURE" {
|
||||
t.Fatalf("localized CDN body=%q", recorder.Body.String())
|
||||
}
|
||||
|
||||
cfg.RequireIndexed = false
|
||||
unindexedServer := NewServer(cfg, backend, nil)
|
||||
recorder = httptest.NewRecorder()
|
||||
unindexedServer.Handler().ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/not-listed.bytes?channel=localized&release_id=localized-1", nil))
|
||||
if recorder.Code != http.StatusNotFound {
|
||||
t.Fatalf("unlisted localized CDN status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
|
||||
blocked := &releaseBackendStub{fakeBackend: &fakeBackend{}, root: fixtureRoot(t), available: false}
|
||||
blockedServer := NewServer(cfg, blocked, nil)
|
||||
recorder = httptest.NewRecorder()
|
||||
blockedServer.Handler().ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes?channel=localized", nil))
|
||||
if recorder.Code != http.StatusConflict {
|
||||
t.Fatalf("blocked localized CDN status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminReleaseCleanupRequiresAuthAndForwards(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
cfg.AuthToken = "control-token"
|
||||
if err := cfg.Normalize(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
backend := &controlBackend{fakeBackend: &fakeBackend{}}
|
||||
server := NewServer(cfg, backend, nil)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/admin/control/release-cleanup", nil))
|
||||
if recorder.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("unauthenticated cleanup status=%d", recorder.Code)
|
||||
}
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/admin/control/release-cleanup", strings.NewReader(`{"execute":false}`))
|
||||
request.Header.Set("Authorization", "Bearer control-token")
|
||||
recorder = httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusAccepted {
|
||||
t.Fatalf("dry-run cleanup status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if len(backend.calls) != 1 || backend.calls[0] != "release.cleanup" {
|
||||
t.Fatalf("calls=%v", backend.calls)
|
||||
}
|
||||
|
||||
request = httptest.NewRequest(http.MethodPost, "/admin/control/release-cleanup", strings.NewReader(`{"execute":true}`))
|
||||
request.Header.Set("Authorization", "Bearer control-token")
|
||||
recorder = httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusBadRequest {
|
||||
t.Fatalf("missing plan cleanup status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -113,6 +113,15 @@ type LocalizedBackend interface {
|
||||
LocalizedRollback(ctx context.Context, params backendrpc.LocalizedRollbackParams) (json.RawMessage, 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)
|
||||
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)
|
||||
}
|
||||
|
||||
// RPCClient adapts *backendrpc.Client to Backend.
|
||||
type RPCClient struct {
|
||||
Client *backendrpc.Client
|
||||
@@ -253,6 +262,22 @@ func (r RPCClient) LocalizedRollback(ctx context.Context, params backendrpc.Loca
|
||||
return r.Client.LocalizedRollback(ctx, params)
|
||||
}
|
||||
|
||||
func (r RPCClient) ReleaseStatus(ctx context.Context) (*backendrpc.ReleaseStatusReport, error) {
|
||||
return r.Client.ReleaseStatus(ctx)
|
||||
}
|
||||
|
||||
func (r RPCClient) ReleaseList(ctx context.Context, params backendrpc.ReleaseListParams) (*backendrpc.ReleaseListReport, error) {
|
||||
return r.Client.ReleaseList(ctx, params)
|
||||
}
|
||||
|
||||
func (r RPCClient) ReleaseDistribution(ctx context.Context, params backendrpc.ReleaseDistributionParams) (*backendrpc.ReleaseDistributionPage, error) {
|
||||
return r.Client.ReleaseDistribution(ctx, params)
|
||||
}
|
||||
|
||||
func (r RPCClient) ReleaseCleanup(ctx context.Context, params backendrpc.ReleaseCleanupParams) (*backendrpc.ReleaseCleanupReport, error) {
|
||||
return r.Client.ReleaseCleanup(ctx, params)
|
||||
}
|
||||
|
||||
func (r RPCClient) ParseStatus(ctx context.Context) (json.RawMessage, error) {
|
||||
return r.Client.ParseStatus(ctx)
|
||||
}
|
||||
|
||||
@@ -51,6 +51,8 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("/v1/bootstrap", s.handleBootstrap)
|
||||
mux.HandleFunc("/v1/launcher/bootstrap", s.handleLauncherBootstrap)
|
||||
mux.HandleFunc("/v1/release", s.handleRelease)
|
||||
mux.HandleFunc("/v1/releases", s.handleReleaseList)
|
||||
mux.HandleFunc("/v1/distribution", s.handleReleaseDistribution)
|
||||
mux.HandleFunc("/v1/resources", s.handleResources)
|
||||
mux.HandleFunc("/v1/server-info", s.handleServerInfoDebug)
|
||||
mux.HandleFunc("/api/launcher/game/config", s.handleLauncherGameConfig)
|
||||
@@ -80,6 +82,8 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("/admin/translation/glossary/query", s.handleAdminGlossaryQuery)
|
||||
mux.HandleFunc("/admin/translation/glossary/diagnose", s.handleAdminGlossaryDiagnose)
|
||||
mux.HandleFunc("/admin/translation/status", s.handleAdminLocalizedStatus)
|
||||
mux.HandleFunc("/admin/releases/status", s.handleAdminReleaseStatus)
|
||||
mux.HandleFunc("/admin/releases", s.handleAdminReleaseList)
|
||||
mux.HandleFunc("/admin/control/", s.handleAdminControl)
|
||||
mux.HandleFunc("/admin/", s.handleAdminIndex)
|
||||
mux.HandleFunc("/"+ServerInfoHost+"/", s.handleServerInfoCDN)
|
||||
@@ -132,6 +136,8 @@ func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) {
|
||||
"/v1/bootstrap",
|
||||
"/v1/launcher/bootstrap",
|
||||
"/v1/release",
|
||||
"/v1/releases",
|
||||
"/v1/distribution",
|
||||
"/v1/resources",
|
||||
"/v1/server-info",
|
||||
"/api/launcher/game/config",
|
||||
@@ -162,6 +168,8 @@ func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) {
|
||||
"/admin/translation/glossary/query",
|
||||
"/admin/translation/glossary/diagnose",
|
||||
"/admin/translation/status",
|
||||
"/admin/releases/status",
|
||||
"/admin/releases",
|
||||
"/admin/control/{action}",
|
||||
},
|
||||
})
|
||||
|
||||
@@ -182,6 +182,117 @@ type pageParam struct {
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
|
||||
// ReleaseListParams selects one Rust-owned release namespace.
|
||||
type ReleaseListParams struct {
|
||||
Channel string `json:"channel,omitempty"`
|
||||
}
|
||||
|
||||
// ReleaseDistributionParams selects a verified release for distribution.
|
||||
type ReleaseDistributionParams struct {
|
||||
Channel string `json:"channel,omitempty"`
|
||||
ReleaseID string `json:"release_id,omitempty"`
|
||||
Offset int `json:"offset,omitempty"`
|
||||
Limit int `json:"limit,omitempty"`
|
||||
}
|
||||
|
||||
// ReleaseCleanupParams controls the dry-run/execute cleanup pair.
|
||||
type ReleaseCleanupParams struct {
|
||||
Execute bool `json:"execute,omitempty"`
|
||||
PlanID string `json:"plan_id,omitempty"`
|
||||
}
|
||||
|
||||
// ReleaseSummary mirrors Rust's dual-release historical summary.
|
||||
type ReleaseSummary struct {
|
||||
Channel string `json:"channel"`
|
||||
ID string `json:"id"`
|
||||
Path string `json:"path"`
|
||||
SourceOfficialReleaseID string `json:"source_official_release_id,omitempty"`
|
||||
CreatedUnixSeconds *uint64 `json:"created_unix_seconds,omitempty"`
|
||||
PublishedUnixSeconds *uint64 `json:"published_unix_seconds,omitempty"`
|
||||
Current bool `json:"current"`
|
||||
CurrentPointerValid bool `json:"current_pointer_valid"`
|
||||
RollbackAvailable bool `json:"rollback_available"`
|
||||
Stale bool `json:"stale"`
|
||||
Damaged bool `json:"damaged"`
|
||||
Referenced bool `json:"referenced"`
|
||||
Unknown bool `json:"unknown"`
|
||||
Lifecycle string `json:"lifecycle"`
|
||||
ManifestContractStatus string `json:"manifest_contract_status"`
|
||||
ArtifactIntegrityStatus string `json:"artifact_integrity_status"`
|
||||
DistributionIntegrityStatus string `json:"distribution_integrity_status"`
|
||||
Legacy bool `json:"legacy"`
|
||||
RollbackPreviousReleaseID string `json:"rollback_previous_release_id,omitempty"`
|
||||
Diagnostics []string `json:"diagnostics,omitempty"`
|
||||
}
|
||||
|
||||
// ReleaseStatusReport is the unified official/localized release view.
|
||||
type ReleaseStatusReport struct {
|
||||
Status string `json:"status"`
|
||||
StatusCode string `json:"status_code"`
|
||||
OfficialCurrentReleaseID string `json:"official_current_release_id,omitempty"`
|
||||
LocalizedCurrentReleaseID string `json:"localized_current_release_id,omitempty"`
|
||||
LocalizedSourceOfficialID string `json:"localized_source_official_release_id,omitempty"`
|
||||
CurrentReleasesMatch bool `json:"current_releases_match"`
|
||||
DefaultDistributionChannel string `json:"default_distribution_channel"`
|
||||
OfficialDistributionReady bool `json:"official_distribution_ready"`
|
||||
LocalizedDistributionReady bool `json:"localized_distribution_ready"`
|
||||
Releases []ReleaseSummary `json:"releases"`
|
||||
}
|
||||
|
||||
// ReleaseListReport is the filtered historical release response.
|
||||
type ReleaseListReport struct {
|
||||
Status string `json:"status"`
|
||||
StatusCode string `json:"status_code"`
|
||||
Channel string `json:"channel,omitempty"`
|
||||
Releases []ReleaseSummary `json:"releases"`
|
||||
}
|
||||
|
||||
// ReleaseDistributionEntry is one Rust-verified resource manifest entry.
|
||||
type ReleaseDistributionEntry struct {
|
||||
URL string `json:"url"`
|
||||
Destination string `json:"destination"`
|
||||
Bytes uint64 `json:"bytes"`
|
||||
BLAKE3 string `json:"blake3"`
|
||||
}
|
||||
|
||||
// ReleaseDistributionPage is a typed page for one selected release.
|
||||
type ReleaseDistributionPage struct {
|
||||
Available bool `json:"available"`
|
||||
Channel string `json:"channel"`
|
||||
ReleaseID string `json:"release_id,omitempty"`
|
||||
ResourceRoot string `json:"resource_root,omitempty"`
|
||||
SourceOfficialReleaseID string `json:"source_official_release_id,omitempty"`
|
||||
Current bool `json:"current"`
|
||||
Status string `json:"status"`
|
||||
StatusCode string `json:"status_code"`
|
||||
ArtifactIntegrityStatus string `json:"artifact_integrity_status"`
|
||||
Total int `json:"total"`
|
||||
Offset int `json:"offset"`
|
||||
Limit int `json:"limit"`
|
||||
Entries []ReleaseDistributionEntry `json:"entries"`
|
||||
Diagnostics []string `json:"diagnostics,omitempty"`
|
||||
}
|
||||
|
||||
// ReleaseCleanupEntry is one retained or removable cleanup observation.
|
||||
type ReleaseCleanupEntry struct {
|
||||
Channel string `json:"channel"`
|
||||
ID string `json:"id"`
|
||||
Path string `json:"path"`
|
||||
Candidate bool `json:"candidate"`
|
||||
RetainReasons []string `json:"retain_reasons,omitempty"`
|
||||
BlockingReferences []string `json:"blocking_references,omitempty"`
|
||||
}
|
||||
|
||||
// ReleaseCleanupReport is the dry-run or execute result.
|
||||
type ReleaseCleanupReport struct {
|
||||
Execute bool `json:"execute"`
|
||||
PlanID string `json:"plan_id"`
|
||||
Revalidated bool `json:"revalidated"`
|
||||
Entries []ReleaseCleanupEntry `json:"entries"`
|
||||
Removed []string `json:"removed"`
|
||||
Diagnostics []string `json:"diagnostics,omitempty"`
|
||||
}
|
||||
|
||||
type tailParam struct {
|
||||
Tail int `json:"tail"`
|
||||
}
|
||||
@@ -981,6 +1092,30 @@ func (c *Client) CatalogDiff(ctx context.Context) (json.RawMessage, error) {
|
||||
return c.rawData(ctx, "catalog.diff", nil)
|
||||
}
|
||||
|
||||
func (c *Client) ReleaseStatus(ctx context.Context) (*ReleaseStatusReport, error) {
|
||||
var out ReleaseStatusReport
|
||||
_, err := c.Call(ctx, "release.status", nil, &out)
|
||||
return &out, err
|
||||
}
|
||||
|
||||
func (c *Client) ReleaseList(ctx context.Context, params ReleaseListParams) (*ReleaseListReport, error) {
|
||||
var out ReleaseListReport
|
||||
_, err := c.Call(ctx, "release.list", params, &out)
|
||||
return &out, err
|
||||
}
|
||||
|
||||
func (c *Client) ReleaseDistribution(ctx context.Context, params ReleaseDistributionParams) (*ReleaseDistributionPage, error) {
|
||||
var out ReleaseDistributionPage
|
||||
_, err := c.Call(ctx, "release.distribution", params, &out)
|
||||
return &out, err
|
||||
}
|
||||
|
||||
func (c *Client) ReleaseCleanup(ctx context.Context, params ReleaseCleanupParams) (*ReleaseCleanupReport, error) {
|
||||
var out ReleaseCleanupReport
|
||||
_, err := c.Call(ctx, "release.cleanup", params, &out)
|
||||
return &out, err
|
||||
}
|
||||
|
||||
func (c *Client) CatalogRefresh(ctx context.Context, force bool) (*TaskAccepted, error) {
|
||||
var out TaskAccepted
|
||||
_, err := c.Call(ctx, "catalog.refresh", boolParam{Force: force}, &out)
|
||||
|
||||
@@ -88,6 +88,55 @@ func TestResourceRepairQueuesTask(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseDistributionUsesTypedRPCContract(t *testing.T) {
|
||||
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
|
||||
if req.Method != "release.distribution" {
|
||||
t.Fatalf("method = %s", req.Method)
|
||||
}
|
||||
var params ReleaseDistributionParams
|
||||
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
||||
t.Fatalf("decode params: %v", err)
|
||||
}
|
||||
if params.Channel != "localized" || params.ReleaseID != "localized-1" || params.Offset != 2 || params.Limit != 10 {
|
||||
t.Fatalf("params = %#v", params)
|
||||
}
|
||||
return testResponse{
|
||||
Result: testEnvelope{
|
||||
OK: true,
|
||||
Status: "ok",
|
||||
Data: map[string]any{
|
||||
"available": true,
|
||||
"channel": "localized",
|
||||
"release_id": "localized-1",
|
||||
"resource_root": "/tmp/localized",
|
||||
"total": 3,
|
||||
"offset": 2,
|
||||
"limit": 10,
|
||||
"entries": []any{map[string]any{
|
||||
"url": "https://example.invalid/data.bin",
|
||||
"destination": "host/data.bin",
|
||||
"bytes": 4,
|
||||
"blake3": "abcd",
|
||||
}},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
page, err := client.ReleaseDistribution(context.Background(), ReleaseDistributionParams{
|
||||
Channel: "localized",
|
||||
ReleaseID: "localized-1",
|
||||
Offset: 2,
|
||||
Limit: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ReleaseDistribution error: %v", err)
|
||||
}
|
||||
if !page.Available || page.ResourceRoot != "/tmp/localized" || len(page.Entries) != 1 {
|
||||
t.Fatalf("page = %#v", page)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaemonRestartSendsControlMethod(t *testing.T) {
|
||||
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
|
||||
if req.Method != "daemon.restart" {
|
||||
|
||||
Reference in New Issue
Block a user