From 1546bee94c3de3d0d2540f27ada362e61f9a91b5 Mon Sep 17 00:00:00 2001 From: utmist-tt <339893541+utmist-tt@users.noreply.github.com> Date: Wed, 21 Oct 2026 20:42:33 -0400 Subject: [PATCH 1/3] fix(auth): preserve existing accounts and require valid sessions --- src/auth.go | 82 +++++------- src/auth_test.go | 131 ++++++++++++++++++- src/go.mod | 6 +- src/go.sum | 43 ++++-- src/shared_storage_test.go | 12 +- src/teams_test.go | 7 +- web-interface/src/components/AccountPage.tsx | 1 - 7 files changed, 208 insertions(+), 74 deletions(-) diff --git a/src/auth.go b/src/auth.go index 507098c..f8d1fe1 100644 --- a/src/auth.go +++ b/src/auth.go @@ -21,6 +21,7 @@ import ( "golang.org/x/crypto/bcrypt" "golang.org/x/crypto/scrypt" + "golang.org/x/text/unicode/norm" _ "modernc.org/sqlite" ) @@ -69,7 +70,8 @@ func newAuthService(dbPath string, log *slog.Logger) (*AuthService, error) { image TEXT, createdAt TEXT NOT NULL, updatedAt TEXT NOT NULL, - role TEXT DEFAULT 'user' + role TEXT DEFAULT 'user', + banned INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS session ( @@ -106,6 +108,19 @@ func newAuthService(dbPath string, log *slog.Logger) (*AuthService, error) { 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, @@ -174,8 +189,8 @@ func (s *AuthService) bootstrapAdmin() error { defer tx.Rollback() _, err = tx.Exec(` - INSERT INTO user (id, name, email, role, createdAt, updatedAt) - VALUES (?, ?, ?, 'admin', ?, ?) + INSERT INTO user (id, name, email, role, emailVerified, createdAt, updatedAt) + VALUES (?, ?, ?, 'admin', 0, ?, ?) `, userID, "Mist Administrator", email, now, now) if err != nil { return err @@ -213,14 +228,14 @@ func verifyPassword(hashed, password string) bool { return bcrypt.CompareHashAndPassword([]byte(hashed), []byte(password)) == nil } parts := strings.Split(hashed, ":") - if len(parts) == 2 { - salt, err1 := hex.DecodeString(parts[0]) - expected, err2 := hex.DecodeString(parts[1]) - if err1 == nil && err2 == nil { - derived, err := scrypt.Key([]byte(password), salt, 16384, 8, 1, len(expected)) - if err == nil && subtle.ConstantTimeCompare(derived, expected) == 1 { - return true - } + 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 @@ -251,7 +266,7 @@ func (s *AuthService) checkRateLimit(key string, windowSec int64, maxReq int) bo } func extractSessionToken(r *http.Request) string { - for _, cookieName := range []string{"better-auth.session_token", "session_token", "member", "identity"} { + 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 { @@ -263,7 +278,7 @@ func extractSessionToken(r *http.Request) string { 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=", "member=", "identity="} { + 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 { @@ -287,7 +302,7 @@ func (s *AuthService) session(r *http.Request) (*Member, error) { 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 > ? + 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 @@ -295,22 +310,11 @@ func (s *AuthService) session(r *http.Request) (*Member, error) { return nil, err } - err = s.db.QueryRowContext(r.Context(), ` - SELECT id, name, email, role - FROM user - WHERE id = ? - `, token).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") + rows, err := s.db.QueryContext(ctx, "SELECT id, role FROM user WHERE COALESCE(banned, 0) = 0") if err != nil { return nil, err } @@ -328,7 +332,7 @@ func (s *AuthService) activeMembers(ctx context.Context) (map[string]AccountStat 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 = ?", id). + 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") @@ -429,7 +433,7 @@ func (s *AuthService) handleSignInEmail(w http.ResponseWriter, r *http.Request) err := s.db.QueryRowContext(r.Context(), ` SELECT id, name, email, role, createdAt, updatedAt FROM user - WHERE LOWER(email) = LOWER(?) + 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") @@ -508,7 +512,7 @@ func (s *AuthService) handleGetSession(w http.ResponseWriter, r *http.Request) { 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 > ? + 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 { @@ -524,22 +528,6 @@ func (s *AuthService) handleGetSession(w http.ResponseWriter, r *http.Request) { return } - err = s.db.QueryRowContext(r.Context(), ` - SELECT id, name, email, role FROM user WHERE id = ? - `, token).Scan(&user.ID, &user.Name, &user.Email, &user.Role) - if err == nil { - writeJSON(w, 200, map[string]interface{}{ - "user": user, - "session": map[string]interface{}{ - "id": "sess-mock", - "userId": user.ID, - "token": token, - "expiresAt": time.Now().Add(24 * time.Hour).Format(time.RFC3339Nano), - }, - }) - return - } - writeJSON(w, 200, map[string]interface{}{"user": nil, "session": nil}) } @@ -688,8 +676,8 @@ func (s *AuthService) handleCreateUser(w http.ResponseWriter, r *http.Request) { defer tx.Rollback() _, err = tx.ExecContext(r.Context(), ` - INSERT INTO user (id, name, email, role, createdAt, updatedAt) - VALUES (?, ?, ?, ?, ?, ?) + 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") diff --git a/src/auth_test.go b/src/auth_test.go index b30d513..9432a5d 100644 --- a/src/auth_test.go +++ b/src/auth_test.go @@ -2,6 +2,8 @@ package main import ( "bytes" + "context" + "database/sql" "encoding/json" "io" "log/slog" @@ -9,6 +11,7 @@ import ( "path/filepath" "strings" "testing" + "time" ) func newTestAuthService(t *testing.T) *AuthService { @@ -169,13 +172,16 @@ func TestAuthService_AdminUserManagement(t *testing.T) { _, _ = 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", - }, "identity=admin1") + }, "better-auth.session_token=test-session-admin1") if rec.Code != 200 { t.Fatalf("create-user failed: %d %s", rec.Code, rec.Body.String()) } @@ -192,13 +198,13 @@ func TestAuthService_AdminUserManagement(t *testing.T) { "name": "Mallory", "email": "mallory@mist.local", "password": "mallorypassword123", - }, "identity=user1") + }, "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, "identity=admin1") + 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()) } @@ -215,7 +221,7 @@ func TestAuthService_AdminUserManagement(t *testing.T) { rec = authRequest(s, "POST", "/auth/admin/set-user-password", map[string]string{ "userId": created.User.ID, "newPassword": "newbobpassword123", - }, "identity=admin1") + }, "better-auth.session_token=test-session-admin1") if rec.Code != 200 { t.Fatalf("set-user-password failed: %d %s", rec.Code, rec.Body.String()) } @@ -264,3 +270,120 @@ func TestAuthService_InternalMembers(t *testing.T) { 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 index 2653e15..972e1e9 100644 --- a/src/go.mod +++ b/src/go.mod @@ -4,9 +4,12 @@ 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 ( @@ -38,12 +41,10 @@ require ( 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/crypto v0.58.0 // 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/text v0.43.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 @@ -55,7 +56,6 @@ require ( modernc.org/libc v1.77.1 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.12.1 // indirect - modernc.org/sqlite v1.60.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 diff --git a/src/go.sum b/src/go.sum index 5e7af4b..1ea1ff4 100644 --- a/src/go.sum +++ b/src/go.sum @@ -29,11 +29,12 @@ github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7O 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-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo= -github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= 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= @@ -103,12 +104,12 @@ 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.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= -golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= 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= @@ -116,23 +117,17 @@ golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT 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.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= -golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= 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.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= -golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= 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.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= 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= @@ -141,8 +136,8 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm 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.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= -golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +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= @@ -171,14 +166,34 @@ k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOP 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= diff --git a/src/shared_storage_test.go b/src/shared_storage_test.go index dbf689d..f3e0f78 100644 --- a/src/shared_storage_test.go +++ b/src/shared_storage_test.go @@ -166,6 +166,10 @@ func TestMemberAuthIsolationAndFailureClosed(t *testing.T) { 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) @@ -178,17 +182,17 @@ func TestMemberAuthIsolationAndFailureClosed(t *testing.T) { if response := request("GET", "/jobs", "", "", ""); response.Code != 401 { t.Fatal("anonymous request accepted") } - if response := request("POST", "/jobs", "member=alice", "http://evil.test", `{"command":["true"]}`); response.Code != 403 { + 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", "member=alice", "http://mist.test", `{"command":["true"]}`); response.Code != 201 { + 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", "member=bob", "", ""); response.Code != 200 || !strings.Contains(response.Body.String(), `"count":0`) { + 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", "member=alice", "", ""); response.Code != 503 { + 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/teams_test.go b/src/teams_test.go index ee53324..b66b325 100644 --- a/src/teams_test.go +++ b/src/teams_test.go @@ -63,6 +63,11 @@ func departmentTestApp(t *testing.T) (*App, *fake.Clientset, []Team) { 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 { if err = atomicJSON(filepath.Join(root, "metadata/provision-status", team.ID+".json"), map[string]any{"state": "Ready", "storage_gib": team.Policy.StorageGiB, "model_storage_gib": team.Policy.ModelStorageGiB}); err != nil { t.Fatal(err) @@ -75,7 +80,7 @@ func departmentTestApp(t *testing.T) (*App, *fake.Clientset, []Team) { } func departmentRequest(a *App, method, path, identity, team, body string) *httptest.ResponseRecorder { req := httptest.NewRequest(method, path, strings.NewReader(body)) - req.Header.Set("Cookie", "identity="+identity) + req.Header.Set("Cookie", "better-auth.session_token=test-session-"+identity) if team != "" { req.Header.Set("X-Mist-Team", team) } diff --git a/web-interface/src/components/AccountPage.tsx b/web-interface/src/components/AccountPage.tsx index bd1f108..466cdd8 100644 --- a/web-interface/src/components/AccountPage.tsx +++ b/web-interface/src/components/AccountPage.tsx @@ -98,7 +98,6 @@ function Members() { [password, setPassword] = useState(''), [error, setError] = useState(''), [busy, setBusy] = useState(false) - const current = useAccount().user const [creating, setCreating] = useState(false) const [resetUser, setResetUser] = useState(null) const [search, setSearch] = useState('') From eee45560ff31f08ac9df10c4a842a85324fb19ad Mon Sep 17 00:00:00 2001 From: utmist-tt <339893541+utmist-tt@users.noreply.github.com> Date: Wed, 21 Oct 2026 20:42:41 -0400 Subject: [PATCH 2/3] feat(storage): use shared team folders over direct Ethernet --- README.md | 8 + .../k3s/evidence/storage-2026-10-21/README.md | 57 ++++ .../storage-2026-10-21/browser-results.json | 9 + .../storage-2026-10-21/datasets-browser.png | Bin 0 -> 92495 bytes .../storage-2026-10-21/deployed-api.json | 6 + .../storage-2026-10-21/host-storage.json | 11 + .../storage-2026-10-21/jobs-browser.png | Bin 0 -> 86090 bytes .../evidence/storage-2026-10-21/nvidia.log | 10 + .../evidence/storage-2026-10-21/results.json | 98 +++++++ .../storage-2026-10-21/tenstorrent.log | 58 ++++ .../storage-2026-10-21/validation.json | 40 +++ .../storage-2026-10-21/workload-volumes.json | 130 +++++++++ deploy/k3s/mist-api.yaml | 10 +- deploy/k3s/storage/README.md | 144 ++++++++++ deploy/k3s/storage/grow-quietbox-store.py | 34 --- deploy/k3s/storage/mist-team-storage.service | 7 +- deploy/k3s/storage/setup-quietbox.sh | 65 ++--- deploy/k3s/storage/setup-storage-link.sh | 35 +++ deploy/k3s/storage/shared.yaml | 38 +-- deploy/k3s/storage/team_folders.py | 151 +++++++++++ deploy/k3s/storage/team_volumes.py | 252 ------------------ deploy/k3s/storage/test_folders.py | 95 +++++++ deploy/k3s/storage/test_migration.py | 82 ------ deploy/k3s/verify_split_storage.py | 123 --------- deploy/k3s/verify_storage_folders.py | 176 ++++++++++++ deploy/k3s/verify_storage_folders_browser.mjs | 59 ++++ deploy/private/README.md | 80 ++---- deploy/private/build-and-deploy.sh | 4 +- docs/agents/simple-storage-execution.md | 60 +++++ scripts/fake-backend.mjs | 18 +- src/member_auth.go | 4 +- src/shared_storage.go | 22 +- src/storage_status.go | 38 +++ src/team_queue.go | 2 +- src/team_runtime.go | 8 +- src/team_storage.go | 2 +- src/teams.go | 95 ++----- src/teams_test.go | 128 +++++++-- web-interface/src/api.ts | 7 + .../src/components/DatasetsPage.test.tsx | 58 ++++ web-interface/src/components/DatasetsPage.tsx | 69 +++-- web-interface/src/components/TeamsPage.tsx | 99 +++---- 42 files changed, 1587 insertions(+), 805 deletions(-) create mode 100644 deploy/k3s/evidence/storage-2026-10-21/README.md create mode 100644 deploy/k3s/evidence/storage-2026-10-21/browser-results.json create mode 100644 deploy/k3s/evidence/storage-2026-10-21/datasets-browser.png create mode 100644 deploy/k3s/evidence/storage-2026-10-21/deployed-api.json create mode 100644 deploy/k3s/evidence/storage-2026-10-21/host-storage.json create mode 100644 deploy/k3s/evidence/storage-2026-10-21/jobs-browser.png create mode 100644 deploy/k3s/evidence/storage-2026-10-21/nvidia.log create mode 100644 deploy/k3s/evidence/storage-2026-10-21/results.json create mode 100644 deploy/k3s/evidence/storage-2026-10-21/tenstorrent.log create mode 100644 deploy/k3s/evidence/storage-2026-10-21/validation.json create mode 100644 deploy/k3s/evidence/storage-2026-10-21/workload-volumes.json create mode 100644 deploy/k3s/storage/README.md delete mode 100644 deploy/k3s/storage/grow-quietbox-store.py create mode 100644 deploy/k3s/storage/setup-storage-link.sh create mode 100644 deploy/k3s/storage/team_folders.py delete mode 100644 deploy/k3s/storage/team_volumes.py create mode 100644 deploy/k3s/storage/test_folders.py delete mode 100644 deploy/k3s/storage/test_migration.py delete mode 100644 deploy/k3s/verify_split_storage.py create mode 100644 deploy/k3s/verify_storage_folders.py create mode 100644 deploy/k3s/verify_storage_folders_browser.mjs create mode 100644 docs/agents/simple-storage-execution.md create mode 100644 src/storage_status.go create mode 100644 web-interface/src/components/DatasetsPage.test.tsx diff --git a/README.md b/README.md index b34c67a..aec78c8 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,14 @@ collect persistent results through the same website or CLI. **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. +## 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 diff --git a/deploy/k3s/evidence/storage-2026-10-21/README.md b/deploy/k3s/evidence/storage-2026-10-21/README.md new file mode 100644 index 0000000..4868077 --- /dev/null +++ b/deploy/k3s/evidence/storage-2026-10-21/README.md @@ -0,0 +1,57 @@ +# 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 new file mode 100644 index 0000000..5a600f2 --- /dev/null +++ b/deploy/k3s/evidence/storage-2026-10-21/browser-results.json @@ -0,0 +1,9 @@ +{ + "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 new file mode 100644 index 0000000000000000000000000000000000000000..45ace932c22f400cab79e251b4bf6f6655384c36 GIT binary patch literal 92495 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�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 }N621ivQ#gqCAJ*#RfV%*i$ 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 literal 0 HcmV?d00001 diff --git a/deploy/k3s/evidence/storage-2026-10-21/nvidia.log b/deploy/k3s/evidence/storage-2026-10-21/nvidia.log new file mode 100644 index 0000000..e447011 --- /dev/null +++ b/deploy/k3s/evidence/storage-2026-10-21/nvidia.log @@ -0,0 +1,10 @@ +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 new file mode 100644 index 0000000..0a2e84e --- /dev/null +++ b/deploy/k3s/evidence/storage-2026-10-21/results.json @@ -0,0 +1,98 @@ +{ + "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 new file mode 100644 index 0000000..989051e --- /dev/null +++ b/deploy/k3s/evidence/storage-2026-10-21/tenstorrent.log @@ -0,0 +1,58 @@ +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 new file mode 100644 index 0000000..3f25652 --- /dev/null +++ b/deploy/k3s/evidence/storage-2026-10-21/validation.json @@ -0,0 +1,40 @@ +{ + "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 new file mode 100644 index 0000000..a66be72 --- /dev/null +++ b/deploy/k3s/evidence/storage-2026-10-21/workload-volumes.json @@ -0,0 +1,130 @@ +[ + { + "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/mist-api.yaml b/deploy/k3s/mist-api.yaml index f41c783..19f8a2b 100644 --- a/deploy/k3s/mist-api.yaml +++ b/deploy/k3s/mist-api.yaml @@ -138,7 +138,7 @@ spec: type: RuntimeDefault containers: - name: api - image: mist-api:departments-20261009 + image: mist-api:folders-20261021 imagePullPolicy: Never ports: - name: http @@ -188,8 +188,12 @@ spec: 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-shared-jobs + value: mist-folder-jobs volumeMounts: - name: storage mountPath: /storage @@ -221,7 +225,7 @@ spec: volumes: - name: storage persistentVolumeClaim: - claimName: mist-shared-api + claimName: mist-folder-api - name: auth-data persistentVolumeClaim: claimName: mist-auth-data diff --git a/deploy/k3s/storage/README.md b/deploy/k3s/storage/README.md new file mode 100644 index 0000000..9a15f42 --- /dev/null +++ b/deploy/k3s/storage/README.md @@ -0,0 +1,144 @@ +# 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/grow-quietbox-store.py b/deploy/k3s/storage/grow-quietbox-store.py deleted file mode 100644 index 4b9997a..0000000 --- a/deploy/k3s/storage/grow-quietbox-store.py +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/python3 -"""Online sparse expansion of the known Mist loop image; preserves existing data.""" -import argparse -import os -from pathlib import Path -import stat -import subprocess - -GIB = 1024**3 -image = Path('/srv/mist-storage-volume.img') -root = Path('/srv/mist-storage') -def run(*args): return subprocess.check_output(args,text=True,stderr=subprocess.STDOUT).strip() - -parser = argparse.ArgumentParser() -parser.add_argument('--gib',type=int,default=1600) -args = parser.parse_args() -if os.geteuid()!=0: raise SystemExit('Requires the actual QuietBox host namespace') -if image.is_symlink() or not stat.S_ISREG(image.stat().st_mode): raise SystemExit('Unknown image') -if run('blkid','-s','LABEL','-o','value',str(image))!='mist-shared': raise SystemExit('Unknown filesystem label') -device = run('findmnt','-n','-o','SOURCE','--mountpoint',str(root)) -if Path(run('losetup','-n','-O','BACK-FILE',device))!=image: raise SystemExit('Unknown backing device') -size=args.gib*GIB -if not 100<=args.gib<=2**20 or size fs.f_bavail*fs.f_frsize-200*GIB: raise SystemExit('Insufficient host space after 200 GiB reserve') -# Capacity is sparse: reserve no large amount of host space in advance. -# The guard above checks that the requested capacity could fit with host headroom. -with image.open('r+b') as backing: - backing.truncate(size) -subprocess.run(['losetup','-c',device],check=True) -subprocess.run(['resize2fs',device],check=True) -print('Known shared image expanded without bulk preallocation:',args.gib,'GiB') -subprocess.run(['df','-h',str(root),'/'],check=True) diff --git a/deploy/k3s/storage/mist-team-storage.service b/deploy/k3s/storage/mist-team-storage.service index 8981428..2ffc705 100644 --- a/deploy/k3s/storage/mist-team-storage.service +++ b/deploy/k3s/storage/mist-team-storage.service @@ -1,12 +1,13 @@ [Unit] -Description=Mist bounded team filesystem provisioning +Description=Mist shared team folders and usage reporting After=local-fs.target nfs-server.service -RequiresMountsFor=/srv/mist-storage +RequiresMountsFor=/srv/mist-data Requires=nfs-server.service [Service] Type=simple -ExecStart=/usr/bin/python3 /usr/local/lib/mist/team_volumes.py +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 diff --git a/deploy/k3s/storage/setup-quietbox.sh b/deploy/k3s/storage/setup-quietbox.sh index e9484b7..799941f 100755 --- a/deploy/k3s/storage/setup-quietbox.sh +++ b/deploy/k3s/storage/setup-quietbox.sh @@ -1,50 +1,41 @@ -#!/bin/bash -# Run as root in QuietBox's host mount namespace. Never formats a physical disk. +#!/usr/bin/env bash +# Initialize ordinary storage folders on QuietBox. Never formats/mounts a disk. set -euo pipefail -image=/srv/mist-storage-volume.img -root=/srv/mist-storage -mkdir -p "$root" -if mountpoint -q "$root"; then - [ -f "$image" ] && [ "$(blkid -s LABEL -o value "$image")" = mist-shared ] - source=$(findmnt -n -o SOURCE --target "$root") - [ "$(losetup -n -O BACK-FILE "$source")" = "$image" ] -else - if [ -n "$(ls -A "$root")" ]; then echo 'Refusing to cover nonempty storage directory' >&2; exit 1; fi - if [ ! -e "$image" ]; then - (set -o noclobber; : > "$image") - chmod 0600 "$image" - truncate -s 100G "$image" - mkfs.ext4 -m 1 -L mist-shared "$image" - fi - # Check this is our ext4 image before mounting an existing file. - [ "$(blkid -s LABEL -o value "$image")" = mist-shared ] - mount -o loop,nodev,nosuid "$image" "$root" +[ "$(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 -if ! grep -q '^/srv/mist-storage-volume.img ' /etc/fstab; then - echo '/srv/mist-storage-volume.img /srv/mist-storage ext4 loop,nodev,nosuid 0 0' >> /etc/fstab +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 -chown 65532:65532 "$root" -chmod 0750 "$root" -install -d -o 65532 -g 65532 -m 0700 "$root/metadata" "$root/metadata/datasets" "$root/.uploads" -install -d -o 65532 -g 65532 -m 0755 "$root/datasets" "$root/jobs" "$root/legacy" -mkdir -p /etc/exports.d /etc/nfs.conf.d -cat > /etc/exports.d/mist.exports <<'EXPORT' -/srv/mist-storage 10.0.0.175(rw,sync,crossmnt,no_subtree_check,root_squash,anonuid=65532,anongid=65532) 10.0.0.112(rw,sync,crossmnt,no_subtree_check,root_squash,anonuid=65532,anongid=65532) +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 -mkdir -p /etc/systemd/system/nfs-server.service.d -printf '[Unit]\nRequiresMountsFor=/srv/mist-storage\n' > /etc/systemd/system/nfs-server.service.d/mist-storage.conf -systemctl daemon-reload systemctl enable --now nfs-server exportfs -ra -if ufw status | grep -q "Status: active"; then - ufw allow from 10.0.0.175 to any port 2049 proto tcp comment mist-nfs - ufw allow from 10.0.0.112 to any port 2049 proto tcp comment mist-nfs +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 -mountpoint "$root" +findmnt -T "$root" df -h "$root" -exportfs -v diff --git a/deploy/k3s/storage/setup-storage-link.sh b/deploy/k3s/storage/setup-storage-link.sh new file mode 100644 index 0000000..33ced6c --- /dev/null +++ b/deploy/k3s/storage/setup-storage-link.sh @@ -0,0 +1,35 @@ +#!/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 index fb9bee0..5298e0d 100644 --- a/deploy/k3s/storage/shared.yaml +++ b/deploy/k3s/storage/shared.yaml @@ -1,62 +1,62 @@ apiVersion: v1 kind: PersistentVolume metadata: - name: mist-shared-api + name: mist-folder-api spec: capacity: - storage: 1600Gi + storage: 1Gi accessModes: [ReadWriteMany] persistentVolumeReclaimPolicy: Retain storageClassName: "" claimRef: - name: mist-shared-api + name: mist-folder-api namespace: mist-system - mountOptions: [nfsvers=4.2, hard, timeo=100, retrans=2] + mountOptions: [nfsvers=4.1, hard, timeo=100, retrans=2] nfs: - server: 10.0.0.112 - path: /srv/mist-storage + server: 192.168.240.2 + path: /srv/mist-data --- apiVersion: v1 kind: PersistentVolumeClaim metadata: - name: mist-shared-api + name: mist-folder-api namespace: mist-system spec: storageClassName: "" - volumeName: mist-shared-api + volumeName: mist-folder-api accessModes: [ReadWriteMany] resources: requests: - # Existing static NFS claim size stays unchanged; actual capacity is in PV/statfs. - storage: 100Gi + # Static NFS binding declaration, not a quota. API reports real disk space. + storage: 1Gi --- apiVersion: v1 kind: PersistentVolume metadata: - name: mist-shared-jobs + name: mist-folder-jobs spec: capacity: - storage: 100Gi + storage: 1Gi accessModes: [ReadWriteMany] persistentVolumeReclaimPolicy: Retain storageClassName: "" claimRef: - name: mist-shared-jobs + name: mist-folder-jobs namespace: mist - mountOptions: [nfsvers=4.2, hard, timeo=100, retrans=2] + mountOptions: [nfsvers=4.1, hard, timeo=100, retrans=2] nfs: - server: 10.0.0.112 - path: /srv/mist-storage + server: 192.168.240.2 + path: /srv/mist-data --- apiVersion: v1 kind: PersistentVolumeClaim metadata: - name: mist-shared-jobs + name: mist-folder-jobs namespace: mist spec: storageClassName: "" - volumeName: mist-shared-jobs + volumeName: mist-folder-jobs accessModes: [ReadWriteMany] resources: requests: - storage: 100Gi + storage: 1Gi diff --git a/deploy/k3s/storage/team_folders.py b/deploy/k3s/storage/team_folders.py new file mode 100644 index 0000000..50c6efc --- /dev/null +++ b/deploy/k3s/storage/team_folders.py @@ -0,0 +1,151 @@ +#!/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/team_volumes.py b/deploy/k3s/storage/team_volumes.py deleted file mode 100644 index 9ea777a..0000000 --- a/deploy/k3s/storage/team_volumes.py +++ /dev/null @@ -1,252 +0,0 @@ -#!/usr/bin/python3 -"""QuietBox host provisioning agent. No network listener or cluster credentials. - -Only API-owned manifests outside all workload mounts request capacity. Filesystem -images enforce capacity on every writer, including jobs. Never shrink/delete or -format an existing unknown image; preserve mounts and original storage data. -""" -import json -import hashlib -import shutil -import os -from pathlib import Path -import re -import stat -import subprocess -import tempfile -import time - -ROOT = Path('/srv/mist-storage') -REQUESTS = ROOT / 'metadata/provision-requests' -STATUS = ROOT / 'metadata/provision-status' -IMAGES = ROOT / '.team-volumes' -PATTERN = re.compile(r'^team-[a-f0-9]{16}$') -GIB = 1024 ** 3 - - -def run(*args): - return subprocess.check_output(args, text=True, stderr=subprocess.STDOUT).strip() - - -def atomic(path, data, mode=0o600, owner=None): - path.parent.mkdir(parents=True, exist_ok=True) - fd, tmp = tempfile.mkstemp(prefix='.mist-', dir=path.parent) - try: - with os.fdopen(fd, 'w') as output: - output.write(data) - output.flush() - os.fsync(output.fileno()) - os.chmod(tmp, mode) - if owner: - os.chown(tmp, *owner) - os.replace(tmp, path) - finally: - if os.path.exists(tmp): - os.unlink(tmp) - - -def manifest(path): - if not PATTERN.fullmatch(path.stem) or path.is_symlink(): - raise ValueError('invalid manifest path') - st = path.stat() - if not stat.S_ISREG(st.st_mode) or st.st_uid != 65532 or st.st_size > 1024: - raise ValueError('manifest must be a bounded API-owned regular file') - body = json.loads(path.read_text()) - if set(body) not in ({'id','storage_gib'},{'id','storage_gib','model_storage_gib'}) or body['id'] != path.stem: - raise ValueError('invalid manifest schema') - body.setdefault('model_storage_gib', body['storage_gib']) - for key in ['storage_gib','model_storage_gib']: - size = body[key] - if type(size) is not int or not 1 <= size <= budget(): - raise ValueError('storage outside the configured allocation budget') - return body - - -def provision_volume(team, gib, model=False): - image = IMAGES / (team + ('-models' if model else '') + '.img') - mount = ROOT / 'teams' / team - if model: mount = mount / '.models' - label = ('model-' if model else 'mist-') + team[-10:] - size = gib * GIB - created = False - if not image.exists(): - fd = os.open(image, os.O_CREAT | os.O_EXCL | os.O_WRONLY | os.O_NOFOLLOW, 0o600) - with os.fdopen(fd, 'wb') as file: - file.truncate(size) - run('mkfs.ext4', '-q', '-m', '1', '-L', label, str(image)) - created = True - if image.is_symlink() or not stat.S_ISREG(image.stat().st_mode): - raise ValueError('unknown image type') - if run('blkid', '-s', 'LABEL', '-o', 'value', str(image)) != label: - raise ValueError('refusing an unknown filesystem image') - old_size = image.stat().st_size - if old_size > size: - raise ValueError('refusing to shrink storage') - mount.mkdir(parents=True, exist_ok=True) - mounted = subprocess.run(['mountpoint', '-q', str(mount)]).returncode == 0 - if mounted: - device = run('findmnt', '-n', '-o', 'SOURCE', '--mountpoint', str(mount)) - if Path(run('losetup', '-n', '-O', 'BACK-FILE', device)) != image: - raise ValueError('mount is backed by an unknown device') - else: - if any(mount.iterdir()): - raise ValueError('refusing to cover a nonempty directory') - run('mount', '-o', 'loop,nodev,nosuid', str(image), str(mount)) - device = run('findmnt', '-n', '-o', 'SOURCE', '--mountpoint', str(mount)) - if old_size < size: - with image.open('r+b') as file: - file.truncate(size) - run('losetup', '-c', device) - # Retry online growth even if the preceding run resized the image but - # crashed before resizing ext4. - filesystem_bytes = os.statvfs(mount).f_blocks * os.statvfs(mount).f_frsize - if filesystem_bytes < size - 64 * 1024 ** 2: - run('losetup', '-c', device) - run('resize2fs', device) - os.chown(mount, 65532, 65532) - os.chmod(mount, 0o750) - # The filesystem root and metadata stay outside selected workload subpaths. - for name in ([] if model else ['metadata', 'metadata/datasets', '.uploads']): - directory = mount / name - directory.mkdir(parents=True, exist_ok=True) - os.chown(directory, 65532, 65532) - os.chmod(directory, 0o700) - for name in (['common','members'] if model else ['datasets', 'jobs', 'legacy', 'common', 'members']): - directory = mount / name - directory.mkdir(exist_ok=True) - os.chown(directory, 65532, 65532) - os.chmod(directory, 0o755) - fstab = Path('/etc/fstab') - entry = f'{image} {mount} ext4 loop,nodev,nosuid,x-systemd.requires-mounts-for=/srv/mist-storage 0 0\n' - contents = fstab.read_text() - if not any(line.startswith(str(image) + ' ') for line in contents.splitlines()): - with fstab.open('a') as file: - file.write(entry) - uuid = run('blkid', '-s', 'UUID', '-o', 'value', str(image)) - opts = f'rw,sync,no_subtree_check,root_squash,anonuid=65532,anongid=65532,fsid={uuid}' - export = f'{mount} 10.0.0.175({opts}) 10.0.0.112({opts})\n' - export_path = Path('/etc/exports.d') / (team + ('-models' if model else '') + '.exports') - changed = not export_path.exists() or export_path.read_text() != export - if changed: - atomic(export_path, export, 0o644) - if changed or created: - run('exportfs', '-ra') - return {'state': 'Ready', 'storage_gib': gib, 'filesystem_uuid': uuid} - - -def budget(): - configured = int(os.environ.get('MIST_TEAM_STORAGE_BUDGET_GIB', '1500')) - if not 2 <= configured <= 2**20: raise ValueError('invalid allocation budget') - fs = os.statvfs(ROOT) - return max(0, min(configured, fs.f_blocks * fs.f_frsize // GIB - 20)) - - -def fingerprint(path): - result = {} - for current, directories, files in os.walk(path, followlinks=False): - for entry in [Path(current), *[Path(current)/name for name in directories+files]]: - relative = str(entry.relative_to(path)) - if relative in result: continue - st = entry.lstat() - if stat.S_ISLNK(st.st_mode): data = ('link', os.readlink(entry)) - elif stat.S_ISDIR(st.st_mode): data = ('directory',) - elif stat.S_ISREG(st.st_mode): - digest = hashlib.sha256() - with entry.open('rb') as source: - while chunk := source.read(1024*1024): digest.update(chunk) - data = ('file', st.st_size, digest.hexdigest()) - else: raise ValueError('cannot migrate special output files') - result[relative] = (data, stat.S_IMODE(st.st_mode), st.st_uid, st.st_gid) - return result - - -def migrate_outputs(team): - root = ROOT/'teams'/team - scopes = [root/'common'] - scopes += [path for path in (root/'members').iterdir() if path.is_dir() and not path.is_symlink()] - for scope in scopes: - source = scope/'jobs' - if not source.exists(): continue - if source.is_symlink(): raise ValueError('unsafe legacy output root') - scope_target = root/'.models'/scope.relative_to(root) - if scope_target.is_symlink(): raise ValueError('unsafe model scope') - scope_target.mkdir(parents=True, exist_ok=True) - os.chown(scope_target,65532,65532); os.chmod(scope_target,0o755) - target = scope_target/'jobs' - target.mkdir(parents=True, exist_ok=True) - os.chown(target,65532,65532); os.chmod(target,0o755) - for job in source.iterdir(): - if job.is_symlink() or not job.is_dir(): raise ValueError('unsafe legacy job directory') - destination = target/job.name - before = fingerprint(job) - if destination.exists(): - if destination.is_symlink() or fingerprint(destination) != before: - raise ValueError('refusing to overwrite conflicting model results') - else: - stage = target/('.mist-migration-'+job.name) - if stage.exists(): - if stage.is_symlink(): raise ValueError('unsafe migration stage') - shutil.rmtree(stage) # Only a controlled duplicate; original still exists. - shutil.copytree(job,stage,symlinks=True) - # copytree preserves modes but not ownership; retain both for API access. - for relative, (_,mode,uid,gid) in before.items(): - item = stage/relative - os.chown(item,uid,gid,follow_symlinks=False) - if not item.is_symlink(): os.chmod(item,mode) - if fingerprint(stage) != before: raise ValueError('output migration checksum mismatch') - os.rename(stage,destination) - shutil.rmtree(job) # Complete, matching copy is published before removal. - - -def provision(body): - team = body['id'] - dataset = provision_volume(team,body['storage_gib']) - models = provision_volume(team,body['model_storage_gib'],model=True) - migrate_outputs(team) - return {**dataset, 'model_storage_gib':body['model_storage_gib'], 'model_filesystem_uuid':models['filesystem_uuid'], 'models_migrated':True} - - -def main(): - if os.geteuid() != 0: - raise SystemExit('Run as root in the host mount namespace') - if subprocess.run(['mountpoint', '-q', str(ROOT)]).returncode: - raise SystemExit('Base Mist storage is not mounted') - for directory in [REQUESTS, STATUS]: - directory.mkdir(parents=True, exist_ok=True) - os.chown(directory, 65532, 65532) - os.chmod(directory, 0o700) - IMAGES.mkdir(exist_ok=True) - os.chmod(IMAGES, 0o700) - os.chown(IMAGES, 0, 0) - (ROOT / 'teams').mkdir(exist_ok=True) - os.chown(ROOT / 'teams', 65532, 65532) - os.chmod(ROOT / 'teams', 0o755) - # Ready records from a previous boot must not authorize an unmounted path. - for path in STATUS.glob('team-*.json'): - atomic(path, json.dumps({'state': 'Provisioning'}), 0o600, (65532, 65532)) - while True: - desired = [] - for path in sorted(REQUESTS.glob('team-*.json')): - try: - desired.append(manifest(path)) - except Exception as error: - print(f'Manifest rejected: {path.name}: {error}', flush=True) - if sum(body['storage_gib']+body['model_storage_gib'] for body in desired) > budget(): - print('Allocation budget exceeded; provisioning refused', flush=True) - time.sleep(2) - continue - for body in desired: - try: - status = provision(body) - except Exception as error: - status = {'state': 'Error', 'storage_gib': body['storage_gib'], 'error': str(error)} - print(f"Provisioning failed: {body['id']}: {error}", flush=True) - path = STATUS / (body['id'] + '.json') - serialized = json.dumps(status, sort_keys=True) - if not path.exists() or path.read_text() != serialized: - atomic(path, serialized, 0o600, (65532, 65532)) - time.sleep(2) - - -if __name__ == '__main__': - main() diff --git a/deploy/k3s/storage/test_folders.py b/deploy/k3s/storage/test_folders.py new file mode 100644 index 0000000..714afd9 --- /dev/null +++ b/deploy/k3s/storage/test_folders.py @@ -0,0 +1,95 @@ +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/storage/test_migration.py b/deploy/k3s/storage/test_migration.py deleted file mode 100644 index e6f7403..0000000 --- a/deploy/k3s/storage/test_migration.py +++ /dev/null @@ -1,82 +0,0 @@ -"""Run as host root in an isolated temporary directory; never uses live stores.""" -import importlib.util -import json -import os -from pathlib import Path -import shutil -import tempfile -import unittest - -spec=importlib.util.spec_from_file_location('volumes',Path(__file__).with_name('team_volumes.py')) -volumes=importlib.util.module_from_spec(spec);spec.loader.exec_module(volumes) - -@unittest.skipUnless(os.geteuid()==0,'Ownership preservation requires root') -class MigrationTests(unittest.TestCase): - def setUp(self): - self.tmp=tempfile.TemporaryDirectory(prefix='mist-migration-test-') - self.previous=volumes.ROOT - volumes.ROOT=Path(self.tmp.name) - self.team='team-aaaaaaaaaaaaaaaa' - self.root=volumes.ROOT/'teams'/self.team - (self.root/'members').mkdir(parents=True) - self.source=self.root/'common'/'jobs'/'mist-test' - self.source.mkdir(parents=True) - (self.source/'weights.bin').write_bytes(b'EXISTING_WEIGHTS') - (self.source/'weights.bin').chmod(0o600) - os.symlink('/missing-outside-file',self.source/'escape') - self.target=self.root/'.models'/'common'/'jobs'/'mist-test' - def tearDown(self): - volumes.ROOT=self.previous - self.tmp.cleanup() - def test_preserve_content_modes_ownership_symlinks_and_repeat(self): - before=volumes.fingerprint(self.source) - volumes.migrate_outputs(self.team) - self.assertEqual(volumes.fingerprint(self.target),before) - self.assertFalse(self.source.exists()) - volumes.migrate_outputs(self.team) - self.assertEqual(volumes.fingerprint(self.target),before) - def test_retry_after_atomic_publish_before_original_removal(self): - shutil.copytree(self.source,self.target,symlinks=True) - volumes.migrate_outputs(self.team) - self.assertFalse(self.source.exists()) - self.assertEqual((self.target/'weights.bin').read_bytes(),b'EXISTING_WEIGHTS') - def test_conflict_does_not_overwrite_either_copy(self): - self.target.mkdir(parents=True) - (self.target/'weights.bin').write_bytes(b'DIFFERENT_MODEL') - with self.assertRaisesRegex(ValueError,'conflicting'): volumes.migrate_outputs(self.team) - self.assertEqual((self.source/'weights.bin').read_bytes(),b'EXISTING_WEIGHTS') - self.assertEqual((self.target/'weights.bin').read_bytes(),b'DIFFERENT_MODEL') - def test_partial_stage_and_symlink_job_directory(self): - stage=self.target.parent/('.mist-migration-'+self.source.name) - stage.mkdir(parents=True) - (stage/'partial').write_bytes(b'partial') - volumes.migrate_outputs(self.team) - self.assertFalse(stage.exists()) - self.assertEqual((self.target/'weights.bin').read_bytes(),b'EXISTING_WEIGHTS') - os.symlink('/outside',self.source) - with self.assertRaisesRegex(ValueError,'unsafe legacy job'): volumes.migrate_outputs(self.team) - def test_member_scope_remains_writable_by_api_owner(self): - member=self.root/'members'/'member-hash' - old=member/'jobs'/'mist-member' - old.mkdir(parents=True) - (old/'weights.bin').write_bytes(b'MEMBER_WEIGHTS') - before=volumes.fingerprint(old) - volumes.migrate_outputs(self.team) - scope=self.root/'.models'/'members'/'member-hash' - self.assertEqual(scope.stat().st_uid,65532) - self.assertEqual(scope.stat().st_mode & 0o777,0o755) - self.assertEqual(volumes.fingerprint(scope/'jobs'/'mist-member'),before) - # Restart repairs a formerly root-owned, inaccessible parent as well. - os.chown(scope,0,0);os.chmod(scope,0o700) - volumes.migrate_outputs(self.team) - self.assertEqual(scope.stat().st_uid,65532) - self.assertEqual(scope.stat().st_mode & 0o777,0o755) - def test_manifest_schema_owner_and_numeric_bounds(self): - request=volumes.ROOT/(self.team+'.json') - for size in [False,0,-1,volumes.budget()+1]: - request.write_text(json.dumps({'id':self.team,'storage_gib':1,'model_storage_gib':size}));os.chown(request,65532,65532) - with self.assertRaises(ValueError):volumes.manifest(request) - request.write_text(json.dumps({'id':self.team,'storage_gib':1,'model_storage_gib':2}));os.chown(request,65532,65532) - self.assertEqual(volumes.manifest(request)['model_storage_gib'],2) - -if __name__=='__main__':unittest.main() diff --git a/deploy/k3s/verify_split_storage.py b/deploy/k3s/verify_split_storage.py deleted file mode 100644 index 81ae113..0000000 --- a/deploy/k3s/verify_split_storage.py +++ /dev/null @@ -1,123 +0,0 @@ -"""Verify migrated/split storage with small data and real accelerator training. - -No quota exhaustion or bulk upload. Credentials stay in the local protected file. -""" -import hashlib -import http.cookiejar -import json -import math -import os -from pathlib import Path -import subprocess -import time -import urllib.error -import urllib.parse -import urllib.request - -BASE = 'http://100.73.139.66:8088' -TEAM = 'team-db3933031a6c2437' -OUT = Path('/home/utmist/mist-department-results-2026-10-09') -REPO = Path(__file__).resolve().parents[2] -credentials = json.loads(Path('/home/utmist/.config/mist/admin-bootstrap.json').read_text()) -client = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar())) -report = {'checks': {}, 'jobs': [], 'bulk_testing': False} - -def api(path, data=None, method=None, expected=200, raw=False): - body = data if isinstance(data, bytes) else json.dumps(data).encode() if data is not None else None - r = urllib.request.Request(BASE + path, data=body, method=method, - headers={'Origin': BASE, 'Content-Type': 'application/octet-stream' if isinstance(data, bytes) else 'application/json', 'X-Mist-Team': TEAM}) - try: - response = client.open(r, timeout=30) - except urllib.error.HTTPError as e: - if e.code != expected: raise RuntimeError(f'{path}: HTTP {e.code}: {e.read().decode()}') from None - return json.load(e) - with response: - assert response.status == expected, (path, response.status) - return response.read() if raw else json.load(response) - -def passed(name): - report['checks'][name] = True - print('PASS ' + name, flush=True) - -def download(job, path): - return api(f'/api/jobs/{job}/files/download?path=' + urllib.parse.quote(path, safe=''), raw=True) - -try: - api('/auth/sign-in/email', {'email': credentials['email'], 'password': credentials['password']}) - teams = api('/api/teams') - team = next(t for t in teams['teams'] if t['id'] == TEAM) - storage = api('/api/storage') - assert storage['model_allocation_gib'] == team['policy']['model_storage_gib'] - assert storage['dataset_allocation_gib'] == team['policy']['storage_gib'] - assert storage['model_capacity_bytes'] > 0 and storage['upload_limit_bytes'] > 0 - report['storage'] = storage - passed('both_pools_ready_with_independent_capacities') - for field in ['storage_gib', 'model_storage_gib']: - policy = dict(team['policy']); policy[field] -= 1 - api('/api/teams/' + TEAM, {'policy': policy}, method='PATCH', expected=400) - policy = dict(team['policy']); policy['storage_gib'] = teams['storage_budget_gib'] - api('/api/teams/' + TEAM, {'policy': policy}, method='PATCH', expected=400) - after = next(t for t in api('/api/teams')['teams'] if t['id'] == TEAM) - assert after['policy'] == team['policy'] - passed('both_shrinks_and_combined_overcommit_rejected_without_mutation') - # Local evidence retains the exact small checkpoint bytes downloaded before migration. - evidence = OUT - old_jobs = {'nvidia':'mist-8644eccafaf0b42c3d4add05', 'tenstorrent':'mist-e5c04ea2cc126a786d6fe783'} - count = 0 - for backend, job in old_jobs.items(): - files = api('/api/jobs/' + job + '/files')['files'] - for f in files: - saved = evidence / (backend + '-' + f['path'].replace('/', '_')) - if saved.exists(): - assert hashlib.sha256(saved.read_bytes()).digest() == hashlib.sha256(download(job, f['path'])).digest() - count += 1 - assert any(f['path'].endswith('.pt') for f in files) - assert count >= 4, count - report['preserved_checkpoint_files'] = count - passed('old_accelerator_checkpoints_preserved_byte_for_byte') - matrix = lambda n,m,p: [[math.sin((i+1)*(j+2)+p) for j in range(m)] for i in range(n)] - x, vx = matrix(128,32,.1), matrix(64,32,1.2) - w = [[v*.1 for v in row] for row in matrix(32,32,2.3)] - multiply = lambda a: [[sum(row[j]*w[j][k] for j in range(32)) for k in range(32)] for row in a] - payload = json.dumps({'x':x,'y':multiply(x),'validation_x':vx,'validation_y':multiply(vx)}).encode() - assert len(payload) < 300000 - ds = api('/api/datasets?filename=regression.json&name=Small%20split%20storage%20check&scope=common', payload, expected=201) - assert ds['size'] == len(payload) and ds['sha256'] == hashlib.sha256(payload).hexdigest() - report['dataset_bytes'] = len(payload) - passed('small_dataset_uploaded_with_verified_checksum') - for backend in ['nvidia', 'tenstorrent']: - training = (REPO / 'deploy/k3s/examples/train_shared.py').read_text().replace('args = parser.parse_args()', - f"args = parser.parse_args(['{backend}','--expected-devices','2','--output','/outputs'])") - job = api('/api/jobs', {'name':'Split storage ' + backend, 'type':'command', 'accelerator':backend, - 'device_count':2 if backend == 'nvidia' else 1, 'script':training, 'script_name':'train.py', - 'dataset_id':ds['id'], 'storage_scope':'common', 'timeout_seconds':600}, expected=201)['job_id'] - report['jobs'].append({'id':job,'backend':backend}) - for _ in range(240): - result = api('/api/jobs/' + job) - if result['job_state'] in ['Success','Failure','Cancelled']: break - time.sleep(2) - assert result['job_state'] == 'Success', result - logs = api('/api/jobs/' + job + '/logs')['logs'] - assert 'DATASET_LOADED file=/inputs/regression.json' in logs and 'TRAINING_PASSED' in logs - files = api('/api/jobs/' + job + '/files')['files'] - assert any(f['path'].endswith('.pt') for f in files) - for f in files: assert len(download(job, f['path'])) == f['size'] - (OUT / ('split-' + backend + '.log')).write_text(logs) - report['jobs'][-1]['node'] = result['node'] - # Inspect actual Kubernetes volumes, rather than inferring them from status. - detail = json.loads(subprocess.check_output(['k3s','kubectl','-n','mist-' + TEAM,'get','job',job,'-o','json'], - env={**os.environ,'KUBECONFIG':'/home/utmist/.kube/config'})) - claims = {v['name']:v['persistentVolumeClaim']['claimName'] for v in detail['spec']['template']['spec']['volumes'] if 'persistentVolumeClaim' in v} - assert claims['input'] == 'team-storage' and claims['checkpoints'] == 'team-models', claims - pod = detail['spec']['template']['spec'] - assert next(v for v in pod['volumes'] if v['name'] == 'input')['persistentVolumeClaim']['readOnly'] - mounts = pod['containers'][0]['volumeMounts'] - assert next(m for m in mounts if m['mountPath'] == '/inputs')['readOnly'] - assert next(m for m in mounts if m['mountPath'] == '/outputs')['subPath'].endswith('/jobs/' + job + '/outputs') - passed(backend + '_uploaded_input_readonly_and_models_saved_in_separate_pvc') - # Preserve job outputs, remove only this small verifier dataset after its jobs finish. - api('/api/datasets/' + ds['id'], method='DELETE') - passed('own_small_probe_removed_after_terminal_jobs') -finally: - api('/auth/sign-out', {}) - (OUT / 'split-storage-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 new file mode 100644 index 0000000..5e8c431 --- /dev/null +++ b/deploy/k3s/verify_storage_folders.py @@ -0,0 +1,176 @@ +#!/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 new file mode 100644 index 0000000..5efbd03 --- /dev/null +++ b/deploy/k3s/verify_storage_folders_browser.mjs @@ -0,0 +1,59 @@ +// 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/private/README.md b/deploy/private/README.md index 5a8fe9e..8ba6b6f 100644 --- a/deploy/private/README.md +++ b/deploy/private/README.md @@ -12,18 +12,18 @@ Setup: [k3s](../../docs/content/k3s-setup.md), [Tailscale/SSH](../../docs/conten | 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 | 1,600 GiB sparse image; separate bounded dataset/model filesystems | -| Team provisioning agent | QuietBox `mist-team-storage.service` | Validated manifests, fstab mounts and NFS exports | +| 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:departments-20261009`, imported into main's k3s containerd. +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.25.1, Node 22.16+, working Docker access, the private kubeconfig, existing +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. @@ -66,56 +66,23 @@ rollback for research users. The pre-cleanup department release `3431ffd` contai the verified team architecture; rebuild a compatible department version rather than restoring the archived pre-platform main for normal users. -## QuietBox host setup +## QuietBox host setup and storage cutover -The existing `deploy/k3s/storage/setup-quietbox.sh` creates only the bounded base -image when missing; it never formats a physical disk. Base exports use `crossmnt` -to expose explicitly mounted child filesystems. Before changing storage, inspect -mounts and existing exports. Do not replace a live filesystem with an empty image. +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. -Install the new provisioning service as root on QuietBox: +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. -```bash -install -d -m 0755 /usr/local/lib/mist -install -m 0755 deploy/k3s/storage/team_volumes.py /usr/local/lib/mist/team_volumes.py -install -m 0644 deploy/k3s/storage/mist-team-storage.service /etc/systemd/system/ -systemctl daemon-reload -systemctl enable --now mist-team-storage -``` - -The agent reads API-owned requests outside workload mounts, enforces the combined -1,500 GiB dataset + model allocation budget, refuses unknown images/nonempty covered directories, -and supports online growth. Team disable/removal never deletes files or frees an -allocation. Shrinking is rejected. New teams default to 200 GiB datasets and 500 GiB models/results. Per-member -folders share the applicable team pool's hard filesystem capacity. PV/PVC sizes are declarations; ext4 enforces actual writes. - -NFS permits only `10.0.0.175` and `10.0.0.112` over the existing LAN. Keep QuietBox -online and its router/network connection intact. No separate switch or direct -cable is required for this pilot. - -## Dataset request limits - -Uploads are bounded by actual dataset free space minus a 64 MiB metadata reserve. -An optional `MIST_MAX_DATASET_GIB` ceiling can lower this (1–1,048,576 GiB). -There is no fixed 64 GiB cap. Nginx streams bodies without buffering; authenticated -API admission enforces capacity. Browser and Go allow 24-hour transfers. ZIP -archives need both staging and expanded space. Cancellation is supported; -interrupted transfers restart from the beginning. - -## Existing store expansion - -`storage/grow-quietbox-store.py --gib 1600` expands only the known, correctly -labelled image, after checking host headroom. It grows sparsely and never formats -physical disks or shrinks existing filesystems. The current image was expanded -online; its initial physical preallocation was released with -`fstrim -v /srv/mist-storage`. Do not unlink or truncate a mounted backing file. -The host had about 1.9 TiB free after release. Logical capacity is not a physical -reservation; account for future image growth alongside existing research files. - -Migration must run without active team jobs and with API submissions paused. -Restart the updated storage agent, wait for both pool capacities to be Ready, -then deploy the updated API. Old policies with no `model_storage_gib` retain the -old allocation independently for each pool. Preserve credentials/auth data. +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 @@ -169,8 +136,8 @@ QuietBox: ```bash systemctl is-active k3s-agent tailscaled nfs-server mist-team-storage mist-portal-firewall -mountpoint /srv/mist-storage -df -h /srv/mist-storage /srv/mist-storage/teams/team-* +findmnt -T /srv/mist-data +df -h /srv/mist-data ``` With root access, inspect `exportfs -v` and the provisioning service journal if a @@ -182,13 +149,12 @@ real login and show two NVIDIA GPUs/four TT boards. Both nodes must be Ready. - **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, child filesystem mounts and provisioning +- **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. An administrator can grow it within - the allocation budget. Delete only unneeded datasets that have no active job - references. No automatic result cleanup was added. +- **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 diff --git a/deploy/private/build-and-deploy.sh b/deploy/private/build-and-deploy.sh index 2c2103c..880c4ba 100755 --- a/deploy/private/build-and-deploy.sh +++ b/deploy/private/build-and-deploy.sh @@ -10,10 +10,10 @@ 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:departments-20261009 . +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:departments-20261009 +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 diff --git a/docs/agents/simple-storage-execution.md b/docs/agents/simple-storage-execution.md new file mode 100644 index 0000000..7eaa2ca --- /dev/null +++ b/docs/agents/simple-storage-execution.md @@ -0,0 +1,60 @@ +# 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/scripts/fake-backend.mjs b/scripts/fake-backend.mjs index d267623..0abce2a 100644 --- a/scripts/fake-backend.mjs +++ b/scripts/fake-backend.mjs @@ -493,6 +493,7 @@ const server = http.createServer(async (req, res) => { sendJSON(res, 200, { teams: state.teams, storage_budget_gib: state.storageBudgetGiB, + storage_limits_enforced: false, }) return } @@ -708,7 +709,22 @@ const server = http.createServer(async (req, res) => { // ---------------------------------------------------- if (pathname === '/storage' && req.method === 'GET') { - sendJSON(res, 200, state.storage) + 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 } diff --git a/src/member_auth.go b/src/member_auth.go index 686a8e5..76d6511 100644 --- a/src/member_auth.go +++ b/src/member_auth.go @@ -37,8 +37,8 @@ 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-models", access - clone.inputPVC = "team-storage" + clone.storage, clone.sharedPVC, clone.team = access.Store, "team-results", access + clone.inputPVC = "team-datasets" return &clone } member, _ := r.Context().Value(memberKey{}).(*Member) diff --git a/src/shared_storage.go b/src/shared_storage.go index 5a9b712..5d7c095 100644 --- a/src/shared_storage.go +++ b/src/shared_storage.go @@ -24,7 +24,7 @@ import ( "time" ) -// Physical per-team dataset capacity is the upload limit; no small fixed ceiling. +// 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}$`) @@ -80,9 +80,6 @@ func (s *SharedStorage) availableUploadLimit(team bool) int64 { return 0 } reserve := int64(1024 * 1024 * 1024) - if team { - reserve = 64 * 1024 * 1024 - } return max(0, min(s.uploadLimit, int64(stats.Bavail)*stats.Bsize-reserve)) } func (s *SharedStorage) dataset(id, owner string) (*Dataset, error) { @@ -141,9 +138,6 @@ func (s *SharedStorage) upload(r *http.Request, owner string) (*Dataset, error) return nil, err } reserve := int64(1024 * 1024 * 1024) - if r.Context().Value(teamContextKey{}) != nil { - reserve = 64 * 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) @@ -320,12 +314,20 @@ func (a *App) storageInfo(w http.ResponseWriter, r *http.Request) { } 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 { - var models syscall.Statfs_t - if err := syscall.Statfs(s.resultsRoot(), &models); err != nil { + usage, err := a.teams.storageStatus(e.team.Team.ID) + if err != nil { executorError(w, err) return } - info["model_capacity_bytes"], info["model_available_bytes"] = models.Blocks*uint64(models.Bsize), models.Bavail*uint64(models.Bsize) + 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) diff --git a/src/storage_status.go b/src/storage_status.go new file mode 100644 index 0000000..524dd9f --- /dev/null +++ b/src/storage_status.go @@ -0,0 +1,38 @@ +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 index 6190f48..f9b6389 100644 --- a/src/team_queue.go +++ b/src/team_queue.go @@ -58,7 +58,7 @@ func (s *TeamService) submitTeam(ctx context.Context, e *KubernetesExecutor, req 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+"/"+ds.ContentPath, source.Policy.StorageGiB, true); err != nil { + 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, "" diff --git a/src/team_runtime.go b/src/team_runtime.go index fe5362a..256f21a 100644 --- a/src/team_runtime.go +++ b/src/team_runtime.go @@ -179,10 +179,10 @@ func (s *TeamService) provisionNamespace(ctx context.Context, t *Team) error { return err } } - if err = s.ensureVolume(ctx, t.ID, ns, "team-storage", fmt.Sprintf("teams/%s", t.ID), t.Policy.StorageGiB, false); err != nil { + 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-models", fmt.Sprintf("teams/%s/.models", t.ID), t.Policy.ModelStorageGiB, false); err != nil { + 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()} @@ -209,13 +209,15 @@ func teamNetworkPolicy(ns string) *networkv1.NetworkPolicy { 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.2", "hard", "timeo=100", "retrans=2"}, PersistentVolumeSource: corev1.PersistentVolumeSource{NFS: &corev1.NFSVolumeSource{Server: envOr("MIST_NFS_SERVER", "10.0.0.112"), Path: "/srv/mist-storage/" + path, ReadOnly: readonly}}}} + 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 { diff --git a/src/team_storage.go b/src/team_storage.go index 5351677..d402fb3 100644 --- a/src/team_storage.go +++ b/src/team_storage.go @@ -376,7 +376,7 @@ func (a *App) datasetFiles(w http.ResponseWriter, r *http.Request) { serveStoredFiles(w, r, path, len(parts) == 3) } -// Keep the public common/member tree stable while files live in two quota pools. +// 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)) diff --git a/src/teams.go b/src/teams.go index 4232096..9af698c 100644 --- a/src/teams.go +++ b/src/teams.go @@ -1,7 +1,7 @@ package main // Teams are domain policy, independent of the authentication provider. Identity -// still comes exclusively from Better Auth; ordinary clients never supply roles. +// comes from the authentication service; ordinary clients never supply roles. import ( "context" "crypto/rand" @@ -15,7 +15,6 @@ import ( "path/filepath" "regexp" "sort" - "strconv" "strings" "sync" "syscall" @@ -29,6 +28,8 @@ import ( ) const teamRegistry = "mist-teams" +const maxStorageAllowanceGiB int64 = 1 << 20 +const sharedFolderLayout = "shared-folders-v1" var teamIDPattern = regexp.MustCompile(`^team-[a-f0-9]{16}$`) @@ -39,7 +40,7 @@ type TeamPolicy struct { Tenstorrent int `json:"tenstorrent"` Concurrent int `json:"concurrent"` Queued int `json:"queued"` - StorageGiB int64 `json:"storage_gib"` // Dataset pool; retained API field for compatibility. + 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"` @@ -106,28 +107,18 @@ 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"}} } -func teamStorageBudgetGiB() int64 { - raw := envOr("MIST_TEAM_STORAGE_BUDGET_GIB", "1500") - value, err := strconv.ParseInt(raw, 10, 64) - if err != nil || value < 2 || value > maxDatasetBytes/(1024*1024*1024) { - panic("invalid MIST_TEAM_STORAGE_BUDGET_GIB") - } - return value -} + +// 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 { - budget := teamStorageBudgetGiB() - if envOr("MIST_REQUIRE_TEAM_FILESYSTEM", "true") == "false" { - return budget - } var fs syscall.Statfs_t if syscall.Statfs(s.app.executor.storage.root, &fs) != nil { return 0 } - return max(0, min(budget, int64(fs.Blocks)*fs.Bsize/(1024*1024*1024)-20)) + return int64(fs.Blocks) * fs.Bsize / (1024 * 1024 * 1024) } func normalizeStoragePolicy(p *TeamPolicy) { - // Old teams had one shared pool. Preserve their allocation for each bucket; - // the host migration moves completed outputs into the new model filesystem. + // Preserve legacy callers which supplied one advisory allowance. if p.ModelStorageGiB == 0 { p.ModelStorageGiB = p.StorageGiB } @@ -146,7 +137,7 @@ func validateTeam(team *Team) error { 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 > teamStorageBudgetGiB() || p.ModelStorageGiB < 1 || p.ModelStorageGiB > teamStorageBudgetGiB() || p.RuntimeSeconds < 10 || p.RuntimeSeconds > 86400 { + 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 { @@ -160,7 +151,6 @@ func validateTeam(team *Team) error { return nil } func newTeamService(a *App) *TeamService { - _ = teamStorageBudgetGiB() 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) { @@ -189,13 +179,6 @@ func (s *TeamService) mutate(ctx context.Context, change func(*[]Team) error) er if err = change(&teams); err != nil { return &submissionError{err} } - var budget int64 - for _, t := range teams { - budget += t.Policy.StorageGiB + t.Policy.ModelStorageGiB - } - if budget > s.storageBudgetGiB() { - return &submissionError{fmt.Errorf("total dataset and model allocations cannot exceed the %dGiB storage budget", s.storageBudgetGiB())} - } data, err := json.Marshal(teams) if err != nil { return err @@ -241,59 +224,26 @@ func (s *TeamService) store(t *Team) (*SharedStorage, error) { if err != nil { return nil, errors.New("team storage is being provisioned") } - var ready struct { - State string `json:"state"` - StorageGiB int64 `json:"storage_gib"` - ModelStorageGiB int64 `json:"model_storage_gib"` - } - if json.Unmarshal(status, &ready) != nil || ready.State != "Ready" || ready.StorageGiB < t.Policy.StorageGiB || ready.ModelStorageGiB < t.Policy.ModelStorageGiB { + var ready teamStorageStatus + if json.Unmarshal(status, &ready) != nil || ready.State != "Ready" || ready.Layout != sharedFolderLayout { return nil, errors.New("team storage is not ready") } - if envOr("MIST_REQUIRE_TEAM_FILESYSTEM", "true") != "false" { - var datasetDevice uint64 - for index, pool := range []struct { - path string - gib int64 - }{{filepath.Join(s.app.executor.storage.root, "teams", t.ID), t.Policy.StorageGiB}, {filepath.Join(s.app.executor.storage.root, "teams", t.ID, ".models"), t.Policy.ModelStorageGiB}} { - var fs syscall.Statfs_t - if err := syscall.Statfs(pool.path, &fs); err != nil { - return nil, err - } - // NFS reports a zero statfs fsid on these nodes. The device ID - // identifies distinct mounted superblocks, including NFS child mounts. - entry, err := os.Stat(pool.path) - if err != nil { - return nil, err - } - identity, ok := entry.Sys().(*syscall.Stat_t) - if !ok { - return nil, errors.New("cannot identify team filesystem") - } - if index == 0 { - datasetDevice = uint64(identity.Dev) - } else if uint64(identity.Dev) == datasetDevice { - return nil, errors.New("separate model filesystem is unavailable") - } - capacity := fs.Blocks * uint64(fs.Bsize) - target := uint64(pool.gib) * 1024 * 1024 * 1024 - if capacity > target+64*1024*1024 || capacity < target*9/10 { - return nil, errors.New("bounded team filesystem is unavailable") - } + 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(s.app.executor.storage.root, "teams", t.ID)) + st, err := NewSharedStorage(filepath.Join(base, "datasets")) if err != nil { return nil, err } - st.modelRoot = filepath.Join(st.root, ".models") - if envOr("MIST_REQUIRE_TEAM_FILESYSTEM", "true") == "false" { - if err := os.MkdirAll(st.modelRoot, 0700); err != nil { - return nil, err - } - } + st.modelRoot = filepath.Join(base, "results") + s.stores[t.ID] = st return st, nil } @@ -373,7 +323,7 @@ func (a *App) teamEndpoint(w http.ResponseWriter, r *http.Request) { result = append(result, t) } } - writeJSON(w, 200, map[string]any{"teams": result, "storage_budget_gib": s.storageBudgetGiB()}) + writeJSON(w, 200, map[string]any{"teams": result, "storage_budget_gib": s.storageBudgetGiB(), "storage_limits_enforced": false}) return } if !isAdmin(m) { @@ -463,9 +413,6 @@ func (a *App) teamEndpoint(w http.ResponseWriter, r *http.Request) { } if body.Policy != nil { normalizeStoragePolicy(body.Policy) - if body.Policy.StorageGiB < t.Policy.StorageGiB || body.Policy.ModelStorageGiB < t.Policy.ModelStorageGiB { - return errors.New("storage cannot be shrunk online; existing files are preserved") - } t.Policy = *body.Policy } return validateTeam(t) diff --git a/src/teams_test.go b/src/teams_test.go index b66b325..b444812 100644 --- a/src/teams_test.go +++ b/src/teams_test.go @@ -10,7 +10,6 @@ import ( "os" "path/filepath" "strings" - "syscall" "testing" "time" @@ -45,7 +44,6 @@ func departmentTestApp(t *testing.T) (*App, *fake.Clientset, []Team) { t.Fatal(err) } } - t.Setenv("MIST_REQUIRE_TEAM_FILESYSTEM", "false") 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}} @@ -69,7 +67,12 @@ func departmentTestApp(t *testing.T) (*App, *fake.Clientset, []Team) { t.Cleanup(func() { _ = app.auth.Close() }) for _, team := range teams { - if err = atomicJSON(filepath.Join(root, "metadata/provision-status", team.ID+".json"), map[string]any{"state": "Ready", "storage_gib": team.Policy.StorageGiB, "model_storage_gib": team.Policy.ModelStorageGiB}); err != nil { + 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 { @@ -157,7 +160,7 @@ func TestDepartmentTeamPermissionsAndScopedSharing(t *testing.T) { t.Fatal("revoked grant remains effective") } } -func TestDepartmentPolicyAndStorageBudget(t *testing.T) { +func TestDepartmentPolicyAndAdvisoryStorage(t *testing.T) { a, _, teams := departmentTestApp(t) p := defaultTeamPolicy() p.CPU = "1" @@ -177,12 +180,12 @@ func TestDepartmentPolicyAndStorageBudget(t *testing.T) { 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.Fatal("overcommitted storage budget") + 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) } - _, actual, _ := a.teams.registry(context.Background()) - if actual[0].Policy.StorageGiB != 200 { - t.Fatal("failed transaction changed persisted allocation") + 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) { @@ -197,7 +200,7 @@ func TestDepartmentDurableFairAdmission(t *testing.T) { 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-storage" + 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 { @@ -407,7 +410,7 @@ func TestDepartmentSplitDatasetAndModelPaths(t *testing.T) { claims[volume.Name] = volume.PersistentVolumeClaim.ClaimName } } - if claims["input"] != "team-storage" || claims["checkpoints"] != "team-models" { + if claims["input"] != "team-datasets" || claims["checkpoints"] != "team-results" { t.Fatalf("storage pools not separated: %+v", claims) } store, err := a.teams.store(&team) @@ -439,21 +442,78 @@ func TestDepartmentSplitDatasetAndModelPaths(t *testing.T) { } } -func TestDepartmentMissingModelMountCannotBorrowDatasetCapacity(t *testing.T) { +func TestDepartmentFoldersFailClosedForMissingOrUnsafePaths(t *testing.T) { a, _, teams := departmentTestApp(t) team := teams[0] - var fs syscall.Statfs_t - if err := syscall.Statfs(a.executor.storage.root, &fs); err != nil { + 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) } - gib := (int64(fs.Blocks)*fs.Bsize + 1024*1024*1024 - 1) / (1024 * 1024 * 1024) - team.Policy.StorageGiB, team.Policy.ModelStorageGiB = gib, gib - if err := atomicJSON(filepath.Join(a.executor.storage.root, "metadata/provision-status", team.ID+".json"), map[string]any{"state": "Ready", "storage_gib": gib, "model_storage_gib": gib}); err != nil { + if _, err := a.teams.store(&team); err == nil { + t.Fatal("symlink results directory accepted") + } + if err := os.Remove(results); err != nil { t.Fatal(err) } - t.Setenv("MIST_REQUIRE_TEAM_FILESYSTEM", "true") - if _, err := a.teams.store(&team); err == nil || !strings.Contains(err.Error(), "separate model filesystem") { - t.Fatalf("missing model mount accepted: %v", 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) } } @@ -497,3 +557,31 @@ func TestDepartmentDefaultPoolsAndLegacyPolicy(t *testing.T) { 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/src/api.ts b/web-interface/src/api.ts index 2ed9c62..ddfeda9 100644 --- a/web-interface/src/api.ts +++ b/web-interface/src/api.ts @@ -180,6 +180,13 @@ export const storageAPI = { 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 diff --git a/web-interface/src/components/DatasetsPage.test.tsx b/web-interface/src/components/DatasetsPage.test.tsx new file mode 100644 index 0000000..443dd0d --- /dev/null +++ b/web-interface/src/components/DatasetsPage.test.tsx @@ -0,0 +1,58 @@ +// @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 index 323b6bf..2118ef0 100644 --- a/web-interface/src/components/DatasetsPage.tsx +++ b/web-interface/src/components/DatasetsPage.tsx @@ -19,11 +19,13 @@ import { TeamStorageBrowser } from '#/components/TeamStorageBrowser.tsx' function StorageMeter({ label, capacity, - available, + used, + advisory = false, }: { label: string capacity: number - available: number + used: number + advisory?: boolean }) { return (

- {formatBytes(available)} available + + {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`} + ) } @@ -155,21 +163,46 @@ export function DatasetsPage() {

)} {storage.data?.enabled && ( -
- - {storage.data.model_capacity_bytes !== undefined && - storage.data.model_available_bytes !== undefined && ( +
+

+ 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 && ( Promise }) { - const { storageBudget } = useTeam() const [policy, setPolicyState] = useState(team.policy) const [editing, setEditing] = useState(false) const signature = JSON.stringify(team.policy) @@ -42,18 +41,8 @@ function PolicyForm({ ['tenstorrent', 'Tenstorrent boards (two chips each)', 0, 4], ['concurrent', 'Simultaneous jobs', 1, 16], ['queued', 'Queued jobs', 1, 200], - [ - 'storage_gib', - 'Dataset storage (GiB)', - team.policy.storage_gib, - storageBudget, - ], - [ - 'model_storage_gib', - 'Model storage (GiB)', - team.policy.model_storage_gib, - storageBudget, - ], + ['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 ( @@ -113,18 +102,20 @@ function PolicyForm({ setPolicy({ ...policy, [key]: Number(e.target.value) }) } /> - - setPolicy({ ...policy, [key]: Number(e.target.value) }) - } - className="mist-range w-full mt-3" - /> + {!key.includes('storage') && ( + + setPolicy({ ...policy, [key]: Number(e.target.value) }) + } + className="mist-range w-full mt-3" + /> + )} ))} @@ -148,9 +139,9 @@ function PolicyForm({ />

- Limits apply to the whole team. Dataset and model pools are separate. - Storage can be expanded; shrinking existing filesystems requires an - offline migration. + Compute limits apply to the whole team. Storage allowances are advisory: + teams share the disk, and changing an allowance does not reserve or + remove space.