diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index c81d217..0000000 --- a/.dockerignore +++ /dev/null @@ -1,5 +0,0 @@ -.git -web-interface/node_modules -web-interface/dist -logs -**/__pycache__ diff --git a/.gitignore b/.gitignore index 6bd8076..1f994cc 100644 --- a/.gitignore +++ b/.gitignore @@ -13,7 +13,6 @@ node_modules/ data/ /logs/ .direnv/ -.fake-backend-config.json *.local # Python caches diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 32a1f7e..1904798 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -13,8 +13,10 @@ honour. The reasons behind major choices are in the [decision log](docs/agents/architecture/decisions.md). > **Status:** target design, revised 27 October 2026 after architecture review. This design replaces the -> platform described in `docs/content/` (container-image jobs, dataset and -> result pools, team namespaces). It starts fresh: no users or data are migrated. +> previous container-image platform (dataset and result pools, team +> namespaces), which was never deployed and has been removed from the tree; it +> remains in Git history before this change. It starts fresh: no users or data +> are migrated. ## Goals @@ -380,8 +382,9 @@ deploy/ k3s manifests, systemd units, host setup web-interface/ portal ``` -The existing `src/` and `cli/` modules belong to the platform being replaced. -They are removed once the new path serves jobs. +The previous platform's `src/`, `cli/`, `deploy/` and `web-interface/` were +removed on 27 October 2026; the portal is rebuilt in `web-interface/` against +the new API. ## Milestones diff --git a/README.md b/README.md index c658ef7..62a5b02 100644 --- a/README.md +++ b/README.md @@ -1,105 +1,43 @@ # Mist -UTMIST's research compute platform. Submit Python scripts or tagged container -images, choose CPU, NVIDIA GPUs or Tenstorrent boards, upload datasets, and -collect persistent results through the same website or CLI. +UTMIST's research compute platform. Mist works like the batch systems on +national HPC clusters: each researcher gets a folder inside their team folders, +logs in over SSH, moves files with `scp`/`rsync`, and runs batch or interactive +jobs that a scheduler places on NVIDIA GPU, CPU and Tenstorrent hardware. -**Private website:** [http://100.73.139.66:8088](http://100.73.139.66:8088), accessible through authorized -Tailscale connectivity. A Mist account and team membership are also required. +## Status -## Architecture redesign — October 26 +Under redesign (October 2026). The previous container-image platform was never +deployed and has been removed from the tree; see Git history before 27 October +2026 if you need it. The new implementation starts from the design docs. -Mist is moving to an HPC-style design: per-team folders, restricted SSH login, -and jobs that run as the researcher's own Linux user under Mist's own scheduler. -Jobs no longer need images or mounts. Start with [ARCHITECTURE.md](ARCHITECTURE.md); -component contracts and the decision log are in -[docs/agents/architecture/](docs/agents/architecture/decisions.md). The -sections below and `docs/content/` describe the platform being replaced. +## Design -## Storage update — October 21 - -New submissions use ordinary team dataset/result folders on QuietBox, accessed -through the direct Ethernet link. Storage allowances are advisory; team access -is enforced. See [current storage operations](deploy/k3s/storage/README.md) for -layout, setup, API semantics, verification and rollback. The earlier operating -snapshots describe the retired image-backed layout. - -## Current installation +- [ARCHITECTURE.md](ARCHITECTURE.md): system overview, components, owners, + milestones and code layout. +- [docs/agents/architecture/](docs/agents/architecture/decisions.md): per-component + contracts and the decision log. +- [docs/agents/plans/](docs/agents/plans/jobs-and-scheduling.md): implementation + plans per owner. +## Cluster | Machine | Role | LAN address | Tailscale address | | ---------------- | --------------------------------------------------------- | ------------ | ----------------- | -| `utmist-z1opa08` | k3s server, API/auth, website, CPU and two RTX A4000 GPUs | `10.0.0.175` | `100.73.139.66` | -| `utmist-tt` | k3s worker, four n300 boards/eight chips, shared storage | `10.0.0.112` | `100.95.175.37` | - - -The cluster and NFS use the existing LAN. Tailscale provides private portal and -administrator SSH access. Kubernetes/containerd run workload containers. - -## Documentation - -Start with the [documentation index](docs/README.md). +| `utmist-z1opa08` | Two RTX A4000 GPUs, CPU | `10.0.0.175` | `100.73.139.66` | +| `utmist-tt` | Four n300 Tenstorrent boards (eight chips), shared storage | `10.0.0.112` | `100.95.175.37` | -- [Using the portal, teams and jobs](docs/content/department-rollout.md) -- [k3s setup, accelerator allocation and diagnostics](docs/content/k3s-setup.md) -- [Tailscale and SSH setup](docs/content/tailscale-ssh.md) -- [Architecture and code organization](docs/content/architecture.md) -- [Storage and upload limits](docs/content/storage.md) -- [Deployment and operations](deploy/private/README.md) -- [Developer setup](docs/content/development.md), [CLI](cli/docs/setup.md), [testing](docs/content/testing.md) +## Development -Main contains the department release merged through -[PR #110](https://github.com/UTMIST/Mist/pull/110). The earlier main and operating -snapshots are preserved in the [historical archive](docs/content/archive/_index.md). - -## Development and checks - -Use Go **1.25.1** and Node.js **22.16.0 or newer**. The Go workspace contains the -API (`src`) and CLI (`cli`). From the repository root: +A Nix dev shell (`flake.nix`, `shell.nix`) provides Go, Node.js, Python, k3s and +kubectl. Check documentation links with: ```bash -go test ./src/... ./cli/... -npm --prefix web-interface ci -npm --prefix web-interface test -npm --prefix web-interface run lint -npm --prefix web-interface run build +python3 scripts/check-docs.py ``` -For a local UI against the authenticated deployed API, follow -[developer setup](docs/content/development.md). For the existing installation, deploy with -`bash deploy/private/build-and-deploy.sh`; read its [prerequisites](deploy/private/README.md) -before running it. Fresh-host provisioning is covered separately by the k3s guide. - -The current API uses Kubernetes only. The unfinished Redis/Docker supervisor, -fake frontend account/chart and dummy CLI configuration have been retired. -Docker remains build tooling for images; it does not schedule Mist jobs. - -Custom accelerator images need compatible training libraries. Credits, Jupyter, -chaining, browser image archives/builds, private pull-credential management and -distributed multi-machine training are outside this release. Automatic backups, -HTTPS and monitoring/cleanup products were explicitly excluded. This is a private -HTTP installation with one control plane, one auth instance and one storage host. - ### Documentation Humans are responsible for editing the human facing docs. This is because the human facing docs will be read by humans, and it is rude to give them untested instructions to follow. Documentation should include explanations of design decisions for later platform maintainers. - -## Frontend Testing - -how to use fake backend for testing frontend. - -(temporary, functionality, to be removed in future commit.) - -```bash -# Terminal 1 -npm --prefix web-interface run dev -- --host 127.0.0.1 --port 3001 --strictPort - -# Terminal 2 -npm --prefix web-interface run fake-backend -``` - - - -  \ No newline at end of file diff --git a/cli/cmd/api_client.go b/cli/cmd/api_client.go deleted file mode 100644 index 555490f..0000000 --- a/cli/cmd/api_client.go +++ /dev/null @@ -1,83 +0,0 @@ -package cmd - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net/http" - "strings" - "time" -) - -func (ctx *AppContext) api(method, path string, body, out interface{}) error { - if ctx == nil { - return fmt.Errorf("missing application context") - } - base := ctx.baseURL() - var data []byte - var err error - if body != nil { - data, err = json.Marshal(body) - if err != nil { - return err - } - } - req, err := http.NewRequest(method, strings.TrimRight(base, "/")+path, bytes.NewReader(data)) - if err != nil { - return err - } - req.Header.Set("Content-Type", "application/json") - if team := ctx.teamID(); team != "" { - req.Header.Set("X-Mist-Team", team) - } - if ctx.Config != nil && ctx.Config.SessionCookie != "" { - req.Header.Set("Cookie", ctx.Config.SessionCookie) - } - client := ctx.HTTPClient - if client == nil { - client = &http.Client{Timeout: 30 * time.Second} - } - response, err := client.Do(req) - if err != nil { - return err - } - defer response.Body.Close() - if response.StatusCode < 200 || response.StatusCode >= 300 { - data, _ := io.ReadAll(io.LimitReader(response.Body, 8192)) - var errorBody struct { - Error string `json:"error"` - } - if json.Unmarshal(data, &errorBody) == nil && errorBody.Error != "" { - return fmt.Errorf("API %d: %s", response.StatusCode, errorBody.Error) - } - return fmt.Errorf("API %d: %s", response.StatusCode, strings.TrimSpace(string(data))) - } - if out != nil { - return json.NewDecoder(response.Body).Decode(out) - } - return nil -} - -func (ctx *AppContext) baseURL() string { - if ctx.APIBaseURL != "" { - return strings.TrimRight(ctx.APIBaseURL, "/") - } - if ctx.Config != nil && ctx.Config.APIBaseURL != "" { - return strings.TrimRight(ctx.Config.APIBaseURL, "/") - } - return "http://127.0.0.1:3000" -} - -func (ctx *AppContext) teamID() string { - if ctx.TeamID == "legacy" { - return "" - } - if ctx.TeamID != "" { - return ctx.TeamID - } - if ctx.Config != nil { - return ctx.Config.TeamID - } - return "" -} diff --git a/cli/cmd/auth.go b/cli/cmd/auth.go deleted file mode 100644 index 9da9f38..0000000 --- a/cli/cmd/auth.go +++ /dev/null @@ -1,13 +0,0 @@ -package cmd - -type AuthCmd struct { - Login LoginCmd `cmd:"" help:"Log in to your account"` - Logout LogoutCmd `cmd:"" help:"Sign out and revoke your session"` - // Status AuthStatusCmd `cmd:"" help:"Check your authentication status" default:1` -} - -func (a *AuthCmd) Run() error { - // Possible fallback if no subcommand is provided - // fmt.Println("(auth root) – try 'mist auth login|logout|status' or mist help") - return nil -} diff --git a/cli/cmd/auth_login.go b/cli/cmd/auth_login.go deleted file mode 100644 index 8fff8d0..0000000 --- a/cli/cmd/auth_login.go +++ /dev/null @@ -1,148 +0,0 @@ -package cmd - -import ( - "bytes" - "encoding/json" - "fmt" - "golang.org/x/term" - "io" - "net/http" - "net/url" - "os" - "path/filepath" - "strings" -) - -type LoginCmd struct { - Email string `help:"Mist member email" required:""` - PasswordStdin bool `help:"Read password from standard input (for automation)"` -} - -func (l *LoginCmd) Run(ctx *AppContext) error { - var password string - if l.PasswordStdin { - data, err := io.ReadAll(io.LimitReader(os.Stdin, 130)) - if err != nil { - return err - } - password = strings.TrimSpace(string(data)) - } else { - fmt.Print("Password: ") - data, err := term.ReadPassword(int(os.Stdin.Fd())) - fmt.Println() - if err != nil { - return fmt.Errorf("password input: %w (use --password-stdin for automation)", err) - } - password = string(data) - } - base := ctx.baseURL() - u, err := url.Parse(base) - if err != nil { - return err - } - u.Path = "/auth/sign-in/email" - u.RawQuery = "" - body, _ := json.Marshal(map[string]string{"email": l.Email, "password": password}) - req, err := http.NewRequest(http.MethodPost, u.String(), bytes.NewReader(body)) - if err != nil { - return err - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Origin", u.Scheme+"://"+u.Host) - response, err := ctx.HTTPClient.Do(req) - if err != nil { - return err - } - defer response.Body.Close() - if response.StatusCode != 200 { - return fmt.Errorf("login failed (HTTP %d)", response.StatusCode) - } - cookies := []string{} - for _, cookie := range response.Cookies() { - if strings.Contains(cookie.Name, "session_token") && cookie.Value != "" { - cookies = append(cookies, cookie.Name+"="+cookie.Value) - } - } - if len(cookies) == 0 { - return fmt.Errorf("login returned no session cookie") - } - if ctx.Config == nil { - ctx.Config = &Config{} - } - ctx.Config.SessionCookie = strings.Join(cookies, "; ") - ctx.Config.AccessToken = "" - ctx.Config.APIBaseURL = base - if err := ctx.saveConfig(); err != nil { - return err - } - fmt.Println("Signed in. Session saved in protected CLI configuration.") - return nil -} -func (ctx *AppContext) saveConfig() error { - path := ctx.ConfigPath - if path == "" { - path = defaultConfigPath() - } - if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { - return err - } - data, err := json.MarshalIndent(ctx.Config, "", " ") - if err != nil { - return err - } - file, err := os.CreateTemp(filepath.Dir(path), ".mist-config-") - if err != nil { - return err - } - defer os.Remove(file.Name()) - if err = file.Chmod(0600); err != nil { - file.Close() - return err - } - if _, err = file.Write(data); err != nil { - file.Close() - return err - } - if err = file.Close(); err != nil { - return err - } - return os.Rename(file.Name(), path) -} - -type LogoutCmd struct{} - -func (l *LogoutCmd) Run(ctx *AppContext) error { - if ctx.Config == nil || ctx.Config.SessionCookie == "" { - fmt.Println("Not signed in.") - return nil - } - base, err := url.Parse(ctx.baseURL()) - if err != nil { - return err - } - origin := base.Scheme + "://" + base.Host - base.Path = "/auth/sign-out" - base.RawQuery = "" - req, err := http.NewRequest(http.MethodPost, base.String(), strings.NewReader("{}")) - if err != nil { - return err - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Origin", origin) - req.Header.Set("Cookie", ctx.Config.SessionCookie) - response, err := ctx.HTTPClient.Do(req) - if err != nil { - return err - } - response.Body.Close() - if response.StatusCode != 200 { - return fmt.Errorf("sign out failed (HTTP %d)", response.StatusCode) - } - ctx.Config.SessionCookie = "" - ctx.Config.AccessToken = "" - if err := ctx.saveConfig(); err != nil { - return err - } - fmt.Println("Signed out.") - return nil -} diff --git a/cli/cmd/config.go b/cli/cmd/config.go deleted file mode 100644 index 3ece81d..0000000 --- a/cli/cmd/config.go +++ /dev/null @@ -1,15 +0,0 @@ -package cmd - -import ( - "os" - "path/filepath" -) - -func defaultConfigPath() string { - dir, err := os.UserConfigDir() - if err != nil { - home, _ := os.UserHomeDir() - return filepath.Join(home, ".config", "mist", "config.json") - } - return filepath.Join(dir, "mist", "config.json") -} diff --git a/cli/cmd/help.go b/cli/cmd/help.go deleted file mode 100644 index cf571e2..0000000 --- a/cli/cmd/help.go +++ /dev/null @@ -1,16 +0,0 @@ -package cmd - -import "fmt" - -type HelpCmd struct{} - -func (h *HelpCmd) Run() error { - fmt.Println("MIST CLI Help") - fmt.Println("Usage: mist [command] [options]") - fmt.Println("Commands:") - fmt.Println(" auth Authentication commands") - fmt.Println(" job Job management commands") - fmt.Println(" config Configuration commands") - fmt.Println(" help Show help information") - return nil -} diff --git a/cli/cmd/job.go b/cli/cmd/job.go deleted file mode 100644 index e5d7faf..0000000 --- a/cli/cmd/job.go +++ /dev/null @@ -1,9 +0,0 @@ -package cmd - -type JobCmd struct { - Submit JobSubmitCmd `cmd:"" help:"Submit a new job"` - Cancel JobCancelCmd `cmd:"" help:"Cancel an existing job"` - Status JobStatusCmd `cmd:"" help:"Check the status of a job"` - Logs JobLogsCmd `cmd:"" help:"Read workload logs"` - List ListCmd `cmd:"" help:"List jobs"` -} diff --git a/cli/cmd/job_cancel.go b/cli/cmd/job_cancel.go deleted file mode 100644 index 0cd7072..0000000 --- a/cli/cmd/job_cancel.go +++ /dev/null @@ -1,19 +0,0 @@ -package cmd - -import ( - "fmt" - "net/url" -) - -type JobCancelCmd struct { - ID string `arg:"" help:"Job ID to cancel"` -} - -func (j *JobCancelCmd) Run(ctx *AppContext) error { - var job Job - if err := ctx.api("POST", "/jobs/"+url.PathEscape(j.ID)+"/cancel", nil, &job); err != nil { - return err - } - fmt.Printf("Job %s: %s\n", job.ID, job.Status) - return nil -} diff --git a/cli/cmd/job_cancel_test.go b/cli/cmd/job_cancel_test.go deleted file mode 100644 index 79da1e2..0000000 --- a/cli/cmd/job_cancel_test.go +++ /dev/null @@ -1,25 +0,0 @@ -package cmd - -import ( - "net/http" - "net/http/httptest" - "testing" -) - -func TestJobCancelCallsAPIWithoutConfirmation(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" || r.URL.Path != "/jobs/cancel-id/cancel" { - t.Errorf("wrong request: %s %s", r.Method, r.URL.Path) - } - _, _ = w.Write([]byte(`{"id":"cancel-id","job_state":"Cancelled"}`)) - })) - defer server.Close() - out := CaptureOutput(func() { - if err := (&JobCancelCmd{ID: "cancel-id"}).Run(&AppContext{APIBaseURL: server.URL}); err != nil { - t.Error(err) - } - }) - if !contains(out, "Cancelled") || contains(out, "Are you sure") { - t.Fatal(out) - } -} diff --git a/cli/cmd/job_list.go b/cli/cmd/job_list.go deleted file mode 100644 index 0dfec2d..0000000 --- a/cli/cmd/job_list.go +++ /dev/null @@ -1,49 +0,0 @@ -package cmd - -import ( - "fmt" - "os" - "text/tabwriter" - "time" -) - -type ListCmd struct { - All bool `help:"Include completed, failed, and cancelled jobs" short:"a"` -} -type Job struct { - ID string `json:"id"` - Name string `json:"name"` - Status string `json:"job_state"` - GPUType string `json:"accelerator"` - CreatedAt time.Time `json:"created"` - Node string `json:"node"` - DeviceCount int `json:"device_count"` - ExitCode *int `json:"exit_code"` - Message string `json:"message"` -} - -func printJobs(jobs []Job) { - w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - fmt.Fprintln(w, "Job ID\tName\tStatus\tCompute\tDevices\tNode\tCreated") - for _, job := range jobs { - fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%d\t%s\t%s\n", job.ID, job.Name, job.Status, job.GPUType, job.DeviceCount, job.Node, job.CreatedAt.Format(time.RFC3339)) - } - _ = w.Flush() -} - -func (l *ListCmd) Run(ctx *AppContext) error { - var response struct { - Jobs []Job `json:"jobs"` - } - if err := ctx.api("GET", "/jobs", nil, &response); err != nil { - return err - } - jobs := []Job{} - for _, job := range response.Jobs { - if l.All || job.Status == "Scheduled" || job.Status == "InProgress" { - jobs = append(jobs, job) - } - } - printJobs(jobs) - return nil -} diff --git a/cli/cmd/job_list_test.go b/cli/cmd/job_list_test.go deleted file mode 100644 index 070ce98..0000000 --- a/cli/cmd/job_list_test.go +++ /dev/null @@ -1,31 +0,0 @@ -package cmd - -import ( - "net/http" - "net/http/httptest" - "testing" -) - -func TestJobListFiltersCompleted(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write([]byte(`{"jobs":[{"id":"running-id","name":"work","job_state":"InProgress","accelerator":"nvidia"},{"id":"done-id","name":"old","job_state":"Success","accelerator":"cpu"}]}`)) - })) - defer server.Close() - ctx := &AppContext{APIBaseURL: server.URL} - out := CaptureOutput(func() { - if err := (&ListCmd{}).Run(ctx); err != nil { - t.Error(err) - } - }) - if !contains(out, "running-id") || contains(out, "done-id") { - t.Fatal(out) - } - out = CaptureOutput(func() { - if err := (&ListCmd{All: true}).Run(ctx); err != nil { - t.Error(err) - } - }) - if !contains(out, "done-id") { - t.Fatal(out) - } -} diff --git a/cli/cmd/job_logs.go b/cli/cmd/job_logs.go deleted file mode 100644 index b183510..0000000 --- a/cli/cmd/job_logs.go +++ /dev/null @@ -1,21 +0,0 @@ -package cmd - -import ( - "fmt" - "net/url" -) - -type JobLogsCmd struct { - ID string `arg:"" help:"Job ID"` -} - -func (j *JobLogsCmd) Run(ctx *AppContext) error { - var response struct { - Logs string `json:"logs"` - } - if err := ctx.api("GET", "/jobs/"+url.PathEscape(j.ID)+"/logs", nil, &response); err != nil { - return err - } - fmt.Print(response.Logs) - return nil -} diff --git a/cli/cmd/job_status.go b/cli/cmd/job_status.go deleted file mode 100644 index 9d15a01..0000000 --- a/cli/cmd/job_status.go +++ /dev/null @@ -1,25 +0,0 @@ -package cmd - -import ( - "fmt" - "net/url" -) - -type JobStatusCmd struct { - ID string `arg:"" help:"Job ID"` -} - -func (j *JobStatusCmd) Run(ctx *AppContext) error { - var job Job - if err := ctx.api("GET", "/jobs/"+url.PathEscape(j.ID), nil, &job); err != nil { - return err - } - printJobs([]Job{job}) - if job.ExitCode != nil { - fmt.Println("Exit code:", *job.ExitCode) - } - if job.Message != "" { - fmt.Println(job.Message) - } - return nil -} diff --git a/cli/cmd/job_status_test.go b/cli/cmd/job_status_test.go deleted file mode 100644 index 09e1f1d..0000000 --- a/cli/cmd/job_status_test.go +++ /dev/null @@ -1,32 +0,0 @@ -package cmd - -import ( - "net/http" - "net/http/httptest" - "testing" -) - -func TestJobStatusPropagatesFailureAndExitCode(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write([]byte(`{"id":"failed-id","name":"failed","job_state":"Failure","exit_code":17}`)) - })) - defer server.Close() - out := CaptureOutput(func() { - if err := (&JobStatusCmd{ID: "failed-id"}).Run(&AppContext{APIBaseURL: server.URL}); err != nil { - t.Error(err) - } - }) - if !contains(out, "Failure") || !contains(out, "Exit code: 17") { - t.Fatal(out) - } -} -func TestJobStatusDoesNotInventSuccessForMissingJob(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(404) - _, _ = w.Write([]byte(`{"error":"job not found"}`)) - })) - defer server.Close() - if err := (&JobStatusCmd{ID: "missing"}).Run(&AppContext{APIBaseURL: server.URL}); err == nil || !contains(err.Error(), "job not found") { - t.Fatal(err) - } -} diff --git a/cli/cmd/job_submit.go b/cli/cmd/job_submit.go deleted file mode 100644 index a54945a..0000000 --- a/cli/cmd/job_submit.go +++ /dev/null @@ -1,63 +0,0 @@ -package cmd - -import ( - "fmt" - "os" - "path/filepath" - "strings" -) - -type JobSubmitCmd struct { - Scope string `help:"Save outputs in common or your member folder (default: own folder)"` - TTRuntime string `help:"Tenstorrent runtime: host or container"` - Script string `arg:"" help:"Path to a Python or shell script"` - Compute string `help:"CPU, NVIDIA, or TT" default:"CPU"` - Devices int `help:"NVIDIA GPU count or Tenstorrent board count" default:"1"` - Dataset string `help:"Owned ready dataset ID to mount at /inputs (optional)"` - Image string `help:"Tagged image from an allowed registry (optional; defaults by compute type)"` - CPU string `help:"Requested CPU quantity, e.g. 2 or 500m"` - Memory string `help:"Requested memory, e.g. 4Gi"` - Timeout int64 `help:"Maximum execution time in seconds" default:"600"` - Name string `help:"Display name for the job"` -} - -func (j *JobSubmitCmd) Run(ctx *AppContext) error { - compute := strings.ToUpper(j.Compute) - if compute == "" { - compute = "CPU" - } - accelerator := "cpu" - devices := j.Devices - if devices == 0 { - devices = 1 - } - switch compute { - case "CPU": - devices = 0 - case "NVIDIA", "CUDA": - accelerator = "nvidia" - case "TT", "TENSTORRENT": - accelerator = "tenstorrent" - default: - return fmt.Errorf("compute must be CPU, NVIDIA, or TT") - } - data, err := os.ReadFile(j.Script) - if err != nil { - return err - } - name := j.Name - if name == "" { - name = filepath.Base(j.Script) - } - request := map[string]interface{}{"type": "command", "name": name, "accelerator": accelerator, - "storage_scope": j.Scope, "tt_runtime": j.TTRuntime, "device_count": devices, "script": string(data), "script_name": filepath.Base(j.Script), - "dataset_id": j.Dataset, "image": j.Image, "cpu": j.CPU, "memory": j.Memory, "timeout_seconds": j.Timeout} - var response struct { - JobID string `json:"job_id"` - } - if err := ctx.api("POST", "/jobs", request, &response); err != nil { - return err - } - fmt.Println("Job submitted:", response.JobID) - return nil -} diff --git a/cli/cmd/job_submit_test.go b/cli/cmd/job_submit_test.go deleted file mode 100644 index 0432adf..0000000 --- a/cli/cmd/job_submit_test.go +++ /dev/null @@ -1,43 +0,0 @@ -package cmd - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "testing" -) - -func TestJobSubmitUsesRealAPI(t *testing.T) { - script := filepath.Join(t.TempDir(), "train.py") - _ = os.WriteFile(script, []byte("print('train')"), 0600) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" || r.URL.Path != "/jobs" { - t.Errorf("wrong request: %s %s", r.Method, r.URL.Path) - } - if r.Header.Get("X-Mist-Team") != "team-selected" { - t.Errorf("missing selected workspace header") - } - var body map[string]interface{} - _ = json.NewDecoder(r.Body).Decode(&body) - if body["accelerator"] != "tenstorrent" || body["device_count"] != float64(2) || body["script"] != "print('train')" { - t.Errorf("wrong submission: %+v", body) - } - w.WriteHeader(201) - _, _ = w.Write([]byte(`{"job_id":"real-id"}`)) - })) - defer server.Close() - var err error - output := CaptureOutput(func() { - err = (&JobSubmitCmd{Script: script, Compute: "TT", Devices: 2}).Run(&AppContext{APIBaseURL: server.URL, Config: &Config{TeamID: "team-selected"}}) - }) - if err != nil || !contains(output, "real-id") || contains(output, "Are you sure") { - t.Fatalf("%v: %s", err, output) - } -} -func TestJobSubmitRejectsMissingFile(t *testing.T) { - if err := (&JobSubmitCmd{Script: "/does-not-exist.py", Compute: "CPU"}).Run(&AppContext{}); err == nil { - t.Fatal("missing file accepted") - } -} diff --git a/cli/cmd/root.go b/cli/cmd/root.go deleted file mode 100644 index 23a532d..0000000 --- a/cli/cmd/root.go +++ /dev/null @@ -1,84 +0,0 @@ -package cmd - -import ( - "encoding/json" - "net/http" - "os" - "time" - - "github.com/alecthomas/kong" -) - -type Config struct { - AccessToken string `json:"access_token,omitempty"` - SessionCookie string `json:"session_cookie,omitempty"` - APIBaseURL string `json:"api_base_url,omitempty"` - TeamID string `json:"team_id,omitempty"` -} - -type AppContext struct { - Config *Config - HTTPClient *http.Client - APIBaseURL string - ConfigPath string - TeamID string -} - -type Globals struct { - Workspace string `name:"team" env:"MIST_TEAM" help:"Team workspace ID, or legacy (overrides saved workspace)"` - ConfigPath string `name:"config" help:"Path to config file" default:"${config_path}"` - APIURL string `name:"api-url" env:"MIST_API_URL" help:"Mist API URL (default http://127.0.0.1:3000)"` -} - -type CLI struct { - Globals - - Team TeamCmd `cmd:"" help:"List and select team workspaces"` - Auth AuthCmd `cmd:"" help:"Authentication commands"` - Job JobCmd `cmd:"" help:"Job management commands"` - Help HelpCmd `cmd:"" help:"Show help information"` -} - -func loadConfig(path string) (*Config, error) { - b, err := os.ReadFile(path) - if err != nil { - return nil, err - } - - var cfg Config - if err := json.Unmarshal(b, &cfg); err != nil { - return nil, err - } - - return &cfg, nil -} - -func Main() { - var cli CLI - // Read command-line arguments - - appCtx := &AppContext{ - HTTPClient: &http.Client{Timeout: 10 * time.Second}, - } - - kctx := kong.Parse(&cli, - kong.Name("mist"), - kong.Description("MIST CLI - Manage your MIST jobs and configurations"), - kong.UsageOnError(), - kong.Vars{"config_path": defaultConfigPath()}, - kong.Bind(appCtx), - ) - - if cfg, err := loadConfig(cli.ConfigPath); err == nil { - appCtx.Config = cfg - } else if !os.IsNotExist(err) { - // config file is present but broken - kctx.FatalIfErrorf(err) - } - - appCtx.APIBaseURL = cli.APIURL - appCtx.TeamID = cli.Globals.Workspace - appCtx.ConfigPath = cli.ConfigPath - err := kctx.Run() - kctx.FatalIfErrorf(err) -} diff --git a/cli/cmd/team.go b/cli/cmd/team.go deleted file mode 100644 index 936d12f..0000000 --- a/cli/cmd/team.go +++ /dev/null @@ -1,84 +0,0 @@ -package cmd - -import "fmt" - -type TeamCmd struct { - List TeamListCmd `cmd:"" help:"List your available team workspaces"` - Use TeamUseCmd `cmd:"" help:"Save a team workspace selection; use legacy for historical account jobs"` -} -type TeamListCmd struct{} -type TeamUseCmd struct { - ID string `arg:"" help:"Team ID, or legacy"` -} -type teamSummary struct { - ID string `json:"id"` - Name string `json:"name"` - Disabled bool `json:"disabled"` - Members []struct { - ID string `json:"id"` - } `json:"members"` -} - -func (j *TeamListCmd) Run(ctx *AppContext) error { - var response struct { - Teams []teamSummary `json:"teams"` - } - if err := ctx.api("GET", "/teams", nil, &response); err != nil { - return err - } - fmt.Printf("%-24s %-28s %s\n", "TEAM ID", "NAME", "STATE") - for _, t := range response.Teams { - state := "Active" - if t.Disabled { - state = "Disabled" - } - if ctx.teamID() == t.ID { - state += " · Selected" - } - fmt.Printf("%-24s %-28s %s\n", t.ID, t.Name, state) - } - return nil -} -func (j *TeamUseCmd) Run(ctx *AppContext) error { - if j.ID != "legacy" { - var response struct { - Teams []teamSummary `json:"teams"` - } - if err := ctx.api("GET", "/teams", nil, &response); err != nil { - return err - } - var session struct { - User *struct { - ID string `json:"id"` - } `json:"user"` - } - if err := ctx.api("GET", "/session", nil, &session); err != nil { - return err - } - found := false - for _, t := range response.Teams { - if t.ID == j.ID && !t.Disabled && session.User != nil { - for _, m := range t.Members { - if m.ID == session.User.ID { - found = true - } - } - } - } - if !found { - return fmt.Errorf("select an active team you belong to; run team list") - } - } - if ctx.Config == nil { - ctx.Config = &Config{} - } - ctx.Config.TeamID = j.ID - if j.ID == "legacy" { - ctx.Config.TeamID = "" - } - if err := ctx.saveConfig(); err != nil { - return err - } - fmt.Println("Workspace selected:", j.ID) - return nil -} diff --git a/cli/cmd/testutil_test.go b/cli/cmd/testutil_test.go deleted file mode 100644 index 6f60274..0000000 --- a/cli/cmd/testutil_test.go +++ /dev/null @@ -1,24 +0,0 @@ -package cmd - -import ( - "bytes" - "io" - "os" -) - -func CaptureOutput(f func()) string { - old := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - f() - w.Close() - os.Stdout = old - var buf bytes.Buffer - io.Copy(&buf, r) - r.Close() - return buf.String() -} - -func contains(s, substr string) bool { - return bytes.Contains([]byte(s), []byte(substr)) -} diff --git a/cli/docs/setup.md b/cli/docs/setup.md deleted file mode 100644 index 789a050..0000000 --- a/cli/docs/setup.md +++ /dev/null @@ -1,68 +0,0 @@ -# Mist CLI - -Requires Go 1.25.1. Build from the repository root: - -```bash -go -C cli build -o ../bin/mist-cli . -bin/mist-cli --help -``` - -The default API URL is `http://127.0.0.1:3000`. Override it using -`MIST_API_URL`, `--api-url`, or the configuration's `api_base_url` field. -The production endpoint is `http://100.73.139.66:8088/api`. Use it from a -Tailnet device or either SSH host, then authenticate: - -```bash -export MIST_API_URL=http://100.73.139.66:8088/api -bin/mist-cli auth login --email your-member-email@example.org -``` - -The password prompt does not echo. Automation can use `--password-stdin`. -The CLI saves a real session cookie in a mode-0600 config; `--config` selects -an alternate file. Logout with `bin/mist-cli auth logout` revokes the session. - -Select your team before submitting new work: - -```bash -bin/mist-cli team list -read -r -p "Paste your team ID from the list: " TEAM_ID -bin/mist-cli team use "$TEAM_ID" -bin/mist-cli job submit /path/to/train.py --compute NVIDIA --devices 1 \ - --cpu 2 --memory 2Gi --timeout 1800 --name my-training -bin/mist-cli job submit /path/to/tt_train.py --compute TT --devices 1 -bin/mist-cli job submit /path/to/task.sh --compute CPU -bin/mist-cli job list # Waiting and running jobs -bin/mist-cli job list --all # Includes completed, failed, and cancelled jobs -read -r -p "Paste the job ID returned by submission: " JOB_ID -bin/mist-cli job status "$JOB_ID" -bin/mist-cli job logs "$JOB_ID" -``` - -Submission sends the actual file contents to the API. `.py` and `.sh` files -are supported, up to 32 KiB. `--image` selects an image allowed by the API; -otherwise the API chooses a runtime for the compute type. Upload datasets through the website, then pass `--dataset dataset-ID` to attach -one read-only at `/inputs`. Dependencies belong in an image allowed by the team registry policy. -Result files are downloadable from the website's job Files panel. - -NVIDIA counts are whole GPUs (one or two); TT counts are whole n300 boards -(one to four, two chips each). Scripts must use the corresponding runtime. -The default TT profile uses QuietBox's existing TT-Metal installation; a -compatible custom image can select the container runtime. For a small -built-in accelerator training check, use the Jobs page or the API's -`training-smoke` submission type. - -Status, logs, cancellation, and errors come from the real API. Cancellation -does not ask for confirmation. The deployed API authenticates each session and derives job/dataset ownership -from the member account. Legacy fake-token authentication is removed. - -```bash -go -C cli test ./... -``` - -The prompts above take actual IDs returned by `team list` and submission. -To cancel that job while waiting/running, use `bin/mist-cli job cancel "$JOB_ID"`. -`--team` or `MIST_TEAM` overrides the saved selection. Use `team use legacy` to -read historical jobs; new work requires a team. `--scope common` requires common -write permission. Custom tagged public images are allowed by team registry policy; -TT custom images use `--tt-runtime container`. See the -[department guide](../../docs/content/department-rollout.md) for limits and storage. diff --git a/cli/go.mod b/cli/go.mod deleted file mode 100644 index 3d70d07..0000000 --- a/cli/go.mod +++ /dev/null @@ -1,10 +0,0 @@ -module mist/cli - -go 1.24.0 - -require ( - github.com/alecthomas/kong v1.12.1 - golang.org/x/term v0.35.0 -) - -require golang.org/x/sys v0.36.0 // indirect diff --git a/cli/go.sum b/cli/go.sum deleted file mode 100644 index 31447c8..0000000 --- a/cli/go.sum +++ /dev/null @@ -1,12 +0,0 @@ -github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= -github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= -github.com/alecthomas/kong v1.12.1 h1:iq6aMJDcFYP9uFrLdsiZQ2ZMmcshduyGv4Pek0MQPW0= -github.com/alecthomas/kong v1.12.1/go.mod h1:p2vqieVMeTAnaC83txKtXe8FLke2X07aruPWXyMPQrU= -github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= -github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= -github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= -github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= -golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= -golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ= -golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA= diff --git a/cli/main.go b/cli/main.go deleted file mode 100644 index 038f096..0000000 --- a/cli/main.go +++ /dev/null @@ -1,5 +0,0 @@ -package main - -import "mist/cli/cmd" - -func main() { cmd.Main() } diff --git a/deploy/k3s/Dockerfile.api b/deploy/k3s/Dockerfile.api deleted file mode 100644 index 0cad2bf..0000000 --- a/deploy/k3s/Dockerfile.api +++ /dev/null @@ -1,7 +0,0 @@ -# Build the static binary from the repository root: -# CGO_ENABLED=0 go -C src build -o ../bin/mist-api . -# docker build -f deploy/k3s/Dockerfile.api -t mist-api:departments-20261009 . -FROM alpine:3.16.3 -COPY bin/mist-api /usr/local/bin/mist-api -USER 65532:65532 -ENTRYPOINT ["/usr/local/bin/mist-api"] diff --git a/deploy/k3s/README.md b/deploy/k3s/README.md deleted file mode 100644 index 55ceeb1..0000000 --- a/deploy/k3s/README.md +++ /dev/null @@ -1,34 +0,0 @@ -# Kubernetes deployment assets - -Current installation: two k3s nodes, authenticated team jobs, scoped NFS storage -and private Tailscale portal. Start with the [k3s setup/handoff](../../docs/content/k3s-setup.md) -and [deployment operations](../private/README.md). - -## Manifest and tooling map - -| Assets | Purpose | -|---|---| -| `mist-api.yaml` | Single API, built-in auth, and controller deployment | -| `team-rbac.yaml`, `team-workload-policy.yaml` | Team provisioning authority and workload admission | -| `nvidia-gpu-operator-values.yaml` | Pinned host-driver/CDI configuration | -| `tenstorrent-operator-values.yaml`, `mist-tenstorrent-claims.yaml` | TT operator configuration and retained legacy claims | -| `tenstorrent-workload-policy.yaml` | Admission protection for legacy `mist` diagnostics | -| `storage/` | NFS manifests, bounded pool provisioning, migration/growth | -| `training_smoke.py`, `examples/` | Accelerator checks and packaged-image examples | -| `gpu-smoke-job.yaml`, `training-*-job.yaml`, `submit_tenstorrent_smoke.py` | Administrator diagnostics in legacy namespace | -| `verify_department*`, `verify_split_storage.py`, `verify_large_dataset.py` | Current authenticated live verifiers | -| `verify_tenstorrent_allocation.py` | Infrastructure-level TT allocation diagnostic | -| `evidence/department-2026-10-09/` | Current release evidence | -| Earlier `*-results.json` | Historical dated test evidence | - -Direct manifest diagnostics require cluster administrator access and bypass -portal team admission. Researchers should use the authenticated portal or CLI. -Archived pre-team API/browser verifiers have been retired from executable source. - -The application image build uses [Dockerfile.api](Dockerfile.api). API/auth images -are imported into main's k3s containerd, not scheduled by a Docker supervisor. -Use `bash deploy/private/build-and-deploy.sh` for the existing installation. - -[Old deployment history](../../docs/content/archive/k3s-deployment-history.md) and -[original node setup](../../docs/content/archive/k3s-setup-history.md) preserve earlier -observations; their unauthenticated/node-local instructions are superseded. diff --git a/deploy/k3s/complete-results.json b/deploy/k3s/complete-results.json deleted file mode 100644 index 641710a..0000000 --- a/deploy/k3s/complete-results.json +++ /dev/null @@ -1,104 +0,0 @@ -{ - "site": "http://100.73.139.66:8088", - "started": "2026-10-09T03:36:21.090Z", - "jobs": [ - { - "id": "mist-22600b7fdeaa5e5f921b25e9", - "backend": "cpu", - "state": "Success", - "node": "utmist-z1opa08", - "files": [ - { - "path": "dataset-check.json", - "size": 37 - } - ] - }, - { - "id": "mist-78b599aa6e10655067ab8d86", - "backend": "nvidia", - "state": "Success", - "node": "utmist-z1opa08", - "files": [ - { - "path": "allocation.json", - "size": 85 - }, - { - "path": "nvidia-0.pt", - "size": 5409 - }, - { - "path": "nvidia-1.pt", - "size": 5409 - }, - { - "path": "results.json", - "size": 1081 - } - ] - }, - { - "id": "mist-5a2e0e2e99adcded65b1fc5d", - "backend": "tenstorrent", - "state": "Success", - "node": "utmist-tt", - "files": [ - { - "path": "allocation.json", - "size": 72 - }, - { - "path": "results.json", - "size": 977 - }, - { - "path": "tenstorrent-0.pt", - "size": 5843 - }, - { - "path": "tenstorrent-1.pt", - "size": 5843 - } - ] - } - ], - "checks": { - "browser_login": true, - "browser_upload_select_train_download": true, - "member_password_change": true, - "member_creation_isolation_deactivation": true, - "anonymous_signup_csrf_denied": true - }, - "dataset": { - "id": "dataset-197f830f9dfd0a7b1d4cf7b3", - "sha256": "98f61f87e9d30c36f0a8933028bbfcda06d56c393f12a4b5363cc1584decc01e", - "size": 248398 - }, - "browser_errors": [], - "finished": "2026-10-09T03:37:08.265Z", - "quietbox_submission": { - "submitted_from": "utmist-tt", - "job_id": "mist-32bf668f4232ca4b3b3f9472", - "node": "utmist-z1opa08", - "state": "Success", - "downloaded_metrics": { - "initial": 0.08051128685474396, - "final": 2.4448332247573035e-09, - "cuda": true - } - }, - "operations": { - "api_auth_restart_preserves_jobs_datasets_downloads": true, - "legacy_outputs_downloadable": 10, - "checked_zip_upload_and_unused_delete": true, - "auth_backup_integrity": "ok", - "checked_at": "2026-10-09T03:43:07Z" - }, - "images": { - "mist-api:complete-20261009": "sha256:de7b759498b0fdc157d846b36b5bebb560c6e7a1cc7f3c5d34904a6494273611", - "mist-auth:members-20261009": "sha256:bec799095b8ca79faabe75457143e366b67d3ca0fe6cc3dc7de5c5c9c4517c64" - }, - "scope": "Five-part foundation, privately hosted; HTTPS Serve prerequisite remains explicit", - "evidence_directory": "/home/utmist/mist-complete-results-2026-10-09" -} diff --git a/deploy/k3s/evidence/department-2026-10-09/README.md b/deploy/k3s/evidence/department-2026-10-09/README.md deleted file mode 100644 index a9596b2..0000000 --- a/deploy/k3s/evidence/department-2026-10-09/README.md +++ /dev/null @@ -1,94 +0,0 @@ -# Department verification — October 9, 2026 - -Evidence from the actual two-node private pilot before repository cleanup. -The original Go/Docker/logger counts below belong to that earlier release; -[current cleanup verification](../../../../docs/content/repository-cleanup.md) records -the maintained suites after their obsolete implementations were removed. -Reports contain synthetic -verification data and job/device IDs; no passwords, cookies or auth secrets. -Scripts are in `deploy/k3s/verify_department*`. They require existing authorized -local credentials and recorded machine addresses; inspect them before rerunning. -They create bounded test fixtures and preserve files/history afterward. - -| Check | Evidence | -|---|---| -| Fourteen API/storage/queue/training/isolation/legacy checks | [results.json](results.json) | -| Eleven browser checks, including pagination and mobile | [browser-results.json](browser-results.json) | -| Streamed 2 GiB + 4 MiB dataset, SHA256 and mounted Job input | [large upload](large-dataset-results.json), [job log](large-dataset-job.log) | -| Two concurrent TT jobs, distinct boards; failure/deadline | [runtime-results.json](runtime-results.json) | -| SSH on TT → CLI → NVIDIA on main | [remote-cli-results.json](remote-cli-results.json), [status](remote-cli-status.txt), [log](remote-cli-job.log) | -| Six server admission denials | [admission-results.json](admission-results.json) | -| Installed host chains, synthetic untrusted source | [portal-firewall.log](portal-firewall.log) | -| NVIDIA training metrics / actual allocation | [metrics](nvidia-results.json), [allocation](nvidia-allocation.json), [log](mist-8644eccafaf0b42c3d4add05.log) | -| TT training metrics / actual allocation | [metrics](tenstorrent-results.json), [allocation](tenstorrent-allocation.json), [log](mist-e5c04ea2cc126a786d6fe783.log) | -| Job writes fill only the bounded test filesystem | [capacity log](mist-1c4a852d366b9d2349e1cf7c.log) | -| Actual container cannot reach private services/credentials | [isolation log](mist-b46534ddeb9f18852b75f645.log) | -| Full release suites and final browser rerun | [release-validation.json](release-validation.json) | -| Dependency audit, zero reported vulnerabilities | [web audit](web-audit.json), [auth audit](auth-audit.json) | - -Screenshots: [jobs desktop](final-jobs-desktop.png), -[jobs mobile](final-jobs-mobile.png), [job composer](final-job-composer.png), -[mobile composer](final-job-composer-mobile.png), [datasets](final-datasets-desktop.png), -[overview](final-overview.png). - -Saved training weights were downloaded and checked during verification. They -remain in the protected local evidence directory and team job outputs; binary -weights and protected account/session state are not checked in. - -## Local checks - -```bash -go -C src test ./... -run 'Test(Department|Kubernetes|Shared|Dataset|Auth|Member|Image|Hardware|Result)' -count=1 -go -C cli test ./... -npm --prefix web-interface test -npx --prefix web-interface tsc --noEmit -p web-interface/tsconfig.json -``` - -Full frontend ESLint, shell/Python syntax checks and the production frontend -build passed. Use the pinned Go/Node versions in the deployment script. The final -release run passed all 33 backend and 10 Docker module tests, with no skips, using -a dedicated Redis container and the existing CPU Docker fixtures. CLI and logger -suites, TypeScript and all 10 React tests passed. The final browser rerun passed -all 11 checks with no browser errors. See [release validation](release-validation.json). - -The host firewall test used an isolated synthetic source on the actual installed -chains, not an external researcher's laptop. Regression training demonstrates -the tested runtimes/devices; arbitrary models, hostile-code VM isolation, HA, -per-member hard storage quotas and excluded rollout features are not claimed. - -## Separate storage pools follow-up - -[Small-file storage report](split-storage-results.json): seven live checks passed, -using a 260,729-byte dataset. Both NVIDIA GPUs and one TT board/two chips trained -from the uploaded data and saved/downloaded outputs through the new model PVC. -Existing accelerator outputs match their pre-migration evidence byte for byte. -Both pool shrink requests and combined budget overcommit were rejected without -changing persisted policy. The own small dataset was removed after both jobs. -Logs: [NVIDIA](split-nvidia.log), [Tenstorrent](split-tenstorrent.log). - -Six isolated migration tests passed using only tiny temporary files: content, -mode/ownership/link preservation, idempotence, retry after publication, conflicts, -partial stages, safe manifests and API access through member-folder parents. -Live NFS checks identified zero statfs filesystem IDs; readiness uses distinct -mount device identities. Dataset/model exports have separate stable files. - -The eleven browser checks and nine React tests passed after migration. Large -uploads and quota exhaustion were not repeated, at the user’s request. These -checks do not demonstrate uploads at 200/500 GiB or every disk-full scenario. -The initial unused 1.5 TiB bulk reservation was released using filesystem discard, -preserving data. After reclaiming deleted test blocks, the sparse backing file -used about 6 GiB physically and QuietBox had about 1.9 TiB free on its host disk. - -## Packaged image through the regular-member UI - -[Container mode report](container-mode-result.json), [filled form](container-mode-form.png), -[completed job](container-mode-completed.png). The visible desktop browser selected -NVIDIA → Container → one GPU and submitted the custom image with blank command -and argument fields. Actual job manifest inspection confirmed the image startup -was preserved and no Python script was injected. Real CUDA backpropagation, -convergence, saved weights/metrics and checkpoint reload passed. The image was -preloaded locally, so this does not claim a registry push/pull test. - -The walkthrough found and fixed an image-field editing bug: clearing an override -now leaves the field blank while the member types a replacement. A regression -test covers clearing, intermediate typing and preserving image startup defaults. diff --git a/deploy/k3s/evidence/department-2026-10-09/admission-results.json b/deploy/k3s/evidence/department-2026-10-09/admission-results.json deleted file mode 100644 index f39b83b..0000000 --- a/deploy/k3s/evidence/department-2026-10-09/admission-results.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "host_network": { - "denied": true, - "reason": "The pods \"admission-probe\" is invalid: : ValidatingAdmissionPolicy 'mist-team-boundary' with binding 'mist-team-boundary' denied request: Team workloads cannot join host namespaces." - }, - "host_pid": { - "denied": true, - "reason": "The pods \"admission-probe\" is invalid: : ValidatingAdmissionPolicy 'mist-team-boundary' with binding 'mist-team-boundary' denied request: Team workloads cannot join host namespaces." - }, - "service_account_token": { - "denied": true, - "reason": "The pods \"admission-probe\" is invalid: : ValidatingAdmissionPolicy 'mist-team-boundary' with binding 'mist-team-boundary' denied request: Team workloads must disable Kubernetes credentials." - }, - "privileged_container": { - "denied": true, - "reason": "The pods \"admission-probe\" is invalid: : ValidatingAdmissionPolicy 'mist-team-boundary' with binding 'mist-team-boundary' denied request: Team containers must drop capabilities and forbid privilege escalation." - }, - "root_host_path": { - "denied": true, - "reason": "The pods \"admission-probe\" is invalid: : ValidatingAdmissionPolicy 'mist-team-boundary' with binding 'mist-team-boundary' denied request: Team volumes allow PVCs, scripts, scratch space and read-only TT runtime mounts." - }, - "capability_add": { - "denied": true, - "reason": "The pods \"admission-probe\" is invalid: : ValidatingAdmissionPolicy 'mist-team-boundary' with binding 'mist-team-boundary' denied request: Team containers must drop capabilities and forbid privilege escalation." - } -} diff --git a/deploy/k3s/evidence/department-2026-10-09/auth-audit.json b/deploy/k3s/evidence/department-2026-10-09/auth-audit.json deleted file mode 100644 index c9ea51a..0000000 --- a/deploy/k3s/evidence/department-2026-10-09/auth-audit.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "auditReportVersion": 2, - "vulnerabilities": {}, - "metadata": { - "vulnerabilities": { - "info": 0, - "low": 0, - "moderate": 0, - "high": 0, - "critical": 0, - "total": 0 - }, - "dependencies": { - "prod": 26, - "dev": 0, - "optional": 0, - "peer": 0, - "peerOptional": 0, - "total": 25 - } - } -} diff --git a/deploy/k3s/evidence/department-2026-10-09/browser-results.json b/deploy/k3s/evidence/department-2026-10-09/browser-results.json deleted file mode 100644 index b74736b..0000000 --- a/deploy/k3s/evidence/department-2026-10-09/browser-results.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "checks": { - "admin_browser_login": true, - "browser_account_creation": true, - "browser_team_creation_membership_common_write_and_policy": true, - "browser_upload_common_folder_browse_and_download": true, - "job_defaults_follow_loaded_team_policy": true, - "regular_browser_selfservice_image_submit_expand_logs_result_download": true, - "regular_browser_admin_controls_denied": true, - "browser_password_reset_revokes_sessions_new_login_works": true, - "responsive_job_dialog_and_device_slider": true, - "history_pagination_next_previous_real_legacy_jobs": true, - "desktop_mobile_layout_no_overflow_or_browser_errors": true - }, - "browser_errors": [], - "started": "2026-10-09T07:54:23.922Z", - "job_id": "mist-6b49a490b1bf833a1e194415", - "finished": "2026-10-09T07:54:45.282Z" -} \ No newline at end of file diff --git a/deploy/k3s/evidence/department-2026-10-09/cleanup-validation.json b/deploy/k3s/evidence/department-2026-10-09/cleanup-validation.json deleted file mode 100644 index 59eeb18..0000000 --- a/deploy/k3s/evidence/department-2026-10-09/cleanup-validation.json +++ /dev/null @@ -1,56 +0,0 @@ -{ - "recorded_at": "2026-10-09T08:41:50.836023+00:00", - "base_main_commit": "3431ffdb713fac72881fc7f61f2750a82fe3f916", - "source_branch": "chore/main-cleanup-20261009", - "go": { - "race_detector": true, - "api_tests_passed": 29, - "cli_tests_passed": 6, - "skipped_tests": 0, - "vet": "passed", - "api_cli_static_builds": "passed", - "retired_executor_fail_fast": "passed", - "deadcode_tool": "golang.org/x/tools/cmd/deadcode v0.38.0, with tests", - "unreachable_functions_after_cleanup": 0 - }, - "frontend": { - "react_tests_passed": 10, - "typescript": "passed", - "lint": "passed", - "production_build": "passed", - "npm_audit_vulnerabilities": 0 - }, - "migration": { - "tests_passed": 6, - "skipped_tests": 0, - "environment": "disposable root container; read-only source, isolated tiny temporary files; no live storage" - }, - "candidate_api_smoke": { - "checks": { - "compiled_api_startup_real_kubernetes_health": true, - "real_auth_gateway_session": true, - "anonymous_jobs_denied": true, - "sigterm_graceful_shutdown": true - }, - "temporary_listener": "localhost only, removed after test", - "team_controller_enabled": false, - "cluster_or_account_mutations": false, - "production_redeployed": false - }, - "infrastructure_audit": { - "nodes_ready": 2, - "api_auth_available": true, - "operators_ready": true, - "ssh_quietbox_connection": "passed", - "tailscale_ssh_handler_enabled": false, - "live_host_service_enablement_checked": true - }, - "production_redeployed": false, - "large_storage_tests_run": false, - "fresh_training_or_browser_runs_claimed": false, - "notes": [ - "Prior live training/browser/isolation evidence is for the pre-cleanup department release", - "Main still requires independent pull-request review", - "Legacy source removal preserves existing Kubernetes jobs/PVCs and host files" - ] -} diff --git a/deploy/k3s/evidence/department-2026-10-09/container-mode-completed.png b/deploy/k3s/evidence/department-2026-10-09/container-mode-completed.png deleted file mode 100644 index efe6177..0000000 Binary files a/deploy/k3s/evidence/department-2026-10-09/container-mode-completed.png and /dev/null differ diff --git a/deploy/k3s/evidence/department-2026-10-09/container-mode-form.png b/deploy/k3s/evidence/department-2026-10-09/container-mode-form.png deleted file mode 100644 index ab2243b..0000000 Binary files a/deploy/k3s/evidence/department-2026-10-09/container-mode-form.png and /dev/null differ diff --git a/deploy/k3s/evidence/department-2026-10-09/container-mode-result.json b/deploy/k3s/evidence/department-2026-10-09/container-mode-result.json deleted file mode 100644 index ac76a98..0000000 --- a/deploy/k3s/evidence/department-2026-10-09/container-mode-result.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "job_id": "mist-a939ac7bc458ac8fc6d6dbd6", - "name": "My packaged CUDA container", - "image": "docker.io/utmist/mist-cuda-student:demo-20261009", - "mode": "Container", - "submitted_through_ui": true, - "regular_member": true, - "status": "Success", - "node": "utmist-z1opa08", - "image_command_overridden": false, - "script_injected": false, - "gpu": "NVIDIA RTX A4000", - "device": "cuda:0", - "allocated_gpus": 1, - "cuda_version": "12.4", - "initial_loss": 14.088242530822754, - "final_loss": 0.004935909993946552, - "steps": 200, - "checkpoint_reload_passed": true, - "startup": "image CMD", - "script_path": "/app/train.py" -} \ No newline at end of file diff --git a/deploy/k3s/evidence/department-2026-10-09/final-datasets-desktop.png b/deploy/k3s/evidence/department-2026-10-09/final-datasets-desktop.png deleted file mode 100644 index f455d80..0000000 Binary files a/deploy/k3s/evidence/department-2026-10-09/final-datasets-desktop.png and /dev/null differ diff --git a/deploy/k3s/evidence/department-2026-10-09/final-job-composer-mobile.png b/deploy/k3s/evidence/department-2026-10-09/final-job-composer-mobile.png deleted file mode 100644 index 529ccd6..0000000 Binary files a/deploy/k3s/evidence/department-2026-10-09/final-job-composer-mobile.png and /dev/null differ diff --git a/deploy/k3s/evidence/department-2026-10-09/final-job-composer.png b/deploy/k3s/evidence/department-2026-10-09/final-job-composer.png deleted file mode 100644 index 3765cac..0000000 Binary files a/deploy/k3s/evidence/department-2026-10-09/final-job-composer.png and /dev/null differ diff --git a/deploy/k3s/evidence/department-2026-10-09/final-jobs-desktop.png b/deploy/k3s/evidence/department-2026-10-09/final-jobs-desktop.png deleted file mode 100644 index 182e15e..0000000 Binary files a/deploy/k3s/evidence/department-2026-10-09/final-jobs-desktop.png and /dev/null differ diff --git a/deploy/k3s/evidence/department-2026-10-09/final-jobs-mobile.png b/deploy/k3s/evidence/department-2026-10-09/final-jobs-mobile.png deleted file mode 100644 index 2e771b2..0000000 Binary files a/deploy/k3s/evidence/department-2026-10-09/final-jobs-mobile.png and /dev/null differ diff --git a/deploy/k3s/evidence/department-2026-10-09/final-overview.png b/deploy/k3s/evidence/department-2026-10-09/final-overview.png deleted file mode 100644 index a9a5d9b..0000000 Binary files a/deploy/k3s/evidence/department-2026-10-09/final-overview.png and /dev/null differ diff --git a/deploy/k3s/evidence/department-2026-10-09/large-dataset-job.log b/deploy/k3s/evidence/department-2026-10-09/large-dataset-job.log deleted file mode 100644 index 9bdddba..0000000 --- a/deploy/k3s/evidence/department-2026-10-09/large-dataset-job.log +++ /dev/null @@ -1 +0,0 @@ -LARGE_DATASET_JOB_PASSED diff --git a/deploy/k3s/evidence/department-2026-10-09/large-dataset-results.json b/deploy/k3s/evidence/department-2026-10-09/large-dataset-results.json deleted file mode 100644 index 020df46..0000000 --- a/deploy/k3s/evidence/department-2026-10-09/large-dataset-results.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "checks": { - "actual_upload_over_2_gib_checksum_verified": true, - "large_file_mounted_readonly_real_job": true, - "own_probe_removed_after_job_no_user_files_deleted": true - }, - "team_id": "team-7acd03dd93cac1af", - "dataset_id": "dataset-22a8a7f21644eebf07f8e9b0", - "bytes": 2151677952, - "upload_seconds": 65.0, - "job_id": "mist-8444679f5946e19925cd4732" -} diff --git a/deploy/k3s/evidence/department-2026-10-09/mist-1c4a852d366b9d2349e1cf7c.log b/deploy/k3s/evidence/department-2026-10-09/mist-1c4a852d366b9d2349e1cf7c.log deleted file mode 100644 index 2783429..0000000 --- a/deploy/k3s/evidence/department-2026-10-09/mist-1c4a852d366b9d2349e1cf7c.log +++ /dev/null @@ -1 +0,0 @@ -STORAGE_QUOTA_PASSED bytes=1027604480 diff --git a/deploy/k3s/evidence/department-2026-10-09/mist-28ba381f47c609714d91aafb.log b/deploy/k3s/evidence/department-2026-10-09/mist-28ba381f47c609714d91aafb.log deleted file mode 100644 index 232354e..0000000 --- a/deploy/k3s/evidence/department-2026-10-09/mist-28ba381f47c609714d91aafb.log +++ /dev/null @@ -1,56 +0,0 @@ -2026-10-09 05:50:26.046 | DEBUG | ttnn::78 - Initial ttnn.CONFIG: -Config{cache_path=/root/.cache/ttnn,model_cache_path=/root/.cache/ttnn/models,tmp_dir=/tmp/ttnn,enable_model_cache=false,enable_fast_runtime_mode=true,validate_program_args=false,throw_exception_on_fallback=false,enable_logging=false,enable_graph_report=false,enable_graph_python_stack_traces=false,enable_detailed_buffer_report=false,enable_detailed_tensor_report=false,enable_comparison_mode=false,comparison_mode_should_raise_exception=false,comparison_mode_pcc=0.9999,root_report_path=generated/ttnn/reports,report_name=std::nullopt,std::nullopt} -2026-10-09 05:50:26.248 | info | Device | Opening user mode device driver (tt_cluster.cpp:228) -2026-10-09 05:50:26.248 | info | UMD | Cluster constructor started. (cluster.cpp:389) -2026-10-09 05:50:26.249 | info | UMD | Creating TopologyDiscovery for architecture: wormhole_b0 (topology_discovery.cpp:96) -2026-10-09 05:50:26.249 | info | UMD | Starting topology discovery. (topology_discovery.cpp:115) -2026-10-09 05:50:26.250 | info | UMD | Established firmware bundle version: 19.4.2 (topology_discovery.cpp:575) -2026-10-09 05:50:26.258 | info | UMD | Completed topology discovery. (topology_discovery.cpp:119) -2026-10-09 05:50:26.302 | warning | UMD | hwloc_set_area_membind(): failed for physical_device_id: 1 on NodeSet: [0] with errno: Operation not permitted (pid: 1 tid: 134777867474752) (cpuset_lib.cpp:440) -2026-10-09 05:50:26.302 | warning | UMD | ---- ttSiliconDevice::init_hugepage: bind_area_to_memory_nodeset() failed (physical_device_id: 1 ch: 0). Hugepage allocation is not on NumaNode matching TT Device. Side-Effect is decreased Device->Host perf (Issue #893). (silicon_sysmem_manager.cpp:271) -2026-10-09 05:50:26.302 | warning | UMD | hwloc_set_area_membind(): failed for physical_device_id: 1 on NodeSet: [0] with errno: Operation not permitted (pid: 1 tid: 134777867474752) (cpuset_lib.cpp:440) -2026-10-09 05:50:26.302 | warning | UMD | ---- ttSiliconDevice::init_hugepage: bind_area_to_memory_nodeset() failed (physical_device_id: 1 ch: 1). Hugepage allocation is not on NumaNode matching TT Device. Side-Effect is decreased Device->Host perf (Issue #893). (silicon_sysmem_manager.cpp:271) -2026-10-09 05:50:26.305 | info | UMD | Opening local chip ids/PCIe ids: {0}/[1] and remote chip ids {1} (cluster.cpp:173) -2026-10-09 05:50:26.305 | info | UMD | IOMMU: disabled (cluster.cpp:147) -2026-10-09 05:50:26.305 | info | UMD | KMD version: 2.5.0 (cluster.cpp:150) -2026-10-09 05:50:26.305 | info | UMD | Cluster constructor completed. (cluster.cpp:648) -2026-10-09 05:50:26.306 | info | UMD | Starting devices in cluster (cluster.cpp:1188) -2026-10-09 05:50:26.310 | info | UMD | Starting devices in cluster completed. (cluster.cpp:1196) -Tenstorrent devices=2; torch used for data/metrics=2.11.0+cpu -Opening Tenstorrent device 0 -2026-10-09 05:50:26.386 | info | Distributed | Using auto discovery to generate mesh graph. (metal_env.cpp:437) -2026-10-09 05:50:26.386 | info | Distributed | Constructing control plane using auto-discovery (no mesh graph descriptor). (metal_env.cpp:499) -2026-10-09 05:50:26.386 | info | Fabric | Logical multi-mesh adjacency: intermesh degree histogram {0:1}; intra-mesh degree histograms mesh0 {1:2} (topology_mapper_utils.cpp:3329) -2026-10-09 05:50:26.386 | info | Fabric | Physical multi-mesh adjacency: intermesh degree histogram {0:1}; intra-mesh degree histograms mesh0 {1:2} (topology_mapper_utils.cpp:3337) -tt:0 step=0 mse=0.18213829 -tt:0 step=20 mse=0.00123700 -tt:0 step=40 mse=0.00011524 -tt:0 step=60 mse=0.00006454 -tt:0 step=80 mse=0.00006277 -{"backend": "tenstorrent", "device_id": 0, "initial_mse": 0.3193739652633667, "final_mse": 6.238709465833381e-05, "validation_mse": 0.00017507212760392576, "max_weight_change": 0.3046875, "checkpoint": "/checkpoints/mist-28ba381f47c609714d91aafb/tenstorrent-0.pt"} -Opening Tenstorrent device 1 -2026-10-09 05:50:40.978 | info | Metal | Fabric config changed from FabricConfig::DISABLED to FabricConfig::FABRIC_1D, reinitializing control plane (metal_env.cpp:337) -2026-10-09 05:50:40.978 | info | Distributed | Using auto discovery to generate mesh graph. (metal_env.cpp:437) -2026-10-09 05:50:40.978 | info | Distributed | Constructing control plane using auto-discovery (no mesh graph descriptor). (metal_env.cpp:499) -2026-10-09 05:50:40.978 | info | Fabric | Logical multi-mesh adjacency: intermesh degree histogram {0:1}; intra-mesh degree histograms mesh0 {1:2} (topology_mapper_utils.cpp:3329) -2026-10-09 05:50:40.978 | info | Fabric | Physical multi-mesh adjacency: intermesh degree histogram {0:1}; intra-mesh degree histograms mesh0 {1:2} (topology_mapper_utils.cpp:3337) -2026-10-09 05:50:40.979 | info | Metal | Enabling FabricConfig::FABRIC_1D only for dispatch. If your workload requires fabric, please set the fabric config accordingly. (device_manager.cpp:291) -2026-10-09 05:50:40.979 | info | Metal | Dispatch on FabricConfig::FABRIC_1D with 1 Command Queues - (device_manager.cpp:299) -2026-10-09 05:50:40.980 | info | Metal | Initializing Fabric (fabric_firmware_initializer.cpp:296) -2026-10-09 05:50:42.982 | info | Metal | Fabric initialized on 2 devices (fabric_firmware_initializer.cpp:444) -2026-10-09 05:50:42.982 | info | Metal | Fabric Initialized with config FabricConfig::FABRIC_1D (fabric_firmware_initializer.cpp:313) -tt:1 step=0 mse=0.18213829 -tt:1 step=20 mse=0.00123700 -tt:1 step=40 mse=0.00011524 -tt:1 step=60 mse=0.00006454 -tt:1 step=80 mse=0.00006277 -{"backend": "tenstorrent", "device_id": 1, "initial_mse": 0.3193739652633667, "final_mse": 6.238709465833381e-05, "validation_mse": 0.00017507212760392576, "max_weight_change": 0.3046875, "checkpoint": "/checkpoints/mist-28ba381f47c609714d91aafb/tenstorrent-1.pt"} -TRAINING_PASSED backend=tenstorrent devices=2 -2026-10-09 05:50:56.847 | info | BuildKernels | JIT cache stats: 0/58 hits (0.0%) [0 cached, 10 build-once dedup, 0 merged artifacts, 0 merged genfiles] (build_cache_telemetry.cpp:207) -2026-10-09 05:50:56.847 | info | BuildKernels | JIT telemetry: 2 registered TelemetryTokens (build_cache_telemetry.cpp:230) -2026-10-09 05:50:56.852 | info | Device | Closing user mode device drivers (tt_cluster.cpp:513) -2026-10-09 05:50:56.852 | info | UMD | Closing devices in cluster (cluster.cpp:1201) -2026-10-09 05:50:56.860 | info | UMD | Closing devices in cluster completed. (cluster.cpp:1210) -2026-10-09 05:50:56.860 | info | UMD | Cluster destructor started. (cluster.cpp:916) -2026-10-09 05:50:56.860 | info | UMD | Cluster destructor completed. (cluster.cpp:919) diff --git a/deploy/k3s/evidence/department-2026-10-09/mist-8644eccafaf0b42c3d4add05.log b/deploy/k3s/evidence/department-2026-10-09/mist-8644eccafaf0b42c3d4add05.log deleted file mode 100644 index 3886ab9..0000000 --- a/deploy/k3s/evidence/department-2026-10-09/mist-8644eccafaf0b42c3d4add05.log +++ /dev/null @@ -1,18 +0,0 @@ -CUDA devices=2; torch=2.5.1+cu124 -Training on cuda:0: NVIDIA RTX A4000 -DATASET_LOADED file=/inputs/regression.json -cuda:0 step=0 mse=0.08051129 -cuda:0 step=20 mse=0.00135718 -cuda:0 step=40 mse=0.00003812 -cuda:0 step=60 mse=0.00000139 -cuda:0 step=80 mse=0.00000006 -{"backend": "nvidia", "device_id": 0, "initial_mse": 0.08051128685474396, "final_mse": 2.4448332247573035e-09, "validation_mse": 5.969065242084071e-09, "max_weight_change": 0.09999077022075653, "checkpoint": "/outputs/nvidia-0.pt"} -Training on cuda:1: NVIDIA RTX A4000 -DATASET_LOADED file=/inputs/regression.json -cuda:1 step=0 mse=0.08051129 -cuda:1 step=20 mse=0.00135718 -cuda:1 step=40 mse=0.00003812 -cuda:1 step=60 mse=0.00000139 -cuda:1 step=80 mse=0.00000006 -{"backend": "nvidia", "device_id": 1, "initial_mse": 0.08051128685474396, "final_mse": 2.4448332247573035e-09, "validation_mse": 5.969065242084071e-09, "max_weight_change": 0.09999077022075653, "checkpoint": "/outputs/nvidia-1.pt"} -TRAINING_PASSED backend=nvidia devices=2 diff --git a/deploy/k3s/evidence/department-2026-10-09/mist-aeba50ca436d70aa879381ce.log b/deploy/k3s/evidence/department-2026-10-09/mist-aeba50ca436d70aa879381ce.log deleted file mode 100644 index 13c62c2..0000000 --- a/deploy/k3s/evidence/department-2026-10-09/mist-aeba50ca436d70aa879381ce.log +++ /dev/null @@ -1,56 +0,0 @@ -2026-10-09 05:50:26.124 | DEBUG | ttnn::78 - Initial ttnn.CONFIG: -Config{cache_path=/root/.cache/ttnn,model_cache_path=/root/.cache/ttnn/models,tmp_dir=/tmp/ttnn,enable_model_cache=false,enable_fast_runtime_mode=true,validate_program_args=false,throw_exception_on_fallback=false,enable_logging=false,enable_graph_report=false,enable_graph_python_stack_traces=false,enable_detailed_buffer_report=false,enable_detailed_tensor_report=false,enable_comparison_mode=false,comparison_mode_should_raise_exception=false,comparison_mode_pcc=0.9999,root_report_path=generated/ttnn/reports,report_name=std::nullopt,std::nullopt} -2026-10-09 05:50:26.323 | info | Device | Opening user mode device driver (tt_cluster.cpp:228) -2026-10-09 05:50:26.323 | info | UMD | Cluster constructor started. (cluster.cpp:389) -2026-10-09 05:50:26.324 | info | UMD | Creating TopologyDiscovery for architecture: wormhole_b0 (topology_discovery.cpp:96) -2026-10-09 05:50:26.324 | info | UMD | Starting topology discovery. (topology_discovery.cpp:115) -2026-10-09 05:50:26.325 | info | UMD | Established firmware bundle version: 19.4.2 (topology_discovery.cpp:575) -2026-10-09 05:50:26.333 | info | UMD | Completed topology discovery. (topology_discovery.cpp:119) -2026-10-09 05:50:26.366 | warning | UMD | hwloc_set_area_membind(): failed for physical_device_id: 2 on NodeSet: [0] with errno: Operation not permitted (pid: 1 tid: 125262848497472) (cpuset_lib.cpp:440) -2026-10-09 05:50:26.366 | warning | UMD | ---- ttSiliconDevice::init_hugepage: bind_area_to_memory_nodeset() failed (physical_device_id: 2 ch: 0). Hugepage allocation is not on NumaNode matching TT Device. Side-Effect is decreased Device->Host perf (Issue #893). (silicon_sysmem_manager.cpp:271) -2026-10-09 05:50:26.366 | warning | UMD | hwloc_set_area_membind(): failed for physical_device_id: 2 on NodeSet: [0] with errno: Operation not permitted (pid: 1 tid: 125262848497472) (cpuset_lib.cpp:440) -2026-10-09 05:50:26.366 | warning | UMD | ---- ttSiliconDevice::init_hugepage: bind_area_to_memory_nodeset() failed (physical_device_id: 2 ch: 1). Hugepage allocation is not on NumaNode matching TT Device. Side-Effect is decreased Device->Host perf (Issue #893). (silicon_sysmem_manager.cpp:271) -2026-10-09 05:50:26.369 | info | UMD | Opening local chip ids/PCIe ids: {0}/[2] and remote chip ids {1} (cluster.cpp:173) -2026-10-09 05:50:26.369 | info | UMD | IOMMU: disabled (cluster.cpp:147) -2026-10-09 05:50:26.369 | info | UMD | KMD version: 2.5.0 (cluster.cpp:150) -2026-10-09 05:50:26.369 | info | UMD | Cluster constructor completed. (cluster.cpp:648) -2026-10-09 05:50:26.371 | info | UMD | Starting devices in cluster (cluster.cpp:1188) -2026-10-09 05:50:26.374 | info | UMD | Starting devices in cluster completed. (cluster.cpp:1196) -Tenstorrent devices=2; torch used for data/metrics=2.11.0+cpu -Opening Tenstorrent device 0 -2026-10-09 05:50:26.449 | info | Distributed | Using auto discovery to generate mesh graph. (metal_env.cpp:437) -2026-10-09 05:50:26.449 | info | Distributed | Constructing control plane using auto-discovery (no mesh graph descriptor). (metal_env.cpp:499) -2026-10-09 05:50:26.449 | info | Fabric | Logical multi-mesh adjacency: intermesh degree histogram {0:1}; intra-mesh degree histograms mesh0 {1:2} (topology_mapper_utils.cpp:3329) -2026-10-09 05:50:26.449 | info | Fabric | Physical multi-mesh adjacency: intermesh degree histogram {0:1}; intra-mesh degree histograms mesh0 {1:2} (topology_mapper_utils.cpp:3337) -tt:0 step=0 mse=0.18213829 -tt:0 step=20 mse=0.00123700 -tt:0 step=40 mse=0.00011524 -tt:0 step=60 mse=0.00006454 -tt:0 step=80 mse=0.00006277 -{"backend": "tenstorrent", "device_id": 0, "initial_mse": 0.3193739652633667, "final_mse": 6.238709465833381e-05, "validation_mse": 0.00017507212760392576, "max_weight_change": 0.3046875, "checkpoint": "/checkpoints/mist-aeba50ca436d70aa879381ce/tenstorrent-0.pt"} -Opening Tenstorrent device 1 -2026-10-09 05:50:40.352 | info | Metal | Fabric config changed from FabricConfig::DISABLED to FabricConfig::FABRIC_1D, reinitializing control plane (metal_env.cpp:337) -2026-10-09 05:50:40.352 | info | Distributed | Using auto discovery to generate mesh graph. (metal_env.cpp:437) -2026-10-09 05:50:40.352 | info | Distributed | Constructing control plane using auto-discovery (no mesh graph descriptor). (metal_env.cpp:499) -2026-10-09 05:50:40.352 | info | Fabric | Logical multi-mesh adjacency: intermesh degree histogram {0:1}; intra-mesh degree histograms mesh0 {1:2} (topology_mapper_utils.cpp:3329) -2026-10-09 05:50:40.352 | info | Fabric | Physical multi-mesh adjacency: intermesh degree histogram {0:1}; intra-mesh degree histograms mesh0 {1:2} (topology_mapper_utils.cpp:3337) -2026-10-09 05:50:40.353 | info | Metal | Enabling FabricConfig::FABRIC_1D only for dispatch. If your workload requires fabric, please set the fabric config accordingly. (device_manager.cpp:291) -2026-10-09 05:50:40.353 | info | Metal | Dispatch on FabricConfig::FABRIC_1D with 1 Command Queues - (device_manager.cpp:299) -2026-10-09 05:50:40.354 | info | Metal | Initializing Fabric (fabric_firmware_initializer.cpp:296) -2026-10-09 05:50:42.326 | info | Metal | Fabric initialized on 2 devices (fabric_firmware_initializer.cpp:444) -2026-10-09 05:50:42.326 | info | Metal | Fabric Initialized with config FabricConfig::FABRIC_1D (fabric_firmware_initializer.cpp:313) -tt:1 step=0 mse=0.18213829 -tt:1 step=20 mse=0.00123700 -tt:1 step=40 mse=0.00011524 -tt:1 step=60 mse=0.00006454 -tt:1 step=80 mse=0.00006277 -{"backend": "tenstorrent", "device_id": 1, "initial_mse": 0.3193739652633667, "final_mse": 6.238709465833381e-05, "validation_mse": 0.00017507212760392576, "max_weight_change": 0.3046875, "checkpoint": "/checkpoints/mist-aeba50ca436d70aa879381ce/tenstorrent-1.pt"} -TRAINING_PASSED backend=tenstorrent devices=2 -2026-10-09 05:50:56.182 | info | BuildKernels | JIT cache stats: 0/58 hits (0.0%) [0 cached, 10 build-once dedup, 0 merged artifacts, 0 merged genfiles] (build_cache_telemetry.cpp:207) -2026-10-09 05:50:56.182 | info | BuildKernels | JIT telemetry: 2 registered TelemetryTokens (build_cache_telemetry.cpp:230) -2026-10-09 05:50:56.187 | info | Device | Closing user mode device drivers (tt_cluster.cpp:513) -2026-10-09 05:50:56.187 | info | UMD | Closing devices in cluster (cluster.cpp:1201) -2026-10-09 05:50:56.196 | info | UMD | Closing devices in cluster completed. (cluster.cpp:1210) -2026-10-09 05:50:56.196 | info | UMD | Cluster destructor started. (cluster.cpp:916) -2026-10-09 05:50:56.196 | info | UMD | Cluster destructor completed. (cluster.cpp:919) diff --git a/deploy/k3s/evidence/department-2026-10-09/mist-b46534ddeb9f18852b75f645.log b/deploy/k3s/evidence/department-2026-10-09/mist-b46534ddeb9f18852b75f645.log deleted file mode 100644 index 74e595e..0000000 --- a/deploy/k3s/evidence/department-2026-10-09/mist-b46534ddeb9f18852b75f645.log +++ /dev/null @@ -1 +0,0 @@ -ISOLATION_PASSED diff --git a/deploy/k3s/evidence/department-2026-10-09/mist-e5c04ea2cc126a786d6fe783.log b/deploy/k3s/evidence/department-2026-10-09/mist-e5c04ea2cc126a786d6fe783.log deleted file mode 100644 index aaeffb1..0000000 --- a/deploy/k3s/evidence/department-2026-10-09/mist-e5c04ea2cc126a786d6fe783.log +++ /dev/null @@ -1,58 +0,0 @@ -2026-10-09 05:24:57.648 | DEBUG | ttnn::78 - Initial ttnn.CONFIG: -Config{cache_path=/root/.cache/ttnn,model_cache_path=/root/.cache/ttnn/models,tmp_dir=/tmp/ttnn,enable_model_cache=false,enable_fast_runtime_mode=true,validate_program_args=false,throw_exception_on_fallback=false,enable_logging=false,enable_graph_report=false,enable_graph_python_stack_traces=false,enable_detailed_buffer_report=false,enable_detailed_tensor_report=false,enable_comparison_mode=false,comparison_mode_should_raise_exception=false,comparison_mode_pcc=0.9999,root_report_path=generated/ttnn/reports,report_name=std::nullopt,std::nullopt} -2026-10-09 05:24:57.853 | info | Device | Opening user mode device driver (tt_cluster.cpp:228) -2026-10-09 05:24:57.853 | info | UMD | Cluster constructor started. (cluster.cpp:389) -2026-10-09 05:24:57.854 | info | UMD | Creating TopologyDiscovery for architecture: wormhole_b0 (topology_discovery.cpp:96) -2026-10-09 05:24:57.854 | info | UMD | Starting topology discovery. (topology_discovery.cpp:115) -2026-10-09 05:24:57.856 | info | UMD | Established firmware bundle version: 19.4.2 (topology_discovery.cpp:575) -2026-10-09 05:24:57.863 | info | UMD | Completed topology discovery. (topology_discovery.cpp:119) -2026-10-09 05:24:57.900 | warning | UMD | hwloc_set_area_membind(): failed for physical_device_id: 1 on NodeSet: [0] with errno: Operation not permitted (pid: 1 tid: 136277795993408) (cpuset_lib.cpp:440) -2026-10-09 05:24:57.900 | warning | UMD | ---- ttSiliconDevice::init_hugepage: bind_area_to_memory_nodeset() failed (physical_device_id: 1 ch: 0). Hugepage allocation is not on NumaNode matching TT Device. Side-Effect is decreased Device->Host perf (Issue #893). (silicon_sysmem_manager.cpp:271) -2026-10-09 05:24:57.900 | warning | UMD | hwloc_set_area_membind(): failed for physical_device_id: 1 on NodeSet: [0] with errno: Operation not permitted (pid: 1 tid: 136277795993408) (cpuset_lib.cpp:440) -2026-10-09 05:24:57.900 | warning | UMD | ---- ttSiliconDevice::init_hugepage: bind_area_to_memory_nodeset() failed (physical_device_id: 1 ch: 1). Hugepage allocation is not on NumaNode matching TT Device. Side-Effect is decreased Device->Host perf (Issue #893). (silicon_sysmem_manager.cpp:271) -2026-10-09 05:24:57.903 | info | UMD | Opening local chip ids/PCIe ids: {0}/[1] and remote chip ids {1} (cluster.cpp:173) -2026-10-09 05:24:57.903 | info | UMD | IOMMU: disabled (cluster.cpp:147) -2026-10-09 05:24:57.903 | info | UMD | KMD version: 2.5.0 (cluster.cpp:150) -2026-10-09 05:24:57.903 | info | UMD | Cluster constructor completed. (cluster.cpp:648) -2026-10-09 05:24:57.904 | info | UMD | Starting devices in cluster (cluster.cpp:1188) -2026-10-09 05:24:57.908 | info | UMD | Starting devices in cluster completed. (cluster.cpp:1196) -Tenstorrent devices=2; torch used for data/metrics=2.11.0+cpu -Opening Tenstorrent device 0 -2026-10-09 05:24:57.985 | info | Distributed | Using auto discovery to generate mesh graph. (metal_env.cpp:437) -2026-10-09 05:24:57.985 | info | Distributed | Constructing control plane using auto-discovery (no mesh graph descriptor). (metal_env.cpp:499) -2026-10-09 05:24:57.986 | info | Fabric | Logical multi-mesh adjacency: intermesh degree histogram {0:1}; intra-mesh degree histograms mesh0 {1:2} (topology_mapper_utils.cpp:3329) -2026-10-09 05:24:57.986 | info | Fabric | Physical multi-mesh adjacency: intermesh degree histogram {0:1}; intra-mesh degree histograms mesh0 {1:2} (topology_mapper_utils.cpp:3337) -DATASET_LOADED file=/inputs/regression.json -tt:0 step=0 mse=0.06471165 -tt:0 step=20 mse=0.00108452 -tt:0 step=40 mse=0.00004048 -tt:0 step=60 mse=0.00003227 -tt:0 step=80 mse=0.00003213 -{"backend": "tenstorrent", "device_id": 0, "initial_mse": 0.08051129430532455, "final_mse": 3.2111081964103505e-05, "validation_mse": 5.255065480014309e-05, "max_weight_change": 0.0986328125, "checkpoint": "/outputs/tenstorrent-0.pt"} -Opening Tenstorrent device 1 -2026-10-09 05:25:13.424 | info | Metal | Fabric config changed from FabricConfig::DISABLED to FabricConfig::FABRIC_1D, reinitializing control plane (metal_env.cpp:337) -2026-10-09 05:25:13.424 | info | Distributed | Using auto discovery to generate mesh graph. (metal_env.cpp:437) -2026-10-09 05:25:13.424 | info | Distributed | Constructing control plane using auto-discovery (no mesh graph descriptor). (metal_env.cpp:499) -2026-10-09 05:25:13.424 | info | Fabric | Logical multi-mesh adjacency: intermesh degree histogram {0:1}; intra-mesh degree histograms mesh0 {1:2} (topology_mapper_utils.cpp:3329) -2026-10-09 05:25:13.424 | info | Fabric | Physical multi-mesh adjacency: intermesh degree histogram {0:1}; intra-mesh degree histograms mesh0 {1:2} (topology_mapper_utils.cpp:3337) -2026-10-09 05:25:13.425 | info | Metal | Enabling FabricConfig::FABRIC_1D only for dispatch. If your workload requires fabric, please set the fabric config accordingly. (device_manager.cpp:291) -2026-10-09 05:25:13.425 | info | Metal | Dispatch on FabricConfig::FABRIC_1D with 1 Command Queues - (device_manager.cpp:299) -2026-10-09 05:25:13.427 | info | Metal | Initializing Fabric (fabric_firmware_initializer.cpp:296) -2026-10-09 05:25:15.409 | info | Metal | Fabric initialized on 2 devices (fabric_firmware_initializer.cpp:444) -2026-10-09 05:25:15.409 | info | Metal | Fabric Initialized with config FabricConfig::FABRIC_1D (fabric_firmware_initializer.cpp:313) -DATASET_LOADED file=/inputs/regression.json -tt:1 step=0 mse=0.06471165 -tt:1 step=20 mse=0.00108452 -tt:1 step=40 mse=0.00004048 -tt:1 step=60 mse=0.00003227 -tt:1 step=80 mse=0.00003213 -{"backend": "tenstorrent", "device_id": 1, "initial_mse": 0.08051129430532455, "final_mse": 3.2111081964103505e-05, "validation_mse": 5.255065480014309e-05, "max_weight_change": 0.0986328125, "checkpoint": "/outputs/tenstorrent-1.pt"} -TRAINING_PASSED backend=tenstorrent devices=2 -2026-10-09 05:25:29.422 | info | BuildKernels | JIT cache stats: 0/58 hits (0.0%) [0 cached, 10 build-once dedup, 0 merged artifacts, 0 merged genfiles] (build_cache_telemetry.cpp:207) -2026-10-09 05:25:29.422 | info | BuildKernels | JIT telemetry: 2 registered TelemetryTokens (build_cache_telemetry.cpp:230) -2026-10-09 05:25:29.427 | info | Device | Closing user mode device drivers (tt_cluster.cpp:513) -2026-10-09 05:25:29.427 | info | UMD | Closing devices in cluster (cluster.cpp:1201) -2026-10-09 05:25:29.436 | info | UMD | Closing devices in cluster completed. (cluster.cpp:1210) -2026-10-09 05:25:29.436 | info | UMD | Cluster destructor started. (cluster.cpp:916) -2026-10-09 05:25:29.436 | info | UMD | Cluster destructor completed. (cluster.cpp:919) diff --git a/deploy/k3s/evidence/department-2026-10-09/nvidia-allocation.json b/deploy/k3s/evidence/department-2026-10-09/nvidia-allocation.json deleted file mode 100644 index d428085..0000000 --- a/deploy/k3s/evidence/department-2026-10-09/nvidia-allocation.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "device_nodes": [ - "/dev/nvidia0", - "/dev/nvidia1" - ], - "gpu_count": 2 -} diff --git a/deploy/k3s/evidence/department-2026-10-09/nvidia-results.json b/deploy/k3s/evidence/department-2026-10-09/nvidia-results.json deleted file mode 100644 index c301509..0000000 --- a/deploy/k3s/evidence/department-2026-10-09/nvidia-results.json +++ /dev/null @@ -1,36 +0,0 @@ -[ - { - "backend": "nvidia", - "device_id": 0, - "initial_mse": 0.08051128685474396, - "final_mse": 2.4448332247573035e-09, - "validation_mse": 5.969065242084071e-09, - "max_weight_change": 0.09999077022075653, - "checkpoint": "/outputs/nvidia-0.pt", - "checkpoint_reload_mse": 5.969065242084071e-09, - "device_name": "NVIDIA RTX A4000", - "started_at": "2026-10-09T05:24:44.711442+00:00", - "finished_at": "2026-10-09T05:24:45.079501+00:00", - "device_nodes": [ - "/dev/nvidia0", - "/dev/nvidia1" - ] - }, - { - "backend": "nvidia", - "device_id": 1, - "initial_mse": 0.08051128685474396, - "final_mse": 2.4448332247573035e-09, - "validation_mse": 5.969065242084071e-09, - "max_weight_change": 0.09999077022075653, - "checkpoint": "/outputs/nvidia-1.pt", - "checkpoint_reload_mse": 5.969065242084071e-09, - "device_name": "NVIDIA RTX A4000", - "started_at": "2026-10-09T05:24:45.310209+00:00", - "finished_at": "2026-10-09T05:24:45.517661+00:00", - "device_nodes": [ - "/dev/nvidia0", - "/dev/nvidia1" - ] - } -] diff --git a/deploy/k3s/evidence/department-2026-10-09/portal-firewall.log b/deploy/k3s/evidence/department-2026-10-09/portal-firewall.log deleted file mode 100644 index 1ad75d5..0000000 --- a/deploy/k3s/evidence/department-2026-10-09/portal-firewall.log +++ /dev/null @@ -1,5 +0,0 @@ -PASS untrusted source reaches portal -PASS untrusted source denied 100.73.139.66:22 -PASS untrusted source denied 100.73.139.66:6443 -PASS untrusted source denied 100.73.139.66:30491 -PASS untrusted source denied 10.43.1.116:3000 diff --git a/deploy/k3s/evidence/department-2026-10-09/release-validation.json b/deploy/k3s/evidence/department-2026-10-09/release-validation.json deleted file mode 100644 index bee973b..0000000 --- a/deploy/k3s/evidence/department-2026-10-09/release-validation.json +++ /dev/null @@ -1,101 +0,0 @@ -{ - "recorded_at": "2026-10-09T07:59:10.227717+00:00", - "source_branch": "feat/local-job-foundation", - "suites": { - "backend": { - "passed": true, - "test_count": 33, - "skipped": 0, - "tests": [ - "TestContainerJobCPU", - "TestRunContainerCPUIntegration", - "TestHardwareCountsExternalScheduledPodsAndDRAReservations", - "TestHardwareDoesNotAdvertiseOfflineOrIncompleteInventory", - "TestGPURequestIncludesInitPeakAndSidecars", - "TestHardwarePermissionFailureIsAnError", - "TestCustomImagePreservesEntrypointAndWorkingDirectory", - "TestTenstorrentCustomImageRestrictionIsInCatalogAndValidation", - "TestIntegration", - "TestDummySupervisors", - "TestStatusRegistry_BasicOperations", - "TestJobEnqueueAndSupervisor", - "TestKubernetesRejectInvalidSubmission", - "TestKubernetesReportsActualExitBeforeControllerCompletion", - "TestKubernetesReportsCurrentWaitingReasonAfterPlacement", - "TestKubernetesCancellationPersistsAcrossExecutorRestart", - "TestKubernetesDoesNotExposeUnmanagedJobs", - "TestKubernetesHTTPRejectsUnknownAndMultipleObjects", - "TestSharedDatasetAtomicLimitsAndOwnership", - "TestSharedDatasetConfiguredCeilingAndMissingStorage", - "TestSharedZipRejectsTraversalSymlinksAndExpansion", - "TestSharedJobMountAndEscapingOutput", - "TestMemberAuthIsolationAndFailureClosed", - "TestDepartmentTeamPermissionsAndScopedSharing", - "TestDepartmentPolicyAndStorageBudget", - "TestDepartmentDurableFairAdmission", - "TestDepartmentAgedHeadDrainsCapacityWithoutStarvation", - "TestDepartmentDemotionRevokesAdministratorWritePrivilege", - "TestMemberProxyAddressCannotBeSpoofed", - "TestDepartmentPendingPodClaimDoesNotDoubleReserveBoards", - "TestDepartmentSplitDatasetAndModelPaths", - "TestDepartmentMissingModelMountCannotBorrowDatasetCapacity", - "TestDepartmentDefaultPoolsAndLegacyPolicy" - ] - }, - "docker_module": { - "passed": true, - "test_count": 10, - "skipped": 0, - "tests": [ - "TestCreateDeleteVolume", - "TestCreateVolumeTwice", - "TestRemoveNonexistentVolume", - "TestRunContainerCPU", - "TestRemoveVolumeInUse", - "TestAttachNonexistentVolume", - "TestTwoContainersSameVolume", - "TestTwoContainersSameVolumeConcurrent", - "TestVolumeLimit", - "TestContainerLimit" - ] - } - }, - "other_checks": { - "cli_go_tests": "passed", - "logger_go_tests": "passed", - "react_tests": { - "passed": true, - "test_count": 10 - }, - "typescript": "passed", - "full_frontend_eslint": "passed", - "production_frontend_build": "passed", - "migration_tests": { - "passed": true, - "test_count": 6 - }, - "live_browser": { - "passed": true, - "check_count": 11, - "browser_errors": [] - }, - "live_cluster": { - "nodes_ready": 2, - "api_available_replicas": 1, - "auth_available_replicas": 1 - } - }, - "legacy_test_isolation": "Dedicated Redis container with no published ports; preview Redis and data preserved. Existing CPU Docker fixtures ran against host Docker; test containers and volumes removed.", - "live_accelerator_evidence": [ - "split-storage-results.json", - "runtime-results.json", - "container-mode-result.json" - ], - "large_upload_or_exhaustion_repeated": false, - "limitations": [ - "No 200/500 GiB transfer stress or exhaustive edge-case claim", - "Custom CUDA demonstration image preloaded locally; no registry push tested in that demonstration", - "Arbitrary research model compatibility, high availability and disk-failure recovery not verified", - "Features explicitly excluded by the user are outside this release" - ] -} diff --git a/deploy/k3s/evidence/department-2026-10-09/remote-cli-job.log b/deploy/k3s/evidence/department-2026-10-09/remote-cli-job.log deleted file mode 100644 index 6a0ac8c..0000000 --- a/deploy/k3s/evidence/department-2026-10-09/remote-cli-job.log +++ /dev/null @@ -1 +0,0 @@ -REMOTE_CLI_NVIDIA_PASSED devices=1 diff --git a/deploy/k3s/evidence/department-2026-10-09/remote-cli-results.json b/deploy/k3s/evidence/department-2026-10-09/remote-cli-results.json deleted file mode 100644 index bdcdcd4..0000000 --- a/deploy/k3s/evidence/department-2026-10-09/remote-cli-results.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "origin_node": "utmist-tt", - "destination_node": "utmist-z1opa08", - "team_id": "team-db3933031a6c2437", - "login": true, - "team_selection": true, - "job_id": "mist-7d426bc89696c01e19474c00", - "job_succeeded": true -} diff --git a/deploy/k3s/evidence/department-2026-10-09/remote-cli-status.txt b/deploy/k3s/evidence/department-2026-10-09/remote-cli-status.txt deleted file mode 100644 index b87a496..0000000 --- a/deploy/k3s/evidence/department-2026-10-09/remote-cli-status.txt +++ /dev/null @@ -1,3 +0,0 @@ -Job ID Name Status Compute Devices Node Created -mist-7d426bc89696c01e19474c00 remote-cli-nvidia Success nvidia 1 utmist-z1opa08 2026-10-09T05:39:46Z -Exit code: 0 diff --git a/deploy/k3s/evidence/department-2026-10-09/results.json b/deploy/k3s/evidence/department-2026-10-09/results.json deleted file mode 100644 index fa86627..0000000 --- a/deploy/k3s/evidence/department-2026-10-09/results.json +++ /dev/null @@ -1,87 +0,0 @@ -{ - "started": "2026-10-09T05:22:29.799Z", - "site": "http://100.73.139.66:8088", - "checks": { - "team_filesystems_ready": true, - "team_read_write_and_cross_team_denial": true, - "scoped_readonly_sharing_and_running_job_revocation": true, - "durable_fifo_team_concurrency_fair_turns_and_cancel": true, - "policy_limits_and_legacy_bypass_denied": true, - "live_container_private_network_and_credentials_denied": true, - "training_writes_obey_filesystem_capacity": true, - "nvidia_real_training_uploaded_data_and_downloaded_weights": true, - "tenstorrent_real_training_uploaded_data_and_downloaded_weights": true, - "account_deactivation_stops_running_jobs": true, - "browser_rows_expand_and_saved_files_open": true, - "browser_admin_workspace_and_shared_folders": true, - "online_team_storage_growth": true, - "legacy_history_preserved": true - }, - "jobs": [ - { - "id": "mist-75fac1f6c8220c1a3cf11109", - "name": "Read-only shared input", - "team": "team-1416c881530c4ea5" - }, - { - "id": "mist-53ad27bd38ebb05d81fd6035", - "name": "Revoke shared input", - "team": "team-1416c881530c4ea5" - }, - { - "id": "mist-29df16c58727bed96f5d4ed5", - "name": "Queue holds capacity", - "team": "team-db3933031a6c2437" - }, - { - "id": "mist-f27e6e3cc96da7e8541a6752", - "name": "Queue survives restart", - "team": "team-db3933031a6c2437" - }, - { - "id": "mist-835e04d572467f624e6724e6", - "name": "Other team gets a turn", - "team": "team-1416c881530c4ea5" - }, - { - "id": "mist-b46534ddeb9f18852b75f645", - "name": "Workload network isolation", - "team": "team-db3933031a6c2437" - }, - { - "id": "mist-1c4a852d366b9d2349e1cf7c", - "name": "Filesystem capacity enforcement", - "team": "team-1416c881530c4ea5" - }, - { - "id": "mist-8644eccafaf0b42c3d4add05", - "name": "Department training nvidia", - "team": "team-db3933031a6c2437", - "node": "utmist-z1opa08" - }, - { - "id": "mist-e5c04ea2cc126a786d6fe783", - "name": "Department training tenstorrent", - "team": "team-db3933031a6c2437", - "node": "utmist-tt" - }, - { - "id": "mist-5aa3c1f395918b993de4e076", - "name": "Account deactivation releases job", - "team": "team-1416c881530c4ea5" - } - ], - "teams": [ - "team-db3933031a6c2437", - "team-1416c881530c4ea5" - ], - "legacy_job_count_before": 47, - "storage": { - "available_bytes": 994050048, - "capacity_bytes": 1021313024, - "enabled": true, - "upload_limit_bytes": 2147483648 - }, - "browser_errors": [], - "finished": "2026-10-09T05:25:50.157Z" -} \ No newline at end of file diff --git a/deploy/k3s/evidence/department-2026-10-09/runtime-results.json b/deploy/k3s/evidence/department-2026-10-09/runtime-results.json deleted file mode 100644 index 4d5b421..0000000 --- a/deploy/k3s/evidence/department-2026-10-09/runtime-results.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "checks": { - "concurrent_tt_jobs_distinct_boards_real_training": true, - "nonzero_exit_visible_no_retry": true, - "execution_deadline_failure_visible": true - }, - "jobs": [ - { - "id": "mist-28ba381f47c609714d91aafb", - "name": "Concurrent TT board A" - }, - { - "id": "mist-aeba50ca436d70aa879381ce", - "name": "Concurrent TT board B" - }, - { - "id": "mist-613d2049819ee13de76a57f1", - "name": "Intentional failure" - }, - { - "id": "mist-34416e68372f1bb5048f40b9", - "name": "Execution deadline" - } - ], - "allocations": { - "mist-28ba381f47c609714d91aafb": [ - { - "driver": "tenstorrent.com", - "pool": "utmist-tt", - "device": "tt-0" - } - ], - "mist-aeba50ca436d70aa879381ce": [ - { - "driver": "tenstorrent.com", - "pool": "utmist-tt", - "device": "tt-1" - } - ] - } -} diff --git a/deploy/k3s/evidence/department-2026-10-09/split-nvidia.log b/deploy/k3s/evidence/department-2026-10-09/split-nvidia.log deleted file mode 100644 index 3886ab9..0000000 --- a/deploy/k3s/evidence/department-2026-10-09/split-nvidia.log +++ /dev/null @@ -1,18 +0,0 @@ -CUDA devices=2; torch=2.5.1+cu124 -Training on cuda:0: NVIDIA RTX A4000 -DATASET_LOADED file=/inputs/regression.json -cuda:0 step=0 mse=0.08051129 -cuda:0 step=20 mse=0.00135718 -cuda:0 step=40 mse=0.00003812 -cuda:0 step=60 mse=0.00000139 -cuda:0 step=80 mse=0.00000006 -{"backend": "nvidia", "device_id": 0, "initial_mse": 0.08051128685474396, "final_mse": 2.4448332247573035e-09, "validation_mse": 5.969065242084071e-09, "max_weight_change": 0.09999077022075653, "checkpoint": "/outputs/nvidia-0.pt"} -Training on cuda:1: NVIDIA RTX A4000 -DATASET_LOADED file=/inputs/regression.json -cuda:1 step=0 mse=0.08051129 -cuda:1 step=20 mse=0.00135718 -cuda:1 step=40 mse=0.00003812 -cuda:1 step=60 mse=0.00000139 -cuda:1 step=80 mse=0.00000006 -{"backend": "nvidia", "device_id": 1, "initial_mse": 0.08051128685474396, "final_mse": 2.4448332247573035e-09, "validation_mse": 5.969065242084071e-09, "max_weight_change": 0.09999077022075653, "checkpoint": "/outputs/nvidia-1.pt"} -TRAINING_PASSED backend=nvidia devices=2 diff --git a/deploy/k3s/evidence/department-2026-10-09/split-storage-results.json b/deploy/k3s/evidence/department-2026-10-09/split-storage-results.json deleted file mode 100644 index 4958668..0000000 --- a/deploy/k3s/evidence/department-2026-10-09/split-storage-results.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "checks": { - "both_pools_ready_with_independent_capacities": true, - "both_shrinks_and_combined_overcommit_rejected_without_mutation": true, - "old_accelerator_checkpoints_preserved_byte_for_byte": true, - "small_dataset_uploaded_with_verified_checksum": true, - "nvidia_uploaded_input_readonly_and_models_saved_in_separate_pvc": true, - "tenstorrent_uploaded_input_readonly_and_models_saved_in_separate_pvc": true, - "own_small_probe_removed_after_terminal_jobs": true - }, - "jobs": [ - { - "id": "mist-976551958cfdf6a5ad42427c", - "backend": "nvidia", - "node": "utmist-z1opa08" - }, - { - "id": "mist-68ba775f5254308450de267c", - "backend": "tenstorrent", - "node": "utmist-tt" - } - ], - "bulk_testing": false, - "storage": { - "available_bytes": 2049966080, - "capacity_bytes": 2077229056, - "dataset_allocation_gib": 2, - "enabled": true, - "max_dataset_bytes": 1125899906842624, - "model_allocation_gib": 2, - "model_available_bytes": 2002780160, - "model_capacity_bytes": 2040528896, - "upload_limit_bytes": 1982857216 - }, - "preserved_checkpoint_files": 8, - "dataset_bytes": 260729 -} diff --git a/deploy/k3s/evidence/department-2026-10-09/split-tenstorrent.log b/deploy/k3s/evidence/department-2026-10-09/split-tenstorrent.log deleted file mode 100644 index 6272129..0000000 --- a/deploy/k3s/evidence/department-2026-10-09/split-tenstorrent.log +++ /dev/null @@ -1,58 +0,0 @@ -2026-10-09 07:05:33.186 | DEBUG | ttnn::78 - Initial ttnn.CONFIG: -Config{cache_path=/root/.cache/ttnn,model_cache_path=/root/.cache/ttnn/models,tmp_dir=/tmp/ttnn,enable_model_cache=false,enable_fast_runtime_mode=true,validate_program_args=false,throw_exception_on_fallback=false,enable_logging=false,enable_graph_report=false,enable_graph_python_stack_traces=false,enable_detailed_buffer_report=false,enable_detailed_tensor_report=false,enable_comparison_mode=false,comparison_mode_should_raise_exception=false,comparison_mode_pcc=0.9999,root_report_path=generated/ttnn/reports,report_name=std::nullopt,std::nullopt} -2026-10-09 07:05:33.388 | info | Device | Opening user mode device driver (tt_cluster.cpp:228) -2026-10-09 07:05:33.388 | info | UMD | Cluster constructor started. (cluster.cpp:389) -2026-10-09 07:05:33.388 | info | UMD | Creating TopologyDiscovery for architecture: wormhole_b0 (topology_discovery.cpp:96) -2026-10-09 07:05:33.388 | info | UMD | Starting topology discovery. (topology_discovery.cpp:115) -2026-10-09 07:05:33.389 | info | UMD | Established firmware bundle version: 19.4.2 (topology_discovery.cpp:575) -2026-10-09 07:05:33.397 | info | UMD | Completed topology discovery. (topology_discovery.cpp:119) -2026-10-09 07:05:33.433 | warning | UMD | hwloc_set_area_membind(): failed for physical_device_id: 1 on NodeSet: [0] with errno: Operation not permitted (pid: 1 tid: 130452827985728) (cpuset_lib.cpp:440) -2026-10-09 07:05:33.433 | warning | UMD | ---- ttSiliconDevice::init_hugepage: bind_area_to_memory_nodeset() failed (physical_device_id: 1 ch: 0). Hugepage allocation is not on NumaNode matching TT Device. Side-Effect is decreased Device->Host perf (Issue #893). (silicon_sysmem_manager.cpp:271) -2026-10-09 07:05:33.433 | warning | UMD | hwloc_set_area_membind(): failed for physical_device_id: 1 on NodeSet: [0] with errno: Operation not permitted (pid: 1 tid: 130452827985728) (cpuset_lib.cpp:440) -2026-10-09 07:05:33.433 | warning | UMD | ---- ttSiliconDevice::init_hugepage: bind_area_to_memory_nodeset() failed (physical_device_id: 1 ch: 1). Hugepage allocation is not on NumaNode matching TT Device. Side-Effect is decreased Device->Host perf (Issue #893). (silicon_sysmem_manager.cpp:271) -2026-10-09 07:05:33.436 | info | UMD | Opening local chip ids/PCIe ids: {0}/[1] and remote chip ids {1} (cluster.cpp:173) -2026-10-09 07:05:33.436 | info | UMD | IOMMU: disabled (cluster.cpp:147) -2026-10-09 07:05:33.436 | info | UMD | KMD version: 2.5.0 (cluster.cpp:150) -2026-10-09 07:05:33.436 | info | UMD | Cluster constructor completed. (cluster.cpp:648) -2026-10-09 07:05:33.438 | info | UMD | Starting devices in cluster (cluster.cpp:1188) -2026-10-09 07:05:33.441 | info | UMD | Starting devices in cluster completed. (cluster.cpp:1196) -Tenstorrent devices=2; torch used for data/metrics=2.11.0+cpu -Opening Tenstorrent device 0 -2026-10-09 07:05:33.515 | info | Distributed | Using auto discovery to generate mesh graph. (metal_env.cpp:437) -2026-10-09 07:05:33.515 | info | Distributed | Constructing control plane using auto-discovery (no mesh graph descriptor). (metal_env.cpp:499) -2026-10-09 07:05:33.516 | info | Fabric | Logical multi-mesh adjacency: intermesh degree histogram {0:1}; intra-mesh degree histograms mesh0 {1:2} (topology_mapper_utils.cpp:3329) -2026-10-09 07:05:33.516 | info | Fabric | Physical multi-mesh adjacency: intermesh degree histogram {0:1}; intra-mesh degree histograms mesh0 {1:2} (topology_mapper_utils.cpp:3337) -DATASET_LOADED file=/inputs/regression.json -tt:0 step=0 mse=0.06471165 -tt:0 step=20 mse=0.00108452 -tt:0 step=40 mse=0.00004048 -tt:0 step=60 mse=0.00003227 -tt:0 step=80 mse=0.00003213 -{"backend": "tenstorrent", "device_id": 0, "initial_mse": 0.08051129430532455, "final_mse": 3.2111081964103505e-05, "validation_mse": 5.255065480014309e-05, "max_weight_change": 0.0986328125, "checkpoint": "/outputs/tenstorrent-0.pt"} -Opening Tenstorrent device 1 -2026-10-09 07:05:48.403 | info | Metal | Fabric config changed from FabricConfig::DISABLED to FabricConfig::FABRIC_1D, reinitializing control plane (metal_env.cpp:337) -2026-10-09 07:05:48.403 | info | Distributed | Using auto discovery to generate mesh graph. (metal_env.cpp:437) -2026-10-09 07:05:48.403 | info | Distributed | Constructing control plane using auto-discovery (no mesh graph descriptor). (metal_env.cpp:499) -2026-10-09 07:05:48.404 | info | Fabric | Logical multi-mesh adjacency: intermesh degree histogram {0:1}; intra-mesh degree histograms mesh0 {1:2} (topology_mapper_utils.cpp:3329) -2026-10-09 07:05:48.404 | info | Fabric | Physical multi-mesh adjacency: intermesh degree histogram {0:1}; intra-mesh degree histograms mesh0 {1:2} (topology_mapper_utils.cpp:3337) -2026-10-09 07:05:48.405 | info | Metal | Enabling FabricConfig::FABRIC_1D only for dispatch. If your workload requires fabric, please set the fabric config accordingly. (device_manager.cpp:291) -2026-10-09 07:05:48.405 | info | Metal | Dispatch on FabricConfig::FABRIC_1D with 1 Command Queues - (device_manager.cpp:299) -2026-10-09 07:05:48.406 | info | Metal | Initializing Fabric (fabric_firmware_initializer.cpp:296) -2026-10-09 07:05:50.399 | info | Metal | Fabric initialized on 2 devices (fabric_firmware_initializer.cpp:444) -2026-10-09 07:05:50.399 | info | Metal | Fabric Initialized with config FabricConfig::FABRIC_1D (fabric_firmware_initializer.cpp:313) -DATASET_LOADED file=/inputs/regression.json -tt:1 step=0 mse=0.06471165 -tt:1 step=20 mse=0.00108452 -tt:1 step=40 mse=0.00004048 -tt:1 step=60 mse=0.00003227 -tt:1 step=80 mse=0.00003213 -{"backend": "tenstorrent", "device_id": 1, "initial_mse": 0.08051129430532455, "final_mse": 3.2111081964103505e-05, "validation_mse": 5.255065480014309e-05, "max_weight_change": 0.0986328125, "checkpoint": "/outputs/tenstorrent-1.pt"} -TRAINING_PASSED backend=tenstorrent devices=2 -2026-10-09 07:06:04.775 | info | BuildKernels | JIT cache stats: 0/58 hits (0.0%) [0 cached, 10 build-once dedup, 0 merged artifacts, 0 merged genfiles] (build_cache_telemetry.cpp:207) -2026-10-09 07:06:04.775 | info | BuildKernels | JIT telemetry: 2 registered TelemetryTokens (build_cache_telemetry.cpp:230) -2026-10-09 07:06:04.779 | info | Device | Closing user mode device drivers (tt_cluster.cpp:513) -2026-10-09 07:06:04.779 | info | UMD | Closing devices in cluster (cluster.cpp:1201) -2026-10-09 07:06:04.788 | info | UMD | Closing devices in cluster completed. (cluster.cpp:1210) -2026-10-09 07:06:04.788 | info | UMD | Cluster destructor started. (cluster.cpp:916) -2026-10-09 07:06:04.788 | info | UMD | Cluster destructor completed. (cluster.cpp:919) diff --git a/deploy/k3s/evidence/department-2026-10-09/tenstorrent-allocation.json b/deploy/k3s/evidence/department-2026-10-09/tenstorrent-allocation.json deleted file mode 100644 index 591a9ba..0000000 --- a/deploy/k3s/evidence/department-2026-10-09/tenstorrent-allocation.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "device_nodes": [ - "/dev/tenstorrent/1" - ], - "chip_count": 2 -} diff --git a/deploy/k3s/evidence/department-2026-10-09/tenstorrent-results.json b/deploy/k3s/evidence/department-2026-10-09/tenstorrent-results.json deleted file mode 100644 index 6dfc9ed..0000000 --- a/deploy/k3s/evidence/department-2026-10-09/tenstorrent-results.json +++ /dev/null @@ -1,32 +0,0 @@ -[ - { - "backend": "tenstorrent", - "device_id": 0, - "initial_mse": 0.08051129430532455, - "final_mse": 3.2111081964103505e-05, - "validation_mse": 5.255065480014309e-05, - "max_weight_change": 0.0986328125, - "checkpoint": "/outputs/tenstorrent-0.pt", - "checkpoint_reload_mse": 5.255065480014309e-05, - "started_at": "2026-10-09T05:25:02.341652+00:00", - "finished_at": "2026-10-09T05:25:13.379742+00:00", - "device_nodes": [ - "/dev/tenstorrent/1" - ] - }, - { - "backend": "tenstorrent", - "device_id": 1, - "initial_mse": 0.08051129430532455, - "final_mse": 3.2111081964103505e-05, - "validation_mse": 5.255065480014309e-05, - "max_weight_change": 0.0986328125, - "checkpoint": "/outputs/tenstorrent-1.pt", - "checkpoint_reload_mse": 5.255065480014309e-05, - "started_at": "2026-10-09T05:25:25.167206+00:00", - "finished_at": "2026-10-09T05:25:28.814332+00:00", - "device_nodes": [ - "/dev/tenstorrent/1" - ] - } -] diff --git a/deploy/k3s/evidence/department-2026-10-09/web-audit.json b/deploy/k3s/evidence/department-2026-10-09/web-audit.json deleted file mode 100644 index 65a3f25..0000000 --- a/deploy/k3s/evidence/department-2026-10-09/web-audit.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "auditReportVersion": 2, - "vulnerabilities": {}, - "metadata": { - "vulnerabilities": { - "info": 0, - "low": 0, - "moderate": 0, - "high": 0, - "critical": 0, - "total": 0 - }, - "dependencies": { - "prod": 156, - "dev": 195, - "optional": 6, - "peer": 57, - "peerOptional": 0, - "total": 355 - } - } -} diff --git a/deploy/k3s/evidence/storage-2026-10-21/README.md b/deploy/k3s/evidence/storage-2026-10-21/README.md deleted file mode 100644 index 4868077..0000000 --- a/deploy/k3s/evidence/storage-2026-10-21/README.md +++ /dev/null @@ -1,57 +0,0 @@ -# Shared-folder storage release verification - -Verified on the real two-machine installation October 21, 2026 (America/New_York). -Source branch: `feat/simple-storage-ethernet-20261021`, based on main `ba2bb86`. - -## Evidence - -- [Validation summary](validation.json): source checks, small Ethernet sample, - authentication prerequisites and operating boundaries. -- [Live API/training results](results.json): eight checks including regular-member - access, upload checksum/download, ZIP traversal rejection, team isolation, - active-dataset deletion protection, both accelerators and sampled usage. -- [Actual workload mounts](workload-volumes.json): direct Ethernet NFS exports, - selected read-only inputs, scoped outputs and disabled service-account tokens. -- [Deployed API artifact](deployed-api.json): image identity and binary checksum, - matched to the built candidate. -- [QuietBox audit](host-storage.json): ordinary ext4 directories, zero image files - in the new root, service state, shared capacity and persistent Ethernet profile. -- [Browser checks](browser-results.json), [dataset screen](datasets-browser.png), - [job screen](jobs-browser.png): real regular-member login, advisory display, - an actual browser upload and visible completed jobs; no JavaScript errors. -- Training logs: [NVIDIA](nvidia.log), [Tenstorrent](tenstorrent.log). - -Training uploaded **255,854 bytes**, requested **one NVIDIA GPU** and **one n300 -board (two chips)**, read `/inputs/regression.json`, trained actual accelerator -operations and saved/reloaded/downloaded checkpoints from `/outputs`. Loss and -held-out validation checks passed. The browser added an 8-byte CSV afterward. - -All **43 API + 6 CLI Go tests** pass with race detection, alongside vet, -**16 React tests**, TypeScript, lint/build and **6 Python folder tests**. The CLI -entry package has no test files; no actual test was skipped. Vite/launch-editor -were patched within the existing major version and npm reports zero vulnerabilities. - -The host is now configured with the new API/web release and local folder service. -The separate old auth deployment remains scaled to zero for rollback. Existing -auth credentials and deactivated-account state were preserved. No physical disk -was formatted, old storage data deleted, large quota allocated, or bulk upload run. - -## Failures resolved during verification - -- Go-auth main previously accepted user-ID cookies, could not verify existing - Better Auth password hashes, omitted an old schema's required `emailVerified` - value, and did not honor old deactivated accounts. Targeted fixes and regression - tests were required to deploy latest main safely. -- The first dynamic team bindings retained the old export prefix. Verification - caught the mount failure; the new test jobs were cancelled, only the new wrong - bindings were replaced, and the correct Ethernet/folder exports were tested. - The successful results linked above are from the final rerun. -- NFS 4.2 reused an existing transport to the old LAN alias. New bindings use a - separate NFS 4.1 connection, verified on the Ethernet path. - -Private credentials, sessions, database backups and rollback manifests are kept -outside Git in `/home/utmist/mist-storage-validation-20261021`. The reusable -[live verifier](../../verify_storage_folders.py) creates its own test account and -workspaces; never publish its `member-private.json` file. - -Operations and rollback: [storage guide](../../storage/README.md). diff --git a/deploy/k3s/evidence/storage-2026-10-21/browser-results.json b/deploy/k3s/evidence/storage-2026-10-21/browser-results.json deleted file mode 100644 index 5a600f2..0000000 --- a/deploy/k3s/evidence/storage-2026-10-21/browser-results.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "checks": [ - "regular_member_login_and_advisory_storage_display", - "real_browser_upload_with_verified_checksum", - "both_real_training_jobs_visible_in_member_history", - "no_browser_javascript_errors" - ], - "browser_errors": [] -} \ No newline at end of file diff --git a/deploy/k3s/evidence/storage-2026-10-21/datasets-browser.png b/deploy/k3s/evidence/storage-2026-10-21/datasets-browser.png deleted file mode 100644 index 45ace93..0000000 Binary files a/deploy/k3s/evidence/storage-2026-10-21/datasets-browser.png and /dev/null differ diff --git a/deploy/k3s/evidence/storage-2026-10-21/deployed-api.json b/deploy/k3s/evidence/storage-2026-10-21/deployed-api.json deleted file mode 100644 index 66853f1..0000000 --- a/deploy/k3s/evidence/storage-2026-10-21/deployed-api.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "image": "mist-api:folders-20261021", - "image_id": "sha256:350ced027c45a35e388f4ec5ef34f818074e856ff100c5e12c8f058fbbe8a9ef", - "binary_sha256": "ef980d2e624a3f015a3f45d87f49563861475e6c56aa8f808fb85f76990e3204", - "node": "utmist-z1opa08" -} diff --git a/deploy/k3s/evidence/storage-2026-10-21/host-storage.json b/deploy/k3s/evidence/storage-2026-10-21/host-storage.json deleted file mode 100644 index 69b3477..0000000 --- a/deploy/k3s/evidence/storage-2026-10-21/host-storage.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "root": "/srv/mist-data", - "filesystem": "/dev/nvme0n1p2 ext4", - "image_files_in_new_root": 0, - "logical_regular_file_bytes": 535003, - "capacity_bytes": 3936290357248, - "available_bytes": 2010709856256, - "storage_service": "active", - "ethernet_speed_mbps": "1000", - "ethernet_profile_autoconnect": "yes" -} diff --git a/deploy/k3s/evidence/storage-2026-10-21/jobs-browser.png b/deploy/k3s/evidence/storage-2026-10-21/jobs-browser.png deleted file mode 100644 index 32aa044..0000000 Binary files a/deploy/k3s/evidence/storage-2026-10-21/jobs-browser.png and /dev/null differ diff --git a/deploy/k3s/evidence/storage-2026-10-21/nvidia.log b/deploy/k3s/evidence/storage-2026-10-21/nvidia.log deleted file mode 100644 index e447011..0000000 --- a/deploy/k3s/evidence/storage-2026-10-21/nvidia.log +++ /dev/null @@ -1,10 +0,0 @@ -CUDA devices=1; torch=2.5.1+cu124 -Training on cuda:0: NVIDIA RTX A4000 -DATASET_LOADED file=/inputs/regression.json -cuda:0 step=0 mse=0.32162201 -cuda:0 step=20 mse=0.00133983 -cuda:0 step=40 mse=0.00006271 -cuda:0 step=60 mse=0.00000431 -cuda:0 step=80 mse=0.00000035 -{"backend": "nvidia", "device_id": 0, "initial_mse": 0.32162201404571533, "final_mse": 3.100358014762605e-08, "validation_mse": 9.109567145060282e-08, "max_weight_change": 0.37507763504981995, "checkpoint": "/outputs/nvidia-0.pt"} -TRAINING_PASSED backend=nvidia devices=1 diff --git a/deploy/k3s/evidence/storage-2026-10-21/results.json b/deploy/k3s/evidence/storage-2026-10-21/results.json deleted file mode 100644 index 0a2e84e..0000000 --- a/deploy/k3s/evidence/storage-2026-10-21/results.json +++ /dev/null @@ -1,98 +0,0 @@ -{ - "checks": [ - "regular_member_login_and_shared_folder_readiness", - "membership_and_common_write_permissions_enforced", - "unsafe_archive_rejected", - "small_upload_checksum_download_and_cross_team_isolation", - "active_dataset_deletion_blocked", - "nvidia_real_training_and_output_downloads", - "tenstorrent_real_training_and_output_downloads", - "host_sampled_dataset_and_model_usage" - ], - "jobs": [ - { - "id": "mist-b5e74aec693e5111ae262449", - "backend": "nvidia", - "metrics": [ - { - "backend": "nvidia", - "device_id": 0, - "initial_mse": 0.32162201404571533, - "final_mse": 3.100358014762605e-08, - "validation_mse": 9.109567145060282e-08, - "max_weight_change": 0.37507763504981995, - "checkpoint": "/outputs/nvidia-0.pt", - "checkpoint_reload_mse": 9.109567145060282e-08, - "device_name": "NVIDIA RTX A4000", - "started_at": "2026-10-22T00:28:43.814031+00:00", - "finished_at": "2026-10-22T00:28:44.148257+00:00", - "device_nodes": [ - "/dev/nvidia1" - ] - } - ], - "node": "utmist-z1opa08", - "status": "Success" - }, - { - "id": "mist-57208c18e561c8cef36e6697", - "backend": "tenstorrent", - "metrics": [ - { - "backend": "tenstorrent", - "device_id": 0, - "initial_mse": 0.32162201404571533, - "final_mse": 5.1175324188079685e-05, - "validation_mse": 0.00011455668573034927, - "max_weight_change": 0.369140625, - "checkpoint": "/outputs/tenstorrent-0.pt", - "checkpoint_reload_mse": 0.00011455668573034927, - "started_at": "2026-10-22T00:28:49.539632+00:00", - "finished_at": "2026-10-22T00:28:59.859064+00:00", - "device_nodes": [ - "/dev/tenstorrent/1" - ] - }, - { - "backend": "tenstorrent", - "device_id": 1, - "initial_mse": 0.32162201404571533, - "final_mse": 5.1175324188079685e-05, - "validation_mse": 0.00011455668573034927, - "max_weight_change": 0.369140625, - "checkpoint": "/outputs/tenstorrent-1.pt", - "checkpoint_reload_mse": 0.00011455668573034927, - "started_at": "2026-10-22T00:29:11.964071+00:00", - "finished_at": "2026-10-22T00:29:15.617335+00:00", - "device_nodes": [ - "/dev/tenstorrent/1" - ] - } - ], - "node": "utmist-tt", - "status": "Success" - } - ], - "bulk_testing": false, - "dataset_bytes": 255854, - "storage": { - "available_bytes": 2010710540288, - "capacity_bytes": 3936291389440, - "dataset_allocation_gib": 1, - "dataset_used_bytes": 256360, - "enabled": true, - "max_dataset_bytes": 1125899906842624, - "model_allocation_gib": 1, - "model_used_bytes": 6065, - "storage_limits_enforced": false, - "storage_low": false, - "storage_mode": "shared-folders", - "upload_limit_bytes": 2009636798464, - "usage_observed_at": "2026-10-22T00:28:54Z", - "usage_stale": false - }, - "teams": [ - "team-a91af070ba2a6bdc", - "team-71d2f7b638c597e1" - ] -} diff --git a/deploy/k3s/evidence/storage-2026-10-21/tenstorrent.log b/deploy/k3s/evidence/storage-2026-10-21/tenstorrent.log deleted file mode 100644 index 989051e..0000000 --- a/deploy/k3s/evidence/storage-2026-10-21/tenstorrent.log +++ /dev/null @@ -1,58 +0,0 @@ -2026-10-22 00:28:44.905 | DEBUG | ttnn::78 - Initial ttnn.CONFIG: -Config{cache_path=/root/.cache/ttnn,model_cache_path=/root/.cache/ttnn/models,tmp_dir=/tmp/ttnn,enable_model_cache=false,enable_fast_runtime_mode=true,validate_program_args=false,throw_exception_on_fallback=false,enable_logging=false,enable_graph_report=false,enable_graph_python_stack_traces=false,enable_detailed_buffer_report=false,enable_detailed_tensor_report=false,enable_comparison_mode=false,comparison_mode_should_raise_exception=false,comparison_mode_pcc=0.9999,root_report_path=generated/ttnn/reports,report_name=std::nullopt,std::nullopt} -2026-10-22 00:28:45.104 | info | Device | Opening user mode device driver (tt_cluster.cpp:228) -2026-10-22 00:28:45.104 | info | UMD | Cluster constructor started. (cluster.cpp:389) -2026-10-22 00:28:45.104 | info | UMD | Creating TopologyDiscovery for architecture: wormhole_b0 (topology_discovery.cpp:96) -2026-10-22 00:28:45.104 | info | UMD | Starting topology discovery. (topology_discovery.cpp:115) -2026-10-22 00:28:45.106 | info | UMD | Established firmware bundle version: 19.4.2 (topology_discovery.cpp:575) -2026-10-22 00:28:45.114 | info | UMD | Completed topology discovery. (topology_discovery.cpp:119) -2026-10-22 00:28:45.151 | warning | UMD | hwloc_set_area_membind(): failed for physical_device_id: 1 on NodeSet: [0] with errno: Operation not permitted (pid: 1 tid: 137514765571904) (cpuset_lib.cpp:440) -2026-10-22 00:28:45.151 | warning | UMD | ---- ttSiliconDevice::init_hugepage: bind_area_to_memory_nodeset() failed (physical_device_id: 1 ch: 0). Hugepage allocation is not on NumaNode matching TT Device. Side-Effect is decreased Device->Host perf (Issue #893). (silicon_sysmem_manager.cpp:271) -2026-10-22 00:28:45.151 | warning | UMD | hwloc_set_area_membind(): failed for physical_device_id: 1 on NodeSet: [0] with errno: Operation not permitted (pid: 1 tid: 137514765571904) (cpuset_lib.cpp:440) -2026-10-22 00:28:45.151 | warning | UMD | ---- ttSiliconDevice::init_hugepage: bind_area_to_memory_nodeset() failed (physical_device_id: 1 ch: 1). Hugepage allocation is not on NumaNode matching TT Device. Side-Effect is decreased Device->Host perf (Issue #893). (silicon_sysmem_manager.cpp:271) -2026-10-22 00:28:45.154 | info | UMD | Opening local chip ids/PCIe ids: {0}/[1] and remote chip ids {1} (cluster.cpp:173) -2026-10-22 00:28:45.154 | info | UMD | IOMMU: disabled (cluster.cpp:147) -2026-10-22 00:28:45.154 | info | UMD | KMD version: 2.5.0 (cluster.cpp:150) -2026-10-22 00:28:45.154 | info | UMD | Cluster constructor completed. (cluster.cpp:648) -2026-10-22 00:28:45.155 | info | UMD | Starting devices in cluster (cluster.cpp:1188) -2026-10-22 00:28:45.158 | info | UMD | Starting devices in cluster completed. (cluster.cpp:1196) -Tenstorrent devices=2; torch used for data/metrics=2.11.0+cpu -Opening Tenstorrent device 0 -2026-10-22 00:28:45.209 | info | Distributed | Using auto discovery to generate mesh graph. (metal_env.cpp:437) -2026-10-22 00:28:45.209 | info | Distributed | Constructing control plane using auto-discovery (no mesh graph descriptor). (metal_env.cpp:499) -2026-10-22 00:28:45.210 | info | Fabric | Logical multi-mesh adjacency: intermesh degree histogram {0:1}; intra-mesh degree histograms mesh0 {1:2} (topology_mapper_utils.cpp:3329) -2026-10-22 00:28:45.210 | info | Fabric | Physical multi-mesh adjacency: intermesh degree histogram {0:1}; intra-mesh degree histograms mesh0 {1:2} (topology_mapper_utils.cpp:3337) -DATASET_LOADED file=/inputs/regression.json -tt:0 step=0 mse=0.18711002 -tt:0 step=20 mse=0.00110440 -tt:0 step=40 mse=0.00008407 -tt:0 step=60 mse=0.00005286 -tt:0 step=80 mse=0.00005125 -{"backend": "tenstorrent", "device_id": 0, "initial_mse": 0.32162201404571533, "final_mse": 5.1175324188079685e-05, "validation_mse": 0.00011455668573034927, "max_weight_change": 0.369140625, "checkpoint": "/outputs/tenstorrent-0.pt"} -Opening Tenstorrent device 1 -2026-10-22 00:28:59.905 | info | Metal | Fabric config changed from FabricConfig::DISABLED to FabricConfig::FABRIC_1D, reinitializing control plane (metal_env.cpp:337) -2026-10-22 00:28:59.905 | info | Distributed | Using auto discovery to generate mesh graph. (metal_env.cpp:437) -2026-10-22 00:28:59.905 | info | Distributed | Constructing control plane using auto-discovery (no mesh graph descriptor). (metal_env.cpp:499) -2026-10-22 00:28:59.906 | info | Fabric | Logical multi-mesh adjacency: intermesh degree histogram {0:1}; intra-mesh degree histograms mesh0 {1:2} (topology_mapper_utils.cpp:3329) -2026-10-22 00:28:59.906 | info | Fabric | Physical multi-mesh adjacency: intermesh degree histogram {0:1}; intra-mesh degree histograms mesh0 {1:2} (topology_mapper_utils.cpp:3337) -2026-10-22 00:28:59.907 | info | Metal | Enabling FabricConfig::FABRIC_1D only for dispatch. If your workload requires fabric, please set the fabric config accordingly. (device_manager.cpp:291) -2026-10-22 00:28:59.907 | info | Metal | Dispatch on FabricConfig::FABRIC_1D with 1 Command Queues - (device_manager.cpp:299) -2026-10-22 00:28:59.909 | info | Metal | Initializing Fabric (fabric_firmware_initializer.cpp:296) -2026-10-22 00:29:01.929 | info | Metal | Fabric initialized on 2 devices (fabric_firmware_initializer.cpp:444) -2026-10-22 00:29:01.929 | info | Metal | Fabric Initialized with config FabricConfig::FABRIC_1D (fabric_firmware_initializer.cpp:313) -DATASET_LOADED file=/inputs/regression.json -tt:1 step=0 mse=0.18711002 -tt:1 step=20 mse=0.00110440 -tt:1 step=40 mse=0.00008407 -tt:1 step=60 mse=0.00005286 -tt:1 step=80 mse=0.00005125 -{"backend": "tenstorrent", "device_id": 1, "initial_mse": 0.32162201404571533, "final_mse": 5.1175324188079685e-05, "validation_mse": 0.00011455668573034927, "max_weight_change": 0.369140625, "checkpoint": "/outputs/tenstorrent-1.pt"} -TRAINING_PASSED backend=tenstorrent devices=2 -2026-10-22 00:29:16.207 | info | BuildKernels | JIT cache stats: 0/58 hits (0.0%) [0 cached, 10 build-once dedup, 0 merged artifacts, 0 merged genfiles] (build_cache_telemetry.cpp:207) -2026-10-22 00:29:16.207 | info | BuildKernels | JIT telemetry: 2 registered TelemetryTokens (build_cache_telemetry.cpp:230) -2026-10-22 00:29:16.212 | info | Device | Closing user mode device drivers (tt_cluster.cpp:513) -2026-10-22 00:29:16.212 | info | UMD | Closing devices in cluster (cluster.cpp:1201) -2026-10-22 00:29:16.220 | info | UMD | Closing devices in cluster completed. (cluster.cpp:1210) -2026-10-22 00:29:16.220 | info | UMD | Cluster destructor started. (cluster.cpp:916) -2026-10-22 00:29:16.220 | info | UMD | Cluster destructor completed. (cluster.cpp:919) diff --git a/deploy/k3s/evidence/storage-2026-10-21/validation.json b/deploy/k3s/evidence/storage-2026-10-21/validation.json deleted file mode 100644 index 3f25652..0000000 --- a/deploy/k3s/evidence/storage-2026-10-21/validation.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "base_main": "ba2bb86", - "branch": "feat/simple-storage-ethernet-20261021", - "go_api_tests": 43, - "go_cli_tests": 6, - "go_race": "passed", - "go_vet": "passed", - "react_tests": 16, - "typescript": "passed", - "frontend_lint": "passed", - "frontend_build": "passed", - "python_folder_tests": 6, - "npm_audit_vulnerabilities": 0, - "fake_backend_contract": "passed", - "ethernet": { - "main": "192.168.240.1/30", - "main_interface": "enp6s0", - "quietbox": "192.168.240.2/30", - "quietbox_interface": "enp200s0", - "link_mbps": 1000, - "main_route": "192.168.240.2 dev enp6s0 src 192.168.240.1 uid 1000 \n cache", - "small_probe_bytes": 16777216, - "small_probe_write_MBps": 43.0, - "small_probe_read_MBps": 101.0, - "performance_note": "Single small direct-I/O NFS sample; not a sustained workload benchmark." - }, - "auth_prerequisites": [ - "Require real unexpired database sessions; remove identity-cookie fallbacks", - "Preserve Better Auth scrypt passwords, NOT NULL schema and deactivated accounts", - "Existing administrator login verified against isolated database copy and live portal" - ], - "old_storage": "Retained unchanged for rollback; no migration", - "large_storage_testing": false, - "new_storage_mode": "ordinary directories with advisory team allowances", - "remaining_boundaries": [ - "Single storage host and shared physical capacity", - "No equal-space guarantee or per-team hard quota", - "No automatic backup, local dataset cache, or resumable upload" - ] -} diff --git a/deploy/k3s/evidence/storage-2026-10-21/workload-volumes.json b/deploy/k3s/evidence/storage-2026-10-21/workload-volumes.json deleted file mode 100644 index a66be72..0000000 --- a/deploy/k3s/evidence/storage-2026-10-21/workload-volumes.json +++ /dev/null @@ -1,130 +0,0 @@ -[ - { - "id": "mist-b5e74aec693e5111ae262449", - "namespace": "mist-team-a91af070ba2a6bdc", - "node": "utmist-z1opa08", - "automount_service_account_token": false, - "volumes": [ - { - "claim": "team-results", - "read_only": false, - "nfs": { - "path": "/srv/mist-data/teams/team-a91af070ba2a6bdc/results", - "server": "192.168.240.2" - }, - "mount_options": [ - "nfsvers=4.1", - "hard", - "timeo=100", - "retrans=2" - ] - }, - { - "claim": "team-datasets", - "read_only": true, - "nfs": { - "path": "/srv/mist-data/teams/team-a91af070ba2a6bdc/datasets", - "server": "192.168.240.2" - }, - "mount_options": [ - "nfsvers=4.1", - "hard", - "timeo=100", - "retrans=2" - ] - } - ], - "mounts": [ - { - "mountPath": "/checkpoints/mist-b5e74aec693e5111ae262449", - "name": "checkpoints", - "subPath": "members/21008782734082c80dbd4eb4/jobs/mist-b5e74aec693e5111ae262449/outputs" - }, - { - "mountPath": "/outputs", - "name": "checkpoints", - "subPath": "members/21008782734082c80dbd4eb4/jobs/mist-b5e74aec693e5111ae262449/outputs" - }, - { - "mountPath": "/inputs", - "name": "input", - "readOnly": true, - "subPath": "members/21008782734082c80dbd4eb4/datasets/dataset-3c4ddd41a5c75880df01796f/content" - }, - { - "mountPath": "/mist-script", - "name": "script", - "readOnly": true - } - ] - }, - { - "id": "mist-57208c18e561c8cef36e6697", - "namespace": "mist-team-a91af070ba2a6bdc", - "node": "utmist-tt", - "automount_service_account_token": false, - "volumes": [ - { - "claim": "team-results", - "read_only": false, - "nfs": { - "path": "/srv/mist-data/teams/team-a91af070ba2a6bdc/results", - "server": "192.168.240.2" - }, - "mount_options": [ - "nfsvers=4.1", - "hard", - "timeo=100", - "retrans=2" - ] - }, - { - "claim": "team-datasets", - "read_only": true, - "nfs": { - "path": "/srv/mist-data/teams/team-a91af070ba2a6bdc/datasets", - "server": "192.168.240.2" - }, - "mount_options": [ - "nfsvers=4.1", - "hard", - "timeo=100", - "retrans=2" - ] - } - ], - "mounts": [ - { - "mountPath": "/checkpoints/mist-57208c18e561c8cef36e6697", - "name": "checkpoints", - "subPath": "members/21008782734082c80dbd4eb4/jobs/mist-57208c18e561c8cef36e6697/outputs" - }, - { - "mountPath": "/outputs", - "name": "checkpoints", - "subPath": "members/21008782734082c80dbd4eb4/jobs/mist-57208c18e561c8cef36e6697/outputs" - }, - { - "mountPath": "/home/utmist-tt/TT/tt-metal", - "name": "metal", - "readOnly": true - }, - { - "mountPath": "/home/utmist-tt/.local/share/uv/python", - "name": "python", - "readOnly": true - }, - { - "mountPath": "/inputs", - "name": "input", - "readOnly": true, - "subPath": "members/21008782734082c80dbd4eb4/datasets/dataset-3c4ddd41a5c75880df01796f/content" - }, - { - "mountPath": "/mist-script", - "name": "script", - "readOnly": true - } - ] - } -] diff --git a/deploy/k3s/examples/Dockerfile.training b/deploy/k3s/examples/Dockerfile.training deleted file mode 100644 index 7c80e76..0000000 --- a/deploy/k3s/examples/Dockerfile.training +++ /dev/null @@ -1,8 +0,0 @@ -# CPU: docker build -f deploy/k3s/examples/Dockerfile.training -t mist-training:cpu-v1 deploy/k3s/examples -# NVIDIA: add --build-arg BASE_IMAGE=pytorch/pytorch:2.5.1-cuda12.4-cudnn9-runtime -ARG BASE_IMAGE=python:3.10-slim -FROM ${BASE_IMAGE} -WORKDIR /app -COPY train.py dataset.csv /app/ -ENTRYPOINT ["python", "-u", "train.py"] -CMD ["--backend", "cpu"] diff --git a/deploy/k3s/examples/dataset.csv b/deploy/k3s/examples/dataset.csv deleted file mode 100644 index e6fa537..0000000 --- a/deploy/k3s/examples/dataset.csv +++ /dev/null @@ -1,8 +0,0 @@ -x,y --2,-5 --1,-2 --0.5,-0.5 -0,1 -0.5,2.5 -1,4 -2,7 diff --git a/deploy/k3s/examples/train.py b/deploy/k3s/examples/train.py deleted file mode 100644 index 59f54cc..0000000 --- a/deploy/k3s/examples/train.py +++ /dev/null @@ -1,74 +0,0 @@ -"""Packaged regression example: CPU stdlib, or PyTorch CUDA on each GPU. - -The tiny dataset is baked into the image until Mist's dataset service exists. -The default entrypoint and /app WORKDIR deliberately test image semantics. -""" -import argparse -import csv -import json -import os -from pathlib import Path - - -def cpu(rows, output): - weight, bias = 0.0, 0.0 - initial = sum(y * y for x, y in rows) / len(rows) - for _ in range(200): - errors = [(weight * x + bias - y, x) for x, y in rows] - weight -= 0.05 * 2 * sum(error * x for error, x in errors) / len(rows) - bias -= 0.05 * 2 * sum(error for error, x in errors) / len(rows) - loss = sum((weight * x + bias - y) ** 2 for x, y in rows) / len(rows) - checkpoint = output / 'model.json' - checkpoint.write_text(json.dumps({'weight': weight, 'bias': bias}) + '\n') - restored = json.loads(checkpoint.read_text()) - assert restored['weight'] == weight and restored['bias'] == bias - return [{'initial_loss': initial, 'final_loss': loss, 'checkpoint': checkpoint.name}] - - -def nvidia(rows, output, expected): - import torch - assert torch.cuda.is_available(), 'CUDA is unavailable; refusing CPU fallback' - assert torch.cuda.device_count() == expected, 'Allocated GPU count differs' - results = [] - for index in range(expected): - device = torch.device(f'cuda:{index}') - inputs = torch.tensor([[x] for x, y in rows], device=device) - targets = torch.tensor([[y] for x, y in rows], device=device) - model = torch.nn.Linear(1, 1, device=device) - optimizer = torch.optim.SGD(model.parameters(), lr=0.05) - initial = torch.nn.functional.mse_loss(model(inputs), targets).item() - for _ in range(200): - optimizer.zero_grad() - loss = torch.nn.functional.mse_loss(model(inputs), targets) - loss.backward() - optimizer.step() - final = torch.nn.functional.mse_loss(model(inputs), targets).item() - checkpoint = output / f'nvidia-{index}.pt' - torch.save(model.state_dict(), checkpoint) - restored = torch.nn.Linear(1, 1, device=device) - restored.load_state_dict(torch.load(checkpoint, weights_only=True, map_location=device)) - assert torch.allclose(model(inputs), restored(inputs)) - results.append({'device': torch.cuda.get_device_name(index), 'initial_loss': initial, 'final_loss': final, 'checkpoint': checkpoint.name}) - return results - - -def main(): - parser = argparse.ArgumentParser() - parser.add_argument('--backend', choices=['cpu', 'nvidia'], default='cpu') - parser.add_argument('--devices', type=int, default=1) - args = parser.parse_args() - assert Path.cwd() == Path('/app'), 'Image WORKDIR was overridden' - with Path('dataset.csv').open() as dataset: - rows = [(float(row['x']), float(row['y'])) for row in csv.DictReader(dataset)] - output = Path(os.environ.get('MIST_OUTPUT_DIR', '/outputs')) - output.mkdir(parents=True, exist_ok=True) - results = cpu(rows, output) if args.backend == 'cpu' else nvidia(rows, output, args.devices) - assert all(result['final_loss'] < 0.001 for result in results), 'Training did not converge' - evidence = {'backend': args.backend, 'cwd': str(Path.cwd()), 'results': results, 'greeting': os.environ.get('GREETING', '')} - (output / 'results.json').write_text(json.dumps(evidence, indent=2) + '\n') - print(json.dumps(evidence), flush=True) - print(f'PACKAGED_TRAINING_PASSED backend={args.backend}', flush=True) - - -if __name__ == '__main__': - main() diff --git a/deploy/k3s/examples/train_shared.py b/deploy/k3s/examples/train_shared.py deleted file mode 100644 index 9ae65c8..0000000 --- a/deploy/k3s/examples/train_shared.py +++ /dev/null @@ -1,212 +0,0 @@ -"""Small accelerator training check; TT gradients are computed explicitly in TT-NN. - -CPU Torch creates the data, records metrics, and serializes checkpoints. All -forward passes, gradients, and weight updates run on the selected accelerator. -This is a linear-regression smoke test, not a model compatibility benchmark. -""" - -import argparse -import json -import math -import os -import subprocess -import sys -import time -from datetime import datetime, timezone -from pathlib import Path - -import torch - - -def dataset(): - # Uploaded dataset; deliberately no generated-data fallback. - path = Path(os.environ.get("MIST_INPUT_DIR", "/inputs")) / "regression.json" - data = json.loads(path.read_text()) - tensors = [torch.tensor(data[key], dtype=torch.float32) for key in - ("x", "y", "validation_x", "validation_y")] - assert [tuple(t.shape) for t in tensors] == [(128,32),(128,32),(64,32),(64,32)] - print(f"DATASET_LOADED file={path}", flush=True) - return tensors - - -def await_start(output, start_file, metadata): - if not start_file: - return - (output / "ready.json").write_text(json.dumps({**metadata, - "ready_at": datetime.now(timezone.utc).isoformat()}) + "\n") - print(f"BARRIER_READY {json.dumps(metadata)}", flush=True) - deadline = time.monotonic() + 300 - while not start_file.exists(): - assert time.monotonic() < deadline, "Training start barrier timed out" - time.sleep(0.2) - - -def check_and_save(backend, device_id, weights, initial, final, validation, output): - change = weights.abs().max().item() - assert all(math.isfinite(v) for v in (initial, final, validation, change)) - assert final < initial * 0.02, f"Loss did not converge: {initial} -> {final}" - assert validation < 5e-4, f"Held-out MSE too high: {validation}" - assert change > 0.05, "Weights were not updated" - path = output / f"{backend}-{device_id}.pt" - torch.save({"weights": weights.cpu(), "initial_mse": initial, - "final_mse": final, "validation_mse": validation}, path) - loaded = torch.load(path, map_location="cpu", weights_only=True) - torch.testing.assert_close(loaded["weights"], weights.cpu(), rtol=0, atol=0) - result = {"backend": backend, "device_id": device_id, - "initial_mse": initial, "final_mse": final, - "validation_mse": validation, "max_weight_change": change, - "checkpoint": str(path)} - print(json.dumps(result), flush=True) - return result, loaded["weights"] - - -def nvidia(output, steps, expected_devices, start_file, board_pci): - assert torch.cuda.is_available(), "CUDA unavailable; CPU fallback is forbidden" - count = torch.cuda.device_count() - assert count == expected_devices, f"Expected {expected_devices} allocated GPUs; saw {count}" - print(f"CUDA devices={count}; torch={torch.__version__}", flush=True) - nodes = sorted(str(path) for path in Path("/dev").glob("nvidia[0-9]*")) - assert len(nodes) == count, f"Unexpected NVIDIA device nodes: {nodes}" - (output / "allocation.json").write_text(json.dumps({"device_nodes": nodes, - "gpu_count": count}, indent=2) + "\n") - results = [] - for index in range(count): - device = torch.device(f"cuda:{index}") - print(f"Training on {device}: {torch.cuda.get_device_name(index)}", flush=True) - x, y, validation_x, validation_y = (t.to(device) for t in dataset()) - model = torch.nn.Linear(32, 32, bias=False).to(device) - torch.nn.init.zeros_(model.weight) - optimizer = torch.optim.SGD(model.parameters(), lr=0.2) - if index == 0: - await_start(output, start_file, {"device_nodes": nodes, "gpu_count": count}) - started = datetime.now(timezone.utc).isoformat() - initial = (model(x) - y).square().mean().item() - for step in range(steps): - optimizer.zero_grad(set_to_none=True) - residual = model(x) - y - loss = residual.square().sum() / (2 * x.shape[0]) - loss.backward() - assert model.weight.grad.is_cuda - optimizer.step() - if step % 20 == 0: - print(f"cuda:{index} step={step} mse={residual.square().mean().item():.8f}", flush=True) - with torch.no_grad(): - final = (model(x) - y).square().mean().item() - validation = (model(validation_x) - validation_y).square().mean().item() - result, weights = check_and_save("nvidia", index, model.weight.T.detach(), - initial, final, validation, output) - restored_error = (validation_x @ weights.to(device) - validation_y).square().mean().item() - assert abs(restored_error - validation) < 1e-7 - result["checkpoint_reload_mse"] = restored_error - result["device_name"] = torch.cuda.get_device_name(index) - result["started_at"] = started - result["finished_at"] = datetime.now(timezone.utc).isoformat() - result["device_nodes"] = nodes - results.append(result) - return results - - -def tenstorrent(output, steps, expected_devices, start_file, board_pci): - nodes = sorted(str(path) for path in Path("/dev/tenstorrent").iterdir()) - - def pci_address(node): - path = Path("/sys/class/tenstorrent") / ("tenstorrent!" + Path(node).name) - assert path.exists(), f"Missing PCI identity for {node}" - return path.resolve().parents[1].name - - if board_pci: - nodes = [node for node in nodes if pci_address(node) == board_pci] - assert len(nodes) == 1, f"PCI selector does not match one allocated board: {board_pci}" - assert len(nodes) * 2 == expected_devices, f"Unexpected n300 device nodes: {nodes}" - if len(nodes) > 1: - # Arbitrary n300 subsets need not form a connected fabric mesh. - # This test trains independently, so use one runtime per board. - results = [] - for ordinal, node in enumerate(nodes): - bdf = pci_address(node) - board_output = output / ("board-" + Path(node).name) - command = [sys.executable, __file__, "tenstorrent", "--steps", str(steps), - "--expected-devices", "2", "--output", str(board_output), "--board-pci", bdf] - if start_file: - command += ["--start-file", str(start_file)] - print(f"Training reserved board {node}, PCI {bdf}", flush=True) - subprocess.run(command, env={**os.environ, "TT_VISIBLE_DEVICES": bdf}, check=True) - for result in json.loads((board_output / "results.json").read_text()): - result["board_local_device_id"] = result["device_id"] - result["device_id"] = ordinal * 2 + result["device_id"] - result["board_pci"] = bdf - results.append(result) - assert len(results) == expected_devices - (output / "allocation.json").write_text(json.dumps({"device_nodes": nodes, - "chip_count": expected_devices}, indent=2) + "\n") - return results - - import ttnn - - count = ttnn.get_num_devices() - assert count == expected_devices, f"Expected {expected_devices} allocated chips; saw {count}" - (output / "allocation.json").write_text(json.dumps({"device_nodes": nodes, - "chip_count": count}, indent=2) + "\n") - print(f"Tenstorrent devices={count}; torch used for data/metrics={torch.__version__}", flush=True) - results = [] - for index in range(count): - print(f"Opening Tenstorrent device {index}", flush=True) - device = ttnn.open_device(device_id=index) - try: - if index == 0 and start_file: - await_start(output, start_file, {"device_nodes": nodes, "chip_count": count}) - started = datetime.now(timezone.utc).isoformat() - x, y, validation_x, validation_y = dataset() - options = dict(dtype=ttnn.bfloat16, layout=ttnn.TILE_LAYOUT, - device=device, memory_config=ttnn.DRAM_MEMORY_CONFIG) - dx, dy, dvx = (ttnn.from_torch(t, **options) for t in (x, y, validation_x)) - dxt = ttnn.from_torch(x.T.contiguous(), **options) - weights = ttnn.from_torch(torch.zeros(32, 32), **options) - - def mse(inputs, target, matrix): - prediction = ttnn.to_torch(ttnn.matmul(inputs, matrix)).float() - return (prediction - target).square().mean().item() - - initial = mse(dx, y, weights) - for step in range(steps): - prediction = ttnn.matmul(dx, weights) - residual = ttnn.subtract(prediction, dy) - # Gradient of ||XW-Y||^2/(2N) is X.T@(XW-Y)/N. - gradient = ttnn.matmul(dxt, residual) - weights = ttnn.subtract(weights, ttnn.multiply(gradient, 0.2 / x.shape[0])) - if step % 20 == 0: - print(f"tt:{index} step={step} mse={mse(dx, y, weights):.8f}", flush=True) - final = mse(dx, y, weights) - validation = mse(dvx, validation_y, weights) - host_weights = ttnn.to_torch(weights).float() - result, loaded = check_and_save("tenstorrent", index, host_weights, - initial, final, validation, output) - restored = ttnn.from_torch(loaded, **options) - restored_error = mse(dvx, validation_y, restored) - assert abs(restored_error - validation) < 1e-7 - result["checkpoint_reload_mse"] = restored_error - result["started_at"] = started - result["finished_at"] = datetime.now(timezone.utc).isoformat() - result["device_nodes"] = nodes - results.append(result) - finally: - ttnn.close_device(device) - return results - - -if __name__ == "__main__": - parser = argparse.ArgumentParser() - parser.add_argument("backend", choices=("nvidia", "tenstorrent")) - parser.add_argument("--steps", type=int, default=100) - parser.add_argument("--output", type=Path, default=Path("/checkpoints")) - parser.add_argument("--expected-devices", type=int, - help="Expected allocated chip/GPU count; defaults to the original whole-node test") - parser.add_argument("--start-file", type=Path, - help="Optional shared-file barrier for the concurrent allocation test") - parser.add_argument("--board-pci", help=argparse.SUPPRESS) - args = parser.parse_args() - args.output.mkdir(parents=True, exist_ok=True) - expected = args.expected_devices or (2 if args.backend == "nvidia" else 8) - results = globals()[args.backend](args.output, args.steps, expected, args.start_file, args.board_pci) - (args.output / "results.json").write_text(json.dumps(results, indent=2) + "\n") - print(f"TRAINING_PASSED backend={args.backend} devices={len(results)}", flush=True) diff --git a/deploy/k3s/foundation-results.json b/deploy/k3s/foundation-results.json deleted file mode 100644 index c13ecca..0000000 --- a/deploy/k3s/foundation-results.json +++ /dev/null @@ -1,133 +0,0 @@ -{ - "recorded_at": "2026-10-09T02:57:05.690026+00:00", - "branch": "feat/local-job-foundation", - "baseline_commit": "2f9b178", - "scope": "Parts 1\u20133 plus node-local persistent outputs. Shared datasets/downloads (4), deployment/login (5) remain.", - "api_image": "mist-api:foundation-20261008", - "api_image_id": "sha256:57ed3a9eebe54610f7353f29bbd24d93259f0d664e171381a5023a2d7d5e4770", - "checks": { - "frontend_interactions": 5, - "kubernetes_backend_tests": 12, - "src_test_suite": "passed with isolated Redis and existing Docker test image", - "cli_test_suite": "passed", - "typescript": "passed", - "production_build": "passed", - "changed_frontend_lint": "passed", - "browser_cpu_packaged_image": "passed", - "browser_two_nvidia_gpus": "passed", - "browser_tt_two_boards_four_chips": "passed", - "browser_exit_17_and_cancel": "passed", - "nvidia_occupied_queue_cancel_reuse": "passed", - "tt_occupied_queue_cancel_reuse": "passed", - "persistent_outputs_new_containers_both_nodes": "passed", - "literal_arguments_working_directory_output_alias": "passed", - "image_pull_reason_and_deadline_in_browser": "passed", - "history_survives_api_restarts": "passed", - "rbac_inventory_read_only": "passed" - }, - "browser_jobs": { - "packaged-cpu": { - "id": "mist-261e82c2848fc79b234afc4a", - "state": "Success", - "node": "utmist-z1opa08", - "device_count": 0 - }, - "tt-two-boards": { - "id": "mist-f9a86752969598c182c3d79f", - "state": "Success", - "node": "utmist-tt", - "device_count": 2 - }, - "packaged-two-gpus": { - "id": "mist-d3756747c2e68bf76ea2e876", - "state": "Success", - "node": "utmist-z1opa08", - "device_count": 2 - }, - "failure": { - "id": "mist-aa022009f288d22836d04b85", - "state": "Failure", - "node": "utmist-z1opa08", - "device_count": 0 - }, - "cancel": { - "id": "mist-99d4a51c2f49d394a8796ba3", - "state": "Cancelled", - "node": "utmist-z1opa08", - "device_count": 0 - } - }, - "allocation_jobs": { - "literal-arguments": "mist-2aee9108f5ef651e0b06eccc", - "nvidia-holder": "mist-cce552238d50aaf3fc8ab895", - "nvidia-queued": "mist-e0d28d07f23bd40653c65de0", - "tenstorrent-holder": "mist-5737ba8da7abaccd3a45698d", - "tenstorrent-queued": "mist-aa8e4101f47855166dfa4b36" - }, - "image_pull_failure_job": "mist-2e6965771e3330e013df1fc3", - "idle_hardware": { - "observed_at": "2026-10-09T02:57:05.530844763Z", - "machines": [ - { - "name": "utmist-z1opa08", - "address": "10.0.0.175", - "ready": true, - "schedulable": true, - "cpu_allocatable": "56", - "memory_allocatable": "164912728Ki" - }, - { - "name": "utmist-tt", - "address": "10.0.0.112", - "ready": true, - "schedulable": true, - "cpu_allocatable": "32", - "memory_allocatable": "511127964Ki" - } - ], - "pools": [ - { - "accelerator": "nvidia", - "node": "utmist-z1opa08", - "unit": "GPU", - "total": 2, - "allocated": 0, - "available": 2, - "ready": true - }, - { - "accelerator": "tenstorrent", - "node": "utmist-tt", - "unit": "board", - "total": 4, - "allocated": 0, - "available": 4, - "ready": true, - "chips_per_device": 2 - } - ] - }, - "job_history_count": 40, - "job_history_read_seconds": 0.193, - "evidence_directory": "/home/utmist/mist-foundation-results-2026-10-08", - "evidence_files": [ - "browser.json", - "foundation-acceptance.json", - "image-pull-failure.json", - "rbac.json", - "jobs-browser.png", - "machines-browser.png", - "nvidia-training-browser.png", - "image-pull-browser.png", - "cpu-persisted-files.txt", - "nvidia-persisted-files.txt", - "tenstorrent-persisted-files.txt" - ], - "limits": [ - "Trusted local single-owner pilot; no authenticated tenant isolation", - "Node-local output storage; no dataset uploads or browser downloads", - "TT uses tested image and host runtime; arbitrary models/autograd/distributed training not validated", - "Private registry secret wiring documented; private registry login not exercised", - "Jobs/output files retained; no automatic cleanup, storage quota or backup" - ] -} diff --git a/deploy/k3s/gpu-smoke-job.yaml b/deploy/k3s/gpu-smoke-job.yaml deleted file mode 100644 index 0aa711a..0000000 --- a/deploy/k3s/gpu-smoke-job.yaml +++ /dev/null @@ -1,28 +0,0 @@ -apiVersion: batch/v1 -kind: Job -metadata: - name: gpu-smoke - namespace: mist - labels: - app.kubernetes.io/name: mist-gpu-smoke -spec: - backoffLimit: 0 - activeDeadlineSeconds: 600 - template: - metadata: - labels: - app.kubernetes.io/name: mist-gpu-smoke - spec: - restartPolicy: Never - containers: - - name: vectoradd - image: nvcr.io/nvidia/k8s/cuda-sample:vectoradd-cuda12.5.0 - resources: - requests: - cpu: 250m - memory: 256Mi - nvidia.com/gpu: 1 - limits: - cpu: '1' - memory: 512Mi - nvidia.com/gpu: 1 diff --git a/deploy/k3s/mist-api-forward.service b/deploy/k3s/mist-api-forward.service deleted file mode 100644 index 648fa61..0000000 --- a/deploy/k3s/mist-api-forward.service +++ /dev/null @@ -1,11 +0,0 @@ -[Unit] -Description=Mist Kubernetes API on localhost -After=network-online.target - -[Service] -ExecStart=/usr/local/bin/k3s kubectl --kubeconfig /home/utmist/.kube/config --namespace mist-system port-forward service/mist-api 3000:3000 --address 127.0.0.1 -Restart=always -RestartSec=2 - -[Install] -WantedBy=default.target diff --git a/deploy/k3s/mist-api-results.json b/deploy/k3s/mist-api-results.json deleted file mode 100644 index aab9beb..0000000 --- a/deploy/k3s/mist-api-results.json +++ /dev/null @@ -1,191 +0,0 @@ -{ - "verified_at": "2026-10-03T20:42:24.107077+00:00", - "status": "passed", - "implementation": { - "executor": "kubernetes (default)", - "workload_namespace": "mist", - "api_namespace": "mist-system", - "owner": "utmist (configured single owner, not authenticated)", - "api_url": "http://127.0.0.1:3000", - "jobs_url": "http://127.0.0.1:3001/jobs", - "api_image": "mist-api:k3s-pilot-20261003", - "api_image_config_digest": "sha256:d33a5da4e1761641292340b2f607d4ec8ce95afb5f76c2797044b30747a81743", - "api_image_index_digest": "sha256:54f36ea00c210f7d3c28a909404ae3b0eab4600a97e72deaee6db2b0b9642b6d" - }, - "cluster": { - "k3s_version": "v1.36.4+k3s1", - "nodes": { - "utmist-z1opa08": { - "internal_ip": "10.0.0.175", - "nvidia_rtx_a4000_gpus": 2 - }, - "utmist-tt": { - "internal_ip": "10.0.0.112", - "n300_boards": 4, - "chips_per_board": 2 - } - }, - "tt_operator": "0.3.0", - "fabric_manager": "0.2.30", - "tt_dra_driver": "0.0.59" - }, - "api_acceptance": { - "run": "api-1003-202246", - "submitted_jobs": 17, - "expected_terminal_states": { - "Success": 13, - "Failure": 2, - "Cancelled": 2 - }, - "invalid_submissions_rejected_without_creating_jobs": true, - "unmanaged_jobs_hidden_and_cancellation_denied": true, - "cpu_execution_and_persisted_output": true, - "actual_failure_exit_code": 17, - "deadline_failure_reported": true, - "cancelled_logs_retained": true, - "cancellation_released_whole_tt_reservation": true, - "gpu_queues_and_automatic_reuse": true, - "tt_queues_and_automatic_reuse": true, - "nvidia_gpu_counts_tested": [ - 1, - 2 - ], - "tt_board_counts_tested": [ - 1, - 2, - 3, - 4 - ], - "training": "Independent 32x32 regression per chip/GPU; NVIDIA autograd, TT explicit TT-NN gradients", - "concurrency": { - "nvidia": { - "concurrent_jobs": 2, - "first_device_training_overlap_seconds": 1.168433 - }, - "tenstorrent": { - "concurrent_jobs": 4, - "first_device_training_overlap_seconds": 9.202772 - } - }, - "archived_checkpoints_from_initial_17_jobs": { - "cpu": { - "persisted_output": true - }, - "nvidia": { - "training_results": 5, - "checkpoints": 5, - "max_validation_mse": 9.320668823420419e-07, - "convergence_and_checkpoint_reload_passed": true - }, - "tenstorrent": { - "training_results": 24, - "checkpoints": 24, - "max_validation_mse": 0.00017509252938907593, - "convergence_and_checkpoint_reload_passed": true - } - }, - "additional_three_board_job": { - "id": "mist-05fcb5a03c359c8a24890d84", - "state": "Success", - "chips_tested": 6, - "exit_code": 0 - } - }, - "clients": { - "cli_submit_status_logs_cancel": "passed", - "browser_cpu_submit_status_logs_cancel": "passed", - "browser_two_tt_boards_four_chip_training": "passed", - "browser_temporary_api_outage_recovery": "passed" - }, - "restart": { - "retained_jobs": 23, - "states_exit_codes_and_logs_preserved": true, - "local_port_forward_recovered": true - }, - "access_checks": [ - { - "verb": "create", - "resource": "jobs", - "namespace": "mist", - "allowed": true, - "expected": true - }, - { - "verb": "get", - "resource": "pods/log", - "namespace": "mist", - "allowed": true, - "expected": true - }, - { - "verb": "get", - "resource": "resourceclaims.resource.k8s.io", - "namespace": "mist", - "allowed": true, - "expected": true - }, - { - "verb": "get", - "resource": "secrets", - "namespace": "mist", - "allowed": false, - "expected": false - }, - { - "verb": "get", - "resource": "nodes", - "namespace": "mist", - "allowed": false, - "expected": false - }, - { - "verb": "create", - "resource": "pods/exec", - "namespace": "mist", - "allowed": false, - "expected": false - }, - { - "verb": "create", - "resource": "jobs", - "namespace": "gpu-operator", - "allowed": false, - "expected": false - } - ], - "tests": { - "backend_full_suite": "go -C src test -timeout 90s ./... passed with an isolated local Redis", - "backend_kubernetes_suite": "go -C src test -run TestKubernetes ./... passed", - "cli": "go -C cli test ./... passed", - "web_component_tests": "3 tests passed", - "web_production_build": "npm run build passed", - "typescript": "npx tsc --noEmit passed", - "default_executor_and_default_private_kubeconfig_loading": "passed" - }, - "evidence_directory": "/home/utmist/mist-api-results-2026-10-03", - "evidence_files": [ - "api-acceptance.json", - "checkpoint-verification.json", - "concurrency-verification.json", - "cpu-checkpoints.tar.gz", - "nvidia-checkpoints.tar.gz", - "tenstorrent-checkpoints.tar.gz", - "cli.json", - "browser.json", - "jobs-browser.png", - "rbac.json", - "restart.json", - "default-executor.json", - "three-boards.json", - "browser-recovery.json" - ], - "limits": [ - "Local single-owner pilot; no per-request authentication, credits or team quotas.", - "Node-local checkpoint PVCs shared by jobs of the same compute profile.", - "History depends on retained Kubernetes Jobs; normal logs depend on retained Pods and kubelet log retention.", - "Host-mounted TT-Metal/Python runtime; hugepage mounts are host-shared.", - "Independent chip tests do not validate large models, TT autograd, distributed training or arbitrary mesh topology.", - "Vendor ResourceSlice memory capacity is incorrect; claims do not use it for placement.", - "Legacy Docker/Redis supervisor remains incomplete; other web pages and authentication commands retain prototype behavior." - ] -} diff --git a/deploy/k3s/mist-api.yaml b/deploy/k3s/mist-api.yaml deleted file mode 100644 index 19f8a2b..0000000 --- a/deploy/k3s/mist-api.yaml +++ /dev/null @@ -1,243 +0,0 @@ -apiVersion: v1 -kind: Namespace -metadata: - name: mist-system ---- -apiVersion: v1 -kind: Namespace -metadata: - name: mist ---- -apiVersion: v1 -kind: ServiceAccount -metadata: - name: mist-api - namespace: mist-system ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: Role -metadata: - name: mist-api - namespace: mist -rules: - - apiGroups: [batch] - resources: [jobs] - verbs: [get, list, create, update, delete] - - apiGroups: [""] - resources: [pods, events] - verbs: [get, list] - - apiGroups: [""] - resources: [pods/log] - verbs: [get] - - apiGroups: [""] - resources: [configmaps] - verbs: [get, create, update, delete] - - apiGroups: [resource.k8s.io] - resources: [resourceclaims, resourceclaimtemplates] - verbs: [get] ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: RoleBinding -metadata: - name: mist-api - namespace: mist -roleRef: - apiGroup: rbac.authorization.k8s.io - kind: Role - name: mist-api -subjects: - - kind: ServiceAccount - name: mist-api - namespace: mist-system ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole -metadata: - name: mist-hardware-reader -rules: - - apiGroups: [""] - resources: [nodes, pods] - verbs: [list] - - apiGroups: [resource.k8s.io] - resources: [resourceclaims, resourceslices] - verbs: [list] ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRoleBinding -metadata: - name: mist-hardware-reader -roleRef: - apiGroup: rbac.authorization.k8s.io - kind: ClusterRole - name: mist-hardware-reader -subjects: - - kind: ServiceAccount - name: mist-api - namespace: mist-system ---- -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: mist-cpu-checkpoints - namespace: mist -spec: - accessModes: [ReadWriteOnce] - storageClassName: local-path - resources: - requests: - storage: 256Mi ---- -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: training-nvidia-checkpoints - namespace: mist -spec: - accessModes: [ReadWriteOnce] - storageClassName: local-path - resources: - requests: - storage: 256Mi ---- -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: training-tenstorrent-checkpoints - namespace: mist -spec: - accessModes: [ReadWriteOnce] - storageClassName: local-path - resources: - requests: - storage: 256Mi ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: mist-api - namespace: mist-system -spec: - replicas: 1 - strategy: - type: Recreate - selector: - matchLabels: - app: mist-api - template: - metadata: - labels: - app: mist-api - spec: - serviceAccountName: mist-api - nodeSelector: - kubernetes.io/hostname: utmist-z1opa08 - securityContext: - runAsNonRoot: true - runAsUser: 65532 - seccompProfile: - type: RuntimeDefault - containers: - - name: api - image: mist-api:folders-20261021 - imagePullPolicy: Never - ports: - - name: http - containerPort: 3000 - env: - - name: MIST_TEAMS_ENABLED - value: "true" - - name: MIST_INTERNAL_TOKEN - valueFrom: - secretKeyRef: - name: mist-internal-auth - key: MIST_INTERNAL_TOKEN - - name: MIST_EXECUTOR - value: kubernetes - - name: MIST_HTTP_ADDR - value: 0.0.0.0:3000 - - name: MIST_NAMESPACE - value: mist - - name: MIST_PILOT_OWNER - value: utmist - # Local examples must be built/imported on the CPU/NVIDIA node. - # Registry-backed images are approved here using exact references. - - name: MIST_ALLOWED_IMAGES - value: "python:3.10-slim,pytorch/pytorch:2.5.1-cuda12.4-cudnn9-runtime,ghcr.io/tenstorrent/tt-metal/tt-metalium/ubuntu-22.04-dev-amd64:6b765c0bc2b40ea02566d169d7b0ee902377ba04,busybox:1.36,alpine:3.16.3,nvcr.io/nvidia/k8s/cuda-sample:vectoradd-cuda12.5.0,mist-training:cpu-v1,mist-training:nvidia-v1" - - name: MIST_AUTH_DATABASE - value: /data/auth.sqlite - - name: BETTER_AUTH_SECRET - valueFrom: - secretKeyRef: - name: mist-auth-bootstrap - key: BETTER_AUTH_SECRET - - name: MIST_ADMIN_EMAIL - valueFrom: - secretKeyRef: - name: mist-auth-bootstrap - key: MIST_ADMIN_EMAIL - - name: MIST_ADMIN_PASSWORD - valueFrom: - secretKeyRef: - name: mist-auth-bootstrap - key: MIST_ADMIN_PASSWORD - - name: MIST_TRUSTED_PROXY_IPS - value: "10.42.0.1,10.0.0.175,127.0.0.1" - - name: MIST_LEGACY_OWNER_EMAIL - value: utmist.infra@gmail.com - - name: MIST_ALLOWED_ORIGINS - value: http://100.73.139.66:8088,http://utmist.tail459b5e.ts.net:8088,http://127.0.0.1:8088,http://127.0.0.1:3001,http://localhost:3001,https://utmist.tail459b5e.ts.net - - name: MIST_STORAGE_ROOT - value: /storage - - name: MIST_NFS_SERVER - value: 192.168.240.2 - - name: MIST_NFS_ROOT - value: /srv/mist-data - - name: MIST_SHARED_PVC - value: mist-folder-jobs - volumeMounts: - - name: storage - mountPath: /storage - - name: auth-data - mountPath: /data - securityContext: - allowPrivilegeEscalation: false - readOnlyRootFilesystem: true - capabilities: - drop: [ALL] - resources: - requests: - cpu: 100m - memory: 128Mi - limits: - cpu: "2" - memory: 512Mi - readinessProbe: - httpGet: - path: /healthz - port: http - initialDelaySeconds: 2 - livenessProbe: - httpGet: - path: /healthz - port: http - initialDelaySeconds: 15 - periodSeconds: 15 - volumes: - - name: storage - persistentVolumeClaim: - claimName: mist-folder-api - - name: auth-data - persistentVolumeClaim: - claimName: mist-auth-data ---- -apiVersion: v1 -kind: Service -metadata: - name: mist-api - namespace: mist-system -spec: - selector: - app: mist-api - ports: - - port: 3000 - targetPort: http diff --git a/deploy/k3s/mist-tenstorrent-claims.yaml b/deploy/k3s/mist-tenstorrent-claims.yaml deleted file mode 100644 index 6436108..0000000 --- a/deploy/k3s/mist-tenstorrent-claims.yaml +++ /dev/null @@ -1,72 +0,0 @@ -# One per-pod claim; n300 count is boards, not individual chips. -apiVersion: resource.k8s.io/v1 -kind: ResourceClaimTemplate -metadata: - name: mist-tenstorrent-1-boards - namespace: mist -spec: - spec: - devices: - requests: - - name: boards - exactly: - deviceClassName: tenstorrent.com - count: 1 - selectors: - - cel: - expression: device.attributes["tenstorrent.com"].boardName == "n300" - && device.attributes["tenstorrent.com"].chipCount == 2 ---- -apiVersion: resource.k8s.io/v1 -kind: ResourceClaimTemplate -metadata: - name: mist-tenstorrent-2-boards - namespace: mist -spec: - spec: - devices: - requests: - - name: boards - exactly: - deviceClassName: tenstorrent.com - count: 2 - selectors: - - cel: - expression: device.attributes["tenstorrent.com"].boardName == "n300" - && device.attributes["tenstorrent.com"].chipCount == 2 ---- -apiVersion: resource.k8s.io/v1 -kind: ResourceClaimTemplate -metadata: - name: mist-tenstorrent-3-boards - namespace: mist -spec: - spec: - devices: - requests: - - name: boards - exactly: - deviceClassName: tenstorrent.com - count: 3 - selectors: - - cel: - expression: device.attributes["tenstorrent.com"].boardName == "n300" - && device.attributes["tenstorrent.com"].chipCount == 2 ---- -apiVersion: resource.k8s.io/v1 -kind: ResourceClaimTemplate -metadata: - name: mist-tenstorrent-4-boards - namespace: mist -spec: - spec: - devices: - requests: - - name: boards - exactly: - deviceClassName: tenstorrent.com - count: 4 - selectors: - - cel: - expression: device.attributes["tenstorrent.com"].boardName == "n300" - && device.attributes["tenstorrent.com"].chipCount == 2 diff --git a/deploy/k3s/mist-web.service b/deploy/k3s/mist-web.service deleted file mode 100644 index 9a136d7..0000000 --- a/deploy/k3s/mist-web.service +++ /dev/null @@ -1,13 +0,0 @@ -[Unit] -Description=Mist web interface on localhost -After=mist-api-forward.service - -[Service] -WorkingDirectory=/home/utmist/Mist/web-interface -ExecStart=/home/utmist/.local/share/mist-runtimes/node-22.16.0/bin/node /home/utmist/Mist/web-interface/node_modules/vite/bin/vite.js --host 127.0.0.1 --port 3001 --strictPort -Environment=MIST_API_PROXY=http://127.0.0.1:3000 -Restart=always -RestartSec=2 - -[Install] -WantedBy=default.target diff --git a/deploy/k3s/nvidia-gpu-operator-values.yaml b/deploy/k3s/nvidia-gpu-operator-values.yaml deleted file mode 100644 index 5d897e5..0000000 --- a/deploy/k3s/nvidia-gpu-operator-values.yaml +++ /dev/null @@ -1,10 +0,0 @@ -# This node already has a working NVIDIA host driver. Let the operator install -# the container toolkit and device plugin without replacing that driver. -driver: - enabled: false - -# CDI handles allocated workload GPUs. Enable the NRI plugin for GPU management -# containers without changing k3s's containerd runtime configuration. This is -# intended for the pilot; NVIDIA documents the NRI plugin as not yet GA. -cdi: - nriPluginEnabled: true diff --git a/deploy/k3s/setup-runbook.md b/deploy/k3s/setup-runbook.md deleted file mode 100644 index 643eb3a..0000000 --- a/deploy/k3s/setup-runbook.md +++ /dev/null @@ -1,9 +0,0 @@ -# Current k3s setup runbook - -The maintained two-node runbook is [k3s setup and accelerator handoff](../../docs/content/k3s-setup.md). -It covers current versions, LAN/Tailscale roles, fresh server/worker boundaries, -NVIDIA/TT operators, diagnostics and troubleshooting. - -- [Tailscale and administrator SSH](../../docs/content/tailscale-ssh.md) -- [Application deployment and service operations](../private/README.md) -- [Original single-node installation and worker-join history](../../docs/content/archive/k3s-setup-history.md) diff --git a/deploy/k3s/storage/README.md b/deploy/k3s/storage/README.md deleted file mode 100644 index 9a15f42..0000000 --- a/deploy/k3s/storage/README.md +++ /dev/null @@ -1,144 +0,0 @@ -# Shared folders over direct Ethernet - -Storage implementation for the two-machine installation, October 21, 2026. -The authoritative data for new submissions is an ordinary directory on QuietBox's -existing disk. There are no new disk images, loop devices or per-team mounts. - -```text -NVIDIA enp6s0 192.168.240.1/30 - | - direct 1-Gbps Ethernet - | -QuietBox enp200s0 192.168.240.2/30 - /srv/mist-data/ - .mist-storage-layout.json - metadata/provision-requests/ API-owned desired teams - metadata/provision-status/ readiness and sampled usage - teams// - datasets/ - metadata/datasets/ published dataset records - .uploads/ upload staging - common/datasets//content/ - members//datasets//content/ - results/ - common/jobs//outputs/ - members//jobs//outputs/ -``` - -## Access and limits - -- Accounts and sessions remain in the main-node local SQLite PVC, separate from - shared files. Go handles authentication and team authorization. -- All members can read their team's folders. A member writes their own folder; - common writes require permission. Other teams require an explicit grant. -- Kubernetes mounts only the selected dataset and the job's output directory. - Datasets are read-only at `/inputs`; results are writable at `/outputs`. -- `storage_gib` and `model_storage_gib` remain API fields, with defaults of - 200 and 500 GiB. They are **advisory allowances**. They neither reserve physical - space nor prevent job writes. They may be raised/lowered without migration. -- Every team shares the underlying disk. No equal-space guarantee or automatic - backup, replication, dataset cache or result deletion is provided. -- Uploads use current disk free space minus 1 GiB, plus an optional - `MIST_MAX_DATASET_GIB` ceiling. Jobs can still exhaust the shared disk. - ZIP processing needs both archive and extraction space. Transfers stream; - interrupted uploads restart. Existing CPU/device/runtime policies remain hard - admission limits. - -## Provisioning and usage - -`team_folders.py` is a local root-owned service without a network listener or -cluster credentials. It accepts bounded API-owned manifests, creates directories, -rejects symlinks/unexpected mounts and publishes `shared-folders-v1` readiness. -Usage counts logical regular-file bytes, excludes symlinks, and deduplicates hard -links within each pool. The host samples every 30 seconds; active writes may make -these approximate. The API does not walk the NFS directory tree for every poll. - -`GET /storage` reports: - -- `capacity_bytes`, `available_bytes`: actual shared filesystem space. -- `dataset_used_bytes`, `model_used_bytes`: sampled team file usage. -- `model_capacity_bytes`, `model_available_bytes`: compatibility aliases for the - same shared disk, not a separate model filesystem or reservation. -- `dataset_allocation_gib`, `model_allocation_gib`: advisory values. -- `storage_limits_enforced: false`, `storage_mode: shared-folders`. -- `usage_observed_at`, `usage_stale`, `storage_low`: freshness and disk warning. - -The old `GET /teams` field `storage_budget_gib` remains informational shared -capacity for compatibility. Allocations are not summed into a reservation budget. - -## Host setup - -Run commands as root in the **owning host's** mount/network namespace. Scripts -are installation-specific; verify interface names/addresses on another system. - -On NVIDIA: - -```bash -bash deploy/k3s/storage/setup-storage-link.sh main -``` - -On QuietBox, from a checkout/copy of these files: - -```bash -bash deploy/k3s/storage/setup-storage-link.sh quietbox -bash deploy/k3s/storage/setup-quietbox.sh -install -d -m 0755 /usr/local/lib/mist -install -m 0755 deploy/k3s/storage/team_folders.py /usr/local/lib/mist/team_folders.py -install -m 0644 deploy/k3s/storage/mist-team-storage.service /etc/systemd/system/ -systemctl daemon-reload -systemctl enable --now mist-team-storage -``` - -The NetworkManager profile `mist-storage-direct` persists addresses without a -gateway. Existing LAN/default routes, Tailscale and k3s node addresses stay in use. -NFS permits only `192.168.240.1` and `192.168.240.2`; the firewall allows main's -TCP 2049 on `enp200s0`. Root squash maps root requests to the storage API identity. - -New bindings use NFS **4.1**. During inspection, Linux reused an existing 4.2 -connection to QuietBox's old LAN address even when mounting its Ethernet alias. -A separate 4.1 connection makes the new Ethernet transport verifiable without -tearing down existing old mounts. Verify both the mount and TCP connection, not -only the requested mount address. - -The initial cutover uses new `mist-folder-api`/`mist-folder-jobs` bindings and -per-team `team-datasets`/`team-results` claims, leaving old bindings intact. -Apply `shared.yaml` and the API deployment, then wait for new team readiness. -`ensureVolume` refuses an existing binding pointing to a different NFS export. - -## Prelaunch reset and rollback - -The user chose fresh storage without data migration. Old files under -`/srv/mist-storage` and its backing images remain untouched as rollback material. -Old retained Jobs may still appear in history; their old files are not copied to -the new root. New datasets and results use `/srv/mist-data`. - -Before cutover, record the API/auth deployment specifications, web release target -and team policies. Create a consistent SQLite backup using its database backup -API. Pause the old API/auth before the Go-auth API takes the shared auth PVC. - -Rollback restores the previous API/auth deployment and provisioning unit, and -repoints Nginx's web release symlink. Old volumes were not replaced or deleted. -An operational rollback after accepting new writes must account for those writes; -this is a prelaunch cutover, not automatic bidirectional migration. - -## Verify - -```bash -python3 -m unittest discover -s deploy/k3s/storage -p test_folders.py -v -python3 deploy/k3s/verify_storage_folders.py --out /tmp/mist-storage-check -``` - -The live verifier creates two workspaces and a regular account, checks small -uploads/downloads, archive rejection and team isolation, then trains on one -NVIDIA GPU and one n300 board. It retains a protected account file in its output -directory for optional browser review; never commit that file. Completed test -jobs/results remain inspectable. It does not migrate or delete old research data. - -Optional browser review uses Playwright from `MIST_PLAYWRIGHT_MODULE` (the local -installation defaults to `/tmp/mist-browser-check/node_modules/playwright/index.mjs`): - -```bash -MIST_STORAGE_EVIDENCE_DIR=/tmp/mist-storage-check node deploy/k3s/verify_storage_folders_browser.mjs -``` - -Recorded live results: [October 21 evidence](../evidence/storage-2026-10-21/README.md). diff --git a/deploy/k3s/storage/mist-team-storage.service b/deploy/k3s/storage/mist-team-storage.service deleted file mode 100644 index 2ffc705..0000000 --- a/deploy/k3s/storage/mist-team-storage.service +++ /dev/null @@ -1,16 +0,0 @@ -[Unit] -Description=Mist shared team folders and usage reporting -After=local-fs.target nfs-server.service -RequiresMountsFor=/srv/mist-data -Requires=nfs-server.service - -[Service] -Type=simple -Environment=MIST_STORAGE_ROOT=/srv/mist-data -ExecStart=/usr/bin/python3 /usr/local/lib/mist/team_folders.py -Restart=on-failure -RestartSec=5 -UMask=0077 - -[Install] -WantedBy=multi-user.target diff --git a/deploy/k3s/storage/setup-quietbox.sh b/deploy/k3s/storage/setup-quietbox.sh deleted file mode 100755 index 799941f..0000000 --- a/deploy/k3s/storage/setup-quietbox.sh +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/bin/env bash -# Initialize ordinary storage folders on QuietBox. Never formats/mounts a disk. -set -euo pipefail -[ "$(id -u)" -eq 0 ] || { echo 'Run as root on QuietBox' >&2; exit 1; } -root=/srv/mist-data -[ ! -L "$root" ] || { echo 'Refusing a symlink storage root' >&2; exit 1; } -if [ -d "$root" ] && [ -n "$(ls -A "$root")" ] && [ ! -f "$root/.mist-storage-layout.json" ]; then - echo 'Refusing to adopt a nonempty unknown directory' >&2 - exit 1 -fi -install -d -o 65532 -g 65532 -m 0750 "$root" -if [ -e "$root/.mist-storage-layout.json" ]; then - python3 - "$root" <<'PY' -import json, sys -from pathlib import Path -marker = Path(sys.argv[1]) / '.mist-storage-layout.json' -assert not marker.is_symlink() -assert json.loads(marker.read_text())['layout'] == 'shared-folders-v1' -PY -else - printf '{"layout":"shared-folders-v1"}\n' > "$root/.mist-storage-layout.json" - chmod 0644 "$root/.mist-storage-layout.json" -fi -install -d -o 65532 -g 65532 -m 0700 "$root/metadata" "$root/metadata/provision-requests" "$root/metadata/provision-status" -install -d -o 65532 -g 65532 -m 0755 "$root/teams" -install -d /etc/exports.d /etc/nfs.conf.d -cat > /etc/exports.d/mist-folders.exports <<'EXPORT' -/srv/mist-data 192.168.240.1(rw,sync,no_subtree_check,root_squash,anonuid=65532,anongid=65532,fsid=8410) 192.168.240.2(rw,sync,no_subtree_check,root_squash,anonuid=65532,anongid=65532,fsid=8410) -EXPORT -cat > /etc/nfs.conf.d/mist.conf <<'CONF' -[nfsd] -vers3=n -vers4=y -CONF -systemctl enable --now nfs-server -exportfs -ra -if ufw status | grep -q 'Status: active'; then - ufw allow in on enp200s0 from 192.168.240.1 to any port 2049 proto tcp comment mist-direct-nfs -fi -findmnt -T "$root" -df -h "$root" diff --git a/deploy/k3s/storage/setup-storage-link.sh b/deploy/k3s/storage/setup-storage-link.sh deleted file mode 100644 index 33ced6c..0000000 --- a/deploy/k3s/storage/setup-storage-link.sh +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env bash -# Run on each owning host as root. No gateway or cluster-interface changes. -set -euo pipefail -case "${1:-}" in - main) iface=enp6s0; address=192.168.240.1/30 ;; - quietbox) iface=enp200s0; address=192.168.240.2/30 ;; - *) echo 'Usage: setup-storage-link.sh main|quietbox' >&2; exit 2 ;; -esac -[ "$(id -u)" -eq 0 ] || { echo 'Run as root on the owning host' >&2; exit 1; } -[ "$(cat "/sys/class/net/$iface/carrier")" = 1 ] || { echo "$iface has no cable link" >&2; exit 1; } -[ -z "$(ip -4 route show default dev "$iface")" ] || { echo 'Refusing to replace a default-route connection' >&2; exit 1; } -profile=mist-storage-direct -current=$(nmcli -g GENERAL.CONNECTION device show "$iface") -install -d -m 0700 /var/lib/mist -if [ -n "$current" ] && [ "$current" != '--' ] && [ "$current" != "$profile" ]; then - uuid=$(nmcli -g connection.uuid connection show "$current") - auto=$(nmcli -g connection.autoconnect connection show "$uuid") - record=/var/lib/mist/storage-link-previous.txt - if [ ! -f "$record" ]; then - printf '%s\n%s\n%s\n' "$iface" "$uuid" "$auto" > "$record" - chmod 0600 "$record" - fi - nmcli connection modify "$uuid" connection.autoconnect no -fi -if nmcli connection show "$profile" >/dev/null 2>&1; then - [ "$(nmcli -g connection.interface-name connection show "$profile")" = "$iface" ] || exit 1 -else - nmcli connection add type ethernet ifname "$iface" con-name "$profile" -fi -nmcli connection modify "$profile" \ - connection.autoconnect yes connection.autoconnect-priority 100 \ - ipv4.method manual ipv4.addresses "$address" ipv4.gateway '' \ - ipv4.dns '' ipv4.never-default yes ipv6.method disabled -nmcli --wait 15 connection up "$profile" -ip -4 address show dev "$iface" diff --git a/deploy/k3s/storage/shared.yaml b/deploy/k3s/storage/shared.yaml deleted file mode 100644 index 5298e0d..0000000 --- a/deploy/k3s/storage/shared.yaml +++ /dev/null @@ -1,62 +0,0 @@ -apiVersion: v1 -kind: PersistentVolume -metadata: - name: mist-folder-api -spec: - capacity: - storage: 1Gi - accessModes: [ReadWriteMany] - persistentVolumeReclaimPolicy: Retain - storageClassName: "" - claimRef: - name: mist-folder-api - namespace: mist-system - mountOptions: [nfsvers=4.1, hard, timeo=100, retrans=2] - nfs: - server: 192.168.240.2 - path: /srv/mist-data ---- -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: mist-folder-api - namespace: mist-system -spec: - storageClassName: "" - volumeName: mist-folder-api - accessModes: [ReadWriteMany] - resources: - requests: - # Static NFS binding declaration, not a quota. API reports real disk space. - storage: 1Gi ---- -apiVersion: v1 -kind: PersistentVolume -metadata: - name: mist-folder-jobs -spec: - capacity: - storage: 1Gi - accessModes: [ReadWriteMany] - persistentVolumeReclaimPolicy: Retain - storageClassName: "" - claimRef: - name: mist-folder-jobs - namespace: mist - mountOptions: [nfsvers=4.1, hard, timeo=100, retrans=2] - nfs: - server: 192.168.240.2 - path: /srv/mist-data ---- -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: mist-folder-jobs - namespace: mist -spec: - storageClassName: "" - volumeName: mist-folder-jobs - accessModes: [ReadWriteMany] - resources: - requests: - storage: 1Gi diff --git a/deploy/k3s/storage/team_folders.py b/deploy/k3s/storage/team_folders.py deleted file mode 100644 index 50c6efc..0000000 --- a/deploy/k3s/storage/team_folders.py +++ /dev/null @@ -1,151 +0,0 @@ -#!/usr/bin/env python3 -"""Prepare ordinary team directories and publish usage; never format or migrate. - -Only bounded API-owned requests outside workload mounts are accepted. Allowances -are advisory. The filesystem's real free space is shared by all teams. -""" -import json -import os -from pathlib import Path -import re -import stat -import tempfile -import time - -LAYOUT = 'shared-folders-v1' -TEAM_ID = re.compile(r'^team-[a-f0-9]{16}$') - - -class FolderStore: - def __init__(self, root, uid=65532, gid=65532): - self.root = Path(root) - self.uid, self.gid = uid, gid - marker = self.root / '.mist-storage-layout.json' - if self.root.is_symlink() or marker.is_symlink(): - raise ValueError('storage root and layout marker must not be symlinks') - if json.loads(marker.read_text()).get('layout') != LAYOUT: - raise ValueError('shared-folder storage has not been initialized') - self.device = self.root.stat().st_dev - self.requests = self.directory('metadata/provision-requests', 0o700) - self.status = self.directory('metadata/provision-status', 0o700) - self.directory('teams', 0o755) - - def directory(self, relative, mode): - path = self.root - parts = Path(relative).parts - if not parts or any(part in ('.', '..', '/') for part in parts): - raise ValueError('invalid storage directory') - for part in parts: - path = path / part - if not path.exists() and not path.is_symlink(): - path.mkdir(mode=0o750) - os.chown(path, self.uid, self.gid) - entry = path.lstat() - if not stat.S_ISDIR(entry.st_mode) or entry.st_dev != self.device: - raise ValueError('storage directories must be ordinary directories on the shared disk') - os.chmod(path, mode) - os.chown(path, self.uid, self.gid) - return path - - def request(self, path): - entry = path.lstat() - if (not TEAM_ID.fullmatch(path.stem) or not stat.S_ISREG(entry.st_mode) - or entry.st_uid != self.uid or entry.st_size > 1024): - raise ValueError('request must be a bounded API-owned regular file') - body = json.loads(path.read_text()) - if set(body) != {'id', 'storage_gib', 'model_storage_gib'} or body['id'] != path.stem: - raise ValueError('invalid team request schema') - for key in ('storage_gib', 'model_storage_gib'): - if type(body[key]) is not int or not 1 <= body[key] <= 2**20: - raise ValueError('invalid advisory allowance') - return body - - @staticmethod - def usage(path): - """Logical regular-file bytes. Never follow workload-created symlinks.""" - total = 0 - device = path.stat().st_dev - seen = set() - def failed(error): - if not isinstance(error, FileNotFoundError): - raise error - for _, _, files, descriptor in os.fwalk(path, follow_symlinks=False, onerror=failed): - if os.fstat(descriptor).st_dev != device: - raise ValueError('unexpected nested filesystem in team storage') - for name in files: - try: - info = os.stat(name, dir_fd=descriptor, follow_symlinks=False) - except FileNotFoundError: - continue # A live job may rename/delete files during sampling. - if stat.S_ISREG(info.st_mode): - identity = (info.st_dev, info.st_ino) - if identity not in seen: - total += info.st_size - seen.add(identity) - return total - - def provision(self, body, previous=None): - team = body['id'] - if not TEAM_ID.fullmatch(team): - raise ValueError('invalid team ID') - datasets = self.directory(f'teams/{team}/datasets', 0o750) - results = self.directory(f'teams/{team}/results', 0o750) - for name in ('metadata', 'metadata/datasets', '.uploads'): - self.directory(f'teams/{team}/datasets/{name}', 0o700) - for pool in ('datasets', 'results'): - for scope in ('common', 'members'): - self.directory(f'teams/{team}/{pool}/{scope}', 0o755) - usage = previous or {} - if time.time() - usage.get('usage_observed_unix', 0) >= 30: - usage = {'dataset_used_bytes': self.usage(datasets), - 'model_used_bytes': self.usage(results), - 'usage_observed_unix': int(time.time())} - return {**body, 'state': 'Ready', 'layout': LAYOUT, - **{key: usage[key] for key in - ('dataset_used_bytes', 'model_used_bytes', 'usage_observed_unix')}} - - def publish(self, team, value): - path = self.status / f'{team}.json' - serialized = json.dumps(value, sort_keys=True) - if path.exists() and not path.is_symlink() and path.read_text() == serialized: - return - fd, temporary = tempfile.mkstemp(prefix='.mist-', dir=self.status) - try: - with os.fdopen(fd, 'w') as stream: - stream.write(serialized) - stream.flush() - os.fsync(stream.fileno()) - os.chown(temporary, self.uid, self.gid) - os.replace(temporary, path) - finally: - if os.path.exists(temporary): - os.unlink(temporary) - - def reconcile(self, cache): - for path in sorted(self.requests.glob('team-*.json')): - try: - body = self.request(path) - except Exception as error: - print(f'Request rejected: {path.name}: {error}', flush=True) - continue - try: - value = self.provision(body, cache.get(body['id'])) - cache[body['id']] = value - except Exception as error: - value = {'state': 'Error', 'layout': LAYOUT, 'error': str(error)} - print(f"Provisioning failed: {body['id']}: {error}", flush=True) - self.publish(body['id'], value) - - -def main(): - if os.geteuid() != 0: - raise SystemExit('Run as root on the storage host') - store = FolderStore(os.environ.get('MIST_STORAGE_ROOT', '/srv/mist-data')) - cache = {} - while True: - store.reconcile(cache) - time.sleep(2) - - -if __name__ == '__main__': - main() diff --git a/deploy/k3s/storage/test_folders.py b/deploy/k3s/storage/test_folders.py deleted file mode 100644 index 714afd9..0000000 --- a/deploy/k3s/storage/test_folders.py +++ /dev/null @@ -1,95 +0,0 @@ -import json -import os -from pathlib import Path -import tempfile -import unittest - -from team_folders import FolderStore, LAYOUT - - -class FolderTests(unittest.TestCase): - def setUp(self): - self.temporary = tempfile.TemporaryDirectory() - self.addCleanup(self.temporary.cleanup) - self.root = Path(self.temporary.name) - (self.root / '.mist-storage-layout.json').write_text(json.dumps({'layout': LAYOUT})) - self.store = FolderStore(self.root, os.getuid(), os.getgid()) - self.team = 'team-aaaaaaaaaaaaaaaa' - self.body = {'id': self.team, 'storage_gib': 200, 'model_storage_gib': 500} - - def request(self, body): - path = self.store.requests / f'{self.team}.json' - path.write_text(json.dumps(body)) - return path - - def test_plain_folders_preserve_files_and_allow_lower_allowances(self): - ready = self.store.provision(self.body) - data = self.root / 'teams' / self.team / 'datasets' - models = data.parent / 'results' - (data / 'common' / 'tiny.csv').write_bytes(b'1,2\n') - (models / 'common' / 'model.bin').write_bytes(b'weights') - lower = {**self.body, 'storage_gib': 1, 'model_storage_gib': 1} - updated = self.store.provision(lower) - self.assertEqual(ready['layout'], LAYOUT) - self.assertEqual(updated['dataset_used_bytes'], 4) - self.assertEqual(updated['model_used_bytes'], 7) - self.assertEqual(updated['storage_gib'], 1) - self.assertEqual(data.stat().st_dev, models.stat().st_dev) - self.assertFalse(list(self.root.rglob('*.img'))) - - def test_usage_does_not_follow_links_or_double_count_hardlinks(self): - self.store.provision(self.body) - data = self.root / 'teams' / self.team / 'datasets' - (data / 'common' / 'data').write_bytes(b'123') - os.link(data / 'common' / 'data', data / 'common' / 'alias') - (data / 'common' / 'outside').symlink_to('/etc') - (data / 'common' / 'file-link').symlink_to('/etc/passwd') - self.assertEqual(FolderStore.usage(data), 3) - - def test_unsafe_paths_fail_and_do_not_change_target(self): - outside = self.root / 'outside' - outside.mkdir(mode=0o700) - (self.root / 'teams' / self.team).symlink_to(outside) - with self.assertRaises(ValueError): - self.store.provision(self.body) - self.assertEqual(list(outside.iterdir()), []) - - def test_requests_reject_symlinks_invalid_schema_ids_and_allowances(self): - path = self.request(self.body) - self.assertEqual(self.store.request(path), self.body) - for body in [dict(self.body, storage_gib=True), dict(self.body, model_storage_gib=0), - dict(self.body, id='../outside'), dict(self.body, extra='unexpected')]: - self.request(body) - with self.assertRaises(ValueError): - self.store.request(path) - path.unlink() - target = self.root / 'request.json' - target.write_text(json.dumps(self.body)) - path.symlink_to(target) - with self.assertRaises(ValueError): - self.store.request(path) - - def test_reconciliation_publishes_errors_and_recovers(self): - self.request(self.body) - cache = {} - self.store.reconcile(cache) - path = self.store.status / f'{self.team}.json' - self.assertEqual(json.loads(path.read_text())['state'], 'Ready') - results = self.root / 'teams' / self.team / 'results' - results.rename(results.with_name('retained')) - results.symlink_to('/tmp') - self.store.reconcile(cache) - self.assertEqual(json.loads(path.read_text())['state'], 'Error') - results.unlink() - results.with_name('retained').rename(results) - self.store.reconcile(cache) - self.assertEqual(json.loads(path.read_text())['state'], 'Ready') - - def test_uninitialized_root_is_refused(self): - (self.root / '.mist-storage-layout.json').write_text('{"layout":"old-image-layout"}') - with self.assertRaises(ValueError): - FolderStore(self.root, os.getuid(), os.getgid()) - - -if __name__ == '__main__': - unittest.main() diff --git a/deploy/k3s/submit_tenstorrent_smoke.py b/deploy/k3s/submit_tenstorrent_smoke.py deleted file mode 100644 index bec956a..0000000 --- a/deploy/k3s/submit_tenstorrent_smoke.py +++ /dev/null @@ -1,59 +0,0 @@ -"""Submit a uniquely named training check reserving one to four n300 boards. - -Uses the admin KUBECONFIG, k3s kubectl, and Python PyYAML. This submits the -small TT-NN regression test; application/model integration is separate. -""" - -import argparse -import copy -from pathlib import Path -import re -import subprocess - -import yaml - - -def manifests(name, boards, steps): - docs = list(yaml.safe_load_all(Path(__file__).with_name("training-tenstorrent-job.yaml").read_text())) - pvc, claim, job = copy.deepcopy(docs) - claim["metadata"]["name"] = f"training-tenstorrent-{boards}-boards" - claim["spec"]["spec"]["devices"]["requests"][0]["exactly"]["count"] = boards - job["metadata"]["name"] = name - pod = job["spec"]["template"]["spec"] - pod["resourceClaims"][0]["resourceClaimTemplateName"] = claim["metadata"]["name"] - train = pod["containers"][0] - train["command"] = train["command"][:4] + ["--expected-devices", str(boards * 2), - "--steps", str(steps), "--output", f"/checkpoints/{name}"] - for resources in (train["resources"]["requests"], train["resources"]["limits"]): - resources["hugepages-1Gi"] = f"{boards * 2}Gi" - train["resources"]["requests"]["cpu"] = str(boards * 2) - train["resources"]["requests"]["memory"] = f"{boards * 4}Gi" - train["resources"]["limits"]["cpu"] = str(boards * 8) - train["resources"]["limits"]["memory"] = f"{boards * 8}Gi" - return pvc, claim, job - - -if __name__ == "__main__": - parser = argparse.ArgumentParser() - parser.add_argument("name", help="Unique Kubernetes Job name; also used for the checkpoint directory") - parser.add_argument("--boards", type=int, choices=range(1, 5), default=1) - parser.add_argument("--steps", type=int, default=100) - parser.add_argument("--dry-run", action="store_true", help="Print the manifests without changing the cluster") - args = parser.parse_args() - if len(args.name) > 63 or not re.fullmatch(r"[a-z0-9](?:[-a-z0-9]*[a-z0-9])?", args.name): - parser.error("name must be a DNS label with at most 63 characters") - if args.steps < 100: - parser.error("at least 100 steps are required for the convergence check") - pvc, claim, job = manifests(args.name, args.boards, args.steps) - if args.dry_run: - print(yaml.safe_dump_all([pvc, claim, job]), end="") - else: - subprocess.run(["k3s", "kubectl", "apply", "-f", "-"], - input=yaml.safe_dump_all([pvc, claim]), text=True, check=True) - configmap = subprocess.run(["k3s", "kubectl", "-n", "mist", "create", "configmap", - "training-smoke-script", "--from-file=" + str(Path(__file__).with_name("training_smoke.py")), - "--dry-run=client", "-o", "yaml"], text=True, capture_output=True, check=True).stdout - subprocess.run(["k3s", "kubectl", "apply", "-f", "-"], input=configmap, text=True, check=True) - subprocess.run(["k3s", "kubectl", "create", "-f", "-"], - input=yaml.safe_dump(job), text=True, check=True) - print(f"Requested {args.boards} board(s), {args.boards * 2} chips; checkpoints: /checkpoints/{args.name}") diff --git a/deploy/k3s/team-rbac.yaml b/deploy/k3s/team-rbac.yaml deleted file mode 100644 index 0a44d35..0000000 --- a/deploy/k3s/team-rbac.yaml +++ /dev/null @@ -1,39 +0,0 @@ -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole -metadata: - name: mist-team-controller -rules: - - apiGroups: [""] - resources: [namespaces, configmaps, persistentvolumes, persistentvolumeclaims, resourcequotas] - verbs: [get, list, create, update, patch] - - apiGroups: [""] - resources: [configmaps] - verbs: [delete] - - apiGroups: [batch] - resources: [jobs] - verbs: [get, list, create, update, patch, delete] - - apiGroups: [""] - resources: [pods, events] - verbs: [get, list] - - apiGroups: [""] - resources: [pods/log] - verbs: [get] - - apiGroups: [resource.k8s.io] - resources: [resourceclaims, resourceclaimtemplates] - verbs: [get, list, create] - - apiGroups: [networking.k8s.io] - resources: [networkpolicies] - verbs: [get, list, create, update] ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRoleBinding -metadata: - name: mist-team-controller -roleRef: - apiGroup: rbac.authorization.k8s.io - kind: ClusterRole - name: mist-team-controller -subjects: - - kind: ServiceAccount - name: mist-api - namespace: mist-system diff --git a/deploy/k3s/team-workload-policy.yaml b/deploy/k3s/team-workload-policy.yaml deleted file mode 100644 index f12757e..0000000 --- a/deploy/k3s/team-workload-policy.yaml +++ /dev/null @@ -1,38 +0,0 @@ -apiVersion: admissionregistration.k8s.io/v1 -kind: ValidatingAdmissionPolicy -metadata: - name: mist-team-boundary -spec: - failurePolicy: Fail - matchConstraints: - resourceRules: - - apiGroups: [""] - apiVersions: [v1] - operations: [CREATE, UPDATE] - resources: [pods, pods/ephemeralcontainers] - variables: - - name: containers - expression: 'object.spec.containers + (has(object.spec.initContainers) ? object.spec.initContainers : []) + (has(object.spec.ephemeralContainers) ? object.spec.ephemeralContainers : [])' - validations: - - expression: '(!has(object.spec.hostNetwork) || !object.spec.hostNetwork) && (!has(object.spec.hostPID) || !object.spec.hostPID) && (!has(object.spec.hostIPC) || !object.spec.hostIPC)' - message: Team workloads cannot join host namespaces. - - expression: 'has(object.spec.automountServiceAccountToken) && !object.spec.automountServiceAccountToken' - message: Team workloads must disable Kubernetes credentials. - - expression: 'has(object.spec.securityContext) && has(object.spec.securityContext.seccompProfile) && object.spec.securityContext.seccompProfile.type == "RuntimeDefault"' - message: Team workloads must use the default seccomp profile. - - expression: 'variables.containers.all(c, has(c.securityContext) && has(c.securityContext.allowPrivilegeEscalation) && !c.securityContext.allowPrivilegeEscalation && (!has(c.securityContext.privileged) || !c.securityContext.privileged) && has(c.securityContext.capabilities) && has(c.securityContext.capabilities.drop) && "ALL" in c.securityContext.capabilities.drop && (!has(c.securityContext.capabilities.add) || size(c.securityContext.capabilities.add) == 0))' - message: Team containers must drop capabilities and forbid privilege escalation. - - expression: '!has(object.spec.volumes) || object.spec.volumes.all(v, has(v.persistentVolumeClaim) || has(v.configMap) || has(v.emptyDir) || (has(v.hostPath) && v.hostPath.path in ["/home/utmist-tt/TT/tt-metal", "/home/utmist-tt/.local/share/uv/python"] && variables.containers.all(c, !has(c.volumeMounts) || c.volumeMounts.all(m, m.name != v.name || (has(m.readOnly) && m.readOnly)))))' - message: Team volumes allow PVCs, scripts, scratch space and read-only TT runtime mounts. ---- -apiVersion: admissionregistration.k8s.io/v1 -kind: ValidatingAdmissionPolicyBinding -metadata: - name: mist-team-boundary -spec: - policyName: mist-team-boundary - validationActions: [Deny] - matchResources: - namespaceSelector: - matchLabels: - mist.io/workloads: "true" diff --git a/deploy/k3s/tenstorrent-allocation-results.json b/deploy/k3s/tenstorrent-allocation-results.json deleted file mode 100644 index c62a4a7..0000000 --- a/deploy/k3s/tenstorrent-allocation-results.json +++ /dev/null @@ -1,153 +0,0 @@ -{ - "verified_at": "2026-10-03T19:23:13.903202+00:00", - "node": "utmist-tt", - "k3s_version": "v1.36.4+k3s1", - "hardware": { - "boards": 4, - "type": "n300", - "chips_per_board": 2, - "total_chips": 8 - }, - "operator": { - "chart_version": "0.3.0", - "fabric_manager_version": "0.2.30", - "dra_driver_version": "0.0.59", - "namespace": "tt-operator-system", - "host_driver_managed": false, - "image_ids": { - "ghcr.io/tenstorrent/tt-fabric-manager:0.2.30": "ghcr.io/tenstorrent/tt-fabric-manager@sha256:82c9063a5182051f0980459eaacb3e2d878a197874dfe05c6a5eb9288f4cca33", - "ghcr.io/tenstorrent/tt-dra-driver:0.0.59": "ghcr.io/tenstorrent/tt-dra-driver@sha256:ac0089e9b6939170300aa325c62605b48cf12313ca6cec8c9d8a04f416d12dfb" - } - }, - "allocation": { - "device_class": "tenstorrent.com", - "unit": "one n300 board including both chips", - "claims": "one ResourceClaimTemplate-generated claim per pod", - "hugepages_1Gi_per_board": "2Gi", - "privileged_training": false - }, - "concurrency_test": { - "run": "tt-alloc-1003-190834", - "simultaneous_jobs": 4, - "boards_per_job": 1, - "chips_tested_per_job": 2, - "steps_per_chip": 500, - "overlap_seconds": 3.924301, - "reserved_devices": { - "tt-alloc-1003-190834-0-wzcrc": "tt-3", - "tt-alloc-1003-190834-1-b8fdc": "tt-1", - "tt-alloc-1003-190834-2-5qqtz": "tt-2", - "tt-alloc-1003-190834-3-f552p": "tt-0" - }, - "device_access_probes": { - "tt-alloc-1003-190834-0-wzcrc": { - "allowed": [ - "0" - ], - "denied": [ - "1", - "2", - "3" - ] - }, - "tt-alloc-1003-190834-1-b8fdc": { - "allowed": [ - "2" - ], - "denied": [ - "0", - "1", - "3" - ] - }, - "tt-alloc-1003-190834-2-5qqtz": { - "allowed": [ - "3" - ], - "denied": [ - "0", - "1", - "2" - ] - }, - "tt-alloc-1003-190834-3-f552p": { - "allowed": [ - "1" - ], - "denied": [ - "0", - "2", - "3" - ] - } - }, - "fifth_job_pending": true, - "queue_messages": [ - "0/2 nodes are available: 1 cannot allocate all claims, 1 node(s) didn't match Pod's node affinity/selector. still not schedulable, preemption: 0/2 nodes are available: 2 Preemption is not helpful for scheduling." - ], - "fifth_job_completed_after_release": true, - "all_jobs_exit_code": 0, - "final_training_mse": 6.243230745894834e-05, - "held_out_mse": 0.00017509252938907593, - "checkpoint_reload_passed": true - }, - "additional_checks": { - "allocator_restart_recovered": true, - "training_after_restart_chips": 2, - "nvidia_cuda_regression": "Test PASSED", - "multi_board_training": [ - { - "boards": 2, - "chips": 4, - "passed": true, - "checkpoint_reload_passed": true - }, - { - "boards": 4, - "chips": 8, - "passed": true, - "checkpoint_reload_passed": true - } - ], - "admission": { - "allowed": { - "accepted": true, - "message": "pod/allocation-policy-check created (server dry run)\n" - }, - "privileged": { - "accepted": false, - "message": "The Pod \"allocation-policy-check\" is invalid: spec.containers[0].securityContext: Invalid value: {\"Capabilities\":{\"Add\":null,\"Drop\":[\"ALL\"]},\"Privileged\":true,\"SELinuxOptions\":null,\"WindowsOptions\":null,\"RunAsUser\":null,\"RunAsGroup\":null,\"RunAsNonRoot\":null,\"ReadOnlyRootFilesystem\":null,\"AllowPrivilegeEscalation\":false,\"ProcMount\":null,\"SeccompProfile\":null,\"AppArmorProfile\":null}: cannot set `allowPrivilegeEscalation` to false and `privileged` to true\n" - }, - "docker-socket": { - "accepted": false, - "message": "The pods \"allocation-policy-check\" is invalid: : ValidatingAdmissionPolicy 'mist-accelerator-isolation' with binding 'mist-accelerator-isolation' denied request: Use DRA/CDI for devices and PVCs for storage; arbitrary host mounts are forbidden in Mist.\n" - }, - "writable-metal": { - "accepted": false, - "message": "The pods \"allocation-policy-check\" is invalid: : ValidatingAdmissionPolicy 'mist-accelerator-isolation' with binding 'mist-accelerator-isolation' denied request: The host TT-Metal and Python environment mounts must be read-only.\n" - }, - "added-capability": { - "accepted": false, - "message": "The pods \"allocation-policy-check\" is invalid: : ValidatingAdmissionPolicy 'mist-accelerator-isolation' with binding 'mist-accelerator-isolation' denied request: Mist workloads cannot enable privileged access, privilege escalation, or additional Linux capabilities.\n" - } - } - }, - "runtime": { - "tt_metal_host_commit": "80ec10ab611570a6818fdebc1398c8c9141fb9df", - "multi_board_smoke": "Separate runtime per reserved board, selected by PCI BDF in TT_VISIBLE_DEVICES; each chip trains independently." - }, - "limitations": [ - "Training is a synthetic TTNN linear-regression test with explicit analytic gradients, not arbitrary-model autograd or distributed training.", - "DRA board-count requests do not guarantee a connected fabric topology for multi-board models.", - "Mist API integration still needs to create Kubernetes Jobs and device claims.", - "Host runtime directories and hugepage filesystems remain shared; this is not a full untrusted-tenant security boundary.", - "Vendor ResourceSlice memory capacity is incorrect (281453674972256 bytes per n300); claims intentionally do not use memory-capacity placement." - ], - "artifacts": { - "directory": "/home/utmist/k3s-allocation-results-2026-10-03", - "concurrent_checkpoints": "/home/utmist/k3s-allocation-results-2026-10-03/concurrent-checkpoints.tar.gz", - "recovery_and_multi_board_checkpoints": "/home/utmist/k3s-allocation-results-2026-10-03/additional-checkpoints.tar.gz", - "checkpoint_pvc": "mist/training-tenstorrent-checkpoints", - "concurrent_pvc_directory": "/checkpoints/tt-alloc-1003-190834" - } -} diff --git a/deploy/k3s/tenstorrent-operator-values.yaml b/deploy/k3s/tenstorrent-operator-values.yaml deleted file mode 100644 index 63f75aa..0000000 --- a/deploy/k3s/tenstorrent-operator-values.yaml +++ /dev/null @@ -1,29 +0,0 @@ -# Pinned with tt-operator chart 0.3.0. Host TT driver/firmware are managed -# separately. Reuse the cluster's existing NVIDIA-provided NFD installation. -node-feature-discovery: - enabled: false -tt-k8s-driver-manager: - enabled: false -tt-telemetry: - enabled: false -jobset: - enabled: false -kubepmix: - enabled: false -tt-fabric-manager: - enabled: true - fullnameOverride: tt-fabric-manager - imagePullSecrets: [] - controller: - nodeSelector: - kubernetes.io/hostname: utmist-z1opa08 - agent: - nodeSelector: - kubernetes.io/hostname: utmist-tt -tt-dra-driver: - enabled: true - imagePullSecrets: [] - kubeletPlugin: - nodeSelector: - kubernetes.io/hostname: utmist-tt - fabricManagerAgentAddress: tt-fabric-manager-agent.tt-operator-system.svc.cluster.local:50053 diff --git a/deploy/k3s/tenstorrent-workload-policy.yaml b/deploy/k3s/tenstorrent-workload-policy.yaml deleted file mode 100644 index eb14ca1..0000000 --- a/deploy/k3s/tenstorrent-workload-policy.yaml +++ /dev/null @@ -1,40 +0,0 @@ -# Applies only to the managed workload namespace. Operator DaemonSets live -# elsewhere and retain the privileges their vendor components require. -# Direct single-node probes remain allowed: device cgroups deny unclaimed -# character devices. Whole-device-directory and Docker-socket mounts are denied. -apiVersion: admissionregistration.k8s.io/v1 -kind: ValidatingAdmissionPolicy -metadata: - name: mist-accelerator-isolation -spec: - failurePolicy: Fail - matchConstraints: - resourceRules: - - apiGroups: [""] - apiVersions: [v1] - operations: [CREATE, UPDATE] - resources: [pods, pods/ephemeralcontainers] - variables: - - name: containers - expression: 'object.spec.containers + (has(object.spec.initContainers) ? object.spec.initContainers : []) + (has(object.spec.ephemeralContainers) ? object.spec.ephemeralContainers : [])' - validations: - - expression: 'variables.containers.all(c, !has(c.securityContext) || ((!has(c.securityContext.privileged) || !c.securityContext.privileged) && (!has(c.securityContext.allowPrivilegeEscalation) || !c.securityContext.allowPrivilegeEscalation) && (!has(c.securityContext.capabilities) || !has(c.securityContext.capabilities.add) || size(c.securityContext.capabilities.add) == 0)))' - message: Mist workloads cannot enable privileged access, privilege escalation, or additional Linux capabilities. - - expression: '(!has(object.spec.hostPID) || !object.spec.hostPID) && (!has(object.spec.hostIPC) || !object.spec.hostIPC)' - message: Mist workloads cannot join the host PID or IPC namespace. - - expression: '!has(object.spec.volumes) || object.spec.volumes.all(v, !has(v.hostPath) || v.hostPath.path in ["/home/utmist-tt/TT/tt-metal", "/home/utmist-tt/.local/share/uv/python", "/dev/tenstorrent/0", "/dev/tenstorrent/1", "/dev/tenstorrent/2", "/dev/tenstorrent/3"])' - message: Use DRA/CDI for devices and PVCs for storage; arbitrary host mounts are forbidden in Mist. - - expression: '!has(object.spec.volumes) || object.spec.volumes.all(v, !has(v.hostPath) || !(v.hostPath.path in ["/home/utmist-tt/TT/tt-metal", "/home/utmist-tt/.local/share/uv/python"]) || variables.containers.all(c, !has(c.volumeMounts) || c.volumeMounts.all(m, m.name != v.name || (has(m.readOnly) && m.readOnly))))' - message: The host TT-Metal and Python environment mounts must be read-only. ---- -apiVersion: admissionregistration.k8s.io/v1 -kind: ValidatingAdmissionPolicyBinding -metadata: - name: mist-accelerator-isolation -spec: - policyName: mist-accelerator-isolation - validationActions: [Deny] - matchResources: - namespaceSelector: - matchLabels: - kubernetes.io/metadata.name: mist diff --git a/deploy/k3s/training-nvidia-job.yaml b/deploy/k3s/training-nvidia-job.yaml deleted file mode 100644 index d6d3393..0000000 --- a/deploy/k3s/training-nvidia-job.yaml +++ /dev/null @@ -1,50 +0,0 @@ -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: training-nvidia-checkpoints - namespace: mist -spec: - accessModes: [ReadWriteOnce] - storageClassName: local-path - resources: - requests: - storage: 256Mi ---- -apiVersion: batch/v1 -kind: Job -metadata: - name: training-nvidia - namespace: mist -spec: - backoffLimit: 0 - activeDeadlineSeconds: 1800 - template: - spec: - nodeSelector: - kubernetes.io/hostname: utmist-z1opa08 - restartPolicy: Never - containers: - - name: train - image: pytorch/pytorch:2.5.1-cuda12.4-cudnn9-runtime - command: [python, -u, /scripts/training_smoke.py, nvidia] - resources: - requests: - cpu: "2" - memory: 2Gi - limits: - cpu: "4" - memory: 4Gi - nvidia.com/gpu: "2" - volumeMounts: - - name: script - mountPath: /scripts - readOnly: true - - name: checkpoints - mountPath: /checkpoints - volumes: - - name: script - configMap: - name: training-smoke-script - - name: checkpoints - persistentVolumeClaim: - claimName: training-nvidia-checkpoints diff --git a/deploy/k3s/training-smoke-results.json b/deploy/k3s/training-smoke-results.json deleted file mode 100644 index 6474e8f..0000000 --- a/deploy/k3s/training-smoke-results.json +++ /dev/null @@ -1,61 +0,0 @@ -{ - "date": "2026-10-03", - "test": "32x32 linear regression, 100 full-batch gradient steps", - "dataset": { - "seed": 42, - "training_samples": 128, - "validation_samples": 64 - }, - "acceptance": { - "maximum_final_to_initial_mse_ratio": 0.02, - "maximum_validation_mse": 0.0005, - "minimum_max_weight_change": 0.05, - "checkpoint_reload_mse_tolerance": 1e-07 - }, - "scope": "Independent device tests; no distributed training or performance benchmark. TT uses direct host devices, explicit TT-NN gradients, and a host-mounted Python/TT-Metal build; DRA scheduling and TT autograd are not tested.", - "tenstorrent_host_metal_commit": "80ec10ab611570a6818fdebc1398c8c9141fb9df", - "backends": { - "nvidia": { - "status": "passed", - "devices_tested": 2, - "node": "utmist-z1opa08", - "image_id": "docker.io/pytorch/pytorch@sha256:c8268a92a69bd500f8be0e665b2630ee006dadaf7bfbc24249141b15ff622755", - "container_started_at": "2026-10-03T18:26:07Z", - "container_finished_at": "2026-10-03T18:26:12Z", - "training_mse_initial": 0.3193739950656891, - "training_mse_final_range": [ - 2.018663423086764e-07, - 2.018663423086764e-07 - ], - "validation_mse_range": [ - 9.320668823420419e-07, - 9.320668823420419e-07 - ], - "checkpoint_files": 2, - "pvc": "training-nvidia-checkpoints", - "logs": "/home/utmist/k3s-training-results-2026-10-03/nvidia.log", - "checkpoint_archive": "/home/utmist/k3s-training-results-2026-10-03/nvidia-checkpoints.tar.gz" - }, - "tenstorrent": { - "status": "passed", - "devices_tested": 8, - "node": "utmist-tt", - "image_id": "ghcr.io/tenstorrent/tt-metal/tt-metalium/ubuntu-22.04-dev-amd64@sha256:14621babd1f9d1c97cc3ee9528e180a31f30cec2d6114ca281daf1a9477c7a74", - "container_started_at": "2026-10-03T18:23:05Z", - "container_finished_at": "2026-10-03T18:23:24Z", - "training_mse_initial": 0.3193739652633667, - "training_mse_final_range": [ - 6.238709465833381e-05, - 6.238709465833381e-05 - ], - "validation_mse_range": [ - 0.00017507212760392576, - 0.00017507212760392576 - ], - "checkpoint_files": 8, - "pvc": "training-tenstorrent-checkpoints", - "logs": "/home/utmist/k3s-training-results-2026-10-03/tenstorrent.log", - "checkpoint_archive": "/home/utmist/k3s-training-results-2026-10-03/tenstorrent-checkpoints.tar.gz" - } - } -} diff --git a/deploy/k3s/training-tenstorrent-job.yaml b/deploy/k3s/training-tenstorrent-job.yaml deleted file mode 100644 index 75b4f0b..0000000 --- a/deploy/k3s/training-tenstorrent-job.yaml +++ /dev/null @@ -1,106 +0,0 @@ -# One n300 board (two chips), reserved through Tenstorrent DRA/CDI. -# Host driver, TT-Metal checkout, and Python environment remain host-managed. -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: training-tenstorrent-checkpoints - namespace: mist -spec: - accessModes: [ReadWriteOnce] - storageClassName: local-path - resources: - requests: - storage: 256Mi ---- -apiVersion: resource.k8s.io/v1 -kind: ResourceClaimTemplate -metadata: - name: training-tenstorrent-board - namespace: mist -spec: - spec: - devices: - requests: - - name: board - exactly: - deviceClassName: tenstorrent.com - count: 1 - selectors: - - cel: - expression: 'device.attributes["tenstorrent.com"].boardName == "n300" && device.attributes["tenstorrent.com"].chipCount == 2' ---- -apiVersion: batch/v1 -kind: Job -metadata: - name: training-tenstorrent-dra - namespace: mist -spec: - backoffLimit: 0 - activeDeadlineSeconds: 1800 - template: - spec: - nodeSelector: - kubernetes.io/hostname: utmist-tt - restartPolicy: Never - automountServiceAccountToken: false - resourceClaims: - - name: board - resourceClaimTemplateName: training-tenstorrent-board - containers: - - name: train - image: ghcr.io/tenstorrent/tt-metal/tt-metalium/ubuntu-22.04-dev-amd64:6b765c0bc2b40ea02566d169d7b0ee902377ba04 - imagePullPolicy: IfNotPresent - workingDir: /tmp - command: [/home/utmist-tt/TT/tt-metal/python_env/bin/python, -u, /scripts/training_smoke.py, tenstorrent, --expected-devices, "2", --output, /checkpoints/dra-single] - securityContext: - privileged: false - allowPrivilegeEscalation: false - capabilities: - drop: [ALL] - env: - - name: TT_METAL_HOME - value: /home/utmist-tt/TT/tt-metal - - name: PYTHONPATH - value: /home/utmist-tt/TT/tt-metal - - name: LD_LIBRARY_PATH - value: /home/utmist-tt/TT/tt-metal/build/lib - - name: TT_METAL_CACHE - value: /tmp/tt-metal-cache - resources: - claims: - - name: board - requests: - cpu: "2" - memory: 4Gi - hugepages-1Gi: 2Gi - limits: - cpu: "8" - memory: 8Gi - hugepages-1Gi: 2Gi - volumeMounts: - - name: script - mountPath: /scripts - readOnly: true - - name: checkpoints - mountPath: /checkpoints - - name: metal - mountPath: /home/utmist-tt/TT/tt-metal - readOnly: true - - name: python - mountPath: /home/utmist-tt/.local/share/uv/python - readOnly: true - volumes: - - name: script - configMap: - name: training-smoke-script - - name: checkpoints - persistentVolumeClaim: - claimName: training-tenstorrent-checkpoints - - name: metal - hostPath: - path: /home/utmist-tt/TT/tt-metal - type: Directory - - name: python - hostPath: - path: /home/utmist-tt/.local/share/uv/python - type: Directory diff --git a/deploy/k3s/training_smoke.py b/deploy/k3s/training_smoke.py deleted file mode 100644 index a21a610..0000000 --- a/deploy/k3s/training_smoke.py +++ /dev/null @@ -1,209 +0,0 @@ -"""Small accelerator training check; TT gradients are computed explicitly in TT-NN. - -CPU Torch creates the data, records metrics, and serializes checkpoints. All -forward passes, gradients, and weight updates run on the selected accelerator. -This is a linear-regression smoke test, not a model compatibility benchmark. -""" - -import argparse -import json -import math -import os -import subprocess -import sys -import time -from datetime import datetime, timezone -from pathlib import Path - -import torch - - -def dataset(): - generator = torch.Generator().manual_seed(42) - truth = torch.randn(32, 32, generator=generator) * 0.1 - x = torch.randn(128, 32, generator=generator) - validation_x = torch.randn(64, 32, generator=generator) - return x, x @ truth, validation_x, validation_x @ truth - - -def await_start(output, start_file, metadata): - if not start_file: - return - (output / "ready.json").write_text(json.dumps({**metadata, - "ready_at": datetime.now(timezone.utc).isoformat()}) + "\n") - print(f"BARRIER_READY {json.dumps(metadata)}", flush=True) - deadline = time.monotonic() + 300 - while not start_file.exists(): - assert time.monotonic() < deadline, "Training start barrier timed out" - time.sleep(0.2) - - -def check_and_save(backend, device_id, weights, initial, final, validation, output): - change = weights.abs().max().item() - assert all(math.isfinite(v) for v in (initial, final, validation, change)) - assert final < initial * 0.02, f"Loss did not converge: {initial} -> {final}" - assert validation < 5e-4, f"Held-out MSE too high: {validation}" - assert change > 0.05, "Weights were not updated" - path = output / f"{backend}-{device_id}.pt" - torch.save({"weights": weights.cpu(), "initial_mse": initial, - "final_mse": final, "validation_mse": validation}, path) - loaded = torch.load(path, map_location="cpu", weights_only=True) - torch.testing.assert_close(loaded["weights"], weights.cpu(), rtol=0, atol=0) - result = {"backend": backend, "device_id": device_id, - "initial_mse": initial, "final_mse": final, - "validation_mse": validation, "max_weight_change": change, - "checkpoint": str(path)} - print(json.dumps(result), flush=True) - return result, loaded["weights"] - - -def nvidia(output, steps, expected_devices, start_file, board_pci): - assert torch.cuda.is_available(), "CUDA unavailable; CPU fallback is forbidden" - count = torch.cuda.device_count() - assert count == expected_devices, f"Expected {expected_devices} allocated GPUs; saw {count}" - print(f"CUDA devices={count}; torch={torch.__version__}", flush=True) - nodes = sorted(str(path) for path in Path("/dev").glob("nvidia[0-9]*")) - assert len(nodes) == count, f"Unexpected NVIDIA device nodes: {nodes}" - (output / "allocation.json").write_text(json.dumps({"device_nodes": nodes, - "gpu_count": count}, indent=2) + "\n") - results = [] - for index in range(count): - device = torch.device(f"cuda:{index}") - print(f"Training on {device}: {torch.cuda.get_device_name(index)}", flush=True) - x, y, validation_x, validation_y = (t.to(device) for t in dataset()) - model = torch.nn.Linear(32, 32, bias=False).to(device) - torch.nn.init.zeros_(model.weight) - optimizer = torch.optim.SGD(model.parameters(), lr=0.2) - if index == 0: - await_start(output, start_file, {"device_nodes": nodes, "gpu_count": count}) - started = datetime.now(timezone.utc).isoformat() - initial = (model(x) - y).square().mean().item() - for step in range(steps): - optimizer.zero_grad(set_to_none=True) - residual = model(x) - y - loss = residual.square().sum() / (2 * x.shape[0]) - loss.backward() - assert model.weight.grad.is_cuda - optimizer.step() - if step % 20 == 0: - print(f"cuda:{index} step={step} mse={residual.square().mean().item():.8f}", flush=True) - with torch.no_grad(): - final = (model(x) - y).square().mean().item() - validation = (model(validation_x) - validation_y).square().mean().item() - result, weights = check_and_save("nvidia", index, model.weight.T.detach(), - initial, final, validation, output) - restored_error = (validation_x @ weights.to(device) - validation_y).square().mean().item() - assert abs(restored_error - validation) < 1e-7 - result["checkpoint_reload_mse"] = restored_error - result["device_name"] = torch.cuda.get_device_name(index) - result["started_at"] = started - result["finished_at"] = datetime.now(timezone.utc).isoformat() - result["device_nodes"] = nodes - results.append(result) - return results - - -def tenstorrent(output, steps, expected_devices, start_file, board_pci): - nodes = sorted(str(path) for path in Path("/dev/tenstorrent").iterdir()) - - def pci_address(node): - path = Path("/sys/class/tenstorrent") / ("tenstorrent!" + Path(node).name) - assert path.exists(), f"Missing PCI identity for {node}" - return path.resolve().parents[1].name - - if board_pci: - nodes = [node for node in nodes if pci_address(node) == board_pci] - assert len(nodes) == 1, f"PCI selector does not match one allocated board: {board_pci}" - assert len(nodes) * 2 == expected_devices, f"Unexpected n300 device nodes: {nodes}" - if len(nodes) > 1: - # Arbitrary n300 subsets need not form a connected fabric mesh. - # This test trains independently, so use one runtime per board. - results = [] - for ordinal, node in enumerate(nodes): - bdf = pci_address(node) - board_output = output / ("board-" + Path(node).name) - command = [sys.executable, __file__, "tenstorrent", "--steps", str(steps), - "--expected-devices", "2", "--output", str(board_output), "--board-pci", bdf] - if start_file: - command += ["--start-file", str(start_file)] - print(f"Training reserved board {node}, PCI {bdf}", flush=True) - subprocess.run(command, env={**os.environ, "TT_VISIBLE_DEVICES": bdf}, check=True) - for result in json.loads((board_output / "results.json").read_text()): - result["board_local_device_id"] = result["device_id"] - result["device_id"] = ordinal * 2 + result["device_id"] - result["board_pci"] = bdf - results.append(result) - assert len(results) == expected_devices - (output / "allocation.json").write_text(json.dumps({"device_nodes": nodes, - "chip_count": expected_devices}, indent=2) + "\n") - return results - - import ttnn - - count = ttnn.get_num_devices() - assert count == expected_devices, f"Expected {expected_devices} allocated chips; saw {count}" - (output / "allocation.json").write_text(json.dumps({"device_nodes": nodes, - "chip_count": count}, indent=2) + "\n") - print(f"Tenstorrent devices={count}; torch used for data/metrics={torch.__version__}", flush=True) - results = [] - for index in range(count): - print(f"Opening Tenstorrent device {index}", flush=True) - device = ttnn.open_device(device_id=index) - try: - if index == 0 and start_file: - await_start(output, start_file, {"device_nodes": nodes, "chip_count": count}) - started = datetime.now(timezone.utc).isoformat() - x, y, validation_x, validation_y = dataset() - options = dict(dtype=ttnn.bfloat16, layout=ttnn.TILE_LAYOUT, - device=device, memory_config=ttnn.DRAM_MEMORY_CONFIG) - dx, dy, dvx = (ttnn.from_torch(t, **options) for t in (x, y, validation_x)) - dxt = ttnn.from_torch(x.T.contiguous(), **options) - weights = ttnn.from_torch(torch.zeros(32, 32), **options) - - def mse(inputs, target, matrix): - prediction = ttnn.to_torch(ttnn.matmul(inputs, matrix)).float() - return (prediction - target).square().mean().item() - - initial = mse(dx, y, weights) - for step in range(steps): - prediction = ttnn.matmul(dx, weights) - residual = ttnn.subtract(prediction, dy) - # Gradient of ||XW-Y||^2/(2N) is X.T@(XW-Y)/N. - gradient = ttnn.matmul(dxt, residual) - weights = ttnn.subtract(weights, ttnn.multiply(gradient, 0.2 / x.shape[0])) - if step % 20 == 0: - print(f"tt:{index} step={step} mse={mse(dx, y, weights):.8f}", flush=True) - final = mse(dx, y, weights) - validation = mse(dvx, validation_y, weights) - host_weights = ttnn.to_torch(weights).float() - result, loaded = check_and_save("tenstorrent", index, host_weights, - initial, final, validation, output) - restored = ttnn.from_torch(loaded, **options) - restored_error = mse(dvx, validation_y, restored) - assert abs(restored_error - validation) < 1e-7 - result["checkpoint_reload_mse"] = restored_error - result["started_at"] = started - result["finished_at"] = datetime.now(timezone.utc).isoformat() - result["device_nodes"] = nodes - results.append(result) - finally: - ttnn.close_device(device) - return results - - -if __name__ == "__main__": - parser = argparse.ArgumentParser() - parser.add_argument("backend", choices=("nvidia", "tenstorrent")) - parser.add_argument("--steps", type=int, default=100) - parser.add_argument("--output", type=Path, default=Path("/checkpoints")) - parser.add_argument("--expected-devices", type=int, - help="Expected allocated chip/GPU count; defaults to the original whole-node test") - parser.add_argument("--start-file", type=Path, - help="Optional shared-file barrier for the concurrent allocation test") - parser.add_argument("--board-pci", help=argparse.SUPPRESS) - args = parser.parse_args() - args.output.mkdir(parents=True, exist_ok=True) - expected = args.expected_devices or (2 if args.backend == "nvidia" else 8) - results = globals()[args.backend](args.output, args.steps, expected, args.start_file, args.board_pci) - (args.output / "results.json").write_text(json.dumps(results, indent=2) + "\n") - print(f"TRAINING_PASSED backend={args.backend} devices={len(results)}", flush=True) diff --git a/deploy/k3s/verify_department.mjs b/deploy/k3s/verify_department.mjs deleted file mode 100644 index 06ba8e7..0000000 --- a/deploy/k3s/verify_department.mjs +++ /dev/null @@ -1,562 +0,0 @@ -// Run from the repo root. Playwright is supplied externally; credentials and -// cookies stay in a protected local state file, never in the public report. -import { - readFileSync, - writeFileSync, - mkdirSync, - existsSync, - chmodSync, -} from "node:fs"; -import { randomBytes } from "node:crypto"; -import { execFileSync } from "node:child_process"; -const { chromium } = await import( - process.env.MIST_PLAYWRIGHT_MODULE ?? - "/tmp/mist-browser-check/node_modules/playwright/index.mjs" -); -const base = process.env.MIST_SITE_URL ?? "http://100.73.139.66:8088"; -const out = - process.env.MIST_EVIDENCE_DIR ?? - "/home/utmist/mist-department-results-2026-10-09"; -mkdirSync(out, { recursive: true, mode: 0o700 }); -const statePath = out + "/private-state.json"; -const credentials = JSON.parse( - readFileSync("/home/utmist/.config/mist/admin-bootstrap.json"), -); -const browser = await chromium.launch({ headless: true }); -const admin = await browser.newContext({ - baseURL: base, - viewport: { width: 1440, height: 1000 }, -}); -const errors = []; -const state = existsSync(statePath) ? JSON.parse(readFileSync(statePath)) : {}; -const report = { - started: new Date().toISOString(), - site: base, - checks: {}, - jobs: [], - teams: [], -}; -function save() { - writeFileSync(statePath, JSON.stringify(state, null, 2), { mode: 0o600 }); - chmodSync(statePath, 0o600); -} -function check(ok, message) { - if (!ok) throw new Error(message); -} -function passed(name) { - report.checks[name] = true; - writeFileSync(out + "/results.json", JSON.stringify(report, null, 2)); - console.log("PASS " + name); -} -async function call( - ctx, - path, - method = "GET", - data, - team = "", - expected = 200, -) { - let r; - for (let attempt = 0; attempt < (method === "GET" ? 4 : 1); attempt++) { - try { - r = await ctx.request.fetch(base + path, { - method, - data, - timeout: 15000, - headers: { Origin: base, ...(team ? { "X-Mist-Team": team } : {}) }, - }); - if (method === "GET" && [502, 503].includes(r.status()) && attempt < 3) { - await new Promise((resolve) => setTimeout(resolve, 1500)); - continue; - } - break; - } catch { - if (method === "GET" && attempt < 3) { - await new Promise((resolve) => setTimeout(resolve, 1500)); - continue; - } - throw new Error( - `${method} ${path}: transport interrupted; no credentials logged`, - ); - } - } - - check( - r.status() === expected, - `${method} ${path} expected ${expected}, got ${r.status()}: ${(await r.text()).slice(0, 1500)}`, - ); - return path.includes("/files/download") - ? r.body() - : r.headers()["content-type"]?.includes("json") - ? r.json() - : r.body(); -} -async function pick(target, label, optionsOrValue, maybeValue) { - const value = maybeValue === undefined ? optionsOrValue : maybeValue - await target.getByLabel(label, {exact:true}).click() - await target.locator('[role=option][data-value='+JSON.stringify(String(value))+']').click() -} -async function login(ctx, email, password) { - await call(ctx, "/auth/sign-in/email", "POST", { email, password }); -} -async function waitReady(team, ctx = admin) { - for (let i = 0; i < 60; i++) { - const r = await ctx.request.get(base + "/api/storage", { - headers: { "X-Mist-Team": team }, - }); - if (r.ok()) return r.json(); - await new Promise((r) => setTimeout(r, 1000)); - } - throw new Error("Team storage never ready " + team); -} -async function submit(ctx, team, name, data = {}) { - const result = await call( - ctx, - "/api/jobs", - "POST", - { - name, - type: "command", - accelerator: "cpu", - command: ["python", "-c", "print('JOB_PASSED',flush=True)"], - timeout_seconds: 180, - ...data, - }, - team, - 201, - ); - report.jobs.push({ id: result.job_id, name, team }); - return result.job_id; -} -async function waitJob(ctx, team, id, want = "Success", seconds = 600) { - for (let i = 0; i < seconds; i += 2) { - const j = await call(ctx, "/api/jobs/" + id, "GET", undefined, team); - if (j.job_state === want) return j; - if (["Success", "Failure", "Cancelled"].includes(j.job_state)) { - const logs = await call( - ctx, - "/api/jobs/" + id + "/logs", - "GET", - undefined, - team, - ); - throw new Error( - `${id} ${j.job_state}: ${JSON.stringify(logs).slice(-3000)}`, - ); - } - await new Promise((r) => setTimeout(r, 2000)); - } - throw new Error("Job timed out " + id); -} -async function terminalJob(ctx, team, id) { - const j = await waitJob(ctx, team, id); - const logs = await call( - ctx, - "/api/jobs/" + id + "/logs", - "GET", - undefined, - team, - ); - writeFileSync(out + "/" + id + ".log", logs.logs); - return { j, logs: logs.logs }; -} -async function upload(ctx, team, name, text, scope) { - return call( - ctx, - `/api/datasets?filename=${encodeURIComponent(name)}${scope ? "&scope=" + scope : ""}`, - "POST", - Buffer.from(text), - team, - 201, - ); -} -const kube = (...args) => - execFileSync("k3s", ["kubectl", ...args], { - env: { ...process.env, KUBECONFIG: "/home/utmist/.kube/config" }, - encoding: "utf8", - }); -try { - await login(admin, credentials.email, credentials.password); - const account = (await call(admin, "/api/session")).user; - state.adminID = account.id; - const legacyBefore = await call(admin, "/api/jobs"); - report.legacy_job_count_before = legacyBefore.count; - if (!state.a) { - state.a = await call( - admin, - "/api/teams", - "POST", - { name: "Research pilot", storage_gib: 1 }, - "", - 201, - ); - save(); - } - if (!state.b) { - state.b = await call( - admin, - "/api/teams", - "POST", - { name: "Verification isolation", storage_gib: 1 }, - "", - 201, - ); - save(); - } - const A = state.a.id, - B = state.b.id; - report.teams = [A, B]; - // Only unfinished fixtures created by this verifier are cancelled on rerun. - for (const team of [A, B]) { - const jobs = JSON.parse( - kube("get", "jobs", "-n", "mist-" + team, "-o", "json"), - ).items; - for (const job of jobs) { - if ( - job.metadata.annotations?.["mist.io/cancelled-at"] || - job.status?.conditions?.some( - (c) => ["Complete", "Failed"].includes(c.type) && c.status === "True", - ) - ) - continue; - kube( - "patch", - "job", - job.metadata.name, - "-n", - job.metadata.namespace, - "--type=merge", - "-p", - JSON.stringify({ - spec: { suspend: true }, - metadata: { - annotations: { - "mist.io/cancelled-at": new Date().toISOString(), - "mist.io/stop-reason": "Previous verification fixture cancelled", - }, - }, - }), - ); - } - } - - await call( - admin, - `/api/teams/${A}`, - "PATCH", - { disabled: false, policy: { ...state.a.policy, concurrent: 1 } }, - "", - ); - await call(admin, `/api/teams/${B}`, "PATCH", { disabled: false }, ""); - await call(admin, `/api/teams/${A}/members`, "POST", { - user_id: account.id, - common_writer: true, - }); - for (const [key, name] of [ - ["alice", "Researcher A"], - ["mate", "Teammate A"], - ["bob", "Researcher B"], - ]) { - if (!state[key]) { - const password = randomBytes(20).toString("base64url"), - email = `${key}-${Date.now()}@mist.invalid`; - const result = await call(admin, "/auth/admin/create-user", "POST", { - name, - email, - password, - role: "user", - }); - state[key] = { id: result.user.id, name, email, password }; - save(); - } - await call(admin, `/api/teams/${key === "bob" ? B : A}/members`, "POST", { - user_id: state[key].id, - common_writer: false, - }); - } - const alice = await browser.newContext({ baseURL: base }), - mate = await browser.newContext({ baseURL: base }), - bob = await browser.newContext({ baseURL: base }); - await Promise.all([ - login(alice, state.alice.email, state.alice.password), - login(mate, state.mate.email, state.mate.password), - login(bob, state.bob.email, state.bob.password), - ]); - await Promise.all([waitReady(A), waitReady(B, bob)]); - passed("team_filesystems_ready"); - const capacity = await call(admin, "/api/storage", "GET", undefined, A); - check( - capacity.capacity_bytes < 1.1 * 1024 ** 3, - "Team sees parent disk capacity", - ); - report.storage = capacity; - const ds = await upload(alice, A, "member.txt", "TEAM_A_MEMBER_DATA"); - const common = await upload( - admin, - A, - "common.txt", - "TEAM_A_COMMON_DATA", - "common", - ); - check( - (await call(mate, "/api/datasets", "GET", undefined, A)).datasets.some( - (d) => d.id === ds.id, - ), - "Teammate cannot read member data", - ); - await call(mate, `/api/datasets/${ds.id}`, "DELETE", undefined, A, 403); - await call( - alice, - "/api/datasets?filename=no.txt&scope=common", - "POST", - Buffer.from("bad"), - A, - 403, - ); - await call(bob, `/api/datasets/${ds.id}`, "GET", undefined, B, 404); - await call(bob, "/api/datasets", "GET", undefined, A, 403); - await call(bob, "/api/teams", "POST", { name: "unauthorized" }, "", 403); - passed("team_read_write_and_cross_team_denial"); - await call(admin, `/api/teams/${A}/grants`, "POST", { - target_team: B, - scope: state.alice.id, - }); - const grant = (await call(admin, "/api/teams")).teams - .find((t) => t.id === A) - .grants.find((g) => g.scope === state.alice.id && g.target_team === B); - const shared = await call( - bob, - `/api/datasets/${ds.id}/files/download?path=member.txt`, - "GET", - undefined, - B, - ); - check(shared.toString() === "TEAM_A_MEMBER_DATA", "Shared download mismatch"); - await call(bob, `/api/datasets/${common.id}`, "GET", undefined, B, 404); - await call(bob, `/api/datasets/${ds.id}`, "DELETE", undefined, B, 403); - const sharedID = await submit(bob, B, "Read-only shared input", { - dataset_id: ds.id, - script: - "from pathlib import Path\nassert Path('/inputs/member.txt').read_text()=='TEAM_A_MEMBER_DATA'\ntry:\n Path('/inputs/bad').write_text('bad')\n raise AssertionError('writable shared input')\nexcept OSError: pass\nPath('/outputs/shared.json').write_text('ok')\nprint('SHARED_INPUT_PASSED',flush=True)", - command: undefined, - }); - await terminalJob(bob, B, sharedID); - const revokedID = await submit(bob, B, "Revoke shared input", { - dataset_id: ds.id, - command: ["python", "-c", "import time; time.sleep(180)"], - }); - await waitJob(bob, B, revokedID, "InProgress", 90); - await call(admin, `/api/teams/${A}/grants/${grant.id}`, "DELETE"); - await waitJob(bob, B, revokedID, "Cancelled", 60); - await call(bob, `/api/datasets/${ds.id}/files`, "GET", undefined, B, 404); - passed("scoped_readonly_sharing_and_running_job_revocation"); - // Durable FIFO queue survives an API/controller rollout, and teams each get a turn. - const running = await submit(alice, A, "Queue holds capacity", { - command: [ - "python", - "-c", - 'import time; print("RUNNING",flush=True); time.sleep(180)', - ], - }); - await waitJob(alice, A, running, "InProgress", 90); - const queued = await submit(alice, A, "Queue survives restart"); - const other = await submit(bob, B, "Other team gets a turn"); - await terminalJob(bob, B, other); - check( - (await call(alice, "/api/jobs/" + queued, "GET", undefined, A)) - .job_state === "Scheduled", - "Team concurrency bypassed", - ); - kube("rollout", "restart", "deployment/mist-api", "-n", "mist-system"); - kube( - "rollout", - "status", - "deployment/mist-api", - "-n", - "mist-system", - "--timeout=120s", - ); - check( - (await call(alice, "/api/jobs/" + queued, "GET", undefined, A)) - .job_state === "Scheduled", - "Queue lost on restart", - ); - await call(alice, `/api/jobs/${running}/cancel`, "POST", undefined, A); - await terminalJob(alice, A, queued); - passed("durable_fifo_team_concurrency_fair_turns_and_cancel"); - for (const body of [ - { cpu: "9" }, - { memory: "64Gi" }, - { device_count: 3, accelerator: "nvidia" }, - { timeout_seconds: 86401 }, - { image: "evil.invalid/app:v1" }, - { storage_scope: state.mate.id }, - ]) { - await call( - alice, - "/api/jobs", - "POST", - { command: ["true"], ...body }, - A, - body.storage_scope ? 403 : 400, - ); - } - await call(alice, "/api/jobs", "POST", { command: ["true"] }, "", 403); - passed("policy_limits_and_legacy_bypass_denied"); - // Network probes run inside an ordinary job, under the actual k3s policy. - const isolation = await submit(alice, A, "Workload network isolation", { - script: `import socket,os\nfrom pathlib import Path\nassert not Path('/var/run/secrets/kubernetes.io/serviceaccount/token').exists()\nfor host,port in [('10.43.1.116',3000),('10.0.0.112',2049),('10.0.0.175',6443),('100.95.175.37',22)]:\n s=socket.socket();s.settimeout(2)\n try:\n s.connect((host,port))\n raise AssertionError('private network reachable '+host)\n except (TimeoutError,ConnectionRefusedError,OSError): pass\n finally: s.close()\nassert socket.gethostbyname('github.com')\nPath('/outputs/isolation.txt').write_text('private network and credentials denied')\nprint('ISOLATION_PASSED',flush=True)`, - command: undefined, - }); - check( - (await terminalJob(alice, A, isolation)).logs.includes("ISOLATION_PASSED"), - "Missing isolation marker", - ); - passed("live_container_private_network_and_credentials_denied"); - // The full-volume writer cleans its own test file; it never touches real data. - const quota = await submit(bob, B, "Filesystem capacity enforcement", { - script: `import errno,json\nfrom pathlib import Path\np=Path('/outputs/quota-probe.bin');size=0\ntry:\n with p.open('wb',buffering=0) as f:\n for i in range(2048):\n f.write(b'x'*1048576);size+=1048576\n raise AssertionError('2GiB fit in a 1GiB team filesystem')\nexcept OSError as e:\n assert e.errno in (errno.ENOSPC,errno.EDQUOT),e\n print('STORAGE_QUOTA_PASSED bytes='+str(size),flush=True)\nfinally:\n p.unlink(missing_ok=True)\nPath('/outputs/quota.json').write_text(json.dumps({'bytes_before_limit':size}))`, - command: undefined, - }); - check( - (await terminalJob(bob, B, quota)).logs.includes("STORAGE_QUOTA_PASSED"), - "Quota not enforced", - ); - passed("training_writes_obey_filesystem_capacity"); - const matrix = (n, m, p) => - Array.from({ length: n }, (_, i) => - Array.from({ length: m }, (_, j) => Math.sin((i + 1) * (j + 2) + p)), - ); - const multiply = (x, w) => - x.map((row) => - w[0].map((_, k) => row.reduce((sum, v, j) => sum + v * w[j][k], 0)), - ); - const x = matrix(128, 32, 0.1), - vx = matrix(64, 32, 1.2), - truth = matrix(32, 32, 2.3).map((row) => row.map((v) => v * 0.1)); - const regression = await upload( - admin, - A, - "regression.json", - JSON.stringify({ - x, - y: multiply(x, truth), - validation_x: vx, - validation_y: multiply(vx, truth), - }), - "common", - ); - state.dataset = regression; - save(); - const training = readFileSync("deploy/k3s/examples/train_shared.py", "utf8"); - for (const backend of ["nvidia", "tenstorrent"]) { - const script = training.replace( - "args = parser.parse_args()", - `args = parser.parse_args(['${backend}','--expected-devices','2','--output','/outputs'])`, - ); - const id = await submit(admin, A, "Department training " + backend, { - accelerator: backend, - device_count: backend === "nvidia" ? 2 : 1, - script, - command: undefined, - dataset_id: regression.id, - storage_scope: "common", - timeout_seconds: 600, - }); - const { j, logs } = await terminalJob(admin, A, id); - check( - logs.includes("TRAINING_PASSED") && - logs.includes("DATASET_LOADED file=/inputs/regression.json"), - "Training did not use uploaded data", - ); - const files = ( - await call(admin, `/api/jobs/${id}/files`, "GET", undefined, A) - ).files; - check(files.length > 0, "No persisted model outputs"); - for (const f of files) { - const data = await call( - admin, - `/api/jobs/${id}/files/download?path=${encodeURIComponent(f.path)}`, - "GET", - undefined, - A, - ); - check(data.length === f.size, "Downloaded model size mismatch"); - writeFileSync(out + `/${backend}-${f.path.replaceAll("/", "_")}`, data); - } - report.jobs.find((e) => e.id === id).node = j.node; - passed(backend + "_real_training_uploaded_data_and_downloaded_weights"); - } - const page = await admin.newPage(); - page.on("pageerror", (e) => errors.push(e.message)); - await page.goto("/teams"); - await page.getByRole("heading", { name: "Teams", exact: true }).waitFor(); - await pick(page, "Workspace", { exact: true }, A); - await page.screenshot({ path: out + "/teams-admin.png", fullPage: true }); - await page.goto("/jobs"); - await page - .getByRole("heading", { name: "Job history", exact: true }) - .waitFor(); - await page - .getByRole("button", { name: /Show details for Department training/ }) - .first() - .click(); - await page - .getByRole("button", { name: "Files", exact: true }) - .first() - .click(); - await page.getByRole("heading", { name: "Saved files" }).waitFor(); - await page.screenshot({ path: out + "/jobs-expanded.png", fullPage: true }); - passed("browser_rows_expand_and_saved_files_open"); - await page - .getByRole("button", { name: /Hide details for Department training/ }) - .first() - .click(); - await page.screenshot({ path: out + "/jobs-rows.png", fullPage: true }); - await page.goto("/datasets"); - await page.getByRole("heading", { name: /Team folders/ }).waitFor(); - await page.screenshot({ path: out + "/team-folders.png", fullPage: true }); - passed("browser_admin_workspace_and_shared_folders"); - const before = state.a.policy.storage_gib; - await call( - admin, - `/api/teams/${A}`, - "PATCH", - { policy: { ...state.a.policy, storage_gib: before + 1 } }, - "", - ); - state.a.policy.storage_gib = before + 1; - save(); - const grown = await waitReady(A); - check( - grown.capacity_bytes > capacity.capacity_bytes, - "Storage growth did not resize filesystem", - ); - passed("online_team_storage_growth"); - await call( - admin, - `/api/teams/${A}`, - "PATCH", - { policy: { ...state.a.policy, storage_gib: 1 } }, - "", - 400, - ); - await call(admin, `/api/teams/${B}`, "PATCH", { disabled: true }); - for (const member of [state.alice, state.mate]) - await call(admin, `/api/teams/${A}/members/${member.id}`, "DELETE"); - const legacyAfter = await call(admin, "/api/jobs"); - check(legacyAfter.count === legacyBefore.count, "Legacy job history changed"); - passed("legacy_history_preserved"); - check(errors.length === 0, errors.join("; ")); - report.browser_errors = errors; - report.finished = new Date().toISOString(); - writeFileSync(out + "/results.json", JSON.stringify(report, null, 2)); - console.log(JSON.stringify(report, null, 2)); -} catch (e) { - report.error = e.message; - writeFileSync(out + "/results.json", JSON.stringify(report, null, 2)); - throw e; -} finally { - await browser.close(); -} - diff --git a/deploy/k3s/verify_department_admission.py b/deploy/k3s/verify_department_admission.py deleted file mode 100644 index 49c1f89..0000000 --- a/deploy/k3s/verify_department_admission.py +++ /dev/null @@ -1,24 +0,0 @@ -# Verification for this recorded two-machine pilot; never prints credentials. -import copy,json,os,pathlib,subprocess -out=pathlib.Path('/home/utmist/mist-department-results-2026-10-09') -env={**os.environ,'KUBECONFIG':'/home/utmist/.kube/config'} -ns='mist-team-db3933031a6c2437' -def k(*args, data=None):return subprocess.run(['k3s','kubectl',*args],input=data,text=True,capture_output=True,env=env) -jobs=json.loads(k('get','jobs','-n',ns,'-o','json').stdout)['items'] -source=next(j for j in jobs if j['metadata']['name']=='mist-7d426bc89696c01e19474c00') -base={'apiVersion':'v1','kind':'Pod','metadata':{'name':'admission-probe','namespace':ns},'spec':source['spec']['template']['spec']} -probes={ -'host_network':lambda p:p['spec'].update(hostNetwork=True), -'host_pid':lambda p:p['spec'].update(hostPID=True), -'service_account_token':lambda p:p['spec'].update(automountServiceAccountToken=True), -'privileged_container':lambda p:p['spec']['containers'][0]['securityContext'].update(privileged=True,allowPrivilegeEscalation=True), -'root_host_path':lambda p:p['spec']['volumes'].append({'name':'forbidden','hostPath':{'path':'/'}}), -'capability_add':lambda p:p['spec']['containers'][0]['securityContext']['capabilities'].update(add=['SYS_ADMIN'])} -report={} -for name,edit in probes.items(): - p=copy.deepcopy(base);edit(p) - r=k('create','--dry-run=server','-f','-',data=json.dumps(p)) - assert r.returncode and 'mist-team-boundary' in r.stderr,(name,r.stderr) - report[name]={'denied':True,'reason':r.stderr.strip()} - print('PASS ADMISSION_DENIED_'+name) -(out/'admission-results.json').write_text(json.dumps(report,indent=2)+'\n') diff --git a/deploy/k3s/verify_department_browser.mjs b/deploy/k3s/verify_department_browser.mjs deleted file mode 100644 index bc3c5ec..0000000 --- a/deploy/k3s/verify_department_browser.mjs +++ /dev/null @@ -1,419 +0,0 @@ -// Real admin/researcher browser flow. Keep passwords only in protected state. -import { - readFileSync, - writeFileSync, - mkdirSync, - existsSync, - chmodSync, -} from "node:fs"; -import { randomBytes } from "node:crypto"; -const { chromium } = await import( - process.env.MIST_PLAYWRIGHT_MODULE ?? - "/tmp/mist-browser-check/node_modules/playwright/index.mjs" -); -const base = process.env.MIST_SITE_URL ?? "http://100.73.139.66:8088"; -const out = - process.env.MIST_EVIDENCE_DIR ?? - "/home/utmist/mist-department-results-2026-10-09"; -mkdirSync(out, { recursive: true, mode: 0o700 }); -const creds = JSON.parse( - readFileSync("/home/utmist/.config/mist/admin-bootstrap.json"), -); -const file = out + "/browser-private-state.json"; -const state = existsSync(file) ? JSON.parse(readFileSync(file)) : {}; -const report = { - checks: {}, - browser_errors: [], - started: new Date().toISOString(), -}; -function save() { - writeFileSync(file, JSON.stringify(state, null, 2), { mode: 0o600 }); - chmodSync(file, 0o600); -} -function check(ok, message) { - if (!ok) throw new Error(message); -} -function pass(name) { - report.checks[name] = true; - writeFileSync(out + "/browser-results.json", JSON.stringify(report, null, 2)); - console.log("PASS " + name); -} -const browser = await chromium.launch({ headless: true }); -const ctx = await browser.newContext({ - baseURL: base, - viewport: { width: 1600, height: 1100 }, -}); -const page = await ctx.newPage(); -page.on("pageerror", (e) => report.browser_errors.push(e.message)); -async function pick(target, label, optionsOrValue, maybeValue) { - const value = maybeValue === undefined ? optionsOrValue : maybeValue; - await target.getByLabel(label, { exact: true }).click(); - await target - .locator("[role=option][data-value=" + JSON.stringify(String(value)) + "]") - .click(); -} -async function login(target, email, password) { - await target.goto("/jobs"); - await target.getByLabel("Email", { exact: true }).fill(email); - await target.getByLabel("Password", { exact: true }).fill(password); - await target.getByRole("button", { name: "Sign in", exact: true }).click(); - await target.getByRole("heading", { name: "Jobs", exact: true }).waitFor(); -} -async function api(context, path, method = "GET", data, team, expected = 200) { - let r; - try { - r = await context.request.fetch(base + path, { - method, - data, - headers: { Origin: base, ...(team ? { "X-Mist-Team": team } : {}) }, - }); - } catch { - throw new Error( - method + " " + path + " transport failure; no credentials logged", - ); - } - check( - r.status() === expected, - `${path}: expected ${expected}, got ${r.status()}`, - ); - return r.headers()["content-type"]?.includes("json") ? r.json() : r.body(); -} -try { - await login(page, creds.email, creds.password); - pass("admin_browser_login"); - const account = (await api(ctx, "/api/session")).user; - if (state.team && process.env.MIST_VERIFY_NEW_TEAM === "1") { - await api(ctx, `/api/teams/${state.team.id}`, "PATCH", { - disabled: true, - name: "Previous browser verification", - }); - delete state.team; - save(); - } - if (state.member && process.env.MIST_VERIFY_NEW_MEMBER === "1") { - delete state.member; - save(); - } - if (!state.member) { - const m = { - name: "Browser verification researcher", - email: `browser-${Date.now()}@mist.invalid`, - password: randomBytes(20).toString("base64url"), - }; - await page.goto("/profile"); - await page.getByRole("tab", { name: "Members", exact: true }).click(); - await page.getByRole("button", { name: "New member", exact: true }).click(); - await page.getByLabel("Name", { exact: true }).fill(m.name); - await page.getByLabel("Email", { exact: true }).fill(m.email); - await page.getByLabel("Initial password").fill(m.password); - const response = page.waitForResponse((r) => - r.url().endsWith("/auth/admin/create-user"), - ); - await page.getByRole("button", { name: "Add member", exact: true }).click(); - const created = await response; - check(created.ok(), "UI account creation failed"); - m.id = (await created.json()).user.id; - state.member = m; - save(); - } - pass("browser_account_creation"); - if (!state.team) { - await page.goto("/teams"); - await page.getByRole("button", { name: "New team", exact: true }).click(); - await page.getByLabel("New team name").fill("Browser verification"); - await page.getByLabel("Dataset storage (GiB)", { exact: true }).fill("1"); - await page.getByLabel("Model storage (GiB)", { exact: true }).fill("1"); - const response = page.waitForResponse( - (r) => r.url().endsWith("/api/teams") && r.request().method() === "POST", - ); - await page - .getByRole("button", { name: "Create team", exact: true }) - .click(); - const created = await response; - check(created.ok(), "UI team creation failed"); - state.team = await created.json(); - save(); - } - const team = state.team.id; - await api(ctx, `/api/teams/${team}`, "PATCH", { disabled: false }); - await api(ctx, `/api/teams/${team}/members`, "POST", { - user_id: account.id, - common_writer: true, - }); - await page.goto("/teams"); - await pick(page, "Workspace", { exact: true }, team); - await page - .getByRole("button", { name: "Manage Browser verification", exact: true }) - .click(); - const section = page.getByRole("dialog", { - name: "Team settings", - exact: true, - }); - await pick(section, "Existing Mist account", state.member.id); - await section.getByLabel("Can write common folder").check(); - await section - .getByRole("button", { name: "Add/update teammate", exact: true }) - .click(); - await section.locator("li").filter({ hasText: state.member.email }).waitFor(); - await page.getByRole("tab", { name: "Limits", exact: true }).click(); - await section.getByLabel("Total CPU cores").fill("4"); - await section.getByLabel("Total memory (for example 16Gi)").fill("8Gi"); - await section - .getByLabel("Maximum job runtime (seconds)", { exact: true }) - .fill("120"); - await section - .getByRole("button", { name: "Save limits", exact: true }) - .click(); - await section - .getByRole("status") - .filter({ hasText: "Team limits saved." }) - .waitFor(); - pass("browser_team_creation_membership_common_write_and_policy"); - await page - .getByRole("button", { name: "Close Team settings", exact: true }) - .click(); - await page.goto("/datasets"); - await page.getByRole("button", { name: "Add dataset", exact: true }).click(); - await page - .getByLabel("Dataset name (optional)") - .fill("Browser-uploaded dataset"); - await pick(page, "Upload folder", "common"); - await page.getByLabel("File or ZIP archive").setInputFiles({ - name: "ui.txt", - mimeType: "text/plain", - buffer: Buffer.from("BROWSER_UPLOADED_DATA"), - }); - await page - .getByRole("button", { name: "Upload dataset", exact: true }) - .click({ timeout: 120000 }); - await page - .getByRole("status") - .filter({ hasText: "is ready to use" }) - .waitFor({ timeout: 60000 }); - const ds = ( - await api(ctx, "/api/datasets", "GET", undefined, team) - ).datasets.find((d) => d.name === "Browser-uploaded dataset"); - check(ds, "Browser dataset missing"); - state.dataset = ds; - save(); - await page.locator(".mist-folder-details > summary").click(); - await pick(page, "Browse common or teammate files", team + ":common"); - await page - .getByRole("link", { name: "Download", exact: true }) - .first() - .waitFor(); - const downloaded = page.waitForEvent("download"); - await page - .getByRole("link", { name: "Download", exact: true }) - .first() - .click(); - check( - (await (await downloaded).failure()) === null, - "Browser dataset download failed", - ); - await page.screenshot({ - path: out + "/final-datasets-desktop.png", - fullPage: true, - }); - pass("browser_upload_common_folder_browse_and_download"); - const memberCtx = await browser.newContext({ - baseURL: base, - viewport: { width: 1440, height: 1000 }, - }), - member = await memberCtx.newPage(); - member.on("pageerror", (e) => report.browser_errors.push(e.message)); - await login(member, state.member.email, state.member.password); - await pick(member, "Workspace", { exact: true }, team); - await member.getByRole("button", { name: "New job", exact: true }).click(); - const jobName = "Browser container experiment " + Date.now(); - await member.waitForFunction( - () => document.querySelector("#job-timeout")?.value === "120", - ); - pass("job_defaults_follow_loaded_team_policy"); - await member.getByLabel("Name", { exact: true }).fill(jobName); - await member.getByLabel("Container image").fill("python:3.11-slim"); - await pick(member, "Dataset (optional)", ds.id); - await member - .getByLabel("Python script", { exact: true }) - .fill( - "from pathlib import Path\nassert Path('/inputs/ui.txt').read_text()=='BROWSER_UPLOADED_DATA'\nPath('/outputs/browser-result.txt').write_text('BROWSER_JOB_PASSED')\nprint('BROWSER_JOB_PASSED',flush=True)\n", - ); - await member.getByText("Resources and environment", { exact: true }).click(); - await member.getByLabel("Deadline (seconds)").fill("120"); - const submission = member.waitForResponse( - (r) => r.url().endsWith("/api/jobs") && r.request().method() === "POST", - ); - await member.getByRole("button", { name: "Submit job", exact: true }).click(); - const submitted = await submission; - check(submitted.ok(), "Browser job submission failed: " + submitted.status() + " " + (submitted.ok() ? "" : await submitted.text())); - const id = (await submitted.json()).job_id; - report.job_id = id; - for (let i = 0; i < 100; i++) { - const j = await api(memberCtx, `/api/jobs/${id}`, "GET", undefined, team); - if (j.job_state === "Success") break; - check( - !["Failure", "Cancelled"].includes(j.job_state), - "Browser container experiment failed", - ); - await new Promise((r) => setTimeout(r, 2000)); - } - check( - (await api(memberCtx, `/api/jobs/${id}`, "GET", undefined, team)) - .job_state === "Success", - "Browser job did not finish", - ); - await member - .getByRole("button", { name: "Show details for " + jobName, exact: true }) - .click(); - await member.getByRole("button", { name: "Logs", exact: true }).click(); - await member.getByText("BROWSER_JOB_PASSED", { exact: false }).waitFor(); - await member.getByRole("button", { name: "Files", exact: true }).click(); - const output = member.waitForEvent("download"); - await member - .getByRole("link", { name: "browser-result.txt", exact: true }) - .click(); - await (await output).saveAs(out + "/browser-result.txt"); - pass("regular_browser_selfservice_image_submit_expand_logs_result_download"); - // A regular account has no administrator controls and cannot reset accounts. - await member.goto("/teams"); - check( - (await member - .getByRole("button", { name: "Create team", exact: true }) - .count()) === 0, - "Regular user sees team administration", - ); - await api( - memberCtx, - "/auth/admin/set-user-password", - "POST", - { userId: account.id, newPassword: randomBytes(20).toString("base64url") }, - undefined, - 403, - ); - pass("regular_browser_admin_controls_denied"); - // Admin resets a test account's password in the same portal. - await page.goto("/profile"); - await page.getByRole("tab", { name: "Members", exact: true }).click(); - await page - .getByRole("button", { name: "Reset password", exact: true }) - .click(); - await pick(page, "Account to reset", state.member.id); - const next = randomBytes(20).toString("base64url"); - await page.getByLabel("New temporary password").fill(next); - await page - .getByRole("button", { - name: "Reset password and sign out sessions", - exact: true, - }) - .click(); - await page - .getByRole("status") - .filter({ hasText: "Password reset and existing sessions signed out." }) - .waitFor(); - await api(memberCtx, "/api/jobs", "GET", undefined, team, 401); - state.member.password = next; - save(); - await memberCtx.close(); - const reloginCtx = await browser.newContext({ baseURL: base }), - relogin = await reloginCtx.newPage(); - await login(relogin, state.member.email, next); - await reloginCtx.close(); - pass("browser_password_reset_revokes_sessions_new_login_works"); - await page - .getByRole("button", { name: "Close Reset password", exact: true }) - .click(); - await page.goto("/jobs"); - await pick(page, "Workspace", { exact: true }, team); - await page - .getByRole("button", { name: "Show details for " + jobName, exact: true }) - .click(); - await page.screenshot({ - path: out + "/final-jobs-desktop.png", - fullPage: true, - }); - await page - .getByRole("button", { name: "Hide details for " + jobName, exact: true }) - .click(); - await page.setViewportSize({ width: 390, height: 844 }); - await page.screenshot({ - path: out + "/final-jobs-mobile.png", - fullPage: true, - }); - check( - await page.evaluate( - () => document.documentElement.scrollWidth <= window.innerWidth, - ), - "Mobile viewport overflows horizontally", - ); - await page.setViewportSize({ width: 1600, height: 1100 }); - await page.getByRole("button", { name: "New job", exact: true }).click(); - await page.getByRole("radio", { name: /Tenstorrent/ }).check(); - await page.waitForFunction(() => - document - .querySelector('input[name="compute"]:checked') - ?.closest("label") - ?.classList.contains("is-selected"), - ); - await page - .getByRole("radio", { name: "Training check", exact: true }) - .check(); - await page.screenshot({ - path: out + "/final-job-composer.png", - fullPage: true, - }); - await page.setViewportSize({ width: 390, height: 844 }); - check( - await page - .getByRole("dialog") - .evaluate((el) => el.scrollWidth <= el.clientWidth), - "Mobile job composer overflows horizontally", - ); - await page.screenshot({ - path: out + "/final-job-composer-mobile.png", - fullPage: false, - }); - await page - .getByRole("button", { name: "Close New job", exact: true }) - .click(); - await page.setViewportSize({ width: 1600, height: 1100 }); - pass("responsive_job_dialog_and_device_slider"); - await pick(page, "Workspace", { exact: true }, ""); - await page.waitForFunction( - () => !document.querySelector('[aria-label="Next page"]')?.disabled, - ); - check( - (await page.getByRole("button", { name: /Show details for/ }).count()) <= - 10, - "History exceeds page size", - ); - check( - await page - .getByRole("button", { name: "Next page", exact: true }) - .isEnabled(), - "Preserved history has no next page", - ); - await page.getByRole("button", { name: "Next page", exact: true }).click(); - await page.getByRole("status").filter({ hasText: "Page 2 of" }).waitFor(); - await page - .getByRole("button", { name: "Previous page", exact: true }) - .click(); - await page.getByRole("status").filter({ hasText: "Page 1 of" }).waitFor(); - pass("history_pagination_next_previous_real_legacy_jobs"); - await pick(page, "Workspace", { exact: true }, team); - await page.goto("/dashboard"); - await page.screenshot({ path: out + "/final-overview.png", fullPage: true }); - pass("desktop_mobile_layout_no_overflow_or_browser_errors"); - await api(ctx, `/api/teams/${team}`, "PATCH", { disabled: true }); - const pilot = JSON.parse(readFileSync(out + "/private-state.json")).a.id; - await pick(page, "Workspace", { exact: true }, pilot); - check(report.browser_errors.length === 0, report.browser_errors.join("; ")); - report.finished = new Date().toISOString(); - writeFileSync(out + "/browser-results.json", JSON.stringify(report, null, 2)); - console.log(JSON.stringify(report, null, 2)); -} catch (e) { - report.error = e.message; - writeFileSync(out + "/browser-results.json", JSON.stringify(report, null, 2)); - throw e; -} finally { - await browser.close(); -} diff --git a/deploy/k3s/verify_department_remote_cli.py b/deploy/k3s/verify_department_remote_cli.py deleted file mode 100644 index 38ec76a..0000000 --- a/deploy/k3s/verify_department_remote_cli.py +++ /dev/null @@ -1,47 +0,0 @@ -# Verification for this recorded two-machine pilot; never prints credentials. -import json, pathlib, subprocess, time, shlex -root=pathlib.Path('/home/utmist/mist-department-results-2026-10-09') -credentials=json.loads(pathlib.Path('/home/utmist/.config/mist/admin-bootstrap.json').read_text()) -ssh=['ssh','-i','/home/utmist/.ssh/id_ed25519_quietbox_codex','-o','IdentitiesOnly=yes','-o','BatchMode=yes','utmist-tt@100.95.175.37'] -base='~/mist-department-verification/mist-cli --api-url http://100.73.139.66:8088/api --config ~/mist-department-verification/verification-config.json ' -def run(cmd, data=None): - p=subprocess.run(ssh+[cmd], input=data, text=True,capture_output=True,timeout=40) - if p.returncode: raise RuntimeError('Remote command failed: '+p.stderr[:2000]) - return p.stdout -probe="""from pathlib import Path -import torch -assert torch.cuda.is_available(), 'CUDA unavailable' -assert torch.cuda.device_count()==1, torch.cuda.device_count() -x=torch.randn(128,128,device='cuda',requires_grad=True) -loss=(x@x).square().mean() -loss.backward() -assert torch.isfinite(x.grad).all() -Path('/outputs/remote-cli-proof.txt').write_text('REMOTE_CLI_NVIDIA_PASSED devices=1') -print('REMOTE_CLI_NVIDIA_PASSED devices=1',flush=True) -""" -run("cat > ~/mist-department-verification/nvidia.py",probe) -report={'origin_node':'utmist-tt','destination_node':'utmist-z1opa08','team_id':'team-db3933031a6c2437'} -try: - run(base+'auth login --email '+shlex.quote(credentials['email'])+' --password-stdin',credentials['password']+'\n') - report['login']=True - run(base+'team use team-db3933031a6c2437') - report['team_selection']=True - submitted=run(base+'job submit ~/mist-department-verification/nvidia.py --compute NVIDIA --devices 1 --timeout 120 --name remote-cli-nvidia') - job=submitted.strip().split()[-1]; report['job_id']=job - for _ in range(90): - status=run(base+'job status '+job) - if 'Success' in status: break - if 'Failure' in status or 'Cancelled' in status: raise RuntimeError(status) - time.sleep(2) - else: raise RuntimeError('Remote submitted job did not finish') - logs=run(base+'job logs '+job) - assert 'utmist-z1opa08' in status and 'REMOTE_CLI_NVIDIA_PASSED' in logs - report['job_succeeded']=True - (root/'remote-cli-status.txt').write_text(status) - (root/'remote-cli-job.log').write_text(logs) - print(status,logs) -finally: - run(base+'auth logout') - run("python3 -c \"from pathlib import Path; Path.home().joinpath('mist-department-verification/verification-config.json').unlink(missing_ok=True)\"") -(root/'remote-cli-results.json').write_text(json.dumps(report,indent=2)+'\n') -print('PASS SSH_TENSTORRENT_CLI_SUBMISSION_NVIDIA_ON_MAIN') diff --git a/deploy/k3s/verify_department_runtime.py b/deploy/k3s/verify_department_runtime.py deleted file mode 100644 index 57e35ce..0000000 --- a/deploy/k3s/verify_department_runtime.py +++ /dev/null @@ -1,81 +0,0 @@ -"""Live execution checks for the recorded pilot; credentials remain local.""" -import http.cookiejar -import json -import pathlib -import time -import urllib.error -import urllib.request - -ROOT = pathlib.Path('/home/utmist/mist-department-results-2026-10-09') -BASE = 'http://100.73.139.66:8088' -TEAM = 'team-db3933031a6c2437' -credentials = json.loads(pathlib.Path('/home/utmist/.config/mist/admin-bootstrap.json').read_text()) -client = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar())) -report = {'checks': {}, 'jobs': []} - - -def api(path, body=None): - request = urllib.request.Request(BASE + path, data=json.dumps(body).encode() if body is not None else None, - headers={'Origin': BASE, 'Content-Type': 'application/json', 'X-Mist-Team': TEAM}) - with client.open(request, timeout=30) as response: - return json.load(response) - - -def submit(name, **data): - job = api('/api/jobs', {'name': name, 'type': 'command', 'accelerator': 'cpu', 'timeout_seconds': 120, - 'command': ['python', '-c', "print('PASSED',flush=True)"], **data})['job_id'] - report['jobs'].append({'id': job, 'name': name}) - return job - - -def terminal(job, expected): - for _ in range(120): - result = api('/api/jobs/' + job) - if result['job_state'] in ['Success', 'Failure', 'Cancelled']: - assert result['job_state'] == expected, result - log = api('/api/jobs/' + job + '/logs')['logs'] - (ROOT / (job + '.log')).write_text(log) - return result, log - time.sleep(2) - raise RuntimeError('Job timed out ' + job) - - -try: - api('/auth/sign-in/email', {'email': credentials['email'], 'password': credentials['password']}) - first = submit('Concurrent TT board A', type='training-smoke', accelerator='tenstorrent', device_count=1, command=None) - second = submit('Concurrent TT board B', type='training-smoke', accelerator='tenstorrent', device_count=1, command=None) - allocations = {} - concurrent = False - for _ in range(120): - jobs = [api('/api/jobs/' + job) for job in [first, second]] - if all(j['job_state'] == 'InProgress' for j in jobs): - concurrent = True - for job in jobs: - if job.get('devices'): - allocations[job['id']] = job['devices'] - if all(j['job_state'] in ['Success', 'Failure', 'Cancelled'] for j in jobs): - break - time.sleep(1) - assert concurrent, 'TT jobs did not overlap' - for job in [first, second]: - result, log = terminal(job, 'Success') - assert result['node'] == 'utmist-tt' and 'TRAINING_PASSED backend=tenstorrent devices=2' in log, log - assert len(allocations) == 2, allocations - left = {(d['pool'], d['device']) for d in allocations[first]} - right = {(d['pool'], d['device']) for d in allocations[second]} - assert len(left) == len(right) == 1 and left.isdisjoint(right), allocations - report['allocations'] = allocations - report['checks']['concurrent_tt_jobs_distinct_boards_real_training'] = True - print('PASS CONCURRENT_TT_JOBS_DISTINCT_BOARDS_TRAINING') - failure = submit('Intentional failure', command=['python', '-c', "print('EXPECTED_EXIT_7',flush=True); raise SystemExit(7)"]) - result, log = terminal(failure, 'Failure') - assert result.get('exit_code') == 7 and 'EXPECTED_EXIT_7' in log - report['checks']['nonzero_exit_visible_no_retry'] = True - deadline = submit('Execution deadline', timeout_seconds=10, command=['python', '-c', "import time; print('DEADLINE_STARTED',flush=True); time.sleep(120)"]) - result, log = terminal(deadline, 'Failure') - assert 'Deadline' in result.get('message', '') or 'deadline' in result.get('message', '').lower(), result - report['checks']['execution_deadline_failure_visible'] = True - print('PASS EXIT_CODE_AND_DEADLINE_FAILURES') -finally: - api('/auth/sign-out', {}) -(ROOT / 'runtime-results.json').write_text(json.dumps(report, indent=2) + '\n') diff --git a/deploy/k3s/verify_large_dataset.py b/deploy/k3s/verify_large_dataset.py deleted file mode 100644 index ed8fcee..0000000 --- a/deploy/k3s/verify_large_dataset.py +++ /dev/null @@ -1,83 +0,0 @@ -"""Verify a streamed dataset exceeds 2 GiB and can be mounted by a real Job.""" -import hashlib -import http.cookiejar -import json -import pathlib -import tempfile -import time -import urllib.request - -OUT = pathlib.Path('/home/utmist/mist-department-results-2026-10-09') -BASE = 'http://100.73.139.66:8088' -credentials = json.loads(pathlib.Path('/home/utmist/.config/mist/admin-bootstrap.json').read_text()) -client = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar())) -report = {'checks': {}} -team = '' - -def call(path, body=None): - request = urllib.request.Request(BASE + path, data=json.dumps(body).encode() if body is not None else None, - headers={'Origin': BASE, 'Content-Type': 'application/json', **({'X-Mist-Team': team} if team else {})}) - with client.open(request, timeout=30) as response: - return json.load(response) - -try: - call('/auth/sign-in/email', {'email': credentials['email'], 'password': credentials['password']}) - admin = call('/api/session')['user'] - teams = call('/api/teams')['teams'] - existing = next((t for t in teams if t['name'] == 'Large dataset verification'), None) - if existing is None: - existing = call('/api/teams', {'name': 'Large dataset verification', 'storage_gib': 4}) - team = existing['id'] - request = urllib.request.Request(BASE + '/api/teams/' + team, method='PATCH', data=b'{"disabled":false}', headers={'Origin': BASE, 'Content-Type': 'application/json'}) - client.open(request, timeout=30).close() - call('/api/teams/' + team + '/members', {'user_id': admin['id'], 'common_writer': True}) - for _ in range(60): - try: - storage = call('/api/storage') - if storage['upload_limit_bytes'] > 2**31: break - except Exception: pass - time.sleep(1) - else: raise RuntimeError('Large team filesystem not ready') - # Sparse local source avoids allocating a second local multi-GiB copy; - # all bytes are nevertheless streamed over the actual portal/NFS path. - size = 2**31 + 4 * 1024**2 - with tempfile.TemporaryFile() as source: - source.write(b'LARGE_DATASET_START') - source.truncate(size) - source.seek(size - len(b'LARGE_DATASET_END!')); source.write(b'LARGE_DATASET_END!') - source.seek(0) - digest = hashlib.sha256() - while chunk := source.read(1024 * 1024): digest.update(chunk) - checksum = digest.hexdigest() - source.seek(0) - request = urllib.request.Request(BASE + '/api/datasets?filename=large.bin&name=Large%20dataset%20probe', data=source, - headers={'Origin': BASE, 'Content-Type': 'application/octet-stream', 'Content-Length': str(size), 'X-Mist-Team': team}) - started = time.monotonic() - with client.open(request, timeout=14400) as response: - dataset = json.load(response) - assert dataset['size'] == size and dataset['sha256'] == checksum, dataset - report.update(team_id=team, dataset_id=dataset['id'], bytes=size, upload_seconds=round(time.monotonic()-started, 1)) - report['checks']['actual_upload_over_2_gib_checksum_verified'] = True - print('PASS STREAMED_UPLOAD_OVER_2_GIB_CHECKSUM', flush=True) - script = f"from pathlib import Path\np=Path('/inputs/large.bin')\nassert p.stat().st_size=={size}\nwith p.open('rb') as f:\n assert f.read(19)==b'LARGE_DATASET_START'\n f.seek(-len(b'LARGE_DATASET_END!'),2)\n assert f.read()==b'LARGE_DATASET_END!'\nprint('LARGE_DATASET_JOB_PASSED',flush=True)\n" - job = call('/api/jobs', {'name':'Large dataset input verification', 'type':'command', 'accelerator':'cpu', 'dataset_id': dataset['id'], 'script':script, 'script_name':'verify.py', 'timeout_seconds':120})['job_id'] - report['job_id'] = job - for _ in range(90): - result = call('/api/jobs/' + job) - if result['job_state'] in ['Success','Failure','Cancelled']: break - time.sleep(2) - assert result['job_state'] == 'Success', result - logs = call('/api/jobs/' + job + '/logs')['logs'] - assert 'LARGE_DATASET_JOB_PASSED' in logs - (OUT/'large-dataset-job.log').write_text(logs) - report['checks']['large_file_mounted_readonly_real_job'] = True - # Remove only this verifier's large dataset after its sole Job has finished. - request = urllib.request.Request(BASE + '/api/datasets/' + dataset['id'], method='DELETE', headers={'X-Mist-Team':team}) - client.open(request, timeout=30).close() - report['checks']['own_probe_removed_after_job_no_user_files_deleted'] = True - request = urllib.request.Request(BASE + '/api/teams/' + team, method='PATCH', data=b'{"disabled":true}', headers={'Origin': BASE, 'Content-Type': 'application/json'}) - client.open(request, timeout=30).close() - print('PASS LARGE_FILE_MOUNT_AND_OWN_PROBE_CLEANUP', flush=True) -finally: - call('/auth/sign-out', {}) - (OUT/'large-dataset-results.json').write_text(json.dumps(report, indent=2)+'\n') diff --git a/deploy/k3s/verify_storage_folders.py b/deploy/k3s/verify_storage_folders.py deleted file mode 100644 index 5e8c431..0000000 --- a/deploy/k3s/verify_storage_folders.py +++ /dev/null @@ -1,176 +0,0 @@ -#!/usr/bin/env python3 -"""Small live checks: regular-member uploads, isolation, and NVIDIA/TT training. - -Creates two small verification workspaces and one regular account. No large files, -quota exhaustion, migration or old-data deletion. Credentials stay outside Git. -""" -import argparse -import hashlib -import http.cookiejar -import io -import json -import os -from pathlib import Path -import random -import secrets -import time -import urllib.error -import urllib.parse -import urllib.request -import zipfile - - -class Client: - def __init__(self, base): - self.base = base.rstrip('/') - self.opener = urllib.request.build_opener( - urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar())) - - def call(self, path, body=None, team=None, method=None, expected=200, raw=False): - payload = body if isinstance(body, bytes) else json.dumps(body).encode() if body is not None else None - headers = {'Origin': self.base, 'Content-Type': 'application/octet-stream' if isinstance(body, bytes) else 'application/json'} - if team: - headers['X-Mist-Team'] = team - request = urllib.request.Request(self.base + path, data=payload, headers=headers, method=method) - try: - response = self.opener.open(request, timeout=30) - except urllib.error.HTTPError as error: - if error.code != expected: - raise RuntimeError(f'{path}: unexpected HTTP {error.code}') from None - response = error - with response: - assert response.code == expected, (path, response.code) - return response.read() if raw else json.load(response) - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('--base', default='http://100.73.139.66:8088') - parser.add_argument('--credentials', type=Path, default=Path('/home/utmist/.config/mist/admin-bootstrap.json')) - parser.add_argument('--out', required=True, type=Path) - args = parser.parse_args() - args.out.mkdir(parents=True, exist_ok=True, mode=0o700) - os.chmod(args.out, 0o700) - report = {'checks': [], 'jobs': [], 'bulk_testing': False} - admin, member = Client(args.base), Client(args.base) - jobs = [] - stamp = secrets.token_hex(4) - - def passed(name): - report['checks'].append(name) - print('PASS ' + name, flush=True) - - try: - creds = json.loads(args.credentials.read_text()) - identity = admin.call('/auth/sign-in/email', {'email': creds['email'], 'password': creds['password']})['user'] - teams = [admin.call('/api/teams', {'name': 'Storage verification ' + stamp + suffix, - 'storage_gib': 1, 'model_storage_gib': 1}, expected=201) for suffix in (' A', ' B')] - for team in teams: - admin.call('/api/teams/' + team['id'] + '/members', {'user_id': identity['id'], 'common_writer': True}) - email, password = 'storage-check-' + stamp + '@mist.local', secrets.token_urlsafe(24) - user = admin.call('/auth/admin/create-user', {'name': 'Storage check ' + stamp, 'email': email, - 'password': password, 'role': 'user'})['user'] - admin.call('/api/teams/' + teams[0]['id'] + '/members', {'user_id': user['id'], 'common_writer': False}) - member.call('/auth/sign-in/email', {'email': email, 'password': password}) - team = teams[0]['id'] - state = args.out / 'member-private.json' - state.write_text(json.dumps({'email': email, 'password': password, 'team_id': team})) - state.chmod(0o600) - for _ in range(60): - try: - storage = member.call('/api/storage', team=team) - break - except RuntimeError: - time.sleep(1) - else: - raise AssertionError('team folders did not become ready') - assert storage['storage_mode'] == 'shared-folders' and storage['storage_limits_enforced'] is False - assert storage['upload_limit_bytes'] > 0 - passed('regular_member_login_and_shared_folder_readiness') - member.call('/api/jobs', team=teams[1]['id'], expected=403) - member.call('/api/datasets?filename=forbidden.txt&scope=common', b'no', team=team, expected=403) - passed('membership_and_common_write_permissions_enforced') - - archive = io.BytesIO() - with zipfile.ZipFile(archive, 'w') as z: - z.writestr('../escape.txt', 'no') - member.call('/api/datasets?filename=unsafe.zip&format=zip', archive.getvalue(), team=team, expected=400) - passed('unsafe_archive_rejected') - - rng = random.Random(42) - weights = [[rng.gauss(0, .1) for _ in range(32)] for _ in range(32)] - x = [[rng.gauss(0, 1) for _ in range(32)] for _ in range(128)] - vx = [[rng.gauss(0, 1) for _ in range(32)] for _ in range(64)] - def targets(rows): - return [[sum(row[k] * weights[k][j] for k in range(32)) for j in range(32)] for row in rows] - payload = json.dumps({'x': x, 'y': targets(x), 'validation_x': vx, 'validation_y': targets(vx)}).encode() - assert len(payload) < 400000 - dataset = member.call('/api/datasets?filename=regression.json&name=Ethernet%20storage%20check', payload, team=team, expected=201) - report['dataset_bytes'] = len(payload) - assert dataset['sha256'] == hashlib.sha256(payload).hexdigest() - download = '/api/datasets/' + dataset['id'] + '/files/download?path=regression.json' - assert member.call(download, team=team, raw=True) == payload - admin.call('/api/datasets/' + dataset['id'], team=teams[1]['id'], expected=404) - admin.call('/api/storage/files?' + urllib.parse.urlencode({'source_team': team, 'scope': user['id']}), team=teams[1]['id'], expected=404) - passed('small_upload_checksum_download_and_cross_team_isolation') - - source = (Path(__file__).parent / 'examples/train_shared.py').read_text() - for backend, expected in [('nvidia', 1), ('tenstorrent', 2)]: - script = source.replace('args = parser.parse_args()', - f"args = parser.parse_args(['{backend}','--expected-devices','{expected}','--output','/outputs'])") - job = member.call('/api/jobs', {'name': 'Ethernet folder training ' + backend, 'type': 'command', - 'accelerator': backend, 'device_count': 1, 'dataset_id': dataset['id'], 'script': script, - 'script_name': 'train.py', 'timeout_seconds': 600}, team=team, expected=201)['job_id'] - jobs.append(job) - report['jobs'].append({'id': job, 'backend': backend}) - member.call('/api/datasets/' + dataset['id'], team=team, method='DELETE', expected=409) - passed('active_dataset_deletion_blocked') - - deadline = time.monotonic() + 660 - for job in report['jobs']: - while time.monotonic() < deadline: - status = member.call('/api/jobs/' + job['id'], team=team) - if status['job_state'] in ('Success', 'Failure', 'Cancelled'): - break - time.sleep(2) - log = member.call('/api/jobs/' + job['id'] + '/logs', team=team)['logs'] - (args.out / (job['backend'] + '.log')).write_text(log) - assert status['job_state'] == 'Success', (job['backend'], status.get('message')) - assert 'DATASET_LOADED file=/inputs/regression.json' in log and 'TRAINING_PASSED' in log - files = member.call('/api/jobs/' + job['id'] + '/files', team=team)['files'] - assert any(f['path'].endswith('.pt') for f in files) - for file in files: - content = member.call('/api/jobs/' + job['id'] + '/files/download?' + urllib.parse.urlencode({'path': file['path']}), team=team, raw=True) - assert len(content) == file['size'] - if file['path'] == 'results.json': - job['metrics'] = json.loads(content) - job['node'], job['status'] = status['node'], status['job_state'] - passed(job['backend'] + '_real_training_and_output_downloads') - for _ in range(45): - storage = member.call('/api/storage', team=team) - if storage['dataset_used_bytes'] >= len(payload) and storage['model_used_bytes'] > 0: - break - time.sleep(1) - assert storage['dataset_used_bytes'] >= len(payload) and storage['model_used_bytes'] > 0 - assert storage['usage_stale'] is False - report['storage'] = storage - passed('host_sampled_dataset_and_model_usage') - report['teams'] = [t['id'] for t in teams] - except Exception: - for job in jobs: - try: - member.call('/api/jobs/' + job + '/cancel', team=team, method='POST') - except Exception: - pass - raise - finally: - for client in (member, admin): - try: - client.call('/auth/sign-out', {}, method='POST') - except Exception: - pass - (args.out / 'results.json').write_text(json.dumps(report, indent=2) + '\n') - - -if __name__ == '__main__': - main() diff --git a/deploy/k3s/verify_storage_folders_browser.mjs b/deploy/k3s/verify_storage_folders_browser.mjs deleted file mode 100644 index 5efbd03..0000000 --- a/deploy/k3s/verify_storage_folders_browser.mjs +++ /dev/null @@ -1,59 +0,0 @@ -// Review the real portal using the regular account from verify_storage_folders.py. -// State and screenshots are written only to the protected external evidence dir. -import { readFileSync, writeFileSync } from 'node:fs' -import { createHash } from 'node:crypto' - -const { chromium } = await import( - process.env.MIST_PLAYWRIGHT_MODULE ?? - '/tmp/mist-browser-check/node_modules/playwright/index.mjs' -) -const base = process.env.MIST_SITE_URL ?? 'http://100.73.139.66:8088' -const out = process.env.MIST_STORAGE_EVIDENCE_DIR -if (!out) throw new Error('Set MIST_STORAGE_EVIDENCE_DIR to the live verifier output directory') -const state = JSON.parse(readFileSync(`${out}/member-private.json`, 'utf8')) -const browser = await chromium.launch({ headless: true }) -const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } }) -const page = await context.newPage() -const report = { checks: [], browser_errors: [] } -page.on('pageerror', (error) => report.browser_errors.push(error.message)) -try { - await page.goto(`${base}/datasets`) - await page.getByLabel('Email', { exact: true }).fill(state.email) - await page.getByLabel('Password', { exact: true }).fill(state.password) - await page.getByRole('button', { name: 'Sign in', exact: true }).click() - await page.getByRole('heading', { name: 'Datasets', exact: true }).waitFor() - await page.getByLabel('Workspace', { exact: true }).click() - await page.locator(`[role=option][data-value="${state.team_id}"]`).click() - await page.getByText(/Shared disk:/).waitFor() - await page.getByText('Advisory allowance — shared disk space is not reserved.').first().waitFor() - report.checks.push('regular_member_login_and_advisory_storage_display') - - await page.getByRole('button', { name: 'Add dataset', exact: true }).click() - await page.getByLabel('Dataset name (optional)').fill('Browser Ethernet check') - const payload = Buffer.from('x,y\n1,2\n') - await page.getByLabel('File or ZIP archive', { exact: true }).setInputFiles({ - name: 'browser.csv', mimeType: 'text/csv', buffer: payload, - }) - const response = page.waitForResponse((r) => r.url().includes('/api/datasets?') && r.request().method() === 'POST') - await page.getByRole('button', { name: 'Upload dataset', exact: true }).click() - const uploaded = await response - if (uploaded.status() !== 201) throw new Error(`Upload HTTP ${uploaded.status()}`) - const dataset = await uploaded.json() - if (dataset.sha256 !== createHash('sha256').update(payload).digest('hex')) throw new Error('Browser upload checksum mismatch') - await page.getByText('Browser Ethernet check is ready to use.', { exact: true }).waitFor() - report.checks.push('real_browser_upload_with_verified_checksum') - await page.screenshot({ path: `${out}/datasets-browser.png`, fullPage: true }) - - await page.goto(`${base}/jobs`) - await page.getByText('Ethernet folder training nvidia', { exact: true }).waitFor() - await page.getByText('Ethernet folder training tenstorrent', { exact: true }).waitFor() - report.checks.push('both_real_training_jobs_visible_in_member_history') - await page.screenshot({ path: `${out}/jobs-browser.png`, fullPage: true }) - if (report.browser_errors.length) throw new Error('Browser reported JavaScript errors') - report.checks.push('no_browser_javascript_errors') - console.log('PASS ' + report.checks.join(', ')) -} finally { - await context.request.post(`${base}/auth/sign-out`, { data: {}, headers: { Origin: base } }).catch(() => {}) - await browser.close() - writeFileSync(`${out}/browser-results.json`, JSON.stringify(report, null, 2)) -} diff --git a/deploy/k3s/verify_tenstorrent_allocation.py b/deploy/k3s/verify_tenstorrent_allocation.py deleted file mode 100644 index 4762791..0000000 --- a/deploy/k3s/verify_tenstorrent_allocation.py +++ /dev/null @@ -1,225 +0,0 @@ -"""Live acceptance test: four isolated n300 jobs, a fifth queued, then reuse. - -Run from the repo root with an admin KUBECONFIG and Python PyYAML installed. -Test jobs and checkpoint subdirectories are unique per run and retained. -The device probes mount character nodes solely to check cgroup enforcement; -normal training jobs receive their devices exclusively through DRA/CDI. -""" - -import argparse -import copy -from datetime import datetime, timezone -import json -from pathlib import Path -import subprocess -import time - -import yaml - - -def kubectl(*args, data=None): - result = subprocess.run(["k3s", "kubectl", *args], input=data, text=True, - capture_output=True, timeout=45) - if result.returncode: - raise RuntimeError(result.stderr or result.stdout) - return result.stdout - - -def get(kind, *args): - return json.loads(kubectl("-n", "mist", "get", kind, *args, "-o", "json")) - - -def poll(check, description, timeout=240): - deadline = time.monotonic() + timeout - while time.monotonic() < deadline: - result = check() - if result: - return result - time.sleep(2) - raise AssertionError(f"Timed out: {description}") - - -def pod_for(job): - pods = get("pods", "-l", f"job-name={job}")["items"] - if not pods: - return None - assert len(pods) == 1, f"Expected one pod for {job}" - pod = pods[0] - assert pod["status"]["phase"] != "Failed", kubectl("-n", "mist", "logs", pod["metadata"]["name"]) - return pod - - -def collect_results(run, jobs, pods, artifacts): - """Read retained PVC data after training pods exit, then remove the reader.""" - reader_name = run + "-results" - reader = {"apiVersion": "v1", "kind": "Pod", "metadata": { - "name": reader_name, "namespace": "mist"}, "spec": { - "restartPolicy": "Never", "automountServiceAccountToken": False, - "activeDeadlineSeconds": 180, - "nodeSelector": {"kubernetes.io/hostname": "utmist-tt"}, - "containers": [{"name": "reader", "image": pods[0]["spec"]["containers"][0]["image"], - "imagePullPolicy": "IfNotPresent", "command": ["sleep", "180"], - "securityContext": {"allowPrivilegeEscalation": False, "capabilities": {"drop": ["ALL"]}}, - "resources": {"requests": {"cpu": "10m", "memory": "32Mi"}, - "limits": {"cpu": "1", "memory": "256Mi"}}, - "volumeMounts": [{"name": "checkpoints", "mountPath": "/checkpoints", "readOnly": True}]}], - "volumes": [{"name": "checkpoints", "persistentVolumeClaim": { - "claimName": "training-tenstorrent-checkpoints", "readOnly": True}}]}} - kubectl("create", "-f", "-", data=json.dumps(reader)) - try: - poll(lambda: get("pod", reader_name)["status"]["phase"] == "Running", "PVC reader running", timeout=90) - script = '''import json, pathlib, sys -root=pathlib.Path("/checkpoints")/sys.argv[1] -data=[] -for i in range(5): - folder=root/str(i) - results=json.loads((folder/"results.json").read_text()) - for result in results: - assert (folder/("tenstorrent-"+str(result["device_id"])+".pt")).stat().st_size>1000 - data.append({"allocation":json.loads((folder/"allocation.json").read_text()), "results":results}) -print(json.dumps(data)) -''' - summaries = json.loads(kubectl("-n", "mist", "exec", reader_name, "--", "python3", "-c", script, run)) - for index, summary in enumerate(summaries): - summary["job"] = jobs[index]["metadata"]["name"] - assert len(summary["results"]) == 2 and summary["allocation"]["chip_count"] == 2 - assert len(summary["allocation"]["device_nodes"]) == 1 - for result in summary["results"]: - assert result["final_mse"] < result["initial_mse"] * 0.02 - assert result["validation_mse"] < 0.0005 - assert result["max_weight_change"] > 0.05 - assert abs(result["checkpoint_reload_mse"] - result["validation_mse"]) < 1e-7 - starts = [datetime.fromisoformat(s["results"][0]["started_at"]) for s in summaries[:4]] - ends = [datetime.fromisoformat(s["results"][0]["finished_at"]) for s in summaries[:4]] - overlap = (min(ends) - max(starts)).total_seconds() - assert overlap > 0, "First-chip training must overlap across all four jobs" - released = min(datetime.fromisoformat(p["status"]["containerStatuses"][0]["state"]["terminated"]["finishedAt"].replace("Z", "+00:00")) - for p in pods[:4]) - assert datetime.fromisoformat(summaries[4]["results"][0]["started_at"]) > released - data = {"four_job_training_overlap_seconds": overlap, "jobs": summaries} - (artifacts / "training-results.json").write_text(json.dumps(data, indent=2) + "\n") - with (artifacts / "concurrent-checkpoints.tar.gz").open("wb") as output: - subprocess.run(["k3s", "kubectl", "-n", "mist", "exec", reader_name, "--", "tar", - "-C", "/checkpoints", "-czf", "-", run], stdout=output, check=True, timeout=45) - return data - finally: - kubectl("-n", "mist", "delete", "pod", reader_name, "--wait=false", "--ignore-not-found") - - -def main(): - parser = argparse.ArgumentParser() - parser.add_argument("--artifacts", type=Path, required=True) - args = parser.parse_args() - args.artifacts.mkdir(parents=True, exist_ok=True) - run = "tt-alloc-" + datetime.now(timezone.utc).strftime("%m%d-%H%M%S") - checkpoint_root = f"/checkpoints/{run}" - docs = list(yaml.safe_load_all(Path("deploy/k3s/training-tenstorrent-job.yaml").read_text())) - base = next(doc for doc in docs if doc["kind"] == "Job") - kubectl("apply", "-f", "-", data=yaml.safe_dump_all(doc for doc in docs if doc["kind"] != "Job")) - configmap = kubectl("-n", "mist", "create", "configmap", "training-smoke-script", - "--from-file=deploy/k3s/training_smoke.py", "--dry-run=client", "-o", "yaml") - kubectl("apply", "-f", "-", data=configmap) - names = [f"{run}-{i}" for i in range(5)] - - def job(index): - manifest = copy.deepcopy(base) - manifest["metadata"]["name"] = names[index] - manifest["metadata"]["labels"] = {"mist.io/allocation-test": run} - spec = manifest["spec"]["template"]["spec"] - spec["automountServiceAccountToken"] = False - container = spec["containers"][0] - container["command"] = container["command"][:4] + [ - "--expected-devices", "2", "--steps", "500", "--output", f"{checkpoint_root}/{index}", - "--start-file", f"{checkpoint_root}/start"] - for number in range(4): - spec["volumes"].append({"name": f"probe-{number}", "hostPath": { - "path": f"/dev/tenstorrent/{number}", "type": "CharDevice"}}) - container["volumeMounts"].append({"name": f"probe-{number}", - "mountPath": f"/isolation/{number}"}) - return manifest - - kubectl("apply", "-f", "-", data=yaml.safe_dump_all(job(i) for i in range(4))) - print(f"RUN={run}: waiting for four initialized training jobs", flush=True) - - def ready(): - pods = [pod_for(name) for name in names[:4]] - if not all(pod and pod["status"]["phase"] == "Running" for pod in pods): - return None - logs = [kubectl("-n", "mist", "logs", pod["metadata"]["name"]) for pod in pods] - return pods if all("BARRIER_READY" in log for log in logs) else None - - pods = poll(ready, "four jobs ready") - claims = get("resourceclaims") - allocations = {} - for pod in pods: - claim_name = pod["status"]["resourceClaimStatuses"][0]["resourceClaimName"] - claim = next(c for c in claims["items"] if c["metadata"]["name"] == claim_name) - result = claim["status"]["allocation"]["devices"]["results"] - assert len(result) == 1 - allocations[pod["metadata"]["name"]] = result[0]["device"] - assert len(set(allocations.values())) == 4, allocations - (args.artifacts / "claims-while-full.json").write_text(json.dumps(claims, indent=2) + "\n") - (args.artifacts / "pods-while-full.json").write_text(json.dumps(pods, indent=2) + "\n") - - probe = '''import json,os,pathlib -visible=list(pathlib.Path("/dev/tenstorrent").iterdir()) -assert len(visible)==1, visible -allowed=[]; denied=[] -for i in range(4): - try: - fd=os.open("/isolation/"+str(i),os.O_RDWR); os.close(fd); allowed.append(str(i)) - except PermissionError: - denied.append(str(i)) -assert allowed==[visible[0].name], (allowed,visible) -assert len(denied)==3, denied -print(json.dumps({"allowed":allowed,"denied":denied})) -''' - isolation = {} - for pod in pods: - name = pod["metadata"]["name"] - isolation[name] = json.loads(kubectl("-n", "mist", "exec", name, "--", "python3", "-c", probe)) - print(f"Four distinct reservations, device access isolated: {allocations}", flush=True) - - kubectl("apply", "-f", "-", data=yaml.safe_dump(job(4))) - - def queued(): - pod = pod_for(names[4]) - if not pod: - return None - events = get("events", "--field-selector", f"involvedObject.name={pod['metadata']['name']}")["items"] - failed = [event for event in events if event["reason"] == "FailedScheduling"] - if not failed: - return None - assert pod["status"]["phase"] == "Pending", pod - assert not pod["spec"].get("nodeName"), pod - assert any("resourceclaim" in e["message"].lower() or - "cannot allocate" in e["message"].lower() for e in failed), failed - return {"pod": pod, "events": failed} - - queue_evidence = poll(queued, "fifth job blocked by device allocation", timeout=60) - (args.artifacts / "fifth-job-queued.json").write_text(json.dumps(queue_evidence, indent=2) + "\n") - print("Fifth job is pending because all boards are reserved; releasing training barrier", flush=True) - kubectl("-n", "mist", "exec", pods[0]["metadata"]["name"], "--", "touch", f"{checkpoint_root}/start") - - def complete(): - jobs = [get("jobs", name) for name in names] - assert not any(j["status"].get("failed") for j in jobs), "A training job failed" - return jobs if all(j["status"].get("succeeded") == 1 for j in jobs) else None - - jobs = poll(complete, "all five jobs successfully trained", timeout=300) - final_pods = [pod_for(name) for name in names] - for index, pod in enumerate(final_pods): - log = kubectl("-n", "mist", "logs", pod["metadata"]["name"]) - assert "TRAINING_PASSED backend=tenstorrent devices=2" in log - assert pod["status"]["containerStatuses"][0]["state"]["terminated"]["exitCode"] == 0 - (args.artifacts / f"job-{index}.log").write_text(log) - summary = {"run": run, "allocations": allocations, "isolation": isolation, - "fifth_job_queued": True, "all_five_jobs_complete": True, - "checkpoint_root": checkpoint_root, "jobs": jobs, "pods": final_pods} - summary["training"] = collect_results(run, jobs, final_pods, args.artifacts) - (args.artifacts / "acceptance.json").write_text(json.dumps(summary, indent=2) + "\n") - print(f"ALLOCATION_PASSED run={run}; all five jobs completed; checkpoints at {checkpoint_root}", flush=True) - - -if __name__ == "__main__": - main() diff --git a/deploy/private/README.md b/deploy/private/README.md deleted file mode 100644 index 8ba6b6f..0000000 --- a/deploy/private/README.md +++ /dev/null @@ -1,171 +0,0 @@ -# Private deployment operations - -Current user/admin and architecture guide: [department rollout](../../docs/content/department-rollout.md). -Execution/evidence: [rollout checklist](../../docs/content/department-rollout-execution.md). -Setup: [k3s](../../docs/content/k3s-setup.md), [Tailscale/SSH](../../docs/content/tailscale-ssh.md), -[storage](../../docs/content/storage.md), and [developer setup](../../docs/content/development.md). - -## Services and persistent state - -| Component | Location | Persistent state | -|---|---|---| -| Nginx production portal | Main, localhost and `100.73.139.66:8088` | `/srv/mist-web/releases`, atomic `current` symlink | -| Go API, auth, and admission controller | `mist-system`, main, one Recreate replica | Team/queue ConfigMaps; Kubernetes Jobs; Local `mist-auth-data` SQLite WAL PVC | -| k3s server / agent | Main / QuietBox | Existing cluster and device operators | -| NFSv4 storage | QuietBox | Ordinary `/srv/mist-data/teams` dataset/result folders over direct Ethernet | -| Team provisioning agent | QuietBox `mist-team-storage.service` | Validated requests, ordinary directories and sampled usage | -| Portal host firewall | Both `mist-portal-firewall.service` | Owned INPUT/FORWARD chains and host config | - -API image tag is `mist-api:folders-20261021`, imported into main's k3s containerd. -New jobs run in `mist-team-` namespaces. Historical `mist` workloads/PVCs remain. -Never scale the current API above one replica: admission requires one controller. - -## Build and redeploy on main - -Run from a checkout of the reviewed current main on the NVIDIA host. Required: -Go 1.26.0, Node 22.16+, working Docker access, the private kubeconfig, existing -auth Secrets, GPU/TT operators and mounted QuietBox storage. The script is for -this existing installation; it is not a fresh-cluster/bootstrap installer. -Checking out/merging Git does not restart or deploy services automatically. -The API is Kubernetes-only; Redis and the retired Docker supervisor are unnecessary. - - -```bash -bash deploy/private/build-and-deploy.sh -``` - -The script builds API/CLI and the API container image, imports it, applies -RBAC/admission/network manifests, waits for API rollout, installs web -packages, type-checks/builds the SPA and atomically installs its production -release. It requires the existing Docker and Kubernetes access. Host imports -and web installation use authorized Docker bind/chroot/nsenter access; the -process must enter the actual host mount/network namespace for host changes. - -Bootstrap credentials remain outside Git in mode-0600 local files and Kubernetes -Secrets. `prepare-internal-secret.py` reuses the internal credential on redeploy. -Never recreate the auth database, change its signing secret or print Secret YAML -as part of an ordinary deploy. The script fails if bootstrap credentials are -missing. Fresh-host installation needs deliberate address/node configuration, -storage preparation and protected Secret creation; this is a deployment of the -existing two-machine installation. - -Frontend-only release: - -```bash -npm --prefix web-interface run build -# As root in the actual main host namespace: -bash deploy/private/install-web.sh /home/utmist/Mist -``` - -Use the pinned Node/Go runtime locations from the build script. A frontend -rollback can repoint `/srv/mist-web/current` atomically to a previous complete -release. Keep its API contract compatible. For a backend regression, rebuild a -known department version with the same manifests/Secrets/state. The earlier -account-only API does not enforce team policies and is not a safe ordinary -rollback for research users. The pre-cleanup department release `3431ffd` contains -the verified team architecture; rebuild a compatible department version rather -than restoring the archived pre-platform main for normal users. - -## QuietBox host setup and storage cutover - -Follow [shared-folder storage operations](../k3s/storage/README.md). This supersedes -image-backed setup, online filesystem growth and model-pool migration instructions. -New storage uses `/srv/mist-data` over `192.168.240.2` and new NFS volume bindings. -Existing image files and bindings are retained for rollback; fresh storage was -requested without data migration. No physical disk is formatted. - -Team dataset/model values are advisory. Track actual shared-disk free space and -sampled team file usage. Uploads preserve 1 GiB of disk headroom; running jobs can -still exhaust storage. New Kubernetes PV/PVC sizes are binding declarations, -not capacity reservations or write limits. - -The October 21 rollout also preserves existing Better Auth password hashes and -deactivated accounts in Go auth, and requires real unexpired sessions. Session -cookies based only on user IDs are rejected. Auth data remains on its existing -local PVC; a consistent pre-cutover database snapshot is retained privately. - -## Portal network rules - -Install `portal-firewall.sh` under `/usr/local/lib/mist` and the accompanying -systemd unit on both hosts. `/etc/mist-portal-firewall.conf` contains -`MIST_PORTAL_PORT=8088` on main and `MIST_PORTAL_PORT=0` on QuietBox. Enable/restart -`mist-portal-firewall` after installation. The unit follows Tailscale/k3s service -restarts; manually reapply after UFW/netfilter resets. - -The script restricts ordinary sources on `tailscale0` to main TCP 8088 and denies -forwarded cluster traffic. The explicit administrator/infrastructure IPv4 and -IPv6 allowlist is in the script. Update it before relying on a new administrator -device. LAN rules and cloud Tailscale ACL/sharing settings are unchanged. -`verify-portal-firewall.sh` checks the actual installed chains using an isolated -synthetic untrusted source and removes its temporary namespace/rules afterward. -It requires root in the main host network namespace. - -## Services to restart and inspect - -Use a root-capable administrator session for service mutations. Logs are available -through `journalctl -u ` on the owning host and `kubectl logs` for pods. -Ordinary application redeployment should use the build script; restart k3s/NFS -only when resolving their own infrastructure problem because workloads depend on them. - -| Component | Owner | Status command | -|---|---|---| -| Portal | Main | `systemctl status nginx --no-pager` | -| API, auth, and controller | Cluster | `k3s kubectl -n mist-system rollout status deployment/mist-api` | -| Control plane | Main | `systemctl status k3s --no-pager` | -| Worker | QuietBox | `systemctl status k3s-agent --no-pager` | -| Shared storage | QuietBox | `systemctl status nfs-server mist-team-storage --no-pager` | -| Private network | Both | `systemctl status tailscaled --no-pager` | -| Portal restrictions | Both | `systemctl status mist-portal-firewall --no-pager` | - -The `mist-api-forward` and `mist-web` user services are local development helpers, -not requirements for the production portal. The default-namespace test Nginx and -k3s's Traefik also do not serve the current portal; it uses host Nginx on 8088. - -## Check after startup - -Main: - -```bash -systemctl is-active k3s tailscaled nginx mist-portal-firewall -KUBECONFIG=/home/utmist/.kube/config k3s kubectl get nodes -KUBECONFIG=/home/utmist/.kube/config k3s kubectl get pods -n mist-system -curl -fsS http://127.0.0.1:8088/api/session -``` - -QuietBox: - -```bash -systemctl is-active k3s-agent tailscaled nfs-server mist-team-storage mist-portal-firewall -findmnt -T /srv/mist-data -df -h /srv/mist-data -``` - -With root access, inspect `exportfs -v` and the provisioning service journal if a -team is not ready. Anonymous `/api/jobs` must return 401; the website must allow -real login and show two NVIDIA GPUs/four TT boards. Both nodes must be Ready. - -## Troubleshooting - -- **401:** Sign in again. SSH/Tailscale identity is separate from Mist identity. -- **403:** Confirm active team membership, scope writing authority and current - policy. Administrators also need team membership to use files/jobs. -- **503:** Check auth and API pods, DNS, shared directory readiness and provisioning - status. Access and missing team mounts fail closed. -- **Queued:** Check per-team concurrency/resources, node health and device - availability. Aged large requests drain their target node; no preemption. -- **507 / ENOSPC:** Team filesystem is full. Check actual QuietBox disk space; advisory allowances do not add capacity. - Delete only unneeded datasets that have no active job references. No automatic result cleanup was added. -- **Image rejected:** Use an explicit tag/digest from an allowed registry. No - per-image approval is needed. Private pull-credential management is excluded. -- **Image pull/runtime failure:** Verify the image is publicly accessible and - includes its dependencies. Custom TT images need compatible TT libraries; - select container runtime. The tested default TT profile remains available. -- **Revocation pending:** An admin mutation returns 503 if job suspension could - not be completed; correct cluster connectivity and retry. -- **Login rate limited:** Retry after the indicated delay. Session checks are - authoritative and do not share the restrictive password-login rate limit. - -Existing `backup-auth.sh` is a manual foundation utility; no automatic backup, -recovery, HTTPS/Serve, monitoring or cleanup service was added. HTTP remains -private over Tailscale. There is one control plane, one auth database instance -and one NFS storage host; no high-availability failover is claimed. diff --git a/deploy/private/backup-auth.sh b/deploy/private/backup-auth.sh deleted file mode 100755 index bf49162..0000000 --- a/deploy/private/backup-auth.sh +++ /dev/null @@ -1,15 +0,0 @@ -#!/bin/bash -# Run on main node. SQLite online backup produces a consistent database snapshot. -# Output is protected and contains member credentials/session data. -set -euo pipefail -export KUBECONFIG=${KUBECONFIG:-/home/utmist/.kube/config} -backup_dir=${1:-/home/utmist/.local/share/mist-backups} -mkdir -p "$backup_dir" -chmod 0700 "$backup_dir" -umask 077 -file="$backup_dir/auth-$(date -u +%Y%m%dT%H%M%S).sqlite" -trap 'rm -f "$file.partial"' EXIT -k3s kubectl exec -n mist-system deployment/mist-api -- cat /data/auth.sqlite > "$file.partial" -test -s "$file.partial" -mv "$file.partial" "$file" -echo "Auth backup saved: $file" diff --git a/deploy/private/build-and-deploy.sh b/deploy/private/build-and-deploy.sh deleted file mode 100755 index 880c4ba..0000000 --- a/deploy/private/build-and-deploy.sh +++ /dev/null @@ -1,31 +0,0 @@ -#!/bin/bash -# API/auth images are built/imported on the main node; no registry push is needed. -# Requires Kubernetes credentials and root privileges for image import/web install. -set -euo pipefail -cd "$(dirname "$0")/../.." -export PATH=/home/utmist/.local/share/mist-runtimes/go-1.25.1/bin:/home/utmist/.local/share/mist-runtimes/node-22.16.0/bin:$PATH -export KUBECONFIG=${KUBECONFIG:-/home/utmist/.kube/config} -# Never silently deploy an unauthenticated service if its bootstrap secret is missing. -k3s kubectl get secret mist-auth-bootstrap -n mist-system >/dev/null -python3 deploy/private/prepare-internal-secret.py -CGO_ENABLED=0 go -C src build -o ../bin/mist-api . -CGO_ENABLED=0 go -C cli build -o ../bin/mist-cli . -docker build -f deploy/k3s/Dockerfile.api -t mist-api:folders-20261021 . -archive=$(mktemp /tmp/mist-deploy.XXXXXX.tar) -trap 'python3 -c "import os,sys; os.unlink(sys.argv[1])" "$archive"' EXIT -docker save -o "$archive" mist-api:folders-20261021 -# Existing approved Docker access permits host import, without changing sudo policy. -docker run --rm --mount type=bind,src=/,dst=/host alpine:3.16.3 chroot /host /usr/local/bin/k3s ctr images import "$archive" -k3s kubectl apply -f deploy/k3s/storage/shared.yaml -f deploy/k3s/team-rbac.yaml -f deploy/k3s/team-workload-policy.yaml -f deploy/k3s/mist-api.yaml -k3s kubectl rollout restart deployment/mist-api -n mist-system -k3s kubectl rollout status deployment/mist-api -n mist-system --timeout=120s -npm --prefix web-interface ci -npx --prefix web-interface tsc --noEmit -p web-interface/tsconfig.json -npm --prefix web-interface run build -docker run --rm --privileged --pid host --network host --mount type=bind,src=/,dst=/host alpine:3.16.3 chroot /host /usr/bin/nsenter -t 1 -m -n -p -r -w /bin/bash "$PWD/deploy/private/install-web.sh" "$PWD" -for attempt in $(seq 1 15); do - if curl --fail --silent http://127.0.0.1:8088/api/session; then exit 0; fi - sleep 2 -done -echo 'Private website session check failed' >&2 -exit 1 diff --git a/deploy/private/install-web.sh b/deploy/private/install-web.sh deleted file mode 100755 index 9d26d22..0000000 --- a/deploy/private/install-web.sh +++ /dev/null @@ -1,17 +0,0 @@ -#!/bin/bash -# Run as root in the main node's host mount namespace, after npm run build. -set -euo pipefail -repo=${1:-/home/utmist/Mist} -release=/srv/mist-web/releases/$(date -u +%Y%m%dT%H%M%S) -install -d -m 0755 "$release" -cp -r "$repo/web-interface/dist/." "$release/" -chmod -R a+rX "$release" -ln -s "$release" /srv/mist-web/current.new -mv -Tf /srv/mist-web/current.new /srv/mist-web/current -install -m 0644 "$repo/deploy/private/nginx.conf" /etc/nginx/conf.d/mist.conf -install -d -m 0755 /etc/systemd/system/nginx.service.d -install -m 0644 "$repo/deploy/private/nginx-mist-startup.conf" /etc/systemd/system/nginx.service.d/mist.conf -systemctl daemon-reload -nginx -t -systemctl enable nginx -systemctl reload nginx diff --git a/deploy/private/mist-portal-firewall.service b/deploy/private/mist-portal-firewall.service deleted file mode 100644 index 2c6fa97..0000000 --- a/deploy/private/mist-portal-firewall.service +++ /dev/null @@ -1,15 +0,0 @@ -[Unit] -Description=Mist portal access boundary for shared Tailscale researchers -After=network-pre.target tailscaled.service ufw.service k3s.service k3s-agent.service -Wants=tailscaled.service -PartOf=tailscaled.service k3s.service k3s-agent.service -Before=nginx.service - -[Service] -Type=oneshot -EnvironmentFile=/etc/mist-portal-firewall.conf -ExecStart=/usr/local/lib/mist/portal-firewall.sh -RemainAfterExit=yes - -[Install] -WantedBy=multi-user.target diff --git a/deploy/private/nginx-mist-startup.conf b/deploy/private/nginx-mist-startup.conf deleted file mode 100644 index 53a50e0..0000000 --- a/deploy/private/nginx-mist-startup.conf +++ /dev/null @@ -1,9 +0,0 @@ -# Install as /etc/systemd/system/nginx.service.d/mist.conf. -[Unit] -StartLimitIntervalSec=0 -Wants=network-online.target tailscaled.service k3s.service -After=network-online.target tailscaled.service k3s.service -[Service] -# Tailscale may need a few seconds to restore its address before bind(). -Restart=on-failure -RestartSec=5 diff --git a/deploy/private/nginx.conf b/deploy/private/nginx.conf deleted file mode 100644 index fae10b9..0000000 --- a/deploy/private/nginx.conf +++ /dev/null @@ -1,36 +0,0 @@ -server { - listen 127.0.0.1:8088; - listen 100.73.139.66:8088; - server_name utmist.tail459b5e.ts.net 100.73.139.66 localhost; - root /srv/mist-web/current; - index index.html; - client_max_body_size 0; - client_body_timeout 86400s; - add_header X-Content-Type-Options nosniff always; - add_header Referrer-Policy same-origin always; - add_header X-Frame-Options DENY always; - location /api/ { - proxy_pass http://10.43.1.116:3000/; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_request_buffering off; - proxy_read_timeout 86400s; - proxy_send_timeout 86400s; - } - location /auth/ { - proxy_pass http://10.43.1.116:3000; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - client_max_body_size 64k; - } - location /assets/ { - try_files $uri =404; - expires 1y; - add_header Cache-Control "public, immutable"; - } - location / { - add_header Cache-Control "no-cache"; - try_files $uri $uri/ /index.html; - } -} diff --git a/deploy/private/portal-firewall.sh b/deploy/private/portal-firewall.sh deleted file mode 100644 index 366a776..0000000 --- a/deploy/private/portal-firewall.sh +++ /dev/null @@ -1,31 +0,0 @@ -#!/bin/bash -# Root, actual host network namespace. Replace only Mist's chains atomically; -# preserve all other firewall tables/chains and existing LAN access. -set -euo pipefail -portal_port=${MIST_PORTAL_PORT:-8088} -for family in iptables ip6tables; do - restore="${family}-restore" - "$family" -w -N MIST-TAIL-INPUT 2>/dev/null || true - "$family" -w -N MIST-TAIL-FORWARD 2>/dev/null || true - { - printf '*filter\n-F MIST-TAIL-INPUT\n-F MIST-TAIL-FORWARD\n' - if [ "$family" = iptables ]; then - for trusted in 100.73.139.66 100.95.175.37 100.99.194.52; do - printf -- '-A MIST-TAIL-INPUT -s %s/32 -j RETURN\n' "$trusted" - printf -- '-A MIST-TAIL-FORWARD -s %s/32 -j RETURN\n' "$trusted" - done - if [ "$portal_port" != 0 ]; then - printf -- '-A MIST-TAIL-INPUT -p tcp --dport %s -j RETURN\n' "$portal_port" - fi - else - for trusted in fd7a:115c:a1e0::d401:8bda fd7a:115c:a1e0::f72a:af26 fd7a:115c:a1e0::2901:c2d7; do - printf -- '-A MIST-TAIL-INPUT -s %s/128 -j RETURN\n' "$trusted" - printf -- '-A MIST-TAIL-FORWARD -s %s/128 -j RETURN\n' "$trusted" - done - fi - # No IPv6 portal listener is enabled; do not permit an alternate bypass. - printf -- '-A MIST-TAIL-INPUT -j DROP\n-A MIST-TAIL-FORWARD -j DROP\nCOMMIT\n' - } | "$restore" --wait 10 --noflush - "$family" -w -C INPUT -i tailscale0 -j MIST-TAIL-INPUT 2>/dev/null || "$family" -w -I INPUT 1 -i tailscale0 -j MIST-TAIL-INPUT - "$family" -w -C FORWARD -i tailscale0 -j MIST-TAIL-FORWARD 2>/dev/null || "$family" -w -I FORWARD 1 -i tailscale0 -j MIST-TAIL-FORWARD -done diff --git a/deploy/private/prepare-internal-secret.py b/deploy/private/prepare-internal-secret.py deleted file mode 100644 index 4c5c528..0000000 --- a/deploy/private/prepare-internal-secret.py +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/python3 -"""Reuse the internal auth token on ordinary deployments. Never print its value.""" -import base64 -import json -import os -from pathlib import Path -import secrets -import subprocess - -env = {**os.environ, 'KUBECONFIG': os.environ.get('KUBECONFIG', '/home/utmist/.kube/config')} -command = ['k3s', 'kubectl'] -token_file = Path('/home/utmist/.config/mist/internal-token') -existing = subprocess.run(command + ['get', 'secret', 'mist-internal-auth', '-n', 'mist-system', '-o', 'json'], env=env, capture_output=True, text=True) -if existing.returncode == 0: - token = base64.b64decode(json.loads(existing.stdout)['data']['MIST_INTERNAL_TOKEN']).decode() -else: - if 'NotFound' not in existing.stderr: - raise SystemExit('Cannot inspect internal Secret; refusing to replace credentials') - token = token_file.read_text() if token_file.exists() else secrets.token_urlsafe(48) -if len(token) < 32: - raise SystemExit('Internal token is invalid') -token_file.parent.mkdir(parents=True, exist_ok=True) -token_file.write_text(token) -token_file.chmod(0o600) -body = {'apiVersion': 'v1', 'kind': 'Secret', 'metadata': {'name': 'mist-internal-auth', 'namespace': 'mist-system'}, 'stringData': {'MIST_INTERNAL_TOKEN': token}} -result = subprocess.run(command + ['apply', '-f', '-'], input=json.dumps(body), env=env, capture_output=True, text=True) -if result.returncode: - raise SystemExit('Cannot apply internal Secret') -print('Internal auth Secret ready; credentials preserved') diff --git a/deploy/private/verify-portal-firewall.sh b/deploy/private/verify-portal-firewall.sh deleted file mode 100644 index 3bf6417..0000000 --- a/deploy/private/verify-portal-firewall.sh +++ /dev/null @@ -1,52 +0,0 @@ -#!/bin/bash -# Root in the host namespace. Exercise the installed enforcement chains with -# an isolated, simulated untrusted Tailnet source; no real peer is impersonated. -set -euo pipefail -namespace=mist-firewall-check -source_ip=100.127.254.253 -if ip netns list | awk '{print $1}' | grep -qx "$namespace"; then - echo 'Verification namespace already exists; refusing to replace it' >&2; exit 1 -fi -if ip route show "$source_ip/32" | grep -q .; then - echo 'Verification source already routed; refusing to replace it' >&2; exit 1 -fi -iptables -S MIST-TAIL-INPUT >/dev/null -cleanup() { - iptables -w -D INPUT -i mist-fw-host -j MIST-TAIL-INPUT 2>/dev/null || true - iptables -w -D INPUT -i mist-fw-host -j ACCEPT 2>/dev/null || true - iptables -w -D FORWARD -i mist-fw-host -j MIST-TAIL-FORWARD 2>/dev/null || true - ip route del "$source_ip/32" dev mist-fw-host 2>/dev/null || true - ip netns del "$namespace" 2>/dev/null || true - ip link del mist-fw-host 2>/dev/null || true -} -trap cleanup EXIT -ip netns add "$namespace" -ip link add mist-fw-host type veth peer name mist-fw-guest -ip link set mist-fw-guest netns "$namespace" -ip addr add 198.18.0.1/30 dev mist-fw-host -ip link set mist-fw-host up -ip route add "$source_ip/32" dev mist-fw-host -ip netns exec "$namespace" ip addr add 198.18.0.2/30 dev mist-fw-guest -ip netns exec "$namespace" ip addr add "$source_ip/32" dev lo -ip netns exec "$namespace" ip link set lo up -ip netns exec "$namespace" ip link set mist-fw-guest up -ip netns exec "$namespace" ip route add default via 198.18.0.1 -# Emulate the real tailscale0 dispatch with a test-only interface. Accept after -# RETURN avoids Tailscale's deliberate rejection of 100/10 on non-tail devices. -iptables -w -I INPUT 1 -i mist-fw-host -j ACCEPT -iptables -w -I INPUT 1 -i mist-fw-host -j MIST-TAIL-INPUT -iptables -w -I FORWARD 1 -i mist-fw-host -j MIST-TAIL-FORWARD -code=$(ip netns exec "$namespace" curl --interface "$source_ip" --max-time 4 -sS -o /dev/null -w '%{http_code}' http://100.73.139.66:8088/api/session) -[ "$code" = 200 ] -echo 'PASS untrusted source reaches portal' -for target in 100.73.139.66:22 100.73.139.66:6443 100.73.139.66:30491 10.43.1.116:3000; do - if ! ip netns exec "$namespace" python3 -c 'import socket,sys; host,port=sys.argv[1].split(":"); s=socket.socket(); s.settimeout(2); s.bind((sys.argv[2],0)) -try: - s.connect((host,int(port))) -except OSError: - sys.exit(0) -sys.exit(1)' "$target" "$source_ip"; then - echo "FAIL untrusted source reached $target" >&2; exit 1 - fi - echo "PASS untrusted source denied $target" -done diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index 2cad2d2..0000000 --- a/docs/README.md +++ /dev/null @@ -1,40 +0,0 @@ -# Mist Documentation - -This directory contains the documentation site for Mist, powered by **Hugo** and the **Hextra** documentation theme. - -## Viewing Documentation Locally - -To preview the documentation locally with live-reload: - -```bash -cd docs -hugo server -``` - -The site will be available at `http://localhost:1313/docs/`. - -To build the static site (outputting to `web-interface/public/docs` for the web portal): - -```bash -cd docs -hugo -``` - -## Documentation Guides - -Documentation source files are located in `content/`: - -| Guide | Description | -|---|---| -| [Department operating guide](content/department-rollout.md) | Sign in, manage teams, upload data, submit/download jobs | -| [Architecture](content/architecture.md) | Service topologies, boundaries, and codebase layout | -| [k3s setup](content/k3s-setup.md) | Cluster setup, worker nodes, and hardware acceleration | -| [Tailscale and SSH](content/tailscale-ssh.md) | Remote secure networking and administrator SSH | -| [Storage](content/storage.md) | Dataset and model shared storage pools | -| [Developer setup](content/development.md) | Local frontend and backend development | -| [Testing](content/testing.md) | Automated tests and evidence runs | -| [Design system](content/design-system.md) | Portal design principles and components | -| [Frontend architecture](content/frontend-architecture.md) | UI layout and backend offloading plan | -| [Execution record](content/department-rollout-execution.md) | Department rollout status and validation | -| [Cleanup record](content/repository-cleanup.md) | Repository maintenance history | -| [Historical archive](content/archive/_index.md) | Archived previous-generation guides and plans | diff --git a/docs/agents/architecture/scheduler.md b/docs/agents/architecture/scheduler.md index f1c55cd..5a65b74 100644 --- a/docs/agents/architecture/scheduler.md +++ b/docs/agents/architecture/scheduler.md @@ -269,7 +269,7 @@ under sustained higher-priority demand; the simulator must report that separatel ## Reconciliation The scheduler and the executor agree through events plus regular full -comparisons (see [executors.md](executors.md#reconciliation)): +comparisons (see [executors.md](executors.md#cleanup-and-reconciliation)): | Durable state | Authoritative observation | Action | |---|---|---| diff --git a/docs/agents/simple-storage-execution.md b/docs/agents/simple-storage-execution.md deleted file mode 100644 index 7eaa2ca..0000000 --- a/docs/agents/simple-storage-execution.md +++ /dev/null @@ -1,60 +0,0 @@ -# Shared folders and Ethernet storage — October 21, 2026 - -## Scope - -Implement on `feat/simple-storage-ethernet-20261021` from main `ba2bb86`. -The user authorized implementation and verification on both machines, with fresh -storage, no data migration and no large storage tests. Preserve main's Go auth. - -## Target - -- Dedicated direct Ethernet: NVIDIA `enp6s0` at `192.168.240.1/30`, QuietBox - `enp200s0` at `192.168.240.2/30`. No gateway; retain existing LAN/cluster paths. -- QuietBox ordinary directory `/srv/mist-data`, exported over NFS on the direct - link; no filesystem images or formatting. Keep old storage as rollback material. -- Team layout: `teams//datasets/{common,members,...}` and - `teams//results/{common,members}`. Only trusted API metadata lies at the root. -- Existing dataset/model allowance fields remain API-compatible but become - advisory. Actual disk free space bounds uploads. CPU/GPU/runtime limits remain - enforced. Team grants and scoped workload mounts remain authoritative. -- A small root-owned local provisioning agent creates directories, refuses unsafe - paths/mounts, and publishes logical byte usage. The API validates readiness and - serves the usage record without recursively scanning NFS on every request. -- Preserve `/inputs`, `/outputs`, dataset IDs and wire fields for new jobs. - -## Execution - -1. Establish baseline tests and inspect running Jobs, mounts and network profiles. -2. Persist Ethernet addresses; verify both directions and measure a small transfer. -3. Replace image provisioning with folder provisioning; adapt backend, UI and tests. -4. Install fresh NFS root/provisioner, prepare current team policies there and - switch storage volumes during a controlled API pause. Do not delete old disks. -5. Verify small uploads/downloads, cross-team isolation, and actual NVIDIA/TT jobs. -6. Record sanitized evidence, document operations, commit and push a reviewable PR. - -## Verification boundaries - -No hundreds-of-GB allocations/uploads, host-disk formatting, unrelated auth -redesign or reconfiguration of cluster node addresses. Keep secrets outside Git. -Main's independent-review rule still applies to merging the eventual PR. - -## Completed - -- Both persistent Ethernet profiles configured and bidirectional connectivity - verified. A 16 MiB direct-I/O NFS sample measured 43 MB/s write, 101 MB/s read. -- Fresh ordinary-folder storage deployed; no old-data migration or deletion. - New NFS 4.1 volume bindings avoid reusing the old 4.2 LAN transport. -- Folder provisioner, backend usage/readiness, advisory UI and developer fake - backend updated. Existing grants, scopes and accelerator admission retained. -- Latest main's Go auth required targeted compatibility/session fixes before - deployment: valid database sessions, existing scrypt hashes/schema, and inactive - accounts. Existing credentials were checked privately on a database snapshot. -- Real regular-member upload/download/isolation checks, NVIDIA/TT training, - checkpoint reload/download and browser review passed with small files. -- [Sanitized evidence](../../deploy/k3s/evidence/storage-2026-10-21/README.md) and - [operations](../../deploy/k3s/storage/README.md) contain the current contracts. - -Human-authored October 9 storage operating snapshots under `docs/content` still -describe the previous image-backed release. README/operations now point to the -current guide; maintainers should reconcile those snapshots when updating human -documentation. This task follows the repository's README/agent-doc editing scope. diff --git a/docs/content/_index.md b/docs/content/_index.md deleted file mode 100644 index c11a6df..0000000 --- a/docs/content/_index.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -type: docs -cascade: - type: docs -title: "Documentation Overview" -weight: 1 ---- - -# UTMIST Compute Platform Documentation - -Current main-branch documentation, verified against the two running machines on -**October 9, 2026**. Addresses and versions describe this installation; adjust -host-specific settings when provisioning another environment. - -## Find the right guide - -| Task | Guide | -|---|---| -| Sign in, manage teams, upload data, submit/download jobs | [Department operating guide](department-rollout.md) | -| Understand services and source layout | [Architecture](architecture.md) | -| Install/inspect k3s, join a worker, verify accelerator allocation | [k3s setup](k3s-setup.md) | -| Connect privately and administer either host | [Tailscale and SSH](tailscale-ssh.md) | -| Understand dataset/model pools and physical capacity | [Storage](storage.md) | -| Build, deploy, restart or troubleshoot services | [Deployment operations](../../deploy/private/README.md) | -| Develop locally with the existing authenticated API | [Developer setup](development.md) | -| Authenticate and submit from a terminal | [CLI](../../cli/docs/setup.md) | -| Run automated checks and read recorded live evidence | [Testing](testing.md) | -| Maintain UI components and interactions | [Design system](design-system.md) | -| Understand frontend architecture and backend offloading | [Frontend architecture](frontend-architecture.md) | -| Read completed scope, exclusions and release verification | [Execution record](department-rollout-execution.md) | -| See what repository cleanup removed and verified | [Cleanup record](repository-cleanup.md) | - -## Access and execution - -```text -Browser / CLI → Tailscale portal :8088 → Nginx → Go API → k3s - | | - Better Auth + team policy - | - CPU/NVIDIA or Tenstorrent Job - | - scoped dataset/model NFS mounts on QuietBox -``` - -Mist login is separate from Tailscale membership and Linux SSH login. Researchers -use the portal; administrator SSH is restricted by the installed host rules. -The worker reaches the control plane and storage through LAN addresses. - -[Earlier plans and single-owner guides](archive/_index.md) are historical only. -Current guidance does not require Redis, an SSH session to launch a job, a public -cloud load balancer, or permission to approve every tagged image. diff --git a/docs/content/architecture.md b/docs/content/architecture.md deleted file mode 100644 index 8a3a755..0000000 --- a/docs/content/architecture.md +++ /dev/null @@ -1,78 +0,0 @@ ---- -title: "Architecture & Services" -weight: 20 ---- - -# Architecture - -Current private department installation, verified October 9, 2026. -[User/admin policy](department-rollout.md) and [k3s details](k3s-setup.md) cover -its operating contracts. - -```mermaid -flowchart LR - Client[Browser or authenticated CLI] --> Portal[Nginx private Tailscale portal :8088] - Portal --> API[Go API, built-in auth and single admission controller] - API --> Auth[Local SQLite PVC] - API --> State[Team policy ConfigMaps and suspended Jobs] - State --> K8s[k3s scheduler and device operators] - K8s --> Main[CPU and NVIDIA node] - K8s --> TT[QuietBox Tenstorrent worker] - Main --> Store[Scoped NFS dataset and model pools on QuietBox] - TT --> Store -``` - -## Job flow - -1. Nginx serves the built SPA and forwards `/api` and `/auth` to Go. -2. Go verifies the account via its built-in authentication service. Go checks current team membership, image - registry rules, storage scope and resource/runtime/queue policy. -3. The API persists a suspended Job in the team's namespace and prepares its - scoped output directory. A single controller admits queued jobs fairly. -4. Kubernetes places the admitted pod on the appropriate node. NVIDIA's plugin - reserves whole GPUs; Tenstorrent DRA reserves whole n300 boards. -5. containerd starts the image. `/inputs` is read-only; `/outputs` is the job's - own directory in the model pool. Logs/status come from actual pods and Jobs. -6. Jobs and output files remain after exit. Cancellation or revocation stops - pods and frees reservations while retaining history and outputs. - -Nginx currently proxies to one API service; the API must remain one replica -because it owns a single admission controller. Kubernetes placement uses -resource requests and device availability. It does not automatically turn one -submitted model into distributed training across both accelerator types. - -## Source organization - -| Location | Responsibility | -|---|---| -| `src/main.go`, `app.go` | Process lifecycle, HTTP routes and controller ownership | -| `src/jobs_http.go`, `http.go`, `job_types.go` | Job endpoints, shared responses and wire types | -| `src/kubernetes.go`, `job_states.go`, `outputs.go` | Container specification, execution/state and retained local-output initialization | -| `src/auth.go`, `member_auth.go` | In-process SQLite authentication, session management, identity and authoritative authorization | -| `src/teams.go`, `team_queue.go`, `team_runtime.go`, `team_storage.go` | Membership/policy, fair admission, isolation and scoped storage | -| `src/shared_storage.go`, `hardware.go`, `images.go` | Uploads/results, inventory and image/runtime profiles | -| `cli/` | Authenticated terminal client; no execution logic | -| `web-interface/` | React portal, typed API client, account/team state and UI components | -| `deploy/k3s/` | Manifests, accelerator diagnostics, live verifiers and evidence | -| `deploy/k3s/storage/` | QuietBox provisioning/migration/growth and service unit | -| `deploy/private/` | Image/web deployment, Nginx and host access rules | -| `docs/` | Current guides; historical records are under `docs/archive/` | - -API and CLI are separate Go modules joined by `go.work`. There is no Redis -scheduler or Docker supervisor in the current code. Historical account jobs and -local PVCs remain readable through explicit legacy-workspace compatibility. -Removing the old executor does not delete these Kubernetes resources. - -## State and boundaries - -- Better Auth uses a main-node local SQLite WAL PVC, not NFS. -- Team policies and queue state live in Kubernetes; no separate queue database. -- Every team has separate bounded dataset and model/result NFS filesystems. - Member/common folders share their corresponding team's pool. -- Workloads receive scoped mounts, no Kubernetes token, constrained host paths - and a network policy. Accelerator drivers still share the host kernel. -- Portal/SSH use Tailscale. Cluster API, Flannel and NFS use the LAN. - -There is one control plane, one auth instance and one storage host. Availability -and arbitrary-model/distributed-training support are limited as described in the -[operating guide](department-rollout.md). diff --git a/docs/content/archive/_index.md b/docs/content/archive/_index.md deleted file mode 100644 index 7c8bcdf..0000000 --- a/docs/content/archive/_index.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -title: "Historical Records" -weight: 200 -sidebar: - open: false ---- - -# Historical records - -These files preserve earlier design decisions and verification phases. They are -not setup or operating instructions for the current main branch. Some commands -refer to removed prototypes; their original source remains in Git history. - -| Record | Phase | -|---|---| -| [Foundation execution plan](foundation-execution-plan.md) | Initial work plan and progress | -| [Shared storage/deployment plan](shared-storage-deployment-plan.md) | Original parts 4 and 5 | -| [Kubernetes pilot](kubernetes-pilot.md) | October 3 executor snapshot | -| [Local job foundation](local-job-foundation.md) | October 8 single-owner platform | -| [Complete foundation](complete-foundation.md) | Account-only platform before teams | -| [k3s setup history](k3s-setup-history.md) | Original NVIDIA installation and QuietBox join | -| [k3s deployment history](k3s-deployment-history.md) | Earlier accelerator/API acceptance | - -The old main is preserved on -[`archive/main-before-k3s-20261009`](https://github.com/UTMIST/Mist/tree/archive/main-before-k3s-20261009). -The pre-cleanup department release is commit -[`3431ffd`](https://github.com/UTMIST/Mist/tree/3431ffdb713fac72881fc7f61f2750a82fe3f916). -Use the [current documentation](../_index.md) for this installation. diff --git a/docs/content/archive/complete-foundation.md b/docs/content/archive/complete-foundation.md deleted file mode 100644 index fb0aa37..0000000 --- a/docs/content/archive/complete-foundation.md +++ /dev/null @@ -1,227 +0,0 @@ -> **Historical record — superseded.** This text describes an earlier phase. -> Commands and claims below are retained as history, not current setup instructions. -> Start with the [current documentation index](../_index.md) and -> [department operating guide](../department-rollout.md). - -> Current department storage and portal settings: [department rollout](../department-rollout.md). -> This document records the earlier foundation; its 100 GiB pilot was later expanded sparsely. - -# Mist foundation: historical operating snapshot - -> **Historical account-only snapshot.** The [department rollout](../department-rollout.md) -> supersedes these instructions: team membership, scoped folders, enforced quotas, -> self-service images and the new portal are implemented. Use that guide and -> [current deployment operations](../../../deploy/private/README.md) for current behavior. -> The remaining sections record the preceding foundation, including its limitations. - -Implemented on `feat/local-job-foundation`, October 8–9, 2026. This continues the -[original foundation](local-job-foundation.md) with shared datasets, output -retrieval, separate member accounts and persistent private hosting. - -## Use the website - -Open **http://100.73.139.66:8088** from a device on the existing Tailscale network. -The alternative MagicDNS address is http://utmist.tail459b5e.ts.net:8088. - -1. Sign in with a Mist account, independent of the machine's Linux/SSH login. -2. Open **Datasets**, upload one file or a ZIP, and wait for **Ready**. -3. Open **Jobs**, choose CPU, NVIDIA GPUs or Tenstorrent boards, an approved - image, and optionally the uploaded dataset. Submit a script or container. -4. Follow the actual status, placement and logs. Save files under `/outputs`. -5. Click **Files** on the job to download results. Results remain after exit. - -The initial administrator's credentials are stored locally in -`/home/utmist/.config/mist/admin-bootstrap.json` (mode 0600), outside Git. View -that file locally, sign in, then change the password on **Account**. The file -contains a bootstrap password, not a password that automatically tracks changes. -Do not distribute its auth secret. Administrators add members on **Account**; -members can change passwords there. Deactivation revokes sessions. Public signup -is disabled. There is no email delivery/password-reset email service configured; -an administrator can reset a member password through Better Auth's authenticated -`/auth/admin/set-user-password` endpoint. Admin roles are managed by that service, -never by editing one's profile. - -**HTTPS status:** Tailscale Serve is disabled on this tailnet. Its CLI reports -that an account administrator must enable Serve in the Tailscale console. The -current HTTP endpoint is bound to localhost and the Tailscale IP only. Remote -traffic uses Tailscale's encrypted network, but browsers do not treat HTTP as a -secure origin. No public Funnel/domain was enabled. After enabling Serve, run -`tailscale serve --bg --https=443 http://127.0.0.1:8088`, change -`MIST_SITE_ORIGIN` in the auth deployment to `https://utmist.tail459b5e.ts.net`, -and restart auth so cookies use Secure. The HTTPS origin is already in the -trusted-origin lists. Check Serve status and browser login before distributing -that URL. Do not claim HTTPS is available before that verification. - -## Running architecture - -```text -Team browser / CLI / either SSH host - │ private Tailscale network - ▼ -Main node Nginx :8088 ── static production React build - │ /api/* and /auth/* - ▼ -Go Mist API, Kubernetes Deployment (mist-system) - ├── Better Auth service ── local SQLite PVC on main node - ├── dataset metadata/uploads/results ── shared NFS PVC - └── Kubernetes Jobs (mist namespace) - ├── Main node: CPU or 1–2 NVIDIA GPUs - └── QuietBox: 1–4 TT boards, two chips per board - │ - selected /inputs (read-only), own /outputs (writable) - │ - QuietBox /srv/mist-storage (NFSv4 over current LAN) -``` - -Nginx proxies directly to the existing API ClusterIP `10.43.1.116:3000`; -production does not rely on Vite, SSH sessions or the development port-forward. -The local preview at `127.0.0.1:3001` remains available and now uses real login. -Kubernetes schedules jobs using the requested resources and node profiles. -TT allocations use DRA boards. NVIDIA allocations use whole GPUs. Neither device -sharing within a GPU/board nor distributed mixed-backend training is implemented. - -## Storage layout and limits - -QuietBox exports `/srv/mist-storage` to only LAN IPs `10.0.0.175` and -`10.0.0.112`, using NFSv4 and root squash (anonymous UID/GID 65532). UFW admits -TCP 2049 from those two addresses. Its router Ethernet connection stays intact. -The main machine continues using its existing Wi-Fi connection. - -A dedicated **100 GiB sparse ext4 image** on QuietBox's existing NVMe backs the -store. No physical disk was formatted. Actual usable capacity is slightly smaller -because of filesystem overhead. `/etc/fstab` mounts it at boot; `nfs-server` -is enabled. Keep the NVMe's free space above this pilot budget. - -```text -/srv/mist-storage/ - metadata/datasets/.json owner, checksum, size, ready state - .uploads// staging, removed on failure/completion - datasets//content/ selected input files, read-only job mount - jobs//outputs/ job's own persistent results - legacy/{cpu,nvidia,tenstorrent}// copied original results -``` - -The upload limit is 2 GiB per file/archive and 2 GiB expanded. ZIPs reject unsafe -paths, symlinks, special files, duplicate files and more than 10,000 entries. -Uploads stream to staging, are checksummed and published atomically. A failed -upload never becomes Ready. Metadata, unrelated jobs and other datasets are not -mounted into a workload. No archive is executed by the upload service. - -`/inputs` and `MIST_INPUT_DIR` identify an attached dataset. `/outputs` and -`MIST_OUTPUT_DIR` identify persistent outputs; `/checkpoints/` remains an -alias. Jobs without a selected dataset have no `/inputs` mount. Image entrypoints -and working directories remain unchanged unless explicitly overridden. - -The budget applies to all shared files together, not separately to each PVC. -Two static RWX PV/PVCs expose the same backing store to different namespaces. -A PVC request alone is not a hard quota; the image's filesystem enforces capacity. -Upload admission leaves 1 GiB spare, but running jobs can still fill storage: -write errors must be handled by job code. Per-member/job disk quotas are future -work. Do not store SQLite on NFS. - -Original local checkpoint PVCs remain intact. Their completed outputs were -copied to `legacy/` and remain downloadable through retained job IDs. New jobs -use shared storage. QuietBox is now a storage dependency: if it is offline, mounts, -uploads, downloads and jobs that need those files may wait. NFS is not a backup. - -### Cleanup and retention - -Nothing deletes datasets or results automatically. Delete unneeded datasets -through the website; active-job references prevent deletion. For job results, -first verify the owning job has finished and no pod can write to it, then archive -and remove only that job's directory from `jobs//outputs` on QuietBox. -Keep the parent directory for retained Kubernetes Job history. Do not delete -storage metadata, the mounted image file, or entire legacy PVC directories. -Monitor `df -h /srv/mist-storage` on QuietBox. Remove only test output archives -that have already been saved elsewhere. Legacy copies use additional space; -original PVCs are intentionally not removed by this migration. - -## Authentication and ownership - -Better Auth 1.7.7 handles passwords, sessions, database migrations, role checks, -rate limiting and revocation. Go verifies each session with the auth service; -unavailable auth returns 503, anonymous access 401. Browser mutations require -trusted origins. Cookies are HttpOnly and SameSite=Lax; Secure requires the HTTPS -configuration described above. Session duration is seven days. - -New members receive stable owner labels derived from their auth user ID. Existing -`utmist` jobs are mapped to the initial administrator's configured email. Other -members cannot list, inspect, cancel, read logs from or download that owner's -jobs, and cannot use/delete its datasets. Hardware totals are visible to members -because those devices are shared. Administrators' membership permissions do not -automatically give access to another member's job files. - -This is a private, trusted team pilot. Approved containers, TT's read-only host -runtime mounts and NFS Unix permissions do not constitute a hardened hostile -multi-tenant sandbox. Public hosting, hostile code isolation, member quotas, -credits, priority/fair queues, Jupyter and job chaining require later work. - -## CLI - -```bash -export MIST_API_URL=http://100.73.139.66:8088/api -bin/mist-cli auth login --email your-member-email@example.org -bin/mist-cli job list --all -bin/mist-cli job submit train.py --compute NVIDIA --devices 1 -bin/mist-cli auth logout -``` - -Login prompts without echoing the password; `--password-stdin` supports controlled -automation. Cookies are saved atomically in a mode-0600 CLI config. Logout revokes -the session. Fake legacy tokens are no longer used. An alternate config can be -selected with `--config`; never paste a session cookie into a public document. - -## API additions - -All data routes require an authenticated cookie when `MIST_AUTH_URL` is configured. - -| Route | Function | -|---|---| -| `GET /session` | Auth enabled flag and current member; no session token | -| `/auth/*` | Same-origin proxy to maintained Better Auth endpoints | -| `GET /storage` | Actual capacity/free bytes and upload limit | -| `GET /datasets` | Current owner's ready datasets | -| `POST /datasets?filename=data.csv&name=Example` | Raw streaming file upload | -| `POST /datasets?filename=data.zip&format=zip` | Checked ZIP upload/extraction | -| `GET /datasets/` | Own ready dataset metadata | -| `DELETE /datasets/` | Delete own dataset if no active job references it | -| `POST /jobs` with `dataset_id` | Mount own ready dataset read-only | -| `GET /jobs//files` | Own job's regular result files | -| `GET /jobs//files/download?path=model.pt` | Stream attachment/download | - -The website prefixes Go routes with `/api`; auth stays at `/auth`. Listing limits -outputs to 5,000 files/10,000 entries/20 directory levels. Download paths use Go's -`os.Root` to prevent traversal and escaping symlinks. Save plain regular files -inside `/outputs` if you want them to be listed/downloaded. - -## Deployment, restart and rollback - -See [deployment scripts](../../../deploy/private/README.md). k3s server/agent, NFS and -Nginx are system services. Kubernetes restores API/auth deployments. The frontend -is a static release, with `/srv/mist-web/current` pointing at the active build. -Deployment configuration, RBAC, claim templates and image policies are checked -in; bootstrap credentials and cluster tokens are not. - -Rollback frontend by repointing `current` to an earlier retained release and -reloading Nginx. Rollback an API image with `kubectl set image` after importing -that image. Keep auth, shared storage and login enabled during rollback; the old -unauthenticated foundation API is not a suitable private member-service rollback. -Database migrations require a pre-upgrade backup and version-specific review. - -## Verification - -Repeatable checks: - -- `deploy/k3s/verify_complete_browser.mjs`: actual login, dataset upload, CPU, - two NVIDIA GPUs and a TT board consuming the same uploaded dataset, downloads, - member creation/password change/isolation/deactivation, denied anonymous signup - and cross-origin writes. Uses protected local credentials; logs no secrets. -- `deploy/k3s/verify_quietbox_submission.py`: authenticated submission from - QuietBox, NVIDIA training assigned to the main machine, downloaded loss metrics. -- `src/shared_storage_test.go`: upload limits/atomic publication, ZIP attacks, - read-only dataset binding, escaping download symlinks, ownership and auth outage. -- Existing job/device/queue/cancellation/image tests remain in the repository. - -Detailed evidence is under `/home/utmist/mist-complete-results-2026-10-09`; -final verified results are recorded in `deploy/k3s/complete-results.json`. An accelerator smoke test proves this small -training workload; it does not prove arbitrary model/runtime compatibility. diff --git a/docs/content/archive/foundation-execution-plan.md b/docs/content/archive/foundation-execution-plan.md deleted file mode 100644 index 48eef71..0000000 --- a/docs/content/archive/foundation-execution-plan.md +++ /dev/null @@ -1,390 +0,0 @@ -> **Historical record — superseded.** This text describes an earlier phase. -> Commands and claims below are retained as history, not current setup instructions. -> Start with the [current documentation index](../_index.md) and -> [department operating guide](../department-rollout.md). - -# Mist foundation execution plan - -Recorded: October 8, 2026. This file preserves the agreed scope and should be -updated with implementation progress, test evidence, and remaining work. - -## Goal and decisions - -Make the local website usable for running container jobs on the existing -NVIDIA and Tenstorrent machines. Start with the working Kubernetes pilot, -rather than rebuilding execution. The user has requested implementation on -a new branch, organized code, and proper documentation. - -Today's scope is tasks 1–3 plus persistent output folders and verification. -Shared dataset storage, uploads, and result downloads are the next foundation -task. Website deployment and login follow the foundation. - -Decisions already made: - -- Develop and test locally. Team members can use the CLI over SSH or open the - local website through an SSH tunnel. A public website is unnecessary now. -- Mist API creates Kubernetes Jobs; k3s schedules them; containerd runs the - images. Normal job execution does not use SSH. -- Images are built and pushed to a registry, then selected by reference. A - browser image archive uploader or image build service is outside today's scope. -- CPU and NVIDIA support approved custom images. Tenstorrent initially uses - its existing tested image and host runtime profile. -- NVIDIA allocations are whole GPUs. Tenstorrent allocations are whole n300 - boards, with two accelerator chips per board. -- Start with persistent node-local results folders. Add shared storage later. -- For shared storage, the preferred initial host is Tenstorrent because it has - more free disk space. NFS over the existing network is the simplest proposal. -- Wi-Fi is adequate for initial testing. A future second Ethernet cable can - provide a dedicated storage link. Preserve Tenstorrent's existing router link: - its k3s configuration and network access currently depend on it. -- Preserve existing work and Git history. Implement on a new branch. Retiring - or merging the existing main branch is a later repository decision. -- No permission prompts are needed for the agreed implementation and tests. - Do not introduce unrelated public exposure, account changes, or disk formatting. - -## Architecture - -```text -Local website / CLI - | - v - Mist API - | - v - k3s cluster - / \ - v v -NVIDIA PC Tenstorrent -containerd containerd - | | - v v -Job container + allocated accelerator + persistent results folder -``` - -The full whiteboard plan also includes membership/authentication, credits, -priority policy, a storage box, Jupyter, job chaining, and optional agents. -Kubernetes supplies placement and device allocation. Mist supplies admission, -user policy, UI, dataset handling, and artifact access. Selecting an accelerator -does not convert CPU code into CUDA or Tenstorrent code, or distribute one model -across machines automatically. - -## Current baseline - -Repo: `/home/utmist/Mist`. Before this implementation, the branch is -`fix/local-runtime-setup` and contains substantial uncommitted k3s pilot work. -Preserve these changes when creating the new branch. - -An independent main preview exists at `/home/utmist/Mist-main-preview`. -Use main's reusable UI/components/types where helpful. Its Jobs data and several -actions are prototypes, and its legacy Docker/Redis executor is incomplete. - -| Machine | Kubernetes node | Tailnet IP | Cluster LAN IP | Resources | -| --- | --- | --- | --- | --- | -| Main/NVIDIA | `utmist-z1opa08` | `100.73.139.66` | `10.0.0.175` | 2 NVIDIA RTX A4000 GPUs | -| QuietBox | `utmist-tt` | `100.95.175.37` | `10.0.0.112` | 4 n300 boards / 8 Wormhole chips | - -Both nodes report Ready with k3s `v1.36.4+k3s1`. Workload namespace is `mist`; -API namespace is `mist-system`. - -- API already runs inside k3s. Local access is `http://127.0.0.1:3000`. -- Working Jobs page: `http://127.0.0.1:3001/jobs`. -- Vite proxies `/api` to the API; this proxy does not ship in the static build. -- User services: `mist-api-forward.service`, `mist-web.service`. -- Main preview is separate: UI port 3011, API port 3010. -- Existing Jobs page supports scripts, accelerator counts, training checks, - real states, logs, and cancellation. -- API is a trusted local, single-owner pilot. Owner is configuration, not an - authenticated identity. Authentication and tenant isolation remain work. -- Existing checkpoints are in three node-local `local-path` PVCs. Their declared - request is 256 MiB each; this is not a meaningful dataset storage budget. -- Shared storage and dataset upload/download services have not been configured. - -Storage readings taken during planning: - -| Machine | Filesystem capacity | Used | Available | -| --- | --- | --- | --- | -| NVIDIA | 439 GiB | 87 GiB | 330 GiB | -| Tenstorrent NVMe | 3.6 TiB | 1.6 TiB | 1.9 TiB | - -NVIDIA also has an unmounted ~447 GiB `/dev/sdb`. Its contents are unknown; -do not treat it as empty or format it as part of this plan. - -NVIDIA currently reaches QuietBox over Wi-Fi via the router. QuietBox's active -router Ethernet link negotiates 1 Gbps. Both machines have unused 10 Gbps -Ethernet hardware; a future direct link requires compatible ports/cable and -verification. Being on the same tailnet or k3s cluster does not share disks. - -## Implementation order - -### A. Establish the branch and API contracts - -- [x] Create a new foundation branch from the current working checkout. -- [x] Preserve existing pilot changes and inspect main for useful components. -- [x] Separate inventory and submission validation from HTTP/UI presentation. -- [x] Document new response/request shapes before connecting UI controls. -- [x] Keep changes reviewable and avoid unrelated rewrites. - -### B. Task 2: hardware discovery and availability - -Proposed endpoint: `GET /hardware`. Publish the final contract with the code. - -- [x] Report node readiness, accelerator type, allocation unit, total, - allocated, and currently available counts. -- [x] Count NVIDIA allocations from scheduled, nonterminal Kubernetes pods, - including workloads outside Mist. Do not subtract every submitted job. -- [x] Count Tenstorrent devices from ResourceSlices and actual DRA claim - allocations. Account for reservations while a workload starts. -- [x] Label TT counts as boards and show two chips per n300 board. -- [x] Treat machine/inventory failures explicitly; do not present unknown - resources as confidently available. -- [x] Give the API only the additional read permissions required for inventory. -- [x] Keep k3s responsible for actual allocation. UI availability is a snapshot; - a submitted job may wait even if devices looked free moments earlier. - -TT's current vendor ResourceSlice memory capacity is incorrect. Do not use that -value to display accelerator memory or determine placement. The current system -has neither GPU fractions nor arbitrary TT chip sharing. - -### C. Task 3: container image execution - -- [x] Expose approved image choices and explicit image references, command, - arguments, and CPU/memory/deadline controls through typed API/UI code. -- [x] Keep image approval on the server. An approved image can still fail to - pull or be incompatible; report the actual error. -- [x] Preserve the image's working directory. The current executor overrides - every workload with `/tmp`, which can break `python train.py` in an image - whose code lives in `/app`. -- [x] Define command/argument behavior, including whether the image's default - ENTRYPOINT/CMD may run. Kubernetes command/args are arrays; do not silently - treat every string as a shell program. -- [x] Retain script and training-check modes with their tested runtime profiles. -- [x] Support approved custom CPU/NVIDIA images. Keep the tested TT image/profile - restriction visible instead of promising arbitrary TT image compatibility. -- [x] Configure/document image pull credentials where needed; do not ask users - to paste registry credentials into a job form. -- [x] Verify an actual custom image with packaged code, rather than testing - only scripts injected into existing base images. A local fixture image may - be imported into containerd for the initial test. -- [x] Document resource limits and deadlines. Current Job deadline includes - waiting time as well as execution; make any change to this behavior explicit. - -### D. Persistent output folders - -- [x] Provide a predictable, writable per-job directory backed by the existing - checkpoint PVC on the selected machine. -- [x] Create the directory reliably and verify permissions for the supported - image profiles. -- [x] Preserve/document `MIST_CHECKPOINT_DIR` and `checkpoint_directory` so - workloads know where to save their results. -- [x] Verify a real output file remains after the container exits. -- [x] Describe how to retrieve files during local testing and where they live. -- [x] Keep job identifiers/storage references independent of physical disk - paths so shared storage can be added later. - -Files written only into the container's writable layer are not persistent -results. The existing shared checkpoint roots are also not a complete tenant -isolation boundary; do not represent this pilot as ready for untrusted users. - -### E. Task 1: finish website job controls - -- [x] Connect the website to the new hardware and image contracts. -- [x] Keep real job submission, polling, logs, assigned node, failure reasons, - exit codes, and cancellation. -- [x] Show requested resources and the persistent output location. -- [x] Make waiting-for-resources, machine unavailable, and image pull failures - understandable. -- [x] Preserve connection recovery and protect against duplicate submissions. -- [x] Use existing UI conventions and accessible labels. Keep frontend concerns - separate from the executor and typed API client. - -The prototype Dashboard/Machines/auth pages are not proof of working metrics or -authentication. Surface real hardware information in the relevant workflow. - -### F. Verify and hand off - -- [x] Run focused backend tests for execution semantics, inventory accounting, - validation, and cancellation where changed. -- [x] Run frontend type checking, production build, and meaningful interaction - checks for new behavior. -- [x] Run a real custom CPU image job through the website. -- [x] Run fresh NVIDIA and TT training checks through the website. -- [x] Verify occupied/free counts during execution and after completion. -- [x] Verify waiting/reuse and cancellation release for both allocation paths. -- [x] Verify failed commands and image pulls produce useful states/errors. -- [x] Verify saved outputs persist after pod completion. -- [x] Update the deployed API image if necessary; editing Go source does not - update the binary currently running in k3s. -- [x] Record actual results and date them. Older October 3 acceptance evidence - does not substitute for tests of today's changes. -- [x] Document startup, API contracts, image preparation, output access, tests, - and remaining work for the team. - -Avoid unrelated operator upgrades or repeat full infrastructure acceptance tests -without a concrete regression risk. Small synthetic training checks demonstrate -device execution and learning, but each team's actual model needs compatibility -validation; distributed training and TT autograd are not established by these checks. - -## Today's definition of done - -```text -Local website - | -Choose approved image + command + hardware + device count - | -Mist API creates a k3s Job - | -Selected machine runs the container - | -Real states/logs + correct device accounting + persistent output file -``` - -Implementation should be verifiable from the browser, with failures and -cancellation behaving correctly and documentation sufficient for another -developer to resume the work. - -## Next foundation task: shared datasets and downloads - -This is task 4, after today's execution scope: - -1. Create a storage folder on QuietBox with a bounded disk budget. -2. Configure private NFS access for both machines and Kubernetes workloads. -3. Add streaming dataset uploads, progress/error handling, a stable dataset ID, - and a ready state before jobs may use an upload. -4. Attach only the selected dataset read-only and the job's outputs writable. -5. Add result listing/downloads with ownership checks and retention rules. -6. Test one uploaded dataset with jobs on both machines. - -Proposed physical layout, not yet created: - -```text -Tenstorrent: /srv/mist-storage/ -├── datasets/ -│ └── dataset-id/ -└── jobs/ - └── job-id/ - └── outputs/ -``` - -Container paths may become `/inputs` and `/outputs`; preserve compatibility -with the existing checkpoint contract or document the migration explicitly. -NVIDIA reads TT-hosted files over the current LAN, which includes Wi-Fi. -Caching large datasets on NVIDIA and a dedicated Ethernet link can follow. -Central storage on one machine needs backup/retention planning and is unavailable -when that host fails. Archives need safe extraction and upload limits. - -## Later: deployment and product features - -Task 5 follows the foundation: - -- Serve a production frontend and route `/api` to the existing Mist API. -- Provide a team-accessible Tailnet address/hostname and persistent services. -- Implement login, job/file ownership, and appropriate member limits before - supporting multiple users. -- Add public domain/HTTPS only if public access is requested. - -Credits, fairness/priority policies, Jupyter, chaining, agents, detailed monitoring, -portable TT runtime images, and distributed model training are separate workstreams. - -## Resume notes and useful files - -### Code - -- `src/kubernetes.go`: executor, validation, resource requests, job status/logs/cancel. -- `src/kubernetes_test.go`: existing Kubernetes executor tests. -- `src/api.go`: API mode selection and request types. -- `web-interface/src/api.ts`: typed API client. -- `web-interface/src/components/JobsPage.tsx`: working job UI. -- `web-interface/src/components/JobsPage.test.tsx`: existing UI checks. -- `deploy/k3s/mist-api.yaml`: API Deployment, RBAC, Service, and checkpoint PVCs. -- `deploy/k3s/mist-tenstorrent-claims.yaml`: 1–4 board claim templates. -- `deploy/k3s/tenstorrent-workload-policy.yaml`: accelerator workload admission policy. -- `deploy/k3s/training_smoke.py`: actual NVIDIA/TT regression training check. -- `deploy/k3s/verify_mist_api.py`: earlier API allocation/queue/training acceptance tool. - -### Existing documentation and evidence - -- `docs/kubernetes-pilot.md`, `deploy/k3s/README.md`, `src/jobs.md`. -- `deploy/k3s/mist-api-results.json`: October 3 acceptance summary. -- `/home/utmist/mist-api-results-2026-10-03`: detailed prior acceptance artifacts. - -### Local tools and access - -- Go: `/home/utmist/.local/share/mist-runtimes/go-1.25.1/bin/go`. -- Node: `/home/utmist/.local/share/mist-runtimes/node-22.16.0/bin/node`. - Prepend that runtime's directory to PATH when running npm/npx. -- Kubeconfig: `/home/utmist/.kube/config`. -- QuietBox SSH: user `utmist-tt`, host `100.95.175.37`, existing identity file - `/home/utmist/.ssh/id_ed25519_quietbox_codex`; use IdentitiesOnly and BatchMode. -- Browser checks can use the temporary Playwright installation at - `/tmp/mist-browser-check/node_modules/playwright` and existing cached Chromium. -- User is in the Docker group. Sudo requires a password; prior authorized root - cluster operations used a Docker host bind/chroot. Do not print secret values. -- Do not include credentials, join tokens, registry secrets, or private keys in - documentation, Git, or tool output. - -Model recommendation for the user's remaining quota: GPT-6.1 Sol, Medium -reasoning, Standard speed. This was advice, not a settings change. Saved settings -were Max/Fast at the time of planning. Exact quota consumption cannot be predicted. - -## Progress log - -- Planning: agreed scope recorded; existing API health and both Ready nodes checked. -- Branch: `feat/local-job-foundation`. Commit `2f9b178` preserves the prior working - pilot and this plan. Commit identity uses the connected GitHub account's public - noreply address for this commit only; global Git settings were not changed. -- Backend additions: `hardware.go`, `images.go`, `outputs.go`, and `job_states.go`. - Inventory reads node/pod/claim/slice state; image defaults are preserved; - outputs have per-job mounts and an initialization step; history reads are batched. -- Frontend: typed inventory/catalog clients, a shared polling hook, separate - submission/card/log components, and live Machines information. -- Focused backend tests pass. Frontend interaction tests (5), type checking, and - production build pass. A test initially submitted before the catalog loaded; - it was corrected to wait for the form's actual readiness. Final lint also - passed after removing unnecessary test casts and shadowed callback names. -- Example images `mist-training:cpu-v1` and `mist-training:nvidia-v1` were built - and imported on the NVIDIA node. API image `mist-api:foundation-20261008` - is deployed; the final binary image ID is recorded in `foundation-results.json`. -- Fresh browser checks passed: packaged CPU training with image ENTRYPOINT/CMD - and `/app` WORKDIR, both NVIDIA GPUs, TT two-board/four-chip training, exit 17, - cancellation, real states/logs, and the live Machines page. -- Fresh allocation checks passed for NVIDIA and TT: whole-machine occupied/free - counts, queued follower, cancellation with retained logs, automatic device - reuse, and return to 2 GPUs / 4 boards available. -- Persistent output checks used new read-only containers after training exited. - CPU model/results, both NVIDIA checkpoints, and four TT chip checkpoints and - metrics were readable on the proper nodes. SHA256/file evidence was saved. -- A deliberately missing approved image exposed its real pull reason in the API - and browser, then failed with DeadlineExceeded. Temporary image approval was - removed, and prior job/cancellation history survived API restarts. -- Final focused backend tests: 12 passed. Frontend interaction tests: 5 passed; - type checking, production build, and lint of changed frontend files passed. - CLI tests passed. The full src suite passed in 33.325 seconds with isolated - Redis and the existing Docker test image. Its initial 15-second legacy CPU - test timed out during large-image disk activity; the suite passed after the - imports finished. No legacy executor behavior was changed. -- The first browser verifier expected a completed TT claim to retain allocations. - Kubernetes had correctly released them. The verifier now checks completed - request/log evidence and checks actual DRA devices during live reservations. -- Fresh RBAC checks passed: required inventory lists allowed; Secrets, node - writes, pod exec, and Jobs in other namespaces denied. Private registry - Secret wiring is implemented/documented; no private registry login was tested. -- Detailed evidence: `/home/utmist/mist-foundation-results-2026-10-08`. - Compact checked-in summary: `deploy/k3s/foundation-results.json`. - Final idle snapshot: both nodes Ready, 0 allocated / 2 available NVIDIA GPUs, - 0 allocated / 4 available TT boards. All 40 retained Mist Jobs were terminal. - Job history read measured 0.193 seconds, compared with the 3.586-second baseline. -- Today's agreed implementation and verification are complete: parts 1–3 plus - persistent local outputs. The percentage updates referred to today's scope. - The complete platform still needs part 4 (shared datasets/downloads) and - part 5 (deployment/login). The team handoff is `docs/local-job-foundation.md`. -- Screen question checked: automatic suspend on AC has timeout 0 (disabled), - so the display may turn off while work continues. Power settings were not changed. - -## References - -- [Kubernetes Jobs](https://kubernetes.io/docs/concepts/workloads/controllers/job/) -- [Container images](https://kubernetes.io/docs/concepts/containers/images/) -- [K3s private registries](https://docs.k3s.io/installation/private-registry) -- [Kubernetes persistent volumes](https://kubernetes.io/docs/concepts/storage/persistent-volumes/) -- [Local path provisioner](https://github.com/rancher/local-path-provisioner) -- [Tenstorrent platform support](https://docs.tenstorrent.com/tt-operator/latest/platform-support.html) diff --git a/docs/content/archive/k3s-deployment-history.md b/docs/content/archive/k3s-deployment-history.md deleted file mode 100644 index 9f6cd55..0000000 --- a/docs/content/archive/k3s-deployment-history.md +++ /dev/null @@ -1,408 +0,0 @@ -> **Historical record — superseded.** This text describes an earlier phase. -> Commands and claims below are retained as history, not current setup instructions. -> Start with the [current documentation index](../_index.md) and -> [department operating guide](../department-rollout.md). - -# Accelerator jobs on k3s - - -**October 9 update:** Team workspaces, scoped storage, enforced limits, durable queueing and private -production hosting are implemented. Earlier local pilot descriptions below record the -original phase. Use [the current operating guide](../department-rollout.md) for today's behavior. - -For the original NVIDIA-node setup and recovery procedure, read the -[setup runbook](k3s-setup-history.md). Its final section records the QuietBox worker -join on 2026-10-03. - -The NVIDIA server runs Ubuntu 22.04, k3s 1.36, and two RTX A4000 GPUs. The host -driver is pre-installed. NVIDIA GPU Operator v26.7.1 is configured with -[`nvidia-gpu-operator-values.yaml`](../../../deploy/k3s/nvidia-gpu-operator-values.yaml). Pin the -chart version when installing or upgrading: - -```bash -sudo env KUBECONFIG=/etc/rancher/k3s/k3s.yaml helm upgrade --install gpu-operator \ - oci://nvcr.io/nvidia/cloud-native-charts/gpu-operator \ - --version v26.7.1 --namespace gpu-operator --create-namespace \ - --values deploy/k3s/nvidia-gpu-operator-values.yaml --wait -``` - -Run from the repository root. Check that the operator pods are healthy and the -node advertises two GPUs before submitting a workload: - -```bash -sudo k3s kubectl -n gpu-operator get pods -sudo k3s kubectl describe node utmist-z1opa08 -``` - -The `Allocatable` section must include `nvidia.com/gpu: 2`. Then run the -single-GPU CUDA smoke test: - -```bash -sudo k3s kubectl create namespace mist -sudo k3s kubectl apply -f deploy/k3s/gpu-smoke-job.yaml -sudo k3s kubectl -n mist wait --for=condition=complete job/gpu-smoke --timeout=180s -sudo k3s kubectl -n mist logs job/gpu-smoke -``` - -The expected log contains `Test PASSED`. To rerun the Job, delete only the -finished smoke Job and apply the manifest again: - -```bash -sudo k3s kubectl -n mist delete job gpu-smoke -sudo k3s kubectl apply -f deploy/k3s/gpu-smoke-job.yaml -``` - -No Service or NodePort is needed for a batch Job. This test verifies that k3s -can allocate an NVIDIA GPU and execute CUDA. Mist now submits these resources -through its Kubernetes executor; see the [API setup](#mist-api-cli-and-jobs-page). - -The cluster also has `utmist-tt` as an agent at `10.0.0.112`. Tenstorrent -DRA allocation is installed there: four n300 boards, each containing two -Wormhole chips. The NVIDIA smoke Job remains specific to `utmist-z1opa08`. - -## Tenstorrent allocation - -TT-Operator **0.3.0** installs Fabric Manager **0.2.30** and the DRA driver -**0.0.59**, configured by -[`tenstorrent-operator-values.yaml`](../../../deploy/k3s/tenstorrent-operator-values.yaml). -The existing host KMD/firmware and the existing NFD installation are retained; -driver management, duplicate NFD, telemetry, JobSet, and PMIx are disabled. -The manually verified `tenstorrent.com/has-tt=true` node label enables discovery. - -To reproduce the installation from the repo root: - -```bash -export KUBECONFIG=/home/utmist/.kube/config -k3s kubectl label node utmist-tt tenstorrent.com/has-tt=true --overwrite -helm upgrade --install tt-operator oci://ghcr.io/tenstorrent/helm/tt-operator \ - --version 0.3.0 --namespace tt-operator-system --create-namespace \ - --values deploy/k3s/tenstorrent-operator-values.yaml -k3s kubectl apply -f deploy/k3s/tenstorrent-workload-policy.yaml -k3s kubectl -n tt-operator-system get pods -k3s kubectl get deviceclasses,resourceslices -k3s kubectl get resourceslices -o yaml -``` - -All three operator pods must be Ready. The `tenstorrent.com` DeviceClass -and a ResourceSlice on `utmist-tt` must exist, with **four** `n300` devices -whose `chipCount` is **2**. ResourceSlices are the inventory; claims show -reservations. TT does not appear as an `nvidia.com/gpu` node resource. - -The supported reservation unit is a whole **n300 board**, including its -remote chip. Jobs can reserve one to four boards; separate jobs can use -the remaining boards. A request waits if insufficient boards or other -requested resources are free. Each pod gets its own ResourceClaim through -a ResourceClaimTemplate, and only its allocated device nodes are injected -through CDI. Boards are released when the pod finishes, even while its -completed Job and checkpoints are retained. - -[`training-tenstorrent-job.yaml`](../../../deploy/k3s/training-tenstorrent-job.yaml) now requests -one board, runs without privileged access, and reserves 2 GiB of 1 GiB -hugepages. The host still has 16 GiB of such hugepages. It mounts the existing -TT-Metal checkout and Python environment read-only, so it is specific to -this QuietBox rather than a portable training image. - -For uniquely named training checks and separate checkpoint directories, -use [`submit_tenstorrent_smoke.py`](../../../deploy/k3s/submit_tenstorrent_smoke.py). It needs -Python PyYAML, available on the server. For example: - -```bash -export KUBECONFIG=/home/utmist/.kube/config -python3 deploy/k3s/submit_tenstorrent_smoke.py my-tt-test-a --boards 1 -python3 deploy/k3s/submit_tenstorrent_smoke.py my-tt-test-b --boards 1 -k3s kubectl -n mist get jobs,pods,resourceclaims -k3s kubectl -n mist logs -f job/my-tt-test-a -``` - -Use `--boards 2` for four chips, or `--boards 4` for all eight. The script -submits the small regression test described below. A different model needs -compatible Tenstorrent training code. Claim count, expected chip count, -hugepage reservation, and checkpoint directory must all match the job. -These tests train on each allocated chip independently; they do not perform -distributed training of one model. For multiple boards, the check launches -one runtime per board using its PCI address in `TT_VISIBLE_DEVICES`. This -avoids assuming that an arbitrary selected subset forms a connected mesh. -The scheduler reserves boards but does not promise their fabric topology; -a model spanning boards needs an appropriate topology and training runtime. - -The admission policy applies to pods in `mist`, including init and ephemeral -containers. It rejects privileged access, added capabilities, host PID/IPC, -arbitrary host mounts, and writable mounts of the host runtime. This blocks -the previous privileged whole-machine test from being submitted again. -Individual character-device mounts remain allowed for the verification -probes, with access to unclaimed devices denied by device cgroups. The policy -is a guard for managed workloads, not a complete security boundary for -untrusted tenants or cluster administrators; hugepage mounts are host-shared. - -### Allocation acceptance test - -On 2026-10-03, four jobs reserved distinct boards and trained concurrently -on all eight chips. Their first-chip training intervals overlapped for -**3.924 seconds**. Each job could open its own device and received permission -errors opening the other three, even when the other character nodes were -mounted for the probe. A fifth job remained Pending with `cannot allocate -all claims`, then started automatically after a board was released. All five -jobs passed training, held-out-data, and checkpoint reload checks. Automatic -claim cleanup left all four boards free afterward. - -To repeat that live test (it requires all four boards to be free): - -```bash -export KUBECONFIG=/home/utmist/.kube/config -python3 deploy/k3s/verify_tenstorrent_allocation.py \ - --artifacts /home/utmist/k3s-allocation-results-new-run -``` - -The test holds initialized jobs at a shared-file barrier to inspect their -reservations, checks actual device access, observes the fifth job queuing, -and then releases the barrier. It verifies overlapping training timestamps, -loss thresholds, checkpoint files, and board reuse. Test jobs are retained; -the temporary PVC reader is deleted. The barrier times out after five -minutes if interrupted. The checkpoint PVC is retained. - -Operator restart recovery and an NVIDIA CUDA regression check also passed. -See [`tenstorrent-allocation-results.json`](../../../deploy/k3s/tenstorrent-allocation-results.json) -for pinned image IDs and results. Logs, claim/queue snapshots, admission -checks, and checkpoint archives are in -`/home/utmist/k3s-allocation-results-2026-10-03` on the NVIDIA server. -The vendor currently advertises an incorrect board-memory capacity in the -ResourceSlice; these claims select board type/count and do not use that -capacity for placement. Verify/correct it before adding memory-based selectors. - -## Training smoke jobs - -[`training_smoke.py`](../../../deploy/k3s/training_smoke.py) learns a 32-input/32-output linear -mapping from 128 synthetic samples and evaluates 64 held-out samples. Each -device must complete 100 gradient updates, reduce training MSE by at least -98%, achieve held-out MSE below `0.0005`, change its weights, and reload its -checkpoint successfully. NVIDIA uses PyTorch CUDA autograd. Tenstorrent uses -TT-NN for forward passes, explicit analytic gradients, and weight updates; -CPU Torch creates the data, reads metrics, and saves checkpoints. This does -not validate automatic differentiation or arbitrary models on Tenstorrent. - -The NVIDIA Job reserves both GPUs and tests each independently. The current -Tenstorrent manifest reserves one n300 board and tests its two chips -independently. The original eight-chip privileged Job, named -`training-tenstorrent`, is retained only as historical evidence; the new -Job is `training-tenstorrent-dra` and uses DRA/CDI reservations. - -Run from the repository root: - -```bash -sudo k3s kubectl -n mist create configmap training-smoke-script \ - --from-file=deploy/k3s/training_smoke.py --dry-run=client -o yaml \ - | sudo k3s kubectl apply -f - -sudo k3s kubectl apply -f deploy/k3s/training-nvidia-job.yaml -sudo k3s kubectl apply -f deploy/k3s/training-tenstorrent-job.yaml -sudo k3s kubectl -n mist logs -f job/training-nvidia -sudo k3s kubectl -n mist logs -f job/training-tenstorrent-dra -sudo k3s kubectl -n mist get jobs,pvc -``` - -Each successful log ends with `TRAINING_PASSED`. Checkpoints and -`results.json` are stored in the corresponding `training-*-checkpoints` PVC, -with the one-board TT check under its `dra-single` directory, -which uses storage local to the selected node. Preserve the PVCs when -removing a completed Job. Delete only the completed Job before applying its -manifest to rerun it; retained checkpoint filenames will be overwritten. -The TT manifest depends on QuietBox's existing host paths, hugepages, and -TT-Metal checkout and is not a portable training image. - -The original whole-node Jobs passed on 2026-10-03, with exit code zero. All ten devices changed -weights, met the held-out-data threshold, and reloaded their saved -checkpoints. The archives were also checked after the Pods completed. - -| Backend | Devices tested independently | Initial training MSE | Final training MSE | Held-out MSE | -| --- | --- | --- | --- | --- | -| NVIDIA RTX A4000, PyTorch FP32 | 2 GPUs | 0.319374 | 0.000000201866 | 0.000000932067 | -| Wormhole, TT-NN BF16 | 8 chips | 0.319374 | 0.0000623871 | 0.000175072 | - -Precision and implementations differ; these figures establish convergence -and are not a performance comparison. Exact image IDs, the TT-Metal host -commit, Job timestamps, artifact locations, and acceptance thresholds are in -[`training-smoke-results.json`](../../../deploy/k3s/training-smoke-results.json). Local logs and -checkpoint archives are retained under -`/home/utmist/k3s-training-results-2026-10-03` on the NVIDIA server. The -completed Kubernetes Jobs and their checkpoint PVCs are retained too. - - -## Mist API, CLI and Jobs page - -The API now uses Kubernetes by default and runs in `mist-system`. Its service -account manages Jobs and script ConfigMaps in `mist`, reads Pod logs/events -and TT claims, and has no cluster administrator credentials. The configured -pilot owner is `utmist`. No authentication or public ingress is configured. -The foundation also lists node/pod/claim/slice state for live hardware inventory. -See [the foundation guide](local-job-foundation.md) for contracts, -website instructions, persistent outputs, and the developer handoff. - -### Build and install on the current server - -Requires Go 1.25.1, Docker, k3s, and the private administrative kubeconfig. -Use the already installed GPU/TT operators described above. The API image is -built locally and imported into k3s containerd; `imagePullPolicy: Never` -means that it must be present on the selected server before deployment. - -```bash -export KUBECONFIG=/home/utmist/.kube/config -CGO_ENABLED=0 go -C src build -o ../bin/mist-api . -docker build -f deploy/k3s/Dockerfile.api -t mist-api:foundation-20261008 . -docker build -f deploy/k3s/examples/Dockerfile.training \ - -t mist-training:cpu-v1 deploy/k3s/examples -docker build -f deploy/k3s/examples/Dockerfile.training \ - --build-arg BASE_IMAGE=pytorch/pytorch:2.5.1-cuda12.4-cudnn9-runtime \ - -t mist-training:nvidia-v1 deploy/k3s/examples -docker save -o /tmp/mist-foundation-images.tar \ - mist-api:foundation-20261008 mist-training:cpu-v1 mist-training:nvidia-v1 -sudo k3s ctr -n k8s.io images import --platform linux/amd64 /tmp/mist-foundation-images.tar -k3s kubectl apply -f deploy/k3s/mist-api.yaml -k3s kubectl apply -f deploy/k3s/mist-tenstorrent-claims.yaml -k3s kubectl apply -f deploy/k3s/tenstorrent-workload-policy.yaml -k3s kubectl -n mist create configmap training-smoke-script \ - --from-file=deploy/k3s/training_smoke.py --dry-run=client -o yaml \ - | k3s kubectl apply -f - -k3s kubectl -n mist-system rollout status deployment/mist-api --timeout=120s -``` - -The API manifest creates namespaces, RBAC and all three checkpoint PVCs -without submitting a training Job. If rebuilding the same local image tag, -import the new image and run `rollout restart deployment/mist-api` in -`mist-system`. Preserve Jobs and PVCs when restarting the API. - -For a manual local endpoint: - -```bash -k3s kubectl -n mist-system port-forward service/mist-api 3000:3000 --address 127.0.0.1 -``` - -Then in another terminal: - -```bash -curl http://127.0.0.1:3000/healthz -go -C cli build -o ../bin/mist . -bin/mist job list --all -cd web-interface -npm install --package-lock=false -npm run dev -- --host 127.0.0.1 --port 3001 --strictPort -``` - -Open http://127.0.0.1:3001/jobs. Choose CPU, NVIDIA GPUs or Tenstorrent boards; -submit a Python script, select an approved **Container image** with its own -entrypoint/arguments, or choose **Small training check** for an accelerator. -The page displays actual placement, queue messages, exit status and logs. -Hardware availability is live; the Machines route uses the same inventory. -Cancel stops a waiting/running workload and retains cancellation history. -CLI commands are in [the CLI guide](../../../cli/docs/setup.md). - -The installed user units keep both local services running during the user's -systemd session. Their paths target this server and need adjustment on another -host; they do not enable user lingering or guarantee operation before login. - -```bash -mkdir -p ~/.config/systemd/user -cp deploy/k3s/mist-api-forward.service deploy/k3s/mist-web.service ~/.config/systemd/user/ -systemctl --user daemon-reload -systemctl --user enable --now mist-api-forward mist-web -systemctl --user status mist-api-forward mist-web -``` - -Do not run manual listeners on the same ports while these units are active. -Stop them with `systemctl --user stop mist-web mist-api-forward` if needed. - -### Submission examples - -```bash -curl -sS http://127.0.0.1:3000/jobs -H 'Content-Type: application/json' \ - -d '{"name":"hello","accelerator":"cpu","command":["python","-c","print(42)"]}' -curl -sS http://127.0.0.1:3000/jobs -H 'Content-Type: application/json' \ - -d '{"name":"one-board","type":"training-smoke","accelerator":"tenstorrent","device_count":1}' -curl -sS http://127.0.0.1:3000/jobs -H 'Content-Type: application/json' \ - -d '{"name":"one-gpu","type":"training-smoke","accelerator":"nvidia","device_count":1}' -``` - -CPU jobs request no accelerator. NVIDIA requests accept one or two GPUs; -TT requests accept one to four boards, each containing two chips. Defaults -are 250m CPU/256Mi RAM for CPU jobs, 2 CPU/2Gi for NVIDIA, and 2 CPU/4Gi plus -2Gi hugepages per TT board. CPU and memory limits equal requests. The API -accepts CPU up to 8, memory 64Mi to 32Gi (TT minimum 2Gi), and deadlines from -10 seconds to 24 hours, default 600 seconds. Deadlines include queue time. -Images must be approved by the API's allowlist; submissions cannot specify -privileged access or host mounts. Environment variables `MIST_JOB_ID`, -`MIST_OUTPUT_DIR=/outputs`, and `MIST_CHECKPOINT_DIR=/checkpoints/` -provide the job identity and two aliases for its persistent directory. - -Storage remains node-local with a shared backing PVC per compute profile. -New workloads mount only their job-specific subdirectory. Retained Jobs -provide history across API restarts, without TTL. Normal logs remain in pod -logs; cancellation preserves their last 32KiB in the Job metadata. Keep the -Pods/PVCs or archive them before cleanup. This is a local single-owner pilot, -not an authenticated multi-user deployment. - -### Custom images and private registries - -The deployment's `MIST_ALLOWED_IMAGES` approves exact image references; -`GET /images` exposes them to the website. For real workloads, build/push an -image containing code/dependencies, approve its immutable tag/digest, and let -the selected node pull it. Local example tags must be imported on the NVIDIA -server. An approved image is not guaranteed to be pullable or CUDA compatible. -The TT profile still requires its tested image and read-only host runtime. - -CPU/NVIDIA container jobs preserve image ENTRYPOINT/CMD and WORKDIR when no -override is supplied. Arguments are array entries, not a parsed shell command. -Scripts and training checks retain their tested runtime profiles. Write results -to `/outputs`; files elsewhere in the container are not persistent outputs. - -For private images, an administrator creates a `kubernetes.io/dockerconfigjson` -Secret in workload namespace `mist`, then adds `MIST_IMAGE_PULL_SECRETS` to -the API Deployment environment with the Secret name(s), comma-separated. -Jobs receive those names as `imagePullSecrets`. Mist does not read Secret -contents or accept registry passwords in the submission form. Keep Secret -manifests/credentials outside Git. K3s node registry configuration is another -option: [private registries](https://docs.k3s.io/installation/private-registry). - -### Foundation acceptance - -With both accelerators free and the example images imported, run the browser -story, then the allocation/output checks using the same artifact directory: - -```bash -export KUBECONFIG=/home/utmist/.kube/config -export MIST_PLAYWRIGHT_MODULE=/tmp/mist-browser-check/node_modules/playwright/index.mjs -node deploy/k3s/verify_foundation_browser.mjs /home/utmist/mist-foundation-results-new-run -python3 deploy/k3s/verify_foundation.py --artifacts /home/utmist/mist-foundation-results-new-run -``` - -Playwright and Chromium are test tooling, separate from the web application. -Set `MIST_PLAYWRIGHT_MODULE` to its installed `index.mjs`, or install Playwright -where Node can resolve it. `MIST_WEB_URL` changes the browser endpoint; -`--url` changes the API endpoint for the Python tool. -Checks cover packaged CPU/NVIDIA images, both GPUs, TT training, real UI -states/logs/cancellation, occupied/free counts, queues, reuse, and saved files -read by fresh containers on both nodes. Temporary read-only output probes are -removed; test Jobs and result directories remain. The final markers are -`FOUNDATION_BROWSER_PASSED` and `FOUNDATION_ALLOCATION_OUTPUTS_PASSED`. -October 8 evidence is under `/home/utmist/mist-foundation-results-2026-10-08`. -See [the checked-in summary](../../../deploy/k3s/foundation-results.json) for actual job IDs, -deployed image ID, and completed checks. - -### API acceptance test - -This test needs both GPUs and all four TT boards available. It submits 17 -uniquely named jobs, holds initialized devices at barriers to prove separate -reservations, verifies queued jobs, training/checkpoint reload, CPU failures, -deadlines, retained cancellation logs, and cancellation freeing all TT boards. - -```bash -export KUBECONFIG=/home/utmist/.kube/config -python3 deploy/k3s/verify_mist_api.py \ - --artifacts /home/utmist/mist-api-results-new-run -``` - -Successful output ends with `MIST_API_PASSED`. Jobs and checkpoint directories -are retained. A barrier times out after five minutes if interrupted. The -verification script uses the operator's administrative context to inspect -claims/devices and release test barriers; the Mist API itself cannot exec. -Results from 2026-10-03 are in [mist-api-results.json](../../../deploy/k3s/mist-api-results.json), -with raw evidence and checkpoint archives under -`/home/utmist/mist-api-results-2026-10-03`. These checks establish small model -training and allocation, not large/distributed-model support or performance. diff --git a/docs/content/archive/k3s-setup-history.md b/docs/content/archive/k3s-setup-history.md deleted file mode 100644 index d32e55d..0000000 --- a/docs/content/archive/k3s-setup-history.md +++ /dev/null @@ -1,283 +0,0 @@ -> **Historical record — superseded.** This text describes an earlier phase. -> Commands and claims below are retained as history, not current setup instructions. -> Start with the [current documentation index](../_index.md) and -> [department operating guide](../department-rollout.md). - -# Single-node k3s and NVIDIA GPU pilot: setup and handoff - -This runbook records the configuration observed on **2026-09-28**, a repeatable -setup and validation procedure, and the failure encountered during setup. Run -commands from the Mist repository root unless noted. The -[Kubernetes execution pilot](kubernetes-pilot.md) describes the -implemented Kubernetes executor and remaining product work. - -## What the pieces do - -The machine already had two NVIDIA GPUs and a working host driver. A **single -k3s server** was installed on it. A server includes a local kubelet and -container runtime and can run Jobs without a separate `k3s-agent` service. -The NVIDIA GPU Operator added the Kubernetes integration: - -1. Its **device plugin** registers `nvidia.com/gpu` with the kubelet, allowing - the scheduler to reserve a GPU for a Pod that requests one. -2. Its **container toolkit** prepares CDI device information that containerd - uses to expose the assigned GPU and host driver libraries to a workload. - The enabled NRI plugin handles GPU management containers and avoids - rewriting k3s's containerd runtime configuration. -3. A CUDA sample Job requests `nvidia.com/gpu: 1` and exercises the device. - -The operator was configured **not** to replace the existing host driver. Those -values are in -[`nvidia-gpu-operator-values.yaml`](../../../deploy/k3s/nvidia-gpu-operator-values.yaml). NVIDIA -states that its NRI plugin is not yet generally available; this is a pilot -configuration. See the [GPU Operator installation guide](https://docs.nvidia.com/datacenter/cloud-native/gpu-operator/latest/getting-started.html) -and [CDI/NRI guide](https://docs.nvidia.com/datacenter/cloud-native/gpu-operator/latest/cdi.html). - -## Observed state on 2026-09-28 - -| Item | Observed value | -| --- | --- | -| Node | `utmist-z1opa08`, one control-plane and worker node | -| OS | Ubuntu 22.04.5 LTS | -| k3s | `v1.36.4+k3s1`; `k3s.service` enabled and active | -| Container runtime | k3s containerd `2.3.4-k3s1.36` | -| GPUs | Two NVIDIA RTX A4000 cards | -| Host driver | NVIDIA `590.48.01`, already present before GPU Operator deployment | -| GPU Operator | Helm release `gpu-operator`, chart `v26.7.1`, namespace `gpu-operator` | -| Test namespace | `mist` | -| Node resource | Capacity and allocatable: `nvidia.com/gpu: 2` | - -The node registered at 19:23 EDT. GPU Operator resources appeared around 19:39 -EDT; Helm release revision 2 was deployed at 19:45 EDT. The `gpu-smoke` Job -completed at about 19:44 EDT. After the service fix, a new `gpu-confirm` Job -completed at 19:54 EDT. Both sample logs contained `Test PASSED`. Each Job -requested **one** GPU. The node advertised two, but these tests did not -independently identify or exercise each physical card. - -These times and versions come from systemd, Kubernetes object timestamps, -`helm list`, `nvidia-smi`, and the completed Job logs. The exact historical -k3s installation command was not retained, so the commands below are a -reproduction recipe rather than a transcript. - -## Reproduce or verify this pilot - -Check versions against the target host before rebuilding or upgrading. Have a -working NVIDIA driver and `helm` available. Never commit the kubeconfig, -cluster token, or private keys to this repository. - -### 1. Confirm the GPUs work on the host - -```bash -nvidia-smi -L -nvidia-smi --query-gpu=name,driver_version --format=csv,noheader -``` - -On this node the first command lists two RTX A4000 cards. Resolve host driver -or hardware failures before diagnosing Kubernetes GPU allocation. - -### 2. Run one k3s server - -For a fresh **single-node** install, pin the version used in this pilot: - -```bash -curl -sfL https://get.k3s.io | sudo env INSTALL_K3S_VERSION='v1.36.4+k3s1' sh -s - server -sudo systemctl is-active k3s -sudo k3s kubectl get nodes -o wide -``` - -Expect `active` and a `Ready` node. The installer writes an administrative -kubeconfig at `/etc/rancher/k3s/k3s.yaml`; `sudo k3s kubectl` uses it without -copying credentials. Any copied kubeconfig must be kept private. The -[k3s quick-start guide](https://docs.k3s.io/quick-start) explains that a -single server can host workloads and that agents join additional machines. - -This same host must **not** run a separate `k3s-agent` service: - -```bash -systemctl is-active k3s-agent -systemctl is-enabled k3s-agent -``` - -Expected outputs here are `inactive` and `disabled`. These commands can -return nonzero for those expected states. - -### 3. Install the GPU Operator - -The tracked Helm values disable Operator driver installation and enable its -NRI plugin for GPU management containers. Keep the chart version pinned when -reproducing this setup: - -```bash -sudo env KUBECONFIG=/etc/rancher/k3s/k3s.yaml helm upgrade --install gpu-operator \ - oci://nvcr.io/nvidia/cloud-native-charts/gpu-operator \ - --version v26.7.1 --namespace gpu-operator --create-namespace \ - --values deploy/k3s/nvidia-gpu-operator-values.yaml --wait - -sudo k3s kubectl -n gpu-operator get pods -sudo k3s kubectl describe node utmist-z1opa08 -``` - -The device plugin and container toolkit Pods should be Running. In the node's -`Capacity` and `Allocatable` sections, expect `nvidia.com/gpu: 2` for this -hardware. A completed validator Pod is normal. The operator may need a little -time to register devices after k3s starts; check again after its Pods are -healthy. - -### 4. Execute CUDA through k3s - -The tracked [`gpu-smoke-job.yaml`](../../../deploy/k3s/gpu-smoke-job.yaml) requests and limits one -`nvidia.com/gpu` device. Create the namespace if it does not already exist: - -```bash -sudo k3s kubectl create namespace mist -sudo k3s kubectl apply -f deploy/k3s/gpu-smoke-job.yaml -sudo k3s kubectl -n mist wait --for=condition=complete job/gpu-smoke --timeout=180s -sudo k3s kubectl -n mist describe job gpu-smoke -sudo k3s kubectl -n mist logs job/gpu-smoke -``` - -Expect `1 Succeeded`, `nvidia.com/gpu: 1` in the Job's requests and limits, -and `Test PASSED` in the logs. This establishes that k3s scheduled a GPU Pod, -the runtime exposed a device inside the container, and CUDA executed. Merely -seeing the host GPUs or the Kubernetes resource count is a weaker check. - -A completed Job does not rerun on `kubectl apply`. To repeat this test, delete -**only the completed smoke Job**, then apply the manifest again: - -```bash -sudo k3s kubectl -n mist delete job gpu-smoke -sudo k3s kubectl apply -f deploy/k3s/gpu-smoke-job.yaml -sudo k3s kubectl -n mist wait --for=condition=complete job/gpu-smoke --timeout=180s -sudo k3s kubectl -n mist logs job/gpu-smoke -``` - -Save logs first if they are needed for an incident review. This batch Job -does not need a Service or NodePort. - -## Failure encountered: duplicate agent on port 6444 - -Both `k3s.service` and `k3s-agent.service` had been enabled on the **same -host**. The agent listened on `127.0.0.1:6444`, which the server's API process -also needed. The server repeatedly exited with: - -```text -failed to listen on 127.0.0.1:6444: bind: address already in use -``` - -During the conflict, `kubectl` could not connect to `127.0.0.1:6443`. -`nvidia-smi` still saw the GPUs, but new Kubernetes workloads could not use -them because the control plane was unavailable. Agent logs also referenced -`https://myserver:6443`, an example server name rather than this node's -working endpoint. The fix on 2026-09-28 was: - -```bash -sudo systemctl stop k3s-agent.service -sudo systemctl disable k3s-agent.service -sudo systemctl is-active k3s -sudo k3s kubectl get nodes -``` - -The server recovered without replacing the host driver or reinstalling the -operator. If it does not recover, inspect logs before restarting it, -especially when other workloads are running: - -```bash -systemctl status k3s k3s-agent --no-pager -journalctl -u k3s -u k3s-agent -n 100 --no-pager -ss -ltn '( sport = :6443 or sport = :6444 )' -``` - -## Troubleshooting and limits - -- **API connection refused:** Check `k3s.service` and its journal first. On - this node, confirm the duplicate agent has not been enabled again. -- **Node Ready but no `nvidia.com/gpu`:** Check `nvidia-smi -L`, the device - plugin and toolkit Pods, and - `sudo k3s kubectl -n gpu-operator logs daemonset/nvidia-device-plugin-daemonset --tail=100`. - Compare live Helm values with the tracked file if the release has changed. -- **GPU Job Pending:** Use `sudo k3s kubectl -n mist describe pod ` - to inspect scheduler events and requested resources. Two GPUs are the - observed capacity, not a promise that both are free at every moment. -- **Pod starts but CUDA fails:** Inspect its logs, host `nvidia-smi`, toolkit - Pod logs, and device plugin logs. An advertised resource alone does not - prove CUDA works. - -The smoke test proves **one requested GPU worked through k3s on the test -date**. It does not prove independent operation of both cards, GPU sharing, -performance, reboot durability, multi-node behavior, or Mist API integration. -The subsequent 2026-10-03 allocation/training and Mist API checks are recorded -in [the current deployment guide](k3s-deployment-history.md#mist-api-cli-and-jobs-page). - -## QuietBox worker joined on 2026-10-03 - -`utmist-tt` joined the existing `utmist-z1opa08` cluster as an agent. Both -machines are on the `10.0.0.0/24` LAN as well as the same Tailscale network; -the cluster uses their LAN addresses and the default Flannel VXLAN backend. -The server was not changed to advertise its Tailscale address. - -| Node | Role | Kubernetes InternalIP | k3s version | -| --- | --- | --- | --- | -| `utmist-z1opa08` | server and NVIDIA worker | `10.0.0.175` | `v1.36.4+k3s1` | -| `utmist-tt` | Tenstorrent host and k3s agent | `10.0.0.112` | `v1.36.4+k3s1` | - -QuietBox previously ran an independent `v1.36.5+k3s1` server with only a test -Nginx workload, standard system pods, and no PVCs or user Jobs. Its server -state was backed up to the root-only -`/root/k3s-server-prejoin-2026-10-03.tar.gz` on QuietBox before uninstalling -that server. The agent is enabled at boot; no `k3s.service` remains there. - -The agent's `/etc/rancher/k3s/config.yaml` points to -`https://10.0.0.175:6443`, sets `node-ip: 10.0.0.112`, uses `enp201s0` for -Flannel, and reads its persistent, root-only bootstrap token from -`/etc/rancher/k3s/agent-token`. Do not commit or print that token. The -installation token expires after one hour; the persistent token was installed -and verified by restarting `k3s-agent`. - -QuietBox has UFW active with default deny for incoming and routed traffic. -The join added inbound UDP `8472` and TCP `10250` from `10.0.0.175` on -`enp201s0`, plus routed traffic between `10.42.1.0/24` on `cni0` and -`10.42.0.0/24` on `flannel.1` in both directions. The NVIDIA server's UFW is -inactive. A temporary BusyBox pod placed on QuietBox resolved -`kubernetes.default.svc.cluster.local` and fetched the Nginx Service whose -pod ran on the NVIDIA node; its log ended with `CROSS_NODE_OK`. The test pod -was then removed. - -QuietBox exposes four `/dev/tenstorrent` devices and has the `tenstorrent` -kernel module loaded. TT-Operator 0.3.0 now runs Fabric Manager 0.2.30 and -DRA driver 0.0.59 with host driver management disabled. It advertises four -n300 board reservations, each covering two chips, through the -`tenstorrent.com` DeviceClass and ResourceSlices. Host KMD and firmware are -retained. See [installation, allocation, and live tests](k3s-deployment-history.md#tenstorrent-allocation). - -Four nonprivileged training jobs passed concurrently, with device cgroups -denying access to the other jobs' boards. A fifth job queued and then -trained after automatic board release. The `mist` admission policy blocks -the former privileged whole-machine setup from bypassing reservations. -Distributed training and untrusted-tenant security remain separate work. - -To verify the current cluster from the server: - -```bash -sudo k3s kubectl get nodes -o wide -sudo k3s kubectl get pods -A -o wide -sudo k3s kubectl describe node utmist-tt -``` - -The administrative kubeconfig copy at `/home/utmist/.kube/config` is owned by -`utmist` and mode `0600` as of this join. - - -## Mist application integration on 2026-10-03 - -The deployed API now creates real CPU, NVIDIA and Tenstorrent Jobs. CLI and -browser checks exercised submission, actual status/logs, cancellation, and -TT board-count selection. Live acceptance checks covered distinct concurrent -reservations, queuing and reuse, whole-device requests, exit-17 failure, -deadline failure, persisted checkpoints, and cancellation freeing all boards. -The API uses a dedicated namespaced service account, not the administrative -kubeconfig used by local operators. See [API installation and live evidence](k3s-deployment-history.md#mist-api-cli-and-jobs-page). - -The user's `mist-api-forward` and `mist-web` systemd units expose only -`127.0.0.1:3000` and `127.0.0.1:3001`. These user services require a user -systemd session. Current authentication, credits, team quotas, shared storage -and large/distributed model support remain outside the validated pilot. diff --git a/docs/content/archive/kubernetes-pilot.md b/docs/content/archive/kubernetes-pilot.md deleted file mode 100644 index 8e5230a..0000000 --- a/docs/content/archive/kubernetes-pilot.md +++ /dev/null @@ -1,139 +0,0 @@ -> **Historical record — superseded.** This text describes an earlier phase. -> Commands and claims below are retained as history, not current setup instructions. -> Start with the [current documentation index](../_index.md) and -> [department operating guide](../department-rollout.md). - -# Kubernetes execution pilot - - -**October 9 update:** Shared datasets/results, member login and private production -hosting are implemented. Earlier local pilot descriptions below record the -original phase. Use [the current operating guide](complete-foundation.md) for today's behavior. - -## Implemented on 2026-10-03 - -Mist uses Kubernetes `batch/v1` Jobs by default. The API submits actual -commands or uploaded scripts, reports real exit codes and scheduler messages, -retrieves pod logs, and cancels workloads. The CLI and Jobs page use these -endpoints. The optional `MIST_EXECUTOR=docker` path retains the older, -incomplete Docker/Redis supervisor for compatibility. - -Kubernetes owns placement, device accounting, and Job lifecycle. Mist validates -submission parameters and configured owner labels. Authentication, team -policy, credits, quotas, priority, and audit accounting are still application -work; the current API is a local single-owner pilot. - -```mermaid -flowchart LR - User[Jobs page / CLI] --> API[Mist API] - API --> Validate[Image, resources, script and deadline validation] - Validate --> Jobs[Kubernetes Jobs] - Jobs --> NVIDIA[CPU / NVIDIA server] - Jobs --> Claims[Tenstorrent DRA claims] - Claims --> TT[QuietBox boards via CDI] - Jobs --> State[Job and Pod status / logs] - State --> API - NVIDIA --> Artifacts[Node-local checkpoint PVCs] - TT --> Artifacts -``` - -See [installation, commands and acceptance tests](k3s-deployment-history.md#mist-api-cli-and-jobs-page) -and [job lifecycle and API endpoints](../../../src/jobs.md). - -## Hardware and scheduling - -| Node | Workload resources | Allocation | -| --- | --- | --- | -| `utmist-z1opa08` | CPU and two NVIDIA RTX A4000 GPUs | `nvidia.com/gpu`, one or two whole GPUs | -| `utmist-tt` | Four n300 Wormhole boards, eight chips | DRA/CDI, one to four whole two-chip boards | - -CPU and NVIDIA jobs select the server because their pilot storage is local -to it. Tenstorrent jobs select QuietBox and reference one of four precreated -ResourceClaimTemplates. Each pod receives its own claim, not a shared claim -between jobs. Resources are released after completion or cancellation; -completed Jobs and checkpoints are retained. Requests wait when their -required CPU, RAM, hugepages, or devices are occupied. Scheduling does not -split a model or combine NVIDIA and Tenstorrent automatically. - -TT-Operator 0.3.0 runs Fabric Manager 0.2.30 and DRA driver 0.0.59 while -preserving host KMD 2.5.0 and firmware 19.4.2. Read-only host TT-Metal and -Python mounts make the runtime specific to this QuietBox. The vendor -ResourceSlice's memory capacity is incorrect; claims select board name, -chip count and board count, without using that capacity. - -The TT test trains each allocated chip independently using TT-NN forward -passes, analytic gradients and weight updates. For multiple boards, separate -runtimes select each board by PCI address through `TT_VISIBLE_DEVICES`. -Arbitrary board subsets need not form a usable connected mesh. Autograd, -distributed training of one model, and large production models are untested. - -## Verified behavior - -Live API acceptance checks passed for 17 jobs: - -- CPU success with actual output, exit-17 failure, deadline failure, and - cancellation with retained logs. -- Two simultaneous one-GPU NVIDIA jobs with distinct device nodes; a third - queued and automatically reused a GPU. A two-GPU job also trained. -- Four simultaneous one-board TT jobs with distinct claims and devices; - a fifth queued and trained after release. -- Two-board and four-board TT jobs trained on four and eight chips. -- Cancelling a four-board reservation released capacity for a waiting job. -- All successful accelerator runs met loss thresholds and reloaded their - checkpoints. Archives confirmed five NVIDIA and 24 TT checkpoint files - from those API checks after the pods completed. - -Separate infrastructure checks also verified that each TT job could open -its allocated board while device cgroups denied the other three, and that -allocation recovered after restarting the DRA driver. CLI and actual browser -checks exercise submission, status, logs and cancellation; backend/CLI/web -tests and a frontend production build cover the application changes. -See [recorded results](../../../deploy/k3s/mist-api-results.json) for evidence paths. - -## Access and retention - -The API runs in `mist-system` with a dedicated service account and a Role -restricted to workloads in `mist`. The October 8 foundation adds a read-only -inventory ClusterRole for listing nodes, pods, ResourceClaims and ResourceSlices. -Secrets, node writes, pod exec, and creating jobs in other namespaces remain -outside its permissions. The October 3 checks predate this inventory role. Workload pods -receive no Kubernetes API token. The `mist` admission policy disallows -privileged execution and broad host device mounts. - -The service account can create workload Jobs, so RBAC alone does not make -it a complete security boundary. Admission policy, shared host hugepages, -shared checkpoint PVCs, and arbitrary script execution require further -hardening before accepting untrusted tenants. The local API has no -per-request authentication; `MIST_PILOT_OWNER` is configuration. - -API and web access bind to loopback, with no public ingress. A port-forward -exposes the deployed API. Retained Jobs carry submission/owner metadata and -cancelled logs across API restarts. There is no Job TTL. Normal logs depend -on retained pods and kubelet retention, and checkpoint PVCs are node-local. -Deleting those resources can remove history or artifacts. - -## Next milestones - -The October 8 local foundation implements approved custom CPU/NVIDIA container -execution, live Jobs/Machines inventory, and persistent per-job `/outputs` -mounts. See [the current guide and developer handoff](local-job-foundation.md) -and [execution plan](foundation-execution-plan.md) for fresh checks and the -agreed next foundation task: shared datasets and result downloads, followed -by team deployment/login. The milestones below cover the wider platform. - -1. Implement authentication and per-user/team authorization before remote - access; define quotas, concurrency limits, credits and priority policy. -2. Package the TT runtime into a reproducible training image and validate - real pilot-team models, topology and distributed training requirements. -3. Provide dataset upload and artifact access, shared/object storage, durable - metadata/log archival, cleanup policy, and datastore/PVC backups. -4. Add network restrictions, workload admission hardening, and operational - monitoring before opening the cluster to untrusted users. - -## References - -- [Kubernetes Job API](https://kubernetes.io/docs/concepts/workloads/controllers/job/) -- [Dynamic Resource Allocation](https://kubernetes.io/docs/concepts/resource-management/dynamic-resource-allocation/) -- [K3s storage](https://docs.k3s.io/storage) -- [Tenstorrent operator platform support](https://docs.tenstorrent.com/tt-operator/latest/platform-support.html) -- [Tenstorrent DRA driver](https://github.com/tenstorrent/tt-dra-driver) diff --git a/docs/content/archive/local-job-foundation.md b/docs/content/archive/local-job-foundation.md deleted file mode 100644 index 12e48d7..0000000 --- a/docs/content/archive/local-job-foundation.md +++ /dev/null @@ -1,334 +0,0 @@ -> **Historical record — superseded.** This text describes an earlier phase. -> Commands and claims below are retained as history, not current setup instructions. -> Start with the [current documentation index](../_index.md) and -> [department operating guide](../department-rollout.md). - -# Local job foundation - - -**October 9 update:** Shared datasets/results, member login and private production -hosting are implemented. Earlier local pilot descriptions below record the -original phase. Use [the current operating guide](complete-foundation.md) for today's behavior. - -Implementation branch: `feat/local-job-foundation`. Recorded October 8, 2026 -(America/New_York). The preserved starting point is commit `2f9b178`. -The [execution plan](foundation-execution-plan.md) records scope and progress. - -## What runs now - -```text -Website :3001 / CLI - | - v -Mist API :3000 -- validates image, resources, and workload - | - v -k3s Job + pod -- Kubernetes schedules; containerd runs the image - / \ - v v -NVIDIA PC QuietBox -CPU / 2 GPUs 4 n300 boards / 8 chips - | | - v v -Per-job output folder on the selected node's persistent volume -``` - -Open **http://127.0.0.1:3001/jobs**. The **Machines** page shows the same real -inventory. Jobs support Python scripts, approved container images, and small -accelerator training checks. Dashboard/login and other prototype pages remain -separate work. Job execution uses Kubernetes, without SSH or a Docker daemon -inside the job. SSH is useful for administration and reaching the local website. - -The current API serves one configured owner, `utmist`. This is a trusted local -pilot. Membership, login, credits, quotas, public deployment, shared datasets, -and browser result downloads are subsequent tasks. - -## Use the website - -1. Check hardware availability at the top of **Jobs**. -2. Select **Compute**: CPU, NVIDIA, or Tenstorrent. -3. For accelerators, request whole GPUs or whole boards. A board has two chips. -4. Select **Workload** and an approved **Container image**. -5. For **Container image** workloads, optionally provide an executable and - arguments, one argument per line. Leaving them blank uses the image defaults. -6. **Resources and environment** contains CPU/RAM, deadline, working directory, - and `NAME=value` environment variables. Save files under `/outputs`. -7. Submit. The job card shows its ID, state, assigned machine, resources, - output path, and exit code. **Logs** reads actual container output. - **Cancel** stops a running or waiting job and preserves recent logs. - -An image must contain its own code and dependencies, or provide a runtime for -script mode. Requesting a GPU does not convert CPU code into accelerator code. -The form accepts an image reference; it does not upload/build Docker archives. - -### Included examples - -The current deployment approves and locally caches these two example images: - -| Image | Website settings | What it checks | -| --- | --- | --- | -| `mist-training:cpu-v1` | CPU; Container image; blank command/arguments | Packaged regression code and dataset, default entrypoint and `/app` working directory, saved/reloaded model | -| `mist-training:nvidia-v1` | NVIDIA; Container image; arguments below | CUDA training and checkpoint reload on each allocated GPU, without CPU fallback | - -For two GPUs, select **GPUs = 2** and enter these arguments, one per line: - -```text ---backend -nvidia ---devices -2 -``` - -For Tenstorrent, choose **Small training check** and one to four boards. This -uses the already tested TT image and installed host runtime. It trains a small -regression model on the chips of every reserved board. TT-NN gradients are -computed explicitly; this does not establish arbitrary PyTorch model or TT -autograd compatibility. Cross-machine/distributed training is separate work. - -## API contracts - -The [job lifecycle reference](../../../src/jobs.md) describes state and retention. -The browser uses `/api`; Vite proxies it to the API. The API itself has these -unprefixed routes: - -| Route | Response | -| --- | --- | -| `GET /hardware` | `{observed_at, machines: [...], pools: [...]}` | -| `GET /images` | `{images: [{reference, accelerators}], profiles: [...]}` | -| `POST /jobs` | HTTP 201, `{job_id, job}` | -| `GET /jobs` | `{count, jobs: [...]}` | -| `GET /jobs/` | Actual job state, resources, placement and output paths | -| `GET /jobs//logs` | `{logs}` | -| `POST /jobs//cancel` | Retained job with state `Cancelled` | -| `GET /healthz` | Jobs API connectivity and executor | - -### Submission - -```json -{ - "name": "my-training-job", - "type": "command", - "accelerator": "nvidia", - "device_count": 1, - "image": "mist-training:nvidia-v1", - "args": ["--backend", "nvidia", "--devices", "1"], - "cpu": "2", - "memory": "2Gi", - "timeout_seconds": 600, - "env": {"GREETING": "my first job"} -} -``` - -| Field | Behavior | -| --- | --- | -| `name` | Optional display name, at most 128 bytes | -| `type` | `command` (default) or `training-smoke` | -| `accelerator` | `cpu` (default), `nvidia`, `tenstorrent` | -| `device_count` | CPU: 0; NVIDIA: 1–2; TT: 1–4 boards. Accelerator default is 1 | -| `image` | Exact server-approved reference. Required when using only image defaults | -| `command` / `args` | Optional arrays, at most 64 combined entries, each at most 8192 bytes. No implicit shell parsing | -| `working_directory` | Optional absolute container path. Custom CPU/NVIDIA containers preserve image WORKDIR when omitted | -| `script` / `script_name` | Optional Python/shell script, at most 32 KiB; filename only, `.py` or `.sh`. Cannot accompany `command` | -| `env` | Up to 32 entries, values at most 4096 bytes. Managed Mist/TT variables cannot be overridden | -| `cpu` | Kubernetes quantity, greater than zero and at most 8; request equals limit | -| `memory` | 64Mi–32Gi; TT minimum 2Gi; request equals limit | -| `timeout_seconds` | 10–86400; default 600; includes queue/startup time | - -`command` replaces the image ENTRYPOINT; `args` replaces CMD. With neither, -the image's entrypoint and arguments run. Scripts and training checks use -their runtime profile and default `/tmp` working directory. TT uses `/tmp` -and its host Python/TT-NN runtime unless explicitly configured otherwise. -To use shell syntax, request it explicitly, for example -`"command": ["sh", "-c"], "args": ["python train.py && echo done"]`. - -CPU defaults: 250m/256Mi. NVIDIA defaults: 2 CPU/2Gi. TT defaults: 2 CPU/4Gi -RAM plus 2Gi of 1Gi hugepages per board. Jobs have no retries. Images are -pulled with `IfNotPresent`; approval does not guarantee availability or -hardware compatibility. Invalid requests return HTTP 400 without creating -Jobs. Kubernetes failures include a message and actual exit code when available. - -### Hardware inventory - -Each machine reports `name`, internal `address`, `ready`, `schedulable`, -`cpu_allocatable`, and `memory_allocatable`. CPU/RAM are Kubernetes allocatable -capacity, not current utilization or remaining CPU/RAM. - -Each pool reports `accelerator`, `node`, `unit`, `total`, `allocated`, -`available`, `ready`, and optional `message` / `chips_per_device`. - -- NVIDIA: count scheduled nonterminal Pod reservations across all namespaces, - including bound Pending and Terminating pods and effective init/sidecar - requests. Unplaced queued pods do not consume a device. -- Tenstorrent: use current complete ResourceSlice generations and deduplicated - ResourceClaim allocations across all namespaces. Claims reserve boards - even before a pod starts. Administrative access claims do not consume devices. -- Unavailable/cordoned/tainted nodes or missing ready device drivers are not - advertised as ready. Incomplete TT inventory has `available: null`. -- Failed discovery returns HTTP 503. The UI marks availability unknown; - it does not invent zero allocations or free hardware. -- The known incorrect TT vendor memory capacity is ignored. - -The snapshot is assembled from separate Kubernetes reads and can change before -submission. Kubernetes remains the allocation authority. Active TT `devices` -details can disappear from a completed job once its claim is released; the -request count and saved `allocation.json` remain useful records. - -## Output persistence - -An init container creates a job-specific directory before the workload starts. -The workload mounts only this directory at two aliases: - -```text -Persistent volume / / - ├── model/checkpoint files - └── results.json - -Inside the workload: - /outputs MIST_OUTPUT_DIR - /checkpoints/ MIST_CHECKPOINT_DIR - MIST_JOB_ID -``` - -Both mount paths refer to the same files. `output_directory` is `/outputs` for -new jobs; old retained jobs keep their original `checkpoint_directory` contract. -The per-job directory is writable by the supported root/nonroot images. Its -permissions and shared backing PVC are not an authenticated tenant boundary. -Writes elsewhere in the container are not saved results. - -| Compute | PVC in `mist` | Physical node | -| --- | --- | --- | -| CPU | `mist-cpu-checkpoints` | `utmist-z1opa08` | -| NVIDIA | `training-nvidia-checkpoints` | `utmist-z1opa08` | -| Tenstorrent | `training-tenstorrent-checkpoints` | `utmist-tt` | - -Find the host directory using the administrative kubeconfig: - -```bash -k3s kubectl -n mist get pvc -k3s kubectl get pv -``` - -Read the selected PV's `spec.hostPath.path` and node affinity. On that node, -the files are under `//`. A read-only helper Pod -can also mount the corresponding PVC on its node. The verification tool below -does this after training has exited, records SHA256 hashes/results, and deletes -its temporary helper Pods. There is no browser file download endpoint yet. - -The local-path PVCs request 256Mi each; local-path does not enforce that as a -disk quota. Jobs/results are retained without TTL or automatic cleanup. -Normal logs are up to 64KiB and depend on pod/kubelet retention. Cancellation -stores up to 32KiB in Job annotations. API restarts preserve Kubernetes history. -Node/disk loss can lose outputs; shared storage and backups follow separately. - -## Development and deployment - -See the [deployment guide](k3s-deployment-history.md#mist-api-cli-and-jobs-page) -for image build/import and startup. Existing services on this machine are: - -```bash -systemctl --user status mist-api-forward mist-web -systemctl --user restart mist-api-forward mist-web -``` - -From a laptop with SSH access to the NVIDIA server, tunnel the local website: - -```bash -ssh -L 3001:127.0.0.1:3001 utmist@100.73.139.66 -``` - -Open `http://127.0.0.1:3001/jobs` on that laptop while the SSH connection is -open. A different laptop port, such as `-L 3002:127.0.0.1:3001`, avoids a local -port conflict. This does not require public ingress. - -The API runs in `mist-system` with namespaced workload permissions and a -read-only inventory ClusterRole: list nodes, pods, ResourceClaims and -ResourceSlices. It cannot read Secrets, exec into pods, update nodes, or create -workloads in other namespaces. The browser contains no Kubernetes credentials. - -Custom registry images require an administrator to update `MIST_ALLOWED_IMAGES` -with exact references, rebuild/reconfigure the API deployment as needed, and -verify image/code compatibility. Prefer immutable tags or digests. For a private -registry, create its image-pull Secret in workload namespace `mist`; set the -API's `MIST_IMAGE_PULL_SECRETS` to its name (or comma-separated names). -The API references the Secrets without reading their contents. Keep credentials -out of the job form, environment variables, and Git. K3s registry configuration -is a separate option for mirrors/private endpoints. - -### Code map - -| Area | Files | -| --- | --- | -| Request model and routing | `src/api.go`, `src/kubernetes.go` | -| Validation, Job construction, cancellation | `src/kubernetes.go` | -| Live inventory | `src/hardware.go` | -| Approved image/profile catalog | `src/images.go` | -| Output directory initialization | `src/outputs.go` | -| Batched job history reads | `src/job_states.go` | -| API types and client | `web-interface/src/api.ts` | -| Polling/recovery | `web-interface/src/hooks/usePolling.ts` | -| Jobs orchestration and form/cards/logs | `web-interface/src/components/JobsPage.tsx`, `components/jobs/*` | -| Shared inventory and Machines page | `components/HardwarePanel.tsx`, `components/MachinesPage.tsx` | -| Cluster deployment/RBAC | `deploy/k3s/mist-api.yaml` | -| Packaged examples | `deploy/k3s/examples/*` | - -### Verification - -```bash -go -C src test -run 'Test(Kubernetes|Hardware|GPURequest|CustomImage|TenstorrentCustom)' ./... -go -C cli test ./... -cd web-interface -npm test -npx tsc --noEmit -npm run build -``` - -The full legacy backend suite additionally requires its Docker images and -Redis. It is separate from the Kubernetes path. - -Fresh live checks require both accelerators initially free, the approved -example images imported, Playwright/Chromium installed, and admin kubectl access: - -```bash -export KUBECONFIG=/home/utmist/.kube/config -export MIST_PLAYWRIGHT_MODULE=/tmp/mist-browser-check/node_modules/playwright/index.mjs -node deploy/k3s/verify_foundation_browser.mjs /home/utmist/mist-foundation-results-new-run -python3 deploy/k3s/verify_foundation.py --artifacts /home/utmist/mist-foundation-results-new-run -``` - -Use one artifact directory for both commands and run them sequentially. -The browser check covers packaged CPU training, both NVIDIA GPUs, two TT -boards/four chips, real card states/logs, failure, and cancellation. The API -check covers occupied/free counts, queuing and reuse after cancellation for -both device types, command/argument semantics, and persistent files read from -new containers on both nodes. These tools submit real jobs and retain results. -Their final markers are `FOUNDATION_BROWSER_PASSED` and -`FOUNDATION_ALLOCATION_OUTPUTS_PASSED`. - -Today's detailed evidence is saved under -`/home/utmist/mist-foundation-results-2026-10-08`. The checked-in -[verification summary](../../../deploy/k3s/foundation-results.json) records actual -job IDs, the deployed image ID, and completed checks. Browser and allocation/ -persistence checks passed on October 8 EDT (October 9 UTC). A separate live -check verified image-pull error reporting, deadline failure, and retained -history across API restarts. The normal image approval list was restored. - -Automated checks passed: 12 focused backend tests, the full src suite with its -local dependencies, CLI tests, 5 frontend interaction tests, type checking, -production build, and lint of changed frontend files. These are small model -execution checks, not a claim about every team model or distributed training. -See the execution plan's progress log and the developer handoff below. - -## Developer handoff: next work - -1. **Shared datasets and downloads (task 4):** storage on QuietBox, private - NFS/shared mount on both nodes, upload limits/progress, stable dataset IDs, - read-only inputs, job output listing/downloads, retention and backup rules. - Preserve the `/outputs` contract and migrate the checkpoint alias explicitly. -2. **Team deployment/login (task 5):** production web hosting with `/api` - routing, authenticated membership, job/file ownership, then team limits. -3. **Model/platform work:** portable TT image, real team models, scheduling - fairness/credits/priority, monitoring, Jupyter and chaining as separate work. - -Current Wi-Fi/LAN can support initial shared-storage tests. A second Ethernet -cable can improve throughput later. Do not remove QuietBox's existing router -link or format the NVIDIA machine's unmounted disk as part of this work. diff --git a/docs/content/archive/shared-storage-deployment-plan.md b/docs/content/archive/shared-storage-deployment-plan.md deleted file mode 100644 index 385621c..0000000 --- a/docs/content/archive/shared-storage-deployment-plan.md +++ /dev/null @@ -1,251 +0,0 @@ -> **Historical record — superseded.** This text describes an earlier phase. -> Commands and claims below are retained as history, not current setup instructions. -> Start with the [current documentation index](../_index.md) and -> [department operating guide](../department-rollout.md). - -# Complete foundation: parts 4 and 5 - -Authorized October 8, 2026 after completion of parts 1–3. Continue on -`feat/local-job-foundation` from `8f1b7f1`. The user requests finishing the -remaining foundation instead of handing it to other developers. No permission -prompts are needed for this work. Preserve existing jobs, checkpoints, network -links and unrelated disk contents. - -## Remaining scope - -### Part 4: shared datasets and result downloads - -- [x] Create `/srv/mist-storage` on QuietBox's existing NVMe filesystem. -- [x] Provide NFSv4 shared storage to the two current Kubernetes nodes, over - their existing LAN. Keep QuietBox's router Ethernet cable connected. -- [x] Install the NFS host client/server packages and persistent configuration. -- [x] Add shared persistent volumes and mount storage in the API. -- [x] Upload datasets through the API and website with size limits/progress, - stable dataset IDs, checksums, ready/error handling, and atomic publication. -- [x] Select a dataset for a job; attach only that dataset read-only at `/inputs`. -- [x] Store new job outputs on shared storage; preserve the `/outputs` and - checkpoint aliases. Keep old local checkpoints retrievable. -- [x] List and download saved job files with ownership checks, streaming, and - protection against traversal and escaping symlinks. Do not execute archives. -- [x] Verify an uploaded dataset is consumed by jobs on both physical machines, - with actual accelerator computation and persistent downloadable results. - -Use a bounded pilot budget (initially 100GiB for new datasets/outputs), an -upload limit (initially 2GiB per dataset), and explicit cleanup/retention docs. -NFS is central storage, not a backup. Preserve the existing checkpoint PVCs. -Metadata must not be writable by workload containers through output mounts. - -### Part 5: login and private deployment - -- [x] Use maintained Better Auth for separate Mist member accounts. Keep the - existing Go executor and add a small auth service with persistent SQLite on - a node-local volume, not SQLite on NFS. -- [x] Seed the first administrator from protected local credentials, outside - Git/tool output. Disable public signup; administrators add members. -- [x] Authenticate API access, derive job/dataset ownership from the session, - and verify isolation with two actual member sessions. -- [x] Add login/logout, current account, password change and member management - UI. Remove prototype profile/account behavior from the active workflow. -- [x] Serve the production frontend with same-origin API/auth proxying and - persistent services; avoid relying on Vite for the deployed website. -- [x] Make the site accessible through the existing private Tailscale network. - Use HTTPS through Tailscale Serve if already available; otherwise keep a - private Tailnet endpoint and record the HTTPS prerequisite accurately. -- [x] Add startup, restoration, membership, image approval, storage quota, - backup and cleanup documentation and repeatable deployment scripts. -- [x] Verify browser login → upload → select dataset/image/device → run → logs - → download, from both machines, plus denial of anonymous/other-owner access. -- [x] Commit tested changes and record exact deployed image/evidence paths. - -## Decisions - -- Keep image-reference submission and current NVIDIA/TT allocation units. - Arbitrary TT models, distributed training, credits, Jupyter, chaining and - image build/archive upload services are outside the five-part foundation. -- Deploy privately; no public domain, Funnel, paid service or external account - provisioning is implied. Use existing host and cluster access. -- A shared tailnet infrastructure account does not identify individual team - members; Mist accounts provide independent ownership. -- Existing owner `utmist` and retained jobs remain visible to the initial - administrator through an explicit migration mapping. -- HTTP cookies alone are not authorization. Go verifies sessions against the - auth service, and state-changing routes require trusted browser origins. -- Test first, then replace the deployed API/frontend with the verified build. - -## Resume information - -- Go/Node runtimes: `~/.local/share/mist-runtimes/go-1.25.1/bin/go` and - `~/.local/share/mist-runtimes/node-22.16.0/bin/node`. -- Kubeconfig: `/home/utmist/.kube/config`; k3s nodes `utmist-z1opa08`, `utmist-tt`. -- QuietBox SSH: `utmist-tt@100.95.175.37`, identity - `/home/utmist/.ssh/id_ed25519_quietbox_codex`, IdentitiesOnly/BatchMode. -- Authorized host operations can use Docker bind/chroot. Package installation - needs host networking because host systemd-resolved listens on loopback. -- Current API deployed in `mist-system`; workloads in `mist`. -- Main tailnet hostname: `utmist.tail459b5e.ts.net`; IP `100.73.139.66`. -- Baseline evidence: `deploy/k3s/foundation-results.json`, - `/home/utmist/mist-foundation-results-2026-10-08`. - -## Progress - -- Plan recorded; repository clean at start. QuietBox SSH and authorized Docker - host access work. QuietBox still has approximately 1.9TiB available. -- Tailscale is connected; no Serve configuration exists. Existing Nginx is - active. Preserve its current default service while adding the Mist endpoint. - -## Completed verification (October 9 UTC) - -- Actual production browser flow passed: login, dataset upload, CPU and both NVIDIA - GPUs, one TT board/two chips, logs, saved file download, account/member actions, - ownership isolation and deactivation. No browser runtime errors. -- QuietBox submitted an authenticated NVIDIA training job, which ran on the main - node and returned downloadable loss metrics. -- Focused Go tests (including race checks), CLI tests/login/logout, frontend - interaction tests, TypeScript and production build passed. -- Old checkpoint PVCs preserved; legacy outputs copied to shared storage. -- NFS/client firewall configuration and production restart/backup checks are - documented in `docs/complete-foundation.md` and `deploy/private/README.md`. -- Private HTTP website is live. Tailscale Serve is disabled; HTTPS requires the - tailnet administrator to enable Serve, accurately recorded as a prerequisite. - -Final review: frontend dependencies now use the checked-in npm lockfile; the -obsolete Bun lockfile was removed. CLI login/logout use real sessions, and -`job submit --dataset` attaches an owned dataset. SQLite backup integrity was -`ok`; restart checks preserved historical/new jobs, datasets and downloadable -results. The production website does not depend on a terminal session. - -## Department rollout scope decision — October 9, 2026 - -The user plans to introduce Mist to several research teams next week and has -explicitly excluded these additions from that rollout: - -- Automatic backups and additional recovery testing. -- HTTPS/Tailscale Serve enablement. -- Monitoring and storage cleanup features. - -The remaining proposed priorities are team membership/permissions and workload -isolation, team resource quotas, fair queuing, representative research workload -and capacity tests, and a documented self-service container-image workflow. -These are proposed follow-up work, not completed foundation features. - -The current access model remains private HTTP over the existing Tailscale -network. Existing manual operating procedures are documented in the foundation -guide. Credits, special job priorities, Jupyter, chaining, and browser image -upload/build services remain outside the immediate rollout scope. - -### Team self-service clarification - -The user clarified that administrators assign each team's conditions and limits; -teams normally do not request administrator permission for individual actions. -The intended rollout model is: - -- Administrators configure membership, permissions and team resource policies. -- Team members upload datasets, submit compatible container images/references, - run jobs and retrieve results independently within their assigned policies. -- The platform validates those policies and admits/queues requests automatically; - ordinary submissions do not require a human approval step. -- Replace the proposed per-image administrator approval process with a documented - self-service image policy. Define allowed image sources and runtime compatibility - as platform/team rules rather than approving each new image manually. - -Current foundation behavior remains distinct: individual owned jobs are already -self-service, but image eligibility uses a global exact-reference allowlist and -Tenstorrent uses the fixed tested runtime profile. Team policies and self-service -custom image eligibility are follow-up implementation work. - -### Team membership and storage requirements - -The user requires multiple teammates per team, explicitly enrolled as members. -Each team needs one team storage area containing a common folder and a folder -for each member. The user clarified that member folders are visible within the -team; they are not private from teammates: - -- Authorized team members can browse/read the team's common folder and the - individual member folders within that team. -- Member folders organize each teammate's files; they do not create a separate - privacy boundary within the team. Write/delete permissions need an explicit - policy and are not implied by permission to view files. -- Members of another team cannot discover, list, read, download, modify or use - the team's files through jobs unless explicitly granted access. -- Explicit cross-team grants must specify their recipients, storage scope and - permitted actions; grants must be revocable and checked on subsequent access. -- All dataset/file access and job input/output attachment must verify the - authenticated member, team membership or explicit grant, and selected scope. -- Workloads mount only their authorized selected input and output locations. - File and network access from running containers must enforce the same boundary; - a UI filter or a differently named folder is insufficient isolation. -- Administrators assign memberships and team conditions. Normal uploads and - submissions within those conditions remain self-service. - -These are new team features. The completed foundation currently provides -per-account ownership checks, rather than this team-sharing/membership model. - -Example intended organization: - -```text -storage/ - team-a/ # visible to Team A members - common/ - alice/ - bob/ - team-b/ # visible to Team B members - common/ - carol/ - dan/ -``` - -Folder names illustrate the organization; enforced authorization determines -access. Cross-team sharing exposes only the explicitly granted scope. - -### Portal login and administrator workspace - -The same portal and login page serve administrators and regular members. -Authenticated roles determine which controls appear and which API actions are -authorized; there is no separate administrator account required for ordinary -research work. - -- Regular members use their team workspace to upload files, submit jobs, view - logs and retrieve results within the team's policies. -- Administrators have the same research workflow plus administration controls - for teams, memberships, resource limits, image policies and access grants. -- An administrator can also be a team member. Ordinary uploads and jobs use the - selected team's membership, storage permissions and resource policies. -- Administrative actions must be explicitly authorized on the server; hiding - controls in the UI alone is insufficient. Administrative authority is distinct - from membership and does not implicitly make the account a member of every - team or bypass limits on its own research jobs. - -The foundation already supports login, basic member administration and ordinary -administrator jobs. The team workspace and additional team administration -controls described here remain follow-up work. - -### Rollout review: implementation details still to define - -The main workflow is covered by the proposed rollout scope. These details belong -within that work; they are not completed features or authorization to implement -every proposed default: - -- File permissions: distinguish team-wide read access from write/delete access. - Proposed default is members managing their own folders, with explicitly - assigned write permissions for the common folder and cross-team grants. -- Membership and grant revocation: deny subsequent access immediately and define - how to handle queued/running jobs that already hold access. Revoking a browser - permission does not remove a file mount from an existing running job. -- Storage quota enforcement: count datasets and generated results, including - writes from running containers. Upload-only checks do not enforce a team cap. -- Failure handling: expose useful reasons for image pull failure, full storage, - unavailable devices and offline nodes; define cancellation, runtime limits and - retry behavior so jobs release resources and do not silently duplicate work. -- Rollout capacity and migration: verify representative dataset/model sizes and - GPU/TT runtime compatibility, and preserve existing account-owned files/jobs - when introducing team ownership. Current pilot limits are 2GiB per upload and - 100GiB total new shared storage. -- Private container registries: decide whether launch supports private images; - if supported, credentials must be team-scoped and unavailable to other teams - and their workloads. Public image references do not need registry credentials. - -Account administration should also define an administrator-assisted password -reset path; self-service email recovery and external email provisioning are not -implied. Automatic backups, HTTPS, monitoring and cleanup remain excluded as -requested. Multi-machine distributed training is outside the existing foundation -and should not be represented as tested by the two separate accelerator smokes. diff --git a/docs/content/complete-foundation.md b/docs/content/complete-foundation.md deleted file mode 100644 index f8009cf..0000000 --- a/docs/content/complete-foundation.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -title: "Complete Foundation (Historical)" -weight: 301 ---- - -# Historical guide - -This phase has been superseded by the department platform on main. - -- [Current documentation](_index.md) -- [Current operating guide](department-rollout.md) -- [Archived original record](archive/complete-foundation.md) diff --git a/docs/content/department-rollout-execution.md b/docs/content/department-rollout-execution.md deleted file mode 100644 index acfec62..0000000 --- a/docs/content/department-rollout-execution.md +++ /dev/null @@ -1,179 +0,0 @@ ---- -title: "Rollout Execution Record" -weight: 100 ---- - -# Department rollout execution - -Authorized October 9, 2026: implement all seven agreed remaining parts on -`feat/local-job-foundation`, preserve existing accounts/jobs/files and verify the -deployed flow on both machines. No permission prompts or cloud deployment. - -## Architecture and defaults - -- Keep Better Auth as the identity/password authority. Team membership and - policies are persisted in Kubernetes ConfigMaps with optimistic concurrency. - Administrators use the same account for admin and ordinary team work. -- Each team has a namespace and separate bounded dataset/model NFS filesystems and PVCs. Inputs mount - read-only; outputs mount only a job's own directory. Team storage is organized - as common and member datasets/results. Teammates can read all team folders. -- Members manage their own uploads/results. Common-folder writers are explicitly - assigned. Only administrators set cross-team grants (read/use, scoped to a - common or member folder). Cross-team write grants are outside the initial - implementation; no write authority is inferred from read authority. -- Dedicated ext4 filesystem images enforce team capacity on uploads and workload - writes. A small QuietBox host service handles trusted provisioning requests, - mounts, online expansion and NFS exports. It never formats physical disks or - deletes team data. Provisioning requests live outside workload mounts. -- Combined team allocation budget is 1,500 GiB within the 1,600 GiB sparse - store. New team defaults are 200 GiB datasets + 500 GiB models/results. Reject allocation - above the budget. Storage expansion is supported; shrinking is rejected. -- Default team workload policy denies inbound connections and private-network - outbound access, including direct NFS/API access. Permit DNS and public HTTPS. - Extend workload admission restrictions to the managed team namespaces. -- A single API/controller replica persists submissions as suspended Kubernetes - Jobs. Round-robin admission across teams reserves resources for admitted jobs; - enforce per-team CPU/memory/GPU/board/concurrency/runtime and queue limits. - Queue state survives restarts. Retry is explicit; do not duplicate training. -- Custom image references are self-service within team registry rules. Preserve - tested default profiles and validate explicit TT container-vs-host runtime - selection. Private registry credentials are not part of initial rollout. -- Revocation denies new requests immediately and suspends affected queued/running - jobs, removing their pods/mounts. Completed output data is retained. -- Legacy per-account work remains available through a clearly selected legacy - workspace; team work requires explicit membership, including administrators. -- Keep the site private through Tailscale machine sharing. Restrict ordinary - shared-user network access to the portal while preserving infrastructure and - known administrator SSH access. No cloud account, Serve or Funnel setup. - -## Execution checklist - -- [x] Team APIs, membership/admin controls and team workspace selector. -- [x] Team common/member storage, file browsing/download and cross-team grants. -- [x] Filesystem capacity enforcement, namespace and container/network isolation. -- [x] Resource policies and durable fair job admission/controller. -- [x] Self-service image policy, compatible runtime selection and guidance. -- [x] Account reset UI, clear job errors/cancellation, legacy preservation. -- [x] Deploy, verify full browser/API flows, accelerator training, real quota - exhaustion, denial inside containers, revocation and queue restart tests. -- [x] Document operating limits, deployment/restart and evidence; commit results. - -Automatic backups, HTTPS, monitoring/cleanup products, credits, Jupyter, chaining, -browser image archives/builds and distributed multi-machine training are excluded. -Capacity checks and queued-job reconciliation implement the agreed limits and job -behavior; they do not add a separate monitoring service. - -## Verified results - -Recorded October 9 on the private production portal. Reports and selected logs -are checked in under -[department evidence](../../deploy/k3s/evidence/department-2026-10-09/README.md). - -- 14 API/cluster/storage checks: membership, write denial, scoped read-only grants, - running-job revocation, FIFO/fair turns, API restart, cancellation, limits, - real container network/credential denial, real job-write ENOSPC, accelerator - training, account deactivation, browser expansion, filesystem growth and - preservation of all 47 legacy jobs. -- 11 browser checks: admin/member flow, account/team operations, upload/download, - custom public image submission, logs/results, admin denial, password reset, - deadline defaults, actual pagination and desktop/mobile layout without JS errors. -- Simultaneous TT training used distinct boards (`tt-0`, `tt-1`), one board/two - chips per job. NVIDIA training used both GPUs; an SSH CLI submission from TT - also ran successfully on main's NVIDIA GPU. -- Admission rejected host network/PID, Kubernetes tokens, privileged containers, - arbitrary host paths and added capabilities. Host firewall allowed the portal - and denied synthetic untrusted-source SSH/Kubernetes/NodePort/backend sockets. -- Failure exit code and execution deadline were observed without automatic retries. -- Focused Go suites, CLI suite, nine React tests, TypeScript and changed-file ESLint - passed. Web/auth npm audits reported zero vulnerabilities at verification time. - -At that initial phase, the full historical Go integration suite had not run -against its legacy Redis/Docker fixtures. The final release run below completed -those suites successfully. The deployed Kubernetes execution -path does not use Redis. Accelerator tests are regression training, not proof of -arbitrary-model portability. External researcher laptop/cloud ACL verification, -HA, hard per-member storage quotas and deliberately excluded features are not -claimed. - -Research pilot remains active with the administrator and sample files/results. -Verification-only accounts are deactivated and verification-only teams disabled; -files/history are retained. Documentation describes current setup, operational -limits and redeployment. Changes were implemented on `feat/local-job-foundation`. See the publication -record below for the subsequent archive and pull request. - -## Latest UI and dataset refinement - -- Focused native dialogs for job submission, dataset upload, team creation and - settings, member creation and password reset; desktop and mobile layouts checked. -- Shared searchable Picker, with keyboard selection, empty results, focus return - and menus that open above near the bottom of the viewport. Shared Tabs support - arrow keys and Home/End. Device/allocation sliders retain exact number entry. -- Compact team cards, paginated searchable member/history rows, status filters, - collapsible hardware/file details and a storage usage bar. Compute cards and - the warm neutral/olive theme are retained. Script editing comes before optional - data/output settings; technical dataset metadata stays behind a disclosure. -- Uploads stream with a configurable 64 GiB ceiling bounded by actual team free - capacity. Real **2 GiB + 4 MiB** upload, SHA256, Job input read and removal of - only the verifier's probe passed. Four-hour requests and explicit cancellation - are supported; resumable upload is outside this implementation. -- Latest checks: all 11 browser checks, nine React tests, TypeScript, targeted - ESLint, focused Go/CLI suites, Python parsing and shell syntax passed. - -That refinement was completed on `feat/local-job-foundation`. Publication is -recorded below. - -## Follow-up: separate large dataset/model pools (requested October 9) - -Confirmed defaults: **200 GiB datasets + 500 GiB models/results per team**, -admin configurable. This changes the earlier 64 GiB ceiling and shared quota. -The recorded baseline above passed before this follow-up. Follow-up checks use -small files; large transfers/exhaustion are excluded at the user’s request. - -- [x] Expand the known image sparsely; release unused bulk physical preallocation. -- [x] Separate bounded dataset/model filesystems and migrate existing outputs. -- [x] Extend policy/admin controls and expose actual free space for both pools. -- [x] Enforce upload capacity against datasets and job writes against models. -- [x] Verify migration, bounds/denials, small uploads and accelerator jobs. - Large upload/exhaustion tests are excluded at the user’s request. -- [x] Update current documentation and commit the completed follow-up. - -Follow-up evidence: seven small-file live checks, six migration tests, eleven -browser checks, nine React tests, focused Go suites, CLI tests, TypeScript, ESLint -and syntax checks. Verified NFS mount identity, separate exports, member-parent -permissions and preserved checkpoints. QuietBox’s unused bulk reservation and -blocks belonging to the deleted large probe were reclaimed; existing research -files outside Mist remain untouched. See current [rollout](department-rollout.md) -for sparse storage capacity and the two-pool API compatibility rules. - -## Release verification and publication (October 9) - -- Full Go backend: **33 tests passed, zero skips**, including legacy integration - tests against a dedicated Redis container with no published ports. Preview - Redis data was preserved. -- Nested Docker module: **10 tests passed, zero skips**, including concurrent - container/volume and limit fixtures. Test containers and volumes were removed. -- Full CLI/logger suites, **10 React tests**, TypeScript, full frontend ESLint - and production build passed. All **11 live browser checks** passed again with - no browser errors. Both cluster nodes and API/auth deployments are Ready. -- Visible regular-member **Container** submission trained a real CUDA network - using a custom image's own CMD. The walkthrough found and fixed clearing the - image field, with a regression test. Image was preloaded, not registry-pushed. -- Large upload and exhaustion tests were not repeated at the user's request; - no 200/500 GiB stress, every-edge-case or arbitrary-model claim is made. - -Current documentation covers user/admin operations, image submission, storage, -deployment, limits, design and verification. The historical `main` tip -`1050b022e36399f4b3b1dba1a3b3d670fcfca539` is preserved on the remote branch -[`archive/main-before-k3s-20261009`](https://github.com/UTMIST/Mist/tree/archive/main-before-k3s-20261009). -The department implementation is prepared for main in -[PR #110](https://github.com/UTMIST/Mist/pull/110), using a squash merge. -The initial merge attempt required an independent review. PR #110 was then -merged on October 9 at 08:06:48 UTC as commit -`3431ffdb713fac72881fc7f61f2750a82fe3f916`; main now contains the verified release. -The account used by this agent has maintain/push access, and the repository still -requires an independent reviewer for subsequent pull requests. The live -private portal already runs the verified implementation. Git publication does -not reset accounts, jobs or files, or switch the live services again. - -See [the final release record](../../deploy/k3s/evidence/department-2026-10-09/release-validation.json) -and [testing instructions](testing.md). diff --git a/docs/content/department-rollout.md b/docs/content/department-rollout.md deleted file mode 100644 index 7d13333..0000000 --- a/docs/content/department-rollout.md +++ /dev/null @@ -1,390 +0,0 @@ ---- -title: "Department Operating Guide" -weight: 10 ---- - -# Mist department rollout - -Merged into **main** through [PR #110](https://github.com/UTMIST/Mist/pull/110) on -October 9, 2026. This is the current operating guide. Original accounts, jobs, -datasets and checkpoint PVCs are preserved; earlier instructions are in the -[historical archive](archive/_index.md). - -## Use the portal - -Open **http://100.73.139.66:8088** after connecting to the existing Tailscale network -or accepting a share of the main machine. A Mist account is also required; a -Tailscale share does not grant a Mist role or team membership. - -1. Sign in. Choose your team in the sidebar **Workspace** selector. -2. Open **Datasets → Add dataset**, choose **My folder** or an authorized common - folder, and upload a file or ZIP. New team storage is prepared asynchronously; the portal shows a waiting - state and enables upload when ready. Wait for the dataset ready message. -3. Open **Jobs → New job**. Select hardware and an image tag/digest, attach a dataset if - needed, and submit your Python script or container entrypoint/arguments. -4. Jobs appear as compact history rows, paginated with 10/25/50 rows per page. - Search across jobs/people/IDs or filter status. Click a row to expand resource details, - **Logs**, **Files**, and cancellation when authorized. -5. Save results under `/outputs`. They remain after exit and can be downloaded - from the job or browsed through **Datasets → Team folders**. - -**Research pilot** is an initial workspace containing the administrator and -verified sample training results. **Legacy files and jobs** shows preserved -account-owned resources. New uploads/submissions require a team; this prevents -using the legacy workspace to bypass quotas. - -The administrator signs in through the same screen and can also perform normal -research work. Initial credentials remain in the protected bootstrap file at -`/home/utmist/.config/mist/admin-bootstrap.json`. That file does not track later -password changes. Never share or commit its auth secret. - -## Administration - -On **Account → Members**, use **New member** to create accounts. Search the -compact list to open their password reset -dialog. **My account** contains your own password settings. A reset revokes existing sessions. There is no email delivery -service; hand the temporary password to its owner privately. Members can change -their own password. Public signup remains disabled. - -On **Teams**: - -- Use **New team** to create a team and choose its initial storage allocation. The portal enrolls - the creating administrator as a common-folder writer. -- Open **Manage → Teammates** to add existing accounts. Members may belong to several teams. Update - **Can write common folder** independently of membership. -- Open **Manage → Limits** to set team CPU, RAM, NVIDIA GPUs, TT boards, concurrency, queued-job capacity, - maximum runtime, storage and allowed registry hosts. -- Open **Manage → Sharing** to share a **common** or **member** folder with a specific other team. Grants are - read/download/use permissions. They never permit writing or deleting the - source team's data. -- Remove membership, revoke a grant or disable a team. Subsequent requests lose - access immediately. Affected queued/running jobs are suspended and their pods - are removed; saved data remains. The controller checks team memberships and - current admin roles every reconciliation. - -Administrators can manage team settings but must explicitly belong to a team to -use its workspace. Admin status does not silently grant access to all files. -Removing a member keeps their stored data available to the remaining team. - -## Permissions - -| Operation | Ordinary teammate | Common writer | Administrator enrolled in team | -|---|---|---|---| -| Read common/member folders and team jobs | Yes | Yes | Yes | -| Upload/delete own datasets; save own job results | Yes | Yes | Yes | -| Upload/delete common datasets; save common results | No | Yes | Yes | -| Cancel a teammate's job | No | No | Yes | -| Manage membership, limits or cross-team grants | No | No | Yes | -| Read another team's storage without a grant/membership | No | No | No | - -Dataset deletion is blocked while a queued, running or terminating job references -it, including a job in a recipient team. Finished outputs have no automatic -retention/cleanup policy. Inputs are read-only for all jobs. - -## Running architecture - -```text -Researcher browser / authenticated CLI on either machine - | - private Tailscale portal :8088 - | - Nginx on NVIDIA machine - /api/* /auth/* - | - Go API + single controller - | | - Better Auth Kubernetes - local SQLite PVC team policy ConfigMaps - suspended Jobs - | - fair admission + scheduler - / \ - CPU / NVIDIA node QuietBox TT node - 1–2 whole GPUs 1–4 n300 boards - \ / - scoped NFS mounts over LAN - read-only /inputs; own /outputs - | - QuietBox bounded team filesystems -``` - -Production runs without Vite or an SSH session. Nginx, k3s/k3s-agent, Tailscale, -NFS and the team volume service start through systemd. API/auth run in -`mist-system`; workloads run in `mist-team-`. Auth remains a single SQLite -instance on the main machine's local volume. - -## Storage and enforced capacity - -QuietBox's known ext4 backing image now has **1,600 GiB logical capacity**. -It is sparse: unused capacity does not reserve that amount of physical disk space. -The initial bulk reservation was released with filesystem discard (`fstrim`), -without shrinking the filesystem or deleting existing data. At the October 9 -check, QuietBox had about **1.9 TiB free** on its host disk; the backing file used -about 6 GiB after reclaiming the deleted test dataset’s blocks. Actual shared -data was about 1.4 GiB; filesystem metadata also uses space. - -The combined allocation budget is **1,500 GiB**, additionally bounded by the -base filesystem capacity less 20 GiB. New teams default to **200 GiB datasets + -500 GiB models/results**. API field `storage_gib` means datasets; the new -`model_storage_gib` means models/results. Legacy create requests specifying only -`storage_gib` apply that size to both pools for compatibility. Two teams at these defaults fit; more teams need smaller -admin allocations or additional capacity. Disabled teams retain files and their -allocations. Existing pilot teams retain their previous sizes in each pool; -they are not silently expanded to the new defaults. - -Each team has two independently bounded ext4 filesystems. Common and member -folders share the applicable team pool; member folders do not have separate hard -quotas. Filesystem overhead reduces usable capacity below the nominal allocation. -No physical disk was formatted. - -```text -/srv/mist-storage/ - metadata/provision-requests/ API-owned requests, outside job mounts - metadata/provision-status/ readiness records for both pools - .team-volumes/team-.img dataset filesystem image - .team-volumes/team--models.img model filesystem image - teams/team-/ dataset filesystem - metadata/datasets/ API-owned published metadata - .uploads/ API-owned upload staging - common/datasets//content/ - members//datasets//content/ - .models/ separate model filesystem - common/jobs//outputs/ - members//jobs//outputs/ - datasets/, jobs/, legacy/ preserved foundation files -``` - -`mist-team-storage.service` validates trusted requests, creates images, mounts them, -records fstab entries and exports each filesystem separately over NFS. Existing -unknown filesystems are never formatted. Online growth is supported; shrinking -and deletion are refused. The API verifies both capacities and distinct mount -device identities, including when NFS reports a zero filesystem ID. A missing -model mount cannot borrow the dataset allocation. - -Existing team job outputs are copied into the model pool, preserving hashes, -permissions, ownership and symlinks without following them. Originals are removed -only after the complete published copy is verified. Conflicts fail closed; -interrupted copies can be retried. Perform migration with submissions paused and -no active team jobs. Dataset paths, job IDs and virtual folder URLs stay stable. -Inputs use read-only `team-storage`; outputs use `team-models` and only the -specific job directory. Actual ext4 capacity enforces writes; PV/PVC numbers are -allocation declarations. Existing bound static NFS claims are preserved during -growth rather than attempting unsupported PVC resizing. - -Upload admission uses actual remaining **dataset** capacity, with a 64 MiB -metadata reserve. There is no fixed 64 GiB upload cap. Administrators can set an -optional smaller `MIST_MAX_DATASET_GIB` ceiling (1–1,048,576); that ceiling does not -create disk space. The portal shows used/free space separately for datasets and -models/results. ZIP extraction needs space for the archive and expanded files; -it rejects traversal, symlinks and special files, with at most 10,000 entries. - -Nginx streams uploads without body buffering; the authenticated API enforces -storage bounds. Browser/API transfer windows are 24 hours. Uploads support -progress and cancellation; interrupted transfers restart. Resumable upload is -not implemented. Earlier baseline verification transferred and removed a -2 GiB + 4 MiB probe. Follow-up verification uses small files, as requested; -no 200/500 GiB upload or exhaustion test is performed. - -Jobs can exhaust their model filesystem and must handle `ENOSPC`/`EDQUOT`. -Dataset capacity remains separate, as do other teams' filesystems. The host still -needs free space for both sparse image growth and software outside Mist. Logical -quotas are not physical space reservations or a backup. - -NFS permits only the two node LAN addresses and uses root squash. The machines' -existing router/Wi-Fi/Ethernet connections remain intact. QuietBox must be online -for shared storage; NFS is not a backup. - -## Admission and resource rules - -| Limit | Default per team | Supported setting | -|---|---|---| -| CPU / memory | 8 cores / 32 GiB | Up to 32 cores / 128 GiB across jobs | -| NVIDIA GPUs | 2 | 0–2 whole GPUs | -| TT boards | 4 | 0–4 boards, two chips each | -| Simultaneous / queued jobs | 2 / 50 | 1–16 / 1–200 | -| Dataset storage | 200 GiB | At least 1 GiB; combined dataset/model allocations at most 1,500 GiB | -| Model/result storage | 500 GiB | At least 1 GiB; independently bounded from datasets | -| Maximum runtime | 24 hours | 10 seconds–24 hours | - -Each job remains limited to 8 cores and 32 GiB. The default execution deadline -is 600 seconds; the browser defaults to the lower team maximum when needed. The deadline begins after admission and includes pod/image startup. -Queued jobs have a separate 24-hour wait limit. - -Submissions are persisted as suspended Jobs. The single controller admits at -most one FIFO head per team per pass, rotating the last-served team through a -persisted cursor. Admitted jobs reserve their requested resources even while -their pods are starting. After 30 seconds, an eligible waiting head reserves its -node against later admissions so a large request can receive draining capacity. -Running work is not preempted for fairness. k3s then binds pods to the appropriate -node and NVIDIA/DRA provides the actual claimed devices. - -Queue state survives API restart; there is no Redis dependency in this execution -path. Cancellation and revocation preserve a bounded log snapshot. A failed job -does not automatically rerun training (`backoffLimit=0`); resubmission is explicit. -Do not scale the API/controller above one replica: multiple controllers require -a leader lease or equivalent admission coordination. The deployment uses Recreate. - -## Container image workflow - -Teams build and push their image to a registry allowed in their policy, then enter -its tag or digest in the portal. Default registry hosts are Docker Hub, GHCR and -NVIDIA's public registry. No individual image approval is required. - -```text -Developer builds image → pushes tagged image to allowed registry - | - enters reference in Mist - | - node pulls image with containerd - | - entrypoint/command runs as a Kubernetes Job -``` - -Include dependencies and compatible accelerator libraries in the image. Use a -digest when reproducibility matters. Docker archive upload/build in the browser -and team-managed private registry credentials are excluded from this release. -Normal public image tags, default entrypoints, explicit commands/arguments, -environment variables, scripts and working directories are supported. - -### Container mode in the portal - -1. Select your team, then **Jobs → New job**. -2. Choose **NVIDIA** and the GPU count, then **Container**. -3. Enter a tagged image containing your code and dependencies. -4. Leave **Command executable** and **Arguments** blank to use the image's - `ENTRYPOINT`/`CMD`. Enter them only to override the image startup. -5. Submit, expand the history row, and open **Logs** or **Files**. Save files - under `/outputs` to retain/download them after the job finishes. - -A regular-member UI demonstration submitted a custom image containing -`/app/train.py` and `CMD ["python", "/app/train.py"]`. No script or command override -was injected. Its two-layer PyTorch network trained for 200 steps on one RTX A4000 -using `cuda:0`, reduced loss from 14.088 to 0.004936, saved weights/metrics and -verified checkpoint reload. [Container mode evidence](../../deploy/k3s/evidence/department-2026-10-09/container-mode-result.json). -The demonstration image was preloaded on the main node; it was not pushed to a -public registry. Normal team workflows build/push to an allowed registry. - -The default NVIDIA image contains CUDA-compatible PyTorch. Tenstorrent can use -the tested installed runtime with the default pinned image, or **Runtime included -in my image** for a compatible custom container. Custom containers receive DRA -devices and huge pages; they do not receive the host Python/TT-Metal mounts. -Successful regression training is not a guarantee that every model/library -supports TT. Intra-device sharing and distributed mixed-backend training remain -outside this release. - -## Network boundary - -Workload namespaces have a deny-by-default ingress/egress policy. They may resolve -DNS and reach public IPv4 HTTPS, but cannot directly connect to the private API, -Kubernetes, NFS, Tailscale SSH or another team's containers. Their storage mounts -are made by the trusted kubelet. Pods do not receive service-account tokens, host -network/PID/IPC access, privileged access or extra capabilities. The only permitted -host paths are the read-only tested TT runtime paths. GPU drivers still share the -host kernel; this is not VM isolation for hostile external code. - -Host rules restrict ordinary shared Tailscale sources to main's TCP 8088. Both -machines deny their direct backend/SSH/NodePort access and forwarded cluster -traffic for those sources. Infrastructure addresses `100.73.139.66`, -`100.95.175.37` and the known administrator `100.99.194.52` (and their matching -Tailscale IPv6 addresses) retain administrative access. LAN access is unchanged. -Adding an administrator device requires updating that explicit host allowlist. - -`mist-portal-firewall.service` installs these rules after Tailscale/k3s and is -reapplied with their service restarts. Tailscale's cloud sharing/ACL policy was -not edited. Tests exercised the actual installed chains with an isolated simulated -untrusted source; a browser on an external shared researcher's laptop is outside -the test environment. Manual netfilter/UFW resets should be followed by restarting -this service. The portal remains private HTTP; no Serve, Funnel or public hosting. - -## CLI on either machine - -```bash -export MIST_API_URL=http://100.73.139.66:8088/api -bin/mist-cli auth login --email your-mist-email -bin/mist-cli team list -bin/mist-cli team use TEAM_ID_FROM_LIST -bin/mist-cli job submit train.py --compute NVIDIA --devices 1 -bin/mist-cli job list --all -bin/mist-cli auth logout -``` - -On QuietBox the updated binary is installed at `~/.local/bin/mist-cli`; use that -path in place of `bin/mist-cli`. Use an actual team ID returned by `team list`. Login prompts without echoing the -password. The selected team and cookie are stored in mode-0600 config. `--team` -or `MIST_TEAM` overrides the saved workspace for a command; `team use legacy` -selects historical account jobs. `job submit --scope common` requires common write -permission; `--tt-runtime container` chooses an image-provided TT runtime. Other -machines can submit to any allowed hardware through the same portal API; SSH is -not how k3s launches their jobs. - -## Developer API contract - -Browser calls use the portal's `/api` prefix; Nginx strips it for Go. Auth calls -use `/auth`. Both use the Better Auth session cookie. Send `X-Mist-Team` with an -actual team ID for team work; `team_id` is also supported on download links. -Identity, current role, membership and write scope are checked on the server. -Missing team selection can read historical account data but cannot submit/upload. - -| Method and path (after `/api`) | Operation | -|---|---| -| `GET /session`, `/teams` | Current account and permitted team list | -| `POST /teams` | Admin creates `{name, storage_gib, model_storage_gib}`; enroll separately | -| `PATCH /teams/{id}` | Admin updates name, disabled flag or a complete policy | -| `POST /teams/{id}/members` | Admin enrolls/updates `{user_id, common_writer}` | -| `DELETE /teams/{id}/members/{user_id}` | Admin removes membership | -| `POST /teams/{id}/grants` | Admin adds `{target_team, scope}` read/use grant | -| `DELETE /teams/{id}/grants/{grant_id}` | Admin revokes a grant | -| `GET /hardware`, `/images` | Actual capacity and compatible image/runtime profiles | -| `POST /jobs`, `GET /jobs`, `GET /jobs/{id}` | Submit and inspect team jobs | -| `POST /jobs/{id}/cancel`, `GET /jobs/{id}/logs` | Authorized cancellation and logs | -| `GET /jobs/{id}/files` | Saved output listing; `/files/download?path=...` downloads | -| `GET /storage`, `/storage/folders` | Actual filesystem space and permitted folders | -| `GET /storage/files?source_team=...&scope=...` | Browse a scoped folder; add `path` and `download=true` to download | -| `GET /datasets`, `GET /datasets/{id}` | Available own/shared datasets and metadata | -| `POST /datasets?filename=...&scope=...` | Raw file body; optional `name` and `format=zip` | -| `DELETE /datasets/{id}` | Delete a writable dataset with no active job references | - -`scope` is `common` or a member's auth user ID, never a filesystem path. Job -submission uses `storage_scope` for its output location. A simple container body: - -```json -{ - "name": "Baseline experiment", - "type": "command", - "accelerator": "cpu", - "image": "python:3.11-slim", - "command": ["python"], - "args": ["/app/train.py"], - "timeout_seconds": 600 -} -``` - -The image must contain `/app/train.py`; inputs mount at `/inputs`, outputs at -`/outputs`. Submit scripts with `script` and `script_name` instead. NVIDIA/TT -requests also specify `device_count`; TT counts **boards**, not chips. The request -and response types are in `web-interface/src/api.ts`, and authority lives in Go, -not browser validation. The server returns 201 for newly created teams, datasets and jobs, and JSON -errors for rejected requests. - -## Deploy and hand off - -The main-node build command is `bash deploy/private/build-and-deploy.sh`. -It preserves bootstrap/internal auth credentials, builds the API/CLI and imports API/auth images, -applies policy/RBAC manifests, waits for rollouts, type-checks and builds the SPA, -and installs an atomic Nginx release. See [private operations](../../deploy/private/README.md) -for host services and recovery of deployment changes. API/auth image tags are -`mist-api:departments-20261009` and `mist-auth:departments-20261009`. - -Build the CLI with `go -C cli build -o ../bin/mist-cli .`. New developer tasks -should retain the identity → team policy → queued Job → scoped storage boundary. -Never accept owner/roles from a submission or mount an entire store into a job. - -Tests: [department verifier](../../deploy/k3s/verify_department.mjs), -[browser verifier](../../deploy/k3s/verify_department_browser.mjs), [large-dataset verifier](../../deploy/k3s/verify_large_dataset.py), Go team tests, -CLI tests and React job/control tests. Checked-in evidence is linked from the execution -plan. Protected verification state contains credentials and is never checked in. - -Automatic backups/recovery, HTTPS, monitoring/cleanup products, credits, Jupyter, -job chaining, browser image archives/builds and public/cloud hosting remain -explicitly excluded. There is one control plane and no new high-availability -failover mechanism. diff --git a/docs/content/design-system.md b/docs/content/design-system.md deleted file mode 100644 index 2885488..0000000 --- a/docs/content/design-system.md +++ /dev/null @@ -1,78 +0,0 @@ ---- -title: "Portal Design System" -weight: 80 ---- - -# Portal design - -October 9, 2026. The visual direction takes inspiration from Cursor's restrained -website: generous spacing, warm neutral surfaces, precise type, quiet borders -and restrained olive accents. Mist keeps its own research workflow and identity. - -## Shared decisions - -- Warm off-white page canvas, white content surfaces, muted sidebar, dark ink. -- Self-hosted Manrope Latin variable font; no runtime external font request. - The SIL Open Font License is included under `web-interface/public/fonts`. -- A fixed desktop sidebar with navigation, workspace and account controls. - At narrow widths it becomes a compact top navigation. -- Interactive components use shared icon headings, clear boundaries, primary/secondary - button hierarchy, hover/focus/pressed feedback and explicit loading/empty states. -- Compute is selected with native keyboard-accessible radio cards; workload modes - use segmented radio controls. Job settings are grouped into numbered sections. Discrete device sliders - are paired with exact numeric inputs, so precision is never lost. -- Dataset uploads support click-to-select and drag/drop; common/member folders - are selectable tiles, with explicit read-only sharing indicators. -- Jobs uses clickable status metrics, collapsible hardware availability, a focused - submission dialog, - and compact job-history rows. Each accessible row button expands details; - History has 10/25/50 rows per page, search, status filters and Previous/Next - controls. There is no infinite scrolling. Logs/Files are secondary actions. Expanded content retains actual runtime data. -- Overview introduces the next experiment and links directly into jobs/datasets. - Team/account administration uses compact team cards and searchable, paginated - member rows. Team settings use Teammates/Limits/Sharing tabs; account settings - separate personal work from member administration. Creation/reset actions open - focused dialogs rather than filling the page with permanently open forms. -- Layouts stack on smaller screens, tables scroll within their containers, - long names wrap/truncate appropriately, and focus states remain visible. - Motion respects reduced-motion preferences. Status always includes text. - -## Interaction principles - -- Each feature has one primary action. Details and advanced settings appear on - demand; explanatory copy is brief and placed next to the relevant decision. -- Shared Picker replaces legacy selects with a search field, clear selected state, - keyboard navigation and no-match feedback. Long account/image labels wrap in - options; the current selection truncates without widening the form. -- Native Modal provides a focus trap, Escape/backdrop close and focus return. - File transfer disables closing until finished or explicitly cancelled. Dialogs - scroll within the screen and retain a visible primary action for job submission. -- Shared Tabs has one tab stop, arrow-key navigation, Home/End and labelled panels. - Buttons, radio cards, disclosure controls and file drop targets preserve native - semantics; icons supplement text rather than hiding the action's meaning. -- Dataset library rows show name, location, file count, size and state. IDs and - checksums are optional details. The upload ceiling reflects actual free space; - the storage bar explains why a larger allocation may be needed. -- Use sliders for counts/allocations with meaningful bounds. Keep exact inputs for - resource quantities and runtime, which may need precise values and units. - -## Source map - -| Concern | Source | -|---|---| -| Colors, type, spacing, responsive layout | `web-interface/src/styles.css` | -| Navigation, workspace/account controls | `components/Navbar.tsx`, `teams.tsx` | -| Overview | `routes/dashboard.tsx` | -| Job workbench/history | `components/JobsPage.tsx`, `components/jobs/JobCard.tsx` | -| Submission and clear validation | `components/jobs/JobSubmissionForm.tsx` | -| Searchable inputs, dialogs and tabs | `components/Picker.tsx`, `components/Modal.tsx`, `components/Tabs.tsx` | -| Shared surfaces/actions | `components/Card.tsx`, `components/Buttons.tsx` | -| Team storage/admin | `components/TeamStorageBrowser.tsx`, `components/TeamsPage.tsx` | - -Component paths above are relative to `web-interface/src`. Prefer these shared -styles/components over inventing a new visual treatment for each feature. - -Real browser verification covered administrator/member actions, row expansion, -file upload/download, container submission and password reset at desktop and -390 px mobile widths. Nine React tests also cover job/history behavior and keyboard control flows. -Screenshots and reports are linked in the rollout checklist. diff --git a/docs/content/development.md b/docs/content/development.md deleted file mode 100644 index 83c0345..0000000 --- a/docs/content/development.md +++ /dev/null @@ -1,81 +0,0 @@ ---- -title: "Developer Setup" -weight: 60 ---- - -# Developer setup - -Use Go **1.25.1** and Node.js **22.16.0 or newer**. Run repository commands from -`/home/utmist/Mist` on main, or your own checkout on a development machine. - -On the existing main machine, the pinned runtimes are available at: - -```bash -export PATH=/home/utmist/.local/share/mist-runtimes/go-1.25.1/bin:/home/utmist/.local/share/mist-runtimes/node-22.16.0/bin:$PATH -go version -node --version -``` - -## Frontend against the existing authenticated API - -Main's `mist-api-forward` user service already forwards the API to -`127.0.0.1:3000`; `mist-web` runs a local Vite preview on `127.0.0.1:3001`. -These are development conveniences. The deployed Nginx portal runs independently. - -```bash -systemctl --user status mist-api-forward mist-web -``` - -If those ports are free, use two terminals on main: - -```bash -export KUBECONFIG=/home/utmist/.kube/config -k3s kubectl -n mist-system port-forward service/mist-api 3000:3000 --address 127.0.0.1 -``` - -```bash -npm --prefix web-interface ci -npm --prefix web-interface run dev -- --host 127.0.0.1 --port 3001 --strictPort -``` - -Open http://127.0.0.1:3001 and sign in with your Mist account. This points to real -accounts, team storage and hardware; submitting jobs or changing policies affects -the installation. Do not start duplicate listeners while the user services run. - -For a UI on another development machine, set `MIST_API_PROXY` to an API endpoint -reachable from it. Configure its exact browser origin in the API/auth trusted -origins before relying on session requests; Tailscale connectivity alone does -not add a trusted browser origin. - -## Backend and CLI - -The root `go.work` joins `src` and `cli`; it replaces the unused root module. - -```bash -go test ./src/... ./cli/... -count=1 -go vet ./src/... ./cli/... -go -C src build -o ../bin/mist-api . -go -C cli build -o ../bin/mist-cli . -``` - -`NewKubernetesApp` exposes the real API. Unit tests use fake Kubernetes clients -and temporary small files, so they need neither Redis nor Docker. Live verifiers -use the existing authenticated installation and protected credentials. - -For backend changes, use an isolated test cluster/namespace with its own auth -and storage, or the documented maintenance deployment of this installation. -Standalone auth-disabled mode remains an explicit trusted development option; -it does not reproduce department authentication/team isolation. Never deploy it -for research users. `MIST_EXECUTOR=docker` is no longer supported and fails at -startup instead of selecting the retired supervisor. - -## Deploying the existing installation - -```bash -bash deploy/private/build-and-deploy.sh -``` - -This requires existing administrative kubeconfig, Docker/root access, auth -Secrets, operators and mounted storage. It is not a fresh-machine installer. -Read [operations](../../deploy/private/README.md) and [testing](testing.md) first. -Current user/admin behavior is in [the operating guide](department-rollout.md). diff --git a/docs/content/foundation-execution-plan.md b/docs/content/foundation-execution-plan.md deleted file mode 100644 index 53c606b..0000000 --- a/docs/content/foundation-execution-plan.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -title: "Foundation Execution Plan (Historical)" -weight: 302 ---- - -# Historical guide - -This phase has been superseded by the department platform on main. - -- [Current documentation](_index.md) -- [Current operating guide](department-rollout.md) -- [Archived original record](archive/foundation-execution-plan.md) diff --git a/docs/content/frontend-architecture.md b/docs/content/frontend-architecture.md deleted file mode 100644 index b1a81bf..0000000 --- a/docs/content/frontend-architecture.md +++ /dev/null @@ -1,130 +0,0 @@ ---- -title: "Frontend Architecture" -weight: 90 ---- - -# Frontend Architecture & Backend Offloading - -Documentation of the current Mist frontend ([`web-interface`](../../web-interface)), its structural organization, and a roadmap of client-side processing to offload to the backend. - -## Architecture overview - -```mermaid -flowchart TD - User([User Browser]) --> Shell[Root Shell: __root.tsx] - Shell --> AG[AccountGate: auth.tsx] - AG --> TG[TeamGate: teams.tsx] - TG --> Nav[Navbar.tsx] - TG --> RouterOutlet[TanStack Router Outlet] - - RouterOutlet --> DPage[Dashboard: dashboard.tsx] - RouterOutlet --> JPage[Jobs Page: jobs.tsx] - RouterOutlet --> SPage[Datasets Page: datasets.tsx] - RouterOutlet --> MPage[Machines Page: machines.tsx] - RouterOutlet --> TPage[Teams Page: teams.tsx] - RouterOutlet --> PPage[Profile: profile.tsx] - - JPage --> JSF[JobSubmissionForm.tsx] - JPage --> JC[JobCard.tsx] - JC --> JL[JobLogs.tsx] - JC --> JF[JobFiles.tsx] - - SPage --> TSB[TeamStorageBrowser.tsx] -``` - -### Technology stack - -- **Vite 7.3**: Build tool and development proxy (`/api` and `/auth` forwards). -- **React 19.3**: Functional component architecture with custom hooks. -- **TanStack Router**: File-based routing located in `src/routes/` with code-splitting. -- **Tailwind CSS 4**: Modern styling via `@tailwindcss/vite` and customized Mist theme classes. -- **Better Auth Client**: Authentication state and admin member management. -- **Vitest 5 & Testing Library**: Unit and component tests. - -### Source layout - -| Location | Purpose | -|---|---| -| `src/main.tsx`, `router.tsx` | Router initialization and DOM hydration | -| `src/routes/__root.tsx` | Shell wrapper containing `AccountGate`, `TeamGate`, and `Navbar` | -| `src/routes/` | File-based routes (`dashboard.tsx`, `jobs.tsx`, `datasets.tsx`, `machines.tsx`, `teams.tsx`, `profile.tsx`) | -| `src/auth.tsx` | Better Auth client configuration, `AccountContext`, and `AccountGate` | -| `src/teams.tsx` | `TeamContext`, active team selection, workspace selector | -| `src/api.ts` | Typed HTTP client for jobs, datasets, hardware, and teams | -| `src/hooks/usePolling.ts` | In-flight request deduplication and abortable periodic polling | -| `src/components/jobs/` | `JobSubmissionForm`, `JobCard`, `JobLogs`, `JobFiles` | -| `src/components/` | `HardwarePanel`, `TeamStorageBrowser`, `Modal`, `Picker`, `Tabs`, `Card`, `Buttons` | - ---- - -## Backend offloading plan - -To keep the frontend as lightweight and simple as possible, the following responsibilities currently performed on the client should be handled by the backend: - -1. **Pagination and filtering**: - - `JobsPage`: Replace client-side slicing and array filtering with query parameters on `GET /jobs?status=...&q=...&page=...&limit=...`. - - `DatasetsPage`: Add `GET /datasets?q=...`. - - `AccountPage`: Replace Better Auth `listUsers({ limit: 1000 })` with a paginated endpoint `GET /members?page=...&q=...`. -2. **Metrics aggregation**: - - Provide `GET /metrics/summary` or summary counters in list endpoints to avoid transferring full item arrays just to compute badge totals. -3. **Image catalog and validation**: - - Move registry string parsing and image approval checks from `JobSubmissionForm` to server-side submission validation. -4. **Quota and limit calculation**: - - Replace client-side calculation of `maxDevices` and `maxTimeout` with authoritative constraints provided by the team policy API. -5. **Form parsing**: - - Accept raw multiline environment variable text and shell commands on `POST /jobs`, delegating parsing and validation to the server. -6. **Error status handling**: - - Replace English error string matching (e.g. `isPreparing`) with typed machine-readable status fields (`{ status: "provisioning" }`). -7. **Permission flags**: - - Return explicit capability booleans (`can_cancel`, `can_delete`, `can_manage`) on entities rather than parsing role strings on the client. -8. **Atomic operations**: - - Consolidate multi-call client sequences (e.g. team creation + creator enrollment, password reset + session revocation) into atomic backend operations. -9. **Event-driven updates**: - - Replace high-frequency polling with Server-Sent Events (SSE) or WebSockets for live job status and log streaming. - ---- - -## Testing with the fake backend - -A self-contained testing backend is provided in `scripts/fake-backend.mjs`. It simulates all Mist API endpoints and Better Auth handlers in memory. - -### Running locally - -```bash -# Terminal 1: Start fake backend (default port 3000) -npm --prefix web-interface run fake-backend -``` - -```bash -# Terminal 2: Start frontend dev server (port 3001) -npm --prefix web-interface run dev -- --host 127.0.0.1 --port 3001 --strictPort -``` - -Open http://127.0.0.1:3001 in your browser. Pre-seeded users include `ada@utmist.gitlab.io` (admin), `alan@utmist.gitlab.io`, and `grace@utmist.gitlab.io`. - -### Configuring hypothetical scenarios and fake information - -A companion configure tool is provided at `scripts/configure-fake-backend.mjs` to set up hypothetical cluster topologies, error conditions, and user setups: - -```bash -# Interactive menu (choose scenarios, add fake nodes, switch active user) -npm --prefix web-interface run configure-fake-backend - -# Switch to a preset scenario directly -node scripts/configure-fake-backend.mjs --scenario high-load -node scripts/configure-fake-backend.mjs --scenario unhealthy -node scripts/configure-fake-backend.mjs --scenario empty-slate -node scripts/configure-fake-backend.mjs --scenario large-cluster -node scripts/configure-fake-backend.mjs --scenario default - -# Inspect current cluster topology & status -node scripts/configure-fake-backend.mjs --status - -# Add custom fake machines, users, or teams -node scripts/configure-fake-backend.mjs --add-machine name=dgx-custom,ip=10.99.1.5,cpu=64,ram=256Gi,gpu=8 -node scripts/configure-fake-backend.mjs --add-user name="Marie Curie",email=curie@rad.edu,role=admin -node scripts/configure-fake-backend.mjs --set-user curie@rad.edu -``` - -If the fake backend is running, `configure-fake-backend.mjs` updates the live server immediately without requiring a restart, while also saving to `.fake-backend-config.json` for persistence. - diff --git a/docs/content/k3s-setup.md b/docs/content/k3s-setup.md deleted file mode 100644 index fb80ea3..0000000 --- a/docs/content/k3s-setup.md +++ /dev/null @@ -1,229 +0,0 @@ ---- -title: "k3s Cluster & Accelerators" -weight: 30 ---- - -# k3s setup and accelerator handoff - -Verified on **October 9, 2026**. This guide describes the existing two-node -installation, fresh-install boundaries and diagnostic commands. Routine -application deployment is in [operations](../../deploy/private/README.md). - -## Installed cluster - -| Item | Main/NVIDIA | QuietBox original | -|---|---|---| -| Kubernetes node | `utmist-z1opa08` | `utmist-tt` | -| Linux user | `utmist` | `utmist-tt` | -| Role/service | server + worker, `k3s` | worker only, `k3s-agent` | -| k3s | `v1.36.4+k3s1` | `v1.36.4+k3s1` | -| OS/kernel | Ubuntu 22.04.5 LTS / 6.8.0-138-generic | Same | -| containerd | `2.3.4-k3s1.36` | Same | -| LAN/InternalIP | `10.0.0.175` | `10.0.0.112` | -| LAN interface | `wlxe0d362c98a69` (Wi-Fi) | `enp201s0` (Ethernet to router) | -| Tailscale IP | `100.73.139.66` | `100.95.175.37` | -| Accelerator | 2 × NVIDIA RTX A4000 | 4 × n300 boards, 2 Wormhole chips per board | -| Operator | GPU Operator `v26.7.1` | TT-Operator `0.3.0`, Fabric Manager `0.2.30`, DRA `0.0.59` | - -Main's host NVIDIA driver is **590.48.01**. QuietBox's `tenstorrent` kernel -module is loaded and `/dev/tenstorrent/0` through `3` exist. Operators retain -these host drivers. Both nodes were Ready and all operator deployments/daemonsets -were Ready at the audit. Service enablement was checked; a new reboot was not tested. - -```text -Researcher → Tailscale → main Nginx :8088 → Mist API/auth - | - k3s server :6443 at 10.0.0.175 - / \ - CPU/NVIDIA TT agent - 10.0.0.112 - \ / - NFS on QuietBox :2049 -``` - -The server already includes kubelet/containerd and can run workloads. Do not -start a separate agent on main: the earlier duplicate agent conflicted on port -6444. QuietBox has no independent `k3s` server service; its old test cluster was -retired before joining this server. - -## Verify from main - -Use the existing private admin kubeconfig. These commands do not print secrets: - -```bash -export KUBECONFIG=/home/utmist/.kube/config -k3s kubectl get nodes -o wide -k3s kubectl get deployments,daemonsets -A -k3s kubectl get pods -n mist-system -o wide -k3s kubectl get deviceclasses,resourceslices -helm list -A -``` - -The admin kubeconfig is private infrastructure access, not a researcher client -configuration. Keep it and node tokens out of Git and job containers. - -On main, expect `systemctl is-active k3s` to report active. On QuietBox, expect -`systemctl is-active k3s-agent` to report active. Both services are enabled at boot. - -## Fresh server installation - -This is for a new, prepared host. Do not reinstall the working control plane to -redeploy Mist or assume this reconstructs its existing accounts/storage. -Pin the observed version when reproducing it: - -```bash -curl -sfL https://get.k3s.io | sudo env INSTALL_K3S_VERSION='v1.36.4+k3s1' sh -s - server -sudo systemctl is-active k3s -sudo k3s kubectl get nodes -o wide -``` - -The installer creates `/etc/rancher/k3s/k3s.yaml`. Choose stable, reachable node -addresses and unique hostnames. Pin `node-ip`/`flannel-iface` in configuration -when multiple interfaces could select the wrong route. This installation uses -LAN addresses, not Tailscale addresses, for its Kubernetes nodes. - -## Join a fresh worker - -First verify the worker can reach **10.0.0.175:6443**. A machine on another LAN -cannot join this setup solely by being in the same tailnet; cluster advertising, -Flannel, NFS routes and firewalls would need a planned network change. - -For a clean worker, an administrator securely installs an appropriate persistent -join token in `/etc/rancher/k3s/agent-token`, owned by root with mode **0600**. -The persistent join token is obtained through authorized access to the server's -`/var/lib/rancher/k3s/server/node-token` or configured agent-token source; -do not paste it into Git, terminal transcripts or this guide. A temporary -bootstrap token is not a substitute for the persistent token used after restart. - -QuietBox's current `/etc/rancher/k3s/config.yaml` contains: - -```yaml -server: https://10.0.0.175:6443 -token-file: /etc/rancher/k3s/agent-token -node-ip: 10.0.0.112 -flannel-iface: enp201s0 -``` - -Use the new worker's own LAN IP/interface when provisioning another host. Then: - -```bash -curl -sfL https://get.k3s.io | sudo env INSTALL_K3S_VERSION='v1.36.4+k3s1' sh -s - agent -sudo systemctl is-active k3s-agent -sudo systemctl is-enabled k3s-agent -``` - -If the host already has another cluster, inspect its Jobs/PVCs and retain its -state before retiring it; the fresh-worker recipe does not migrate cluster data. -QuietBox's original test-server snapshot is root-only at -`/root/k3s-server-prejoin-2026-10-03.tar.gz`. It is historical, not a backup of the -current department platform. - -## Cluster and storage traffic - -| Destination/port | Purpose | Current network | -|---|---|---| -| Main TCP 6443 | Kubernetes API/agent registration | LAN, infrastructure access | -| Node UDP 8472 | Default Flannel VXLAN | LAN between nodes | -| Node TCP 10250 | kubelet access | LAN between nodes | -| QuietBox TCP 2049 | NFSv4 dataset/model mounts | LAN, main and QuietBox sources | -| Main TCP 8088 | Private portal | Tailscale and localhost listeners | -| Host TCP 22 | Ubuntu SSH | Authorized administrator access | - -Pods use `10.42.0.0/16`; Services use `10.43.0.0/16`. QuietBox's UFW allows -8472/UDP and 10250/TCP from main on `enp201s0`, NFS from both LAN host IPs and -forwarding between the node pod subnets. Main's installed host firewall -restricts ordinary Tailscale users to the portal. Keep VXLAN/kubelet/NFS access -scoped to trusted infrastructure sources. Wi-Fi works for the current route; -large transfers depend on LAN throughput. No direct host-to-host cable is required. - -## NVIDIA integration - -The operator's device plugin advertises `nvidia.com/gpu: 2`. CDI injects assigned -GPUs into workload containers. The tracked values retain the host driver and -enable the operator's NRI/CDI configuration. - -From the repository root on main, installation/upgrade for a prepared cluster: - -```bash -export KUBECONFIG=/home/utmist/.kube/config -helm upgrade --install gpu-operator oci://nvcr.io/nvidia/cloud-native-charts/gpu-operator \ - --version v26.7.1 --namespace gpu-operator --create-namespace \ - --values deploy/k3s/nvidia-gpu-operator-values.yaml --wait -k3s kubectl -n gpu-operator get pods -k3s kubectl get node utmist-z1opa08 -o jsonpath='{.status.allocatable.nvidia\.com/gpu}{"\n"}' -``` - -Expect 2 GPUs advertised; that is total schedulable capacity, not currently free -capacity. Each request reserves 1 or 2 whole GPUs. GPU partitioning/time slicing -is not configured. Inspect host `nvidia-smi -L` locally if devices disappear. - -## Tenstorrent integration - -The operator uses existing NFD and host KMD/firmware. It advertises four `n300` -DRA devices, each a two-chip board. TT capacity is in ResourceSlices, not the -NVIDIA extended resource field. - -```bash -export KUBECONFIG=/home/utmist/.kube/config -k3s kubectl label node utmist-tt tenstorrent.com/has-tt=true --overwrite -helm upgrade --install tt-operator oci://ghcr.io/tenstorrent/helm/tt-operator \ - --version 0.3.0 --namespace tt-operator-system --create-namespace \ - --values deploy/k3s/tenstorrent-operator-values.yaml -k3s kubectl -n tt-operator-system get pods -k3s kubectl get deviceclasses,resourceslices -``` - -Mist creates board-count ResourceClaimTemplates in managed team namespaces. -DRA allocates claims and CDI exposes only those boards. A one-board request can -use its two chips; other jobs can reserve remaining boards. Multi-board allocation -does not guarantee fabric topology or distributed model training. - -The default TT profile mounts the tested host checkout/environment read-only; -a compatible custom image can use `tt_runtime: container`. TT smoke checks use -TT-NN forward/analytic-gradient/update operations, not a proof of arbitrary -PyTorch autograd/model compatibility. - -## Test a workload - -Use **Jobs → New job → NVIDIA/Tenstorrent → Training check** after signing in and -selecting your team. This exercises authentication, quotas, queue, allocator, -training and persistent output. Use the [CLI](../../cli/docs/setup.md) from either -host to submit a real script through the same API. - -The direct manifest below is an administrator infrastructure diagnostic in the -legacy `mist` namespace. It bypasses the portal's team admission and does not -establish team-policy enforcement. Run it only when the GPU is available: - -```bash -export KUBECONFIG=/home/utmist/.kube/config -k3s kubectl apply -f deploy/k3s/gpu-smoke-job.yaml -k3s kubectl -n mist wait --for=condition=complete job/gpu-smoke --timeout=180s -k3s kubectl -n mist logs job/gpu-smoke -``` - -Expected CUDA marker: `Test PASSED`. Applying a completed Job does not rerun it; -retain needed evidence before deleting only that completed diagnostic Job. - -## Troubleshooting - -- **Node missing/NotReady:** inspect `systemctl status k3s` on main or - `systemctl status k3s-agent` on QuietBox, then the matching journal and LAN route. -- **Main API down:** confirm the duplicate main agent stays inactive; inspect - port 6444 conflicts before restarting infrastructure. -- **Missing NVIDIA resource:** check host driver and GPU operator/plugin pods. -- **Missing TT devices:** check `/dev/tenstorrent`, the KMD and TT operator pods, - then ResourceSlices/ResourceClaims. -- **Job waiting:** inspect portal message, team limits and actual device capacity; - cluster admins can inspect pod/claim events in its team namespace. -- **Image pull fails:** image must be accessible and contain compatible code. - Locally imported Docker images must also be imported into k3s containerd. -- **Storage unavailable:** verify QuietBox's NFS/team-volume services and both pool - mounts. Do not delete PVCs or overwrite mounted backing files. - -Current live evidence is in [the verification record](../../deploy/k3s/evidence/department-2026-10-09/README.md). -No new reboot, operator upgrade or rejoin was performed for this documentation audit. - -References: [K3s quick start](https://docs.k3s.io/quick-start), -[network requirements](https://docs.k3s.io/installation/requirements), -[GPU Operator](https://docs.nvidia.com/datacenter/cloud-native/gpu-operator/latest/getting-started.html), -[TT-Operator](https://docs.tenstorrent.com/tt-operator/latest/). diff --git a/docs/content/kubernetes-pilot.md b/docs/content/kubernetes-pilot.md deleted file mode 100644 index a91ce37..0000000 --- a/docs/content/kubernetes-pilot.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -title: "Kubernetes Pilot (Historical)" -weight: 303 ---- - -# Historical guide - -This phase has been superseded by the department platform on main. - -- [Current documentation](_index.md) -- [Current operating guide](department-rollout.md) -- [Archived original record](archive/kubernetes-pilot.md) diff --git a/docs/content/local-job-foundation.md b/docs/content/local-job-foundation.md deleted file mode 100644 index 5cf0eda..0000000 --- a/docs/content/local-job-foundation.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -title: "Local Job Foundation (Historical)" -weight: 304 ---- - -# Historical guide - -This phase has been superseded by the department platform on main. - -- [Current documentation](_index.md) -- [Current operating guide](department-rollout.md) -- [Archived original record](archive/local-job-foundation.md) diff --git a/docs/content/repository-cleanup.md b/docs/content/repository-cleanup.md deleted file mode 100644 index 332e596..0000000 --- a/docs/content/repository-cleanup.md +++ /dev/null @@ -1,84 +0,0 @@ ---- -title: "Repository Cleanup" -weight: 110 ---- - -# Repository cleanup — October 9, 2026 - -Based on main commit `3431ffd`, the department release merged through PR #110. -Changes are published in [PR #111](https://github.com/UTMIST/Mist/pull/111) -on `chore/main-cleanup-20261009`. The main ruleset still -requires a pull request and an independent review; Git publication is separate -from deploying services. - -## Removed and organized - -- Retired the unfinished Redis scheduler/supervisor, status registry, fake auth - handlers, Docker manager and prototype CPU/CUDA/ROCm images/scripts. Removed - their tests, Redis Compose/config and unused custom logger/config. Docker - remains image build/import tooling for the current platform. -- Removed the empty tracked `go` file and unused root Go module. `go.work` now - joins the active API and CLI modules. `go mod tidy` pruned retired dependencies. -- Split API lifecycle (`main.go`, `app.go`), HTTP job handlers (`jobs_http.go`), - shared errors/responses (`http.go`) and job types (`job_types.go`) from the - mixed prototype. Current wire fields, authentication/team enforcement and - Kubernetes state/history handling remain compatible. -- Removed unreachable `teamHTTPError` after compiler-based dead-code analysis. - A final analysis with tests reports no remaining unreachable Go functions. -- Removed the CLI dummy configuration command/tests, unused input/job helpers, - editor workspace artifact, malformed command tag and empty command fallback. - Persistent config-path handling is in production code; output helpers are - compiled only for tests. -- Removed the unused frontend fake-user/logout helper, obsolete chart and - `use-immer`/`immer` packages. Existing interactive components remain in use. -- Removed five pre-team API/browser verifiers that no longer reflect deployed - authentication/storage. Current department/storage/accelerator verifiers and - dated evidence remain. -- Archived seven superseded guides and preserved old document entry points. - Added a current documentation index, k3s/Tailscale/SSH/storage/development - handoff and a source/architecture map. Corrected submission HTTP status, - current TT runtime/image rules and main's merged release state. -- Removed the plaintext password from the old SSH guide. Git history retains it; - retire that credential if valid. This cleanup did not rotate account passwords. - -## Verification - -- [Sanitized validation report](../../deploy/k3s/evidence/department-2026-10-09/cleanup-validation.json). -- Full current Go workspace tests with race detection: **29 API tests + 6 CLI - tests**, no skipped tests; CLI entrypoint has no test files. Vet passes. -- API and CLI static builds, CLI/root/job help and retired-executor rejection pass. -- **10 React tests**, TypeScript, full frontend lint and production build pass. - Removing the unused dependency reported **zero npm vulnerabilities**. -- **6 migration tests** pass as root in a disposable container with read-only - test source and tiny temporary files. An ordinary non-root invocation skips - these ownership tests; it is not represented as successful coverage. -- Both live cluster nodes, operator deployments/daemonsets and API/auth services - were inspected. Host service enablement, regular OpenSSH/Tailscale SSH settings, - LAN interfaces, token-file permissions, GPU/KMD inventory and storage usage - were checked on the owning hosts. No rejoin/reboot/operator upgrade was run. -- The compiled candidate API is checked on a temporary authenticated localhost - listener against real Kubernetes/auth: startup/health, anonymous session, - anonymous-job denial and graceful SIGTERM. Team admission is disabled for - this read-only probe, so it cannot run a second department controller. -- Local documentation links/anchors and current shell examples are checked by - `python3 scripts/check-docs.py`, without executing the examples. - -The existing portal continues to run the verified department release. These -source changes do not redeploy it. Previous live CUDA/TT/browser/isolation -[release evidence](../../deploy/k3s/evidence/department-2026-10-09/README.md) remains -valid for that release; it is not claimed as a fresh training/browser run of the -cleanup candidate. No large storage tests, production restarts, account/job/file -mutations or full-disk cleanup were performed. - -Full-host storage and account data live outside the repository. Removing old -source/fixtures does not remove existing Docker containers/volumes or Kubernetes -Jobs/PVCs. Historical account jobs/files remain accessible through the current -legacy workspace. No blanket prune, formatting or history rewrite is used. - -## Reproduce current source checks - -See [testing](testing.md), [developer setup](development.md) and -[documentation index](_index.md). Raw local validation output is kept outside Git -under `/home/utmist/mist-cleanup-validation-20261009`; publish only sanitized -summaries. Credentials, session state, kubeconfig and SSH private keys stay out -of committed documentation/evidence. diff --git a/docs/content/shared-storage-deployment-plan.md b/docs/content/shared-storage-deployment-plan.md deleted file mode 100644 index f5252e5..0000000 --- a/docs/content/shared-storage-deployment-plan.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -title: "Shared Storage Plan (Historical)" -weight: 305 ---- - -# Historical guide - -This phase has been superseded by the department platform on main. - -- [Current documentation](_index.md) -- [Current operating guide](department-rollout.md) -- [Archived original record](archive/shared-storage-deployment-plan.md) diff --git a/docs/content/storage.md b/docs/content/storage.md deleted file mode 100644 index 2672e8e..0000000 --- a/docs/content/storage.md +++ /dev/null @@ -1,105 +0,0 @@ ---- -title: "Storage & Upload Limits" -weight: 50 ---- - -# Storage and upload limits - -Current department storage on **QuietBox**, verified October 9, 2026. Both -compute nodes mount scoped NFSv4 paths over their existing LAN. - -## Layout and authority - -```text -QuietBox /srv/mist-storage-volume.img (sparse base filesystem) -└── /srv/mist-storage/ - ├── metadata/ API-owned requests and metadata - ├── legacy/ preserved historical files - └── teams// bounded dataset filesystem - ├── common/datasets/ - ├── members//datasets/ - └── .models/ separate bounded model/result filesystem - ├── common/jobs//outputs/ - └── members//jobs//outputs/ -``` - -Member directory keys are derived internally; use API/UI scopes rather than -constructing filesystem paths. Teammates can read their team's common/member -folders. Members write their own folders; common writing needs assigned authority. -Other teams need an explicit read/use grant. Cross-team grants never permit writes. -Administrators must belong to a team to use its storage. - -Inputs mount read-only at `/inputs`; each job mounts only its own result directory -at `/outputs` and the checkpoint alias. Auth data lives on main's local SQLite -PVC. File ownership/permissions support the mounts; API and workload policy -provide authorization. They are not separate hard quotas for every member. - -## Capacity - -| Limit | Current value/behavior | -|---|---| -| New team datasets | **200 GiB total**, administrator configurable | -| New team models/results | **500 GiB total**, administrator configurable | -| Base logical capacity | **1,600 GiB** sparse ext4 image | -| Combined team allocation budget | **1,500 GiB** across dataset and model pools | -| Per-member hard quota | None; members share their team's corresponding pool | -| Expansion/shrink | Online expansion supported; shrink rejected | -| Team disable/removal | Preserves files and reserved allocation | - -At most two full 700-GiB default allocations fit within the budget before other -reserved allocations are counted. Existing teams retain their configured sizes; -new defaults do not silently resize them. Administrators can allocate smaller -pools for more teams. Policy overcommit is rejected before persistence. - -Dedicated bounded filesystems enforce both upload capacity and workload result -writes. PVC declarations alone do not enforce NFS capacity. The base/PVC sizes -may retain earlier declarative values; actual filesystem checks are authoritative. - -The image is sparse, so logical quota is not a physical disk reservation. During -the audit the base contained about **1.4 GiB** of data, the backing file consumed -about **5.9 GiB** physically and QuietBox had **1.9 TiB** free. These are dated -observations, not permanent capacity guarantees. The earlier unused bulk -reservation was released; unrelated research files were preserved. - -## Uploads - -Use **Datasets → Add dataset**, choose your own/common folder and upload a file -or ZIP. Bodies stream through Nginx and the API. Capacity is bounded by dataset -free space minus a **64 MiB metadata reserve**, with an optional lower -`MIST_MAX_DATASET_GIB` ceiling. There is no fixed 64 GiB upload limit. - -Transfers can run for up to **24 hours** and can be cancelled. An interrupted -transfer restarts from the beginning; resumable uploads are not implemented. -ZIP uploads require space for staging plus expanded content and reject unsafe -paths/links and expansion beyond the allowed limit. Dataset deletion is blocked -while any active/queued/terminating authorized job references it. - -Use the saved Files panel to download results. There is no automatic results -cleanup or retention service. Jobs that write outside `/outputs` do not retain -those container files. - -## Administration and inspection - -Set dataset/model sizes in **Teams → Manage → Limits**. The QuietBox provisioning -service checks manifests, mounts bounded images and publishes distinct NFS exports. -The API reports storage ready only when both mounts have the expected identities -and capacities. Missing model storage cannot borrow dataset capacity. - -On QuietBox: - -```bash -systemctl status nfs-server mist-team-storage --no-pager -mountpoint /srv/mist-storage -df -h / /srv/mist-storage -findmnt -t ext4,nfs,nfs4 -``` - -With root access, inspect `exportfs -v`, per-team mounts and the storage-agent -journal when readiness fails. Migration/expansion and service installation are -in [deployment operations](../../deploy/private/README.md). Never truncate or unlink -mounted image files, recreate populated filesystems or treat team removal as cleanup. - -Small-file migration, quota bounds and real accelerator input/output paths passed. -The earlier 2 GiB + 4 MiB test is historical evidence; large transfers and -exhaustion were not repeated after the user's request. No 200/500 GiB stress, -automatic backup or disk-failure recovery is claimed. See [testing](testing.md). diff --git a/docs/content/tailscale-ssh.md b/docs/content/tailscale-ssh.md deleted file mode 100644 index 19bd196..0000000 --- a/docs/content/tailscale-ssh.md +++ /dev/null @@ -1,148 +0,0 @@ ---- -title: "Tailscale & Admin SSH" -weight: 40 ---- - -# Tailscale and SSH setup - -Verified against both hosts on **October 9, 2026**. Tailscale **1.102.4** is -running on both. Both use Ubuntu OpenSSH on port **22**, with Tailscale's built-in -SSH handler **disabled**. The private portal uses the existing Tailscale network. - -## Machine addresses - -| Machine | Linux user | Tailscale IP | LAN IP | SSH command | -|---|---|---|---|---| -| NVIDIA/main | `utmist` | `100.73.139.66` | `10.0.0.175` | `ssh utmist@100.73.139.66` | -| QuietBox original | `utmist-tt` | `100.95.175.37` | `10.0.0.112` | `ssh utmist-tt@100.95.175.37` | - -Main's observed MagicDNS name is `utmist.tail459b5e.ts.net`. Its OS hostname has -mixed-case letters; Kubernetes normalizes the node name to `utmist-z1opa08`. -Use the IP commands above when DNS or shared-device name resolution is uncertain. - -## What each login grants - -- **Tailscale access:** reach a shared/authorized machine over the private network. -- **Linux SSH login:** administer that machine with its local account/password or - an authorized SSH key. This password is unrelated to Google/Tailscale login. -- **Mist login:** use the website/CLI with a Mist account and assigned team role. - -Researchers can use http://100.73.139.66:8088 without SSH. Machine sharing does -not create a Mist account or automatically permit administrator SSH. - -## Check the existing setup - -Run locally on either machine: - -```bash -tailscale version -tailscale status -tailscale ip -4 -systemctl is-active tailscaled ssh -``` - -From an authorized administrator device, connect with the table's SSH command. -An SSH key connection to QuietBox was checked during this documentation audit. -Both hosts' OpenSSH configurations enable public-key and password authentication. -Passwords are intentionally absent from this repository. - -## Set up a fresh Ubuntu host - -Run these steps from its local console or an existing administrator session. -For an already connected host, verify its current tailnet before changing login. - -```bash -curl -fsSL https://tailscale.com/install.sh | sh -sudo systemctl enable --now tailscaled -sudo tailscale up -``` - -Open the displayed authentication link and join the intended existing tailnet. -Use its administrator console to manage machine sharing or network access. -Do not log out an existing infrastructure node merely to repeat installation. - -Install Ubuntu's SSH server and choose regular SSH over Tailscale: - -```bash -sudo apt update -sudo apt install openssh-server -sudo /usr/sbin/sshd -t -sudo systemctl enable --now ssh -sudo tailscale set --ssh=false -tailscale ip -4 -``` - -Disabling the built-in SSH handler makes Ubuntu OpenSSH handle authentication. -Tailscale SSH has its own policy and can reject connections even when ordinary -network traffic is allowed. Changing handlers may interrupt a session that uses -Tailscale SSH; perform the switch from a local console when applicable. - -## SSH keys and convenient aliases - -On your administrator laptop, use an existing key or create one: - -```bash -ssh-keygen -t ed25519 -ssh-copy-id -i ~/.ssh/id_ed25519.pub utmist@100.73.139.66 -ssh-copy-id -i ~/.ssh/id_ed25519.pub utmist-tt@100.95.175.37 -``` - -`ssh-copy-id` requires an existing allowed login and adds only the public key. -Alternatively, a host administrator can install the `.pub` contents in that -Linux user's `~/.ssh/authorized_keys`. Keep private keys on their owner device. - -Optional entries in your laptop's `~/.ssh/config`: - -```sshconfig -Host mist-main - HostName 100.73.139.66 - User utmist - IdentityFile ~/.ssh/id_ed25519 - IdentitiesOnly yes - -Host mist-quietbox - HostName 100.95.175.37 - User utmist-tt - IdentityFile ~/.ssh/id_ed25519 - IdentitiesOnly yes -``` - -Connect with `ssh mist-main` or `ssh mist-quietbox`. Adjust the IdentityFile when -using a different key. A successful network connection still needs valid Linux -credentials and access permitted by the host rules. - -## Current host restrictions - -The installed `mist-portal-firewall.service` permits ordinary Tailscale sources -to main's TCP **8088**, denying direct SSH/backend/cluster access. Infrastructure -IPs and the recorded administrator device **100.99.194.52** retain broader access. -The script contains matching IPv6 entries. Adding an administrator device needs -an explicit update to [the host allowlist](../../deploy/private/portal-firewall.sh), -plus any applicable tailnet policy. LAN rules are separate. - -A plain SSH public key does not bypass this firewall. Access from an external -researcher's device has not been verified by this host-side documentation audit. - -The GCP jump-host, port-2025 and manual Discord-reservation instructions from the -old guide are superseded for this installation. Its plaintext password has been -removed from current documentation; Git history still contains that old value. -Retire that credential if it remains valid. No passwords were changed by this audit. - -## Troubleshooting - -| Symptom | Check | -|---|---| -| Timeout or host unreachable | Tailscale connectivity, machine online, host allowlist and tailnet access | -| `tailnet policy does not permit you to SSH` | Built-in Tailscale SSH handler/policy; current hosts use regular OpenSSH | -| `Permission denied` | Correct Linux user, authorized key or Linux password | -| Browser loads but cannot sign in | Mist account/session and trusted browser origin; SSH credentials are unrelated | -| SSH works but worker cannot join | k3s currently uses LAN `10.0.0.175:6443`; tailnet membership alone does not supply that route | - -The current website is private HTTP over Tailscale. Public hosting, Serve/Funnel -and HTTPS setup were excluded from this release. Tailscale subscription limits -should be checked in the account console; machine count and user-seat count are -different and no fixed seat limit is asserted here. - -References: [Tailscale Linux installation](https://tailscale.com/docs/install/linux), -[Tailscale SSH](https://tailscale.com/docs/features/tailscale-ssh), -[Ubuntu OpenSSH](https://documentation.ubuntu.com/server/how-to/security/openssh-server/). diff --git a/docs/content/testing.md b/docs/content/testing.md deleted file mode 100644 index 2060c3d..0000000 --- a/docs/content/testing.md +++ /dev/null @@ -1,87 +0,0 @@ ---- -title: "Automated Testing" -weight: 70 ---- - -# Testing - -Use the pinned Go/Node runtimes in [developer setup](development.md). The root -workspace joins the API and CLI. From the repository root: - -```bash -go test ./src/... ./cli/... -count=1 -go test -race ./src/... ./cli/... -count=1 -go vet ./src/... ./cli/... -npm --prefix web-interface ci -npm --prefix web-interface test -npx --prefix web-interface tsc --noEmit -p web-interface/tsconfig.json -npm --prefix web-interface run lint -npm --prefix web-interface run build -python3 scripts/check-docs.py -``` - -## Automated scope - -Go tests use fake Kubernetes clients and small temporary filesystem fixtures for -job state/cancellation/validation, hardware allocation, authentication/isolation, -team grants/quotas/admission/restarts, scoped storage and uploads/ZIP checks. -Application tests cover unavailable-cluster startup and the scoped compatibility -status endpoint. CLI tests exercise real request/response behavior through test -servers. React tests cover submission, image defaults/editing, pagination, -logs/cancellation and accessible shared controls. Migration tests use tiny files. They require root to verify ownership; a normal -user run reports skips. Run them as root in an isolated environment, for example: - -```bash -sudo python3 -m unittest discover -s deploy/k3s/storage -p test_migration.py -v -``` - -The tests replace the store root with a temporary directory and do not access -live storage. Docker can provide an isolated root environment when sudo is -unavailable. - -Current automated suites do not require Redis or Docker. The old supervisor, -Docker manager/logger tests and fake CLI-config tests were removed with their -retired implementations. This is not a test skip: those products are no longer -part of the application. The earlier full legacy suite results remain as dated -[pre-cleanup evidence](../../deploy/k3s/evidence/department-2026-10-09/release-validation.json). - -## Real installation checks - -| Verifier | Purpose | -|---|---| -| `deploy/k3s/verify_department.mjs` | API, auth, teams, grants, queue/revocation, storage and training | -| `deploy/k3s/verify_department_browser.mjs` | Real admin/member UI, custom image flow, pagination and responsive layout | -| `deploy/k3s/verify_department_runtime.py` | Concurrent TT allocations, failure and execution deadline | -| `deploy/k3s/verify_department_remote_cli.py` | SSH to TT, submit through CLI, execute on NVIDIA | -| `deploy/k3s/verify_department_admission.py` | Admission denials for unsafe workload specifications | -| `deploy/k3s/verify_split_storage.py` | Separate dataset/model pools, migration preservation and small-file training | -| `deploy/k3s/verify_large_dataset.py` | Earlier large-upload fixture; not part of ordinary checks | -| `deploy/private/verify-portal-firewall.sh` | Installed host chains with a synthetic untrusted source | - -These require existing protected credentials, node addresses and operators. -Inspect the script before rerunning: API restart, revocation/deactivation, quota -exhaustion and firewall tests belong in an idle maintenance window. Verification -fixtures preserve history/files; new browser teams reserve 1 GiB per pool even -after being disabled. Do not repeatedly create fixtures without accounting for -reserved storage. - -The retained `verify_tenstorrent_allocation.py` is an administrator infrastructure -diagnostic; it bypasses the portal's team admission. Pre-team browser/API -verifiers have been retired because they no longer reflect current auth/storage. - -## Evidence and limits - -The [October 9 evidence](../../deploy/k3s/evidence/department-2026-10-09/README.md) -records real CUDA/TT training, separate board/GPU reservations, input/output -mounts, checkpoint reload/download, grants/revocation, queue restart and browser -flows. The regular-member Container demonstration preserved its own image CMD; -that image was locally preloaded, not registry-pushed. - -The [cleanup record](repository-cleanup.md) separates new source checks from -previous live-release evidence. A running service is not silently redeployed by -checking out a branch or editing documentation. - -Large uploads and exhaustion are not repeated at the user's request. The -separate-pool follow-up used a 260,729-byte dataset. No 200/500 GiB transfer stress, -every possible edge case, arbitrary research model, HA or disk-failure recovery -is claimed. Excluded features remain outside the release. diff --git a/docs/frontend-architecture.md b/docs/frontend-architecture.md deleted file mode 100644 index f27ed6a..0000000 --- a/docs/frontend-architecture.md +++ /dev/null @@ -1,125 +0,0 @@ -# Frontend Architecture & Backend Offloading - -Documentation of the current Mist frontend ([`web-interface`](../web-interface)), its structural organization, and a roadmap of client-side processing to offload to the backend. - -## Architecture overview - -```mermaid -flowchart TD - User([User Browser]) --> Shell[Root Shell: __root.tsx] - Shell --> AG[AccountGate: auth.tsx] - AG --> TG[TeamGate: teams.tsx] - TG --> Nav[Navbar.tsx] - TG --> RouterOutlet[TanStack Router Outlet] - - RouterOutlet --> DPage[Dashboard: dashboard.tsx] - RouterOutlet --> JPage[Jobs Page: jobs.tsx] - RouterOutlet --> SPage[Datasets Page: datasets.tsx] - RouterOutlet --> MPage[Machines Page: machines.tsx] - RouterOutlet --> TPage[Teams Page: teams.tsx] - RouterOutlet --> PPage[Profile: profile.tsx] - - JPage --> JSF[JobSubmissionForm.tsx] - JPage --> JC[JobCard.tsx] - JC --> JL[JobLogs.tsx] - JC --> JF[JobFiles.tsx] - - SPage --> TSB[TeamStorageBrowser.tsx] -``` - -### Technology stack - -- **Vite 7.3**: Build tool and development proxy (`/api` and `/auth` forwards). -- **React 19.3**: Functional component architecture with custom hooks. -- **TanStack Router**: File-based routing located in `src/routes/` with code-splitting. -- **Tailwind CSS 4**: Modern styling via `@tailwindcss/vite` and customized Mist theme classes. -- **Better Auth Client**: Authentication state and admin member management. -- **Vitest 5 & Testing Library**: Unit and component tests. - -### Source layout - -| Location | Purpose | -|---|---| -| `src/main.tsx`, `router.tsx` | Router initialization and DOM hydration | -| `src/routes/__root.tsx` | Shell wrapper containing `AccountGate`, `TeamGate`, and `Navbar` | -| `src/routes/` | File-based routes (`dashboard.tsx`, `jobs.tsx`, `datasets.tsx`, `machines.tsx`, `teams.tsx`, `profile.tsx`) | -| `src/auth.tsx` | Better Auth client configuration, `AccountContext`, and `AccountGate` | -| `src/teams.tsx` | `TeamContext`, active team selection, workspace selector | -| `src/api.ts` | Typed HTTP client for jobs, datasets, hardware, and teams | -| `src/hooks/usePolling.ts` | In-flight request deduplication and abortable periodic polling | -| `src/components/jobs/` | `JobSubmissionForm`, `JobCard`, `JobLogs`, `JobFiles` | -| `src/components/` | `HardwarePanel`, `TeamStorageBrowser`, `Modal`, `Picker`, `Tabs`, `Card`, `Buttons` | - ---- - -## Backend offloading plan - -To keep the frontend as lightweight and simple as possible, the following responsibilities currently performed on the client should be handled by the backend: - -1. **Pagination and filtering**: - - `JobsPage`: Replace client-side slicing and array filtering with query parameters on `GET /jobs?status=...&q=...&page=...&limit=...`. - - `DatasetsPage`: Add `GET /datasets?q=...`. - - `AccountPage`: Replace Better Auth `listUsers({ limit: 1000 })` with a paginated endpoint `GET /members?page=...&q=...`. -2. **Metrics aggregation**: - - Provide `GET /metrics/summary` or summary counters in list endpoints to avoid transferring full item arrays just to compute badge totals. -3. **Image catalog and validation**: - - Move registry string parsing and image approval checks from `JobSubmissionForm` to server-side submission validation. -4. **Quota and limit calculation**: - - Replace client-side calculation of `maxDevices` and `maxTimeout` with authoritative constraints provided by the team policy API. -5. **Form parsing**: - - Accept raw multiline environment variable text and shell commands on `POST /jobs`, delegating parsing and validation to the server. -6. **Error status handling**: - - Replace English error string matching (e.g. `isPreparing`) with typed machine-readable status fields (`{ status: "provisioning" }`). -7. **Permission flags**: - - Return explicit capability booleans (`can_cancel`, `can_delete`, `can_manage`) on entities rather than parsing role strings on the client. -8. **Atomic operations**: - - Consolidate multi-call client sequences (e.g. team creation + creator enrollment, password reset + session revocation) into atomic backend operations. -9. **Event-driven updates**: - - Replace high-frequency polling with Server-Sent Events (SSE) or WebSockets for live job status and log streaming. - ---- - -## Testing with the fake backend - -A self-contained testing backend is provided in `scripts/fake-backend.mjs`. It simulates all Mist API endpoints and Better Auth handlers in memory. - -### Running locally - -```bash -# Terminal 1: Start fake backend (default port 3000) -npm --prefix web-interface run fake-backend -``` - -```bash -# Terminal 2: Start frontend dev server (port 3001) -npm --prefix web-interface run dev -- --host 127.0.0.1 --port 3001 --strictPort -``` - -Open http://127.0.0.1:3001 in your browser. Pre-seeded users include `ada@utmist.gitlab.io` (admin), `alan@utmist.gitlab.io`, and `grace@utmist.gitlab.io`. - -### Configuring hypothetical scenarios and fake information - -A companion configure tool is provided at `scripts/configure-fake-backend.mjs` to set up hypothetical cluster topologies, error conditions, and user setups: - -```bash -# Interactive menu (choose scenarios, add fake nodes, switch active user) -npm --prefix web-interface run configure-fake-backend - -# Switch to a preset scenario directly -node scripts/configure-fake-backend.mjs --scenario high-load -node scripts/configure-fake-backend.mjs --scenario unhealthy -node scripts/configure-fake-backend.mjs --scenario empty-slate -node scripts/configure-fake-backend.mjs --scenario large-cluster -node scripts/configure-fake-backend.mjs --scenario default - -# Inspect current cluster topology & status -node scripts/configure-fake-backend.mjs --status - -# Add custom fake machines, users, or teams -node scripts/configure-fake-backend.mjs --add-machine name=dgx-custom,ip=10.99.1.5,cpu=64,ram=256Gi,gpu=8 -node scripts/configure-fake-backend.mjs --add-user name="Marie Curie",email=curie@rad.edu,role=admin -node scripts/configure-fake-backend.mjs --set-user curie@rad.edu -``` - -If the fake backend is running, `configure-fake-backend.mjs` updates the live server immediately without requiring a restart, while also saving to `.fake-backend-config.json` for persistence. - diff --git a/docs/go.mod b/docs/go.mod deleted file mode 100644 index 1e7e5e9..0000000 --- a/docs/go.mod +++ /dev/null @@ -1,5 +0,0 @@ -module github.com/UTMIST/Mist/docs - -go 1.25.3 - -require github.com/imfing/hextra v0.13.0 // indirect diff --git a/docs/go.sum b/docs/go.sum deleted file mode 100644 index 6557292..0000000 --- a/docs/go.sum +++ /dev/null @@ -1,2 +0,0 @@ -github.com/imfing/hextra v0.13.0 h1:1zhCYy0tA9ZRErFBlU6NJEgR1HjK69bgd52EmpwnG00= -github.com/imfing/hextra v0.13.0/go.mod h1:vi+yhpq8YPp/aghvJlNKVnJKcPJ/VyAEcfC1BSV9ARo= diff --git a/docs/hugo.yaml b/docs/hugo.yaml deleted file mode 100644 index 94f90f1..0000000 --- a/docs/hugo.yaml +++ /dev/null @@ -1,92 +0,0 @@ -baseURL: "/docs/" -title: "Mist Documentation" -defaultContentLanguage: "en" -enableRobotsTXT: true - -module: - imports: - - path: "github.com/imfing/hextra" - -markup: - highlight: - noClasses: false - guessSyntax: true - goldmark: - renderer: - unsafe: true - extensions: - passthrough: - delimiters: - block: [['\[', '\]'], ['$$', '$$']] - inline: [['\(', '\)']] - enable: true - -menu: - main: - - name: "Overview" - pageRef: "/" - weight: 1 - - name: "Web Portal" - url: "/" - weight: 2 - - name: "Search" - weight: 5 - params: - type: search - - name: "GitHub" - url: "https://github.com/UTMIST/Mist" - weight: 10 - params: - icon: "github" - -params: - description: "Documentation and operational guides for UTMIST's research compute platform." - - externalLinkDecoration: true - - navbar: - displayTitle: true - displayLogo: true - logo: - text: "Mist Docs" - width: wide - - theme: - default: "system" - displayToggle: true - - footer: - enable: true - displayCopyright: true - displayPoweredBy: false - width: normal - - displayUpdatedDate: true - dateFormat: "January 2, 2006" - - search: - enable: true - type: flexsearch - flexsearch: - index: content - tokenize: forward - - editURL: - enable: true - base: "https://github.com/UTMIST/Mist/edit/main/docs/content" - - highlight: - copy: - enable: true - display: hover - - toc: - enable: true - displayTags: true - - page: - width: normal - displayPagination: true - - imageZoom: - enable: true diff --git a/go.work b/go.work deleted file mode 100644 index adb0998..0000000 --- a/go.work +++ /dev/null @@ -1,6 +0,0 @@ -go 1.26.0 - -use ( - ./cli - ./src -) diff --git a/go.work.sum b/go.work.sum deleted file mode 100644 index a4e06a3..0000000 --- a/go.work.sum +++ /dev/null @@ -1,30 +0,0 @@ -cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= -github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c= -github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= -github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= -github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= -github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= -github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= -github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= -github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= -github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= -github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= -go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= -golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= -golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= -golang.org/x/tools v0.51.0/go.mod h1:9eEncMayCV6zRMGhR5eZEC2iBx98qWcF1HZ9Z7wJOoA= -k8s.io/gengo/v2 v2.0.0-20250604051438-85fd79dbfd9f/go.mod h1:EJykeLsmFC60UQbYJezXkEsG2FLrt0GPNkU5iK5GWxU= -modernc.org/cc/v4 v4.29.7/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= -modernc.org/ccgo/v4 v4.36.1/go.mod h1:rrtGc2QkS239nYb/mQNuBMyjq3/y3ZXWbBjPoV3wqzA= -modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= -modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= -modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= -modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= -modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= -modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= -modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= -modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/pre_compute_platform.md b/pre_compute_platform.md deleted file mode 100644 index 6f67bdb..0000000 --- a/pre_compute_platform.md +++ /dev/null @@ -1,12 +0,0 @@ -# Machine access - -The earlier GCP jump-host, shared-password and manual reservation guide is -superseded by the current private Mist installation. - -- [Tailscale and SSH setup](docs/content/tailscale-ssh.md) -- [Research portal and team job submission](docs/content/department-rollout.md) -- [k3s cluster setup](docs/content/k3s-setup.md) - -Passwords are not published in current documentation. The previous password -remains in Git history; retire it if still valid. Researchers use Mist accounts -and team membership, while administrator SSH is separately restricted. diff --git a/scripts/configure-fake-backend.mjs b/scripts/configure-fake-backend.mjs deleted file mode 100644 index 4796596..0000000 --- a/scripts/configure-fake-backend.mjs +++ /dev/null @@ -1,547 +0,0 @@ -#!/usr/bin/env node -/** - * Mist Fake Backend Configuration CLI - * - * Configures hypothetical scenarios, fake users, machines, and hardware pools - * for local testing of the web interface. - * - * Usage: - * node scripts/configure-fake-backend.mjs # Interactive mode - * node scripts/configure-fake-backend.mjs --scenario high-load # Switch scenario - * node scripts/configure-fake-backend.mjs --status # Show current topology - * node scripts/configure-fake-backend.mjs --list-scenarios # List all presets - * node scripts/configure-fake-backend.mjs --reset # Reset to default - */ - -import fs from 'node:fs' -import path from 'node:path' -import http from 'node:http' -import readline from 'node:readline/promises' -import { stdin as input, stdout as output } from 'node:process' -import { fileURLToPath } from 'node:url' -import { - getScenario, - SCENARIO_DESCRIPTIONS, - SCENARIO_FACTORIES, -} from './fake-backend-scenarios.mjs' - -const ROOT_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') -const CONFIG_FILE = path.resolve(ROOT_DIR, '.fake-backend-config.json') - -// Parse CLI flags -const argv = process.argv.slice(2) -function getFlag(name) { - const idx = argv.indexOf(name) - return idx !== -1 && argv[idx + 1] ? argv[idx + 1] : null -} -function hasFlag(name) { - return argv.includes(name) -} - -const PORT = Number( - getFlag('--port') || process.env.PORT || process.env.MIST_FAKE_PORT || 3000, -) -const HOST = process.env.HOST || '127.0.0.1' - -// --- Server Communication Helpers --- - -function requestJSON(method, endpointPath, body = null) { - return new Promise((resolve, reject) => { - const data = body ? JSON.stringify(body) : null - const req = http.request( - { - hostname: HOST, - port: PORT, - path: endpointPath, - method, - headers: { - 'Content-Type': 'application/json', - ...(data ? { 'Content-Length': Buffer.byteLength(data) } : {}), - }, - timeout: 1500, - }, - (res) => { - let resData = '' - res.on('data', (chunk) => (resData += chunk)) - res.on('end', () => { - try { - resolve({ - status: res.statusCode, - data: resData ? JSON.parse(resData) : null, - }) - } catch (e) { - resolve({ status: res.statusCode, raw: resData }) - } - }) - }, - ) - req.on('timeout', () => { - req.destroy() - reject(new Error('Connection timed out')) - }) - req.on('error', (err) => reject(err)) - if (data) req.write(data) - req.end() - }) -} - -async function checkLiveServer() { - try { - const res = await requestJSON('GET', '/_fake/state') - if (res.status === 200 && res.data) { - return res.data - } - } catch {} - return null -} - -function loadLocalConfigFile() { - if (fs.existsSync(CONFIG_FILE)) { - try { - return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8')) - } catch {} - } - return null -} - -function saveLocalConfigFile(state) { - fs.writeFileSync(CONFIG_FILE, JSON.stringify(state, null, 2), 'utf-8') -} - -async function applyState(state) { - // 1. Save to local config file - saveLocalConfigFile(state) - - // 2. If live server running, update it dynamically - const live = await checkLiveServer() - if (live) { - try { - await requestJSON('POST', '/_fake/state', state) - console.log(`\n✓ Live fake backend at http://${HOST}:${PORT} updated instantly!`) - console.log(`✓ Saved configuration to .fake-backend-config.json`) - return - } catch (err) { - console.warn(`\n(Live update failed: ${err.message})`) - } - } - console.log(`\n✓ Saved configuration to .fake-backend-config.json`) - console.log(` (The fake backend is offline. Changes will take effect when it starts.)`) -} - -function parseKeyValue(inputStr) { - const result = {} - const pairs = inputStr.split(',') - for (const pair of pairs) { - const [k, v] = pair.split('=').map((s) => s.trim()) - if (k && v !== undefined) { - result[k] = v.replace(/^["']|["']$/g, '') - } - } - return result -} - -function printStatus(state) { - console.log('\n======================================================') - console.log(`Scenario: ${state.activeScenario || state.name || 'custom'}`) - const activeUser = - state.users?.[state.currentUserIndex] || state.currentUser || null - console.log( - `Active User: ${activeUser ? `${activeUser.name} <${activeUser.email}> [${activeUser.role}]` : 'None'}`, - ) - console.log(`Total Users: ${state.users?.length ?? 0}`) - console.log('------------------------------------------------------') - console.log('Machines & Nodes:') - for (const m of state.machines || []) { - const status = m.ready ? (m.schedulable ? 'Ready' : 'Placement Paused') : 'Offline' - console.log(` • ${m.name.padEnd(22)} ${m.address.padEnd(14)} [${status.padEnd(16)}] ${m.cpu_allocatable} CPU · ${m.memory_allocatable} RAM`) - } - console.log('Hardware Accelerator Pools:') - for (const p of state.pools || []) { - const avail = p.available === null ? 'Unknown' : p.available - const msg = p.message ? ` (${p.message})` : '' - console.log(` • ${p.accelerator.toUpperCase().padEnd(12)} Node: ${p.node.padEnd(20)} Total: ${p.total} Allocated: ${p.allocated} Available: ${avail}${msg}`) - } - console.log('Teams:') - for (const t of state.teams || []) { - console.log(` • ${t.name.padEnd(26)} Members: ${t.members?.length ?? 0} Policy: ${t.policy?.storage_gib ?? 0} GiB Datasets / ${t.policy?.model_storage_gib ?? 0} GiB Models`) - } - console.log('Jobs in History:') - const states = {} - for (const j of state.jobs || []) { - states[j.job_state] = (states[j.job_state] || 0) + 1 - } - console.log(` Total: ${state.jobs?.length ?? 0} (${Object.entries(states).map(([k, v]) => `${k}: ${v}`).join(', ') || '0'})`) - console.log('======================================================\n') -} - -// --- CLI Commands --- - -async function main() { - if (hasFlag('--help') || hasFlag('-h')) { - console.log(` -Mist Fake Backend Configurator - -Commands & Options: - --scenario Load a preset scenario (default, high-load, unhealthy, empty-slate, large-cluster) - --list-scenarios List all available built-in scenarios - --status Display current cluster topology, machines, users, and jobs - --set-user Set which user is active in the web session - --add-user Add a user: name="...",email="...",role=admin|user - --add-machine Add a machine: name="...",ip="...",cpu=32,ram=128Gi,gpu=4,tt=0 - --add-team Add a team: name="...",storage=300,models=600 - --config Load configuration from a JSON file - --export Export active configuration to a JSON file - --reset Reset back to the default scenario - --port Target port (default: 3000) - -Run with no flags for the interactive menu. -`) - return - } - - if (hasFlag('--list-scenarios')) { - console.log('\nAvailable Hypothetical Scenarios:') - for (const [name, desc] of Object.entries(SCENARIO_DESCRIPTIONS)) { - console.log(` • ${name.padEnd(16)} : ${desc}`) - } - console.log('') - return - } - - // Current state resolution - let currentState = (await checkLiveServer()) || loadLocalConfigFile() || getScenario('default') - - if (hasFlag('--status')) { - const live = await checkLiveServer() - console.log(live ? `[Backend Server: ONLINE at http://${HOST}:${PORT}]` : `[Backend Server: OFFLINE (reading config file)]`) - printStatus(currentState) - return - } - - if (hasFlag('--scenario')) { - const name = getFlag('--scenario') - try { - const scenario = getScenario(name) - await applyState(scenario) - printStatus(scenario) - } catch (err) { - console.error(`Error: ${err.message}`) - process.exit(1) - } - return - } - - if (hasFlag('--reset')) { - const defaultScenario = getScenario('default') - await applyState(defaultScenario) - console.log('Reset to default scenario successfully.') - return - } - - if (hasFlag('--config')) { - const filePath = getFlag('--config') - if (!fs.existsSync(filePath)) { - console.error(`File not found: ${filePath}`) - process.exit(1) - } - const data = JSON.parse(fs.readFileSync(filePath, 'utf-8')) - await applyState(data) - console.log(`Loaded configuration from ${filePath}.`) - return - } - - if (hasFlag('--export')) { - const outPath = getFlag('--export') - fs.writeFileSync(outPath, JSON.stringify(currentState, null, 2), 'utf-8') - console.log(`Exported configuration to ${outPath}.`) - return - } - - if (hasFlag('--set-user')) { - const target = getFlag('--set-user') - const idx = (currentState.users || []).findIndex( - (u) => u.id === target || u.email.toLowerCase() === target.toLowerCase(), - ) - if (idx === -1) { - console.error(`User '${target}' not found. Available: ${currentState.users.map((u) => u.email).join(', ')}`) - process.exit(1) - } - currentState.currentUserIndex = idx - await applyState(currentState) - console.log(`Active user set to: ${currentState.users[idx].name} (${currentState.users[idx].email})`) - return - } - - if (hasFlag('--add-user')) { - const kv = parseKeyValue(getFlag('--add-user')) - if (!kv.name || !kv.email) { - console.error('Error: name and email are required (e.g. name="Marie Curie",email="curie@rad.edu",role=admin)') - process.exit(1) - } - const newUser = { - id: 'usr_' + Date.now().toString(36), - name: kv.name, - email: kv.email, - role: kv.role || 'user', - banned: false, - } - currentState.users = currentState.users || [] - currentState.users.push(newUser) - await applyState(currentState) - console.log(`Added user: ${newUser.name} <${newUser.email}> (${newUser.role})`) - return - } - - if (hasFlag('--add-machine')) { - const kv = parseKeyValue(getFlag('--add-machine')) - if (!kv.name) { - console.error('Error: machine name is required (e.g. name=dgx-a100,ip=10.0.1.50,cpu=64,ram=256Gi,gpu=8)') - process.exit(1) - } - const newMachine = { - name: kv.name, - address: kv.ip || kv.address || '10.0.1.' + Math.floor(Math.random() * 200 + 20), - ready: kv.ready !== 'false', - schedulable: kv.schedulable !== 'false', - cpu_allocatable: kv.cpu || '32', - memory_allocatable: kv.ram || '128Gi', - } - currentState.machines = currentState.machines || [] - currentState.machines.push(newMachine) - - // Add GPU or TT pools if requested - currentState.pools = currentState.pools || [] - if (Number(kv.gpu) > 0) { - currentState.pools.push({ - accelerator: 'nvidia', - node: newMachine.name, - unit: 'GPU', - total: Number(kv.gpu), - allocated: 0, - available: Number(kv.gpu), - ready: true, - }) - } - if (Number(kv.tt) > 0) { - currentState.pools.push({ - accelerator: 'tenstorrent', - node: newMachine.name, - unit: 'board', - total: Number(kv.tt), - allocated: 0, - available: Number(kv.tt), - ready: true, - chips_per_device: 2, - }) - } - await applyState(currentState) - console.log(`Added machine: ${newMachine.name} with ${kv.gpu || 0} GPUs and ${kv.tt || 0} Tenstorrent boards.`) - return - } - - if (hasFlag('--add-team')) { - const kv = parseKeyValue(getFlag('--add-team')) - if (!kv.name) { - console.error('Error: team name is required (e.g. name="Quantum ML",storage=500,models=1000)') - process.exit(1) - } - const activeUser = currentState.users?.[currentState.currentUserIndex] - const newTeam = { - id: 'team-' + kv.name.toLowerCase().replace(/\W+/g, '-'), - name: kv.name, - disabled: false, - policy: { - cpu: kv.cpu || '16', - memory: kv.memory || '64Gi', - nvidia: Number(kv.gpu ?? 2), - tenstorrent: Number(kv.tt ?? 2), - concurrent: 4, - queued: 20, - storage_gib: Number(kv.storage || 300), - model_storage_gib: Number(kv.models || 600), - runtime_seconds: 86400, - registries: ['docker.io', 'ghcr.io'], - }, - members: activeUser - ? [{ id: activeUser.id, name: activeUser.name, email: activeUser.email, common_writer: true }] - : [], - grants: [], - } - currentState.teams = currentState.teams || [] - currentState.teams.push(newTeam) - await applyState(currentState) - console.log(`Added team: ${newTeam.name}`) - return - } - - // --- Interactive Terminal Menu --- - const rl = readline.createInterface({ input, output }) - try { - while (true) { - const live = await checkLiveServer() - console.clear() - console.log('======================================================') - console.log(' MIST FAKE BACKEND CONFIGURATOR') - console.log('======================================================') - console.log(`Backend Server: ${live ? `ONLINE (http://${HOST}:${PORT})` : 'OFFLINE (saving to config file)'}`) - console.log(`Active Scenario: ${currentState.activeScenario || currentState.name || 'custom'}`) - const u = currentState.users?.[currentState.currentUserIndex] - console.log(`Active User: ${u ? `${u.name} <${u.email}> (${u.role})` : 'None'}`) - console.log(`Machines: ${currentState.machines?.map((m) => m.name).join(', ') || 'None'}`) - console.log(`Teams: ${currentState.teams?.map((t) => t.name).join(', ') || 'None'}`) - console.log('------------------------------------------------------') - console.log('Select an action:') - console.log(' 1) Switch to a hypothetical scenario preset') - console.log(' 2) Switch active logged-in user') - console.log(' 3) Add a fake machine / node') - console.log(' 4) Add a fake user') - console.log(' 5) Add a fake team') - console.log(' 6) View full cluster status') - console.log(' 7) Reset to default scenario') - console.log(' 8) Exit') - console.log('------------------------------------------------------') - - const choice = (await rl.question('Enter choice [1-8]: ')).trim() - - if (choice === '1') { - console.log('\nAvailable Hypothetical Scenarios:') - const keys = Object.keys(SCENARIO_DESCRIPTIONS) - keys.forEach((key, i) => { - console.log(` ${i + 1}) ${key.padEnd(16)} : ${SCENARIO_DESCRIPTIONS[key]}`) - }) - const sel = (await rl.question('\nSelect scenario [1-5]: ')).trim() - const idx = parseInt(sel, 10) - 1 - if (keys[idx]) { - currentState = getScenario(keys[idx]) - await applyState(currentState) - await rl.question('\nPress Enter to continue...') - } - } else if (choice === '2') { - console.log('\nAvailable Users:') - currentState.users.forEach((user, i) => { - const mark = i === currentState.currentUserIndex ? ' (ACTIVE)' : '' - console.log(` ${i + 1}) ${user.name} <${user.email}> [${user.role}]${mark}`) - }) - const sel = (await rl.question('\nSelect user number: ')).trim() - const idx = parseInt(sel, 10) - 1 - if (currentState.users[idx]) { - currentState.currentUserIndex = idx - await applyState(currentState) - } - } else if (choice === '3') { - console.log('\n--- Add Fake Machine ---') - const name = (await rl.question('Machine name (e.g. dgx-h100-01): ')).trim() - if (!name) continue - const ip = (await rl.question('IP Address (default 10.0.1.25): ')).trim() || '10.0.1.25' - const cpu = (await rl.question('Allocatable CPU cores (default 32): ')).trim() || '32' - const ram = (await rl.question('Allocatable RAM (default 128Gi): ')).trim() || '128Gi' - const gpu = parseInt((await rl.question('NVIDIA GPUs count (default 0): ')).trim() || '0', 10) - const tt = parseInt((await rl.question('Tenstorrent boards count (default 0): ')).trim() || '0', 10) - - const machine = { - name, - address: ip, - ready: true, - schedulable: true, - cpu_allocatable: cpu, - memory_allocatable: ram, - } - currentState.machines = currentState.machines || [] - currentState.machines.push(machine) - - currentState.pools = currentState.pools || [] - if (gpu > 0) { - currentState.pools.push({ - accelerator: 'nvidia', - node: name, - unit: 'GPU', - total: gpu, - allocated: 0, - available: gpu, - ready: true, - }) - } - if (tt > 0) { - currentState.pools.push({ - accelerator: 'tenstorrent', - node: name, - unit: 'board', - total: tt, - allocated: 0, - available: tt, - ready: true, - chips_per_device: 2, - }) - } - await applyState(currentState) - await rl.question('\nMachine added! Press Enter to continue...') - } else if (choice === '4') { - console.log('\n--- Add Fake User ---') - const name = (await rl.question('Full name (e.g. Marie Curie): ')).trim() - if (!name) continue - const email = (await rl.question('Email (e.g. marie@utmist.gitlab.io): ')).trim() - if (!email) continue - const role = (await rl.question('Role (admin / user, default user): ')).trim() || 'user' - currentState.users = currentState.users || [] - currentState.users.push({ - id: 'usr_' + Date.now().toString(36), - name, - email, - role, - banned: false, - }) - await applyState(currentState) - await rl.question('\nUser added! Press Enter to continue...') - } else if (choice === '5') { - console.log('\n--- Add Fake Team ---') - const name = (await rl.question('Team name (e.g. Quantum Computing Lab): ')).trim() - if (!name) continue - const storage = parseInt((await rl.question('Dataset storage in GiB (default 300): ')).trim() || '300', 10) - const models = parseInt((await rl.question('Model storage in GiB (default 600): ')).trim() || '600', 10) - const activeUser = currentState.users?.[currentState.currentUserIndex] - - const newTeam = { - id: 'team-' + name.toLowerCase().replace(/\W+/g, '-'), - name, - disabled: false, - policy: { - cpu: '16', - memory: '64Gi', - nvidia: 2, - tenstorrent: 2, - concurrent: 4, - queued: 20, - storage_gib: storage, - model_storage_gib: models, - runtime_seconds: 86400, - registries: ['docker.io', 'ghcr.io'], - }, - members: activeUser - ? [{ id: activeUser.id, name: activeUser.name, email: activeUser.email, common_writer: true }] - : [], - grants: [], - } - currentState.teams = currentState.teams || [] - currentState.teams.push(newTeam) - await applyState(currentState) - await rl.question('\nTeam added! Press Enter to continue...') - } else if (choice === '6') { - printStatus(currentState) - await rl.question('\nPress Enter to return to menu...') - } else if (choice === '7') { - currentState = getScenario('default') - await applyState(currentState) - await rl.question('\nReset to default scenario! Press Enter to continue...') - } else if (choice === '8') { - break - } - } - } finally { - rl.close() - } -} - -main().catch((err) => { - console.error('Fatal error:', err) - process.exit(1) -}) diff --git a/scripts/dev.sh b/scripts/dev.sh deleted file mode 100755 index 0f5b7a9..0000000 --- a/scripts/dev.sh +++ /dev/null @@ -1,236 +0,0 @@ -#!/usr/bin/env bash -# -# scripts/dev.sh - Bring up local development environment for Mist -# -set -euo pipefail - -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -cd "$ROOT_DIR" - -# Text styles -BOLD='\033[1m' -GREEN='\033[0;32m' -YELLOW='\033[0;33m' -BLUE='\033[0;34m' -RED='\033[0;31m' -NC='\033[0m' - -log_info() { echo -e "${BLUE}[INFO]${NC} $*"; } -log_ok() { echo -e "${GREEN}[OK]${NC} $*"; } -log_warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } -log_error() { echo -e "${RED}[ERROR]${NC} $*" >&2; } - -usage() { - echo -e "${BOLD}Usage:${NC} $0 [options] - -${BOLD}Options:${NC} - --frontend-only Run only the web interface dev server (Vite) - --port-forward Forward Kubernetes mist-api service to localhost:3000 and run frontend - --full-stack Start Backend API (with built-in auth) and Frontend dev server - --k3s Start a local k3s server instance - --port Frontend dev server port (default: 3001) - --api-proxy Set backend API proxy URL (default: http://127.0.0.1:3000) - --help, -h Show this help message - -${BOLD}Examples:${NC} - $0 Auto-detect cluster and start local dev environment - $0 --port-forward Forward deployed cluster API (port 3000) & start frontend (port 3001) - $0 --api-proxy http://100.73.139.66:8088/api" - exit 0 -} - -# Parse options -MODE="auto" -FRONTEND_PORT="3001" -API_PROXY="${MIST_API_PROXY:-http://127.0.0.1:3000}" -START_K3S=false - -while [[ $# -gt 0 ]]; do - case "$1" in - --frontend-only) - MODE="frontend" - shift - ;; - --port-forward) - MODE="port-forward" - shift - ;; - --full-stack) - MODE="full-stack" - shift - ;; - --k3s) - START_K3S=true - shift - ;; - --port) - FRONTEND_PORT="$2" - shift 2 - ;; - --api-proxy) - API_PROXY="$2" - shift 2 - ;; - --help|-h) - usage - ;; - *) - log_error "Unknown option: $1" - usage - ;; - esac -done - -# Track background PIDs for cleanup -PIDS=() -cleanup() { - if [ ${#PIDS[@]} -gt 0 ]; then - log_info "Stopping background services..." - for pid in "${PIDS[@]}"; do - if kill -0 "$pid" 2>/dev/null; then - kill "$pid" 2>/dev/null || true - fi - done - wait 2>/dev/null || true - fi - log_ok "All services stopped." -} -trap cleanup EXIT INT TERM - -# Ensure dependencies exist -check_command() { - if ! command -v "$1" >/dev/null 2>&1; then - log_error "Command '$1' not found. Please activate the dev environment using 'direnv' or 'nix develop'." - exit 1 - fi -} - -check_command node -check_command npm -check_command go - -# Discover KUBECONFIG if not set -find_kubeconfig() { - if [ -n "${KUBECONFIG:-}" ] && [ -f "$KUBECONFIG" ]; then - return 0 - fi - for path in \ - "/home/utmist/.kube/config" \ - "$HOME/.kube/config" \ - "/etc/rancher/k3s/k3s.yaml" \ - "$HOME/.config/rancher/k3s/k3s.yaml"; do - if [ -r "$path" ]; then - export KUBECONFIG="$path" - log_info "Found Kubeconfig at: $KUBECONFIG" - return 0 - fi - done - return 1 -} - -# Check cluster reachability -cluster_reachable() { - if command -v kubectl >/dev/null 2>&1; then - kubectl cluster-info >/dev/null 2>&1 && return 0 - fi - if command -v k3s >/dev/null 2>&1; then - k3s kubectl cluster-info >/dev/null 2>&1 && return 0 - fi - return 1 -} - -KUBECTL_CMD="kubectl" -if ! command -v kubectl >/dev/null 2>&1 && command -v k3s >/dev/null 2>&1; then - KUBECTL_CMD="k3s kubectl" -fi - -# Start local k3s if requested -if [ "$START_K3S" = true ]; then - log_info "Starting local k3s server..." - check_command k3s - K3S_DATA_DIR="$ROOT_DIR/.k3s-dev" - mkdir -p "$K3S_DATA_DIR" - - if [ "$(id -u)" -eq 0 ]; then - k3s server --data-dir "$K3S_DATA_DIR" & - else - log_info "Running k3s in rootless mode..." - k3s server --rootless --data-dir "$K3S_DATA_DIR" & - fi - PIDS+=($!) - - export KUBECONFIG="$HOME/.kube/k3s.yaml" - log_info "Waiting for k3s cluster to be ready..." - for _ in $(seq 1 30); do - if cluster_reachable; then - log_ok "Local k3s cluster is ready!" - break - fi - sleep 2 - done -fi - -# Auto-detect mode if "auto" -if [ "$MODE" = "auto" ]; then - find_kubeconfig || true - if cluster_reachable; then - # Check if mist-api service exists in mist-system - if $KUBECTL_CMD -n mist-system get service/mist-api >/dev/null 2>&1; then - log_info "Detected active cluster with service/mist-api in mist-system." - MODE="port-forward" - else - log_info "Detected Kubernetes cluster. Will use local full-stack mode with cluster connectivity." - MODE="full-stack" - fi - else - log_info "No reachable Kubernetes cluster found. Starting frontend with API proxy: $API_PROXY" - MODE="frontend" - fi -fi - -# Setup web-interface dependencies -if [ ! -d "web-interface/node_modules" ]; then - log_info "Installing web-interface dependencies..." - npm --prefix web-interface ci -fi - -# Execute according to mode -case "$MODE" in - port-forward) - log_info "Starting port-forward for service/mist-api on 127.0.0.1:3000..." - $KUBECTL_CMD -n mist-system port-forward service/mist-api 3000:3000 --address 127.0.0.1 & - PIDS+=($!) - sleep 2 - - log_info "Starting Vite frontend at http://127.0.0.1:${FRONTEND_PORT} (proxying to http://127.0.0.1:3000)..." - export MIST_API_PROXY="http://127.0.0.1:3000" - npm --prefix web-interface run dev -- --host 127.0.0.1 --port "$FRONTEND_PORT" --strictPort - ;; - - full-stack) - # 1. Build and start API with built-in auth - log_info "Building mist-api..." - go -C src build -o ../bin/mist-api . - log_info "Starting mist-api on 127.0.0.1:3000..." - export BETTER_AUTH_SECRET="${BETTER_AUTH_SECRET:-mist-local-dev-secret-key-32-chars-long}" - export MIST_ADMIN_EMAIL="${MIST_ADMIN_EMAIL:-admin@mist.local}" - export MIST_ADMIN_PASSWORD="${MIST_ADMIN_PASSWORD:-mistadmin12345}" - export MIST_AUTH_DATABASE="${MIST_AUTH_DATABASE:-./data/auth.sqlite}" - export MIST_HTTP_ADDR="127.0.0.1:3000" - export MIST_SITE_ORIGIN="http://127.0.0.1:${FRONTEND_PORT}" - bin/mist-api & - PIDS+=($!) - sleep 2 - - # 2. Start Frontend - log_info "Starting Vite frontend at http://127.0.0.1:${FRONTEND_PORT}..." - export MIST_API_PROXY="http://127.0.0.1:3000" - npm --prefix web-interface run dev -- --host 127.0.0.1 --port "$FRONTEND_PORT" --strictPort - ;; - - frontend) - log_info "Starting Vite frontend at http://127.0.0.1:${FRONTEND_PORT} (proxying to ${API_PROXY})..." - export MIST_API_PROXY="$API_PROXY" - npm --prefix web-interface run dev -- --host 127.0.0.1 --port "$FRONTEND_PORT" --strictPort - ;; -esac diff --git a/scripts/fake-backend-scenarios.mjs b/scripts/fake-backend-scenarios.mjs deleted file mode 100644 index b9a0636..0000000 --- a/scripts/fake-backend-scenarios.mjs +++ /dev/null @@ -1,643 +0,0 @@ -/** - * Mist Fake Backend - Built-in Hypothetical Scenarios - * - * Provides modular scenario generators for testing various cluster topologies, - * load conditions, failure modes, and organizational structures. - */ - -export const SCENARIO_DESCRIPTIONS = { - default: 'Standard balanced cluster with 1 GPU node, 1 Tenstorrent node, 2 teams, and 3 users.', - 'high-load': 'Resource-exhausted cluster: 100% GPU/TT saturation, long waiting queue, and 95% full storage.', - unhealthy: 'Degraded cluster with offline nodes, failed jobs, driver error messages, and storage errors.', - 'empty-slate': 'Clean empty cluster with 0 jobs, 0 datasets, and 0 teams for onboarding / first-run testing.', - 'large-cluster': 'Multi-node enterprise setup with 5 machines (H100s, Wormholes), 5 teams, and 30+ jobs.', -} - -export function createDefaultScenario() { - const now = Date.now() - return { - name: 'default', - description: SCENARIO_DESCRIPTIONS.default, - currentUserIndex: 0, - storageBudgetGiB: 2000, - users: [ - { - id: 'usr_ada', - name: 'Ada Lovelace', - email: 'ada@utmist.gitlab.io', - role: 'admin', - banned: false, - }, - { - id: 'usr_alan', - name: 'Alan Turing', - email: 'alan@utmist.gitlab.io', - role: 'user', - banned: false, - }, - { - id: 'usr_grace', - name: 'Grace Hopper', - email: 'grace@utmist.gitlab.io', - role: 'user', - banned: false, - }, - ], - machines: [ - { - name: 'mist-gpu-node-01', - address: '10.0.1.10', - ready: true, - schedulable: true, - cpu_allocatable: '32', - memory_allocatable: '128Gi', - }, - { - name: 'mist-tt-worker-01', - address: '10.0.1.11', - ready: true, - schedulable: true, - cpu_allocatable: '16', - memory_allocatable: '64Gi', - }, - ], - pools: [ - { - accelerator: 'nvidia', - node: 'mist-gpu-node-01', - unit: 'GPU', - total: 4, - allocated: 1, - available: 3, - ready: true, - }, - { - accelerator: 'tenstorrent', - node: 'mist-tt-worker-01', - unit: 'board', - total: 4, - allocated: 0, - available: 4, - ready: true, - chips_per_device: 2, - }, - ], - storage: { - enabled: true, - capacity_bytes: 1099511627776, // 1 TiB - available_bytes: 879609302220, // 800 GiB - model_capacity_bytes: 2199023255552, // 2 TiB - model_available_bytes: 1759218604441, // 1.6 TiB - dataset_allocation_gib: 500, - model_allocation_gib: 1000, - upload_limit_bytes: 5368709120, // 5 GiB - }, - teams: [ - { - id: 'team-vision', - name: 'Computer Vision', - disabled: false, - policy: { - cpu: '16', - memory: '64Gi', - nvidia: 2, - tenstorrent: 4, - concurrent: 4, - queued: 20, - storage_gib: 300, - model_storage_gib: 600, - runtime_seconds: 86400, - registries: ['docker.io', 'ghcr.io', 'quay.io'], - }, - members: [ - { - id: 'usr_ada', - name: 'Ada Lovelace', - email: 'ada@utmist.gitlab.io', - common_writer: true, - }, - { - id: 'usr_alan', - name: 'Alan Turing', - email: 'alan@utmist.gitlab.io', - common_writer: false, - }, - ], - grants: [ - { - id: 'grant-nlp-share', - target_team: 'team-nlp', - scope: 'common', - }, - ], - }, - { - id: 'team-nlp', - name: 'Natural Language Processing', - disabled: false, - policy: { - cpu: '8', - memory: '32Gi', - nvidia: 1, - tenstorrent: 2, - concurrent: 2, - queued: 10, - storage_gib: 200, - model_storage_gib: 400, - runtime_seconds: 43200, - registries: ['docker.io', 'ghcr.io'], - }, - members: [ - { - id: 'usr_ada', - name: 'Ada Lovelace', - email: 'ada@utmist.gitlab.io', - common_writer: true, - }, - { - id: 'usr_grace', - name: 'Grace Hopper', - email: 'grace@utmist.gitlab.io', - common_writer: false, - }, - ], - grants: [], - }, - ], - jobs: [ - { - id: 'job-seed-001', - name: 'ResNet50 Vision Benchmark', - created: new Date(now - 3600000).toISOString(), - job_state: 'Success', - accelerator: 'nvidia', - device_count: 2, - image: 'ghcr.io/utmist/pytorch-cuda:latest', - cpu: '8', - memory: '32Gi', - node: 'mist-gpu-node-01', - exit_code: 0, - message: 'Job completed successfully in 1420s', - checkpoint_directory: '/outputs/checkpoints/job-seed-001', - output_directory: '/outputs/runs/job-seed-001', - timeout_seconds: 7200, - team_id: 'team-vision', - creator_id: 'usr_ada', - creator_name: 'Ada Lovelace', - storage_scope: 'common', - can_cancel: false, - }, - { - id: 'job-seed-002', - name: 'BERT Fine-tuning Test', - created: new Date(now - 1800000).toISOString(), - job_state: 'InProgress', - accelerator: 'nvidia', - device_count: 1, - image: 'ghcr.io/utmist/pytorch-cuda:latest', - cpu: '4', - memory: '16Gi', - node: 'mist-gpu-node-01', - checkpoint_directory: '/outputs/checkpoints/job-seed-002', - output_directory: '/outputs/runs/job-seed-002', - timeout_seconds: 3600, - team_id: 'team-nlp', - creator_id: 'usr_grace', - creator_name: 'Grace Hopper', - storage_scope: 'usr_grace', - can_cancel: true, - }, - { - id: 'job-seed-003', - name: 'Tenstorrent Wormhole Matrix Multiplication', - created: new Date(now - 600000).toISOString(), - job_state: 'Scheduled', - accelerator: 'tenstorrent', - device_count: 2, - image: 'ghcr.io/utmist/tt-metal-runtime:latest', - cpu: '4', - memory: '16Gi', - message: 'Queued; waiting for 2 Tenstorrent n300 board allocations', - checkpoint_directory: '/outputs/checkpoints/job-seed-003', - output_directory: '/outputs/runs/job-seed-003', - timeout_seconds: 1800, - team_id: 'team-vision', - creator_id: 'usr_alan', - creator_name: 'Alan Turing', - storage_scope: 'usr_alan', - can_cancel: true, - }, - ], - datasets: [ - { - id: 'ds-cifar100', - name: 'CIFAR-100 Benchmark Dataset', - filename: 'cifar100.zip', - size: 169000000, - files: 100, - sha256: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', - created: new Date(now - 86400000).toISOString(), - state: 'ready', - team_id: 'team-vision', - team_name: 'Computer Vision', - scope: 'common', - member_name: 'Ada Lovelace', - shared: false, - }, - { - id: 'ds-wikitext', - name: 'Wikitext-103 Subset', - filename: 'wikitext.tar.gz', - size: 512000000, - files: 1, - sha256: 'ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb', - created: new Date(now - 43200000).toISOString(), - state: 'ready', - team_id: 'team-nlp', - team_name: 'Natural Language Processing', - scope: 'common', - member_name: 'Grace Hopper', - shared: true, - }, - ], - } -} - -export function createHighLoadScenario() { - const now = Date.now() - const base = createDefaultScenario() - base.name = 'high-load' - base.description = SCENARIO_DESCRIPTIONS['high-load'] - - // Saturated hardware: 0 available - base.pools = [ - { - accelerator: 'nvidia', - node: 'mist-gpu-node-01', - unit: 'GPU', - total: 4, - allocated: 4, - available: 0, - ready: true, - }, - { - accelerator: 'tenstorrent', - node: 'mist-tt-worker-01', - unit: 'board', - total: 4, - allocated: 4, - available: 0, - ready: true, - chips_per_device: 2, - }, - ] - - // Storage 95% consumed - base.storage = { - enabled: true, - capacity_bytes: 1073741824000, // 1 TiB - available_bytes: 32212254720, // 30 GiB left (near capacity!) - model_capacity_bytes: 2147483648000, - model_available_bytes: 53687091200, // 50 GiB left - dataset_allocation_gib: 980, - model_allocation_gib: 1950, - upload_limit_bytes: 2147483648, - } - - // 4 running jobs taking all accelerators + 8 queued jobs - base.jobs = [ - { - id: 'job-hl-run-01', - name: 'LLM Multi-GPU Pretraining Node 1', - created: new Date(now - 7200000).toISOString(), - job_state: 'InProgress', - accelerator: 'nvidia', - device_count: 2, - image: 'ghcr.io/utmist/pytorch-cuda:latest', - cpu: '16', - memory: '64Gi', - node: 'mist-gpu-node-01', - checkpoint_directory: '/outputs/checkpoints/job-hl-run-01', - output_directory: '/outputs/runs/job-hl-run-01', - team_id: 'team-vision', - creator_name: 'Ada Lovelace', - can_cancel: true, - }, - { - id: 'job-hl-run-02', - name: 'Diffusion Video Upscaler', - created: new Date(now - 3600000).toISOString(), - job_state: 'InProgress', - accelerator: 'nvidia', - device_count: 2, - image: 'ghcr.io/utmist/pytorch-cuda:latest', - cpu: '16', - memory: '64Gi', - node: 'mist-gpu-node-01', - checkpoint_directory: '/outputs/checkpoints/job-hl-run-02', - output_directory: '/outputs/runs/job-hl-run-02', - team_id: 'team-nlp', - creator_name: 'Grace Hopper', - can_cancel: true, - }, - { - id: 'job-hl-run-03', - name: 'TT Wormhole Full-Board Stress Test', - created: new Date(now - 5400000).toISOString(), - job_state: 'InProgress', - accelerator: 'tenstorrent', - device_count: 4, - image: 'ghcr.io/utmist/tt-metal-runtime:latest', - cpu: '16', - memory: '64Gi', - node: 'mist-tt-worker-01', - checkpoint_directory: '/outputs/checkpoints/job-hl-run-03', - output_directory: '/outputs/runs/job-hl-run-03', - team_id: 'team-vision', - creator_name: 'Alan Turing', - can_cancel: true, - }, - // Queued jobs - ...Array.from({ length: 8 }, (_, i) => ({ - id: `job-hl-wait-0${i + 1}`, - name: `Queued Experiment #${i + 1}`, - created: new Date(now - (8 - i) * 300000).toISOString(), - job_state: 'Scheduled', - accelerator: i % 2 === 0 ? 'nvidia' : 'tenstorrent', - device_count: (i % 2) + 1, - image: i % 2 === 0 ? 'ghcr.io/utmist/pytorch-cuda:latest' : 'ghcr.io/utmist/tt-metal-runtime:latest', - cpu: '4', - memory: '16Gi', - message: `Waiting for resources (Position in queue: ${i + 1})`, - checkpoint_directory: `/outputs/checkpoints/wait-${i + 1}`, - output_directory: `/outputs/runs/wait-${i + 1}`, - team_id: i % 2 === 0 ? 'team-vision' : 'team-nlp', - creator_name: i % 3 === 0 ? 'Ada Lovelace' : i % 3 === 1 ? 'Alan Turing' : 'Grace Hopper', - can_cancel: true, - })), - ] - - return base -} - -export function createUnhealthyScenario() { - const now = Date.now() - const base = createDefaultScenario() - base.name = 'unhealthy' - base.description = SCENARIO_DESCRIPTIONS.unhealthy - - base.machines = [ - { - name: 'mist-gpu-node-01', - address: '10.0.1.10', - ready: false, - schedulable: false, - cpu_allocatable: '0', - memory_allocatable: '0Gi', - }, - { - name: 'mist-tt-worker-01', - address: '10.0.1.11', - ready: true, - schedulable: false, // Placement paused! - cpu_allocatable: '16', - memory_allocatable: '64Gi', - }, - ] - - base.pools = [ - { - accelerator: 'nvidia', - node: 'mist-gpu-node-01', - unit: 'GPU', - total: 4, - allocated: 0, - available: null, - ready: false, - message: 'Node offline; NVIDIA driver communication lost (NVML error 999)', - }, - { - accelerator: 'tenstorrent', - node: 'mist-tt-worker-01', - unit: 'board', - total: 4, - allocated: 0, - available: 0, - ready: false, - chips_per_device: 2, - message: 'Node cordon active: scheduling paused for driver kernel patch', - }, - ] - - base.jobs = [ - { - id: 'job-fail-001', - name: 'Vision Model OOM Failure', - created: new Date(now - 7200000).toISOString(), - job_state: 'Failure', - accelerator: 'nvidia', - device_count: 2, - image: 'ghcr.io/utmist/pytorch-cuda:latest', - cpu: '8', - memory: '16Gi', - node: 'mist-gpu-node-01', - exit_code: 137, - message: 'Command terminated by SIGKILL (Out of Memory - cgroup limit exceeded)', - checkpoint_directory: '/outputs/checkpoints/job-fail-001', - output_directory: '/outputs/runs/job-fail-001', - team_id: 'team-vision', - creator_name: 'Ada Lovelace', - can_cancel: false, - }, - { - id: 'job-fail-002', - name: 'Faulty Python Script Crash', - created: new Date(now - 3600000).toISOString(), - job_state: 'Failure', - accelerator: 'cpu', - device_count: 0, - image: 'docker.io/library/python:3.11-slim', - cpu: '2', - memory: '4Gi', - node: 'mist-tt-worker-01', - exit_code: 1, - message: 'RuntimeError: ModuleNotFoundError: No module named torch_geometric', - checkpoint_directory: '/outputs/checkpoints/job-fail-002', - output_directory: '/outputs/runs/job-fail-002', - team_id: 'team-nlp', - creator_name: 'Grace Hopper', - can_cancel: false, - }, - { - id: 'job-stuck-003', - name: 'Stuck Queued Job', - created: new Date(now - 1800000).toISOString(), - job_state: 'Scheduled', - accelerator: 'nvidia', - device_count: 1, - image: 'ghcr.io/utmist/pytorch-cuda:latest', - cpu: '4', - memory: '16Gi', - message: '0/2 nodes are available: 1 node is offline, 1 node has untolerated taint', - checkpoint_directory: '/outputs/checkpoints/job-stuck-003', - output_directory: '/outputs/runs/job-stuck-003', - team_id: 'team-vision', - creator_name: 'Alan Turing', - can_cancel: true, - }, - ] - - return base -} - -export function createEmptySlateScenario() { - return { - name: 'empty-slate', - description: SCENARIO_DESCRIPTIONS['empty-slate'], - currentUserIndex: 0, - storageBudgetGiB: 1000, - users: [ - { - id: 'usr_newcomer', - name: 'New Researcher', - email: 'researcher@utmist.gitlab.io', - role: 'admin', - banned: false, - }, - ], - machines: [ - { - name: 'lab-cluster-master', - address: '192.168.1.10', - ready: true, - schedulable: true, - cpu_allocatable: '16', - memory_allocatable: '64Gi', - }, - ], - pools: [ - { - accelerator: 'nvidia', - node: 'lab-cluster-master', - unit: 'GPU', - total: 2, - allocated: 0, - available: 2, - ready: true, - }, - ], - storage: { - enabled: true, - capacity_bytes: 536870912000, // 500 GiB - available_bytes: 536870912000, - model_capacity_bytes: 1073741824000, - model_available_bytes: 1073741824000, - dataset_allocation_gib: 0, - model_allocation_gib: 0, - upload_limit_bytes: 5368709120, - }, - teams: [], - jobs: [], - datasets: [], - } -} - -export function createLargeClusterScenario() { - const now = Date.now() - const base = createDefaultScenario() - base.name = 'large-cluster' - base.description = SCENARIO_DESCRIPTIONS['large-cluster'] - base.storageBudgetGiB = 10000 - - base.users = [ - { id: 'usr_ada', name: 'Ada Lovelace', email: 'ada@utmist.gitlab.io', role: 'admin', banned: false }, - { id: 'usr_alan', name: 'Alan Turing', email: 'alan@utmist.gitlab.io', role: 'user', banned: false }, - { id: 'usr_grace', name: 'Grace Hopper', email: 'grace@utmist.gitlab.io', role: 'user', banned: false }, - { id: 'usr_curie', name: 'Marie Curie', email: 'marie@utmist.gitlab.io', role: 'admin', banned: false }, - { id: 'usr_feynman', name: 'Richard Feynman', email: 'feynman@utmist.gitlab.io', role: 'user', banned: false }, - { id: 'usr_shannon', name: 'Claude Shannon', email: 'shannon@utmist.gitlab.io', role: 'user', banned: false }, - { id: 'usr_neumann', name: 'John von Neumann', email: 'neumann@utmist.gitlab.io', role: 'user', banned: true }, - ] - - base.machines = [ - { name: 'dgx-h100-node-01', address: '10.10.1.1', ready: true, schedulable: true, cpu_allocatable: '64', memory_allocatable: '512Gi' }, - { name: 'dgx-h100-node-02', address: '10.10.1.2', ready: true, schedulable: true, cpu_allocatable: '64', memory_allocatable: '512Gi' }, - { name: 'tt-wormhole-box-01', address: '10.10.1.3', ready: true, schedulable: true, cpu_allocatable: '32', memory_allocatable: '128Gi' }, - { name: 'tt-wormhole-box-02', address: '10.10.1.4', ready: true, schedulable: true, cpu_allocatable: '32', memory_allocatable: '128Gi' }, - { name: 'control-plane-k3s', address: '10.10.1.100', ready: true, schedulable: true, cpu_allocatable: '16', memory_allocatable: '64Gi' }, - ] - - base.pools = [ - { accelerator: 'nvidia', node: 'dgx-h100-node-01', unit: 'GPU', total: 8, allocated: 4, available: 4, ready: true }, - { accelerator: 'nvidia', node: 'dgx-h100-node-02', unit: 'GPU', total: 8, allocated: 6, available: 2, ready: true }, - { accelerator: 'tenstorrent', node: 'tt-wormhole-box-01', unit: 'board', total: 4, allocated: 2, available: 2, ready: true, chips_per_device: 2 }, - { accelerator: 'tenstorrent', node: 'tt-wormhole-box-02', unit: 'board', total: 4, allocated: 1, available: 3, ready: true, chips_per_device: 2 }, - ] - - base.teams = [ - { - id: 'team-cv', - name: 'Computer Vision Lab', - disabled: false, - policy: { cpu: '32', memory: '128Gi', nvidia: 8, tenstorrent: 4, concurrent: 8, queued: 40, storage_gib: 1000, model_storage_gib: 2000, runtime_seconds: 86400, registries: ['docker.io', 'ghcr.io'] }, - members: [{ id: 'usr_ada', name: 'Ada Lovelace', email: 'ada@utmist.gitlab.io', common_writer: true }, { id: 'usr_curie', name: 'Marie Curie', email: 'marie@utmist.gitlab.io', common_writer: true }], - grants: [], - }, - { - id: 'team-llm', - name: 'Foundation Models & NLP', - disabled: false, - policy: { cpu: '64', memory: '256Gi', nvidia: 8, tenstorrent: 2, concurrent: 6, queued: 30, storage_gib: 2000, model_storage_gib: 4000, runtime_seconds: 86400, registries: ['docker.io', 'ghcr.io'] }, - members: [{ id: 'usr_grace', name: 'Grace Hopper', email: 'grace@utmist.gitlab.io', common_writer: true }, { id: 'usr_shannon', name: 'Claude Shannon', email: 'shannon@utmist.gitlab.io', common_writer: false }], - grants: [], - }, - { - id: 'team-robotics', - name: 'Autonomous Robotics', - disabled: false, - policy: { cpu: '16', memory: '64Gi', nvidia: 4, tenstorrent: 4, concurrent: 4, queued: 20, storage_gib: 800, model_storage_gib: 1200, runtime_seconds: 43200, registries: ['docker.io'] }, - members: [{ id: 'usr_alan', name: 'Alan Turing', email: 'alan@utmist.gitlab.io', common_writer: true }, { id: 'usr_feynman', name: 'Richard Feynman', email: 'feynman@utmist.gitlab.io', common_writer: false }], - grants: [], - }, - ] - - // Generate 25 jobs across all states - const jobStates = ['Success', 'InProgress', 'Scheduled', 'Failure', 'Cancelled'] - base.jobs = Array.from({ length: 25 }, (_, i) => ({ - id: `job-enterprise-${String(i + 1).padStart(3, '0')}`, - name: `Model Exploration #${i + 1}`, - created: new Date(now - (25 - i) * 1200000).toISOString(), - job_state: jobStates[i % jobStates.length], - accelerator: i % 3 === 0 ? 'nvidia' : i % 3 === 1 ? 'tenstorrent' : 'cpu', - device_count: i % 3 === 2 ? 0 : (i % 4) + 1, - image: i % 3 === 0 ? 'ghcr.io/utmist/pytorch-cuda:latest' : i % 3 === 1 ? 'ghcr.io/utmist/tt-metal-runtime:latest' : 'docker.io/library/python:3.11-slim', - cpu: String((i % 4 + 1) * 4), - memory: `${(i % 4 + 1) * 16}Gi`, - node: i % 2 === 0 ? 'dgx-h100-node-01' : 'tt-wormhole-box-01', - exit_code: jobStates[i % jobStates.length] === 'Failure' ? 1 : jobStates[i % jobStates.length] === 'Success' ? 0 : undefined, - message: jobStates[i % jobStates.length] === 'Failure' ? 'Killed by cluster controller' : undefined, - checkpoint_directory: `/outputs/checkpoints/job-enterprise-${i + 1}`, - output_directory: `/outputs/runs/job-enterprise-${i + 1}`, - team_id: i % 2 === 0 ? 'team-cv' : 'team-llm', - creator_name: base.users[i % base.users.length].name, - can_cancel: jobStates[i % jobStates.length] === 'InProgress' || jobStates[i % jobStates.length] === 'Scheduled', - })) - - return base -} - -export const SCENARIO_FACTORIES = { - default: createDefaultScenario, - 'high-load': createHighLoadScenario, - unhealthy: createUnhealthyScenario, - 'empty-slate': createEmptySlateScenario, - 'large-cluster': createLargeClusterScenario, -} - -export function getScenario(name) { - const factory = SCENARIO_FACTORIES[name] - if (!factory) { - throw new Error(`Unknown scenario '${name}'. Available: ${Object.keys(SCENARIO_FACTORIES).join(', ')}`) - } - return factory() -} diff --git a/scripts/fake-backend.mjs b/scripts/fake-backend.mjs deleted file mode 100644 index 0abce2a..0000000 --- a/scripts/fake-backend.mjs +++ /dev/null @@ -1,898 +0,0 @@ -#!/usr/bin/env node -/** - * Mist Fake Backend Server - * - * A standalone, zero-dependency Node.js HTTP server simulating the Mist Go API - * and Better Auth service. Designed for local frontend development, testing, - * and rapid prototyping without requiring k3s, GPUs, or Better Auth SQLite. - * - * Usage: - * node scripts/fake-backend.mjs - * node scripts/fake-backend.mjs --scenario high-load - * node scripts/fake-backend.mjs --config ./custom-config.json - * PORT=3000 node scripts/fake-backend.mjs - * npm --prefix web-interface run fake-backend - */ - -import http from 'node:http' -import fs from 'node:fs' -import path from 'node:path' -import { URL, fileURLToPath } from 'node:url' -import crypto from 'node:crypto' -import { - getScenario, - SCENARIO_DESCRIPTIONS, - SCENARIO_FACTORIES, -} from './fake-backend-scenarios.mjs' - -// Parse command line arguments -const args = process.argv.slice(2) -function getArg(flag) { - const idx = args.indexOf(flag) - return idx !== -1 && args[idx + 1] ? args[idx + 1] : null -} - -const PORT = Number( - getArg('--port') || process.env.PORT || process.env.MIST_FAKE_PORT || 3000, -) -const HOST = process.env.HOST || '127.0.0.1' -const ROOT_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') -const DEFAULT_CONFIG_FILE = path.resolve(ROOT_DIR, '.fake-backend-config.json') - -// --- Initial State Management --- - -let state = { - activeScenario: 'default', - users: [], - currentUserIndex: 0, - machines: [], - pools: [], - storageBudgetGiB: 2000, - storage: {}, - teams: [], - jobs: [], - datasets: [], - images: { - self_service: true, - teams_enabled: true, - registries: ['docker.io', 'ghcr.io', 'quay.io', 'localhost:5000'], - images: [ - { reference: 'docker.io/library/python:3.11-slim', accelerators: ['cpu'] }, - { reference: 'docker.io/library/pytorch-cpu:2.2.0', accelerators: ['cpu'] }, - { reference: 'ghcr.io/utmist/pytorch-cuda:latest', accelerators: ['nvidia'] }, - { reference: 'ghcr.io/utmist/tt-metal-runtime:latest', accelerators: ['tenstorrent'] }, - ], - profiles: [ - { - accelerator: 'cpu', - default_image: 'docker.io/library/python:3.11-slim', - device_unit: 'core', - max_devices: 16, - note: 'Standard CPU runtime', - }, - { - accelerator: 'nvidia', - default_image: 'ghcr.io/utmist/pytorch-cuda:latest', - device_unit: 'GPU', - max_devices: 4, - note: 'NVIDIA CUDA 12.4 + PyTorch runtime', - }, - { - accelerator: 'tenstorrent', - default_image: 'ghcr.io/utmist/tt-metal-runtime:latest', - device_unit: 'board', - max_devices: 4, - note: 'Tenstorrent Wormhole n300 PCIe runtime', - }, - ], - }, - jobLogs: new Map(), - jobFiles: new Map(), -} - -export function applyConfiguration(config) { - if (config.users) state.users = JSON.parse(JSON.stringify(config.users)) - if (config.currentUserIndex !== undefined) state.currentUserIndex = config.currentUserIndex - if (config.activeUserId) { - const idx = state.users.findIndex( - (u) => u.id === config.activeUserId || u.email.toLowerCase() === config.activeUserId.toLowerCase(), - ) - if (idx >= 0) state.currentUserIndex = idx - } - if (config.machines) state.machines = JSON.parse(JSON.stringify(config.machines)) - if (config.pools) state.pools = JSON.parse(JSON.stringify(config.pools)) - if (config.storage) state.storage = JSON.parse(JSON.stringify(config.storage)) - if (config.storageBudgetGiB !== undefined) state.storageBudgetGiB = config.storageBudgetGiB - if (config.teams) state.teams = JSON.parse(JSON.stringify(config.teams)) - if (config.jobs) { - state.jobs = JSON.parse(JSON.stringify(config.jobs)) - for (const job of state.jobs) { - if (!state.jobLogs.has(job.id)) { - state.jobLogs.set( - job.id, - `[${job.created}] Job ${job.name} initialized in state ${job.job_state}\n`, - ) - } - } - } - if (config.datasets) state.datasets = JSON.parse(JSON.stringify(config.datasets)) - if (config.images) state.images = JSON.parse(JSON.stringify(config.images)) - if (config.name) state.activeScenario = config.name -} - -// Determine initial scenario / config -function initializeState() { - const cliScenario = getArg('--scenario') || process.env.MIST_FAKE_SCENARIO - const cliConfig = getArg('--config') || process.env.MIST_FAKE_CONFIG - - if (cliScenario) { - try { - console.log(`[Config] Loading requested scenario: '${cliScenario}'`) - applyConfiguration(getScenario(cliScenario)) - return - } catch (err) { - console.warn(`[Config] Warning: ${err.message}. Falling back to default.`) - } - } - - if (cliConfig && fs.existsSync(cliConfig)) { - try { - console.log(`[Config] Loading configuration from: ${cliConfig}`) - const raw = fs.readFileSync(cliConfig, 'utf-8') - applyConfiguration(JSON.parse(raw)) - return - } catch (err) { - console.warn(`[Config] Failed to load config from ${cliConfig}: ${err.message}`) - } - } - - if (fs.existsSync(DEFAULT_CONFIG_FILE)) { - try { - console.log(`[Config] Found local ${DEFAULT_CONFIG_FILE}, loading...`) - const raw = fs.readFileSync(DEFAULT_CONFIG_FILE, 'utf-8') - applyConfiguration(JSON.parse(raw)) - return - } catch (err) { - console.warn(`[Config] Failed to parse ${DEFAULT_CONFIG_FILE}: ${err.message}`) - } - } - - // Fallback to default scenario - applyConfiguration(getScenario('default')) -} - -initializeState() - -// Automatically progress queued jobs over time -setInterval(() => { - for (const job of state.jobs) { - if (job.job_state === 'Scheduled') { - // Find suitable node if available - const pool = state.pools.find( - (p) => p.accelerator === job.accelerator && p.ready && (p.available === null || p.available > 0), - ) - if (pool) { - job.job_state = 'InProgress' - job.node = pool.node - job.message = `Pod running on ${job.node}` - const logs = state.jobLogs.get(job.id) || `[${new Date().toISOString()}] Job submitted.\n` - state.jobLogs.set( - job.id, - logs + `[${new Date().toISOString()}] Admitted to cluster. Initializing pod on ${job.node}...\n`, - ) - } - } else if (job.job_state === 'InProgress') { - const elapsed = Date.now() - new Date(job.created).getTime() - if (elapsed > 20000 && !job.id.startsWith('job-hl-run')) { - job.job_state = 'Success' - job.exit_code = 0 - job.message = 'Job completed successfully' - job.can_cancel = false - const logs = state.jobLogs.get(job.id) || '' - state.jobLogs.set( - job.id, - logs + `[${new Date().toISOString()}] Execution finished with code 0.\nOutputs saved.\n`, - ) - if (!state.jobFiles.has(job.id)) { - state.jobFiles.set(job.id, [ - { path: 'output.log', size: 2048, modified: new Date().toISOString() }, - { path: 'model_weights.bin', size: 52428800, modified: new Date().toISOString() }, - ]) - } - } - } - } -}, 5000) - -// --- Helper Functions --- - -function parseJSONBody(req) { - return new Promise((resolve, reject) => { - let data = '' - req.on('data', (chunk) => { - data += chunk - if (data.length > 50 * 1024 * 1024) { - req.destroy() - reject(new Error('Request payload too large')) - } - }) - req.on('end', () => { - if (!data.trim()) return resolve({}) - try { - resolve(JSON.parse(data)) - } catch (err) { - reject(new Error('Malformed JSON: ' + err.message)) - } - }) - req.on('error', reject) - }) -} - -function sendJSON(res, statusCode, payload) { - const body = JSON.stringify(payload) - res.writeHead(statusCode, { - 'Content-Type': 'application/json', - 'Content-Length': Buffer.byteLength(body), - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET, POST, PATCH, DELETE, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type, X-Mist-Team, Authorization', - }) - res.end(body) -} - -function sendError(res, statusCode, message) { - sendJSON(res, statusCode, { error: message }) -} - -// --- Request Router --- - -const server = http.createServer(async (req, res) => { - // CORS Preflight - if (req.method === 'OPTIONS') { - res.writeHead(204, { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET, POST, PATCH, DELETE, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type, X-Mist-Team, Authorization', - 'Access-Control-Max-Age': '86400', - }) - res.end() - return - } - - const parsedUrl = new URL(req.url, `http://${req.headers.host || 'localhost'}`) - let pathname = parsedUrl.pathname - const query = Object.fromEntries(parsedUrl.searchParams.entries()) - const activeTeam = req.headers['x-mist-team'] || query.team_id || '' - - // Normalize path by stripping optional '/api' prefix - if (pathname.startsWith('/api/')) { - pathname = pathname.slice(4) - } else if (pathname === '/api') { - pathname = '/' - } - - const currentUser = state.users[state.currentUserIndex] || null - - try { - // ---------------------------------------------------- - // FAKE BACKEND DYNAMIC CONFIGURATION & SCENARIO CONTROL - // ---------------------------------------------------- - - if (pathname === '/_fake/state' && req.method === 'GET') { - sendJSON(res, 200, { - activeScenario: state.activeScenario, - currentUserIndex: state.currentUserIndex, - currentUser, - users: state.users, - machines: state.machines, - pools: state.pools, - storage: state.storage, - storageBudgetGiB: state.storageBudgetGiB, - teams: state.teams, - jobs: state.jobs, - datasets: state.datasets, - }) - return - } - - if ((pathname === '/_fake/state' || pathname === '/_fake/configure') && req.method === 'POST') { - const body = await parseJSONBody(req) - applyConfiguration(body) - sendJSON(res, 200, { - success: true, - message: 'Configuration applied successfully', - activeScenario: state.activeScenario, - }) - return - } - - if (pathname === '/_fake/scenarios' && req.method === 'GET') { - sendJSON(res, 200, { - scenarios: Object.entries(SCENARIO_DESCRIPTIONS).map(([name, description]) => ({ - name, - description, - active: state.activeScenario === name, - })), - }) - return - } - - const scenarioMatch = pathname.match(/^\/_fake\/scenario\/([^/]+)$/) - if (scenarioMatch && req.method === 'POST') { - const name = scenarioMatch[1] - try { - const scenarioData = getScenario(name) - applyConfiguration(scenarioData) - sendJSON(res, 200, { - success: true, - scenario: name, - message: `Switched to hypothetical scenario: '${name}'`, - }) - } catch (err) { - sendError(res, 400, err.message) - } - return - } - - if (pathname === '/_fake/reset' && req.method === 'POST') { - const defaultData = getScenario('default') - applyConfiguration(defaultData) - sendJSON(res, 200, { success: true, message: 'Reset to default scenario' }) - return - } - - // ---------------------------------------------------- - // AUTH & SESSION ENDPOINTS - // ---------------------------------------------------- - - if (pathname === '/session' && req.method === 'GET') { - sendJSON(res, 200, { - enabled: true, - user: currentUser, - }) - return - } - - if (pathname === '/auth/get-session' && req.method === 'GET') { - if (!currentUser) { - sendJSON(res, 200, null) - } else { - sendJSON(res, 200, { - user: currentUser, - session: { - id: 'sess_' + currentUser.id, - userId: currentUser.id, - expiresAt: new Date(Date.now() + 86400000).toISOString(), - }, - }) - } - return - } - - if (pathname === '/auth/sign-in/email' && req.method === 'POST') { - const body = await parseJSONBody(req) - const user = state.users.find( - (u) => u.email.toLowerCase() === (body.email || '').toLowerCase(), - ) - if (!user) { - sendJSON(res, 401, { - error: { message: 'Invalid credentials or user not found' }, - }) - return - } - if (user.banned) { - sendJSON(res, 403, { - error: { message: 'Account is deactivated' }, - }) - return - } - state.currentUserIndex = state.users.findIndex((u) => u.id === user.id) - sendJSON(res, 200, { - user, - session: { - id: 'sess_' + user.id, - userId: user.id, - expiresAt: new Date(Date.now() + 86400000).toISOString(), - }, - }) - return - } - - if (pathname === '/auth/sign-out' && req.method === 'POST') { - state.currentUserIndex = -1 // signed out - sendJSON(res, 200, { success: true }) - return - } - - if (pathname === '/auth/change-password' && req.method === 'POST') { - await parseJSONBody(req) - sendJSON(res, 200, { success: true }) - return - } - - if ( - (pathname === '/auth/admin/list-users' || pathname === '/auth/admin/listUsers') && - (req.method === 'GET' || req.method === 'POST') - ) { - sendJSON(res, 200, { - users: state.users, - total: state.users.length, - }) - return - } - - if (pathname === '/auth/admin/create-user' && req.method === 'POST') { - const body = await parseJSONBody(req) - if (!body.name || !body.email) { - sendError(res, 400, 'Name and email are required') - return - } - const newUser = { - id: 'usr_' + crypto.randomBytes(4).toString('hex'), - name: body.name, - email: body.email, - role: body.role || 'user', - banned: false, - } - state.users.push(newUser) - sendJSON(res, 200, { user: newUser }) - return - } - - if (pathname === '/auth/admin/set-user-password' && req.method === 'POST') { - await parseJSONBody(req) - sendJSON(res, 200, { success: true }) - return - } - - if (pathname === '/auth/admin/revoke-user-sessions' && req.method === 'POST') { - await parseJSONBody(req) - sendJSON(res, 200, { success: true }) - return - } - - if (pathname === '/auth/admin/ban-user' && req.method === 'POST') { - const body = await parseJSONBody(req) - const target = state.users.find((u) => u.id === body.userId) - if (target) target.banned = true - sendJSON(res, 200, { success: true }) - return - } - - if (pathname === '/auth/admin/unban-user' && req.method === 'POST') { - const body = await parseJSONBody(req) - const target = state.users.find((u) => u.id === body.userId) - if (target) target.banned = false - sendJSON(res, 200, { success: true }) - return - } - - // ---------------------------------------------------- - // HARDWARE & IMAGES ENDPOINTS - // ---------------------------------------------------- - - if (pathname === '/hardware' && req.method === 'GET') { - sendJSON(res, 200, { - observed_at: new Date().toISOString(), - machines: state.machines, - pools: state.pools, - }) - return - } - - if (pathname === '/images' && req.method === 'GET') { - sendJSON(res, 200, state.images) - return - } - - // ---------------------------------------------------- - // TEAMS ENDPOINTS - // ---------------------------------------------------- - - if (pathname === '/teams' && req.method === 'GET') { - sendJSON(res, 200, { - teams: state.teams, - storage_budget_gib: state.storageBudgetGiB, - storage_limits_enforced: false, - }) - return - } - - if (pathname === '/teams' && req.method === 'POST') { - const body = await parseJSONBody(req) - const newTeam = { - id: 'team-' + (body.name || 'unnamed').toLowerCase().replace(/\W+/g, '-'), - name: body.name || 'New Team', - disabled: false, - policy: { - cpu: '8', - memory: '32Gi', - nvidia: 1, - tenstorrent: 2, - concurrent: 2, - queued: 10, - storage_gib: Number(body.storage_gib || 200), - model_storage_gib: Number(body.model_storage_gib || 500), - runtime_seconds: 86400, - registries: ['docker.io', 'ghcr.io'], - }, - members: currentUser - ? [ - { - id: currentUser.id, - name: currentUser.name, - email: currentUser.email, - common_writer: true, - }, - ] - : [], - grants: [], - } - state.teams.push(newTeam) - sendJSON(res, 200, newTeam) - return - } - - const teamMatch = pathname.match(/^\/teams\/([^/]+)$/) - if (teamMatch && req.method === 'PATCH') { - const teamId = teamMatch[1] - const team = state.teams.find((t) => t.id === teamId) - if (!team) return sendError(res, 404, 'Team not found') - const body = await parseJSONBody(req) - if (body.name !== undefined) team.name = body.name - if (body.disabled !== undefined) team.disabled = body.disabled - if (body.policy !== undefined) team.policy = { ...team.policy, ...body.policy } - sendJSON(res, 200, team) - return - } - - const teamMembersMatch = pathname.match(/^\/teams\/([^/]+)\/members$/) - if (teamMembersMatch && req.method === 'POST') { - const teamId = teamMembersMatch[1] - const team = state.teams.find((t) => t.id === teamId) - if (!team) return sendError(res, 404, 'Team not found') - const body = await parseJSONBody(req) - const user = state.users.find((u) => u.id === body.user_id) - if (!user) return sendError(res, 404, 'User not found') - const existing = team.members.find((m) => m.id === user.id) - if (existing) { - existing.common_writer = !!body.common_writer - } else { - team.members.push({ - id: user.id, - name: user.name, - email: user.email, - common_writer: !!body.common_writer, - }) - } - sendJSON(res, 200, team) - return - } - - const teamMemberDeleteMatch = pathname.match(/^\/teams\/([^/]+)\/members\/([^/]+)$/) - if (teamMemberDeleteMatch && req.method === 'DELETE') { - const teamId = teamMemberDeleteMatch[1] - const userId = decodeURIComponent(teamMemberDeleteMatch[2]) - const team = state.teams.find((t) => t.id === teamId) - if (!team) return sendError(res, 404, 'Team not found') - team.members = team.members.filter((m) => m.id !== userId) - sendJSON(res, 200, team) - return - } - - const teamGrantsMatch = pathname.match(/^\/teams\/([^/]+)\/grants$/) - if (teamGrantsMatch && req.method === 'POST') { - const teamId = teamGrantsMatch[1] - const team = state.teams.find((t) => t.id === teamId) - if (!team) return sendError(res, 404, 'Team not found') - const body = await parseJSONBody(req) - const newGrant = { - id: 'grant-' + crypto.randomBytes(3).toString('hex'), - target_team: body.target_team, - scope: body.scope || 'common', - } - team.grants.push(newGrant) - sendJSON(res, 200, team) - return - } - - const teamGrantDeleteMatch = pathname.match(/^\/teams\/([^/]+)\/grants\/([^/]+)$/) - if (teamGrantDeleteMatch && req.method === 'DELETE') { - const teamId = teamGrantDeleteMatch[1] - const grantId = teamGrantDeleteMatch[2] - const team = state.teams.find((t) => t.id === teamId) - if (!team) return sendError(res, 404, 'Team not found') - team.grants = team.grants.filter((g) => g.id !== grantId) - sendJSON(res, 200, team) - return - } - - // ---------------------------------------------------- - // JOBS ENDPOINTS - // ---------------------------------------------------- - - if (pathname === '/jobs' && req.method === 'GET') { - let filtered = state.jobs - if (activeTeam) { - filtered = filtered.filter((j) => j.team_id === activeTeam) - } - sendJSON(res, 200, { - jobs: filtered, - count: filtered.length, - }) - return - } - - if (pathname === '/jobs' && req.method === 'POST') { - const body = await parseJSONBody(req) - const jobId = 'job-' + crypto.randomBytes(4).toString('hex') - const newJob = { - id: jobId, - name: body.name || `Experiment ${jobId}`, - created: new Date().toISOString(), - job_state: 'Scheduled', - accelerator: body.accelerator || 'cpu', - device_count: body.device_count || (body.accelerator === 'cpu' ? 0 : 1), - image: body.image || 'docker.io/library/python:3.11-slim', - cpu: body.cpu || '2', - memory: body.memory || '4Gi', - message: 'Job submitted; awaiting placement', - checkpoint_directory: `/outputs/checkpoints/${jobId}`, - output_directory: `/outputs/runs/${jobId}`, - working_directory: body.working_directory || '/workspace', - timeout_seconds: body.timeout_seconds || 3600, - team_id: activeTeam || 'legacy', - creator_id: currentUser?.id || 'usr_anonymous', - creator_name: currentUser?.name || 'Local Developer', - storage_scope: body.storage_scope || currentUser?.id || 'common', - can_cancel: true, - } - state.jobs.unshift(newJob) - state.jobLogs.set( - jobId, - `[${newJob.created}] Job created: ${newJob.name}\n[${newJob.created}] Image: ${newJob.image}\n[${newJob.created}] Queueing job on Kubernetes admission controller...\n`, - ) - sendJSON(res, 200, { - job_id: jobId, - job: newJob, - }) - return - } - - const jobCancelMatch = pathname.match(/^\/jobs\/([^/]+)\/cancel$/) - if (jobCancelMatch && req.method === 'POST') { - const jobId = decodeURIComponent(jobCancelMatch[1]) - const job = state.jobs.find((j) => j.id === jobId) - if (!job) return sendError(res, 404, 'Job not found') - job.job_state = 'Cancelled' - job.message = 'Cancelled by user' - job.can_cancel = false - const logs = state.jobLogs.get(jobId) || '' - state.jobLogs.set( - jobId, - logs + `[${new Date().toISOString()}] Job cancelled by user.\n`, - ) - sendJSON(res, 200, job) - return - } - - const jobLogsMatch = pathname.match(/^\/jobs\/([^/]+)\/logs$/) - if (jobLogsMatch && req.method === 'GET') { - const jobId = decodeURIComponent(jobLogsMatch[1]) - const logs = state.jobLogs.get(jobId) || 'No logs recorded for this job yet.' - sendJSON(res, 200, { logs }) - return - } - - const jobFilesMatch = pathname.match(/^\/jobs\/([^/]+)\/files$/) - if (jobFilesMatch && req.method === 'GET') { - const jobId = decodeURIComponent(jobFilesMatch[1]) - const files = state.jobFiles.get(jobId) || [] - sendJSON(res, 200, { files }) - return - } - - const jobDownloadMatch = pathname.match(/^\/jobs\/([^/]+)\/files\/download$/) - if (jobDownloadMatch && req.method === 'GET') { - const filePath = query.path || 'output.txt' - res.writeHead(200, { - 'Content-Type': 'text/plain; charset=utf-8', - 'Content-Disposition': `attachment; filename="${filePath.split('/').pop()}"`, - 'Access-Control-Allow-Origin': '*', - }) - res.end(`[Mock file content for ${filePath} from job]`) - return - } - - // ---------------------------------------------------- - // STORAGE & DATASETS ENDPOINTS - // ---------------------------------------------------- - - if (pathname === '/storage' && req.method === 'GET') { - const team = state.teams.find((t) => t.id === activeTeam) - const datasets = state.datasets.filter((d) => !activeTeam || d.team_id === activeTeam) - const jobs = state.jobs.filter((j) => !activeTeam || j.team_id === activeTeam) - sendJSON(res, 200, { - ...state.storage, - storage_mode: 'shared-folders', - storage_limits_enforced: false, - dataset_used_bytes: datasets.reduce((total, dataset) => total + dataset.size, 0), - model_used_bytes: jobs.reduce((total, job) => - total + (state.jobFiles.get(job.id) ?? []).reduce((sum, file) => sum + file.size, 0), 0), - dataset_allocation_gib: team?.policy.storage_gib ?? state.storage.dataset_allocation_gib, - model_allocation_gib: team?.policy.model_storage_gib ?? state.storage.model_allocation_gib, - usage_observed_at: new Date().toISOString(), - usage_stale: false, - storage_low: state.storage.available_bytes < state.storage.capacity_bytes / 10, - }) - return - } - - if (pathname === '/storage/folders' && req.method === 'GET') { - const folders = [] - for (const t of state.teams) { - folders.push({ - team_id: t.id, - team_name: t.name, - scope: 'common', - name: 'Common Space', - shared: false, - writable: true, - }) - for (const m of t.members) { - folders.push({ - team_id: t.id, - team_name: t.name, - scope: m.id, - name: `${m.name}'s Folder`, - shared: false, - writable: m.id === currentUser?.id, - }) - } - } - sendJSON(res, 200, { folders }) - return - } - - if (pathname === '/storage/files' && req.method === 'GET') { - if (query.download === 'true') { - const filePath = query.path || 'downloaded-file.dat' - res.writeHead(200, { - 'Content-Type': 'application/octet-stream', - 'Content-Disposition': `attachment; filename="${filePath.split('/').pop()}"`, - 'Access-Control-Allow-Origin': '*', - }) - res.end(`[Mock content of ${filePath} in team ${query.source_team}]`) - return - } - sendJSON(res, 200, { - files: [ - { - path: 'data/train_manifest.json', - size: 15360, - modified: new Date(Date.now() - 7200000).toISOString(), - }, - { - path: 'models/model_final.pt', - size: 89128960, - modified: new Date(Date.now() - 3600000).toISOString(), - }, - ], - }) - return - } - - if (pathname === '/datasets' && req.method === 'GET') { - let filtered = state.datasets - if (activeTeam) { - filtered = filtered.filter((d) => !d.team_id || d.team_id === activeTeam) - } - sendJSON(res, 200, { - datasets: filtered, - upload_limit_bytes: state.storage.upload_limit_bytes || 5368709120, - }) - return - } - - if (pathname === '/datasets' && req.method === 'POST') { - const filename = query.filename || 'dataset.bin' - const name = query.name || filename - const scope = query.scope || currentUser?.id || 'common' - const activeTeamObj = state.teams.find((t) => t.id === activeTeam) - - let totalBytes = 0 - req.on('data', (chunk) => { - totalBytes += chunk.length - }) - - req.on('end', () => { - const newDs = { - id: 'ds-' + crypto.randomBytes(4).toString('hex'), - name, - filename, - size: totalBytes || 1048576, - files: query.format === 'zip' ? 12 : 1, - sha256: crypto.randomBytes(32).toString('hex'), - created: new Date().toISOString(), - state: 'ready', - team_id: activeTeam || 'legacy', - team_name: activeTeamObj?.name || 'Legacy Workspace', - scope, - member_name: currentUser?.name || 'Local Pilot', - shared: false, - } - state.datasets.unshift(newDs) - sendJSON(res, 200, newDs) - }) - return - } - - const datasetDeleteMatch = pathname.match(/^\/datasets\/([^/]+)$/) - if (datasetDeleteMatch && req.method === 'DELETE') { - const datasetId = datasetDeleteMatch[1] - state.datasets = state.datasets.filter((d) => d.id !== datasetId) - sendJSON(res, 200, { success: true }) - return - } - - const datasetFilesMatch = pathname.match(/^\/datasets\/([^/]+)\/files$/) - if (datasetFilesMatch && req.method === 'GET') { - sendJSON(res, 200, { - files: [ - { - path: 'metadata.json', - size: 2048, - modified: new Date().toISOString(), - }, - { - path: 'samples/sample_001.png', - size: 45056, - modified: new Date().toISOString(), - }, - ], - }) - return - } - - const datasetDownloadMatch = pathname.match(/^\/datasets\/([^/]+)\/files\/download$/) - if (datasetDownloadMatch && req.method === 'GET') { - const filePath = query.path || 'dataset-file.dat' - res.writeHead(200, { - 'Content-Type': 'application/octet-stream', - 'Content-Disposition': `attachment; filename="${filePath.split('/').pop()}"`, - 'Access-Control-Allow-Origin': '*', - }) - res.end(`[Mock content of dataset file ${filePath}]`) - return - } - - if (pathname === '/healthz' && req.method === 'GET') { - sendJSON(res, 200, { status: 'ok', executor: 'fake-backend' }) - return - } - - sendError(res, 404, `Endpoint not found: ${req.method} ${pathname}`) - } catch (err) { - console.error('[Fake Backend Error]', err) - sendError(res, 500, 'Internal Server Error: ' + err.message) - } -}) - -server.listen(PORT, HOST, () => { - console.log(`======================================================`) - console.log(`Mist Fake Backend running at http://${HOST}:${PORT}`) - console.log(`Active Scenario: '${state.activeScenario}'`) - console.log(`Simulated User: ${state.users[state.currentUserIndex]?.name} (${state.users[state.currentUserIndex]?.role})`) - console.log(`Machines: ${state.machines.map((m) => m.name).join(', ')}`) - console.log(`Teams: ${state.teams.map((t) => t.name).join(', ')}`) - console.log(`Jobs: ${state.jobs.length} in history`) - console.log(`Run 'node scripts/configure-fake-backend.mjs' to modify`) - console.log(`======================================================`) -}) - -process.on('SIGINT', () => { - server.close(() => { - console.log('\nFake backend stopped.') - process.exit(0) - }) -}) diff --git a/src/app.go b/src/app.go deleted file mode 100644 index d74cd05..0000000 --- a/src/app.go +++ /dev/null @@ -1,122 +0,0 @@ -package main - -import ( - "context" - "log/slog" - "net" - "net/http" - "os" - "sync" - "time" - - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -// App owns the HTTP gateway and the single team admission controller. -// Execution and persisted job state live in Kubernetes. -type App struct { - httpServer *http.Server - wg sync.WaitGroup - log *slog.Logger - executor *KubernetesExecutor - auth *AuthGateway - teams *TeamService -} - -func NewKubernetesApp(executor *KubernetesExecutor, log *slog.Logger) *App { - mux := http.NewServeMux() - a := &App{executor: executor, log: log, httpServer: &http.Server{ - Addr: envOr("MIST_HTTP_ADDR", "127.0.0.1:3000"), Handler: mux, - ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 30 * time.Second, WriteTimeout: 60 * time.Second, IdleTimeout: 60 * time.Second}} - mux.HandleFunc("/session", func(w http.ResponseWriter, r *http.Request) { - writeJSON(w, 200, map[string]interface{}{"enabled": false, "user": nil}) - }) - mux.HandleFunc("/datasets", a.datasets) - mux.HandleFunc("/datasets/", a.dataset) - mux.HandleFunc("GET /storage", a.storageInfo) - mux.HandleFunc("GET /storage/folders", a.storageFolders) - mux.HandleFunc("GET /storage/files", a.storageFolderFiles) - authDisabled := os.Getenv("MIST_AUTH_DISABLED") == "true" - authDB := os.Getenv("MIST_AUTH_DATABASE") - authRequired := os.Getenv("MIST_AUTH_ENABLED") == "true" || - os.Getenv("MIST_TEAMS_ENABLED") == "true" || - os.Getenv("BETTER_AUTH_SECRET") != "" || - authDB != "" || - os.Getenv("MIST_AUTH_URL") != "" - - if !authDisabled && authRequired { - if authDB == "" { - authDB = "./data/auth.sqlite" - } - var err error - a.auth, err = newAuthService(authDB, log) - if err != nil { - panic(err) - } - if os.Getenv("MIST_TEAMS_ENABLED") == "true" { - if executor.storage == nil { - panic("team workspaces require shared storage") - } - a.teams = newTeamService(a) - mux.HandleFunc("/teams", a.teamEndpoint) - mux.HandleFunc("/teams/", a.teamEndpoint) - a.httpServer.Handler = a.auth.middleware(a.teams.middleware(mux)) - } else { - a.httpServer.Handler = a.auth.middleware(mux) - } - } - mux.HandleFunc("/jobs", a.kubernetesJobs) - mux.HandleFunc("/jobs/status", a.getJobStatus) - mux.HandleFunc("/jobs/", a.kubernetesJob) - mux.HandleFunc("GET /hardware", a.hardware) - mux.HandleFunc("GET /images", a.images) - mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - writeJSON(w, 405, map[string]string{"error": "method not allowed"}) - return - } - _, err := executor.client.BatchV1().Jobs(executor.namespace).List(r.Context(), metav1.ListOptions{Limit: 1, LabelSelector: managedLabel + "=mist"}) - if err != nil { - executorError(w, err) - return - } - writeJSON(w, 200, map[string]string{"status": "ok", "executor": "kubernetes"}) - }) - return a -} - -func (a *App) Start() error { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - if _, err := a.executor.client.BatchV1().Jobs(a.executor.namespace).List(ctx, metav1.ListOptions{Limit: 1}); err != nil { - return err - } - listener, err := net.Listen("tcp", a.httpServer.Addr) - if err != nil { - return err - } - if a.teams != nil { - a.teams.start() - } - a.wg.Add(1) - go func() { - defer a.wg.Done() - a.log.Info("http server started", "address", listener.Addr().String()) - if err := a.httpServer.Serve(listener); err != nil && err != http.ErrServerClosed { - a.log.Error("HTTP server error", "error", err) - } - }() - return nil -} - -func (a *App) Shutdown(ctx context.Context) error { - err := a.httpServer.Shutdown(ctx) - a.wg.Wait() - if a.teams != nil { - a.teams.shutdown() - } - if a.auth != nil { - _ = a.auth.Close() - } - return err -} diff --git a/src/app_test.go b/src/app_test.go deleted file mode 100644 index 3590490..0000000 --- a/src/app_test.go +++ /dev/null @@ -1,66 +0,0 @@ -package main - -import ( - "context" - "errors" - "io" - "log/slog" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/client-go/kubernetes/fake" - clienttesting "k8s.io/client-go/testing" -) - -func TestAppStartupRejectsUnavailableKubernetes(t *testing.T) { - e := testExecutor() - unavailable := errors.New("cluster unavailable") - e.client.(*fake.Clientset).PrependReactor("list", "jobs", func(clienttesting.Action) (bool, runtime.Object, error) { - return true, nil, unavailable - }) - app := NewKubernetesApp(e, slog.New(slog.NewTextHandler(io.Discard, nil))) - if err := app.Start(); !errors.Is(err, unavailable) { - t.Fatalf("started gateway despite unavailable executor: %v", err) - } -} - -func TestKubernetesCompatibilityStatusUsesScopedExecutor(t *testing.T) { - e := testExecutor() - job, err := e.submit(context.Background(), CreateJobRequest{Command: []string{"true"}}) - if err != nil { - t.Fatal(err) - } - app := NewKubernetesApp(e, slog.New(slog.NewTextHandler(io.Discard, nil))) - for _, tc := range []struct { - method string - path string - want int - }{ - {http.MethodGet, "/jobs/status?id=" + job.ID, http.StatusOK}, - {http.MethodGet, "/jobs/status?id=unknown-job", http.StatusNotFound}, - {http.MethodGet, "/jobs/status", http.StatusBadRequest}, - {http.MethodPost, "/jobs/status?id=" + job.ID, http.StatusMethodNotAllowed}, - } { - t.Run(tc.method+tc.path, func(t *testing.T) { - response := httptest.NewRecorder() - app.httpServer.Handler.ServeHTTP(response, httptest.NewRequest(tc.method, tc.path, nil)) - if response.Code != tc.want { - t.Fatalf("status %d, wanted %d: %s", response.Code, tc.want, response.Body.String()) - } - if tc.want == http.StatusOK && !strings.Contains(response.Body.String(), job.ID) { - t.Fatal("compatibility endpoint did not return the managed Kubernetes job") - } - }) - } - other := *e - other.owner = "other-member" - otherApp := NewKubernetesApp(&other, app.log) - response := httptest.NewRecorder() - otherApp.httpServer.Handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/jobs/status?id="+job.ID, nil)) - if response.Code != http.StatusNotFound { - t.Fatalf("compatibility route leaked another owner's job: %d", response.Code) - } -} diff --git a/src/auth.go b/src/auth.go deleted file mode 100644 index f8d1fe1..0000000 --- a/src/auth.go +++ /dev/null @@ -1,879 +0,0 @@ -package main - -import ( - "context" - "crypto/rand" - "crypto/subtle" - "database/sql" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "io" - "log/slog" - "net/http" - "os" - "path/filepath" - "strconv" - "strings" - "sync" - "time" - - "golang.org/x/crypto/bcrypt" - "golang.org/x/crypto/scrypt" - "golang.org/x/text/unicode/norm" - _ "modernc.org/sqlite" -) - -type AuthService struct { - db *sql.DB - log *slog.Logger - origins map[string]bool - legacyEmail string - mu sync.Mutex -} - -func newAuthService(dbPath string, log *slog.Logger) (*AuthService, error) { - if log == nil { - log = slog.Default() - } - if dbPath == "" { - dbPath = envOr("MIST_AUTH_DATABASE", "./data/auth.sqlite") - } - if dbPath != ":memory:" && !strings.HasPrefix(dbPath, "file:") { - if err := os.MkdirAll(filepath.Dir(dbPath), 0700); err != nil { - return nil, fmt.Errorf("auth db dir: %w", err) - } - } - - db, err := sql.Open("sqlite", dbPath) - if err != nil { - return nil, fmt.Errorf("open auth db: %w", err) - } - - for _, pragma := range []string{ - "PRAGMA journal_mode = WAL;", - "PRAGMA busy_timeout = 5000;", - "PRAGMA foreign_keys = ON;", - } { - if _, err := db.Exec(pragma); err != nil { - log.Debug("auth pragma", "pragma", pragma, "error", err) - } - } - - schema := ` - CREATE TABLE IF NOT EXISTS user ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - email TEXT NOT NULL UNIQUE, - emailVerified INTEGER DEFAULT 0, - image TEXT, - createdAt TEXT NOT NULL, - updatedAt TEXT NOT NULL, - role TEXT DEFAULT 'user', - banned INTEGER NOT NULL DEFAULT 0 - ); - - CREATE TABLE IF NOT EXISTS session ( - id TEXT PRIMARY KEY, - token TEXT NOT NULL UNIQUE, - userId TEXT NOT NULL, - expiresAt TEXT NOT NULL, - createdAt TEXT NOT NULL, - updatedAt TEXT NOT NULL, - ipAddress TEXT, - userAgent TEXT, - impersonatedBy TEXT, - FOREIGN KEY(userId) REFERENCES user(id) ON DELETE CASCADE - ); - - CREATE TABLE IF NOT EXISTS account ( - id TEXT PRIMARY KEY, - userId TEXT NOT NULL, - accountId TEXT NOT NULL, - providerId TEXT NOT NULL, - password TEXT, - createdAt TEXT NOT NULL, - updatedAt TEXT NOT NULL, - FOREIGN KEY(userId) REFERENCES user(id) ON DELETE CASCADE - ); - - CREATE TABLE IF NOT EXISTS rate_limit ( - key TEXT PRIMARY KEY, - count INTEGER NOT NULL, - lastRequest INTEGER NOT NULL - ); - ` - if _, err := db.Exec(schema); err != nil { - db.Close() - return nil, fmt.Errorf("init auth schema: %w", err) - } - // Preserve deactivated Better Auth accounts, and upgrade Go-auth databases - // created before that compatibility column was retained. - var hasBanned int - if err := db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('user') WHERE name='banned'").Scan(&hasBanned); err != nil { - db.Close() - return nil, err - } - if hasBanned == 0 { - if _, err := db.Exec("ALTER TABLE user ADD COLUMN banned INTEGER NOT NULL DEFAULT 0"); err != nil { - db.Close() - return nil, err - } - } - - s := &AuthService{ - db: db, - log: log, - origins: map[string]bool{}, - legacyEmail: os.Getenv("MIST_LEGACY_OWNER_EMAIL"), - } - - defaultOrigins := []string{ - "http://127.0.0.1:3000", - "http://127.0.0.1:3001", - "http://localhost:3000", - "http://localhost:3001", - "http://127.0.0.1:8088", - "http://localhost:8088", - } - for _, o := range defaultOrigins { - s.origins[o] = true - } - if siteOrigin := os.Getenv("MIST_SITE_ORIGIN"); siteOrigin != "" { - s.origins[strings.TrimSpace(siteOrigin)] = true - } - for _, o := range strings.Split(os.Getenv("MIST_ALLOWED_ORIGINS"), ",") { - if o = strings.TrimSpace(o); o != "" { - s.origins[o] = true - } - } - - if err := s.bootstrapAdmin(); err != nil { - log.Warn("bootstrap admin failed", "error", err) - } - - return s, nil -} - -func (s *AuthService) Close() error { - if s.db != nil { - return s.db.Close() - } - return nil -} - -func (s *AuthService) bootstrapAdmin() error { - var count int - if err := s.db.QueryRow("SELECT COUNT(*) FROM user").Scan(&count); err != nil { - return err - } - if count > 0 { - return nil - } - email := os.Getenv("MIST_ADMIN_EMAIL") - password := os.Getenv("MIST_ADMIN_PASSWORD") - if email == "" || password == "" || len(password) < 12 { - return nil - } - hashed, err := hashPassword(password) - if err != nil { - return err - } - now := time.Now().UTC().Format(time.RFC3339Nano) - userID := "user-" + randomToken(12) - tx, err := s.db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - - _, err = tx.Exec(` - INSERT INTO user (id, name, email, role, emailVerified, createdAt, updatedAt) - VALUES (?, ?, ?, 'admin', 0, ?, ?) - `, userID, "Mist Administrator", email, now, now) - if err != nil { - return err - } - - accountID := "acc-" + randomToken(12) - _, err = tx.Exec(` - INSERT INTO account (id, userId, accountId, providerId, password, createdAt, updatedAt) - VALUES (?, ?, ?, 'credential', ?, ?, ?) - `, accountID, userID, email, hashed, now, now) - if err != nil { - return err - } - - if err := tx.Commit(); err != nil { - return err - } - s.log.Info("Initial Mist administrator created; credentials are not logged") - return nil -} - -func randomToken(byteCount int) string { - b := make([]byte, byteCount) - _, _ = rand.Read(b) - return hex.EncodeToString(b) -} - -func hashPassword(password string) (string, error) { - hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) - return string(hash), err -} - -func verifyPassword(hashed, password string) bool { - if strings.HasPrefix(hashed, "$2a$") || strings.HasPrefix(hashed, "$2b$") { - return bcrypt.CompareHashAndPassword([]byte(hashed), []byte(password)) == nil - } - parts := strings.Split(hashed, ":") - if len(parts) == 2 && len(parts[0]) == 32 && len(parts[1]) == 128 { - _, saltErr := hex.DecodeString(parts[0]) - expected, err := hex.DecodeString(parts[1]) - if saltErr == nil && err == nil { - // Better Auth uses the hex salt text (not decoded bytes), NFKC - // normalization and scrypt r=16. Preserve existing account logins. - derived, err := scrypt.Key([]byte(norm.NFKC.String(password)), []byte(parts[0]), 16384, 16, 1, 64) - return err == nil && subtle.ConstantTimeCompare(derived, expected) == 1 - } - } - return false -} - -func (s *AuthService) checkRateLimit(key string, windowSec int64, maxReq int) bool { - s.mu.Lock() - defer s.mu.Unlock() - now := time.Now().Unix() - var count int - var lastRequest int64 - err := s.db.QueryRow("SELECT count, lastRequest FROM rate_limit WHERE key = ?", key).Scan(&count, &lastRequest) - if err == sql.ErrNoRows { - _, _ = s.db.Exec("INSERT INTO rate_limit (key, count, lastRequest) VALUES (?, 1, ?)", key, now) - return true - } else if err != nil { - return true - } - if now-lastRequest > windowSec { - _, _ = s.db.Exec("UPDATE rate_limit SET count = 1, lastRequest = ? WHERE key = ?", now, key) - return true - } - if count >= maxReq { - return false - } - _, _ = s.db.Exec("UPDATE rate_limit SET count = count + 1 WHERE key = ?", key) - return true -} - -func extractSessionToken(r *http.Request) string { - for _, cookieName := range []string{"better-auth.session_token", "session_token"} { - if c, err := r.Cookie(cookieName); err == nil && c.Value != "" { - val := c.Value - if idx := strings.Index(val, "."); idx != -1 { - val = val[:idx] - } - return val - } - } - cookieHeader := r.Header.Get("Cookie") - for _, part := range strings.Split(cookieHeader, ";") { - part = strings.TrimSpace(part) - for _, name := range []string{"better-auth.session_token=", "session_token="} { - if strings.HasPrefix(part, name) { - val := strings.TrimPrefix(part, name) - if idx := strings.Index(val, "."); idx != -1 { - val = val[:idx] - } - return val - } - } - } - return "" -} - -func (s *AuthService) session(r *http.Request) (*Member, error) { - token := extractSessionToken(r) - if token == "" { - return nil, nil - } - now := time.Now().UTC().Format(time.RFC3339Nano) - var m Member - err := s.db.QueryRowContext(r.Context(), ` - SELECT u.id, u.name, u.email, u.role - FROM session s - JOIN user u ON s.userId = u.id - WHERE s.token = ? AND s.expiresAt > ? AND COALESCE(u.banned, 0) = 0 - `, token, now).Scan(&m.ID, &m.Name, &m.Email, &m.Role) - if err == nil { - return &m, nil - } else if !errors.Is(err, sql.ErrNoRows) { - return nil, err - } - - return nil, nil -} - -func (s *AuthService) activeMembers(ctx context.Context) (map[string]AccountStatus, error) { - rows, err := s.db.QueryContext(ctx, "SELECT id, role FROM user WHERE COALESCE(banned, 0) = 0") - if err != nil { - return nil, err - } - defer rows.Close() - result := map[string]AccountStatus{} - for rows.Next() { - var id, role string - if err := rows.Scan(&id, &role); err != nil { - return nil, err - } - result[id] = AccountStatus{Role: role} - } - return result, rows.Err() -} - -func (s *AuthService) LookupMember(ctx context.Context, id string) (*Member, error) { - var m Member - err := s.db.QueryRowContext(ctx, "SELECT id, name, email, role FROM user WHERE id = ? AND COALESCE(banned, 0) = 0", id). - Scan(&m.ID, &m.Name, &m.Email, &m.Role) - if err != nil { - return nil, errors.New("account unavailable") - } - return &m, nil -} - -func (s *AuthService) lookupMember(r *http.Request, id string) (*Member, error) { - return s.LookupMember(r.Context(), id) -} - -func writeAuthError(w http.ResponseWriter, status int, message, code string) { - resp := map[string]interface{}{"message": message} - if code != "" { - resp["code"] = code - } - writeJSON(w, status, resp) -} - -func setSessionCookies(w http.ResponseWriter, token string, maxAge int, isSecure bool) { - for _, name := range []string{"better-auth.session_token", "session_token"} { - cookie := &http.Cookie{ - Name: name, - Value: token, - Path: "/", - MaxAge: maxAge, - HttpOnly: true, - SameSite: http.SameSiteLaxMode, - Secure: isSecure, - } - if maxAge < 0 { - cookie.Expires = time.Unix(0, 0) - } else { - cookie.Expires = time.Now().Add(time.Duration(maxAge) * time.Second) - } - http.SetCookie(w, cookie) - } -} - -func (s *AuthService) ServeHTTP(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Cache-Control", "no-store") - path := r.URL.Path - - switch { - case path == "/auth/sign-in/email" && r.Method == http.MethodPost: - s.handleSignInEmail(w, r) - case path == "/auth/sign-out" && r.Method == http.MethodPost: - s.handleSignOut(w, r) - case path == "/auth/get-session" && r.Method == http.MethodGet: - s.handleGetSession(w, r) - case path == "/auth/change-password" && r.Method == http.MethodPost: - s.handleChangePassword(w, r) - case path == "/auth/admin/list-users" && r.Method == http.MethodGet: - s.handleListUsers(w, r) - case path == "/auth/admin/create-user" && r.Method == http.MethodPost: - s.handleCreateUser(w, r) - case path == "/auth/admin/set-user-password" && r.Method == http.MethodPost: - s.handleSetUserPassword(w, r) - case (path == "/auth/admin/revoke-user-sessions" || path == "/auth/admin/revoke-user-session") && r.Method == http.MethodPost: - s.handleRevokeUserSessions(w, r) - case path == "/auth/admin/get-user" && r.Method == http.MethodGet: - s.handleGetUser(w, r) - case path == "/auth/healthz" && r.Method == http.MethodGet: - s.handleHealthz(w, r) - default: - writeAuthError(w, 404, "Endpoint not found", "NOT_FOUND") - } -} - -func (s *AuthService) handleSignInEmail(w http.ResponseWriter, r *http.Request) { - ip := clientAddress(r) - if !s.checkRateLimit("sign-in:"+ip, 60, 8) { - writeAuthError(w, 429, "Too many login attempts. Please try again later.", "TOO_MANY_REQUESTS") - return - } - var req struct { - Email string `json:"email"` - Password string `json:"password"` - } - if err := json.NewDecoder(io.LimitReader(r.Body, 64*1024)).Decode(&req); err != nil { - writeAuthError(w, 400, "Invalid request body", "BAD_REQUEST") - return - } - req.Email = strings.TrimSpace(req.Email) - if req.Email == "" || req.Password == "" { - writeAuthError(w, 400, "Email and password are required", "BAD_REQUEST") - return - } - - var user struct { - ID string - Name string - Email string - Role string - CreatedAt string - UpdatedAt string - } - err := s.db.QueryRowContext(r.Context(), ` - SELECT id, name, email, role, createdAt, updatedAt - FROM user - WHERE LOWER(email) = LOWER(?) AND COALESCE(banned, 0) = 0 - `, req.Email).Scan(&user.ID, &user.Name, &user.Email, &user.Role, &user.CreatedAt, &user.UpdatedAt) - if errors.Is(err, sql.ErrNoRows) { - writeAuthError(w, 401, "Invalid email or password", "INVALID_EMAIL_OR_PASSWORD") - return - } else if err != nil { - writeAuthError(w, 500, "Authentication failed", "INTERNAL_ERROR") - return - } - - var hashedPassword string - err = s.db.QueryRowContext(r.Context(), "SELECT password FROM account WHERE userId = ?", user.ID).Scan(&hashedPassword) - if err != nil || !verifyPassword(hashedPassword, req.Password) { - writeAuthError(w, 401, "Invalid email or password", "INVALID_EMAIL_OR_PASSWORD") - return - } - - token := randomToken(24) - sessionID := "sess-" + randomToken(12) - now := time.Now().UTC() - expiresAt := now.Add(7 * 24 * time.Hour).Format(time.RFC3339Nano) - createdAt := now.Format(time.RFC3339Nano) - userAgent := r.UserAgent() - - _, err = s.db.ExecContext(r.Context(), ` - INSERT INTO session (id, token, userId, expiresAt, createdAt, updatedAt, ipAddress, userAgent) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `, sessionID, token, user.ID, expiresAt, createdAt, createdAt, ip, userAgent) - if err != nil { - writeAuthError(w, 500, "Failed to create session", "INTERNAL_ERROR") - return - } - - isSecure := strings.HasPrefix(os.Getenv("MIST_SITE_ORIGIN"), "https:") || r.TLS != nil - setSessionCookies(w, token, 604800, isSecure) - - writeJSON(w, 200, map[string]interface{}{ - "redirect": false, - "token": token, - "user": map[string]interface{}{ - "id": user.ID, - "name": user.Name, - "email": user.Email, - "role": user.Role, - "emailVerified": false, - "createdAt": user.CreatedAt, - "updatedAt": user.UpdatedAt, - }, - }) -} - -func (s *AuthService) handleSignOut(w http.ResponseWriter, r *http.Request) { - token := extractSessionToken(r) - if token != "" { - _, _ = s.db.ExecContext(r.Context(), "DELETE FROM session WHERE token = ?", token) - } - isSecure := strings.HasPrefix(os.Getenv("MIST_SITE_ORIGIN"), "https:") || r.TLS != nil - setSessionCookies(w, "", -1, isSecure) - writeJSON(w, 200, map[string]interface{}{"success": true}) -} - -func (s *AuthService) handleGetSession(w http.ResponseWriter, r *http.Request) { - token := extractSessionToken(r) - if token == "" { - writeJSON(w, 200, map[string]interface{}{"user": nil, "session": nil}) - return - } - now := time.Now().UTC().Format(time.RFC3339Nano) - var user Member - var sess struct { - ID string - UserID string - Token string - ExpiresAt string - } - err := s.db.QueryRowContext(r.Context(), ` - SELECT u.id, u.name, u.email, u.role, s.id, s.userId, s.token, s.expiresAt - FROM session s - JOIN user u ON s.userId = u.id - WHERE s.token = ? AND s.expiresAt > ? AND COALESCE(u.banned, 0) = 0 - `, token, now).Scan(&user.ID, &user.Name, &user.Email, &user.Role, - &sess.ID, &sess.UserID, &sess.Token, &sess.ExpiresAt) - if err == nil { - writeJSON(w, 200, map[string]interface{}{ - "user": user, - "session": map[string]interface{}{ - "id": sess.ID, - "userId": sess.UserID, - "token": sess.Token, - "expiresAt": sess.ExpiresAt, - }, - }) - return - } - - writeJSON(w, 200, map[string]interface{}{"user": nil, "session": nil}) -} - -func (s *AuthService) handleChangePassword(w http.ResponseWriter, r *http.Request) { - member, err := s.session(r) - if err != nil || member == nil { - writeAuthError(w, 401, "Sign in to Mist", "UNAUTHORIZED") - return - } - var req struct { - CurrentPassword string `json:"currentPassword"` - NewPassword string `json:"newPassword"` - RevokeOtherSessions bool `json:"revokeOtherSessions"` - } - if err := json.NewDecoder(io.LimitReader(r.Body, 64*1024)).Decode(&req); err != nil { - writeAuthError(w, 400, "Invalid request body", "BAD_REQUEST") - return - } - if len(req.NewPassword) < 12 || len(req.NewPassword) > 128 { - writeAuthError(w, 400, "Password must contain 12–128 characters", "BAD_REQUEST") - return - } - var currentHashed string - err = s.db.QueryRowContext(r.Context(), "SELECT password FROM account WHERE userId = ?", member.ID).Scan(¤tHashed) - if err != nil || !verifyPassword(currentHashed, req.CurrentPassword) { - writeAuthError(w, 400, "Invalid current password", "BAD_REQUEST") - return - } - newHashed, err := hashPassword(req.NewPassword) - if err != nil { - writeAuthError(w, 500, "Failed to hash password", "INTERNAL_ERROR") - return - } - now := time.Now().UTC().Format(time.RFC3339Nano) - _, err = s.db.ExecContext(r.Context(), "UPDATE account SET password = ?, updatedAt = ? WHERE userId = ?", newHashed, now, member.ID) - if err != nil { - writeAuthError(w, 500, "Failed to update password", "INTERNAL_ERROR") - return - } - token := extractSessionToken(r) - if req.RevokeOtherSessions && token != "" { - _, _ = s.db.ExecContext(r.Context(), "DELETE FROM session WHERE userId = ? AND token != ?", member.ID, token) - } - writeJSON(w, 200, map[string]interface{}{"status": true, "token": token}) -} - -func (s *AuthService) handleListUsers(w http.ResponseWriter, r *http.Request) { - member, err := s.session(r) - if err != nil || !isAdmin(member) { - writeAuthError(w, 403, "Administrator access required", "FORBIDDEN") - return - } - limit := 1000 - if raw := r.URL.Query().Get("limit"); raw != "" { - if val, err := strconv.Atoi(raw); err == nil && val > 0 && val <= 1000 { - limit = val - } - } - rows, err := s.db.QueryContext(r.Context(), ` - SELECT id, name, email, role, createdAt, updatedAt - FROM user - ORDER BY createdAt DESC - LIMIT ? - `, limit) - if err != nil { - writeAuthError(w, 500, "Failed to query members", "INTERNAL_ERROR") - return - } - defer rows.Close() - - type userRecord struct { - ID string `json:"id"` - Name string `json:"name"` - Email string `json:"email"` - Role string `json:"role"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` - } - users := []userRecord{} - for rows.Next() { - var u userRecord - if err := rows.Scan(&u.ID, &u.Name, &u.Email, &u.Role, &u.CreatedAt, &u.UpdatedAt); err != nil { - writeAuthError(w, 500, "Failed to scan members", "INTERNAL_ERROR") - return - } - users = append(users, u) - } - var total int - _ = s.db.QueryRowContext(r.Context(), "SELECT COUNT(*) FROM user").Scan(&total) - writeJSON(w, 200, map[string]interface{}{ - "users": users, - "total": total, - }) -} - -func (s *AuthService) handleCreateUser(w http.ResponseWriter, r *http.Request) { - member, err := s.session(r) - if err != nil || !isAdmin(member) { - writeAuthError(w, 403, "Administrator access required", "FORBIDDEN") - return - } - var req struct { - Name string `json:"name"` - Email string `json:"email"` - Password string `json:"password"` - Role string `json:"role"` - } - if err := json.NewDecoder(io.LimitReader(r.Body, 64*1024)).Decode(&req); err != nil { - writeAuthError(w, 400, "Invalid request body", "BAD_REQUEST") - return - } - req.Name = strings.TrimSpace(req.Name) - req.Email = strings.TrimSpace(req.Email) - if req.Name == "" || req.Email == "" { - writeAuthError(w, 400, "Name and email are required", "BAD_REQUEST") - return - } - if len(req.Password) < 12 || len(req.Password) > 128 { - writeAuthError(w, 400, "Password must contain 12–128 characters", "BAD_REQUEST") - return - } - if req.Role == "" { - req.Role = "user" - } - - var existing string - err = s.db.QueryRowContext(r.Context(), "SELECT id FROM user WHERE LOWER(email) = LOWER(?)", req.Email).Scan(&existing) - if err == nil { - writeAuthError(w, 400, "Email already exists", "EMAIL_EXISTS") - return - } - - hashed, err := hashPassword(req.Password) - if err != nil { - writeAuthError(w, 500, "Failed to hash password", "INTERNAL_ERROR") - return - } - - userID := "user-" + randomToken(12) - now := time.Now().UTC().Format(time.RFC3339Nano) - tx, err := s.db.BeginTx(r.Context(), nil) - if err != nil { - writeAuthError(w, 500, "Failed to start transaction", "INTERNAL_ERROR") - return - } - defer tx.Rollback() - - _, err = tx.ExecContext(r.Context(), ` - INSERT INTO user (id, name, email, role, emailVerified, createdAt, updatedAt) - VALUES (?, ?, ?, ?, 0, ?, ?) - `, userID, req.Name, req.Email, req.Role, now, now) - if err != nil { - writeAuthError(w, 500, "Failed to create user", "INTERNAL_ERROR") - return - } - - accountID := "acc-" + randomToken(12) - _, err = tx.ExecContext(r.Context(), ` - INSERT INTO account (id, userId, accountId, providerId, password, createdAt, updatedAt) - VALUES (?, ?, ?, 'credential', ?, ?, ?) - `, accountID, userID, req.Email, hashed, now, now) - if err != nil { - writeAuthError(w, 500, "Failed to create account", "INTERNAL_ERROR") - return - } - - if err := tx.Commit(); err != nil { - writeAuthError(w, 500, "Failed to commit user creation", "INTERNAL_ERROR") - return - } - - writeJSON(w, 200, map[string]interface{}{ - "user": map[string]interface{}{ - "id": userID, - "name": req.Name, - "email": req.Email, - "role": req.Role, - "createdAt": now, - "updatedAt": now, - }, - }) -} - -func (s *AuthService) handleSetUserPassword(w http.ResponseWriter, r *http.Request) { - member, err := s.session(r) - if err != nil || !isAdmin(member) { - writeAuthError(w, 403, "Administrator access required", "FORBIDDEN") - return - } - var req struct { - UserID string `json:"userId"` - NewPassword string `json:"newPassword"` - } - if err := json.NewDecoder(io.LimitReader(r.Body, 64*1024)).Decode(&req); err != nil { - writeAuthError(w, 400, "Invalid request body", "BAD_REQUEST") - return - } - if req.UserID == "" { - writeAuthError(w, 400, "User ID is required", "BAD_REQUEST") - return - } - if len(req.NewPassword) < 12 || len(req.NewPassword) > 128 { - writeAuthError(w, 400, "Password must contain 12–128 characters", "BAD_REQUEST") - return - } - hashed, err := hashPassword(req.NewPassword) - if err != nil { - writeAuthError(w, 500, "Failed to hash password", "INTERNAL_ERROR") - return - } - now := time.Now().UTC().Format(time.RFC3339Nano) - res, err := s.db.ExecContext(r.Context(), "UPDATE account SET password = ?, updatedAt = ? WHERE userId = ?", hashed, now, req.UserID) - if err != nil { - writeAuthError(w, 500, "Failed to update password", "INTERNAL_ERROR") - return - } - if rows, _ := res.RowsAffected(); rows == 0 { - var email string - err = s.db.QueryRowContext(r.Context(), "SELECT email FROM user WHERE id = ?", req.UserID).Scan(&email) - if err != nil { - writeAuthError(w, 404, "User not found", "USER_NOT_FOUND") - return - } - accountID := "acc-" + randomToken(12) - _, _ = s.db.ExecContext(r.Context(), ` - INSERT INTO account (id, userId, accountId, providerId, password, createdAt, updatedAt) - VALUES (?, ?, ?, 'credential', ?, ?, ?) - `, accountID, req.UserID, email, hashed, now, now) - } - writeJSON(w, 200, map[string]interface{}{"status": true}) -} - -func (s *AuthService) handleRevokeUserSessions(w http.ResponseWriter, r *http.Request) { - member, err := s.session(r) - if err != nil || !isAdmin(member) { - writeAuthError(w, 403, "Administrator access required", "FORBIDDEN") - return - } - var req struct { - UserID string `json:"userId"` - } - if err := json.NewDecoder(io.LimitReader(r.Body, 64*1024)).Decode(&req); err != nil { - writeAuthError(w, 400, "Invalid request body", "BAD_REQUEST") - return - } - if req.UserID != "" { - _, _ = s.db.ExecContext(r.Context(), "DELETE FROM session WHERE userId = ?", req.UserID) - } - writeJSON(w, 200, map[string]interface{}{"status": true}) -} - -func (s *AuthService) handleGetUser(w http.ResponseWriter, r *http.Request) { - member, err := s.session(r) - if err != nil || member == nil { - writeAuthError(w, 401, "Sign in to Mist", "UNAUTHORIZED") - return - } - id := r.URL.Query().Get("id") - if id == "" { - id = member.ID - } - if id != member.ID && !isAdmin(member) { - writeAuthError(w, 403, "Administrator access required", "FORBIDDEN") - return - } - var u Member - err = s.db.QueryRowContext(r.Context(), "SELECT id, name, email, role FROM user WHERE id = ?", id). - Scan(&u.ID, &u.Name, &u.Email, &u.Role) - if err != nil { - writeAuthError(w, 404, "User not found", "USER_NOT_FOUND") - return - } - writeJSON(w, 200, u) -} - -func (s *AuthService) handleInternalMembers(w http.ResponseWriter, r *http.Request) { - configured := os.Getenv("MIST_INTERNAL_TOKEN") - authHeader := r.Header.Get("Authorization") - presented := strings.TrimPrefix(authHeader, "Bearer ") - if configured == "" || len(configured) < 32 || subtle.ConstantTimeCompare([]byte(configured), []byte(presented)) != 1 { - writeJSON(w, 403, map[string]string{"error": "Forbidden"}) - return - } - active, err := s.activeMembers(r.Context()) - if err != nil { - writeJSON(w, 500, map[string]string{"error": "Failed to read members"}) - return - } - type memberEntry struct { - ID string `json:"id"` - Role string `json:"role"` - } - var list []memberEntry - for id, status := range active { - list = append(list, memberEntry{ID: id, Role: status.Role}) - } - writeJSON(w, 200, map[string]interface{}{"members": list}) -} - -func (s *AuthService) handleHealthz(w http.ResponseWriter, r *http.Request) { - var one int - if err := s.db.QueryRowContext(r.Context(), "SELECT 1").Scan(&one); err != nil { - w.WriteHeader(503) - _, _ = w.Write([]byte("database unavailable")) - return - } - w.WriteHeader(200) - _, _ = w.Write([]byte("ok")) -} - -func (s *AuthService) middleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Cache-Control", "no-store") - if r.URL.Path == "/healthz" { - next.ServeHTTP(w, r) - return - } - if strings.HasPrefix(r.URL.Path, "/auth/") { - r.Body = http.MaxBytesReader(w, r.Body, 64*1024) - s.ServeHTTP(w, r) - return - } - if r.URL.Path == "/internal/members" && r.Method == http.MethodGet { - s.handleInternalMembers(w, r) - return - } - member, err := s.session(r) - if err != nil { - writeJSON(w, 503, map[string]string{"error": "Authentication service unavailable"}) - return - } - if r.URL.Path == "/session" && r.Method == http.MethodGet { - writeJSON(w, 200, map[string]interface{}{"enabled": true, "user": member}) - return - } - if member == nil { - writeJSON(w, 401, map[string]string{"error": "Sign in to Mist"}) - return - } - if r.Method != http.MethodGet && r.Method != http.MethodHead { - origin := r.Header.Get("Origin") - if (origin != "" && !s.origins[origin]) || r.Header.Get("Sec-Fetch-Site") == "cross-site" { - writeJSON(w, 403, map[string]string{"error": "Untrusted request origin"}) - return - } - } - next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), memberKey{}, member))) - }) -} diff --git a/src/auth_test.go b/src/auth_test.go deleted file mode 100644 index 9432a5d..0000000 --- a/src/auth_test.go +++ /dev/null @@ -1,389 +0,0 @@ -package main - -import ( - "bytes" - "context" - "database/sql" - "encoding/json" - "io" - "log/slog" - "net/http/httptest" - "path/filepath" - "strings" - "testing" - "time" -) - -func newTestAuthService(t *testing.T) *AuthService { - t.Helper() - dbPath := filepath.Join(t.TempDir(), "test-auth.sqlite") - s, err := newAuthService(dbPath, slog.New(slog.NewTextHandler(io.Discard, nil))) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { s.Close() }) - return s -} - -func authRequest(s *AuthService, method, path string, body any, cookie string) *httptest.ResponseRecorder { - var reader io.Reader - if body != nil { - data, _ := json.Marshal(body) - reader = bytes.NewReader(data) - } - req := httptest.NewRequest(method, path, reader) - if cookie != "" { - req.Header.Set("Cookie", cookie) - } - rec := httptest.NewRecorder() - s.ServeHTTP(rec, req) - return rec -} - -func TestAuthService_BootstrapAdmin(t *testing.T) { - t.Setenv("MIST_ADMIN_EMAIL", "admin@mist.local") - t.Setenv("MIST_ADMIN_PASSWORD", "adminpassword123") - s := newTestAuthService(t) - - var count int - if err := s.db.QueryRow("SELECT COUNT(*) FROM user WHERE role = 'admin'").Scan(&count); err != nil { - t.Fatal(err) - } - if count != 1 { - t.Fatalf("expected 1 admin user, got %d", count) - } - - rec := authRequest(s, "POST", "/auth/sign-in/email", map[string]string{ - "email": "admin@mist.local", - "password": "adminpassword123", - }, "") - if rec.Code != 200 { - t.Fatalf("expected 200 login for bootstrap admin, got %d %s", rec.Code, rec.Body.String()) - } -} - -func TestAuthService_SignInAndSessionLifecycle(t *testing.T) { - s := newTestAuthService(t) - - // Create user - hashed, _ := hashPassword("initialpassword123") - _, err := s.db.Exec(`INSERT INTO user (id, name, email, role, createdAt, updatedAt) VALUES ('u1', 'Test User', 'user@mist.local', 'user', datetime('now'), datetime('now'))`) - if err != nil { - t.Fatal(err) - } - _, err = s.db.Exec(`INSERT INTO account (id, userId, accountId, providerId, password, createdAt, updatedAt) VALUES ('a1', 'u1', 'user@mist.local', 'credential', ?, datetime('now'), datetime('now'))`, hashed) - if err != nil { - t.Fatal(err) - } - - // Bad password - rec := authRequest(s, "POST", "/auth/sign-in/email", map[string]string{ - "email": "user@mist.local", - "password": "wrongpassword123", - }, "") - if rec.Code != 401 { - t.Fatalf("expected 401 for wrong password, got %d", rec.Code) - } - - // Non-existent user - rec = authRequest(s, "POST", "/auth/sign-in/email", map[string]string{ - "email": "nobody@mist.local", - "password": "wrongpassword123", - }, "") - if rec.Code != 401 { - t.Fatalf("expected 401 for non-existent user, got %d", rec.Code) - } - - // Valid login - rec = authRequest(s, "POST", "/auth/sign-in/email", map[string]string{ - "email": "user@mist.local", - "password": "initialpassword123", - }, "") - if rec.Code != 200 { - t.Fatalf("expected 200 for valid login, got %d %s", rec.Code, rec.Body.String()) - } - - cookie := rec.Header().Get("Set-Cookie") - if !strings.Contains(cookie, "better-auth.session_token=") { - t.Fatalf("missing better-auth.session_token cookie: %s", cookie) - } - - // Parse session token from cookie - var sessionToken string - for _, c := range rec.Result().Cookies() { - if c.Name == "better-auth.session_token" { - sessionToken = c.Value - } - } - if sessionToken == "" { - t.Fatal("empty session token") - } - - // Get session - rec = authRequest(s, "GET", "/auth/get-session", nil, "better-auth.session_token="+sessionToken) - if rec.Code != 200 { - t.Fatalf("expected 200 get-session, got %d", rec.Code) - } - var sessResp struct { - User *Member `json:"user"` - } - if err := json.Unmarshal(rec.Body.Bytes(), &sessResp); err != nil { - t.Fatal(err) - } - if sessResp.User == nil || sessResp.User.ID != "u1" { - t.Fatalf("unexpected session response: %+v", sessResp.User) - } - - // Change password - rec = authRequest(s, "POST", "/auth/change-password", map[string]any{ - "currentPassword": "initialpassword123", - "newPassword": "newpassword12345", - "revokeOtherSessions": true, - }, "better-auth.session_token="+sessionToken) - if rec.Code != 200 { - t.Fatalf("expected 200 change-password, got %d %s", rec.Code, rec.Body.String()) - } - - // Sign out - rec = authRequest(s, "POST", "/auth/sign-out", map[string]string{}, "better-auth.session_token="+sessionToken) - if rec.Code != 200 { - t.Fatalf("expected 200 sign-out, got %d", rec.Code) - } - - // Get session after logout -> should be nil - rec = authRequest(s, "GET", "/auth/get-session", nil, "better-auth.session_token="+sessionToken) - if rec.Code != 200 { - t.Fatalf("expected 200 get-session after logout, got %d", rec.Code) - } - json.Unmarshal(rec.Body.Bytes(), &sessResp) - if sessResp.User != nil { - t.Fatalf("expected nil user after logout, got %+v", sessResp.User) - } -} - -func TestAuthService_AdminUserManagement(t *testing.T) { - s := newTestAuthService(t) - - // Seed admin and user - hashed, _ := hashPassword("adminpassword123") - _, _ = s.db.Exec(`INSERT INTO user (id, name, email, role, createdAt, updatedAt) VALUES ('admin1', 'Admin', 'admin@mist.local', 'admin', datetime('now'), datetime('now'))`) - _, _ = s.db.Exec(`INSERT INTO account (id, userId, accountId, providerId, password, createdAt, updatedAt) VALUES ('a_admin', 'admin1', 'admin@mist.local', 'credential', ?, datetime('now'), datetime('now'))`, hashed) - - _, _ = s.db.Exec(`INSERT INTO user (id, name, email, role, createdAt, updatedAt) VALUES ('user1', 'User', 'user@mist.local', 'user', datetime('now'), datetime('now'))`) - _, _ = s.db.Exec(`INSERT INTO account (id, userId, accountId, providerId, password, createdAt, updatedAt) VALUES ('a_user', 'user1', 'user@mist.local', 'credential', ?, datetime('now'), datetime('now'))`, hashed) - - seedTestSession(t, s, "admin1") - seedTestSession(t, s, "user1") - - // Admin creates a new user - rec := authRequest(s, "POST", "/auth/admin/create-user", map[string]string{ - "name": "Bob", - "email": "bob@mist.local", - "password": "bobpassword123", - "role": "user", - }, "better-auth.session_token=test-session-admin1") - if rec.Code != 200 { - t.Fatalf("create-user failed: %d %s", rec.Code, rec.Body.String()) - } - var created struct { - User Member `json:"user"` - } - json.Unmarshal(rec.Body.Bytes(), &created) - if created.User.Email != "bob@mist.local" { - t.Fatalf("unexpected user created: %+v", created.User) - } - - // Non-admin cannot create user - rec = authRequest(s, "POST", "/auth/admin/create-user", map[string]string{ - "name": "Mallory", - "email": "mallory@mist.local", - "password": "mallorypassword123", - }, "better-auth.session_token=test-session-user1") - if rec.Code != 403 { - t.Fatalf("expected 403 for non-admin create-user, got %d", rec.Code) - } - - // List users - rec = authRequest(s, "GET", "/auth/admin/list-users?limit=100", nil, "better-auth.session_token=test-session-admin1") - if rec.Code != 200 { - t.Fatalf("list-users failed: %d %s", rec.Code, rec.Body.String()) - } - var listResp struct { - Users []Member `json:"users"` - Total int `json:"total"` - } - json.Unmarshal(rec.Body.Bytes(), &listResp) - if listResp.Total < 3 || len(listResp.Users) < 3 { - t.Fatalf("expected at least 3 users, got %d (total %d)", len(listResp.Users), listResp.Total) - } - - // Reset password - rec = authRequest(s, "POST", "/auth/admin/set-user-password", map[string]string{ - "userId": created.User.ID, - "newPassword": "newbobpassword123", - }, "better-auth.session_token=test-session-admin1") - if rec.Code != 200 { - t.Fatalf("set-user-password failed: %d %s", rec.Code, rec.Body.String()) - } - - // Login with new password - rec = authRequest(s, "POST", "/auth/sign-in/email", map[string]string{ - "email": "bob@mist.local", - "password": "newbobpassword123", - }, "") - if rec.Code != 200 { - t.Fatalf("expected 200 for user login with new password, got %d %s", rec.Code, rec.Body.String()) - } -} - -func TestAuthService_InternalMembers(t *testing.T) { - token := "abcdef12345678901234567890123456" - t.Setenv("MIST_INTERNAL_TOKEN", token) - s := newTestAuthService(t) - - _, _ = s.db.Exec(`INSERT INTO user (id, name, email, role, createdAt, updatedAt) VALUES ('u1', 'Alice', 'alice@mist.local', 'user', datetime('now'), datetime('now'))`) - _, _ = s.db.Exec(`INSERT INTO user (id, name, email, role, createdAt, updatedAt) VALUES ('u2', 'Bob', 'bob@mist.local', 'admin', datetime('now'), datetime('now'))`) - - req := httptest.NewRequest("GET", "/internal/members", nil) - rec := httptest.NewRecorder() - s.handleInternalMembers(rec, req) - if rec.Code != 403 { - t.Fatalf("expected 403 without token, got %d", rec.Code) - } - - req = httptest.NewRequest("GET", "/internal/members", nil) - req.Header.Set("Authorization", "Bearer "+token) - rec = httptest.NewRecorder() - s.handleInternalMembers(rec, req) - if rec.Code != 200 { - t.Fatalf("expected 200 with token, got %d", rec.Code) - } - var body struct { - Members []struct { - ID string `json:"id"` - Active bool `json:"active"` - Role string `json:"role"` - } `json:"members"` - } - json.Unmarshal(rec.Body.Bytes(), &body) - if len(body.Members) != 2 { - t.Fatalf("expected 2 members, got %d", len(body.Members)) - } -} - -// Fixture sessions exercise the same lookup as real sign-in, never user-ID cookies. -func seedTestSession(t *testing.T, service *AuthService, id string) { - t.Helper() - now := time.Now().UTC() - _, err := service.db.Exec(`INSERT INTO session (id,token,userId,expiresAt,createdAt,updatedAt) VALUES (?,?,?,?,?,?)`, "session-"+id, "test-session-"+id, id, now.Add(time.Hour).Format(time.RFC3339Nano), now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano)) - if err != nil { - t.Fatal(err) - } -} - -func TestAuthServiceRejectsUserIDAndExpiredSessionCookies(t *testing.T) { - service := newTestAuthService(t) - now := time.Now().UTC().Format(time.RFC3339Nano) - _, err := service.db.Exec(`INSERT INTO user (id,name,email,role,createdAt,updatedAt) VALUES (?,?,?,?,?,?)`, "known-user", "Known", "known@mist.local", "admin", now, now) - if err != nil { - t.Fatal(err) - } - seedTestSession(t, service, "known-user") - for _, cookie := range []string{"identity=known-user", "member=known-user", "session_token=known-user", "better-auth.session_token=known-user"} { - request := httptest.NewRequest("GET", "/session", nil) - request.Header.Set("Cookie", cookie) - member, err := service.session(request) - if err != nil || member != nil { - t.Fatalf("forged identity cookie accepted: %v", err) - } - response := authRequest(service, "GET", "/auth/get-session", nil, cookie) - var body struct { - User *Member `json:"user"` - } - if err := json.Unmarshal(response.Body.Bytes(), &body); err != nil || body.User != nil { - t.Fatal("session endpoint accepted a forged identity") - } - } - if _, err := service.db.Exec(`UPDATE session SET expiresAt=?`, time.Now().Add(-time.Hour).UTC().Format(time.RFC3339Nano)); err != nil { - t.Fatal(err) - } - request := httptest.NewRequest("GET", "/session", nil) - request.Header.Set("Cookie", "better-auth.session_token=test-session-known-user") - if member, err := service.session(request); err != nil || member != nil { - t.Fatalf("expired session accepted: %v", err) - } -} - -func TestBetterAuthPasswordCompatibility(t *testing.T) { - // Public test vector generated with Better Auth's Node scrypt parameters. - const hash = "0123456789abcdef0123456789abcdef:8582d1a409fb81869c9f080d49ce2d8839d611a9905642ce9e7b9e5c1cf7e7b71add2af990c5095edf0733f174780d62b2f7ff2fb42cd07a5edf561bef26dc34" - if !verifyPassword(hash, "legacy-password-123") || verifyPassword(hash, "wrong-password") { - t.Fatal("existing Better Auth password compatibility is broken") - } -} - -func TestDeactivatedAccountsCannotUseExistingSessions(t *testing.T) { - service := newTestAuthService(t) - now := time.Now().UTC().Format(time.RFC3339Nano) - _, err := service.db.Exec(`INSERT INTO user (id,name,email,role,banned,createdAt,updatedAt) VALUES (?,?,?,?,?,?,?)`, "inactive", "Inactive", "inactive@mist.local", "admin", 1, now, now) - if err != nil { - t.Fatal(err) - } - seedTestSession(t, service, "inactive") - request := httptest.NewRequest("GET", "/session", nil) - request.Header.Set("Cookie", "better-auth.session_token=test-session-inactive") - if member, err := service.session(request); err != nil || member != nil { - t.Fatal("deactivated account retained access") - } - active, err := service.activeMembers(context.Background()) - if err != nil { - t.Fatal(err) - } - if _, exists := active["inactive"]; exists { - t.Fatal("deactivated account can keep running jobs") - } - if _, err := service.LookupMember(context.Background(), "inactive"); err == nil { - t.Fatal("deactivated account can be enrolled") - } - response := authRequest(service, "GET", "/auth/get-session", nil, "better-auth.session_token=test-session-inactive") - var body struct { - User *Member `json:"user"` - } - if err := json.Unmarshal(response.Body.Bytes(), &body); err != nil || body.User != nil { - t.Fatal("session endpoint exposes deactivated account") - } -} - -func TestExistingBetterAuthSchemaCanCreateAccounts(t *testing.T) { - path := filepath.Join(t.TempDir(), "existing.sqlite") - db, err := sql.Open("sqlite", path) - if err != nil { - t.Fatal(err) - } - _, err = db.Exec(`CREATE TABLE user (id TEXT PRIMARY KEY, name TEXT NOT NULL, email TEXT NOT NULL UNIQUE, emailVerified INTEGER NOT NULL, createdAt TEXT NOT NULL, updatedAt TEXT NOT NULL, role TEXT)`) - db.Close() - if err != nil { - t.Fatal(err) - } - t.Setenv("MIST_ADMIN_EMAIL", "existing-schema@mist.local") - t.Setenv("MIST_ADMIN_PASSWORD", "test-password-123") - service, err := newAuthService(path, nil) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = service.Close() }) - login := authRequest(service, "POST", "/auth/sign-in/email", map[string]string{"email": "existing-schema@mist.local", "password": "test-password-123"}, "") - if login.Code != 200 { - t.Fatal("bootstrap must support old NOT NULL columns") - } - var body struct { - Token string `json:"token"` - } - if err := json.Unmarshal(login.Body.Bytes(), &body); err != nil { - t.Fatal(err) - } - created := authRequest(service, "POST", "/auth/admin/create-user", map[string]string{"name": "New member", "email": "new-schema@mist.local", "password": "test-password-456"}, "better-auth.session_token="+body.Token) - if created.Code != 200 { - t.Fatalf("member creation failed against existing schema: %s", created.Body) - } -} diff --git a/src/go.mod b/src/go.mod deleted file mode 100644 index 972e1e9..0000000 --- a/src/go.mod +++ /dev/null @@ -1,63 +0,0 @@ -module mist - -go 1.26.0 - -require ( - github.com/distribution/reference v0.6.0 - golang.org/x/crypto v0.58.0 - golang.org/x/text v0.43.0 - k8s.io/api v0.34.1 - k8s.io/apimachinery v0.34.1 - k8s.io/client-go v0.34.1 - modernc.org/sqlite v1.60.1 -) - -require ( - github.com/davecgh/go-spew v1.1.1 // indirect - github.com/dustin/go-humanize v1.0.1 // indirect - github.com/emicklei/go-restful/v3 v3.12.2 // indirect - github.com/fxamacker/cbor/v2 v2.9.0 // indirect - github.com/go-logr/logr v1.4.3 // indirect - github.com/go-openapi/jsonpointer v0.21.0 // indirect - github.com/go-openapi/jsonreference v0.20.2 // indirect - github.com/go-openapi/swag v0.23.0 // indirect - github.com/gogo/protobuf v1.3.2 // indirect - github.com/google/gnostic-models v0.7.0 // indirect - github.com/google/uuid v1.6.0 // indirect - github.com/josharian/intern v1.0.0 // indirect - github.com/json-iterator/go v1.1.12 // indirect - github.com/mailru/easyjson v0.7.7 // indirect - github.com/mattn/go-isatty v0.0.24 // indirect - github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect - github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect - github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/ncruces/go-strftime v1.0.0 // indirect - github.com/opencontainers/go-digest v1.0.0 // indirect - github.com/pkg/errors v0.9.1 // indirect - github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - github.com/rogpeppe/go-internal v1.14.1 // indirect - github.com/spf13/pflag v1.0.6 // indirect - github.com/stretchr/testify v1.11.1 // indirect - github.com/x448/float16 v0.8.4 // indirect - go.yaml.in/yaml/v2 v2.4.2 // indirect - go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/net v0.60.0 // indirect - golang.org/x/oauth2 v0.27.0 // indirect - golang.org/x/sys v0.49.0 // indirect - golang.org/x/term v0.47.0 // indirect - golang.org/x/time v0.12.0 // indirect - google.golang.org/protobuf v1.36.11 // indirect - gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect - gopkg.in/inf.v0 v0.9.1 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect - k8s.io/klog/v2 v2.130.1 // indirect - k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect - k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect - modernc.org/libc v1.77.1 // indirect - modernc.org/mathutil v1.7.1 // indirect - modernc.org/memory v1.12.1 // indirect - sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect - sigs.k8s.io/randfill v1.0.0 // indirect - sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect - sigs.k8s.io/yaml v1.6.0 // indirect -) diff --git a/src/go.sum b/src/go.sum deleted file mode 100644 index 1ea1ff4..0000000 --- a/src/go.sum +++ /dev/null @@ -1,204 +0,0 @@ -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= -github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= -github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= -github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= -github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= -github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= -github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= -github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= -github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= -github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= -github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= -github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= -github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= -github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= -github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= -github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= -github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= -github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= -github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= -github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= -github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= -github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= -github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= -github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= -github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= -github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= -github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= -github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= -github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= -github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= -github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= -github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= -github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= -github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= -github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= -github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= -github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= -github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= -github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= -github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= -github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= -github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM= -github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= -github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4= -github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog= -github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= -github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= -github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= -github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= -github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= -github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= -github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= -go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= -go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= -go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= -golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w= -golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= -golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU= -golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= -golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M= -golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA= -golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= -golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= -golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM= -golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc= -golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s= -golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= -golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.51.0 h1:k4Xc/1Om9jwkBJBo4NVLMSARBoWtK10mx+W5BnXCeAI= -golang.org/x/tools v0.51.0/go.mod h1:9eEncMayCV6zRMGhR5eZEC2iBx98qWcF1HZ9Z7wJOoA= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= -google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= -gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= -gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= -gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -k8s.io/api v0.34.1 h1:jC+153630BMdlFukegoEL8E/yT7aLyQkIVuwhmwDgJM= -k8s.io/api v0.34.1/go.mod h1:SB80FxFtXn5/gwzCoN6QCtPD7Vbu5w2n1S0J5gFfTYk= -k8s.io/apimachinery v0.34.1 h1:dTlxFls/eikpJxmAC7MVE8oOeP1zryV7iRyIjB0gky4= -k8s.io/apimachinery v0.34.1/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= -k8s.io/client-go v0.34.1 h1:ZUPJKgXsnKwVwmKKdPfw4tB58+7/Ik3CrjOEhsiZ7mY= -k8s.io/client-go v0.34.1/go.mod h1:kA8v0FP+tk6sZA0yKLRG67LWjqufAoSHA2xVGKw9Of8= -k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= -k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= -k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOPolHyvO06MXG5TUIj2mNAA= -k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts= -k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y= -k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= -modernc.org/cc/v4 v4.29.7 h1:q+NXGJ0bK3b4TXFYQQVr9pYETGnmwFWkrUzJnMya/Tg= -modernc.org/cc/v4 v4.29.7/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= -modernc.org/ccgo/v4 v4.36.1 h1:ZNIUZAryN0UgnJwtyxrdEzcFc3yD4Cu4AzjfPXsLsIE= -modernc.org/ccgo/v4 v4.36.1/go.mod h1:rrtGc2QkS239nYb/mQNuBMyjq3/y3ZXWbBjPoV3wqzA= -modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= -modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= -modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= -modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= -modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc= -modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= -modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= -modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= -modernc.org/libc v1.77.1 h1:Ct8j47QtiZ1Enj2DtFXQtUqrPCAjdCmPjtCuvrYQ0Hs= -modernc.org/libc v1.77.1/go.mod h1:87/pZ4L6nD1zqW4nItuS12YO7hN1igAah34xjnQo/W0= -modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= -modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= -modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g= -modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= -modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= -modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= -modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= -modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= -modernc.org/sqlite v1.60.1 h1:/blz53O951KWFOso4QQvEs/Fq6cDBKLtMVrYNSeJVKw= -modernc.org/sqlite v1.60.1/go.mod h1:1dIoEagfDE72QytD5scH1lxARtaUgKgHC/NuApA27r0= -modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= -modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= -modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= -modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= -sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= -sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= -sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= -sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= -sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= -sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= -sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= -sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/src/hardware.go b/src/hardware.go deleted file mode 100644 index d979231..0000000 --- a/src/hardware.go +++ /dev/null @@ -1,266 +0,0 @@ -package main - -import ( - "context" - "fmt" - "net" - "net/http" - "sync" - "time" - - corev1 "k8s.io/api/core/v1" - resourcev1 "k8s.io/api/resource/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -type MachineHardware struct { - Name string `json:"name"` - Address string `json:"address"` - Ready bool `json:"ready"` - Schedulable bool `json:"schedulable"` - CPUAllocatable string `json:"cpu_allocatable"` - MemoryAllocatable string `json:"memory_allocatable"` -} - -type AcceleratorPool struct { - Accelerator string `json:"accelerator"` - Node string `json:"node"` - Unit string `json:"unit"` - Total int64 `json:"total"` - Allocated int64 `json:"allocated"` - Available *int64 `json:"available"` - Ready bool `json:"ready"` - ChipsPerDevice int `json:"chips_per_device,omitempty"` - Message string `json:"message,omitempty"` -} - -type HardwareSnapshot struct { - ObservedAt time.Time `json:"observed_at"` - Machines []MachineHardware `json:"machines"` - Pools []AcceleratorPool `json:"pools"` -} - -func (e *KubernetesExecutor) hardware(ctx context.Context) (*HardwareSnapshot, error) { - var nodes *corev1.NodeList - var pods *corev1.PodList - var claims *resourcev1.ResourceClaimList - var slices *resourcev1.ResourceSliceList - // These reads are independent. Fail rather than invent free-device counts - // when permissions/connectivity are incomplete. - reads := []func() error{ - func() (err error) { nodes, err = e.client.CoreV1().Nodes().List(ctx, metav1.ListOptions{}); return }, - func() (err error) { pods, err = e.client.CoreV1().Pods("").List(ctx, metav1.ListOptions{}); return }, - func() (err error) { - claims, err = e.client.ResourceV1().ResourceClaims("").List(ctx, metav1.ListOptions{}) - return - }, - func() (err error) { - slices, err = e.client.ResourceV1().ResourceSlices().List(ctx, metav1.ListOptions{}) - return - }, - } - errors := make([]error, len(reads)) - var wg sync.WaitGroup - for i, read := range reads { - wg.Add(1) - go func(i int, read func() error) { defer wg.Done(); errors[i] = read() }(i, read) - } - wg.Wait() - for _, err := range errors { - if err != nil { - return nil, fmt.Errorf("hardware discovery: %w", err) - } - } - return e.hardwareFrom(nodes.Items, pods.Items, claims.Items, slices.Items), nil -} - -func conditionReady(conditions []corev1.PodCondition) bool { - for _, condition := range conditions { - if condition.Type == corev1.PodReady { - return condition.Status == corev1.ConditionTrue - } - } - return false -} - -func driverReady(pods []corev1.Pod, node, driver string) bool { - for _, pod := range pods { - if pod.Spec.NodeName != node || pod.DeletionTimestamp != nil { - continue - } - matches := pod.Labels["app.kubernetes.io/name"] == driver || pod.Labels["app"] == driver - if matches && conditionReady(pod.Status.Conditions) { - return true - } - } - return false -} - -// podGPURequest follows effective scheduling requests: app containers plus -// restartable init containers, or the peak of sequential init requirements. -// Scheduled Pending and Terminating pods still reserve resources; terminal -// pods and unplaced pods do not. This counts every namespace, not just Mist. -func podGPURequest(pod corev1.Pod) int64 { - if pod.Spec.NodeName == "" || pod.Status.Phase == corev1.PodSucceeded || pod.Status.Phase == corev1.PodFailed { - return 0 - } - request := func(c corev1.Container) int64 { - q, ok := c.Resources.Requests["nvidia.com/gpu"] - if !ok { - q = c.Resources.Limits["nvidia.com/gpu"] - } - return q.Value() - } - var app, sidecars, peak int64 - for _, c := range pod.Spec.Containers { - app += request(c) - } - for _, c := range pod.Spec.InitContainers { - value := request(c) - if c.RestartPolicy != nil && *c.RestartPolicy == corev1.ContainerRestartPolicyAlways { - sidecars += value - value = 0 - } - peak = max(peak, sidecars+value) - } - return max(app+sidecars, peak) -} - -func (e *KubernetesExecutor) hardwareFrom(nodes []corev1.Node, pods []corev1.Pod, claims []resourcev1.ResourceClaim, slices []resourcev1.ResourceSlice) *HardwareSnapshot { - snapshot := &HardwareSnapshot{ObservedAt: time.Now().UTC(), Machines: []MachineHardware{}, Pools: []AcceleratorPool{}} - machines := map[string]MachineHardware{} - nvidiaCapacity, nvidiaAllocatable := int64(0), int64(0) - for _, node := range nodes { - if node.Name != e.cpuNode && node.Name != e.ttNode { - continue - } - machine := MachineHardware{Name: node.Name} - for _, condition := range node.Status.Conditions { - if condition.Type == corev1.NodeReady { - machine.Ready = condition.Status == corev1.ConditionTrue - } - } - machine.Schedulable = machine.Ready && !node.Spec.Unschedulable - for _, taint := range node.Spec.Taints { - // Pilot jobs have no tolerations; a blocking taint prevents placement. - if taint.Effect == corev1.TaintEffectNoSchedule || taint.Effect == corev1.TaintEffectNoExecute { - machine.Schedulable = false - } - } - for _, address := range node.Status.Addresses { - if address.Type == corev1.NodeInternalIP { - if machine.Address == "" { - machine.Address = address.Address - } - if ip := net.ParseIP(address.Address); ip != nil && ip.To4() != nil { - machine.Address = address.Address - break - } - } - } - cpu, memory := node.Status.Allocatable[corev1.ResourceCPU], node.Status.Allocatable[corev1.ResourceMemory] - machine.CPUAllocatable, machine.MemoryAllocatable = cpu.String(), memory.String() - machines[node.Name] = machine - if node.Name == e.cpuNode { - capacity, allocatable := node.Status.Capacity["nvidia.com/gpu"], node.Status.Allocatable["nvidia.com/gpu"] - nvidiaCapacity, nvidiaAllocatable = capacity.Value(), allocatable.Value() - } - } - for _, name := range []string{e.cpuNode, e.ttNode} { - machine, ok := machines[name] - if !ok { - machine = MachineHardware{Name: name} - } - snapshot.Machines = append(snapshot.Machines, machine) - } - nvidia := AcceleratorPool{Accelerator: "nvidia", Node: e.cpuNode, Unit: "GPU", Total: nvidiaCapacity} - for _, pod := range pods { - if pod.Spec.NodeName == e.cpuNode { - nvidia.Allocated += podGPURequest(pod) - } - } - nvidia.Ready = machines[e.cpuNode].Schedulable && nvidiaAllocatable > 0 && driverReady(pods, e.cpuNode, "nvidia-device-plugin-daemonset") - free := max(int64(0), nvidiaAllocatable-nvidia.Allocated) - if !nvidia.Ready { - free = 0 - nvidia.Message = "Node or NVIDIA device plugin is unavailable for placement" - } - nvidia.Available = &free - - // Use only the newest complete generation of each advertised device pool. - // Ignore the vendor's incorrect memory capacity and deduplicate devices. - type poolGeneration struct { - generation int64 - expected int64 - slices []resourcev1.ResourceSlice - } - pools := map[string]*poolGeneration{} - for _, slice := range slices { - if slice.Spec.Driver != "tenstorrent.com" || slice.Spec.NodeName == nil || *slice.Spec.NodeName != e.ttNode { - continue - } - key := slice.Spec.Driver + "/" + slice.Spec.Pool.Name - pool := pools[key] - if pool == nil || slice.Spec.Pool.Generation > pool.generation { - pool = &poolGeneration{generation: slice.Spec.Pool.Generation, expected: slice.Spec.Pool.ResourceSliceCount} - pools[key] = pool - } - if slice.Spec.Pool.Generation == pool.generation { - pool.slices = append(pool.slices, slice) - } - } - devices := map[string]bool{} - complete := len(pools) > 0 - for _, pool := range pools { - if int64(len(pool.slices)) != pool.expected { - complete = false - } - for _, slice := range pool.slices { - for _, device := range slice.Spec.Devices { - board, chips := device.Attributes["boardName"], device.Attributes["chipCount"] - if board.StringValue == nil || *board.StringValue != "n300" || chips.IntValue == nil || *chips.IntValue != 2 { - continue - } - devices[slice.Spec.Driver+"/"+slice.Spec.Pool.Name+"/"+device.Name] = true - } - } - } - allocated := map[string]bool{} - for _, claim := range claims { - if claim.Status.Allocation == nil { - continue - } - for _, allocation := range claim.Status.Allocation.Devices.Results { - if allocation.AdminAccess != nil && *allocation.AdminAccess { - continue - } - key := allocation.Driver + "/" + allocation.Pool + "/" + allocation.Device - if devices[key] { - allocated[key] = true - } - } - } - tt := AcceleratorPool{Accelerator: "tenstorrent", Node: e.ttNode, Unit: "board", ChipsPerDevice: 2, Total: int64(len(devices)), Allocated: int64(len(allocated))} - tt.Ready = machines[e.ttNode].Schedulable && complete && driverReady(pods, e.ttNode, "tt-dra-driver") - ttFree := max(int64(0), tt.Total-tt.Allocated) - if !tt.Ready { - ttFree = 0 - tt.Message = "Node, TT device driver, or complete device inventory is unavailable" - } - tt.Available = &ttFree - if !complete { - tt.Available = nil - } - snapshot.Pools = append(snapshot.Pools, nvidia, tt) - return snapshot -} - -func (a *App) hardware(w http.ResponseWriter, r *http.Request) { - snapshot, err := a.executor.hardware(r.Context()) - if err != nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": err.Error()}) - return - } - w.Header().Set("Cache-Control", "no-store") - writeJSON(w, http.StatusOK, snapshot) -} diff --git a/src/hardware_test.go b/src/hardware_test.go deleted file mode 100644 index 985c150..0000000 --- a/src/hardware_test.go +++ /dev/null @@ -1,109 +0,0 @@ -package main - -import ( - "errors" - "io" - "log/slog" - "net/http/httptest" - "strings" - "testing" - - corev1 "k8s.io/api/core/v1" - resourcev1 "k8s.io/api/resource/v1" - "k8s.io/apimachinery/pkg/api/resource" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/client-go/kubernetes/fake" - ktesting "k8s.io/client-go/testing" -) - -func hardwareFixture() ([]corev1.Node, []corev1.Pod, []resourcev1.ResourceSlice) { - nodes := []corev1.Node{} - for _, name := range []string{"cpu-node", "tt-node"} { - nodes = append(nodes, corev1.Node{ObjectMeta: metav1.ObjectMeta{Name: name}, Status: corev1.NodeStatus{ - Conditions: []corev1.NodeCondition{{Type: corev1.NodeReady, Status: corev1.ConditionTrue}}, - Capacity: corev1.ResourceList{"nvidia.com/gpu": resource.MustParse("2")}, - Allocatable: corev1.ResourceList{"nvidia.com/gpu": resource.MustParse("2")}, - }}) - } - pods := []corev1.Pod{} - for _, driver := range []struct{ node, name string }{{"cpu-node", "nvidia-device-plugin-daemonset"}, {"tt-node", "tt-dra-driver"}} { - pods = append(pods, corev1.Pod{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"app.kubernetes.io/name": driver.name}}, - Spec: corev1.PodSpec{NodeName: driver.node}, Status: corev1.PodStatus{Conditions: []corev1.PodCondition{{Type: corev1.PodReady, Status: corev1.ConditionTrue}}}}) - } - slice := resourcev1.ResourceSlice{Spec: resourcev1.ResourceSliceSpec{Driver: "tenstorrent.com", NodeName: ptr("tt-node"), Pool: resourcev1.ResourcePool{Name: "tt-node", Generation: 1, ResourceSliceCount: 1}}} - for _, name := range []string{"tt-0", "tt-1", "tt-2", "tt-3"} { - slice.Spec.Devices = append(slice.Spec.Devices, resourcev1.Device{Name: name, Attributes: map[resourcev1.QualifiedName]resourcev1.DeviceAttribute{ - "boardName": {StringValue: ptr("n300")}, "chipCount": {IntValue: ptr(int64(2))}, - }}) - } - return nodes, pods, []resourcev1.ResourceSlice{slice} -} - -func gpuContainer(count string) corev1.Container { - return corev1.Container{Resources: corev1.ResourceRequirements{Requests: corev1.ResourceList{"nvidia.com/gpu": resource.MustParse(count)}}} -} - -func TestHardwareCountsExternalScheduledPodsAndDRAReservations(t *testing.T) { - e := testExecutor() - nodes, pods, slices := hardwareFixture() - for _, state := range []struct { - node string - phase corev1.PodPhase - }{{"cpu-node", corev1.PodPending}, {"", corev1.PodPending}, {"cpu-node", corev1.PodSucceeded}} { - pods = append(pods, corev1.Pod{ObjectMeta: metav1.ObjectMeta{Namespace: "another-team"}, Spec: corev1.PodSpec{NodeName: state.node, Containers: []corev1.Container{gpuContainer("1")}}, Status: corev1.PodStatus{Phase: state.phase}}) - } - claim := resourcev1.ResourceClaim{ObjectMeta: metav1.ObjectMeta{Namespace: "another-team"}, Status: resourcev1.ResourceClaimStatus{Allocation: &resourcev1.AllocationResult{ - Devices: resourcev1.DeviceAllocationResult{Results: []resourcev1.DeviceRequestAllocationResult{{Driver: "tenstorrent.com", Pool: "tt-node", Device: "tt-0"}}}, - }}} - // An allocation reserves the board even before its pod starts; duplicate - // references must not count the same physical device twice. - snapshot := e.hardwareFrom(nodes, pods, []resourcev1.ResourceClaim{claim, claim}, slices) - if p := snapshot.Pools[0]; p.Total != 2 || p.Allocated != 1 || p.Available == nil || *p.Available != 1 { - t.Fatalf("bad GPU accounting: %+v", p) - } - if p := snapshot.Pools[1]; p.Total != 4 || p.Allocated != 1 || p.Available == nil || *p.Available != 3 || p.ChipsPerDevice != 2 { - t.Fatalf("bad TT accounting: %+v", p) - } -} - -func TestHardwareDoesNotAdvertiseOfflineOrIncompleteInventory(t *testing.T) { - e := testExecutor() - nodes, pods, slices := hardwareFixture() - nodes[0].Status.Conditions[0].Status = corev1.ConditionFalse - slices[0].Spec.Pool.Generation = 2 - slices[0].Spec.Pool.ResourceSliceCount = 2 - snapshot := e.hardwareFrom(nodes, pods, nil, slices) - if p := snapshot.Pools[0]; p.Ready || *p.Available != 0 { - t.Fatalf("offline node appeared available: %+v", p) - } - if p := snapshot.Pools[1]; p.Ready || p.Available != nil { - t.Fatalf("partial inventory appeared available: %+v", p) - } - nodes[0].Status.Conditions[0].Status = corev1.ConditionTrue - nodes[0].Spec.Unschedulable = true - snapshot = e.hardwareFrom(nodes, pods, nil, nil) - if snapshot.Pools[0].Ready { - t.Fatal("cordoned node appeared schedulable") - } -} - -func TestGPURequestIncludesInitPeakAndSidecars(t *testing.T) { - app, sidecar, init := gpuContainer("1"), gpuContainer("1"), gpuContainer("3") - sidecar.RestartPolicy = ptr(corev1.ContainerRestartPolicyAlways) - pod := corev1.Pod{Spec: corev1.PodSpec{NodeName: "cpu-node", Containers: []corev1.Container{app}, InitContainers: []corev1.Container{sidecar, init}}} - if n := podGPURequest(pod); n != 4 { - t.Fatalf("init peak reservation = %d, want 4", n) - } -} - -func TestHardwarePermissionFailureIsAnError(t *testing.T) { - e := testExecutor() - e.client.(*fake.Clientset).PrependReactor("list", "nodes", func(ktesting.Action) (bool, runtime.Object, error) { return true, nil, errors.New("forbidden") }) - app := NewKubernetesApp(e, slog.New(slog.NewTextHandler(io.Discard, nil))) - response := httptest.NewRecorder() - app.httpServer.Handler.ServeHTTP(response, httptest.NewRequest("GET", "/hardware", nil)) - if response.Code != 503 || !strings.Contains(response.Body.String(), "forbidden") { - t.Fatalf("invented availability after failed discovery: %d %s", response.Code, response.Body.String()) - } -} diff --git a/src/http.go b/src/http.go deleted file mode 100644 index 18e7e9c..0000000 --- a/src/http.go +++ /dev/null @@ -1,35 +0,0 @@ -package main - -import ( - "encoding/json" - "errors" - "net/http" - "syscall" - - apierrors "k8s.io/apimachinery/pkg/api/errors" -) - -func writeJSON(w http.ResponseWriter, code int, value interface{}) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(code) - _ = json.NewEncoder(w).Encode(value) -} - -func executorError(w http.ResponseWriter, err error) { - code := http.StatusBadGateway - var invalid *submissionError - if errors.As(err, &invalid) { - code = http.StatusBadRequest - } - if apierrors.IsNotFound(err) { - code = http.StatusNotFound - } - var denied *permissionError - if errors.As(err, &denied) { - code = http.StatusForbidden - } - if errors.Is(err, syscall.ENOSPC) || errors.Is(err, syscall.EDQUOT) { - code = 507 - } - writeJSON(w, code, map[string]string{"error": err.Error()}) -} diff --git a/src/images.go b/src/images.go deleted file mode 100644 index cd9da95..0000000 --- a/src/images.go +++ /dev/null @@ -1,59 +0,0 @@ -package main - -import ( - "net/http" - "sort" -) - -type ImageOption struct { - Reference string `json:"reference"` - Accelerators []string `json:"accelerators"` -} - -type ComputeProfile struct { - Accelerator string `json:"accelerator"` - DefaultImage string `json:"default_image"` - DeviceUnit string `json:"device_unit"` - MaxDevices int `json:"max_devices"` - Note string `json:"note,omitempty"` -} - -type ImageCatalog struct { - Images []ImageOption `json:"images"` - Profiles []ComputeProfile `json:"profiles"` - Registries []string `json:"registries,omitempty"` - SelfService bool `json:"self_service"` - TeamsEnabled bool `json:"teams_enabled"` -} - -// Accelerator lists describe server policy, not a guarantee that an image's -// code supports that hardware. Team TT jobs can select a compatible container -// runtime or the tested read-only host runtime profile. -func (e *KubernetesExecutor) imageCatalog() ImageCatalog { - catalog := ImageCatalog{Images: []ImageOption{}, Profiles: []ComputeProfile{ - {Accelerator: "cpu", DefaultImage: cpuImage, DeviceUnit: "none", MaxDevices: 0}, - {Accelerator: "nvidia", DefaultImage: nvidiaImage, DeviceUnit: "GPU", MaxDevices: 2}, - {Accelerator: "tenstorrent", DefaultImage: ttImage, DeviceUnit: "board", MaxDevices: 4, - Note: "One n300 board has two chips. This pilot uses the tested image and installed host TT runtime."}, - }} - for ref := range e.allowedImages { - accelerators := []string{"cpu", "nvidia"} - if ref == ttImage { - accelerators = append(accelerators, "tenstorrent") - } - catalog.Images = append(catalog.Images, ImageOption{ref, accelerators}) - } - sort.Slice(catalog.Images, func(i, j int) bool { return catalog.Images[i].Reference < catalog.Images[j].Reference }) - return catalog -} - -func (a *App) images(w http.ResponseWriter, r *http.Request) { - e := a.requestExecutor(r) - catalog := e.imageCatalog() - catalog.TeamsEnabled = a.teams != nil - if e.team != nil { - catalog.SelfService = true - catalog.Registries = e.team.Team.Policy.Registries - } - writeJSON(w, http.StatusOK, catalog) -} diff --git a/src/images_test.go b/src/images_test.go deleted file mode 100644 index abba9d5..0000000 --- a/src/images_test.go +++ /dev/null @@ -1,43 +0,0 @@ -package main - -import "testing" - -func TestCustomImagePreservesEntrypointAndWorkingDirectory(t *testing.T) { - e := testExecutor() - image := "registry.example/training:v1" - e.allowedImages[image] = true - req, err := e.normalize(CreateJobRequest{Image: image, Args: []string{"--epochs", "10"}}) - if err != nil { - t.Fatal(err) - } - job := e.buildJob("image-test", req) - c := job.Spec.Template.Spec.Containers[0] - if len(c.Command) != 0 || c.WorkingDir != "" || len(c.Args) != 2 { - t.Fatalf("overrode image defaults: %+v", c) - } - req.WorkingDirectory = "/app" - if c := e.buildJob("override-test", req).Spec.Template.Spec.Containers[0]; c.WorkingDir != "/app" { - t.Fatal("explicit working directory ignored") - } - if _, err := e.normalize(CreateJobRequest{Image: image, WorkingDirectory: "relative/path"}); err == nil { - t.Fatal("accepted relative working directory") - } - if _, err := e.normalize(CreateJobRequest{}); err == nil { - t.Fatal("empty submission silently ran a default image") - } -} - -func TestTenstorrentCustomImageRestrictionIsInCatalogAndValidation(t *testing.T) { - e := testExecutor() - e.allowedImages["custom/tt:v1"] = true - if _, err := e.normalize(CreateJobRequest{Image: "custom/tt:v1", Accelerator: "tenstorrent"}); err == nil { - t.Fatal("accepted incompatible TT image") - } - for _, image := range e.imageCatalog().Images { - for _, accelerator := range image.Accelerators { - if accelerator == "tenstorrent" && image.Reference != ttImage { - t.Fatal("catalog offers incompatible TT image") - } - } - } -} diff --git a/src/job_states.go b/src/job_states.go deleted file mode 100644 index c7c77fc..0000000 --- a/src/job_states.go +++ /dev/null @@ -1,49 +0,0 @@ -package main - -import ( - "context" - - corev1 "k8s.io/api/core/v1" - resourcev1 "k8s.io/api/resource/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -// A list response reads each collection once. History polling must not issue -// one pod/event/claim request per retained job (the pilot retains all jobs). -type jobStateData struct { - pods map[string][]corev1.Pod - events []corev1.Event - claims map[string]resourcev1.ResourceClaim -} - -func (e *KubernetesExecutor) loadJobState(ctx context.Context) (*jobStateData, error) { - pods, err := e.client.CoreV1().Pods(e.namespace).List(ctx, metav1.ListOptions{LabelSelector: managedLabel + "=mist,mist.io/owner=" + e.owner}) - if err != nil { - return nil, err - } - data := &jobStateData{pods: map[string][]corev1.Pod{}, claims: map[string]resourcev1.ResourceClaim{}} - needEvents, needClaims := false, false - for _, pod := range pods.Items { - name := pod.Labels["batch.kubernetes.io/job-name"] - data.pods[name] = append(data.pods[name], pod) - needEvents = needEvents || (pod.Status.Phase == corev1.PodPending && pod.Spec.NodeName == "") - needClaims = needClaims || len(pod.Status.ResourceClaimStatuses) > 0 - } - if needEvents { - events, err := e.client.CoreV1().Events(e.namespace).List(ctx, metav1.ListOptions{}) - if err != nil { - return nil, err - } - data.events = events.Items - } - if needClaims { - claims, err := e.client.ResourceV1().ResourceClaims(e.namespace).List(ctx, metav1.ListOptions{}) - if err != nil { - return nil, err - } - for _, claim := range claims.Items { - data.claims[claim.Name] = claim - } - } - return data, nil -} diff --git a/src/job_types.go b/src/job_types.go deleted file mode 100644 index e44b061..0000000 --- a/src/job_types.go +++ /dev/null @@ -1,84 +0,0 @@ -package main - -import "time" - -type JobState string - -const ( - JobStateScheduled JobState = "Scheduled" - JobStateInProgress JobState = "InProgress" - JobStateSuccess JobState = "Success" - JobStateFailure JobState = "Failure" - JobStateCancelled JobState = "Cancelled" -) - -type Job struct { - ID string `json:"id"` - Type string `json:"type"` - Payload map[string]interface{} `json:"payload"` - Retries int `json:"retries"` - Created time.Time `json:"created"` - RequiredGPU string `json:"required_gpu,omitempty"` - JobState JobState `json:"job_state"` - ConsumerID *string `json:"consumer_id,omitempty"` - TimeAssigned *time.Time `json:"time_assigned,omitempty"` - TimeStarted *time.Time `json:"time_started,omitempty"` - TimeCompleted *time.Time `json:"time_completed,omitempty"` - Result map[string]interface{} `json:"result,omitempty"` - Error *string `json:"error,omitempty"` -} - -type CreateJobRequest struct { - Type string `json:"type"` - DatasetID string `json:"dataset_id,omitempty"` - StorageScope string `json:"storage_scope,omitempty"` - TTRuntime string `json:"tt_runtime,omitempty"` - Payload map[string]interface{} `json:"payload"` - RequiredGPU string `json:"gpu,omitempty"` - Name string `json:"name,omitempty"` - Image string `json:"image,omitempty"` - Command []string `json:"command,omitempty"` - Args []string `json:"args,omitempty"` - WorkingDirectory string `json:"working_directory,omitempty"` - Script string `json:"script,omitempty"` - ScriptName string `json:"script_name,omitempty"` - Env map[string]string `json:"env,omitempty"` - CPU string `json:"cpu,omitempty"` - Memory string `json:"memory,omitempty"` - Accelerator string `json:"accelerator,omitempty"` - DeviceCount int `json:"device_count,omitempty"` - TimeoutSeconds int64 `json:"timeout_seconds,omitempty"` -} - -type KubernetesJobStatus struct { - Job - Name string `json:"name"` - Image string `json:"image"` - Accelerator string `json:"accelerator"` - DeviceCount int `json:"device_count"` - CPU string `json:"cpu"` - Memory string `json:"memory"` - Owner string `json:"owner"` - Node string `json:"node,omitempty"` - Pod string `json:"pod,omitempty"` - ExitCode *int32 `json:"exit_code,omitempty"` - Message string `json:"message,omitempty"` - Devices []AllocatedDevice `json:"devices,omitempty"` - CheckpointDirectory string `json:"checkpoint_directory"` - OutputDirectory string `json:"output_directory"` - WorkingDirectory string `json:"working_directory,omitempty"` - TimeoutSeconds int64 `json:"timeout_seconds"` - DatasetID string `json:"dataset_id,omitempty"` - TeamID string `json:"team_id,omitempty"` - CreatorID string `json:"creator_id,omitempty"` - CreatorName string `json:"creator_name,omitempty"` - StorageScope string `json:"storage_scope,omitempty"` - QueuePosition int `json:"queue_position,omitempty"` - CanCancel *bool `json:"can_cancel,omitempty"` -} - -type AllocatedDevice struct { - Driver string `json:"driver"` - Pool string `json:"pool"` - Device string `json:"device"` -} diff --git a/src/jobs.md b/src/jobs.md deleted file mode 100644 index df373c2..0000000 --- a/src/jobs.md +++ /dev/null @@ -1,54 +0,0 @@ -# Job lifecycle and API - -Current authenticated team execution. See [the operating guide](../docs/content/department-rollout.md#developer-api-contract) -for the complete API and [architecture](../docs/content/architecture.md) for source layout. - -## Lifecycle - -1. Authenticate the Better Auth session and select an active team. Validate image - registry, workload, device count, CPU/memory/runtime and writing authority. -2. Create a suspended `mist-...` Job in `mist-team-` and persist its - submission/creator metadata. Scripts use a Job-owned ConfigMap. -3. A single durable controller applies fair team admission and resource limits. - Kubernetes then schedules the pod using whole GPU resources or TT DRA claims. -4. Read actual Job/pod status, placement, logs and exit code. API states remain - `Scheduled`, `InProgress`, `Success`, `Failure` and `Cancelled`. Scheduled - includes queued/admitted/pending work; inspect the message/queue metadata. -5. Cancellation/revocation suspends the Job and removes pods, freeing allocated - devices. It preserves outputs/history; explicit cancellation saves recent logs. -6. Dataset mounts are read-only `/inputs`. Outputs are the job's own model-pool - directory at `/outputs` and `/checkpoints/`. Finished outputs remain. - -A team's timeout starts after admission, not while waiting in the Mist queue. -The controller is single-replica. Jobs have no automatic retry or TTL. Retained -pods/kubelet retention determine normal log availability; cancellation keeps the -last 32 KiB. Workloads receive no service-account token or arbitrary host mounts. - -Historical account jobs and original local PVCs remain available through the -explicit legacy workspace. New deployed submissions/uploads require a team. -The former Redis/Docker executor and fake supervisor endpoints are retired. - -## Job endpoints - -Browser/CLI calls use the portal prefix `/api`; Nginx strips it before Go. -Team work sends `X-Mist-Team` with a real team ID. Go derives identity and authority -from the session, not request ownership fields. - -| Method/path after `/api` | Action | -|---|---| -| `POST /jobs` | Submit; **201** with `job_id` and job metadata | -| `GET /jobs` | List permitted team jobs, or selected legacy account history | -| `GET /jobs/{id}` | Actual state, placement, resources, exit and active TT allocation | -| `GET /jobs/{id}/logs` | Recent stdout/stderr JSON, up to 64 KiB | -| `POST /jobs/{id}/cancel` | Authorized cancellation | -| `DELETE /jobs/{id}` | Cancellation alias, preserves history | -| `GET /jobs/{id}/files` | Output listing; `/files/download?path=...` downloads | -| `GET /jobs/status?id=...` | Compatibility status route using the same scoped executor | -| `GET /hardware`, `/images` | Capacity and image/runtime policy profiles | -| `GET /healthz` | Kubernetes Jobs API availability | - -Scripts are limited to 32 KiB; request JSON is bounded and rejects unknown fields -or multiple objects. An explicit image with no command/arguments preserves its -ENTRYPOINT/CMD. CPU/NVIDIA preserve image WORKDIR without an override. TT requires -compatible libraries and an explicit host/container runtime choice when applicable. -Custom tagged images are self-service within allowed registry rules. diff --git a/src/jobs_http.go b/src/jobs_http.go deleted file mode 100644 index 6044ce9..0000000 --- a/src/jobs_http.go +++ /dev/null @@ -1,109 +0,0 @@ -package main - -import ( - "encoding/json" - "io" - "net/http" - "strings" -) - -func (a *App) kubernetesJobs(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodPost: - var req CreateJobRequest - decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 128*1024)) - decoder.DisallowUnknownFields() - if err := decoder.Decode(&req); err != nil { - writeJSON(w, 400, map[string]string{"error": "invalid submission: " + err.Error()}) - return - } - if err := decoder.Decode(new(interface{})); err != io.EOF { - writeJSON(w, 400, map[string]string{"error": "request must contain one JSON object"}) - return - } - status, err := a.requestExecutor(r).submit(r.Context(), req) - if err != nil { - executorError(w, err) - return - } - writeJSON(w, http.StatusCreated, map[string]interface{}{"job_id": status.ID, "job": status}) - case http.MethodGet: - jobs, err := a.requestExecutor(r).list(r.Context()) - if err != nil { - executorError(w, err) - return - } - writeJSON(w, 200, map[string]interface{}{"jobs": jobs, "count": len(jobs)}) - default: - w.Header().Set("Allow", "GET, POST") - writeJSON(w, 405, map[string]string{"error": "method not allowed"}) - } -} - -func (a *App) kubernetesJob(w http.ResponseWriter, r *http.Request) { - if strings.Contains(r.URL.Path, "/files") { - a.jobFiles(w, r) - return - } - parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/jobs/"), "/") - if len(parts) == 0 || parts[0] == "" || len(parts) > 2 { - http.NotFound(w, r) - return - } - id := parts[0] - if len(parts) == 2 && parts[1] == "logs" && r.Method == http.MethodGet { - logs, err := a.requestExecutor(r).logs(r.Context(), id) - if err != nil { - executorError(w, err) - return - } - writeJSON(w, 200, map[string]string{"job_id": id, "logs": logs}) - return - } - if (len(parts) == 2 && parts[1] == "cancel" && r.Method == http.MethodPost) || (len(parts) == 1 && r.Method == http.MethodDelete) { - status, err := a.requestExecutor(r).cancel(r.Context(), id) - if err != nil { - executorError(w, err) - return - } - writeJSON(w, 200, status) - return - } - if len(parts) == 1 && r.Method == http.MethodGet { - status, err := a.requestExecutor(r).get(r.Context(), id) - if err != nil { - executorError(w, err) - return - } - writeJSON(w, 200, status) - return - } - writeJSON(w, 405, map[string]string{"error": "method not allowed"}) -} - -func (a *App) getJobStatus(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - jobID := r.URL.Query().Get("id") - if jobID == "" { - // Try to get from path if query param not provided - path := strings.TrimPrefix(r.URL.Path, "/jobs/status/") - if path != "" && path != "/jobs/status" { - jobID = path - } - } - - if jobID == "" { - http.Error(w, "Job ID is required", http.StatusBadRequest) - return - } - status, err := a.requestExecutor(r).get(r.Context(), jobID) - if err != nil { - executorError(w, err) - return - } - writeJSON(w, 200, status) -} diff --git a/src/kubernetes.go b/src/kubernetes.go deleted file mode 100644 index 7130dbe..0000000 --- a/src/kubernetes.go +++ /dev/null @@ -1,815 +0,0 @@ -package main - -import ( - "context" - "crypto/rand" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "io" - "os" - "path/filepath" - "sort" - "strconv" - "strings" - "time" - - batchv1 "k8s.io/api/batch/v1" - corev1 "k8s.io/api/core/v1" - apierrors "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/apimachinery/pkg/api/resource" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/util/validation" - "k8s.io/client-go/kubernetes" - "k8s.io/client-go/rest" - "k8s.io/client-go/tools/clientcmd" - "k8s.io/client-go/util/retry" -) - -const ( - managedLabel = "app.kubernetes.io/managed-by" - requestAnnotation = "mist.io/submission" - cancelAnnotation = "mist.io/cancelled-at" - cancelLogsAnnotation = "mist.io/cancelled-logs" - ttImage = "ghcr.io/tenstorrent/tt-metal/tt-metalium/ubuntu-22.04-dev-amd64:6b765c0bc2b40ea02566d169d7b0ee902377ba04" - nvidiaImage = "pytorch/pytorch:2.5.1-cuda12.4-cudnn9-runtime" - cpuImage = "python:3.10-slim" - ttMetal = "/home/utmist-tt/TT/tt-metal" - ttPython = ttMetal + "/python_env/bin/python" - maxLogBytes int64 = 64 * 1024 -) - -type KubernetesExecutor struct { - client kubernetes.Interface - namespace string - owner string - allowedImages map[string]bool - pullSecrets []corev1.LocalObjectReference - cpuNode, ttNode string - storage *SharedStorage - sharedPVC string - team *TeamAccess - inputPVC string - inputSubPath string - outputSubPath string -} - -func NewKubernetesExecutor() (*KubernetesExecutor, error) { - var config *rest.Config - var err error - if path := os.Getenv("KUBECONFIG"); path != "" { - config, err = clientcmd.NewNonInteractiveDeferredLoadingClientConfig(clientcmd.NewDefaultClientConfigLoadingRules(), &clientcmd.ConfigOverrides{}).ClientConfig() - } else { - config, err = rest.InClusterConfig() - if err != nil { - config, err = clientcmd.NewNonInteractiveDeferredLoadingClientConfig(clientcmd.NewDefaultClientConfigLoadingRules(), &clientcmd.ConfigOverrides{}).ClientConfig() - } - } - if err != nil { - return nil, fmt.Errorf("load Kubernetes credentials: %w", err) - } - config.Timeout = 20 * time.Second - config.QPS = 20 - config.Burst = 40 - client, err := kubernetes.NewForConfig(config) - if err != nil { - return nil, err - } - e := &KubernetesExecutor{client: client, namespace: envOr("MIST_NAMESPACE", "mist"), - owner: envOr("MIST_PILOT_OWNER", "pilot"), cpuNode: envOr("MIST_CPU_NODE", "utmist-z1opa08"), - ttNode: envOr("MIST_TT_NODE", "utmist-tt"), allowedImages: map[string]bool{cpuImage: true, ttImage: true, - nvidiaImage: true, "busybox:1.36": true, "alpine:3.16.3": true, - "nvcr.io/nvidia/k8s/cuda-sample:vectoradd-cuda12.5.0": true}} - if images := os.Getenv("MIST_ALLOWED_IMAGES"); images != "" { - e.allowedImages = map[string]bool{} - for _, image := range strings.Split(images, ",") { - e.allowedImages[strings.TrimSpace(image)] = true - } - } - if len(validation.IsValidLabelValue(e.owner)) != 0 { - return nil, errors.New("MIST_PILOT_OWNER must be a valid label value") - } - for _, name := range strings.Split(os.Getenv("MIST_IMAGE_PULL_SECRETS"), ",") { - name = strings.TrimSpace(name) - if name == "" { - continue - } - if len(validation.IsDNS1123Subdomain(name)) != 0 { - return nil, errors.New("invalid MIST_IMAGE_PULL_SECRETS name") - } - e.pullSecrets = append(e.pullSecrets, corev1.LocalObjectReference{Name: name}) - } - if root := os.Getenv("MIST_STORAGE_ROOT"); root != "" { - e.storage, err = NewSharedStorage(root) - if err != nil { - return nil, err - } - e.sharedPVC = envOr("MIST_SHARED_PVC", "mist-shared-jobs") - } - return e, nil -} - -func envOr(key, fallback string) string { - if value := os.Getenv(key); value != "" { - return value - } - return fallback -} -func ptr[T any](value T) *T { return &value } - -func (e *KubernetesExecutor) normalize(req CreateJobRequest) (CreateJobRequest, error) { - explicitImage := req.Image != "" - if len(req.Payload) != 0 { - return req, errors.New("use image/command/args or script instead of the legacy payload") - } - if req.Accelerator == "" { - req.Accelerator = req.RequiredGPU - } - switch strings.ToLower(req.Accelerator) { - case "", "cpu": - req.Accelerator = "cpu" - case "nvidia", "cuda": - req.Accelerator = "nvidia" - case "tt", "tenstorrent": - req.Accelerator = "tenstorrent" - default: - return req, errors.New("accelerator must be cpu, nvidia, or tenstorrent") - } - if req.RequiredGPU != "" { - alias := strings.ToLower(req.RequiredGPU) - if alias == "tt" { - alias = "tenstorrent" - } - if alias == "cuda" { - alias = "nvidia" - } - if alias != req.Accelerator { - return req, errors.New("gpu and accelerator disagree") - } - } - req.RequiredGPU = "" - if req.Type == "" { - req.Type = "command" - } - if req.Type != "command" && req.Type != "training-smoke" { - return req, errors.New("type must be command or training-smoke") - } - if req.Accelerator == "cpu" { - if req.DeviceCount != 0 { - return req, errors.New("CPU jobs cannot request accelerator devices") - } - } else { - if req.DeviceCount == 0 { - req.DeviceCount = 1 - } - maximum := 2 - if req.Accelerator == "tenstorrent" { - maximum = 4 - } - if req.DeviceCount < 1 || req.DeviceCount > maximum { - return req, fmt.Errorf("device_count must be between 1 and %d", maximum) - } - } - if req.Image == "" { - switch req.Accelerator { - case "tenstorrent": - req.Image = ttImage - case "nvidia": - req.Image = nvidiaImage - default: - req.Image = cpuImage - } - } - if e.team != nil { - if err := validateImageReference(req.Image, e.team.Team.Policy.Registries, e.allowedImages); err != nil { - return req, err - } - } else if !e.allowedImages[req.Image] { - return req, errors.New("image is not in the server's approved image list") - } - if e.team == nil && req.Accelerator == "tenstorrent" && req.Image != ttImage { - return req, errors.New("this QuietBox pilot requires the configured TT runtime image") - } - if req.Type == "training-smoke" && (req.Accelerator == "cpu" || req.Script != "" || len(req.Command) != 0) { - return req, errors.New("training-smoke requires NVIDIA or Tenstorrent and supplies its own command") - } - if req.Script != "" { - if len(req.Command) != 0 { - return req, errors.New("provide script or command, not both") - } - if len(req.Script) > 32*1024 { - return req, errors.New("script exceeds 32 KiB") - } - if req.ScriptName == "" { - req.ScriptName = "script.py" - } - if filepath.Base(req.ScriptName) != req.ScriptName || strings.ContainsAny(req.ScriptName, "\\\x00") || len(req.ScriptName) > 63 || - (filepath.Ext(req.ScriptName) != ".py" && filepath.Ext(req.ScriptName) != ".sh") { - return req, errors.New("script_name must be a .py or .sh filename without directories") - } - } else if req.Type == "command" && len(req.Command) == 0 && !explicitImage { - return req, errors.New("provide an image to run its default entrypoint, a command, or a script") - } - if len(req.Command) != 0 && req.Command[0] == "" { - return req, errors.New("command executable cannot be empty") - } - if req.WorkingDirectory != "" && (!filepath.IsAbs(req.WorkingDirectory) || len(req.WorkingDirectory) > 1024 || strings.ContainsRune(req.WorkingDirectory, 0)) { - return req, errors.New("working_directory must be an absolute container path") - } - if len(req.Command)+len(req.Args) > 64 { - return req, errors.New("too many command arguments") - } - for _, value := range append(append([]string{}, req.Command...), req.Args...) { - if len(value) > 8192 || strings.ContainsRune(value, 0) { - return req, errors.New("invalid command argument") - } - } - if len(req.Env) > 32 { - return req, errors.New("too many environment variables") - } - for key, value := range req.Env { - if len(validation.IsEnvVarName(key)) != 0 || len(value) > 4096 || strings.ContainsRune(value, 0) { - return req, errors.New("invalid environment variable") - } - if key == "MIST_JOB_ID" || key == "MIST_CHECKPOINT_DIR" || key == "MIST_OUTPUT_DIR" || key == "MIST_INPUT_DIR" { - return req, fmt.Errorf("%s is managed by Mist", key) - } - if req.Accelerator == "tenstorrent" && (key == "TT_METAL_VISIBLE_DEVICES" || ((e.team == nil || (req.Image == ttImage && req.TTRuntime != "container")) && (strings.HasPrefix(key, "TT_METAL_") || key == "PYTHONPATH" || key == "LD_LIBRARY_PATH"))) { - return req, fmt.Errorf("%s is managed by the TT runtime profile", key) - } - } - if len(req.Name) > 128 { - return req, errors.New("name exceeds 128 characters") - } - if req.TimeoutSeconds == 0 { - req.TimeoutSeconds = 600 - } - if req.TimeoutSeconds < 10 || req.TimeoutSeconds > 86400 { - return req, errors.New("timeout_seconds must be between 10 and 86400") - } - if req.CPU == "" { - req.CPU = "250m" - if req.Accelerator != "cpu" { - req.CPU = "2" - } - if req.Accelerator == "tenstorrent" { - req.CPU = strconv.Itoa(req.DeviceCount * 2) - } - } - if req.Memory == "" { - req.Memory = "256Mi" - if req.Accelerator == "nvidia" { - req.Memory = "2Gi" - } - if req.Accelerator == "tenstorrent" { - req.Memory = strconv.Itoa(req.DeviceCount*4) + "Gi" - } - } - cpu, err := resource.ParseQuantity(req.CPU) - if err != nil || cpu.Sign() <= 0 || cpu.Cmp(resource.MustParse("8")) > 0 { - return req, errors.New("cpu must be a Kubernetes quantity greater than zero and at most 8") - } - memory, err := resource.ParseQuantity(req.Memory) - if err != nil || memory.Cmp(resource.MustParse("64Mi")) < 0 || memory.Cmp(resource.MustParse("32Gi")) > 0 { - return req, errors.New("memory must be between 64Mi and 32Gi") - } - if req.Accelerator == "tenstorrent" && memory.Cmp(resource.MustParse("2Gi")) < 0 { - return req, errors.New("Tenstorrent runtime requires at least 2Gi memory") - } - if e.team != nil { - if err := validateTeamRequest(e.team.Team, e.team.Member, &req); err != nil { - return req, err - } - } - return req, nil -} - -func (e *KubernetesExecutor) buildJob(id string, req CreateJobRequest) *batchv1.Job { - requestJSON, _ := json.Marshal(req) - labels := map[string]string{managedLabel: "mist", "mist.io/owner": e.owner} - if e.team != nil { - labels["mist.io/team"] = e.team.Team.ID - labels["mist.io/creator"] = memberOwner(e.team.Member.ID) - } - resources := corev1.ResourceList{corev1.ResourceCPU: resource.MustParse(req.CPU), corev1.ResourceMemory: resource.MustParse(req.Memory)} - workingDir := req.WorkingDirectory - if workingDir == "" && (req.Script != "" || req.Type == "training-smoke" || req.Accelerator == "tenstorrent") { - workingDir = "/tmp" - } - container := corev1.Container{Name: "workload", Image: req.Image, ImagePullPolicy: corev1.PullIfNotPresent, - Command: req.Command, Args: req.Args, WorkingDir: workingDir, - SecurityContext: &corev1.SecurityContext{Privileged: ptr(false), AllowPrivilegeEscalation: ptr(false), Capabilities: &corev1.Capabilities{Drop: []corev1.Capability{"ALL"}}}, - Resources: corev1.ResourceRequirements{Requests: resources.DeepCopy(), Limits: resources.DeepCopy()}, - VolumeMounts: []corev1.VolumeMount{{Name: "checkpoints", MountPath: "/checkpoints/" + id, SubPath: id}, {Name: "checkpoints", MountPath: "/outputs", SubPath: id}}, - Env: []corev1.EnvVar{{Name: "MIST_JOB_ID", Value: id}, {Name: "MIST_CHECKPOINT_DIR", Value: "/checkpoints/" + id}, {Name: "MIST_OUTPUT_DIR", Value: "/outputs"}}} - keys := []string{} - for key := range req.Env { - keys = append(keys, key) - } - sort.Strings(keys) - for _, key := range keys { - container.Env = append(container.Env, corev1.EnvVar{Name: key, Value: req.Env[key]}) - } - pvc := "mist-cpu-checkpoints" - pod := corev1.PodSpec{RestartPolicy: corev1.RestartPolicyNever, AutomountServiceAccountToken: ptr(false), - ImagePullSecrets: e.pullSecrets, - TerminationGracePeriodSeconds: ptr(int64(5)), NodeSelector: map[string]string{"kubernetes.io/hostname": e.cpuNode}} - if e.team != nil { - pod.SecurityContext = &corev1.PodSecurityContext{SeccompProfile: &corev1.SeccompProfile{Type: corev1.SeccompProfileTypeRuntimeDefault}} - } - if req.Accelerator == "nvidia" { - pvc = "training-nvidia-checkpoints" - count := resource.MustParse(strconv.Itoa(req.DeviceCount)) - container.Resources.Requests["nvidia.com/gpu"] = count - container.Resources.Limits["nvidia.com/gpu"] = count - } - if req.Accelerator == "tenstorrent" { - pvc = "training-tenstorrent-checkpoints" - pod.NodeSelector = map[string]string{"kubernetes.io/hostname": e.ttNode} - pod.ResourceClaims = []corev1.PodResourceClaim{{Name: "boards", ResourceClaimTemplateName: ptr(fmt.Sprintf("mist-tenstorrent-%d-boards", req.DeviceCount))}} - container.Resources.Claims = []corev1.ResourceClaim{{Name: "boards"}} - hugepages := resource.MustParse(fmt.Sprintf("%dGi", req.DeviceCount*2)) - container.Resources.Requests["hugepages-1Gi"] = hugepages - container.Resources.Limits["hugepages-1Gi"] = hugepages - if req.TTRuntime != "container" { - container.Env = append(container.Env, corev1.EnvVar{Name: "TT_METAL_HOME", Value: ttMetal}, corev1.EnvVar{Name: "PYTHONPATH", Value: ttMetal}, - corev1.EnvVar{Name: "LD_LIBRARY_PATH", Value: ttMetal + "/build/lib"}, corev1.EnvVar{Name: "TT_METAL_CACHE", Value: "/tmp/tt-metal-cache"}) - for _, host := range []struct{ name, path string }{{"metal", ttMetal}, {"python", "/home/utmist-tt/.local/share/uv/python"}} { - pod.Volumes = append(pod.Volumes, corev1.Volume{Name: host.name, VolumeSource: corev1.VolumeSource{HostPath: &corev1.HostPathVolumeSource{Path: host.path, Type: ptr(corev1.HostPathDirectory)}}}) - container.VolumeMounts = append(container.VolumeMounts, corev1.VolumeMount{Name: host.name, MountPath: host.path, ReadOnly: true}) - } - } - } - pod.Volumes = append(pod.Volumes, corev1.Volume{Name: "checkpoints", VolumeSource: corev1.VolumeSource{PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ClaimName: pvc}}}) - pod.InitContainers = []corev1.Container{prepareOutputs(id)} - if e.storage != nil { - pod.Volumes[len(pod.Volumes)-1].PersistentVolumeClaim.ClaimName = e.sharedPVC - subpath := "jobs/" + id + "/outputs" - if e.outputSubPath != "" { - subpath = e.outputSubPath - } - for i := range container.VolumeMounts { - if container.VolumeMounts[i].Name == "checkpoints" { - container.VolumeMounts[i].SubPath = subpath - } - } - // The API creates directories before submission. Init is restricted to this job's directory. - pod.InitContainers = nil - if req.DatasetID != "" { - volume, subpath := "checkpoints", "datasets/"+req.DatasetID+"/content" - if e.inputSubPath != "" { - subpath = e.inputSubPath - } - if e.inputPVC != "" { - volume, subpath = "input", e.inputSubPath - pod.Volumes = append(pod.Volumes, corev1.Volume{Name: volume, VolumeSource: corev1.VolumeSource{PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ClaimName: e.inputPVC, ReadOnly: true}}}) - } - container.VolumeMounts = append(container.VolumeMounts, corev1.VolumeMount{Name: volume, MountPath: "/inputs", SubPath: subpath, ReadOnly: true}) - container.Env = append(container.Env, corev1.EnvVar{Name: "MIST_INPUT_DIR", Value: "/inputs"}) - } - } - if req.Script != "" || req.Type == "training-smoke" { - configmap, mount := id+"-script", "/mist-script" - python := "python" - if req.Accelerator == "tenstorrent" && req.TTRuntime != "container" { - python = ttPython - } - if req.Type == "training-smoke" { - configmap, mount = "training-smoke-script", "/scripts" - expected := req.DeviceCount - if req.Accelerator == "tenstorrent" { - expected *= 2 - } - container.Command = []string{python, "-u", "/scripts/training_smoke.py", req.Accelerator, - "--expected-devices", strconv.Itoa(expected), "--output", "/checkpoints/" + id} - } else { - container.Command = []string{python, "-u", mount + "/" + req.ScriptName} - if filepath.Ext(req.ScriptName) == ".sh" { - container.Command = []string{"sh", mount + "/" + req.ScriptName} - } - } - pod.Volumes = append(pod.Volumes, corev1.Volume{Name: "script", VolumeSource: corev1.VolumeSource{ConfigMap: &corev1.ConfigMapVolumeSource{LocalObjectReference: corev1.LocalObjectReference{Name: configmap}}}}) - container.VolumeMounts = append(container.VolumeMounts, corev1.VolumeMount{Name: "script", MountPath: mount, ReadOnly: true}) - } - pod.Containers = []corev1.Container{container} - annotations := map[string]string{requestAnnotation: string(requestJSON)} - if e.storage != nil { - annotations["mist.io/storage"] = "shared-v1" - } - if e.team != nil { - annotations["mist.io/creator-id"] = e.team.Member.ID - annotations["mist.io/creator-name"] = e.team.Member.Name - annotations["mist.io/storage-scope"] = req.StorageScope - annotations["mist.io/output-path"] = e.outputSubPath - annotations["mist.io/queued-at"] = time.Now().UTC().Format(time.RFC3339Nano) - if isAdmin(e.team.Member) { - annotations["mist.io/admin-submission"] = "true" - } - } - return &batchv1.Job{ObjectMeta: metav1.ObjectMeta{Name: id, Namespace: e.namespace, Labels: labels, Annotations: annotations}, - Spec: batchv1.JobSpec{BackoffLimit: ptr(int32(0)), ActiveDeadlineSeconds: ptr(req.TimeoutSeconds), - Template: corev1.PodTemplateSpec{ObjectMeta: metav1.ObjectMeta{Labels: labels}, Spec: pod}}} -} - -func (e *KubernetesExecutor) submit(ctx context.Context, request CreateJobRequest) (*KubernetesJobStatus, error) { - if e.team != nil { - return e.team.Service.submitTeam(ctx, e, request) - } - if e.storage != nil { - e.storage.mu.Lock() - defer e.storage.mu.Unlock() - } - req, err := e.normalize(request) - if err != nil { - return nil, &submissionError{err} - } - if req.DatasetID != "" { - if e.storage == nil { - return nil, &submissionError{errors.New("shared datasets are not configured")} - } - if _, err := e.storage.dataset(req.DatasetID, e.owner); err != nil { - return nil, &submissionError{errors.New("dataset unavailable")} - } - } - if req.Accelerator == "tenstorrent" { - if _, err := e.client.ResourceV1().ResourceClaimTemplates(e.namespace).Get(ctx, fmt.Sprintf("mist-tenstorrent-%d-boards", req.DeviceCount), metav1.GetOptions{}); err != nil { - return nil, fmt.Errorf("Tenstorrent claim template unavailable: %w", err) - } - } - if req.Type == "training-smoke" { - if _, err := e.client.CoreV1().ConfigMaps(e.namespace).Get(ctx, "training-smoke-script", metav1.GetOptions{}); err != nil { - return nil, fmt.Errorf("training smoke script unavailable: %w", err) - } - } - random := make([]byte, 12) - if _, err := rand.Read(random); err != nil { - return nil, err - } - id := "mist-" + hex.EncodeToString(random) - if e.storage != nil { - if err := e.storage.prepareJob(id); err != nil { - return nil, err - } - } - jobCreated := false - defer func() { - if e.storage != nil && !jobCreated { - _ = os.RemoveAll(filepath.Join(e.storage.root, "jobs", id)) - } - }() - var script *corev1.ConfigMap - if req.Script != "" { - script, err = e.client.CoreV1().ConfigMaps(e.namespace).Create(ctx, &corev1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{Name: id + "-script", Labels: map[string]string{managedLabel: "mist"}}, Data: map[string]string{req.ScriptName: req.Script}}, metav1.CreateOptions{}) - if err != nil { - return nil, err - } - } - job, err := e.client.BatchV1().Jobs(e.namespace).Create(ctx, e.buildJob(id, req), metav1.CreateOptions{}) - if err != nil { - if script != nil { - _ = e.client.CoreV1().ConfigMaps(e.namespace).Delete(ctx, script.Name, metav1.DeleteOptions{}) - } - return nil, err - } - jobCreated = true - if script != nil { - script.OwnerReferences = []metav1.OwnerReference{*metav1.NewControllerRef(job, batchv1.SchemeGroupVersion.WithKind("Job"))} - if _, err := e.client.CoreV1().ConfigMaps(e.namespace).Update(ctx, script, metav1.UpdateOptions{}); err != nil { - // Roll back submission rather than returning an error for a running orphan. - _ = e.client.BatchV1().Jobs(e.namespace).Delete(ctx, id, metav1.DeleteOptions{PropagationPolicy: ptr(metav1.DeletePropagationForeground)}) - _ = e.client.CoreV1().ConfigMaps(e.namespace).Delete(ctx, script.Name, metav1.DeleteOptions{}) - return nil, fmt.Errorf("attach script ownership: %w", err) - } - } - return e.baseStatus(job) -} - -type submissionError struct{ error } - -func (e *submissionError) Unwrap() error { return e.error } - -func (e *KubernetesExecutor) managedJob(ctx context.Context, id string) (*batchv1.Job, error) { - if len(validation.IsDNS1123Subdomain(id)) != 0 { - return nil, &submissionError{errors.New("invalid job ID")} - } - job, err := e.client.BatchV1().Jobs(e.namespace).Get(ctx, id, metav1.GetOptions{}) - if err == nil && (job.Labels[managedLabel] != "mist" || job.Labels["mist.io/owner"] != e.owner) { - return nil, apierrors.NewNotFound(batchv1.Resource("jobs"), id) - } - return job, err -} - -func (e *KubernetesExecutor) get(ctx context.Context, id string) (*KubernetesJobStatus, error) { - job, err := e.managedJob(ctx, id) - if err != nil { - return nil, err - } - return e.statusFor(ctx, job) -} - -func terminal(job *batchv1.Job) bool { - for _, condition := range job.Status.Conditions { - if condition.Status == corev1.ConditionTrue && (condition.Type == batchv1.JobComplete || condition.Type == batchv1.JobFailed) { - return true - } - } - return false -} - -func (e *KubernetesExecutor) baseStatus(job *batchv1.Job) (*KubernetesJobStatus, error) { - var req CreateJobRequest - if err := json.Unmarshal([]byte(job.Annotations[requestAnnotation]), &req); err != nil { - return nil, fmt.Errorf("invalid stored submission: %w", err) - } - status := &KubernetesJobStatus{Job: Job{ID: job.Name, Type: req.Type, Created: job.CreationTimestamp.Time, - JobState: JobStateScheduled, Payload: map[string]interface{}{"command": req.Command, "args": req.Args}}, Name: req.Name, - Image: req.Image, Accelerator: req.Accelerator, DeviceCount: req.DeviceCount, CPU: req.CPU, Memory: req.Memory, Owner: e.owner, CheckpointDirectory: "/checkpoints/" + job.Name, - WorkingDirectory: req.WorkingDirectory, TimeoutSeconds: req.TimeoutSeconds, DatasetID: req.DatasetID} - status.Owner = job.Labels["mist.io/owner"] - status.TeamID, status.CreatorID, status.CreatorName, status.StorageScope = job.Labels["mist.io/team"], job.Annotations["mist.io/creator-id"], job.Annotations["mist.io/creator-name"], job.Annotations["mist.io/storage-scope"] - if e.team != nil { - status.CanCancel = ptr(isAdmin(e.team.Member) || status.CreatorID == e.team.Member.ID) - } - if job.Annotations["mist.io/queued-at"] != "" && job.Annotations["mist.io/admitted-at"] == "" { - status.Message = "Queued: waiting for a fair turn and available team/machine resources" - } - // Older pilot jobs mounted the shared checkpoint root, before /outputs existed. - for _, container := range job.Spec.Template.Spec.Containers { - if container.Name != "workload" { - continue - } - for _, mount := range container.VolumeMounts { - if mount.MountPath == "/outputs" { - status.OutputDirectory = "/outputs" - } - } - } - if status.Name == "" { - status.Name = job.Name - } - if req.Accelerator != "cpu" { - status.RequiredGPU = req.Accelerator - } - return status, nil -} - -func (e *KubernetesExecutor) statusFor(ctx context.Context, job *batchv1.Job) (*KubernetesJobStatus, error) { - return e.statusWithData(ctx, job, nil) -} - -func (e *KubernetesExecutor) statusWithData(ctx context.Context, job *batchv1.Job, data *jobStateData) (*KubernetesJobStatus, error) { - status, err := e.baseStatus(job) - if err != nil { - return nil, err - } - if job.Status.StartTime != nil { - status.TimeAssigned = &job.Status.StartTime.Time - } - if job.Status.CompletionTime != nil { - status.TimeCompleted = &job.Status.CompletionTime.Time - } - for _, condition := range job.Status.Conditions { - if condition.Status != corev1.ConditionTrue { - continue - } - if condition.Type == batchv1.JobComplete { - status.JobState = JobStateSuccess - } - if condition.Type == batchv1.JobFailed { - status.JobState = JobStateFailure - status.Message = condition.Reason + ": " + condition.Message - status.Error = ptr(status.Message) - status.TimeCompleted = &condition.LastTransitionTime.Time - } - } - pods := &corev1.PodList{} - if data != nil { - pods.Items = data.pods[job.Name] - } else { - pods, err = e.client.CoreV1().Pods(e.namespace).List(ctx, metav1.ListOptions{LabelSelector: "batch.kubernetes.io/job-name=" + job.Name}) - if err != nil { - return nil, err - } - } - sort.Slice(pods.Items, func(i, j int) bool { return pods.Items[i].CreationTimestamp.Before(&pods.Items[j].CreationTimestamp) }) - if len(pods.Items) != 0 { - pod := pods.Items[len(pods.Items)-1] - status.Node, status.Pod = pod.Spec.NodeName, pod.Name - if pod.Status.Phase == corev1.PodRunning && !terminal(job) { - status.JobState = JobStateInProgress - } - for _, init := range pod.Status.InitContainerStatuses { - if init.State.Waiting != nil && !terminal(job) { - status.Message = init.Name + ": " + init.State.Waiting.Reason + ": " + init.State.Waiting.Message - } - if exit := init.State.Terminated; exit != nil && exit.ExitCode != 0 { - status.JobState = JobStateFailure - status.ExitCode = &exit.ExitCode - status.Message = init.Name + " failed: " + exit.Reason - status.Error = ptr(status.Message) - } - } - for _, container := range pod.Status.ContainerStatuses { - if container.Name != "workload" { - continue - } - if container.State.Running != nil { - status.TimeStarted = &container.State.Running.StartedAt.Time - } - if container.State.Terminated != nil { - exit := container.State.Terminated - status.ExitCode = &exit.ExitCode - status.TimeStarted = &exit.StartedAt.Time - status.TimeCompleted = &exit.FinishedAt.Time - if exit.ExitCode != 0 { - status.JobState = JobStateFailure - if status.Message == "" { - status.Message = exit.Reason - } - status.Error = ptr(status.Message) - } - } - if container.State.Waiting != nil && container.State.Waiting.Reason != "PodInitializing" && !terminal(job) { - status.Message = container.State.Waiting.Reason + ": " + container.State.Waiting.Message - } - } - if pod.Status.Phase == corev1.PodPending && pod.Spec.NodeName == "" && !terminal(job) { - events := &corev1.EventList{} - if data != nil { - events.Items = data.events - } else { - events, err = e.client.CoreV1().Events(e.namespace).List(ctx, metav1.ListOptions{FieldSelector: "involvedObject.uid=" + string(pod.UID)}) - if err != nil { - return nil, err - } - } - for _, event := range events.Items { - if event.InvolvedObject.UID == pod.UID && event.Reason == "FailedScheduling" { - status.Message = event.Message - } - } - } - for _, claimStatus := range pod.Status.ResourceClaimStatuses { - if claimStatus.ResourceClaimName == nil { - continue - } - var allocation []AllocatedDevice - if data != nil { - claim := data.claims[*claimStatus.ResourceClaimName] - if claim.Status.Allocation != nil { - for _, result := range claim.Status.Allocation.Devices.Results { - allocation = append(allocation, AllocatedDevice{result.Driver, result.Pool, result.Device}) - } - } - } else { - claim, err := e.client.ResourceV1().ResourceClaims(e.namespace).Get(ctx, *claimStatus.ResourceClaimName, metav1.GetOptions{}) - if apierrors.IsNotFound(err) { - continue - } - if err != nil { - return nil, err - } - if claim.Status.Allocation != nil { - for _, result := range claim.Status.Allocation.Devices.Results { - allocation = append(allocation, AllocatedDevice{result.Driver, result.Pool, result.Device}) - } - } - } - status.Devices = append(status.Devices, allocation...) - } - } - if cancelled := job.Annotations[cancelAnnotation]; cancelled != "" { - status.JobState = JobStateCancelled - status.Message = "Cancelled by user" - if reason := job.Annotations["mist.io/stop-reason"]; reason != "" { - status.Message = reason - } - if when, err := time.Parse(time.RFC3339, cancelled); err == nil { - status.TimeCompleted = &when - } - } - return status, nil -} - -func (e *KubernetesExecutor) list(ctx context.Context) ([]KubernetesJobStatus, error) { - jobs, err := e.client.BatchV1().Jobs(e.namespace).List(ctx, metav1.ListOptions{LabelSelector: managedLabel + "=mist,mist.io/owner=" + e.owner}) - if err != nil { - return nil, err - } - result := make([]KubernetesJobStatus, 0, len(jobs.Items)) - data, err := e.loadJobState(ctx) - if err != nil { - return nil, err - } - for i := range jobs.Items { - status, err := e.statusWithData(ctx, &jobs.Items[i], data) - if err != nil { - return nil, err - } - result = append(result, *status) - } - sort.Slice(result, func(i, j int) bool { return result[i].Created.After(result[j].Created) }) - return result, nil -} - -func (e *KubernetesExecutor) logs(ctx context.Context, id string) (string, error) { - job, err := e.managedJob(ctx, id) - if err != nil { - return "", err - } - if job.Annotations[cancelAnnotation] != "" { - return job.Annotations[cancelLogsAnnotation], nil - } - pods, err := e.client.CoreV1().Pods(e.namespace).List(ctx, metav1.ListOptions{LabelSelector: "batch.kubernetes.io/job-name=" + id}) - if err != nil { - return "", err - } - var logs strings.Builder - for _, pod := range pods.Items { - if len(pod.Status.ContainerStatuses) == 0 { - continue - } - state := pod.Status.ContainerStatuses[0].State - if state.Running == nil && state.Terminated == nil { - continue - } - stream, err := e.client.CoreV1().Pods(e.namespace).GetLogs(pod.Name, &corev1.PodLogOptions{Container: "workload", TailLines: ptr(int64(1000)), LimitBytes: ptr(maxLogBytes)}).Stream(ctx) - if err != nil { - return "", err - } - data, err := io.ReadAll(io.LimitReader(stream, maxLogBytes)) - stream.Close() - if err != nil { - return "", err - } - logs.Write(data) - } - value := logs.String() - if len(value) > int(maxLogBytes) { - value = value[len(value)-int(maxLogBytes):] - } - return value, nil -} - -func (e *KubernetesExecutor) cancel(ctx context.Context, id string) (*KubernetesJobStatus, error) { - job, err := e.managedJob(ctx, id) - if err != nil { - return nil, err - } - if e.team != nil && !isAdmin(e.team.Member) && job.Annotations["mist.io/creator-id"] != e.team.Member.ID { - return nil, &permissionError{errors.New("Only the job owner or an administrator can cancel this job")} - } - if job.Annotations[cancelAnnotation] != "" { - return e.statusFor(ctx, job) - } - if terminal(job) { - return nil, &submissionError{errors.New("completed jobs cannot be cancelled")} - } - initial, err := e.statusFor(ctx, job) - if err != nil { - return nil, err - } - if initial.ExitCode != nil { - return nil, &submissionError{errors.New("workload has already exited")} - } - logs, logErr := e.logs(ctx, id) - if logErr != nil { - return nil, fmt.Errorf("preserve logs before cancellation: %w", logErr) - } - // Keep cancellation annotations below Kubernetes' metadata size limit, - // including submissions and replacement encoding of non-UTF-8 output. - if len(logs) > 32*1024 { - logs = logs[len(logs)-32*1024:] - } - err = retry.RetryOnConflict(retry.DefaultRetry, func() error { - current, err := e.managedJob(ctx, id) - if err != nil { - return err - } - if terminal(current) { - return &submissionError{errors.New("job completed before cancellation")} - } - status, err := e.statusFor(ctx, current) - if err != nil { - return err - } - if status.ExitCode != nil { - return &submissionError{errors.New("workload has already exited")} - } - if current.Annotations[cancelAnnotation] != "" { - job = current - return nil - } - current.Spec.Suspend = ptr(true) - current.Annotations[cancelAnnotation] = time.Now().UTC().Format(time.RFC3339) - current.Annotations[cancelLogsAnnotation] = logs - job, err = e.client.BatchV1().Jobs(e.namespace).Update(ctx, current, metav1.UpdateOptions{}) - return err - }) - if err != nil { - return nil, err - } - return e.statusFor(ctx, job) -} diff --git a/src/kubernetes_test.go b/src/kubernetes_test.go deleted file mode 100644 index e11a0ab..0000000 --- a/src/kubernetes_test.go +++ /dev/null @@ -1,159 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "io" - "log/slog" - "net/http/httptest" - "strings" - "testing" - - batchv1 "k8s.io/api/batch/v1" - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/client-go/kubernetes/fake" -) - -func testExecutor() *KubernetesExecutor { - return &KubernetesExecutor{client: fake.NewSimpleClientset(), namespace: "mist", owner: "test", cpuNode: "cpu-node", ttNode: "tt-node", - allowedImages: map[string]bool{cpuImage: true, nvidiaImage: true, ttImage: true}} -} - -func TestKubernetesRejectInvalidSubmission(t *testing.T) { - e := testExecutor() - for _, request := range []CreateJobRequest{ - {Command: []string{"true"}, Accelerator: "amd"}, - {Command: []string{"true"}, Accelerator: "cpu", DeviceCount: 1}, - {Command: []string{"true"}, Accelerator: "nvidia", DeviceCount: 3}, - {Command: []string{"true"}, Accelerator: "tenstorrent", DeviceCount: 5}, - {Command: []string{"true"}, CPU: "-1"}, - {Command: []string{"true"}, CPU: "9"}, - {Command: []string{"true"}, Memory: "1Ti"}, - {Command: []string{"true"}, Image: "unapproved/image:latest"}, - {Script: "print(1)", ScriptName: "../escape.py"}, - {Script: "print(1)", Command: []string{"sh"}}, - {Command: []string{"true"}, Env: map[string]string{"MIST_JOB_ID": "spoofed"}}, - {Command: []string{"true"}, Accelerator: "tenstorrent", Env: map[string]string{"TT_METAL_HOME": "/host"}}, - } { - if _, err := e.submit(context.Background(), request); err == nil { - t.Errorf("accepted invalid submission: %+v", request) - } - } - jobs, _ := e.client.BatchV1().Jobs("mist").List(context.Background(), metav1.ListOptions{}) - if len(jobs.Items) != 0 { - t.Fatal("invalid submissions created jobs") - } -} - -func TestKubernetesReportsActualExitBeforeControllerCompletion(t *testing.T) { - e := testExecutor() - ctx := context.Background() - status, err := e.submit(ctx, CreateJobRequest{Command: []string{"sh", "-c", "exit 17"}}) - if err != nil { - t.Fatal(err) - } - _, err = e.client.CoreV1().Pods("mist").Create(ctx, &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "failed-pod", Labels: map[string]string{"batch.kubernetes.io/job-name": status.ID}}, - Status: corev1.PodStatus{Phase: corev1.PodFailed, ContainerStatuses: []corev1.ContainerStatus{{Name: "workload", State: corev1.ContainerState{Terminated: &corev1.ContainerStateTerminated{ExitCode: 17, Reason: "Error"}}}}}}, metav1.CreateOptions{}) - if err != nil { - t.Fatal(err) - } - actual, err := e.get(ctx, status.ID) - if err != nil { - t.Fatal(err) - } - if actual.JobState != JobStateFailure || actual.ExitCode == nil || *actual.ExitCode != 17 { - t.Fatalf("wrong status: %+v", actual) - } - if _, err := e.cancel(ctx, status.ID); err == nil { - t.Fatal("cancellation overwrote an already failed workload") - } -} - -func TestKubernetesReportsCurrentWaitingReasonAfterPlacement(t *testing.T) { - e := testExecutor() - ctx := context.Background() - job, err := e.submit(ctx, CreateJobRequest{Accelerator: "nvidia", Command: []string{"true"}}) - if err != nil { - t.Fatal(err) - } - _, err = e.client.CoreV1().Pods("mist").Create(ctx, &corev1.Pod{ - ObjectMeta: metav1.ObjectMeta{Name: "waiting-pod", UID: "waiting-uid", Labels: map[string]string{ - "batch.kubernetes.io/job-name": job.ID, managedLabel: "mist", "mist.io/owner": e.owner}}, - Spec: corev1.PodSpec{NodeName: e.cpuNode}, - Status: corev1.PodStatus{Phase: corev1.PodPending, ContainerStatuses: []corev1.ContainerStatus{{Name: "workload", - State: corev1.ContainerState{Waiting: &corev1.ContainerStateWaiting{Reason: "ImagePullBackOff", Message: "image unavailable"}}}}}, - }, metav1.CreateOptions{}) - if err != nil { - t.Fatal(err) - } - _, err = e.client.CoreV1().Events("mist").Create(ctx, &corev1.Event{ - ObjectMeta: metav1.ObjectMeta{Name: "old-scheduling-failure"}, - InvolvedObject: corev1.ObjectReference{UID: "waiting-uid"}, Reason: "FailedScheduling", Message: "Insufficient nvidia.com/gpu", - }, metav1.CreateOptions{}) - if err != nil { - t.Fatal(err) - } - actual, err := e.get(ctx, job.ID) - if err != nil { - t.Fatal(err) - } - listed, err := e.list(ctx) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(actual.Message, "ImagePullBackOff") || len(listed) != 1 || !strings.Contains(listed[0].Message, "ImagePullBackOff") { - t.Fatalf("old queue event replaced current pull failure: single=%+v list=%+v", actual, listed) - } -} - -func TestKubernetesCancellationPersistsAcrossExecutorRestart(t *testing.T) { - e := testExecutor() - ctx := context.Background() - status, err := e.submit(ctx, CreateJobRequest{Command: []string{"sleep", "120"}}) - if err != nil { - t.Fatal(err) - } - if _, err := e.cancel(ctx, status.ID); err != nil { - t.Fatal(err) - } - restarted := *e - actual, err := restarted.get(ctx, status.ID) - if err != nil { - t.Fatal(err) - } - job, _ := e.client.BatchV1().Jobs("mist").Get(ctx, status.ID, metav1.GetOptions{}) - if actual.JobState != JobStateCancelled || job.Spec.Suspend == nil || !*job.Spec.Suspend { - t.Fatal("cancelled job did not retain state and stop scheduling") - } - if _, err := restarted.cancel(ctx, status.ID); err != nil { - t.Fatal("repeat cancellation must be idempotent", err) - } -} - -func TestKubernetesDoesNotExposeUnmanagedJobs(t *testing.T) { - e := testExecutor() - ctx := context.Background() - _, _ = e.client.BatchV1().Jobs("mist").Create(ctx, &batchv1.Job{ObjectMeta: metav1.ObjectMeta{Name: "operator-job"}}, metav1.CreateOptions{}) - if _, err := e.get(ctx, "operator-job"); err == nil { - t.Fatal("exposed unmanaged job") - } - if _, err := e.cancel(ctx, "operator-job"); err == nil { - t.Fatal("cancelled unmanaged job") - } -} - -func TestKubernetesHTTPRejectsUnknownAndMultipleObjects(t *testing.T) { - app := NewKubernetesApp(testExecutor(), slog.New(slog.NewTextHandler(io.Discard, nil))) - for _, body := range []string{`{"command":["true"],"privileged":true}`, `{"command":["true"]} {"command":["false"]}`} { - recorder := httptest.NewRecorder() - app.httpServer.Handler.ServeHTTP(recorder, httptest.NewRequest("POST", "/jobs", strings.NewReader(body))) - if recorder.Code != 400 { - t.Fatalf("unexpected response %d: %s", recorder.Code, recorder.Body.String()) - } - var result map[string]string - if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil || result["error"] == "" { - t.Fatal("missing structured error") - } - } -} diff --git a/src/main.go b/src/main.go deleted file mode 100644 index fb3241d..0000000 --- a/src/main.go +++ /dev/null @@ -1,39 +0,0 @@ -package main - -import ( - "context" - "log/slog" - "os" - "os/signal" - "syscall" - "time" -) - -func main() { - log := slog.New(slog.NewJSONHandler(os.Stdout, nil)) - if mode := envOr("MIST_EXECUTOR", "kubernetes"); mode != "kubernetes" { - log.Error("unsupported executor; the Docker/Redis executor has been retired", "executor", mode) - os.Exit(1) - } - executor, err := NewKubernetesExecutor() - if err != nil { - log.Error("Kubernetes configuration failed", "error", err) - os.Exit(1) - } - if os.Getenv("MIST_AUTH_DISABLED") != "true" && os.Getenv("MIST_AUTH_ENABLED") == "" { - _ = os.Setenv("MIST_AUTH_ENABLED", "true") - } - app := NewKubernetesApp(executor, log) - if err := app.Start(); err != nil { - log.Error("API startup failed", "error", err) - os.Exit(1) - } - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - defer stop() - <-ctx.Done() - shutdown, cancel := context.WithTimeout(context.Background(), 15*time.Second) - defer cancel() - if err := app.Shutdown(shutdown); err != nil { - log.Error("API shutdown failed", "error", err) - } -} diff --git a/src/member_auth.go b/src/member_auth.go deleted file mode 100644 index 76d6511..0000000 --- a/src/member_auth.go +++ /dev/null @@ -1,69 +0,0 @@ -package main - -import ( - "crypto/sha256" - "encoding/hex" - "net" - "net/http" - "os" - "strings" -) - -type memberKey struct{} - -func hashID(id string) string { - digest := sha256.Sum256([]byte(id)) - return hex.EncodeToString(digest[:12]) -} - -type Member struct { - ID string `json:"id"` - Email string `json:"email"` - Name string `json:"name"` - Role string `json:"role"` -} - -type AccountStatus struct { - Role string `json:"role"` -} - -type AuthGateway = AuthService - -func newAuthGateway(raw string) (*AuthGateway, error) { - return newAuthService(raw, nil) -} - -func (a *App) requestExecutor(r *http.Request) *KubernetesExecutor { - if access, _ := r.Context().Value(teamContextKey{}).(*TeamAccess); access != nil { - clone := *a.executor - clone.namespace, clone.owner = access.Team.namespace(), access.Team.ID - clone.storage, clone.sharedPVC, clone.team = access.Store, "team-results", access - clone.inputPVC = "team-datasets" - return &clone - } - member, _ := r.Context().Value(memberKey{}).(*Member) - if member == nil { - return a.executor - } - clone := *a.executor - if a.auth != nil && strings.EqualFold(member.Email, a.auth.legacyEmail) { - clone.owner = a.executor.owner - } else { - digest := sha256.Sum256([]byte(member.ID)) - clone.owner = "user-" + hex.EncodeToString(digest[:12]) - } - return &clone -} - -// Trust exactly the deployed Nginx host/gateway, never a client-supplied chain. -func clientAddress(r *http.Request) string { - host, _, _ := net.SplitHostPort(r.RemoteAddr) - for _, trusted := range strings.Split(os.Getenv("MIST_TRUSTED_PROXY_IPS"), ",") { - if trusted != "" && trusted == host { - if ip := net.ParseIP(r.Header.Get("X-Real-IP")); ip != nil { - return ip.String() - } - } - } - return host -} diff --git a/src/outputs.go b/src/outputs.go deleted file mode 100644 index 9ea11f7..0000000 --- a/src/outputs.go +++ /dev/null @@ -1,24 +0,0 @@ -package main - -import ( - corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/resource" -) - -// prepareOutputs creates the job's PVC subdirectory before kubelet mounts it -// into the workload. Both supported root and nonroot images can write results. -// Retained node-local jobs use this initializer. Team jobs prepare their scoped -// NFS output directory through the API and do not mount the parent checkpoint PVC. -func prepareOutputs(id string) corev1.Container { - return corev1.Container{ - Name: "prepare-outputs", Image: "busybox:1.36", ImagePullPolicy: corev1.PullIfNotPresent, - Command: []string{"sh", "-c", `mkdir -p "/checkpoints/$MIST_JOB_ID" && chmod 0777 "/checkpoints/$MIST_JOB_ID"`}, - Env: []corev1.EnvVar{{Name: "MIST_JOB_ID", Value: id}}, - VolumeMounts: []corev1.VolumeMount{{Name: "checkpoints", MountPath: "/checkpoints"}}, - SecurityContext: &corev1.SecurityContext{RunAsUser: ptr(int64(0)), Privileged: ptr(false), AllowPrivilegeEscalation: ptr(false), Capabilities: &corev1.Capabilities{Drop: []corev1.Capability{"ALL"}}}, - Resources: corev1.ResourceRequirements{ - Requests: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("10m"), corev1.ResourceMemory: resource.MustParse("16Mi")}, - Limits: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("100m"), corev1.ResourceMemory: resource.MustParse("32Mi")}, - }, - } -} diff --git a/src/shared_storage.go b/src/shared_storage.go deleted file mode 100644 index 5d7c095..0000000 --- a/src/shared_storage.go +++ /dev/null @@ -1,593 +0,0 @@ -package main - -import ( - "archive/zip" - "crypto/rand" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "io" - batchv1 "k8s.io/api/batch/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "mime" - "net/http" - "os" - "path/filepath" - "regexp" - "sort" - "strconv" - "strings" - "sync" - "syscall" - "time" -) - -// Uploads are bounded by real shared-disk free space and an optional upload cap. -const maxDatasetBytes int64 = 1 << 50 - -var datasetIDPattern = regexp.MustCompile(`^dataset-[a-f0-9]{24}$`) - -type SharedStorage struct { - root string - modelRoot string - mu sync.Mutex - uploadMu sync.Mutex - uploadLimit int64 -} -type Dataset struct { - ID string `json:"id"` - Name string `json:"name"` - Filename string `json:"filename"` - Owner string `json:"owner"` - Size int64 `json:"size"` - SHA256 string `json:"sha256"` - Created time.Time `json:"created"` - State string `json:"state"` - Files int `json:"files"` - TeamID string `json:"team_id,omitempty"` - TeamName string `json:"team_name,omitempty"` - Scope string `json:"scope,omitempty"` - MemberName string `json:"member_name,omitempty"` - ContentPath string `json:"content_path,omitempty"` - Shared bool `json:"shared,omitempty"` -} - -func NewSharedStorage(root string) (*SharedStorage, error) { - if !filepath.IsAbs(root) { - return nil, errors.New("storage root must be absolute") - } - for _, dir := range []string{"metadata/datasets", ".uploads", "datasets", "jobs", "legacy"} { - if err := os.MkdirAll(filepath.Join(root, dir), 0700); err != nil { - return nil, err - } - } - limit := maxDatasetBytes - if configured := os.Getenv("MIST_MAX_DATASET_GIB"); configured != "" { - gib, err := strconv.ParseInt(configured, 10, 64) - if err != nil || gib < 1 || gib > maxDatasetBytes/(1024*1024*1024) { - return nil, errors.New("MIST_MAX_DATASET_GIB is outside the supported range") - } - limit = gib * 1024 * 1024 * 1024 - } - return &SharedStorage{root: root, uploadLimit: limit}, nil -} - -func (s *SharedStorage) availableUploadLimit(team bool) int64 { - var stats syscall.Statfs_t - if syscall.Statfs(s.root, &stats) != nil { - return 0 - } - reserve := int64(1024 * 1024 * 1024) - return max(0, min(s.uploadLimit, int64(stats.Bavail)*stats.Bsize-reserve)) -} -func (s *SharedStorage) dataset(id, owner string) (*Dataset, error) { - if !datasetIDPattern.MatchString(id) { - return nil, os.ErrNotExist - } - data, err := os.ReadFile(filepath.Join(s.root, "metadata/datasets", id+".json")) - if err != nil { - return nil, err - } - var ds Dataset - if err = json.Unmarshal(data, &ds); err != nil { - return nil, err - } - if ds.ID != id || ds.Owner != owner || ds.State != "Ready" { - return nil, os.ErrNotExist - } - return &ds, nil -} -func (s *SharedStorage) resultsRoot() string { - if s.modelRoot != "" { - return s.modelRoot - } - return s.root -} -func (s *SharedStorage) prepareJob(id string) error { - dir := filepath.Join(s.resultsRoot(), "jobs", id, "outputs") - if err := os.MkdirAll(dir, 0755); err != nil { - return err - } - return os.Chmod(dir, 0777) -} -func safeRelative(path string) bool { - return path != "" && path != "." && filepath.IsLocal(path) && !strings.ContainsAny(path, "\\\x00") && filepath.Clean(path) == path -} -func (s *SharedStorage) upload(r *http.Request, owner string) (*Dataset, error) { - filename := r.URL.Query().Get("filename") - if !safeRelative(filename) || filepath.Base(filename) != filename || len(filename) > 200 { - return nil, &submissionError{errors.New("invalid upload filename")} - } - name := strings.TrimSpace(r.URL.Query().Get("name")) - if name == "" { - name = filename - } - if len(name) > 120 { - return nil, &submissionError{errors.New("dataset name exceeds 120 characters")} - } - format := r.URL.Query().Get("format") - if format != "" && format != "zip" { - return nil, &submissionError{errors.New("format must be zip or omitted")} - } - s.uploadMu.Lock() - defer s.uploadMu.Unlock() - var stats syscall.Statfs_t - if err := syscall.Statfs(s.root, &stats); err != nil { - return nil, err - } - reserve := int64(1024 * 1024 * 1024) - remaining := int64(stats.Bavail)*stats.Bsize - reserve - if remaining <= 0 { - return nil, fmt.Errorf("dataset storage has insufficient free space: %w", syscall.ENOSPC) - } - limit := min(s.uploadLimit, remaining) - if r.ContentLength > limit { - return nil, &submissionError{errors.New("dataset exceeds upload limit or available storage")} - } - random := make([]byte, 12) - if _, err := rand.Read(random); err != nil { - return nil, err - } - id := "dataset-" + hex.EncodeToString(random) - stage := filepath.Join(s.root, ".uploads", id) - if err := os.Mkdir(stage, 0700); err != nil { - return nil, err - } - defer os.RemoveAll(stage) - upload := filepath.Join(stage, "upload") - file, err := os.OpenFile(upload, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) - if err != nil { - return nil, err - } - checksum := sha256.New() - n, copyErr := io.Copy(io.MultiWriter(file, checksum), io.LimitReader(r.Body, limit+1)) - syncErr := file.Sync() - closeErr := file.Close() - if copyErr != nil { - return nil, copyErr - } - if syncErr != nil { - return nil, syncErr - } - if closeErr != nil { - return nil, closeErr - } - if err = r.Context().Err(); err != nil { - return nil, err - } - if n > limit { - return nil, &submissionError{errors.New("dataset exceeds upload limit or available storage")} - } - if n == 0 { - return nil, &submissionError{errors.New("dataset is empty")} - } - content := filepath.Join(stage, "content") - if err = os.Mkdir(content, 0755); err != nil { - return nil, err - } - count, size := 1, n - if format == "zip" { - count, size, err = extractDatasetZip(upload, content, min(s.uploadLimit, remaining-n)) - if err != nil { - return nil, &submissionError{err} - } - } else { - if err = os.Rename(upload, filepath.Join(content, filename)); err != nil { - return nil, err - } - if err = os.Chmod(filepath.Join(content, filename), 0644); err != nil { - return nil, err - } - } - ds := &Dataset{ID: id, Name: name, Filename: filename, Owner: owner, Size: size, SHA256: hex.EncodeToString(checksum.Sum(nil)), Created: time.Now().UTC(), State: "Ready", Files: count} - relative := "datasets/" + id - access, _ := r.Context().Value(teamContextKey{}).(*TeamAccess) - if access != nil { - access.Service.mu.Lock() - defer access.Service.mu.Unlock() - scope := r.URL.Query().Get("scope") - if scope == "" { - scope = access.Member.ID - } - if err = access.Service.canWrite(r.Context(), access, scope); err != nil { - return nil, err - } - ds.TeamID, ds.Scope, ds.MemberName = access.Team.ID, scope, access.Member.Name - relative = scopePath(scope) + "/datasets/" + id - ds.ContentPath = relative + "/content" - } - target := filepath.Join(s.root, relative) - if err = os.MkdirAll(target, 0755); err != nil { - return nil, err - } - published := false - defer func() { - if !published { - _ = os.RemoveAll(target) - } - }() - if err = os.Rename(content, filepath.Join(target, "content")); err != nil { - return nil, err - } - data, _ := json.Marshal(ds) - temp := filepath.Join(stage, "metadata.json") - if err = os.WriteFile(temp, data, 0600); err != nil { - return nil, err - } - if err = os.Rename(temp, filepath.Join(s.root, "metadata/datasets", id+".json")); err != nil { - return nil, err - } - published = true - return ds, nil -} -func extractDatasetZip(path, destination string, limit int64) (int, int64, error) { - archive, err := zip.OpenReader(path) - if err != nil { - return 0, 0, errors.New("invalid ZIP archive") - } - defer archive.Close() - if len(archive.File) > 10000 { - return 0, 0, errors.New("ZIP contains too many entries") - } - var total int64 - files := 0 - for _, entry := range archive.File { - name := strings.TrimSuffix(entry.Name, "/") - if !safeRelative(name) || entry.Mode()&os.ModeSymlink != 0 || (!entry.FileInfo().IsDir() && !entry.Mode().IsRegular()) { - return 0, 0, errors.New("ZIP contains an unsafe path or special file") - } - target := filepath.Join(destination, name) - if entry.FileInfo().IsDir() { - if err = os.MkdirAll(target, 0755); err != nil { - return 0, 0, err - } - continue - } - if entry.UncompressedSize64 > uint64(max(int64(0), limit-total)) { - return 0, 0, errors.New("expanded ZIP exceeds dataset limit") - } - if err = os.MkdirAll(filepath.Dir(target), 0755); err != nil { - return 0, 0, err - } - output, err := os.OpenFile(target, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644) - if err != nil { - return 0, 0, err - } - reader, err := entry.Open() - if err != nil { - output.Close() - return 0, 0, err - } - n, copyErr := io.Copy(output, io.LimitReader(reader, limit-total+1)) - reader.Close() - closeErr := output.Close() - total += n - if copyErr != nil { - return 0, 0, copyErr - } - if closeErr != nil { - return 0, 0, closeErr - } - if total > limit { - return 0, 0, errors.New("expanded ZIP exceeds dataset limit") - } - files++ - } - if files == 0 { - return 0, 0, errors.New("ZIP contains no files") - } - return files, total, nil -} -func (a *App) storageInfo(w http.ResponseWriter, r *http.Request) { - e := a.requestExecutor(r) - s := e.storage - if s == nil { - writeJSON(w, 200, map[string]interface{}{"enabled": false}) - return - } - var stat syscall.Statfs_t - if err := syscall.Statfs(s.root, &stat); err != nil { - executorError(w, err) - return - } - info := map[string]interface{}{"enabled": true, "capacity_bytes": stat.Blocks * uint64(stat.Bsize), "available_bytes": stat.Bavail * uint64(stat.Bsize), "upload_limit_bytes": s.availableUploadLimit(e.team != nil), "max_dataset_bytes": s.uploadLimit} - if e.team != nil { - usage, err := a.teams.storageStatus(e.team.Team.ID) - if err != nil { - executorError(w, err) - return - } - info["storage_mode"] = "shared-folders" - info["storage_limits_enforced"] = false - // Retained response fields are aliases for the same shared disk, not - // independent model capacity. New clients use the sampled usage fields. - info["model_capacity_bytes"], info["model_available_bytes"] = info["capacity_bytes"], info["available_bytes"] - info["dataset_used_bytes"], info["model_used_bytes"] = usage.DatasetUsedBytes, usage.ModelUsedBytes - info["usage_observed_at"] = usage.observedAt() - info["usage_stale"] = time.Since(time.Unix(usage.UsageObservedUnix, 0)) > 2*time.Minute - info["storage_low"] = stat.Bavail*uint64(stat.Bsize) < max(uint64(5*1024*1024*1024), stat.Blocks*uint64(stat.Bsize)/10) - info["dataset_allocation_gib"], info["model_allocation_gib"] = e.team.Team.Policy.StorageGiB, e.team.Team.Policy.ModelStorageGiB - } - writeJSON(w, 200, info) -} -func (a *App) datasets(w http.ResponseWriter, r *http.Request) { - e := a.requestExecutor(r) - s := e.storage - if s == nil { - writeJSON(w, 503, map[string]string{"error": "Shared storage is not configured"}) - return - } - if e.team != nil { - a.teamDatasets(w, r, e) - return - } - switch r.Method { - case http.MethodGet: - s.mu.Lock() - defer s.mu.Unlock() - entries, err := os.ReadDir(filepath.Join(s.root, "metadata/datasets")) - if err != nil { - executorError(w, err) - return - } - list := []Dataset{} - for _, entry := range entries { - if ds, err := s.dataset(strings.TrimSuffix(entry.Name(), ".json"), e.owner); err == nil { - list = append(list, *ds) - } - } - sort.Slice(list, func(i, j int) bool { return list[i].Created.After(list[j].Created) }) - writeJSON(w, 200, map[string]interface{}{"datasets": list, "upload_limit_bytes": s.availableUploadLimit(false)}) - case http.MethodPost: - controller := http.NewResponseController(w) - _ = controller.SetReadDeadline(time.Now().Add(24 * time.Hour)) - _ = controller.SetWriteDeadline(time.Now().Add(24 * time.Hour)) - r.Body = http.MaxBytesReader(w, r.Body, s.uploadLimit+1) - ds, err := s.upload(r, e.owner) - if err != nil { - executorError(w, err) - return - } - writeJSON(w, 201, ds) - default: - writeJSON(w, 405, map[string]string{"error": "method not allowed"}) - } -} -func (a *App) dataset(w http.ResponseWriter, r *http.Request) { - if strings.Contains(r.URL.Path, "/files") { - a.datasetFiles(w, r) - return - } - e := a.requestExecutor(r) - s := e.storage - if s == nil { - writeJSON(w, 503, map[string]string{"error": "Shared storage is not configured"}) - return - } - id := strings.TrimPrefix(r.URL.Path, "/datasets/") - if e.team != nil { - a.teamDataset(w, r, e, id) - return - } - s.mu.Lock() - defer s.mu.Unlock() - ds, err := s.dataset(id, e.owner) - if err != nil { - writeJSON(w, 404, map[string]string{"error": "dataset not found"}) - return - } - switch r.Method { - case http.MethodGet: - writeJSON(w, 200, ds) - case http.MethodDelete: - jobs, err := e.client.BatchV1().Jobs(e.namespace).List(r.Context(), metav1.ListOptions{LabelSelector: managedLabel + "=mist,mist.io/owner=" + e.owner}) - if err != nil { - executorError(w, err) - return - } - for _, job := range jobs.Items { - var req CreateJobRequest - _ = json.Unmarshal([]byte(job.Annotations[requestAnnotation]), &req) - if req.DatasetID == id && !terminal(&job) { - if job.Annotations[cancelAnnotation] != "" { - pods, err := e.client.CoreV1().Pods(e.namespace).List(r.Context(), metav1.ListOptions{LabelSelector: "batch.kubernetes.io/job-name=" + job.Name}) - if err != nil { - executorError(w, err) - return - } - if len(pods.Items) == 0 { - continue - } - } - writeJSON(w, 409, map[string]string{"error": "Dataset is still referenced by an active or terminating job"}) - return - } - } - if err = os.Remove(filepath.Join(s.root, "metadata/datasets", id+".json")); err != nil { - executorError(w, err) - return - } - if err = os.RemoveAll(filepath.Join(s.root, "datasets", id)); err != nil { - executorError(w, err) - return - } - writeJSON(w, 200, map[string]string{"id": id, "state": "Deleted"}) - default: - writeJSON(w, 405, map[string]string{"error": "method not allowed"}) - } -} -func (s *SharedStorage) outputPath(job *batchv1.Job) (string, error) { - if path := job.Annotations["mist.io/output-path"]; path != "" { - if !safeRelative(path) { - return "", errors.New("invalid job storage path") - } - return filepath.Join(s.resultsRoot(), path), nil - } - if job.Annotations["mist.io/storage"] == "shared-v1" { - return filepath.Join(s.resultsRoot(), "jobs", job.Name, "outputs"), nil - } - for _, vol := range job.Spec.Template.Spec.Volumes { - if vol.Name == "checkpoints" && vol.PersistentVolumeClaim != nil && vol.PersistentVolumeClaim.ClaimName == "mist-shared-jobs" { - return filepath.Join(s.resultsRoot(), "jobs", job.Name, "outputs"), nil - } - } - var req CreateJobRequest - if err := json.Unmarshal([]byte(job.Annotations[requestAnnotation]), &req); err != nil { - return "", err - } - if req.Accelerator != "cpu" && req.Accelerator != "nvidia" && req.Accelerator != "tenstorrent" { - return "", errors.New("unknown legacy output storage") - } - return filepath.Join(s.resultsRoot(), "legacy", req.Accelerator, job.Name), nil -} - -type ResultFile struct { - Path string `json:"path"` - Size int64 `json:"size"` - Modified time.Time `json:"modified"` -} - -func listResultFiles(root *os.Root) ([]ResultFile, error) { - files := []ResultFile{} - visited := 0 - var visit func(string, int) error - visit = func(path string, depth int) error { - if depth > 20 { - return errors.New("output directory exceeds 20 levels") - } - dir, err := root.Open(path) - if err != nil { - return err - } - defer dir.Close() - for { - entries, readErr := dir.ReadDir(128) - for _, entry := range entries { - visited++ - if visited > 10000 { - return errors.New("output directory exceeds 10000 entries") - } - name := filepath.Join(path, entry.Name()) - info, err := root.Lstat(name) - if err != nil { - return err - } - if info.IsDir() { - if err = visit(name, depth+1); err != nil { - return err - } - } else if info.Mode().IsRegular() { - files = append(files, ResultFile{name, info.Size(), info.ModTime()}) - } - if len(files) > 5000 { - return errors.New("output directory exceeds 5000 files") - } - } - if readErr == io.EOF { - break - } - if readErr != nil { - return readErr - } - } - return nil - } - err := visit(".", 0) - sort.Slice(files, func(i, j int) bool { return files[i].Path < files[j].Path }) - return files, err -} -func (a *App) jobFiles(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - writeJSON(w, 405, map[string]string{"error": "method not allowed"}) - return - } - parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/jobs/"), "/") - if len(parts) < 2 || len(parts) > 3 || parts[1] != "files" || (len(parts) == 3 && parts[2] != "download") { - writeJSON(w, 404, map[string]string{"error": "not found"}) - return - } - e := a.requestExecutor(r) - job, err := e.managedJob(r.Context(), parts[0]) - if err != nil { - executorError(w, err) - return - } - if e.storage == nil { - writeJSON(w, 503, map[string]string{"error": "Shared storage is not configured"}) - return - } - path, err := e.storage.outputPath(job) - if err != nil { - executorError(w, err) - return - } - serveStoredFiles(w, r, path, len(parts) == 3) -} -func serveStoredFiles(w http.ResponseWriter, r *http.Request, path string, download bool) { - root, err := os.OpenRoot(path) - if os.IsNotExist(err) { - if !download { - writeJSON(w, 200, map[string]interface{}{"files": []ResultFile{}}) - } else { - writeJSON(w, 404, map[string]string{"error": "file not found"}) - } - return - } - if err != nil { - executorError(w, err) - return - } - defer root.Close() - if !download { - files, err := listResultFiles(root) - if err != nil { - executorError(w, err) - return - } - writeJSON(w, 200, map[string]interface{}{"files": files}) - return - } - filename := r.URL.Query().Get("path") - if !safeRelative(filename) { - writeJSON(w, 400, map[string]string{"error": "Invalid file path"}) - return - } - file, err := root.Open(filename) - if err != nil { - writeJSON(w, 404, map[string]string{"error": "file not found"}) - return - } - defer file.Close() - info, err := file.Stat() - if err != nil || !info.Mode().IsRegular() { - writeJSON(w, 404, map[string]string{"error": "file not found"}) - return - } - _ = http.NewResponseController(w).SetWriteDeadline(time.Now().Add(24 * time.Hour)) - w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": filepath.Base(filename)})) - w.Header().Set("X-Content-Type-Options", "nosniff") - w.Header().Set("Content-Type", "application/octet-stream") - http.ServeContent(w, r, filename, info.ModTime(), file) -} diff --git a/src/shared_storage_test.go b/src/shared_storage_test.go deleted file mode 100644 index f3e0f78..0000000 --- a/src/shared_storage_test.go +++ /dev/null @@ -1,198 +0,0 @@ -package main - -import ( - "archive/zip" - "bytes" - "context" - "io" - "log/slog" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "testing" - "time" -) - -func uploadTest(t *testing.T, s *SharedStorage, path string, data []byte) (*Dataset, error) { - t.Helper() - return s.upload(httptest.NewRequest("POST", path, bytes.NewReader(data)), "test") -} -func TestSharedDatasetAtomicLimitsAndOwnership(t *testing.T) { - s, err := NewSharedStorage(t.TempDir()) - if err != nil { - t.Fatal(err) - } - s.uploadLimit = 16 - ds, err := uploadTest(t, s, "/datasets?filename=data.csv", []byte("1,2\n")) - if err != nil { - t.Fatal(err) - } - if ds.State != "Ready" || ds.Files != 1 || ds.Size != 4 || len(ds.SHA256) != 64 { - t.Fatalf("bad dataset %+v", ds) - } - if _, err = s.dataset(ds.ID, "other"); err == nil { - t.Fatal("another member can access dataset") - } - if _, err = uploadTest(t, s, "/datasets?filename=data.csv", bytes.Repeat([]byte("a"), 17)); err == nil { - t.Fatal("oversized upload accepted") - } - if _, err = uploadTest(t, s, "/datasets?filename=../escape", []byte("a")); err == nil { - t.Fatal("unsafe filename accepted") - } - entries, _ := os.ReadDir(filepath.Join(s.root, ".uploads")) - if len(entries) != 0 { - t.Fatal("failed upload retained staging files") - } - entries, _ = os.ReadDir(filepath.Join(s.root, "metadata/datasets")) - if len(entries) != 1 { - t.Fatal("failed upload published metadata") - } -} - -func TestSharedDatasetConfiguredCeilingAndMissingStorage(t *testing.T) { - t.Setenv("MIST_MAX_DATASET_GIB", "4") - root := t.TempDir() - s, err := NewSharedStorage(root) - if err != nil { - t.Fatal(err) - } - // Reject an oversized declared body without reading or staging gigabytes. - request := httptest.NewRequest("POST", "/datasets?filename=data.bin", strings.NewReader("small")) - request.ContentLength = 5 * 1024 * 1024 * 1024 - if _, err = s.upload(request, "test"); err == nil { - t.Fatal("configured dataset ceiling was bypassed") - } - if limit := s.availableUploadLimit(true); limit < 0 || limit > 4*1024*1024*1024 { - t.Fatalf("unsafe advertised upload limit: %d", limit) - } - if err = os.RemoveAll(root); err != nil { - t.Fatal(err) - } - if s.availableUploadLimit(true) != 0 { - t.Fatal("missing storage advertised upload capacity") - } - for _, value := range []string{"0", "1048577", "invalid"} { - t.Setenv("MIST_MAX_DATASET_GIB", value) - if _, err = NewSharedStorage(t.TempDir()); err == nil { - t.Fatalf("accepted invalid upload ceiling %q", value) - } - } -} -func TestSharedZipRejectsTraversalSymlinksAndExpansion(t *testing.T) { - for _, tc := range []struct { - name string - mode os.FileMode - data string - limit int64 - }{{"../escape", 0644, "x", 100}, {"symlink", os.ModeSymlink | 0777, "/etc/passwd", 100}, {"data", 0644, strings.Repeat("x", 1000), 10}} { - var buffer bytes.Buffer - writer := zip.NewWriter(&buffer) - header := &zip.FileHeader{Name: tc.name, Method: zip.Deflate} - header.SetMode(tc.mode) - file, _ := writer.CreateHeader(header) - _, _ = io.WriteString(file, tc.data) - writer.Close() - dir := t.TempDir() - path := filepath.Join(dir, "test.zip") - os.WriteFile(path, buffer.Bytes(), 0600) - if _, _, err := extractDatasetZip(path, filepath.Join(dir, "out"), tc.limit); err == nil { - t.Fatalf("accepted unsafe archive %s", tc.name) - } - } -} -func TestSharedJobMountAndEscapingOutput(t *testing.T) { - e := testExecutor() - s, _ := NewSharedStorage(t.TempDir()) - e.storage = s - e.sharedPVC = "mist-shared-jobs" - ds, err := uploadTest(t, s, "/datasets?filename=data.csv", []byte("1,2\n")) - if err != nil { - t.Fatal(err) - } - status, err := e.submit(context.Background(), CreateJobRequest{Command: []string{"true"}, DatasetID: ds.ID}) - if err != nil { - t.Fatal(err) - } - job, _ := e.managedJob(context.Background(), status.ID) - spec := job.Spec.Template.Spec - if len(spec.InitContainers) != 0 { - t.Fatal("shared job exposes full storage in init") - } - input := false - for _, mount := range spec.Containers[0].VolumeMounts { - if mount.MountPath == "/inputs" { - input = mount.ReadOnly && mount.SubPath == "datasets/"+ds.ID+"/content" - } - } - if !input { - t.Fatal("dataset not mounted read-only") - } - output, _ := s.outputPath(job) - os.WriteFile(filepath.Join(output, "model.json"), []byte("{}"), 0644) - os.Symlink("/etc/passwd", filepath.Join(output, "escape")) - app := NewKubernetesApp(e, slog.New(slog.NewTextHandler(io.Discard, nil))) - for _, tc := range []struct { - path string - status int - }{{"model.json", 200}, {"../metadata", 400}, {"escape", 404}} { - req := httptest.NewRequest("GET", "/jobs/"+status.ID+"/files/download?path="+tc.path, nil) - response := httptest.NewRecorder() - app.httpServer.Handler.ServeHTTP(response, req) - if response.Code != tc.status { - t.Fatalf("%s: got %d: %s", tc.path, response.Code, response.Body.String()) - } - } - e.owner = "other" - response := httptest.NewRecorder() - app.httpServer.Handler.ServeHTTP(response, httptest.NewRequest("GET", "/jobs/"+status.ID+"/files", nil)) - if response.Code != 404 { - t.Fatal("foreign job files accessible") - } -} -func TestMemberAuthIsolationAndFailureClosed(t *testing.T) { - t.Setenv("MIST_AUTH_DATABASE", filepath.Join(t.TempDir(), "auth.sqlite")) - t.Setenv("MIST_ALLOWED_ORIGINS", "http://mist.test") - app := NewKubernetesApp(testExecutor(), slog.New(slog.NewTextHandler(io.Discard, nil))) - now := time.Now().UTC().Format(time.RFC3339Nano) - for _, m := range []Member{ - {ID: "alice", Name: "Alice", Email: "alice@example.org", Role: "user"}, - {ID: "bob", Name: "Bob", Email: "bob@example.org", Role: "user"}, - } { - if _, err := app.auth.db.Exec(` - INSERT INTO user (id, name, email, role, createdAt, updatedAt) - VALUES (?, ?, ?, ?, ?, ?) - `, m.ID, m.Name, m.Email, m.Role, now, now); err != nil { - t.Fatal(err) - } - } - seedTestSession(t, app.auth, "alice") - seedTestSession(t, app.auth, "bob") - t.Cleanup(func() { _ = app.auth.Close() }) - - request := func(method, path, cookie, origin, body string) *httptest.ResponseRecorder { - req := httptest.NewRequest(method, path, strings.NewReader(body)) - req.Header.Set("Cookie", cookie) - req.Header.Set("Origin", origin) - req.Header.Set("X-Mist-Owner", "test") - response := httptest.NewRecorder() - app.httpServer.Handler.ServeHTTP(response, req) - return response - } - if response := request("GET", "/jobs", "", "", ""); response.Code != 401 { - t.Fatal("anonymous request accepted") - } - if response := request("POST", "/jobs", "better-auth.session_token=test-session-alice", "http://evil.test", `{"command":["true"]}`); response.Code != 403 { - t.Fatal("cross-origin mutation accepted") - } - if response := request("POST", "/jobs", "better-auth.session_token=test-session-alice", "http://mist.test", `{"command":["true"]}`); response.Code != 201 { - t.Fatalf("submit failed %d %s", response.Code, response.Body.String()) - } - if response := request("GET", "/jobs", "better-auth.session_token=test-session-bob", "", ""); response.Code != 200 || !strings.Contains(response.Body.String(), `"count":0`) { - t.Fatalf("another member sees jobs: %s", response.Body.String()) - } - app.auth.Close() - if response := request("GET", "/jobs", "better-auth.session_token=test-session-alice", "", ""); response.Code != 503 { - t.Fatal("auth failure did not close access") - } -} diff --git a/src/storage_status.go b/src/storage_status.go deleted file mode 100644 index 524dd9f..0000000 --- a/src/storage_status.go +++ /dev/null @@ -1,38 +0,0 @@ -package main - -import ( - "encoding/json" - "errors" - "os" - "path/filepath" - "time" -) - -// Usage is sampled on the owning storage host; HTTP handlers never walk NFS -// trees. Numbers are logical file bytes and may lag active writes by 30 seconds. -type teamStorageStatus struct { - State string `json:"state"` - Layout string `json:"layout"` - DatasetUsedBytes int64 `json:"dataset_used_bytes"` - ModelUsedBytes int64 `json:"model_used_bytes"` - UsageObservedUnix int64 `json:"usage_observed_unix"` -} - -func (s *TeamService) storageStatus(id string) (teamStorageStatus, error) { - var status teamStorageStatus - data, err := os.ReadFile(filepath.Join(s.app.executor.storage.root, "metadata/provision-status", id+".json")) - if err != nil { - return status, err - } - if err = json.Unmarshal(data, &status); err != nil { - return status, err - } - if status.State != "Ready" || status.Layout != sharedFolderLayout || status.DatasetUsedBytes < 0 || status.ModelUsedBytes < 0 || status.UsageObservedUnix <= 0 { - return status, errors.New("team storage usage is unavailable") - } - return status, nil -} - -func (s teamStorageStatus) observedAt() string { - return time.Unix(s.UsageObservedUnix, 0).UTC().Format(time.RFC3339) -} diff --git a/src/team_queue.go b/src/team_queue.go deleted file mode 100644 index f9b6389..0000000 --- a/src/team_queue.go +++ /dev/null @@ -1,539 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "errors" - "os" - "path/filepath" - "sort" - "time" - - batchv1 "k8s.io/api/batch/v1" - corev1 "k8s.io/api/core/v1" - apierrors "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/client-go/util/retry" -) - -func (s *TeamService) submitTeam(ctx context.Context, e *KubernetesExecutor, request CreateJobRequest) (*KubernetesJobStatus, error) { - s.mu.Lock() - defer s.mu.Unlock() - t, err := s.team(ctx, e.team.Team.ID) - if err != nil { - return nil, err - } - e.team.Team = t - req, err := e.normalize(request) - if err != nil { - return nil, &submissionError{err} - } - if err = s.provisionNamespace(ctx, t); err != nil { - return nil, err - } - jobs, err := e.client.BatchV1().Jobs(e.namespace).List(ctx, metav1.ListOptions{LabelSelector: managedLabel + "=mist"}) - if err != nil { - return nil, err - } - queued := 0 - for _, job := range jobs.Items { - if !terminal(&job) && job.Annotations[cancelAnnotation] == "" && job.Annotations["mist.io/admitted-at"] == "" { - queued++ - } - } - if queued >= t.Policy.Queued { - return nil, &submissionError{errors.New("team queue is full; wait for jobs to start or cancel a queued job")} - } - id, err := randomJobID() - if err != nil { - return nil, err - } - var source *Team - if req.DatasetID != "" { - var ds *Dataset - _, source, ds, err = s.resolveDataset(ctx, t, req.DatasetID) - if err != nil { - return nil, &submissionError{errors.New("dataset is unavailable to this team")} - } - e.inputSubPath = ds.ContentPath - if source.ID != t.ID { - claim := "input-" + req.DatasetID - if err = s.ensureVolume(ctx, source.ID, e.namespace, claim, "teams/"+source.ID+"/datasets/"+ds.ContentPath, source.Policy.StorageGiB, true); err != nil { - return nil, err - } - e.inputPVC, e.inputSubPath = claim, "" - } - } - e.outputSubPath = scopePath(req.StorageScope) + "/jobs/" + id + "/outputs" - output := filepath.Join(e.storage.resultsRoot(), e.outputSubPath) - if err = os.MkdirAll(output, 0755); err != nil { - return nil, err - } - if err = os.Chmod(output, 0777); err != nil { - return nil, err - } - committed := false - defer func() { - if !committed { - _ = os.RemoveAll(filepath.Dir(output)) - } - }() - if req.Script != "" { - _, err = e.client.CoreV1().ConfigMaps(e.namespace).Create(ctx, &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: id + "-script", Labels: map[string]string{managedLabel: "mist"}}, Data: map[string]string{req.ScriptName: req.Script}}, metav1.CreateOptions{}) - if err != nil { - return nil, err - } - } - job := e.buildJob(id, req) - job.Spec.Suspend = ptr(true) - if source != nil { - job.Annotations["mist.io/input-team"] = source.ID - } - job, err = e.client.BatchV1().Jobs(e.namespace).Create(ctx, job, metav1.CreateOptions{}) - if err != nil { - if req.Script != "" { - _ = e.client.CoreV1().ConfigMaps(e.namespace).Delete(ctx, id+"-script", metav1.DeleteOptions{}) - } - return nil, err - } - if req.Script != "" { - defer func() { - if !committed { - propagation := metav1.DeletePropagationForeground - _ = e.client.BatchV1().Jobs(e.namespace).Delete(ctx, id, metav1.DeleteOptions{PropagationPolicy: &propagation}) - _ = e.client.CoreV1().ConfigMaps(e.namespace).Delete(ctx, id+"-script", metav1.DeleteOptions{}) - } - }() - - cm, readErr := e.client.CoreV1().ConfigMaps(e.namespace).Get(ctx, id+"-script", metav1.GetOptions{}) - if readErr != nil { - return nil, readErr - } - cm.OwnerReferences = []metav1.OwnerReference{*metav1.NewControllerRef(job, batchv1.SchemeGroupVersion.WithKind("Job"))} - if _, err = e.client.CoreV1().ConfigMaps(e.namespace).Update(ctx, cm, metav1.UpdateOptions{}); err != nil { - return nil, err - } - } - committed = true - return e.baseStatus(job) -} -func randomJobID() (string, error) { - id, err := randomID("mist-") - if err != nil { - return "", err - } - suffix, err := randomID("") - if err != nil { - return "", err - } - return id + suffix[:8], nil -} -func (s *TeamService) start() { - go func() { - defer close(s.done) - ticker := time.NewTicker(2 * time.Second) - defer ticker.Stop() - for { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - s.mu.Lock() - err := s.reconcile(ctx) - s.mu.Unlock() - cancel() - if err != nil { - s.app.log.Error("team reconciliation failed", "error", err) - } - select { - case <-s.stop: - return - case <-ticker.C: - } - } - }() -} -func (s *TeamService) shutdown() { close(s.stop); <-s.done } -func (s *TeamService) reconcile(ctx context.Context) error { - _, teams, err := s.registry(ctx) - if err != nil { - return err - } - for i := range teams { - t := &teams[i] - if err = s.provisionRequest(t); err != nil { - return err - } - if _, err = s.store(t); err != nil { - continue - } - if err = s.provisionNamespace(ctx, t); err != nil { - return err - } - } - if err = s.revokeJobs(ctx); err != nil { - return err - } - return s.admit(ctx, teams) -} -func (s *TeamService) revoked(ctx context.Context, t *Team, job *batchv1.Job, active map[string]AccountStatus) (string, error) { - if t == nil || t.Disabled { - return "Team disabled or unavailable", nil - } - id := job.Annotations["mist.io/creator-id"] - if t.member(id) == nil { - return "Team membership revoked", nil - } - status, ok := active[id] - if !ok { - return "Account unavailable", nil - } - var err error - var req CreateJobRequest - if err = json.Unmarshal([]byte(job.Annotations[requestAnnotation]), &req); err != nil { - return "Invalid stored submission", nil - } - m := &Member{ID: id, Role: status.Role} - if err = validateTeamRequest(t, m, &req); err != nil { - return "Team policy changed: " + err.Error(), nil - } - if err = validateImageReference(req.Image, t.Policy.Registries, s.app.executor.allowedImages); err != nil { - return "Image policy changed", nil - } - if req.DatasetID != "" { - if _, _, _, err = s.resolveDataset(ctx, t, req.DatasetID); err != nil { - return "Dataset access revoked", nil - } - } - return "", nil -} -func (s *TeamService) revokeJobs(ctx context.Context) error { - _, teams, err := s.registry(ctx) - if err != nil { - return err - } - // A fresh authoritative account snapshot is used for each reconciliation, so - // disabling an account also releases jobs even if its team membership remains. - active, err := s.app.auth.activeMembers(ctx) - if err != nil { - return err - } - jobs, err := s.app.executor.client.BatchV1().Jobs("").List(ctx, metav1.ListOptions{LabelSelector: "mist.io/team"}) - if err != nil { - return err - } - for _, job := range jobs.Items { - if terminal(&job) || job.Annotations[cancelAnnotation] != "" { - continue - } - reason, err := s.revoked(ctx, findTeam(teams, job.Labels["mist.io/team"]), &job, active) - if err != nil { - return err - } - if reason != "" { - if err = s.stopJob(ctx, &job, reason); err != nil { - return err - } - } - } - return nil -} -func (s *TeamService) stopJob(ctx context.Context, job *batchv1.Job, reason string) error { - e := *s.app.executor - e.namespace, e.owner = job.Namespace, job.Labels["mist.io/owner"] - logs, err := e.logs(ctx, job.Name) - if err != nil { - logs = "Logs unavailable at cancellation: " + err.Error() - } - if len(logs) > 32*1024 { - logs = logs[len(logs)-32*1024:] - } - return retry.RetryOnConflict(retry.DefaultRetry, func() error { - current, err := e.managedJob(ctx, job.Name) - if err != nil { - return err - } - if terminal(current) || current.Annotations[cancelAnnotation] != "" { - return nil - } - current.Spec.Suspend = ptr(true) - current.Annotations[cancelAnnotation] = time.Now().UTC().Format(time.RFC3339) - current.Annotations[cancelLogsAnnotation] = logs - current.Annotations["mist.io/stop-reason"] = reason - _, err = e.client.BatchV1().Jobs(job.Namespace).Update(ctx, current, metav1.UpdateOptions{}) - return err - }) -} - -type admissionUse struct { - CPU, Memory int64 - NVIDIA, TT, Concurrent int -} - -func (u *admissionUse) add(req CreateJobRequest) { - u.CPU += quantity(req.CPU).MilliValue() - u.Memory += quantity(req.Memory).Value() - if req.Accelerator == "nvidia" { - u.NVIDIA += req.DeviceCount - } - if req.Accelerator == "tenstorrent" { - u.TT += req.DeviceCount - } - u.Concurrent++ -} -func (u admissionUse) fits(p TeamPolicy, req CreateJobRequest) bool { - v := u - v.add(req) - return v.CPU <= quantity(p.CPU).MilliValue() && v.Memory <= quantity(p.Memory).Value() && v.NVIDIA <= p.NVIDIA && v.TT <= p.Tenstorrent && v.Concurrent <= p.Concurrent -} -func (s *TeamService) admit(ctx context.Context, teams []Team) error { - c := s.app.executor.client - jobs, err := c.BatchV1().Jobs("").List(ctx, metav1.ListOptions{LabelSelector: "mist.io/team"}) - if err != nil { - return err - } - pods, err := c.CoreV1().Pods("").List(ctx, metav1.ListOptions{}) - if err != nil { - return err - } - snapshot, err := s.app.executor.hardware(ctx) - if err != nil { - return err - } - machineFree := map[string]*admissionUse{} - ready := map[string]bool{} - for _, m := range snapshot.Machines { - machineFree[m.Name] = &admissionUse{CPU: quantity(m.CPUAllocatable).MilliValue(), Memory: quantity(m.MemoryAllocatable).Value()} - ready[m.Name] = m.Schedulable - } - nvidiaFree, ttFree := 0, 0 - for _, pool := range snapshot.Pools { - if pool.Ready && pool.Available != nil { - if pool.Accelerator == "nvidia" { - nvidiaFree = int(*pool.Available) - } else { - ttFree = int(*pool.Available) - } - } - } - placed := map[string]bool{} - ttAllocated := map[string]int{} - podJobs := map[string]string{} - claimJobs := map[string]string{} - for _, pod := range pods.Items { - jobName := pod.Labels["batch.kubernetes.io/job-name"] - if jobName == "" { - continue - } - jobKey := pod.Namespace + "/" + jobName - podJobs[pod.Namespace+"/"+pod.Name] = jobKey - for _, status := range pod.Status.ResourceClaimStatuses { - if status.ResourceClaimName != nil { - claimJobs[pod.Namespace+"/"+*status.ResourceClaimName] = jobKey - } - } - } - claims, err := c.ResourceV1().ResourceClaims("").List(ctx, metav1.ListOptions{}) - if err != nil { - return err - } - for _, claim := range claims.Items { - if claim.Status.Allocation == nil { - continue - } - for _, owner := range claim.OwnerReferences { - if owner.Kind == "Job" { - claimJobs[claim.Namespace+"/"+claim.Name] = claim.Namespace + "/" + owner.Name - } else if owner.Kind == "Pod" { - if jobKey := podJobs[claim.Namespace+"/"+owner.Name]; jobKey != "" { - claimJobs[claim.Namespace+"/"+claim.Name] = jobKey - } - } - } - if jobKey := claimJobs[claim.Namespace+"/"+claim.Name]; jobKey != "" { - for _, result := range claim.Status.Allocation.Devices.Results { - if result.Driver == "tenstorrent.com" { - ttAllocated[jobKey]++ - } - } - } - } - for _, pod := range pods.Items { - if pod.Status.Phase == corev1.PodSucceeded || pod.Status.Phase == corev1.PodFailed { - continue - } - if pod.Spec.NodeName != "" { - placed[pod.Namespace+"/"+pod.Labels["batch.kubernetes.io/job-name"]] = true - if free := machineFree[pod.Spec.NodeName]; free != nil { - var cpu, mem int64 - for _, ct := range pod.Spec.Containers { - a, b := ct.Resources.Requests[corev1.ResourceCPU], ct.Resources.Requests[corev1.ResourceMemory] - cpu += a.MilliValue() - mem += b.Value() - } - free.CPU -= cpu - free.Memory -= mem - } - } - } - use := map[string]*admissionUse{} - queue := map[string][]batchv1.Job{} - for _, t := range teams { - use[t.ID] = &admissionUse{} - } - for _, job := range jobs.Items { - if terminal(&job) || job.Annotations[cancelAnnotation] != "" { - continue - } - var req CreateJobRequest - if json.Unmarshal([]byte(job.Annotations[requestAnnotation]), &req) != nil { - continue - } - id := job.Labels["mist.io/team"] - if use[id] == nil { - continue - } - if job.Annotations["mist.io/admitted-at"] == "" { - if time.Since(queuedAt(&job)) > 24*time.Hour { - if err = s.stopJob(ctx, &job, "Queue wait exceeded 24 hours"); err != nil { - return err - } - continue - } - queue[id] = append(queue[id], job) - } else { - use[id].add(req) - if !placed[job.Namespace+"/"+job.Name] { - node := s.app.executor.cpuNode - if req.Accelerator == "tenstorrent" { - node = s.app.executor.ttNode - ttFree -= max(0, req.DeviceCount-ttAllocated[job.Namespace+"/"+job.Name]) - } - if req.Accelerator == "nvidia" { - nvidiaFree -= req.DeviceCount - } - if free := machineFree[node]; free != nil { - free.CPU -= quantity(req.CPU).MilliValue() - free.Memory -= quantity(req.Memory).Value() - } - } - } - } - cursor, err := c.CoreV1().ConfigMaps("mist-system").Get(ctx, "mist-queue-cursor", metav1.GetOptions{}) - if apierrors.IsNotFound(err) { - cursor = &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "mist-queue-cursor"}, Data: map[string]string{}} - } else if err != nil { - return err - } - sortedTeams(teams) - start := 0 - for i, t := range teams { - if t.ID == cursor.Data["last_team"] { - start = (i + 1) % len(teams) - } - } - aged := map[string]batchv1.Job{} - for _, t := range teams { - q := queue[t.ID] - sort.Slice(q, func(i, j int) bool { return queuedAt(&q[i]).Before(queuedAt(&q[j])) }) - queue[t.ID] = q - if t.Disabled || len(q) == 0 || time.Since(queuedAt(&q[0])) < 30*time.Second { - continue - } - var req CreateJobRequest - _ = json.Unmarshal([]byte(q[0].Annotations[requestAnnotation]), &req) - if !use[t.ID].fits(t.Policy, req) { - continue - } - node := s.app.executor.cpuNode - if req.Accelerator == "tenstorrent" { - node = s.app.executor.ttNode - } - if !ready[node] { - continue - } - if old, ok := aged[node]; !ok || queuedAt(&q[0]).Before(queuedAt(&old)) { - aged[node] = q[0] - } - } - admitted := false - for i := 0; i < len(teams); i++ { - t := teams[(start+i)%len(teams)] - if t.Disabled { - continue - } - q := queue[t.ID] - sort.Slice(q, func(i, j int) bool { return queuedAt(&q[i]).Before(queuedAt(&q[j])) }) - // FIFO within a team; one admission per team per pass. Large requests cannot - // be perpetually overtaken by smaller later submissions from the same team. - if len(q) == 0 { - continue - } - job := q[0] - var req CreateJobRequest - if json.Unmarshal([]byte(job.Annotations[requestAnnotation]), &req) != nil { - continue - } - if !use[t.ID].fits(t.Policy, req) { - continue - } - node := s.app.executor.cpuNode - if req.Accelerator == "tenstorrent" { - node = s.app.executor.ttNode - } - if reserved, ok := aged[node]; ok && reserved.Name != job.Name { - continue - } - free := machineFree[node] - if free == nil || !ready[node] || quantity(req.CPU).MilliValue() > free.CPU || quantity(req.Memory).Value() > free.Memory { - continue - } - if req.Accelerator == "nvidia" && req.DeviceCount > nvidiaFree || req.Accelerator == "tenstorrent" && req.DeviceCount > ttFree { - continue - } - err = retry.RetryOnConflict(retry.DefaultRetry, func() error { - fresh, err := c.BatchV1().Jobs(job.Namespace).Get(ctx, job.Name, metav1.GetOptions{}) - if err != nil { - return err - } - if fresh.Annotations[cancelAnnotation] != "" || fresh.Annotations["mist.io/admitted-at"] != "" { - return nil - } - fresh.Spec.Suspend = ptr(false) - fresh.Annotations["mist.io/admitted-at"] = time.Now().UTC().Format(time.RFC3339Nano) - _, err = c.BatchV1().Jobs(job.Namespace).Update(ctx, fresh, metav1.UpdateOptions{}) - return err - }) - if err != nil { - return err - } - admitted = true - cursor.Data["last_team"] = t.ID - use[t.ID].add(req) - free.CPU -= quantity(req.CPU).MilliValue() - free.Memory -= quantity(req.Memory).Value() - if req.Accelerator == "nvidia" { - nvidiaFree -= req.DeviceCount - } - if req.Accelerator == "tenstorrent" { - ttFree -= req.DeviceCount - } - } - if admitted { - if cursor.ResourceVersion == "" { - _, err = c.CoreV1().ConfigMaps("mist-system").Create(ctx, cursor, metav1.CreateOptions{}) - } else { - _, err = c.CoreV1().ConfigMaps("mist-system").Update(ctx, cursor, metav1.UpdateOptions{}) - } - return err - } - return nil -} - -// Silence is not successful admission: a queued job remains suspended and its -// status explains that resource availability and team limits govern placement. - -func queuedAt(job *batchv1.Job) time.Time { - t, err := time.Parse(time.RFC3339Nano, job.Annotations["mist.io/queued-at"]) - if err == nil { - return t - } - return job.CreationTimestamp.Time -} diff --git a/src/team_runtime.go b/src/team_runtime.go deleted file mode 100644 index 256f21a..0000000 --- a/src/team_runtime.go +++ /dev/null @@ -1,241 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "os" - "path/filepath" - "regexp" - "strings" - "time" - - "github.com/distribution/reference" - corev1 "k8s.io/api/core/v1" - networkv1 "k8s.io/api/networking/v1" - apierrors "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/apimachinery/pkg/api/resource" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/util/intstr" -) - -var registryPattern = regexp.MustCompile(`^[a-z0-9]+([.-][a-z0-9]+)*\.[a-z]{2,}$`) - -type permissionError struct{ error } - -func memberOwner(id string) string { m := &Member{ID: id}; return accountOwner(m) } -func accountOwner(m *Member) string { return "member-" + hashID(m.ID) } -func validateImageReference(image string, registries []string, local map[string]bool) error { - if local[image] && strings.HasPrefix(image, "mist-training:") { - return nil - } - named, err := reference.ParseNormalizedNamed(image) - if err != nil { - return errors.New("invalid container image reference") - } - if !contains(registries, reference.Domain(named)) { - return errors.New("image registry is not allowed by this team's policy") - } - if _, tagged := named.(reference.Tagged); !tagged { - if _, digested := named.(reference.Digested); !digested { - return errors.New("include an image tag or sha256 digest") - } - } - return nil -} -func validateTeamRequest(t *Team, m *Member, req *CreateJobRequest) error { - if t.Disabled || t.member(m.ID) == nil { - return &permissionError{errors.New("team membership was revoked")} - } - p := t.Policy - if quantity(req.CPU).Cmp(resource.MustParse(p.CPU)) > 0 || quantity(req.Memory).Cmp(resource.MustParse(p.Memory)) > 0 { - return errors.New("job exceeds the team's CPU or memory limit") - } - if req.Accelerator == "nvidia" && req.DeviceCount > p.NVIDIA || req.Accelerator == "tenstorrent" && req.DeviceCount > p.Tenstorrent { - return errors.New("job exceeds the team's accelerator limit") - } - if req.TimeoutSeconds > p.RuntimeSeconds { - return errors.New("job runtime exceeds team policy") - } - if req.StorageScope == "" { - req.StorageScope = m.ID - } - if req.StorageScope != m.ID && (req.StorageScope != "common" || (!t.member(m.ID).CommonWriter && !isAdmin(m))) { - return &permissionError{errors.New("You can write your own folder; common-folder writes require permission")} - } - if req.TTRuntime != "" && req.TTRuntime != "host" && req.TTRuntime != "container" { - return errors.New("tt_runtime must be host or container") - } - if req.Accelerator != "tenstorrent" && req.TTRuntime != "" { - return errors.New("tt_runtime applies only to Tenstorrent jobs") - } - if req.Accelerator == "tenstorrent" { - if req.TTRuntime == "" { - if req.Image == ttImage { - req.TTRuntime = "host" - } else { - req.TTRuntime = "container" - } - } - if req.TTRuntime == "host" && req.Image != ttImage { - return errors.New("host TT runtime requires the tested default image; use container runtime for custom images") - } - if req.Type == "training-smoke" && (req.Image != ttImage || req.TTRuntime != "host") { - return errors.New("TT training check requires the tested host runtime") - } - } - return nil -} -func atomicJSON(path string, v any) error { - data, err := json.Marshal(v) - if err != nil { - return err - } - if err = os.MkdirAll(filepath.Dir(path), 0700); err != nil { - return err - } - f, err := os.CreateTemp(filepath.Dir(path), ".mist-") - if err != nil { - return err - } - defer os.Remove(f.Name()) - if _, err = f.Write(data); err != nil { - f.Close() - return err - } - if err = f.Sync(); err != nil { - f.Close() - return err - } - if err = f.Close(); err != nil { - return err - } - return os.Rename(f.Name(), path) -} -func (s *TeamService) provisionRequest(t *Team) error { - return atomicJSON(filepath.Join(s.app.executor.storage.root, "metadata/provision-requests", t.ID+".json"), map[string]any{"id": t.ID, "storage_gib": t.Policy.StorageGiB, "model_storage_gib": t.Policy.ModelStorageGiB}) -} -func (s *TeamService) provisionNamespace(ctx context.Context, t *Team) error { - signature, _ := json.Marshal(t.Policy) - cached := s.provisioned[t.ID] - if cached.signature == string(signature) && time.Since(cached.checked) < 30*time.Second { - return nil - } - c := s.app.executor.client - ns := t.namespace() - _, err := c.CoreV1().Namespaces().Get(ctx, ns, metav1.GetOptions{}) - if apierrors.IsNotFound(err) { - _, err = c.CoreV1().Namespaces().Create(ctx, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: ns, Labels: map[string]string{"mist.io/workloads": "true", "mist.io/team": t.ID}}}, metav1.CreateOptions{}) - } - if err != nil { - return err - } - quota := &corev1.ResourceQuota{ObjectMeta: metav1.ObjectMeta{Name: "team-limits", Namespace: ns}, Spec: corev1.ResourceQuotaSpec{Hard: corev1.ResourceList{ - "requests.cpu": resource.MustParse(t.Policy.CPU), "limits.cpu": resource.MustParse(t.Policy.CPU), "requests.memory": resource.MustParse(t.Policy.Memory), "limits.memory": resource.MustParse(t.Policy.Memory), - "requests.nvidia.com/gpu": resource.MustParse(fmt.Sprint(t.Policy.NVIDIA)), "pods": resource.MustParse(fmt.Sprint(t.Policy.Concurrent)), - "requests.hugepages-1Gi": resource.MustParse(fmt.Sprintf("%dGi", t.Policy.Tenstorrent*2)), - }}} - old, err := c.CoreV1().ResourceQuotas(ns).Get(ctx, quota.Name, metav1.GetOptions{}) - if apierrors.IsNotFound(err) { - _, err = c.CoreV1().ResourceQuotas(ns).Create(ctx, quota, metav1.CreateOptions{}) - } else if err == nil && !quantitiesEqual(old.Spec.Hard, quota.Spec.Hard) { - quota.ResourceVersion = old.ResourceVersion - _, err = c.CoreV1().ResourceQuotas(ns).Update(ctx, quota, metav1.UpdateOptions{}) - } - if err != nil { - return err - } - policy := teamNetworkPolicy(ns) - if _, err = c.NetworkingV1().NetworkPolicies(ns).Get(ctx, policy.Name, metav1.GetOptions{}); apierrors.IsNotFound(err) { - _, err = c.NetworkingV1().NetworkPolicies(ns).Create(ctx, policy, metav1.CreateOptions{}) - } - if err != nil { - return err - } - for _, name := range []string{"training-smoke-script"} { - if _, err = c.CoreV1().ConfigMaps(ns).Get(ctx, name, metav1.GetOptions{}); apierrors.IsNotFound(err) { - source, readErr := c.CoreV1().ConfigMaps(s.app.executor.namespace).Get(ctx, name, metav1.GetOptions{}) - if readErr != nil { - return readErr - } - _, err = c.CoreV1().ConfigMaps(ns).Create(ctx, &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: name}, Data: source.Data}, metav1.CreateOptions{}) - } - if err != nil { - return err - } - } - for i := 1; i <= 4; i++ { - name := fmt.Sprintf("mist-tenstorrent-%d-boards", i) - if _, err = c.ResourceV1().ResourceClaimTemplates(ns).Get(ctx, name, metav1.GetOptions{}); apierrors.IsNotFound(err) { - source, readErr := c.ResourceV1().ResourceClaimTemplates(s.app.executor.namespace).Get(ctx, name, metav1.GetOptions{}) - if readErr != nil { - return readErr - } - source.ObjectMeta = metav1.ObjectMeta{Name: name} - _, err = c.ResourceV1().ResourceClaimTemplates(ns).Create(ctx, source, metav1.CreateOptions{}) - } - if err != nil { - return err - } - } - if err = s.ensureVolume(ctx, t.ID, ns, "team-datasets", fmt.Sprintf("teams/%s/datasets", t.ID), t.Policy.StorageGiB, false); err != nil { - return err - } - if err = s.ensureVolume(ctx, t.ID, ns, "team-results", fmt.Sprintf("teams/%s/results", t.ID), t.Policy.ModelStorageGiB, false); err != nil { - return err - } - s.provisioned[t.ID] = teamProvisioned{signature: string(signature), checked: time.Now()} - return nil -} -func quantitiesEqual(a, b corev1.ResourceList) bool { - if len(a) != len(b) { - return false - } - for k, v := range a { - w, ok := b[k] - if !ok || v.Cmp(w) != 0 { - return false - } - } - return true -} -func teamNetworkPolicy(ns string) *networkv1.NetworkPolicy { - return &networkv1.NetworkPolicy{ObjectMeta: metav1.ObjectMeta{Name: "team-boundary", Namespace: ns}, Spec: networkv1.NetworkPolicySpec{PodSelector: metav1.LabelSelector{}, PolicyTypes: []networkv1.PolicyType{networkv1.PolicyTypeIngress, networkv1.PolicyTypeEgress}, Ingress: []networkv1.NetworkPolicyIngressRule{}, Egress: []networkv1.NetworkPolicyEgressRule{ - {To: []networkv1.NetworkPolicyPeer{{NamespaceSelector: &metav1.LabelSelector{MatchLabels: map[string]string{"kubernetes.io/metadata.name": "kube-system"}}, PodSelector: &metav1.LabelSelector{MatchLabels: map[string]string{"k8s-app": "kube-dns"}}}}, Ports: []networkv1.NetworkPolicyPort{{Protocol: ptr(corev1.ProtocolUDP), Port: ptr(intstr.FromInt32(53))}, {Protocol: ptr(corev1.ProtocolTCP), Port: ptr(intstr.FromInt32(53))}}}, - {To: []networkv1.NetworkPolicyPeer{{IPBlock: &networkv1.IPBlock{CIDR: "0.0.0.0/0", Except: []string{"0.0.0.0/8", "10.0.0.0/8", "100.64.0.0/10", "127.0.0.0/8", "169.254.0.0/16", "172.16.0.0/12", "192.168.0.0/16", "224.0.0.0/4", "240.0.0.0/4"}}}}, Ports: []networkv1.NetworkPolicyPort{{Protocol: ptr(corev1.ProtocolTCP), Port: ptr(intstr.FromInt32(443))}}}, - }}} -} -func (s *TeamService) ensureVolume(ctx context.Context, sourceTeam, namespace, claim, path string, gib int64, readonly bool) error { - c := s.app.executor.client - name := namespace + "-" + claim - pv := &corev1.PersistentVolume{ObjectMeta: metav1.ObjectMeta{Name: name, Labels: map[string]string{"mist.io/team": sourceTeam}}, Spec: corev1.PersistentVolumeSpec{Capacity: corev1.ResourceList{corev1.ResourceStorage: resource.MustParse(fmt.Sprintf("%dGi", gib))}, AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteMany}, PersistentVolumeReclaimPolicy: corev1.PersistentVolumeReclaimRetain, StorageClassName: "", ClaimRef: &corev1.ObjectReference{Name: claim, Namespace: namespace}, MountOptions: []string{"nfsvers=4.1", "hard", "timeo=100", "retrans=2"}, PersistentVolumeSource: corev1.PersistentVolumeSource{NFS: &corev1.NFSVolumeSource{Server: envOr("MIST_NFS_SERVER", "192.168.240.2"), Path: envOr("MIST_NFS_ROOT", "/srv/mist-data") + "/" + path, ReadOnly: readonly}}}} - if existing, err := c.CoreV1().PersistentVolumes().Get(ctx, name, metav1.GetOptions{}); apierrors.IsNotFound(err) { - if _, err = c.CoreV1().PersistentVolumes().Create(ctx, pv, metav1.CreateOptions{}); err != nil { - return err - } - } else if err != nil { - return err - } else if existing.Spec.NFS == nil || *existing.Spec.NFS != *pv.Spec.NFS { - return fmt.Errorf("storage volume %s points at a different export; replace its retained binding during storage cutover", name) - } else if existing.Spec.Capacity.Storage().Cmp(pv.Spec.Capacity[corev1.ResourceStorage]) < 0 { - existing.Spec.Capacity = pv.Spec.Capacity - if _, err = c.CoreV1().PersistentVolumes().Update(ctx, existing, metav1.UpdateOptions{}); err != nil { - return err - } - } - pvc := &corev1.PersistentVolumeClaim{ObjectMeta: metav1.ObjectMeta{Name: claim, Namespace: namespace}, Spec: corev1.PersistentVolumeClaimSpec{AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteMany}, StorageClassName: ptr(""), VolumeName: name, Resources: corev1.VolumeResourceRequirements{Requests: corev1.ResourceList{corev1.ResourceStorage: resource.MustParse(fmt.Sprintf("%dGi", gib))}}}} - if _, err := c.CoreV1().PersistentVolumeClaims(namespace).Get(ctx, claim, metav1.GetOptions{}); apierrors.IsNotFound(err) { - _, err = c.CoreV1().PersistentVolumeClaims(namespace).Create(ctx, pvc, metav1.CreateOptions{}) - return err - } else { - return err - } -} -func quantity(value string) *resource.Quantity { - if value == "" { - value = "0" - } - q := resource.MustParse(value) - return &q -} diff --git a/src/team_storage.go b/src/team_storage.go deleted file mode 100644 index d402fb3..0000000 --- a/src/team_storage.go +++ /dev/null @@ -1,422 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "errors" - "net/http" - "os" - "path/filepath" - "sort" - "strings" - "time" - - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -func scopePath(scope string) string { - if scope == "common" { - return "common" - } - return "members/" + hashID(scope) -} - -type StorageFolder struct { - TeamID string `json:"team_id"` - TeamName string `json:"team_name"` - Scope string `json:"scope"` - Name string `json:"name"` - Shared bool `json:"shared"` - Writable bool `json:"writable"` -} - -func (a *App) storageFolders(w http.ResponseWriter, r *http.Request) { - e := a.requestExecutor(r) - if e.team == nil { - writeJSON(w, 200, map[string]any{"folders": []StorageFolder{}}) - return - } - _, teams, err := a.teams.registry(r.Context()) - if err != nil { - executorError(w, err) - return - } - folders := []StorageFolder{} - for _, source := range teams { - if source.Disabled { - continue - } - scopes := map[string]string{} - if source.ID == e.team.Team.ID { - scopes["common"] = "Common" - for _, m := range source.Members { - scopes[m.ID] = m.Name - } - jobs, err := e.client.BatchV1().Jobs(e.namespace).List(r.Context(), metav1.ListOptions{LabelSelector: managedLabel + "=mist"}) - if err != nil { - executorError(w, err) - return - } - for _, job := range jobs.Items { - scope := job.Annotations["mist.io/storage-scope"] - if scope != "" && scope != "common" { - if _, exists := scopes[scope]; !exists { - scopes[scope] = job.Annotations["mist.io/creator-name"] + " (retained files)" - } - } - } - list, err := a.teams.accessibleDatasets(r.Context(), e.team.Team) - if err != nil { - executorError(w, err) - return - } - for _, ds := range list { - if ds.TeamID == source.ID && ds.Scope != "" { - if _, ok := scopes[ds.Scope]; !ok { - scopes[ds.Scope] = ds.MemberName + " (retained files)" - } - } - } - } - for _, g := range source.Grants { - if g.TargetTeam == e.team.Team.ID { - if g.Scope == "common" { - scopes[g.Scope] = "Common" - } else if m := source.member(g.Scope); m != nil { - scopes[g.Scope] = m.Name - } else { - scopes[g.Scope] = "Former member" - } - } - } - for scope, name := range scopes { - folders = append(folders, StorageFolder{TeamID: source.ID, TeamName: source.Name, Scope: scope, Name: name, Shared: source.ID != e.team.Team.ID, Writable: source.ID == e.team.Team.ID && a.teams.canWrite(r.Context(), e.team, scope) == nil}) - } - } - sort.Slice(folders, func(i, j int) bool { - if folders[i].TeamName == folders[j].TeamName { - return folders[i].Name < folders[j].Name - } - return folders[i].TeamName < folders[j].TeamName - }) - writeJSON(w, 200, map[string]any{"folders": folders}) -} -func (a *App) storageFolderFiles(w http.ResponseWriter, r *http.Request) { - e := a.requestExecutor(r) - if e.team == nil { - writeJSON(w, 400, map[string]string{"error": "Select a team workspace"}) - return - } - sourceID, scope := r.URL.Query().Get("source_team"), r.URL.Query().Get("scope") - if sourceID == "" { - sourceID = e.team.Team.ID - } - source, err := a.teams.team(r.Context(), sourceID) - if err != nil || source.Disabled { - writeJSON(w, 404, map[string]string{"error": "folder not found"}) - return - } - authorized := sourceID == e.team.Team.ID - if !authorized { - for _, grant := range source.Grants { - if grant.TargetTeam == e.team.Team.ID && grant.Scope == scope { - authorized = true - } - } - } - if !authorized || (scope != "common" && (scope == "" || len(scope) > 128 || strings.ContainsAny(scope, "/\\\x00"))) { - writeJSON(w, 404, map[string]string{"error": "folder not found"}) - return - } - store, err := a.teams.store(source) - if err != nil { - executorError(w, err) - return - } - serveTeamFolder(w, r, store, scope) -} -func (s *TeamService) resolveDataset(ctx context.Context, target *Team, id string) (*SharedStorage, *Team, *Dataset, error) { - if !datasetIDPattern.MatchString(id) { - return nil, nil, nil, os.ErrNotExist - } - if store, err := s.store(target); err == nil { - if ds, err := store.dataset(id, target.ID); err == nil { - return store, target, ds, nil - } - } - _, teams, err := s.registry(ctx) - if err != nil { - return nil, nil, nil, err - } - for i := range teams { - source := &teams[i] - if source.Disabled || source.ID == target.ID { - continue - } - scopes := map[string]bool{} - for _, g := range source.Grants { - if g.TargetTeam == target.ID { - scopes[g.Scope] = true - } - } - if len(scopes) == 0 { - continue - } - store, err := s.store(source) - if err != nil { - continue - } - ds, err := store.dataset(id, source.ID) - if err == nil && scopes[ds.Scope] { - ds.Shared = true - return store, source, ds, nil - } - } - return nil, nil, nil, os.ErrNotExist -} -func (s *TeamService) accessibleDatasets(ctx context.Context, target *Team) ([]Dataset, error) { - _, teams, err := s.registry(ctx) - if err != nil { - return nil, err - } - result := []Dataset{} - for i := range teams { - source := &teams[i] - if source.Disabled { - continue - } - scopes := map[string]bool{} - for _, g := range source.Grants { - if g.TargetTeam == target.ID { - scopes[g.Scope] = true - } - } - if source.ID != target.ID && len(scopes) == 0 { - continue - } - store, err := s.store(source) - if err != nil { - if source.ID == target.ID { - return nil, err - } - continue - } - entries, err := os.ReadDir(filepath.Join(store.root, "metadata/datasets")) - if err != nil { - return nil, err - } - for _, entry := range entries { - ds, err := store.dataset(strings.TrimSuffix(entry.Name(), ".json"), source.ID) - if err != nil { - continue - } - if source.ID == target.ID || scopes[ds.Scope] { - ds.TeamName = source.Name - ds.Shared = source.ID != target.ID - result = append(result, *ds) - } - } - } - sort.Slice(result, func(i, j int) bool { return result[i].Created.After(result[j].Created) }) - return result, nil -} -func (s *TeamService) canWrite(ctx context.Context, access *TeamAccess, scope string) error { - t, err := s.team(ctx, access.Team.ID) - if err != nil { - return err - } - m := t.member(access.Member.ID) - if t.Disabled || m == nil { - return &permissionError{errors.New("Team membership was revoked")} - } - if scope == access.Member.ID { - return nil - } - if scope == "common" && (m.CommonWriter || isAdmin(access.Member)) { - return nil - } - return &permissionError{errors.New("You can manage your own folder; common-folder writes require permission")} -} -func (a *App) teamDatasets(w http.ResponseWriter, r *http.Request, e *KubernetesExecutor) { - access := e.team - switch r.Method { - case http.MethodGet: - list, err := a.teams.accessibleDatasets(r.Context(), access.Team) - if err != nil { - executorError(w, err) - return - } - writeJSON(w, 200, map[string]any{"datasets": list, "upload_limit_bytes": e.storage.availableUploadLimit(true)}) - case http.MethodPost: - controller := http.NewResponseController(w) - _ = controller.SetReadDeadline(time.Now().Add(24 * time.Hour)) - _ = controller.SetWriteDeadline(time.Now().Add(24 * time.Hour)) - scope := r.URL.Query().Get("scope") - if scope == "" { - scope = access.Member.ID - } - if err := a.teams.canWrite(r.Context(), access, scope); err != nil { - executorError(w, err) - return - } - // upload() rechecks membership/permissions before atomically publishing. - r.Body = http.MaxBytesReader(w, r.Body, e.storage.uploadLimit+1) - ds, err := e.storage.upload(r, access.Team.ID) - if err != nil { - executorError(w, err) - return - } - writeJSON(w, 201, ds) - default: - writeJSON(w, 405, map[string]string{"error": "method not allowed"}) - } -} -func (a *App) teamDataset(w http.ResponseWriter, r *http.Request, e *KubernetesExecutor, id string) { - store, source, ds, err := a.teams.resolveDataset(r.Context(), e.team.Team, id) - if err != nil { - writeJSON(w, 404, map[string]string{"error": "dataset not found"}) - return - } - switch r.Method { - case http.MethodGet: - writeJSON(w, 200, ds) - case http.MethodDelete: - if source.ID != e.team.Team.ID { - writeJSON(w, 403, map[string]string{"error": "Shared datasets are read-only"}) - return - } - a.teams.mu.Lock() - defer a.teams.mu.Unlock() - if err = a.teams.canWrite(r.Context(), e.team, ds.Scope); err != nil { - executorError(w, err) - return - } - store.mu.Lock() - defer store.mu.Unlock() - jobs, err := e.client.BatchV1().Jobs("").List(r.Context(), metav1.ListOptions{LabelSelector: managedLabel + "=mist"}) - if err != nil { - executorError(w, err) - return - } - for _, job := range jobs.Items { - var req CreateJobRequest - _ = json.Unmarshal([]byte(job.Annotations[requestAnnotation]), &req) - if req.DatasetID == id && !terminal(&job) { - pods, err := e.client.CoreV1().Pods(job.Namespace).List(r.Context(), metav1.ListOptions{LabelSelector: "batch.kubernetes.io/job-name=" + job.Name}) - if err != nil { - executorError(w, err) - return - } - if job.Annotations[cancelAnnotation] == "" || len(pods.Items) > 0 { - writeJSON(w, 409, map[string]string{"error": "Dataset is referenced by an active, queued or terminating job"}) - return - } - } - } - if err = os.Remove(filepath.Join(store.root, "metadata/datasets", id+".json")); err != nil { - executorError(w, err) - return - } - path, err := store.contentPath(ds) - if err != nil { - executorError(w, err) - return - } - if err = os.RemoveAll(filepath.Dir(path)); err != nil { - executorError(w, err) - return - } - writeJSON(w, 200, map[string]string{"id": id, "state": "Deleted"}) - default: - writeJSON(w, 405, map[string]string{"error": "method not allowed"}) - } -} -func (s *SharedStorage) contentPath(ds *Dataset) (string, error) { - path := ds.ContentPath - if path == "" { - path = "datasets/" + ds.ID + "/content" - } - if !safeRelative(path) { - return "", errors.New("invalid dataset storage path") - } - return filepath.Join(s.root, path), nil -} -func (a *App) datasetFiles(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - writeJSON(w, 405, map[string]string{"error": "method not allowed"}) - return - } - parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/datasets/"), "/") - if len(parts) < 2 || len(parts) > 3 || parts[1] != "files" || (len(parts) == 3 && parts[2] != "download") { - writeJSON(w, 404, map[string]string{"error": "not found"}) - return - } - e := a.requestExecutor(r) - store := e.storage - if store == nil { - writeJSON(w, 503, map[string]string{"error": "Storage is unavailable"}) - return - } - var ds *Dataset - var err error - if e.team != nil { - store, _, ds, err = a.teams.resolveDataset(r.Context(), e.team.Team, parts[0]) - } else { - ds, err = store.dataset(parts[0], e.owner) - } - if err != nil { - writeJSON(w, 404, map[string]string{"error": "dataset not found"}) - return - } - path, err := store.contentPath(ds) - if err != nil { - executorError(w, err) - return - } - serveStoredFiles(w, r, path, len(parts) == 3) -} - -// Keep the public common/member tree stable across dataset and result directories. -func serveTeamFolder(w http.ResponseWriter, r *http.Request, store *SharedStorage, scope string) { - data := filepath.Join(store.root, scopePath(scope)) - models := filepath.Join(store.resultsRoot(), scopePath(scope)) - if r.URL.Query().Get("download") == "true" { - path := r.URL.Query().Get("path") - if strings.HasPrefix(path, "jobs/") { - serveStoredFiles(w, r, models, true) - } else if strings.HasPrefix(path, "datasets/") { - serveStoredFiles(w, r, data, true) - } else { - writeJSON(w, 400, map[string]string{"error": "Invalid folder file path"}) - } - return - } - files := []ResultFile{} - for _, path := range []string{filepath.Join(data, "datasets"), filepath.Join(models, "jobs")} { - root, err := os.OpenRoot(path) - if os.IsNotExist(err) { - continue - } - if err != nil { - executorError(w, err) - return - } - listed, err := listResultFiles(root) - root.Close() - if err != nil { - executorError(w, err) - return - } - prefix := filepath.Base(path) + "/" - for _, f := range listed { - f.Path = prefix + f.Path - files = append(files, f) - } - if len(files) > 10000 { - writeJSON(w, 400, map[string]string{"error": "folder has too many files"}) - return - } - } - sort.Slice(files, func(i, j int) bool { return files[i].Path < files[j].Path }) - writeJSON(w, 200, map[string]any{"files": files}) -} diff --git a/src/teams.go b/src/teams.go deleted file mode 100644 index 9af698c..0000000 --- a/src/teams.go +++ /dev/null @@ -1,546 +0,0 @@ -package main - -// Teams are domain policy, independent of the authentication provider. Identity -// comes from the authentication service; ordinary clients never supply roles. -import ( - "context" - "crypto/rand" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "os" - "path/filepath" - "regexp" - "sort" - "strings" - "sync" - "syscall" - "time" - - corev1 "k8s.io/api/core/v1" - apierrors "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/apimachinery/pkg/api/resource" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/client-go/util/retry" -) - -const teamRegistry = "mist-teams" -const maxStorageAllowanceGiB int64 = 1 << 20 -const sharedFolderLayout = "shared-folders-v1" - -var teamIDPattern = regexp.MustCompile(`^team-[a-f0-9]{16}$`) - -type TeamPolicy struct { - CPU string `json:"cpu"` - Memory string `json:"memory"` - NVIDIA int `json:"nvidia"` - Tenstorrent int `json:"tenstorrent"` - Concurrent int `json:"concurrent"` - Queued int `json:"queued"` - StorageGiB int64 `json:"storage_gib"` // Advisory dataset allowance; retained wire field. - ModelStorageGiB int64 `json:"model_storage_gib"` - RuntimeSeconds int64 `json:"runtime_seconds"` - Registries []string `json:"registries"` -} -type TeamMember struct { - ID string `json:"id"` - Name string `json:"name"` - Email string `json:"email"` - CommonWriter bool `json:"common_writer"` -} -type StorageGrant struct { - ID string `json:"id"` - TargetTeam string `json:"target_team"` - Scope string `json:"scope"` -} -type Team struct { - ID string `json:"id"` - Name string `json:"name"` - Created time.Time `json:"created"` - Disabled bool `json:"disabled"` - Policy TeamPolicy `json:"policy"` - Members []TeamMember `json:"members"` - Grants []StorageGrant `json:"grants"` -} -type TeamService struct { - app *App - mu sync.Mutex // admissions and policy changes are serialized on the single controller - storageMu sync.Mutex - stores map[string]*SharedStorage - provisioned map[string]teamProvisioned - stop chan struct{} - done chan struct{} -} -type teamProvisioned struct { - signature string - checked time.Time -} -type teamContextKey struct{} -type TeamAccess struct { - Team *Team - Member *Member - Store *SharedStorage - Service *TeamService -} - -func isAdmin(m *Member) bool { return m != nil && contains(strings.Split(m.Role, ","), "admin") } -func contains(values []string, value string) bool { - for _, v := range values { - if v == value { - return true - } - } - return false -} -func (t *Team) member(id string) *TeamMember { - for i := range t.Members { - if t.Members[i].ID == id { - return &t.Members[i] - } - } - return nil -} -func (t *Team) namespace() string { return "mist-" + t.ID } -func defaultTeamPolicy() TeamPolicy { - return TeamPolicy{CPU: "8", Memory: "32Gi", NVIDIA: 2, Tenstorrent: 4, Concurrent: 2, Queued: 50, StorageGiB: 200, ModelStorageGiB: 500, RuntimeSeconds: 86400, Registries: []string{"docker.io", "ghcr.io", "nvcr.io"}} -} - -// storageBudgetGiB retains the existing response field as informational physical -// shared capacity. Advisory allowances are not added up or reserved on disk. -func (s *TeamService) storageBudgetGiB() int64 { - var fs syscall.Statfs_t - if syscall.Statfs(s.app.executor.storage.root, &fs) != nil { - return 0 - } - return int64(fs.Blocks) * fs.Bsize / (1024 * 1024 * 1024) -} -func normalizeStoragePolicy(p *TeamPolicy) { - // Preserve legacy callers which supplied one advisory allowance. - if p.ModelStorageGiB == 0 { - p.ModelStorageGiB = p.StorageGiB - } -} -func validateTeam(team *Team) error { - normalizeStoragePolicy(&team.Policy) - if strings.TrimSpace(team.Name) == "" || len(team.Name) > 100 { - return errors.New("team name must contain 1–100 characters") - } - p := team.Policy - cpu, err := resource.ParseQuantity(p.CPU) - if err != nil || cpu.Sign() <= 0 || cpu.Cmp(resource.MustParse("32")) > 0 { - return errors.New("team CPU must be greater than zero and at most 32") - } - mem, err := resource.ParseQuantity(p.Memory) - if err != nil || mem.Cmp(resource.MustParse("64Mi")) < 0 || mem.Cmp(resource.MustParse("128Gi")) > 0 { - return errors.New("team memory must be between 64Mi and 128Gi") - } - if p.NVIDIA < 0 || p.NVIDIA > 2 || p.Tenstorrent < 0 || p.Tenstorrent > 4 || p.Concurrent < 1 || p.Concurrent > 16 || p.Queued < 1 || p.Queued > 200 || p.StorageGiB < 1 || p.StorageGiB > maxStorageAllowanceGiB || p.ModelStorageGiB < 1 || p.ModelStorageGiB > maxStorageAllowanceGiB || p.RuntimeSeconds < 10 || p.RuntimeSeconds > 86400 { - return errors.New("invalid team device, concurrency, queue, storage or runtime limits") - } - if len(p.Registries) > 20 { - return errors.New("too many image registries") - } - for _, host := range p.Registries { - if !registryPattern.MatchString(host) || strings.Contains(host, "..") { - return errors.New("image registries must be lowercase public registry hostnames") - } - } - return nil -} -func newTeamService(a *App) *TeamService { - return &TeamService{app: a, stores: map[string]*SharedStorage{}, provisioned: map[string]teamProvisioned{}, stop: make(chan struct{}), done: make(chan struct{})} -} -func (s *TeamService) registry(ctx context.Context) (*corev1.ConfigMap, []Team, error) { - cm, err := s.app.executor.client.CoreV1().ConfigMaps("mist-system").Get(ctx, teamRegistry, metav1.GetOptions{}) - if apierrors.IsNotFound(err) { - return &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: teamRegistry, Namespace: "mist-system"}, Data: map[string]string{"teams": "[]"}}, []Team{}, nil - } - if err != nil { - return nil, nil, err - } - var teams []Team - if err = json.Unmarshal([]byte(cm.Data["teams"]), &teams); err != nil { - return nil, nil, fmt.Errorf("invalid persisted team registry: %w", err) - } - for i := range teams { - normalizeStoragePolicy(&teams[i].Policy) - } - return cm, teams, nil -} -func (s *TeamService) mutate(ctx context.Context, change func(*[]Team) error) error { - return retry.RetryOnConflict(retry.DefaultRetry, func() error { - cm, teams, err := s.registry(ctx) - if err != nil { - return err - } - if err = change(&teams); err != nil { - return &submissionError{err} - } - data, err := json.Marshal(teams) - if err != nil { - return err - } - if len(data) > 240*1024 { - return &submissionError{errors.New("team registry is full")} - } - cm.Data["teams"] = string(data) - if cm.ResourceVersion == "" { - _, err = s.app.executor.client.CoreV1().ConfigMaps("mist-system").Create(ctx, cm, metav1.CreateOptions{}) - if apierrors.IsAlreadyExists(err) { - return apierrors.NewConflict(corev1.Resource("configmaps"), teamRegistry, err) - } - } else { - _, err = s.app.executor.client.CoreV1().ConfigMaps("mist-system").Update(ctx, cm, metav1.UpdateOptions{}) - } - return err - }) -} -func findTeam(teams []Team, id string) *Team { - for i := range teams { - if teams[i].ID == id { - return &teams[i] - } - } - return nil -} -func (s *TeamService) team(ctx context.Context, id string) (*Team, error) { - _, teams, err := s.registry(ctx) - if err != nil { - return nil, err - } - t := findTeam(teams, id) - if t == nil { - return nil, os.ErrNotExist - } - return t, nil -} -func (s *TeamService) store(t *Team) (*SharedStorage, error) { - s.storageMu.Lock() - defer s.storageMu.Unlock() - status, err := os.ReadFile(filepath.Join(s.app.executor.storage.root, "metadata/provision-status", t.ID+".json")) - if err != nil { - return nil, errors.New("team storage is being provisioned") - } - var ready teamStorageStatus - if json.Unmarshal(status, &ready) != nil || ready.State != "Ready" || ready.Layout != sharedFolderLayout { - return nil, errors.New("team storage is not ready") - } - base := filepath.Join(s.app.executor.storage.root, "teams", t.ID) - for _, path := range []string{base, filepath.Join(base, "datasets"), filepath.Join(base, "results")} { - info, err := os.Lstat(path) - if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { - return nil, errors.New("team storage directories are unavailable") - } - } - if st := s.stores[t.ID]; st != nil { - return st, nil - } - st, err := NewSharedStorage(filepath.Join(base, "datasets")) - if err != nil { - return nil, err - } - st.modelRoot = filepath.Join(base, "results") - - s.stores[t.ID] = st - return st, nil -} -func (s *TeamService) middleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - id := r.Header.Get("X-Mist-Team") - if id == "" { - id = r.URL.Query().Get("team_id") - } - if id == "" || strings.HasPrefix(r.URL.Path, "/teams") || r.URL.Path == "/session" || r.URL.Path == "/healthz" || strings.HasPrefix(r.URL.Path, "/auth/") { - if id == "" && r.Method == http.MethodPost && (r.URL.Path == "/jobs" || r.URL.Path == "/datasets") { - writeJSON(w, 403, map[string]string{"error": "Select a team workspace before submitting jobs or uploading files"}) - return - } - next.ServeHTTP(w, r) - return - } - m, _ := r.Context().Value(memberKey{}).(*Member) - if m == nil { - writeJSON(w, 401, map[string]string{"error": "Sign in to Mist"}) - return - } - t, err := s.team(r.Context(), id) - if err != nil { - if !os.IsNotExist(err) { - executorError(w, err) - } else { - writeJSON(w, 404, map[string]string{"error": "team not found"}) - } - return - } - if t.Disabled || t.member(m.ID) == nil { - writeJSON(w, 403, map[string]string{"error": "You are not an active member of this team"}) - return - } - store, err := s.store(t) - if err != nil { - writeJSON(w, 503, map[string]string{"error": err.Error()}) - return - } - access := &TeamAccess{Team: t, Member: m, Store: store, Service: s} - next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), teamContextKey{}, access))) - }) -} -func randomID(prefix string) (string, error) { - b := make([]byte, 8) - _, err := rand.Read(b) - return prefix + hex.EncodeToString(b), err -} -func decodeTeamJSON(w http.ResponseWriter, r *http.Request, value any) error { - d := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64*1024)) - d.DisallowUnknownFields() - if err := d.Decode(value); err != nil { - return &submissionError{err} - } - if d.Decode(new(any)) != io.EOF { - return &submissionError{errors.New("expected one JSON object")} - } - return nil -} -func (a *App) teamEndpoint(w http.ResponseWriter, r *http.Request) { - s := a.teams - if s == nil { - writeJSON(w, 503, map[string]string{"error": "Team workspaces are not enabled"}) - return - } - m, _ := r.Context().Value(memberKey{}).(*Member) - if r.URL.Path == "/teams" && r.Method == http.MethodGet { - _, teams, err := s.registry(r.Context()) - if err != nil { - executorError(w, err) - return - } - result := []Team{} - for _, t := range teams { - if isAdmin(m) || t.member(m.ID) != nil { - result = append(result, t) - } - } - writeJSON(w, 200, map[string]any{"teams": result, "storage_budget_gib": s.storageBudgetGiB(), "storage_limits_enforced": false}) - return - } - if !isAdmin(m) { - writeJSON(w, 403, map[string]string{"error": "Administrator access required"}) - return - } - s.mu.Lock() - defer s.mu.Unlock() - parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/teams"), "/") - if r.URL.Path == "/teams" && r.Method == http.MethodPost { - var body struct { - Name string `json:"name"` - StorageGiB *int64 `json:"storage_gib"` - ModelStorageGiB *int64 `json:"model_storage_gib"` - Policy *TeamPolicy `json:"policy"` - } - if err := decodeTeamJSON(w, r, &body); err != nil { - executorError(w, err) - return - } - id, err := randomID("team-") - if err != nil { - executorError(w, err) - return - } - t := Team{ID: id, Name: body.Name, Created: time.Now().UTC(), Policy: defaultTeamPolicy(), Members: []TeamMember{}, Grants: []StorageGrant{}} - if body.Policy != nil { - t.Policy = *body.Policy - } - if body.StorageGiB != nil { - t.Policy.StorageGiB = *body.StorageGiB - if body.ModelStorageGiB == nil { - t.Policy.ModelStorageGiB = *body.StorageGiB - } - } - if body.ModelStorageGiB != nil { - if *body.ModelStorageGiB < 1 { - executorError(w, &submissionError{errors.New("model storage must be at least 1 GiB")}) - return - } - t.Policy.ModelStorageGiB = *body.ModelStorageGiB - } - if err = validateTeam(&t); err != nil { - executorError(w, &submissionError{err}) - return - } - err = s.mutate(r.Context(), func(list *[]Team) error { - if len(*list) >= 100 { - return errors.New("at most 100 teams are supported") - } - *list = append(*list, t) - return nil - }) - if err != nil { - executorError(w, err) - return - } - writeJSON(w, 201, t) - return - } - if len(parts) < 2 || !teamIDPattern.MatchString(parts[1]) { - writeJSON(w, 404, map[string]string{"error": "team not found"}) - return - } - id := parts[1] - switch { - case len(parts) == 2 && r.Method == http.MethodPatch: - var body struct { - Name *string `json:"name"` - Policy *TeamPolicy `json:"policy"` - Disabled *bool `json:"disabled"` - } - if err := decodeTeamJSON(w, r, &body); err != nil { - executorError(w, err) - return - } - err := s.mutate(r.Context(), func(list *[]Team) error { - t := findTeam(*list, id) - if t == nil { - return errors.New("team not found") - } - if body.Name != nil { - t.Name = *body.Name - } - if body.Disabled != nil { - t.Disabled = *body.Disabled - } - if body.Policy != nil { - normalizeStoragePolicy(body.Policy) - t.Policy = *body.Policy - } - return validateTeam(t) - }) - if err != nil { - executorError(w, err) - return - } - case len(parts) == 3 && parts[2] == "members" && r.Method == http.MethodPost: - var body struct { - UserID string `json:"user_id"` - CommonWriter bool `json:"common_writer"` - } - if err := decodeTeamJSON(w, r, &body); err != nil { - executorError(w, err) - return - } - member, err := a.auth.lookupMember(r, body.UserID) - if err != nil { - executorError(w, &submissionError{errors.New("select an existing active Mist account")}) - return - } - err = s.mutate(r.Context(), func(list *[]Team) error { - t := findTeam(*list, id) - if t == nil { - return errors.New("team not found") - } - entry := TeamMember{ID: member.ID, Name: member.Name, Email: member.Email, CommonWriter: body.CommonWriter} - if old := t.member(member.ID); old != nil { - *old = entry - } else { - t.Members = append(t.Members, entry) - } - return nil - }) - if err != nil { - executorError(w, err) - return - } - case len(parts) == 4 && parts[2] == "members" && r.Method == http.MethodDelete: - err := s.mutate(r.Context(), func(list *[]Team) error { - t := findTeam(*list, id) - if t == nil { - return errors.New("team not found") - } - members := []TeamMember{} - for _, member := range t.Members { - if member.ID != parts[3] { - members = append(members, member) - } - } - t.Members = members - return nil - }) - if err != nil { - executorError(w, err) - return - } - case len(parts) == 3 && parts[2] == "grants" && r.Method == http.MethodPost: - var body struct { - TargetTeam string `json:"target_team"` - Scope string `json:"scope"` - } - if err := decodeTeamJSON(w, r, &body); err != nil { - executorError(w, err) - return - } - grantID, err := randomID("grant-") - if err != nil { - executorError(w, err) - return - } - err = s.mutate(r.Context(), func(list *[]Team) error { - t := findTeam(*list, id) - target := findTeam(*list, body.TargetTeam) - if t == nil || target == nil || target.Disabled || target.ID == id { - return errors.New("select a different active target team") - } - if body.Scope != "common" && t.member(body.Scope) == nil { - return errors.New("scope must be common or a source team member ID") - } - for _, g := range t.Grants { - if g.TargetTeam == body.TargetTeam && g.Scope == body.Scope { - return errors.New("this grant already exists") - } - } - t.Grants = append(t.Grants, StorageGrant{ID: grantID, TargetTeam: body.TargetTeam, Scope: body.Scope}) - return nil - }) - if err != nil { - executorError(w, err) - return - } - case len(parts) == 4 && parts[2] == "grants" && r.Method == http.MethodDelete: - err := s.mutate(r.Context(), func(list *[]Team) error { - t := findTeam(*list, id) - if t == nil { - return errors.New("team not found") - } - grants := []StorageGrant{} - for _, g := range t.Grants { - if g.ID != parts[3] { - grants = append(grants, g) - } - } - t.Grants = grants - return nil - }) - if err != nil { - executorError(w, err) - return - } - default: - writeJSON(w, 405, map[string]string{"error": "method not allowed"}) - return - } - // Reconcile revoked job access before reporting successful policy changes. - if err := s.revokeJobs(r.Context()); err != nil { - writeJSON(w, 503, map[string]string{"error": "Policy saved; job access reconciliation pending: " + err.Error()}) - return - } - t, err := s.team(r.Context(), id) - if err != nil { - executorError(w, err) - return - } - writeJSON(w, 200, t) -} -func sortedTeams(teams []Team) { - sort.Slice(teams, func(i, j int) bool { return teams[i].ID < teams[j].ID }) -} diff --git a/src/teams_test.go b/src/teams_test.go deleted file mode 100644 index b444812..0000000 --- a/src/teams_test.go +++ /dev/null @@ -1,587 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "fmt" - "io" - "log/slog" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "testing" - "time" - - batchv1 "k8s.io/api/batch/v1" - corev1 "k8s.io/api/core/v1" - resourcev1 "k8s.io/api/resource/v1" - "k8s.io/apimachinery/pkg/api/resource" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/client-go/kubernetes/fake" - kt "k8s.io/client-go/testing" -) - -func departmentTestApp(t *testing.T) (*App, *fake.Clientset, []Team) { - t.Helper() - root := t.TempDir() - store, err := NewSharedStorage(root) - if err != nil { - t.Fatal(err) - } - teams := []Team{{ID: "team-aaaaaaaaaaaaaaaa", Name: "A", Policy: defaultTeamPolicy(), Members: []TeamMember{{ID: "alice", Name: "Alice"}}, Grants: []StorageGrant{}}, {ID: "team-bbbbbbbbbbbbbbbb", Name: "B", Policy: defaultTeamPolicy(), Members: []TeamMember{{ID: "bob", Name: "Bob"}}, Grants: []StorageGrant{}}} - data, _ := json.Marshal(teams) - c := fake.NewSimpleClientset(&corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: teamRegistry, Namespace: "mist-system", ResourceVersion: "1"}, Data: map[string]string{"teams": string(data)}}, &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "training-smoke-script", Namespace: "mist"}, Data: map[string]string{"training_smoke.py": "print('ok')"}}, &corev1.Node{ObjectMeta: metav1.ObjectMeta{Name: "main"}, Status: corev1.NodeStatus{Allocatable: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("8"), corev1.ResourceMemory: resource.MustParse("32Gi")}, Conditions: []corev1.NodeCondition{{Type: corev1.NodeReady, Status: corev1.ConditionTrue}}}}) - c.PrependReactor("create", "configmaps", func(action kt.Action) (bool, runtime.Object, error) { - action.(kt.CreateAction).GetObject().(*corev1.ConfigMap).ResourceVersion = "1" - return false, nil, nil - }) - for i := 1; i <= 4; i++ { - name := fmt.Sprintf("mist-tenstorrent-%d-boards", i) - _, err = c.ResourceV1().ResourceClaimTemplates("mist").Create(context.Background(), &resourcev1.ResourceClaimTemplate{ObjectMeta: metav1.ObjectMeta{Name: name}}, metav1.CreateOptions{}) - if err != nil { - t.Fatal(err) - } - } - t.Setenv("MIST_TEAMS_ENABLED", "true") - t.Setenv("MIST_AUTH_DATABASE", filepath.Join(t.TempDir(), "auth.sqlite")) - e := &KubernetesExecutor{client: c, namespace: "mist", owner: "legacy", storage: store, cpuNode: "main", ttNode: "tt", allowedImages: map[string]bool{cpuImage: true, ttImage: true, nvidiaImage: true}} - app := NewKubernetesApp(e, slog.New(slog.NewTextHandler(io.Discard, nil))) - now := time.Now().UTC().Format(time.RFC3339Nano) - for _, m := range []Member{ - {ID: "alice", Name: "Alice", Email: "alice@mist.local", Role: "user"}, - {ID: "bob", Name: "Bob", Email: "bob@mist.local", Role: "user"}, - {ID: "admin", Name: "Admin", Email: "admin@mist.local", Role: "admin"}, - } { - if _, err = app.auth.db.Exec(` - INSERT INTO user (id, name, email, role, createdAt, updatedAt) - VALUES (?, ?, ?, ?, ?, ?) - `, m.ID, m.Name, m.Email, m.Role, now, now); err != nil { - t.Fatal(err) - } - } - for _, id := range []string{"alice", "bob", "admin"} { - seedTestSession(t, app.auth, id) - } - t.Cleanup(func() { _ = app.auth.Close() }) - - for _, team := range teams { - for _, pool := range []string{"datasets", "results"} { - if err := os.MkdirAll(filepath.Join(root, "teams", team.ID, pool), 0750); err != nil { - t.Fatal(err) - } - } - if err = atomicJSON(filepath.Join(root, "metadata/provision-status", team.ID+".json"), teamStorageStatus{State: "Ready", Layout: sharedFolderLayout, UsageObservedUnix: time.Now().Unix()}); err != nil { - t.Fatal(err) - } - if _, err = app.teams.store(&team); err != nil { - t.Fatal(err) - } - } - return app, c, teams -} -func departmentRequest(a *App, method, path, identity, team, body string) *httptest.ResponseRecorder { - req := httptest.NewRequest(method, path, strings.NewReader(body)) - req.Header.Set("Cookie", "better-auth.session_token=test-session-"+identity) - if team != "" { - req.Header.Set("X-Mist-Team", team) - } - w := httptest.NewRecorder() - a.httpServer.Handler.ServeHTTP(w, req) - return w -} -func TestDepartmentTeamPermissionsAndScopedSharing(t *testing.T) { - a, _, teams := departmentTestApp(t) - alice, bob := teams[0], teams[1] - r := departmentRequest(a, "POST", "/datasets?filename=data.txt&scope=common", "alice", alice.ID, "data") - if r.Code != 403 { - t.Fatalf("member wrote common folder: %d %s", r.Code, r.Body) - } - r = departmentRequest(a, "POST", "/datasets?filename=data.txt", "alice", alice.ID, "data") - if r.Code != 201 { - t.Fatalf("own upload: %d %s", r.Code, r.Body) - } - var ds Dataset - _ = json.Unmarshal(r.Body.Bytes(), &ds) - if ds.Scope != "alice" || !strings.HasPrefix(ds.ContentPath, "members/") { - t.Fatalf("incorrect member folder: %+v", ds) - } - for _, path := range []string{"/datasets/" + ds.ID, "/datasets/" + ds.ID + "/files", "/storage/files?source_team=" + alice.ID + "&scope=alice"} { - r = departmentRequest(a, "GET", path, "bob", bob.ID, "") - if r.Code != 404 { - t.Fatalf("foreign data exposed at %s: %d", path, r.Code) - } - } - if r = departmentRequest(a, "GET", "/datasets", "bob", alice.ID, ""); r.Code != 403 { - t.Fatal("spoofed team membership accepted") - } - if r = departmentRequest(a, "POST", "/teams", "bob", "", `{"name":"bad"}`); r.Code != 403 { - t.Fatal("member created team") - } - if r = departmentRequest(a, "POST", "/jobs", "alice", "", `{"command":["true"]}`); r.Code != 403 { - t.Fatal("legacy submission bypasses team limits") - } - r = departmentRequest(a, "POST", "/teams/"+alice.ID+"/grants", "admin", "", `{"target_team":"`+bob.ID+`","scope":"alice"}`) - if r.Code != 200 { - t.Fatalf("grant failed: %d %s", r.Code, r.Body) - } - r = departmentRequest(a, "GET", "/datasets/"+ds.ID+"/files", "bob", bob.ID, "") - if r.Code != 200 || !strings.Contains(r.Body.String(), "data.txt") { - t.Fatalf("grant did not expose files: %d %s", r.Code, r.Body) - } - r = departmentRequest(a, "DELETE", "/datasets/"+ds.ID, "bob", bob.ID, "") - if r.Code != 403 { - t.Fatal("read grant permits deletion") - } - r = departmentRequest(a, "POST", "/jobs", "bob", bob.ID, `{"command":["true"],"dataset_id":"`+ds.ID+`"}`) - if r.Code != 201 { - t.Fatalf("shared input submission: %d %s", r.Code, r.Body) - } - var response struct { - Job KubernetesJobStatus `json:"job"` - } - _ = json.Unmarshal(r.Body.Bytes(), &response) - if response.Job.StorageScope != "bob" { - t.Fatal("output ownership incorrectly inherited from input") - } - _, updated, _ := a.teams.registry(context.Background()) - grant := findTeam(updated, alice.ID).Grants[0] - r = departmentRequest(a, "DELETE", "/teams/"+alice.ID+"/grants/"+grant.ID, "admin", "", "") - if r.Code != 200 { - t.Fatalf("revoke failed: %d %s", r.Code, r.Body) - } - r = departmentRequest(a, "GET", "/jobs/"+response.Job.ID, "bob", bob.ID, "") - if !strings.Contains(r.Body.String(), `"job_state":"Cancelled"`) { - t.Fatalf("revoked queued job not stopped: %s", r.Body) - } - r = departmentRequest(a, "GET", "/datasets/"+ds.ID+"/files", "bob", bob.ID, "") - if r.Code != 404 { - t.Fatal("revoked grant remains effective") - } -} -func TestDepartmentPolicyAndAdvisoryStorage(t *testing.T) { - a, _, teams := departmentTestApp(t) - p := defaultTeamPolicy() - p.CPU = "1" - p.NVIDIA = 0 - p.RuntimeSeconds = 60 - team := teams[0] - team.Policy = p - access := &TeamAccess{Team: &team, Member: &Member{ID: "alice"}} - e := *a.executor - e.team = access - for _, req := range []CreateJobRequest{{Command: []string{"true"}, CPU: "2"}, {Command: []string{"true"}, Accelerator: "nvidia"}, {Command: []string{"true"}, StorageScope: "bob"}, {Command: []string{"true"}, Image: "evil.invalid/test:v1"}, {Command: []string{"true"}, Image: "ghcr.io/team/app"}} { - req.TimeoutSeconds = 30 - if _, err := e.normalize(req); err == nil { - t.Fatalf("accepted invalid team request: %+v", req) - } - } - if _, err := e.normalize(CreateJobRequest{Command: []string{"true"}, TimeoutSeconds: 30}); err != nil { - t.Fatalf("valid request rejected: %v", err) - } - if err := a.teams.mutate(context.Background(), func(list *[]Team) error { (*list)[0].Policy.StorageGiB = 1500; return nil }); err != nil { - t.Fatalf("advisory allowance must not reserve disk space: %v", err) - } - changed := departmentRequest(a, "GET", "/teams", "alice", "", "") - if changed.Code != 200 || !strings.Contains(changed.Body.String(), `"storage_limits_enforced":false`) { - t.Fatalf("advisory semantics missing: %s", changed.Body) - } -} -func TestDepartmentDurableFairAdmission(t *testing.T) { - a, c, teams := departmentTestApp(t) - ctx := context.Background() - for i := range teams { - teams[i].Policy.Concurrent = 1 - } - _ = a.teams.mutate(ctx, func(list *[]Team) error { *list = teams; return nil }) - ids := map[string][]string{} - for _, team := range teams { - store, _ := a.teams.store(&team) - for i := 0; i < 2; i++ { - e := *a.executor - e.namespace, e.owner, e.storage, e.sharedPVC = team.namespace(), team.ID, store, "team-datasets" - e.team = &TeamAccess{Team: &team, Member: &Member{ID: team.Members[0].ID}, Service: a.teams} - job, err := e.submit(ctx, CreateJobRequest{Command: []string{"true"}, CPU: "1", Memory: "64Mi"}) - if err != nil { - t.Fatal(err) - } - ids[team.ID] = append(ids[team.ID], job.ID) - } - } - if err := a.teams.admit(ctx, teams); err != nil { - t.Fatal(err) - } - for _, team := range teams { - for i, id := range ids[team.ID] { - job, err := c.BatchV1().Jobs(team.namespace()).Get(ctx, id, metav1.GetOptions{}) - if err != nil { - t.Fatal(err) - } - if (job.Annotations["mist.io/admitted-at"] != "") != (i == 0) { - t.Fatalf("fair admission/concurrency failed for %s job %d", team.ID, i) - } - } - } - // Reconstruct controller state as after a restart; reservations remain in Jobs. - restarted := newTeamService(a) - if err := restarted.admit(ctx, teams); err != nil { - t.Fatal(err) - } - for _, team := range teams { - job, _ := c.BatchV1().Jobs(team.namespace()).Get(ctx, ids[team.ID][1], metav1.GetOptions{}) - if job.Spec.Suspend == nil || !*job.Spec.Suspend { - t.Fatal("restart lost active reservations") - } - first, _ := c.BatchV1().Jobs(team.namespace()).Get(ctx, ids[team.ID][0], metav1.GetOptions{}) - first.Status.Conditions = []batchv1.JobCondition{{Type: batchv1.JobComplete, Status: corev1.ConditionTrue, LastTransitionTime: metav1.NewTime(time.Now())}} - _, _ = c.BatchV1().Jobs(team.namespace()).UpdateStatus(ctx, first, metav1.UpdateOptions{}) - } - if err := restarted.admit(ctx, teams); err != nil { - t.Fatal(err) - } - for _, team := range teams { - job, _ := c.BatchV1().Jobs(team.namespace()).Get(ctx, ids[team.ID][1], metav1.GetOptions{}) - if job.Spec.Suspend == nil || *job.Spec.Suspend { - t.Fatal("completed jobs did not release admission") - } - } -} - -func TestDepartmentAgedHeadDrainsCapacityWithoutStarvation(t *testing.T) { - a, c, teams := departmentTestApp(t) - ctx := context.Background() - create := func(index int, id string, cpu string, admitted bool, age time.Duration) *batchv1.Job { - team := teams[index] - e := *a.executor - e.owner, e.namespace = team.ID, team.namespace() - e.team = &TeamAccess{Team: &team, Member: &Member{ID: team.Members[0].ID}} - req, err := e.normalize(CreateJobRequest{Command: []string{"true"}, CPU: cpu, Memory: "64Mi"}) - if err != nil { - t.Fatal(err) - } - job := e.buildJob(id, req) - job.Spec.Suspend = ptr(!admitted) - job.Annotations["mist.io/queued-at"] = time.Now().Add(-age).Format(time.RFC3339Nano) - if admitted { - job.Annotations["mist.io/admitted-at"] = time.Now().Format(time.RFC3339Nano) - } - saved, err := c.BatchV1().Jobs(e.namespace).Create(ctx, job, metav1.CreateOptions{}) - if err != nil { - t.Fatal(err) - } - return saved - } - big := create(0, "mist-aaaaaaaaaaaaaaaaaaaaaaaa", "8", false, 40*time.Second) - running := create(1, "mist-bbbbbbbbbbbbbbbbbbbbbbbb", "4", true, time.Minute) - small := create(1, "mist-cccccccccccccccccccccccc", "2", false, 0) - if err := a.teams.admit(ctx, teams); err != nil { - t.Fatal(err) - } - still, _ := c.BatchV1().Jobs(small.Namespace).Get(ctx, small.Name, metav1.GetOptions{}) - if still.Spec.Suspend == nil || !*still.Spec.Suspend { - t.Fatal("small requests overtook an aged large request") - } - running.Status.Conditions = []batchv1.JobCondition{{Type: batchv1.JobComplete, Status: corev1.ConditionTrue}} - _, _ = c.BatchV1().Jobs(running.Namespace).UpdateStatus(ctx, running, metav1.UpdateOptions{}) - if err := a.teams.admit(ctx, teams); err != nil { - t.Fatal(err) - } - admitted, _ := c.BatchV1().Jobs(big.Namespace).Get(ctx, big.Name, metav1.GetOptions{}) - if admitted.Spec.Suspend == nil || *admitted.Spec.Suspend { - t.Fatal("large request did not receive drained capacity") - } -} -func TestDepartmentDemotionRevokesAdministratorWritePrivilege(t *testing.T) { - a, _, teams := departmentTestApp(t) - team := teams[0] - e := *a.executor - e.team = &TeamAccess{Team: &team, Member: &Member{ID: "alice", Role: "admin"}} - req, err := e.normalize(CreateJobRequest{Command: []string{"true"}, StorageScope: "common"}) - if err != nil { - t.Fatal(err) - } - job := e.buildJob("mist-dddddddddddddddddddddddd", req) - reason, err := a.teams.revoked(context.Background(), &team, job, map[string]AccountStatus{"alice": {Role: "user"}}) - if err != nil || reason == "" { - t.Fatalf("historical admin flag bypassed current role: %s %v", reason, err) - } -} -func TestMemberProxyAddressCannotBeSpoofed(t *testing.T) { - t.Setenv("MIST_TRUSTED_PROXY_IPS", "10.42.0.1,10.0.0.175") - r := httptest.NewRequest("GET", "/auth/get-session", nil) - r.RemoteAddr = "100.127.1.1:12345" - r.Header.Set("X-Real-IP", "1.2.3.4") - if clientAddress(r) != "100.127.1.1" { - t.Fatal("untrusted caller spoofed rate-limit address") - } - r.RemoteAddr = "10.42.0.1:23456" - if clientAddress(r) != "1.2.3.4" { - t.Fatal("trusted Nginx address not forwarded") - } - r.Header.Set("X-Real-IP", "1.2.3.4,5.6.7.8") - if clientAddress(r) != "10.42.0.1" { - t.Fatal("forwarded chain accepted") - } -} - -// A claim can be owned by its Pod before the Pod is bound. The device inventory -// already subtracts that allocation; admission must not reserve it a second time. -func TestDepartmentPendingPodClaimDoesNotDoubleReserveBoards(t *testing.T) { - a, c, teams := departmentTestApp(t) - ctx := context.Background() - nodes, pods, slices := hardwareFixture() - a.executor.cpuNode, a.executor.ttNode = "cpu-node", "tt-node" - for _, node := range nodes { - node.Status.Allocatable[corev1.ResourceCPU] = resource.MustParse("32") - node.Status.Allocatable[corev1.ResourceMemory] = resource.MustParse("128Gi") - if _, err := c.CoreV1().Nodes().Create(ctx, &node, metav1.CreateOptions{}); err != nil { - t.Fatal(err) - } - } - for i := range pods { - pods[i].Name = fmt.Sprintf("driver-%d", i) - if _, err := c.CoreV1().Pods("drivers").Create(ctx, &pods[i], metav1.CreateOptions{}); err != nil { - t.Fatal(err) - } - } - slices[0].Name = "boards" - slices[0].Spec.Devices = slices[0].Spec.Devices[:2] - if _, err := c.ResourceV1().ResourceSlices().Create(ctx, &slices[0], metav1.CreateOptions{}); err != nil { - t.Fatal(err) - } - team := teams[0] - request := CreateJobRequest{Accelerator: "tenstorrent", DeviceCount: 1, CPU: "2", Memory: "4Gi"} - data, _ := json.Marshal(request) - for _, name := range []string{"pending-allocated", "queued"} { - annotations := map[string]string{requestAnnotation: string(data), "mist.io/queued-at": time.Now().Format(time.RFC3339Nano)} - if name == "pending-allocated" { - annotations["mist.io/admitted-at"] = time.Now().Format(time.RFC3339Nano) - } - _, err := c.BatchV1().Jobs(team.namespace()).Create(ctx, &batchv1.Job{ObjectMeta: metav1.ObjectMeta{Name: name, Labels: map[string]string{"mist.io/team": team.ID}, Annotations: annotations}, Spec: batchv1.JobSpec{Suspend: ptr(name == "queued")}}, metav1.CreateOptions{}) - if err != nil { - t.Fatal(err) - } - } - _, err := c.CoreV1().Pods(team.namespace()).Create(ctx, &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "unbound", Labels: map[string]string{"batch.kubernetes.io/job-name": "pending-allocated"}}, Status: corev1.PodStatus{Phase: corev1.PodPending}}, metav1.CreateOptions{}) - if err != nil { - t.Fatal(err) - } - claim := resourcev1.ResourceClaim{ObjectMeta: metav1.ObjectMeta{Name: "allocated", OwnerReferences: []metav1.OwnerReference{{Kind: "Pod", Name: "unbound"}}}, Status: resourcev1.ResourceClaimStatus{Allocation: &resourcev1.AllocationResult{Devices: resourcev1.DeviceAllocationResult{Results: []resourcev1.DeviceRequestAllocationResult{{Driver: "tenstorrent.com", Pool: "tt-node", Device: "tt-0"}}}}}} - if _, err = c.ResourceV1().ResourceClaims(team.namespace()).Create(ctx, &claim, metav1.CreateOptions{}); err != nil { - t.Fatal(err) - } - if err = a.teams.admit(ctx, teams); err != nil { - t.Fatal(err) - } - queued, err := c.BatchV1().Jobs(team.namespace()).Get(ctx, "queued", metav1.GetOptions{}) - if err != nil { - t.Fatal(err) - } - if queued.Annotations["mist.io/admitted-at"] == "" { - t.Fatal("free board withheld by double-counting pending Pod claim") - } -} - -func TestDepartmentSplitDatasetAndModelPaths(t *testing.T) { - a, c, teams := departmentTestApp(t) - team := teams[0] - uploaded := departmentRequest(a, "POST", "/datasets?filename=train.csv", "alice", team.ID, "1,2\n") - if uploaded.Code != 201 { - t.Fatal(uploaded.Body.String()) - } - var ds Dataset - json.Unmarshal(uploaded.Body.Bytes(), &ds) - submitted := departmentRequest(a, "POST", "/jobs", "alice", team.ID, fmt.Sprintf(`{"accelerator":"cpu","type":"command","command":["true"],"dataset_id":%q}`, ds.ID)) - if submitted.Code != 201 { - t.Fatal(submitted.Body.String()) - } - var result struct { - ID string `json:"job_id"` - } - json.Unmarshal(submitted.Body.Bytes(), &result) - job, err := c.BatchV1().Jobs(team.namespace()).Get(context.Background(), result.ID, metav1.GetOptions{}) - if err != nil { - t.Fatal(err) - } - claims := map[string]string{} - for _, volume := range job.Spec.Template.Spec.Volumes { - if volume.PersistentVolumeClaim != nil { - claims[volume.Name] = volume.PersistentVolumeClaim.ClaimName - } - } - if claims["input"] != "team-datasets" || claims["checkpoints"] != "team-results" { - t.Fatalf("storage pools not separated: %+v", claims) - } - store, err := a.teams.store(&team) - if err != nil { - t.Fatal(err) - } - output, err := store.outputPath(job) - if err != nil { - t.Fatal(err) - } - if !strings.HasPrefix(output, store.modelRoot+string(os.PathSeparator)) { - t.Fatal("result path falls into datasets") - } - if err = os.WriteFile(filepath.Join(output, "weights.bin"), []byte("WEIGHTS"), 0600); err != nil { - t.Fatal(err) - } - listed := departmentRequest(a, "GET", "/storage/files?scope=alice", "alice", team.ID, "") - if listed.Code != 200 || !strings.Contains(listed.Body.String(), "weights.bin") || !strings.Contains(listed.Body.String(), "train.csv") { - t.Fatalf("virtual scope lost one pool: %s", listed.Body.String()) - } - path := "/storage/files?scope=alice&download=true&path=jobs/" + job.Name + "/outputs/weights.bin" - downloaded := departmentRequest(a, "GET", path, "alice", team.ID, "") - if downloaded.Code != 200 || downloaded.Body.String() != "WEIGHTS" { - t.Fatal("model download failed") - } - escaped := departmentRequest(a, "GET", path+"/../../../../outside", "alice", team.ID, "") - if escaped.Code == 200 { - t.Fatal("virtual model folder escaped its scope") - } -} - -func TestDepartmentFoldersFailClosedForMissingOrUnsafePaths(t *testing.T) { - a, _, teams := departmentTestApp(t) - team := teams[0] - results := filepath.Join(a.executor.storage.root, "teams", team.ID, "results") - if err := os.RemoveAll(results); err != nil { - t.Fatal(err) - } - if _, err := a.teams.store(&team); err == nil { - t.Fatal("missing results directory accepted") - } - if err := os.Symlink(t.TempDir(), results); err != nil { - t.Fatal(err) - } - if _, err := a.teams.store(&team); err == nil { - t.Fatal("symlink results directory accepted") - } - if err := os.Remove(results); err != nil { - t.Fatal(err) - } - if err := os.Mkdir(results, 0750); err != nil { - t.Fatal(err) - } - path := filepath.Join(a.executor.storage.root, "metadata/provision-status", team.ID+".json") - if err := atomicJSON(path, map[string]any{"state": "Ready", "storage_gib": 200, "model_storage_gib": 500}); err != nil { - t.Fatal(err) - } - if _, err := a.teams.store(&team); err == nil { - t.Fatal("old image layout status accepted") - } -} - -func TestDepartmentSharedStorageUsageAndAdvisoryDecrease(t *testing.T) { - a, _, teams := departmentTestApp(t) - team := teams[0] - path := filepath.Join(a.executor.storage.root, "metadata/provision-status", team.ID+".json") - status := teamStorageStatus{State: "Ready", Layout: sharedFolderLayout, DatasetUsedBytes: 123, ModelUsedBytes: 456, UsageObservedUnix: time.Now().Add(-3 * time.Minute).Unix()} - if err := atomicJSON(path, status); err != nil { - t.Fatal(err) - } - response := departmentRequest(a, "GET", "/storage", "alice", team.ID, "") - var info struct { - DatasetUsed int64 `json:"dataset_used_bytes"` - ModelUsed int64 `json:"model_used_bytes"` - Stale bool `json:"usage_stale"` - Enforced bool `json:"storage_limits_enforced"` - Capacity uint64 `json:"capacity_bytes"` - Available uint64 `json:"available_bytes"` - ModelCapacity uint64 `json:"model_capacity_bytes"` - ModelAvailable uint64 `json:"model_available_bytes"` - } - if response.Code != 200 { - t.Fatal(response.Body.String()) - } - if err := json.Unmarshal(response.Body.Bytes(), &info); err != nil { - t.Fatal(err) - } - if info.DatasetUsed != 123 || info.ModelUsed != 456 || !info.Stale || info.Enforced || info.Capacity == 0 || info.Available == 0 { - t.Fatalf("incorrect folder usage: %+v", info) - } - if info.ModelCapacity != info.Capacity || info.ModelAvailable != info.Available { - t.Fatal("compatibility fields must refer to the same shared disk") - } - policy := team.Policy - policy.StorageGiB, policy.ModelStorageGiB = 1, 1 - body, _ := json.Marshal(map[string]any{"policy": policy}) - changed := departmentRequest(a, "PATCH", "/teams/"+team.ID, "admin", "", string(body)) - if changed.Code != 200 { - t.Fatalf("allowance decrease must not require migration: %s", changed.Body) - } - upload := departmentRequest(a, "POST", "/datasets?filename=tiny.csv", "alice", team.ID, "1,2\n") - if upload.Code != 201 { - t.Fatalf("advisory change blocked upload: %s", upload.Body) - } -} - -func TestDepartmentDefaultPoolsAndLegacyPolicy(t *testing.T) { - a, _, _ := departmentTestApp(t) - // Leave room for a default team without allocating any real disk images. - if err := a.teams.mutate(context.Background(), func(teams *[]Team) error { - for i := range *teams { - (*teams)[i].Policy.StorageGiB = 1 - (*teams)[i].Policy.ModelStorageGiB = 1 - } - return nil - }); err != nil { - t.Fatal(err) - } - created := departmentRequest(a, "POST", "/teams", "admin", "", `{"name":"Default pools"}`) - if created.Code != 201 { - t.Fatalf("%d %s", created.Code, created.Body.String()) - } - var team Team - if err := json.Unmarshal(created.Body.Bytes(), &team); err != nil { - t.Fatal(err) - } - if team.Policy.StorageGiB != 200 || team.Policy.ModelStorageGiB != 500 { - t.Fatalf("unexpected defaults: %+v", team.Policy) - } - for _, body := range []string{`{"name":"Invalid model pool","model_storage_gib":0}`, `{"name":"Invalid model pool","model_storage_gib":-1}`} { - rejected := departmentRequest(a, "POST", "/teams", "admin", "", body) - if rejected.Code != 400 { - t.Fatalf("invalid declared pool accepted: %d %s", rejected.Code, rejected.Body.String()) - } - } - legacy := TeamPolicy{StorageGiB: 3} - normalizeStoragePolicy(&legacy) - if legacy.StorageGiB != 3 || legacy.ModelStorageGiB != 3 { - t.Fatal("legacy allocation silently expanded") - } - p := defaultTeamPolicy() - p.ModelStorageGiB = -1 - if err := validateTeam(&Team{Name: "Invalid", Policy: p}); err == nil { - t.Fatal("negative model pool accepted") - } -} - -func TestTeamVolumesUseEthernetFolderExportsAndRejectStaleBindings(t *testing.T) { - a, client, teams := departmentTestApp(t) - t.Setenv("MIST_NFS_SERVER", "192.168.240.2") - t.Setenv("MIST_NFS_ROOT", "/srv/mist-data") - team := teams[0] - ctx := context.Background() - if err := a.teams.provisionNamespace(ctx, &team); err != nil { - t.Fatal(err) - } - for _, pair := range [][2]string{{"team-datasets", "datasets"}, {"team-results", "results"}} { - pv, err := client.CoreV1().PersistentVolumes().Get(ctx, team.namespace()+"-"+pair[0], metav1.GetOptions{}) - if err != nil { - t.Fatal(err) - } - if pv.Spec.NFS.Server != "192.168.240.2" || pv.Spec.NFS.Path != "/srv/mist-data/teams/"+team.ID+"/"+pair[1] { - t.Fatalf("wrong Ethernet export: %+v", pv.Spec.NFS) - } - pv.Spec.NFS.Path = "/obsolete-export" - if _, err = client.CoreV1().PersistentVolumes().Update(ctx, pv, metav1.UpdateOptions{}); err != nil { - t.Fatal(err) - } - err = a.teams.ensureVolume(ctx, team.ID, team.namespace(), pair[0], "teams/"+team.ID+"/"+pair[1], 1, false) - if err == nil { - t.Fatal("stale export binding accepted") - } - } -} diff --git a/web-interface/.cta.json b/web-interface/.cta.json deleted file mode 100644 index 38d1229..0000000 --- a/web-interface/.cta.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "projectName": "web-interface", - "mode": "file-router", - "typescript": true, - "packageManager": "bun", - "includeExamples": false, - "tailwind": true, - "addOnOptions": {}, - "envVarValues": {}, - "git": false, - "routerOnly": true, - "version": 1, - "framework": "react", - "chosenAddOns": ["eslint"] -} diff --git a/web-interface/.gitignore b/web-interface/.gitignore deleted file mode 100644 index 8b25bb5..0000000 --- a/web-interface/.gitignore +++ /dev/null @@ -1,13 +0,0 @@ -node_modules -.DS_Store -dist -dist-ssr -*.local -.env -.nitro -.tanstack -.wrangler -.output -.vinxi -__unconfig* -todos.json diff --git a/web-interface/.prettierignore b/web-interface/.prettierignore deleted file mode 100644 index e820c72..0000000 --- a/web-interface/.prettierignore +++ /dev/null @@ -1,7 +0,0 @@ -package-lock.json -pnpm-lock.yaml -yarn.lock -public/docs -dist -dist-ssr -.tanstack diff --git a/web-interface/.vscode/settings.json b/web-interface/.vscode/settings.json deleted file mode 100644 index 00b5278..0000000 --- a/web-interface/.vscode/settings.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "files.watcherExclude": { - "**/routeTree.gen.ts": true - }, - "search.exclude": { - "**/routeTree.gen.ts": true - }, - "files.readonlyInclude": { - "**/routeTree.gen.ts": true - } -} diff --git a/web-interface/README.md b/web-interface/README.md deleted file mode 100644 index d965fb4..0000000 --- a/web-interface/README.md +++ /dev/null @@ -1,65 +0,0 @@ -# Mist web interface - -The authenticated React portal serves real team Jobs, hardware inventory, -dataset uploads, common/member folder browsing, account administration and team -policy/sharing controls. Production: **http://100.73.139.66:8088** through Tailscale. -See the [operating guide](../docs/content/department-rollout.md). - -## Start locally - -Use Node.js **22.16.0 or newer**. The main machine's existing API port-forward -listens on `127.0.0.1:3000`; its Vite development service uses port 3001. -Check those services before starting duplicate listeners: - -```bash -systemctl --user status mist-api-forward mist-web -``` - -With the API/port-forward available and port 3001 free, from this directory: - -```bash -npm ci -npm run dev -- --host 127.0.0.1 --port 3001 --strictPort -``` - -Vite forwards `/api` and `/auth` to the API on port 3000. `MIST_API_PROXY` changes -that target. Authenticate with a real Mist account and select your team; this -preview reaches real jobs/data. See [development](../docs/content/development.md) for -other-host origins and backend work. Production uses a static Nginx release, -not Vite or these per-user services. - -## Responsibilities - -- `src/auth.tsx`, `teams.tsx`: account/team context; server authorization remains - authoritative for every operation. -- `src/api.ts`: typed API models/requests and upload progress. -- `src/components/JobsPage.tsx`, `components/jobs/`: paginated history, focused - submission dialog, actual logs, cancellation and saved files. -- `src/components/DatasetsPage.tsx`, `TeamStorageBrowser.tsx`: uploads and - authorized common/member/shared storage browsing. -- `src/components/TeamsPage.tsx`, `AccountPage.tsx`: team policy/membership/sharing, - member administration and password controls. -- `src/components/Picker.tsx`, `Tabs.tsx`, `Modal.tsx`, `Card.tsx`: shared controls. -- `src/components/HardwarePanel.tsx`, `MachinesPage.tsx`: live node/device inventory. -- `src/hooks/usePolling.ts`: independent polling, cancellation and recovery. -- `src/routes/`: TanStack file routes; `routeTree.gen.ts` is generated. - -Compute choices request whole GPUs or TT boards. One TT board has two chips. -Custom tagged images follow team registry policy and need compatible libraries; -TT may use the tested host profile or an image-provided runtime. Container mode -preserves image startup with blank overrides. CPU/RAM inventory shows allocation, -not live utilization. Inputs are read-only; save results in `/outputs`. - -The old fake account helpers/chart and unused `use-immer` dependency are retired. -Current behavior and components follow [the design system](../docs/content/design-system.md). - -## Verify - -```bash -npm test -npx tsc --noEmit -npm run lint -npm run build -``` - -See [testing](../docs/content/testing.md) for browser-to-cluster checks and their scope. diff --git a/web-interface/eslint.config.js b/web-interface/eslint.config.js deleted file mode 100644 index 7cdf373..0000000 --- a/web-interface/eslint.config.js +++ /dev/null @@ -1,20 +0,0 @@ -// @ts-check - -import { tanstackConfig } from '@tanstack/eslint-config' - -export default [ - ...tanstackConfig, - { - rules: { - 'import/no-cycle': 'off', - 'import/order': 'off', - 'sort-imports': 'off', - '@typescript-eslint/array-type': 'off', - '@typescript-eslint/require-await': 'off', - 'pnpm/json-enforce-catalog': 'off', - }, - }, - { - ignores: ['eslint.config.js', 'prettier.config.js', 'public/**', 'dist/**'], - }, -] diff --git a/web-interface/index.html b/web-interface/index.html deleted file mode 100644 index b0d9689..0000000 --- a/web-interface/index.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - MIST Web Interface - - -
- - - diff --git a/web-interface/package-lock.json b/web-interface/package-lock.json deleted file mode 100644 index 2176980..0000000 --- a/web-interface/package-lock.json +++ /dev/null @@ -1,4695 +0,0 @@ -{ - "name": "web-interface", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "web-interface", - "dependencies": { - "@tailwindcss/vite": "^4.1.18", - "@tanstack/react-devtools": "latest", - "@tanstack/react-router": "latest", - "@tanstack/react-router-devtools": "latest", - "@tanstack/router-plugin": "^1.132.0", - "better-auth": "1.7.7", - "lucide-react": "^0.545.0", - "react": "^19.2.0", - "react-dom": "^19.2.0", - "tailwindcss": "^4.1.18" - }, - "devDependencies": { - "@tailwindcss/typography": "^0.5.16", - "@tanstack/devtools-vite": "latest", - "@tanstack/eslint-config": "latest", - "@testing-library/dom": "^10.4.1", - "@testing-library/react": "^16.3.0", - "@types/node": "^22.10.2", - "@types/react": "^19.2.0", - "@types/react-dom": "^19.2.0", - "@vitejs/plugin-react": "^5.1.4", - "jsdom": "^28.1.0", - "prettier": "^3.8.1", - "typescript": "^5.7.2", - "vite": "^7.3.7", - "vite-tsconfig-paths": "^5.1.4", - "vitest": "^5.0.3" - }, - "engines": { - "node": ">=22.16.0" - } - }, - "node_modules/@acemir/cssom": { - "version": "0.9.31", - "dev": true, - "license": "MIT" - }, - "node_modules/@asamuzakjp/css-color": { - "version": "5.1.11", - "dev": true, - "license": "MIT", - "dependencies": { - "@asamuzakjp/generational-cache": "^1.0.1", - "@csstools/css-calc": "^3.2.0", - "@csstools/css-color-parser": "^4.1.0", - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/@asamuzakjp/dom-selector": { - "version": "6.8.1", - "dev": true, - "license": "MIT", - "dependencies": { - "@asamuzakjp/nwsapi": "^2.3.9", - "bidi-js": "^1.0.3", - "css-tree": "^3.1.0", - "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.2.6" - } - }, - "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { - "version": "11.5.3", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@asamuzakjp/generational-cache": { - "version": "1.0.1", - "dev": true, - "license": "MIT", - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/@asamuzakjp/nwsapi": { - "version": "2.3.9", - "dev": true, - "license": "MIT" - }, - "node_modules/@babel/code-frame": { - "version": "7.29.7", - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.29.7", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/compat-data": { - "version": "7.29.7", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.29.7", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", - "@babel/helper-compilation-targets": "^7.29.7", - "@babel/helper-module-transforms": "^7.29.7", - "@babel/helpers": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/template": "^7.29.7", - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7", - "@jridgewell/remapping": "^2.3.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/generator": { - "version": "7.29.8", - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.8", - "@babel/types": "^7.29.8", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.29.7", - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.29.7", - "@babel/helper-validator-option": "^7.29.7", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-globals": { - "version": "7.29.7", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.29.7", - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.29.7", - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7", - "@babel/traverse": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.29.7", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.29.7", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.29.7", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-option": { - "version": "7.29.7", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.29.7", - "license": "MIT", - "dependencies": { - "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.29.9", - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.8" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-self": { - "version": "7.29.7", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-source": { - "version": "7.29.7", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/runtime": { - "version": "7.29.7", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/template": { - "version": "7.29.7", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.29.8", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.8", - "@babel/helper-globals": "^7.29.7", - "@babel/parser": "^7.29.8", - "@babel/template": "^7.29.7", - "@babel/types": "^7.29.8", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.29.8", - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@better-auth/core": { - "version": "1.7.7", - "resolved": "https://registry.npmjs.org/@better-auth/core/-/core-1.7.7.tgz", - "integrity": "sha512-srgFzHEjB2WWlokFz1i4BfG41sNhsA105Pukgqu6RrEh5A+LSkzf1mCHydVipkjt9l5qswtBMi7IL6ouHbZL4Q==", - "license": "MIT", - "dependencies": { - "@opentelemetry/semantic-conventions": "^1.41.1", - "@standard-schema/spec": "^1.1.0", - "zod": "^4.5.4" - }, - "peerDependencies": { - "@better-auth/utils": "0.4.2", - "@better-fetch/fetch": "1.3.2", - "@opentelemetry/api": "^1.9.0", - "better-call": "1.4.0", - "jose": "^6.1.0", - "kysely": "^0.28.5 || ^0.29.0", - "nanostores": "^1.0.1" - }, - "peerDependenciesMeta": { - "@opentelemetry/api": { - "optional": true - } - } - }, - "node_modules/@better-auth/drizzle-adapter": { - "version": "1.7.7", - "resolved": "https://registry.npmjs.org/@better-auth/drizzle-adapter/-/drizzle-adapter-1.7.7.tgz", - "integrity": "sha512-qon0U94PR5FQysuyGoAlGVQpIIiNg5dtDpwjE63sN/HjoRWZadZzHPcS9mn7JsJyQZK6iTNhd7LD0AaXt2f78w==", - "license": "MIT", - "peerDependencies": { - "@better-auth/core": "^1.7.7", - "@better-auth/utils": "0.4.2", - "drizzle-orm": "^0.45.2 || >=1.0.0-rc.1 <2.0.0" - }, - "peerDependenciesMeta": { - "drizzle-orm": { - "optional": true - } - } - }, - "node_modules/@better-auth/kysely-adapter": { - "version": "1.7.7", - "resolved": "https://registry.npmjs.org/@better-auth/kysely-adapter/-/kysely-adapter-1.7.7.tgz", - "integrity": "sha512-9FONOCgOcrQI9wJ5v1oDGIg9sT7pa9RjZZQ4fsCTudo8R87SrIGNUvUxDFTJhZvUblvFfGaIBR9Vb8LA+8bqLQ==", - "license": "MIT", - "peerDependencies": { - "@better-auth/core": "^1.7.7", - "@better-auth/utils": "0.4.2", - "kysely": "^0.28.17 || ^0.29.0" - }, - "peerDependenciesMeta": { - "kysely": { - "optional": true - } - } - }, - "node_modules/@better-auth/memory-adapter": { - "version": "1.7.7", - "resolved": "https://registry.npmjs.org/@better-auth/memory-adapter/-/memory-adapter-1.7.7.tgz", - "integrity": "sha512-FqKFEe+b5tXXV0gRbyirca+qXgMpLOeJ8Wk19yykb/scQKy0WJ4k22WzEV3TTPctQV9DZhwCXPlDtQhnlOv5Gg==", - "license": "MIT", - "peerDependencies": { - "@better-auth/core": "^1.7.7", - "@better-auth/utils": "0.4.2" - } - }, - "node_modules/@better-auth/mongo-adapter": { - "version": "1.7.7", - "resolved": "https://registry.npmjs.org/@better-auth/mongo-adapter/-/mongo-adapter-1.7.7.tgz", - "integrity": "sha512-ZnVDuRrXqbf0oHphrEN27oTImNH5NTO26dtWatq5cXyYAsH7goof9QIoTOmxTbBxUQahKBw9T6+9h6n+RtOE+g==", - "license": "MIT", - "peerDependencies": { - "@better-auth/core": "^1.7.7", - "@better-auth/utils": "0.4.2", - "mongodb": "^6.0.0 || ^7.0.0" - }, - "peerDependenciesMeta": { - "mongodb": { - "optional": true - } - } - }, - "node_modules/@better-auth/prisma-adapter": { - "version": "1.7.7", - "resolved": "https://registry.npmjs.org/@better-auth/prisma-adapter/-/prisma-adapter-1.7.7.tgz", - "integrity": "sha512-4YcnrcVdvWiAZw0sZl63tWUClPXg5r3QsLn7C2sG7kKEBY06zOng0ibecyxRBixKq1zE1ceHuEUqez5Rhi+nQA==", - "license": "MIT", - "peerDependencies": { - "@better-auth/core": "^1.7.7", - "@better-auth/utils": "0.4.2", - "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", - "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" - }, - "peerDependenciesMeta": { - "@prisma/client": { - "optional": true - }, - "prisma": { - "optional": true - } - } - }, - "node_modules/@better-auth/telemetry": { - "version": "1.7.7", - "resolved": "https://registry.npmjs.org/@better-auth/telemetry/-/telemetry-1.7.7.tgz", - "integrity": "sha512-PaRA6i+kAioVYmza2gHPcdrxP0hAotph4KV9hft2GeNJq+AZXDiBu3FuLM9HxvcmiZeUT74Vufqd9ypmB8veTA==", - "license": "MIT", - "peerDependencies": { - "@better-auth/core": "^1.7.7", - "@better-auth/utils": "0.4.2", - "@better-fetch/fetch": "1.3.2" - } - }, - "node_modules/@better-auth/utils": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@better-auth/utils/-/utils-0.4.2.tgz", - "integrity": "sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A==", - "license": "MIT", - "dependencies": { - "@noble/hashes": "^2.0.1" - } - }, - "node_modules/@better-fetch/fetch": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@better-fetch/fetch/-/fetch-1.3.2.tgz", - "integrity": "sha512-Gs7n99b5tqUC6cQAPbV0uED3IraHB6xQbHLQ/C3l7ZFafHScOx9pQ+DYmP5blbLFShVWLqxNUlI9wi4xU/X+ow==", - "license": "MIT" - }, - "node_modules/@bramus/specificity": { - "version": "2.4.2", - "dev": true, - "license": "MIT", - "dependencies": { - "css-tree": "^3.0.0" - }, - "bin": { - "specificity": "bin/cli.js" - } - }, - "node_modules/@cacheable/memory": { - "version": "2.2.0", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@cacheable/utils": "^2.5.0", - "@keyv/bigmap": "^1.3.1", - "hookified": "^1.15.1", - "keyv": "^5.6.0" - } - }, - "node_modules/@cacheable/utils": { - "version": "2.5.0", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "hashery": "^1.5.1", - "keyv": "^5.6.0" - } - }, - "node_modules/@csstools/color-helpers": { - "version": "6.1.1", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "engines": { - "node": ">=20.19.0" - } - }, - "node_modules/@csstools/css-calc": { - "version": "3.4.0", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=20.19.0" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0" - } - }, - "node_modules/@csstools/css-color-parser": { - "version": "4.2.3", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "dependencies": { - "@csstools/color-helpers": "^6.1.1", - "@csstools/css-calc": "^3.4.0" - }, - "engines": { - "node": ">=20.19.0" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0" - } - }, - "node_modules/@csstools/css-parser-algorithms": { - "version": "4.0.0", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=20.19.0" - }, - "peerDependencies": { - "@csstools/css-tokenizer": "^4.0.0" - } - }, - "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.1.14", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "peerDependencies": { - "css-tree": "^3.2.1" - }, - "peerDependenciesMeta": { - "css-tree": { - "optional": true - } - } - }, - "node_modules/@csstools/css-tokenizer": { - "version": "4.0.1", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=20.19.0" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.28.2", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.10.1", - "dev": true, - "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } - }, - "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.2", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, - "node_modules/@eslint/config-array": { - "version": "0.23.5", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@eslint/object-schema": "^3.0.5", - "debug": "^4.3.1", - "minimatch": "^10.2.4" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@eslint/config-helpers": { - "version": "0.7.0", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@eslint/core": "^1.2.1" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@eslint/core": { - "version": "1.2.1", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@eslint/js": { - "version": "10.0.1", - "dev": true, - "license": "MIT", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "eslint": "^10.0.0" - }, - "peerDependenciesMeta": { - "eslint": { - "optional": true - } - } - }, - "node_modules/@eslint/object-schema": { - "version": "3.0.5", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@eslint/plugin-kit": { - "version": "0.7.3", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@eslint/core": "^1.2.1", - "levn": "^0.4.1" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@exodus/bytes": { - "version": "1.16.0", - "dev": true, - "license": "MIT", - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - }, - "peerDependencies": { - "@noble/hashes": "^1.8.0 || ^2.0.0" - }, - "peerDependenciesMeta": { - "@noble/hashes": { - "optional": true - } - } - }, - "node_modules/@humanfs/core": { - "version": "0.19.2", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@humanfs/types": "^0.15.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/node": { - "version": "0.16.8", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@humanfs/core": "^0.19.2", - "@humanfs/types": "^0.15.0", - "@humanwhocodes/retry": "^0.4.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/types": { - "version": "0.15.0", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.6.0", - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@keyv/bigmap": { - "version": "1.3.1", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "hashery": "^1.4.0", - "hookified": "^1.15.0" - }, - "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "keyv": "^5.6.0" - } - }, - "node_modules/@keyv/serialize": { - "version": "1.1.1", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/@neodrag/core": { - "version": "3.0.0-next.11", - "license": "MIT" - }, - "node_modules/@neodrag/solid": { - "version": "3.0.0-next.11", - "license": "MIT", - "peerDependencies": { - "@neodrag/core": "3.0.0-next.11", - "solid-js": "^1.0.0" - } - }, - "node_modules/@noble/ciphers": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.4.0.tgz", - "integrity": "sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw==", - "license": "MIT", - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@noble/hashes": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", - "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", - "license": "MIT", - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@opentelemetry/semantic-conventions": { - "version": "1.43.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", - "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==", - "license": "Apache-2.0", - "engines": { - "node": ">=14" - } - }, - "node_modules/@oxc-parser/binding-linux-x64-gnu": { - "version": "0.120.0", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-project/types": { - "version": "0.120.0", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/Boshen" - } - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.3", - "dev": true, - "license": "MIT" - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.63.5", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@solid-primitives/event-listener": { - "version": "2.4.6", - "license": "MIT", - "dependencies": { - "@solid-primitives/utils": "^6.4.1" - }, - "peerDependencies": { - "solid-js": "^1.6.12" - } - }, - "node_modules/@solid-primitives/keyboard": { - "version": "1.3.7", - "license": "MIT", - "dependencies": { - "@solid-primitives/event-listener": "^2.4.6", - "@solid-primitives/rootless": "^1.5.4", - "@solid-primitives/utils": "^6.4.1" - }, - "peerDependencies": { - "solid-js": "^1.6.12" - } - }, - "node_modules/@solid-primitives/resize-observer": { - "version": "2.2.0", - "license": "MIT", - "dependencies": { - "@solid-primitives/event-listener": "^2.4.6", - "@solid-primitives/rootless": "^1.5.4", - "@solid-primitives/static-store": "^0.1.4", - "@solid-primitives/utils": "^6.4.1" - }, - "peerDependencies": { - "solid-js": "^1.6.12" - } - }, - "node_modules/@solid-primitives/rootless": { - "version": "1.5.4", - "license": "MIT", - "dependencies": { - "@solid-primitives/utils": "^6.4.1" - }, - "peerDependencies": { - "solid-js": "^1.6.12" - } - }, - "node_modules/@solid-primitives/static-store": { - "version": "0.1.4", - "license": "MIT", - "dependencies": { - "@solid-primitives/utils": "^6.4.1" - }, - "peerDependencies": { - "solid-js": "^1.6.12" - } - }, - "node_modules/@solid-primitives/utils": { - "version": "6.4.1", - "license": "MIT", - "peerDependencies": { - "solid-js": "^1.6.12" - } - }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "license": "MIT" - }, - "node_modules/@stylistic/eslint-plugin": { - "version": "5.10.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/types": "^8.56.0", - "eslint-visitor-keys": "^4.2.1", - "espree": "^10.4.0", - "estraverse": "^5.3.0", - "picomatch": "^4.0.3" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "peerDependencies": { - "eslint": "^9.0.0 || ^10.0.0" - } - }, - "node_modules/@tailwindcss/node": { - "version": "4.3.3", - "license": "MIT", - "dependencies": { - "@jridgewell/remapping": "^2.3.5", - "enhanced-resolve": "^5.24.1", - "jiti": "^2.7.0", - "lightningcss": "1.32.0", - "magic-string": "^0.30.21", - "source-map-js": "^1.2.1", - "tailwindcss": "4.3.3" - } - }, - "node_modules/@tailwindcss/oxide": { - "version": "4.3.3", - "license": "MIT", - "engines": { - "node": ">= 20" - }, - "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.3.3", - "@tailwindcss/oxide-darwin-arm64": "4.3.3", - "@tailwindcss/oxide-darwin-x64": "4.3.3", - "@tailwindcss/oxide-freebsd-x64": "4.3.3", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", - "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", - "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", - "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", - "@tailwindcss/oxide-linux-x64-musl": "4.3.3", - "@tailwindcss/oxide-wasm32-wasi": "4.3.3", - "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", - "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" - } - }, - "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.3.3", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/typography": { - "version": "0.5.20", - "dev": true, - "license": "MIT", - "dependencies": { - "postcss-selector-parser": "6.0.10" - }, - "peerDependencies": { - "tailwindcss": ">=3.0.0 || >=4.0.0 || insiders" - } - }, - "node_modules/@tailwindcss/vite": { - "version": "4.3.3", - "license": "MIT", - "dependencies": { - "@tailwindcss/node": "4.3.3", - "@tailwindcss/oxide": "4.3.3", - "tailwindcss": "4.3.3" - }, - "peerDependencies": { - "vite": "^5.2.0 || ^6 || ^7 || ^8" - } - }, - "node_modules/@tanstack/devtools": { - "version": "0.15.0", - "license": "MIT", - "dependencies": { - "@neodrag/core": "3.0.0-next.11", - "@neodrag/solid": "3.0.0-next.11", - "@solid-primitives/event-listener": "^2.4.3", - "@solid-primitives/keyboard": "^1.3.3", - "@solid-primitives/resize-observer": "^2.1.3", - "@tanstack/devtools-client": "0.0.8", - "@tanstack/devtools-event-bus": "0.4.3", - "@tanstack/devtools-ui": "0.7.1", - "clsx": "^2.1.1", - "goober": "^2.1.16", - "solid-js": "^1.9.9" - }, - "bin": { - "intent": "bin/intent.js" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "solid-js": ">=1.9.7" - } - }, - "node_modules/@tanstack/devtools-bundler-core": { - "version": "0.1.3", - "dev": true, - "license": "MIT", - "dependencies": { - "@tanstack/devtools-client": "0.0.8", - "@tanstack/devtools-event-bus": "0.4.3", - "chalk": "^5.6.2", - "launch-editor": "^2.14.1", - "magic-string": "^0.30.0", - "oxc-parser": "^0.120.0", - "picomatch": "^4.0.5" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, - "node_modules/@tanstack/devtools-client": { - "version": "0.0.8", - "license": "MIT", - "dependencies": { - "@tanstack/devtools-event-client": "^0.5.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, - "node_modules/@tanstack/devtools-event-bus": { - "version": "0.4.3", - "license": "MIT", - "dependencies": { - "ws": "^8.18.3" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, - "node_modules/@tanstack/devtools-event-client": { - "version": "0.5.0", - "license": "MIT", - "bin": { - "intent": "bin/intent.js" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, - "node_modules/@tanstack/devtools-ui": { - "version": "0.7.1", - "license": "MIT", - "dependencies": { - "clsx": "^2.1.1", - "dayjs": "^1.11.19", - "goober": "^2.1.16", - "solid-js": "^1.9.9" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "solid-js": ">=1.9.7" - } - }, - "node_modules/@tanstack/devtools-vite": { - "version": "0.8.5", - "resolved": "https://registry.npmjs.org/@tanstack/devtools-vite/-/devtools-vite-0.8.5.tgz", - "integrity": "sha512-xaifCEmiwwzizlbp973oISXNsnmuU/BugLa66gryAZaPJJ/qo1kJd2DxY5X960eHwmyIS3SN0KYrL3/aRhucmA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@tanstack/devtools-bundler-core": "0.1.3", - "@tanstack/devtools-client": "0.0.8", - "@tanstack/devtools-event-bus": "0.4.3", - "chalk": "^5.6.2" - }, - "bin": { - "intent": "bin/intent.js" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/@tanstack/eslint-config": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@tanstack/eslint-config/-/eslint-config-0.4.0.tgz", - "integrity": "sha512-V+Cd81W/f65dqKJKpytbwTGx9R+IwxKAHsG/uJ3nSLYEh36hlAr54lRpstUhggQB8nf/cP733cIw8DuD2dzQUg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint/js": "^10.0.1", - "@stylistic/eslint-plugin": "^5.8.0", - "eslint-plugin-import-x": "^4.16.1", - "eslint-plugin-n": "^17.24.0", - "globals": "^17.3.0", - "typescript-eslint": "^8.55.0", - "vue-eslint-parser": "^10.4.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "eslint": "^9.0.0 || ^10.0.0" - } - }, - "node_modules/@tanstack/history": { - "version": "1.162.4", - "license": "MIT", - "engines": { - "node": ">=20.19" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, - "node_modules/@tanstack/react-devtools": { - "version": "0.10.13", - "resolved": "https://registry.npmjs.org/@tanstack/react-devtools/-/react-devtools-0.10.13.tgz", - "integrity": "sha512-r0cdB/14+st2Vxl/1Bodlyq4VC8pf9SykLQDYbGHDc1+V146WQo5B3KlYLkfyxD9Q/6+9NPRQHZyl8fvZVMdRA==", - "license": "MIT", - "dependencies": { - "@tanstack/devtools": "0.15.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "@types/react": ">=16.8", - "@types/react-dom": ">=16.8", - "react": ">=16.8", - "react-dom": ">=16.8" - } - }, - "node_modules/@tanstack/react-router": { - "version": "1.170.41", - "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.170.41.tgz", - "integrity": "sha512-Mpvw8Wm5MGDbTqI8Yc9flLKxbOHwoWocv0H2aGKcgw2a017oK5nZWqa954P+DrUx/8fI98YNywnHP5XcD3tQ9w==", - "license": "MIT", - "dependencies": { - "@tanstack/history": "1.162.4", - "@tanstack/react-store": "^0.11.2", - "@tanstack/router-core": "1.171.34", - "isbot": "^5.1.22" - }, - "engines": { - "node": ">=20.19" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "react": ">=18.0.0 || >=19.0.0", - "react-dom": ">=18.0.0 || >=19.0.0" - } - }, - "node_modules/@tanstack/react-router-devtools": { - "version": "1.167.2", - "resolved": "https://registry.npmjs.org/@tanstack/react-router-devtools/-/react-router-devtools-1.167.2.tgz", - "integrity": "sha512-VuoqB7OhDUrBa4WOFSKiAQ8xI8sJpeZdo9sYE1hQa89iMuVFUQu1iWE4iCiQhB+6TYOZDZ9e+iHKxYjop4ZVYw==", - "license": "MIT", - "dependencies": { - "@tanstack/router-devtools-core": "1.168.2" - }, - "engines": { - "node": ">=20.19" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "@tanstack/react-router": "^1.170.36", - "@tanstack/router-core": "^1.171.30", - "react": ">=18.0.0 || >=19.0.0", - "react-dom": ">=18.0.0 || >=19.0.0" - }, - "peerDependenciesMeta": { - "@tanstack/router-core": { - "optional": true - } - } - }, - "node_modules/@tanstack/react-router/node_modules/@tanstack/router-core": { - "version": "1.171.34", - "resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.171.34.tgz", - "integrity": "sha512-X+19pGuuLVPvvAvPYw9v/+ktRk5cCll8AtfpBpOhDRmVgUMrfqze0467xtq8YRVVs/mmo5rVtWn/7Tp32RDBKw==", - "license": "MIT", - "dependencies": { - "@tanstack/history": "1.162.4", - "cookie-es": "^3.0.0", - "seroval": "^1.6.7", - "seroval-plugins": "^1.6.7" - }, - "engines": { - "node": ">=20.19" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, - "node_modules/@tanstack/react-store": { - "version": "0.11.2", - "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.11.2.tgz", - "integrity": "sha512-oj5O5DmiMWE+ih+SioX2ayC1kbK7d3fpLx0B75fPJr9nY3zU+hoW010yr64qXsY/9fyiLVE2bn5mGNV3owm8RQ==", - "license": "MIT", - "dependencies": { - "@tanstack/store": "0.11.2", - "use-sync-external-store": "^1.6.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/@tanstack/router-core": { - "version": "1.171.32", - "license": "MIT", - "dependencies": { - "@tanstack/history": "1.162.4", - "cookie-es": "^3.0.0", - "seroval": "^1.6.2", - "seroval-plugins": "^1.6.2" - }, - "engines": { - "node": ">=20.19" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, - "node_modules/@tanstack/router-devtools-core": { - "version": "1.168.2", - "license": "MIT", - "dependencies": { - "clsx": "^2.1.1", - "goober": "^2.1.16" - }, - "engines": { - "node": ">=20.19" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "@tanstack/router-core": "^1.171.30", - "csstype": "^3.0.10" - }, - "peerDependenciesMeta": { - "csstype": { - "optional": true - } - } - }, - "node_modules/@tanstack/router-generator": { - "version": "1.167.38", - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.8", - "@tanstack/router-core": "1.171.32", - "@tanstack/router-utils": "1.162.3", - "@tanstack/virtual-file-routes": "1.162.0", - "jiti": "^2.7.0", - "magic-string": "^0.30.21", - "prettier": "^3.9.6", - "zod": "^4.5.4" - }, - "engines": { - "node": ">=20.19" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, - "node_modules/@tanstack/router-plugin": { - "version": "1.168.40", - "license": "MIT", - "dependencies": { - "@babel/core": "^7.29.7", - "@babel/template": "^7.29.7", - "@babel/types": "^7.29.8", - "@tanstack/router-core": "1.171.32", - "@tanstack/router-generator": "1.167.38", - "@tanstack/router-utils": "1.162.3", - "chokidar": "^5.0.0", - "unplugin": "^3.3.0", - "zod": "^4.5.4" - }, - "engines": { - "node": ">=20.19" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "@rsbuild/core": ">=1.0.2 || ^2.0.0", - "@tanstack/react-router": "^1.170.38", - "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0 || >=8.0.0", - "vite-plugin-solid": "^2.11.10 || ^3.0.0-0", - "webpack": ">=5.92.0" - }, - "peerDependenciesMeta": { - "@rsbuild/core": { - "optional": true - }, - "@tanstack/react-router": { - "optional": true - }, - "vite": { - "optional": true - }, - "vite-plugin-solid": { - "optional": true - }, - "webpack": { - "optional": true - } - } - }, - "node_modules/@tanstack/router-utils": { - "version": "1.162.3", - "license": "MIT", - "dependencies": { - "@babel/generator": "^7.29.8", - "@babel/parser": "^7.29.8", - "@babel/types": "^7.29.8", - "ansis": "^4.3.1", - "babel-dead-code-elimination": "^1.0.12", - "diff": "^8.0.4", - "pathe": "^2.0.3", - "tinyglobby": "^0.2.17" - }, - "engines": { - "node": ">=20.19" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, - "node_modules/@tanstack/store": { - "version": "0.11.2", - "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.11.2.tgz", - "integrity": "sha512-sJ4mjol8uQsHV0gOJzzjwXfh2Fwm+Sz0+8deqiTm4jGbMdjzNSW+xZCFm0kUa870uhd8yi+DpKnZb5Kc4apa0Q==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, - "node_modules/@tanstack/virtual-file-routes": { - "version": "1.162.0", - "license": "MIT", - "engines": { - "node": ">=20.19" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, - "node_modules/@testing-library/dom": { - "version": "10.4.2", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.10.4", - "@babel/runtime": "^7.12.5", - "@types/aria-query": "^5.0.1", - "aria-query": "5.3.0", - "dom-accessibility-api": "^0.5.9", - "lz-string": "^1.5.0", - "picocolors": "1.1.1", - "pretty-format": "^27.0.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@testing-library/react": { - "version": "16.3.3", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.12.5" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@testing-library/dom": "^10.0.0", - "@types/react": "^18.0.0 || ^19.0.0", - "@types/react-dom": "^18.0.0 || ^19.0.0", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@types/aria-query": { - "version": "5.0.4", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" - } - }, - "node_modules/@types/babel__generator": { - "version": "7.27.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__traverse": { - "version": "7.28.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.28.2" - } - }, - "node_modules/@types/chai": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", - "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@types/deep-eql": "*", - "assertion-error": "^2.0.1" - } - }, - "node_modules/@types/deep-eql": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/@types/esrecurse": { - "version": "4.3.1", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.9", - "license": "MIT" - }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/@types/node": { - "version": "22.20.4", - "devOptional": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/@types/react": { - "version": "19.3.0", - "license": "MIT", - "dependencies": { - "csstype": "^3.2.2" - } - }, - "node_modules/@types/react-dom": { - "version": "19.3.0", - "license": "MIT", - "peerDependencies": { - "@types/react": "^19.3.0" - } - }, - "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.70.1", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.70.1", - "@typescript-eslint/type-utils": "8.70.1", - "@typescript-eslint/utils": "8.70.1", - "@typescript-eslint/visitor-keys": "8.70.1", - "ignore": "^7.0.5", - "natural-compare": "^1.4.0", - "ts-api-utils": "^2.5.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "@typescript-eslint/parser": "^8.70.1", - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.10", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/@typescript-eslint/parser": { - "version": "8.70.1", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/scope-manager": "8.70.1", - "@typescript-eslint/types": "8.70.1", - "@typescript-eslint/typescript-estree": "8.70.1", - "@typescript-eslint/visitor-keys": "8.70.1", - "debug": "^4.4.3" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/project-service": { - "version": "8.70.1", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.70.1", - "@typescript-eslint/types": "^8.70.1", - "debug": "^4.4.3" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "8.70.1", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.70.1", - "@typescript-eslint/visitor-keys": "8.70.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.70.1", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/type-utils": { - "version": "8.70.1", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.70.1", - "@typescript-eslint/typescript-estree": "8.70.1", - "@typescript-eslint/utils": "8.70.1", - "debug": "^4.4.3", - "ts-api-utils": "^2.5.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/types": { - "version": "8.70.1", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.70.1", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/project-service": "8.70.1", - "@typescript-eslint/tsconfig-utils": "8.70.1", - "@typescript-eslint/types": "8.70.1", - "@typescript-eslint/visitor-keys": "8.70.1", - "debug": "^4.4.3", - "minimatch": "^10.2.2", - "semver": "^7.7.3", - "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.5.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.8.5", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@typescript-eslint/utils": { - "version": "8.70.1", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.70.1", - "@typescript-eslint/types": "8.70.1", - "@typescript-eslint/typescript-estree": "8.70.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.70.1", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.70.1", - "eslint-visitor-keys": "^5.0.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "5.0.1", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@unrs/resolver-binding-linux-x64-gnu": { - "version": "1.12.2", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@vitejs/plugin-react": { - "version": "5.2.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.29.0", - "@babel/plugin-transform-react-jsx-self": "^7.27.1", - "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-rc.3", - "@types/babel__core": "^7.20.5", - "react-refresh": "^0.18.0" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/@vitest/mocker": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.3.tgz", - "integrity": "sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "0.3.31", - "@vitest/spy": "5.0.3", - "estree-walker": "^3.0.3", - "magic-string": "^1.2.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "node_modules/@vitest/mocker/node_modules/magic-string": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", - "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.6.0" - } - }, - "node_modules/@vitest/spy": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.3.tgz", - "integrity": "sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==", - "devOptional": true, - "license": "MIT", - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/acorn": { - "version": "8.18.0", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/agent-base": { - "version": "7.1.4", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/ajv": { - "version": "6.15.0", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "5.2.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/ansis": { - "version": "4.4.0", - "license": "ISC", - "engines": { - "node": ">=14" - } - }, - "node_modules/aria-query": { - "version": "5.3.0", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "dequal": "^2.0.3" - } - }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/babel-dead-code-elimination": { - "version": "1.0.12", - "license": "MIT", - "dependencies": { - "@babel/core": "^7.23.7", - "@babel/parser": "^7.23.6", - "@babel/traverse": "^7.23.7", - "@babel/types": "^7.23.6" - } - }, - "node_modules/balanced-match": { - "version": "4.0.4", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/baseline-browser-mapping": { - "version": "2.11.25", - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.cjs" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/better-auth": { - "version": "1.7.7", - "resolved": "https://registry.npmjs.org/better-auth/-/better-auth-1.7.7.tgz", - "integrity": "sha512-Zhgxi/c5ylmfB/sX+nwnBbsFf/j6k0L8DvL6CJXZSrXgxc5pPMC/e7j812zNd4L4B4ELmdURCPw3X8nAOUdcjw==", - "license": "MIT", - "dependencies": { - "@better-auth/core": "1.7.7", - "@better-auth/drizzle-adapter": "1.7.7", - "@better-auth/kysely-adapter": "1.7.7", - "@better-auth/memory-adapter": "1.7.7", - "@better-auth/mongo-adapter": "1.7.7", - "@better-auth/prisma-adapter": "1.7.7", - "@better-auth/telemetry": "1.7.7", - "@better-auth/utils": "0.4.2", - "@better-fetch/fetch": "1.3.2", - "@noble/ciphers": "^2.2.0", - "@noble/hashes": "^2.2.0", - "better-call": "1.4.0", - "defu": "^6.1.4", - "jose": "^6.2.3", - "kysely": "^0.28.17 || ^0.29.0", - "nanostores": "^1.3.0", - "zod": "^4.5.4" - }, - "peerDependencies": { - "@lynx-js/react": "*", - "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", - "@sveltejs/kit": "^2.0.0", - "@tanstack/react-start": "^1.0.0", - "@tanstack/solid-start": "^1.0.0", - "drizzle-kit": ">=0.31.4 || >=1.0.0-beta.1", - "drizzle-orm": "^0.45.2 || >=1.0.0-rc.1 <2.0.0", - "mongodb": "^6.0.0 || ^7.0.0", - "mysql2": "^3.0.0", - "next": "^14.0.0 || ^15.0.0 || ^16.0.0", - "pg": "^8.0.0", - "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0", - "solid-js": "^1.0.0", - "svelte": "^4.0.0 || ^5.0.0", - "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0", - "vue": "^3.0.0" - }, - "peerDependenciesMeta": { - "@lynx-js/react": { - "optional": true - }, - "@prisma/client": { - "optional": true - }, - "@sveltejs/kit": { - "optional": true - }, - "@tanstack/react-start": { - "optional": true - }, - "@tanstack/solid-start": { - "optional": true - }, - "drizzle-kit": { - "optional": true - }, - "drizzle-orm": { - "optional": true - }, - "mongodb": { - "optional": true - }, - "mysql2": { - "optional": true - }, - "next": { - "optional": true - }, - "pg": { - "optional": true - }, - "prisma": { - "optional": true - }, - "react": { - "optional": true - }, - "react-dom": { - "optional": true - }, - "solid-js": { - "optional": true - }, - "svelte": { - "optional": true - }, - "vitest": { - "optional": true - }, - "vue": { - "optional": true - } - } - }, - "node_modules/better-call": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/better-call/-/better-call-1.4.0.tgz", - "integrity": "sha512-bBKOT4vv1kZLDgxVePdilk/Jwkn+dtRRsmi3DzHcDP+WnswyVl6dR59l2HEeP/0cB+bDoopASAesWDPIdd/zZA==", - "license": "MIT", - "dependencies": { - "@better-auth/utils": "^0.5.0", - "@better-fetch/fetch": "^1.3.1", - "rou3": "^0.9.1", - "set-cookie-parser": "^3.1.2" - }, - "peerDependencies": { - "zod": "^4.0.0" - }, - "peerDependenciesMeta": { - "zod": { - "optional": true - } - } - }, - "node_modules/better-call/node_modules/@better-auth/utils": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@better-auth/utils/-/utils-0.5.0.tgz", - "integrity": "sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA==", - "license": "MIT", - "dependencies": { - "@noble/hashes": "^2.0.1" - } - }, - "node_modules/bidi-js": { - "version": "1.1.0", - "dev": true, - "license": "MIT", - "dependencies": { - "require-from-string": "^2.0.2" - } - }, - "node_modules/brace-expansion": { - "version": "5.0.12", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/browserslist": { - "version": "4.29.1", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "baseline-browser-mapping": "^2.11.25", - "caniuse-lite": "^1.0.30001810", - "electron-to-chromium": "^1.5.438", - "node-releases": "^2.0.57", - "update-browserslist-db": "^1.3.3" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/cacheable": { - "version": "2.5.0", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@cacheable/memory": "^2.2.0", - "@cacheable/utils": "^2.5.0", - "hookified": "^1.15.0", - "keyv": "^5.6.0", - "qified": "^0.10.1" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001812", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/chai": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", - "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/chalk": { - "version": "5.6.2", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/chokidar": { - "version": "5.0.0", - "license": "MIT", - "dependencies": { - "readdirp": "^5.0.0" - }, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/clsx": { - "version": "2.1.1", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/comment-parser": { - "version": "1.4.9", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 12.0.0" - } - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "license": "MIT" - }, - "node_modules/cookie-es": { - "version": "3.1.1", - "license": "MIT" - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/css-tree": { - "version": "3.2.1", - "dev": true, - "license": "MIT", - "dependencies": { - "mdn-data": "2.27.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" - } - }, - "node_modules/cssesc": { - "version": "3.0.0", - "dev": true, - "license": "MIT", - "bin": { - "cssesc": "bin/cssesc" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/cssstyle": { - "version": "6.2.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@asamuzakjp/css-color": "^5.0.1", - "@csstools/css-syntax-patches-for-csstree": "^1.0.28", - "css-tree": "^3.1.0", - "lru-cache": "^11.2.6" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/cssstyle/node_modules/lru-cache": { - "version": "11.5.3", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/csstype": { - "version": "3.2.3", - "license": "MIT" - }, - "node_modules/data-urls": { - "version": "7.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^16.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/dayjs": { - "version": "1.11.23", - "license": "MIT" - }, - "node_modules/debug": { - "version": "4.4.3", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/decimal.js": { - "version": "10.6.0", - "dev": true, - "license": "MIT" - }, - "node_modules/deep-is": { - "version": "0.1.4", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/defu": { - "version": "6.1.7", - "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", - "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", - "license": "MIT" - }, - "node_modules/dequal": { - "version": "2.0.3", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/diff": { - "version": "8.0.4", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.3.1" - } - }, - "node_modules/dom-accessibility-api": { - "version": "0.5.16", - "dev": true, - "license": "MIT" - }, - "node_modules/electron-to-chromium": { - "version": "1.5.438", - "license": "ISC" - }, - "node_modules/enhanced-resolve": { - "version": "5.25.1", - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.4", - "tapable": "^2.3.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/entities": { - "version": "8.1.0", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=20.19.0" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/es-module-lexer": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", - "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/esbuild": { - "version": "0.28.2", - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.2", - "@esbuild/android-arm": "0.28.2", - "@esbuild/android-arm64": "0.28.2", - "@esbuild/android-x64": "0.28.2", - "@esbuild/darwin-arm64": "0.28.2", - "@esbuild/darwin-x64": "0.28.2", - "@esbuild/freebsd-arm64": "0.28.2", - "@esbuild/freebsd-x64": "0.28.2", - "@esbuild/linux-arm": "0.28.2", - "@esbuild/linux-arm64": "0.28.2", - "@esbuild/linux-ia32": "0.28.2", - "@esbuild/linux-loong64": "0.28.2", - "@esbuild/linux-mips64el": "0.28.2", - "@esbuild/linux-ppc64": "0.28.2", - "@esbuild/linux-riscv64": "0.28.2", - "@esbuild/linux-s390x": "0.28.2", - "@esbuild/linux-x64": "0.28.2", - "@esbuild/netbsd-arm64": "0.28.2", - "@esbuild/netbsd-x64": "0.28.2", - "@esbuild/openbsd-arm64": "0.28.2", - "@esbuild/openbsd-x64": "0.28.2", - "@esbuild/openharmony-arm64": "0.28.2", - "@esbuild/sunos-x64": "0.28.2", - "@esbuild/win32-arm64": "0.28.2", - "@esbuild/win32-ia32": "0.28.2", - "@esbuild/win32-x64": "0.28.2" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint": { - "version": "10.11.0", - "dev": true, - "license": "MIT", - "peer": true, - "workspaces": [ - "packages/*" - ], - "dependencies": { - "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.2", - "@eslint/config-array": "^0.23.5", - "@eslint/config-helpers": "^0.7.0", - "@eslint/core": "^1.2.1", - "@eslint/plugin-kit": "^0.7.3", - "@humanfs/node": "^0.16.6", - "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.2", - "@types/estree": "^1.0.6", - "ajv": "^6.14.0", - "cross-spawn": "^7.0.6", - "debug": "^4.3.2", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^9.1.2", - "eslint-visitor-keys": "^5.0.1", - "espree": "^11.2.0", - "esquery": "^1.7.0", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "11.1.5 || >11.1.6 <12", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "minimatch": "^10.2.5", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "jiti": "*" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - } - } - }, - "node_modules/eslint-compat-utils": { - "version": "0.5.1", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.5.4" - }, - "engines": { - "node": ">=12" - }, - "peerDependencies": { - "eslint": ">=6.0.0" - } - }, - "node_modules/eslint-compat-utils/node_modules/semver": { - "version": "7.8.5", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/eslint-import-context": { - "version": "0.1.9", - "dev": true, - "license": "MIT", - "dependencies": { - "get-tsconfig": "^4.10.1", - "stable-hash-x": "^0.2.0" - }, - "engines": { - "node": "^12.20.0 || ^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint-import-context" - }, - "peerDependencies": { - "unrs-resolver": "^1.0.0" - }, - "peerDependenciesMeta": { - "unrs-resolver": { - "optional": true - } - } - }, - "node_modules/eslint-plugin-es-x": { - "version": "7.8.0", - "dev": true, - "funding": [ - "https://github.com/sponsors/ota-meshi", - "https://opencollective.com/eslint" - ], - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.1.2", - "@eslint-community/regexpp": "^4.11.0", - "eslint-compat-utils": "^0.5.1" - }, - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "peerDependencies": { - "eslint": ">=8" - } - }, - "node_modules/eslint-plugin-import-x": { - "version": "4.17.1", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "^8.56.0", - "comment-parser": "^1.4.1", - "debug": "^4.4.1", - "eslint-import-context": "^0.1.9", - "is-glob": "^4.0.3", - "minimatch": "^9.0.3 || ^10.1.2", - "semver": "^7.7.2", - "stable-hash-x": "^0.2.0", - "unrs-resolver": "^1.9.2" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint-plugin-import-x" - }, - "peerDependencies": { - "@typescript-eslint/utils": "^8.56.0", - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "eslint-import-resolver-node": "*" - }, - "peerDependenciesMeta": { - "@typescript-eslint/utils": { - "optional": true - }, - "eslint-import-resolver-node": { - "optional": true - } - } - }, - "node_modules/eslint-plugin-import-x/node_modules/semver": { - "version": "7.8.5", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/eslint-plugin-n": { - "version": "17.24.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.5.0", - "enhanced-resolve": "^5.17.1", - "eslint-plugin-es-x": "^7.8.0", - "get-tsconfig": "^4.8.1", - "globals": "^15.11.0", - "globrex": "^0.1.2", - "ignore": "^5.3.2", - "semver": "^7.6.3", - "ts-declaration-location": "^1.0.6" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": ">=8.23.0" - } - }, - "node_modules/eslint-plugin-n/node_modules/globals": { - "version": "15.15.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint-plugin-n/node_modules/semver": { - "version": "7.8.5", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/eslint-scope": { - "version": "9.1.2", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "@types/esrecurse": "^4.3.1", - "@types/estree": "^1.0.8", - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint/node_modules/eslint-visitor-keys": { - "version": "5.0.1", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint/node_modules/espree": { - "version": "11.2.0", - "dev": true, - "license": "BSD-2-Clause", - "peer": true, - "dependencies": { - "acorn": "^8.16.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^5.0.1" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/espree": { - "version": "10.4.0", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.15.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esquery": { - "version": "1.7.0", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/esutils": { - "version": "2.0.3", - "dev": true, - "license": "BSD-2-Clause", - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/expect-type": { - "version": "1.4.0", - "devOptional": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/fdir": { - "version": "6.5.0", - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/file-entry-cache": { - "version": "11.1.5", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "flat-cache": "^6.1.23" - } - }, - "node_modules/find-up": { - "version": "5.0.0", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/flat-cache": { - "version": "6.1.23", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "cacheable": "^2.5.0", - "flatted": "^3.4.2", - "hookified": "^1.15.0" - } - }, - "node_modules/flatted": { - "version": "3.4.4", - "dev": true, - "license": "ISC", - "peer": true - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/get-tsconfig": { - "version": "4.14.3", - "dev": true, - "license": "MIT", - "dependencies": { - "resolve-pkg-maps": "^1.0.0" - }, - "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" - } - }, - "node_modules/glob-parent": { - "version": "6.0.2", - "dev": true, - "license": "ISC", - "peer": true, - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/globals": { - "version": "17.12.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/globrex": { - "version": "0.1.2", - "dev": true, - "license": "MIT" - }, - "node_modules/goober": { - "version": "2.1.19", - "license": "MIT", - "peerDependencies": { - "csstype": "^3.0.10" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "license": "ISC" - }, - "node_modules/hashery": { - "version": "1.5.1", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "hookified": "^1.15.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/hookified": { - "version": "1.15.1", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/html-encoding-sniffer": { - "version": "6.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@exodus/bytes": "^1.6.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/http-proxy-agent": { - "version": "7.0.2", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/ignore": { - "version": "5.3.2", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-potential-custom-element-name": { - "version": "1.0.1", - "dev": true, - "license": "MIT" - }, - "node_modules/isbot": { - "version": "5.2.2", - "license": "Unlicense", - "engines": { - "node": ">=18" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "dev": true, - "license": "ISC", - "peer": true - }, - "node_modules/jiti": { - "version": "2.7.0", - "license": "MIT", - "bin": { - "jiti": "lib/jiti-cli.mjs" - } - }, - "node_modules/jose": { - "version": "6.2.12", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", - "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "license": "MIT" - }, - "node_modules/jsdom": { - "version": "28.1.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@acemir/cssom": "^0.9.31", - "@asamuzakjp/dom-selector": "^6.8.1", - "@bramus/specificity": "^2.4.2", - "@exodus/bytes": "^1.11.0", - "cssstyle": "^6.0.1", - "data-urls": "^7.0.0", - "decimal.js": "^10.6.0", - "html-encoding-sniffer": "^6.0.0", - "http-proxy-agent": "^7.0.2", - "https-proxy-agent": "^7.0.6", - "is-potential-custom-element-name": "^1.0.1", - "parse5": "^8.0.0", - "saxes": "^6.0.0", - "symbol-tree": "^3.2.4", - "tough-cookie": "^6.0.0", - "undici": "^7.21.0", - "w3c-xmlserializer": "^5.0.0", - "webidl-conversions": "^8.0.1", - "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^16.0.0", - "xml-name-validator": "^5.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - }, - "peerDependencies": { - "canvas": "^3.0.0" - }, - "peerDependenciesMeta": { - "canvas": { - "optional": true - } - } - }, - "node_modules/jsesc": { - "version": "3.1.0", - "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/json5": { - "version": "2.2.3", - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/keyv": { - "version": "5.6.0", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@keyv/serialize": "^1.1.1" - } - }, - "node_modules/kysely": { - "version": "0.29.6", - "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.29.6.tgz", - "integrity": "sha512-hHaB8C/rfzDDtr/t8YZwxAuPJTT0zHyaPoVzcXwDYhYNAgH/4sIfVhi/XLLIY+bL/FqaIJnjATDbi8ObSELmxg==", - "license": "MIT", - "engines": { - "node": ">=22.0.0" - } - }, - "node_modules/launch-editor": { - "version": "2.14.2", - "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.14.2.tgz", - "integrity": "sha512-NHJEb30MGBCkmZXLZwz25t37QR4aY03ZzPYUJXOJaOa92uWwExI9SBQUuIznBYc1xkD7cJNGX7oc3Nl3sq2kQQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "picocolors": "^1.1.1", - "shell-quote": "^1.10.0" - } - }, - "node_modules/levn": { - "version": "0.4.1", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/lightningcss": { - "version": "1.32.0", - "license": "MPL-2.0", - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "cpu": [ - "x64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/locate-path": { - "version": "6.0.0", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lru-cache": { - "version": "5.1.1", - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" - } - }, - "node_modules/lucide-react": { - "version": "0.545.0", - "license": "ISC", - "peerDependencies": { - "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/lz-string": { - "version": "1.5.0", - "dev": true, - "license": "MIT", - "bin": { - "lz-string": "bin/bin.js" - } - }, - "node_modules/magic-string": { - "version": "0.30.21", - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/mdn-data": { - "version": "2.27.1", - "dev": true, - "license": "CC0-1.0" - }, - "node_modules/minimatch": { - "version": "10.2.6", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.8" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.19", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/nanostores": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/nanostores/-/nanostores-1.5.5.tgz", - "integrity": "sha512-FixtE239Gl6Gz0ZGCK0WzvNwObNCIwVT+L7zQcf1QgcA6AzBxblzqe9OT88qiucNkhsjSOqiogSaF5jVbJiqSA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "engines": { - "node": "^20.0.0 || >=22.0.0" - } - }, - "node_modules/napi-postinstall": { - "version": "0.3.4", - "dev": true, - "license": "MIT", - "bin": { - "napi-postinstall": "lib/cli.js" - }, - "engines": { - "node": "^12.20.0 || ^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/napi-postinstall" - } - }, - "node_modules/natural-compare": { - "version": "1.4.0", - "dev": true, - "license": "MIT" - }, - "node_modules/node-releases": { - "version": "2.0.57", - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/obug": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", - "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", - "devOptional": true, - "funding": [ - "https://github.com/sponsors/sxzz", - "https://opencollective.com/debug" - ], - "license": "MIT", - "engines": { - "node": ">=12.20.0" - } - }, - "node_modules/optionator": { - "version": "0.9.4", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/oxc-parser": { - "version": "0.120.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@oxc-project/types": "^0.120.0" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/sponsors/Boshen" - }, - "optionalDependencies": { - "@oxc-parser/binding-android-arm-eabi": "0.120.0", - "@oxc-parser/binding-android-arm64": "0.120.0", - "@oxc-parser/binding-darwin-arm64": "0.120.0", - "@oxc-parser/binding-darwin-x64": "0.120.0", - "@oxc-parser/binding-freebsd-x64": "0.120.0", - "@oxc-parser/binding-linux-arm-gnueabihf": "0.120.0", - "@oxc-parser/binding-linux-arm-musleabihf": "0.120.0", - "@oxc-parser/binding-linux-arm64-gnu": "0.120.0", - "@oxc-parser/binding-linux-arm64-musl": "0.120.0", - "@oxc-parser/binding-linux-ppc64-gnu": "0.120.0", - "@oxc-parser/binding-linux-riscv64-gnu": "0.120.0", - "@oxc-parser/binding-linux-riscv64-musl": "0.120.0", - "@oxc-parser/binding-linux-s390x-gnu": "0.120.0", - "@oxc-parser/binding-linux-x64-gnu": "0.120.0", - "@oxc-parser/binding-linux-x64-musl": "0.120.0", - "@oxc-parser/binding-openharmony-arm64": "0.120.0", - "@oxc-parser/binding-wasm32-wasi": "0.120.0", - "@oxc-parser/binding-win32-arm64-msvc": "0.120.0", - "@oxc-parser/binding-win32-ia32-msvc": "0.120.0", - "@oxc-parser/binding-win32-x64-msvc": "0.120.0" - } - }, - "node_modules/p-limit": { - "version": "3.1.0", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "5.0.0", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/parse5": { - "version": "8.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "entities": "^8.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "node_modules/path-exists": { - "version": "4.0.0", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/pathe": { - "version": "2.0.3", - "license": "MIT" - }, - "node_modules/picocolors": { - "version": "1.1.1", - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.7", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/postcss": { - "version": "8.5.28", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.18", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/postcss-selector-parser": { - "version": "7.1.6", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", - "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/prettier": { - "version": "3.9.9", - "license": "MIT", - "bin": { - "prettier": "bin/prettier.cjs" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - }, - "node_modules/pretty-format": { - "version": "27.5.1", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1", - "ansi-styles": "^5.0.0", - "react-is": "^17.0.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/qified": { - "version": "0.10.1", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "hookified": "^2.1.1" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/qified/node_modules/hookified": { - "version": "2.2.0", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/react": { - "version": "19.3.0", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "19.3.0", - "license": "MIT", - "dependencies": { - "scheduler": "^0.28.0" - }, - "peerDependencies": { - "react": "^19.3.0" - } - }, - "node_modules/react-is": { - "version": "17.0.2", - "dev": true, - "license": "MIT" - }, - "node_modules/react-refresh": { - "version": "0.18.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/readdirp": { - "version": "5.1.1", - "license": "MIT", - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/require-from-string": { - "version": "2.0.2", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/resolve-pkg-maps": { - "version": "1.0.0", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" - } - }, - "node_modules/rollup": { - "version": "4.63.5", - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.9" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@napi-rs/lzma-linux-x64-gnu": "1.5.1", - "@rollup/rollup-android-arm-eabi": "4.63.5", - "@rollup/rollup-android-arm64": "4.63.5", - "@rollup/rollup-darwin-arm64": "4.63.5", - "@rollup/rollup-darwin-x64": "4.63.5", - "@rollup/rollup-freebsd-arm64": "4.63.5", - "@rollup/rollup-freebsd-x64": "4.63.5", - "@rollup/rollup-linux-arm-gnueabihf": "4.63.5", - "@rollup/rollup-linux-arm-musleabihf": "4.63.5", - "@rollup/rollup-linux-arm64-gnu": "4.63.5", - "@rollup/rollup-linux-arm64-musl": "4.63.5", - "@rollup/rollup-linux-loong64-gnu": "4.63.5", - "@rollup/rollup-linux-loong64-musl": "4.63.5", - "@rollup/rollup-linux-ppc64-gnu": "4.63.5", - "@rollup/rollup-linux-ppc64-musl": "4.63.5", - "@rollup/rollup-linux-riscv64-gnu": "4.63.5", - "@rollup/rollup-linux-riscv64-musl": "4.63.5", - "@rollup/rollup-linux-s390x-gnu": "4.63.5", - "@rollup/rollup-linux-x64-gnu": "4.63.5", - "@rollup/rollup-linux-x64-musl": "4.63.5", - "@rollup/rollup-openbsd-x64": "4.63.5", - "@rollup/rollup-openharmony-arm64": "4.63.5", - "@rollup/rollup-win32-arm64-msvc": "4.63.5", - "@rollup/rollup-win32-ia32-msvc": "4.63.5", - "@rollup/rollup-win32-x64-gnu": "4.63.5", - "@rollup/rollup-win32-x64-msvc": "4.63.5", - "fsevents": "~2.3.2" - } - }, - "node_modules/rou3": { - "version": "0.9.2", - "resolved": "https://registry.npmjs.org/rou3/-/rou3-0.9.2.tgz", - "integrity": "sha512-3SOzvaAg8rkHrXtRjpCvCvbyO5to9oOO27Z/XqHEYXfMRVSw/qMIVdmaOk9W2lcRLtR6dlqTjo9hDeJk70QBYQ==", - "license": "MIT" - }, - "node_modules/saxes": { - "version": "6.0.0", - "dev": true, - "license": "ISC", - "dependencies": { - "xmlchars": "^2.2.0" - }, - "engines": { - "node": ">=v12.22.7" - } - }, - "node_modules/scheduler": { - "version": "0.28.0", - "license": "MIT" - }, - "node_modules/semver": { - "version": "6.3.1", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/seroval": { - "version": "1.6.9", - "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.6.9.tgz", - "integrity": "sha512-UeEzHwAK+yl0YLbsvROV1t5yMYEwZZ0CfKYxDvUgS2fO9Vxdf8djqVIB+OuVpuawRNPqw68LE1Du/GMsW2ZPqA==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/seroval-plugins": { - "version": "1.6.9", - "resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.6.9.tgz", - "integrity": "sha512-kiDK/Tb8IWZ61WEU8pA2PDmAuw37gKph9Y77OZlD56mg0Y2g0Bg7gZ1HeHhs5d40w+nRoUq/wj08V26ilbez9g==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "seroval": "^1.0" - } - }, - "node_modules/set-cookie-parser": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.3.tgz", - "integrity": "sha512-xletSdAI5efyVJjsatBbzDkXl3M/Zmad3xfnQe7ZXuBB0yNwW7C2WFsNGjdRNfZunmNHQuX79xs/ACMTLCdcew==", - "license": "MIT" - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/shell-quote": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.12.0.tgz", - "integrity": "sha512-PcByqNyT/38F2kDNi006HAMRJaULuBzq/FOsw3qdZvX/GA9W/jamDaRskgHjubHiftXK5sIFxLNkvrXUwcof6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/solid-js": { - "version": "1.9.17", - "resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.9.17.tgz", - "integrity": "sha512-4GAmLOWPfNV8H9Jfn2LSFTpNEG6jiOKPiwd5lnLGQtehHaX6l3TOLzJqQ7mqwB/3k1Yy3zyumFVopmc/tm34ow==", - "license": "MIT", - "dependencies": { - "csstype": "^3.1.0", - "seroval": "~1.6.8", - "seroval-plugins": "~1.6.8" - } - }, - "node_modules/source-map-js": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", - "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/stable-hash-x": { - "version": "0.2.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/std-env": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", - "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/symbol-tree": { - "version": "3.2.4", - "dev": true, - "license": "MIT" - }, - "node_modules/tailwindcss": { - "version": "4.3.3", - "license": "MIT" - }, - "node_modules/tapable": { - "version": "2.3.3", - "license": "MIT", - "engines": { - "node": ">=6" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/tinybench": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.1.tgz", - "integrity": "sha512-IAbQaPJIIdhVxi0mqy9lez1wEwje0UQf9F1vwtbelEG5Nbgn0nPOxCvU+mNeNS9gyJTAhrHp74CbUaSynQpZcQ==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/tinyexec": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", - "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/tinyglobby": { - "version": "0.2.17", - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tldts": { - "version": "7.4.15", - "dev": true, - "license": "MIT", - "dependencies": { - "tldts-core": "^7.4.15" - }, - "bin": { - "tldts": "bin/cli.js" - } - }, - "node_modules/tldts-core": { - "version": "7.4.15", - "dev": true, - "license": "MIT" - }, - "node_modules/tough-cookie": { - "version": "6.0.2", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "tldts": "^7.0.5" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/tr46": { - "version": "6.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "punycode": "^2.3.1" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/ts-api-utils": { - "version": "2.5.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.12" - }, - "peerDependencies": { - "typescript": ">=4.8.4" - } - }, - "node_modules/ts-declaration-location": { - "version": "1.0.7", - "dev": true, - "funding": [ - { - "type": "ko-fi", - "url": "https://ko-fi.com/rebeccastevens" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/ts-declaration-location" - } - ], - "license": "BSD-3-Clause", - "dependencies": { - "picomatch": "^4.0.2" - }, - "peerDependencies": { - "typescript": ">=4.0.0" - } - }, - "node_modules/tsconfck": { - "version": "3.1.6", - "dev": true, - "license": "MIT", - "bin": { - "tsconfck": "bin/tsconfck.js" - }, - "engines": { - "node": "^18 || >=20" - }, - "peerDependencies": { - "typescript": "^5.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/type-check": { - "version": "0.4.0", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "prelude-ls": "^1.2.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/typescript-eslint": { - "version": "8.70.1", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/eslint-plugin": "8.70.1", - "@typescript-eslint/parser": "8.70.1", - "@typescript-eslint/typescript-estree": "8.70.1", - "@typescript-eslint/utils": "8.70.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/undici": { - "version": "7.29.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20.18.1" - } - }, - "node_modules/undici-types": { - "version": "6.21.0", - "devOptional": true, - "license": "MIT" - }, - "node_modules/unplugin": { - "version": "3.4.0", - "license": "MIT", - "dependencies": { - "@jridgewell/remapping": "^2.3.5", - "picomatch": "^4.0.7", - "webpack-virtual-modules": "^0.6.2" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "peerDependencies": { - "@farmfe/core": "*", - "@rsbuild/core": "*", - "@rspack/core": "*", - "bun-types-no-globals": "*", - "esbuild": "*", - "rolldown": "*", - "rollup": "*", - "unloader": "*", - "vite": "*", - "webpack": "*" - }, - "peerDependenciesMeta": { - "@farmfe/core": { - "optional": true - }, - "@rsbuild/core": { - "optional": true - }, - "@rspack/core": { - "optional": true - }, - "bun-types-no-globals": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "rolldown": { - "optional": true - }, - "rollup": { - "optional": true - }, - "unloader": { - "optional": true - }, - "vite": { - "optional": true - }, - "webpack": { - "optional": true - } - } - }, - "node_modules/unrs-resolver": { - "version": "1.12.2", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "napi-postinstall": "^0.3.4" - }, - "funding": { - "url": "https://opencollective.com/unrs-resolver" - }, - "optionalDependencies": { - "@unrs/resolver-binding-android-arm-eabi": "1.12.2", - "@unrs/resolver-binding-android-arm64": "1.12.2", - "@unrs/resolver-binding-darwin-arm64": "1.12.2", - "@unrs/resolver-binding-darwin-x64": "1.12.2", - "@unrs/resolver-binding-freebsd-x64": "1.12.2", - "@unrs/resolver-binding-linux-arm-gnueabihf": "1.12.2", - "@unrs/resolver-binding-linux-arm-musleabihf": "1.12.2", - "@unrs/resolver-binding-linux-arm64-gnu": "1.12.2", - "@unrs/resolver-binding-linux-arm64-musl": "1.12.2", - "@unrs/resolver-binding-linux-loong64-gnu": "1.12.2", - "@unrs/resolver-binding-linux-loong64-musl": "1.12.2", - "@unrs/resolver-binding-linux-ppc64-gnu": "1.12.2", - "@unrs/resolver-binding-linux-riscv64-gnu": "1.12.2", - "@unrs/resolver-binding-linux-riscv64-musl": "1.12.2", - "@unrs/resolver-binding-linux-s390x-gnu": "1.12.2", - "@unrs/resolver-binding-linux-x64-gnu": "1.12.2", - "@unrs/resolver-binding-linux-x64-musl": "1.12.2", - "@unrs/resolver-binding-openharmony-arm64": "1.12.2", - "@unrs/resolver-binding-wasm32-wasi": "1.12.2", - "@unrs/resolver-binding-win32-arm64-msvc": "1.12.2", - "@unrs/resolver-binding-win32-ia32-msvc": "1.12.2", - "@unrs/resolver-binding-win32-x64-msvc": "1.12.2" - } - }, - "node_modules/update-browserslist-db": { - "version": "1.3.3", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, - "node_modules/uri-js": { - "version": "4.4.1", - "dev": true, - "license": "BSD-2-Clause", - "peer": true, - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/use-sync-external-store": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz", - "integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==", - "license": "MIT", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "dev": true, - "license": "MIT" - }, - "node_modules/vite": { - "version": "7.3.7", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.7.tgz", - "integrity": "sha512-AHEzTORMbi7/7aXp7w7KZ5+DCUdSeLsJz0grY8xHpc9DHdYb0vQu6DqJfm2tOw3gDExNgPgnJTXxM6syYFHaCA==", - "license": "MIT", - "dependencies": { - "esbuild": "^0.27.0 || ^0.28.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "lightningcss": "^1.21.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/vite-tsconfig-paths": { - "version": "5.1.4", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.1.1", - "globrex": "^0.1.2", - "tsconfck": "^3.0.3" - }, - "peerDependencies": { - "vite": "*" - }, - "peerDependenciesMeta": { - "vite": { - "optional": true - } - } - }, - "node_modules/vitest": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.3.tgz", - "integrity": "sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/mocker": "5.0.3", - "chai": "^6.2.2", - "es-module-lexer": "^2.3.2", - "expect-type": "^1.4.0", - "magic-string": "^1.2.3", - "obug": "^2.1.4", - "picomatch": "^4.0.7", - "std-env": "^4.2.0", - "tinybench": "^6.1.4", - "tinyexec": "^1.3.0", - "tinyglobby": "^0.2.17", - "why-is-node-running": "3.2.1" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^22.12.0 || ^24.0.0 || >=26.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@opentelemetry/api": "^1.9.0", - "@types/node": "^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "5.0.3", - "@vitest/browser-preview": "5.0.3", - "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", - "@vitest/coverage-istanbul": "5.0.3", - "@vitest/coverage-v8": "5.0.3", - "@vitest/ui": "5.0.3", - "happy-dom": "*", - "jsdom": "*", - "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@opentelemetry/api": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser-playwright": { - "optional": true - }, - "@vitest/browser-preview": { - "optional": true - }, - "@vitest/browser-webdriverio": { - "optional": true - }, - "@vitest/coverage-istanbul": { - "optional": true - }, - "@vitest/coverage-v8": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - }, - "vite": { - "optional": false - } - } - }, - "node_modules/vitest/node_modules/magic-string": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", - "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.6.0" - } - }, - "node_modules/vue-eslint-parser": { - "version": "10.4.1", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "eslint-scope": "^8.2.0 || ^9.0.0", - "eslint-visitor-keys": "^4.2.0 || ^5.0.0", - "espree": "^10.3.0 || ^11.0.0", - "esquery": "^1.6.0", - "semver": "^7.6.3" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://github.com/sponsors/mysticatea" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0" - } - }, - "node_modules/vue-eslint-parser/node_modules/semver": { - "version": "7.8.5", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/w3c-xmlserializer": { - "version": "5.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "xml-name-validator": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/webidl-conversions": { - "version": "8.0.1", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=20" - } - }, - "node_modules/webpack-virtual-modules": { - "version": "0.6.2", - "license": "MIT" - }, - "node_modules/whatwg-mimetype": { - "version": "5.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" - } - }, - "node_modules/whatwg-url": { - "version": "16.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "@exodus/bytes": "^1.11.0", - "tr46": "^6.0.0", - "webidl-conversions": "^8.0.1" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/which": { - "version": "2.0.2", - "dev": true, - "license": "ISC", - "peer": true, - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/why-is-node-running": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", - "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", - "devOptional": true, - "license": "MIT", - "bin": { - "why-is-node-running": "cli.js" - }, - "engines": { - "node": ">=20.11" - } - }, - "node_modules/word-wrap": { - "version": "1.2.5", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/ws": { - "version": "8.21.3", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/xml-name-validator": { - "version": "5.0.0", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/xmlchars": { - "version": "2.2.0", - "dev": true, - "license": "MIT" - }, - "node_modules/yallist": { - "version": "3.1.1", - "license": "ISC" - }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/zod": { - "version": "4.6.5", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - } - } -} diff --git a/web-interface/package.json b/web-interface/package.json deleted file mode 100644 index a2e274d..0000000 --- a/web-interface/package.json +++ /dev/null @@ -1,61 +0,0 @@ -{ - "name": "web-interface", - "private": true, - "type": "module", - "engines": { - "node": ">=22.16.0" - }, - "imports": { - "#/*": "./src/*" - }, - "scripts": { - "dev": "vite dev --port 3000", - "build": "vite build", - "build:docs": "hugo -s ../docs", - "preview": "vite preview", - "test": "vitest run", - "lint": "eslint", - "fake-backend": "node ../scripts/fake-backend.mjs", - "configure-fake-backend": "node ../scripts/configure-fake-backend.mjs", - "format": "prettier --check .", - "check": "prettier --write . && eslint --fix" - }, - "dependencies": { - "@tailwindcss/vite": "^4.1.18", - "@tanstack/react-devtools": "latest", - "@tanstack/react-router": "latest", - "@tanstack/react-router-devtools": "latest", - "@tanstack/router-plugin": "^1.132.0", - "better-auth": "1.7.7", - "lucide-react": "^0.545.0", - "react": "^19.2.0", - "react-dom": "^19.2.0", - "tailwindcss": "^4.1.18" - }, - "devDependencies": { - "@tailwindcss/typography": "^0.5.16", - "@tanstack/devtools-vite": "latest", - "@tanstack/eslint-config": "latest", - "@testing-library/dom": "^10.4.1", - "@testing-library/react": "^16.3.0", - "@types/node": "^22.10.2", - "@types/react": "^19.2.0", - "@types/react-dom": "^19.2.0", - "@vitejs/plugin-react": "^5.1.4", - "jsdom": "^28.1.0", - "prettier": "^3.8.1", - "typescript": "^5.7.2", - "vite": "^7.3.7", - "vite-tsconfig-paths": "^5.1.4", - "vitest": "^5.0.3" - }, - "pnpm": { - "onlyBuiltDependencies": [ - "esbuild", - "lightningcss" - ] - }, - "overrides": { - "postcss-selector-parser": "^7.1.6" - } -} diff --git a/web-interface/pnpm-lock.yaml b/web-interface/pnpm-lock.yaml deleted file mode 100644 index 0e4a31e..0000000 --- a/web-interface/pnpm-lock.yaml +++ /dev/null @@ -1,4599 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - dependencies: - '@tailwindcss/vite': - specifier: ^4.1.18 - version: 4.3.3(vite@7.3.7(@types/node@22.20.5)(jiti@2.7.0)(lightningcss@1.32.0)) - '@tanstack/react-devtools': - specifier: latest - version: 0.10.13(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(csstype@3.2.3)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(solid-js@1.9.17) - '@tanstack/react-router': - specifier: latest - version: 1.170.45(react-dom@19.3.1(react@19.3.1))(react@19.3.1) - '@tanstack/react-router-devtools': - specifier: latest - version: 1.167.2(@tanstack/react-router@1.170.45(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(@tanstack/router-core@1.171.38)(csstype@3.2.3)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) - '@tanstack/router-plugin': - specifier: ^1.132.0 - version: 1.168.47(@tanstack/react-router@1.170.45(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(esbuild@0.28.2)(rollup@4.64.7)(vite@7.3.7(@types/node@22.20.5)(jiti@2.7.0)(lightningcss@1.32.0)) - better-auth: - specifier: 1.7.7 - version: 1.7.7(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(solid-js@1.9.17)(vitest@5.0.5(@types/node@22.20.5)(jsdom@28.1.0(@noble/hashes@2.4.0))(vite@7.3.7(@types/node@22.20.5)(jiti@2.7.0)(lightningcss@1.32.0))) - lucide-react: - specifier: ^0.545.0 - version: 0.545.0(react@19.3.1) - react: - specifier: ^19.2.0 - version: 19.3.1 - react-dom: - specifier: ^19.2.0 - version: 19.3.1(react@19.3.1) - tailwindcss: - specifier: ^4.1.18 - version: 4.3.3 - devDependencies: - '@tailwindcss/typography': - specifier: ^0.5.16 - version: 0.5.20(tailwindcss@4.3.3) - '@tanstack/devtools-vite': - specifier: latest - version: 0.8.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(vite@7.3.7(@types/node@22.20.5)(jiti@2.7.0)(lightningcss@1.32.0)) - '@tanstack/eslint-config': - specifier: latest - version: 0.4.0(@typescript-eslint/utils@8.71.2(eslint@10.13.0(jiti@2.7.0))(typescript@5.9.3))(eslint@10.13.0(jiti@2.7.0))(typescript@5.9.3) - '@testing-library/dom': - specifier: ^10.4.1 - version: 10.4.2 - '@testing-library/react': - specifier: ^16.3.0 - version: 16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) - '@types/node': - specifier: ^22.10.2 - version: 22.20.5 - '@types/react': - specifier: ^19.2.0 - version: 19.3.0 - '@types/react-dom': - specifier: ^19.2.0 - version: 19.3.0(@types/react@19.3.0) - '@vitejs/plugin-react': - specifier: ^5.1.4 - version: 5.2.0(vite@7.3.7(@types/node@22.20.5)(jiti@2.7.0)(lightningcss@1.32.0)) - jsdom: - specifier: ^28.1.0 - version: 28.1.0(@noble/hashes@2.4.0) - prettier: - specifier: ^3.8.1 - version: 3.9.10 - typescript: - specifier: ^5.7.2 - version: 5.9.3 - vite: - specifier: ^7.3.7 - version: 7.3.7(@types/node@22.20.5)(jiti@2.7.0)(lightningcss@1.32.0) - vite-tsconfig-paths: - specifier: ^5.1.4 - version: 5.1.4(typescript@5.9.3)(vite@7.3.7(@types/node@22.20.5)(jiti@2.7.0)(lightningcss@1.32.0)) - vitest: - specifier: ^5.0.3 - version: 5.0.5(@types/node@22.20.5)(jsdom@28.1.0(@noble/hashes@2.4.0))(vite@7.3.7(@types/node@22.20.5)(jiti@2.7.0)(lightningcss@1.32.0)) - -packages: - - '@acemir/cssom@0.9.32': - resolution: {integrity: sha512-OijrAUu7rrJhSIoHNg6LMPS5e344nwUHA+1EFYYjrh64D+GmytUsfBQjci45TjT+rd5Spsp8B5xsZDT+TgyXXQ==} - - '@asamuzakjp/css-color@5.1.11': - resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} - engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} - - '@asamuzakjp/dom-selector@6.8.1': - resolution: {integrity: sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==} - - '@asamuzakjp/generational-cache@1.0.1': - resolution: {integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==} - engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} - - '@asamuzakjp/nwsapi@2.3.9': - resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} - - '@babel/code-frame@7.29.7': - resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} - engines: {node: '>=6.9.0'} - - '@babel/compat-data@7.29.7': - resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} - engines: {node: '>=6.9.0'} - - '@babel/core@7.29.7': - resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} - engines: {node: '>=6.9.0'} - - '@babel/generator@7.29.8': - resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} - engines: {node: '>=6.9.0'} - - '@babel/helper-compilation-targets@7.29.7': - resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} - engines: {node: '>=6.9.0'} - - '@babel/helper-globals@7.29.7': - resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} - engines: {node: '>=6.9.0'} - - '@babel/helper-module-imports@7.29.7': - resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} - engines: {node: '>=6.9.0'} - - '@babel/helper-module-transforms@7.29.7': - resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/helper-plugin-utils@7.29.7': - resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-string-parser@7.29.7': - resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-validator-identifier@7.29.7': - resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} - engines: {node: '>=6.9.0'} - - '@babel/helper-validator-option@7.29.7': - resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} - engines: {node: '>=6.9.0'} - - '@babel/helpers@7.29.10': - resolution: {integrity: sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg==} - engines: {node: '>=6.9.0'} - - '@babel/parser@7.29.9': - resolution: {integrity: sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==} - engines: {node: '>=6.0.0'} - hasBin: true - - '@babel/plugin-transform-react-jsx-self@7.29.7': - resolution: {integrity: sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-react-jsx-source@7.29.7': - resolution: {integrity: sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/runtime@7.29.10': - resolution: {integrity: sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==} - engines: {node: '>=6.9.0'} - - '@babel/template@7.29.7': - resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} - engines: {node: '>=6.9.0'} - - '@babel/traverse@7.29.10': - resolution: {integrity: sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ==} - engines: {node: '>=6.9.0'} - - '@babel/types@7.29.8': - resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} - engines: {node: '>=6.9.0'} - - '@better-auth/core@1.7.7': - resolution: {integrity: sha512-srgFzHEjB2WWlokFz1i4BfG41sNhsA105Pukgqu6RrEh5A+LSkzf1mCHydVipkjt9l5qswtBMi7IL6ouHbZL4Q==} - peerDependencies: - '@better-auth/utils': 0.4.2 - '@better-fetch/fetch': 1.3.2 - '@opentelemetry/api': ^1.9.0 - better-call: 1.4.0 - jose: ^6.1.0 - kysely: ^0.28.5 || ^0.29.0 - nanostores: ^1.0.1 - peerDependenciesMeta: - '@opentelemetry/api': - optional: true - - '@better-auth/drizzle-adapter@1.7.7': - resolution: {integrity: sha512-qon0U94PR5FQysuyGoAlGVQpIIiNg5dtDpwjE63sN/HjoRWZadZzHPcS9mn7JsJyQZK6iTNhd7LD0AaXt2f78w==} - peerDependencies: - '@better-auth/core': ^1.7.7 - '@better-auth/utils': 0.4.2 - drizzle-orm: ^0.45.2 || >=1.0.0-rc.1 <2.0.0 - peerDependenciesMeta: - drizzle-orm: - optional: true - - '@better-auth/kysely-adapter@1.7.7': - resolution: {integrity: sha512-9FONOCgOcrQI9wJ5v1oDGIg9sT7pa9RjZZQ4fsCTudo8R87SrIGNUvUxDFTJhZvUblvFfGaIBR9Vb8LA+8bqLQ==} - peerDependencies: - '@better-auth/core': ^1.7.7 - '@better-auth/utils': 0.4.2 - kysely: ^0.28.17 || ^0.29.0 - peerDependenciesMeta: - kysely: - optional: true - - '@better-auth/memory-adapter@1.7.7': - resolution: {integrity: sha512-FqKFEe+b5tXXV0gRbyirca+qXgMpLOeJ8Wk19yykb/scQKy0WJ4k22WzEV3TTPctQV9DZhwCXPlDtQhnlOv5Gg==} - peerDependencies: - '@better-auth/core': ^1.7.7 - '@better-auth/utils': 0.4.2 - - '@better-auth/mongo-adapter@1.7.7': - resolution: {integrity: sha512-ZnVDuRrXqbf0oHphrEN27oTImNH5NTO26dtWatq5cXyYAsH7goof9QIoTOmxTbBxUQahKBw9T6+9h6n+RtOE+g==} - peerDependencies: - '@better-auth/core': ^1.7.7 - '@better-auth/utils': 0.4.2 - mongodb: ^6.0.0 || ^7.0.0 - peerDependenciesMeta: - mongodb: - optional: true - - '@better-auth/prisma-adapter@1.7.7': - resolution: {integrity: sha512-4YcnrcVdvWiAZw0sZl63tWUClPXg5r3QsLn7C2sG7kKEBY06zOng0ibecyxRBixKq1zE1ceHuEUqez5Rhi+nQA==} - peerDependencies: - '@better-auth/core': ^1.7.7 - '@better-auth/utils': 0.4.2 - '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 - prisma: ^5.0.0 || ^6.0.0 || ^7.0.0 - peerDependenciesMeta: - '@prisma/client': - optional: true - prisma: - optional: true - - '@better-auth/telemetry@1.7.7': - resolution: {integrity: sha512-PaRA6i+kAioVYmza2gHPcdrxP0hAotph4KV9hft2GeNJq+AZXDiBu3FuLM9HxvcmiZeUT74Vufqd9ypmB8veTA==} - peerDependencies: - '@better-auth/core': ^1.7.7 - '@better-auth/utils': 0.4.2 - '@better-fetch/fetch': 1.3.2 - - '@better-auth/utils@0.4.2': - resolution: {integrity: sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A==} - - '@better-auth/utils@0.5.0': - resolution: {integrity: sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA==} - - '@better-fetch/fetch@1.3.2': - resolution: {integrity: sha512-Gs7n99b5tqUC6cQAPbV0uED3IraHB6xQbHLQ/C3l7ZFafHScOx9pQ+DYmP5blbLFShVWLqxNUlI9wi4xU/X+ow==} - - '@bramus/specificity@2.4.3': - resolution: {integrity: sha512-0GEz8qJWokkUVYQYRukmkSHIFNoBH8QTreeHrAFbvz1XFYc3Y+puhMs8PQ5x8GNugiC2l5jvDBXKLbBIByeSBw==} - hasBin: true - - '@cacheable/memory@2.2.0': - resolution: {integrity: sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==} - - '@cacheable/utils@2.5.0': - resolution: {integrity: sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==} - - '@csstools/color-helpers@6.1.2': - resolution: {integrity: sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==} - engines: {node: '>=20.19.0'} - - '@csstools/css-calc@3.4.3': - resolution: {integrity: sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA==} - engines: {node: '>=20.19.0'} - peerDependencies: - '@csstools/css-parser-algorithms': ^4.0.2 - '@csstools/css-tokenizer': ^4.0.2 - - '@csstools/css-color-parser@4.2.6': - resolution: {integrity: sha512-iiPQ3iRWwnJkeEn6RIu6SJPr7hYrLz6XZ9s/QZl+2/LI5KQVjpl2fdmDSZKuD4xP6GMmMPgHFFXg6k1Wkz0Trg==} - engines: {node: '>=20.19.0'} - peerDependencies: - '@csstools/css-parser-algorithms': ^4.0.2 - '@csstools/css-tokenizer': ^4.0.2 - - '@csstools/css-parser-algorithms@4.0.2': - resolution: {integrity: sha512-40cSKyMvK+tq4qz6Awrlye2WGuOKt3FwPgtGg6KTfbHOWNw+Rk1rzbAtZnZ6IBhsY491HLRnDXwoyBAijmmILA==} - engines: {node: '>=20.19.0'} - peerDependencies: - '@csstools/css-tokenizer': ^4.0.2 - - '@csstools/css-syntax-patches-for-csstree@1.1.15': - resolution: {integrity: sha512-J0u7HkVl2nzSlhsiTOp4AmwcUQ3D+mGEEKfBy/7To5/y7F2OHwyLrXfrhR0SMgr4p5Lo+eaMVSeai24zUcBIxA==} - peerDependencies: - css-tree: ^3.2.1 - peerDependenciesMeta: - css-tree: - optional: true - - '@csstools/css-tokenizer@4.0.2': - resolution: {integrity: sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==} - engines: {node: '>=20.19.0'} - - '@emnapi/core@1.10.0': - resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} - - '@emnapi/runtime@1.10.0': - resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} - - '@emnapi/wasi-threads@1.2.1': - resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} - - '@esbuild/aix-ppc64@0.28.2': - resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - - '@esbuild/android-arm64@0.28.2': - resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm@0.28.2': - resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - - '@esbuild/android-x64@0.28.2': - resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - - '@esbuild/darwin-arm64@0.28.2': - resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-x64@0.28.2': - resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - - '@esbuild/freebsd-arm64@0.28.2': - resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - - '@esbuild/freebsd-x64@0.28.2': - resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - - '@esbuild/linux-arm64@0.28.2': - resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm@0.28.2': - resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - - '@esbuild/linux-ia32@0.28.2': - resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - - '@esbuild/linux-loong64@0.28.2': - resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - - '@esbuild/linux-mips64el@0.28.2': - resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - - '@esbuild/linux-ppc64@0.28.2': - resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-riscv64@0.28.2': - resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-s390x@0.28.2': - resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - - '@esbuild/linux-x64@0.28.2': - resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - - '@esbuild/netbsd-arm64@0.28.2': - resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - - '@esbuild/netbsd-x64@0.28.2': - resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - - '@esbuild/openbsd-arm64@0.28.2': - resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - - '@esbuild/openbsd-x64@0.28.2': - resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - - '@esbuild/openharmony-arm64@0.28.2': - resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - - '@esbuild/sunos-x64@0.28.2': - resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - - '@esbuild/win32-arm64@0.28.2': - resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - - '@esbuild/win32-ia32@0.28.2': - resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - - '@esbuild/win32-x64@0.28.2': - resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - - '@eslint-community/eslint-utils@4.10.1': - resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - peerDependencies: - eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 - - '@eslint-community/regexpp@4.12.2': - resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} - engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - - '@eslint/config-array@0.23.5': - resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - '@eslint/config-helpers@0.7.0': - resolution: {integrity: sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - '@eslint/core@1.2.1': - resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - '@eslint/js@10.0.1': - resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - peerDependencies: - eslint: ^10.0.0 - peerDependenciesMeta: - eslint: - optional: true - - '@eslint/object-schema@3.0.5': - resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - '@eslint/plugin-kit@0.7.3': - resolution: {integrity: sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - '@exodus/bytes@1.16.0': - resolution: {integrity: sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==} - engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} - peerDependencies: - '@noble/hashes': ^1.8.0 || ^2.0.0 - peerDependenciesMeta: - '@noble/hashes': - optional: true - - '@humanfs/core@0.19.2': - resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} - engines: {node: '>=18.18.0'} - - '@humanfs/node@0.16.8': - resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} - engines: {node: '>=18.18.0'} - - '@humanfs/types@0.15.0': - resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} - engines: {node: '>=18.18.0'} - - '@humanwhocodes/module-importer@1.0.1': - resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} - engines: {node: '>=12.22'} - - '@humanwhocodes/retry@0.4.3': - resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} - engines: {node: '>=18.18'} - - '@jridgewell/gen-mapping@0.3.13': - resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} - - '@jridgewell/remapping@2.3.5': - resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} - - '@jridgewell/resolve-uri@3.1.2': - resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} - engines: {node: '>=6.0.0'} - - '@jridgewell/sourcemap-codec@1.6.0': - resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} - - '@jridgewell/trace-mapping@0.3.31': - resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - - '@keyv/bigmap@1.3.1': - resolution: {integrity: sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==} - engines: {node: '>= 18'} - peerDependencies: - keyv: ^5.6.0 - - '@keyv/serialize@1.1.1': - resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==} - - '@napi-rs/lzma-linux-x64-gnu@1.5.1': - resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} - engines: {node: ^22.20 || ^24.12 || >=25} - cpu: [x64] - os: [linux] - - '@napi-rs/wasm-runtime@1.2.5': - resolution: {integrity: sha512-HshSvXzmBxNzqejd3k/KemgQ3hdREUYFRRrgxOZ+gPljDIsO2SDZrVZMd4Z/HsxudQbE62OVyVFE8z9BCurfjA==} - engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} - peerDependencies: - '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4 - '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4 - - '@neodrag/core@3.0.0-next.11': - resolution: {integrity: sha512-3WQWxyrbxiaK9zS5JU2wJsW2gpoQlZBXVghduBh61JpqaeE0T0cte8R0qYK2RuJo3J2TYQYqxO19CpG/C1i5eg==} - - '@neodrag/solid@3.0.0-next.11': - resolution: {integrity: sha512-vCBIn/pimjWMQ6vhTS2/O1XNAwzVtc4eUhdbQ91WykbZWWqQ5NocDXt/1OdYrEkeRzJcpCv8wEz5PnMkgKP81Q==} - peerDependencies: - '@neodrag/core': 3.0.0-next.11 - solid-js: ^1.0.0 - - '@noble/ciphers@2.4.0': - resolution: {integrity: sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw==} - engines: {node: '>= 20.19.0'} - - '@noble/hashes@2.4.0': - resolution: {integrity: sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==} - engines: {node: '>= 20.19.0'} - - '@opentelemetry/semantic-conventions@1.43.0': - resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==} - engines: {node: '>=14'} - - '@oxc-parser/binding-android-arm-eabi@0.120.0': - resolution: {integrity: sha512-WU3qtINx802wOl8RxAF1v0VvmC2O4D9M8Sv486nLeQ7iPHVmncYZrtBhB4SYyX+XZxj2PNnCcN+PW21jHgiOxg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [android] - - '@oxc-parser/binding-android-arm64@0.120.0': - resolution: {integrity: sha512-SEf80EHdhlbjZEgzeWm0ZA/br4GKMenDW3QB/gtyeTV1gStvvZeFi40ioHDZvds2m4Z9J1bUAUL8yn1/+A6iGg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [android] - - '@oxc-parser/binding-darwin-arm64@0.120.0': - resolution: {integrity: sha512-xVrrbCai8R8CUIBu3CjryutQnEYhZqs1maIqDvtUCFZb8vY33H7uh9mHpL3a0JBIKoBUKjPH8+rzyAeXnS2d6A==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [darwin] - - '@oxc-parser/binding-darwin-x64@0.120.0': - resolution: {integrity: sha512-xyHBbnJ6mydnQUH7MAcafOkkrNzQC6T+LXgDH/3InEq2BWl/g424IMRiJVSpVqGjB+p2bd0h0WRR8iIwzjU7rw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [darwin] - - '@oxc-parser/binding-freebsd-x64@0.120.0': - resolution: {integrity: sha512-UMnVRllquXUYTeNfFKmxTTEdZ/ix1nLl0ducDzMSREoWYGVIHnOOxoKMWlCOvRr9Wk/HZqo2rh1jeumbPGPV9A==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [freebsd] - - '@oxc-parser/binding-linux-arm-gnueabihf@0.120.0': - resolution: {integrity: sha512-tkvn2CQ7QdcsMnpfiX3fd3wA3EFsWKYlcQzq9cFw/xc89Al7W6Y4O0FgLVkVQpo0Tnq/qtE1XfkJOnRRA9S/NA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [linux] - - '@oxc-parser/binding-linux-arm-musleabihf@0.120.0': - resolution: {integrity: sha512-WN5y135Ic42gQDk9grbwY9++fDhqf8knN6fnP+0WALlAUh4odY/BDK1nfTJRSfpJD9P3r1BwU0m3pW2DU89whQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [linux] - - '@oxc-parser/binding-linux-arm64-gnu@0.120.0': - resolution: {integrity: sha512-1GgQBCcXvFMw99EPdMy+4NZ3aYyXsxjf9kbUUg8HuAy3ZBXzOry5KfFEzT9nqmgZI1cuetvApkiJBZLAPo8uaw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - - '@oxc-parser/binding-linux-arm64-musl@0.120.0': - resolution: {integrity: sha512-gmMQ70gsPdDBgpcErvJEoWNBr7bJooSLlvOBVBSGfOzlP5NvJ3bFvnUeZZ9d+dPrqSngtonf7nyzWUTUj/U+lw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - - '@oxc-parser/binding-linux-ppc64-gnu@0.120.0': - resolution: {integrity: sha512-T/kZuU0ajop0xhzVMwH5r3srC9Nqup5HaIo+3uFjIN5uPxa0LvSxC1ZqP4aQGJVW5G0z8/nCkjIfSMS91P/wzw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [ppc64] - os: [linux] - - '@oxc-parser/binding-linux-riscv64-gnu@0.120.0': - resolution: {integrity: sha512-vn21KXLAXzaI3N5CZWlBr1iWeXLl9QFIMor7S1hUjUGTeUuWCoE6JZB040/ZNDwf+JXPX8Ao9KbmJq9FMC2iGw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [riscv64] - os: [linux] - - '@oxc-parser/binding-linux-riscv64-musl@0.120.0': - resolution: {integrity: sha512-SUbUxlar007LTGmSLGIC5x/WJvwhdX+PwNzFJ9f/nOzZOrCFbOT4ikt7pJIRg1tXVsEfzk5mWpGO1NFiSs4PIw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [riscv64] - os: [linux] - - '@oxc-parser/binding-linux-s390x-gnu@0.120.0': - resolution: {integrity: sha512-hYiPJTxyfJY2+lMBFk3p2bo0R9GN+TtpPFlRqVchL1qvLG+pznstramHNvJlw9AjaoRUHwp9IKR7UZQnRPGjgQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [s390x] - os: [linux] - - '@oxc-parser/binding-linux-x64-gnu@0.120.0': - resolution: {integrity: sha512-q+5jSVZkprJCIy3dzJpApat0InJaoxQLsJuD6DkX8hrUS61z2lHQ1Fe9L2+TYbKHXCLWbL0zXe7ovkIdopBGMQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - - '@oxc-parser/binding-linux-x64-musl@0.120.0': - resolution: {integrity: sha512-D9QDDZNnH24e7X4ftSa6ar/2hCavETfW3uk0zgcMIrZNy459O5deTbWrjGzZiVrSWigGtlQwzs2McBP0QsfV1w==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - - '@oxc-parser/binding-openharmony-arm64@0.120.0': - resolution: {integrity: sha512-TBU8ZwOUWAOUWVfmI16CYWbvh4uQb9zHnGBHsw5Cp2JUVG044OIY1CSHODLifqzQIMTXvDvLzcL89GGdUIqNrA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [openharmony] - - '@oxc-parser/binding-wasm32-wasi@0.120.0': - resolution: {integrity: sha512-WG/FOZgDJCpJnuF3ToG/K28rcOmSY7FmFmfBKYb2fmLyhDzPpUldFGV7/Fz4ru0Iz/v4KPmf8xVgO8N3lO4KHA==} - engines: {node: '>=14.0.0'} - cpu: [wasm32] - - '@oxc-parser/binding-win32-arm64-msvc@0.120.0': - resolution: {integrity: sha512-1T0HKGcsz/BKo77t7+89L8Qvu4f9DoleKWHp3C5sJEcbCjDOLx3m9m722bWZTY+hANlUEs+yjlK+lBFsA+vrVQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [win32] - - '@oxc-parser/binding-win32-ia32-msvc@0.120.0': - resolution: {integrity: sha512-L7vfLzbOXsjBXV0rv/6Y3Jd9BRjPeCivINZAqrSyAOZN3moCopDN+Psq9ZrGNZtJzP8946MtlRFZ0Als0wBCOw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [ia32] - os: [win32] - - '@oxc-parser/binding-win32-x64-msvc@0.120.0': - resolution: {integrity: sha512-ys+upfqNtSu58huAhJMBKl3XCkGzyVFBlMlGPzHeFKgpFF/OdgNs1MMf8oaJIbgMH8ZxgGF7qfue39eJohmKIg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [win32] - - '@oxc-project/types@0.120.0': - resolution: {integrity: sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg==} - - '@rolldown/pluginutils@1.0.0-rc.3': - resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} - - '@rollup/rollup-android-arm-eabi@4.64.7': - resolution: {integrity: sha512-GFF2tHk0NqYVObrK0+/ps/LEXsV/v2q6Lyiy7mZr+Ej24fpQScvqK2ziz9NHeEQYnKWKym38Ml7dZbPtC9tuAw==} - cpu: [arm] - os: [android] - - '@rollup/rollup-android-arm64@4.64.7': - resolution: {integrity: sha512-BOymcXJFYYXqluDidj6sLelNjXruC3u1BqVxLn2OPCXH9xaPSOTH+MS4uychsGI1NU7tJoiv9KvqijDHFe+hhQ==} - cpu: [arm64] - os: [android] - - '@rollup/rollup-darwin-arm64@4.64.7': - resolution: {integrity: sha512-jgVpGweIbls2H5jfBB70tnX52abMd5pJr3xmtoDILx6JdmGl+Un+tMb9R+MbHPVB46tTBpY8emtn7M71SnQhEQ==} - cpu: [arm64] - os: [darwin] - - '@rollup/rollup-darwin-x64@4.64.7': - resolution: {integrity: sha512-AVMpt94mrXH7WjyWKmd9nT4RC5yM0AN3Lt6A89xVIsuLDi3dDX6dxMytLbW36FlppY9JsoqUoS3LUUpUrYxiMQ==} - cpu: [x64] - os: [darwin] - - '@rollup/rollup-freebsd-arm64@4.64.7': - resolution: {integrity: sha512-Nje29c/MyajxRNFGU05oyeTDS6SWdMg/wWMSlZSBTBju5BKz2NVc0kFiFSQGzRELNiB+CBk/ZvAVsWsENgjGag==} - cpu: [arm64] - os: [freebsd] - - '@rollup/rollup-freebsd-x64@4.64.7': - resolution: {integrity: sha512-xxgN5/kMzCNz/NYJ4t49B0/WS6InNlWv6xvgwUQqNnWM9WJMMwMUGWrAxdNEgYWdHL0sMnuh8I87StGrIMTA+w==} - cpu: [x64] - os: [freebsd] - - '@rollup/rollup-linux-arm-gnueabihf@4.64.7': - resolution: {integrity: sha512-5U/9gVn/X657I8+NHoHO9Wnfyd0lEXwqbslobRoB9TC8SwYzOitcIr6TLE7531Q/NWs3L4mb+YOGzlf3vHIeYw==} - cpu: [arm] - os: [linux] - - '@rollup/rollup-linux-arm-musleabihf@4.64.7': - resolution: {integrity: sha512-hcDS0a/Y8ggM3dcOfzH7UpSaogCViWTtNDSl0EXpWIj8g8/r86ITqptjil2hayTXS0Umzb+Yt3mjgpQVFVf2+A==} - cpu: [arm] - os: [linux] - - '@rollup/rollup-linux-arm64-gnu@4.64.7': - resolution: {integrity: sha512-awbAr4N7Qd/yjZAQBEEPzgcPveYG2Gi5L6gq1/Bp+3SMRjn5F68Wv1zKsntzvvoioBR6yKvhLtWqu11am1TsxA==} - cpu: [arm64] - os: [linux] - - '@rollup/rollup-linux-arm64-musl@4.64.7': - resolution: {integrity: sha512-Ut7xQJfeLhzu2yN3rHzVvL9ccyPJvN4aCdP1V7hJ5lGxX/eCzgB9SiMk/Md3J+rWWZgvoYsDW4BUFggJr32pBA==} - cpu: [arm64] - os: [linux] - - '@rollup/rollup-linux-loong64-gnu@4.64.7': - resolution: {integrity: sha512-trqoni/vHkuM5oo5iQhoKPh6fjjD7E80pMgCXWgGxU8vSRPUlwvG8GeYvEEcB6eJAxNa99WH9iVHPHelgtKiHA==} - cpu: [loong64] - os: [linux] - - '@rollup/rollup-linux-loong64-musl@4.64.7': - resolution: {integrity: sha512-gvlUy0XFIwtUWiQmhc+4GT2Knm3KosinpUVhr7CtTFA78gFDnE5NWwMPzHrJsYpKkh2LlY7yvBIboN1FAV6Xeg==} - cpu: [loong64] - os: [linux] - - '@rollup/rollup-linux-ppc64-gnu@4.64.7': - resolution: {integrity: sha512-8yS/kSXTJ/47Z62JW+C2AJ3vd/8zZw016vFjtNpZwmZlwJumLua4s1i78dkYuSSqQ+0aIMxJcBp/4uIwp2ehjA==} - cpu: [ppc64] - os: [linux] - - '@rollup/rollup-linux-ppc64-musl@4.64.7': - resolution: {integrity: sha512-mxVSKvvty0hOP1AFo/l4Kpv3i3HK0zx4rYUrirZDToYlWhS3xzmIkq3vUJ7rtrM2cPyXveDyDT6M1FfXZ9hTRA==} - cpu: [ppc64] - os: [linux] - - '@rollup/rollup-linux-riscv64-gnu@4.64.7': - resolution: {integrity: sha512-lEse1zra1CagM+uHH2bl28GInDsZZ45NJzHei2OMaXQFzZWKBO1hFo07rS0xB6ayV4WU360UrSmPDSyCMpYsUQ==} - cpu: [riscv64] - os: [linux] - - '@rollup/rollup-linux-riscv64-musl@4.64.7': - resolution: {integrity: sha512-d/cqCoiG9WMZ5VuJZ5Qdlv9g97QyoGwnmvHccvGhKSacTy7xFrAS+jMcJSBdMNOw1oacSkl6p7K6O/DRYTKL3g==} - cpu: [riscv64] - os: [linux] - - '@rollup/rollup-linux-s390x-gnu@4.64.7': - resolution: {integrity: sha512-kVEA//CeDOZZP1XCH2JBgqXb9+ALlwuA/4civaazMVdgzaSQC9WM38w65zG/LLk0lA7F9Od19CSBZW7J9mCbNg==} - cpu: [s390x] - os: [linux] - - '@rollup/rollup-linux-x64-gnu@4.64.7': - resolution: {integrity: sha512-14oLfDsOSr0TSntT/34jRg/V8B2XW/Jomh9zX9CjIM9gwwNlI1OCymS/GVn0DV32h67eRf7U0GGem0rvfXgNwQ==} - cpu: [x64] - os: [linux] - - '@rollup/rollup-linux-x64-musl@4.64.7': - resolution: {integrity: sha512-dFVtPfjZqAnUPpPY4Bfg+87yo1IBW9HiY8iCFtg49Pn46MfZTebSHZh8PL6SOsgyt9LQP8Nj/sE6jm3aWpySmQ==} - cpu: [x64] - os: [linux] - - '@rollup/rollup-openbsd-x64@4.64.7': - resolution: {integrity: sha512-Dm9CZ2SM3u2NgvBj4awW/gGqkOwzH8f6MSYIJE6+bKcKLI+T0+LhpD6vtetTfrIIx+rLXDf5RJqBP6v7V5O+wg==} - cpu: [x64] - os: [openbsd] - - '@rollup/rollup-openharmony-arm64@4.64.7': - resolution: {integrity: sha512-oaxsvS2mL6+78741CT2HCORBvy1USg3Wqpt5qvZwy3aMXRMl4zxyDo8maeFRWJkF3dp0Wbi71fKH5Gr9YI3vYA==} - cpu: [arm64] - os: [openharmony] - - '@rollup/rollup-win32-arm64-msvc@4.64.7': - resolution: {integrity: sha512-UKZz1Y4mxUqkWh60uH7B1aZn6UcAimPk4ipRktpb3wfZq7JrzZYyAXTwiafkb3T+kd5P3Sf+vzcRdsDks4WtjQ==} - cpu: [arm64] - os: [win32] - - '@rollup/rollup-win32-ia32-msvc@4.64.7': - resolution: {integrity: sha512-WWkSuC1J+mZJnsDh0mRaCAFSWfQdl6y3fDjZeWElI6zHo8CcOSG5lY3yurKJjEq+FrX0WD60sFrVVpbHqzMmrw==} - cpu: [ia32] - os: [win32] - - '@rollup/rollup-win32-x64-gnu@4.64.7': - resolution: {integrity: sha512-u9bp2yXCI3/BUjUxY6uWknAY32pfvvDCPysyUTySotx0dj3ydlFHrRQYfn/b/kZgnfjyEwySMtI7Ga054uiysQ==} - cpu: [x64] - os: [win32] - - '@rollup/rollup-win32-x64-msvc@4.64.7': - resolution: {integrity: sha512-EhX6N3XPMEnVU5cRfNgzPOKcSucsbu3nNNWuoAaEuKIqmddBKHN5b9d4rPe65T1I+FaOT176JKiZNsUBp3rqbA==} - cpu: [x64] - os: [win32] - - '@solid-primitives/event-listener@2.4.6': - resolution: {integrity: sha512-5I0YJcTVYIWoMmgBSROBZGcz+ymhew/pGTg2dHW74BUjFKsV8Li4bOZYl0YAGP4mHw5o4UBd9/BEesqBci3wxw==} - peerDependencies: - solid-js: ^1.6.12 - - '@solid-primitives/keyboard@1.3.7': - resolution: {integrity: sha512-558RPNYnXx4nGh537DSqAn4xMrC8iFipl/5+xzgzWoTNFst4RnUN3BOLmtDjJ0UGGoQXVMALYR3bNOHM0xnt1Q==} - peerDependencies: - solid-js: ^1.6.12 - - '@solid-primitives/resize-observer@2.2.0': - resolution: {integrity: sha512-9Fuu/EWBeGj+atGHRJp70HKhdfalmpjwxY8a32NZixdLNmfCJ45AfhLQNr6uOzETbbiMx4iCKlTrJ8KZCHC2Ww==} - peerDependencies: - solid-js: ^1.6.12 - - '@solid-primitives/rootless@1.5.4': - resolution: {integrity: sha512-TOIZa1VUfVJ+9nkCcRajw3U4t9vBOP1HxX1WHNTbXq32mXwlqTvUnC4CRIilohcryBkT9u2ZkhUDSHRTaGp55g==} - peerDependencies: - solid-js: ^1.6.12 - - '@solid-primitives/static-store@0.1.4': - resolution: {integrity: sha512-LgtVaVBtB7EbmS4+M0b8xY5Iq6pUWXBsIC4VgtrFKDGDdyCaDt88sHk0fUlx1Enxm/XZnZyLXJABRoa39RjJqA==} - peerDependencies: - solid-js: ^1.6.12 - - '@solid-primitives/utils@6.4.1': - resolution: {integrity: sha512-ISSB5QX1qP2ynrheIpYwc4oKR5Ny4siNuUyf1qZniy+Il+p/PtDB0QK1Dnle8noiHpwRD3gpPdubOC3qI/Zamg==} - peerDependencies: - solid-js: ^1.6.12 - - '@standard-schema/spec@1.1.0': - resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - - '@stylistic/eslint-plugin@5.10.0': - resolution: {integrity: sha512-nPK52ZHvot8Ju/0A4ucSX1dcPV2/1clx0kLcH5wDmrE4naKso7TUC/voUyU1O9OTKTrR6MYip6LP0ogEMQ9jPQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^9.0.0 || ^10.0.0 - - '@tailwindcss/node@4.3.3': - resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==} - - '@tailwindcss/oxide-android-arm64@4.3.3': - resolution: {integrity: sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==} - engines: {node: '>= 20'} - cpu: [arm64] - os: [android] - - '@tailwindcss/oxide-darwin-arm64@4.3.3': - resolution: {integrity: sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==} - engines: {node: '>= 20'} - cpu: [arm64] - os: [darwin] - - '@tailwindcss/oxide-darwin-x64@4.3.3': - resolution: {integrity: sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==} - engines: {node: '>= 20'} - cpu: [x64] - os: [darwin] - - '@tailwindcss/oxide-freebsd-x64@4.3.3': - resolution: {integrity: sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==} - engines: {node: '>= 20'} - cpu: [x64] - os: [freebsd] - - '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': - resolution: {integrity: sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==} - engines: {node: '>= 20'} - cpu: [arm] - os: [linux] - - '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': - resolution: {integrity: sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==} - engines: {node: '>= 20'} - cpu: [arm64] - os: [linux] - - '@tailwindcss/oxide-linux-arm64-musl@4.3.3': - resolution: {integrity: sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==} - engines: {node: '>= 20'} - cpu: [arm64] - os: [linux] - - '@tailwindcss/oxide-linux-x64-gnu@4.3.3': - resolution: {integrity: sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==} - engines: {node: '>= 20'} - cpu: [x64] - os: [linux] - - '@tailwindcss/oxide-linux-x64-musl@4.3.3': - resolution: {integrity: sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==} - engines: {node: '>= 20'} - cpu: [x64] - os: [linux] - - '@tailwindcss/oxide-wasm32-wasi@4.3.3': - resolution: {integrity: sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==} - engines: {node: '>=14.0.0'} - cpu: [wasm32] - bundledDependencies: - - '@napi-rs/wasm-runtime' - - '@emnapi/core' - - '@emnapi/runtime' - - '@tybys/wasm-util' - - '@emnapi/wasi-threads' - - tslib - - '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': - resolution: {integrity: sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==} - engines: {node: '>= 20'} - cpu: [arm64] - os: [win32] - - '@tailwindcss/oxide-win32-x64-msvc@4.3.3': - resolution: {integrity: sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==} - engines: {node: '>= 20'} - cpu: [x64] - os: [win32] - - '@tailwindcss/oxide@4.3.3': - resolution: {integrity: sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==} - engines: {node: '>= 20'} - - '@tailwindcss/typography@0.5.20': - resolution: {integrity: sha512-hwbzQuNUfcPvbegQFatVPl/MY/tcM9KLl963hQ5laJKPh81TEZ1+dNG9PirGvcaDBkp+BCshExAyKVPW91dozw==} - peerDependencies: - tailwindcss: '>=3.0.0 || >=4.0.0 || insiders' - - '@tailwindcss/vite@4.3.3': - resolution: {integrity: sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==} - peerDependencies: - vite: ^5.2.0 || ^6 || ^7 || ^8 - - '@tanstack/devtools-bundler-core@0.1.3': - resolution: {integrity: sha512-F0tlxIyfFqXkZ1mJP1EjtkiSeJA+ztXY2AYOHf7r4goCIEAOp86N9PFJ/yv8vu1TnmxGS6vKsZCS3Kyls9xQQA==} - engines: {node: '>=18'} - - '@tanstack/devtools-client@0.0.8': - resolution: {integrity: sha512-cG3iZkGWCwN330bLBKa8+9r4Of2AXNoz2zUqcsy/4XsD3105ghVBx78cGyvJj9fSclNomPxoqAnDGXXhg1WLvA==} - engines: {node: '>=18'} - - '@tanstack/devtools-event-bus@0.4.3': - resolution: {integrity: sha512-NeegBt5/n2E5q4DbrXHqECBq42+kDi6JBOp8/+RNqkIE+P4hJpbM36kGyjnGQFMWOoku31qhMyX9/48VuTTdmg==} - engines: {node: '>=18'} - - '@tanstack/devtools-event-client@0.5.0': - resolution: {integrity: sha512-H+OH3zC6Vhu/K0NaVfQKknEKawc/+2PT+D3SB3Ox0V8SiMlTo0abbmH2rH0721R2aNYbjdMXA1oENOd8E2UVoA==} - engines: {node: '>=18'} - hasBin: true - - '@tanstack/devtools-ui@0.7.1': - resolution: {integrity: sha512-3xQ/ezZ2qVNszhjpCN2N3jn7uHc2J1PMgcyjHzH4XZBt9xAQyMMcPNoR2cd7rzReyxWoJpZUWiDBmOJiCtLj9A==} - engines: {node: '>=18'} - peerDependencies: - solid-js: '>=1.9.7' - - '@tanstack/devtools-vite@0.8.5': - resolution: {integrity: sha512-xaifCEmiwwzizlbp973oISXNsnmuU/BugLa66gryAZaPJJ/qo1kJd2DxY5X960eHwmyIS3SN0KYrL3/aRhucmA==} - engines: {node: '>=18'} - hasBin: true - peerDependencies: - vite: ^6.0.0 || ^7.0.0 || ^8.0.0 - - '@tanstack/devtools@0.15.0': - resolution: {integrity: sha512-KyS7Qi82Eq9yG8C+uae3ikAPb+D5rfzVhvOOjXVAHZ/64s6TWJlDWpREw1FrHIYZaI7paErpsExSSKlF5BKA/A==} - engines: {node: '>=18'} - hasBin: true - peerDependencies: - solid-js: '>=1.9.7' - - '@tanstack/eslint-config@0.4.0': - resolution: {integrity: sha512-V+Cd81W/f65dqKJKpytbwTGx9R+IwxKAHsG/uJ3nSLYEh36hlAr54lRpstUhggQB8nf/cP733cIw8DuD2dzQUg==} - engines: {node: '>=18'} - peerDependencies: - eslint: ^9.0.0 || ^10.0.0 - - '@tanstack/history@1.162.5': - resolution: {integrity: sha512-w1+YvwmTkwt5n8yUqEH3IWZ9DsmorCZW/EQ3KYHnqEgIkOZpH54NvW6/Epq3Dzb8H0ifIeYX8PExvrNLIH5FIA==} - engines: {node: '>=20.19'} - - '@tanstack/react-devtools@0.10.13': - resolution: {integrity: sha512-r0cdB/14+st2Vxl/1Bodlyq4VC8pf9SykLQDYbGHDc1+V146WQo5B3KlYLkfyxD9Q/6+9NPRQHZyl8fvZVMdRA==} - engines: {node: '>=18'} - peerDependencies: - '@types/react': '>=16.8' - '@types/react-dom': '>=16.8' - react: '>=16.8' - react-dom: '>=16.8' - - '@tanstack/react-router-devtools@1.167.2': - resolution: {integrity: sha512-VuoqB7OhDUrBa4WOFSKiAQ8xI8sJpeZdo9sYE1hQa89iMuVFUQu1iWE4iCiQhB+6TYOZDZ9e+iHKxYjop4ZVYw==} - engines: {node: '>=20.19'} - peerDependencies: - '@tanstack/react-router': ^1.170.36 - '@tanstack/router-core': ^1.171.30 - react: '>=18.0.0 || >=19.0.0' - react-dom: '>=18.0.0 || >=19.0.0' - peerDependenciesMeta: - '@tanstack/router-core': - optional: true - - '@tanstack/react-router@1.170.45': - resolution: {integrity: sha512-6tuiIAFb82zsk/YxveJRyGjbsm6kIfgUuoH7qCp2R2aSHveW6W0t/6j+Sn4m1iIlA2lLU4cvooqks08/YMOThw==} - engines: {node: '>=20.19'} - peerDependencies: - react: '>=18.0.0 || >=19.0.0' - react-dom: '>=18.0.0 || >=19.0.0' - - '@tanstack/react-store@0.11.2': - resolution: {integrity: sha512-oj5O5DmiMWE+ih+SioX2ayC1kbK7d3fpLx0B75fPJr9nY3zU+hoW010yr64qXsY/9fyiLVE2bn5mGNV3owm8RQ==} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - - '@tanstack/router-core@1.171.38': - resolution: {integrity: sha512-XRZVxiO6BIexICmEo3XfPmpoyyLhbjgaMJsONz4gIf5oYys0UiaG1BJGRyj9x8QZPDZcBhvAMuTYN70wSxV0gA==} - engines: {node: '>=20.19'} - - '@tanstack/router-devtools-core@1.168.2': - resolution: {integrity: sha512-pCCdQ5M/NUeWdiAbPmjyfcNdls6G4vaVlIQbPf1IqZk1FLYFr00aQvi578yUuWFBNM2KNS2QcwM1uPucbtGf5Q==} - engines: {node: '>=20.19'} - peerDependencies: - '@tanstack/router-core': ^1.171.30 - csstype: ^3.0.10 - peerDependenciesMeta: - csstype: - optional: true - - '@tanstack/router-generator@1.167.45': - resolution: {integrity: sha512-oCtNqtsMNcBJkPIOu1O7a68jnxt4TkVJj3jHrW3VgiZdBeaj/lBJQh50yvzmNRswRcyIkRg0g30g25bbLRrAGw==} - engines: {node: '>=20.19'} - - '@tanstack/router-plugin@1.168.47': - resolution: {integrity: sha512-1qvMto2Id0Uulux4qd+WttCbOFloIEQ5x82m68T4knt6NmUDQC66P/u/5yzN0FWGSO0u2U2nbDSIQoR6g7HNmQ==} - engines: {node: '>=20.19'} - peerDependencies: - '@rsbuild/core': '>=1.0.2 || ^2.0.0' - '@tanstack/react-router': ^1.170.45 - vite: '>=5.0.0 || >=6.0.0 || >=7.0.0 || >=8.0.0' - vite-plugin-solid: ^2.11.10 || ^3.0.0-0 - webpack: '>=5.92.0' - peerDependenciesMeta: - '@rsbuild/core': - optional: true - '@tanstack/react-router': - optional: true - vite: - optional: true - vite-plugin-solid: - optional: true - webpack: - optional: true - - '@tanstack/router-utils@1.162.4': - resolution: {integrity: sha512-3TSH3rwAQTqQOtTBF8IXMFXmGNJWnjwIm+l87uguyC/of0pSRKcU9GJrozaf0fY9it8dLQvUZJA2MOlfqbW1sA==} - engines: {node: '>=20.19'} - - '@tanstack/store@0.11.2': - resolution: {integrity: sha512-sJ4mjol8uQsHV0gOJzzjwXfh2Fwm+Sz0+8deqiTm4jGbMdjzNSW+xZCFm0kUa870uhd8yi+DpKnZb5Kc4apa0Q==} - - '@tanstack/virtual-file-routes@1.162.0': - resolution: {integrity: sha512-uhOeFyxLcU41HzvrxsGpiWdcMbScY1EDgbZ5K7DVRMYInbLYWAC0EA/kx9wXAoSM8q82bUG2hRl8+EAjE6XAbA==} - engines: {node: '>=20.19'} - - '@testing-library/dom@10.4.2': - resolution: {integrity: sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==} - engines: {node: '>=18'} - - '@testing-library/react@16.3.3': - resolution: {integrity: sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==} - engines: {node: '>=18'} - peerDependencies: - '@testing-library/dom': ^10.0.0 - '@types/react': ^18.0.0 || ^19.0.0 - '@types/react-dom': ^18.0.0 || ^19.0.0 - react: ^18.0.0 || ^19.0.0 - react-dom: ^18.0.0 || ^19.0.0 - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@tybys/wasm-util@0.10.4': - resolution: {integrity: sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==} - - '@types/aria-query@5.0.4': - resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} - - '@types/babel__core@7.20.5': - resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} - - '@types/babel__generator@7.27.0': - resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} - - '@types/babel__template@7.4.4': - resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} - - '@types/babel__traverse@7.28.0': - resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} - - '@types/chai@5.2.3': - resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} - - '@types/deep-eql@4.0.2': - resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} - - '@types/esrecurse@4.3.1': - resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} - - '@types/estree@1.0.9': - resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} - - '@types/json-schema@7.0.15': - resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - - '@types/node@22.20.5': - resolution: {integrity: sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==} - - '@types/react-dom@19.3.0': - resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==} - peerDependencies: - '@types/react': ^19.3.0 - - '@types/react@19.3.0': - resolution: {integrity: sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==} - - '@typescript-eslint/eslint-plugin@8.71.2': - resolution: {integrity: sha512-K72/pvT7qpm8q3k8G5u93NCSN1RcLxX5h7Gw0bQpbMXviBHeY6Nx1WEwmk9wP+eKTSQ8ecxvcgt/ZX/rB938xQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - '@typescript-eslint/parser': ^8.71.2 - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/parser@8.71.2': - resolution: {integrity: sha512-aTtmZjp/x3gsGHkDs04/1I8kNogBJkpQaeGaN0PfiG43NzDEmW2n+OP5im9wfsvyX0yP1UZWBrAGtDBfH9jEVw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/project-service@8.71.2': - resolution: {integrity: sha512-Pd68GsLVh5ZXlcEccFPdhSOvtHZ/2WU0XsA8zmXOS3F158lVlcTLhfQ6qvtL1RzbMie0Kp8xtbVwAg4nejKPHw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/scope-manager@8.71.2': - resolution: {integrity: sha512-YF4wgg5iUX5Obywoac27B56FhozMNFhdJSbh11H5GBAwmBtfyRhmgTQqW9tRNNmHDvsZ2CX6UGzoz+YfYn2Leg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@typescript-eslint/tsconfig-utils@8.71.2': - resolution: {integrity: sha512-jz49TAWokm/7yWD9/o837nYax9DyALxIPOImdImvVzo6canBCZYzin8zJyqNR5k1j9K1DE0bVvr0XYB+99uAdg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/type-utils@8.71.2': - resolution: {integrity: sha512-cC8UaalCViJkb3rH/xLIvidEwoFRtMZMUqDjjJMGEKaTFwYRHYBcoczlEGHuvkHEO8svRDrgZ0yQgOArFmwang==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/types@8.71.2': - resolution: {integrity: sha512-y5Ukl3oVp7e/VGxqIoEXoeb/Roj71PAJ4WG/N/RGj+eeDvKRLM15kGr9MijLbN0i03S52zxu31Z98E5gWChJKw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@typescript-eslint/typescript-estree@8.71.2': - resolution: {integrity: sha512-DgNOEsl4pdYzwSL+N/Cl33tX1Hlw3FvId73LnKXfq7jnrF2viOEn7X+P7RBkkSWCVm92gnWd28GIC5vJuLREeA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/utils@8.71.2': - resolution: {integrity: sha512-RyKa1G1M9rKHDhrk+HJtiF1MvLD5Ux2JW3TSkzw+02pRALdpJb+QzG1Y5em+LvCgzGwgg8H7mBRFKljNz7ImPw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/visitor-keys@8.71.2': - resolution: {integrity: sha512-zozlN0UA32bSG2zkKAgQyRYSbEISVoyU6vvkKYo2aSny74D8Jh4e+POkJ6Lk0dlezZi5x7TtD+88Xb06IB8nhg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@unrs/resolver-binding-android-arm-eabi@1.12.2': - resolution: {integrity: sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==} - cpu: [arm] - os: [android] - - '@unrs/resolver-binding-android-arm64@1.12.2': - resolution: {integrity: sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==} - cpu: [arm64] - os: [android] - - '@unrs/resolver-binding-darwin-arm64@1.12.2': - resolution: {integrity: sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==} - cpu: [arm64] - os: [darwin] - - '@unrs/resolver-binding-darwin-x64@1.12.2': - resolution: {integrity: sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==} - cpu: [x64] - os: [darwin] - - '@unrs/resolver-binding-freebsd-x64@1.12.2': - resolution: {integrity: sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==} - cpu: [x64] - os: [freebsd] - - '@unrs/resolver-binding-linux-arm-gnueabihf@1.12.2': - resolution: {integrity: sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==} - cpu: [arm] - os: [linux] - - '@unrs/resolver-binding-linux-arm-musleabihf@1.12.2': - resolution: {integrity: sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==} - cpu: [arm] - os: [linux] - - '@unrs/resolver-binding-linux-arm64-gnu@1.12.2': - resolution: {integrity: sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==} - cpu: [arm64] - os: [linux] - - '@unrs/resolver-binding-linux-arm64-musl@1.12.2': - resolution: {integrity: sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==} - cpu: [arm64] - os: [linux] - - '@unrs/resolver-binding-linux-loong64-gnu@1.12.2': - resolution: {integrity: sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==} - cpu: [loong64] - os: [linux] - - '@unrs/resolver-binding-linux-loong64-musl@1.12.2': - resolution: {integrity: sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==} - cpu: [loong64] - os: [linux] - - '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2': - resolution: {integrity: sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==} - cpu: [ppc64] - os: [linux] - - '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2': - resolution: {integrity: sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==} - cpu: [riscv64] - os: [linux] - - '@unrs/resolver-binding-linux-riscv64-musl@1.12.2': - resolution: {integrity: sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==} - cpu: [riscv64] - os: [linux] - - '@unrs/resolver-binding-linux-s390x-gnu@1.12.2': - resolution: {integrity: sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==} - cpu: [s390x] - os: [linux] - - '@unrs/resolver-binding-linux-x64-gnu@1.12.2': - resolution: {integrity: sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==} - cpu: [x64] - os: [linux] - - '@unrs/resolver-binding-linux-x64-musl@1.12.2': - resolution: {integrity: sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==} - cpu: [x64] - os: [linux] - - '@unrs/resolver-binding-openharmony-arm64@1.12.2': - resolution: {integrity: sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==} - cpu: [arm64] - os: [openharmony] - - '@unrs/resolver-binding-wasm32-wasi@1.12.2': - resolution: {integrity: sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==} - engines: {node: '>=14.0.0'} - cpu: [wasm32] - - '@unrs/resolver-binding-win32-arm64-msvc@1.12.2': - resolution: {integrity: sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==} - cpu: [arm64] - os: [win32] - - '@unrs/resolver-binding-win32-ia32-msvc@1.12.2': - resolution: {integrity: sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==} - cpu: [ia32] - os: [win32] - - '@unrs/resolver-binding-win32-x64-msvc@1.12.2': - resolution: {integrity: sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==} - cpu: [x64] - os: [win32] - - '@vitejs/plugin-react@5.2.0': - resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==} - engines: {node: ^20.19.0 || >=22.12.0} - peerDependencies: - vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 - - '@vitest/mocker@5.0.5': - resolution: {integrity: sha512-xLL3lkDegJZw+MGoGaCCsFayDZthGgpAtr3Vziyfc4p7xH89K7KRPcRBBh/kEYm0Aqkt5DtTmkYpBtRV/FrHZg==} - peerDependencies: - msw: ^2.4.9 || ^3.0.0 - vite: ^6.0.0 || ^7.0.0 || ^8.0.0 - peerDependenciesMeta: - msw: - optional: true - vite: - optional: true - - '@vitest/spy@5.0.5': - resolution: {integrity: sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==} - - acorn-jsx@5.3.2: - resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} - peerDependencies: - acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - - acorn@8.19.0: - resolution: {integrity: sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==} - engines: {node: '>=0.4.0'} - hasBin: true - - agent-base@7.1.4: - resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} - engines: {node: '>= 14'} - - ajv@6.15.0: - resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} - - ansi-regex@5.0.1: - resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} - engines: {node: '>=8'} - - ansi-styles@5.2.0: - resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} - engines: {node: '>=10'} - - ansis@4.4.0: - resolution: {integrity: sha512-9k3v7xcHwgdO/DruxGIg4HtjvlAZlcnsX/mzqUb1t3NkYnl9kK2UJ+Gq0io+vQf7iT//BD/HB/NBkUR1LWxoeA==} - engines: {node: '>=14'} - - aria-query@5.3.0: - resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} - - assertion-error@2.0.1: - resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} - engines: {node: '>=12'} - - babel-dead-code-elimination@1.0.12: - resolution: {integrity: sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==} - - balanced-match@4.0.4: - resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} - engines: {node: 18 || 20 || >=22} - - baseline-browser-mapping@2.11.29: - resolution: {integrity: sha512-YeesxhUmR4h5y5V8KF8iBCqJb8YFrBJ++XBTeGcWIaQOYXGN19JM0FoiOjAhwuDoln8owMgXcWBmgHtYSkky2w==} - engines: {node: '>=6.0.0'} - hasBin: true - - better-auth@1.7.7: - resolution: {integrity: sha512-Zhgxi/c5ylmfB/sX+nwnBbsFf/j6k0L8DvL6CJXZSrXgxc5pPMC/e7j812zNd4L4B4ELmdURCPw3X8nAOUdcjw==} - peerDependencies: - '@lynx-js/react': '*' - '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 - '@sveltejs/kit': ^2.0.0 - '@tanstack/react-start': ^1.0.0 - '@tanstack/solid-start': ^1.0.0 - drizzle-kit: '>=0.31.4 || >=1.0.0-beta.1' - drizzle-orm: ^0.45.2 || >=1.0.0-rc.1 <2.0.0 - mongodb: ^6.0.0 || ^7.0.0 - mysql2: ^3.0.0 - next: ^14.0.0 || ^15.0.0 || ^16.0.0 - pg: ^8.0.0 - prisma: ^5.0.0 || ^6.0.0 || ^7.0.0 - react: ^18.0.0 || ^19.0.0 - react-dom: ^18.0.0 || ^19.0.0 - solid-js: ^1.0.0 - svelte: ^4.0.0 || ^5.0.0 - vitest: ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 - vue: ^3.0.0 - peerDependenciesMeta: - '@lynx-js/react': - optional: true - '@prisma/client': - optional: true - '@sveltejs/kit': - optional: true - '@tanstack/react-start': - optional: true - '@tanstack/solid-start': - optional: true - drizzle-kit: - optional: true - drizzle-orm: - optional: true - mongodb: - optional: true - mysql2: - optional: true - next: - optional: true - pg: - optional: true - prisma: - optional: true - react: - optional: true - react-dom: - optional: true - solid-js: - optional: true - svelte: - optional: true - vitest: - optional: true - vue: - optional: true - - better-call@1.4.0: - resolution: {integrity: sha512-bBKOT4vv1kZLDgxVePdilk/Jwkn+dtRRsmi3DzHcDP+WnswyVl6dR59l2HEeP/0cB+bDoopASAesWDPIdd/zZA==} - peerDependencies: - zod: ^4.0.0 - peerDependenciesMeta: - zod: - optional: true - - bidi-js@1.1.0: - resolution: {integrity: sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==} - - brace-expansion@5.0.12: - resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==} - engines: {node: 20 || >=22} - - browserslist@4.29.3: - resolution: {integrity: sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==} - engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} - hasBin: true - - cacheable@2.5.0: - resolution: {integrity: sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==} - - caniuse-lite@1.0.30001816: - resolution: {integrity: sha512-+8Yx7BXCoqk6IF8yTicjfUR6D/ldJzdlsodlEOiOSPmhK844WIgk67/zIQaGAOxNkZyyvBlrRFnLvqKIoqbAFw==} - - chai@6.3.0: - resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==} - engines: {node: '>=18'} - - chalk@5.6.2: - resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} - engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} - - chokidar@5.0.0: - resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} - engines: {node: '>= 20.19.0'} - - clsx@2.1.1: - resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} - engines: {node: '>=6'} - - comment-parser@1.4.9: - resolution: {integrity: sha512-+2AvZKjJaNq9GQljm3tr0utwrig5BL+jgJGvz5rDpGKNIp+ojcRXWUwBbh/sGIi1QCprR6PsKFakub+w1v+4hQ==} - engines: {node: '>= 12.0.0'} - - convert-source-map@2.0.0: - resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - - cookie-es@3.1.1: - resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==} - - cross-spawn@7.0.6: - resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} - engines: {node: '>= 8'} - - css-tree@3.2.1: - resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} - engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} - - cssesc@3.0.0: - resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} - engines: {node: '>=4'} - hasBin: true - - cssstyle@6.2.0: - resolution: {integrity: sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==} - engines: {node: '>=20'} - - csstype@3.2.3: - resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} - - data-urls@7.0.0: - resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} - engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} - - dayjs@1.11.23: - resolution: {integrity: sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==} - - debug@4.4.3: - resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - - decimal.js@10.6.0: - resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} - - deep-is@0.1.4: - resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} - - defu@6.1.7: - resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} - - dequal@2.0.3: - resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} - engines: {node: '>=6'} - - detect-libc@2.1.2: - resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} - engines: {node: '>=8'} - - diff@9.0.0: - resolution: {integrity: sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==} - engines: {node: '>=0.3.1'} - - dom-accessibility-api@0.5.16: - resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} - - electron-to-chromium@1.5.458: - resolution: {integrity: sha512-/hIH2gRSmxrf4/X4Nw/t/Wc8PVS22l8/3HnWjWTWsTOM5dsnMUPZF/+u+yr3LqweNuTBJE1rq6qAQzKkZVwMfA==} - - enhanced-resolve@5.26.0: - resolution: {integrity: sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw==} - engines: {node: '>=10.13.0'} - - entities@8.1.0: - resolution: {integrity: sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==} - engines: {node: '>=20.19.0'} - - es-module-lexer@2.3.2: - resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} - - esbuild@0.28.2: - resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} - engines: {node: '>=18'} - hasBin: true - - escalade@3.2.0: - resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} - engines: {node: '>=6'} - - escape-string-regexp@4.0.0: - resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} - engines: {node: '>=10'} - - eslint-compat-utils@0.5.1: - resolution: {integrity: sha512-3z3vFexKIEnjHE3zCMRo6fn/e44U7T1khUjg+Hp0ZQMCigh28rALD0nPFBcGZuiLC5rLZa2ubQHDRln09JfU2Q==} - engines: {node: '>=12'} - peerDependencies: - eslint: '>=6.0.0' - - eslint-import-context@0.1.9: - resolution: {integrity: sha512-K9Hb+yRaGAGUbwjhFNHvSmmkZs9+zbuoe3kFQ4V1wYjrepUFYM2dZAfNtjbbj3qsPfUfsA68Bx/ICWQMi+C8Eg==} - engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} - peerDependencies: - unrs-resolver: ^1.0.0 - peerDependenciesMeta: - unrs-resolver: - optional: true - - eslint-plugin-es-x@7.8.0: - resolution: {integrity: sha512-7Ds8+wAAoV3T+LAKeu39Y5BzXCrGKrcISfgKEqTS4BDN8SFEDQd0S43jiQ8vIa3wUKD07qitZdfzlenSi8/0qQ==} - engines: {node: ^14.18.0 || >=16.0.0} - peerDependencies: - eslint: '>=8' - - eslint-plugin-import-x@4.17.1: - resolution: {integrity: sha512-4cdstYkKCyjumM2Q9NSI03K8D2a9F4Ssz33K2lv2hQa4KmR9jPLwk3uWGtNvclfqBrPGfGuMBwsGMbe6dMRbfg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - '@typescript-eslint/utils': ^8.56.0 - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - eslint-import-resolver-node: '*' - peerDependenciesMeta: - '@typescript-eslint/utils': - optional: true - eslint-import-resolver-node: - optional: true - - eslint-plugin-n@17.24.0: - resolution: {integrity: sha512-/gC7/KAYmfNnPNOb3eu8vw+TdVnV0zhdQwexsw6FLXbhzroVj20vRn2qL8lDWDGnAQ2J8DhdfvXxX9EoxvERvw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: '>=8.23.0' - - eslint-scope@9.1.2: - resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - eslint-visitor-keys@3.4.3: - resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - - eslint-visitor-keys@4.2.1: - resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - eslint-visitor-keys@5.0.1: - resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - eslint@10.13.0: - resolution: {integrity: sha512-UohDjD4zSzEwhf58M3O7vc/ycc8O1I7dNlhtD0WxE8Px8EHNJKtUwzBqrzeGhisRuv2ag4sAYswszCJRgKXYUg==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - hasBin: true - peerDependencies: - jiti: '*' - peerDependenciesMeta: - jiti: - optional: true - - espree@10.4.0: - resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - espree@11.2.0: - resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - esquery@1.7.0: - resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} - engines: {node: '>=0.10'} - - esrecurse@4.3.0: - resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} - engines: {node: '>=4.0'} - - estraverse@5.3.0: - resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} - engines: {node: '>=4.0'} - - estree-walker@3.0.3: - resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} - - esutils@2.0.3: - resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} - engines: {node: '>=0.10.0'} - - expect-type@1.4.0: - resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} - engines: {node: '>=12.0.0'} - - fast-deep-equal@3.1.3: - resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - - fast-json-stable-stringify@2.1.0: - resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} - - fast-levenshtein@2.0.6: - resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - - fdir@6.5.0: - resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} - engines: {node: '>=12.0.0'} - peerDependencies: - picomatch: ^3 || ^4 - peerDependenciesMeta: - picomatch: - optional: true - - file-entry-cache@11.1.5: - resolution: {integrity: sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==} - - find-up@5.0.0: - resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} - engines: {node: '>=10'} - - flat-cache@6.1.23: - resolution: {integrity: sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==} - - flatted@3.4.4: - resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} - - fsevents@2.3.3: - resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - - gensync@1.0.0-beta.2: - resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} - engines: {node: '>=6.9.0'} - - get-tsconfig@4.14.3: - resolution: {integrity: sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==} - - glob-parent@6.0.2: - resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} - engines: {node: '>=10.13.0'} - - globals@15.15.0: - resolution: {integrity: sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==} - engines: {node: '>=18'} - - globals@17.13.0: - resolution: {integrity: sha512-RwMTC61u7hrG3ZJMkZQOK4JLJrKS8QtoTii63EmWvFXHqycY9FObBJQCbsLxSO8kjKhWE5kV1GC+Vb1g3RI0Zg==} - engines: {node: '>=18'} - - globrex@0.1.2: - resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} - - goober@2.1.19: - resolution: {integrity: sha512-U7veizMqxyKlM58+Z5j2ngJBH/r9siDmxpvNxSw0PylF6WQvrASJEZrxh1hidRBJc2jqoBVSyOban5u8m+6Rxg==} - peerDependencies: - csstype: ^3.0.10 - - graceful-fs@4.2.11: - resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - - hashery@1.5.1: - resolution: {integrity: sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==} - engines: {node: '>=20'} - - hookified@1.15.1: - resolution: {integrity: sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==} - - hookified@2.2.0: - resolution: {integrity: sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==} - - html-encoding-sniffer@6.0.0: - resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} - engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} - - http-proxy-agent@7.0.2: - resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} - engines: {node: '>= 14'} - - https-proxy-agent@7.0.6: - resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} - engines: {node: '>= 14'} - - ignore@5.3.2: - resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} - engines: {node: '>= 4'} - - ignore@7.0.12: - resolution: {integrity: sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==} - engines: {node: '>= 4'} - - imurmurhash@0.1.4: - resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} - engines: {node: '>=0.8.19'} - - is-extglob@2.1.1: - resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} - engines: {node: '>=0.10.0'} - - is-glob@4.0.3: - resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} - engines: {node: '>=0.10.0'} - - is-potential-custom-element-name@1.0.1: - resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} - - isbot@5.2.2: - resolution: {integrity: sha512-iQcBXcd+Rv/pkubRyGh2utW2j1oPG5hZY6TUhVPpqK4G+o3IbxpJNx04hgksjc/N7GK5pEorUxDeg31cFgEk/w==} - engines: {node: '>=18'} - - isexe@2.0.0: - resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - - jiti@2.7.0: - resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} - hasBin: true - - jose@6.2.12: - resolution: {integrity: sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==} - - js-tokens@4.0.0: - resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - - jsdom@28.1.0: - resolution: {integrity: sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==} - engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} - peerDependencies: - canvas: ^3.0.0 - peerDependenciesMeta: - canvas: - optional: true - - jsesc@3.1.0: - resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} - engines: {node: '>=6'} - hasBin: true - - json-schema-traverse@0.4.1: - resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} - - json-stable-stringify-without-jsonify@1.0.1: - resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} - - json5@2.2.3: - resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} - engines: {node: '>=6'} - hasBin: true - - keyv@5.6.0: - resolution: {integrity: sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==} - - kysely@0.29.6: - resolution: {integrity: sha512-hHaB8C/rfzDDtr/t8YZwxAuPJTT0zHyaPoVzcXwDYhYNAgH/4sIfVhi/XLLIY+bL/FqaIJnjATDbi8ObSELmxg==} - engines: {node: '>=22.0.0'} - - launch-editor@2.14.2: - resolution: {integrity: sha512-NHJEb30MGBCkmZXLZwz25t37QR4aY03ZzPYUJXOJaOa92uWwExI9SBQUuIznBYc1xkD7cJNGX7oc3Nl3sq2kQQ==} - - levn@0.4.1: - resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} - engines: {node: '>= 0.8.0'} - - lightningcss-android-arm64@1.32.0: - resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [android] - - lightningcss-darwin-arm64@1.32.0: - resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [darwin] - - lightningcss-darwin-x64@1.32.0: - resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [darwin] - - lightningcss-freebsd-x64@1.32.0: - resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [freebsd] - - lightningcss-linux-arm-gnueabihf@1.32.0: - resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} - engines: {node: '>= 12.0.0'} - cpu: [arm] - os: [linux] - - lightningcss-linux-arm64-gnu@1.32.0: - resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [linux] - - lightningcss-linux-arm64-musl@1.32.0: - resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [linux] - - lightningcss-linux-x64-gnu@1.32.0: - resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [linux] - - lightningcss-linux-x64-musl@1.32.0: - resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [linux] - - lightningcss-win32-arm64-msvc@1.32.0: - resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [win32] - - lightningcss-win32-x64-msvc@1.32.0: - resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [win32] - - lightningcss@1.32.0: - resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} - engines: {node: '>= 12.0.0'} - - locate-path@6.0.0: - resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} - engines: {node: '>=10'} - - lru-cache@11.5.3: - resolution: {integrity: sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==} - engines: {node: 20 || >=22} - - lru-cache@5.1.1: - resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} - - lucide-react@0.545.0: - resolution: {integrity: sha512-7r1/yUuflQDSt4f1bpn5ZAocyIxcTyVyBBChSVtBKn5M+392cPmI5YJMWOJKk/HUWGm5wg83chlAZtCcGbEZtw==} - peerDependencies: - react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 - - lz-string@1.5.0: - resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} - hasBin: true - - magic-string@0.30.21: - resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - - magic-string@1.4.3: - resolution: {integrity: sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==} - - mdn-data@2.27.1: - resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} - - minimatch@10.2.6: - resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} - engines: {node: 18 || 20 || >=22} - - ms@2.1.3: - resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - - nanoid@3.3.20: - resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - - nanostores@1.5.5: - resolution: {integrity: sha512-FixtE239Gl6Gz0ZGCK0WzvNwObNCIwVT+L7zQcf1QgcA6AzBxblzqe9OT88qiucNkhsjSOqiogSaF5jVbJiqSA==} - engines: {node: ^20.0.0 || >=22.0.0} - - napi-postinstall@0.3.4: - resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} - engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} - hasBin: true - - natural-compare@1.4.0: - resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - - node-releases@2.0.59: - resolution: {integrity: sha512-5qr2okC4ItAI0hG5SaV0HoYMpnM/Y6+Oqr7AmPYy/2uEoGvS+GbSKLeRxSS1uAdu59yz0ouzcuXievSgg///WA==} - engines: {node: '>=18'} - - obug@3.0.0: - resolution: {integrity: sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==} - engines: {node: '>=12.20.0'} - - optionator@0.9.4: - resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} - engines: {node: '>= 0.8.0'} - - oxc-parser@0.120.0: - resolution: {integrity: sha512-WyPWZlcIm+Fkte63FGfgFB8mAAk33aH9h5N9lphXVOHSXEBFFsmYdOBedVKly363aWABjZdaj/m9lBfEY4wt+w==} - engines: {node: ^20.19.0 || >=22.12.0} - - p-limit@3.1.0: - resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} - engines: {node: '>=10'} - - p-locate@5.0.0: - resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} - engines: {node: '>=10'} - - parse5@8.0.1: - resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} - - path-exists@4.0.0: - resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} - engines: {node: '>=8'} - - path-key@3.1.1: - resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} - engines: {node: '>=8'} - - pathe@2.0.3: - resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} - - picocolors@1.1.1: - resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - - picomatch@4.0.7: - resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} - engines: {node: '>=12'} - - postcss-selector-parser@6.0.10: - resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==} - engines: {node: '>=4'} - - postcss@8.5.30: - resolution: {integrity: sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==} - engines: {node: ^10 || ^12 || >=14} - - prelude-ls@1.2.1: - resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} - engines: {node: '>= 0.8.0'} - - prettier@3.9.10: - resolution: {integrity: sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA==} - engines: {node: '>=14'} - hasBin: true - - pretty-format@27.5.1: - resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} - engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} - - punycode@2.3.1: - resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} - engines: {node: '>=6'} - - qified@0.10.1: - resolution: {integrity: sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==} - engines: {node: '>=20'} - - react-dom@19.3.1: - resolution: {integrity: sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ==} - peerDependencies: - react: ^19.3.1 - - react-is@17.0.2: - resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} - - react-refresh@0.18.0: - resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} - engines: {node: '>=0.10.0'} - - react@19.3.1: - resolution: {integrity: sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==} - engines: {node: '>=0.10.0'} - - readdirp@5.1.1: - resolution: {integrity: sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==} - engines: {node: '>= 20.19.0'} - - require-from-string@2.0.2: - resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} - engines: {node: '>=0.10.0'} - - resolve-pkg-maps@1.0.0: - resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} - - rollup@4.64.7: - resolution: {integrity: sha512-aCiJONoZp0lNGJ6sYqspLkh6UXaWEey/7Ko2TZyXQ4kgzAhDUxAgbt/oulXKmD+zFHXw1k+RF6ZZWOrjp7DZnQ==} - engines: {node: '>=18.0.0', npm: '>=8.0.0'} - hasBin: true - - rou3@0.9.2: - resolution: {integrity: sha512-3SOzvaAg8rkHrXtRjpCvCvbyO5to9oOO27Z/XqHEYXfMRVSw/qMIVdmaOk9W2lcRLtR6dlqTjo9hDeJk70QBYQ==} - - saxes@6.0.0: - resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} - engines: {node: '>=v12.22.7'} - - scheduler@0.28.0: - resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==} - - semver@6.3.1: - resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} - hasBin: true - - semver@7.8.5: - resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} - engines: {node: '>=10'} - hasBin: true - - seroval-plugins@1.6.9: - resolution: {integrity: sha512-kiDK/Tb8IWZ61WEU8pA2PDmAuw37gKph9Y77OZlD56mg0Y2g0Bg7gZ1HeHhs5d40w+nRoUq/wj08V26ilbez9g==} - engines: {node: '>=10'} - peerDependencies: - seroval: ^1.0 - - seroval@1.6.9: - resolution: {integrity: sha512-UeEzHwAK+yl0YLbsvROV1t5yMYEwZZ0CfKYxDvUgS2fO9Vxdf8djqVIB+OuVpuawRNPqw68LE1Du/GMsW2ZPqA==} - engines: {node: '>=10'} - - set-cookie-parser@3.1.4: - resolution: {integrity: sha512-iEmdEew+Tn1BwMGMP8LzGBEPC7Mf1sySjglCeEPb/Vchm9bnGezePvacYcId+fGprQzIng+TBkzqfJ/qS7AsBg==} - - shebang-command@2.0.0: - resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} - engines: {node: '>=8'} - - shebang-regex@3.0.0: - resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} - engines: {node: '>=8'} - - shell-quote@1.12.0: - resolution: {integrity: sha512-PcByqNyT/38F2kDNi006HAMRJaULuBzq/FOsw3qdZvX/GA9W/jamDaRskgHjubHiftXK5sIFxLNkvrXUwcof6Q==} - engines: {node: '>= 0.4'} - - solid-js@1.9.17: - resolution: {integrity: sha512-4GAmLOWPfNV8H9Jfn2LSFTpNEG6jiOKPiwd5lnLGQtehHaX6l3TOLzJqQ7mqwB/3k1Yy3zyumFVopmc/tm34ow==} - - source-map-js@1.2.2: - resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} - engines: {node: '>=0.10.0'} - - stable-hash-x@0.2.0: - resolution: {integrity: sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==} - engines: {node: '>=12.0.0'} - - std-env@4.3.0: - resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==} - - symbol-tree@3.2.4: - resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} - - tailwindcss@4.3.3: - resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} - - tapable@2.3.3: - resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} - engines: {node: '>=6'} - - tinybench@6.2.2: - resolution: {integrity: sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==} - engines: {node: '>=20.0.0'} - - tinyexec@1.3.1: - resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} - engines: {node: '>=18'} - - tinyglobby@0.2.17: - resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} - engines: {node: '>=12.0.0'} - - tldts-core@7.4.18: - resolution: {integrity: sha512-KI2PKvCmvQI/xD6ypPqkU0i7oxNXR/O58RLRaHR4svsgVUQ2JE/XC8BtMbG6PSehD27I8sZJRcC1ASnCy6lp3g==} - - tldts@7.4.18: - resolution: {integrity: sha512-somxA8xKAUzlAxl0TX/jMSYIbRNFIVGnI96pVGJPeMc5BsHmfVXn6mOJT6U++ELnl04eGfzpfcEuZe8WiASkXg==} - hasBin: true - - tough-cookie@6.0.2: - resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} - engines: {node: '>=16'} - - tr46@6.0.0: - resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} - engines: {node: '>=20'} - - ts-api-utils@2.5.0: - resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} - engines: {node: '>=18.12'} - peerDependencies: - typescript: '>=4.8.4' - - ts-declaration-location@1.0.7: - resolution: {integrity: sha512-EDyGAwH1gO0Ausm9gV6T2nUvBgXT5kGoCMJPllOaooZ+4VvJiKBdZE7wK18N1deEowhcUptS+5GXZK8U/fvpwA==} - peerDependencies: - typescript: '>=4.0.0' - - tsconfck@3.1.6: - resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==} - engines: {node: ^18 || >=20} - deprecated: unmaintained - hasBin: true - peerDependencies: - typescript: ^5.0.0 - peerDependenciesMeta: - typescript: - optional: true - - tslib@2.8.1: - resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - - type-check@0.4.0: - resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} - engines: {node: '>= 0.8.0'} - - typescript-eslint@8.71.2: - resolution: {integrity: sha512-Rz4effbMy2g9wnvOJZG6SbVavX313Lap1vNEbk1RUochzickvzP8P4HB+bkNg5e50eVz1hQGjh9K1omI1xB4Bw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' - - typescript@5.9.3: - resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} - engines: {node: '>=14.17'} - hasBin: true - - undici-types@6.21.0: - resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - - undici@7.30.1: - resolution: {integrity: sha512-03KrMbjIFDWQR5RNPrSw3bd1r12JpyFQl9/yxjUbJtqkjmllqC5lFjZh6ujeuJCKr8EOQvRW872Oy/az+nLYxg==} - engines: {node: '>=20.18.1'} - - unplugin@3.4.0: - resolution: {integrity: sha512-9skdIFlCsPdFV7wUfZxNsFInlW+7nJmGu2gkTu0OUhF56aXGsHab9x52/QhdJ4lC7ZDPWTxbiC4ANqVlsuaW3w==} - engines: {node: ^20.19.0 || >=22.12.0} - peerDependencies: - '@farmfe/core': '*' - '@rsbuild/core': '*' - '@rspack/core': '*' - bun-types-no-globals: '*' - esbuild: '*' - rolldown: '*' - rollup: '*' - unloader: '*' - vite: '*' - webpack: '*' - peerDependenciesMeta: - '@farmfe/core': - optional: true - '@rsbuild/core': - optional: true - '@rspack/core': - optional: true - bun-types-no-globals: - optional: true - esbuild: - optional: true - rolldown: - optional: true - rollup: - optional: true - unloader: - optional: true - vite: - optional: true - webpack: - optional: true - - unrs-resolver@1.12.2: - resolution: {integrity: sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==} - - update-browserslist-db@1.3.4: - resolution: {integrity: sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==} - hasBin: true - peerDependencies: - browserslist: '>= 4.21.0' - - uri-js@4.4.1: - resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - - use-sync-external-store@1.7.0: - resolution: {integrity: sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - - util-deprecate@1.0.2: - resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - - vite-tsconfig-paths@5.1.4: - resolution: {integrity: sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w==} - peerDependencies: - vite: '*' - peerDependenciesMeta: - vite: - optional: true - - vite@7.3.7: - resolution: {integrity: sha512-AHEzTORMbi7/7aXp7w7KZ5+DCUdSeLsJz0grY8xHpc9DHdYb0vQu6DqJfm2tOw3gDExNgPgnJTXxM6syYFHaCA==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - peerDependencies: - '@types/node': ^20.19.0 || >=22.12.0 - jiti: '>=1.21.0' - less: ^4.0.0 - lightningcss: ^1.21.0 - sass: ^1.70.0 - sass-embedded: ^1.70.0 - stylus: '>=0.54.8' - sugarss: ^5.0.0 - terser: ^5.16.0 - tsx: ^4.8.1 - yaml: ^2.4.2 - peerDependenciesMeta: - '@types/node': - optional: true - jiti: - optional: true - less: - optional: true - lightningcss: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - tsx: - optional: true - yaml: - optional: true - - vitest@5.0.5: - resolution: {integrity: sha512-cPODOMF8YnkiiYtDdHo6n4wWmBrOBBbOIYH2d33M4l3CdNn72h+qMmO6uXffUN6A5AZFXNND/d+krS9mgkXRfg==} - engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0} - hasBin: true - peerDependencies: - '@edge-runtime/vm': '*' - '@opentelemetry/api': ^1.9.0 - '@types/node': ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 5.0.5 - '@vitest/browser-preview': 5.0.5 - '@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0 - '@vitest/coverage-istanbul': 5.0.5 - '@vitest/coverage-v8': 5.0.5 - '@vitest/ui': 5.0.5 - happy-dom: '*' - jsdom: '*' - vite: ^6.4.0 || ^7.0.0 || ^8.0.0 - peerDependenciesMeta: - '@edge-runtime/vm': - optional: true - '@opentelemetry/api': - optional: true - '@types/node': - optional: true - '@vitest/browser-playwright': - optional: true - '@vitest/browser-preview': - optional: true - '@vitest/browser-webdriverio': - optional: true - '@vitest/coverage-istanbul': - optional: true - '@vitest/coverage-v8': - optional: true - '@vitest/ui': - optional: true - happy-dom: - optional: true - jsdom: - optional: true - - vue-eslint-parser@10.4.1: - resolution: {integrity: sha512-Gk6gRDj0n/fkRa3C3l0bBheoBckUq/Rs0F/TvMWIS6nzzx67amAViMe9CkNgsP2tXyQONvGiHQESHwFtZ3aYDA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - - w3c-xmlserializer@5.0.0: - resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} - engines: {node: '>=18'} - - webidl-conversions@8.0.1: - resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} - engines: {node: '>=20'} - - webpack-virtual-modules@0.6.2: - resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} - - whatwg-mimetype@5.0.0: - resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} - engines: {node: '>=20'} - - whatwg-url@16.0.1: - resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} - engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} - - which@2.0.2: - resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} - engines: {node: '>= 8'} - hasBin: true - - why-is-node-running@3.2.1: - resolution: {integrity: sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==} - engines: {node: '>=20.11'} - hasBin: true - - word-wrap@1.2.5: - resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} - engines: {node: '>=0.10.0'} - - ws@8.22.0: - resolution: {integrity: sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==} - engines: {node: '>=10.0.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: '>=5.0.2' - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - - xml-name-validator@5.0.0: - resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} - engines: {node: '>=18'} - - xmlchars@2.2.0: - resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} - - yallist@3.1.1: - resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} - - yocto-queue@0.1.0: - resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} - engines: {node: '>=10'} - - zod@4.6.7: - resolution: {integrity: sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==} - -snapshots: - - '@acemir/cssom@0.9.32': {} - - '@asamuzakjp/css-color@5.1.11': - dependencies: - '@asamuzakjp/generational-cache': 1.0.1 - '@csstools/css-calc': 3.4.3(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2) - '@csstools/css-color-parser': 4.2.6(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2) - '@csstools/css-parser-algorithms': 4.0.2(@csstools/css-tokenizer@4.0.2) - '@csstools/css-tokenizer': 4.0.2 - - '@asamuzakjp/dom-selector@6.8.1': - dependencies: - '@asamuzakjp/nwsapi': 2.3.9 - bidi-js: 1.1.0 - css-tree: 3.2.1 - is-potential-custom-element-name: 1.0.1 - lru-cache: 11.5.3 - - '@asamuzakjp/generational-cache@1.0.1': {} - - '@asamuzakjp/nwsapi@2.3.9': {} - - '@babel/code-frame@7.29.7': - dependencies: - '@babel/helper-validator-identifier': 7.29.7 - js-tokens: 4.0.0 - picocolors: 1.1.1 - - '@babel/compat-data@7.29.7': {} - - '@babel/core@7.29.7': - dependencies: - '@babel/code-frame': 7.29.7 - '@babel/generator': 7.29.8 - '@babel/helper-compilation-targets': 7.29.7 - '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) - '@babel/helpers': 7.29.10 - '@babel/parser': 7.29.9 - '@babel/template': 7.29.7 - '@babel/traverse': 7.29.10 - '@babel/types': 7.29.8 - '@jridgewell/remapping': 2.3.5 - convert-source-map: 2.0.0 - debug: 4.4.3 - gensync: 1.0.0-beta.2 - json5: 2.2.3 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color - - '@babel/generator@7.29.8': - dependencies: - '@babel/parser': 7.29.9 - '@babel/types': 7.29.8 - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - jsesc: 3.1.0 - - '@babel/helper-compilation-targets@7.29.7': - dependencies: - '@babel/compat-data': 7.29.7 - '@babel/helper-validator-option': 7.29.7 - browserslist: 4.29.3 - lru-cache: 5.1.1 - semver: 6.3.1 - - '@babel/helper-globals@7.29.7': {} - - '@babel/helper-module-imports@7.29.7': - dependencies: - '@babel/traverse': 7.29.10 - '@babel/types': 7.29.8 - transitivePeerDependencies: - - supports-color - - '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': - dependencies: - '@babel/core': 7.29.7 - '@babel/helper-module-imports': 7.29.7 - '@babel/helper-validator-identifier': 7.29.7 - '@babel/traverse': 7.29.10 - transitivePeerDependencies: - - supports-color - - '@babel/helper-plugin-utils@7.29.7': {} - - '@babel/helper-string-parser@7.29.7': {} - - '@babel/helper-validator-identifier@7.29.7': {} - - '@babel/helper-validator-option@7.29.7': {} - - '@babel/helpers@7.29.10': - dependencies: - '@babel/template': 7.29.7 - '@babel/types': 7.29.8 - - '@babel/parser@7.29.9': - dependencies: - '@babel/types': 7.29.8 - - '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)': - dependencies: - '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.29.7 - - '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)': - dependencies: - '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.29.7 - - '@babel/runtime@7.29.10': {} - - '@babel/template@7.29.7': - dependencies: - '@babel/code-frame': 7.29.7 - '@babel/parser': 7.29.9 - '@babel/types': 7.29.8 - - '@babel/traverse@7.29.10': - dependencies: - '@babel/code-frame': 7.29.7 - '@babel/generator': 7.29.8 - '@babel/helper-globals': 7.29.7 - '@babel/parser': 7.29.9 - '@babel/template': 7.29.7 - '@babel/types': 7.29.8 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - - '@babel/types@7.29.8': - dependencies: - '@babel/helper-string-parser': 7.29.7 - '@babel/helper-validator-identifier': 7.29.7 - - '@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5)': - dependencies: - '@better-auth/utils': 0.4.2 - '@better-fetch/fetch': 1.3.2 - '@opentelemetry/semantic-conventions': 1.43.0 - '@standard-schema/spec': 1.1.0 - better-call: 1.4.0(zod@4.6.7) - jose: 6.2.12 - kysely: 0.29.6 - nanostores: 1.5.5 - zod: 4.6.7 - - '@better-auth/drizzle-adapter@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2)': - dependencies: - '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5) - '@better-auth/utils': 0.4.2 - - '@better-auth/kysely-adapter@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2)(kysely@0.29.6)': - dependencies: - '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5) - '@better-auth/utils': 0.4.2 - optionalDependencies: - kysely: 0.29.6 - - '@better-auth/memory-adapter@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2)': - dependencies: - '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5) - '@better-auth/utils': 0.4.2 - - '@better-auth/mongo-adapter@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2)': - dependencies: - '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5) - '@better-auth/utils': 0.4.2 - - '@better-auth/prisma-adapter@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2)': - dependencies: - '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5) - '@better-auth/utils': 0.4.2 - - '@better-auth/telemetry@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)': - dependencies: - '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5) - '@better-auth/utils': 0.4.2 - '@better-fetch/fetch': 1.3.2 - - '@better-auth/utils@0.4.2': - dependencies: - '@noble/hashes': 2.4.0 - - '@better-auth/utils@0.5.0': - dependencies: - '@noble/hashes': 2.4.0 - - '@better-fetch/fetch@1.3.2': {} - - '@bramus/specificity@2.4.3': - dependencies: - css-tree: 3.2.1 - - '@cacheable/memory@2.2.0': - dependencies: - '@cacheable/utils': 2.5.0 - '@keyv/bigmap': 1.3.1(keyv@5.6.0) - hookified: 1.15.1 - keyv: 5.6.0 - - '@cacheable/utils@2.5.0': - dependencies: - hashery: 1.5.1 - keyv: 5.6.0 - - '@csstools/color-helpers@6.1.2': {} - - '@csstools/css-calc@3.4.3(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2)': - dependencies: - '@csstools/css-parser-algorithms': 4.0.2(@csstools/css-tokenizer@4.0.2) - '@csstools/css-tokenizer': 4.0.2 - - '@csstools/css-color-parser@4.2.6(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2)': - dependencies: - '@csstools/color-helpers': 6.1.2 - '@csstools/css-calc': 3.4.3(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2) - '@csstools/css-parser-algorithms': 4.0.2(@csstools/css-tokenizer@4.0.2) - '@csstools/css-tokenizer': 4.0.2 - - '@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2)': - dependencies: - '@csstools/css-tokenizer': 4.0.2 - - '@csstools/css-syntax-patches-for-csstree@1.1.15(css-tree@3.2.1)': - optionalDependencies: - css-tree: 3.2.1 - - '@csstools/css-tokenizer@4.0.2': {} - - '@emnapi/core@1.10.0': - dependencies: - '@emnapi/wasi-threads': 1.2.1 - tslib: 2.8.1 - optional: true - - '@emnapi/runtime@1.10.0': - dependencies: - tslib: 2.8.1 - optional: true - - '@emnapi/wasi-threads@1.2.1': - dependencies: - tslib: 2.8.1 - optional: true - - '@esbuild/aix-ppc64@0.28.2': - optional: true - - '@esbuild/android-arm64@0.28.2': - optional: true - - '@esbuild/android-arm@0.28.2': - optional: true - - '@esbuild/android-x64@0.28.2': - optional: true - - '@esbuild/darwin-arm64@0.28.2': - optional: true - - '@esbuild/darwin-x64@0.28.2': - optional: true - - '@esbuild/freebsd-arm64@0.28.2': - optional: true - - '@esbuild/freebsd-x64@0.28.2': - optional: true - - '@esbuild/linux-arm64@0.28.2': - optional: true - - '@esbuild/linux-arm@0.28.2': - optional: true - - '@esbuild/linux-ia32@0.28.2': - optional: true - - '@esbuild/linux-loong64@0.28.2': - optional: true - - '@esbuild/linux-mips64el@0.28.2': - optional: true - - '@esbuild/linux-ppc64@0.28.2': - optional: true - - '@esbuild/linux-riscv64@0.28.2': - optional: true - - '@esbuild/linux-s390x@0.28.2': - optional: true - - '@esbuild/linux-x64@0.28.2': - optional: true - - '@esbuild/netbsd-arm64@0.28.2': - optional: true - - '@esbuild/netbsd-x64@0.28.2': - optional: true - - '@esbuild/openbsd-arm64@0.28.2': - optional: true - - '@esbuild/openbsd-x64@0.28.2': - optional: true - - '@esbuild/openharmony-arm64@0.28.2': - optional: true - - '@esbuild/sunos-x64@0.28.2': - optional: true - - '@esbuild/win32-arm64@0.28.2': - optional: true - - '@esbuild/win32-ia32@0.28.2': - optional: true - - '@esbuild/win32-x64@0.28.2': - optional: true - - '@eslint-community/eslint-utils@4.10.1(eslint@10.13.0(jiti@2.7.0))': - dependencies: - eslint: 10.13.0(jiti@2.7.0) - eslint-visitor-keys: 3.4.3 - - '@eslint-community/regexpp@4.12.2': {} - - '@eslint/config-array@0.23.5': - dependencies: - '@eslint/object-schema': 3.0.5 - debug: 4.4.3 - minimatch: 10.2.6 - transitivePeerDependencies: - - supports-color - - '@eslint/config-helpers@0.7.0': - dependencies: - '@eslint/core': 1.2.1 - - '@eslint/core@1.2.1': - dependencies: - '@types/json-schema': 7.0.15 - - '@eslint/js@10.0.1(eslint@10.13.0(jiti@2.7.0))': - optionalDependencies: - eslint: 10.13.0(jiti@2.7.0) - - '@eslint/object-schema@3.0.5': {} - - '@eslint/plugin-kit@0.7.3': - dependencies: - '@eslint/core': 1.2.1 - levn: 0.4.1 - - '@exodus/bytes@1.16.0(@noble/hashes@2.4.0)': - optionalDependencies: - '@noble/hashes': 2.4.0 - - '@humanfs/core@0.19.2': - dependencies: - '@humanfs/types': 0.15.0 - - '@humanfs/node@0.16.8': - dependencies: - '@humanfs/core': 0.19.2 - '@humanfs/types': 0.15.0 - '@humanwhocodes/retry': 0.4.3 - - '@humanfs/types@0.15.0': {} - - '@humanwhocodes/module-importer@1.0.1': {} - - '@humanwhocodes/retry@0.4.3': {} - - '@jridgewell/gen-mapping@0.3.13': - dependencies: - '@jridgewell/sourcemap-codec': 1.6.0 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/remapping@2.3.5': - dependencies: - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/resolve-uri@3.1.2': {} - - '@jridgewell/sourcemap-codec@1.6.0': {} - - '@jridgewell/trace-mapping@0.3.31': - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.6.0 - - '@keyv/bigmap@1.3.1(keyv@5.6.0)': - dependencies: - hashery: 1.5.1 - hookified: 1.15.1 - keyv: 5.6.0 - - '@keyv/serialize@1.1.1': {} - - '@napi-rs/lzma-linux-x64-gnu@1.5.1': - optional: true - - '@napi-rs/wasm-runtime@1.2.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': - dependencies: - '@emnapi/core': 1.10.0 - '@emnapi/runtime': 1.10.0 - '@tybys/wasm-util': 0.10.4 - optional: true - - '@neodrag/core@3.0.0-next.11': {} - - '@neodrag/solid@3.0.0-next.11(@neodrag/core@3.0.0-next.11)(solid-js@1.9.17)': - dependencies: - '@neodrag/core': 3.0.0-next.11 - solid-js: 1.9.17 - - '@noble/ciphers@2.4.0': {} - - '@noble/hashes@2.4.0': {} - - '@opentelemetry/semantic-conventions@1.43.0': {} - - '@oxc-parser/binding-android-arm-eabi@0.120.0': - optional: true - - '@oxc-parser/binding-android-arm64@0.120.0': - optional: true - - '@oxc-parser/binding-darwin-arm64@0.120.0': - optional: true - - '@oxc-parser/binding-darwin-x64@0.120.0': - optional: true - - '@oxc-parser/binding-freebsd-x64@0.120.0': - optional: true - - '@oxc-parser/binding-linux-arm-gnueabihf@0.120.0': - optional: true - - '@oxc-parser/binding-linux-arm-musleabihf@0.120.0': - optional: true - - '@oxc-parser/binding-linux-arm64-gnu@0.120.0': - optional: true - - '@oxc-parser/binding-linux-arm64-musl@0.120.0': - optional: true - - '@oxc-parser/binding-linux-ppc64-gnu@0.120.0': - optional: true - - '@oxc-parser/binding-linux-riscv64-gnu@0.120.0': - optional: true - - '@oxc-parser/binding-linux-riscv64-musl@0.120.0': - optional: true - - '@oxc-parser/binding-linux-s390x-gnu@0.120.0': - optional: true - - '@oxc-parser/binding-linux-x64-gnu@0.120.0': - optional: true - - '@oxc-parser/binding-linux-x64-musl@0.120.0': - optional: true - - '@oxc-parser/binding-openharmony-arm64@0.120.0': - optional: true - - '@oxc-parser/binding-wasm32-wasi@0.120.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': - dependencies: - '@napi-rs/wasm-runtime': 1.2.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) - transitivePeerDependencies: - - '@emnapi/core' - - '@emnapi/runtime' - optional: true - - '@oxc-parser/binding-win32-arm64-msvc@0.120.0': - optional: true - - '@oxc-parser/binding-win32-ia32-msvc@0.120.0': - optional: true - - '@oxc-parser/binding-win32-x64-msvc@0.120.0': - optional: true - - '@oxc-project/types@0.120.0': {} - - '@rolldown/pluginutils@1.0.0-rc.3': {} - - '@rollup/rollup-android-arm-eabi@4.64.7': - optional: true - - '@rollup/rollup-android-arm64@4.64.7': - optional: true - - '@rollup/rollup-darwin-arm64@4.64.7': - optional: true - - '@rollup/rollup-darwin-x64@4.64.7': - optional: true - - '@rollup/rollup-freebsd-arm64@4.64.7': - optional: true - - '@rollup/rollup-freebsd-x64@4.64.7': - optional: true - - '@rollup/rollup-linux-arm-gnueabihf@4.64.7': - optional: true - - '@rollup/rollup-linux-arm-musleabihf@4.64.7': - optional: true - - '@rollup/rollup-linux-arm64-gnu@4.64.7': - optional: true - - '@rollup/rollup-linux-arm64-musl@4.64.7': - optional: true - - '@rollup/rollup-linux-loong64-gnu@4.64.7': - optional: true - - '@rollup/rollup-linux-loong64-musl@4.64.7': - optional: true - - '@rollup/rollup-linux-ppc64-gnu@4.64.7': - optional: true - - '@rollup/rollup-linux-ppc64-musl@4.64.7': - optional: true - - '@rollup/rollup-linux-riscv64-gnu@4.64.7': - optional: true - - '@rollup/rollup-linux-riscv64-musl@4.64.7': - optional: true - - '@rollup/rollup-linux-s390x-gnu@4.64.7': - optional: true - - '@rollup/rollup-linux-x64-gnu@4.64.7': - optional: true - - '@rollup/rollup-linux-x64-musl@4.64.7': - optional: true - - '@rollup/rollup-openbsd-x64@4.64.7': - optional: true - - '@rollup/rollup-openharmony-arm64@4.64.7': - optional: true - - '@rollup/rollup-win32-arm64-msvc@4.64.7': - optional: true - - '@rollup/rollup-win32-ia32-msvc@4.64.7': - optional: true - - '@rollup/rollup-win32-x64-gnu@4.64.7': - optional: true - - '@rollup/rollup-win32-x64-msvc@4.64.7': - optional: true - - '@solid-primitives/event-listener@2.4.6(solid-js@1.9.17)': - dependencies: - '@solid-primitives/utils': 6.4.1(solid-js@1.9.17) - solid-js: 1.9.17 - - '@solid-primitives/keyboard@1.3.7(solid-js@1.9.17)': - dependencies: - '@solid-primitives/event-listener': 2.4.6(solid-js@1.9.17) - '@solid-primitives/rootless': 1.5.4(solid-js@1.9.17) - '@solid-primitives/utils': 6.4.1(solid-js@1.9.17) - solid-js: 1.9.17 - - '@solid-primitives/resize-observer@2.2.0(solid-js@1.9.17)': - dependencies: - '@solid-primitives/event-listener': 2.4.6(solid-js@1.9.17) - '@solid-primitives/rootless': 1.5.4(solid-js@1.9.17) - '@solid-primitives/static-store': 0.1.4(solid-js@1.9.17) - '@solid-primitives/utils': 6.4.1(solid-js@1.9.17) - solid-js: 1.9.17 - - '@solid-primitives/rootless@1.5.4(solid-js@1.9.17)': - dependencies: - '@solid-primitives/utils': 6.4.1(solid-js@1.9.17) - solid-js: 1.9.17 - - '@solid-primitives/static-store@0.1.4(solid-js@1.9.17)': - dependencies: - '@solid-primitives/utils': 6.4.1(solid-js@1.9.17) - solid-js: 1.9.17 - - '@solid-primitives/utils@6.4.1(solid-js@1.9.17)': - dependencies: - solid-js: 1.9.17 - - '@standard-schema/spec@1.1.0': {} - - '@stylistic/eslint-plugin@5.10.0(eslint@10.13.0(jiti@2.7.0))': - dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@10.13.0(jiti@2.7.0)) - '@typescript-eslint/types': 8.71.2 - eslint: 10.13.0(jiti@2.7.0) - eslint-visitor-keys: 4.2.1 - espree: 10.4.0 - estraverse: 5.3.0 - picomatch: 4.0.7 - - '@tailwindcss/node@4.3.3': - dependencies: - '@jridgewell/remapping': 2.3.5 - enhanced-resolve: 5.26.0 - jiti: 2.7.0 - lightningcss: 1.32.0 - magic-string: 0.30.21 - source-map-js: 1.2.2 - tailwindcss: 4.3.3 - - '@tailwindcss/oxide-android-arm64@4.3.3': - optional: true - - '@tailwindcss/oxide-darwin-arm64@4.3.3': - optional: true - - '@tailwindcss/oxide-darwin-x64@4.3.3': - optional: true - - '@tailwindcss/oxide-freebsd-x64@4.3.3': - optional: true - - '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': - optional: true - - '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': - optional: true - - '@tailwindcss/oxide-linux-arm64-musl@4.3.3': - optional: true - - '@tailwindcss/oxide-linux-x64-gnu@4.3.3': - optional: true - - '@tailwindcss/oxide-linux-x64-musl@4.3.3': - optional: true - - '@tailwindcss/oxide-wasm32-wasi@4.3.3': - optional: true - - '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': - optional: true - - '@tailwindcss/oxide-win32-x64-msvc@4.3.3': - optional: true - - '@tailwindcss/oxide@4.3.3': - optionalDependencies: - '@tailwindcss/oxide-android-arm64': 4.3.3 - '@tailwindcss/oxide-darwin-arm64': 4.3.3 - '@tailwindcss/oxide-darwin-x64': 4.3.3 - '@tailwindcss/oxide-freebsd-x64': 4.3.3 - '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.3 - '@tailwindcss/oxide-linux-arm64-gnu': 4.3.3 - '@tailwindcss/oxide-linux-arm64-musl': 4.3.3 - '@tailwindcss/oxide-linux-x64-gnu': 4.3.3 - '@tailwindcss/oxide-linux-x64-musl': 4.3.3 - '@tailwindcss/oxide-wasm32-wasi': 4.3.3 - '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3 - '@tailwindcss/oxide-win32-x64-msvc': 4.3.3 - - '@tailwindcss/typography@0.5.20(tailwindcss@4.3.3)': - dependencies: - postcss-selector-parser: 6.0.10 - tailwindcss: 4.3.3 - - '@tailwindcss/vite@4.3.3(vite@7.3.7(@types/node@22.20.5)(jiti@2.7.0)(lightningcss@1.32.0))': - dependencies: - '@tailwindcss/node': 4.3.3 - '@tailwindcss/oxide': 4.3.3 - tailwindcss: 4.3.3 - vite: 7.3.7(@types/node@22.20.5)(jiti@2.7.0)(lightningcss@1.32.0) - - '@tanstack/devtools-bundler-core@0.1.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': - dependencies: - '@tanstack/devtools-client': 0.0.8 - '@tanstack/devtools-event-bus': 0.4.3 - chalk: 5.6.2 - launch-editor: 2.14.2 - magic-string: 0.30.21 - oxc-parser: 0.120.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) - picomatch: 4.0.7 - transitivePeerDependencies: - - '@emnapi/core' - - '@emnapi/runtime' - - bufferutil - - utf-8-validate - - '@tanstack/devtools-client@0.0.8': - dependencies: - '@tanstack/devtools-event-client': 0.5.0 - - '@tanstack/devtools-event-bus@0.4.3': - dependencies: - ws: 8.22.0 - transitivePeerDependencies: - - bufferutil - - utf-8-validate - - '@tanstack/devtools-event-client@0.5.0': {} - - '@tanstack/devtools-ui@0.7.1(csstype@3.2.3)(solid-js@1.9.17)': - dependencies: - clsx: 2.1.1 - dayjs: 1.11.23 - goober: 2.1.19(csstype@3.2.3) - solid-js: 1.9.17 - transitivePeerDependencies: - - csstype - - '@tanstack/devtools-vite@0.8.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(vite@7.3.7(@types/node@22.20.5)(jiti@2.7.0)(lightningcss@1.32.0))': - dependencies: - '@tanstack/devtools-bundler-core': 0.1.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) - '@tanstack/devtools-client': 0.0.8 - '@tanstack/devtools-event-bus': 0.4.3 - chalk: 5.6.2 - vite: 7.3.7(@types/node@22.20.5)(jiti@2.7.0)(lightningcss@1.32.0) - transitivePeerDependencies: - - '@emnapi/core' - - '@emnapi/runtime' - - bufferutil - - utf-8-validate - - '@tanstack/devtools@0.15.0(csstype@3.2.3)(solid-js@1.9.17)': - dependencies: - '@neodrag/core': 3.0.0-next.11 - '@neodrag/solid': 3.0.0-next.11(@neodrag/core@3.0.0-next.11)(solid-js@1.9.17) - '@solid-primitives/event-listener': 2.4.6(solid-js@1.9.17) - '@solid-primitives/keyboard': 1.3.7(solid-js@1.9.17) - '@solid-primitives/resize-observer': 2.2.0(solid-js@1.9.17) - '@tanstack/devtools-client': 0.0.8 - '@tanstack/devtools-event-bus': 0.4.3 - '@tanstack/devtools-ui': 0.7.1(csstype@3.2.3)(solid-js@1.9.17) - clsx: 2.1.1 - goober: 2.1.19(csstype@3.2.3) - solid-js: 1.9.17 - transitivePeerDependencies: - - bufferutil - - csstype - - utf-8-validate - - '@tanstack/eslint-config@0.4.0(@typescript-eslint/utils@8.71.2(eslint@10.13.0(jiti@2.7.0))(typescript@5.9.3))(eslint@10.13.0(jiti@2.7.0))(typescript@5.9.3)': - dependencies: - '@eslint/js': 10.0.1(eslint@10.13.0(jiti@2.7.0)) - '@stylistic/eslint-plugin': 5.10.0(eslint@10.13.0(jiti@2.7.0)) - eslint: 10.13.0(jiti@2.7.0) - eslint-plugin-import-x: 4.17.1(@typescript-eslint/utils@8.71.2(eslint@10.13.0(jiti@2.7.0))(typescript@5.9.3))(eslint@10.13.0(jiti@2.7.0)) - eslint-plugin-n: 17.24.0(eslint@10.13.0(jiti@2.7.0))(typescript@5.9.3) - globals: 17.13.0 - typescript-eslint: 8.71.2(eslint@10.13.0(jiti@2.7.0))(typescript@5.9.3) - vue-eslint-parser: 10.4.1(eslint@10.13.0(jiti@2.7.0)) - transitivePeerDependencies: - - '@typescript-eslint/utils' - - eslint-import-resolver-node - - supports-color - - typescript - - '@tanstack/history@1.162.5': {} - - '@tanstack/react-devtools@0.10.13(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(csstype@3.2.3)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(solid-js@1.9.17)': - dependencies: - '@tanstack/devtools': 0.15.0(csstype@3.2.3)(solid-js@1.9.17) - '@types/react': 19.3.0 - '@types/react-dom': 19.3.0(@types/react@19.3.0) - react: 19.3.1 - react-dom: 19.3.1(react@19.3.1) - transitivePeerDependencies: - - bufferutil - - csstype - - solid-js - - utf-8-validate - - '@tanstack/react-router-devtools@1.167.2(@tanstack/react-router@1.170.45(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(@tanstack/router-core@1.171.38)(csstype@3.2.3)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': - dependencies: - '@tanstack/react-router': 1.170.45(react-dom@19.3.1(react@19.3.1))(react@19.3.1) - '@tanstack/router-devtools-core': 1.168.2(@tanstack/router-core@1.171.38)(csstype@3.2.3) - react: 19.3.1 - react-dom: 19.3.1(react@19.3.1) - optionalDependencies: - '@tanstack/router-core': 1.171.38 - transitivePeerDependencies: - - csstype - - '@tanstack/react-router@1.170.45(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': - dependencies: - '@tanstack/history': 1.162.5 - '@tanstack/react-store': 0.11.2(react-dom@19.3.1(react@19.3.1))(react@19.3.1) - '@tanstack/router-core': 1.171.38 - isbot: 5.2.2 - react: 19.3.1 - react-dom: 19.3.1(react@19.3.1) - - '@tanstack/react-store@0.11.2(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': - dependencies: - '@tanstack/store': 0.11.2 - react: 19.3.1 - react-dom: 19.3.1(react@19.3.1) - use-sync-external-store: 1.7.0(react@19.3.1) - - '@tanstack/router-core@1.171.38': - dependencies: - '@tanstack/history': 1.162.5 - cookie-es: 3.1.1 - seroval: 1.6.9 - seroval-plugins: 1.6.9(seroval@1.6.9) - - '@tanstack/router-devtools-core@1.168.2(@tanstack/router-core@1.171.38)(csstype@3.2.3)': - dependencies: - '@tanstack/router-core': 1.171.38 - clsx: 2.1.1 - goober: 2.1.19(csstype@3.2.3) - optionalDependencies: - csstype: 3.2.3 - - '@tanstack/router-generator@1.167.45': - dependencies: - '@babel/types': 7.29.8 - '@tanstack/router-core': 1.171.38 - '@tanstack/router-utils': 1.162.4 - '@tanstack/virtual-file-routes': 1.162.0 - jiti: 2.7.0 - magic-string: 0.30.21 - prettier: 3.9.10 - zod: 4.6.7 - transitivePeerDependencies: - - supports-color - - '@tanstack/router-plugin@1.168.47(@tanstack/react-router@1.170.45(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(esbuild@0.28.2)(rollup@4.64.7)(vite@7.3.7(@types/node@22.20.5)(jiti@2.7.0)(lightningcss@1.32.0))': - dependencies: - '@babel/core': 7.29.7 - '@babel/template': 7.29.7 - '@babel/types': 7.29.8 - '@tanstack/router-core': 1.171.38 - '@tanstack/router-generator': 1.167.45 - '@tanstack/router-utils': 1.162.4 - chokidar: 5.0.0 - unplugin: 3.4.0(esbuild@0.28.2)(rollup@4.64.7)(vite@7.3.7(@types/node@22.20.5)(jiti@2.7.0)(lightningcss@1.32.0)) - zod: 4.6.7 - optionalDependencies: - '@tanstack/react-router': 1.170.45(react-dom@19.3.1(react@19.3.1))(react@19.3.1) - vite: 7.3.7(@types/node@22.20.5)(jiti@2.7.0)(lightningcss@1.32.0) - transitivePeerDependencies: - - '@farmfe/core' - - '@rspack/core' - - bun-types-no-globals - - esbuild - - rolldown - - rollup - - supports-color - - unloader - - '@tanstack/router-utils@1.162.4': - dependencies: - '@babel/generator': 7.29.8 - '@babel/parser': 7.29.9 - '@babel/types': 7.29.8 - ansis: 4.4.0 - babel-dead-code-elimination: 1.0.12 - diff: 9.0.0 - pathe: 2.0.3 - tinyglobby: 0.2.17 - transitivePeerDependencies: - - supports-color - - '@tanstack/store@0.11.2': {} - - '@tanstack/virtual-file-routes@1.162.0': {} - - '@testing-library/dom@10.4.2': - dependencies: - '@babel/code-frame': 7.29.7 - '@babel/runtime': 7.29.10 - '@types/aria-query': 5.0.4 - aria-query: 5.3.0 - dom-accessibility-api: 0.5.16 - lz-string: 1.5.0 - picocolors: 1.1.1 - pretty-format: 27.5.1 - - '@testing-library/react@16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': - dependencies: - '@babel/runtime': 7.29.10 - '@testing-library/dom': 10.4.2 - react: 19.3.1 - react-dom: 19.3.1(react@19.3.1) - optionalDependencies: - '@types/react': 19.3.0 - '@types/react-dom': 19.3.0(@types/react@19.3.0) - - '@tybys/wasm-util@0.10.4': - dependencies: - tslib: 2.8.1 - optional: true - - '@types/aria-query@5.0.4': {} - - '@types/babel__core@7.20.5': - dependencies: - '@babel/parser': 7.29.9 - '@babel/types': 7.29.8 - '@types/babel__generator': 7.27.0 - '@types/babel__template': 7.4.4 - '@types/babel__traverse': 7.28.0 - - '@types/babel__generator@7.27.0': - dependencies: - '@babel/types': 7.29.8 - - '@types/babel__template@7.4.4': - dependencies: - '@babel/parser': 7.29.9 - '@babel/types': 7.29.8 - - '@types/babel__traverse@7.28.0': - dependencies: - '@babel/types': 7.29.8 - - '@types/chai@5.2.3': - dependencies: - '@types/deep-eql': 4.0.2 - assertion-error: 2.0.1 - - '@types/deep-eql@4.0.2': {} - - '@types/esrecurse@4.3.1': {} - - '@types/estree@1.0.9': {} - - '@types/json-schema@7.0.15': {} - - '@types/node@22.20.5': - dependencies: - undici-types: 6.21.0 - - '@types/react-dom@19.3.0(@types/react@19.3.0)': - dependencies: - '@types/react': 19.3.0 - - '@types/react@19.3.0': - dependencies: - csstype: 3.2.3 - - '@typescript-eslint/eslint-plugin@8.71.2(@typescript-eslint/parser@8.71.2(eslint@10.13.0(jiti@2.7.0))(typescript@5.9.3))(eslint@10.13.0(jiti@2.7.0))(typescript@5.9.3)': - dependencies: - '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.71.2(eslint@10.13.0(jiti@2.7.0))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.71.2 - '@typescript-eslint/type-utils': 8.71.2(eslint@10.13.0(jiti@2.7.0))(typescript@5.9.3) - '@typescript-eslint/utils': 8.71.2(eslint@10.13.0(jiti@2.7.0))(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.71.2 - eslint: 10.13.0(jiti@2.7.0) - ignore: 7.0.12 - natural-compare: 1.4.0 - ts-api-utils: 2.5.0(typescript@5.9.3) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/parser@8.71.2(eslint@10.13.0(jiti@2.7.0))(typescript@5.9.3)': - dependencies: - '@typescript-eslint/scope-manager': 8.71.2 - '@typescript-eslint/types': 8.71.2 - '@typescript-eslint/typescript-estree': 8.71.2(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.71.2 - debug: 4.4.3 - eslint: 10.13.0(jiti@2.7.0) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/project-service@8.71.2(typescript@5.9.3)': - dependencies: - '@typescript-eslint/tsconfig-utils': 8.71.2(typescript@5.9.3) - '@typescript-eslint/types': 8.71.2 - debug: 4.4.3 - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/scope-manager@8.71.2': - dependencies: - '@typescript-eslint/types': 8.71.2 - '@typescript-eslint/visitor-keys': 8.71.2 - - '@typescript-eslint/tsconfig-utils@8.71.2(typescript@5.9.3)': - dependencies: - typescript: 5.9.3 - - '@typescript-eslint/type-utils@8.71.2(eslint@10.13.0(jiti@2.7.0))(typescript@5.9.3)': - dependencies: - '@typescript-eslint/types': 8.71.2 - '@typescript-eslint/typescript-estree': 8.71.2(typescript@5.9.3) - '@typescript-eslint/utils': 8.71.2(eslint@10.13.0(jiti@2.7.0))(typescript@5.9.3) - debug: 4.4.3 - eslint: 10.13.0(jiti@2.7.0) - ts-api-utils: 2.5.0(typescript@5.9.3) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/types@8.71.2': {} - - '@typescript-eslint/typescript-estree@8.71.2(typescript@5.9.3)': - dependencies: - '@typescript-eslint/project-service': 8.71.2(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.71.2(typescript@5.9.3) - '@typescript-eslint/types': 8.71.2 - '@typescript-eslint/visitor-keys': 8.71.2 - debug: 4.4.3 - minimatch: 10.2.6 - semver: 7.8.5 - tinyglobby: 0.2.17 - ts-api-utils: 2.5.0(typescript@5.9.3) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/utils@8.71.2(eslint@10.13.0(jiti@2.7.0))(typescript@5.9.3)': - dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@10.13.0(jiti@2.7.0)) - '@typescript-eslint/scope-manager': 8.71.2 - '@typescript-eslint/types': 8.71.2 - '@typescript-eslint/typescript-estree': 8.71.2(typescript@5.9.3) - eslint: 10.13.0(jiti@2.7.0) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/visitor-keys@8.71.2': - dependencies: - '@typescript-eslint/types': 8.71.2 - eslint-visitor-keys: 5.0.1 - - '@unrs/resolver-binding-android-arm-eabi@1.12.2': - optional: true - - '@unrs/resolver-binding-android-arm64@1.12.2': - optional: true - - '@unrs/resolver-binding-darwin-arm64@1.12.2': - optional: true - - '@unrs/resolver-binding-darwin-x64@1.12.2': - optional: true - - '@unrs/resolver-binding-freebsd-x64@1.12.2': - optional: true - - '@unrs/resolver-binding-linux-arm-gnueabihf@1.12.2': - optional: true - - '@unrs/resolver-binding-linux-arm-musleabihf@1.12.2': - optional: true - - '@unrs/resolver-binding-linux-arm64-gnu@1.12.2': - optional: true - - '@unrs/resolver-binding-linux-arm64-musl@1.12.2': - optional: true - - '@unrs/resolver-binding-linux-loong64-gnu@1.12.2': - optional: true - - '@unrs/resolver-binding-linux-loong64-musl@1.12.2': - optional: true - - '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2': - optional: true - - '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2': - optional: true - - '@unrs/resolver-binding-linux-riscv64-musl@1.12.2': - optional: true - - '@unrs/resolver-binding-linux-s390x-gnu@1.12.2': - optional: true - - '@unrs/resolver-binding-linux-x64-gnu@1.12.2': - optional: true - - '@unrs/resolver-binding-linux-x64-musl@1.12.2': - optional: true - - '@unrs/resolver-binding-openharmony-arm64@1.12.2': - optional: true - - '@unrs/resolver-binding-wasm32-wasi@1.12.2': - dependencies: - '@emnapi/core': 1.10.0 - '@emnapi/runtime': 1.10.0 - '@napi-rs/wasm-runtime': 1.2.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) - optional: true - - '@unrs/resolver-binding-win32-arm64-msvc@1.12.2': - optional: true - - '@unrs/resolver-binding-win32-ia32-msvc@1.12.2': - optional: true - - '@unrs/resolver-binding-win32-x64-msvc@1.12.2': - optional: true - - '@vitejs/plugin-react@5.2.0(vite@7.3.7(@types/node@22.20.5)(jiti@2.7.0)(lightningcss@1.32.0))': - dependencies: - '@babel/core': 7.29.7 - '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7) - '@rolldown/pluginutils': 1.0.0-rc.3 - '@types/babel__core': 7.20.5 - react-refresh: 0.18.0 - vite: 7.3.7(@types/node@22.20.5)(jiti@2.7.0)(lightningcss@1.32.0) - transitivePeerDependencies: - - supports-color - - '@vitest/mocker@5.0.5(vite@7.3.7(@types/node@22.20.5)(jiti@2.7.0)(lightningcss@1.32.0))': - dependencies: - '@jridgewell/trace-mapping': 0.3.31 - '@vitest/spy': 5.0.5 - estree-walker: 3.0.3 - magic-string: 1.4.3 - optionalDependencies: - vite: 7.3.7(@types/node@22.20.5)(jiti@2.7.0)(lightningcss@1.32.0) - - '@vitest/spy@5.0.5': {} - - acorn-jsx@5.3.2(acorn@8.19.0): - dependencies: - acorn: 8.19.0 - - acorn@8.19.0: {} - - agent-base@7.1.4: {} - - ajv@6.15.0: - dependencies: - fast-deep-equal: 3.1.3 - fast-json-stable-stringify: 2.1.0 - json-schema-traverse: 0.4.1 - uri-js: 4.4.1 - - ansi-regex@5.0.1: {} - - ansi-styles@5.2.0: {} - - ansis@4.4.0: {} - - aria-query@5.3.0: - dependencies: - dequal: 2.0.3 - - assertion-error@2.0.1: {} - - babel-dead-code-elimination@1.0.12: - dependencies: - '@babel/core': 7.29.7 - '@babel/parser': 7.29.9 - '@babel/traverse': 7.29.10 - '@babel/types': 7.29.8 - transitivePeerDependencies: - - supports-color - - balanced-match@4.0.4: {} - - baseline-browser-mapping@2.11.29: {} - - better-auth@1.7.7(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(solid-js@1.9.17)(vitest@5.0.5(@types/node@22.20.5)(jsdom@28.1.0(@noble/hashes@2.4.0))(vite@7.3.7(@types/node@22.20.5)(jiti@2.7.0)(lightningcss@1.32.0))): - dependencies: - '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5) - '@better-auth/drizzle-adapter': 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2) - '@better-auth/kysely-adapter': 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2)(kysely@0.29.6) - '@better-auth/memory-adapter': 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2) - '@better-auth/mongo-adapter': 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2) - '@better-auth/prisma-adapter': 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2) - '@better-auth/telemetry': 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2) - '@better-auth/utils': 0.4.2 - '@better-fetch/fetch': 1.3.2 - '@noble/ciphers': 2.4.0 - '@noble/hashes': 2.4.0 - better-call: 1.4.0(zod@4.6.7) - defu: 6.1.7 - jose: 6.2.12 - kysely: 0.29.6 - nanostores: 1.5.5 - zod: 4.6.7 - optionalDependencies: - react: 19.3.1 - react-dom: 19.3.1(react@19.3.1) - solid-js: 1.9.17 - vitest: 5.0.5(@types/node@22.20.5)(jsdom@28.1.0(@noble/hashes@2.4.0))(vite@7.3.7(@types/node@22.20.5)(jiti@2.7.0)(lightningcss@1.32.0)) - transitivePeerDependencies: - - '@opentelemetry/api' - - better-call@1.4.0(zod@4.6.7): - dependencies: - '@better-auth/utils': 0.5.0 - '@better-fetch/fetch': 1.3.2 - rou3: 0.9.2 - set-cookie-parser: 3.1.4 - optionalDependencies: - zod: 4.6.7 - - bidi-js@1.1.0: - dependencies: - require-from-string: 2.0.2 - - brace-expansion@5.0.12: - dependencies: - balanced-match: 4.0.4 - - browserslist@4.29.3: - dependencies: - baseline-browser-mapping: 2.11.29 - caniuse-lite: 1.0.30001816 - electron-to-chromium: 1.5.458 - node-releases: 2.0.59 - update-browserslist-db: 1.3.4(browserslist@4.29.3) - - cacheable@2.5.0: - dependencies: - '@cacheable/memory': 2.2.0 - '@cacheable/utils': 2.5.0 - hookified: 1.15.1 - keyv: 5.6.0 - qified: 0.10.1 - - caniuse-lite@1.0.30001816: {} - - chai@6.3.0: {} - - chalk@5.6.2: {} - - chokidar@5.0.0: - dependencies: - readdirp: 5.1.1 - - clsx@2.1.1: {} - - comment-parser@1.4.9: {} - - convert-source-map@2.0.0: {} - - cookie-es@3.1.1: {} - - cross-spawn@7.0.6: - dependencies: - path-key: 3.1.1 - shebang-command: 2.0.0 - which: 2.0.2 - - css-tree@3.2.1: - dependencies: - mdn-data: 2.27.1 - source-map-js: 1.2.2 - - cssesc@3.0.0: {} - - cssstyle@6.2.0: - dependencies: - '@asamuzakjp/css-color': 5.1.11 - '@csstools/css-syntax-patches-for-csstree': 1.1.15(css-tree@3.2.1) - css-tree: 3.2.1 - lru-cache: 11.5.3 - - csstype@3.2.3: {} - - data-urls@7.0.0(@noble/hashes@2.4.0): - dependencies: - whatwg-mimetype: 5.0.0 - whatwg-url: 16.0.1(@noble/hashes@2.4.0) - transitivePeerDependencies: - - '@noble/hashes' - - dayjs@1.11.23: {} - - debug@4.4.3: - dependencies: - ms: 2.1.3 - - decimal.js@10.6.0: {} - - deep-is@0.1.4: {} - - defu@6.1.7: {} - - dequal@2.0.3: {} - - detect-libc@2.1.2: {} - - diff@9.0.0: {} - - dom-accessibility-api@0.5.16: {} - - electron-to-chromium@1.5.458: {} - - enhanced-resolve@5.26.0: - dependencies: - graceful-fs: 4.2.11 - tapable: 2.3.3 - - entities@8.1.0: {} - - es-module-lexer@2.3.2: {} - - esbuild@0.28.2: - optionalDependencies: - '@esbuild/aix-ppc64': 0.28.2 - '@esbuild/android-arm': 0.28.2 - '@esbuild/android-arm64': 0.28.2 - '@esbuild/android-x64': 0.28.2 - '@esbuild/darwin-arm64': 0.28.2 - '@esbuild/darwin-x64': 0.28.2 - '@esbuild/freebsd-arm64': 0.28.2 - '@esbuild/freebsd-x64': 0.28.2 - '@esbuild/linux-arm': 0.28.2 - '@esbuild/linux-arm64': 0.28.2 - '@esbuild/linux-ia32': 0.28.2 - '@esbuild/linux-loong64': 0.28.2 - '@esbuild/linux-mips64el': 0.28.2 - '@esbuild/linux-ppc64': 0.28.2 - '@esbuild/linux-riscv64': 0.28.2 - '@esbuild/linux-s390x': 0.28.2 - '@esbuild/linux-x64': 0.28.2 - '@esbuild/netbsd-arm64': 0.28.2 - '@esbuild/netbsd-x64': 0.28.2 - '@esbuild/openbsd-arm64': 0.28.2 - '@esbuild/openbsd-x64': 0.28.2 - '@esbuild/openharmony-arm64': 0.28.2 - '@esbuild/sunos-x64': 0.28.2 - '@esbuild/win32-arm64': 0.28.2 - '@esbuild/win32-ia32': 0.28.2 - '@esbuild/win32-x64': 0.28.2 - - escalade@3.2.0: {} - - escape-string-regexp@4.0.0: {} - - eslint-compat-utils@0.5.1(eslint@10.13.0(jiti@2.7.0)): - dependencies: - eslint: 10.13.0(jiti@2.7.0) - semver: 7.8.5 - - eslint-import-context@0.1.9(unrs-resolver@1.12.2): - dependencies: - get-tsconfig: 4.14.3 - stable-hash-x: 0.2.0 - optionalDependencies: - unrs-resolver: 1.12.2 - - eslint-plugin-es-x@7.8.0(eslint@10.13.0(jiti@2.7.0)): - dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@10.13.0(jiti@2.7.0)) - '@eslint-community/regexpp': 4.12.2 - eslint: 10.13.0(jiti@2.7.0) - eslint-compat-utils: 0.5.1(eslint@10.13.0(jiti@2.7.0)) - - eslint-plugin-import-x@4.17.1(@typescript-eslint/utils@8.71.2(eslint@10.13.0(jiti@2.7.0))(typescript@5.9.3))(eslint@10.13.0(jiti@2.7.0)): - dependencies: - '@typescript-eslint/types': 8.71.2 - comment-parser: 1.4.9 - debug: 4.4.3 - eslint: 10.13.0(jiti@2.7.0) - eslint-import-context: 0.1.9(unrs-resolver@1.12.2) - is-glob: 4.0.3 - minimatch: 10.2.6 - semver: 7.8.5 - stable-hash-x: 0.2.0 - unrs-resolver: 1.12.2 - optionalDependencies: - '@typescript-eslint/utils': 8.71.2(eslint@10.13.0(jiti@2.7.0))(typescript@5.9.3) - transitivePeerDependencies: - - supports-color - - eslint-plugin-n@17.24.0(eslint@10.13.0(jiti@2.7.0))(typescript@5.9.3): - dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@10.13.0(jiti@2.7.0)) - enhanced-resolve: 5.26.0 - eslint: 10.13.0(jiti@2.7.0) - eslint-plugin-es-x: 7.8.0(eslint@10.13.0(jiti@2.7.0)) - get-tsconfig: 4.14.3 - globals: 15.15.0 - globrex: 0.1.2 - ignore: 5.3.2 - semver: 7.8.5 - ts-declaration-location: 1.0.7(typescript@5.9.3) - transitivePeerDependencies: - - typescript - - eslint-scope@9.1.2: - dependencies: - '@types/esrecurse': 4.3.1 - '@types/estree': 1.0.9 - esrecurse: 4.3.0 - estraverse: 5.3.0 - - eslint-visitor-keys@3.4.3: {} - - eslint-visitor-keys@4.2.1: {} - - eslint-visitor-keys@5.0.1: {} - - eslint@10.13.0(jiti@2.7.0): - dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@10.13.0(jiti@2.7.0)) - '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.23.5 - '@eslint/config-helpers': 0.7.0 - '@eslint/core': 1.2.1 - '@eslint/plugin-kit': 0.7.3 - '@humanfs/node': 0.16.8 - '@humanwhocodes/module-importer': 1.0.1 - '@humanwhocodes/retry': 0.4.3 - '@types/estree': 1.0.9 - ajv: 6.15.0 - cross-spawn: 7.0.6 - debug: 4.4.3 - escape-string-regexp: 4.0.0 - eslint-scope: 9.1.2 - eslint-visitor-keys: 5.0.1 - espree: 11.2.0 - esquery: 1.7.0 - esutils: 2.0.3 - fast-deep-equal: 3.1.3 - file-entry-cache: 11.1.5 - find-up: 5.0.0 - glob-parent: 6.0.2 - ignore: 5.3.2 - imurmurhash: 0.1.4 - is-glob: 4.0.3 - json-stable-stringify-without-jsonify: 1.0.1 - minimatch: 10.2.6 - natural-compare: 1.4.0 - optionator: 0.9.4 - optionalDependencies: - jiti: 2.7.0 - transitivePeerDependencies: - - supports-color - - espree@10.4.0: - dependencies: - acorn: 8.19.0 - acorn-jsx: 5.3.2(acorn@8.19.0) - eslint-visitor-keys: 4.2.1 - - espree@11.2.0: - dependencies: - acorn: 8.19.0 - acorn-jsx: 5.3.2(acorn@8.19.0) - eslint-visitor-keys: 5.0.1 - - esquery@1.7.0: - dependencies: - estraverse: 5.3.0 - - esrecurse@4.3.0: - dependencies: - estraverse: 5.3.0 - - estraverse@5.3.0: {} - - estree-walker@3.0.3: - dependencies: - '@types/estree': 1.0.9 - - esutils@2.0.3: {} - - expect-type@1.4.0: {} - - fast-deep-equal@3.1.3: {} - - fast-json-stable-stringify@2.1.0: {} - - fast-levenshtein@2.0.6: {} - - fdir@6.5.0(picomatch@4.0.7): - optionalDependencies: - picomatch: 4.0.7 - - file-entry-cache@11.1.5: - dependencies: - flat-cache: 6.1.23 - - find-up@5.0.0: - dependencies: - locate-path: 6.0.0 - path-exists: 4.0.0 - - flat-cache@6.1.23: - dependencies: - cacheable: 2.5.0 - flatted: 3.4.4 - hookified: 1.15.1 - - flatted@3.4.4: {} - - fsevents@2.3.3: - optional: true - - gensync@1.0.0-beta.2: {} - - get-tsconfig@4.14.3: - dependencies: - resolve-pkg-maps: 1.0.0 - - glob-parent@6.0.2: - dependencies: - is-glob: 4.0.3 - - globals@15.15.0: {} - - globals@17.13.0: {} - - globrex@0.1.2: {} - - goober@2.1.19(csstype@3.2.3): - dependencies: - csstype: 3.2.3 - - graceful-fs@4.2.11: {} - - hashery@1.5.1: - dependencies: - hookified: 1.15.1 - - hookified@1.15.1: {} - - hookified@2.2.0: {} - - html-encoding-sniffer@6.0.0(@noble/hashes@2.4.0): - dependencies: - '@exodus/bytes': 1.16.0(@noble/hashes@2.4.0) - transitivePeerDependencies: - - '@noble/hashes' - - http-proxy-agent@7.0.2: - dependencies: - agent-base: 7.1.4 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - - https-proxy-agent@7.0.6: - dependencies: - agent-base: 7.1.4 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - - ignore@5.3.2: {} - - ignore@7.0.12: {} - - imurmurhash@0.1.4: {} - - is-extglob@2.1.1: {} - - is-glob@4.0.3: - dependencies: - is-extglob: 2.1.1 - - is-potential-custom-element-name@1.0.1: {} - - isbot@5.2.2: {} - - isexe@2.0.0: {} - - jiti@2.7.0: {} - - jose@6.2.12: {} - - js-tokens@4.0.0: {} - - jsdom@28.1.0(@noble/hashes@2.4.0): - dependencies: - '@acemir/cssom': 0.9.32 - '@asamuzakjp/dom-selector': 6.8.1 - '@bramus/specificity': 2.4.3 - '@exodus/bytes': 1.16.0(@noble/hashes@2.4.0) - cssstyle: 6.2.0 - data-urls: 7.0.0(@noble/hashes@2.4.0) - decimal.js: 10.6.0 - html-encoding-sniffer: 6.0.0(@noble/hashes@2.4.0) - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 - is-potential-custom-element-name: 1.0.1 - parse5: 8.0.1 - saxes: 6.0.0 - symbol-tree: 3.2.4 - tough-cookie: 6.0.2 - undici: 7.30.1 - w3c-xmlserializer: 5.0.0 - webidl-conversions: 8.0.1 - whatwg-mimetype: 5.0.0 - whatwg-url: 16.0.1(@noble/hashes@2.4.0) - xml-name-validator: 5.0.0 - transitivePeerDependencies: - - '@noble/hashes' - - supports-color - - jsesc@3.1.0: {} - - json-schema-traverse@0.4.1: {} - - json-stable-stringify-without-jsonify@1.0.1: {} - - json5@2.2.3: {} - - keyv@5.6.0: - dependencies: - '@keyv/serialize': 1.1.1 - - kysely@0.29.6: {} - - launch-editor@2.14.2: - dependencies: - picocolors: 1.1.1 - shell-quote: 1.12.0 - - levn@0.4.1: - dependencies: - prelude-ls: 1.2.1 - type-check: 0.4.0 - - lightningcss-android-arm64@1.32.0: - optional: true - - lightningcss-darwin-arm64@1.32.0: - optional: true - - lightningcss-darwin-x64@1.32.0: - optional: true - - lightningcss-freebsd-x64@1.32.0: - optional: true - - lightningcss-linux-arm-gnueabihf@1.32.0: - optional: true - - lightningcss-linux-arm64-gnu@1.32.0: - optional: true - - lightningcss-linux-arm64-musl@1.32.0: - optional: true - - lightningcss-linux-x64-gnu@1.32.0: - optional: true - - lightningcss-linux-x64-musl@1.32.0: - optional: true - - lightningcss-win32-arm64-msvc@1.32.0: - optional: true - - lightningcss-win32-x64-msvc@1.32.0: - optional: true - - lightningcss@1.32.0: - dependencies: - detect-libc: 2.1.2 - optionalDependencies: - lightningcss-android-arm64: 1.32.0 - lightningcss-darwin-arm64: 1.32.0 - lightningcss-darwin-x64: 1.32.0 - lightningcss-freebsd-x64: 1.32.0 - lightningcss-linux-arm-gnueabihf: 1.32.0 - lightningcss-linux-arm64-gnu: 1.32.0 - lightningcss-linux-arm64-musl: 1.32.0 - lightningcss-linux-x64-gnu: 1.32.0 - lightningcss-linux-x64-musl: 1.32.0 - lightningcss-win32-arm64-msvc: 1.32.0 - lightningcss-win32-x64-msvc: 1.32.0 - - locate-path@6.0.0: - dependencies: - p-locate: 5.0.0 - - lru-cache@11.5.3: {} - - lru-cache@5.1.1: - dependencies: - yallist: 3.1.1 - - lucide-react@0.545.0(react@19.3.1): - dependencies: - react: 19.3.1 - - lz-string@1.5.0: {} - - magic-string@0.30.21: - dependencies: - '@jridgewell/sourcemap-codec': 1.6.0 - - magic-string@1.4.3: - dependencies: - '@jridgewell/sourcemap-codec': 1.6.0 - - mdn-data@2.27.1: {} - - minimatch@10.2.6: - dependencies: - brace-expansion: 5.0.12 - - ms@2.1.3: {} - - nanoid@3.3.20: {} - - nanostores@1.5.5: {} - - napi-postinstall@0.3.4: {} - - natural-compare@1.4.0: {} - - node-releases@2.0.59: {} - - obug@3.0.0: {} - - optionator@0.9.4: - dependencies: - deep-is: 0.1.4 - fast-levenshtein: 2.0.6 - levn: 0.4.1 - prelude-ls: 1.2.1 - type-check: 0.4.0 - word-wrap: 1.2.5 - - oxc-parser@0.120.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0): - dependencies: - '@oxc-project/types': 0.120.0 - optionalDependencies: - '@oxc-parser/binding-android-arm-eabi': 0.120.0 - '@oxc-parser/binding-android-arm64': 0.120.0 - '@oxc-parser/binding-darwin-arm64': 0.120.0 - '@oxc-parser/binding-darwin-x64': 0.120.0 - '@oxc-parser/binding-freebsd-x64': 0.120.0 - '@oxc-parser/binding-linux-arm-gnueabihf': 0.120.0 - '@oxc-parser/binding-linux-arm-musleabihf': 0.120.0 - '@oxc-parser/binding-linux-arm64-gnu': 0.120.0 - '@oxc-parser/binding-linux-arm64-musl': 0.120.0 - '@oxc-parser/binding-linux-ppc64-gnu': 0.120.0 - '@oxc-parser/binding-linux-riscv64-gnu': 0.120.0 - '@oxc-parser/binding-linux-riscv64-musl': 0.120.0 - '@oxc-parser/binding-linux-s390x-gnu': 0.120.0 - '@oxc-parser/binding-linux-x64-gnu': 0.120.0 - '@oxc-parser/binding-linux-x64-musl': 0.120.0 - '@oxc-parser/binding-openharmony-arm64': 0.120.0 - '@oxc-parser/binding-wasm32-wasi': 0.120.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) - '@oxc-parser/binding-win32-arm64-msvc': 0.120.0 - '@oxc-parser/binding-win32-ia32-msvc': 0.120.0 - '@oxc-parser/binding-win32-x64-msvc': 0.120.0 - transitivePeerDependencies: - - '@emnapi/core' - - '@emnapi/runtime' - - p-limit@3.1.0: - dependencies: - yocto-queue: 0.1.0 - - p-locate@5.0.0: - dependencies: - p-limit: 3.1.0 - - parse5@8.0.1: - dependencies: - entities: 8.1.0 - - path-exists@4.0.0: {} - - path-key@3.1.1: {} - - pathe@2.0.3: {} - - picocolors@1.1.1: {} - - picomatch@4.0.7: {} - - postcss-selector-parser@6.0.10: - dependencies: - cssesc: 3.0.0 - util-deprecate: 1.0.2 - - postcss@8.5.30: - dependencies: - nanoid: 3.3.20 - picocolors: 1.1.1 - source-map-js: 1.2.2 - - prelude-ls@1.2.1: {} - - prettier@3.9.10: {} - - pretty-format@27.5.1: - dependencies: - ansi-regex: 5.0.1 - ansi-styles: 5.2.0 - react-is: 17.0.2 - - punycode@2.3.1: {} - - qified@0.10.1: - dependencies: - hookified: 2.2.0 - - react-dom@19.3.1(react@19.3.1): - dependencies: - react: 19.3.1 - scheduler: 0.28.0 - - react-is@17.0.2: {} - - react-refresh@0.18.0: {} - - react@19.3.1: {} - - readdirp@5.1.1: {} - - require-from-string@2.0.2: {} - - resolve-pkg-maps@1.0.0: {} - - rollup@4.64.7: - dependencies: - '@types/estree': 1.0.9 - optionalDependencies: - '@napi-rs/lzma-linux-x64-gnu': 1.5.1 - '@rollup/rollup-android-arm-eabi': 4.64.7 - '@rollup/rollup-android-arm64': 4.64.7 - '@rollup/rollup-darwin-arm64': 4.64.7 - '@rollup/rollup-darwin-x64': 4.64.7 - '@rollup/rollup-freebsd-arm64': 4.64.7 - '@rollup/rollup-freebsd-x64': 4.64.7 - '@rollup/rollup-linux-arm-gnueabihf': 4.64.7 - '@rollup/rollup-linux-arm-musleabihf': 4.64.7 - '@rollup/rollup-linux-arm64-gnu': 4.64.7 - '@rollup/rollup-linux-arm64-musl': 4.64.7 - '@rollup/rollup-linux-loong64-gnu': 4.64.7 - '@rollup/rollup-linux-loong64-musl': 4.64.7 - '@rollup/rollup-linux-ppc64-gnu': 4.64.7 - '@rollup/rollup-linux-ppc64-musl': 4.64.7 - '@rollup/rollup-linux-riscv64-gnu': 4.64.7 - '@rollup/rollup-linux-riscv64-musl': 4.64.7 - '@rollup/rollup-linux-s390x-gnu': 4.64.7 - '@rollup/rollup-linux-x64-gnu': 4.64.7 - '@rollup/rollup-linux-x64-musl': 4.64.7 - '@rollup/rollup-openbsd-x64': 4.64.7 - '@rollup/rollup-openharmony-arm64': 4.64.7 - '@rollup/rollup-win32-arm64-msvc': 4.64.7 - '@rollup/rollup-win32-ia32-msvc': 4.64.7 - '@rollup/rollup-win32-x64-gnu': 4.64.7 - '@rollup/rollup-win32-x64-msvc': 4.64.7 - fsevents: 2.3.3 - - rou3@0.9.2: {} - - saxes@6.0.0: - dependencies: - xmlchars: 2.2.0 - - scheduler@0.28.0: {} - - semver@6.3.1: {} - - semver@7.8.5: {} - - seroval-plugins@1.6.9(seroval@1.6.9): - dependencies: - seroval: 1.6.9 - - seroval@1.6.9: {} - - set-cookie-parser@3.1.4: {} - - shebang-command@2.0.0: - dependencies: - shebang-regex: 3.0.0 - - shebang-regex@3.0.0: {} - - shell-quote@1.12.0: {} - - solid-js@1.9.17: - dependencies: - csstype: 3.2.3 - seroval: 1.6.9 - seroval-plugins: 1.6.9(seroval@1.6.9) - - source-map-js@1.2.2: {} - - stable-hash-x@0.2.0: {} - - std-env@4.3.0: {} - - symbol-tree@3.2.4: {} - - tailwindcss@4.3.3: {} - - tapable@2.3.3: {} - - tinybench@6.2.2: {} - - tinyexec@1.3.1: {} - - tinyglobby@0.2.17: - dependencies: - fdir: 6.5.0(picomatch@4.0.7) - picomatch: 4.0.7 - - tldts-core@7.4.18: {} - - tldts@7.4.18: - dependencies: - tldts-core: 7.4.18 - - tough-cookie@6.0.2: - dependencies: - tldts: 7.4.18 - - tr46@6.0.0: - dependencies: - punycode: 2.3.1 - - ts-api-utils@2.5.0(typescript@5.9.3): - dependencies: - typescript: 5.9.3 - - ts-declaration-location@1.0.7(typescript@5.9.3): - dependencies: - picomatch: 4.0.7 - typescript: 5.9.3 - - tsconfck@3.1.6(typescript@5.9.3): - optionalDependencies: - typescript: 5.9.3 - - tslib@2.8.1: - optional: true - - type-check@0.4.0: - dependencies: - prelude-ls: 1.2.1 - - typescript-eslint@8.71.2(eslint@10.13.0(jiti@2.7.0))(typescript@5.9.3): - dependencies: - '@typescript-eslint/eslint-plugin': 8.71.2(@typescript-eslint/parser@8.71.2(eslint@10.13.0(jiti@2.7.0))(typescript@5.9.3))(eslint@10.13.0(jiti@2.7.0))(typescript@5.9.3) - '@typescript-eslint/parser': 8.71.2(eslint@10.13.0(jiti@2.7.0))(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.71.2(typescript@5.9.3) - '@typescript-eslint/utils': 8.71.2(eslint@10.13.0(jiti@2.7.0))(typescript@5.9.3) - eslint: 10.13.0(jiti@2.7.0) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - typescript@5.9.3: {} - - undici-types@6.21.0: {} - - undici@7.30.1: {} - - unplugin@3.4.0(esbuild@0.28.2)(rollup@4.64.7)(vite@7.3.7(@types/node@22.20.5)(jiti@2.7.0)(lightningcss@1.32.0)): - dependencies: - '@jridgewell/remapping': 2.3.5 - picomatch: 4.0.7 - webpack-virtual-modules: 0.6.2 - optionalDependencies: - esbuild: 0.28.2 - rollup: 4.64.7 - vite: 7.3.7(@types/node@22.20.5)(jiti@2.7.0)(lightningcss@1.32.0) - - unrs-resolver@1.12.2: - dependencies: - napi-postinstall: 0.3.4 - optionalDependencies: - '@unrs/resolver-binding-android-arm-eabi': 1.12.2 - '@unrs/resolver-binding-android-arm64': 1.12.2 - '@unrs/resolver-binding-darwin-arm64': 1.12.2 - '@unrs/resolver-binding-darwin-x64': 1.12.2 - '@unrs/resolver-binding-freebsd-x64': 1.12.2 - '@unrs/resolver-binding-linux-arm-gnueabihf': 1.12.2 - '@unrs/resolver-binding-linux-arm-musleabihf': 1.12.2 - '@unrs/resolver-binding-linux-arm64-gnu': 1.12.2 - '@unrs/resolver-binding-linux-arm64-musl': 1.12.2 - '@unrs/resolver-binding-linux-loong64-gnu': 1.12.2 - '@unrs/resolver-binding-linux-loong64-musl': 1.12.2 - '@unrs/resolver-binding-linux-ppc64-gnu': 1.12.2 - '@unrs/resolver-binding-linux-riscv64-gnu': 1.12.2 - '@unrs/resolver-binding-linux-riscv64-musl': 1.12.2 - '@unrs/resolver-binding-linux-s390x-gnu': 1.12.2 - '@unrs/resolver-binding-linux-x64-gnu': 1.12.2 - '@unrs/resolver-binding-linux-x64-musl': 1.12.2 - '@unrs/resolver-binding-openharmony-arm64': 1.12.2 - '@unrs/resolver-binding-wasm32-wasi': 1.12.2 - '@unrs/resolver-binding-win32-arm64-msvc': 1.12.2 - '@unrs/resolver-binding-win32-ia32-msvc': 1.12.2 - '@unrs/resolver-binding-win32-x64-msvc': 1.12.2 - - update-browserslist-db@1.3.4(browserslist@4.29.3): - dependencies: - browserslist: 4.29.3 - escalade: 3.2.0 - picocolors: 1.1.1 - - uri-js@4.4.1: - dependencies: - punycode: 2.3.1 - - use-sync-external-store@1.7.0(react@19.3.1): - dependencies: - react: 19.3.1 - - util-deprecate@1.0.2: {} - - vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@7.3.7(@types/node@22.20.5)(jiti@2.7.0)(lightningcss@1.32.0)): - dependencies: - debug: 4.4.3 - globrex: 0.1.2 - tsconfck: 3.1.6(typescript@5.9.3) - optionalDependencies: - vite: 7.3.7(@types/node@22.20.5)(jiti@2.7.0)(lightningcss@1.32.0) - transitivePeerDependencies: - - supports-color - - typescript - - vite@7.3.7(@types/node@22.20.5)(jiti@2.7.0)(lightningcss@1.32.0): - dependencies: - esbuild: 0.28.2 - fdir: 6.5.0(picomatch@4.0.7) - picomatch: 4.0.7 - postcss: 8.5.30 - rollup: 4.64.7 - tinyglobby: 0.2.17 - optionalDependencies: - '@types/node': 22.20.5 - fsevents: 2.3.3 - jiti: 2.7.0 - lightningcss: 1.32.0 - - vitest@5.0.5(@types/node@22.20.5)(jsdom@28.1.0(@noble/hashes@2.4.0))(vite@7.3.7(@types/node@22.20.5)(jiti@2.7.0)(lightningcss@1.32.0)): - dependencies: - '@types/chai': 5.2.3 - '@vitest/mocker': 5.0.5(vite@7.3.7(@types/node@22.20.5)(jiti@2.7.0)(lightningcss@1.32.0)) - chai: 6.3.0 - es-module-lexer: 2.3.2 - expect-type: 1.4.0 - magic-string: 1.4.3 - obug: 3.0.0 - picomatch: 4.0.7 - std-env: 4.3.0 - tinybench: 6.2.2 - tinyexec: 1.3.1 - tinyglobby: 0.2.17 - vite: 7.3.7(@types/node@22.20.5)(jiti@2.7.0)(lightningcss@1.32.0) - why-is-node-running: 3.2.1 - optionalDependencies: - '@types/node': 22.20.5 - jsdom: 28.1.0(@noble/hashes@2.4.0) - transitivePeerDependencies: - - msw - - vue-eslint-parser@10.4.1(eslint@10.13.0(jiti@2.7.0)): - dependencies: - debug: 4.4.3 - eslint: 10.13.0(jiti@2.7.0) - eslint-scope: 9.1.2 - eslint-visitor-keys: 5.0.1 - espree: 11.2.0 - esquery: 1.7.0 - semver: 7.8.5 - transitivePeerDependencies: - - supports-color - - w3c-xmlserializer@5.0.0: - dependencies: - xml-name-validator: 5.0.0 - - webidl-conversions@8.0.1: {} - - webpack-virtual-modules@0.6.2: {} - - whatwg-mimetype@5.0.0: {} - - whatwg-url@16.0.1(@noble/hashes@2.4.0): - dependencies: - '@exodus/bytes': 1.16.0(@noble/hashes@2.4.0) - tr46: 6.0.0 - webidl-conversions: 8.0.1 - transitivePeerDependencies: - - '@noble/hashes' - - which@2.0.2: - dependencies: - isexe: 2.0.0 - - why-is-node-running@3.2.1: {} - - word-wrap@1.2.5: {} - - ws@8.22.0: {} - - xml-name-validator@5.0.0: {} - - xmlchars@2.2.0: {} - - yallist@3.1.1: {} - - yocto-queue@0.1.0: {} - - zod@4.6.7: {} diff --git a/web-interface/prettier.config.js b/web-interface/prettier.config.js deleted file mode 100644 index f7279f7..0000000 --- a/web-interface/prettier.config.js +++ /dev/null @@ -1,10 +0,0 @@ -// @ts-check - -/** @type {import('prettier').Config} */ -const config = { - semi: false, - singleQuote: true, - trailingComma: 'all', -} - -export default config diff --git a/web-interface/public/favicon.ico b/web-interface/public/favicon.ico deleted file mode 100644 index bd48292..0000000 Binary files a/web-interface/public/favicon.ico and /dev/null differ diff --git a/web-interface/public/favicon.svg b/web-interface/public/favicon.svg deleted file mode 100644 index a526ef2..0000000 --- a/web-interface/public/favicon.svg +++ /dev/null @@ -1,150 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - M - - diff --git a/web-interface/public/fonts/OFL.txt b/web-interface/public/fonts/OFL.txt deleted file mode 100644 index e271172..0000000 --- a/web-interface/public/fonts/OFL.txt +++ /dev/null @@ -1,93 +0,0 @@ -Copyright 2018 The Manrope Project Authors (https://github.com/googlefonts/manrope) - -This Font Software is licensed under the SIL Open Font License, Version 1.1. -This license is copied below, and is also available with a FAQ at: -http://scripts.sil.org/OFL - - ------------------------------------------------------------ -SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 ------------------------------------------------------------ - -PREAMBLE -The goals of the Open Font License (OFL) are to stimulate worldwide -development of collaborative font projects, to support the font creation -efforts of academic and linguistic communities, and to provide a free and -open framework in which fonts may be shared and improved in partnership -with others. - -The OFL allows the licensed fonts to be used, studied, modified and -redistributed freely as long as they are not sold by themselves. The -fonts, including any derivative works, can be bundled, embedded, -redistributed and/or sold with any software provided that any reserved -names are not used by derivative works. The fonts and derivatives, -however, cannot be released under any other type of license. The -requirement for fonts to remain under this license does not apply -to any document created using the fonts or their derivatives. - -DEFINITIONS -"Font Software" refers to the set of files released by the Copyright -Holder(s) under this license and clearly marked as such. This may -include source files, build scripts and documentation. - -"Reserved Font Name" refers to any names specified as such after the -copyright statement(s). - -"Original Version" refers to the collection of Font Software components as -distributed by the Copyright Holder(s). - -"Modified Version" refers to any derivative made by adding to, deleting, -or substituting -- in part or in whole -- any of the components of the -Original Version, by changing formats or by porting the Font Software to a -new environment. - -"Author" refers to any designer, engineer, programmer, technical -writer or other person who contributed to the Font Software. - -PERMISSION & CONDITIONS -Permission is hereby granted, free of charge, to any person obtaining -a copy of the Font Software, to use, study, copy, merge, embed, modify, -redistribute, and sell modified and unmodified copies of the Font -Software, subject to the following conditions: - -1) Neither the Font Software nor any of its individual components, -in Original or Modified Versions, may be sold by itself. - -2) Original or Modified Versions of the Font Software may be bundled, -redistributed and/or sold with any software, provided that each copy -contains the above copyright notice and this license. These can be -included either as stand-alone text files, human-readable headers or -in the appropriate machine-readable metadata fields within text or -binary files as long as those fields can be easily viewed by the user. - -3) No Modified Version of the Font Software may use the Reserved Font -Name(s) unless explicit written permission is granted by the corresponding -Copyright Holder. This restriction only applies to the primary font name as -presented to the users. - -4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font -Software shall not be used to promote, endorse or advertise any -Modified Version, except to acknowledge the contribution(s) of the -Copyright Holder(s) and the Author(s) or with their explicit written -permission. - -5) The Font Software, modified or unmodified, in part or in whole, -must be distributed entirely under this license, and must not be -distributed under any other license. The requirement for fonts to -remain under this license does not apply to any document created -using the Font Software. - -TERMINATION -This license becomes null and void if any of the above conditions are -not met. - -DISCLAIMER -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT -OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE -COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL -DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM -OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/web-interface/public/fonts/manrope-latin.woff2 b/web-interface/public/fonts/manrope-latin.woff2 deleted file mode 100644 index 71eb731..0000000 Binary files a/web-interface/public/fonts/manrope-latin.woff2 and /dev/null differ diff --git a/web-interface/public/logo192.png b/web-interface/public/logo192.png deleted file mode 100644 index 51ffbf3..0000000 Binary files a/web-interface/public/logo192.png and /dev/null differ diff --git a/web-interface/public/logo512.png b/web-interface/public/logo512.png deleted file mode 100644 index b3bf8bb..0000000 Binary files a/web-interface/public/logo512.png and /dev/null differ diff --git a/web-interface/public/manifest.json b/web-interface/public/manifest.json deleted file mode 100644 index 7edbebb..0000000 --- a/web-interface/public/manifest.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "short_name": "Web Interface", - "name": "UTMIST Compute Platform Web Interface", - "icons": [ - { - "src": "favicon.ico", - "sizes": "64x64 32x32 24x24 16x16", - "type": "image/x-icon" - }, - { - "src": "logo192.png", - "type": "image/png", - "sizes": "192x192" - }, - { - "src": "logo512.png", - "type": "image/png", - "sizes": "512x512" - } - ], - "start_url": ".", - "display": "standalone", - "theme_color": "#000000", - "background_color": "#ffffff" -} diff --git a/web-interface/public/robots.txt b/web-interface/public/robots.txt deleted file mode 100644 index e9e57dc..0000000 --- a/web-interface/public/robots.txt +++ /dev/null @@ -1,3 +0,0 @@ -# https://www.robotstxt.org/robotstxt.html -User-agent: * -Disallow: diff --git a/web-interface/public/sample-avatar.png b/web-interface/public/sample-avatar.png deleted file mode 100755 index 1186d55..0000000 Binary files a/web-interface/public/sample-avatar.png and /dev/null differ diff --git a/web-interface/src/api.ts b/web-interface/src/api.ts deleted file mode 100644 index ddfeda9..0000000 --- a/web-interface/src/api.ts +++ /dev/null @@ -1,331 +0,0 @@ -import type { Member } from '#/auth.tsx' - -export type Compute = 'cpu' | 'nvidia' | 'tenstorrent' - -export type Job = { - id: string - name: string - created: string - job_state: 'Scheduled' | 'InProgress' | 'Success' | 'Failure' | 'Cancelled' - accelerator: Compute - device_count: number - image: string - cpu: string - memory: string - node?: string - exit_code?: number - message?: string - checkpoint_directory: string - output_directory?: string - working_directory?: string - timeout_seconds?: number - team_id?: string - creator_id?: string - creator_name?: string - storage_scope?: string - can_cancel?: boolean -} - -export type Submission = { - name?: string - dataset_id?: string - type: 'command' | 'training-smoke' - accelerator: Compute - device_count?: number - script?: string - script_name?: string - image?: string - cpu?: string - memory?: string - timeout_seconds?: number - command?: string[] - args?: string[] - env?: Record - working_directory?: string - storage_scope?: string - tt_runtime?: 'host' | 'container' -} - -export type MachineHardware = { - name: string - address: string - ready: boolean - schedulable: boolean - cpu_allocatable: string - memory_allocatable: string -} - -export type AcceleratorPool = { - accelerator: Exclude - node: string - unit: string - total: number - allocated: number - available: number | null - ready: boolean - chips_per_device?: number - message?: string -} - -export type HardwareSnapshot = { - observed_at: string - machines: MachineHardware[] - pools: AcceleratorPool[] -} - -export type ImageCatalog = { - self_service?: boolean - teams_enabled?: boolean - registries?: string[] - images: { reference: string; accelerators: Compute[] }[] - profiles: { - accelerator: Compute - default_image: string - device_unit: string - max_devices: number - note?: string - }[] -} - -const base = (import.meta.env.VITE_API_URL ?? '/api').replace(/\/$/, '') -let activeTeam = '' -export function setActiveTeam(id: string) { - activeTeam = id -} -function teamQuery(): Record { - return activeTeam ? { team_id: activeTeam } : {} -} - -async function request(path: string, options?: RequestInit): Promise { - const requestTeam = activeTeam - const response = await fetch(`${base}${path}`, { - ...options, - headers: { - 'Content-Type': 'application/json', - ...(requestTeam ? { 'X-Mist-Team': requestTeam } : {}), - ...options?.headers, - }, - }) - if (response.status === 401 && path !== '/session') - window.dispatchEvent(new Event('mist:sign-in-required')) - if ( - response.status === 403 && - requestTeam && - (!options?.method || options.method === 'GET') && - !path.startsWith('/teams') - ) - window.dispatchEvent( - new CustomEvent('mist:workspace-access-revoked', { detail: requestTeam }), - ) - let body - try { - body = await response.json() - } catch { - throw new Error( - `Mist API returned HTTP ${response.status}. Check the API connection.`, - ) - } - if (!response.ok) - throw new Error(body.error ?? `Request failed (${response.status})`) - return body as T -} - -export const jobsAPI = { - session: (signal?: AbortSignal) => - request<{ enabled: boolean; user: Member | null }>('/session', { signal }), - list: (signal?: AbortSignal) => - request<{ jobs: Job[]; count: number }>('/jobs', { signal }), - hardware: (signal?: AbortSignal) => - request('/hardware', { signal }), - images: (signal?: AbortSignal) => - request('/images', { signal }), - submit: (submission: Submission) => - request<{ job_id: string; job: Job }>('/jobs', { - method: 'POST', - body: JSON.stringify(submission), - }), - cancel: (id: string) => - request(`/jobs/${encodeURIComponent(id)}/cancel`, { method: 'POST' }), - logs: (id: string, signal?: AbortSignal) => - request<{ logs: string }>(`/jobs/${encodeURIComponent(id)}/logs`, { - signal, - }), -} - -export type Dataset = { - id: string - name: string - filename: string - size: number - files: number - sha256: string - created: string - state: string - team_id?: string - team_name?: string - scope?: string - member_name?: string - shared?: boolean -} -export type ResultFile = { path: string; size: number; modified: string } -export const storageAPI = { - list: (signal?: AbortSignal) => - request<{ datasets: Dataset[]; upload_limit_bytes: number }>('/datasets', { - signal, - }), - info: (signal?: AbortSignal) => - request<{ - enabled: boolean - capacity_bytes: number - available_bytes: number - model_capacity_bytes?: number - model_available_bytes?: number - storage_mode?: 'shared-folders' - storage_limits_enforced?: boolean - dataset_used_bytes?: number - model_used_bytes?: number - usage_observed_at?: string - usage_stale?: boolean - storage_low?: boolean - dataset_allocation_gib?: number - model_allocation_gib?: number - upload_limit_bytes: number - }>('/storage', { signal }), - remove: (id: string) => - request(`/datasets/${encodeURIComponent(id)}`, { method: 'DELETE' }), - files: (id: string, signal?: AbortSignal) => - request<{ files: ResultFile[] }>(`/jobs/${encodeURIComponent(id)}/files`, { - signal, - }), - downloadURL: (id: string, path: string) => - `${base}/jobs/${encodeURIComponent(id)}/files/download?${new URLSearchParams({ path, ...teamQuery() })}`, - datasetFiles: (id: string, signal?: AbortSignal) => - request<{ files: ResultFile[] }>( - `/datasets/${encodeURIComponent(id)}/files`, - { signal }, - ), - datasetDownloadURL: (id: string, path: string) => - `${base}/datasets/${encodeURIComponent(id)}/files/download?${new URLSearchParams({ path, ...teamQuery() })}`, - folders: (signal?: AbortSignal) => - request<{ folders: StorageFolder[] }>('/storage/folders', { signal }), - folderFiles: (folder: StorageFolder, signal?: AbortSignal) => - request<{ files: ResultFile[] }>( - `/storage/files?${new URLSearchParams({ source_team: folder.team_id, scope: folder.scope })}`, - { signal }, - ), - folderDownloadURL: (folder: StorageFolder, path: string) => - `${base}/storage/files?${new URLSearchParams({ source_team: folder.team_id, scope: folder.scope, path, download: 'true', ...teamQuery() })}`, - upload: ( - file: File, - name: string, - zip: boolean, - progress: (value: number) => void, - scope?: string, - signal?: AbortSignal, - ) => - new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest() - const abort = () => xhr.abort() - if (signal?.aborted) { - reject(new Error('Upload cancelled')) - return - } - signal?.addEventListener('abort', abort, { once: true }) - xhr.onloadend = () => signal?.removeEventListener('abort', abort) - xhr.onabort = () => reject(new Error('Upload cancelled')) - xhr.open( - 'POST', - `${base}/datasets?${new URLSearchParams({ filename: file.name, name, ...(scope ? { scope } : {}), ...(zip ? { format: 'zip' } : {}) })}`, - ) - xhr.timeout = 24 * 60 * 60 * 1000 - if (activeTeam) xhr.setRequestHeader('X-Mist-Team', activeTeam) - xhr.upload.onprogress = (e) => { - if (e.lengthComputable) progress(Math.round((100 * e.loaded) / e.total)) - } - xhr.onerror = () => reject(new Error('Upload connection failed')) - xhr.ontimeout = () => reject(new Error('Upload timed out')) - xhr.onload = () => { - if (xhr.status === 401) - window.dispatchEvent(new Event('mist:sign-in-required')) - try { - const body = JSON.parse(xhr.responseText) - if (xhr.status >= 200 && xhr.status < 300) resolve(body) - else reject(new Error(body.error ?? `Upload failed (${xhr.status})`)) - } catch { - reject(new Error('Invalid upload response')) - } - } - xhr.send(file) - }), -} - -export type TeamPolicy = { - cpu: string - memory: string - nvidia: number - tenstorrent: number - concurrent: number - queued: number - storage_gib: number - model_storage_gib: number - runtime_seconds: number - registries: string[] -} -export type TeamMember = { - id: string - name: string - email: string - common_writer: boolean -} -export type StorageGrant = { id: string; target_team: string; scope: string } -export type Team = { - id: string - name: string - disabled: boolean - policy: TeamPolicy - members: TeamMember[] - grants: StorageGrant[] -} -export type StorageFolder = { - team_id: string - team_name: string - scope: string - name: string - shared: boolean - writable: boolean -} -export const teamsAPI = { - list: (signal?: AbortSignal) => - request<{ teams: Team[]; storage_budget_gib: number }>('/teams', { - signal, - }), - create: (name: string, storage_gib = 200, model_storage_gib = 500) => - request('/teams', { - method: 'POST', - body: JSON.stringify({ name, storage_gib, model_storage_gib }), - }), - update: ( - id: string, - change: { name?: string; disabled?: boolean; policy?: TeamPolicy }, - ) => - request(`/teams/${id}`, { - method: 'PATCH', - body: JSON.stringify(change), - }), - enroll: (id: string, user_id: string, common_writer: boolean) => - request(`/teams/${id}/members`, { - method: 'POST', - body: JSON.stringify({ user_id, common_writer }), - }), - removeMember: (id: string, user: string) => - request(`/teams/${id}/members/${encodeURIComponent(user)}`, { - method: 'DELETE', - }), - grant: (id: string, target_team: string, scope: string) => - request(`/teams/${id}/grants`, { - method: 'POST', - body: JSON.stringify({ target_team, scope }), - }), - revoke: (id: string, grant: string) => - request(`/teams/${id}/grants/${grant}`, { method: 'DELETE' }), -} diff --git a/web-interface/src/auth.tsx b/web-interface/src/auth.tsx deleted file mode 100644 index 546b417..0000000 --- a/web-interface/src/auth.tsx +++ /dev/null @@ -1,130 +0,0 @@ -import { createContext, useContext, useState, useEffect } from 'react' -import type { ReactNode } from 'react' -import { createAuthClient } from 'better-auth/react' -import { adminClient } from 'better-auth/client/plugins' -import { jobsAPI } from '#/api.ts' -import { errorMessage, usePolling } from '#/hooks/usePolling.ts' -import { BackendConnectionError } from '#/components/BackendConnectionError.tsx' - -export const authClient = createAuthClient({ - basePath: '/auth', - plugins: [adminClient()], -}) -export type Member = { - id: string - name: string - email: string - role: string -} -const AccountContext = createContext<{ - user: Member | null - enabled: boolean - refresh: () => void -}>({ user: null, enabled: false, refresh: () => {} }) -export const useAccount = () => useContext(AccountContext) - -function Login({ refresh }: { refresh: () => void }) { - const [email, setEmail] = useState('') - const [password, setPassword] = useState('') - const [error, setError] = useState('') - const [busy, setBusy] = useState(false) - return ( -
-

MIST / RESEARCH COMPUTE

-

Sign in to Mist

-

- Run jobs on the team’s compute machines. Ask a Mist administrator for an - account. -

-
{ - e.preventDefault() - setBusy(true) - setError('') - try { - const result = await authClient.signIn.email({ email, password }) - if (result.error) throw new Error(result.error.message) - setPassword('') - refresh() - } catch (err) { - setError(errorMessage(err)) - } finally { - setBusy(false) - } - }} - > -
- - setEmail(e.target.value)} - /> -
-
- - setPassword(e.target.value)} - /> -
- {error && ( -

- {error} -

- )} - -
-
- ) -} -export function AccountGate({ children }: { children: ReactNode }) { - const session = usePolling(jobsAPI.session, 60000) - useEffect(() => { - window.addEventListener('mist:sign-in-required', session.refresh) - return () => - window.removeEventListener('mist:sign-in-required', session.refresh) - }, [session.refresh]) - if (session.loading && !session.data) - return ( -

- Loading account… -

- ) - if (session.error) - return ( - - ) - if (session.data?.enabled && !session.data.user) - return - return ( - - {children} - - ) -} diff --git a/web-interface/src/components/AccountPage.tsx b/web-interface/src/components/AccountPage.tsx deleted file mode 100644 index 466cdd8..0000000 --- a/web-interface/src/components/AccountPage.tsx +++ /dev/null @@ -1,428 +0,0 @@ -import { Picker } from '#/components/Picker.tsx' -import { Modal } from '#/components/Modal.tsx' -import { Tabs } from '#/components/Tabs.tsx' -import { useState } from 'react' -import { Users, KeyRound, Plus, Search } from 'lucide-react' -import { PanelHeading } from '#/components/Card.tsx' -import { authClient, useAccount } from '#/auth.tsx' -import { errorMessage, usePolling } from '#/hooks/usePolling.ts' - -const field = 'border rounded p-2 w-full' -function PasswordReset({ - users, - initialUserId = '', -}: { - initialUserId?: string - users: { id: string; name: string; email: string }[] -}) { - const [userId, setUserId] = useState(initialUserId) - const [password, setPassword] = useState('') - const [message, setMessage] = useState('') - const [busy, setBusy] = useState(false) - return ( -
{ - e.preventDefault() - setBusy(true) - setMessage('') - try { - const reset = await authClient.admin.setUserPassword({ - userId, - newPassword: password, - }) - if (reset.error) throw new Error(reset.error.message) - const revoke = await authClient.admin.revokeUserSessions({ userId }) - if (revoke.error) - throw new Error( - `Password changed; session revocation needs retry: ${revoke.error.message}`, - ) - setPassword('') - setMessage( - 'Password reset and existing sessions signed out. Share the new password privately.', - ) - } catch (err) { - setMessage(errorMessage(err)) - } finally { - setBusy(false) - } - }} - > -
- - setUserId(e.target.value)} - > - - {users.map((u) => ( - - ))} - -
-
- - setPassword(e.target.value)} - /> -
- - {message &&

{message}

} -
- ) -} -async function listMembers() { - const result = await authClient.admin.listUsers({ query: { limit: 1000 } }) - if (result.error) throw new Error(result.error.message) - return result.data -} -function Members() { - const members = usePolling(listMembers, 30000) - const [name, setName] = useState(''), - [email, setEmail] = useState(''), - [password, setPassword] = useState(''), - [error, setError] = useState(''), - [busy, setBusy] = useState(false) - const [creating, setCreating] = useState(false) - const [resetUser, setResetUser] = useState(null) - const [search, setSearch] = useState('') - const [page, setPage] = useState(1) - const filtered = (members.data?.users ?? []).filter((member) => - `${member.name} ${member.email}` - .toLowerCase() - .includes(search.toLowerCase()), - ) - const totalPages = Math.max(1, Math.ceil(filtered.length / 10)) - const currentPage = Math.min(page, totalPages) - const visibleMembers = filtered.slice( - (currentPage - 1) * 10, - currentPage * 10, - ) - function closeCreate() { - setCreating(false) - setName('') - setEmail('') - setPassword('') - setError('') - } - return ( -
- } - action={ - - } - /> - {creating && ( - -
{ - e.preventDefault() - setBusy(true) - setError('') - try { - const result = await authClient.admin.createUser({ - name, - email, - password, - role: 'user', - }) - if (result.error) throw new Error(result.error.message) - setName('') - setEmail('') - setPassword('') - members.refresh() - setCreating(false) - } catch (err) { - setError(errorMessage(err)) - } finally { - setBusy(false) - } - }} - > -
- - setName(e.target.value)} - /> -
-
- - setEmail(e.target.value)} - /> -
-
- - setPassword(e.target.value)} - /> -
- -

- Share the initial password privately. Members can change it after - signing in. -

- {error &&

{error}

} -
-
- )} -
- - {members.data?.total ?? 0} accounts -
- {(error || members.error) && ( -

- {error || members.error} -

- )} -
    - {visibleMembers.map((member) => ( -
  • -
    - {member.name} - {member.email} -
    - - {member.role === 'admin' ? 'Admin' : 'Member'} - -
    - -
    -
  • - ))} -
- {!members.loading && !filtered.length && ( -

- {search ? 'No members match your search.' : 'No members yet.'} -

- )} - {members.loading && !members.data && ( -

- Loading members… -

- )} - {totalPages > 1 && ( -
- {filtered.length} accounts -
- - - Page {currentPage} of {totalPages} - - -
-
- )} - - {resetUser !== null && ( - setResetUser(null)}> - - - )} -
- ) -} -export function AccountPage() { - const account = useAccount() - const [tab, setTab] = useState('personal') - const isAdmin = account.user?.role.split(',').includes('admin') - const [current, setCurrent] = useState(''), - [next, setNext] = useState(''), - [message, setMessage] = useState(''), - [busy, setBusy] = useState(false) - if (!account.enabled) - return

Member login is disabled in this local pilot.

- return ( -
-
-
-

- {isAdmin ? 'Administrator & teammate' : 'Your workspace'} -

-

Account

-

- {account.user?.name} · {account.user?.email} -

-
-
- {isAdmin && ( - - )} - - {isAdmin && ( - - )} -
- ) -} diff --git a/web-interface/src/components/BackendConnectionError.test.tsx b/web-interface/src/components/BackendConnectionError.test.tsx deleted file mode 100644 index d074456..0000000 --- a/web-interface/src/components/BackendConnectionError.test.tsx +++ /dev/null @@ -1,76 +0,0 @@ -// @vitest-environment jsdom -import { afterEach, expect, test, vi } from 'vitest' -import { cleanup, fireEvent, render, screen } from '@testing-library/react' -import { BackendConnectionError } from './BackendConnectionError' - -afterEach(cleanup) - -test('renders connection error message and status badge', () => { - render( - , - ) - - const heading = screen.getByRole('heading', { level: 1 }) - expect(heading.textContent).toContain('Unable to reach the compute backend') - expect( - screen.getByText('Failed to fetch /api/session: connection refused'), - ).toBeTruthy() - expect(screen.getByText('Backend Offline')).toBeTruthy() -}) - -test('triggers onRetry when Try again button is clicked', () => { - const onRetry = vi.fn() - render( - , - ) - - const retryButton = screen.getByRole('button', { - name: 'Try again to connect to the backend', - }) - expect(retryButton).toBeTruthy() - expect(retryButton.textContent).toContain('Try again') - - fireEvent.click(retryButton) - expect(onRetry).toHaveBeenCalledTimes(1) -}) - -test('disables button and displays loading state when isRetrying is true', () => { - render( - {}} - isRetrying={true} - />, - ) - - const retryButton = screen.getByRole('button', { - name: 'Try again to connect to the backend', - }) - expect(retryButton.hasAttribute('disabled')).toBe(true) - expect(retryButton.textContent).toContain('Checking connection…') -}) - -test('provides links to static documentation and GitHub issues', () => { - render( - {}} - />, - ) - - const docsLink = screen.getByRole('link', { - name: /Documentation.*View documentation/s, - }) - expect(docsLink.getAttribute('href')).toBe('/docs/') - - const githubLink = screen.getByRole('link', { - name: /Report an Issue.*Open GitHub issues/s, - }) - expect(githubLink.getAttribute('href')).toBe( - 'https://github.com/UTMIST/Mist/issues', - ) - expect(githubLink.getAttribute('target')).toBe('_blank') -}) diff --git a/web-interface/src/components/BackendConnectionError.tsx b/web-interface/src/components/BackendConnectionError.tsx deleted file mode 100644 index 347cec4..0000000 --- a/web-interface/src/components/BackendConnectionError.tsx +++ /dev/null @@ -1,241 +0,0 @@ -import { useState } from 'react' -import { - AlertCircle, - BookOpen, - ExternalLink, - Github, - RefreshCw, -} from 'lucide-react' - -export interface BackendConnectionErrorProps { - error?: string - onRetry?: () => void | Promise - isRetrying?: boolean -} - -export function BackendConnectionError({ - error = 'Unable to establish connection to the Mist API service.', - onRetry, - isRetrying: externalRetrying, -}: BackendConnectionErrorProps) { - const [internalRetrying, setInternalRetrying] = useState(false) - const isRetrying = externalRetrying ?? internalRetrying - - async function handleRetry() { - if (isRetrying || !onRetry) return - setInternalRetrying(true) - try { - await onRetry() - } finally { - setInternalRetrying(false) - } - } - - return ( -
-
- {/* Brand header & status badge */} -
-
- -
- - Mist - - Research Compute -
-
- -
-
-
- - {/* Primary Message */} -
-

SYSTEM STATUS / API DISCONNECTED

-

- Unable to reach the compute backend -

-

- The web portal is running, but cannot communicate with the Mist API - or account service. The backend server may be restarting, undergoing - maintenance, or temporarily unreachable over the private network. -

-
- - {/* Error diagnostics banner */} -
-
-
-
- - {/* Action button */} -
- - - Automatic retries happen in the background. - -
- - {/* Informational Cards: Docs & GitHub */} -
- {/* Static Documentation link */} - -
-
- - - Static & Active - -
-

- Documentation - -

-

- The static documentation site remains unaffected and fully - accessible even when the compute backend is down. Browse - operating guides, architecture, and setup manuals. -

-
-
- View documentation - -
-
- - {/* GitHub Repository Issue link */} - -
-
- - - GitHub - -
-

- Report an Issue -

-

- Notice an unexpected service outage or API bug? Submit a report - or check active incidents on the UTMIST GitHub repository. -

-
-
- Open GitHub issues -
-
-
- - {/* Troubleshooting checklist */} -
-

- Troubleshooting checklist -

-
    -
  • - Private mesh: Verify that your Tailscale client - is connected to the UTMIST tailnet. -
  • -
  • - Local dev: Ensure backend server is active at{' '} - - http://127.0.0.1:3000 - - . -
  • -
  • - Cluster admin: Check service logs with{' '} - - kubectl -n mist-system get pods - - . -
  • -
-
-
-
- ) -} diff --git a/web-interface/src/components/Buttons.tsx b/web-interface/src/components/Buttons.tsx deleted file mode 100644 index 07d334b..0000000 --- a/web-interface/src/components/Buttons.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import type { ReactNode } from 'react' - -const variantStyles = { - success: 'bg-green-200 text-green-800 hover:bg-green-300', - warning: 'bg-yellow-200 text-yellow-800 hover:bg-yellow-300', - danger: 'bg-red-200 text-red-800 hover:bg-red-300', - normal: 'bg-gray-100 text-gray-800 hover:bg-gray-200', - disabled: 'bg-gray-200 text-gray-800', -} - -const fontSizeStyles = { - xs: 'text-xs', - sm: 'text-sm', - base: 'text-base', - lg: 'text-lg', - xl: 'text-xl', -} - -type ButtonVariant = keyof typeof variantStyles -type ButtonFontSize = keyof typeof fontSizeStyles - -export function Button({ - children, - onClick, - variant, - fontSize, -}: { - children: ReactNode - onClick: () => void - variant: ButtonVariant - fontSize: ButtonFontSize -}) { - return ( - - ) -} diff --git a/web-interface/src/components/Card.tsx b/web-interface/src/components/Card.tsx deleted file mode 100644 index c5ce615..0000000 --- a/web-interface/src/components/Card.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import { Link } from '@tanstack/react-router' -import type { ReactNode } from 'react' - -export function PanelHeading({ - title, - description, - icon, - action, -}: { - title: string - description?: string - icon: ReactNode - action?: ReactNode -}) { - return ( -
- -
-

{title}

- {description &&

{description}

} -
- {action} -
- ) -} - -export function CardInfoField({ - label, - value, - link, -}: { - label: string - value: string - link?: string -}) { - return ( -
-
{label}
- {link ? ( - - {value} - - ) : ( -
{value}
- )} -
- ) -} - -export function CardHeader({ - header, - children, -}: { - header: string - children: ReactNode -}) { - return ( -
-

{header}

-
- {/* Actions */} - {children} -
-
- ) -} - -export default function Card({ - children, - className = '', -}: { - children: ReactNode - className?: string -}) { - return ( -
- {children} -
- ) -} diff --git a/web-interface/src/components/Controls.test.tsx b/web-interface/src/components/Controls.test.tsx deleted file mode 100644 index c0fca52..0000000 --- a/web-interface/src/components/Controls.test.tsx +++ /dev/null @@ -1,88 +0,0 @@ -// @vitest-environment jsdom -import { afterEach, expect, test, vi } from 'vitest' -import { cleanup, fireEvent, render, screen } from '@testing-library/react' -import { useState } from 'react' -import { Picker } from './Picker' -import { Tabs } from './Tabs' - -afterEach(cleanup) - -test('picker filters a long account list and selects by keyboard', () => { - const change = vi.fn() - render( - - {Array.from({ length: 30 }, (_, i) => ( - - ))} - , - ) - const trigger = screen.getByRole('combobox', { name: 'Account' }) - fireEvent.click(trigger) - const search = screen.getByRole('combobox', { name: 'Search Account' }) - expect(document.activeElement).toBe(search) - fireEvent.change(search, { target: { value: 'Researcher 29' } }) - expect(screen.getAllByRole('option')).toHaveLength(1) - fireEvent.keyDown(search, { key: 'Enter' }) - expect(change).toHaveBeenCalledWith({ target: { value: '29' } }) - expect(screen.queryByRole('listbox')).toBeNull() - expect(document.activeElement).toBe(trigger) -}) - -test('empty search cannot select an unrelated option; Escape returns focus', () => { - const change = vi.fn() - render( - - - , - ) - const trigger = screen.getByRole('combobox', { name: 'Device' }) - fireEvent.click(trigger) - const search = screen.getByRole('combobox', { name: 'Search Device' }) - fireEvent.change(search, { target: { value: 'missing' } }) - fireEvent.keyDown(search, { key: 'ArrowDown' }) - fireEvent.keyDown(search, { key: 'Enter' }) - expect(change).not.toHaveBeenCalled() - expect(screen.getByText('No matches')).toBeTruthy() - fireEvent.keyDown(search, { key: 'Escape' }) - expect(screen.queryByRole('listbox')).toBeNull() - expect(document.activeElement).toBe(trigger) -}) - -test('tab keyboard navigation wraps, focuses the chosen tab and controls the panel', () => { - function Settings() { - const [tab, setTab] = useState('members') - return ( - <> - - - - ) - } - render() - fireEvent.keyDown(screen.getByRole('tab', { name: 'Members' }), { - key: 'ArrowLeft', - }) - const limits = screen.getByRole('tab', { name: 'Limits' }) - expect(limits.getAttribute('aria-selected')).toBe('true') - expect(document.activeElement).toBe(limits) - expect( - document.getElementById(limits.getAttribute('aria-controls')!)?.hidden, - ).toBe(false) - fireEvent.keyDown(limits, { key: 'Home' }) - expect( - screen.getByRole('tab', { name: 'Members' }).getAttribute('tabindex'), - ).toBe('0') -}) diff --git a/web-interface/src/components/DatasetsPage.test.tsx b/web-interface/src/components/DatasetsPage.test.tsx deleted file mode 100644 index 443dd0d..0000000 --- a/web-interface/src/components/DatasetsPage.test.tsx +++ /dev/null @@ -1,58 +0,0 @@ -// @vitest-environment jsdom -import { afterEach, beforeEach, expect, test, vi } from 'vitest' -import { cleanup, render, screen } from '@testing-library/react' -import { DatasetsPage } from './DatasetsPage' -import { storageAPI } from '../api' - -vi.mock('../api', () => ({ storageAPI: { list: vi.fn(), info: vi.fn() } })) -vi.mock('../teams', () => ({ - useTeam: () => ({ - selected: 'team-a', - team: { name: 'Team A', members: [] }, - }), -})) -vi.mock('../auth', () => ({ - useAccount: () => ({ user: { id: 'alice', role: 'user' } }), -})) -vi.mock('./TeamStorageBrowser', () => ({ TeamStorageBrowser: () => null })) - -beforeEach(() => { - vi.resetAllMocks() - vi.mocked(storageAPI.list).mockResolvedValue({ - datasets: [], - upload_limit_bytes: 1024, - }) - vi.mocked(storageAPI.info).mockResolvedValue({ - enabled: true, - capacity_bytes: 100 * 1024 ** 3, - available_bytes: 60 * 1024 ** 3, - upload_limit_bytes: 1024, - storage_mode: 'shared-folders', - storage_limits_enforced: false, - dataset_used_bytes: 2 * 1024 ** 3, - model_used_bytes: 3 * 1024 ** 3, - dataset_allocation_gib: 1, - model_allocation_gib: 5, - usage_stale: true, - storage_low: true, - }) -}) -afterEach(cleanup) - -test('shows team file usage independently of shared disk usage and advisory allowances', async () => { - render() - await screen.findByText(/Shared disk:/) - expect(screen.getByText('2 GiB / 1 GiB')).toBeTruthy() - expect(screen.getByText('3 GiB / 5 GiB')).toBeTruthy() - expect(screen.getByText(/Above advisory allowance/)).toBeTruthy() - expect(screen.getByText(/shared disk space is not reserved/)).toBeTruthy() - expect(screen.getByText(/Shared disk:/).textContent).toContain( - '60 GiB free of 100 GiB', - ) -}) - -test('reports stale usage and shared-disk exhaustion risk separately', async () => { - render() - await screen.findByText(/Storage usage reporting is delayed/) - expect(screen.getByText(/Shared disk space is low/)).toBeTruthy() -}) diff --git a/web-interface/src/components/DatasetsPage.tsx b/web-interface/src/components/DatasetsPage.tsx deleted file mode 100644 index 2118ef0..0000000 --- a/web-interface/src/components/DatasetsPage.tsx +++ /dev/null @@ -1,436 +0,0 @@ -import { Picker } from '#/components/Picker.tsx' -import { Modal } from '#/components/Modal.tsx' -import { formatBytes } from '#/format.ts' -import { useState, useRef, useEffect } from 'react' -import { - UploadCloud, - Database, - Trash2, - FileCheck2, - LoaderCircle, -} from 'lucide-react' -import { PanelHeading } from '#/components/Card.tsx' -import { storageAPI } from '#/api.ts' -import { errorMessage, usePolling } from '#/hooks/usePolling.ts' -import { useTeam } from '#/teams.tsx' -import { useAccount } from '#/auth.tsx' -import { TeamStorageBrowser } from '#/components/TeamStorageBrowser.tsx' - -function StorageMeter({ - label, - capacity, - used, - advisory = false, -}: { - label: string - capacity: number - used: number - advisory?: boolean -}) { - return ( -
-
-
- - - {advisory - ? used > capacity - ? 'Above advisory allowance — writes are still allowed while disk space remains.' - : 'Advisory allowance — shared disk space is not reserved.' - : `${formatBytes(Math.max(0, capacity - used))} available`} - -
- ) -} - -export function DatasetsPage() { - const { selected, team } = useTeam() - const { user } = useAccount() - const [scope, setScope] = useState(user?.id ?? '') - const commonWriter = - team?.members.find((m) => m.id === user?.id)?.common_writer || - user?.role.split(',').includes('admin') - const datasets = usePolling(storageAPI.list, 10000), - storage = usePolling(storageAPI.info, 15000) - const [file, setFile] = useState(null), - [name, setName] = useState(''), - [zip, setZip] = useState(false), - [progress, setProgress] = useState(0), - [busy, setBusy] = useState(false), - [error, setError] = useState(''), - [message, setMessage] = useState('') - const [uploading, setUploading] = useState(false) - const uploadRequest = useRef(null) - useEffect(() => () => uploadRequest.current?.abort(), []) - const [search, setSearch] = useState('') - const [dragging, setDragging] = useState(false) - function chooseFile(next: File | null) { - setFile(next) - setZip(next?.name.toLowerCase().endsWith('.zip') ?? false) - } - const limit = datasets.data?.upload_limit_bytes ?? 0 - const isPreparing = (value: string) => - value.includes('team storage is being provisioned') || - value.includes('team storage is not ready') - const storagePending = - !!selected && - (datasets.loading || - storage.loading || - isPreparing(datasets.error) || - isPreparing(storage.error)) - const visibleDatasets = - datasets.data?.datasets.filter((ds) => - ds.name.toLowerCase().includes(search.toLowerCase()), - ) ?? [] - async function upload() { - if (!file) return - setBusy(true) - setError('') - setMessage('') - setProgress(0) - try { - if (file.size > limit) throw new Error('Dataset exceeds the upload limit') - uploadRequest.current = new AbortController() - const ds = await storageAPI.upload( - file, - name, - zip, - setProgress, - scope, - uploadRequest.current.signal, - ) - setMessage(`${ds.name} is ready to use.`) - setUploading(false) - datasets.refresh() - storage.refresh() - } catch (err) { - setError(errorMessage(err)) - } finally { - uploadRequest.current = null - setBusy(false) - } - } - async function remove(id: string) { - setBusy(true) - setError('') - try { - await storageAPI.remove(id) - datasets.refresh() - storage.refresh() - } catch (err) { - setError(errorMessage(err)) - } finally { - setBusy(false) - } - } - return ( -
-
-
-

{team?.name ?? 'Legacy workspace'}

-

Datasets

-

- A shared home for your team's data and experiment results. -

-
- -
- {storagePending && ( -

-

- )} - {storage.data?.enabled && ( -
-

- Shared disk: {formatBytes(storage.data.available_bytes)} free of{' '} - {formatBytes(storage.data.capacity_bytes)}. All teams use this disk. -

- {selected && ( -
- - -
- )} - {selected && ( -

- File usage updates approximately every 30 seconds. -

- )} - {storage.data.storage_low && ( -

- Shared disk space is low. Uploads and jobs may run out of space. -

- )} - {storage.data.usage_stale && ( -

- Storage usage reporting is delayed. The displayed usage may be out - of date. -

- )} -
- )} - {uploading && ( - { - if (!busy) setUploading(false) - }} - > -
{ - e.preventDefault() - void upload() - }} - > - } - /> - {selected && ( -
- - setScope(e.target.value)} - > - - {commonWriter && ( - - )} - -

- All teammates can read these files. Other teams need an - explicit sharing grant. -

-
- )} -
- - setName(e.target.value)} - /> -
-
{ - e.preventDefault() - if (!busy) setDragging(true) - }} - onDragLeave={() => setDragging(false)} - onDrop={(e) => { - e.preventDefault() - setDragging(false) - if (!busy) chooseFile(e.dataTransfer.files.item(0)) - }} - > - {file ? ( -
- -

- {storagePending - ? 'Upload will be available when team storage is ready.' - : `Upload limit: ${formatBytes(limit)}. ZIP extraction also needs space for the expanded files.`} -

- - {error &&

{error}

} - {busy && ( - - )} - {busy && ( -
- -

- {progress === 100 - ? 'Validating and saving dataset…' - : `${progress}% uploaded`} -

-
- )} - -
- )} - {(error || - (!isPreparing(datasets.error) && datasets.error) || - (!isPreparing(storage.error) && storage.error)) && ( -

- {error || - (!isPreparing(datasets.error) && datasets.error) || - storage.error} -

- )} - {message &&

{message}

} -
- } - action={ - - {datasets.data?.datasets.length ?? 0} datasets - - } - /> -
- setSearch(event.target.value)} - /> -
-
    - {visibleDatasets.map((ds) => ( -
  • -
    -

    {ds.name}

    - {ds.team_id && ( -

    - {ds.team_name ?? team?.name} /{' '} - {ds.scope === 'common' ? 'Common' : ds.member_name} - {ds.shared ? ' · Shared read-only' : ''} -

    - )} -

    - {ds.files} file(s) · {formatBytes(ds.size)} · {ds.state} -

    -
    - File details - {ds.id} -

    SHA256: {ds.sha256}

    -
    -
    - -
  • - ))} -
- {!datasets.loading && - !datasets.error && - visibleDatasets.length === 0 && ( -

- {search - ? 'No datasets match your search.' - : 'No datasets yet. Add your first dataset to get started.'} -

- )} -
- {selected ? ( -
- - Team folders Browse shared files & results - - -
- ) : ( -

- Choose a team to upload. These are your preserved account files. -

- )} -
- ) -} diff --git a/web-interface/src/components/HardwarePanel.tsx b/web-interface/src/components/HardwarePanel.tsx deleted file mode 100644 index 7efa2b0..0000000 --- a/web-interface/src/components/HardwarePanel.tsx +++ /dev/null @@ -1,125 +0,0 @@ -import Card, { PanelHeading } from '#/components/Card.tsx' -import { Server } from 'lucide-react' -import type { HardwareSnapshot } from '#/api.ts' - -export const computeNames = { - cpu: 'CPU', - nvidia: 'NVIDIA', - tenstorrent: 'Tenstorrent', -} - -function memoryLabel(quantity: string) { - const match = quantity.match(/^(\d+(?:\.\d+)?)(Ki|Mi|Gi|Ti)?$/) - if (!match) return quantity - const powers: Record = { Ki: 1, Mi: 2, Gi: 3, Ti: 4 } - const bytes = Number(match[1]) * 1024 ** (powers[match[2]] ?? 0) - return `${(bytes / 1024 ** 3).toFixed(1)} GiB` -} - -export function HardwarePanel({ - hardware, - error, - loading, -}: { - hardware: HardwareSnapshot | null - error: string - loading: boolean -}) { - return ( - - } - action={Live} - /> - {loading &&

Loading hardware…

} - {error && ( -

- Hardware unavailable: {error} -

- )} - {hardware && ( - <> -
- - - - - {[ - 'Hardware', - 'Machine', - 'Total', - 'Allocated', - 'Available', - 'Placement', - ].map((label) => ( - - ))} - - - - {hardware.pools.map((pool) => ( - - - - - - - - - ))} - -
- Current accelerator allocations -
- {label} -
- {computeNames[pool.accelerator]} {pool.unit}s - {pool.node}{pool.total}{pool.allocated} - {error || pool.available === null - ? 'Unknown' - : pool.available} - - {error ? 'Unknown' : pool.ready ? 'Ready' : 'Unavailable'} -
-
-
- {hardware.machines.map((machine) => ( -

- {machine.name}:{' '} - {machine.ready - ? machine.schedulable - ? 'Ready' - : 'Placement paused' - : 'Offline'} - {machine.ready && ( - <> - {' '} - · {machine.cpu_allocatable} CPU ·{' '} - {memoryLabel(machine.memory_allocatable)} RAM allocatable - - )} -

- ))} -
- {hardware.pools - .filter((pool) => pool.message) - .map((pool) => ( -

- {computeNames[pool.accelerator]}: {pool.message} -

- ))} -

- Updated {new Date(hardware.observed_at).toLocaleTimeString()}. - Availability can change before submission. Jobs wait for resources. - One Tenstorrent board contains two chips. -

- - )} -
- ) -} diff --git a/web-interface/src/components/JobsPage.test.tsx b/web-interface/src/components/JobsPage.test.tsx deleted file mode 100644 index 3ace18f..0000000 --- a/web-interface/src/components/JobsPage.test.tsx +++ /dev/null @@ -1,286 +0,0 @@ -// @vitest-environment jsdom -import { afterEach, beforeEach, expect, test, vi } from 'vitest' -import { - act, - cleanup, - fireEvent, - render, - screen, - waitFor, -} from '@testing-library/react' -import { JobsPage } from './JobsPage' -import { jobsAPI, storageAPI } from '../api' -import type { Job } from '../api' - -vi.mock('../api', () => ({ - storageAPI: { list: vi.fn(), files: vi.fn() }, - jobsAPI: { - list: vi.fn(), - submit: vi.fn(), - cancel: vi.fn(), - logs: vi.fn(), - hardware: vi.fn(), - images: vi.fn(), - }, -})) - -let jobs: Job[] -beforeEach(() => { - vi.resetAllMocks() - HTMLDialogElement.prototype.showModal = function () { - this.setAttribute('open', '') - } - HTMLDialogElement.prototype.close = function () { - this.removeAttribute('open') - } - vi.mocked(storageAPI.list).mockResolvedValue({ - datasets: [], - upload_limit_bytes: 2147483648, - }) - jobs = [ - { - id: 'real-job', - name: 'Actual training', - created: '2026-10-03T20:00:00Z', - job_state: 'Scheduled', - accelerator: 'tenstorrent', - device_count: 2, - cpu: '4', - memory: '8Gi', - image: 'tt-runtime', - checkpoint_directory: '/checkpoints/real-job', - message: 'Waiting for boards', - }, - ] - vi.mocked(jobsAPI.list).mockImplementation(async () => ({ - jobs, - count: jobs.length, - })) - vi.mocked(jobsAPI.logs).mockResolvedValue({ logs: 'REAL_TRAINING_OUTPUT' }) - vi.mocked(jobsAPI.hardware).mockResolvedValue({ - observed_at: '2026-10-08T12:00:00Z', - machines: [], - pools: [ - { - accelerator: 'nvidia', - node: 'cpu-node', - unit: 'GPU', - total: 2, - allocated: 1, - available: 1, - ready: true, - }, - { - accelerator: 'tenstorrent', - node: 'tt-node', - unit: 'board', - total: 4, - allocated: 2, - available: 2, - ready: true, - chips_per_device: 2, - }, - ], - }) - vi.mocked(jobsAPI.images).mockResolvedValue({ - images: [ - { reference: 'cpu-runtime', accelerators: ['cpu'] }, - { reference: 'nvidia-runtime', accelerators: ['nvidia'] }, - { reference: 'tt-runtime', accelerators: ['tenstorrent'] }, - ], - profiles: [ - { - accelerator: 'cpu', - default_image: 'cpu-runtime', - device_unit: 'none', - max_devices: 0, - }, - { - accelerator: 'nvidia', - default_image: 'nvidia-runtime', - device_unit: 'GPU', - max_devices: 2, - }, - { - accelerator: 'tenstorrent', - default_image: 'tt-runtime', - device_unit: 'board', - max_devices: 4, - }, - ], - }) - vi.mocked(jobsAPI.submit).mockResolvedValue({ - job_id: 'submitted', - job: jobs[0], - }) - vi.mocked(jobsAPI.cancel).mockImplementation(async () => { - jobs = [{ ...jobs[0], job_state: 'Cancelled' }] - return jobs[0] - }) -}) -afterEach(() => { - cleanup() - vi.useRealTimers() -}) - -test('shows real job state, reads logs, and cancels through the API', async () => { - render() - await screen.findByText('Actual training') - fireEvent.click( - screen.getByRole('button', { name: 'Show details for Actual training' }), - ) - expect(screen.getByText('Waiting for boards')).toBeTruthy() - fireEvent.click(screen.getByRole('button', { name: 'Logs' })) - await screen.findByText('REAL_TRAINING_OUTPUT') - fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) - await screen.findByText('Cancelled') - expect(jobsAPI.cancel).toHaveBeenCalledWith('real-job') - await waitFor(() => - expect(screen.queryByRole('button', { name: 'Cancel' })).toBeNull(), - ) -}) - -test('submits the requested Tenstorrent board count', async () => { - render() - fireEvent.click(screen.getByRole('button', { name: 'New job' })) - await screen.findByDisplayValue('cpu-runtime') - fireEvent.click(screen.getByRole('radio', { name: /Tenstorrent/ })) - fireEvent.change(screen.getByLabelText('Boards (two chips each)'), { - target: { value: '2' }, - }) - fireEvent.click(screen.getByRole('radio', { name: 'Training check' })) - fireEvent.click(screen.getByRole('button', { name: 'Submit job' })) - await waitFor(() => - expect(jobsAPI.submit).toHaveBeenCalledWith({ - name: '', - type: 'training-smoke', - accelerator: 'tenstorrent', - device_count: 2, - image: 'tt-runtime', - timeout_seconds: 600, - }), - ) -}) - -test('passes executable and each argument separately, preserving spaces', async () => { - render() - fireEvent.click(screen.getByRole('button', { name: 'New job' })) - await screen.findByDisplayValue('cpu-runtime') - fireEvent.click(screen.getByRole('radio', { name: 'Container' })) - fireEvent.change(screen.getByLabelText('Command executable (optional)'), { - target: { value: 'python' }, - }) - fireEvent.change(screen.getByLabelText('Arguments (one per line)'), { - target: { value: "-c\nprint('two words')" }, - }) - fireEvent.change(screen.getByLabelText('Working directory (optional)'), { - target: { value: '/app' }, - }) - fireEvent.click(screen.getByRole('button', { name: 'Submit job' })) - await waitFor(() => - expect(jobsAPI.submit).toHaveBeenCalledWith( - expect.objectContaining({ - image: 'cpu-runtime', - command: ['python'], - args: ['-c', "print('two words')"], - working_directory: '/app', - }), - ), - ) -}) - -test('rejects unapproved images and invalid board counts before submission', async () => { - render() - fireEvent.click(screen.getByRole('button', { name: 'New job' })) - await screen.findByDisplayValue('cpu-runtime') - fireEvent.change(screen.getByLabelText('Container image'), { - target: { value: 'unapproved/image' }, - }) - expect( - screen.getByRole('button', { name: 'Submit job' }).hasAttribute('disabled'), - ).toBe(true) - fireEvent.click(screen.getByRole('radio', { name: /Tenstorrent/ })) - fireEvent.change(screen.getByLabelText('Boards (two chips each)'), { - target: { value: '5' }, - }) - expect( - screen.getByRole('button', { name: 'Submit job' }).hasAttribute('disabled'), - ).toBe(true) - expect(jobsAPI.submit).not.toHaveBeenCalled() -}) - -test('recovers the job list after a temporary API outage', async () => { - vi.useFakeTimers() - vi.mocked(jobsAPI.list).mockRejectedValueOnce( - new Error('Temporary API outage'), - ) - render() - await act(async () => {}) - expect(screen.getByRole('alert').textContent).toContain( - 'Temporary API outage', - ) - await act(async () => { - await vi.advanceTimersByTimeAsync(3000) - }) - expect(screen.queryByRole('alert')).toBeNull() - expect(screen.getByText('Actual training')).toBeTruthy() -}) - -test('paginates history and searches across all pages', async () => { - jobs = Array.from({ length: 23 }, (_, index) => ({ - ...jobs[0], - id: `job-${index}`, - name: `Experiment ${index + 1}`, - })) - render() - await screen.findByText('Experiment 1') - expect( - screen.getAllByRole('button', { name: /Show details for Experiment/ }), - ).toHaveLength(10) - expect(screen.queryByText('Experiment 11')).toBeNull() - fireEvent.click(screen.getByRole('button', { name: 'Next page' })) - expect(screen.getByText('Experiment 11')).toBeTruthy() - expect(screen.queryByText('Experiment 1')).toBeNull() - fireEvent.click(screen.getByRole('button', { name: 'Next page' })) - expect( - screen.getAllByRole('button', { name: /Show details for Experiment/ }), - ).toHaveLength(3) - expect( - screen.getByRole('button', { name: 'Next page' }).hasAttribute('disabled'), - ).toBe(true) - fireEvent.change(screen.getByLabelText('Search job history'), { - target: { value: 'job-22' }, - }) - expect(screen.getByText('Experiment 23')).toBeTruthy() - expect( - screen.getAllByRole('button', { name: /Show details for Experiment/ }), - ).toHaveLength(1) - expect( - screen - .getByRole('button', { name: 'Previous page' }) - .hasAttribute('disabled'), - ).toBe(true) -}) - -test('keeps a cleared image blank while editing and uses the image startup defaults', async () => { - render() - fireEvent.click(screen.getByRole('button', { name: 'New job' })) - await screen.findByDisplayValue('cpu-runtime') - fireEvent.click(screen.getByRole('radio', { name: 'Container' })) - const image = screen.getByLabelText('Container image') - fireEvent.change(image, { target: { value: '' } }) - expect(image.value).toBe('') - expect( - screen.getByRole('button', { name: 'Submit job' }).hasAttribute('disabled'), - ).toBe(true) - fireEvent.change(image, { target: { value: 'cpu' } }) - expect(image.value).toBe('cpu') - fireEvent.change(image, { target: { value: 'cpu-runtime' } }) - fireEvent.click(screen.getByRole('button', { name: 'Submit job' })) - await waitFor(() => expect(jobsAPI.submit).toHaveBeenCalled()) - const submission = vi.mocked(jobsAPI.submit).mock.calls[0][0] - expect(submission.image).toBe('cpu-runtime') - expect(submission.command).toBeUndefined() - expect(submission.args).toBeUndefined() - expect(submission.script).toBeUndefined() -}) diff --git a/web-interface/src/components/JobsPage.tsx b/web-interface/src/components/JobsPage.tsx deleted file mode 100644 index 7db3a3c..0000000 --- a/web-interface/src/components/JobsPage.tsx +++ /dev/null @@ -1,336 +0,0 @@ -import { Picker } from '#/components/Picker.tsx' -import { useState } from 'react' -import { - History, - Search, - ChevronLeft, - ChevronRight, - CheckCircle2, - Clock3, - Activity, - Plus, -} from 'lucide-react' -import { Modal } from '#/components/Modal.tsx' -import { PanelHeading } from '#/components/Card.tsx' -import { jobsAPI, storageAPI } from '#/api.ts' -import type { Submission } from '#/api.ts' -import { HardwarePanel } from '#/components/HardwarePanel.tsx' -import { JobSubmissionForm } from '#/components/jobs/JobSubmissionForm.tsx' -import { JobCard } from '#/components/jobs/JobCard.tsx' -import { JobLogs } from '#/components/jobs/JobLogs.tsx' -import { errorMessage, usePolling } from '#/hooks/usePolling.ts' -import { useTeam } from '#/teams.tsx' - -export function JobsPage() { - const { team } = useTeam() - const jobs = usePolling(jobsAPI.list, 3000) - const hardware = usePolling(jobsAPI.hardware) - const datasets = usePolling(storageAPI.list, 15000) - const images = usePolling(jobsAPI.images, 15000) - const [selectedLogs, setSelectedLogs] = useState(null) - const [cancelling, setCancelling] = useState(null) - const [actionError, setActionError] = useState('') - const [composing, setComposing] = useState(false) - const [submitted, setSubmitted] = useState('') - const [search, setSearch] = useState('') - const [status, setStatus] = useState('all') - const [page, setPage] = useState(1) - const [pageSize, setPageSize] = useState(10) - const filtered = (jobs.data?.jobs ?? []).filter( - (job) => - (status === 'all' || job.job_state === status) && - `${job.name} ${job.id} ${job.creator_name ?? ''}` - .toLowerCase() - .includes(search.trim().toLowerCase()), - ) - const pageCount = Math.max(1, Math.ceil(filtered.length / pageSize)) - const currentPage = Math.min(page, pageCount) - const start = (currentPage - 1) * pageSize - const visibleJobs = filtered.slice(start, start + pageSize) - function filterStatus(next: string) { - setStatus(next) - setPage(1) - setSelectedLogs(null) - } - function changePage(next: number) { - setPage(next) - setSelectedLogs(null) - } - - async function submit(submission: Submission) { - const response = await jobsAPI.submit(submission) - setSubmitted(response.job_id) - setComposing(false) - setPage(1) - setSearch('') - setStatus('all') - setActionError('') - jobs.refresh() - hardware.refresh() - } - async function cancel(id: string) { - setCancelling(id) - setActionError('') - try { - await jobsAPI.cancel(id) - jobs.refresh() - hardware.refresh() - } catch (err) { - setActionError(errorMessage(err)) - } finally { - setCancelling(null) - } - } - - return ( -
-
-
-

{team?.name ?? 'Legacy workspace'}

-

Jobs

-

- From an experiment to a result. Run your code on the right hardware. -

-
- -
-
- {( - [ - [ - 'Running', - jobs.data?.jobs.filter((j) => j.job_state === 'InProgress') - .length, - 'InProgress', - Activity, - Plus, - ], - [ - 'In queue', - jobs.data?.jobs.filter((j) => j.job_state === 'Scheduled').length, - 'Scheduled', - Clock3, - ], - [ - 'Completed', - jobs.data?.jobs.filter((j) => j.job_state === 'Success').length, - 'Success', - CheckCircle2, - ], - ] as const - ).map(([label, count, state, Icon]) => ( - - ))} -
-
- - Connected compute - - {hardware.data?.pools - .map( - (pool) => - `${pool.available ?? '—'} ${pool.accelerator === 'nvidia' ? 'GPUs' : 'TT boards'}`, - ) - .join(' · ') ?? 'Checking availability…'} - - - -
- {images.error && ( -

- Image catalog unavailable: {images.error} -

- )} - {composing && ( - setComposing(false)}> - - - )} - {submitted && ( -

- Job submitted. Track it in the history below. -

- )} -
-
- {(actionError || jobs.error) && ( -

- {actionError || jobs.error} -

- )} - {jobs.loading &&

Loading jobs…

} - {selectedLogs && ( - setSelectedLogs(null)} - /> - )} -
- } - action={ - - {jobs.data?.count ?? '—'} jobs - - } - /> -
-
-
- filterStatus(e.target.value)} - > - - - - - - - -
-
- - Job / submitted by - Status - Compute - Created -
- {visibleJobs.map((job) => ( - setSelectedLogs(job.id)} - onCancel={() => { - void cancel(job.id) - }} - /> - ))} - {!jobs.loading && !jobs.error && filtered.length === 0 && ( -
-
- )} - -
-
-
-
- ) -} diff --git a/web-interface/src/components/MachinesPage.tsx b/web-interface/src/components/MachinesPage.tsx deleted file mode 100644 index 55a7f4e..0000000 --- a/web-interface/src/components/MachinesPage.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { jobsAPI } from '#/api.ts' -import { HardwarePanel } from '#/components/HardwarePanel.tsx' -import { usePolling } from '#/hooks/usePolling.ts' - -export function MachinesPage() { - const hardware = usePolling(jobsAPI.hardware) - return ( -
-

Machines

- -

- CPU and RAM figures are allocatable scheduling capacity, not live - utilization. Jobs reserve whole NVIDIA GPUs or whole Tenstorrent boards. -

-
- ) -} diff --git a/web-interface/src/components/Modal.tsx b/web-interface/src/components/Modal.tsx deleted file mode 100644 index 64dfabe..0000000 --- a/web-interface/src/components/Modal.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { useEffect, useRef } from 'react' -import { createPortal } from 'react-dom' -import { X } from 'lucide-react' -import type { ReactNode } from 'react' - -/** Native dialog provides focus trapping, focus return and the modal top layer. */ -export function Modal({ - title, - onClose, - children, - closeDisabled = false, -}: { - title: string - onClose: () => void - children: ReactNode - closeDisabled?: boolean -}) { - const dialog = useRef(null) - useEffect(() => { - const current = dialog.current - current?.showModal() - const previous = document.body.style.overflow - document.body.style.overflow = 'hidden' - return () => { - current?.close() - document.body.style.overflow = previous - } - }, []) - return createPortal( - { - event.preventDefault() - if (!closeDisabled) onClose() - }} - onClick={(event) => { - if (!closeDisabled && event.target === event.currentTarget) onClose() - }} - > -
- {title} - -
-
{children}
-
, - document.body, - ) -} diff --git a/web-interface/src/components/Navbar.tsx b/web-interface/src/components/Navbar.tsx deleted file mode 100644 index 3b50311..0000000 --- a/web-interface/src/components/Navbar.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import { Link } from '@tanstack/react-router' -import { authClient, useAccount } from '#/auth.tsx' -import { useState } from 'react' -import { - LayoutDashboard, - Play, - Database, - Server, - Users, - BookOpen, -} from 'lucide-react' -import { WorkspaceSelector } from '#/teams.tsx' - -export default function Navbar() { - const account = useAccount() - const [error, setError] = useState('') - return ( -
- - - - MistResearch compute - - -

WORKSPACE

- -
- - - - - {account.user?.name ?? 'Local pilot'} - Account settings - - - {account.enabled && ( - - )} - {error &&

{error}

} -
-
- ) -} diff --git a/web-interface/src/components/Picker.tsx b/web-interface/src/components/Picker.tsx deleted file mode 100644 index 644e5e3..0000000 --- a/web-interface/src/components/Picker.tsx +++ /dev/null @@ -1,215 +0,0 @@ -import { - Children, - isValidElement, - useEffect, - useId, - useRef, - useState, -} from 'react' -import { Check, ChevronDown, Search } from 'lucide-react' -import type { ReactNode } from 'react' - -type Props = { - id?: string - value: string | number - onChange: (event: { target: { value: string } }) => void - children: ReactNode - disabled?: boolean - required?: boolean - className?: string - 'aria-label'?: string -} -function text(node: ReactNode): string { - return Children.toArray(node) - .map((child) => - isValidElement<{ children?: ReactNode }>(child) - ? text(child.props.children) - : String(child), - ) - .join('') -} - -/** Searchable, keyboard-operable single selection shared across the portal. */ -export function Picker({ - id, - value, - onChange, - children, - disabled, - required, - className = '', - 'aria-label': ariaLabel, -}: Props) { - const options = Children.toArray(children) - .filter(isValidElement<{ value: string | number; children: ReactNode }>) - .map((option) => ({ - value: String(option.props.value), - label: text(option.props.children), - })) - const selected = options.find((option) => option.value === String(value)) - const [open, setOpen] = useState(false) - const [above, setAbove] = useState(false) - const [query, setQuery] = useState('') - const [active, setActive] = useState(0) - const root = useRef(null) - const trigger = useRef(null) - const search = useRef(null) - const uniqueId = useId() - const filtered = options.filter((option) => - option.label.toLowerCase().includes(query.toLowerCase()), - ) - const listId = `${id ?? uniqueId}-options` - useEffect(() => { - if (!open) return - const list = document.getElementById(listId) - const option = document.getElementById(`${listId}-${active}`) - if (!list || !option) return - const bounds = list.getBoundingClientRect() - const item = option.getBoundingClientRect() - if (item.top < bounds.top) list.scrollTop -= bounds.top - item.top - else if (item.bottom > bounds.bottom) - list.scrollTop += item.bottom - bounds.bottom - }, [open, active, listId, query]) - useEffect(() => { - if (!open) return - function place() { - const rect = root.current?.getBoundingClientRect() - if (!rect) return - const bound = Math.min( - window.innerHeight, - root.current?.closest('dialog')?.getBoundingClientRect().bottom ?? - window.innerHeight, - ) - setAbove(bound - rect.bottom < 260 && rect.top > bound - rect.bottom) - } - place() - search.current?.focus() - function outside(event: PointerEvent) { - if (!root.current?.contains(event.target as Node)) setOpen(false) - } - document.addEventListener('pointerdown', outside) - const outsideFocus = (event: FocusEvent) => { - if (!root.current?.contains(event.target as Node)) setOpen(false) - } - document.addEventListener('focusin', outsideFocus) - window.addEventListener('resize', place) - document.addEventListener('scroll', place, true) - return () => { - document.removeEventListener('pointerdown', outside) - document.removeEventListener('focusin', outsideFocus) - window.removeEventListener('resize', place) - document.removeEventListener('scroll', place, true) - } - }, [open]) - function choose(next: string) { - onChange({ target: { value: next } }) - setOpen(false) - trigger.current?.focus() - } - return ( -
- - {open && ( -
{ - if (event.key === 'Escape') { - event.preventDefault() - event.stopPropagation() - setOpen(false) - trigger.current?.focus() - } - if (event.key === 'ArrowDown') { - event.preventDefault() - setActive((index) => - Math.max(0, Math.min(index + 1, filtered.length - 1)), - ) - } - if (event.key === 'ArrowUp') { - event.preventDefault() - setActive((index) => Math.max(index - 1, 0)) - } - if (event.key === 'Enter' && filtered[active]) { - event.preventDefault() - choose(filtered[active].value) - } - }} - > -
-
-
- {filtered.map((option, index) => ( - - ))} - {!filtered.length && ( -

No matches

- )} -
-
- )} -
- ) -} diff --git a/web-interface/src/components/Tabs.tsx b/web-interface/src/components/Tabs.tsx deleted file mode 100644 index 3ab0569..0000000 --- a/web-interface/src/components/Tabs.tsx +++ /dev/null @@ -1,49 +0,0 @@ -/** Shared tab navigation, with arrow keys, Home/End and a single tab stop. */ -export function Tabs({ - id, - label, - value, - onChange, - items, -}: { - id: string - label: string - value: string - onChange: (value: string) => void - items: readonly (readonly [string, string])[] -}) { - return ( -
- {items.map(([key, title], index) => ( - - ))} -
- ) -} diff --git a/web-interface/src/components/TeamStorageBrowser.tsx b/web-interface/src/components/TeamStorageBrowser.tsx deleted file mode 100644 index 49cc3a9..0000000 --- a/web-interface/src/components/TeamStorageBrowser.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import { formatBytes } from '#/format.ts' -import { Picker } from '#/components/Picker.tsx' -import { useCallback, useState } from 'react' -import { storageAPI } from '#/api.ts' -import type { StorageFolder } from '#/api.ts' -import { usePolling } from '#/hooks/usePolling.ts' -import { FolderOpen, Folder, ArrowDownToLine, Users } from 'lucide-react' -import { PanelHeading } from '#/components/Card.tsx' - -function FolderFiles({ folder }: { folder: StorageFolder }) { - const load = useCallback( - (signal: AbortSignal) => storageAPI.folderFiles(folder, signal), - [folder.team_id, folder.scope], - ) - const files = usePolling(load, 10000) - return ( -
-

- {folder.team_name} / {folder.name} - {folder.shared ? ' · Shared read-only' : ''} -

- {files.loading &&

Loading files…

} - {files.error &&

{files.error}

} - {files.data?.files.length === 0 &&

No files in this folder yet.

} -
    - {files.data?.files.map((file) => ( -
  • - - {file.path} · {formatBytes(file.size)} - - - -
  • - ))} -
-
- ) -} -export function TeamStorageBrowser() { - const folders = usePolling(storageAPI.folders, 15000) - const [selected, setSelected] = useState('') - const folder = folders.data?.folders.find( - (f) => `${f.team_id}:${f.scope}` === selected, - ) - return ( -
- } - /> -
- {folders.data?.folders.map((f) => { - const id = `${f.team_id}:${f.scope}` - return ( - - ) - })} -
- - setSelected(e.target.value)} - > - - {folders.data?.folders.map((f) => ( - - ))} - - {folders.error &&

{folders.error}

} - {folder && } -
- ) -} diff --git a/web-interface/src/components/TeamsPage.tsx b/web-interface/src/components/TeamsPage.tsx deleted file mode 100644 index 0b5ca00..0000000 --- a/web-interface/src/components/TeamsPage.tsx +++ /dev/null @@ -1,583 +0,0 @@ -import { Picker } from '#/components/Picker.tsx' -import { useState, useEffect } from 'react' -import { Modal } from '#/components/Modal.tsx' -import { Tabs } from '#/components/Tabs.tsx' -import { Users, Settings2, ArrowUpRight, Plus, UserMinus } from 'lucide-react' -import { PanelHeading } from '#/components/Card.tsx' -import type { Team, TeamPolicy } from '#/api.ts' -import { teamsAPI } from '#/api.ts' -import { authClient, useAccount } from '#/auth.tsx' -import { useTeam } from '#/teams.tsx' -import { errorMessage, usePolling } from '#/hooks/usePolling.ts' - -const field = 'border rounded p-2 w-full' -async function loadAccounts() { - const result = await authClient.admin.listUsers({ query: { limit: 1000 } }) - if (result.error) throw new Error(result.error.message) - return result.data.users -} - -function PolicyForm({ - team, - save, -}: { - team: Team - save: (policy: TeamPolicy) => Promise -}) { - const [policy, setPolicyState] = useState(team.policy) - const [editing, setEditing] = useState(false) - const signature = JSON.stringify(team.policy) - useEffect(() => { - if (!editing) setPolicyState(JSON.parse(signature) as TeamPolicy) - }, [signature, editing]) - function setPolicy(next: TeamPolicy) { - setEditing(true) - setPolicyState(next) - } - const [busy, setBusy] = useState(false) - const [message, setMessage] = useState('') - const numbers = [ - ['nvidia', 'NVIDIA GPUs', 0, 2], - ['tenstorrent', 'Tenstorrent boards (two chips each)', 0, 4], - ['concurrent', 'Simultaneous jobs', 1, 16], - ['queued', 'Queued jobs', 1, 200], - ['storage_gib', 'Dataset allowance (GiB, advisory)', 1, 1048576], - ['model_storage_gib', 'Model allowance (GiB, advisory)', 1, 1048576], - ['runtime_seconds', 'Maximum job runtime (seconds)', 10, 86400], - ] as const - return ( -
{ - e.preventDefault() - setBusy(true) - setMessage('') - try { - await save(policy) - setEditing(false) - setMessage('Team limits saved.') - } catch (err) { - setMessage(errorMessage(err)) - } finally { - setBusy(false) - } - }} - > -

Team limits

-
-
- - setPolicy({ ...policy, cpu: e.target.value })} - /> -
-
- - setPolicy({ ...policy, memory: e.target.value })} - /> -
- {numbers.map(([key, name, min, max]) => ( -
- - - setPolicy({ ...policy, [key]: Number(e.target.value) }) - } - /> - {!key.includes('storage') && ( - - setPolicy({ ...policy, [key]: Number(e.target.value) }) - } - className="mist-range w-full mt-3" - /> - )} -
- ))} -
-
- -