Add X11 forwarding for interactive jobs (xcecli -I -X, ssh -X style)
Render -X jobs' windows on the user's own X server with zero client install, mirroring the ssh -X experience: the job command starts with DISPLAY pointed at a per-job TCP listener on the worker, and the user's xcecli relays that listener's byte stream to their local X server. - xcecli: new -X/--x11 flag (interactive=2 + exdata "x11":1). After submit it fetches the per-job MIT-MAGIC-COOKIE-1, registers it in the user's Xauthority, then bridges one xattach websocket to the local X server address parsed from $DISPLAY (unix socket or TCP). The cookie entry is removed when the session ends. Pre-flight checks report missing DISPLAY/xauth before submit. - apiserver: GET /xce/v1/jobs/:id/xattach relays websocket binary frames to the worker's forwarding port (raw TCP, no ttyd). Close codes match the ttyd attach (4000 unreachable, 4001 ended). The job-finished event ends the relay. Teardown closes both hops as soon as either pump exits, so a client disconnect propagates instead of deadlocking in a blocking read. - worker: XJOB jobs with exdata x11=1 skip VNC, listen on 6100-6199, generate a per-job xauth file (/tmp/job_<id>_xauth, worker-side since the worker has no session), export DISPLAY/XAUTHORITY to the command, and bridge the app connection (loopback) with the relay connection (non-loopback; roles classified by source address, not arrival order). A 60 s no-client watchdog reaps jobs nothing ever attached to; the v1 single-connection boundary refuses a second X connection loudly. t1-verified per the plan matrix: window rendering (xclock/xeyes on Xvfb), normal end -> DONE + xauth removed, no-client 60 s reap (137), kill -> immediate teardown with no residue, -I/-I --browse regression, daemon health, missing-DISPLAY error, second-connection refusal. (--xjob VNC on t1 fails for lack of a vncserver binary; pre-existing.) Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
4f188310ba
commit
85a22271c1
19 changed files with 1744 additions and 50 deletions
|
|
@ -549,8 +549,8 @@ type rawJobData struct {
|
|||
submit *rawSubmitData
|
||||
execHosts []rawHostSlot
|
||||
pendReason *rawPendReason
|
||||
runUsage *rawRunUsage // nil until the job's first usage report
|
||||
pidInfo *rawPidInfo // nil until the worker's start report
|
||||
runUsage *rawRunUsage // nil until the job's first usage report
|
||||
pidInfo *rawPidInfo // nil until the worker's start report
|
||||
}
|
||||
|
||||
// rawWorkerData mirrors the C xce_worker_t fields used by the Go model.
|
||||
|
|
@ -606,6 +606,24 @@ func interactiveURLFromExdata(exdata string) string {
|
|||
return obj.URL
|
||||
}
|
||||
|
||||
// x11CookieFromExdata extracts the per-job X11 auth cookie of an -X job
|
||||
// from the same start-report exdata (the worker packs it as
|
||||
// {"x11cookie": ...} next to the x11:// URL). The xcecli -X client needs it
|
||||
// to register the session's MIT-MAGIC-COOKIE-1 with the user's Xauthority
|
||||
// before the app's first X connection is relayed through.
|
||||
func x11CookieFromExdata(exdata string) string {
|
||||
if exdata == "" {
|
||||
return ""
|
||||
}
|
||||
var obj struct {
|
||||
Cookie string `json:"x11cookie"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(exdata), &obj); err != nil {
|
||||
return ""
|
||||
}
|
||||
return obj.Cookie
|
||||
}
|
||||
|
||||
// pidInfoJSONB marshals a start report's pids/pgids into the PIDInfo Jsonb
|
||||
// the API exposes (processJobData parses the JSONB string before the row
|
||||
// leaves the API, so clients see an object with pids/pgids). Shared by
|
||||
|
|
@ -730,11 +748,13 @@ func rawJobToModel(r rawJobData) *models.JobInfo {
|
|||
// string before the row leaves the API, so clients see an object
|
||||
// with pids/pgids. The interactive URL is promoted to its own
|
||||
// top-level field — it is what xcecli's waitForInteractiveURL and
|
||||
// the --browse flow consume.
|
||||
// the --browse flow consume. An -X job's auth cookie rides the same
|
||||
// report and gets the same treatment.
|
||||
if jb, ok := pidInfoJSONB(p.pids, p.pgids); ok {
|
||||
info.PIDInfo = jb
|
||||
}
|
||||
info.InteractiveJobURL = interactiveURLFromExdata(p.exdata)
|
||||
info.X11Cookie = x11CookieFromExdata(p.exdata)
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
|
|
|||
|
|
@ -455,7 +455,7 @@ func TestRawJobToModel_PendReasonDetailOnlyPromotesName(t *testing.T) {
|
|||
jobID: 9,
|
||||
state: 1, // JOB_STAT_PEND
|
||||
pendReason: &rawPendReason{
|
||||
MainReason: 0, // detail-only: no explicit main reason was set
|
||||
MainReason: 0, // detail-only: no explicit main reason was set
|
||||
HostReasons: []rawHostReason{{Host: "t2", Reason: 2}, {Host: "t1", Reason: 3}},
|
||||
ResReasons: []rawResReason{{Resource: "slots", Host: "", Reason: 4}},
|
||||
},
|
||||
|
|
@ -608,6 +608,57 @@ func TestRawJobToModel_NilPidInfo(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// An -X job's start report carries the per-job X11 auth cookie next to the
|
||||
// x11:// URL; both must surface on the job row (the cookie is what the
|
||||
// xcecli -X client registers with the user's Xauthority).
|
||||
func TestRawJobToModel_X11CookieFromPidInfoExdata(t *testing.T) {
|
||||
r := rawJobData{
|
||||
jobID: 14,
|
||||
state: 2,
|
||||
pidInfo: &rawPidInfo{
|
||||
pids: []int{4321},
|
||||
exdata: `{"url": "x11://10.0.1.5:6100", "x11": 1, "x11cookie": "aabbccdd00112233445566778899eeff"}`,
|
||||
},
|
||||
}
|
||||
|
||||
info := rawJobToModel(r)
|
||||
|
||||
if want := "x11://10.0.1.5:6100"; info.InteractiveJobURL != want {
|
||||
t.Errorf("InteractiveJobURL: got %q, want %q", info.InteractiveJobURL, want)
|
||||
}
|
||||
if want := "aabbccdd00112233445566778899eeff"; info.X11Cookie != want {
|
||||
t.Errorf("X11Cookie: got %q, want %q", info.X11Cookie, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Non-X jobs never carry a cookie: ttyd/VNC exdata must not leak into
|
||||
// X11Cookie (xcecli refuses to attach -X without one, so a wrong cookie
|
||||
// would break the auth model silently).
|
||||
func TestRawJobToModel_X11CookieAbsentForOtherJobs(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
exdata string
|
||||
}{
|
||||
{"ttyd attach", `{"url": "http://10.0.1.5:9000"}`},
|
||||
{"vnc xjob", `{"url": "10.0.1.5::5901"}`},
|
||||
{"x11 without cookie key", `{"url": "x11://10.0.1.5:6100", "x11": 1}`},
|
||||
{"malformed", "not json"},
|
||||
{"empty", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
info := rawJobToModel(rawJobData{
|
||||
jobID: 15,
|
||||
state: 2,
|
||||
pidInfo: &rawPidInfo{pids: []int{1}, exdata: tc.exdata},
|
||||
})
|
||||
if info.X11Cookie != "" {
|
||||
t.Errorf("X11Cookie = %q, want empty for exdata %q", info.X11Cookie, tc.exdata)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRawJobToModel_Timestamps(t *testing.T) {
|
||||
r := rawJobData{
|
||||
jobID: 1,
|
||||
|
|
|
|||
|
|
@ -142,6 +142,7 @@ func (h *DefaultHandler) OnJobPidInfo(jobid uint64, pidInfo *rawPidInfo) {
|
|||
existing.PIDInfo = jb
|
||||
}
|
||||
existing.InteractiveJobURL = interactiveURLFromExdata(pidInfo.exdata)
|
||||
existing.X11Cookie = x11CookieFromExdata(pidInfo.exdata)
|
||||
}
|
||||
store.Jobs.Put(jobid, existing)
|
||||
DefaultBroker.Publish(Event{Type: EventJobUpdated, JobID: jobid,
|
||||
|
|
|
|||
261
src/frontend/apiserver/controllers/job_xattach.go
Normal file
261
src/frontend/apiserver/controllers/job_xattach.go
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
package controllers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"apiserver/backend"
|
||||
"apiserver/common"
|
||||
"apiserver/store"
|
||||
|
||||
logger "apiserver/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Interactive XJOB X11-forwarding attach (websocket relay).
|
||||
//
|
||||
// An -X job (interactive=2 + exdata x11=1) runs its command with DISPLAY
|
||||
// pointed at a forwarding port on the worker (inter_job_utils.c, 6100-6199):
|
||||
// the app's X connections arrive there, and this relay bridges each of them
|
||||
// to one websocket so the app's windows render on the user's own X server,
|
||||
// ssh -X style. Worker ports are typically unreachable from user clients in
|
||||
// production, so the apiserver sits in between exactly as for ttyd attach.
|
||||
//
|
||||
// Unlike the ttyd attach, the worker side is a RAW TCP listener -- no
|
||||
// websocket upgrade, no subprotocol, no ttyd framing. One websocket
|
||||
// connection carries one X connection byte-for-byte in binary frames
|
||||
// (X11 needs no framing of its own; text frames are ignored). Close codes
|
||||
// match the ttyd attach: 4000 = cannot reach the forwarding port, 4001 =
|
||||
// the session (or its job) ended. Auth and job ownership are checked once
|
||||
// at relay setup; the worker port is never exposed to the user.
|
||||
|
||||
const (
|
||||
// xattachDialTimeout bounds the apiserver→worker TCP dial.
|
||||
xattachDialTimeout = 10 * time.Second
|
||||
|
||||
// xattachWriteTimeout bounds any single write on either hop.
|
||||
xattachWriteTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
// xattachUpgrader upgrades the client-facing hop (same shape as the ttyd
|
||||
// attach: the route sits behind JWT auth, so CheckOrigin can stay open).
|
||||
var xattachUpgrader = websocket.Upgrader{
|
||||
ReadBufferSize: 32 * 1024,
|
||||
WriteBufferSize: 32 * 1024,
|
||||
CheckOrigin: func(r *http.Request) bool { return true },
|
||||
}
|
||||
|
||||
// XAttachJob relays the caller's websocket to one X connection of an -X
|
||||
// job's forwarding port. GET /xce/v1/jobs/:jobids/xattach (websocket
|
||||
// upgrade). Auth mirrors AttachJob: Authorization header or ?token= query.
|
||||
func (j JobController) XAttachJob(ctx *gin.Context) {
|
||||
logger.Debug("Enter XAttachJob() function from ", zap.String("client_ip", ctx.ClientIP()))
|
||||
|
||||
token := ctx.GetHeader("Authorization")
|
||||
if token == "" {
|
||||
if qt := ctx.Query("token"); qt != "" {
|
||||
token = "Bearer " + qt
|
||||
}
|
||||
}
|
||||
logonUser, err := GetUsernameFromToken(token)
|
||||
if err != nil {
|
||||
ReturnError(ctx, common.ERROR_CODE, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
|
||||
jobInfo, err := parseJobIDParam(ctx.Param("jobids"))
|
||||
if err != nil {
|
||||
ReturnError(ctx, common.INVALID_PARAMS_CODE, common.INVALID_PARAMS_MSG+": "+err.Error(), nil)
|
||||
return
|
||||
}
|
||||
job, ok := store.Jobs.Get(uint64(jobInfo.Unique_JobID))
|
||||
if !ok {
|
||||
ReturnError(ctx, common.ERROR_NOT_FOUND_CODE, common.ERROR_JOB_NOT_FOUND_MSG, nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Same ownership rule as the ttyd attach: the session is job control.
|
||||
if job.SubmitUser != logonUser {
|
||||
scope, perr := userJobScope(logonUser, "job:control")
|
||||
if perr != nil || !scope.covers(job.SubmitUser) {
|
||||
ReturnError(ctx, common.ERROR_AUTH_CODE, common.ERROR_ACCESS_FORBIDDEN_MSG, nil)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if job.InteractiveJobURL == "" {
|
||||
ReturnError(ctx, common.INVALID_PARAMS_CODE,
|
||||
fmt.Sprintf("job <%s> has no interactive session URL (not an interactive job, not running, or started before URL reporting)",
|
||||
backend.FormatJobID(job.Unique_JobID)), nil)
|
||||
return
|
||||
}
|
||||
workerAddr, err := x11ForwardAddr(job.InteractiveJobURL)
|
||||
if err != nil {
|
||||
ReturnError(ctx, common.INVALID_PARAMS_CODE, "invalid X11 session URL: "+err.Error(), nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Upgrade the client hop. After this point errors go out as websocket
|
||||
// close frames, not HTTP responses.
|
||||
clientConn, err := xattachUpgrader.Upgrade(ctx.Writer, ctx.Request, nil)
|
||||
if err != nil {
|
||||
logger.Error("xattach: client upgrade failed", zap.Error(err))
|
||||
return
|
||||
}
|
||||
defer clientConn.Close()
|
||||
|
||||
workerConn, err := net.DialTimeout("tcp", workerAddr, xattachDialTimeout)
|
||||
if err != nil {
|
||||
logger.Warn("xattach: worker dial failed", zap.String("addr", workerAddr), zap.Error(err))
|
||||
attachClose(clientConn, attachCloseJobGone, "cannot reach the X11 forwarding port on the worker: "+err.Error())
|
||||
return
|
||||
}
|
||||
defer workerConn.Close()
|
||||
|
||||
logger.Info("xattach: relaying X11 connection",
|
||||
zap.Int64("jobid", job.Unique_JobID), zap.String("user", logonUser),
|
||||
zap.String("worker_addr", workerAddr))
|
||||
|
||||
xattachRelay(uint64(job.Unique_JobID), clientConn, workerConn)
|
||||
logger.Info("xattach: relay finished", zap.Int64("jobid", job.Unique_JobID))
|
||||
}
|
||||
|
||||
// x11ForwardAddr turns the reported session URL (x11://host:port) into the
|
||||
// worker TCP address to relay to.
|
||||
func x11ForwardAddr(raw string) (string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if !strings.HasPrefix(raw, "x11://") {
|
||||
return "", fmt.Errorf("not an X11 forwarding URL: %q", raw)
|
||||
}
|
||||
addr := strings.TrimPrefix(raw, "x11://")
|
||||
if addr == "" || strings.Contains(addr, "/") {
|
||||
return "", fmt.Errorf("malformed X11 forwarding URL %q", raw)
|
||||
}
|
||||
return addr, nil
|
||||
}
|
||||
|
||||
// xattachRelay copies bytes between the websocket and the worker TCP
|
||||
// connection until either side goes away. The job's terminal state ends the
|
||||
// relay regardless (EventJobFinished watcher, same as the ttyd attach).
|
||||
func xattachRelay(jobID uint64, client *websocket.Conn, worker net.Conn) {
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
done := make(chan struct{})
|
||||
// teardown closes both hops once; each pump's blocking read unblocks on
|
||||
// the close of the socket it is reading, so one close ends both pumps.
|
||||
// Closing must happen the moment either pump exits -- waiting for
|
||||
// wg.Wait() here would deadlock (the remaining pump blocks in a read
|
||||
// nothing else would ever interrupt). The eventJobFinished watcher and
|
||||
// the late teardown below also call Close; net.Conn and gorilla
|
||||
// websocket Close are both idempotent.
|
||||
var closeOnce sync.Once
|
||||
teardown := func() {
|
||||
closeOnce.Do(func() {
|
||||
close(done)
|
||||
client.Close()
|
||||
worker.Close()
|
||||
})
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer teardown()
|
||||
if !xattachPumpTCP(worker, client, done) {
|
||||
// worker side ended on its own: the X connection (or its job)
|
||||
// ended. Send the policy close before the teardown closes the
|
||||
// socket so the client reliably reads 4001 instead of 1006.
|
||||
attachClose(client, attachCloseJobEnded, "X11 session ended")
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer teardown()
|
||||
// client-initiated end: teardown closes the worker conn so the
|
||||
// TCP pump's blocking read unblocks.
|
||||
xattachPumpWS(client, worker, done)
|
||||
}()
|
||||
|
||||
// The job ending is a session end regardless of the worker hop.
|
||||
go func() {
|
||||
ch := backend.DefaultBroker.Subscribe(4)
|
||||
defer backend.DefaultBroker.Unsubscribe(ch)
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
case ev, ok := <-ch:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if ev.JobID == jobID && ev.Type == backend.EventJobFinished {
|
||||
select {
|
||||
case <-done:
|
||||
default:
|
||||
logger.Info("xattach: job finished, ending relayed session",
|
||||
zap.Uint64("jobid", jobID))
|
||||
_ = client.WriteControl(websocket.CloseMessage,
|
||||
websocket.FormatCloseMessage(attachCloseJobEnded,
|
||||
"X11 session ended (job finished)"),
|
||||
time.Now().Add(xattachWriteTimeout))
|
||||
}
|
||||
go teardown()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Either pump's exit tears both sockets down (teardown above), which
|
||||
// unblocks the other pump's read; wg.Wait below just reaps them.
|
||||
<-done
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// xattachPumpTCP copies worker TCP bytes into websocket binary frames.
|
||||
// Returns false when the TCP side ended on its own (its peer outlived it,
|
||||
// and the caller may want to send it a policy close).
|
||||
func xattachPumpTCP(src net.Conn, dst *websocket.Conn, done <-chan struct{}) bool {
|
||||
buf := make([]byte, 32*1024)
|
||||
for {
|
||||
n, err := src.Read(buf)
|
||||
if n > 0 {
|
||||
_ = dst.SetWriteDeadline(time.Now().Add(xattachWriteTimeout))
|
||||
if werr := dst.WriteMessage(websocket.BinaryMessage, buf[:n]); werr != nil {
|
||||
return true // dst went away first; teardown unblocks us
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
select {
|
||||
case <-done:
|
||||
return true
|
||||
default:
|
||||
return err == net.ErrClosed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// xattachPumpWS copies websocket binary frames into the worker TCP
|
||||
// connection. Text frames are not part of the X11 wire and are ignored.
|
||||
func xattachPumpWS(src *websocket.Conn, dst net.Conn, done <-chan struct{}) {
|
||||
for {
|
||||
msgType, data, err := src.ReadMessage()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if msgType != websocket.BinaryMessage {
|
||||
continue
|
||||
}
|
||||
_ = dst.SetWriteDeadline(time.Now().Add(xattachWriteTimeout))
|
||||
if _, err := dst.Write(data); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
47
src/frontend/apiserver/controllers/job_xattach_test.go
Normal file
47
src/frontend/apiserver/controllers/job_xattach_test.go
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
package controllers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// x11ForwardAddr turns the worker's reported session URL (x11://host:port,
|
||||
// generate_url's X11 branch) into the raw TCP address the relay dials.
|
||||
func TestX11ForwardAddr(t *testing.T) {
|
||||
cases := []struct {
|
||||
raw string
|
||||
want string
|
||||
}{
|
||||
{"x11://10.0.1.5:6100", "10.0.1.5:6100"},
|
||||
{"x11://worker.example:6199", "worker.example:6199"},
|
||||
{" x11://10.0.1.5:6100 ", "10.0.1.5:6100"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, err := x11ForwardAddr(c.raw)
|
||||
if err != nil {
|
||||
t.Errorf("x11ForwardAddr(%q): unexpected error %v", c.raw, err)
|
||||
continue
|
||||
}
|
||||
if got != c.want {
|
||||
t.Errorf("x11ForwardAddr(%q): got %q, want %q", c.raw, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A ttyd or VNC URL routed at the xattach relay is a bug elsewhere in the
|
||||
// chain (the URL decides the backend) -- reject it instead of dialing a
|
||||
// websocket endpoint as raw TCP.
|
||||
func TestX11ForwardAddr_NotX11(t *testing.T) {
|
||||
cases := []string{
|
||||
"", " ",
|
||||
"http://10.0.1.5:9000",
|
||||
"10.0.1.5::5901",
|
||||
"x11://",
|
||||
"x11://10.0.1.5:6100/ws",
|
||||
"x11://10.0.1.5:6100/path?x=1",
|
||||
}
|
||||
for _, raw := range cases {
|
||||
if got, err := x11ForwardAddr(raw); err == nil {
|
||||
t.Errorf("x11ForwardAddr(%q): got %q, want error", raw, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2056,6 +2056,13 @@ func processJobRequest(ctx *gin.Context, req *models.JobSubmitRequest, defaultVa
|
|||
extendDataMap["attach"] = req.Attach
|
||||
}
|
||||
|
||||
// x11=1: an XJOB job running as X11 forwarding (xcecli -I -X). The
|
||||
// worker exports DISPLAY to its forwarding port and reports a per-job
|
||||
// xauth cookie instead of starting VNC.
|
||||
if req.X11 != 0 {
|
||||
extendDataMap["x11"] = req.X11
|
||||
}
|
||||
|
||||
if len(extendDataMap) > 0 {
|
||||
updatedExtendDataBytes, err := json.Marshal(extendDataMap)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -478,6 +478,52 @@ func TestJobHandler_SubmitInteractiveAttachExdata(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestJobHandler_SubmitX11Exdata checks the submit handler packs x11 into
|
||||
// the job's ExtendData for -X jobs: the key selects the X11-forwarding
|
||||
// backend worker-side (DISPLAY to the forwarding port instead of VNC).
|
||||
func TestJobHandler_SubmitX11Exdata(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
req map[string]interface{}
|
||||
wantX11 bool
|
||||
}{
|
||||
{"x11 job", map[string]interface{}{
|
||||
"job_name": "a", "command": "xclock", "interactive": 2, "x11": 1,
|
||||
}, true},
|
||||
{"plain xjob (VNC backend)", map[string]interface{}{
|
||||
"job_name": "a", "command": "xclock", "interactive": 2,
|
||||
}, false},
|
||||
{"tty job without x11", map[string]interface{}{
|
||||
"job_name": "a", "command": "sleep 1", "interactive": 1,
|
||||
}, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
f := newHandlerFixture(t)
|
||||
f.seedQueue("normal")
|
||||
|
||||
w := f.submit(tc.req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
subs := f.submissions()
|
||||
if len(subs) != 1 {
|
||||
t.Fatalf("submitJob calls = %d, want 1", len(subs))
|
||||
}
|
||||
var ex map[string]interface{}
|
||||
if raw := subs[0].Job.ExtendData.String; raw != "" {
|
||||
if err := json.Unmarshal([]byte(raw), &ex); err != nil {
|
||||
t.Fatalf("ExtendData %q is not JSON: %v", raw, err)
|
||||
}
|
||||
}
|
||||
_, hasX11 := ex["x11"]
|
||||
if hasX11 != tc.wantX11 {
|
||||
t.Errorf("ExtendData x11 present = %v, want %v (exdata: %s)", hasX11, tc.wantX11, subs[0].Job.ExtendData.String)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestJobHandler_SmokeControlByID controls one seeded ordinary job by ID
|
||||
// and checks the dispatch is recorded once with its unique_job_id.
|
||||
func TestJobHandler_SmokeControlByID(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -580,6 +580,12 @@ type JobInfo struct {
|
|||
// JobAdditionalFields (the query-field whitelist) must stay in sync with
|
||||
// this name.
|
||||
InteractiveJobURL string `json:"interactive_job_url"`
|
||||
// X11Cookie is the per-job MIT-MAGIC-COOKIE-1 of an -X job (XJOB with
|
||||
// exdata x11=1), extracted from the same start-report exdata as
|
||||
// InteractiveJobURL. The xcecli -X client registers it with the user's
|
||||
// Xauthority before the app's first relayed X connection. Empty for
|
||||
// every other kind of job.
|
||||
X11Cookie string `json:"x11_cookie"`
|
||||
}
|
||||
|
||||
type JobExecHosts struct {
|
||||
|
|
@ -632,6 +638,7 @@ type JobAdditionalFields struct {
|
|||
Pids []int `json:"pids"` // List of process IDs associated with the job
|
||||
Pgids []int `json:"pgids"` // List of process group IDs associated with the job
|
||||
InteractiveJobURL string `json:"interactive_job_url"`
|
||||
X11Cookie string `json:"x11_cookie"` // -X job's per-session auth cookie (from start exdata)
|
||||
// CoreLimit/MemLimit: JobInfo has no equivalent field (these are
|
||||
// submission-time resource limits, not job-state fields), but xcecli's
|
||||
// JobFieldsMap accepts them as valid -o/-l display fields (bug-1674) --
|
||||
|
|
@ -642,38 +649,43 @@ type JobAdditionalFields struct {
|
|||
}
|
||||
|
||||
type JobSubmitRequest struct {
|
||||
JobName string `json:"job_name,omitempty" `
|
||||
Command string `json:"command" binding:"required"`
|
||||
QueueName string `json:"queue_name,omitempty"` // set to DEFAULT_QUEUE in config
|
||||
ProjectName string `json:"project_name,omitempty"` // default project: "default"
|
||||
UserGroup string `json:"user_group,omitempty"`
|
||||
LicenseProject string `json:"license_project,omitempty"`
|
||||
JobDescription string `json:"job_description,omitempty"`
|
||||
Resreq string `json:"resreq,omitempty"` // default: "type == local"
|
||||
Affinity string `json:"affinity,omitempty"`
|
||||
Gpureq string `json:"gpureq,omitempty"`
|
||||
RsvID string `json:"rsv_id,omitempty"`
|
||||
SLA string `json:"sla,omitempty"`
|
||||
MaxNumProcessors int `json:"maxnum_processors,omitempty"`
|
||||
NumProcessors int `json:"num_processors,omitempty"`
|
||||
AskedHosts string `json:"asked_hosts,omitempty"`
|
||||
LaunchType string `json:"launch_type,omitempty"`
|
||||
BeginTime CustomTime `json:"begin_time,omitempty"` //k change type
|
||||
TermTime CustomTime `json:"term_time,omitempty"` //k change type
|
||||
JobPriority int `json:"job_priority,omitempty"`
|
||||
DependCond string `json:"depend_cond,omitempty"`
|
||||
JobGroup string `json:"job_group,omitempty"`
|
||||
HostSpec string `json:"host_spec,omitempty"`
|
||||
Env []string `json:"env,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Exclusive bool `json:"exclusive,omitempty"`
|
||||
Rerunable bool `json:"rerunable,omitempty"`
|
||||
Interactive int `json:"interactive,omitempty"`
|
||||
JobName string `json:"job_name,omitempty" `
|
||||
Command string `json:"command" binding:"required"`
|
||||
QueueName string `json:"queue_name,omitempty"` // set to DEFAULT_QUEUE in config
|
||||
ProjectName string `json:"project_name,omitempty"` // default project: "default"
|
||||
UserGroup string `json:"user_group,omitempty"`
|
||||
LicenseProject string `json:"license_project,omitempty"`
|
||||
JobDescription string `json:"job_description,omitempty"`
|
||||
Resreq string `json:"resreq,omitempty"` // default: "type == local"
|
||||
Affinity string `json:"affinity,omitempty"`
|
||||
Gpureq string `json:"gpureq,omitempty"`
|
||||
RsvID string `json:"rsv_id,omitempty"`
|
||||
SLA string `json:"sla,omitempty"`
|
||||
MaxNumProcessors int `json:"maxnum_processors,omitempty"`
|
||||
NumProcessors int `json:"num_processors,omitempty"`
|
||||
AskedHosts string `json:"asked_hosts,omitempty"`
|
||||
LaunchType string `json:"launch_type,omitempty"`
|
||||
BeginTime CustomTime `json:"begin_time,omitempty"` //k change type
|
||||
TermTime CustomTime `json:"term_time,omitempty"` //k change type
|
||||
JobPriority int `json:"job_priority,omitempty"`
|
||||
DependCond string `json:"depend_cond,omitempty"`
|
||||
JobGroup string `json:"job_group,omitempty"`
|
||||
HostSpec string `json:"host_spec,omitempty"`
|
||||
Env []string `json:"env,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Exclusive bool `json:"exclusive,omitempty"`
|
||||
Rerunable bool `json:"rerunable,omitempty"`
|
||||
Interactive int `json:"interactive,omitempty"`
|
||||
// Attach marks an interactive TTY job as the terminal-attach kind
|
||||
// (xcecli -I without --browse): the session ends with the command, so
|
||||
// the worker may reap the ttyd when no client ever connects. 0 keeps
|
||||
// the web-viewed semantics.
|
||||
Attach int `json:"attach,omitempty"`
|
||||
Attach int `json:"attach,omitempty"`
|
||||
// X11 (exdata key "x11") selects the X11-forwarding backend for an XJOB
|
||||
// (interactive=2) job: the worker exports DISPLAY to its forwarding port
|
||||
// instead of starting VNC, and the app's X connection is relayed to the
|
||||
// user's local X server through the xattach websocket.
|
||||
X11 int `json:"x11,omitempty"`
|
||||
WarningTimePeriod int `json:"warning_time_period,omitempty"`
|
||||
WarningAction string `json:"warning_action,omitempty"`
|
||||
DataSpecFile string `json:"data_spec_file,omitempty"`
|
||||
|
|
|
|||
|
|
@ -147,6 +147,9 @@ func Router() *gin.Engine {
|
|||
// Interactive-job websocket relay (attach): long-lived connection to
|
||||
// the job's ttyd session on the worker, proxied by the apiserver.
|
||||
jobsApi.GET("/:jobids/attach", controllers.RequirePermission("job:control"), jobHandler.AttachJob)
|
||||
// X11-forwarding relay (-X jobs): one websocket per X connection of
|
||||
// the job's forwarding port, raw TCP on the worker side.
|
||||
jobsApi.GET("/:jobids/xattach", controllers.RequirePermission("job:control"), jobHandler.XAttachJob)
|
||||
jobsApi.GET("", controllers.RequirePermission("job:view"), jobHandler.GetJobsInfo)
|
||||
jobsApi.GET("/:jobids", controllers.RequirePermission("job:view"), jobHandler.GetJobByIDs)
|
||||
jobsApi.POST("/create", controllers.RequirePermission("queue:submit"), jobHandler.CreateJob)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue