Init commit of jobs output/error message peek project.
This commit is contained in:
parent
c772d7e601
commit
d2b47e3985
38 changed files with 2267 additions and 21 deletions
|
|
@ -416,3 +416,41 @@ func signalNumber(signame string, action int) int {
|
|||
}
|
||||
return 15 // SIGTERM as safe default
|
||||
}
|
||||
|
||||
// FetchFile fetches up to maxBytes of a job output file from offset via the
|
||||
// controller→worker chain. The filepath must be the fully resolved absolute
|
||||
// path on the exec host.
|
||||
func FetchFile(jobid uint64, filepath string, offset uint64, maxBytes uint32) (*FetchFileResult, error) {
|
||||
cFilepath := C.CString(filepath)
|
||||
defer C.free(unsafe.Pointer(cFilepath))
|
||||
|
||||
msg := C.xce_fetchfile_t{
|
||||
jobid: C.uint64_t(jobid),
|
||||
filepath: cFilepath,
|
||||
offset: C.uint64_t(offset),
|
||||
max_bytes: C.uint32_t(maxBytes),
|
||||
}
|
||||
|
||||
var reply *C.xce_fetchfile_reply_t
|
||||
rc := C.xcecli_fetchfile(gCtx.ctx, &msg, &reply)
|
||||
if rc < 0 {
|
||||
return nil, fmt.Errorf("xcecli_fetchfile failed: %d", int(rc))
|
||||
}
|
||||
if reply == nil {
|
||||
return nil, fmt.Errorf("xcecli_fetchfile returned nil reply")
|
||||
}
|
||||
defer C.free(unsafe.Pointer(reply))
|
||||
|
||||
if reply.error != 0 {
|
||||
return nil, fmt.Errorf("fetchfile remote error: %d", int(reply.error))
|
||||
}
|
||||
|
||||
result := &FetchFileResult{
|
||||
FileSize: uint64(reply.file_size),
|
||||
BytesRead: uint32(reply.bytes_read),
|
||||
}
|
||||
if reply.bytes_read > 0 && reply.content != nil {
|
||||
result.Content = C.GoBytes(unsafe.Pointer(reply.content), C.int(reply.bytes_read))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,3 +8,10 @@ type BackendConfig struct {
|
|||
From string // identity sent to controller (default: "xce_apiserver")
|
||||
Key string // authentication key
|
||||
}
|
||||
|
||||
// FetchFileResult holds the data returned by FetchFile.
|
||||
type FetchFileResult struct {
|
||||
Content []byte
|
||||
FileSize uint64
|
||||
BytesRead uint32
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package backend
|
|||
import (
|
||||
"apiserver/models"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
|
|
@ -76,6 +77,33 @@ func IsArrayJob(id uint64) bool {
|
|||
return extractIDTag(id) == tagJobArray
|
||||
}
|
||||
|
||||
// FormatJobID formats a packed 64-bit job ID as a human-readable string,
|
||||
// matching the C fmt_jobid() output in src/lib/utils.c.
|
||||
// - Regular job: "{cluster}-{apiserver}-{timestamp}-{sequence}"
|
||||
// - Array child: "{cluster}-{apiserver}-{timestamp}-{base}[{idx}]"
|
||||
func FormatJobID(uniqueJobID int64) string {
|
||||
id := uint64(uniqueJobID)
|
||||
clusterID := extractBits(id, clusterIDShift, nClusterIDBits)
|
||||
apiserverID := extractBits(id, apiserverIDShift, nApiserverIDBits)
|
||||
timestamp := extractBits(id, timestampShift, nTimestampBits)
|
||||
|
||||
if extractIDTag(id) == tagJobArray {
|
||||
arrayBase := extractBits(id, sequenceHighShift, nSequenceBits-nArrayIdxBits) << uint(sequenceHighShift)
|
||||
arrayIdx := extractBits(id, 0, nArrayIdxBits)
|
||||
return fmt.Sprintf("%d-%d-%d-%d[%d]",
|
||||
clusterID, apiserverID, timestamp, arrayBase, arrayIdx)
|
||||
}
|
||||
sequence := extractBits(id, 0, nSequenceBits)
|
||||
return fmt.Sprintf("%d-%d-%d-%d",
|
||||
clusterID, apiserverID, timestamp, sequence)
|
||||
}
|
||||
|
||||
// WorkerDefaultOutputPath returns the output file path the worker uses by default
|
||||
// for the given job ID and task index, matching uv_proc.c make_job_output_path().
|
||||
func WorkerDefaultOutputPath(uniqueJobID int64, taskIdx int) string {
|
||||
return fmt.Sprintf("/tmp/job_%s_task%d.out", FormatJobID(uniqueJobID), taskIdx)
|
||||
}
|
||||
|
||||
// rawStartInfo mirrors the C job_startinfo_t fields used by the Go model.
|
||||
type rawStartInfo struct {
|
||||
cmdline string
|
||||
|
|
|
|||
|
|
@ -41,3 +41,8 @@ func EvalSelectForHost(_, _ string) error { return errNoCGO }
|
|||
|
||||
// Cleanup stub — real implementation in clib.go (requires CGO).
|
||||
func Cleanup() {}
|
||||
|
||||
// FetchFile stub — real implementation in clib.go (requires CGO).
|
||||
func FetchFile(_ uint64, _ string, _ uint64, _ uint32) (*FetchFileResult, error) {
|
||||
return nil, errNoCGO
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1434,7 +1434,7 @@ func (j JobController) CreateJob(ctx *gin.Context) {
|
|||
logger.Info("Generated job ID", zap.Int64("jobID", primaryJobID), zap.Int("arraySize", arraySize))
|
||||
|
||||
defaultValues := map[string]string{
|
||||
"job_id": fmt.Sprintf("%d", primaryJobID),
|
||||
"job_id": backend.FormatJobID(primaryJobID),
|
||||
"user_name": submitUser,
|
||||
"job_group": req.JobGroup,
|
||||
"project_name": req.ProjectName,
|
||||
|
|
|
|||
245
src/frontend/apiserver/controllers/output.go
Normal file
245
src/frontend/apiserver/controllers/output.go
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
package controllers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"apiserver/backend"
|
||||
"apiserver/common"
|
||||
"apiserver/models"
|
||||
"apiserver/store"
|
||||
"apiserver/utils"
|
||||
|
||||
logger "apiserver/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// fetchFile is the function used to fetch remote file content.
|
||||
// Overrideable in tests.
|
||||
var fetchFile = backend.FetchFile
|
||||
|
||||
const (
|
||||
// tailChunkSize is the maximum bytes fetched per request in tail mode.
|
||||
tailChunkSize = 4 * 1024 * 1024
|
||||
// incrementalChunkSize is the maximum bytes fetched in incremental mode.
|
||||
incrementalChunkSize = 4 * 1024 * 1024
|
||||
)
|
||||
|
||||
// OutputData represents the response payload for job output.
|
||||
type OutputData struct {
|
||||
Content string `json:"content"`
|
||||
Offset int64 `json:"offset"`
|
||||
JobStatus string `json:"job_status"`
|
||||
Done bool `json:"done"`
|
||||
}
|
||||
|
||||
// GetJobOutput returns the tail or incremental content of a job's output file.
|
||||
// GET /xce/v1/jobs/:jobid/output?offset=0&lines=100&type=stdout
|
||||
func (j JobController) GetJobOutput(ctx *gin.Context) {
|
||||
logger.Debug("Enter GetJobOutput() function from ", zap.String("client_ip", ctx.ClientIP()))
|
||||
|
||||
token := ctx.GetHeader("Authorization")
|
||||
submitUser, err := GetUsernameFromToken(token)
|
||||
if err != nil {
|
||||
ReturnError(ctx, common.ERROR_CODE, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
|
||||
if ctx.IsAborted() {
|
||||
ReturnError(ctx, common.ERROR_AUTH_CODE, common.ERROR_ACCESS_FORBIDDEN_MSG, nil)
|
||||
return
|
||||
}
|
||||
|
||||
jobidStr := ctx.Param("jobids")
|
||||
offsetStr := ctx.DefaultQuery("offset", "0")
|
||||
linesStr := ctx.DefaultQuery("lines", "100")
|
||||
streamType := ctx.DefaultQuery("type", "stdout")
|
||||
|
||||
offset, err := strconv.ParseInt(offsetStr, 10, 64)
|
||||
if err != nil {
|
||||
ReturnError(ctx, common.INVALID_PARAMS_CODE, "invalid offset: "+err.Error(), nil)
|
||||
return
|
||||
}
|
||||
|
||||
lines, err := strconv.Atoi(linesStr)
|
||||
if err != nil || lines <= 0 {
|
||||
ReturnError(ctx, common.INVALID_PARAMS_CODE, "invalid lines: must be a positive integer", nil)
|
||||
return
|
||||
}
|
||||
|
||||
if streamType != "stdout" && streamType != "stderr" {
|
||||
ReturnError(ctx, common.INVALID_PARAMS_CODE, "invalid type: must be 'stdout' or 'stderr'", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse job ID
|
||||
jobInfo, err := parseJobIDParam(jobidStr)
|
||||
if err != nil {
|
||||
ReturnError(ctx, common.INVALID_PARAMS_CODE, common.INVALID_PARAMS_MSG+": "+err.Error(), nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Look up job in store
|
||||
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
|
||||
}
|
||||
|
||||
// Check ownership: users with domain_type=all can view any job
|
||||
permission, perr := GetRequireResourcePermission(submitUser, "job:view")
|
||||
if perr != nil || permission.DomainType != models.DomainTypeAll {
|
||||
if job.SubmitUser != submitUser {
|
||||
ReturnError(ctx, common.ERROR_AUTH_CODE, common.ERROR_ACCESS_FORBIDDEN_MSG, nil)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve output file path
|
||||
var filePath string
|
||||
if streamType == "stderr" {
|
||||
filePath = resolveOutputPath(job.ErrFile, job)
|
||||
} else {
|
||||
filePath = resolveOutputPath(job.OutFile, job)
|
||||
}
|
||||
|
||||
if filePath == "" {
|
||||
ReturnError(ctx, common.ERROR_READ_FILE_CODE, fmt.Sprintf("job <%s>: no output file path could be determined for %s", backend.FormatJobID(job.Unique_JobID), streamType), nil)
|
||||
return
|
||||
}
|
||||
|
||||
var content string
|
||||
var newOffset int64
|
||||
|
||||
if offset == 0 {
|
||||
content, newOffset, err = fetchFileTail(uint64(job.Unique_JobID), filePath, lines)
|
||||
} else {
|
||||
content, newOffset, err = fetchFileFromOffset(uint64(job.Unique_JobID), filePath, offset)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
ReturnError(ctx, common.ERROR_READ_FILE_CODE, fmt.Sprintf("job <%s> (path=%s): failed to read output: %s", backend.FormatJobID(job.Unique_JobID), filePath, err.Error()), nil)
|
||||
return
|
||||
}
|
||||
|
||||
jobStatusStr := models.JobStateStr(job.JobStatus)
|
||||
done := jobStatusStr != "RUN" && jobStatusStr != "PSUSP" && jobStatusStr != "USUSP" && jobStatusStr != "SSUSP"
|
||||
|
||||
ReturnSuccess(ctx, common.SUCCESS_CODE, common.SUCCESS_MSG, OutputData{
|
||||
Content: content,
|
||||
Offset: newOffset,
|
||||
JobStatus: jobStatusStr,
|
||||
Done: done,
|
||||
})
|
||||
}
|
||||
|
||||
// fetchFileTail fetches the last n lines of a file via the backend.
|
||||
// It uses a two-pass approach for files larger than tailChunkSize.
|
||||
func fetchFileTail(jobid uint64, path string, n int) (string, int64, error) {
|
||||
// First pass: fetch from offset 0 to get file size and content for small files.
|
||||
result, err := fetchFile(jobid, path, 0, tailChunkSize)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
|
||||
fileSize := int64(result.FileSize)
|
||||
if fileSize == 0 {
|
||||
return "", 0, nil
|
||||
}
|
||||
|
||||
data := result.Content
|
||||
|
||||
// If the file is larger than our chunk, re-fetch the tail portion.
|
||||
if result.FileSize > tailChunkSize {
|
||||
endOffset := result.FileSize - tailChunkSize
|
||||
result, err = fetchFile(jobid, path, endOffset, tailChunkSize)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
data = result.Content
|
||||
}
|
||||
|
||||
return lastNLines(data, n), fileSize, nil
|
||||
}
|
||||
|
||||
// fetchFileFromOffset fetches content from the given byte offset via the backend.
|
||||
func fetchFileFromOffset(jobid uint64, path string, offset int64) (string, int64, error) {
|
||||
result, err := fetchFile(jobid, path, uint64(offset), incrementalChunkSize)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
|
||||
fileSize := int64(result.FileSize)
|
||||
if int64(offset) >= fileSize {
|
||||
return "", fileSize, nil
|
||||
}
|
||||
|
||||
return string(result.Content), fileSize, nil
|
||||
}
|
||||
|
||||
// lastNLines returns the last n lines from data as a string.
|
||||
func lastNLines(data []byte, n int) string {
|
||||
if len(data) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
lineCount := 0
|
||||
start := 0
|
||||
end := len(data) - 1
|
||||
|
||||
for i := end; i >= 0; i-- {
|
||||
if data[i] == '\n' {
|
||||
lineCount++
|
||||
if lineCount == n+1 {
|
||||
start = i + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return string(data[start:])
|
||||
}
|
||||
|
||||
// resolveOutputPath replaces placeholders in the output file path.
|
||||
// When path is empty, returns the worker's default output path for task 0,
|
||||
// matching uv_proc.c make_job_output_path() behavior.
|
||||
func resolveOutputPath(path string, job models.JobInfo) string {
|
||||
if path == "" {
|
||||
return backend.WorkerDefaultOutputPath(job.Unique_JobID, 0)
|
||||
}
|
||||
formattedID := backend.FormatJobID(job.Unique_JobID)
|
||||
path = strings.ReplaceAll(path, "%J", formattedID)
|
||||
path = strings.ReplaceAll(path, "%I", strconv.Itoa(job.ArrayIdx))
|
||||
path = strings.ReplaceAll(path, "%U", job.SubmitUser)
|
||||
if job.CWD != "" {
|
||||
path = strings.ReplaceAll(path, "%C", job.CWD)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// parseJobIDParam parses a job ID string and returns the corresponding JobInfo.
|
||||
func parseJobIDParam(jobidStr string) (models.JobInfo, error) {
|
||||
// Use the same parsing logic as GetJobByIDs
|
||||
jobArrInfo, err := parseArrayName(jobidStr)
|
||||
if err != nil {
|
||||
return models.JobInfo{}, err
|
||||
}
|
||||
|
||||
if !utils.IsNumeric(jobArrInfo.KeyID) {
|
||||
return models.JobInfo{}, fmt.Errorf("bad jobid: %s", jobArrInfo.KeyID)
|
||||
}
|
||||
|
||||
uniqueJobID, err := strconv.ParseInt(jobArrInfo.KeyID, 10, 64)
|
||||
if err != nil {
|
||||
return models.JobInfo{}, fmt.Errorf("bad jobid: %s", jobArrInfo.KeyID)
|
||||
}
|
||||
|
||||
return models.JobInfo{
|
||||
Unique_JobID: uniqueJobID,
|
||||
ArrayIdx: 0, // peek defaults to base job; array idx can be added later
|
||||
}, nil
|
||||
}
|
||||
|
||||
515
src/frontend/apiserver/controllers/output_test.go
Normal file
515
src/frontend/apiserver/controllers/output_test.go
Normal file
|
|
@ -0,0 +1,515 @@
|
|||
package controllers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"apiserver/backend"
|
||||
"apiserver/common"
|
||||
"apiserver/models"
|
||||
"apiserver/store"
|
||||
"apiserver/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// mockFetchFile simulates FetchFile by reading from the local filesystem.
|
||||
// It is installed by resetJobsState() so all output endpoint tests use it.
|
||||
func mockFetchFile(jobid uint64, filepath string, offset uint64, maxBytes uint32) (*backend.FetchFileResult, error) {
|
||||
data, err := os.ReadFile(filepath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fileSize := uint64(len(data))
|
||||
if offset >= fileSize {
|
||||
return &backend.FetchFileResult{FileSize: fileSize, BytesRead: 0}, nil
|
||||
}
|
||||
chunk := data[offset:]
|
||||
if maxBytes > 0 && uint32(len(chunk)) > maxBytes {
|
||||
chunk = chunk[:maxBytes]
|
||||
}
|
||||
return &backend.FetchFileResult{
|
||||
Content: chunk,
|
||||
FileSize: fileSize,
|
||||
BytesRead: uint32(len(chunk)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func resetJobsState() {
|
||||
store.InitStores()
|
||||
utils.Log = zap.NewNop()
|
||||
initTestAuthConfig() // ensure JWT parsing works in all output tests
|
||||
fetchFile = mockFetchFile
|
||||
}
|
||||
|
||||
func setupTestJob(t *testing.T, tmpDir string) {
|
||||
t.Helper()
|
||||
|
||||
// OutFile/ErrFile use %J placeholder; resolveOutputPath replaces %J with
|
||||
// the formatted job ID (FormatJobID(1001) = "0-0-0-1001" for small test IDs).
|
||||
outFile := filepath.Join(tmpDir, "job_%J.out")
|
||||
errFile := filepath.Join(tmpDir, "job_%J.err")
|
||||
|
||||
job := models.JobInfo{
|
||||
Unique_JobID: 1001,
|
||||
JobID: 1001,
|
||||
ArrayIdx: 0,
|
||||
SubmitUser: "testuser",
|
||||
JobStatus: 0x02, // RUN
|
||||
OutFile: outFile,
|
||||
ErrFile: errFile,
|
||||
CWD: tmpDir,
|
||||
}
|
||||
store.Jobs.Put(1001, job)
|
||||
|
||||
// Write to the resolved paths (with %J replaced by formatted ID "0-0-0-1001").
|
||||
os.WriteFile(filepath.Join(tmpDir, "job_0-0-0-1001.out"), []byte("line1\nline2\nline3\nline4\nline5\n"), 0644)
|
||||
os.WriteFile(filepath.Join(tmpDir, "job_0-0-0-1001.err"), []byte("err1\nerr2\n"), 0644)
|
||||
}
|
||||
|
||||
func setupTestRouter() *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
handler := &JobController{}
|
||||
r.GET("/xce/v1/jobs/:jobids/output", handler.GetJobOutput)
|
||||
return r
|
||||
}
|
||||
|
||||
// testToken returns a signed JWT for "testuser" valid for 1 hour.
|
||||
// initTestAuthConfig() must be called before using this token.
|
||||
func testToken() string {
|
||||
return "Bearer " + signTestJWT("testuser", "admin", time.Now().Add(time.Hour))
|
||||
}
|
||||
|
||||
func decodeResponse(t *testing.T, w *httptest.ResponseRecorder, v interface{}) {
|
||||
t.Helper()
|
||||
t.Logf("response body: %s", w.Body.String())
|
||||
if err := json.Unmarshal(w.Body.Bytes(), v); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetJobOutput_TailLines(t *testing.T) {
|
||||
resetJobsState()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
setupTestJob(t, tmpDir)
|
||||
|
||||
router := setupTestRouter()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/xce/v1/jobs/1001/output?lines=2", nil)
|
||||
req.Header.Set("Authorization", testToken())
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Code int `json:"code"`
|
||||
Data OutputData `json:"data"`
|
||||
}
|
||||
decodeResponse(t, w, &resp)
|
||||
|
||||
if resp.Data.Content != "line4\nline5\n" {
|
||||
t.Errorf("content = %q, want %q", resp.Data.Content, "line4\nline5\n")
|
||||
}
|
||||
if resp.Data.Offset != 30 {
|
||||
t.Errorf("offset = %d, want 30", resp.Data.Offset)
|
||||
}
|
||||
if resp.Data.Done {
|
||||
t.Error("done should be false for running job")
|
||||
}
|
||||
if resp.Data.JobStatus != "RUN" {
|
||||
t.Errorf("job_status = %q, want RUN", resp.Data.JobStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetJobOutput_FromOffset(t *testing.T) {
|
||||
resetJobsState()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
setupTestJob(t, tmpDir)
|
||||
|
||||
router := setupTestRouter()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/xce/v1/jobs/1001/output?offset=12", nil)
|
||||
req.Header.Set("Authorization", testToken())
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Code int `json:"code"`
|
||||
Data OutputData `json:"data"`
|
||||
}
|
||||
decodeResponse(t, w, &resp)
|
||||
|
||||
if resp.Data.Content != "line3\nline4\nline5\n" {
|
||||
t.Errorf("content = %q, want %q", resp.Data.Content, "line3\nline4\nline5\n")
|
||||
}
|
||||
if resp.Data.Offset != 30 {
|
||||
t.Errorf("offset = %d, want 30", resp.Data.Offset)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetJobOutput_FileNotFound(t *testing.T) {
|
||||
resetJobsState()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
outFile := filepath.Join(tmpDir, "job_%J.out")
|
||||
|
||||
job := models.JobInfo{
|
||||
Unique_JobID: 2001,
|
||||
JobID: 2001,
|
||||
ArrayIdx: 0,
|
||||
SubmitUser: "testuser",
|
||||
JobStatus: 0x02,
|
||||
OutFile: outFile,
|
||||
}
|
||||
store.Jobs.Put(2001, job)
|
||||
|
||||
router := setupTestRouter()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/xce/v1/jobs/2001/output?lines=10", nil)
|
||||
req.Header.Set("Authorization", testToken())
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
var resp struct {
|
||||
Code int `json:"code"`
|
||||
}
|
||||
decodeResponse(t, w, &resp)
|
||||
|
||||
if resp.Code != common.ERROR_READ_FILE_CODE {
|
||||
t.Errorf("code = %d, want %d", resp.Code, common.ERROR_READ_FILE_CODE)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetJobOutput_NoOutputFileConfigured(t *testing.T) {
|
||||
resetJobsState()
|
||||
|
||||
job := models.JobInfo{
|
||||
Unique_JobID: 3001,
|
||||
JobID: 3001,
|
||||
ArrayIdx: 0,
|
||||
SubmitUser: "testuser",
|
||||
JobStatus: 0x02,
|
||||
OutFile: "",
|
||||
}
|
||||
store.Jobs.Put(3001, job)
|
||||
|
||||
router := setupTestRouter()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/xce/v1/jobs/3001/output?lines=10", nil)
|
||||
req.Header.Set("Authorization", testToken())
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
// resolveOutputPath uses the worker default (/tmp/job_0-0-0-3001_task0.out).
|
||||
// That file does not exist, so we expect a read error (501).
|
||||
var resp struct {
|
||||
Code int `json:"code"`
|
||||
}
|
||||
decodeResponse(t, w, &resp)
|
||||
|
||||
if resp.Code != common.ERROR_READ_FILE_CODE {
|
||||
t.Errorf("code = %d, want %d", resp.Code, common.ERROR_READ_FILE_CODE)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetJobOutput_DefaultPathFallback verifies that when OutFile is empty,
|
||||
// the worker's default output path is used and content is returned successfully.
|
||||
func TestGetJobOutput_DefaultPathFallback(t *testing.T) {
|
||||
resetJobsState()
|
||||
|
||||
// Use a real-ish packed job ID: cluster=1, apiserver=0, ts=1778311965, seq=0
|
||||
// FormatJobID → "1-0-1778311965-0"
|
||||
// WorkerDefaultOutputPath → "/tmp/job_1-0-1778311965-0_task0.out"
|
||||
const uniqueID = int64(0x0100000006A04BDD<<0) // MakeJobID(1,0,1778311965,0,0)
|
||||
// Use backend.MakeJobID directly for clarity; compute inline:
|
||||
// clusterID=1 at bit 56, timestamp=1778311965 at bit 20
|
||||
// packed = (1 << 56) | (1778311965 << 20)
|
||||
const packedID = int64((uint64(1) << 56) | (uint64(1778311965) << 20))
|
||||
|
||||
// Install a fetchFile that reads from a tmpdir-based path instead of /tmp
|
||||
// to avoid touching /tmp during tests.
|
||||
tmpDir := t.TempDir()
|
||||
workerDefault := backend.WorkerDefaultOutputPath(packedID, 0) // /tmp/job_1-0-1778311965-0_task0.out
|
||||
formattedID := backend.FormatJobID(packedID) // "1-0-1778311965-0"
|
||||
localFile := filepath.Join(tmpDir, "job_"+formattedID+"_task0.out")
|
||||
os.WriteFile(localFile, []byte("worker output line\n"), 0644)
|
||||
|
||||
// Override fetchFile for this test to redirect /tmp paths to tmpDir.
|
||||
origFetchFile := fetchFile
|
||||
fetchFile = func(jobid uint64, path string, offset uint64, maxBytes uint32) (*backend.FetchFileResult, error) {
|
||||
if path == workerDefault {
|
||||
path = localFile
|
||||
}
|
||||
return mockFetchFile(jobid, path, offset, maxBytes)
|
||||
}
|
||||
defer func() { fetchFile = origFetchFile }()
|
||||
|
||||
job := models.JobInfo{
|
||||
Unique_JobID: packedID,
|
||||
JobID: packedID,
|
||||
ArrayIdx: 0,
|
||||
SubmitUser: "testuser",
|
||||
JobStatus: 0x02, // RUN
|
||||
OutFile: "", // no OutFile — should fall back to worker default
|
||||
}
|
||||
store.Jobs.Put(uint64(packedID), job)
|
||||
|
||||
router := setupTestRouter()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
jobIDStr := fmt.Sprintf("%d", packedID)
|
||||
req, _ := http.NewRequest("GET", "/xce/v1/jobs/"+jobIDStr+"/output?lines=10", nil)
|
||||
req.Header.Set("Authorization", testToken())
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Data OutputData `json:"data"`
|
||||
}
|
||||
decodeResponse(t, w, &resp)
|
||||
|
||||
if resp.Data.Content != "worker output line\n" {
|
||||
t.Errorf("content = %q, want %q", resp.Data.Content, "worker output line\n")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetJobOutput_PlaceholderResolution(t *testing.T) {
|
||||
resetJobsState()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
// FormatJobID(4001) = "0-0-0-4001" for small test IDs
|
||||
outFile := filepath.Join(tmpDir, "job_0-0-0-4001.out")
|
||||
|
||||
job := models.JobInfo{
|
||||
Unique_JobID: 4001,
|
||||
JobID: 4001,
|
||||
ArrayIdx: 0,
|
||||
SubmitUser: "testuser",
|
||||
JobStatus: 0x02,
|
||||
OutFile: filepath.Join(tmpDir, "job_%J.out"),
|
||||
}
|
||||
store.Jobs.Put(4001, job)
|
||||
|
||||
os.WriteFile(outFile, []byte("placeholder resolved\n"), 0644)
|
||||
|
||||
router := setupTestRouter()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/xce/v1/jobs/4001/output?lines=10", nil)
|
||||
req.Header.Set("Authorization", testToken())
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Data OutputData `json:"data"`
|
||||
}
|
||||
decodeResponse(t, w, &resp)
|
||||
|
||||
if resp.Data.Content != "placeholder resolved\n" {
|
||||
t.Errorf("content = %q, want %q", resp.Data.Content, "placeholder resolved\n")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetJobOutput_JobNotFound(t *testing.T) {
|
||||
resetJobsState()
|
||||
|
||||
router := setupTestRouter()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/xce/v1/jobs/99999/output?lines=10", nil)
|
||||
req.Header.Set("Authorization", testToken())
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("expected 404, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetJobOutput_InvalidParams(t *testing.T) {
|
||||
resetJobsState()
|
||||
|
||||
router := setupTestRouter()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
url string
|
||||
expectCode int
|
||||
}{
|
||||
{"invalid offset", "/xce/v1/jobs/1001/output?offset=abc", http.StatusBadRequest},
|
||||
{"invalid lines zero", "/xce/v1/jobs/1001/output?lines=0", http.StatusBadRequest},
|
||||
{"invalid type", "/xce/v1/jobs/1001/output?type=combined", http.StatusBadRequest},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", tc.url, nil)
|
||||
req.Header.Set("Authorization", testToken())
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != tc.expectCode {
|
||||
t.Errorf("expected %d, got %d: %s", tc.expectCode, w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLastNLines(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input []byte
|
||||
n int
|
||||
expect string
|
||||
}{
|
||||
{"tail 2 of 5", []byte("a\nb\nc\nd\ne\n"), 2, "d\ne\n"},
|
||||
{"tail all", []byte("a\nb\n"), 100, "a\nb\n"},
|
||||
{"empty", []byte(""), 10, ""},
|
||||
{"no trailing newline", []byte("no newline"), 5, "no newline"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := lastNLines(tc.input, tc.n)
|
||||
if got != tc.expect {
|
||||
t.Errorf("lastNLines(%q, %d) = %q, want %q", tc.input, tc.n, got, tc.expect)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveOutputPath(t *testing.T) {
|
||||
// FormatJobID(123) = "0-0-0-123" for small packed IDs (cluster=0, apiserver=0, ts=0, seq=123)
|
||||
job := models.JobInfo{
|
||||
Unique_JobID: 123,
|
||||
JobID: 123,
|
||||
ArrayIdx: 2,
|
||||
SubmitUser: "alice",
|
||||
CWD: "/home/alice/work",
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"/tmp/job_%J.out", "/tmp/job_0-0-0-123.out"},
|
||||
{"/tmp/job_%J_%I.out", "/tmp/job_0-0-0-123_2.out"},
|
||||
{"/tmp/job_%U.out", "/tmp/job_alice.out"},
|
||||
{"/tmp/job_%C.out", "/tmp/job_/home/alice/work.out"},
|
||||
// empty path → worker default for task 0
|
||||
{"", "/tmp/job_0-0-0-123_task0.out"},
|
||||
{"/tmp/fixed.out", "/tmp/fixed.out"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.input, func(t *testing.T) {
|
||||
result := resolveOutputPath(tc.input, job)
|
||||
if result != tc.expected {
|
||||
t.Errorf("resolveOutputPath(%q) = %q, want %q", tc.input, result, tc.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetJobOutput_Stderr(t *testing.T) {
|
||||
resetJobsState()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
errFile := filepath.Join(tmpDir, "job_5001.err")
|
||||
|
||||
job := models.JobInfo{
|
||||
Unique_JobID: 5001,
|
||||
JobID: 5001,
|
||||
ArrayIdx: 0,
|
||||
SubmitUser: "testuser",
|
||||
JobStatus: 0x02,
|
||||
ErrFile: errFile,
|
||||
}
|
||||
store.Jobs.Put(5001, job)
|
||||
|
||||
os.WriteFile(errFile, []byte("stderr output\n"), 0644)
|
||||
|
||||
router := setupTestRouter()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/xce/v1/jobs/5001/output?type=stderr&lines=10", nil)
|
||||
req.Header.Set("Authorization", testToken())
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Data OutputData `json:"data"`
|
||||
}
|
||||
decodeResponse(t, w, &resp)
|
||||
|
||||
if resp.Data.Content != "stderr output\n" {
|
||||
t.Errorf("content = %q, want %q", resp.Data.Content, "stderr output\n")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetJobOutput_DoneJob(t *testing.T) {
|
||||
resetJobsState()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
outFile := filepath.Join(tmpDir, "job_6001.out")
|
||||
|
||||
job := models.JobInfo{
|
||||
Unique_JobID: 6001,
|
||||
JobID: 6001,
|
||||
ArrayIdx: 0,
|
||||
SubmitUser: "testuser",
|
||||
JobStatus: 0x08, // DONE
|
||||
OutFile: outFile,
|
||||
}
|
||||
store.Jobs.Put(6001, job)
|
||||
|
||||
os.WriteFile(outFile, []byte("finished output\n"), 0644)
|
||||
|
||||
router := setupTestRouter()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/xce/v1/jobs/6001/output?lines=10", nil)
|
||||
req.Header.Set("Authorization", testToken())
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Data OutputData `json:"data"`
|
||||
}
|
||||
decodeResponse(t, w, &resp)
|
||||
|
||||
if !resp.Data.Done {
|
||||
t.Error("done should be true for finished job")
|
||||
}
|
||||
if resp.Data.JobStatus != "DONE" {
|
||||
t.Errorf("job_status = %q, want DONE", resp.Data.JobStatus)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -125,6 +125,9 @@ func Router() *gin.Engine {
|
|||
jobsApi := r.Group("/xce/v1/jobs")
|
||||
{
|
||||
jobHandler := &controllers.JobController{}
|
||||
// Note: /:jobid/output must be registered before /:jobids to avoid
|
||||
// Gin matching "output" as a literal job ID.
|
||||
jobsApi.GET("/:jobids/output", controllers.RequirePermission("job:view"), jobHandler.GetJobOutput)
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@ Use 'xce jobs <action>' to submit/kill/stop/resume/checkpoint/restart jobs.`,
|
|||
cmd.AddCommand(NewResumeJobsCmd(configManager))
|
||||
cmd.AddCommand(NewCheckPointJobsCmd(configManager))
|
||||
cmd.AddCommand(NewRestartJobsCmd(configManager))
|
||||
// Add peek command to view job output
|
||||
cmd.AddCommand(NewPeekJobsCmd(configManager))
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
|
|
|||
153
src/frontend/xcecli/cmd/jobs/peek.go
Normal file
153
src/frontend/xcecli/cmd/jobs/peek.go
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
package jobs
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"xcecli/common/format"
|
||||
"xcecli/common/request"
|
||||
"xcecli/config"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// PeekQuerier implements the jobs peek command.
|
||||
type PeekQuerier struct {
|
||||
configManager *config.ConfigManager
|
||||
lines int
|
||||
stderr bool
|
||||
noFollow bool
|
||||
}
|
||||
|
||||
// NewPeekQuerier creates a new PeekQuerier.
|
||||
func NewPeekQuerier(configManager *config.ConfigManager) *PeekQuerier {
|
||||
return &PeekQuerier{
|
||||
configManager: configManager,
|
||||
lines: 100, // default show last 100 lines
|
||||
}
|
||||
}
|
||||
|
||||
// NewPeekJobsCmd creates the xce jobs peek command.
|
||||
func NewPeekJobsCmd(configManager *config.ConfigManager) *cobra.Command {
|
||||
querier := NewPeekQuerier(configManager)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "peek",
|
||||
Short: "Peek at job output",
|
||||
Long: `Peek at job output in real-time.
|
||||
|
||||
Examples:
|
||||
xce jobs peek jobid # stream stdout output
|
||||
xce jobs peek jobid -n 50 # initial show last 50 lines
|
||||
xce jobs peek jobid --err # view stderr output
|
||||
xce jobs peek jobid --no-follow # one-shot snapshot, no streaming`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) == 0 {
|
||||
fmt.Println("error: job ID is required")
|
||||
fmt.Println()
|
||||
cmd.Help()
|
||||
return nil
|
||||
}
|
||||
jobID := args[0]
|
||||
if err := querier.Run(jobID); err != nil {
|
||||
fmt.Println(strings.ToLower(err.Error()))
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
flags := cmd.Flags()
|
||||
flags.IntVarP(&querier.lines, "lines", "n", 100, "number of initial lines to show")
|
||||
flags.BoolVar(&querier.stderr, "err", false, "view stderr output instead of stdout")
|
||||
flags.BoolVar(&querier.noFollow, "no-follow", false, "one-shot snapshot, do not follow new output")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// Run executes the peek command.
|
||||
func (q *PeekQuerier) Run(jobID string) error {
|
||||
serverInfo, err := q.configManager.GetServerInfo()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Convert job ID to API format
|
||||
apiJobID, err := format.ToAPIJobID(jobID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid job ID %q: %w", jobID, err)
|
||||
}
|
||||
|
||||
apiClient := request.NewAPIClient(serverInfo.URL)
|
||||
apiClient.SetRootCAs(serverInfo.CACert)
|
||||
|
||||
// Determine stream type
|
||||
streamType := "stdout"
|
||||
if q.stderr {
|
||||
streamType = "stderr"
|
||||
}
|
||||
|
||||
// Setup signal handling for graceful exit
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
offset := int64(0)
|
||||
lines := q.lines
|
||||
|
||||
for {
|
||||
// Check for interrupt
|
||||
select {
|
||||
case <-sigChan:
|
||||
fmt.Println()
|
||||
return nil
|
||||
default:
|
||||
}
|
||||
|
||||
// Build query params
|
||||
params := map[string]string{
|
||||
"type": streamType,
|
||||
}
|
||||
if offset == 0 {
|
||||
params["lines"] = strconv.Itoa(lines)
|
||||
} else {
|
||||
params["offset"] = strconv.FormatInt(offset, 10)
|
||||
}
|
||||
|
||||
// Fetch output
|
||||
resp, err := apiClient.GetJobOutput(serverInfo.Token, serverInfo.CACert, apiJobID, params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check for error in response
|
||||
if resp.Code != 200 {
|
||||
return fmt.Errorf("job <%s>: %s", jobID, resp.Msg)
|
||||
}
|
||||
|
||||
// Print new content
|
||||
if resp.Data.Content != "" {
|
||||
fmt.Print(resp.Data.Content)
|
||||
}
|
||||
|
||||
// Update offset for next poll
|
||||
offset = resp.Data.Offset
|
||||
|
||||
// If done, exit
|
||||
if resp.Data.Done {
|
||||
return nil
|
||||
}
|
||||
|
||||
// If one-shot mode, exit after first fetch
|
||||
if q.noFollow {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Poll interval
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
80
src/frontend/xcecli/cmd/jobs/peek_test.go
Normal file
80
src/frontend/xcecli/cmd/jobs/peek_test.go
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
package jobs
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"xcecli/common/format"
|
||||
)
|
||||
|
||||
func TestPeekJobIDParsing(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
wantAPI string
|
||||
}{
|
||||
{"12345", "12345"},
|
||||
{"12345[0]", "12345[0]"},
|
||||
{"12345[2]", "12345[2]"},
|
||||
{"plain_id", "plain_id"}, // ToAPIJobID passes through non-numeric strings
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.input, func(t *testing.T) {
|
||||
apiID, err := format.ToAPIJobID(tc.input)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if apiID != tc.wantAPI {
|
||||
t.Errorf("apiID = %q, want %q", apiID, tc.wantAPI)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeekDefaults(t *testing.T) {
|
||||
q := NewPeekQuerier(nil)
|
||||
if q.lines != 100 {
|
||||
t.Errorf("default lines = %d, want 100", q.lines)
|
||||
}
|
||||
if q.stderr {
|
||||
t.Error("default stderr should be false")
|
||||
}
|
||||
if q.noFollow {
|
||||
t.Error("default noFollow should be false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeekStreamType(t *testing.T) {
|
||||
q := NewPeekQuerier(nil)
|
||||
|
||||
streamType := "stdout"
|
||||
if q.stderr {
|
||||
streamType = "stderr"
|
||||
}
|
||||
if streamType != "stdout" {
|
||||
t.Errorf("default stream type = %q, want stdout", streamType)
|
||||
}
|
||||
|
||||
q2 := NewPeekQuerier(nil)
|
||||
q2.stderr = true
|
||||
streamType = "stdout"
|
||||
if q2.stderr {
|
||||
streamType = "stderr"
|
||||
}
|
||||
if streamType != "stderr" {
|
||||
t.Errorf("stderr stream type = %q, want stderr", streamType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeekCommand_NoJobID(t *testing.T) {
|
||||
q := NewPeekQuerier(nil)
|
||||
|
||||
err := q.Run("")
|
||||
if err == nil {
|
||||
t.Error("expected error for empty job ID")
|
||||
}
|
||||
// Should fail on job ID parsing, not on config
|
||||
if !strings.Contains(strings.ToLower(err.Error()), "invalid") {
|
||||
t.Logf("error message (may vary): %v", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -170,6 +170,45 @@ func (c *APIClient) SubmitJob(token string, req *models.JobSubmitRequest) (*mode
|
|||
return &resp, nil
|
||||
}
|
||||
|
||||
// GetJobOutput retrieves job output from the API server.
|
||||
func (c *APIClient) GetJobOutput(token, certPath, jobID string, params map[string]string) (*models.JobOutputResponse, error) {
|
||||
var resp models.JobOutputResponse
|
||||
baseURL := c.BaseURL
|
||||
if strings.HasPrefix(baseURL, "https://") {
|
||||
c.Client.SetTLSClientConfig(&tls.Config{
|
||||
RootCAs: loadRootCAs(certPath),
|
||||
})
|
||||
}
|
||||
if idx := strings.Index(baseURL, "/xce/v1"); idx != -1 {
|
||||
baseURL = baseURL[:idx]
|
||||
}
|
||||
url := baseURL + "/xce/v1/jobs/" + jobID + "/output"
|
||||
|
||||
req := c.Client.R().
|
||||
SetHeader("Authorization", token).
|
||||
SetResult(&resp)
|
||||
|
||||
for k, v := range params {
|
||||
req.SetQueryParam(k, v)
|
||||
}
|
||||
|
||||
httpResp, err := req.Get(url)
|
||||
if err != nil {
|
||||
errMsg := ExtractErrorMessage(err)
|
||||
return nil, fmt.Errorf("get job output request failed: [%s]", errMsg)
|
||||
}
|
||||
|
||||
if httpResp.StatusCode() != http.StatusOK {
|
||||
logger.Info("get job output failed", zap.Any("resp", resp), zap.Int("status", httpResp.StatusCode()))
|
||||
if resp.Msg != "" {
|
||||
return &resp, fmt.Errorf("get job output failed: %s", resp.Msg)
|
||||
}
|
||||
return &resp, fmt.Errorf("get job output failed: status %d", httpResp.StatusCode())
|
||||
}
|
||||
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// extractErrorMessage extracts a user-friendly error message from various error types
|
||||
func ExtractErrorMessage(err error) string {
|
||||
if err == nil {
|
||||
|
|
|
|||
|
|
@ -515,3 +515,18 @@ func (e *ExitStatusValue) UnmarshalJSON(data []byte) error {
|
|||
*e = ExitStatusValue(s)
|
||||
return nil
|
||||
}
|
||||
|
||||
// JobOutputResponse represents the response from the job output API.
|
||||
type JobOutputResponse struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data JobOutputData `json:"data"`
|
||||
}
|
||||
|
||||
// JobOutputData represents the payload in a job output response.
|
||||
type JobOutputData struct {
|
||||
Content string `json:"content"`
|
||||
Offset int64 `json:"offset"`
|
||||
JobStatus string `json:"job_status"`
|
||||
Done bool `json:"done"`
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue