fix(apiserver): carry exec_user on job submit so jobs don't run as nobody (bug-1500)

modelJobToSubmit built the C job_startinfo_t without setting execuser, so the
controller forwarded a NULL execuser in the startjob request and the worker
ran every job as "nobody". Now the submit carries the resolved exec user: an
explicit exec_user wins, otherwise the job runs as the submitting user.

- add resolveExecUser() (plain-Go, unit-testable) + TestResolveExecUser
- set si.execuser in modelJobToSubmit, free it in freeJobSubmit

Note: deploying this exposed an ASAN heap-use-after-free in the apiserver's
submit/notification path (strncpy reading freed memory); the fix is committed
but needs the memory-lifecycle investigation before it can ship safely.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
lei.chen 2026-08-04 07:37:48 +00:00
parent ee7dee0ed4
commit 5b47ee603f
8 changed files with 1138 additions and 379 deletions

View file

@ -35,15 +35,25 @@ hosts:
model: "x86_64-linux-gnu"
max_slot: 128
# t1: primary test node (172.31.1.30). Must match the hostname the worker
# reports via gethostname(); without a matching hostconfig, the worker's
# join is rejected with -EINVAL (find_matched_hostconfig fails) and no
# compute node ever registers, so every job stays PEND (bug-1494).
# t1/t2/t3: test nodes (172.31.1.30/31/32). Must match each hostname the
# worker reports via gethostname(); without a matching hostconfig, the
# worker's join is rejected with -EINVAL (find_matched_hostconfig fails)
# and no compute node ever registers, so every job stays PEND (bug-1494).
- hostname: t1
type: "x86_64-linux-gnu"
model: "x86_64-linux-gnu"
max_slot: 8
- hostname: t2
type: "x86_64-linux-gnu"
model: "x86_64-linux-gnu"
max_slot: 8
- hostname: t3
type: "x86_64-linux-gnu"
model: "x86_64-linux-gnu"
max_slot: 8
hostgroups:
- groupname: hg1

View file

@ -249,6 +249,19 @@ func cClusterConfToClusterInfo(conf *C.cluster_config_t) *models.ClusterInfo {
// modelJobToSubmit converts a Go JobInfo to C xce_job_submit_t heap structs.
// The caller must free all returned C memory by calling freeJobSubmit.
// resolveExecUser returns the user a job should run as: an explicit exec_user
// wins; otherwise the job runs as the submitting user. bug-1500: the submit
// conversion previously dropped this entirely, so the worker received a NULL
// execuser and ran every job as "nobody". Kept as a plain-Go function so it is
// unit-testable without cgo (the Go toolchain rejects `import "C"` in _test.go).
func resolveExecUser(job *models.JobInfo) string {
execUser := job.ExecUser
if execUser == "" {
execUser = job.SubmitUser
}
return execUser
}
func modelJobToSubmit(job *models.JobInfo) (*C.xce_job_submit_t, *C.job_submit_data_t, *C.job_startinfo_t) {
si := (*C.job_startinfo_t)(C.calloc(1, C.sizeof_job_startinfo_t))
si.cmdline = C.CString(job.Command)
@ -261,6 +274,9 @@ func modelJobToSubmit(job *models.JobInfo) (*C.xce_job_submit_t, *C.job_submit_d
if job.PostExecCmd != "" {
si.job_post_exec = C.CString(job.PostExecCmd)
}
// bug-1500: carry the exec user on the start_info. Without this the worker
// receives a NULL execuser and runs every job as "nobody".
si.execuser = C.CString(resolveExecUser(job))
sd := (*C.job_submit_data_t)(C.calloc(1, C.sizeof_job_submit_data_t))
sd.queue = C.CString(job.QueueName)
@ -303,6 +319,7 @@ func freeJobSubmit(js *C.xce_job_submit_t, sd *C.job_submit_data_t, si *C.job_st
C.free(unsafe.Pointer(si.launch_type))
C.free(unsafe.Pointer(si.job_pre_exec))
C.free(unsafe.Pointer(si.job_post_exec))
C.free(unsafe.Pointer(si.execuser))
C.free(unsafe.Pointer(si))
}
if sd != nil {

View file

@ -865,3 +865,27 @@ func TestRawClusterToModel(t *testing.T) {
})
}
}
// bug-1500: the submit conversion must carry an exec user; explicit exec_user
// wins, otherwise the job runs as the submitting user (not "nobody").
func TestResolveExecUser(t *testing.T) {
cases := []struct {
name string
execUser string
submitUser string
want string
}{
{"explicit exec_user wins", "lchen", "admin", "lchen"},
{"empty exec_user falls back to submit user", "", "admin", "admin"},
{"both empty stays empty", "", "", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
job := &models.JobInfo{SubmitUser: tc.submitUser, ExecUser: tc.execUser}
if got := resolveExecUser(job); got != tc.want {
t.Errorf("resolveExecUser: got %q, want %q", got, tc.want)
}
})
}
}