From 5ab344e1aecee76dc7969b6b69211ec8c33fea9c Mon Sep 17 00:00:00 2001 From: Rongsong Shen Date: Mon, 7 Sep 2026 08:10:10 +0000 Subject: [PATCH] fix(apiserver): propagate submit user as job execuser (bug-1780) modelJobToSubmit() never set job_startinfo_t.execuser when converting a submitted job to the C struct sent to the controller/worker, so every job ran as the worker's 'nobody' default and failed to exec (exit 126). Default execuser to the submitting user via a new resolveExecUser() pure helper, and free the newly allocated C string. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Hb6Edtj4KbS8CrgEWYdA2E --- .wolf/buglog.json | 23 +++++++++++++++++++ src/frontend/apiserver/backend/convert.go | 4 ++++ .../apiserver/backend/convert_pure.go | 12 ++++++++++ .../apiserver/backend/convert_test.go | 22 ++++++++++++++++++ 4 files changed, 61 insertions(+) diff --git a/.wolf/buglog.json b/.wolf/buglog.json index 281a546f..0fa07b54 100644 --- a/.wolf/buglog.json +++ b/.wolf/buglog.json @@ -21183,6 +21183,29 @@ ], "occurrences": 1, "last_seen": "2026-09-07T06:58:00Z" + }, + { + "id": "bug-1780", + "timestamp": "2026-09-07T07:55:00Z", + "error_message": "User-reported (xcecli): 'xcecli jobs submit -n 2 --env /run/current-system/sw/bin/sleep 100' submits successfully but the job immediately fails with Status . xcecd.log shows: '[WARN][controller_connector.c:822] No execuser provided in startjob request, defaulting to nobody', then 'Starting worker as user nobody to process command...', then 'Worker 301734 exited with code 126'.", + "file": "src/frontend/apiserver/backend/convert.go", + "root_cause": "modelJobToSubmit() (src/frontend/apiserver/backend/convert.go, converts a Go models.JobInfo into the C job_startinfo_t sent to the controller) populated si.cmdline/launch_type/job_pre_exec/job_post_exec but never set si.execuser, even though job_startinfo_t (src/include/xce/types.h) has an execuser field and models.JobInfo already has an ExecUser field (used only on the read/display path, in convert_pure.go's rawJobToModel). CreateJob() (controllers/jobs.go) also never populated JobInfo.ExecUser when building the job to submit -- there is no --exec-user flag on xcecli either, so the field was simply always empty at submission. The controller (src/controller/job.c) forwards job.submit->start_info verbatim to the worker without ever touching execuser. xcecd's controller_connector.c (lines 817-822) defaults an empty/NULL execuser to the literal Linux user 'nobody', then (lines 1037-1040) copies that into job_ctx->limits->user_name, which xcecd_dbus_manager.c uses as systemd-run's User= for the job's transient unit. 'nobody' has no home directory/login shell and typically cannot execute or access the real submitting user's binaries/cwd, so the transient unit fails with 'Permission denied' -- surfacing to xcecd_worker_utils.c as exit_code=126, which xcecli then reports as job Status . Every job submitted via xcecli/apiserver was silently running as 'nobody' regardless of who submitted it.", + "fix": "FIXED (2026-09-07), TDD RED->GREEN. Added resolveExecUser(execUser, submitUser string) string to convert_pure.go -- a pure-Go helper (no CGO types) that returns the caller-specified exec user if present, else defaults to the submitting user; this mirrors the existing pure/CGO split in this file (rawJobToModel etc. already keep pure field-mapping logic separate from the CGO struct-building in convert.go specifically so it's testable without CGO -- and because a _test.go file can never itself import \"C\" (golang/go#13938), this split is the only way to unit-test any part of modelJobToSubmit's logic). modelJobToSubmit() now sets si.execuser = C.CString(resolveExecUser(job.ExecUser, job.SubmitUser)); freeJobSubmit() now also frees si.execuser (previously would have leaked once set). New tests in convert_test.go: TestResolveExecUser_DefaultsToSubmitUserWhenEmpty, TestResolveExecUser_PrefersExplicitExecUser, TestResolveExecUser_EmptyWhenBothEmpty -- pass under CGO_ENABLED=0 go test ./backend/... (RED confirmed by the function not existing before this change). Verified with a full /xce-build apiserver asan --incremental: exit 0, all frontend Go suites pass including apiserver/backend (7.595s, real CGO build), fresh xce_apiserver/xcecli binaries produced. Live re-run of the exact reported repro (xcecli jobs submit ... /run/current-system/sw/bin/sleep 100) not yet performed in this session -- recommend a live smoke test to confirm the job now runs as the submitting user and reaches DONE/RUN instead of EXIT before closing.", + "tags": [ + "apiserver", + "convert.go", + "execuser", + "job-submit", + "jobstartinfo", + "nobody", + "permission-denied", + "worker", + "xcecli", + "fixed" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-09-07T07:55:00Z" } ] } diff --git a/src/frontend/apiserver/backend/convert.go b/src/frontend/apiserver/backend/convert.go index 297f4bc1..b0cb999a 100644 --- a/src/frontend/apiserver/backend/convert.go +++ b/src/frontend/apiserver/backend/convert.go @@ -273,6 +273,9 @@ func cClusterConfToClusterInfo(conf *C.cluster_config_t) *models.ClusterInfo { 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) + if execUser := resolveExecUser(job.ExecUser, job.SubmitUser); execUser != "" { + si.execuser = C.CString(execUser) + } if job.LaunchType != "" { si.launch_type = C.CString(job.LaunchType) } @@ -324,6 +327,7 @@ func modelJobToSubmit(job *models.JobInfo) (*C.xce_job_submit_t, *C.job_submit_d func freeJobSubmit(js *C.xce_job_submit_t, sd *C.job_submit_data_t, si *C.job_startinfo_t) { if si != nil { C.free(unsafe.Pointer(si.cmdline)) + C.free(unsafe.Pointer(si.execuser)) C.free(unsafe.Pointer(si.launch_type)) C.free(unsafe.Pointer(si.job_pre_exec)) C.free(unsafe.Pointer(si.job_post_exec)) diff --git a/src/frontend/apiserver/backend/convert_pure.go b/src/frontend/apiserver/backend/convert_pure.go index 52513768..2e265bac 100644 --- a/src/frontend/apiserver/backend/convert_pure.go +++ b/src/frontend/apiserver/backend/convert_pure.go @@ -104,6 +104,18 @@ func WorkerDefaultOutputPath(uniqueJobID int64, taskIdx int) string { return fmt.Sprintf("/tmp/job_%s_task%d.out", FormatJobID(uniqueJobID), taskIdx) } +// resolveExecUser returns the effective exec user for a job submission: the +// caller-specified ExecUser if present, otherwise the submitting user +// (bug-1780: an unset execuser in the startjob request reaches xcecd as +// empty, which controller_connector.c defaults to the literal Linux user +// 'nobody' -- a user with no home/shell that then fails to exec the job). +func resolveExecUser(execUser, submitUser string) string { + if execUser != "" { + return execUser + } + return submitUser +} + // rawStartInfo mirrors the C job_startinfo_t fields used by the Go model. type rawStartInfo struct { cmdline string diff --git a/src/frontend/apiserver/backend/convert_test.go b/src/frontend/apiserver/backend/convert_test.go index 3e135ee4..57a9807a 100644 --- a/src/frontend/apiserver/backend/convert_test.go +++ b/src/frontend/apiserver/backend/convert_test.go @@ -6,6 +6,28 @@ import ( "testing" ) +// bug-1780: modelJobToSubmit must default a job's exec user to its +// submitting user when the caller didn't specify one explicitly, so +// xcecd never falls back to running the job as the literal Linux user +// 'nobody' (which has no home/shell and fails to exec the command). +func TestResolveExecUser_DefaultsToSubmitUserWhenEmpty(t *testing.T) { + if got, want := resolveExecUser("", "alice"), "alice"; got != want { + t.Errorf("resolveExecUser(\"\", %q): got %q, want %q", "alice", got, want) + } +} + +func TestResolveExecUser_PrefersExplicitExecUser(t *testing.T) { + if got, want := resolveExecUser("bob", "alice"), "bob"; got != want { + t.Errorf("resolveExecUser(%q, %q): got %q, want %q", "bob", "alice", got, want) + } +} + +func TestResolveExecUser_EmptyWhenBothEmpty(t *testing.T) { + if got, want := resolveExecUser("", ""), ""; got != want { + t.Errorf("resolveExecUser(\"\", \"\"): got %q, want %q", got, want) + } +} + func TestRawJobToModel_BasicFields(t *testing.T) { r := rawJobData{ jobID: 123,