Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,8 +156,9 @@ go build -o jh .
- `jh dataset upload [dataset-id] <file-path>` - Upload a dataset
- `jh dataset status <dataset-id> [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 <job-name>` - List a job's result files (`--all` for every job file, `--json` for scripts)
- `jh job result download <job-name> [file-name]` - Download one or all result files (`-o` destination, `--force` to overwrite, `--type` for other job file types)

Expand Down Expand Up @@ -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

Expand Down
32 changes: 32 additions & 0 deletions e2e/job_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
package e2e

import (
"encoding/json"
"strings"
"testing"
)
Expand Down Expand Up @@ -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)
}
}
}
35 changes: 33 additions & 2 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.`,
}
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down
121 changes: 121 additions & 0 deletions usage.go
Original file line number Diff line number Diff line change
@@ -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 "<used> of <limit>", 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
}
97 changes: 97 additions & 0 deletions usage_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading