Skip to content

Commit 0b8a874

Browse files
authored
fix(exec): persist detach logs on runtime volume
Move detached exec logs and metadata to the shared runtime volume so post-mortem job inspection survives target-container crashes, while preserving legacy /tmp compatibility and sidecar read fallback.
1 parent 4241af5 commit 0b8a874

7 files changed

Lines changed: 290 additions & 48 deletions

File tree

docs/command-reference.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ agents can react without launching a diagnostic chain on every blip:
110110
- `--detach`: launches the command in the background and returns immediately. Prints per-pod launch details including job id, pid, log path, and metadata path.
111111
- When `--log-dir` is used with multiple declared groups, logs are written into per-group subdirectories under the requested path. `--log-dir` has no effect with `--detach` (detached jobs log inside the pod).
112112
- Detached exec preserves argv to the remote process; it is not flattened back into a single shell string. Detached `--script` uploads a per-pod temp file, launches it, and removes that temp file after completion.
113-
- Detached jobs store combined stdout/stderr and JSON metadata under `/tmp/okdev-exec/` in the target container. For large outputs (training logs, profiles, dumps), prefer `--log-dir` on the caller or redirect inside your command to a session volume; `/tmp` is typically a small tmpfs and heavy writers can fill it.
113+
- Detached jobs store combined stdout/stderr and JSON metadata under `/var/okdev/exec/` on the shared okdev-runtime volume, so logs and metadata survive a target-container crash (e.g. OOMKill) and remain readable through the sidecar. If that directory cannot be created or written by the container user (e.g. a user-supplied root-owned volume with a non-root container), the launcher falls back to the legacy `/tmp/okdev-exec/` with a notice on stderr — same lifetime semantics as before. For large outputs (training logs, profiles, dumps), prefer `--log-dir` on the caller or redirect inside your command to a session volume; the runtime volume is a node-local emptyDir and heavy writers can fill it.
114114
- The `pid` returned from `--detach` is the pid of your command itself (okdev re-parents the launcher so `$!` is your program's pid after `execve`). `kill <pid>` or `okdev exec --pod <pod> -- kill <pid>` therefore targets your command, not a wrapper shell.
115115
- When using `--detach`, pass the command you want okdev to launch directly. Do not add an extra `nohup ... &` inside the command string.
116116
- `--timeout`: per-pod command timeout (e.g., `30s`, `5m`). Pods exceeding the timeout are cancelled and reported as failed.
@@ -128,7 +128,8 @@ agents can react without launching a diagnostic chain on every blip:
128128
### `okdev jobs list [session]`
129129

130130
- Lists detached `okdev exec --detach` jobs across the session's running pods.
131-
- Reads job metadata from `/tmp/okdev-exec/*.json` in the target container.
131+
- Reads job metadata from `/var/okdev/exec/*.json` in the target container (plus the legacy `/tmp/okdev-exec/` location for jobs launched by older okdev versions).
132+
- If the target container is gone (e.g. OOMKilled), listing and `jobs logs` automatically retry through the `okdev-sidecar` container, which mounts the same runtime volume.
132133
- Text output is grouped by logical job id, with one row per detached launch showing job id, summarized state, pod count, earliest start time, and original command.
133134
- JSON output includes both the logical job summary and the per-pod `podStates` records.
134135
- State values: `running` (wrapper alive and user command in flight), `exited` (user command finished; exit code is recorded in `podStates` / JSON), and `orphaned` (metadata still says `running` but the pid has exited or been recycled - typically the wrapper was `SIGKILL`ed or the container was restarted before the completion metadata could be written). Grouped text summaries render forms such as `running(1/2)`, `exited(2/2)`, and `failed(1/2)`.
@@ -152,6 +153,7 @@ agents can react without launching a diagnostic chain on every blip:
152153
- `--label`: stream logs from pods matching label selectors.
153154
- `--exclude`: exclude specific pods from the selected set. Cannot be used with `--pod`.
154155
- `-f` / `--follow`: keep following until every pod in the job reaches a terminal state.
156+
- If the job's container is gone (e.g. OOMKilled), logs are read through the `okdev-sidecar` container instead — job logs live on the shared runtime volume and survive the container.
155157
- If some pod logs are unavailable, okdev still streams the logs it can read and reports the missing pods in a `FAILED:` footer before returning non-zero.
156158

157159
### `okdev jobs wait <job-id> [session]`

internal/cli/exec_jobs.go

Lines changed: 41 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,15 @@ func collectDetachJobs(ctx context.Context, client connect.ExecClient, namespace
171171
defer func() { <-sem }()
172172
var stdout bytes.Buffer
173173
err := connect.RunOnContainer(ctx, client, namespace, pod.Name, container, detachJobsCommand(), false, nil, &stdout, io.Discard)
174+
if err != nil && container != syncthingContainerName && isContainerUnavailableExecError(err) {
175+
// The target container is gone (OOMKilled, crashed). Job
176+
// metadata lives on the shared /var/okdev volume, so the
177+
// always-running sidecar can still read it.
178+
stdout.Reset()
179+
if sideErr := connect.RunOnContainer(ctx, client, namespace, pod.Name, syncthingContainerName, detachJobsCommand(), false, nil, &stdout, io.Discard); sideErr == nil {
180+
err = nil
181+
}
182+
}
174183
if err != nil {
175184
results <- podDetachJobsResult{pod: pod.Name, err: err}
176185
return
@@ -314,26 +323,44 @@ func detachJobsCommand() []string {
314323
// variable (rather than cmdline) is robust against PID reuse even when
315324
// the job's cmdline is the arbitrary user command. The find(1) call
316325
// also reaps stale .tmp files left behind by killed launchers/wrappers.
317-
script := `dir='` + detachMetadataDir + `'
318-
[ -d "$dir" ] || exit 0
319-
find "$dir" -maxdepth 1 -name '*.tmp' -type f -mmin +1 -delete 2>/dev/null || true
320-
for f in "$dir"/*.json; do
321-
[ -e "$f" ] || continue
322-
contents=$(cat "$f")
323-
pid=$(printf '%s' "$contents" | sed -n 's/.*"pid":\([0-9][0-9]*\).*/\1/p' | head -n1)
324-
job_id=$(basename "$f" .json)
325-
alive=0
326-
if [ -n "$pid" ] && [ -r "/proc/$pid/environ" ]; then
327-
if tr '\0' '\n' < "/proc/$pid/environ" 2>/dev/null | grep -Fqx "OKDEV_JOB_ID=$job_id"; then
328-
alive=1
326+
// Both the current metadata dir and the legacy /tmp location are scanned
327+
// so jobs launched by an older okdev remain visible after an upgrade.
328+
script := `for dir in '` + detachMetadataDir + `' '` + legacyDetachMetadataDir + `'; do
329+
[ -d "$dir" ] || continue
330+
find "$dir" -maxdepth 1 -name '*.tmp' -type f -mmin +1 -delete 2>/dev/null || true
331+
for f in "$dir"/*.json; do
332+
[ -e "$f" ] || continue
333+
contents=$(cat "$f")
334+
pid=$(printf '%s' "$contents" | sed -n 's/.*"pid":\([0-9][0-9]*\).*/\1/p' | head -n1)
335+
job_id=$(basename "$f" .json)
336+
alive=0
337+
if [ -n "$pid" ] && [ -r "/proc/$pid/environ" ]; then
338+
if tr '\0' '\n' < "/proc/$pid/environ" 2>/dev/null | grep -Fqx "OKDEV_JOB_ID=$job_id"; then
339+
alive=1
340+
fi
329341
fi
330-
fi
331-
printf '%s\t%s\n' "$alive" "$contents"
342+
printf '%s\t%s\n' "$alive" "$contents"
343+
done
332344
done
333345
`
334346
return []string{"sh", "-c", script}
335347
}
336348

349+
// isContainerUnavailableExecError reports whether an exec failure means the
350+
// container itself is gone or not running (kubelet/CRI phrasing varies), as
351+
// opposed to the command failing. Only then is the sidecar fallback useful.
352+
func isContainerUnavailableExecError(err error) bool {
353+
if err == nil {
354+
return false
355+
}
356+
msg := strings.ToLower(err.Error())
357+
return strings.Contains(msg, "container not found") ||
358+
strings.Contains(msg, "is not running") ||
359+
strings.Contains(msg, "not created or running") ||
360+
strings.Contains(msg, "stopped container") ||
361+
strings.Contains(msg, "container_exited")
362+
}
363+
337364
func parseDetachMetadataLines(raw string) ([]detachMetadata, error) {
338365
lines := strings.Split(raw, "\n")
339366
out := make([]detachMetadata, 0)

internal/cli/exec_jobs_test.go

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ import (
44
"bytes"
55
"context"
66
"errors"
7+
"fmt"
8+
"io"
79
"strings"
10+
"sync"
811
"testing"
912

1013
"github.com/acmore/okdev/internal/kube"
@@ -222,13 +225,115 @@ func TestDetachJobsCommandReconcilesAndReapsTmpFiles(t *testing.T) {
222225
// Best-effort cleanup of stale temp files left by killed wrappers.
223226
"-name '*.tmp'",
224227
"-mmin +1",
228+
// Jobs launched before the /var/okdev move must stay visible, so the
229+
// scan covers both the runtime-volume dir and the legacy /tmp dir.
230+
"'" + detachMetadataDir + "'",
231+
"'" + legacyDetachMetadataDir + "'",
225232
} {
226233
if !strings.Contains(cmd[2], want) {
227234
t.Fatalf("expected detach jobs command to contain %q, got %q", want, cmd[2])
228235
}
229236
}
230237
}
231238

239+
// fakeContainerExecClient routes exec results by "pod|container" so tests can
240+
// model a dead target container alongside a healthy sidecar.
241+
type fakeContainerExecClient struct {
242+
mu sync.Mutex
243+
calls []string
244+
outputs map[string]string
245+
errs map[string]error
246+
}
247+
248+
func (f *fakeContainerExecClient) ExecInteractive(ctx context.Context, namespace, pod string, tty bool, command []string, stdin io.Reader, stdout io.Writer, stderr io.Writer) error {
249+
return f.ExecInteractiveInContainer(ctx, namespace, pod, "", tty, command, stdin, stdout, stderr)
250+
}
251+
252+
func (f *fakeContainerExecClient) ExecInteractiveInContainer(ctx context.Context, namespace, pod, container string, tty bool, command []string, stdin io.Reader, stdout io.Writer, stderr io.Writer) error {
253+
key := pod + "|" + container
254+
f.mu.Lock()
255+
f.calls = append(f.calls, key)
256+
output := f.outputs[key]
257+
err := f.errs[key]
258+
f.mu.Unlock()
259+
if output != "" {
260+
fmt.Fprint(stdout, output)
261+
}
262+
return err
263+
}
264+
265+
func TestCollectDetachJobsFallsBackToSidecarWhenContainerGone(t *testing.T) {
266+
// An OOMKilled target container must not hide job metadata: it lives on
267+
// the shared /var/okdev volume, readable via the always-running sidecar.
268+
meta := "{\"jobId\":\"job-a\",\"pod\":\"okdev-sess-worker-0\",\"container\":\"pytorch\",\"pid\":100,\"command\":\"python train.py\",\"startedAt\":\"2026-07-05T03:00:00Z\",\"stdoutPath\":\"/var/okdev/exec/job-a.log\",\"stderrPath\":\"/var/okdev/exec/job-a.log\",\"metaPath\":\"/var/okdev/exec/job-a.json\",\"state\":\"running\"}\n"
269+
client := &fakeContainerExecClient{
270+
outputs: map[string]string{
271+
"okdev-sess-worker-0|" + syncthingContainerName: meta,
272+
},
273+
errs: map[string]error{
274+
"okdev-sess-worker-0|pytorch": errors.New(`container not found ("pytorch")`),
275+
},
276+
}
277+
pods := []kube.PodSummary{{Name: "okdev-sess-worker-0", Phase: "Running"}}
278+
rows, podErrors := collectDetachJobs(context.Background(), client, "default", pods, "pytorch", "", pdshDefaultFanout)
279+
if len(podErrors) != 0 {
280+
t.Fatalf("unexpected pod errors: %+v", podErrors)
281+
}
282+
if len(rows) != 1 || rows[0].JobID != "job-a" {
283+
t.Fatalf("expected job-a via sidecar fallback, got %+v", rows)
284+
}
285+
// The row keeps the job's original container so stop/signal still target
286+
// the recorded runtime, not the sidecar.
287+
if rows[0].Container != "pytorch" {
288+
t.Fatalf("expected row container pytorch, got %q", rows[0].Container)
289+
}
290+
wantCalls := []string{"okdev-sess-worker-0|pytorch", "okdev-sess-worker-0|" + syncthingContainerName}
291+
if len(client.calls) != 2 || client.calls[0] != wantCalls[0] || client.calls[1] != wantCalls[1] {
292+
t.Fatalf("expected calls %v, got %v", wantCalls, client.calls)
293+
}
294+
}
295+
296+
func TestCollectDetachJobsDoesNotFallBackOnCommandError(t *testing.T) {
297+
// Only container-unavailable errors justify the sidecar retry; a failing
298+
// list command must surface as a pod error without a second exec.
299+
client := &fakeContainerExecClient{
300+
errs: map[string]error{
301+
"okdev-sess-worker-0|pytorch": errors.New("command terminated with exit code 2"),
302+
},
303+
}
304+
pods := []kube.PodSummary{{Name: "okdev-sess-worker-0", Phase: "Running"}}
305+
rows, podErrors := collectDetachJobs(context.Background(), client, "default", pods, "pytorch", "", pdshDefaultFanout)
306+
if len(rows) != 0 {
307+
t.Fatalf("expected no rows, got %+v", rows)
308+
}
309+
if len(podErrors) != 1 || !strings.Contains(podErrors[0].Error, "exit code 2") {
310+
t.Fatalf("expected exit-code pod error, got %+v", podErrors)
311+
}
312+
if len(client.calls) != 1 {
313+
t.Fatalf("expected single exec attempt, got %v", client.calls)
314+
}
315+
}
316+
317+
func TestIsContainerUnavailableExecError(t *testing.T) {
318+
cases := []struct {
319+
err error
320+
want bool
321+
}{
322+
{nil, false},
323+
{errors.New(`container not found ("pytorch")`), true},
324+
{errors.New("container pytorch is not running"), true},
325+
{errors.New("container is not created or running"), true},
326+
{errors.New("cannot exec into a stopped container"), true},
327+
{errors.New("command terminated with exit code 1"), false},
328+
{errors.New("connection reset by peer"), false},
329+
}
330+
for _, tc := range cases {
331+
if got := isContainerUnavailableExecError(tc.err); got != tc.want {
332+
t.Fatalf("isContainerUnavailableExecError(%v) = %v, want %v", tc.err, got, tc.want)
333+
}
334+
}
335+
}
336+
232337
func TestCollectDetachJobsFiltersByJobID(t *testing.T) {
233338
client := &fakePdshExecClient{
234339
outputs: map[string]string{

internal/cli/jobs.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -574,6 +574,15 @@ func streamDetachJobLog(ctx context.Context, client detachJobClient, namespace s
574574
writer := newPrefixedWriter(prefix, out, mu)
575575
defer writer.Flush()
576576
err := client.StreamShInContainer(ctx, namespace, row.Pod, row.Container, script, writer, &stderr)
577+
if err != nil && row.Container != syncthingContainerName && isContainerUnavailableExecError(err) {
578+
// The job's container is gone (OOMKilled, crashed). Logs under
579+
// /var/okdev live on the shared runtime volume, so read them through
580+
// the always-running sidecar instead. Setup failures like these
581+
// happen before any output is streamed, so retrying cannot duplicate
582+
// log lines.
583+
stderr.Reset()
584+
err = client.StreamShInContainer(ctx, namespace, row.Pod, syncthingContainerName, script, writer, &stderr)
585+
}
577586
if err != nil && strings.TrimSpace(stderr.String()) != "" {
578587
return fmt.Errorf("%w: %s", err, strings.TrimSpace(stderr.String()))
579588
}

internal/cli/jobs_logs_wait_test.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,53 @@ func TestRunJobsWaitFailsOnNonZeroExit(t *testing.T) {
101101
}
102102
}
103103

104+
// deadContainerJobsClient wraps fakeJobsClient and fails log streams for the
105+
// named container, modeling an OOMKilled target while the sidecar stays up.
106+
type deadContainerJobsClient struct {
107+
*fakeJobsClient
108+
deadContainer string
109+
streamMu sync.Mutex
110+
streamCalls []string
111+
}
112+
113+
func (c *deadContainerJobsClient) StreamShInContainer(ctx context.Context, namespace, pod, container, script string, stdout, stderr io.Writer) error {
114+
c.streamMu.Lock()
115+
c.streamCalls = append(c.streamCalls, pod+"|"+container)
116+
c.streamMu.Unlock()
117+
if container == c.deadContainer {
118+
return fmt.Errorf("container not found (%q)", container)
119+
}
120+
return c.fakeJobsClient.StreamShInContainer(ctx, namespace, pod, container, script, stdout, stderr)
121+
}
122+
123+
func TestRunJobsLogsFallsBackToSidecarWhenContainerGone(t *testing.T) {
124+
client := &deadContainerJobsClient{
125+
fakeJobsClient: &fakeJobsClient{
126+
listOutputs: map[string][]string{
127+
"okdev-sess-worker-0": {
128+
detachMetadataJSON("job-oom", "okdev-sess-worker-0", "pytorch", 100, "orphaned", nil),
129+
},
130+
},
131+
streamPlans: map[string]fakeJobsStreamPlan{
132+
"okdev-sess-worker-0|read": {stdout: "last words\n"},
133+
},
134+
},
135+
deadContainer: "pytorch",
136+
}
137+
pods := []kube.PodSummary{{Name: "okdev-sess-worker-0", Phase: "Running"}}
138+
var out bytes.Buffer
139+
if err := runJobsLogs(context.Background(), client, "default", pods, "pytorch", "job-oom", pdshDefaultFanout, false, &out); err != nil {
140+
t.Fatalf("runJobsLogs: %v", err)
141+
}
142+
if !strings.Contains(out.String(), "last words") {
143+
t.Fatalf("expected log content via sidecar fallback, got %q", out.String())
144+
}
145+
wantCalls := []string{"okdev-sess-worker-0|pytorch", "okdev-sess-worker-0|" + syncthingContainerName}
146+
if len(client.streamCalls) != 2 || client.streamCalls[0] != wantCalls[0] || client.streamCalls[1] != wantCalls[1] {
147+
t.Fatalf("expected stream calls %v, got %v", wantCalls, client.streamCalls)
148+
}
149+
}
150+
104151
type fakeJobsClient struct {
105152
mu sync.Mutex
106153
listOutputs map[string][]string

internal/cli/pdsh.go

Lines changed: 47 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,28 @@ import (
2525
k8sexec "k8s.io/client-go/util/exec"
2626
)
2727

28-
const detachMetadataDir = "/tmp/okdev-exec"
28+
// detachMetadataDir lives on the okdev-runtime emptyDir volume (mounted at
29+
// /var/okdev in both the target container and the okdev-sidecar), so detached
30+
// job logs and metadata survive a container crash (e.g. OOMKill) and remain
31+
// readable through the sidecar. Containers without that mount (a --container
32+
// override) still work: mkdir -p creates the path on the container overlay,
33+
// with the same lifetime as the old /tmp location.
34+
const detachMetadataDir = "/var/okdev/exec"
35+
36+
// legacyDetachMetadataDir is where detached jobs launched by older okdev
37+
// versions keep their logs and metadata; listing scans it for compatibility.
38+
const legacyDetachMetadataDir = "/tmp/okdev-exec"
39+
2940
const detachPIDPlaceholder = "__OKDEV_DETACH_PID__"
3041
const detachExitPlaceholder = "__OKDEV_DETACH_EXIT__"
3142

43+
// detachDirPlaceholder stands in for the metadata directory in job paths and
44+
// JSON templates until the launcher picks the real directory on the pod:
45+
// detachMetadataDir when the runtime volume is writable, otherwise the legacy
46+
// /tmp location (e.g. a user-supplied okdev-runtime volume that a non-root
47+
// container user cannot write to).
48+
const detachDirPlaceholder = "__OKDEV_DETACH_DIR__"
49+
3250
func newExecCmd(opts *Options) *cobra.Command {
3351
var shell string
3452
var scriptPath string
@@ -1150,12 +1168,21 @@ func detachCommand(spec detachJobSpec) []string {
11501168
wrapper := detachWrapperScript(spec.JobID, spec.Argv, spec.Cleanup, spec.MetaPath, runningTemplate, completionTemplate)
11511169
script := fmt.Sprintf(
11521170
"set -eu\n"+
1153-
"log_path=%s\n"+
1154-
"meta_path=%s\n"+
1155-
"wrapper_path=%s\n"+
1171+
// Prefer the shared runtime volume so logs survive a container
1172+
// crash; fall back to the legacy /tmp location when the volume is
1173+
// absent or not writable by the container user (e.g. a
1174+
// user-supplied okdev-runtime volume with root-only permissions).
1175+
"dir=%s\n"+
1176+
"if ! mkdir -p \"$dir\" 2>/dev/null || [ ! -w \"$dir\" ]; then\n"+
1177+
" dir=%s\n"+
1178+
" echo \"okdev: detach dir not writable, using $dir\" >&2\n"+
1179+
" mkdir -p \"$dir\"\n"+
1180+
"fi\n"+
1181+
"log_path=\"$dir\"/%s\n"+
1182+
"meta_path=\"$dir\"/%s\n"+
1183+
"wrapper_path=\"$dir\"/%s\n"+
11561184
"launch_json=%s\n"+
1157-
"mkdir -p %s\n"+
1158-
"cat >\"$wrapper_path\" <<'OKDEV_DETACH_WRAPPER'\n%s\nOKDEV_DETACH_WRAPPER\n"+
1185+
"sed \"s|%s|$dir|g\" >\"$wrapper_path\" <<'OKDEV_DETACH_WRAPPER'\n%s\nOKDEV_DETACH_WRAPPER\n"+
11591186
"chmod 700 \"$wrapper_path\"\n"+
11601187
"nohup sh \"$wrapper_path\" >\"$log_path\" 2>&1 &\n"+
11611188
"wrapper_pid=$!\n"+
@@ -1186,13 +1213,16 @@ func detachCommand(spec detachJobSpec) []string {
11861213
" echo 'detached job metadata missing pid' >&2\n"+
11871214
" exit 1\n"+
11881215
"fi\n"+
1189-
"printf '%%s\\n' \"$launch_json\" | sed \"s/%s/$pid/g\"\n",
1190-
shellutil.Quote(spec.LogPath),
1191-
shellutil.Quote(spec.MetaPath),
1192-
shellutil.Quote(spec.ScriptPath),
1193-
shellutil.Quote(launchTemplate),
1216+
"printf '%%s\\n' \"$launch_json\" | sed \"s|%s|$dir|g; s/%s/$pid/g\"\n",
11941217
shellutil.Quote(detachMetadataDir),
1218+
shellutil.Quote(legacyDetachMetadataDir),
1219+
shellutil.Quote(filepath.Base(spec.LogPath)),
1220+
shellutil.Quote(filepath.Base(spec.MetaPath)),
1221+
shellutil.Quote(filepath.Base(spec.ScriptPath)),
1222+
shellutil.Quote(launchTemplate),
1223+
detachDirPlaceholder,
11951224
wrapper,
1225+
detachDirPlaceholder,
11961226
detachPIDPlaceholder,
11971227
)
11981228
return []string{"sh", "-c", script}
@@ -1282,9 +1312,12 @@ func newDetachJobSpec(jobID, pod, container, cmd string, argv []string, cleanupP
12821312
jobID = newDetachJobID()
12831313
}
12841314
startedAt := time.Now().UTC().Format(time.RFC3339)
1285-
logPath := filepath.Join(detachMetadataDir, jobID+".log")
1286-
metaPath := filepath.Join(detachMetadataDir, jobID+".json")
1287-
scriptPath := filepath.Join(detachMetadataDir, jobID+".sh")
1315+
// Paths carry detachDirPlaceholder; the launcher script resolves it to
1316+
// the real directory on the pod (runtime volume, or legacy /tmp when
1317+
// that volume is not writable) and seds it into the JSON templates.
1318+
logPath := detachDirPlaceholder + "/" + jobID + ".log"
1319+
metaPath := detachDirPlaceholder + "/" + jobID + ".json"
1320+
scriptPath := detachDirPlaceholder + "/" + jobID + ".sh"
12881321
return detachJobSpec{
12891322
JobID: jobID,
12901323
Pod: pod,

0 commit comments

Comments
 (0)