From 840bdaf8e357fec719ba2e25dcac1eebae06cdfc Mon Sep 17 00:00:00 2001 From: Luke Amdor Date: Fri, 16 Oct 2026 15:01:12 -0500 Subject: [PATCH] feat: `jh job usage` shows your compute usage against your limits JuliaHub administrators can limit how many jobs a user can have active at once, and how many vCPUs and GPUs those jobs can use in total; a submission that would exceed a limit is rejected. `jh job usage` shows where you stand, so you (or a script, with `--json`) can check whether a job fits before submitting it: Compute usage on juliahub.com Jobs 3 of 50 (6%) vCPUs 960 of 1,000 (96%) GPUs 0 of 10 It reads the new `GET /api/v1/jobs/usage` endpoint. On a server without it, the request falls through to the job details route (`GET /api/v1/jobs/{job_name}`, for a job called `usage`) and 404s; that is reported as needing a newer JuliaHub version rather than as a generic HTTP error. A 403 means the user may not run jobs at all. Unit tests cover the request, the 404/403/5xx and incomplete-response errors and the text output; an e2e test checks the `--json` document and skips on instances without the endpoint. Agent-Harness: Claude Code 2.1.283 Agent-Model: claude-opus-5-5 Agent-Session: 1c99da50-3ba3-4b10-9c46-abc0d712b4d3 Agent-Review: reviewed by @lamdor --- CLAUDE.md | 6 ++- README.md | 7 ++- e2e/job_test.go | 32 +++++++++++++ main.go | 35 +++++++++++++- usage.go | 121 ++++++++++++++++++++++++++++++++++++++++++++++++ usage_test.go | 97 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 294 insertions(+), 4 deletions(-) create mode 100644 usage.go create mode 100644 usage_test.go diff --git a/CLAUDE.md b/CLAUDE.md index a0190ec..06230f2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,6 +13,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 +- **usage.go**: `jh job usage` — the user's concurrent jobs, vCPUs and GPUs against their per-user compute limits via `GET /api/v1/jobs/usage` (`{"jobs","vcpus","gpus"}`, each `{"used","limit"}`). Servers without the endpoint route the request to `GET /api/v1/jobs/{job_name}` and 404, reported as `errJobUsageUnsupported` - **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`) @@ -46,6 +47,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 usage`: Compute usage against per-user limits (`--json`) - `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) @@ -136,8 +138,10 @@ go run . package dependency JuMP --json go run . package info JuMP --json ``` -### Test job result operations +### Test job usage and result operations ```bash +go run . job usage +go run . job usage --json go run . job result list jr-abc123xyz go run . job result list --all jr-abc123xyz go run . job result list --json jr-abc123xyz diff --git a/README.md b/README.md index ba64f05..6d3afb6 100644 --- a/README.md +++ b/README.md @@ -156,8 +156,9 @@ go build -o jh . - `jh dataset upload [dataset-id] ` - Upload a dataset - `jh dataset status [version]` - Show dataset status -### Job Results (`jh job result`) +### Jobs (`jh job`) +- `jh job usage` - Show your active jobs, and the vCPUs and GPUs allocated to them, against your compute limits (`--json` for scripts; needs a JuliaHub version that reports compute usage) - `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) @@ -371,6 +372,10 @@ jh dataset upload my-dataset ./updated-data.tar.gz A job produces a result file by setting `ENV["RESULTS_FILE"]` to a file path; JuliaHub uploads it when the job finishes. ```bash +# Check your compute usage against your limits (e.g. before submitting a job) +jh job usage +jh job usage --json | jq '.vcpus.limit - .vcpus.used' + # List a job's result files jh job result list jr-abc123xyz diff --git a/e2e/job_test.go b/e2e/job_test.go index 493441b..02754b0 100644 --- a/e2e/job_test.go +++ b/e2e/job_test.go @@ -3,6 +3,7 @@ package e2e import ( + "encoding/json" "strings" "testing" ) @@ -36,3 +37,34 @@ func TestJobResultListUnknownJob(t *testing.T) { t.Errorf("want a not-found error, got stderr: %s", res.stderr) } } + +// `jh job usage --json` — GET /api/v1/jobs/usage. Skips on instances that +// predate the endpoint. +func TestJobUsageJSON(t *testing.T) { + requireCreds(t) + res := runJH(t, "job", "usage", "--json") + if res.exitCode != 0 { + if strings.Contains(res.stderr, "does not report compute usage") { + t.Skipf("instance predates GET /api/v1/jobs/usage: %s", errorLine(res.stderr)) + } + skipIfUnsupported(t, res) + t.Fatalf("job usage exited %d\nstderr: %s", res.exitCode, res.stderr) + } + var doc map[string]struct { + Used *int64 `json:"used"` + Limit *int64 `json:"limit"` + } + if err := json.Unmarshal([]byte(res.stdout), &doc); err != nil { + t.Fatalf("stdout is not JSON: %v\n%s", err, res.stdout) + } + for _, k := range []string{"jobs", "vcpus", "gpus"} { + u, ok := doc[k] + if !ok || u.Used == nil || u.Limit == nil { + t.Errorf("missing %s.used/limit in %s", k, res.stdout) + continue + } + if *u.Used < 0 || *u.Limit < 0 { + t.Errorf("%s: negative values %d/%d", k, *u.Used, *u.Limit) + } + } +} diff --git a/main.go b/main.go index 4cb861e..01a3db9 100644 --- a/main.go +++ b/main.go @@ -341,7 +341,8 @@ var jobCmd = &cobra.Command{ Long: `Manage jobs on JuliaHub. Jobs are computational tasks that run on JuliaHub's infrastructure. -Use "jh job result" to list and download a job's result files. +Use "jh job result" to list and download a job's result files, and +"jh job usage" to see your compute usage against your limits. Note: "jh job list" and "jh job start" are placeholders, still in development.`, } @@ -388,6 +389,34 @@ Note: This functionality is currently in development.`, }, } +var jobUsageCmd = &cobra.Command{ + Use: "usage", + Short: "Show your compute usage against your limits", + Long: `Show how many jobs you have active, and the vCPUs and GPUs allocated to +them, against the per-user limits set by your JuliaHub administrator. + +A job counts from submission until it finishes; its vCPUs and GPUs are the +per-node counts times its number of nodes. JuliaHub rejects a job submission +that would take any of these above its limit, so scripts can use this to check +whether a job fits before submitting it (use --json). + +Requires a JuliaHub version that reports compute usage.`, + Example: " jh job usage\n jh job usage --json", + Args: cobra.NoArgs, + 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) + } + asJSON, _ := cmd.Flags().GetBool("json") + if err := showJobUsage(server, asJSON); err != nil { + fmt.Fprintf(os.Stderr, "Failed to get compute usage: %v\n", err) + os.Exit(1) + } + }, +} + var jobResultCmd = &cobra.Command{ Use: "result", Short: "List and download job result files", @@ -2714,6 +2743,8 @@ 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") + jobUsageCmd.Flags().StringP("server", "s", "juliahub.com", "JuliaHub server") + jobUsageCmd.Flags().Bool("json", false, "Print only a JSON document ({\"jobs\", \"vcpus\", \"gpus\"}, each {\"used\", \"limit\"}) on stdout") 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") @@ -2808,7 +2839,7 @@ func init() { authCmd.AddCommand(authLoginCmd, authRefreshCmd, authStatusCmd, authEnvCmd) jobResultCmd.AddCommand(jobResultListCmd, jobResultDownloadCmd) - jobCmd.AddCommand(jobListCmd, jobStartCmd, jobResultCmd) + jobCmd.AddCommand(jobListCmd, jobStartCmd, jobUsageCmd, jobResultCmd) datasetCmd.AddCommand(datasetListCmd, datasetDownloadCmd, datasetUploadCmd, datasetStatusCmd) packageCmd.AddCommand(packageSearchCmd, packageInfoCmd, packageDependencyCmd, packageDependentsCmd) registryConfigCmd.Flags().StringP("server", "s", "", "JuliaHub server") diff --git a/usage.go b/usage.go new file mode 100644 index 0000000..9c6c80f --- /dev/null +++ b/usage.go @@ -0,0 +1,121 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "time" +) + +// ResourceUsage is the current usage of one per-user compute limit and the +// effective limit on it (the user's override, else the platform default). +type ResourceUsage struct { + Used int64 `json:"used"` + Limit int64 `json:"limit"` +} + +// JobUsage is GET /api/v1/jobs/usage: the calling user's concurrent jobs, and +// the vCPUs and GPUs allocated to them, against their limits. Usage counts the +// user's Submitted, SubmitInitialized and Running jobs, as job submission does +// when it enforces the limits. +type JobUsage struct { + Jobs ResourceUsage `json:"jobs"` + VCPUs ResourceUsage `json:"vcpus"` + GPUs ResourceUsage `json:"gpus"` +} + +// errJobUsageUnsupported: servers without the endpoint route the request to +// GET /api/v1/jobs/{job_name} as a job called "usage", which answers 404. +var errJobUsageUnsupported = errors.New("this JuliaHub server does not report compute usage (it needs a newer JuliaHub version)") + +func fetchJobUsage(client *http.Client, baseURL, idToken string) (*JobUsage, error) { + req, err := http.NewRequest("GET", baseURL+"/api/v1/jobs/usage", 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") + + 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) + } + switch resp.StatusCode { + case http.StatusOK: + case http.StatusNotFound: + return nil, errJobUsageUnsupported + case http.StatusForbidden: + return nil, fmt.Errorf("you are not allowed to run jobs on this server (status 403): %s", string(body)) + default: + return nil, fmt.Errorf("request failed (status %d): %s", resp.StatusCode, string(body)) + } + + // Decode into pointers first so a missing resource is an error, not a + // silent zero usage against a zero limit. + var parsed struct { + Jobs *ResourceUsage `json:"jobs"` + VCPUs *ResourceUsage `json:"vcpus"` + GPUs *ResourceUsage `json:"gpus"` + } + if err := json.Unmarshal(body, &parsed); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + if parsed.Jobs == nil || parsed.VCPUs == nil || parsed.GPUs == nil { + return nil, fmt.Errorf("unexpected response, missing jobs, vcpus or gpus: %s", string(body)) + } + return &JobUsage{Jobs: *parsed.Jobs, VCPUs: *parsed.VCPUs, GPUs: *parsed.GPUs}, nil +} + +// usageLine is " of ", plus the share of the limit in use when +// anything is used, flagged once the limit is reached. +func usageLine(u ResourceUsage) string { + line := formatCount(int(u.Used)) + " of " + formatCount(int(u.Limit)) + if u.Used == 0 || u.Limit <= 0 { + return line + } + pct := fmt.Sprintf("%d%%", u.Used*100/u.Limit) + if u.Used >= u.Limit { + return line + " (" + pct + ", at limit)" + } + return line + " (" + pct + ")" +} + +func printJobUsage(w io.Writer, server string, usage JobUsage) { + fmt.Fprintf(w, "%s\n", bold("Compute usage on "+server)) + for _, f := range []struct { + label string + usage ResourceUsage + }{ + {"Jobs", usage.Jobs}, + {"vCPUs", usage.VCPUs}, + {"GPUs", usage.GPUs}, + } { + fmt.Fprintf(w, " %s%s\n", dim(pad(f.label, fieldLabelWidth)), usageLine(f.usage)) + } +} + +func showJobUsage(server string, asJSON bool) error { + token, err := ensureValidToken() + if err != nil { + return fmt.Errorf("authentication required: %w", err) + } + client := &http.Client{Timeout: 30 * time.Second} + usage, err := fetchJobUsage(client, jobBaseURL(server), token.IDToken) + if err != nil { + return err + } + if asJSON { + return writeJSON(os.Stdout, usage) + } + printJobUsage(os.Stdout, server, *usage) + return nil +} diff --git a/usage_test.go b/usage_test.go new file mode 100644 index 0000000..26967c6 --- /dev/null +++ b/usage_test.go @@ -0,0 +1,97 @@ +package main + +import ( + "bytes" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +const sampleJobUsage = `{"jobs":{"used":3,"limit":50},"vcpus":{"used":96,"limit":1000},"gpus":{"used":0,"limit":10}}` + +func TestFetchJobUsage(t *testing.T) { + var gotPath, gotAuth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath, gotAuth = r.URL.Path, r.Header.Get("Authorization") + w.Write([]byte(sampleJobUsage)) + })) + defer srv.Close() + + usage, err := fetchJobUsage(srv.Client(), srv.URL, "tok") + if err != nil { + t.Fatal(err) + } + if gotPath != "/api/v1/jobs/usage" || gotAuth != "Bearer tok" { + t.Errorf("request path=%q auth=%q", gotPath, gotAuth) + } + want := JobUsage{ + Jobs: ResourceUsage{Used: 3, Limit: 50}, + VCPUs: ResourceUsage{Used: 96, Limit: 1000}, + GPUs: ResourceUsage{Used: 0, Limit: 10}, + } + if *usage != want { + t.Errorf("usage = %+v, want %+v", *usage, want) + } +} + +func TestFetchJobUsageErrors(t *testing.T) { + for _, tc := range []struct { + status int + body string + check func(error) bool + desc string + }{ + // Servers without the endpoint route the request to + // GET /api/v1/jobs/{job_name} as a job called "usage", which 404s. + {http.StatusNotFound, `{"message":"Not found"}`, func(err error) bool { return errors.Is(err, errJobUsageUnsupported) }, "unsupported"}, + {http.StatusForbidden, `{"message":"Not allowed"}`, func(err error) bool { return strings.Contains(err.Error(), "not allowed to run jobs") }, "forbidden"}, + {http.StatusInternalServerError, "boom", func(err error) bool { return strings.Contains(err.Error(), "status 500") }, "server error"}, + {http.StatusOK, `{"jobs":{"used":1,"limit":2}}`, func(err error) bool { return strings.Contains(err.Error(), "missing") }, "incomplete response"}, + } { + t.Run(tc.desc, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tc.status) + w.Write([]byte(tc.body)) + })) + defer srv.Close() + _, err := fetchJobUsage(srv.Client(), srv.URL, "tok") + if err == nil || !tc.check(err) { + t.Errorf("got error %v", err) + } + }) + } +} + +func TestPrintJobUsage(t *testing.T) { + usage := JobUsage{ + Jobs: ResourceUsage{Used: 3, Limit: 50}, + VCPUs: ResourceUsage{Used: 960, Limit: 1000}, + GPUs: ResourceUsage{Used: 0, Limit: 10}, + } + var buf bytes.Buffer + printJobUsage(&buf, "example.juliahub.com", usage) + want := `Compute usage on example.juliahub.com + Jobs 3 of 50 (6%) + vCPUs 960 of 1,000 (96%) + GPUs 0 of 10 +` + if got := buf.String(); got != want { + t.Errorf("output:\n%s\nwant:\n%s", got, want) + } +} + +func TestPrintJobUsageAtLimit(t *testing.T) { + usage := JobUsage{ + Jobs: ResourceUsage{Used: 50, Limit: 50}, + VCPUs: ResourceUsage{Used: 0, Limit: 1000}, + GPUs: ResourceUsage{Used: 0, Limit: 0}, + } + var buf bytes.Buffer + printJobUsage(&buf, "example.juliahub.com", usage) + if got := buf.String(); !strings.Contains(got, " Jobs 50 of 50 (100%, at limit)\n") || + !strings.Contains(got, " GPUs 0 of 0\n") { + t.Errorf("output:\n%s", got) + } +}