diff --git a/CLAUDE.md b/CLAUDE.md index 890d669..a0190ec 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,6 +12,7 @@ The application follows a command-line interface pattern using the Cobra library - **main.go**: Core CLI structure with command definitions and configuration management - **auth.go**: OAuth2 device flow authentication with JWT token handling +- **jobs.go**: Job result operations (list and download job files) via the Hasura REST job endpoint and the job file download endpoint - **datasets.go**: Dataset operations (list, download, upload, status) with REST API integration - **registries.go**: Registry operations (list, config, add, update, registrator) with REST API integration - **packages.go**: Package operations (search, dependency) with REST API primary path (`/api/v1/ui/packages/info`), GraphQL fallback, and documentation API (`/docs/{registry}/{package}/stable/pkg.json`) @@ -45,6 +46,7 @@ The application follows a command-line interface pattern using the Cobra library 3. **Command Structure**: - `jh auth`: Authentication commands (login, refresh, status, env) + - `jh job result`: Job result files (list, download) - `jh dataset`: Dataset operations (list, download, upload, status) - `jh registry`: Registry operations (list, config — all via REST API) - `jh registry config`: Show registry JSON config by name; subcommands add/update accept JSON via stdin or `--file` @@ -134,6 +136,16 @@ go run . package dependency JuMP --json go run . package info JuMP --json ``` +### Test job result operations +```bash +go run . job result list jr-abc123xyz +go run . job result list --all jr-abc123xyz +go run . job result list --json jr-abc123xyz +go run . job result download jr-abc123xyz +go run . job result download jr-abc123xyz results.tar.gz -o previous-run.tar.gz +go run . job result download jr-abc123xyz code.jl --type source -o src/ +``` + ### Test registry operations ```bash go run . registry list @@ -562,6 +574,15 @@ jh run setup ## Implementation Details +### Job Results (`jobs.go`) + +- `fetchJobDetails` GETs `/api/rest/jobs/{jobname}` (Hasura REST, `X-Hasura-Role: jhuser`); the response is `{"details": [job]}`, an empty list meaning the job does not exist or is not visible. Each job's `files` entries have `name`, `type` (`input`, `source`, `project`, `result`), `size` and `hash` (both null while an upload is in progress) and `upload_timestamp` +- `jh job result list` shows `result` files by default, `--all` for every type; `--json` prints `{"job", "status", "files"}` +- `planJobDownloads` validates `--type`, selects files (download-all skips pending files with a stderr notice; a named pending file is an error) and resolves every destination up front, rejecting two files that would land on the same path. Download-all always treats `-o` as a directory; a named download treats it as a file unless it is an existing directory or ends in a separator +- `downloadJobFile` GETs `/jobs/{jobname}/files/{filename}?filetype=`, which 302-redirects to a short-lived presigned object-store URL. `newJobHTTPClient` strips `Authorization` and `Referer` on every redirect: Go only strips Authorization cross-domain, and the object store rejects a second auth mechanism. It bounds only `ResponseHeaderTimeout`, not the whole request, so large downloads are not cut off +- Downloads stream to a temp file in the destination directory, are checked against the recorded `size` and `sha2_256` hash (stored as hex; base64 also accepted), then moved into place. Files with no usable hash are reported as "(not verified)". Without `--force`, `moveJobFileIntoPlace` uses `os.Link` so a file created at the destination during the download is not replaced +- Destination file names use only the base name of the server-supplied file name and must pass `filepath.IsLocal` (which also rejects Windows device names), so they cannot escape the output directory + ### Registry Operations (`registries.go`) **Shared helpers:** diff --git a/README.md b/README.md index 9abfda9..ba64f05 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ A command-line interface for interacting with JuliaHub, a platform for Julia com - **Authentication**: OAuth2 device flow authentication with JWT token handling - **Dataset Management**: List, download, upload, and check status of datasets +- **Job Results**: List and download a job's result files (and its other input/source/project files) - **Package Management**: Search and explore Julia packages across registries via REST API (GraphQL fallback), with dependency analysis - **Registry Management**: List, add, and update Julia package registries - **Project Management**: List and filter projects using GraphQL API @@ -155,6 +156,11 @@ go build -o jh . - `jh dataset upload [dataset-id] ` - Upload a dataset - `jh dataset status [version]` - Show dataset status +### Job Results (`jh job result`) + +- `jh job result list ` - List a job's result files (`--all` for every job file, `--json` for scripts) +- `jh job result download [file-name]` - Download one or all result files (`-o` destination, `--force` to overwrite, `--type` for other job file types) + ### Package Management (`jh package`) - `jh package search [search-term]` - Search for Julia packages @@ -360,6 +366,27 @@ jh dataset upload --new ./my-data.tar.gz jh dataset upload my-dataset ./updated-data.tar.gz ``` +### Job Result Operations + +A job produces a result file by setting `ENV["RESULTS_FILE"]` to a file path; JuliaHub uploads it when the job finishes. + +```bash +# List a job's result files +jh job result list jr-abc123xyz + +# List every file attached to the job, with its type +jh job result list --all jr-abc123xyz + +# Download all result files into the current directory +jh job result download jr-abc123xyz + +# Download one result file to a specific path (e.g. to reuse a previous run's output) +jh job result download jr-abc123xyz results.tar.gz -o previous-run.tar.gz + +# Download into a directory, overwriting existing files +jh job result download jr-abc123xyz -o results/ --force +``` + ### Package Operations ```bash diff --git a/e2e/job_test.go b/e2e/job_test.go index 1e343e2..493441b 100644 --- a/e2e/job_test.go +++ b/e2e/job_test.go @@ -21,3 +21,18 @@ func TestJobList(t *testing.T) { t.Error("job list produced no output") } } + +// `jh job result list` against a job name that cannot exist: exercises the real +// GET /api/rest/jobs/{jobname} response shape (an empty "details" list) without +// depending on a job with results on the instance. +func TestJobResultListUnknownJob(t *testing.T) { + requireCreds(t) + res := runJH(t, "job", "result", "list", "jr-jhe2enonexistent") + if res.exitCode == 0 { + t.Fatalf("job result list for an unknown job exited 0\nstdout: %s", res.stdout) + } + skipIfUnsupported(t, res) + if !strings.Contains(res.stderr, "not found") { + t.Errorf("want a not-found error, got stderr: %s", res.stderr) + } +} diff --git a/jobs.go b/jobs.go new file mode 100644 index 0000000..e141a68 --- /dev/null +++ b/jobs.go @@ -0,0 +1,512 @@ +package main + +import ( + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "net/http" + "net/url" + "os" + "path/filepath" + "slices" + "sort" + "strings" + "time" + "unicode/utf8" +) + +// jobResultFileType is the file type the job runtime gives the file named by +// the job's RESULTS_FILE environment variable. +const jobResultFileType = "result" + +// jobFileTypes are the job file types JuliaHub records. +var jobFileTypes = []string{"input", "source", "project", jobResultFileType} + +// JobFileHash is the hash JuliaHub records for an uploaded job file, e.g. +// Algorithm "sha2_256" with a hex-encoded Value. Both may be null while the +// upload is still in progress. +type JobFileHash struct { + Algorithm *string `json:"algorithm"` + Value *string `json:"value"` +} + +// JobFile is one entry of a job's "files" list: inputs, sources, project +// environment files and results, identified by (job, type, name). +type JobFile struct { + Name string `json:"name"` + Type string `json:"type"` + Size *int64 `json:"size"` + Hash *JobFileHash `json:"hash"` + UploadTimestamp string `json:"upload_timestamp"` +} + +// JobDetails is the subset of GET /api/rest/jobs/{jobname} the CLI uses. +type JobDetails struct { + JobName string `json:"jobname"` + Alias *string `json:"jobname_alias"` + Status string `json:"status"` + Files []JobFile `json:"files"` +} + +type jobDetailsResponse struct { + Details []JobDetails `json:"details"` +} + +// fetchJobDetails fetches a job by name (e.g. jr-abc123) from the Hasura REST +// endpoint, the same one JuliaHub.jl's JuliaHub.job uses. +func fetchJobDetails(client *http.Client, baseURL, idToken, jobName string) (*JobDetails, error) { + req, err := http.NewRequest("GET", baseURL+"/api/rest/jobs/"+url.PathEscape(jobName), nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+idToken) + req.Header.Set("Accept", "application/json") + req.Header.Set("X-Hasura-Role", "jhuser") + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to make request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("request failed (status %d): %s", resp.StatusCode, string(body)) + } + + var parsed jobDetailsResponse + if err := json.Unmarshal(body, &parsed); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + switch len(parsed.Details) { + case 0: + return nil, fmt.Errorf("job '%s' not found", jobName) + case 1: + return &parsed.Details[0], nil + default: + return nil, fmt.Errorf("unexpected response: %d jobs named '%s'", len(parsed.Details), jobName) + } +} + +// filterJobFiles returns the files of the given type (all files when fileType +// is empty), sorted by type then name. +func filterJobFiles(files []JobFile, fileType string) []JobFile { + var out []JobFile + for _, f := range files { + if fileType == "" || f.Type == fileType { + out = append(out, f) + } + } + sort.SliceStable(out, func(i, j int) bool { + if out[i].Type != out[j].Type { + return out[i].Type < out[j].Type + } + return out[i].Name < out[j].Name + }) + return out +} + +// formatBytes renders a byte count with a binary unit, e.g. 1.5 KiB. +func formatBytes(n int64) string { + const unit = 1024 + if n < unit { + return fmt.Sprintf("%d B", n) + } + div, exp := int64(unit), 0 + for m := n / unit; m >= unit; m /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp]) +} + +func jobFileSizeLabel(f JobFile) string { + if f.Size == nil { + return "pending" + } + return formatBytes(*f.Size) +} + +func jobDisplayName(job *JobDetails) string { + if job.Alias != nil && *job.Alias != "" { + return fmt.Sprintf("%s (%s)", job.JobName, *job.Alias) + } + return job.JobName +} + +func printJobFiles(w io.Writer, job *JobDetails, files []JobFile, showType bool) { + fmt.Fprintf(w, "%s %s %s\n", bold(jobDisplayName(job)), dim(job.Status), dim("("+formatCount(len(files))+" files)")) + if len(files) == 0 { + if showType { + fmt.Fprintln(w, "No files.") + } else { + fmt.Fprintln(w, "No result files. A job uploads one when it sets ENV[\"RESULTS_FILE\"] to a file path.") + } + return + } + + nameWidth := len("NAME") + for _, f := range files { + if n := utf8.RuneCountInString(f.Name); n > nameWidth { + nameWidth = n + } + } + if nameWidth > 60 { + nameWidth = 60 + } + + header := pad("NAME", nameWidth) + " " + padLeft("SIZE", 10) + " UPLOADED" + if showType { + header = pad("TYPE", 8) + " " + header + } + fmt.Fprintln(w, bold(header)) + for _, f := range files { + line := pad(truncate(f.Name, nameWidth), nameWidth) + " " + padLeft(jobFileSizeLabel(f), 10) + " " + dim(formatTimestamp(f.UploadTimestamp)) + if showType { + line = pad(f.Type, 8) + " " + line + } + fmt.Fprintln(w, line) + } +} + +// jobBaseURL is the https base URL for a normalized server name. +func jobBaseURL(server string) string { + return "https://" + server +} + +func newJobHTTPClient() *http.Client { + // No overall Client.Timeout: it would also bound reading the body, and abort + // large downloads over slow links. Only waiting for response headers, the + // phase that can hang silently, is bounded. + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.ResponseHeaderTimeout = 60 * time.Second + return &http.Client{ + Transport: transport, + // The file endpoint 302-redirects to a presigned object-store URL. + // The signature is in the query string; also sending our bearer token + // would be a second auth mechanism the object store rejects, and it + // must not leak to the storage host. Go only strips Authorization on + // cross-domain redirects, so drop it unconditionally. Referer would + // tell the storage host the job and file names, so drop that too. + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) >= 10 { + return errors.New("stopped after 10 redirects") + } + req.Header.Del("Authorization") + req.Header.Del("Referer") + return nil + }, + } +} + +func listJobResults(server, jobName string, all, asJSON bool) error { + token, err := ensureValidToken() + if err != nil { + return fmt.Errorf("authentication required: %w", err) + } + client := &http.Client{Timeout: 30 * time.Second} + job, err := fetchJobDetails(client, jobBaseURL(server), token.IDToken, jobName) + if err != nil { + return err + } + + fileType := jobResultFileType + if all { + fileType = "" + } + files := filterJobFiles(job.Files, fileType) + + if asJSON { + if files == nil { + files = []JobFile{} + } + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(map[string]any{ + "job": job.JobName, + "status": job.Status, + "files": files, + }) + } + printJobFiles(os.Stdout, job, files, all) + return nil +} + +// jobFilePending reports whether a file's upload is still in progress; its +// size (and hash) are null until it completes. +func jobFilePending(f JobFile) bool { + return f.Size == nil +} + +// selectJobFiles picks the files to download: the named file of fileType, or +// every file of fileType when name is empty. Files still being uploaded are +// returned separately as pending when no name is given, and are an error when +// named explicitly. +func selectJobFiles(job *JobDetails, fileType, name string) (files, pending []JobFile, err error) { + candidates := filterJobFiles(job.Files, fileType) + if len(candidates) == 0 { + return nil, nil, fmt.Errorf("job '%s' (%s) has no %s files", job.JobName, job.Status, fileType) + } + if name == "" { + for _, f := range candidates { + if jobFilePending(f) { + pending = append(pending, f) + } else { + files = append(files, f) + } + } + if len(files) == 0 { + return nil, pending, fmt.Errorf("job '%s' (%s) has no completed %s files; %d still uploading", job.JobName, job.Status, fileType, len(pending)) + } + return files, pending, nil + } + for _, f := range candidates { + if f.Name == name { + if jobFilePending(f) { + return nil, nil, fmt.Errorf("'%s' of job '%s' (%s) is still being uploaded", name, job.JobName, job.Status) + } + return []JobFile{f}, nil, nil + } + } + var names []string + for _, f := range candidates { + names = append(names, f.Name) + } + return nil, nil, fmt.Errorf("job '%s' has no %s file named '%s' (available: %s)", job.JobName, fileType, name, strings.Join(names, ", ")) +} + +// jobFileDestination resolves where a file is written. When downloading all +// files (multiple), output is a directory (created if needed), however many +// files there are. For a named file, an existing directory or a path ending in +// a separator receives the file under its own name; anything else is the +// destination file path. An empty output means the current directory. +func jobFileDestination(output, fileName string, multiple bool) (string, error) { + // Job file names come from the server; never let one escape the target + // directory. IsLocal also rejects Windows reserved names such as NUL. + base := filepath.Base(filepath.FromSlash(fileName)) + if base == "." || !filepath.IsLocal(base) { + return "", fmt.Errorf("refusing to write file with unsafe name '%s'", fileName) + } + if output == "" { + return base, nil + } + if multiple || strings.HasSuffix(output, "/") || strings.HasSuffix(output, string(filepath.Separator)) { + return filepath.Join(output, base), nil + } + if info, err := os.Stat(output); err == nil && info.IsDir() { + return filepath.Join(output, base), nil + } + return output, nil +} + +// decodeJobFileHash decodes a recorded SHA-256 hash. JuliaHub stores job file +// hashes as hex; base64 is accepted too since JuliaHub.jl documents FileHash +// values as base64. The digest length keeps the two apart: a 64-character hex +// string decodes as base64 to 48 bytes, not 32, and a 44-character base64 +// digest (with its "=" padding) is never valid hex. +func decodeJobFileHash(value string) ([]byte, error) { + if b, err := hex.DecodeString(value); err == nil && len(b) == sha256.Size { + return b, nil + } + b, err := base64.StdEncoding.DecodeString(value) + if err != nil { + return nil, err + } + if len(b) != sha256.Size { + return nil, fmt.Errorf("expected a %d-byte SHA-256 digest, got %d bytes", sha256.Size, len(b)) + } + return b, nil +} + +// verifyJobFileHash checks sum (a SHA-256 digest) against the recorded hash +// when JuliaHub recorded a sha2_256 one, and reports whether it did. Other, +// missing or empty hashes are skipped. +func verifyJobFileHash(f JobFile, sum []byte) (verified bool, err error) { + if f.Hash == nil || f.Hash.Algorithm == nil || f.Hash.Value == nil || *f.Hash.Algorithm != "sha2_256" || *f.Hash.Value == "" { + return false, nil + } + want, err := decodeJobFileHash(*f.Hash.Value) + if err != nil { + return false, fmt.Errorf("invalid recorded hash for '%s': %w", f.Name, err) + } + if string(want) != string(sum) { + return false, fmt.Errorf("checksum mismatch for '%s': expected sha256 %x, got %x", f.Name, want, sum) + } + return true, nil +} + +func errJobFileExists(dest string) error { + return fmt.Errorf("%s already exists (use --force to overwrite)", dest) +} + +// moveJobFileIntoPlace moves a finished download from tmpName to dest. Without +// force it never replaces a file, including one created during the download: +// os.Link fails atomically when dest exists, unlike os.Rename. Where hard links +// are unsupported it falls back to re-checking dest just before the rename. +// The caller removes tmpName. +func moveJobFileIntoPlace(tmpName, dest string, force bool) error { + if !force { + err := os.Link(tmpName, dest) + if err == nil { + return nil + } + if errors.Is(err, fs.ErrExist) { + return errJobFileExists(dest) + } + if _, err := os.Lstat(dest); err == nil { + return errJobFileExists(dest) + } + } + if err := os.Rename(tmpName, dest); err != nil { + return fmt.Errorf("failed to move download into place: %w", err) + } + return nil +} + +// jobFileDownload is the outcome of downloading one job file. +type jobFileDownload struct { + Bytes int64 + Verified bool // checked against a recorded SHA-256 hash +} + +// downloadJobFile streams one job file to dest via a temporary file in the +// same directory, checking its size and hash before moving it into place. It +// refuses to overwrite dest unless force is set. +func downloadJobFile(client *http.Client, baseURL, idToken, jobName string, f JobFile, dest string, force bool) (jobFileDownload, error) { + var res jobFileDownload + if !force { + if _, err := os.Stat(dest); err == nil { + return res, errJobFileExists(dest) + } + } + + u := fmt.Sprintf("%s/jobs/%s/files/%s?filetype=%s", baseURL, url.PathEscape(jobName), url.PathEscape(f.Name), url.QueryEscape(f.Type)) + req, err := http.NewRequest("GET", u, nil) + if err != nil { + return res, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+idToken) + + resp, err := client.Do(req) + if err != nil { + return res, fmt.Errorf("failed to download '%s': %w", f.Name, err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return res, fmt.Errorf("download of '%s' failed (status %d): %s", f.Name, resp.StatusCode, strings.TrimSpace(string(body))) + } + + dir := filepath.Dir(dest) + if err := os.MkdirAll(dir, 0755); err != nil { + return res, fmt.Errorf("failed to create directory %s: %w", dir, err) + } + tmp, err := os.CreateTemp(dir, "."+filepath.Base(dest)+".*.tmp") + if err != nil { + return res, fmt.Errorf("failed to create temporary file: %w", err) + } + tmpName := tmp.Name() + defer os.Remove(tmpName) // no-op after a successful rename + + h := sha256.New() + n, err := io.Copy(io.MultiWriter(tmp, h), resp.Body) + if err == nil { + // CreateTemp makes the file 0600; give it ordinary download permissions. + err = tmp.Chmod(0644) + } + if cerr := tmp.Close(); err == nil { + err = cerr + } + if err != nil { + return res, fmt.Errorf("failed to write '%s': %w", f.Name, err) + } + if f.Size != nil && n != *f.Size { + return res, fmt.Errorf("size mismatch for '%s': expected %d bytes, got %d", f.Name, *f.Size, n) + } + verified, err := verifyJobFileHash(f, h.Sum(nil)) + if err != nil { + return res, err + } + if err := moveJobFileIntoPlace(tmpName, dest, force); err != nil { + return res, err + } + return jobFileDownload{Bytes: n, Verified: verified}, nil +} + +// jobDownload is one planned file download. +type jobDownload struct { + File JobFile + Dest string +} + +// planJobDownloads selects the files to download and resolves each one's +// destination before anything is written, so a bad request fails up front. It +// also returns the pending files skipped when downloading all files. +func planJobDownloads(job *JobDetails, fileType, fileName, output string) ([]jobDownload, []JobFile, error) { + if !slices.Contains(jobFileTypes, fileType) { + return nil, nil, fmt.Errorf("invalid file type '%s' (want one of: %s)", fileType, strings.Join(jobFileTypes, ", ")) + } + files, pending, err := selectJobFiles(job, fileType, fileName) + if err != nil { + return nil, nil, err + } + // Decide by the request, not by how many files the job has, so -o has the + // same meaning for every job. + multiple := fileName == "" + seen := map[string]string{} + var plan []jobDownload + for _, f := range files { + dest, err := jobFileDestination(output, f.Name, multiple) + if err != nil { + return nil, nil, err + } + if prev, ok := seen[dest]; ok { + return nil, nil, fmt.Errorf("'%s' and '%s' would both be saved as %s", prev, f.Name, dest) + } + seen[dest] = f.Name + plan = append(plan, jobDownload{File: f, Dest: dest}) + } + return plan, pending, nil +} + +func downloadJobResults(server, jobName, fileName, output, fileType string, force bool) error { + token, err := ensureValidToken() + if err != nil { + return fmt.Errorf("authentication required: %w", err) + } + baseURL := jobBaseURL(server) + client := newJobHTTPClient() + + job, err := fetchJobDetails(&http.Client{Timeout: 30 * time.Second}, baseURL, token.IDToken, jobName) + if err != nil { + return err + } + plan, pending, err := planJobDownloads(job, fileType, fileName, output) + if err != nil { + return err + } + for _, f := range pending { + fmt.Fprintf(os.Stderr, "Skipping %s: upload in progress\n", f.Name) + } + + for _, d := range plan { + res, err := downloadJobFile(client, baseURL, token.IDToken, job.JobName, d.File, d.Dest, force) + if err != nil { + return err + } + note := "" + if !res.Verified { + note = " (not verified: no SHA-256 hash recorded)" + } + fmt.Fprintf(os.Stderr, "Downloaded %s (%s) to %s%s\n", d.File.Name, formatBytes(res.Bytes), d.Dest, note) + } + return nil +} diff --git a/jobs_test.go b/jobs_test.go new file mode 100644 index 0000000..ada3ce5 --- /dev/null +++ b/jobs_test.go @@ -0,0 +1,426 @@ +package main + +import ( + "bytes" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +// A job in the shape GET /api/rest/jobs/{jobname} returns (trimmed), with a +// result file, input/source files, and a result whose upload is incomplete. +const sampleJobDetails = `{"details":[{ + "jobname":"jr-abc123","jobname_alias":"nightly fit","status":"Completed", + "outputs":"{}","files":[ + {"name":"code.jl","type":"input","size":3,"hash":{"algorithm":"sha2_256","value":"AAAA"},"upload_timestamp":"2025-03-15T07:56:51+00:00"}, + {"name":"code.jl","type":"source","size":3,"hash":{"algorithm":"sha2_256","value":"AAAA"},"upload_timestamp":"2025-03-15T07:56:51+00:00"}, + {"name":"outdir.tar.gz","type":"result","size":632143,"hash":{"algorithm":"sha2_256","value":"BBBB"},"upload_timestamp":"2025-03-15T07:59:29+00:00"}, + {"name":"partial.csv","type":"result","size":null,"hash":{"algorithm":null,"value":null},"upload_timestamp":null} + ]}]}` + +func parseSampleJob(t *testing.T) *JobDetails { + t.Helper() + var r jobDetailsResponse + if err := json.Unmarshal([]byte(sampleJobDetails), &r); err != nil { + t.Fatalf("unmarshal job: %v", err) + } + return &r.Details[0] +} + +func names(files []JobFile) []string { + var out []string + for _, f := range files { + out = append(out, f.Type+"/"+f.Name) + } + return out +} + +func TestFetchJobDetails(t *testing.T) { + var gotPath, gotAuth, gotRole string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath, gotAuth, gotRole = r.URL.Path, r.Header.Get("Authorization"), r.Header.Get("X-Hasura-Role") + if r.URL.Path == "/api/rest/jobs/jr-missing" { + w.Write([]byte(`{"details":[]}`)) + return + } + w.Write([]byte(sampleJobDetails)) + })) + defer srv.Close() + + job, err := fetchJobDetails(srv.Client(), srv.URL, "tok", "jr-abc123") + if err != nil { + t.Fatal(err) + } + if gotPath != "/api/rest/jobs/jr-abc123" || gotAuth != "Bearer tok" || gotRole != "jhuser" { + t.Errorf("request path=%q auth=%q role=%q", gotPath, gotAuth, gotRole) + } + if job.JobName != "jr-abc123" || job.Status != "Completed" || len(job.Files) != 4 { + t.Errorf("unexpected job %+v", job) + } + if f := job.Files[3]; f.Size != nil || f.Hash == nil || f.Hash.Algorithm != nil { + t.Errorf("null size/hash should decode to nil, got %+v", f) + } + + if _, err := fetchJobDetails(srv.Client(), srv.URL, "tok", "jr-missing"); err == nil || !strings.Contains(err.Error(), "not found") { + t.Errorf("missing job: want a not-found error, got %v", err) + } +} + +func TestFetchJobDetailsHTTPError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte("nope")) + })) + defer srv.Close() + if _, err := fetchJobDetails(srv.Client(), srv.URL, "tok", "jr-abc123"); err == nil || !strings.Contains(err.Error(), "status 401") { + t.Errorf("want a status 401 error, got %v", err) + } +} + +func TestFilterJobFiles(t *testing.T) { + job := parseSampleJob(t) + if got, want := names(filterJobFiles(job.Files, "result")), []string{"result/outdir.tar.gz", "result/partial.csv"}; !reflect.DeepEqual(got, want) { + t.Errorf("result files = %v, want %v", got, want) + } + if got, want := names(filterJobFiles(job.Files, "")), []string{"input/code.jl", "result/outdir.tar.gz", "result/partial.csv", "source/code.jl"}; !reflect.DeepEqual(got, want) { + t.Errorf("all files = %v, want %v", got, want) + } + if got := filterJobFiles(job.Files, "project"); len(got) != 0 { + t.Errorf("project files = %v, want none", names(got)) + } +} + +func TestSelectJobFiles(t *testing.T) { + job := parseSampleJob(t) + + // Without a name, the pending partial.csv is set aside, not downloaded. + all, pending, err := selectJobFiles(job, "result", "") + if err != nil || !reflect.DeepEqual(names(all), []string{"result/outdir.tar.gz"}) || !reflect.DeepEqual(names(pending), []string{"result/partial.csv"}) { + t.Errorf("no name: got %v, pending %v, %v; want outdir.tar.gz, pending partial.csv", names(all), names(pending), err) + } + one, _, err := selectJobFiles(job, "result", "outdir.tar.gz") + if err != nil || !reflect.DeepEqual(names(one), []string{"result/outdir.tar.gz"}) { + t.Errorf("by name: got %v, %v", names(one), err) + } + if _, _, err := selectJobFiles(job, "result", "partial.csv"); err == nil || !strings.Contains(err.Error(), "still being uploaded") { + t.Errorf("named pending file: want a still-uploading error, got %v", err) + } + // code.jl exists, but not as a result file. + if _, _, err := selectJobFiles(job, "result", "code.jl"); err == nil || !strings.Contains(err.Error(), "available: outdir.tar.gz, partial.csv") { + t.Errorf("wrong type: want an error listing available files, got %v", err) + } + if _, _, err := selectJobFiles(job, "project", ""); err == nil || !strings.Contains(err.Error(), "no project files") { + t.Errorf("no files of type: got %v", err) + } + + onlyPending := &JobDetails{JobName: "jr-x", Status: "Running", Files: []JobFile{{Name: "partial.csv", Type: "result"}}} + if _, _, err := selectJobFiles(onlyPending, "result", ""); err == nil || !strings.Contains(err.Error(), "1 still uploading") { + t.Errorf("only pending files: want a still-uploading error, got %v", err) + } +} + +func int64p(n int64) *int64 { return &n } + +func TestPlanJobDownloads(t *testing.T) { + job := parseSampleJob(t) + + // Download-all treats -o as a directory even when only one file is + // selected, so the on-disk shape doesn't depend on the job. + plan, pending, err := planJobDownloads(job, "result", "", "results") + if err != nil { + t.Fatal(err) + } + if len(plan) != 1 || plan[0].Dest != filepath.Join("results", "outdir.tar.gz") || len(pending) != 1 { + t.Errorf("download-all plan = %+v, pending %v", plan, names(pending)) + } + // A named file with -o keeps the file-or-directory rule. + plan, _, err = planJobDownloads(job, "result", "outdir.tar.gz", "results") + if err != nil || len(plan) != 1 || plan[0].Dest != "results" { + t.Errorf("named plan = %+v, %v; want dest results", plan, err) + } + + for _, bad := range []string{"", "results", "all"} { + if _, _, err := planJobDownloads(job, bad, "", ""); err == nil || !strings.Contains(err.Error(), "invalid file type") { + t.Errorf("type %q: want an invalid-type error, got %v", bad, err) + } + } + + // Distinct server names that flatten to the same base name are caught + // before anything is downloaded. + dup := &JobDetails{JobName: "jr-x", Files: []JobFile{ + {Name: "a/x.csv", Type: "result", Size: int64p(1)}, + {Name: "b/x.csv", Type: "result", Size: int64p(1)}, + }} + if _, _, err := planJobDownloads(dup, "result", "", "out"); err == nil || !strings.Contains(err.Error(), "would both be saved as") { + t.Errorf("duplicate destinations: want an error, got %v", err) + } +} + +func TestJobFileDestination(t *testing.T) { + dir := t.TempDir() + cases := []struct { + output, name string + multiple bool + want string + }{ + {"", "out.tar.gz", false, "out.tar.gz"}, + {"", "out.tar.gz", true, "out.tar.gz"}, + {"renamed.tar.gz", "out.tar.gz", false, "renamed.tar.gz"}, + {dir, "out.tar.gz", false, filepath.Join(dir, "out.tar.gz")}, + {"newdir/", "out.tar.gz", false, filepath.Join("newdir", "out.tar.gz")}, + {"newdir", "out.tar.gz", true, filepath.Join("newdir", "out.tar.gz")}, + // Server-supplied names cannot escape the destination directory. + {dir, "../../etc/passwd", false, filepath.Join(dir, "passwd")}, + } + for _, c := range cases { + got, err := jobFileDestination(c.output, c.name, c.multiple) + if err != nil || got != c.want { + t.Errorf("jobFileDestination(%q, %q, %v) = %q, %v; want %q", c.output, c.name, c.multiple, got, err, c.want) + } + } + bad := []string{"", ".", ".."} + if filepath.Separator == '\\' { + bad = append(bad, "NUL", "con", "COM1", "aux.txt") + } + for _, bad := range bad { + if _, err := jobFileDestination("", bad, false); err == nil { + t.Errorf("jobFileDestination(%q) should be rejected", bad) + } + } +} + +func sha256Hex(b []byte) string { + sum := sha256.Sum256(b) + return hex.EncodeToString(sum[:]) +} + +func strp(s string) *string { return &s } + +// jobFileServers starts an API server whose file endpoint redirects to a +// separate "object store" server, like JuliaHub's presigned-URL redirect. +// storeAuth records the Authorization and Referer headers the store received. +func jobFileServers(t *testing.T, content []byte) (api *httptest.Server, storeAuth *string) { + t.Helper() + var auth string + store := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + auth = r.Header.Get("Authorization") + r.Header.Get("Referer") + w.Write(content) + })) + t.Cleanup(store.Close) + api = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer tok" { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + if r.URL.EscapedPath() != "/jobs/jr-abc123/files/out%20dir.tar.gz" || r.URL.Query().Get("filetype") != "result" { + http.Error(w, "File not found", http.StatusBadRequest) + return + } + http.Redirect(w, r, store.URL+"/bucket/key?X-Amz-Signature=sig", http.StatusFound) + })) + t.Cleanup(api.Close) + return api, &auth +} + +func TestDownloadJobFile(t *testing.T) { + content := []byte("result payload") + api, storeAuth := jobFileServers(t, content) + f := JobFile{Name: "out dir.tar.gz", Type: "result", Size: int64p(int64(len(content))), Hash: &JobFileHash{Algorithm: strp("sha2_256"), Value: strp(sha256Hex(content))}} + dest := filepath.Join(t.TempDir(), "sub", "out.tar.gz") + + res, err := downloadJobFile(newJobHTTPClient(), api.URL, "tok", "jr-abc123", f, dest, false) + if err != nil { + t.Fatal(err) + } + if got, _ := os.ReadFile(dest); res.Bytes != int64(len(content)) || !res.Verified || !bytes.Equal(got, content) { + t.Errorf("downloaded %+v %q, want %q verified", res, got, content) + } + // Neither the bearer token nor a Referer naming the job follows the + // redirect to the presigned URL. + if *storeAuth != "" { + t.Errorf("object store received Authorization/Referer %q, want none", *storeAuth) + } + + // An existing file is kept unless forced. + if _, err := downloadJobFile(newJobHTTPClient(), api.URL, "tok", "jr-abc123", f, dest, false); err == nil || !strings.Contains(err.Error(), "already exists") { + t.Errorf("want an already-exists error, got %v", err) + } + if _, err := downloadJobFile(newJobHTTPClient(), api.URL, "tok", "jr-abc123", f, dest, true); err != nil { + t.Errorf("--force download: %v", err) + } +} + +// A chain API -> API -> store -> store keeps the token on the first hop only. +func TestDownloadJobFileRedirectChain(t *testing.T) { + var leaked []string + record := func(r *http.Request) { + if a := r.Header.Get("Authorization"); a != "" { + leaked = append(leaked, r.Host+r.URL.Path+": "+a) + } + } + store := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + record(r) + if r.URL.Path == "/hop" { + http.Redirect(w, r, "/bucket/key", http.StatusFound) + return + } + w.Write([]byte("x")) + })) + defer store.Close() + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/next" { + record(r) + http.Redirect(w, r, store.URL+"/hop", http.StatusFound) + return + } + if r.Header.Get("Authorization") != "Bearer tok" { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + http.Redirect(w, r, "/next", http.StatusFound) + })) + defer api.Close() + + dest := filepath.Join(t.TempDir(), "x") + if _, err := downloadJobFile(newJobHTTPClient(), api.URL, "tok", "jr-abc123", JobFile{Name: "x", Type: "result"}, dest, false); err != nil { + t.Fatal(err) + } + if len(leaked) != 0 { + t.Errorf("Authorization sent after the first hop: %v", leaked) + } +} + +func TestDownloadJobFileUnverified(t *testing.T) { + api, _ := jobFileServers(t, []byte("payload")) + // An empty recorded hash is treated as no hash: downloadable, not verified. + f := JobFile{Name: "out dir.tar.gz", Type: "result", Hash: &JobFileHash{Algorithm: strp("sha2_256"), Value: strp("")}} + res, err := downloadJobFile(newJobHTTPClient(), api.URL, "tok", "jr-abc123", f, filepath.Join(t.TempDir(), "out"), false) + if err != nil || res.Verified { + t.Errorf("empty hash: got %+v, %v; want an unverified download", res, err) + } +} + +func TestDownloadJobFileSizeMismatch(t *testing.T) { + api, _ := jobFileServers(t, []byte("truncated")) + f := JobFile{Name: "out dir.tar.gz", Type: "result", Size: int64p(1000)} + dir := t.TempDir() + if _, err := downloadJobFile(newJobHTTPClient(), api.URL, "tok", "jr-abc123", f, filepath.Join(dir, "out"), false); err == nil || !strings.Contains(err.Error(), "size mismatch") { + t.Fatalf("want a size mismatch, got %v", err) + } + if entries, _ := os.ReadDir(dir); len(entries) != 0 { + t.Errorf("directory not empty after failed download: %v", entries) + } +} + +func TestMoveJobFileIntoPlace(t *testing.T) { + dir := t.TempDir() + tmp := filepath.Join(dir, "tmp") + dest := filepath.Join(dir, "dest") + os.WriteFile(tmp, []byte("new"), 0644) + // A file that appeared at dest during the download is kept without force. + os.WriteFile(dest, []byte("old"), 0644) + if err := moveJobFileIntoPlace(tmp, dest, false); err == nil || !strings.Contains(err.Error(), "already exists") { + t.Errorf("want an already-exists error, got %v", err) + } + if got, _ := os.ReadFile(dest); string(got) != "old" { + t.Errorf("dest overwritten without force: %q", got) + } + if err := moveJobFileIntoPlace(tmp, dest, true); err != nil { + t.Fatal(err) + } + if got, _ := os.ReadFile(dest); string(got) != "new" { + t.Errorf("forced move: dest = %q, want new", got) + } +} + +func TestDownloadJobFileChecksumMismatch(t *testing.T) { + api, _ := jobFileServers(t, []byte("corrupted")) + f := JobFile{Name: "out dir.tar.gz", Type: "result", Hash: &JobFileHash{Algorithm: strp("sha2_256"), Value: strp(sha256Hex([]byte("expected")))}} + dir := t.TempDir() + dest := filepath.Join(dir, "out.tar.gz") + + if _, err := downloadJobFile(newJobHTTPClient(), api.URL, "tok", "jr-abc123", f, dest, false); err == nil || !strings.Contains(err.Error(), "checksum mismatch") { + t.Fatalf("want a checksum mismatch, got %v", err) + } + // Neither the destination nor the temporary file is left behind. + if entries, _ := os.ReadDir(dir); len(entries) != 0 { + t.Errorf("directory not empty after failed download: %v", entries) + } +} + +func TestDownloadJobFileNotFound(t *testing.T) { + api, _ := jobFileServers(t, nil) + f := JobFile{Name: "other.csv", Type: "result"} + dest := filepath.Join(t.TempDir(), "other.csv") + if _, err := downloadJobFile(newJobHTTPClient(), api.URL, "tok", "jr-abc123", f, dest, false); err == nil || !strings.Contains(err.Error(), "status 400") { + t.Errorf("want a status 400 error, got %v", err) + } + if _, err := os.Stat(dest); !os.IsNotExist(err) { + t.Errorf("destination should not exist after a failed download") + } +} + +func TestVerifyJobFileHashEncodings(t *testing.T) { + sum := sha256.Sum256([]byte("payload")) + // JuliaHub records hex (as seen on a live instance); base64 is accepted too. + for _, v := range []string{hex.EncodeToString(sum[:]), base64.StdEncoding.EncodeToString(sum[:])} { + f := JobFile{Name: "f", Hash: &JobFileHash{Algorithm: strp("sha2_256"), Value: strp(v)}} + if verified, err := verifyJobFileHash(f, sum[:]); err != nil || !verified { + t.Errorf("value %q: verified=%v, %v", v, verified, err) + } + other := sha256.Sum256([]byte("other")) + if _, err := verifyJobFileHash(f, other[:]); err == nil || !strings.Contains(err.Error(), "checksum mismatch") { + t.Errorf("value %q: want a mismatch for other content, got %v", v, err) + } + } + bad := JobFile{Name: "f", Hash: &JobFileHash{Algorithm: strp("sha2_256"), Value: strp("AAAA")}} + if _, err := verifyJobFileHash(bad, sum[:]); err == nil || !strings.Contains(err.Error(), "invalid recorded hash") { + t.Errorf("short hash: want an invalid-hash error, got %v", err) + } +} + +func TestVerifyJobFileHashSkipsUnknown(t *testing.T) { + sum := sha256.Sum256([]byte("x")) + for _, h := range []*JobFileHash{nil, {}, {Algorithm: strp("md5"), Value: strp("AAAA")}, {Algorithm: strp("sha2_256"), Value: strp("")}} { + if verified, err := verifyJobFileHash(JobFile{Name: "f", Hash: h}, sum[:]); err != nil || verified { + t.Errorf("hash %+v: want skip, got verified=%v, %v", h, verified, err) + } + } +} + +func TestFormatBytes(t *testing.T) { + for n, want := range map[int64]string{0: "0 B", 1023: "1023 B", 1024: "1.0 KiB", 632143: "617.3 KiB", 5 << 30: "5.0 GiB"} { + if got := formatBytes(n); got != want { + t.Errorf("formatBytes(%d) = %q, want %q", n, got, want) + } + } +} + +func TestPrintJobFiles(t *testing.T) { + job := parseSampleJob(t) + var buf bytes.Buffer + printJobFiles(&buf, job, filterJobFiles(job.Files, "result"), false) + out := buf.String() + for _, want := range []string{"jr-abc123 (nightly fit)", "Completed", "outdir.tar.gz", "617.3 KiB", "partial.csv", "pending"} { + if !strings.Contains(out, want) { + t.Errorf("output missing %q:\n%s", want, out) + } + } + if strings.Contains(out, "TYPE") { + t.Errorf("result-only listing should not have a TYPE column:\n%s", out) + } + + buf.Reset() + printJobFiles(&buf, job, nil, false) + if !strings.Contains(buf.String(), "RESULTS_FILE") { + t.Errorf("empty listing should explain RESULTS_FILE:\n%s", buf.String()) + } +} diff --git a/main.go b/main.go index c1f4f1f..4cb861e 100644 --- a/main.go +++ b/main.go @@ -341,9 +341,9 @@ var jobCmd = &cobra.Command{ Long: `Manage jobs on JuliaHub. Jobs are computational tasks that run on JuliaHub's infrastructure. -You can submit, monitor, and manage jobs through these commands. +Use "jh job result" to list and download a job's result files. -Note: Job functionality is currently in development.`, +Note: "jh job list" and "jh job start" are placeholders, still in development.`, } var jobListCmd = &cobra.Command{ @@ -388,6 +388,79 @@ Note: This functionality is currently in development.`, }, } +var jobResultCmd = &cobra.Command{ + Use: "result", + Short: "List and download job result files", + Long: `List and download the result files of a JuliaHub job. + +A job produces a result file by setting the RESULTS_FILE environment variable +to a file path (in Julia, ENV["RESULTS_FILE"] = "results.tar.gz"); JuliaHub +uploads that file when the job finishes. Jobs are identified by their name, +e.g. jr-abc123xyz.`, +} + +var jobResultListCmd = &cobra.Command{ + Use: "list ", + Short: "List a job's result files", + Long: `List the result files of a job with their size and upload time. + +Use --all to list every file attached to the job (input, source, project and +result files) with its type.`, + Example: " jh job result list jr-abc123xyz\n jh job result list --all jr-abc123xyz\n jh job result list --json jr-abc123xyz", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + server, err := getServerFromFlagOrConfig(cmd) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to get server config: %v\n", err) + os.Exit(1) + } + all, _ := cmd.Flags().GetBool("all") + asJSON, _ := cmd.Flags().GetBool("json") + if err := listJobResults(server, args[0], all, asJSON); err != nil { + fmt.Fprintf(os.Stderr, "Failed to list job results: %v\n", err) + os.Exit(1) + } + }, +} + +var jobResultDownloadCmd = &cobra.Command{ + Use: "download [file-name]", + Short: "Download a job's result files", + Long: `Download result files of a job. + +With a file name, downloads that file; without one, downloads every result +file of the job (skipping files whose upload is still in progress). Files are +saved in the current directory under their own names unless --output is given: +with a file name, --output is the destination path (or a directory to save +into); without one, --output is always a directory, created if needed. + +Existing files are not overwritten unless --force is given. Each download is +checked against the size JuliaHub recorded and, when JuliaHub recorded a +SHA-256 hash for the file, verified against it. + +Use --type to download another kind of job file (input, source, project).`, + Example: " jh job result download jr-abc123xyz\n jh job result download jr-abc123xyz results.tar.gz\n jh job result download jr-abc123xyz results.tar.gz -o previous-run.tar.gz\n jh job result download jr-abc123xyz -o results/", + Args: cobra.RangeArgs(1, 2), + Run: func(cmd *cobra.Command, args []string) { + server, err := getServerFromFlagOrConfig(cmd) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to get server config: %v\n", err) + os.Exit(1) + } + fileName := "" + if len(args) == 2 { + fileName = args[1] + } + output, _ := cmd.Flags().GetString("output") + fileType, _ := cmd.Flags().GetString("type") + force, _ := cmd.Flags().GetBool("force") + if err := downloadJobResults(server, args[0], fileName, output, fileType, force); err != nil { + fmt.Fprintf(os.Stderr, "Failed to download job results: %v\n", err) + os.Exit(1) + } + }, +} + var datasetCmd = &cobra.Command{ Use: "dataset", Short: "Dataset management commands", @@ -2641,6 +2714,13 @@ func init() { authLoginCmd.Flags().StringP("server", "s", "juliahub.com", "JuliaHub server") jobListCmd.Flags().StringP("server", "s", "juliahub.com", "JuliaHub server") jobStartCmd.Flags().StringP("server", "s", "juliahub.com", "JuliaHub server") + jobResultListCmd.Flags().StringP("server", "s", "juliahub.com", "JuliaHub server") + jobResultListCmd.Flags().Bool("all", false, "List all job files (input, source, project, result), not just results") + jobResultListCmd.Flags().Bool("json", false, "Print only a JSON document ({\"job\", \"status\", \"files\": [...]}) on stdout") + jobResultDownloadCmd.Flags().StringP("server", "s", "juliahub.com", "JuliaHub server") + jobResultDownloadCmd.Flags().StringP("output", "o", "", "Destination file or directory (default: current directory)") + jobResultDownloadCmd.Flags().String("type", jobResultFileType, "Job file type to download: result, input, source or project") + jobResultDownloadCmd.Flags().Bool("force", false, "Overwrite existing files") datasetListCmd.Flags().StringP("server", "s", "juliahub.com", "JuliaHub server") datasetDownloadCmd.Flags().StringP("server", "s", "juliahub.com", "JuliaHub server") datasetUploadCmd.Flags().StringP("server", "s", "juliahub.com", "JuliaHub server") @@ -2727,7 +2807,8 @@ func init() { searchPackagesCmd.Flags().Bool("json", false, "Print only a JSON document ({\"results\": [...], \"total\": N}) on stdout") authCmd.AddCommand(authLoginCmd, authRefreshCmd, authStatusCmd, authEnvCmd) - jobCmd.AddCommand(jobListCmd, jobStartCmd) + jobResultCmd.AddCommand(jobResultListCmd, jobResultDownloadCmd) + jobCmd.AddCommand(jobListCmd, jobStartCmd, jobResultCmd) datasetCmd.AddCommand(datasetListCmd, datasetDownloadCmd, datasetUploadCmd, datasetStatusCmd) packageCmd.AddCommand(packageSearchCmd, packageInfoCmd, packageDependencyCmd, packageDependentsCmd) registryConfigCmd.Flags().StringP("server", "s", "", "JuliaHub server") diff --git a/main_test.go b/main_test.go index 4571f81..acad8a0 100644 --- a/main_test.go +++ b/main_test.go @@ -62,6 +62,10 @@ func TestCommandTreeWiring(t *testing.T) { } } + // job result list/download. + requirePath(t, "job", "result", "list") + requirePath(t, "job", "result", "download") + // admin landing-page update/remove. requirePath(t, "admin", "landing-page", "update") requirePath(t, "admin", "landing-page", "remove") diff --git a/tokens.go b/tokens.go index d4e3f9c..c868f0f 100644 --- a/tokens.go +++ b/tokens.go @@ -24,7 +24,7 @@ type TokensResponse struct { Success bool `json:"success"` } -func formatTokenDate(dateStr string) string { +func formatTimestamp(dateStr string) string { // Try parsing as RFC3339 with fractional seconds t, err := time.Parse(time.RFC3339, dateStr) if err != nil { @@ -86,8 +86,8 @@ func listTokens(server string, verbose bool) error { fmt.Printf("Subject: %s\n", tok.Subject) fmt.Printf("Signature: %s\n", tok.Signature) fmt.Printf("Created By: %s\n", tok.CreatedBy) - fmt.Printf("Created At: %s\n", formatTokenDate(tok.CreatedAt)) - fmt.Printf("Expires At: %s", formatTokenDate(tok.ExpiresAt)) + fmt.Printf("Created At: %s\n", formatTimestamp(tok.CreatedAt)) + fmt.Printf("Expires At: %s", formatTimestamp(tok.ExpiresAt)) if tok.ExpiresAtIsEstimate { fmt.Printf(" (estimate)") } diff --git a/tokens_test.go b/tokens_test.go index 652763b..473d36f 100644 --- a/tokens_test.go +++ b/tokens_test.go @@ -5,17 +5,17 @@ import ( "testing" ) -func TestFormatTokenDate(t *testing.T) { +func TestFormatTimestamp(t *testing.T) { // A well-formed RFC3339 timestamp is reformatted into the human layout. - got := formatTokenDate("2025-01-02T03:04:05Z") + got := formatTimestamp("2025-01-02T03:04:05Z") if !strings.Contains(got, "2025") || !strings.Contains(got, "Jan") { - t.Errorf("formatTokenDate reformat = %q, want a Jan 2025 date", got) + t.Errorf("formatTimestamp reformat = %q, want a Jan 2025 date", got) } // Unparseable input is returned verbatim. for _, in := range []string{"", "not-a-date", "2025/01/02"} { - if got := formatTokenDate(in); got != in { - t.Errorf("formatTokenDate(%q) = %q, want passthrough", in, got) + if got := formatTimestamp(in); got != in { + t.Errorf("formatTimestamp(%q) = %q, want passthrough", in, got) } } }