Skip to content

Commit 01431a5

Browse files
authored
feat: fetch remote FirecREST logs into sessions (#1136)
* feat: fetch remote logs into sessions * refactor: use metadata from api * refactor: use pointers from controller * fix: offset for partial lines * fix: stream duplication when stderr is the same as stdout or null
1 parent c5c8112 commit 01431a5

2 files changed

Lines changed: 172 additions & 7 deletions

File tree

internal/remote/firecrest/controller.go

Lines changed: 149 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,16 @@ type FirecrestRemoteSessionController struct {
6767

6868
// fakeStart if true, do not start the remote session and print debug information
6969
fakeStart bool
70+
71+
// stdoutPath and stderrPath are the paths to the Slurm stdout/stderr files on the cluster filesystem.
72+
stdoutPath string
73+
stderrPath string
74+
// stdoutOffset and stderrOffset track how many bytes have been consumed from each log file.
75+
stdoutOffset int
76+
stderrOffset int
77+
// stdoutBuf and stderrBuf hold partial lines between fetches.
78+
stdoutBuf bytes.Buffer
79+
stderrBuf bytes.Buffer
7080
}
7181

7282
func NewFirecrestRemoteSessionController(cfg config.RemoteSessionControllerConfig) (c *FirecrestRemoteSessionController, err error) {
@@ -133,7 +143,7 @@ func (c *FirecrestRemoteSessionController) Start(ctx context.Context) error {
133143
// Start a go routine to update the session status
134144
go c.periodicSessionStatus(ctx)
135145

136-
if err := c.recoverJobID(); err != nil {
146+
if err := c.recoverState(); err != nil {
137147
return err
138148
}
139149
// We recovered an existing job ID, do nothing
@@ -327,8 +337,38 @@ func (c *FirecrestRemoteSessionController) Start(ctx context.Context) error {
327337

328338
slog.Info("submitted job", "jobID", c.jobID)
329339

330-
// Save the job ID for recovery
331-
if err := c.saveJobID(); err != nil {
340+
// After submission, determine the log file paths from the job metadata.
341+
c.stdoutPath = path.Join(sessionPath, fmt.Sprintf("slurm-%s.out", c.jobID))
342+
c.stderrPath = path.Join(sessionPath, fmt.Sprintf("slurm-%s.err", c.jobID))
343+
344+
metaRes, err := c.client.GetJobMetadataComputeSystemNameJobsJobIdMetadataGetWithResponse(startCtx, c.systemName, c.jobID)
345+
if err != nil {
346+
slog.Warn("could not get job metadata, falling back to default log paths", "error", err)
347+
} else if metaRes.JSON200 == nil || metaRes.JSON200.Jobs == nil || len(*metaRes.JSON200.Jobs) == 0 {
348+
slog.Warn("job metadata response empty, falling back to default log paths")
349+
} else {
350+
meta := (*metaRes.JSON200.Jobs)[0]
351+
if meta.StandardOutput != nil && *meta.StandardOutput != "" {
352+
p := *meta.StandardOutput
353+
if !path.IsAbs(p) {
354+
p = path.Join(sessionPath, p)
355+
}
356+
c.stdoutPath = p
357+
}
358+
if meta.StandardError != nil && *meta.StandardError != "" {
359+
p := *meta.StandardError
360+
if !path.IsAbs(p) {
361+
p = path.Join(sessionPath, p)
362+
}
363+
c.stderrPath = p
364+
} else {
365+
// Slurm writes stderr to stdout when StandardError is not explicitly set.
366+
c.stderrPath = c.stdoutPath
367+
}
368+
}
369+
370+
// Save the state for recovery
371+
if err := c.saveState(); err != nil {
332372
return err
333373
}
334374

@@ -561,6 +601,14 @@ func (c *FirecrestRemoteSessionController) periodicSessionStatus(ctx context.Con
561601
} else {
562602
slog.Error("getCurrentStatus failed", "status", state, "error", err)
563603
}
604+
// Fetch any new session logs from the cluster filesystem.
605+
c.fetchSessionLogs(childCtx)
606+
// Persist offsets so we can resume after a restart.
607+
if c.jobID != "" {
608+
if err := c.saveState(); err != nil {
609+
slog.Warn("failed to save controller state", "error", err)
610+
}
611+
}
564612
}()
565613
}
566614
}
@@ -599,6 +647,88 @@ func (c *FirecrestRemoteSessionController) getCurrentStatus(ctx context.Context)
599647
return state, nil
600648
}
601649

650+
// fetchSessionLogs retrieves any new lines from the remote Slurm stdout/stderr files
651+
// via FirecREST and writes them to this container's stdout so they appear in kubectl logs.
652+
func (c *FirecrestRemoteSessionController) fetchSessionLogs(ctx context.Context) {
653+
if c.jobID == "" || c.stdoutPath == "" {
654+
return
655+
}
656+
657+
for _, stream := range c.streamsToFetch() {
658+
c.fetchLogStream(ctx, stream)
659+
}
660+
}
661+
662+
func (c *FirecrestRemoteSessionController) streamsToFetch() []string {
663+
streams := []string{"stdout"}
664+
if c.stderrPath != "" && c.stderrPath != c.stdoutPath {
665+
streams = append(streams, "stderr")
666+
}
667+
return streams
668+
}
669+
670+
func (c *FirecrestRemoteSessionController) fetchLogStream(ctx context.Context, stream string) {
671+
var filePath string
672+
var offset *int
673+
var buf *bytes.Buffer
674+
675+
switch stream {
676+
case "stdout":
677+
filePath = c.stdoutPath
678+
offset = &c.stdoutOffset
679+
buf = &c.stdoutBuf
680+
case "stderr":
681+
filePath = c.stderrPath
682+
offset = &c.stderrOffset
683+
buf = &c.stderrBuf
684+
default:
685+
slog.Warn("unknown stream type", "stream", stream)
686+
return
687+
}
688+
689+
size := 524288 // 512KB
690+
apiOffset := *offset + buf.Len()
691+
params := GetViewFilesystemSystemNameOpsViewGetParams{
692+
Path: filePath,
693+
Offset: &apiOffset,
694+
Size: &size,
695+
}
696+
697+
res, err := c.client.GetViewFilesystemSystemNameOpsViewGetWithResponse(ctx, c.systemName, &params)
698+
if err != nil {
699+
slog.Warn("failed to fetch session log", "stream", stream, "error", err)
700+
return
701+
}
702+
if res.JSON200 == nil || res.JSON200.Output == nil {
703+
// File not available yet or empty — this is expected early in the job lifecycle.
704+
return
705+
}
706+
707+
content := *res.JSON200.Output
708+
if content == "" {
709+
return
710+
}
711+
712+
// Append new content and flush any complete lines.
713+
if _, err := buf.WriteString(content); err != nil {
714+
slog.Warn("failed to buffer session log content", "stream", stream, "error", err)
715+
return
716+
}
717+
for {
718+
data := buf.Bytes()
719+
idx := bytes.IndexByte(data, '\n')
720+
if idx == -1 {
721+
break
722+
}
723+
line := string(data[:idx])
724+
if _, err := fmt.Fprintf(os.Stdout, "[session/%s] %s\n", stream, line); err != nil {
725+
slog.Warn("failed to write session log", "stream", stream, "error", err)
726+
}
727+
buf.Next(idx + 1)
728+
*offset += idx + 1
729+
}
730+
}
731+
602732
func (c *FirecrestRemoteSessionController) renderSessionScript(sessionScript string, fileSystems *[]FileSystem, secretsPath string) string {
603733
return renderSessionScriptStatic(sessionScript, c.partition, fileSystems, secretsPath)
604734
}
@@ -673,7 +803,7 @@ func addSessionMountsToScript(sessionScript string, fileSystems *[]FileSystem, s
673803
return strings.Replace(sessionScript, "#{{SESSION_MOUNTS_PLACEHOLDER}}", mountsStr, 1)
674804
}
675805

676-
func (c *FirecrestRemoteSessionController) saveJobID() error {
806+
func (c *FirecrestRemoteSessionController) saveState() error {
677807
if c.jobID == "" {
678808
return fmt.Errorf("cannot save, job ID is not defined")
679809
}
@@ -684,7 +814,11 @@ func (c *FirecrestRemoteSessionController) saveJobID() error {
684814
savePath := c.getSavePath()
685815

686816
state := savedState{
687-
JobID: c.jobID,
817+
JobID: c.jobID,
818+
StdoutPath: c.stdoutPath,
819+
StderrPath: c.stderrPath,
820+
StdoutOffset: c.stdoutOffset,
821+
StderrOffset: c.stderrOffset,
688822
}
689823
contents, err := json.Marshal(state)
690824
if err != nil {
@@ -699,7 +833,7 @@ func (c *FirecrestRemoteSessionController) deleteSavedState() error {
699833
return os.Remove(savePath)
700834
}
701835

702-
func (c *FirecrestRemoteSessionController) recoverJobID() error {
836+
func (c *FirecrestRemoteSessionController) recoverState() error {
703837
contents, err := os.ReadFile(c.getSavePath())
704838
if err != nil {
705839
return nil
@@ -714,11 +848,19 @@ func (c *FirecrestRemoteSessionController) recoverJobID() error {
714848
c.jobID = state.JobID
715849
slog.Info("recovered job ID", "jobID", c.jobID)
716850
}
851+
c.stdoutPath = state.StdoutPath
852+
c.stderrPath = state.StderrPath
853+
c.stdoutOffset = state.StdoutOffset
854+
c.stderrOffset = state.StderrOffset
717855
return nil
718856
}
719857

720858
type savedState struct {
721-
JobID string `json:"job_id"`
859+
JobID string `json:"job_id"`
860+
StdoutPath string `json:"stdout_path,omitempty"`
861+
StderrPath string `json:"stderr_path,omitempty"`
862+
StdoutOffset int `json:"stdout_offset,omitempty"`
863+
StderrOffset int `json:"stderr_offset,omitempty"`
722864
}
723865

724866
func (c *FirecrestRemoteSessionController) getSaveDirPath() string {

internal/remote/firecrest/controller_test.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,26 @@ func TestRenderSessionScriptStatic(t *testing.T) {
5959
assert.Contains(t, foundMounts, "\"/users:/home/users:ro\"")
6060
assert.Contains(t, foundMounts, "\"/secrets:/secrets:ro\"")
6161
}
62+
63+
func TestStreamsToFetch(t *testing.T) {
64+
tests := []struct {
65+
name string
66+
stdoutPath string
67+
stderrPath string
68+
want []string
69+
}{
70+
{"different paths", "/out", "/err", []string{"stdout", "stderr"}},
71+
{"same path", "/out", "/out", []string{"stdout"}},
72+
{"empty stderr", "/out", "", []string{"stdout"}},
73+
{"stderr explicitly eq stdout", "/out", "/out", []string{"stdout"}},
74+
}
75+
for _, tt := range tests {
76+
t.Run(tt.name, func(t *testing.T) {
77+
c := &FirecrestRemoteSessionController{
78+
stdoutPath: tt.stdoutPath,
79+
stderrPath: tt.stderrPath,
80+
}
81+
assert.Equal(t, tt.want, c.streamsToFetch())
82+
})
83+
}
84+
}

0 commit comments

Comments
 (0)