Merge origin/scheduler-framework into merge-sf-20260903 (97 commits on ours: bug-1490/1494/1498/1509/1513/1521/1522 fixes, PEND-reason P1, scheduler-agent Phase 2, bug-924 owner-pid shim). Bookkeeping excluded per policy: .kiro/, .wolf/, CHANGES.md, reports/ (92 files dropped); .claude/ + CLAUDE.md keep ours. Key theirs-side changes absorbed: - bug-1938/1960: QUEUE_STAT_CLOSED no longer gates dispatch (closed queue refuses new submissions only; pending jobs still run) -- supersedes our QUEUE_CLOSED preason on the dispatch path; sched_queue_status_reason drops that branch - bug-1846: config push now stashes and applies between scheduling rounds (sched_stash_config/sched_apply_pending_config), applied at the same main-thread round boundary that preserves bug-1513 serialization - bug-1760: sched_check_pending_rejoin exposed + xcecli_is_connection_broken public predicate - bug-1936: execuser/exechome/execcwd/exdata carried on submission (superset of our bug-1500 execuser fallback; our job-typed resolveExecUser removed as dead code) - bug-1945~1962: apiserver auth hardening (HS256 pinning, default- secret rejection, logout revocation, bcrypt, RBAC) -- our API-key branch folded into controllers.ParseTokenClaims so both coexist - bug-1736: rawRunUsage job run-usage fields in the Go model - xce_test relocated src/controller/testsuite/test.c -> src/harness/test.c; controller suite split into controller_test.c + fixtures; their clitest fixtures spawn xce_test mid-suite under `meson test -j` Ours preserved across the merge: - PEND-reason P1 (codes 1-9, preason.h, job_preason machinery) -- gate re-ported into their relocated allocator (sched_election.c) after plugin_job_election, with attach on the allocator thread only (bug-1521 race discipline) - PEND-reason P0 main_reason semantics RECONCILED to theirs: the merge initially re-carried our first-detail-add-promotes-main_reason fallback in add_job_preason (4e2ac86), but their whole trace/verify stack treats "detail-only => main_reason 0" as a contract -- sched_trace_capture counts main_reason as its own occurrence ("Expected 2 Was 3" in test_trace_capture), the outcome renderer prints "main reason: none" for detail-only PENDs (test_repl_trace_e2e), and the committed .replay/.sim_verify baselines encode main_reason=0 (test_repl_trace_baseline byte compare, test_trace_equivalence off-vs-traced). The fallback is dropped from the shared lib; the user-visible "overall reason" survives as an API-layer view: rawJobToModel (convert_pure.go) promotes the first detail row to main_reason_name when main_reason is 0 (main_reason VALUE marshals unchanged). add_job_preason keeps only the NULL-reasons guard - bug-1498: sched_job_is_ready rejects non-PEND/suspended jobs - bug-1490/1781: xce_jobctrl_t.action as capnp union discriminant (their jobCtrlUnionDiscriminant, same semantics) - xcecli typed PendingReason field (53c00834), bug-1522 reconnect resend, xcecli_force_reconnect + their is_connection_broken both exported in client.h/libclient - t1/t2/t3 test-node hostconfig in src/configs/hosts.yaml (bug-1494), /xce/v1/events + /xce/v1/apikeys whitelist routes (agent Phase 2) - lib ASAN_OPTIONS=detect_leaks=0 test env + their oom-test block - drop-audit: our bug-1504 fix covered by their bug-1680 (no action) bug-924 follow-up (replaces the67f3f6adscratch-dir wipe): the take-theirs audit's one "drop" was re-evaluated and REVERTED instead of re-applied -- a global `find /tmp -name 'xce_*' -exec rm -rf` at suite start/exit races the LIVE /tmp/xce_* fixtures of sibling suites under `meson test -j` (harness_unit_test's clitest fixtures spawn xce_test as a child mid-suite; the wipe destroyed the parent's fixtures -> test_test_cli failures). Coverage instead: - mkdtemp_clean shim gains mkdtemp_clean_mark/sweep_since (testsuite_support/mkdtemp_clean.[ch]) - new case_scratch_sweep.[ch]: aggregate runners (controller_test.c, harness_test.c -- hundreds of cases per process) call case_scratch_sweep_begin/_end_ok from setUp/tearDown; a PASSED case's dirs are swept immediately in tearDown (after engine reaping), FAILED cases keep their dirs for post-mortem; XCE_MKDTEMP_KEEP_ALL=1 disables. One full controller_unit_test run was observed to leak 12G+ to ENOSPC without this, cascading into unrelated "Expected Non-NULL" failures - harness_unit_test_deps links the shim library as well Test reconciliations on adopt: - core-ready-gates: sched_queue_is_ready now gates only INACT_ADMIN|INACT_WINDOW (bug-1938/1960); CLOSED queues elect -- covered end-to-end by test_queue_admin_control.c - test_worker_resync: the 4 restart-replay RUN assertions predate the bug-1509 B3 marking (8858afed) landing on the same lineage; replayed RUN jobs now assert RUN|UNKWN|TRANSIENT (TWR_ASSERT_REPLAYED_RUN); terminal DONE/EXIT assertions unchanged - convert_pure.go: our job-typed resolveExecUser deleted (superseded by their bug-1780/1936 two-arg version) - test_preason: the 3 P0-promotion assertions reverted to the detail-keeps-main-0 contract; new tests cover limit-code detail rows and explicit-main-wins-over-later-details; apiserver convert_test.go gains the detail-only name-promotion cases Co-Authored-By: Claude Code <noreply@anthropic.com>
154 lines
4.1 KiB
Go
154 lines
4.1 KiB
Go
package middleware
|
|
|
|
import (
|
|
"apiserver/common"
|
|
"apiserver/controllers"
|
|
"apiserver/models"
|
|
"apiserver/store"
|
|
logger "apiserver/utils"
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
func ValidateHandler() gin.HandlerFunc {
|
|
logger.Debug("Enter ValidateHandler() function")
|
|
return func(ctx *gin.Context) {
|
|
if isExcludedPath(ctx.Request.URL.Path) {
|
|
ctx.Next()
|
|
return
|
|
}
|
|
|
|
validateHeader()(ctx)
|
|
if ctx.IsAborted() {
|
|
return
|
|
}
|
|
flag := !strings.Contains("multipart/form-data", ctx.Request.Header.Get("Content-Type"))
|
|
if ctx.Request.Method == http.MethodPost && flag == false {
|
|
isValidBody, err := validateRequestBody(ctx)
|
|
if err != nil || !isValidBody {
|
|
controllers.ReturnError(ctx, common.INVALID_PARAMS_CODE,
|
|
common.ERROR_READ_REQUEST_BODY_MSG+": "+err.Error(), nil)
|
|
ctx.Abort()
|
|
return
|
|
}
|
|
}
|
|
if ctx.IsAborted() {
|
|
return
|
|
}
|
|
ctx.Next()
|
|
}
|
|
}
|
|
|
|
// jwtExcludedPaths are the routes that skip JWT validation. An entry is
|
|
// matched exactly, unless its last segment is a gin catch-all ("*name", as in
|
|
// the route "/swagger/*any"): then any path under the part before the "*"
|
|
// matches (bug-1962).
|
|
var jwtExcludedPaths = []string{
|
|
"/ping",
|
|
"/xce/v1/auth/logon",
|
|
"/swagger/*any",
|
|
"/xce/v1/auth/logout",
|
|
}
|
|
|
|
func isExcludedPath(path string) bool {
|
|
for _, excludedPath := range jwtExcludedPaths {
|
|
if i := strings.LastIndex(excludedPath, "/*"); i >= 0 {
|
|
if strings.HasPrefix(path, excludedPath[:i+1]) {
|
|
return true
|
|
}
|
|
continue
|
|
}
|
|
if path == excludedPath {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Token validation middleware
|
|
func validateHeader() gin.HandlerFunc {
|
|
logger.Debug("Enter validateHeader() function")
|
|
return func(ctx *gin.Context) {
|
|
//continue to validate token
|
|
token := ctx.GetHeader("Authorization")
|
|
if token == "" {
|
|
logger.Error("Token is empty")
|
|
controllers.ReturnError(ctx, common.ERROR_AUTH_CHECK_TOKEN_CODE,
|
|
common.ERROR_AUTH_CHECK_TOKEN_EMPTY_MSG, nil)
|
|
ctx.Abort()
|
|
return
|
|
}
|
|
|
|
// Validate token (this is a placeholder for your actual token validation logic)
|
|
isValid, err := isValidToken(strings.TrimPrefix(token, "Bearer "), ctx.ClientIP())
|
|
if err != nil {
|
|
logger.Error("Validate token error", zap.Error(err))
|
|
controllers.ReturnError(ctx, common.ERROR_AUTH_CHECK_TOKEN_CODE,
|
|
common.ERROR_AUTH_MSG+": "+err.Error(), nil)
|
|
ctx.Abort()
|
|
return
|
|
}
|
|
if !isValid {
|
|
logger.Error("Token is invalid")
|
|
controllers.ReturnError(ctx, common.ERROR_AUTH_CHECK_TOKEN_CODE,
|
|
common.ERROR_AUTH_MSG, nil)
|
|
ctx.Abort()
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// isValidToken verifies the token (signature, HS256 only, expiry, not
|
|
// revoked by logout) and its IP binding.
|
|
func isValidToken(tokenString string, IP string) (bool, error) {
|
|
// Service-account API keys are a distinct credential type from JWTs and
|
|
// carry no IP binding (they're meant for long-lived automated clients
|
|
// like the scheduler-agent MCP server, which may not have a stable IP).
|
|
// API keys also bypass logout-revocation (they outlive JWT sessions).
|
|
if strings.HasPrefix(tokenString, models.APIKeyPrefix) {
|
|
_, ok := store.ValidateAPIKey(tokenString)
|
|
return ok, nil
|
|
}
|
|
|
|
// Their hardening (bug-1945/1946/1950): HS256 pinned, default-secret
|
|
// rejected, logout revocation — all centralized in ParseTokenClaims.
|
|
claims, err := controllers.ParseTokenClaims(tokenString)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return claims.IP == IP, nil
|
|
}
|
|
|
|
func validateRequestBody(ctx *gin.Context) (bool, error) {
|
|
logger.Debug("Enter validateRequestBody function")
|
|
|
|
// read request body and resolve EOF error
|
|
body, err := ctx.GetRawData()
|
|
if err != nil {
|
|
logger.Error("Failed to read request body", zap.Error(err))
|
|
ctx.Abort()
|
|
return false, err
|
|
}
|
|
if len(body) == 0 {
|
|
logger.Error("Request body is empty")
|
|
ctx.Abort()
|
|
return false, errors.New(common.ERROR_REQUEST_BODY_EMPTY_MSG)
|
|
}
|
|
|
|
// reset request body
|
|
ctx.Request.Body = io.NopCloser(bytes.NewBuffer(body))
|
|
// check request body is valid
|
|
if !json.Valid(body) {
|
|
logger.Error("Request body is not valid", zap.Error(err))
|
|
ctx.Abort()
|
|
return false, errors.New(common.ERROR_REQUEST_BODY_NOT_JSON_MSG)
|
|
}
|
|
return true, nil
|
|
}
|