Merge pull request #439 from ClusterCockpit/dev

Dev
This commit is contained in:
Jan Eitzinger
2025-12-04 06:38:16 +01:00
committed by GitHub
105 changed files with 7352 additions and 2353 deletions
+1
View File
@@ -27,3 +27,4 @@ test_ccms_write_api.sh
/.vscode/* /.vscode/*
dist/ dist/
*.db *.db
.idea
+5 -4
View File
@@ -28,9 +28,8 @@ type NodeStates {
type NodeStatesTimed { type NodeStatesTimed {
state: String! state: String!
type: String! counts: [Int!]!
count: Int! times: [Int!]!
time: Int!
} }
type Job { type Job {
@@ -263,6 +262,7 @@ enum SortByAggregate {
type NodeMetrics { type NodeMetrics {
host: String! host: String!
state: String!
subCluster: String! subCluster: String!
metrics: [JobMetricWithName!]! metrics: [JobMetricWithName!]!
} }
@@ -317,7 +317,7 @@ type Query {
node(id: ID!): Node node(id: ID!): Node
nodes(filter: [NodeFilter!], order: OrderByInput): NodeStateResultList! nodes(filter: [NodeFilter!], order: OrderByInput): NodeStateResultList!
nodeStates(filter: [NodeFilter!]): [NodeStates!]! nodeStates(filter: [NodeFilter!]): [NodeStates!]!
nodeStatesTimed(filter: [NodeFilter!]): [NodeStatesTimed!]! nodeStatesTimed(filter: [NodeFilter!], type: String!): [NodeStatesTimed!]!
job(id: ID!): Job job(id: ID!): Job
jobMetrics( jobMetrics(
@@ -376,6 +376,7 @@ type Query {
nodeMetricsList( nodeMetricsList(
cluster: String! cluster: String!
subCluster: String! subCluster: String!
stateFilter: String!
nodeFilter: String! nodeFilter: String!
scopes: [MetricScope!] scopes: [MetricScope!]
metrics: [String!] metrics: [String!]
+1 -1
View File
@@ -1416,7 +1416,7 @@
"deadline", "deadline",
"failed", "failed",
"node_fail", "node_fail",
"out-of-memory", "out_of_memory",
"pending", "pending",
"preempted", "preempted",
"running", "running",
+1 -1
View File
@@ -227,7 +227,7 @@ definitions:
- deadline - deadline
- failed - failed
- node_fail - node_fail
- out-of-memory - out_of_memory
- pending - pending
- preempted - preempted
- running - running
+3
View File
@@ -2,6 +2,9 @@
// All rights reserved. This file is part of cc-backend. // All rights reserved. This file is part of cc-backend.
// Use of this source code is governed by a MIT-style // Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file. // license that can be found in the LICENSE file.
// Package main provides the entry point for the ClusterCockpit backend server.
// This file defines all command-line flags and their default values.
package main package main
import "flag" import "flag"
+4
View File
@@ -2,6 +2,10 @@
// All rights reserved. This file is part of cc-backend. // All rights reserved. This file is part of cc-backend.
// Use of this source code is governed by a MIT-style // Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file. // license that can be found in the LICENSE file.
// Package main provides the entry point for the ClusterCockpit backend server.
// This file contains bootstrap logic for initializing the environment,
// creating default configuration files, and setting up the database.
package main package main
import ( import (
+290 -105
View File
@@ -2,9 +2,14 @@
// All rights reserved. This file is part of cc-backend. // All rights reserved. This file is part of cc-backend.
// Use of this source code is governed by a MIT-style // Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file. // license that can be found in the LICENSE file.
// Package main provides the entry point for the ClusterCockpit backend server.
// It orchestrates initialization of all subsystems including configuration,
// database, authentication, and the HTTP server.
package main package main
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"os" "os"
@@ -13,6 +18,7 @@ import (
"strings" "strings"
"sync" "sync"
"syscall" "syscall"
"time"
"github.com/ClusterCockpit/cc-backend/internal/archiver" "github.com/ClusterCockpit/cc-backend/internal/archiver"
"github.com/ClusterCockpit/cc-backend/internal/auth" "github.com/ClusterCockpit/cc-backend/internal/auth"
@@ -46,90 +52,108 @@ const logoString = `
|_| |_|
` `
// Environment variable names
const (
envGOGC = "GOGC"
)
// Default configurations
const (
defaultArchiveConfig = `{"kind":"file","path":"./var/job-archive"}`
)
var ( var (
date string date string
commit string commit string
version string version string
) )
func main() { func printVersion() {
cliInit()
if flagVersion {
fmt.Print(logoString) fmt.Print(logoString)
fmt.Printf("Version:\t%s\n", version) fmt.Printf("Version:\t%s\n", version)
fmt.Printf("Git hash:\t%s\n", commit) fmt.Printf("Git hash:\t%s\n", commit)
fmt.Printf("Build time:\t%s\n", date) fmt.Printf("Build time:\t%s\n", date)
fmt.Printf("SQL db version:\t%d\n", repository.Version) fmt.Printf("SQL db version:\t%d\n", repository.Version)
fmt.Printf("Job archive version:\t%d\n", archive.Version) fmt.Printf("Job archive version:\t%d\n", archive.Version)
os.Exit(0)
} }
cclog.Init(flagLogLevel, flagLogDateTime) func initGops() error {
if !flagGops {
// If init flag set, run tasks here before any file dependencies cause errors return nil
if flagInit {
initEnv()
cclog.Exit("Successfully setup environment!\n" +
"Please review config.json and .env and adjust it to your needs.\n" +
"Add your job-archive at ./var/job-archive.")
} }
// See https://github.com/google/gops (Runtime overhead is almost zero)
if flagGops {
if err := agent.Listen(agent.Options{}); err != nil { if err := agent.Listen(agent.Options{}); err != nil {
cclog.Abortf("Could not start gops agent with 'gops/agent.Listen(agent.Options{})'. Application startup failed, exited.\nError: %s\n", err.Error()) return fmt.Errorf("starting gops agent: %w", err)
} }
return nil
} }
err := godotenv.Load() func loadEnvironment() error {
if err != nil { if err := godotenv.Load(); err != nil {
cclog.Abortf("Could not parse existing .env file at location './.env'. Application startup failed, exited.\nError: %s\n", err.Error()) return fmt.Errorf("loading .env file: %w", err)
}
return nil
} }
// Initialize sub-modules and handle command line flags. func initConfiguration() error {
// The order here is important!
ccconf.Init(flagConfigFile) ccconf.Init(flagConfigFile)
// Load and check main configuration cfg := ccconf.GetPackageConfig("main")
if cfg := ccconf.GetPackageConfig("main"); cfg != nil { if cfg == nil {
if clustercfg := ccconf.GetPackageConfig("clusters"); clustercfg != nil { return fmt.Errorf("main configuration must be present")
config.Init(cfg, clustercfg)
} else {
cclog.Abort("Cluster configuration must be present")
}
} else {
cclog.Abort("Main configuration must be present")
} }
clustercfg := ccconf.GetPackageConfig("clusters")
if clustercfg == nil {
return fmt.Errorf("cluster configuration must be present")
}
config.Init(cfg, clustercfg)
return nil
}
func initDatabase() error {
repository.Connect(config.Keys.DBDriver, config.Keys.DB)
return nil
}
func handleDatabaseCommands() error {
if flagMigrateDB { if flagMigrateDB {
err := repository.MigrateDB(config.Keys.DBDriver, config.Keys.DB) err := repository.MigrateDB(config.Keys.DBDriver, config.Keys.DB)
if err != nil { if err != nil {
cclog.Abortf("MigrateDB Failed: Could not migrate '%s' database at location '%s' to version %d.\nError: %s\n", config.Keys.DBDriver, config.Keys.DB, repository.Version, err.Error()) return fmt.Errorf("migrating database to version %d: %w", repository.Version, err)
} }
cclog.Exitf("MigrateDB Success: Migrated '%s' database at location '%s' to version %d.\n", config.Keys.DBDriver, config.Keys.DB, repository.Version) cclog.Exitf("MigrateDB Success: Migrated '%s' database at location '%s' to version %d.\n",
config.Keys.DBDriver, config.Keys.DB, repository.Version)
} }
if flagRevertDB { if flagRevertDB {
err := repository.RevertDB(config.Keys.DBDriver, config.Keys.DB) err := repository.RevertDB(config.Keys.DBDriver, config.Keys.DB)
if err != nil { if err != nil {
cclog.Abortf("RevertDB Failed: Could not revert '%s' database at location '%s' to version %d.\nError: %s\n", config.Keys.DBDriver, config.Keys.DB, (repository.Version - 1), err.Error()) return fmt.Errorf("reverting database to version %d: %w", repository.Version-1, err)
} }
cclog.Exitf("RevertDB Success: Reverted '%s' database at location '%s' to version %d.\n", config.Keys.DBDriver, config.Keys.DB, (repository.Version - 1)) cclog.Exitf("RevertDB Success: Reverted '%s' database at location '%s' to version %d.\n",
config.Keys.DBDriver, config.Keys.DB, repository.Version-1)
} }
if flagForceDB { if flagForceDB {
err := repository.ForceDB(config.Keys.DBDriver, config.Keys.DB) err := repository.ForceDB(config.Keys.DBDriver, config.Keys.DB)
if err != nil { if err != nil {
cclog.Abortf("ForceDB Failed: Could not force '%s' database at location '%s' to version %d.\nError: %s\n", config.Keys.DBDriver, config.Keys.DB, repository.Version, err.Error()) return fmt.Errorf("forcing database to version %d: %w", repository.Version, err)
} }
cclog.Exitf("ForceDB Success: Forced '%s' database at location '%s' to version %d.\n", config.Keys.DBDriver, config.Keys.DB, repository.Version) cclog.Exitf("ForceDB Success: Forced '%s' database at location '%s' to version %d.\n",
config.Keys.DBDriver, config.Keys.DB, repository.Version)
} }
repository.Connect(config.Keys.DBDriver, config.Keys.DB) return nil
}
func handleUserCommands() error {
if config.Keys.DisableAuthentication && (flagNewUser != "" || flagDelUser != "") {
return fmt.Errorf("--add-user and --del-user can only be used if authentication is enabled")
}
if !config.Keys.DisableAuthentication { if !config.Keys.DisableAuthentication {
if cfg := ccconf.GetPackageConfig("auth"); cfg != nil { if cfg := ccconf.GetPackageConfig("auth"); cfg != nil {
auth.Init(&cfg) auth.Init(&cfg)
} else { } else {
@@ -137,51 +161,96 @@ func main() {
auth.Init(nil) auth.Init(nil)
} }
if flagNewUser != "" { // Check for default security keys
parts := strings.SplitN(flagNewUser, ":", 3) checkDefaultSecurityKeys()
if len(parts) != 3 || len(parts[0]) == 0 {
cclog.Abortf("Add User: Could not parse supplied argument format: No changes.\n"+
"Want: <username>:[admin,support,manager,api,user]:<password>\n"+
"Have: %s\n", flagNewUser)
}
ur := repository.GetUserRepository() if flagNewUser != "" {
if err := ur.AddUser(&schema.User{ if err := addUser(flagNewUser); err != nil {
Username: parts[0], Projects: make([]string, 0), Password: parts[2], Roles: strings.Split(parts[1], ","), return err
}); err != nil {
cclog.Abortf("Add User: Could not add new user authentication for '%s' and roles '%s'.\nError: %s\n", parts[0], parts[1], err.Error())
} else {
cclog.Printf("Add User: Added new user '%s' with roles '%s'.\n", parts[0], parts[1])
} }
} }
if flagDelUser != "" { if flagDelUser != "" {
ur := repository.GetUserRepository() if err := delUser(flagDelUser); err != nil {
if err := ur.DelUser(flagDelUser); err != nil { return err
cclog.Abortf("Delete User: Could not delete user '%s' from DB.\nError: %s\n", flagDelUser, err.Error())
} else {
cclog.Printf("Delete User: Deleted user '%s' from DB.\n", flagDelUser)
} }
} }
authHandle := auth.GetAuthInstance() authHandle := auth.GetAuthInstance()
if flagSyncLDAP { if flagSyncLDAP {
if authHandle.LdapAuth == nil { if err := syncLDAP(authHandle); err != nil {
cclog.Abort("Sync LDAP: LDAP authentication is not configured, could not synchronize. No changes, exited.") return err
} }
if err := authHandle.LdapAuth.Sync(); err != nil {
cclog.Abortf("Sync LDAP: Could not synchronize, failed with error.\nError: %s\n", err.Error())
}
cclog.Print("Sync LDAP: LDAP synchronization successfull.")
} }
if flagGenJWT != "" { if flagGenJWT != "" {
if err := generateJWT(authHandle, flagGenJWT); err != nil {
return err
}
}
}
return nil
}
// checkDefaultSecurityKeys warns if default JWT keys are detected
func checkDefaultSecurityKeys() {
// Default JWT public key from init.go
defaultJWTPublic := "kzfYrYy+TzpanWZHJ5qSdMj5uKUWgq74BWhQG6copP0="
if os.Getenv("JWT_PUBLIC_KEY") == defaultJWTPublic {
cclog.Warn("Using default JWT keys - not recommended for production environments")
}
}
func addUser(userSpec string) error {
parts := strings.SplitN(userSpec, ":", 3)
if len(parts) != 3 || len(parts[0]) == 0 {
return fmt.Errorf("invalid user format, want: <username>:[admin,support,manager,api,user]:<password>, have: %s", userSpec)
}
ur := repository.GetUserRepository() ur := repository.GetUserRepository()
user, err := ur.GetUser(flagGenJWT) if err := ur.AddUser(&schema.User{
Username: parts[0],
Projects: make([]string, 0),
Password: parts[2],
Roles: strings.Split(parts[1], ","),
}); err != nil {
return fmt.Errorf("adding user '%s' with roles '%s': %w", parts[0], parts[1], err)
}
cclog.Printf("Add User: Added new user '%s' with roles '%s'.\n", parts[0], parts[1])
return nil
}
func delUser(username string) error {
ur := repository.GetUserRepository()
if err := ur.DelUser(username); err != nil {
return fmt.Errorf("deleting user '%s': %w", username, err)
}
cclog.Printf("Delete User: Deleted user '%s' from DB.\n", username)
return nil
}
func syncLDAP(authHandle *auth.Authentication) error {
if authHandle.LdapAuth == nil {
return fmt.Errorf("LDAP authentication is not configured")
}
if err := authHandle.LdapAuth.Sync(); err != nil {
return fmt.Errorf("synchronizing LDAP: %w", err)
}
cclog.Print("Sync LDAP: LDAP synchronization successfull.")
return nil
}
func generateJWT(authHandle *auth.Authentication, username string) error {
ur := repository.GetUserRepository()
user, err := ur.GetUser(username)
if err != nil { if err != nil {
cclog.Abortf("JWT: Could not get supplied user '%s' from DB. No changes, exited.\nError: %s\n", flagGenJWT, err.Error()) return fmt.Errorf("getting user '%s': %w", username, err)
} }
if !user.HasRole(schema.RoleApi) { if !user.HasRole(schema.RoleApi) {
@@ -190,104 +259,220 @@ func main() {
jwt, err := authHandle.JwtAuth.ProvideJWT(user) jwt, err := authHandle.JwtAuth.ProvideJWT(user)
if err != nil { if err != nil {
cclog.Abortf("JWT: User '%s' found in DB, but failed to provide JWT.\nError: %s\n", user.Username, err.Error()) return fmt.Errorf("generating JWT for user '%s': %w", user.Username, err)
} }
cclog.Printf("JWT: Successfully generated JWT for user '%s': %s\n", user.Username, jwt) cclog.Printf("JWT: Successfully generated JWT for user '%s': %s\n", user.Username, jwt)
return nil
} }
} else if flagNewUser != "" || flagDelUser != "" { func initSubsystems() error {
cclog.Abort("Error: Arguments '--add-user' and '--del-user' can only be used if authentication is enabled. No changes, exited.") // Initialize job archive
} archiveCfg := ccconf.GetPackageConfig("archive")
if archiveCfg == nil {
if archiveCfg := ccconf.GetPackageConfig("archive"); archiveCfg != nil { archiveCfg = json.RawMessage(defaultArchiveConfig)
err = archive.Init(archiveCfg, config.Keys.DisableArchive) }
} else { if err := archive.Init(archiveCfg, config.Keys.DisableArchive); err != nil {
err = archive.Init(json.RawMessage("{\"kind\":\"file\",\"path\":\"./var/job-archive\"}"), config.Keys.DisableArchive) return fmt.Errorf("initializing archive: %w", err)
}
if err != nil {
cclog.Abortf("Init: Failed to initialize archive.\nError: %s\n", err.Error())
} }
// Initialize metricdata
if err := metricdata.Init(); err != nil { if err := metricdata.Init(); err != nil {
cclog.Abortf("Init: Failed to initialize metricdata repository.\nError %s\n", err.Error()) return fmt.Errorf("initializing metricdata repository: %w", err)
} }
// Handle database re-initialization
if flagReinitDB { if flagReinitDB {
if err := importer.InitDB(); err != nil { if err := importer.InitDB(); err != nil {
cclog.Abortf("Init DB: Failed to re-initialize repository DB.\nError: %s\n", err.Error()) return fmt.Errorf("re-initializing repository DB: %w", err)
} else {
cclog.Print("Init DB: Sucessfully re-initialized repository DB.")
} }
cclog.Print("Init DB: Successfully re-initialized repository DB.")
} }
// Handle job import
if flagImportJob != "" { if flagImportJob != "" {
if err := importer.HandleImportFlag(flagImportJob); err != nil { if err := importer.HandleImportFlag(flagImportJob); err != nil {
cclog.Abortf("Import Job: Job import failed.\nError: %s\n", err.Error()) return fmt.Errorf("importing job: %w", err)
} else { }
cclog.Printf("Import Job: Imported Job '%s' into DB.\n", flagImportJob) cclog.Printf("Import Job: Imported Job '%s' into DB.\n", flagImportJob)
} }
}
// Initialize taggers
if config.Keys.EnableJobTaggers { if config.Keys.EnableJobTaggers {
tagger.Init() tagger.Init()
} }
// Apply tags if requested
if flagApplyTags { if flagApplyTags {
if err := tagger.RunTaggers(); err != nil { if err := tagger.RunTaggers(); err != nil {
cclog.Abortf("Running job taggers.\nError: %s\n", err.Error()) return fmt.Errorf("running job taggers: %w", err)
} }
} }
if !flagServer { return nil
cclog.Exit("No errors, server flag not set. Exiting cc-backend.")
} }
func runServer(ctx context.Context) error {
var wg sync.WaitGroup var wg sync.WaitGroup
// Metric Store starts after all flags have been processes // Start metric store if enabled
if memorystore.InternalCCMSFlag { if memorystore.InternalCCMSFlag {
if mscfg := ccconf.GetPackageConfig("metric-store"); mscfg != nil { mscfg := ccconf.GetPackageConfig("metric-store")
if mscfg == nil {
return fmt.Errorf("metric store configuration must be present")
}
memorystore.Init(mscfg, &wg) memorystore.Init(mscfg, &wg)
} else {
cclog.Abort("Metric Store configuration must be present")
}
} }
// Start archiver and task manager
archiver.Start(repository.GetJobRepository()) archiver.Start(repository.GetJobRepository())
taskManager.Start(ccconf.GetPackageConfig("cron"), ccconf.GetPackageConfig("archive"))
taskManager.Start(ccconf.GetPackageConfig("cron"), // Initialize web UI
ccconf.GetPackageConfig("archive"))
cfg := ccconf.GetPackageConfig("ui") cfg := ccconf.GetPackageConfig("ui")
web.Init(cfg) web.Init(cfg)
serverInit() // Initialize HTTP server
srv, err := NewServer(version, commit, date)
if err != nil {
return fmt.Errorf("creating server: %w", err)
}
// Channel to collect errors from server
errChan := make(chan error, 1)
// Start HTTP server
wg.Add(1) wg.Add(1)
go func() { go func() {
defer wg.Done() defer wg.Done()
serverStart() if err := srv.Start(ctx); err != nil {
errChan <- err
}
}() }()
// Handle shutdown signals
wg.Add(1) wg.Add(1)
sigs := make(chan os.Signal, 1) sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
go func() { go func() {
defer wg.Done() defer wg.Done()
<-sigs select {
case <-sigs:
cclog.Info("Shutdown signal received")
case <-ctx.Done():
}
runtimeEnv.SystemdNotifiy(false, "Shutting down ...") runtimeEnv.SystemdNotifiy(false, "Shutting down ...")
srv.Shutdown(ctx)
serverShutdown()
util.FsWatcherShutdown() util.FsWatcherShutdown()
taskManager.Shutdown() taskManager.Shutdown()
}() }()
if os.Getenv("GOGC") == "" { // Set GC percent if not configured
if os.Getenv(envGOGC) == "" {
debug.SetGCPercent(25) debug.SetGCPercent(25)
} }
runtimeEnv.SystemdNotifiy(true, "running") runtimeEnv.SystemdNotifiy(true, "running")
// Wait for completion or error
go func() {
wg.Wait() wg.Wait()
cclog.Print("Graceful shutdown completed!") close(errChan)
}()
// Check for server startup errors
select {
case err := <-errChan:
if err != nil {
return err
}
case <-time.After(100 * time.Millisecond):
// Server started successfully, wait for completion
if err := <-errChan; err != nil {
return err
}
}
cclog.Print("Graceful shutdown completed!")
return nil
}
func run() error {
cliInit()
if flagVersion {
printVersion()
return nil
}
// Initialize logger
cclog.Init(flagLogLevel, flagLogDateTime)
// Handle init flag
if flagInit {
initEnv()
cclog.Exit("Successfully setup environment!\n" +
"Please review config.json and .env and adjust it to your needs.\n" +
"Add your job-archive at ./var/job-archive.")
}
// Initialize gops agent
if err := initGops(); err != nil {
return err
}
// Initialize subsystems in dependency order:
// 1. Load environment variables from .env file (contains sensitive configuration)
// 2. Load configuration from config.json (may reference environment variables)
// 3. Handle database migration commands if requested
// 4. Initialize database connection (requires config for connection string)
// 5. Handle user commands if requested (requires database and authentication config)
// 6. Initialize subsystems like archive and metrics (require config and database)
// Load environment and configuration
if err := loadEnvironment(); err != nil {
return err
}
if err := initConfiguration(); err != nil {
return err
}
// Handle database migration (migrate, revert, force)
if err := handleDatabaseCommands(); err != nil {
return err
}
// Initialize database
if err := initDatabase(); err != nil {
return err
}
// Handle user commands (add, delete, sync, JWT)
if err := handleUserCommands(); err != nil {
return err
}
// Initialize subsystems (archive, metrics, taggers)
if err := initSubsystems(); err != nil {
return err
}
// Exit if start server is not requested
if !flagServer {
cclog.Exit("No errors, server flag not set. Exiting cc-backend.")
}
// Run server with context
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
return runServer(ctx)
}
func main() {
if err := run(); err != nil {
cclog.Error(err.Error())
os.Exit(1)
}
} }
+99 -99
View File
@@ -2,6 +2,9 @@
// All rights reserved. This file is part of cc-backend. // All rights reserved. This file is part of cc-backend.
// Use of this source code is governed by a MIT-style // Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file. // license that can be found in the LICENSE file.
// Package main provides the entry point for the ClusterCockpit backend server.
// This file contains HTTP server setup, routing configuration, and
// authentication middleware integration.
package main package main
import ( import (
@@ -36,11 +39,19 @@ import (
httpSwagger "github.com/swaggo/http-swagger" httpSwagger "github.com/swaggo/http-swagger"
) )
var ( var buildInfo web.Build
// Environment variable names
const (
envDebug = "DEBUG"
)
// Server encapsulates the HTTP server state and dependencies
type Server struct {
router *mux.Router router *mux.Router
server *http.Server server *http.Server
apiHandle *api.RestApi apiHandle *api.RestApi
) }
func onFailureResponse(rw http.ResponseWriter, r *http.Request, err error) { func onFailureResponse(rw http.ResponseWriter, r *http.Request, err error) {
rw.Header().Add("Content-Type", "application/json") rw.Header().Add("Content-Type", "application/json")
@@ -51,25 +62,31 @@ func onFailureResponse(rw http.ResponseWriter, r *http.Request, err error) {
}) })
} }
func serverInit() { // NewServer creates and initializes a new Server instance
func NewServer(version, commit, buildDate string) (*Server, error) {
buildInfo = web.Build{Version: version, Hash: commit, Buildtime: buildDate}
s := &Server{
router: mux.NewRouter(),
}
if err := s.init(); err != nil {
return nil, err
}
return s, nil
}
func (s *Server) init() error {
// Setup the http.Handler/Router used by the server // Setup the http.Handler/Router used by the server
graph.Init() graph.Init()
resolver := graph.GetResolverInstance() resolver := graph.GetResolverInstance()
graphQLServer := handler.New( graphQLServer := handler.New(
generated.NewExecutableSchema(generated.Config{Resolvers: resolver})) generated.NewExecutableSchema(generated.Config{Resolvers: resolver}))
// graphQLServer.AddTransport(transport.SSE{})
graphQLServer.AddTransport(transport.POST{}) graphQLServer.AddTransport(transport.POST{})
// graphQLServer.AddTransport(transport.Websocket{
// KeepAlivePingInterval: 10 * time.Second,
// Upgrader: websocket.Upgrader{
// CheckOrigin: func(r *http.Request) bool {
// return true
// },
// },
// })
if os.Getenv("DEBUG") != "1" { if os.Getenv(envDebug) != "1" {
// Having this handler means that a error message is returned via GraphQL instead of the connection simply beeing closed. // Having this handler means that a error message is returned via GraphQL instead of the connection simply beeing closed.
// The problem with this is that then, no more stacktrace is printed to stderr. // The problem with this is that then, no more stacktrace is printed to stderr.
graphQLServer.SetRecoverFunc(func(ctx context.Context, err any) error { graphQLServer.SetRecoverFunc(func(ctx context.Context, err any) error {
@@ -86,46 +103,41 @@ func serverInit() {
authHandle := auth.GetAuthInstance() authHandle := auth.GetAuthInstance()
apiHandle = api.New() s.apiHandle = api.New()
router = mux.NewRouter()
buildInfo := web.Build{Version: version, Hash: commit, Buildtime: date}
info := map[string]any{} info := map[string]any{}
info["hasOpenIDConnect"] = false info["hasOpenIDConnect"] = false
if auth.Keys.OpenIDConfig != nil { if auth.Keys.OpenIDConfig != nil {
openIDConnect := auth.NewOIDC(authHandle) openIDConnect := auth.NewOIDC(authHandle)
openIDConnect.RegisterEndpoints(router) openIDConnect.RegisterEndpoints(s.router)
info["hasOpenIDConnect"] = true info["hasOpenIDConnect"] = true
} }
router.HandleFunc("/login", func(rw http.ResponseWriter, r *http.Request) { s.router.HandleFunc("/login", func(rw http.ResponseWriter, r *http.Request) {
rw.Header().Add("Content-Type", "text/html; charset=utf-8") rw.Header().Add("Content-Type", "text/html; charset=utf-8")
cclog.Debugf("##%v##", info) cclog.Debugf("##%v##", info)
web.RenderTemplate(rw, "login.tmpl", &web.Page{Title: "Login", Build: buildInfo, Infos: info}) web.RenderTemplate(rw, "login.tmpl", &web.Page{Title: "Login", Build: buildInfo, Infos: info})
}).Methods(http.MethodGet) }).Methods(http.MethodGet)
router.HandleFunc("/imprint", func(rw http.ResponseWriter, r *http.Request) { s.router.HandleFunc("/imprint", func(rw http.ResponseWriter, r *http.Request) {
rw.Header().Add("Content-Type", "text/html; charset=utf-8") rw.Header().Add("Content-Type", "text/html; charset=utf-8")
web.RenderTemplate(rw, "imprint.tmpl", &web.Page{Title: "Imprint", Build: buildInfo}) web.RenderTemplate(rw, "imprint.tmpl", &web.Page{Title: "Imprint", Build: buildInfo})
}) })
router.HandleFunc("/privacy", func(rw http.ResponseWriter, r *http.Request) { s.router.HandleFunc("/privacy", func(rw http.ResponseWriter, r *http.Request) {
rw.Header().Add("Content-Type", "text/html; charset=utf-8") rw.Header().Add("Content-Type", "text/html; charset=utf-8")
web.RenderTemplate(rw, "privacy.tmpl", &web.Page{Title: "Privacy", Build: buildInfo}) web.RenderTemplate(rw, "privacy.tmpl", &web.Page{Title: "Privacy", Build: buildInfo})
}) })
secured := router.PathPrefix("/").Subrouter() secured := s.router.PathPrefix("/").Subrouter()
securedapi := router.PathPrefix("/api").Subrouter() securedapi := s.router.PathPrefix("/api").Subrouter()
userapi := router.PathPrefix("/userapi").Subrouter() userapi := s.router.PathPrefix("/userapi").Subrouter()
configapi := router.PathPrefix("/config").Subrouter() configapi := s.router.PathPrefix("/config").Subrouter()
frontendapi := router.PathPrefix("/frontend").Subrouter() frontendapi := s.router.PathPrefix("/frontend").Subrouter()
metricstoreapi := router.PathPrefix("/metricstore").Subrouter() metricstoreapi := s.router.PathPrefix("/metricstore").Subrouter()
if !config.Keys.DisableAuthentication { if !config.Keys.DisableAuthentication {
router.Handle("/login", authHandle.Login( // Create login failure handler (used by both /login and /jwt-login)
// On success: Handled within Login() loginFailureHandler := func(rw http.ResponseWriter, r *http.Request, err error) {
// On failure:
func(rw http.ResponseWriter, r *http.Request, err error) {
rw.Header().Add("Content-Type", "text/html; charset=utf-8") rw.Header().Add("Content-Type", "text/html; charset=utf-8")
rw.WriteHeader(http.StatusUnauthorized) rw.WriteHeader(http.StatusUnauthorized)
web.RenderTemplate(rw, "login.tmpl", &web.Page{ web.RenderTemplate(rw, "login.tmpl", &web.Page{
@@ -135,24 +147,12 @@ func serverInit() {
Build: buildInfo, Build: buildInfo,
Infos: info, Infos: info,
}) })
})).Methods(http.MethodPost) }
router.Handle("/jwt-login", authHandle.Login( s.router.Handle("/login", authHandle.Login(loginFailureHandler)).Methods(http.MethodPost)
// On success: Handled within Login() s.router.Handle("/jwt-login", authHandle.Login(loginFailureHandler))
// On failure:
func(rw http.ResponseWriter, r *http.Request, err error) {
rw.Header().Add("Content-Type", "text/html; charset=utf-8")
rw.WriteHeader(http.StatusUnauthorized)
web.RenderTemplate(rw, "login.tmpl", &web.Page{
Title: "Login failed - ClusterCockpit",
MsgType: "alert-warning",
Message: err.Error(),
Build: buildInfo,
Infos: info,
})
}))
router.Handle("/logout", authHandle.Logout( s.router.Handle("/logout", authHandle.Logout(
http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
rw.Header().Add("Content-Type", "text/html; charset=utf-8") rw.Header().Add("Content-Type", "text/html; charset=utf-8")
rw.WriteHeader(http.StatusOK) rw.WriteHeader(http.StatusOK)
@@ -226,8 +226,8 @@ func serverInit() {
} }
if flagDev { if flagDev {
router.Handle("/playground", playground.Handler("GraphQL playground", "/query")) s.router.Handle("/playground", playground.Handler("GraphQL playground", "/query"))
router.PathPrefix("/swagger/").Handler(httpSwagger.Handler( s.router.PathPrefix("/swagger/").Handler(httpSwagger.Handler(
httpSwagger.URL("http://" + config.Keys.Addr + "/swagger/doc.json"))).Methods(http.MethodGet) httpSwagger.URL("http://" + config.Keys.Addr + "/swagger/doc.json"))).Methods(http.MethodGet)
} }
secured.Handle("/query", graphQLServer) secured.Handle("/query", graphQLServer)
@@ -239,67 +239,46 @@ func serverInit() {
// Mount all /monitoring/... and /api/... routes. // Mount all /monitoring/... and /api/... routes.
routerConfig.SetupRoutes(secured, buildInfo) routerConfig.SetupRoutes(secured, buildInfo)
apiHandle.MountApiRoutes(securedapi) s.apiHandle.MountApiRoutes(securedapi)
apiHandle.MountUserApiRoutes(userapi) s.apiHandle.MountUserApiRoutes(userapi)
apiHandle.MountConfigApiRoutes(configapi) s.apiHandle.MountConfigApiRoutes(configapi)
apiHandle.MountFrontendApiRoutes(frontendapi) s.apiHandle.MountFrontendApiRoutes(frontendapi)
if memorystore.InternalCCMSFlag { if memorystore.InternalCCMSFlag {
apiHandle.MountMetricStoreApiRoutes(metricstoreapi) s.apiHandle.MountMetricStoreApiRoutes(metricstoreapi)
} }
if config.Keys.EmbedStaticFiles { if config.Keys.EmbedStaticFiles {
if i, err := os.Stat("./var/img"); err == nil { if i, err := os.Stat("./var/img"); err == nil {
if i.IsDir() { if i.IsDir() {
cclog.Info("Use local directory for static images") cclog.Info("Use local directory for static images")
router.PathPrefix("/img/").Handler(http.StripPrefix("/img/", http.FileServer(http.Dir("./var/img")))) s.router.PathPrefix("/img/").Handler(http.StripPrefix("/img/", http.FileServer(http.Dir("./var/img"))))
} }
} }
router.PathPrefix("/").Handler(http.StripPrefix("/", web.ServeFiles())) s.router.PathPrefix("/").Handler(http.StripPrefix("/", web.ServeFiles()))
} else { } else {
router.PathPrefix("/").Handler(http.FileServer(http.Dir(config.Keys.StaticFiles))) s.router.PathPrefix("/").Handler(http.FileServer(http.Dir(config.Keys.StaticFiles)))
} }
router.Use(handlers.CompressHandler) s.router.Use(handlers.CompressHandler)
router.Use(handlers.RecoveryHandler(handlers.PrintRecoveryStack(true))) s.router.Use(handlers.RecoveryHandler(handlers.PrintRecoveryStack(true)))
router.Use(handlers.CORS( s.router.Use(handlers.CORS(
handlers.AllowCredentials(), handlers.AllowCredentials(),
handlers.AllowedHeaders([]string{"X-Requested-With", "Content-Type", "Authorization", "Origin"}), handlers.AllowedHeaders([]string{"X-Requested-With", "Content-Type", "Authorization", "Origin"}),
handlers.AllowedMethods([]string{"GET", "POST", "HEAD", "OPTIONS"}), handlers.AllowedMethods([]string{"GET", "POST", "HEAD", "OPTIONS"}),
handlers.AllowedOrigins([]string{"*"}))) handlers.AllowedOrigins([]string{"*"})))
// secured.NotFoundHandler = http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { return nil
// page := web.Page{
// Title: "ClusterCockpit - Not Found",
// Build: buildInfo,
// }
// rw.Header().Add("Content-Type", "text/html; charset=utf-8")
// web.RenderTemplate(rw, "404.tmpl", &page)
// })
// secured.NotFoundHandler = http.HandlerFunc(http.NotFound)
// router.NotFoundHandler = router.NewRoute().HandlerFunc(http.NotFound).GetHandler()
// printEndpoints(router)
} }
// func printEndpoints(r *mux.Router) { // Server timeout defaults (in seconds)
// r.Walk(func(route *mux.Route, router *mux.Router, ancestors []*mux.Route) error { const (
// path, err := route.GetPathTemplate() defaultReadTimeout = 20
// if err != nil { defaultWriteTimeout = 20
// path = "nopath" )
// }
// methods, err := route.GetMethods()
// if err != nil {
// methods = append(methods, "nomethod")
// }
// fmt.Printf("%v %s\n", methods, path)
// return nil
// })
// }
func serverStart() { func (s *Server) Start(ctx context.Context) error {
handler := handlers.CustomLoggingHandler(io.Discard, router, func(_ io.Writer, params handlers.LogFormatterParams) { handler := handlers.CustomLoggingHandler(io.Discard, s.router, func(_ io.Writer, params handlers.LogFormatterParams) {
if strings.HasPrefix(params.Request.RequestURI, "/api/") { if strings.HasPrefix(params.Request.RequestURI, "/api/") {
cclog.Debugf("%s %s (%d, %.02fkb, %dms)", cclog.Debugf("%s %s (%d, %.02fkb, %dms)",
params.Request.Method, params.URL.RequestURI(), params.Request.Method, params.URL.RequestURI(),
@@ -313,9 +292,13 @@ func serverStart() {
} }
}) })
server = &http.Server{ // Use configurable timeouts with defaults
ReadTimeout: 20 * time.Second, readTimeout := time.Duration(defaultReadTimeout) * time.Second
WriteTimeout: 20 * time.Second, writeTimeout := time.Duration(defaultWriteTimeout) * time.Second
s.server = &http.Server{
ReadTimeout: readTimeout,
WriteTimeout: writeTimeout,
Handler: handler, Handler: handler,
Addr: config.Keys.Addr, Addr: config.Keys.Addr,
} }
@@ -323,7 +306,7 @@ func serverStart() {
// Start http or https server // Start http or https server
listener, err := net.Listen("tcp", config.Keys.Addr) listener, err := net.Listen("tcp", config.Keys.Addr)
if err != nil { if err != nil {
cclog.Abortf("Server Start: Starting http listener on '%s' failed.\nError: %s\n", config.Keys.Addr, err.Error()) return fmt.Errorf("starting listener on '%s': %w", config.Keys.Addr, err)
} }
if !strings.HasSuffix(config.Keys.Addr, ":80") && config.Keys.RedirectHTTPTo != "" { if !strings.HasSuffix(config.Keys.Addr, ":80") && config.Keys.RedirectHTTPTo != "" {
@@ -336,7 +319,7 @@ func serverStart() {
cert, err := tls.LoadX509KeyPair( cert, err := tls.LoadX509KeyPair(
config.Keys.HTTPSCertFile, config.Keys.HTTPSKeyFile) config.Keys.HTTPSCertFile, config.Keys.HTTPSKeyFile)
if err != nil { if err != nil {
cclog.Abortf("Server Start: Loading X509 keypair failed. Check options 'https-cert-file' and 'https-key-file' in 'config.json'.\nError: %s\n", err.Error()) return fmt.Errorf("loading X509 keypair (check 'https-cert-file' and 'https-key-file' in config.json): %w", err)
} }
listener = tls.NewListener(listener, &tls.Config{ listener = tls.NewListener(listener, &tls.Config{
Certificates: []tls.Certificate{cert}, Certificates: []tls.Certificate{cert},
@@ -356,17 +339,34 @@ func serverStart() {
// be established first, then the user can be changed, and after that, // be established first, then the user can be changed, and after that,
// the actual http server can be started. // the actual http server can be started.
if err := runtimeEnv.DropPrivileges(config.Keys.Group, config.Keys.User); err != nil { if err := runtimeEnv.DropPrivileges(config.Keys.Group, config.Keys.User); err != nil {
cclog.Abortf("Server Start: Error while preparing server start.\nError: %s\n", err.Error()) return fmt.Errorf("dropping privileges: %w", err)
} }
if err = server.Serve(listener); err != nil && err != http.ErrServerClosed { // Handle context cancellation for graceful shutdown
cclog.Abortf("Server Start: Starting server failed.\nError: %s\n", err.Error()) go func() {
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := s.server.Shutdown(shutdownCtx); err != nil {
cclog.Errorf("Server shutdown error: %v", err)
} }
}()
if err = s.server.Serve(listener); err != nil && err != http.ErrServerClosed {
return fmt.Errorf("server failed: %w", err)
}
return nil
} }
func serverShutdown() { func (s *Server) Shutdown(ctx context.Context) {
// Create a shutdown context with timeout
shutdownCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
// First shut down the server gracefully (waiting for all ongoing requests) // First shut down the server gracefully (waiting for all ongoing requests)
server.Shutdown(context.Background()) if err := s.server.Shutdown(shutdownCtx); err != nil {
cclog.Errorf("Server shutdown error: %v", err)
}
// Archive all the metric store data // Archive all the metric store data
if memorystore.InternalCCMSFlag { if memorystore.InternalCCMSFlag {
+50 -26
View File
@@ -5,14 +5,18 @@ go 1.24.0
toolchain go1.24.1 toolchain go1.24.1
require ( require (
github.com/99designs/gqlgen v0.17.81 github.com/99designs/gqlgen v0.17.84
github.com/ClusterCockpit/cc-lib v0.10.1 github.com/ClusterCockpit/cc-lib v0.10.2
github.com/Masterminds/squirrel v1.5.4 github.com/Masterminds/squirrel v1.5.4
github.com/aws/aws-sdk-go-v2 v1.39.6
github.com/aws/aws-sdk-go-v2/config v1.31.20
github.com/aws/aws-sdk-go-v2/credentials v1.18.24
github.com/aws/aws-sdk-go-v2/service/s3 v1.90.2
github.com/coreos/go-oidc/v3 v3.16.0 github.com/coreos/go-oidc/v3 v3.16.0
github.com/expr-lang/expr v1.17.6 github.com/expr-lang/expr v1.17.6
github.com/go-co-op/gocron/v2 v2.16.0 github.com/go-co-op/gocron/v2 v2.17.0
github.com/go-ldap/ldap/v3 v3.4.12 github.com/go-ldap/ldap/v3 v3.4.12
github.com/go-sql-driver/mysql v1.9.0 github.com/go-sql-driver/mysql v1.9.3
github.com/golang-jwt/jwt/v5 v5.2.2 github.com/golang-jwt/jwt/v5 v5.2.2
github.com/golang-migrate/migrate/v4 v4.18.2 github.com/golang-migrate/migrate/v4 v4.18.2
github.com/google/gops v0.3.28 github.com/google/gops v0.3.28
@@ -23,18 +27,19 @@ require (
github.com/jmoiron/sqlx v1.4.0 github.com/jmoiron/sqlx v1.4.0
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/linkedin/goavro/v2 v2.14.0 github.com/linkedin/goavro/v2 v2.14.0
github.com/mattn/go-sqlite3 v1.14.24 github.com/mattn/go-sqlite3 v1.14.32
github.com/nats-io/nats.go v1.47.0 github.com/nats-io/nats.go v1.47.0
github.com/prometheus/client_golang v1.23.2 github.com/prometheus/client_golang v1.23.2
github.com/prometheus/common v0.66.1 github.com/prometheus/common v0.66.1
github.com/qustavo/sqlhooks/v2 v2.1.0 github.com/qustavo/sqlhooks/v2 v2.1.0
github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 github.com/santhosh-tekuri/jsonschema/v5 v5.3.1
github.com/stretchr/testify v1.11.1
github.com/swaggo/http-swagger v1.3.4 github.com/swaggo/http-swagger v1.3.4
github.com/swaggo/swag v1.16.6 github.com/swaggo/swag v1.16.6
github.com/vektah/gqlparser/v2 v2.5.30 github.com/vektah/gqlparser/v2 v2.5.31
golang.org/x/crypto v0.43.0 golang.org/x/crypto v0.44.0
golang.org/x/oauth2 v0.32.0 golang.org/x/oauth2 v0.32.0
golang.org/x/time v0.13.0 golang.org/x/time v0.14.0
) )
require ( require (
@@ -42,24 +47,40 @@ require (
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect github.com/KyleBanks/depth v1.2.1 // indirect
github.com/agnivade/levenshtein v1.2.1 // indirect github.com/agnivade/levenshtein v1.2.1 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.13 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.13 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.13 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.13 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.3 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.7 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.40.2 // indirect
github.com/aws/smithy-go v1.23.2 // indirect
github.com/beorn7/perks v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/go-openapi/jsonpointer v0.22.1 // indirect github.com/go-openapi/jsonpointer v0.22.3 // indirect
github.com/go-openapi/jsonreference v0.21.2 // indirect github.com/go-openapi/jsonreference v0.21.3 // indirect
github.com/go-openapi/spec v0.22.0 // indirect github.com/go-openapi/spec v0.22.1 // indirect
github.com/go-openapi/swag/conv v0.25.1 // indirect github.com/go-openapi/swag/conv v0.25.4 // indirect
github.com/go-openapi/swag/jsonname v0.25.1 // indirect github.com/go-openapi/swag/jsonname v0.25.4 // indirect
github.com/go-openapi/swag/jsonutils v0.25.1 // indirect github.com/go-openapi/swag/jsonutils v0.25.4 // indirect
github.com/go-openapi/swag/loading v0.25.1 // indirect github.com/go-openapi/swag/loading v0.25.4 // indirect
github.com/go-openapi/swag/stringutils v0.25.1 // indirect github.com/go-openapi/swag/stringutils v0.25.4 // indirect
github.com/go-openapi/swag/typeutils v0.25.1 // indirect github.com/go-openapi/swag/typeutils v0.25.4 // indirect
github.com/go-openapi/swag/yamlutils v0.25.1 // indirect github.com/go-openapi/swag/yamlutils v0.25.4 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/goccy/go-yaml v1.19.0 // indirect
github.com/golang/snappy v0.0.4 // indirect github.com/golang/snappy v0.0.4 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect github.com/gorilla/securecookie v1.1.2 // indirect
@@ -70,7 +91,7 @@ require (
github.com/jonboulle/clockwork v0.5.0 // indirect github.com/jonboulle/clockwork v0.5.0 // indirect
github.com/jpillora/backoff v1.0.0 // indirect github.com/jpillora/backoff v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.18.0 // indirect github.com/klauspost/compress v1.18.1 // indirect
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
@@ -79,25 +100,28 @@ require (
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f // indirect github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f // indirect
github.com/nats-io/nkeys v0.4.11 // indirect github.com/nats-io/nkeys v0.4.11 // indirect
github.com/nats-io/nuid v1.0.1 // indirect github.com/nats-io/nuid v1.0.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/procfs v0.16.1 // indirect github.com/prometheus/procfs v0.16.1 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/sosodev/duration v1.3.1 // indirect github.com/sosodev/duration v1.3.1 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/swaggo/files v1.0.1 // indirect github.com/swaggo/files v1.0.1 // indirect
github.com/urfave/cli/v2 v2.27.7 // indirect github.com/urfave/cli/v2 v2.27.7 // indirect
github.com/urfave/cli/v3 v3.6.1 // indirect
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect
go.uber.org/atomic v1.11.0 // indirect go.uber.org/atomic v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/mod v0.29.0 // indirect golang.org/x/mod v0.30.0 // indirect
golang.org/x/net v0.46.0 // indirect golang.org/x/net v0.47.0 // indirect
golang.org/x/sync v0.17.0 // indirect golang.org/x/sync v0.18.0 // indirect
golang.org/x/sys v0.37.0 // indirect golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.30.0 // indirect golang.org/x/text v0.31.0 // indirect
golang.org/x/tools v0.38.0 // indirect golang.org/x/tools v0.39.0 // indirect
google.golang.org/protobuf v1.36.9 // indirect google.golang.org/protobuf v1.36.10 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
sigs.k8s.io/yaml v1.6.0 // indirect sigs.k8s.io/yaml v1.6.0 // indirect
) )
+104 -58
View File
@@ -1,13 +1,13 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/99designs/gqlgen v0.17.81 h1:kCkN/xVyRb5rEQpuwOHRTYq83i0IuTQg9vdIiwEerTs= github.com/99designs/gqlgen v0.17.84 h1:iVMdiStgUVx/BFkMb0J5GAXlqfqtQ7bqMCYK6v52kQ0=
github.com/99designs/gqlgen v0.17.81/go.mod h1:vgNcZlLwemsUhYim4dC1pvFP5FX0pr2Y+uYUoHFb1ig= github.com/99designs/gqlgen v0.17.84/go.mod h1:qjoUqzTeiejdo+bwUg8unqSpeYG42XrcrQboGIezmFA=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8= github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU= github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
github.com/ClusterCockpit/cc-lib v0.10.1 h1:tjGEH8mFGgznYxO8BKLiiar0eZR1Oytk8x5iIQHZR5s= github.com/ClusterCockpit/cc-lib v0.10.2 h1:0sYW34EEfVLcdmHcZ1uErZepgwcu+HQg38WkBslQjqg=
github.com/ClusterCockpit/cc-lib v0.10.1/go.mod h1:nvTZuxFCTwlos8I1rL5O1RPab7vRtkU8E/PGiaF6pQA= github.com/ClusterCockpit/cc-lib v0.10.2/go.mod h1:0LKjDJs813/NMmaSJXJc11A9rxiFDPV/QdWQbZUp0XY=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM= github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM=
@@ -16,8 +16,8 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/NVIDIA/go-nvml v0.13.0-1 h1:OLX8Jq3dONuPOQPC7rndB6+iDmDakw0XTYgzMxObkEw= github.com/NVIDIA/go-nvml v0.13.0-1 h1:OLX8Jq3dONuPOQPC7rndB6+iDmDakw0XTYgzMxObkEw=
github.com/NVIDIA/go-nvml v0.13.0-1/go.mod h1:+KNA7c7gIBH7SKSJ1ntlwkfN80zdx8ovl4hrK3LmPt4= github.com/NVIDIA/go-nvml v0.13.0-1/go.mod h1:+KNA7c7gIBH7SKSJ1ntlwkfN80zdx8ovl4hrK3LmPt4=
github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo= github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw=
github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y= github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ=
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM= github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU= github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI= github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
@@ -30,6 +30,42 @@ github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7D
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q= github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q=
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE=
github.com/aws/aws-sdk-go-v2 v1.39.6 h1:2JrPCVgWJm7bm83BDwY5z8ietmeJUbh3O2ACnn+Xsqk=
github.com/aws/aws-sdk-go-v2 v1.39.6/go.mod h1:c9pm7VwuW0UPxAEYGyTmyurVcNrbF6Rt/wixFqDhcjE=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3 h1:DHctwEM8P8iTXFxC/QK0MRjwEpWQeM9yzidCRjldUz0=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3/go.mod h1:xdCzcZEtnSTKVDOmUZs4l/j3pSV6rpo1WXl5ugNsL8Y=
github.com/aws/aws-sdk-go-v2/config v1.31.20 h1:/jWF4Wu90EhKCgjTdy1DGxcbcbNrjfBHvksEL79tfQc=
github.com/aws/aws-sdk-go-v2/config v1.31.20/go.mod h1:95Hh1Tc5VYKL9NJ7tAkDcqeKt+MCXQB1hQZaRdJIZE0=
github.com/aws/aws-sdk-go-v2/credentials v1.18.24 h1:iJ2FmPT35EaIB0+kMa6TnQ+PwG5A1prEdAw+PsMzfHg=
github.com/aws/aws-sdk-go-v2/credentials v1.18.24/go.mod h1:U91+DrfjAiXPDEGYhh/x29o4p0qHX5HDqG7y5VViv64=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13 h1:T1brd5dR3/fzNFAQch/iBKeX07/ffu/cLu+q+RuzEWk=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13/go.mod h1:Peg/GBAQ6JDt+RoBf4meB1wylmAipb7Kg2ZFakZTlwk=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.13 h1:a+8/MLcWlIxo1lF9xaGt3J/u3yOZx+CdSveSNwjhD40=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.13/go.mod h1:oGnKwIYZ4XttyU2JWxFrwvhF6YKiK/9/wmE3v3Iu9K8=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.13 h1:HBSI2kDkMdWz4ZM7FjwE7e/pWDEZ+nR95x8Ztet1ooY=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.13/go.mod h1:YE94ZoDArI7awZqJzBAZ3PDD2zSfuP7w6P2knOzIn8M=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.13 h1:eg/WYAa12vqTphzIdWMzqYRVKKnCboVPRlvaybNCqPA=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.13/go.mod h1:/FDdxWhz1486obGrKKC1HONd7krpk38LBt+dutLcN9k=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 h1:x2Ibm/Af8Fi+BH+Hsn9TXGdT+hKbDd5XOTZxTMxDk7o=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3/go.mod h1:IW1jwyrQgMdhisceG8fQLmQIydcT/jWY21rFhzgaKwo=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.4 h1:NvMjwvv8hpGUILarKw7Z4Q0w1H9anXKsesMxtw++MA4=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.4/go.mod h1:455WPHSwaGj2waRSpQp7TsnpOnBfw8iDfPfbwl7KPJE=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13 h1:kDqdFvMY4AtKoACfzIGD8A0+hbT41KTKF//gq7jITfM=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13/go.mod h1:lmKuogqSU3HzQCwZ9ZtcqOc5XGMqtDK7OIc2+DxiUEg=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.13 h1:zhBJXdhWIFZ1acfDYIhu4+LCzdUS2Vbcum7D01dXlHQ=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.13/go.mod h1:JaaOeCE368qn2Hzi3sEzY6FgAZVCIYcC2nwbro2QCh8=
github.com/aws/aws-sdk-go-v2/service/s3 v1.90.2 h1:DhdbtDl4FdNlj31+xiRXANxEE+eC7n8JQz+/ilwQ8Uc=
github.com/aws/aws-sdk-go-v2/service/s3 v1.90.2/go.mod h1:+wArOOrcHUevqdto9k1tKOF5++YTe9JEcPSc9Tx2ZSw=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.3 h1:NjShtS1t8r5LUfFVtFeI8xLAHQNTa7UI0VawXlrBMFQ=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.3/go.mod h1:fKvyjJcz63iL/ftA6RaM8sRCtN4r4zl4tjL3qw5ec7k=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.7 h1:gTsnx0xXNQ6SBbymoDvcoRHL+q4l/dAFsQuKfDWSaGc=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.7/go.mod h1:klO+ejMvYsB4QATfEOIXk8WAEwN4N0aBfJpvC+5SZBo=
github.com/aws/aws-sdk-go-v2/service/sts v1.40.2 h1:HK5ON3KmQV2HcAunnx4sKLB9aPf3gKGwVAf7xnx0QT0=
github.com/aws/aws-sdk-go-v2/service/sts v1.40.2/go.mod h1:E19xDjpzPZC7LS2knI9E6BaRFDK43Eul7vd6rSq2HWk=
github.com/aws/smithy-go v1.23.2 h1:Crv0eatJUQhaManss33hS5r40CG3ZFH+21XSkqMrIUM=
github.com/aws/smithy-go v1.23.2/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
@@ -66,8 +102,8 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo= github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-co-op/gocron/v2 v2.16.0 h1:uqUF6WFZ4enRU45pWFNcn1xpDLc+jBOTKhPQI16Z1xs= github.com/go-co-op/gocron/v2 v2.17.0 h1:e/oj6fcAM8vOOKZxv2Cgfmjo+s8AXC46po5ZPtaSea4=
github.com/go-co-op/gocron/v2 v2.16.0/go.mod h1:opexeOFy5BplhsKdA7bzY9zeYih8I8/WNJ4arTIFPVc= github.com/go-co-op/gocron/v2 v2.17.0/go.mod h1:Zii6he+Zfgy5W9B+JKk/KwejFOW0kZTFvHtwIpR4aBI=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4= github.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4=
@@ -76,35 +112,41 @@ github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-openapi/jsonpointer v0.22.1 h1:sHYI1He3b9NqJ4wXLoJDKmUmHkWy/L7rtEo92JUxBNk= github.com/go-openapi/jsonpointer v0.22.3 h1:dKMwfV4fmt6Ah90zloTbUKWMD+0he+12XYAsPotrkn8=
github.com/go-openapi/jsonpointer v0.22.1/go.mod h1:pQT9OsLkfz1yWoMgYFy4x3U5GY5nUlsOn1qSBH5MkCM= github.com/go-openapi/jsonpointer v0.22.3/go.mod h1:0lBbqeRsQ5lIanv3LHZBrmRGHLHcQoOXQnf88fHlGWo=
github.com/go-openapi/jsonreference v0.21.2 h1:Wxjda4M/BBQllegefXrY/9aq1fxBA8sI5M/lFU6tSWU= github.com/go-openapi/jsonreference v0.21.3 h1:96Dn+MRPa0nYAR8DR1E03SblB5FJvh7W6krPI0Z7qMc=
github.com/go-openapi/jsonreference v0.21.2/go.mod h1:pp3PEjIsJ9CZDGCNOyXIQxsNuroxm8FAJ/+quA0yKzQ= github.com/go-openapi/jsonreference v0.21.3/go.mod h1:RqkUP0MrLf37HqxZxrIAtTWW4ZJIK1VzduhXYBEeGc4=
github.com/go-openapi/spec v0.22.0 h1:xT/EsX4frL3U09QviRIZXvkh80yibxQmtoEvyqug0Tw= github.com/go-openapi/spec v0.22.1 h1:beZMa5AVQzRspNjvhe5aG1/XyBSMeX1eEOs7dMoXh/k=
github.com/go-openapi/spec v0.22.0/go.mod h1:K0FhKxkez8YNS94XzF8YKEMULbFrRw4m15i2YUht4L0= github.com/go-openapi/spec v0.22.1/go.mod h1:c7aeIQT175dVowfp7FeCvXXnjN/MrpaONStibD2WtDA=
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
github.com/go-openapi/swag/conv v0.25.1 h1:+9o8YUg6QuqqBM5X6rYL/p1dpWeZRhoIt9x7CCP+he0= github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4=
github.com/go-openapi/swag/conv v0.25.1/go.mod h1:Z1mFEGPfyIKPu0806khI3zF+/EUXde+fdeksUl2NiDs= github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU=
github.com/go-openapi/swag/jsonname v0.25.1 h1:Sgx+qbwa4ej6AomWC6pEfXrA6uP2RkaNjA9BR8a1RJU= github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI=
github.com/go-openapi/swag/jsonname v0.25.1/go.mod h1:71Tekow6UOLBD3wS7XhdT98g5J5GR13NOTQ9/6Q11Zo= github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag=
github.com/go-openapi/swag/jsonutils v0.25.1 h1:AihLHaD0brrkJoMqEZOBNzTLnk81Kg9cWr+SPtxtgl8= github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA=
github.com/go-openapi/swag/jsonutils v0.25.1/go.mod h1:JpEkAjxQXpiaHmRO04N1zE4qbUEg3b7Udll7AMGTNOo= github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1 h1:DSQGcdB6G0N9c/KhtpYc71PzzGEIc/fZ1no35x4/XBY= github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4 h1:IACsSvBhiNJwlDix7wq39SS2Fh7lUOCJRmx/4SN4sVo=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1/go.mod h1:kjmweouyPwRUEYMSrbAidoLMGeJ5p6zdHi9BgZiqmsg= github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4/go.mod h1:Mt0Ost9l3cUzVv4OEZG+WSeoHwjWLnarzMePNDAOBiM=
github.com/go-openapi/swag/loading v0.25.1 h1:6OruqzjWoJyanZOim58iG2vj934TysYVptyaoXS24kw= github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s=
github.com/go-openapi/swag/loading v0.25.1/go.mod h1:xoIe2EG32NOYYbqxvXgPzne989bWvSNoWoyQVWEZicc= github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE=
github.com/go-openapi/swag/stringutils v0.25.1 h1:Xasqgjvk30eUe8VKdmyzKtjkVjeiXx1Iz0zDfMNpPbw= github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8=
github.com/go-openapi/swag/stringutils v0.25.1/go.mod h1:JLdSAq5169HaiDUbTvArA2yQxmgn4D6h4A+4HqVvAYg= github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0=
github.com/go-openapi/swag/typeutils v0.25.1 h1:rD/9HsEQieewNt6/k+JBwkxuAHktFtH3I3ysiFZqukA= github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw=
github.com/go-openapi/swag/typeutils v0.25.1/go.mod h1:9McMC/oCdS4BKwk2shEB7x17P6HmMmA6dQRtAkSnNb8= github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE=
github.com/go-openapi/swag/yamlutils v0.25.1 h1:mry5ez8joJwzvMbaTGLhw8pXUnhDK91oSJLDPF1bmGk= github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw=
github.com/go-openapi/swag/yamlutils v0.25.1/go.mod h1:cm9ywbzncy3y6uPm/97ysW8+wZ09qsks+9RS8fLWKqg= github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc=
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4=
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg=
github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls=
github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/go-sql-driver/mysql v1.9.0 h1:Y0zIbQXhQKmQgTp44Y1dp3wTXcn804QoTptLZT1vtvo= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.0/go.mod h1:pDetrLJeA3oMujJuvXc8RJoasr589B6A9fwzD3QMrqw= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-yaml v1.19.0 h1:EmkZ9RIsX+Uq4DYFowegAuJo8+xdX3T/2dwNPXbxEYE=
github.com/goccy/go-yaml v1.19.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
@@ -177,8 +219,8 @@ github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2E
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
@@ -197,8 +239,8 @@ github.com/linkedin/goavro/v2 v2.14.0 h1:aNO/js65U+Mwq4yB5f1h01c3wiM458qtRad1DN0
github.com/linkedin/goavro/v2 v2.14.0/go.mod h1:KXx+erlq+RPlGSPmLF7xGo6SAbh8sCQ53x064+ioxhk= github.com/linkedin/goavro/v2 v2.14.0/go.mod h1:KXx+erlq+RPlGSPmLF7xGo6SAbh8sCQ53x064+ioxhk=
github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
@@ -244,8 +286,8 @@ github.com/qustavo/sqlhooks/v2 v2.1.0 h1:54yBemHnGHp/7xgT+pxwmIlMSDNYKx5JW5dfRAi
github.com/qustavo/sqlhooks/v2 v2.1.0/go.mod h1:aMREyKo7fOKTwiLuWPsaHRXEmtqG4yREztO0idF83AU= github.com/qustavo/sqlhooks/v2 v2.1.0/go.mod h1:aMREyKo7fOKTwiLuWPsaHRXEmtqG4yREztO0idF83AU=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 h1:lZUw3E0/J3roVtGQ+SCrUrg3ON6NgVqpn3+iol9aGu4= github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 h1:lZUw3E0/J3roVtGQ+SCrUrg3ON6NgVqpn3+iol9aGu4=
@@ -256,6 +298,8 @@ github.com/sosodev/duration v1.3.1 h1:qtHBDMQ6lvMQsL15g4aopM4HEfOaYuhWBw3NPTtlqq
github.com/sosodev/duration v1.3.1/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg= github.com/sosodev/duration v1.3.1/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
@@ -271,8 +315,10 @@ github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU= github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU=
github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4= github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4=
github.com/vektah/gqlparser/v2 v2.5.30 h1:EqLwGAFLIzt1wpx1IPpY67DwUujF1OfzgEyDsLrN6kE= github.com/urfave/cli/v3 v3.6.1 h1:j8Qq8NyUawj/7rTYdBGrxcH7A/j7/G8Q5LhWEW4G3Mo=
github.com/vektah/gqlparser/v2 v2.5.30/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo= github.com/urfave/cli/v3 v3.6.1/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso=
github.com/vektah/gqlparser/v2 v2.5.31 h1:YhWGA1mfTjID7qJhd1+Vxhpk5HTgydrGU9IgkWBTJ7k=
github.com/vektah/gqlparser/v2 v2.5.31/go.mod h1:c1I28gSOVNzlfc4WuDlqU7voQnsqI6OG2amkBAFmgts=
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg= github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg=
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
@@ -294,33 +340,33 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
@@ -328,19 +374,19 @@ 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.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/time v0.13.0 h1:eUlYslOIt32DgYD6utsuUeHs4d7AsEYLuIAdg7FlYgI= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.13.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+3 -3
View File
@@ -65,7 +65,7 @@ func setup(t *testing.T) *api.RestApi {
} }
] ]
}` }`
const testclusterJson = `{ const testclusterJSON = `{
"name": "testcluster", "name": "testcluster",
"subClusters": [ "subClusters": [
{ {
@@ -128,7 +128,7 @@ func setup(t *testing.T) *api.RestApi {
t.Fatal(err) t.Fatal(err)
} }
if err := os.WriteFile(filepath.Join(jobarchive, "version.txt"), fmt.Appendf(nil, "%d", 2), 0o666); err != nil { if err := os.WriteFile(filepath.Join(jobarchive, "version.txt"), fmt.Appendf(nil, "%d", 3), 0o666); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -136,7 +136,7 @@ func setup(t *testing.T) *api.RestApi {
t.Fatal(err) t.Fatal(err)
} }
if err := os.WriteFile(filepath.Join(jobarchive, "testcluster", "cluster.json"), []byte(testclusterJson), 0o666); err != nil { if err := os.WriteFile(filepath.Join(jobarchive, "testcluster", "cluster.json"), []byte(testclusterJSON), 0o666); err != nil {
t.Fatal(err) t.Fatal(err)
} }
+1 -1
View File
@@ -1423,7 +1423,7 @@ const docTemplate = `{
"deadline", "deadline",
"failed", "failed",
"node_fail", "node_fail",
"out-of-memory", "out_of_memory",
"pending", "pending",
"preempted", "preempted",
"running", "running",
+90 -60
View File
@@ -2,6 +2,7 @@
// All rights reserved. This file is part of cc-backend. // All rights reserved. This file is part of cc-backend.
// Use of this source code is governed by a MIT-style // Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file. // license that can be found in the LICENSE file.
package api package api
import ( import (
@@ -29,9 +30,15 @@ import (
"github.com/gorilla/mux" "github.com/gorilla/mux"
) )
// StopJobApiRequest model const (
type StopJobApiRequest struct { // secondsPerDay is the number of seconds in 24 hours.
JobId *int64 `json:"jobId" example:"123000"` // Used for duplicate job detection within a day window.
secondsPerDay = 86400
)
// StopJobAPIRequest model
type StopJobAPIRequest struct {
JobID *int64 `json:"jobId" example:"123000"`
Cluster *string `json:"cluster" example:"fritz"` Cluster *string `json:"cluster" example:"fritz"`
StartTime *int64 `json:"startTime" example:"1649723812"` StartTime *int64 `json:"startTime" example:"1649723812"`
State schema.JobState `json:"jobState" validate:"required" example:"completed"` State schema.JobState `json:"jobState" validate:"required" example:"completed"`
@@ -40,7 +47,7 @@ type StopJobApiRequest struct {
// DeleteJobApiRequest model // DeleteJobApiRequest model
type DeleteJobApiRequest struct { type DeleteJobApiRequest struct {
JobId *int64 `json:"jobId" validate:"required" example:"123000"` // Cluster Job ID of job JobID *int64 `json:"jobId" validate:"required" example:"123000"` // Cluster Job ID of job
Cluster *string `json:"cluster" example:"fritz"` // Cluster of job Cluster *string `json:"cluster" example:"fritz"` // Cluster of job
StartTime *int64 `json:"startTime" example:"1649723812"` // Start Time of job as epoch StartTime *int64 `json:"startTime" example:"1649723812"` // Start Time of job as epoch
} }
@@ -113,7 +120,8 @@ func (api *RestApi) getJobs(rw http.ResponseWriter, r *http.Request) {
for key, vals := range r.URL.Query() { for key, vals := range r.URL.Query() {
switch key { switch key {
// TODO: add project filter case "project":
filter.Project = &model.StringInput{Eq: &vals[0]}
case "state": case "state":
for _, s := range vals { for _, s := range vals {
state := schema.JobState(s) state := schema.JobState(s)
@@ -363,7 +371,7 @@ func (api *RestApi) getJobById(rw http.ResponseWriter, r *http.Request) {
var metrics GetJobApiRequest var metrics GetJobApiRequest
if err = decode(r.Body, &metrics); err != nil { if err = decode(r.Body, &metrics); err != nil {
http.Error(rw, err.Error(), http.StatusBadRequest) handleError(fmt.Errorf("decoding request failed: %w", err), http.StatusBadRequest, rw)
return return
} }
@@ -434,30 +442,32 @@ func (api *RestApi) getJobById(rw http.ResponseWriter, r *http.Request) {
func (api *RestApi) editMeta(rw http.ResponseWriter, r *http.Request) { func (api *RestApi) editMeta(rw http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(mux.Vars(r)["id"], 10, 64) id, err := strconv.ParseInt(mux.Vars(r)["id"], 10, 64)
if err != nil { if err != nil {
http.Error(rw, err.Error(), http.StatusBadRequest) handleError(fmt.Errorf("parsing job ID failed: %w", err), http.StatusBadRequest, rw)
return return
} }
job, err := api.JobRepository.FindById(r.Context(), id) job, err := api.JobRepository.FindById(r.Context(), id)
if err != nil { if err != nil {
http.Error(rw, err.Error(), http.StatusNotFound) handleError(fmt.Errorf("finding job failed: %w", err), http.StatusNotFound, rw)
return return
} }
var req EditMetaRequest var req EditMetaRequest
if err := decode(r.Body, &req); err != nil { if err := decode(r.Body, &req); err != nil {
http.Error(rw, err.Error(), http.StatusBadRequest) handleError(fmt.Errorf("decoding request failed: %w", err), http.StatusBadRequest, rw)
return return
} }
if err := api.JobRepository.UpdateMetadata(job, req.Key, req.Value); err != nil { if err := api.JobRepository.UpdateMetadata(job, req.Key, req.Value); err != nil {
http.Error(rw, err.Error(), http.StatusInternalServerError) handleError(fmt.Errorf("updating metadata failed: %w", err), http.StatusInternalServerError, rw)
return return
} }
rw.Header().Add("Content-Type", "application/json") rw.Header().Add("Content-Type", "application/json")
rw.WriteHeader(http.StatusOK) rw.WriteHeader(http.StatusOK)
json.NewEncoder(rw).Encode(job) if err := json.NewEncoder(rw).Encode(job); err != nil {
cclog.Errorf("Failed to encode job response: %v", err)
}
} }
// tagJob godoc // tagJob godoc
@@ -480,32 +490,32 @@ func (api *RestApi) editMeta(rw http.ResponseWriter, r *http.Request) {
func (api *RestApi) tagJob(rw http.ResponseWriter, r *http.Request) { func (api *RestApi) tagJob(rw http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(mux.Vars(r)["id"], 10, 64) id, err := strconv.ParseInt(mux.Vars(r)["id"], 10, 64)
if err != nil { if err != nil {
http.Error(rw, err.Error(), http.StatusBadRequest) handleError(fmt.Errorf("parsing job ID failed: %w", err), http.StatusBadRequest, rw)
return return
} }
job, err := api.JobRepository.FindById(r.Context(), id) job, err := api.JobRepository.FindById(r.Context(), id)
if err != nil { if err != nil {
http.Error(rw, err.Error(), http.StatusNotFound) handleError(fmt.Errorf("finding job failed: %w", err), http.StatusNotFound, rw)
return return
} }
job.Tags, err = api.JobRepository.GetTags(repository.GetUserFromContext(r.Context()), job.ID) job.Tags, err = api.JobRepository.GetTags(repository.GetUserFromContext(r.Context()), job.ID)
if err != nil { if err != nil {
http.Error(rw, err.Error(), http.StatusInternalServerError) handleError(fmt.Errorf("getting tags failed: %w", err), http.StatusInternalServerError, rw)
return return
} }
var req TagJobApiRequest var req TagJobApiRequest
if err := decode(r.Body, &req); err != nil { if err := decode(r.Body, &req); err != nil {
http.Error(rw, err.Error(), http.StatusBadRequest) handleError(fmt.Errorf("decoding request failed: %w", err), http.StatusBadRequest, rw)
return return
} }
for _, tag := range req { for _, tag := range req {
tagId, err := api.JobRepository.AddTagOrCreate(repository.GetUserFromContext(r.Context()), *job.ID, tag.Type, tag.Name, tag.Scope) tagId, err := api.JobRepository.AddTagOrCreate(repository.GetUserFromContext(r.Context()), *job.ID, tag.Type, tag.Name, tag.Scope)
if err != nil { if err != nil {
http.Error(rw, err.Error(), http.StatusInternalServerError) handleError(fmt.Errorf("adding tag failed: %w", err), http.StatusInternalServerError, rw)
return return
} }
@@ -519,7 +529,9 @@ func (api *RestApi) tagJob(rw http.ResponseWriter, r *http.Request) {
rw.Header().Add("Content-Type", "application/json") rw.Header().Add("Content-Type", "application/json")
rw.WriteHeader(http.StatusOK) rw.WriteHeader(http.StatusOK)
json.NewEncoder(rw).Encode(job) if err := json.NewEncoder(rw).Encode(job); err != nil {
cclog.Errorf("Failed to encode job response: %v", err)
}
} }
// removeTagJob godoc // removeTagJob godoc
@@ -542,25 +554,25 @@ func (api *RestApi) tagJob(rw http.ResponseWriter, r *http.Request) {
func (api *RestApi) removeTagJob(rw http.ResponseWriter, r *http.Request) { func (api *RestApi) removeTagJob(rw http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(mux.Vars(r)["id"], 10, 64) id, err := strconv.ParseInt(mux.Vars(r)["id"], 10, 64)
if err != nil { if err != nil {
http.Error(rw, err.Error(), http.StatusBadRequest) handleError(fmt.Errorf("parsing job ID failed: %w", err), http.StatusBadRequest, rw)
return return
} }
job, err := api.JobRepository.FindById(r.Context(), id) job, err := api.JobRepository.FindById(r.Context(), id)
if err != nil { if err != nil {
http.Error(rw, err.Error(), http.StatusNotFound) handleError(fmt.Errorf("finding job failed: %w", err), http.StatusNotFound, rw)
return return
} }
job.Tags, err = api.JobRepository.GetTags(repository.GetUserFromContext(r.Context()), job.ID) job.Tags, err = api.JobRepository.GetTags(repository.GetUserFromContext(r.Context()), job.ID)
if err != nil { if err != nil {
http.Error(rw, err.Error(), http.StatusInternalServerError) handleError(fmt.Errorf("getting tags failed: %w", err), http.StatusInternalServerError, rw)
return return
} }
var req TagJobApiRequest var req TagJobApiRequest
if err := decode(r.Body, &req); err != nil { if err := decode(r.Body, &req); err != nil {
http.Error(rw, err.Error(), http.StatusBadRequest) handleError(fmt.Errorf("decoding request failed: %w", err), http.StatusBadRequest, rw)
return return
} }
@@ -573,7 +585,7 @@ func (api *RestApi) removeTagJob(rw http.ResponseWriter, r *http.Request) {
remainingTags, err := api.JobRepository.RemoveJobTagByRequest(repository.GetUserFromContext(r.Context()), *job.ID, rtag.Type, rtag.Name, rtag.Scope) remainingTags, err := api.JobRepository.RemoveJobTagByRequest(repository.GetUserFromContext(r.Context()), *job.ID, rtag.Type, rtag.Name, rtag.Scope)
if err != nil { if err != nil {
http.Error(rw, err.Error(), http.StatusInternalServerError) handleError(fmt.Errorf("removing tag failed: %w", err), http.StatusInternalServerError, rw)
return return
} }
@@ -582,7 +594,9 @@ func (api *RestApi) removeTagJob(rw http.ResponseWriter, r *http.Request) {
rw.Header().Add("Content-Type", "application/json") rw.Header().Add("Content-Type", "application/json")
rw.WriteHeader(http.StatusOK) rw.WriteHeader(http.StatusOK)
json.NewEncoder(rw).Encode(job) if err := json.NewEncoder(rw).Encode(job); err != nil {
cclog.Errorf("Failed to encode job response: %v", err)
}
} }
// removeTags godoc // removeTags godoc
@@ -604,7 +618,7 @@ func (api *RestApi) removeTagJob(rw http.ResponseWriter, r *http.Request) {
func (api *RestApi) removeTags(rw http.ResponseWriter, r *http.Request) { func (api *RestApi) removeTags(rw http.ResponseWriter, r *http.Request) {
var req TagJobApiRequest var req TagJobApiRequest
if err := decode(r.Body, &req); err != nil { if err := decode(r.Body, &req); err != nil {
http.Error(rw, err.Error(), http.StatusBadRequest) handleError(fmt.Errorf("decoding request failed: %w", err), http.StatusBadRequest, rw)
return return
} }
@@ -619,11 +633,10 @@ func (api *RestApi) removeTags(rw http.ResponseWriter, r *http.Request) {
err := api.JobRepository.RemoveTagByRequest(rtag.Type, rtag.Name, rtag.Scope) err := api.JobRepository.RemoveTagByRequest(rtag.Type, rtag.Name, rtag.Scope)
if err != nil { if err != nil {
http.Error(rw, err.Error(), http.StatusInternalServerError) handleError(fmt.Errorf("removing tag failed: %w", err), http.StatusInternalServerError, rw)
return return
} else {
currentCount++
} }
currentCount++
} }
rw.WriteHeader(http.StatusOK) rw.WriteHeader(http.StatusOK)
@@ -674,9 +687,11 @@ func (api *RestApi) startJob(rw http.ResponseWriter, r *http.Request) {
if err != nil && err != sql.ErrNoRows { if err != nil && err != sql.ErrNoRows {
handleError(fmt.Errorf("checking for duplicate failed: %w", err), http.StatusInternalServerError, rw) handleError(fmt.Errorf("checking for duplicate failed: %w", err), http.StatusInternalServerError, rw)
return return
} else if err == nil { }
if err == nil {
for _, job := range jobs { for _, job := range jobs {
if (req.StartTime - job.StartTime) < 86400 { // Check if jobs are within the same day (prevent duplicates)
if (req.StartTime - job.StartTime) < secondsPerDay {
handleError(fmt.Errorf("a job with that jobId, cluster and startTime already exists: dbid: %d, jobid: %d", job.ID, job.JobID), http.StatusUnprocessableEntity, rw) handleError(fmt.Errorf("a job with that jobId, cluster and startTime already exists: dbid: %d, jobid: %d", job.ID, job.JobID), http.StatusUnprocessableEntity, rw)
return return
} }
@@ -693,7 +708,6 @@ func (api *RestApi) startJob(rw http.ResponseWriter, r *http.Request) {
for _, tag := range req.Tags { for _, tag := range req.Tags {
if _, err := api.JobRepository.AddTagOrCreate(repository.GetUserFromContext(r.Context()), id, tag.Type, tag.Name, tag.Scope); err != nil { if _, err := api.JobRepository.AddTagOrCreate(repository.GetUserFromContext(r.Context()), id, tag.Type, tag.Name, tag.Scope); err != nil {
http.Error(rw, err.Error(), http.StatusInternalServerError)
handleError(fmt.Errorf("adding tag to new job %d failed: %w", id, err), http.StatusInternalServerError, rw) handleError(fmt.Errorf("adding tag to new job %d failed: %w", id, err), http.StatusInternalServerError, rw)
return return
} }
@@ -702,9 +716,11 @@ func (api *RestApi) startJob(rw http.ResponseWriter, r *http.Request) {
cclog.Printf("new job (id: %d): cluster=%s, jobId=%d, user=%s, startTime=%d", id, req.Cluster, req.JobID, req.User, req.StartTime) cclog.Printf("new job (id: %d): cluster=%s, jobId=%d, user=%s, startTime=%d", id, req.Cluster, req.JobID, req.User, req.StartTime)
rw.Header().Add("Content-Type", "application/json") rw.Header().Add("Content-Type", "application/json")
rw.WriteHeader(http.StatusCreated) rw.WriteHeader(http.StatusCreated)
json.NewEncoder(rw).Encode(DefaultApiResponse{ if err := json.NewEncoder(rw).Encode(DefaultApiResponse{
Message: "success", Message: "success",
}) }); err != nil {
cclog.Errorf("Failed to encode response: %v", err)
}
} }
// stopJobByRequest godoc // stopJobByRequest godoc
@@ -725,7 +741,7 @@ func (api *RestApi) startJob(rw http.ResponseWriter, r *http.Request) {
// @router /api/jobs/stop_job/ [post] // @router /api/jobs/stop_job/ [post]
func (api *RestApi) stopJobByRequest(rw http.ResponseWriter, r *http.Request) { func (api *RestApi) stopJobByRequest(rw http.ResponseWriter, r *http.Request) {
// Parse request body // Parse request body
req := StopJobApiRequest{} req := StopJobAPIRequest{}
if err := decode(r.Body, &req); err != nil { if err := decode(r.Body, &req); err != nil {
handleError(fmt.Errorf("parsing request body failed: %w", err), http.StatusBadRequest, rw) handleError(fmt.Errorf("parsing request body failed: %w", err), http.StatusBadRequest, rw)
return return
@@ -734,20 +750,22 @@ func (api *RestApi) stopJobByRequest(rw http.ResponseWriter, r *http.Request) {
// Fetch job (that will be stopped) from db // Fetch job (that will be stopped) from db
var job *schema.Job var job *schema.Job
var err error var err error
if req.JobId == nil { if req.JobID == nil {
handleError(errors.New("the field 'jobId' is required"), http.StatusBadRequest, rw) handleError(errors.New("the field 'jobId' is required"), http.StatusBadRequest, rw)
return return
} }
// cclog.Printf("loading db job for stopJobByRequest... : stopJobApiRequest=%v", req) // cclog.Printf("loading db job for stopJobByRequest... : stopJobApiRequest=%v", req)
job, err = api.JobRepository.Find(req.JobId, req.Cluster, req.StartTime) job, err = api.JobRepository.Find(req.JobID, req.Cluster, req.StartTime)
if err != nil { if err != nil {
job, err = api.JobRepository.FindCached(req.JobId, req.Cluster, req.StartTime) // Try cached jobs if not found in main repository
// FIXME: Previous error is hidden cachedJob, cachedErr := api.JobRepository.FindCached(req.JobID, req.Cluster, req.StartTime)
if err != nil { if cachedErr != nil {
handleError(fmt.Errorf("finding job failed: %w", err), http.StatusUnprocessableEntity, rw) // Combine both errors for better debugging
handleError(fmt.Errorf("finding job failed: %w (cached lookup also failed: %v)", err, cachedErr), http.StatusNotFound, rw)
return return
} }
job = cachedJob
} }
api.checkAndHandleStopJob(rw, job, req) api.checkAndHandleStopJob(rw, job, req)
@@ -790,9 +808,11 @@ func (api *RestApi) deleteJobById(rw http.ResponseWriter, r *http.Request) {
} }
rw.Header().Add("Content-Type", "application/json") rw.Header().Add("Content-Type", "application/json")
rw.WriteHeader(http.StatusOK) rw.WriteHeader(http.StatusOK)
json.NewEncoder(rw).Encode(DefaultApiResponse{ if err := json.NewEncoder(rw).Encode(DefaultApiResponse{
Message: fmt.Sprintf("Successfully deleted job %s", id), Message: fmt.Sprintf("Successfully deleted job %s", id),
}) }); err != nil {
cclog.Errorf("Failed to encode response: %v", err)
}
} }
// deleteJobByRequest godoc // deleteJobByRequest godoc
@@ -822,12 +842,12 @@ func (api *RestApi) deleteJobByRequest(rw http.ResponseWriter, r *http.Request)
// Fetch job (that will be deleted) from db // Fetch job (that will be deleted) from db
var job *schema.Job var job *schema.Job
var err error var err error
if req.JobId == nil { if req.JobID == nil {
handleError(errors.New("the field 'jobId' is required"), http.StatusBadRequest, rw) handleError(errors.New("the field 'jobId' is required"), http.StatusBadRequest, rw)
return return
} }
job, err = api.JobRepository.Find(req.JobId, req.Cluster, req.StartTime) job, err = api.JobRepository.Find(req.JobID, req.Cluster, req.StartTime)
if err != nil { if err != nil {
handleError(fmt.Errorf("finding job failed: %w", err), http.StatusUnprocessableEntity, rw) handleError(fmt.Errorf("finding job failed: %w", err), http.StatusUnprocessableEntity, rw)
return return
@@ -841,9 +861,11 @@ func (api *RestApi) deleteJobByRequest(rw http.ResponseWriter, r *http.Request)
rw.Header().Add("Content-Type", "application/json") rw.Header().Add("Content-Type", "application/json")
rw.WriteHeader(http.StatusOK) rw.WriteHeader(http.StatusOK)
json.NewEncoder(rw).Encode(DefaultApiResponse{ if err := json.NewEncoder(rw).Encode(DefaultApiResponse{
Message: fmt.Sprintf("Successfully deleted job %d", job.ID), Message: fmt.Sprintf("Successfully deleted job %d", job.ID),
}) }); err != nil {
cclog.Errorf("Failed to encode response: %v", err)
}
} }
// deleteJobBefore godoc // deleteJobBefore godoc
@@ -885,19 +907,21 @@ func (api *RestApi) deleteJobBefore(rw http.ResponseWriter, r *http.Request) {
rw.Header().Add("Content-Type", "application/json") rw.Header().Add("Content-Type", "application/json")
rw.WriteHeader(http.StatusOK) rw.WriteHeader(http.StatusOK)
json.NewEncoder(rw).Encode(DefaultApiResponse{ if err := json.NewEncoder(rw).Encode(DefaultApiResponse{
Message: fmt.Sprintf("Successfully deleted %d jobs", cnt), Message: fmt.Sprintf("Successfully deleted %d jobs", cnt),
}) }); err != nil {
cclog.Errorf("Failed to encode response: %v", err)
}
} }
func (api *RestApi) checkAndHandleStopJob(rw http.ResponseWriter, job *schema.Job, req StopJobApiRequest) { func (api *RestApi) checkAndHandleStopJob(rw http.ResponseWriter, job *schema.Job, req StopJobAPIRequest) {
// Sanity checks // Sanity checks
if job.State != schema.JobStateRunning { if job.State != schema.JobStateRunning {
handleError(fmt.Errorf("jobId %d (id %d) on %s : job has already been stopped (state is: %s)", job.JobID, job.ID, job.Cluster, job.State), http.StatusUnprocessableEntity, rw) handleError(fmt.Errorf("jobId %d (id %d) on %s : job has already been stopped (state is: %s)", job.JobID, job.ID, job.Cluster, job.State), http.StatusUnprocessableEntity, rw)
return return
} }
if job == nil || job.StartTime > req.StopTime { if job.StartTime > req.StopTime {
handleError(fmt.Errorf("jobId %d (id %d) on %s : stopTime %d must be larger/equal than startTime %d", job.JobID, job.ID, job.Cluster, req.StopTime, job.StartTime), http.StatusBadRequest, rw) handleError(fmt.Errorf("jobId %d (id %d) on %s : stopTime %d must be larger/equal than startTime %d", job.JobID, job.ID, job.Cluster, req.StopTime, job.StartTime), http.StatusBadRequest, rw)
return return
} }
@@ -913,14 +937,14 @@ func (api *RestApi) checkAndHandleStopJob(rw http.ResponseWriter, job *schema.Jo
job.Duration = int32(req.StopTime - job.StartTime) job.Duration = int32(req.StopTime - job.StartTime)
job.State = req.State job.State = req.State
api.JobRepository.Mutex.Lock() api.JobRepository.Mutex.Lock()
defer api.JobRepository.Mutex.Unlock()
if err := api.JobRepository.Stop(*job.ID, job.Duration, job.State, job.MonitoringStatus); err != nil { if err := api.JobRepository.Stop(*job.ID, job.Duration, job.State, job.MonitoringStatus); err != nil {
if err := api.JobRepository.StopCached(*job.ID, job.Duration, job.State, job.MonitoringStatus); err != nil { if err := api.JobRepository.StopCached(*job.ID, job.Duration, job.State, job.MonitoringStatus); err != nil {
api.JobRepository.Mutex.Unlock()
handleError(fmt.Errorf("jobId %d (id %d) on %s : marking job as '%s' (duration: %d) in DB failed: %w", job.JobID, job.ID, job.Cluster, job.State, job.Duration, err), http.StatusInternalServerError, rw) handleError(fmt.Errorf("jobId %d (id %d) on %s : marking job as '%s' (duration: %d) in DB failed: %w", job.JobID, job.ID, job.Cluster, job.State, job.Duration, err), http.StatusInternalServerError, rw)
return return
} }
} }
api.JobRepository.Mutex.Unlock()
cclog.Printf("archiving job... (dbid: %d): cluster=%s, jobId=%d, user=%s, startTime=%d, duration=%d, state=%s", job.ID, job.Cluster, job.JobID, job.User, job.StartTime, job.Duration, job.State) cclog.Printf("archiving job... (dbid: %d): cluster=%s, jobId=%d, user=%s, startTime=%d, duration=%d, state=%s", job.ID, job.Cluster, job.JobID, job.User, job.StartTime, job.Duration, job.State)
@@ -929,7 +953,9 @@ func (api *RestApi) checkAndHandleStopJob(rw http.ResponseWriter, job *schema.Jo
// writing to the filesystem fails, the client will not know. // writing to the filesystem fails, the client will not know.
rw.Header().Add("Content-Type", "application/json") rw.Header().Add("Content-Type", "application/json")
rw.WriteHeader(http.StatusOK) rw.WriteHeader(http.StatusOK)
json.NewEncoder(rw).Encode(job) if err := json.NewEncoder(rw).Encode(job); err != nil {
cclog.Errorf("Failed to encode job response: %v", err)
}
// Monitoring is disabled... // Monitoring is disabled...
if job.MonitoringStatus == schema.MonitoringStatusDisabled { if job.MonitoringStatus == schema.MonitoringStatusDisabled {
@@ -947,7 +973,7 @@ func (api *RestApi) getJobMetrics(rw http.ResponseWriter, r *http.Request) {
for _, scope := range r.URL.Query()["scope"] { for _, scope := range r.URL.Query()["scope"] {
var s schema.MetricScope var s schema.MetricScope
if err := s.UnmarshalGQL(scope); err != nil { if err := s.UnmarshalGQL(scope); err != nil {
http.Error(rw, err.Error(), http.StatusBadRequest) handleError(fmt.Errorf("unmarshaling scope failed: %w", err), http.StatusBadRequest, rw)
return return
} }
scopes = append(scopes, s) scopes = append(scopes, s)
@@ -956,7 +982,7 @@ func (api *RestApi) getJobMetrics(rw http.ResponseWriter, r *http.Request) {
rw.Header().Add("Content-Type", "application/json") rw.Header().Add("Content-Type", "application/json")
rw.WriteHeader(http.StatusOK) rw.WriteHeader(http.StatusOK)
type Respone struct { type Response struct {
Data *struct { Data *struct {
JobMetrics []*model.JobMetricWithName `json:"jobMetrics"` JobMetrics []*model.JobMetricWithName `json:"jobMetrics"`
} `json:"data"` } `json:"data"`
@@ -968,17 +994,21 @@ func (api *RestApi) getJobMetrics(rw http.ResponseWriter, r *http.Request) {
resolver := graph.GetResolverInstance() resolver := graph.GetResolverInstance()
data, err := resolver.Query().JobMetrics(r.Context(), id, metrics, scopes, nil) data, err := resolver.Query().JobMetrics(r.Context(), id, metrics, scopes, nil)
if err != nil { if err != nil {
json.NewEncoder(rw).Encode(Respone{ if err := json.NewEncoder(rw).Encode(Response{
Error: &struct { Error: &struct {
Message string "json:\"message\"" Message string `json:"message"`
}{Message: err.Error()}, }{Message: err.Error()},
}) }); err != nil {
cclog.Errorf("Failed to encode error response: %v", err)
}
return return
} }
json.NewEncoder(rw).Encode(Respone{ if err := json.NewEncoder(rw).Encode(Response{
Data: &struct { Data: &struct {
JobMetrics []*model.JobMetricWithName "json:\"jobMetrics\"" JobMetrics []*model.JobMetricWithName `json:"jobMetrics"`
}{JobMetrics: data}, }{JobMetrics: data},
}) }); err != nil {
cclog.Errorf("Failed to encode response: %v", err)
}
} }
-7
View File
@@ -50,13 +50,6 @@ func freeMetrics(rw http.ResponseWriter, r *http.Request) {
return return
} }
// // TODO: lastCheckpoint might be modified by different go-routines.
// // Load it using the sync/atomic package?
// freeUpTo := lastCheckpoint.Unix()
// if to < freeUpTo {
// freeUpTo = to
// }
bodyDec := json.NewDecoder(r.Body) bodyDec := json.NewDecoder(r.Body)
var selectors [][]string var selectors [][]string
err = bodyDec.Decode(&selectors) err = bodyDec.Decode(&selectors)
+79 -35
View File
@@ -2,6 +2,11 @@
// All rights reserved. This file is part of cc-backend. // All rights reserved. This file is part of cc-backend.
// Use of this source code is governed by a MIT-style // Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file. // license that can be found in the LICENSE file.
// Package api provides the REST API layer for ClusterCockpit.
// It handles HTTP requests for job management, user administration,
// cluster queries, node state updates, and metrics storage operations.
// The API supports both JWT token authentication and session-based authentication.
package api package api
import ( import (
@@ -11,6 +16,7 @@ import (
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"sync" "sync"
"github.com/ClusterCockpit/cc-backend/internal/auth" "github.com/ClusterCockpit/cc-backend/internal/auth"
@@ -39,10 +45,19 @@ import (
// @in header // @in header
// @name X-Auth-Token // @name X-Auth-Token
const (
noticeFilePath = "./var/notice.txt"
noticeFilePerms = 0o644
)
type RestApi struct { type RestApi struct {
JobRepository *repository.JobRepository JobRepository *repository.JobRepository
Authentication *auth.Authentication Authentication *auth.Authentication
MachineStateDir string MachineStateDir string
// RepositoryMutex protects job creation operations from race conditions
// when checking for duplicate jobs during startJob API calls.
// It prevents concurrent job starts with the same jobId/cluster/startTime
// from creating duplicate entries in the database.
RepositoryMutex sync.Mutex RepositoryMutex sync.Mutex
} }
@@ -66,7 +81,6 @@ func (api *RestApi) MountApiRoutes(r *mux.Router) {
// Job Handler // Job Handler
r.HandleFunc("/jobs/start_job/", api.startJob).Methods(http.MethodPost, http.MethodPut) r.HandleFunc("/jobs/start_job/", api.startJob).Methods(http.MethodPost, http.MethodPut)
r.HandleFunc("/jobs/stop_job/", api.stopJobByRequest).Methods(http.MethodPost, http.MethodPut) r.HandleFunc("/jobs/stop_job/", api.stopJobByRequest).Methods(http.MethodPost, http.MethodPut)
// r.HandleFunc("/jobs/import/", api.importJob).Methods(http.MethodPost, http.MethodPut)
r.HandleFunc("/jobs/", api.getJobs).Methods(http.MethodGet) r.HandleFunc("/jobs/", api.getJobs).Methods(http.MethodGet)
r.HandleFunc("/jobs/{id}", api.getJobById).Methods(http.MethodPost) r.HandleFunc("/jobs/{id}", api.getJobById).Methods(http.MethodPost)
r.HandleFunc("/jobs/{id}", api.getCompleteJobById).Methods(http.MethodGet) r.HandleFunc("/jobs/{id}", api.getCompleteJobById).Methods(http.MethodGet)
@@ -97,6 +111,7 @@ func (api *RestApi) MountUserApiRoutes(r *mux.Router) {
func (api *RestApi) MountMetricStoreApiRoutes(r *mux.Router) { func (api *RestApi) MountMetricStoreApiRoutes(r *mux.Router) {
// REST API Uses TokenAuth // REST API Uses TokenAuth
// Note: StrictSlash handles trailing slash variations automatically
r.HandleFunc("/api/free", freeMetrics).Methods(http.MethodPost) r.HandleFunc("/api/free", freeMetrics).Methods(http.MethodPost)
r.HandleFunc("/api/write", writeMetrics).Methods(http.MethodPost) r.HandleFunc("/api/write", writeMetrics).Methods(http.MethodPost)
r.HandleFunc("/api/debug", debugMetrics).Methods(http.MethodGet) r.HandleFunc("/api/debug", debugMetrics).Methods(http.MethodGet)
@@ -146,10 +161,12 @@ func handleError(err error, statusCode int, rw http.ResponseWriter) {
cclog.Warnf("REST ERROR : %s", err.Error()) cclog.Warnf("REST ERROR : %s", err.Error())
rw.Header().Add("Content-Type", "application/json") rw.Header().Add("Content-Type", "application/json")
rw.WriteHeader(statusCode) rw.WriteHeader(statusCode)
json.NewEncoder(rw).Encode(ErrorResponse{ if err := json.NewEncoder(rw).Encode(ErrorResponse{
Status: http.StatusText(statusCode), Status: http.StatusText(statusCode),
Error: err.Error(), Error: err.Error(),
}) }); err != nil {
cclog.Errorf("Failed to encode error response: %v", err)
}
} }
func decode(r io.Reader, val any) error { func decode(r io.Reader, val any) error {
@@ -162,43 +179,43 @@ func (api *RestApi) editNotice(rw http.ResponseWriter, r *http.Request) {
// SecuredCheck() only worked with TokenAuth: Removed // SecuredCheck() only worked with TokenAuth: Removed
if user := repository.GetUserFromContext(r.Context()); !user.HasRole(schema.RoleAdmin) { if user := repository.GetUserFromContext(r.Context()); !user.HasRole(schema.RoleAdmin) {
http.Error(rw, "Only admins are allowed to update the notice.txt file", http.StatusForbidden) handleError(fmt.Errorf("only admins are allowed to update the notice.txt file"), http.StatusForbidden, rw)
return return
} }
// Get Value // Get Value
newContent := r.FormValue("new-content") newContent := r.FormValue("new-content")
// Check FIle // Validate content length to prevent DoS
noticeExists := util.CheckFileExists("./var/notice.txt") if len(newContent) > 10000 {
handleError(fmt.Errorf("notice content exceeds maximum length of 10000 characters"), http.StatusBadRequest, rw)
return
}
// Check File
noticeExists := util.CheckFileExists(noticeFilePath)
if !noticeExists { if !noticeExists {
ntxt, err := os.Create("./var/notice.txt") ntxt, err := os.Create(noticeFilePath)
if err != nil { if err != nil {
cclog.Errorf("Creating ./var/notice.txt failed: %s", err.Error()) handleError(fmt.Errorf("creating notice file failed: %w", err), http.StatusInternalServerError, rw)
http.Error(rw, err.Error(), http.StatusUnprocessableEntity)
return return
} }
ntxt.Close() ntxt.Close()
} }
if newContent != "" { if err := os.WriteFile(noticeFilePath, []byte(newContent), noticeFilePerms); err != nil {
if err := os.WriteFile("./var/notice.txt", []byte(newContent), 0o666); err != nil { handleError(fmt.Errorf("writing to notice file failed: %w", err), http.StatusInternalServerError, rw)
cclog.Errorf("Writing to ./var/notice.txt failed: %s", err.Error())
http.Error(rw, err.Error(), http.StatusUnprocessableEntity)
return return
} else {
rw.Write([]byte("Update Notice Content Success"))
} }
} else {
if err := os.WriteFile("./var/notice.txt", []byte(""), 0o666); err != nil { rw.Header().Set("Content-Type", "text/plain")
cclog.Errorf("Writing to ./var/notice.txt failed: %s", err.Error()) rw.WriteHeader(http.StatusOK)
http.Error(rw, err.Error(), http.StatusUnprocessableEntity) if newContent != "" {
return rw.Write([]byte("Update Notice Content Success"))
} else { } else {
rw.Write([]byte("Empty Notice Content Success")) rw.Write([]byte("Empty Notice Content Success"))
} }
} }
}
func (api *RestApi) getJWT(rw http.ResponseWriter, r *http.Request) { func (api *RestApi) getJWT(rw http.ResponseWriter, r *http.Request) {
rw.Header().Set("Content-Type", "text/plain") rw.Header().Set("Content-Type", "text/plain")
@@ -206,21 +223,20 @@ func (api *RestApi) getJWT(rw http.ResponseWriter, r *http.Request) {
me := repository.GetUserFromContext(r.Context()) me := repository.GetUserFromContext(r.Context())
if !me.HasRole(schema.RoleAdmin) { if !me.HasRole(schema.RoleAdmin) {
if username != me.Username { if username != me.Username {
http.Error(rw, "Only admins are allowed to sign JWTs not for themselves", handleError(fmt.Errorf("only admins are allowed to sign JWTs not for themselves"), http.StatusForbidden, rw)
http.StatusForbidden)
return return
} }
} }
user, err := repository.GetUserRepository().GetUser(username) user, err := repository.GetUserRepository().GetUser(username)
if err != nil { if err != nil {
http.Error(rw, err.Error(), http.StatusUnprocessableEntity) handleError(fmt.Errorf("getting user failed: %w", err), http.StatusNotFound, rw)
return return
} }
jwt, err := api.Authentication.JwtAuth.ProvideJWT(user) jwt, err := api.Authentication.JwtAuth.ProvideJWT(user)
if err != nil { if err != nil {
http.Error(rw, err.Error(), http.StatusUnprocessableEntity) handleError(fmt.Errorf("providing JWT failed: %w", err), http.StatusInternalServerError, rw)
return return
} }
@@ -233,17 +249,20 @@ func (api *RestApi) getRoles(rw http.ResponseWriter, r *http.Request) {
user := repository.GetUserFromContext(r.Context()) user := repository.GetUserFromContext(r.Context())
if !user.HasRole(schema.RoleAdmin) { if !user.HasRole(schema.RoleAdmin) {
http.Error(rw, "only admins are allowed to fetch a list of roles", http.StatusForbidden) handleError(fmt.Errorf("only admins are allowed to fetch a list of roles"), http.StatusForbidden, rw)
return return
} }
roles, err := schema.GetValidRoles(user) roles, err := schema.GetValidRoles(user)
if err != nil { if err != nil {
http.Error(rw, err.Error(), http.StatusInternalServerError) handleError(fmt.Errorf("getting valid roles failed: %w", err), http.StatusInternalServerError, rw)
return return
} }
json.NewEncoder(rw).Encode(roles) rw.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(rw).Encode(roles); err != nil {
cclog.Errorf("Failed to encode roles response: %v", err)
}
} }
func (api *RestApi) updateConfiguration(rw http.ResponseWriter, r *http.Request) { func (api *RestApi) updateConfiguration(rw http.ResponseWriter, r *http.Request) {
@@ -251,38 +270,50 @@ func (api *RestApi) updateConfiguration(rw http.ResponseWriter, r *http.Request)
key, value := r.FormValue("key"), r.FormValue("value") key, value := r.FormValue("key"), r.FormValue("value")
if err := repository.GetUserCfgRepo().UpdateConfig(key, value, repository.GetUserFromContext(r.Context())); err != nil { if err := repository.GetUserCfgRepo().UpdateConfig(key, value, repository.GetUserFromContext(r.Context())); err != nil {
http.Error(rw, err.Error(), http.StatusUnprocessableEntity) handleError(fmt.Errorf("updating configuration failed: %w", err), http.StatusInternalServerError, rw)
return return
} }
rw.WriteHeader(http.StatusOK)
rw.Write([]byte("success")) rw.Write([]byte("success"))
} }
func (api *RestApi) putMachineState(rw http.ResponseWriter, r *http.Request) { func (api *RestApi) putMachineState(rw http.ResponseWriter, r *http.Request) {
if api.MachineStateDir == "" { if api.MachineStateDir == "" {
http.Error(rw, "REST > machine state not enabled", http.StatusNotFound) handleError(fmt.Errorf("machine state not enabled"), http.StatusNotFound, rw)
return return
} }
vars := mux.Vars(r) vars := mux.Vars(r)
cluster := vars["cluster"] cluster := vars["cluster"]
host := vars["host"] host := vars["host"]
// Validate cluster and host to prevent path traversal attacks
if strings.Contains(cluster, "..") || strings.Contains(cluster, "/") || strings.Contains(cluster, "\\") {
handleError(fmt.Errorf("invalid cluster name"), http.StatusBadRequest, rw)
return
}
if strings.Contains(host, "..") || strings.Contains(host, "/") || strings.Contains(host, "\\") {
handleError(fmt.Errorf("invalid host name"), http.StatusBadRequest, rw)
return
}
dir := filepath.Join(api.MachineStateDir, cluster) dir := filepath.Join(api.MachineStateDir, cluster)
if err := os.MkdirAll(dir, 0o755); err != nil { if err := os.MkdirAll(dir, 0o755); err != nil {
http.Error(rw, err.Error(), http.StatusInternalServerError) handleError(fmt.Errorf("creating directory failed: %w", err), http.StatusInternalServerError, rw)
return return
} }
filename := filepath.Join(dir, fmt.Sprintf("%s.json", host)) filename := filepath.Join(dir, fmt.Sprintf("%s.json", host))
f, err := os.Create(filename) f, err := os.Create(filename)
if err != nil { if err != nil {
http.Error(rw, err.Error(), http.StatusInternalServerError) handleError(fmt.Errorf("creating file failed: %w", err), http.StatusInternalServerError, rw)
return return
} }
defer f.Close() defer f.Close()
if _, err := io.Copy(f, r.Body); err != nil { if _, err := io.Copy(f, r.Body); err != nil {
http.Error(rw, err.Error(), http.StatusInternalServerError) handleError(fmt.Errorf("writing file failed: %w", err), http.StatusInternalServerError, rw)
return return
} }
@@ -291,12 +322,25 @@ func (api *RestApi) putMachineState(rw http.ResponseWriter, r *http.Request) {
func (api *RestApi) getMachineState(rw http.ResponseWriter, r *http.Request) { func (api *RestApi) getMachineState(rw http.ResponseWriter, r *http.Request) {
if api.MachineStateDir == "" { if api.MachineStateDir == "" {
http.Error(rw, "REST > machine state not enabled", http.StatusNotFound) handleError(fmt.Errorf("machine state not enabled"), http.StatusNotFound, rw)
return return
} }
vars := mux.Vars(r) vars := mux.Vars(r)
filename := filepath.Join(api.MachineStateDir, vars["cluster"], fmt.Sprintf("%s.json", vars["host"])) cluster := vars["cluster"]
host := vars["host"]
// Validate cluster and host to prevent path traversal attacks
if strings.Contains(cluster, "..") || strings.Contains(cluster, "/") || strings.Contains(cluster, "\\") {
handleError(fmt.Errorf("invalid cluster name"), http.StatusBadRequest, rw)
return
}
if strings.Contains(host, "..") || strings.Contains(host, "/") || strings.Contains(host, "\\") {
handleError(fmt.Errorf("invalid host name"), http.StatusBadRequest, rw)
return
}
filename := filepath.Join(api.MachineStateDir, cluster, fmt.Sprintf("%s.json", host))
// Sets the content-type and 'Last-Modified' Header and so on automatically // Sets the content-type and 'Last-Modified' Header and so on automatically
http.ServeFile(rw, r, filename) http.ServeFile(rw, r, filename)
+86 -24
View File
@@ -2,6 +2,7 @@
// All rights reserved. This file is part of cc-backend. // All rights reserved. This file is part of cc-backend.
// Use of this source code is governed by a MIT-style // Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file. // license that can be found in the LICENSE file.
package api package api
import ( import (
@@ -10,11 +11,12 @@ import (
"net/http" "net/http"
"github.com/ClusterCockpit/cc-backend/internal/repository" "github.com/ClusterCockpit/cc-backend/internal/repository"
cclog "github.com/ClusterCockpit/cc-lib/ccLogger"
"github.com/ClusterCockpit/cc-lib/schema" "github.com/ClusterCockpit/cc-lib/schema"
"github.com/gorilla/mux" "github.com/gorilla/mux"
) )
type ApiReturnedUser struct { type APIReturnedUser struct {
Username string `json:"username"` Username string `json:"username"`
Name string `json:"name"` Name string `json:"name"`
Roles []string `json:"roles"` Roles []string `json:"roles"`
@@ -40,24 +42,42 @@ func (api *RestApi) getUsers(rw http.ResponseWriter, r *http.Request) {
// SecuredCheck() only worked with TokenAuth: Removed // SecuredCheck() only worked with TokenAuth: Removed
if user := repository.GetUserFromContext(r.Context()); !user.HasRole(schema.RoleAdmin) { if user := repository.GetUserFromContext(r.Context()); !user.HasRole(schema.RoleAdmin) {
http.Error(rw, "Only admins are allowed to fetch a list of users", http.StatusForbidden) handleError(fmt.Errorf("only admins are allowed to fetch a list of users"), http.StatusForbidden, rw)
return return
} }
users, err := repository.GetUserRepository().ListUsers(r.URL.Query().Get("not-just-user") == "true") users, err := repository.GetUserRepository().ListUsers(r.URL.Query().Get("not-just-user") == "true")
if err != nil { if err != nil {
http.Error(rw, err.Error(), http.StatusInternalServerError) handleError(fmt.Errorf("listing users failed: %w", err), http.StatusInternalServerError, rw)
return return
} }
json.NewEncoder(rw).Encode(users) rw.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(rw).Encode(users); err != nil {
cclog.Errorf("Failed to encode users response: %v", err)
}
} }
// updateUser godoc
// @summary Update user roles and projects
// @tags User
// @description Allows admins to add/remove roles and projects for a user
// @produce plain
// @param id path string true "Username"
// @param add-role formData string false "Role to add"
// @param remove-role formData string false "Role to remove"
// @param add-project formData string false "Project to add"
// @param remove-project formData string false "Project to remove"
// @success 200 {string} string "Success message"
// @failure 403 {object} api.ErrorResponse "Forbidden"
// @failure 422 {object} api.ErrorResponse "Unprocessable Entity"
// @security ApiKeyAuth
// @router /api/user/{id} [post]
func (api *RestApi) updateUser(rw http.ResponseWriter, r *http.Request) { func (api *RestApi) updateUser(rw http.ResponseWriter, r *http.Request) {
// SecuredCheck() only worked with TokenAuth: Removed // SecuredCheck() only worked with TokenAuth: Removed
if user := repository.GetUserFromContext(r.Context()); !user.HasRole(schema.RoleAdmin) { if user := repository.GetUserFromContext(r.Context()); !user.HasRole(schema.RoleAdmin) {
http.Error(rw, "Only admins are allowed to update a user", http.StatusForbidden) handleError(fmt.Errorf("only admins are allowed to update a user"), http.StatusForbidden, rw)
return return
} }
@@ -67,43 +87,70 @@ func (api *RestApi) updateUser(rw http.ResponseWriter, r *http.Request) {
newproj := r.FormValue("add-project") newproj := r.FormValue("add-project")
delproj := r.FormValue("remove-project") delproj := r.FormValue("remove-project")
// TODO: Handle anything but roles... rw.Header().Set("Content-Type", "application/json")
// Handle role updates
if newrole != "" { if newrole != "" {
if err := repository.GetUserRepository().AddRole(r.Context(), mux.Vars(r)["id"], newrole); err != nil { if err := repository.GetUserRepository().AddRole(r.Context(), mux.Vars(r)["id"], newrole); err != nil {
http.Error(rw, err.Error(), http.StatusUnprocessableEntity) handleError(fmt.Errorf("adding role failed: %w", err), http.StatusUnprocessableEntity, rw)
return return
} }
rw.Write([]byte("Add Role Success")) if err := json.NewEncoder(rw).Encode(DefaultApiResponse{Message: "Add Role Success"}); err != nil {
cclog.Errorf("Failed to encode response: %v", err)
}
} else if delrole != "" { } else if delrole != "" {
if err := repository.GetUserRepository().RemoveRole(r.Context(), mux.Vars(r)["id"], delrole); err != nil { if err := repository.GetUserRepository().RemoveRole(r.Context(), mux.Vars(r)["id"], delrole); err != nil {
http.Error(rw, err.Error(), http.StatusUnprocessableEntity) handleError(fmt.Errorf("removing role failed: %w", err), http.StatusUnprocessableEntity, rw)
return return
} }
rw.Write([]byte("Remove Role Success")) if err := json.NewEncoder(rw).Encode(DefaultApiResponse{Message: "Remove Role Success"}); err != nil {
cclog.Errorf("Failed to encode response: %v", err)
}
} else if newproj != "" { } else if newproj != "" {
if err := repository.GetUserRepository().AddProject(r.Context(), mux.Vars(r)["id"], newproj); err != nil { if err := repository.GetUserRepository().AddProject(r.Context(), mux.Vars(r)["id"], newproj); err != nil {
http.Error(rw, err.Error(), http.StatusUnprocessableEntity) handleError(fmt.Errorf("adding project failed: %w", err), http.StatusUnprocessableEntity, rw)
return return
} }
rw.Write([]byte("Add Project Success")) if err := json.NewEncoder(rw).Encode(DefaultApiResponse{Message: "Add Project Success"}); err != nil {
cclog.Errorf("Failed to encode response: %v", err)
}
} else if delproj != "" { } else if delproj != "" {
if err := repository.GetUserRepository().RemoveProject(r.Context(), mux.Vars(r)["id"], delproj); err != nil { if err := repository.GetUserRepository().RemoveProject(r.Context(), mux.Vars(r)["id"], delproj); err != nil {
http.Error(rw, err.Error(), http.StatusUnprocessableEntity) handleError(fmt.Errorf("removing project failed: %w", err), http.StatusUnprocessableEntity, rw)
return return
} }
rw.Write([]byte("Remove Project Success")) if err := json.NewEncoder(rw).Encode(DefaultApiResponse{Message: "Remove Project Success"}); err != nil {
cclog.Errorf("Failed to encode response: %v", err)
}
} else { } else {
http.Error(rw, "Not Add or Del [role|project]?", http.StatusInternalServerError) handleError(fmt.Errorf("no operation specified: must provide add-role, remove-role, add-project, or remove-project"), http.StatusBadRequest, rw)
} }
} }
// createUser godoc
// @summary Create a new user
// @tags User
// @description Creates a new user with specified credentials and role
// @produce plain
// @param username formData string true "Username"
// @param password formData string false "Password (not required for API users)"
// @param role formData string true "User role"
// @param name formData string false "Full name"
// @param email formData string false "Email address"
// @param project formData string false "Project (required for managers)"
// @success 200 {string} string "Success message"
// @failure 400 {object} api.ErrorResponse "Bad Request"
// @failure 403 {object} api.ErrorResponse "Forbidden"
// @failure 422 {object} api.ErrorResponse "Unprocessable Entity"
// @security ApiKeyAuth
// @router /api/users/ [post]
func (api *RestApi) createUser(rw http.ResponseWriter, r *http.Request) { func (api *RestApi) createUser(rw http.ResponseWriter, r *http.Request) {
// SecuredCheck() only worked with TokenAuth: Removed // SecuredCheck() only worked with TokenAuth: Removed
rw.Header().Set("Content-Type", "text/plain") rw.Header().Set("Content-Type", "text/plain")
me := repository.GetUserFromContext(r.Context()) me := repository.GetUserFromContext(r.Context())
if !me.HasRole(schema.RoleAdmin) { if !me.HasRole(schema.RoleAdmin) {
http.Error(rw, "Only admins are allowed to create new users", http.StatusForbidden) handleError(fmt.Errorf("only admins are allowed to create new users"), http.StatusForbidden, rw)
return return
} }
@@ -111,18 +158,22 @@ func (api *RestApi) createUser(rw http.ResponseWriter, r *http.Request) {
r.FormValue("password"), r.FormValue("role"), r.FormValue("name"), r.FormValue("password"), r.FormValue("role"), r.FormValue("name"),
r.FormValue("email"), r.FormValue("project") r.FormValue("email"), r.FormValue("project")
// Validate username length
if len(username) == 0 || len(username) > 100 {
handleError(fmt.Errorf("username must be between 1 and 100 characters"), http.StatusBadRequest, rw)
return
}
if len(password) == 0 && role != schema.GetRoleString(schema.RoleApi) { if len(password) == 0 && role != schema.GetRoleString(schema.RoleApi) {
http.Error(rw, "Only API users are allowed to have a blank password (login will be impossible)", http.StatusBadRequest) handleError(fmt.Errorf("only API users are allowed to have a blank password (login will be impossible)"), http.StatusBadRequest, rw)
return return
} }
if len(project) != 0 && role != schema.GetRoleString(schema.RoleManager) { if len(project) != 0 && role != schema.GetRoleString(schema.RoleManager) {
http.Error(rw, "only managers require a project (can be changed later)", handleError(fmt.Errorf("only managers require a project (can be changed later)"), http.StatusBadRequest, rw)
http.StatusBadRequest)
return return
} else if len(project) == 0 && role == schema.GetRoleString(schema.RoleManager) { } else if len(project) == 0 && role == schema.GetRoleString(schema.RoleManager) {
http.Error(rw, "managers require a project to manage (can be changed later)", handleError(fmt.Errorf("managers require a project to manage (can be changed later)"), http.StatusBadRequest, rw)
http.StatusBadRequest)
return return
} }
@@ -134,24 +185,35 @@ func (api *RestApi) createUser(rw http.ResponseWriter, r *http.Request) {
Projects: []string{project}, Projects: []string{project},
Roles: []string{role}, Roles: []string{role},
}); err != nil { }); err != nil {
http.Error(rw, err.Error(), http.StatusUnprocessableEntity) handleError(fmt.Errorf("adding user failed: %w", err), http.StatusUnprocessableEntity, rw)
return return
} }
fmt.Fprintf(rw, "User %v successfully created!\n", username) fmt.Fprintf(rw, "User %v successfully created!\n", username)
} }
// deleteUser godoc
// @summary Delete a user
// @tags User
// @description Deletes a user from the system
// @produce plain
// @param username formData string true "Username to delete"
// @success 200 {string} string "Success"
// @failure 403 {object} api.ErrorResponse "Forbidden"
// @failure 422 {object} api.ErrorResponse "Unprocessable Entity"
// @security ApiKeyAuth
// @router /api/users/ [delete]
func (api *RestApi) deleteUser(rw http.ResponseWriter, r *http.Request) { func (api *RestApi) deleteUser(rw http.ResponseWriter, r *http.Request) {
// SecuredCheck() only worked with TokenAuth: Removed // SecuredCheck() only worked with TokenAuth: Removed
if user := repository.GetUserFromContext(r.Context()); !user.HasRole(schema.RoleAdmin) { if user := repository.GetUserFromContext(r.Context()); !user.HasRole(schema.RoleAdmin) {
http.Error(rw, "Only admins are allowed to delete a user", http.StatusForbidden) handleError(fmt.Errorf("only admins are allowed to delete a user"), http.StatusForbidden, rw)
return return
} }
username := r.FormValue("username") username := r.FormValue("username")
if err := repository.GetUserRepository().DelUser(username); err != nil { if err := repository.GetUserRepository().DelUser(username); err != nil {
http.Error(rw, err.Error(), http.StatusUnprocessableEntity) handleError(fmt.Errorf("deleting user failed: %w", err), http.StatusUnprocessableEntity, rw)
return return
} }
+122 -43
View File
@@ -18,7 +18,6 @@ import (
"net" "net"
"net/http" "net/http"
"os" "os"
"strings"
"sync" "sync"
"time" "time"
@@ -32,8 +31,19 @@ import (
"github.com/gorilla/sessions" "github.com/gorilla/sessions"
) )
// Authenticator is the interface for all authentication methods.
// Each authenticator determines if it can handle a login request (CanLogin)
// and performs the actual authentication (Login).
type Authenticator interface { type Authenticator interface {
// CanLogin determines if this authenticator can handle the login request.
// It returns the user object if available and a boolean indicating if this
// authenticator should attempt the login. This method should not perform
// expensive operations or actual authentication.
CanLogin(user *schema.User, username string, rw http.ResponseWriter, r *http.Request) (*schema.User, bool) CanLogin(user *schema.User, username string, rw http.ResponseWriter, r *http.Request) (*schema.User, bool)
// Login performs the actually authentication for the user.
// It returns the authenticated user or an error if authentication fails.
// The user parameter may be nil if the user doesn't exist in the database yet.
Login(user *schema.User, rw http.ResponseWriter, r *http.Request) (*schema.User, error) Login(user *schema.User, rw http.ResponseWriter, r *http.Request) (*schema.User, error)
} }
@@ -42,27 +52,70 @@ var (
authInstance *Authentication authInstance *Authentication
) )
// rateLimiterEntry tracks a rate limiter and its last use time for cleanup
type rateLimiterEntry struct {
limiter *rate.Limiter
lastUsed time.Time
}
var ipUserLimiters sync.Map var ipUserLimiters sync.Map
// getIPUserLimiter returns a rate limiter for the given IP and username combination.
// Rate limiters are created on demand and track 5 attempts per 15 minutes.
func getIPUserLimiter(ip, username string) *rate.Limiter { func getIPUserLimiter(ip, username string) *rate.Limiter {
key := ip + ":" + username key := ip + ":" + username
limiter, ok := ipUserLimiters.Load(key) now := time.Now()
if !ok {
newLimiter := rate.NewLimiter(rate.Every(time.Hour/10), 10) if entry, ok := ipUserLimiters.Load(key); ok {
ipUserLimiters.Store(key, newLimiter) rle := entry.(*rateLimiterEntry)
return newLimiter rle.lastUsed = now
} return rle.limiter
return limiter.(*rate.Limiter)
} }
// More aggressive rate limiting: 5 attempts per 15 minutes
newLimiter := rate.NewLimiter(rate.Every(15*time.Minute/5), 5)
ipUserLimiters.Store(key, &rateLimiterEntry{
limiter: newLimiter,
lastUsed: now,
})
return newLimiter
}
// cleanupOldRateLimiters removes rate limiters that haven't been used recently
func cleanupOldRateLimiters(olderThan time.Time) {
ipUserLimiters.Range(func(key, value any) bool {
entry := value.(*rateLimiterEntry)
if entry.lastUsed.Before(olderThan) {
ipUserLimiters.Delete(key)
cclog.Debugf("Cleaned up rate limiter for %v", key)
}
return true
})
}
// startRateLimiterCleanup starts a background goroutine to clean up old rate limiters
func startRateLimiterCleanup() {
go func() {
ticker := time.NewTicker(1 * time.Hour)
defer ticker.Stop()
for range ticker.C {
// Clean up limiters not used in the last 24 hours
cleanupOldRateLimiters(time.Now().Add(-24 * time.Hour))
}
}()
}
// AuthConfig contains configuration for all authentication methods
type AuthConfig struct { type AuthConfig struct {
LdapConfig *LdapConfig `json:"ldap"` LdapConfig *LdapConfig `json:"ldap"`
JwtConfig *JWTAuthConfig `json:"jwts"` JwtConfig *JWTAuthConfig `json:"jwts"`
OpenIDConfig *OpenIDConfig `json:"oidc"` OpenIDConfig *OpenIDConfig `json:"oidc"`
} }
// Keys holds the global authentication configuration
var Keys AuthConfig var Keys AuthConfig
// Authentication manages all authentication methods and session handling
type Authentication struct { type Authentication struct {
sessionStore *sessions.CookieStore sessionStore *sessions.CookieStore
LdapAuth *LdapAuthenticator LdapAuth *LdapAuthenticator
@@ -86,10 +139,31 @@ func (auth *Authentication) AuthViaSession(
return nil, nil return nil, nil
} }
// TODO: Check if session keys exist // Validate session data with proper type checking
username, _ := session.Values["username"].(string) username, ok := session.Values["username"].(string)
projects, _ := session.Values["projects"].([]string) if !ok || username == "" {
roles, _ := session.Values["roles"].([]string) cclog.Warn("Invalid session: missing or invalid username")
// Invalidate the corrupted session
session.Options.MaxAge = -1
_ = auth.sessionStore.Save(r, rw, session)
return nil, errors.New("invalid session data")
}
projects, ok := session.Values["projects"].([]string)
if !ok {
cclog.Warn("Invalid session: projects not found or invalid type, using empty list")
projects = []string{}
}
roles, ok := session.Values["roles"].([]string)
if !ok || len(roles) == 0 {
cclog.Warn("Invalid session: missing or invalid roles")
// Invalidate the corrupted session
session.Options.MaxAge = -1
_ = auth.sessionStore.Save(r, rw, session)
return nil, errors.New("invalid session data")
}
return &schema.User{ return &schema.User{
Username: username, Username: username,
Projects: projects, Projects: projects,
@@ -103,6 +177,9 @@ func Init(authCfg *json.RawMessage) {
initOnce.Do(func() { initOnce.Do(func() {
authInstance = &Authentication{} authInstance = &Authentication{}
// Start background cleanup of rate limiters
startRateLimiterCleanup()
sessKey := os.Getenv("SESSION_KEY") sessKey := os.Getenv("SESSION_KEY")
if sessKey == "" { if sessKey == "" {
cclog.Warn("environment variable 'SESSION_KEY' not set (will use non-persistent random key)") cclog.Warn("environment variable 'SESSION_KEY' not set (will use non-persistent random key)")
@@ -185,38 +262,36 @@ func GetAuthInstance() *Authentication {
return authInstance return authInstance
} }
// handleUserSync syncs or updates a user in the database based on configuration.
// This is used for both JWT and OIDC authentication when syncUserOnLogin or updateUserOnLogin is enabled.
func handleUserSync(user *schema.User, syncUserOnLogin, updateUserOnLogin bool) {
r := repository.GetUserRepository()
dbUser, err := r.GetUser(user.Username)
if err != nil && err != sql.ErrNoRows {
cclog.Errorf("Error while loading user '%s': %v", user.Username, err)
return
}
if err == sql.ErrNoRows && syncUserOnLogin { // Add new user
if err := r.AddUser(user); err != nil {
cclog.Errorf("Error while adding user '%s' to DB: %v", user.Username, err)
}
} else if err == nil && updateUserOnLogin { // Update existing user
if err := r.UpdateUser(dbUser, user); err != nil {
cclog.Errorf("Error while updating user '%s' in DB: %v", dbUser.Username, err)
}
}
}
// handleTokenUser syncs JWT token user with database
func handleTokenUser(tokenUser *schema.User) { func handleTokenUser(tokenUser *schema.User) {
r := repository.GetUserRepository() handleUserSync(tokenUser, Keys.JwtConfig.SyncUserOnLogin, Keys.JwtConfig.UpdateUserOnLogin)
dbUser, err := r.GetUser(tokenUser.Username)
if err != nil && err != sql.ErrNoRows {
cclog.Errorf("Error while loading user '%s': %v", tokenUser.Username, err)
} else if err == sql.ErrNoRows && Keys.JwtConfig.SyncUserOnLogin { // Adds New User
if err := r.AddUser(tokenUser); err != nil {
cclog.Errorf("Error while adding user '%s' to DB: %v", tokenUser.Username, err)
}
} else if err == nil && Keys.JwtConfig.UpdateUserOnLogin { // Update Existing User
if err := r.UpdateUser(dbUser, tokenUser); err != nil {
cclog.Errorf("Error while updating user '%s' to DB: %v", dbUser.Username, err)
}
}
} }
// handleOIDCUser syncs OIDC user with database
func handleOIDCUser(OIDCUser *schema.User) { func handleOIDCUser(OIDCUser *schema.User) {
r := repository.GetUserRepository() handleUserSync(OIDCUser, Keys.OpenIDConfig.SyncUserOnLogin, Keys.OpenIDConfig.UpdateUserOnLogin)
dbUser, err := r.GetUser(OIDCUser.Username)
if err != nil && err != sql.ErrNoRows {
cclog.Errorf("Error while loading user '%s': %v", OIDCUser.Username, err)
} else if err == sql.ErrNoRows && Keys.OpenIDConfig.SyncUserOnLogin { // Adds New User
if err := r.AddUser(OIDCUser); err != nil {
cclog.Errorf("Error while adding user '%s' to DB: %v", OIDCUser.Username, err)
}
} else if err == nil && Keys.OpenIDConfig.UpdateUserOnLogin { // Update Existing User
if err := r.UpdateUser(dbUser, OIDCUser); err != nil {
cclog.Errorf("Error while updating user '%s' to DB: %v", dbUser.Username, err)
}
}
} }
func (auth *Authentication) SaveSession(rw http.ResponseWriter, r *http.Request, user *schema.User) error { func (auth *Authentication) SaveSession(rw http.ResponseWriter, r *http.Request, user *schema.User) error {
@@ -231,6 +306,7 @@ func (auth *Authentication) SaveSession(rw http.ResponseWriter, r *http.Request,
session.Options.MaxAge = int(auth.SessionMaxAge.Seconds()) session.Options.MaxAge = int(auth.SessionMaxAge.Seconds())
} }
if config.Keys.HTTPSCertFile == "" && config.Keys.HTTPSKeyFile == "" { if config.Keys.HTTPSCertFile == "" && config.Keys.HTTPSKeyFile == "" {
cclog.Warn("HTTPS not configured - session cookies will not have Secure flag set (insecure for production)")
session.Options.Secure = false session.Options.Secure = false
} }
session.Options.SameSite = http.SameSiteStrictMode session.Options.SameSite = http.SameSiteStrictMode
@@ -532,10 +608,13 @@ func securedCheck(user *schema.User, r *http.Request) error {
IPAddress = r.RemoteAddr IPAddress = r.RemoteAddr
} }
// FIXME: IPV6 not handled // Handle both IPv4 and IPv6 addresses properly
if strings.Contains(IPAddress, ":") { // For IPv6, this will strip the port and brackets
IPAddress = strings.Split(IPAddress, ":")[0] // For IPv4, this will strip the port
if host, _, err := net.SplitHostPort(IPAddress); err == nil {
IPAddress = host
} }
// If SplitHostPort fails, IPAddress is already just a host (no port)
// If nothing declared in config: deny all request to this api endpoint // If nothing declared in config: deny all request to this api endpoint
if len(config.Keys.APIAllowedIPs) == 0 { if len(config.Keys.APIAllowedIPs) == 0 {
+176
View File
@@ -0,0 +1,176 @@
// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
// All rights reserved. This file is part of cc-backend.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package auth
import (
"net"
"testing"
"time"
)
// TestGetIPUserLimiter tests the rate limiter creation and retrieval
func TestGetIPUserLimiter(t *testing.T) {
ip := "192.168.1.1"
username := "testuser"
// Get limiter for the first time
limiter1 := getIPUserLimiter(ip, username)
if limiter1 == nil {
t.Fatal("Expected limiter to be created")
}
// Get the same limiter again
limiter2 := getIPUserLimiter(ip, username)
if limiter1 != limiter2 {
t.Error("Expected to get the same limiter instance")
}
// Get a different limiter for different user
limiter3 := getIPUserLimiter(ip, "otheruser")
if limiter1 == limiter3 {
t.Error("Expected different limiter for different user")
}
// Get a different limiter for different IP
limiter4 := getIPUserLimiter("192.168.1.2", username)
if limiter1 == limiter4 {
t.Error("Expected different limiter for different IP")
}
}
// TestRateLimiterBehavior tests that rate limiting works correctly
func TestRateLimiterBehavior(t *testing.T) {
ip := "10.0.0.1"
username := "ratelimituser"
limiter := getIPUserLimiter(ip, username)
// Should allow first 5 attempts
for i := 0; i < 5; i++ {
if !limiter.Allow() {
t.Errorf("Request %d should be allowed within rate limit", i+1)
}
}
// 6th attempt should be blocked
if limiter.Allow() {
t.Error("Request 6 should be blocked by rate limiter")
}
}
// TestCleanupOldRateLimiters tests the cleanup function
func TestCleanupOldRateLimiters(t *testing.T) {
// Clear all existing limiters first to avoid interference from other tests
cleanupOldRateLimiters(time.Now().Add(24 * time.Hour))
// Create some new rate limiters
limiter1 := getIPUserLimiter("1.1.1.1", "user1")
limiter2 := getIPUserLimiter("2.2.2.2", "user2")
if limiter1 == nil || limiter2 == nil {
t.Fatal("Failed to create test limiters")
}
// Cleanup limiters older than 1 second from now (should keep both)
time.Sleep(10 * time.Millisecond) // Small delay to ensure timestamp difference
cleanupOldRateLimiters(time.Now().Add(-1 * time.Second))
// Verify they still exist (should get same instance)
if getIPUserLimiter("1.1.1.1", "user1") != limiter1 {
t.Error("Limiter 1 was incorrectly cleaned up")
}
if getIPUserLimiter("2.2.2.2", "user2") != limiter2 {
t.Error("Limiter 2 was incorrectly cleaned up")
}
// Cleanup limiters older than 1 hour from now (should remove both)
cleanupOldRateLimiters(time.Now().Add(2 * time.Hour))
// Getting them again should create new instances
newLimiter1 := getIPUserLimiter("1.1.1.1", "user1")
if newLimiter1 == limiter1 {
t.Error("Old limiter should have been cleaned up")
}
}
// TestIPv4Extraction tests extracting IPv4 addresses
func TestIPv4Extraction(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{"IPv4 with port", "192.168.1.1:8080", "192.168.1.1"},
{"IPv4 without port", "192.168.1.1", "192.168.1.1"},
{"Localhost with port", "127.0.0.1:3000", "127.0.0.1"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.input
if host, _, err := net.SplitHostPort(result); err == nil {
result = host
}
if result != tt.expected {
t.Errorf("Expected %s, got %s", tt.expected, result)
}
})
}
}
// TestIPv6Extraction tests extracting IPv6 addresses
func TestIPv6Extraction(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{"IPv6 with port", "[2001:db8::1]:8080", "2001:db8::1"},
{"IPv6 localhost with port", "[::1]:3000", "::1"},
{"IPv6 without port", "2001:db8::1", "2001:db8::1"},
{"IPv6 localhost", "::1", "::1"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.input
if host, _, err := net.SplitHostPort(result); err == nil {
result = host
}
if result != tt.expected {
t.Errorf("Expected %s, got %s", tt.expected, result)
}
})
}
}
// TestIPExtractionEdgeCases tests edge cases for IP extraction
func TestIPExtractionEdgeCases(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{"Hostname without port", "example.com", "example.com"},
{"Empty string", "", ""},
{"Just port", ":8080", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.input
if host, _, err := net.SplitHostPort(result); err == nil {
result = host
}
if result != tt.expected {
t.Errorf("Expected %s, got %s", tt.expected, result)
}
})
}
}
+11 -29
View File
@@ -14,7 +14,6 @@ import (
"strings" "strings"
"time" "time"
"github.com/ClusterCockpit/cc-backend/internal/repository"
cclog "github.com/ClusterCockpit/cc-lib/ccLogger" cclog "github.com/ClusterCockpit/cc-lib/ccLogger"
"github.com/ClusterCockpit/cc-lib/schema" "github.com/ClusterCockpit/cc-lib/schema"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
@@ -102,38 +101,21 @@ func (ja *JWTAuthenticator) AuthViaJWT(
// Token is valid, extract payload // Token is valid, extract payload
claims := token.Claims.(jwt.MapClaims) claims := token.Claims.(jwt.MapClaims)
sub, _ := claims["sub"].(string)
var roles []string // Use shared helper to get user from JWT claims
var user *schema.User
// Validate user + roles from JWT against database? user, err = getUserFromJWT(claims, Keys.JwtConfig.ValidateUser, schema.AuthToken, -1)
if Keys.JwtConfig.ValidateUser {
ur := repository.GetUserRepository()
user, err := ur.GetUser(sub)
// Deny any logins for unknown usernames
if err != nil { if err != nil {
cclog.Warn("Could not find user from JWT in internal database.") return nil, err
return nil, errors.New("unknown user")
}
// Take user roles from database instead of trusting the JWT
roles = user.Roles
} else {
// Extract roles from JWT (if present)
if rawroles, ok := claims["roles"].([]any); ok {
for _, rr := range rawroles {
if r, ok := rr.(string); ok {
roles = append(roles, r)
}
}
}
} }
return &schema.User{ // If not validating user, we only get roles from JWT (no projects for this auth method)
Username: sub, if !Keys.JwtConfig.ValidateUser {
Roles: roles, user.Roles = extractRolesFromClaims(claims, false)
AuthType: schema.AuthToken, user.Projects = nil // Standard JWT auth doesn't include projects
AuthSource: -1, }
}, nil
return user, nil
} }
// ProvideJWT generates a new JWT that can be used for authentication // ProvideJWT generates a new JWT that can be used for authentication
+6 -50
View File
@@ -7,14 +7,11 @@ package auth
import ( import (
"crypto/ed25519" "crypto/ed25519"
"database/sql"
"encoding/base64" "encoding/base64"
"errors" "errors"
"fmt"
"net/http" "net/http"
"os" "os"
"github.com/ClusterCockpit/cc-backend/internal/repository"
cclog "github.com/ClusterCockpit/cc-lib/ccLogger" cclog "github.com/ClusterCockpit/cc-lib/ccLogger"
"github.com/ClusterCockpit/cc-lib/schema" "github.com/ClusterCockpit/cc-lib/schema"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
@@ -149,58 +146,17 @@ func (ja *JWTCookieSessionAuthenticator) Login(
} }
claims := token.Claims.(jwt.MapClaims) claims := token.Claims.(jwt.MapClaims)
sub, _ := claims["sub"].(string)
var roles []string // Use shared helper to get user from JWT claims
projects := make([]string, 0) user, err = getUserFromJWT(claims, jc.ValidateUser, schema.AuthSession, schema.AuthViaToken)
if err != nil {
if jc.ValidateUser { return nil, err
var err error
user, err = repository.GetUserRepository().GetUser(sub)
if err != nil && err != sql.ErrNoRows {
cclog.Errorf("Error while loading user '%v'", sub)
} }
// Deny any logins for unknown usernames // Sync or update user if configured
if user == nil { if !jc.ValidateUser && (jc.SyncUserOnLogin || jc.UpdateUserOnLogin) {
cclog.Warn("Could not find user from JWT in internal database.")
return nil, errors.New("unknown user")
}
} else {
var name string
if wrap, ok := claims["name"].(map[string]any); ok {
if vals, ok := wrap["values"].([]any); ok {
if len(vals) != 0 {
name = fmt.Sprintf("%v", vals[0])
for i := 1; i < len(vals); i++ {
name += fmt.Sprintf(" %v", vals[i])
}
}
}
}
// Extract roles from JWT (if present)
if rawroles, ok := claims["roles"].([]any); ok {
for _, rr := range rawroles {
if r, ok := rr.(string); ok {
roles = append(roles, r)
}
}
}
user = &schema.User{
Username: sub,
Name: name,
Roles: roles,
Projects: projects,
AuthType: schema.AuthSession,
AuthSource: schema.AuthViaToken,
}
if jc.SyncUserOnLogin || jc.UpdateUserOnLogin {
handleTokenUser(user) handleTokenUser(user)
} }
}
// (Ask browser to) Delete JWT cookie // (Ask browser to) Delete JWT cookie
deletedCookie := &http.Cookie{ deletedCookie := &http.Cookie{
+136
View File
@@ -0,0 +1,136 @@
// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
// All rights reserved. This file is part of cc-backend.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package auth
import (
"database/sql"
"errors"
"fmt"
"github.com/ClusterCockpit/cc-backend/internal/repository"
cclog "github.com/ClusterCockpit/cc-lib/ccLogger"
"github.com/ClusterCockpit/cc-lib/schema"
"github.com/golang-jwt/jwt/v5"
)
// extractStringFromClaims extracts a string value from JWT claims
func extractStringFromClaims(claims jwt.MapClaims, key string) string {
if val, ok := claims[key].(string); ok {
return val
}
return ""
}
// extractRolesFromClaims extracts roles from JWT claims
// If validateRoles is true, only valid roles are returned
func extractRolesFromClaims(claims jwt.MapClaims, validateRoles bool) []string {
var roles []string
if rawroles, ok := claims["roles"].([]any); ok {
for _, rr := range rawroles {
if r, ok := rr.(string); ok {
if validateRoles {
if schema.IsValidRole(r) {
roles = append(roles, r)
}
} else {
roles = append(roles, r)
}
}
}
}
return roles
}
// extractProjectsFromClaims extracts projects from JWT claims
func extractProjectsFromClaims(claims jwt.MapClaims) []string {
projects := make([]string, 0)
if rawprojs, ok := claims["projects"].([]any); ok {
for _, pp := range rawprojs {
if p, ok := pp.(string); ok {
projects = append(projects, p)
}
}
} else if rawprojs, ok := claims["projects"]; ok {
if projSlice, ok := rawprojs.([]string); ok {
projects = append(projects, projSlice...)
}
}
return projects
}
// extractNameFromClaims extracts name from JWT claims
// Handles both simple string and complex nested structure
func extractNameFromClaims(claims jwt.MapClaims) string {
// Try simple string first
if name, ok := claims["name"].(string); ok {
return name
}
// Try nested structure: {name: {values: [...]}}
if wrap, ok := claims["name"].(map[string]any); ok {
if vals, ok := wrap["values"].([]any); ok {
if len(vals) == 0 {
return ""
}
name := fmt.Sprintf("%v", vals[0])
for i := 1; i < len(vals); i++ {
name += fmt.Sprintf(" %v", vals[i])
}
return name
}
}
return ""
}
// getUserFromJWT creates or retrieves a user based on JWT claims
// If validateUser is true, the user must exist in the database
// Otherwise, a new user object is created from claims
// authSource should be a schema.AuthSource constant (like schema.AuthViaToken)
func getUserFromJWT(claims jwt.MapClaims, validateUser bool, authType schema.AuthType, authSource schema.AuthSource) (*schema.User, error) {
sub := extractStringFromClaims(claims, "sub")
if sub == "" {
return nil, errors.New("missing 'sub' claim in JWT")
}
if validateUser {
// Validate user against database
ur := repository.GetUserRepository()
user, err := ur.GetUser(sub)
if err != nil && err != sql.ErrNoRows {
cclog.Errorf("Error while loading user '%v': %v", sub, err)
return nil, fmt.Errorf("database error: %w", err)
}
// Deny any logins for unknown usernames
if user == nil || err == sql.ErrNoRows {
cclog.Warn("Could not find user from JWT in internal database.")
return nil, errors.New("unknown user")
}
// Return database user (with database roles)
return user, nil
}
// Create user from JWT claims
name := extractNameFromClaims(claims)
roles := extractRolesFromClaims(claims, true) // Validate roles
projects := extractProjectsFromClaims(claims)
return &schema.User{
Username: sub,
Name: name,
Roles: roles,
Projects: projects,
AuthType: authType,
AuthSource: authSource,
}, nil
}
+281
View File
@@ -0,0 +1,281 @@
// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
// All rights reserved. This file is part of cc-backend.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package auth
import (
"testing"
"github.com/ClusterCockpit/cc-lib/schema"
"github.com/golang-jwt/jwt/v5"
)
// TestExtractStringFromClaims tests extracting string values from JWT claims
func TestExtractStringFromClaims(t *testing.T) {
claims := jwt.MapClaims{
"sub": "testuser",
"email": "test@example.com",
"age": 25, // not a string
}
tests := []struct {
name string
key string
expected string
}{
{"Existing string", "sub", "testuser"},
{"Another string", "email", "test@example.com"},
{"Non-existent key", "missing", ""},
{"Non-string value", "age", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := extractStringFromClaims(claims, tt.key)
if result != tt.expected {
t.Errorf("Expected %s, got %s", tt.expected, result)
}
})
}
}
// TestExtractRolesFromClaims tests role extraction and validation
func TestExtractRolesFromClaims(t *testing.T) {
tests := []struct {
name string
claims jwt.MapClaims
validateRoles bool
expected []string
}{
{
name: "Valid roles without validation",
claims: jwt.MapClaims{
"roles": []any{"admin", "user", "invalid_role"},
},
validateRoles: false,
expected: []string{"admin", "user", "invalid_role"},
},
{
name: "Valid roles with validation",
claims: jwt.MapClaims{
"roles": []any{"admin", "user", "api"},
},
validateRoles: true,
expected: []string{"admin", "user", "api"},
},
{
name: "Invalid roles with validation",
claims: jwt.MapClaims{
"roles": []any{"invalid_role", "fake_role"},
},
validateRoles: true,
expected: []string{}, // Should filter out invalid roles
},
{
name: "No roles claim",
claims: jwt.MapClaims{},
validateRoles: false,
expected: []string{},
},
{
name: "Non-array roles",
claims: jwt.MapClaims{
"roles": "admin",
},
validateRoles: false,
expected: []string{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := extractRolesFromClaims(tt.claims, tt.validateRoles)
if len(result) != len(tt.expected) {
t.Errorf("Expected %d roles, got %d", len(tt.expected), len(result))
return
}
for i, role := range result {
if i >= len(tt.expected) || role != tt.expected[i] {
t.Errorf("Expected role %s at position %d, got %s", tt.expected[i], i, role)
}
}
})
}
}
// TestExtractProjectsFromClaims tests project extraction from claims
func TestExtractProjectsFromClaims(t *testing.T) {
tests := []struct {
name string
claims jwt.MapClaims
expected []string
}{
{
name: "Projects as array of interfaces",
claims: jwt.MapClaims{
"projects": []any{"project1", "project2", "project3"},
},
expected: []string{"project1", "project2", "project3"},
},
{
name: "Projects as string array",
claims: jwt.MapClaims{
"projects": []string{"projectA", "projectB"},
},
expected: []string{"projectA", "projectB"},
},
{
name: "No projects claim",
claims: jwt.MapClaims{},
expected: []string{},
},
{
name: "Mixed types in projects array",
claims: jwt.MapClaims{
"projects": []any{"project1", 123, "project2"},
},
expected: []string{"project1", "project2"}, // Should skip non-strings
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := extractProjectsFromClaims(tt.claims)
if len(result) != len(tt.expected) {
t.Errorf("Expected %d projects, got %d", len(tt.expected), len(result))
return
}
for i, project := range result {
if i >= len(tt.expected) || project != tt.expected[i] {
t.Errorf("Expected project %s at position %d, got %s", tt.expected[i], i, project)
}
}
})
}
}
// TestExtractNameFromClaims tests name extraction from various formats
func TestExtractNameFromClaims(t *testing.T) {
tests := []struct {
name string
claims jwt.MapClaims
expected string
}{
{
name: "Simple string name",
claims: jwt.MapClaims{
"name": "John Doe",
},
expected: "John Doe",
},
{
name: "Nested name structure",
claims: jwt.MapClaims{
"name": map[string]any{
"values": []any{"John", "Doe"},
},
},
expected: "John Doe",
},
{
name: "Nested name with single value",
claims: jwt.MapClaims{
"name": map[string]any{
"values": []any{"Alice"},
},
},
expected: "Alice",
},
{
name: "No name claim",
claims: jwt.MapClaims{},
expected: "",
},
{
name: "Empty nested values",
claims: jwt.MapClaims{
"name": map[string]any{
"values": []any{},
},
},
expected: "",
},
{
name: "Nested with non-string values",
claims: jwt.MapClaims{
"name": map[string]any{
"values": []any{123, "Smith"},
},
},
expected: "123 Smith", // Should convert to string
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := extractNameFromClaims(tt.claims)
if result != tt.expected {
t.Errorf("Expected '%s', got '%s'", tt.expected, result)
}
})
}
}
// TestGetUserFromJWT_NoValidation tests getUserFromJWT without database validation
func TestGetUserFromJWT_NoValidation(t *testing.T) {
claims := jwt.MapClaims{
"sub": "testuser",
"name": "Test User",
"roles": []any{"user", "admin"},
"projects": []any{"project1", "project2"},
}
user, err := getUserFromJWT(claims, false, schema.AuthToken, -1)
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if user.Username != "testuser" {
t.Errorf("Expected username 'testuser', got '%s'", user.Username)
}
if user.Name != "Test User" {
t.Errorf("Expected name 'Test User', got '%s'", user.Name)
}
if len(user.Roles) != 2 {
t.Errorf("Expected 2 roles, got %d", len(user.Roles))
}
if len(user.Projects) != 2 {
t.Errorf("Expected 2 projects, got %d", len(user.Projects))
}
if user.AuthType != schema.AuthToken {
t.Errorf("Expected AuthType %v, got %v", schema.AuthToken, user.AuthType)
}
}
// TestGetUserFromJWT_MissingSub tests error when sub claim is missing
func TestGetUserFromJWT_MissingSub(t *testing.T) {
claims := jwt.MapClaims{
"name": "Test User",
}
_, err := getUserFromJWT(claims, false, schema.AuthToken, -1)
if err == nil {
t.Error("Expected error for missing sub claim")
}
if err.Error() != "missing 'sub' claim in JWT" {
t.Errorf("Expected specific error message, got: %v", err)
}
}
+6 -62
View File
@@ -6,7 +6,6 @@
package auth package auth
import ( import (
"database/sql"
"encoding/base64" "encoding/base64"
"errors" "errors"
"fmt" "fmt"
@@ -14,7 +13,6 @@ import (
"os" "os"
"strings" "strings"
"github.com/ClusterCockpit/cc-backend/internal/repository"
cclog "github.com/ClusterCockpit/cc-lib/ccLogger" cclog "github.com/ClusterCockpit/cc-lib/ccLogger"
"github.com/ClusterCockpit/cc-lib/schema" "github.com/ClusterCockpit/cc-lib/schema"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
@@ -77,71 +75,17 @@ func (ja *JWTSessionAuthenticator) Login(
} }
claims := token.Claims.(jwt.MapClaims) claims := token.Claims.(jwt.MapClaims)
sub, _ := claims["sub"].(string)
var roles []string // Use shared helper to get user from JWT claims
projects := make([]string, 0) user, err = getUserFromJWT(claims, Keys.JwtConfig.ValidateUser, schema.AuthSession, schema.AuthViaToken)
if err != nil {
if Keys.JwtConfig.ValidateUser { return nil, err
var err error
user, err = repository.GetUserRepository().GetUser(sub)
if err != nil && err != sql.ErrNoRows {
cclog.Errorf("Error while loading user '%v'", sub)
} }
// Deny any logins for unknown usernames // Sync or update user if configured
if user == nil { if !Keys.JwtConfig.ValidateUser && (Keys.JwtConfig.SyncUserOnLogin || Keys.JwtConfig.UpdateUserOnLogin) {
cclog.Warn("Could not find user from JWT in internal database.")
return nil, errors.New("unknown user")
}
} else {
var name string
if wrap, ok := claims["name"].(map[string]any); ok {
if vals, ok := wrap["values"].([]any); ok {
if len(vals) != 0 {
name = fmt.Sprintf("%v", vals[0])
for i := 1; i < len(vals); i++ {
name += fmt.Sprintf(" %v", vals[i])
}
}
}
}
// Extract roles from JWT (if present)
if rawroles, ok := claims["roles"].([]any); ok {
for _, rr := range rawroles {
if r, ok := rr.(string); ok {
if schema.IsValidRole(r) {
roles = append(roles, r)
}
}
}
}
if rawprojs, ok := claims["projects"].([]any); ok {
for _, pp := range rawprojs {
if p, ok := pp.(string); ok {
projects = append(projects, p)
}
}
} else if rawprojs, ok := claims["projects"]; ok {
projects = append(projects, rawprojs.([]string)...)
}
user = &schema.User{
Username: sub,
Name: name,
Roles: roles,
Projects: projects,
AuthType: schema.AuthSession,
AuthSource: schema.AuthViaToken,
}
if Keys.JwtConfig.SyncUserOnLogin || Keys.JwtConfig.UpdateUserOnLogin {
handleTokenUser(user) handleTokenUser(user)
} }
}
return user, nil return user, nil
} }
+15 -5
View File
@@ -54,8 +54,13 @@ func setCallbackCookie(w http.ResponseWriter, r *http.Request, name, value strin
http.SetCookie(w, c) http.SetCookie(w, c)
} }
// NewOIDC creates a new OIDC authenticator with the configured provider
func NewOIDC(a *Authentication) *OIDC { func NewOIDC(a *Authentication) *OIDC {
provider, err := oidc.NewProvider(context.Background(), Keys.OpenIDConfig.Provider) // Use context with timeout for provider initialization
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
provider, err := oidc.NewProvider(ctx, Keys.OpenIDConfig.Provider)
if err != nil { if err != nil {
cclog.Fatal(err) cclog.Fatal(err)
} }
@@ -111,13 +116,18 @@ func (oa *OIDC) OAuth2Callback(rw http.ResponseWriter, r *http.Request) {
http.Error(rw, "Code not found", http.StatusBadRequest) http.Error(rw, "Code not found", http.StatusBadRequest)
return return
} }
token, err := oa.client.Exchange(context.Background(), code, oauth2.VerifierOption(codeVerifier)) // Exchange authorization code for token with timeout
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
token, err := oa.client.Exchange(ctx, code, oauth2.VerifierOption(codeVerifier))
if err != nil { if err != nil {
http.Error(rw, "Failed to exchange token: "+err.Error(), http.StatusInternalServerError) http.Error(rw, "Failed to exchange token: "+err.Error(), http.StatusInternalServerError)
return return
} }
userInfo, err := oa.provider.UserInfo(context.Background(), oauth2.StaticTokenSource(token)) // Get user info from OIDC provider with same timeout
userInfo, err := oa.provider.UserInfo(ctx, oauth2.StaticTokenSource(token))
if err != nil { if err != nil {
http.Error(rw, "Failed to get userinfo: "+err.Error(), http.StatusInternalServerError) http.Error(rw, "Failed to get userinfo: "+err.Error(), http.StatusInternalServerError)
return return
@@ -180,8 +190,8 @@ func (oa *OIDC) OAuth2Callback(rw http.ResponseWriter, r *http.Request) {
oa.authentication.SaveSession(rw, r, user) oa.authentication.SaveSession(rw, r, user)
cclog.Infof("login successfull: user: %#v (roles: %v, projects: %v)", user.Username, user.Roles, user.Projects) cclog.Infof("login successfull: user: %#v (roles: %v, projects: %v)", user.Username, user.Roles, user.Projects)
ctx := context.WithValue(r.Context(), repository.ContextUserKey, user) userCtx := context.WithValue(r.Context(), repository.ContextUserKey, user)
http.RedirectHandler("/", http.StatusTemporaryRedirect).ServeHTTP(rw, r.WithContext(ctx)) http.RedirectHandler("/", http.StatusTemporaryRedirect).ServeHTTP(rw, r.WithContext(userCtx))
} }
func (oa *OIDC) OAuth2Login(rw http.ResponseWriter, r *http.Request) { func (oa *OIDC) OAuth2Login(rw http.ResponseWriter, r *http.Request) {
+237 -145
View File
@@ -50,8 +50,7 @@ type ResolverRoot interface {
SubCluster() SubClusterResolver SubCluster() SubClusterResolver
} }
type DirectiveRoot struct { type DirectiveRoot struct{}
}
type ComplexityRoot struct { type ComplexityRoot struct {
Accelerator struct { Accelerator struct {
@@ -288,6 +287,7 @@ type ComplexityRoot struct {
NodeMetrics struct { NodeMetrics struct {
Host func(childComplexity int) int Host func(childComplexity int) int
Metrics func(childComplexity int) int Metrics func(childComplexity int) int
State func(childComplexity int) int
SubCluster func(childComplexity int) int SubCluster func(childComplexity int) int
} }
@@ -302,10 +302,9 @@ type ComplexityRoot struct {
} }
NodeStatesTimed struct { NodeStatesTimed struct {
Count func(childComplexity int) int Counts func(childComplexity int) int
State func(childComplexity int) int State func(childComplexity int) int
Time func(childComplexity int) int Times func(childComplexity int) int
Type func(childComplexity int) int
} }
NodesResultList struct { NodesResultList struct {
@@ -330,9 +329,9 @@ type ComplexityRoot struct {
JobsStatistics func(childComplexity int, filter []*model.JobFilter, metrics []string, page *model.PageRequest, sortBy *model.SortByAggregate, groupBy *model.Aggregate, numDurationBins *string, numMetricBins *int) int JobsStatistics func(childComplexity int, filter []*model.JobFilter, metrics []string, page *model.PageRequest, sortBy *model.SortByAggregate, groupBy *model.Aggregate, numDurationBins *string, numMetricBins *int) int
Node func(childComplexity int, id string) int Node func(childComplexity int, id string) int
NodeMetrics func(childComplexity int, cluster string, nodes []string, scopes []schema.MetricScope, metrics []string, from time.Time, to time.Time) int NodeMetrics func(childComplexity int, cluster string, nodes []string, scopes []schema.MetricScope, metrics []string, from time.Time, to time.Time) int
NodeMetricsList func(childComplexity int, cluster string, subCluster string, nodeFilter string, scopes []schema.MetricScope, metrics []string, from time.Time, to time.Time, page *model.PageRequest, resolution *int) int NodeMetricsList func(childComplexity int, cluster string, subCluster string, stateFilter string, nodeFilter string, scopes []schema.MetricScope, metrics []string, from time.Time, to time.Time, page *model.PageRequest, resolution *int) int
NodeStates func(childComplexity int, filter []*model.NodeFilter) int NodeStates func(childComplexity int, filter []*model.NodeFilter) int
NodeStatesTimed func(childComplexity int, filter []*model.NodeFilter) int NodeStatesTimed func(childComplexity int, filter []*model.NodeFilter, typeArg string) int
Nodes func(childComplexity int, filter []*model.NodeFilter, order *model.OrderByInput) int Nodes func(childComplexity int, filter []*model.NodeFilter, order *model.OrderByInput) int
RooflineHeatmap func(childComplexity int, filter []*model.JobFilter, rows int, cols int, minX float64, minY float64, maxX float64, maxY float64) int RooflineHeatmap func(childComplexity int, filter []*model.JobFilter, rows int, cols int, minX float64, minY float64, maxX float64, maxY float64) int
ScopedJobStats func(childComplexity int, id string, metrics []string, scopes []schema.MetricScope) int ScopedJobStats func(childComplexity int, id string, metrics []string, scopes []schema.MetricScope) int
@@ -473,7 +472,7 @@ type QueryResolver interface {
Node(ctx context.Context, id string) (*schema.Node, error) Node(ctx context.Context, id string) (*schema.Node, error)
Nodes(ctx context.Context, filter []*model.NodeFilter, order *model.OrderByInput) (*model.NodeStateResultList, error) Nodes(ctx context.Context, filter []*model.NodeFilter, order *model.OrderByInput) (*model.NodeStateResultList, error)
NodeStates(ctx context.Context, filter []*model.NodeFilter) ([]*model.NodeStates, error) NodeStates(ctx context.Context, filter []*model.NodeFilter) ([]*model.NodeStates, error)
NodeStatesTimed(ctx context.Context, filter []*model.NodeFilter) ([]*model.NodeStatesTimed, error) NodeStatesTimed(ctx context.Context, filter []*model.NodeFilter, typeArg string) ([]*model.NodeStatesTimed, error)
Job(ctx context.Context, id string) (*schema.Job, error) Job(ctx context.Context, id string) (*schema.Job, error)
JobMetrics(ctx context.Context, id string, metrics []string, scopes []schema.MetricScope, resolution *int) ([]*model.JobMetricWithName, error) JobMetrics(ctx context.Context, id string, metrics []string, scopes []schema.MetricScope, resolution *int) ([]*model.JobMetricWithName, error)
JobStats(ctx context.Context, id string, metrics []string) ([]*model.NamedStats, error) JobStats(ctx context.Context, id string, metrics []string) ([]*model.NamedStats, error)
@@ -484,7 +483,7 @@ type QueryResolver interface {
JobsFootprints(ctx context.Context, filter []*model.JobFilter, metrics []string) (*model.Footprints, error) JobsFootprints(ctx context.Context, filter []*model.JobFilter, metrics []string) (*model.Footprints, error)
RooflineHeatmap(ctx context.Context, filter []*model.JobFilter, rows int, cols int, minX float64, minY float64, maxX float64, maxY float64) ([][]float64, error) RooflineHeatmap(ctx context.Context, filter []*model.JobFilter, rows int, cols int, minX float64, minY float64, maxX float64, maxY float64) ([][]float64, error)
NodeMetrics(ctx context.Context, cluster string, nodes []string, scopes []schema.MetricScope, metrics []string, from time.Time, to time.Time) ([]*model.NodeMetrics, error) NodeMetrics(ctx context.Context, cluster string, nodes []string, scopes []schema.MetricScope, metrics []string, from time.Time, to time.Time) ([]*model.NodeMetrics, error)
NodeMetricsList(ctx context.Context, cluster string, subCluster string, nodeFilter string, scopes []schema.MetricScope, metrics []string, from time.Time, to time.Time, page *model.PageRequest, resolution *int) (*model.NodesResultList, error) NodeMetricsList(ctx context.Context, cluster string, subCluster string, stateFilter string, nodeFilter string, scopes []schema.MetricScope, metrics []string, from time.Time, to time.Time, page *model.PageRequest, resolution *int) (*model.NodesResultList, error)
} }
type SubClusterResolver interface { type SubClusterResolver interface {
NumberOfNodes(ctx context.Context, obj *schema.SubCluster) (int, error) NumberOfNodes(ctx context.Context, obj *schema.SubCluster) (int, error)
@@ -1455,12 +1454,21 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin
} }
return e.complexity.NodeMetrics.Host(childComplexity), true return e.complexity.NodeMetrics.Host(childComplexity), true
case "NodeMetrics.metrics": case "NodeMetrics.metrics":
if e.complexity.NodeMetrics.Metrics == nil { if e.complexity.NodeMetrics.Metrics == nil {
break break
} }
return e.complexity.NodeMetrics.Metrics(childComplexity), true return e.complexity.NodeMetrics.Metrics(childComplexity), true
case "NodeMetrics.state":
if e.complexity.NodeMetrics.State == nil {
break
}
return e.complexity.NodeMetrics.State(childComplexity), true
case "NodeMetrics.subCluster": case "NodeMetrics.subCluster":
if e.complexity.NodeMetrics.SubCluster == nil { if e.complexity.NodeMetrics.SubCluster == nil {
break break
@@ -1494,30 +1502,26 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin
return e.complexity.NodeStates.State(childComplexity), true return e.complexity.NodeStates.State(childComplexity), true
case "NodeStatesTimed.count": case "NodeStatesTimed.counts":
if e.complexity.NodeStatesTimed.Count == nil { if e.complexity.NodeStatesTimed.Counts == nil {
break break
} }
return e.complexity.NodeStatesTimed.Count(childComplexity), true return e.complexity.NodeStatesTimed.Counts(childComplexity), true
case "NodeStatesTimed.state": case "NodeStatesTimed.state":
if e.complexity.NodeStatesTimed.State == nil { if e.complexity.NodeStatesTimed.State == nil {
break break
} }
return e.complexity.NodeStatesTimed.State(childComplexity), true return e.complexity.NodeStatesTimed.State(childComplexity), true
case "NodeStatesTimed.time":
if e.complexity.NodeStatesTimed.Time == nil { case "NodeStatesTimed.times":
if e.complexity.NodeStatesTimed.Times == nil {
break break
} }
return e.complexity.NodeStatesTimed.Time(childComplexity), true return e.complexity.NodeStatesTimed.Times(childComplexity), true
case "NodeStatesTimed.type":
if e.complexity.NodeStatesTimed.Type == nil {
break
}
return e.complexity.NodeStatesTimed.Type(childComplexity), true
case "NodesResultList.count": case "NodesResultList.count":
if e.complexity.NodesResultList.Count == nil { if e.complexity.NodesResultList.Count == nil {
@@ -1688,7 +1692,8 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin
return 0, false return 0, false
} }
return e.complexity.Query.NodeMetricsList(childComplexity, args["cluster"].(string), args["subCluster"].(string), args["nodeFilter"].(string), args["scopes"].([]schema.MetricScope), args["metrics"].([]string), args["from"].(time.Time), args["to"].(time.Time), args["page"].(*model.PageRequest), args["resolution"].(*int)), true return e.complexity.Query.NodeMetricsList(childComplexity, args["cluster"].(string), args["subCluster"].(string), args["stateFilter"].(string), args["nodeFilter"].(string), args["scopes"].([]schema.MetricScope), args["metrics"].([]string), args["from"].(time.Time), args["to"].(time.Time), args["page"].(*model.PageRequest), args["resolution"].(*int)), true
case "Query.nodeStates": case "Query.nodeStates":
if e.complexity.Query.NodeStates == nil { if e.complexity.Query.NodeStates == nil {
break break
@@ -1710,7 +1715,8 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin
return 0, false return 0, false
} }
return e.complexity.Query.NodeStatesTimed(childComplexity, args["filter"].([]*model.NodeFilter)), true return e.complexity.Query.NodeStatesTimed(childComplexity, args["filter"].([]*model.NodeFilter), args["type"].(string)), true
case "Query.nodes": case "Query.nodes":
if e.complexity.Query.Nodes == nil { if e.complexity.Query.Nodes == nil {
break break
@@ -2248,9 +2254,8 @@ type NodeStates {
type NodeStatesTimed { type NodeStatesTimed {
state: String! state: String!
type: String! counts: [Int!]!
count: Int! times: [Int!]!
time: Int!
} }
type Job { type Job {
@@ -2483,6 +2488,7 @@ enum SortByAggregate {
type NodeMetrics { type NodeMetrics {
host: String! host: String!
state: String!
subCluster: String! subCluster: String!
metrics: [JobMetricWithName!]! metrics: [JobMetricWithName!]!
} }
@@ -2537,7 +2543,7 @@ type Query {
node(id: ID!): Node node(id: ID!): Node
nodes(filter: [NodeFilter!], order: OrderByInput): NodeStateResultList! nodes(filter: [NodeFilter!], order: OrderByInput): NodeStateResultList!
nodeStates(filter: [NodeFilter!]): [NodeStates!]! nodeStates(filter: [NodeFilter!]): [NodeStates!]!
nodeStatesTimed(filter: [NodeFilter!]): [NodeStatesTimed!]! nodeStatesTimed(filter: [NodeFilter!], type: String!): [NodeStatesTimed!]!
job(id: ID!): Job job(id: ID!): Job
jobMetrics( jobMetrics(
@@ -2596,6 +2602,7 @@ type Query {
nodeMetricsList( nodeMetricsList(
cluster: String! cluster: String!
subCluster: String! subCluster: String!
stateFilter: String!
nodeFilter: String! nodeFilter: String!
scopes: [MetricScope!] scopes: [MetricScope!]
metrics: [String!] metrics: [String!]
@@ -3040,41 +3047,46 @@ func (ec *executionContext) field_Query_nodeMetricsList_args(ctx context.Context
return nil, err return nil, err
} }
args["subCluster"] = arg1 args["subCluster"] = arg1
arg2, err := graphql.ProcessArgField(ctx, rawArgs, "nodeFilter", ec.unmarshalNString2string) arg2, err := graphql.ProcessArgField(ctx, rawArgs, "stateFilter", ec.unmarshalNString2string)
if err != nil { if err != nil {
return nil, err return nil, err
} }
args["nodeFilter"] = arg2 args["stateFilter"] = arg2
arg3, err := graphql.ProcessArgField(ctx, rawArgs, "scopes", ec.unmarshalOMetricScope2ᚕgithubᚗcomᚋClusterCockpitᚋccᚑlibᚋschemaᚐMetricScopeᚄ) arg3, err := graphql.ProcessArgField(ctx, rawArgs, "nodeFilter", ec.unmarshalNString2string)
if err != nil { if err != nil {
return nil, err return nil, err
} }
args["scopes"] = arg3 args["nodeFilter"] = arg3
arg4, err := graphql.ProcessArgField(ctx, rawArgs, "metrics", ec.unmarshalOString2ᚕstring) arg4, err := graphql.ProcessArgField(ctx, rawArgs, "scopes", ec.unmarshalOMetricScope2ᚕgithubᚗcomᚋClusterCockpitᚋccᚑlibᚋschemaᚐMetricScope)
if err != nil { if err != nil {
return nil, err return nil, err
} }
args["metrics"] = arg4 args["scopes"] = arg4
arg5, err := graphql.ProcessArgField(ctx, rawArgs, "from", ec.unmarshalNTime2timeᚐTime) arg5, err := graphql.ProcessArgField(ctx, rawArgs, "metrics", ec.unmarshalOString2ᚕstringᚄ)
if err != nil { if err != nil {
return nil, err return nil, err
} }
args["from"] = arg5 args["metrics"] = arg5
arg6, err := graphql.ProcessArgField(ctx, rawArgs, "to", ec.unmarshalNTime2timeᚐTime) arg6, err := graphql.ProcessArgField(ctx, rawArgs, "from", ec.unmarshalNTime2timeᚐTime)
if err != nil { if err != nil {
return nil, err return nil, err
} }
args["to"] = arg6 args["from"] = arg6
arg7, err := graphql.ProcessArgField(ctx, rawArgs, "page", ec.unmarshalOPageRequest2ᚖgithubᚗcomᚋClusterCockpitᚋccᚑbackendᚋinternalᚋgraphᚋmodelᚐPageRequest) arg7, err := graphql.ProcessArgField(ctx, rawArgs, "to", ec.unmarshalNTime2timeᚐTime)
if err != nil { if err != nil {
return nil, err return nil, err
} }
args["page"] = arg7 args["to"] = arg7
arg8, err := graphql.ProcessArgField(ctx, rawArgs, "resolution", ec.unmarshalOInt2ᚖint) arg8, err := graphql.ProcessArgField(ctx, rawArgs, "page", ec.unmarshalOPageRequest2ᚖgithubᚗcomᚋClusterCockpitᚋccᚑbackendᚋinternalᚋgraphᚋmodelᚐPageRequest)
if err != nil { if err != nil {
return nil, err return nil, err
} }
args["resolution"] = arg8 args["page"] = arg8
arg9, err := graphql.ProcessArgField(ctx, rawArgs, "resolution", ec.unmarshalOInt2ᚖint)
if err != nil {
return nil, err
}
args["resolution"] = arg9
return args, nil return args, nil
} }
@@ -3122,6 +3134,11 @@ func (ec *executionContext) field_Query_nodeStatesTimed_args(ctx context.Context
return nil, err return nil, err
} }
args["filter"] = arg0 args["filter"] = arg0
arg1, err := graphql.ProcessArgField(ctx, rawArgs, "type", ec.unmarshalNString2string)
if err != nil {
return nil, err
}
args["type"] = arg1
return args, nil return args, nil
} }
@@ -7976,6 +7993,50 @@ func (ec *executionContext) fieldContext_NodeMetrics_host(_ context.Context, fie
return fc, nil return fc, nil
} }
func (ec *executionContext) _NodeMetrics_state(ctx context.Context, field graphql.CollectedField, obj *model.NodeMetrics) (ret graphql.Marshaler) {
fc, err := ec.fieldContext_NodeMetrics_state(ctx, field)
if err != nil {
return graphql.Null
}
ctx = graphql.WithFieldContext(ctx, fc)
defer func() {
if r := recover(); r != nil {
ec.Error(ctx, ec.Recover(ctx, r))
ret = graphql.Null
}
}()
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
ctx = rctx // use context from middleware stack in children
return obj.State, nil
})
if err != nil {
ec.Error(ctx, err)
return graphql.Null
}
if resTmp == nil {
if !graphql.HasFieldError(ctx, fc) {
ec.Errorf(ctx, "must not be null")
}
return graphql.Null
}
res := resTmp.(string)
fc.Result = res
return ec.marshalNString2string(ctx, field.Selections, res)
}
func (ec *executionContext) fieldContext_NodeMetrics_state(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
fc = &graphql.FieldContext{
Object: "NodeMetrics",
Field: field,
IsMethod: false,
IsResolver: false,
Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
return nil, errors.New("field of type String does not have child fields")
},
}
return fc, nil
}
func (ec *executionContext) _NodeMetrics_subCluster(ctx context.Context, field graphql.CollectedField, obj *model.NodeMetrics) (ret graphql.Marshaler) { func (ec *executionContext) _NodeMetrics_subCluster(ctx context.Context, field graphql.CollectedField, obj *model.NodeMetrics) (ret graphql.Marshaler) {
return graphql.ResolveField( return graphql.ResolveField(
ctx, ctx,
@@ -8211,52 +8272,38 @@ func (ec *executionContext) fieldContext_NodeStatesTimed_state(_ context.Context
return fc, nil return fc, nil
} }
func (ec *executionContext) _NodeStatesTimed_type(ctx context.Context, field graphql.CollectedField, obj *model.NodeStatesTimed) (ret graphql.Marshaler) { func (ec *executionContext) _NodeStatesTimed_counts(ctx context.Context, field graphql.CollectedField, obj *model.NodeStatesTimed) (ret graphql.Marshaler) {
return graphql.ResolveField( fc, err := ec.fieldContext_NodeStatesTimed_counts(ctx, field)
ctx, if err != nil {
ec.OperationContext, return graphql.Null
field, }
ec.fieldContext_NodeStatesTimed_type, ctx = graphql.WithFieldContext(ctx, fc)
func(ctx context.Context) (any, error) { defer func() {
return obj.Type, nil if r := recover(); r != nil {
}, ec.Error(ctx, ec.Recover(ctx, r))
nil, ret = graphql.Null
ec.marshalNString2string, }
true, }()
true, resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
) ctx = rctx // use context from middleware stack in children
return obj.Counts, nil
})
if err != nil {
ec.Error(ctx, err)
return graphql.Null
}
if resTmp == nil {
if !graphql.HasFieldError(ctx, fc) {
ec.Errorf(ctx, "must not be null")
}
return graphql.Null
}
res := resTmp.([]int)
fc.Result = res
return ec.marshalNInt2ᚕintᚄ(ctx, field.Selections, res)
} }
func (ec *executionContext) fieldContext_NodeStatesTimed_type(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { func (ec *executionContext) fieldContext_NodeStatesTimed_counts(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
fc = &graphql.FieldContext{
Object: "NodeStatesTimed",
Field: field,
IsMethod: false,
IsResolver: false,
Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
return nil, errors.New("field of type String does not have child fields")
},
}
return fc, nil
}
func (ec *executionContext) _NodeStatesTimed_count(ctx context.Context, field graphql.CollectedField, obj *model.NodeStatesTimed) (ret graphql.Marshaler) {
return graphql.ResolveField(
ctx,
ec.OperationContext,
field,
ec.fieldContext_NodeStatesTimed_count,
func(ctx context.Context) (any, error) {
return obj.Count, nil
},
nil,
ec.marshalNInt2int,
true,
true,
)
}
func (ec *executionContext) fieldContext_NodeStatesTimed_count(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
fc = &graphql.FieldContext{ fc = &graphql.FieldContext{
Object: "NodeStatesTimed", Object: "NodeStatesTimed",
Field: field, Field: field,
@@ -8269,23 +8316,38 @@ func (ec *executionContext) fieldContext_NodeStatesTimed_count(_ context.Context
return fc, nil return fc, nil
} }
func (ec *executionContext) _NodeStatesTimed_time(ctx context.Context, field graphql.CollectedField, obj *model.NodeStatesTimed) (ret graphql.Marshaler) { func (ec *executionContext) _NodeStatesTimed_times(ctx context.Context, field graphql.CollectedField, obj *model.NodeStatesTimed) (ret graphql.Marshaler) {
return graphql.ResolveField( fc, err := ec.fieldContext_NodeStatesTimed_times(ctx, field)
ctx, if err != nil {
ec.OperationContext, return graphql.Null
field, }
ec.fieldContext_NodeStatesTimed_time, ctx = graphql.WithFieldContext(ctx, fc)
func(ctx context.Context) (any, error) { defer func() {
return obj.Time, nil if r := recover(); r != nil {
}, ec.Error(ctx, ec.Recover(ctx, r))
nil, ret = graphql.Null
ec.marshalNInt2int, }
true, }()
true, resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
) ctx = rctx // use context from middleware stack in children
return obj.Times, nil
})
if err != nil {
ec.Error(ctx, err)
return graphql.Null
}
if resTmp == nil {
if !graphql.HasFieldError(ctx, fc) {
ec.Errorf(ctx, "must not be null")
}
return graphql.Null
}
res := resTmp.([]int)
fc.Result = res
return ec.marshalNInt2ᚕintᚄ(ctx, field.Selections, res)
} }
func (ec *executionContext) fieldContext_NodeStatesTimed_time(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { func (ec *executionContext) fieldContext_NodeStatesTimed_times(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
fc = &graphql.FieldContext{ fc = &graphql.FieldContext{
Object: "NodeStatesTimed", Object: "NodeStatesTimed",
Field: field, Field: field,
@@ -8324,6 +8386,8 @@ func (ec *executionContext) fieldContext_NodesResultList_items(_ context.Context
switch field.Name { switch field.Name {
case "host": case "host":
return ec.fieldContext_NodeMetrics_host(ctx, field) return ec.fieldContext_NodeMetrics_host(ctx, field)
case "state":
return ec.fieldContext_NodeMetrics_state(ctx, field)
case "subCluster": case "subCluster":
return ec.fieldContext_NodeMetrics_subCluster(ctx, field) return ec.fieldContext_NodeMetrics_subCluster(ctx, field)
case "metrics": case "metrics":
@@ -8853,20 +8917,34 @@ func (ec *executionContext) fieldContext_Query_nodeStates(ctx context.Context, f
} }
func (ec *executionContext) _Query_nodeStatesTimed(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { func (ec *executionContext) _Query_nodeStatesTimed(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
return graphql.ResolveField( fc, err := ec.fieldContext_Query_nodeStatesTimed(ctx, field)
ctx, if err != nil {
ec.OperationContext, return graphql.Null
field, }
ec.fieldContext_Query_nodeStatesTimed, ctx = graphql.WithFieldContext(ctx, fc)
func(ctx context.Context) (any, error) { defer func() {
fc := graphql.GetFieldContext(ctx) if r := recover(); r != nil {
return ec.resolvers.Query().NodeStatesTimed(ctx, fc.Args["filter"].([]*model.NodeFilter)) ec.Error(ctx, ec.Recover(ctx, r))
}, ret = graphql.Null
nil, }
ec.marshalNNodeStatesTimed2ᚕᚖgithubᚗcomᚋClusterCockpitᚋccᚑbackendᚋinternalᚋgraphᚋmodelᚐNodeStatesTimedᚄ, }()
true, resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
true, ctx = rctx // use context from middleware stack in children
) return ec.resolvers.Query().NodeStatesTimed(rctx, fc.Args["filter"].([]*model.NodeFilter), fc.Args["type"].(string))
})
if err != nil {
ec.Error(ctx, err)
return graphql.Null
}
if resTmp == nil {
if !graphql.HasFieldError(ctx, fc) {
ec.Errorf(ctx, "must not be null")
}
return graphql.Null
}
res := resTmp.([]*model.NodeStatesTimed)
fc.Result = res
return ec.marshalNNodeStatesTimed2ᚕᚖgithubᚗcomᚋClusterCockpitᚋccᚑbackendᚋinternalᚋgraphᚋmodelᚐNodeStatesTimedᚄ(ctx, field.Selections, res)
} }
func (ec *executionContext) fieldContext_Query_nodeStatesTimed(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { func (ec *executionContext) fieldContext_Query_nodeStatesTimed(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
@@ -8879,12 +8957,10 @@ func (ec *executionContext) fieldContext_Query_nodeStatesTimed(ctx context.Conte
switch field.Name { switch field.Name {
case "state": case "state":
return ec.fieldContext_NodeStatesTimed_state(ctx, field) return ec.fieldContext_NodeStatesTimed_state(ctx, field)
case "type": case "counts":
return ec.fieldContext_NodeStatesTimed_type(ctx, field) return ec.fieldContext_NodeStatesTimed_counts(ctx, field)
case "count": case "times":
return ec.fieldContext_NodeStatesTimed_count(ctx, field) return ec.fieldContext_NodeStatesTimed_times(ctx, field)
case "time":
return ec.fieldContext_NodeStatesTimed_time(ctx, field)
} }
return nil, fmt.Errorf("no field named %q was found under type NodeStatesTimed", field.Name) return nil, fmt.Errorf("no field named %q was found under type NodeStatesTimed", field.Name)
}, },
@@ -9453,6 +9529,8 @@ func (ec *executionContext) fieldContext_Query_nodeMetrics(ctx context.Context,
switch field.Name { switch field.Name {
case "host": case "host":
return ec.fieldContext_NodeMetrics_host(ctx, field) return ec.fieldContext_NodeMetrics_host(ctx, field)
case "state":
return ec.fieldContext_NodeMetrics_state(ctx, field)
case "subCluster": case "subCluster":
return ec.fieldContext_NodeMetrics_subCluster(ctx, field) return ec.fieldContext_NodeMetrics_subCluster(ctx, field)
case "metrics": case "metrics":
@@ -9476,20 +9554,34 @@ func (ec *executionContext) fieldContext_Query_nodeMetrics(ctx context.Context,
} }
func (ec *executionContext) _Query_nodeMetricsList(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { func (ec *executionContext) _Query_nodeMetricsList(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
return graphql.ResolveField( fc, err := ec.fieldContext_Query_nodeMetricsList(ctx, field)
ctx, if err != nil {
ec.OperationContext, return graphql.Null
field, }
ec.fieldContext_Query_nodeMetricsList, ctx = graphql.WithFieldContext(ctx, fc)
func(ctx context.Context) (any, error) { defer func() {
fc := graphql.GetFieldContext(ctx) if r := recover(); r != nil {
return ec.resolvers.Query().NodeMetricsList(ctx, fc.Args["cluster"].(string), fc.Args["subCluster"].(string), fc.Args["nodeFilter"].(string), fc.Args["scopes"].([]schema.MetricScope), fc.Args["metrics"].([]string), fc.Args["from"].(time.Time), fc.Args["to"].(time.Time), fc.Args["page"].(*model.PageRequest), fc.Args["resolution"].(*int)) ec.Error(ctx, ec.Recover(ctx, r))
}, ret = graphql.Null
nil, }
ec.marshalNNodesResultList2ᚖgithubᚗcomᚋClusterCockpitᚋccᚑbackendᚋinternalᚋgraphᚋmodelᚐNodesResultList, }()
true, resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
true, ctx = rctx // use context from middleware stack in children
) return ec.resolvers.Query().NodeMetricsList(rctx, fc.Args["cluster"].(string), fc.Args["subCluster"].(string), fc.Args["stateFilter"].(string), fc.Args["nodeFilter"].(string), fc.Args["scopes"].([]schema.MetricScope), fc.Args["metrics"].([]string), fc.Args["from"].(time.Time), fc.Args["to"].(time.Time), fc.Args["page"].(*model.PageRequest), fc.Args["resolution"].(*int))
})
if err != nil {
ec.Error(ctx, err)
return graphql.Null
}
if resTmp == nil {
if !graphql.HasFieldError(ctx, fc) {
ec.Errorf(ctx, "must not be null")
}
return graphql.Null
}
res := resTmp.(*model.NodesResultList)
fc.Result = res
return ec.marshalNNodesResultList2ᚖgithubᚗcomᚋClusterCockpitᚋccᚑbackendᚋinternalᚋgraphᚋmodelᚐNodesResultList(ctx, field.Selections, res)
} }
func (ec *executionContext) fieldContext_Query_nodeMetricsList(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { func (ec *executionContext) fieldContext_Query_nodeMetricsList(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
@@ -15315,6 +15407,11 @@ func (ec *executionContext) _NodeMetrics(ctx context.Context, sel ast.SelectionS
if out.Values[i] == graphql.Null { if out.Values[i] == graphql.Null {
out.Invalids++ out.Invalids++
} }
case "state":
out.Values[i] = ec._NodeMetrics_state(ctx, field, obj)
if out.Values[i] == graphql.Null {
out.Invalids++
}
case "subCluster": case "subCluster":
out.Values[i] = ec._NodeMetrics_subCluster(ctx, field, obj) out.Values[i] = ec._NodeMetrics_subCluster(ctx, field, obj)
if out.Values[i] == graphql.Null { if out.Values[i] == graphql.Null {
@@ -15449,18 +15546,13 @@ func (ec *executionContext) _NodeStatesTimed(ctx context.Context, sel ast.Select
if out.Values[i] == graphql.Null { if out.Values[i] == graphql.Null {
out.Invalids++ out.Invalids++
} }
case "type": case "counts":
out.Values[i] = ec._NodeStatesTimed_type(ctx, field, obj) out.Values[i] = ec._NodeStatesTimed_counts(ctx, field, obj)
if out.Values[i] == graphql.Null { if out.Values[i] == graphql.Null {
out.Invalids++ out.Invalids++
} }
case "count": case "times":
out.Values[i] = ec._NodeStatesTimed_count(ctx, field, obj) out.Values[i] = ec._NodeStatesTimed_times(ctx, field, obj)
if out.Values[i] == graphql.Null {
out.Invalids++
}
case "time":
out.Values[i] = ec._NodeStatesTimed_time(ctx, field, obj)
if out.Values[i] == graphql.Null { if out.Values[i] == graphql.Null {
out.Invalids++ out.Invalids++
} }
@@ -19234,7 +19326,7 @@ func (ec *executionContext) unmarshalOAggregate2ᚖgithubᚗcomᚋClusterCockpit
if v == nil { if v == nil {
return nil, nil return nil, nil
} }
var res = new(model.Aggregate) res := new(model.Aggregate)
err := res.UnmarshalGQL(v) err := res.UnmarshalGQL(v)
return res, graphql.ErrorOnPath(ctx, err) return res, graphql.ErrorOnPath(ctx, err)
} }
@@ -19900,7 +19992,7 @@ func (ec *executionContext) unmarshalOSortByAggregate2ᚖgithubᚗcomᚋClusterC
if v == nil { if v == nil {
return nil, nil return nil, nil
} }
var res = new(model.SortByAggregate) res := new(model.SortByAggregate)
err := res.UnmarshalGQL(v) err := res.UnmarshalGQL(v)
return res, graphql.ErrorOnPath(ctx, err) return res, graphql.ErrorOnPath(ctx, err)
} }
+3 -3
View File
@@ -181,6 +181,7 @@ type NodeFilter struct {
type NodeMetrics struct { type NodeMetrics struct {
Host string `json:"host"` Host string `json:"host"`
State string `json:"state"`
SubCluster string `json:"subCluster"` SubCluster string `json:"subCluster"`
Metrics []*JobMetricWithName `json:"metrics"` Metrics []*JobMetricWithName `json:"metrics"`
} }
@@ -197,9 +198,8 @@ type NodeStates struct {
type NodeStatesTimed struct { type NodeStatesTimed struct {
State string `json:"state"` State string `json:"state"`
Type string `json:"type"` Counts []int `json:"counts"`
Count int `json:"count"` Times []int `json:"times"`
Time int `json:"time"`
} }
type NodesResultList struct { type NodesResultList struct {
+159 -25
View File
@@ -312,7 +312,11 @@ func (r *nodeResolver) ID(ctx context.Context, obj *schema.Node) (string, error)
// SchedulerState is the resolver for the schedulerState field. // SchedulerState is the resolver for the schedulerState field.
func (r *nodeResolver) SchedulerState(ctx context.Context, obj *schema.Node) (schema.SchedulerState, error) { func (r *nodeResolver) SchedulerState(ctx context.Context, obj *schema.Node) (schema.SchedulerState, error) {
panic(fmt.Errorf("not implemented: SchedulerState - schedulerState")) if obj.NodeState != "" {
return obj.NodeState, nil
} else {
return "", fmt.Errorf("No SchedulerState (NodeState) on Object")
}
} }
// HealthState is the resolver for the healthState field. // HealthState is the resolver for the healthState field.
@@ -378,7 +382,7 @@ func (r *queryResolver) Node(ctx context.Context, id string) (*schema.Node, erro
// Nodes is the resolver for the nodes field. // Nodes is the resolver for the nodes field.
func (r *queryResolver) Nodes(ctx context.Context, filter []*model.NodeFilter, order *model.OrderByInput) (*model.NodeStateResultList, error) { func (r *queryResolver) Nodes(ctx context.Context, filter []*model.NodeFilter, order *model.OrderByInput) (*model.NodeStateResultList, error) {
repo := repository.GetNodeRepository() repo := repository.GetNodeRepository()
nodes, err := repo.QueryNodes(ctx, filter, order) nodes, err := repo.QueryNodes(ctx, filter, nil, order) // Ignore Paging, Order Unused
count := len(nodes) count := len(nodes)
return &model.NodeStateResultList{Items: nodes, Count: &count}, err return &model.NodeStateResultList{Items: nodes, Count: &count}, err
} }
@@ -387,13 +391,13 @@ func (r *queryResolver) Nodes(ctx context.Context, filter []*model.NodeFilter, o
func (r *queryResolver) NodeStates(ctx context.Context, filter []*model.NodeFilter) ([]*model.NodeStates, error) { func (r *queryResolver) NodeStates(ctx context.Context, filter []*model.NodeFilter) ([]*model.NodeStates, error) {
repo := repository.GetNodeRepository() repo := repository.GetNodeRepository()
stateCounts, serr := repo.CountNodeStates(ctx, filter) stateCounts, serr := repo.CountStates(ctx, filter, "node_state")
if serr != nil { if serr != nil {
cclog.Warnf("Error while counting nodeStates: %s", serr.Error()) cclog.Warnf("Error while counting nodeStates: %s", serr.Error())
return nil, serr return nil, serr
} }
healthCounts, herr := repo.CountHealthStates(ctx, filter) healthCounts, herr := repo.CountStates(ctx, filter, "health_state")
if herr != nil { if herr != nil {
cclog.Warnf("Error while counting healthStates: %s", herr.Error()) cclog.Warnf("Error while counting healthStates: %s", herr.Error())
return nil, herr return nil, herr
@@ -406,26 +410,28 @@ func (r *queryResolver) NodeStates(ctx context.Context, filter []*model.NodeFilt
} }
// NodeStatesTimed is the resolver for the nodeStatesTimed field. // NodeStatesTimed is the resolver for the nodeStatesTimed field.
func (r *queryResolver) NodeStatesTimed(ctx context.Context, filter []*model.NodeFilter) ([]*model.NodeStatesTimed, error) { func (r *queryResolver) NodeStatesTimed(ctx context.Context, filter []*model.NodeFilter, typeArg string) ([]*model.NodeStatesTimed, error) {
panic(fmt.Errorf("not implemented: NodeStatesTimed - NodeStatesTimed")) repo := repository.GetNodeRepository()
// repo := repository.GetNodeRepository()
// stateCounts, serr := repo.CountNodeStates(ctx, filter) if typeArg == "node" {
// if serr != nil { stateCounts, serr := repo.CountStatesTimed(ctx, filter, "node_state")
// cclog.Warnf("Error while counting nodeStates: %s", serr.Error()) if serr != nil {
// return nil, serr cclog.Warnf("Error while counting nodeStates in time: %s", serr.Error())
// } return nil, serr
}
return stateCounts, nil
}
// healthCounts, herr := repo.CountHealthStates(ctx, filter) if typeArg == "health" {
// if herr != nil { healthCounts, herr := repo.CountStatesTimed(ctx, filter, "health_state")
// cclog.Warnf("Error while counting healthStates: %s", herr.Error()) if herr != nil {
// return nil, herr cclog.Warnf("Error while counting healthStates in time: %s", herr.Error())
// } return nil, herr
}
return healthCounts, nil
}
// allCounts := make([]*model.NodeStates, 0) return nil, errors.New("Unknown Node State Query Type")
// allCounts = append(stateCounts, healthCounts...)
// return allCounts, nil
} }
// Job is the resolver for the job field. // Job is the resolver for the job field.
@@ -750,10 +756,14 @@ func (r *queryResolver) NodeMetrics(ctx context.Context, cluster string, nodes [
return nil, err return nil, err
} }
nodeRepo := repository.GetNodeRepository()
stateMap, _ := nodeRepo.MapNodes(cluster)
nodeMetrics := make([]*model.NodeMetrics, 0, len(data)) nodeMetrics := make([]*model.NodeMetrics, 0, len(data))
for hostname, metrics := range data { for hostname, metrics := range data {
host := &model.NodeMetrics{ host := &model.NodeMetrics{
Host: hostname, Host: hostname,
State: stateMap[hostname],
Metrics: make([]*model.JobMetricWithName, 0, len(metrics)*len(scopes)), Metrics: make([]*model.JobMetricWithName, 0, len(metrics)*len(scopes)),
} }
host.SubCluster, err = archive.GetSubClusterByNode(cluster, hostname) host.SubCluster, err = archive.GetSubClusterByNode(cluster, hostname)
@@ -778,7 +788,7 @@ func (r *queryResolver) NodeMetrics(ctx context.Context, cluster string, nodes [
} }
// NodeMetricsList is the resolver for the nodeMetricsList field. // NodeMetricsList is the resolver for the nodeMetricsList field.
func (r *queryResolver) NodeMetricsList(ctx context.Context, cluster string, subCluster string, nodeFilter string, scopes []schema.MetricScope, metrics []string, from time.Time, to time.Time, page *model.PageRequest, resolution *int) (*model.NodesResultList, error) { func (r *queryResolver) NodeMetricsList(ctx context.Context, cluster string, subCluster string, stateFilter string, nodeFilter string, scopes []schema.MetricScope, metrics []string, from time.Time, to time.Time, page *model.PageRequest, resolution *int) (*model.NodesResultList, error) {
if resolution == nil { // Load from Config if resolution == nil { // Load from Config
if config.Keys.EnableResampling != nil { if config.Keys.EnableResampling != nil {
defaultRes := slices.Max(config.Keys.EnableResampling.Resolutions) defaultRes := slices.Max(config.Keys.EnableResampling.Resolutions)
@@ -800,16 +810,139 @@ func (r *queryResolver) NodeMetricsList(ctx context.Context, cluster string, sub
} }
} }
data, totalNodes, hasNextPage, err := metricDataDispatcher.LoadNodeListData(cluster, subCluster, nodeFilter, metrics, scopes, *resolution, from, to, page, ctx) // Build Filters
queryFilters := make([]*model.NodeFilter, 0)
if cluster != "" {
queryFilters = append(queryFilters, &model.NodeFilter{Cluster: &model.StringInput{Eq: &cluster}})
}
if subCluster != "" {
queryFilters = append(queryFilters, &model.NodeFilter{Subcluster: &model.StringInput{Eq: &subCluster}})
}
if nodeFilter != "" && stateFilter != "notindb" {
queryFilters = append(queryFilters, &model.NodeFilter{Hostname: &model.StringInput{Contains: &nodeFilter}})
}
if stateFilter != "all" && stateFilter != "notindb" {
var queryState schema.SchedulerState = schema.SchedulerState(stateFilter)
queryFilters = append(queryFilters, &model.NodeFilter{SchedulerState: &queryState})
}
// if healthFilter != "all" {
// filters = append(filters, &model.NodeFilter{HealthState: &healthFilter})
// }
// Special Case: Disable Paging for missing nodes filter, save IPP for later
var backupItems int
if stateFilter == "notindb" {
backupItems = page.ItemsPerPage
page.ItemsPerPage = -1
}
// Query Nodes From DB
nodeRepo := repository.GetNodeRepository()
rawNodes, serr := nodeRepo.QueryNodes(ctx, queryFilters, page, nil) // Order not Used
if serr != nil {
cclog.Warn("error while loading node database data (Resolver.NodeMetricsList)")
return nil, serr
}
// Intermediate Node Result Info
nodes := make([]string, 0)
stateMap := make(map[string]string)
for _, node := range rawNodes {
nodes = append(nodes, node.Hostname)
stateMap[node.Hostname] = string(node.NodeState)
}
// Setup Vars
var countNodes int
var cerr error
var hasNextPage bool
// Special Case: Find Nodes not in DB node table but in metricStore only
if stateFilter == "notindb" {
// Reapply Original Paging
page.ItemsPerPage = backupItems
// Get Nodes From Topology
var topoNodes []string
if subCluster != "" {
scNodes := archive.NodeLists[cluster][subCluster]
topoNodes = scNodes.PrintList()
} else {
subClusterNodeLists := archive.NodeLists[cluster]
for _, nodeList := range subClusterNodeLists {
topoNodes = append(topoNodes, nodeList.PrintList()...)
}
}
// Compare to all nodes from cluster/subcluster in DB
var missingNodes []string
for _, scanNode := range topoNodes {
if !slices.Contains(nodes, scanNode) {
missingNodes = append(missingNodes, scanNode)
}
}
// Filter nodes by name
if nodeFilter != "" {
filteredNodesByName := []string{}
for _, missingNode := range missingNodes {
if strings.Contains(missingNode, nodeFilter) {
filteredNodesByName = append(filteredNodesByName, missingNode)
}
}
missingNodes = filteredNodesByName
}
// Sort Missing Nodes Alphanumerically
slices.Sort(missingNodes)
// Total Missing
countNodes = len(missingNodes)
// Apply paging
if countNodes > page.ItemsPerPage {
start := (page.Page - 1) * page.ItemsPerPage
end := start + page.ItemsPerPage
if end > countNodes {
end = countNodes
hasNextPage = false
} else {
hasNextPage = true
}
nodes = missingNodes[start:end]
} else {
nodes = missingNodes
}
} else {
// DB Nodes: Count and Find Next Page
countNodes, cerr = nodeRepo.CountNodes(ctx, queryFilters)
if cerr != nil {
cclog.Warn("error while counting node database data (Resolver.NodeMetricsList)")
return nil, cerr
}
// Example Page 4 @ 10 IpP : Does item 41 exist?
// Minimal Page 41 @ 1 IpP : If len(result) is 1, Page 5 exists.
nextPage := &model.PageRequest{
ItemsPerPage: 1,
Page: ((page.Page * page.ItemsPerPage) + 1),
}
nextNodes, err := nodeRepo.QueryNodes(ctx, queryFilters, nextPage, nil) // Order not Used
if err != nil { if err != nil {
cclog.Warn("error while loading node data") cclog.Warn("Error while querying next nodes")
return nil, err
}
hasNextPage = len(nextNodes) == 1
}
// Load Metric Data For Specified Nodes Only
data, err := metricDataDispatcher.LoadNodeListData(cluster, subCluster, nodes, metrics, scopes, *resolution, from, to, ctx)
if err != nil {
cclog.Warn("error while loading node data (Resolver.NodeMetricsList")
return nil, err return nil, err
} }
// Build Result
nodeMetricsList := make([]*model.NodeMetrics, 0, len(data)) nodeMetricsList := make([]*model.NodeMetrics, 0, len(data))
for hostname, metrics := range data { for hostname, metrics := range data {
host := &model.NodeMetrics{ host := &model.NodeMetrics{
Host: hostname, Host: hostname,
State: stateMap[hostname],
Metrics: make([]*model.JobMetricWithName, 0, len(metrics)*len(scopes)), Metrics: make([]*model.JobMetricWithName, 0, len(metrics)*len(scopes)),
} }
host.SubCluster, err = archive.GetSubClusterByNode(cluster, hostname) host.SubCluster, err = archive.GetSubClusterByNode(cluster, hostname)
@@ -830,9 +963,10 @@ func (r *queryResolver) NodeMetricsList(ctx context.Context, cluster string, sub
nodeMetricsList = append(nodeMetricsList, host) nodeMetricsList = append(nodeMetricsList, host)
} }
// Final Return
nodeMetricsListResult := &model.NodesResultList{ nodeMetricsListResult := &model.NodesResultList{
Items: nodeMetricsList, Items: nodeMetricsList,
TotalNodes: &totalNodes, TotalNodes: &countNodes,
HasNextPage: &hasNextPage, HasNextPage: &hasNextPage,
} }
+4 -4
View File
@@ -81,14 +81,14 @@ func setup(t *testing.T) *repository.JobRepository {
tmpdir := t.TempDir() tmpdir := t.TempDir()
jobarchive := filepath.Join(tmpdir, "job-archive") jobarchive := filepath.Join(tmpdir, "job-archive")
if err := os.Mkdir(jobarchive, 0777); err != nil { if err := os.Mkdir(jobarchive, 0o777); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := os.WriteFile(filepath.Join(jobarchive, "version.txt"), []byte(fmt.Sprintf("%d", 2)), 0666); err != nil { if err := os.WriteFile(filepath.Join(jobarchive, "version.txt"), fmt.Appendf(nil, "%d", 3), 0o666); err != nil {
t.Fatal(err) t.Fatal(err)
} }
fritzArchive := filepath.Join(tmpdir, "job-archive", "fritz") fritzArchive := filepath.Join(tmpdir, "job-archive", "fritz")
if err := os.Mkdir(fritzArchive, 0777); err != nil { if err := os.Mkdir(fritzArchive, 0o777); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := copyFile(filepath.Join("testdata", "cluster-fritz.json"), if err := copyFile(filepath.Join("testdata", "cluster-fritz.json"),
@@ -103,7 +103,7 @@ func setup(t *testing.T) *repository.JobRepository {
} }
cfgFilePath := filepath.Join(tmpdir, "config.json") cfgFilePath := filepath.Join(tmpdir, "config.json")
if err := os.WriteFile(cfgFilePath, []byte(testconfig), 0666); err != nil { if err := os.WriteFile(cfgFilePath, []byte(testconfig), 0o666); err != nil {
t.Fatal(err) t.Fatal(err)
} }
+12 -1
View File
@@ -55,7 +55,18 @@ func InitDB() error {
// Bundle 100 inserts into one transaction for better performance // Bundle 100 inserts into one transaction for better performance
if i%100 == 0 { if i%100 == 0 {
r.TransactionCommit(t) if i > 0 {
if err := t.Commit(); err != nil {
cclog.Errorf("transaction commit error: %v", err)
return err
}
// Start a new transaction for the next batch
t, err = r.TransactionInit()
if err != nil {
cclog.Errorf("transaction init error: %v", err)
return err
}
}
fmt.Printf("%d jobs inserted...\r", i) fmt.Printf("%d jobs inserted...\r", i)
} }
+7 -7
View File
@@ -75,10 +75,10 @@ func ArchiveCheckpoints(checkpointsDir, archiveDir string, from int64, deleteIns
var wg sync.WaitGroup var wg sync.WaitGroup
n, errs := int32(0), int32(0) n, errs := int32(0), int32(0)
work := make(chan workItem, NumWorkers) work := make(chan workItem, Keys.NumWorkers)
wg.Add(NumWorkers) wg.Add(Keys.NumWorkers)
for worker := 0; worker < NumWorkers; worker++ { for worker := 0; worker < Keys.NumWorkers; worker++ {
go func() { go func() {
defer wg.Done() defer wg.Done()
for workItem := range work { for workItem := range work {
@@ -116,7 +116,7 @@ func ArchiveCheckpoints(checkpointsDir, archiveDir string, from int64, deleteIns
} }
if errs > 0 { if errs > 0 {
return int(n), fmt.Errorf("%d errors happend while archiving (%d successes)", errs, n) return int(n), fmt.Errorf("%d errors happened while archiving (%d successes)", errs, n)
} }
return int(n), nil return int(n), nil
} }
@@ -147,11 +147,11 @@ func archiveCheckpoints(dir string, archiveDir string, from int64, deleteInstead
} }
filename := filepath.Join(archiveDir, fmt.Sprintf("%d.zip", from)) filename := filepath.Join(archiveDir, fmt.Sprintf("%d.zip", from))
f, err := os.OpenFile(filename, os.O_CREATE|os.O_WRONLY, 0o644) f, err := os.OpenFile(filename, os.O_CREATE|os.O_WRONLY, CheckpointFilePerms)
if err != nil && os.IsNotExist(err) { if err != nil && os.IsNotExist(err) {
err = os.MkdirAll(archiveDir, 0o755) err = os.MkdirAll(archiveDir, CheckpointDirPerms)
if err == nil { if err == nil {
f, err = os.OpenFile(filename, os.O_CREATE|os.O_WRONLY, 0o644) f, err = os.OpenFile(filename, os.O_CREATE|os.O_WRONLY, CheckpointFilePerms)
} }
} }
if err != nil { if err != nil {
-40
View File
@@ -105,46 +105,6 @@ func (b *buffer) firstWrite() int64 {
func (b *buffer) close() {} func (b *buffer) close() {}
/*
func (b *buffer) close() {
if b.closed {
return
}
b.closed = true
n, sum, min, max := 0, 0., math.MaxFloat64, -math.MaxFloat64
for _, x := range b.data {
if x.IsNaN() {
continue
}
n += 1
f := float64(x)
sum += f
min = math.Min(min, f)
max = math.Max(max, f)
}
b.statisticts.samples = n
if n > 0 {
b.statisticts.avg = Float(sum / float64(n))
b.statisticts.min = Float(min)
b.statisticts.max = Float(max)
} else {
b.statisticts.avg = NaN
b.statisticts.min = NaN
b.statisticts.max = NaN
}
}
*/
// func interpolate(idx int, data []Float) Float {
// if idx == 0 || idx+1 == len(data) {
// return NaN
// }
// return (data[idx-1] + data[idx+1]) / 2.0
// }
// Return all known values from `from` to `to`. Gaps of information are represented as NaN. // Return all known values from `from` to `to`. Gaps of information are represented as NaN.
// Simple linear interpolation is done between the two neighboring cells if possible. // Simple linear interpolation is done between the two neighboring cells if possible.
// If values at the start or end are missing, instead of NaN values, the second and thrid // If values at the start or end are missing, instead of NaN values, the second and thrid
+34 -19
View File
@@ -28,6 +28,17 @@ import (
"github.com/linkedin/goavro/v2" "github.com/linkedin/goavro/v2"
) )
// File operation constants
const (
// CheckpointFilePerms defines default permissions for checkpoint files
CheckpointFilePerms = 0o644
// CheckpointDirPerms defines default permissions for checkpoint directories
CheckpointDirPerms = 0o755
// GCTriggerInterval determines how often GC is forced during checkpoint loading
// GC is triggered every GCTriggerInterval*NumWorkers loaded hosts
GCTriggerInterval = 100
)
// Whenever changed, update MarshalJSON as well! // Whenever changed, update MarshalJSON as well!
type CheckpointMetrics struct { type CheckpointMetrics struct {
Data []schema.Float `json:"data"` Data []schema.Float `json:"data"`
@@ -171,9 +182,9 @@ func (m *MemoryStore) ToCheckpoint(dir string, from, to int64) (int, error) {
n, errs := int32(0), int32(0) n, errs := int32(0), int32(0)
var wg sync.WaitGroup var wg sync.WaitGroup
wg.Add(NumWorkers) wg.Add(Keys.NumWorkers)
work := make(chan workItem, NumWorkers*2) work := make(chan workItem, Keys.NumWorkers*2)
for worker := 0; worker < NumWorkers; worker++ { for worker := 0; worker < Keys.NumWorkers; worker++ {
go func() { go func() {
defer wg.Done() defer wg.Done()
@@ -205,7 +216,7 @@ func (m *MemoryStore) ToCheckpoint(dir string, from, to int64) (int, error) {
wg.Wait() wg.Wait()
if errs > 0 { if errs > 0 {
return int(n), fmt.Errorf("[METRICSTORE]> %d errors happend while creating checkpoints (%d successes)", errs, n) return int(n), fmt.Errorf("[METRICSTORE]> %d errors happened while creating checkpoints (%d successes)", errs, n)
} }
return int(n), nil return int(n), nil
} }
@@ -285,11 +296,11 @@ func (l *Level) toCheckpoint(dir string, from, to int64, m *MemoryStore) error {
} }
filepath := path.Join(dir, fmt.Sprintf("%d.json", from)) filepath := path.Join(dir, fmt.Sprintf("%d.json", from))
f, err := os.OpenFile(filepath, os.O_CREATE|os.O_WRONLY, 0o644) f, err := os.OpenFile(filepath, os.O_CREATE|os.O_WRONLY, CheckpointFilePerms)
if err != nil && os.IsNotExist(err) { if err != nil && os.IsNotExist(err) {
err = os.MkdirAll(dir, 0o755) err = os.MkdirAll(dir, CheckpointDirPerms)
if err == nil { if err == nil {
f, err = os.OpenFile(filepath, os.O_CREATE|os.O_WRONLY, 0o644) f, err = os.OpenFile(filepath, os.O_CREATE|os.O_WRONLY, CheckpointFilePerms)
} }
} }
if err != nil { if err != nil {
@@ -307,11 +318,11 @@ func (l *Level) toCheckpoint(dir string, from, to int64, m *MemoryStore) error {
func (m *MemoryStore) FromCheckpoint(dir string, from int64, extension string) (int, error) { func (m *MemoryStore) FromCheckpoint(dir string, from int64, extension string) (int, error) {
var wg sync.WaitGroup var wg sync.WaitGroup
work := make(chan [2]string, NumWorkers) work := make(chan [2]string, Keys.NumWorkers)
n, errs := int32(0), int32(0) n, errs := int32(0), int32(0)
wg.Add(NumWorkers) wg.Add(Keys.NumWorkers)
for worker := 0; worker < NumWorkers; worker++ { for worker := 0; worker < Keys.NumWorkers; worker++ {
go func() { go func() {
defer wg.Done() defer wg.Done()
for host := range work { for host := range work {
@@ -347,7 +358,7 @@ func (m *MemoryStore) FromCheckpoint(dir string, from int64, extension string) (
} }
i++ i++
if i%NumWorkers == 0 && i > 100 { if i%Keys.NumWorkers == 0 && i > GCTriggerInterval {
// Forcing garbage collection runs here regulary during the loading of checkpoints // Forcing garbage collection runs here regulary during the loading of checkpoints
// will decrease the total heap size after loading everything back to memory is done. // will decrease the total heap size after loading everything back to memory is done.
// While loading data, the heap will grow fast, so the GC target size will double // While loading data, the heap will grow fast, so the GC target size will double
@@ -368,7 +379,7 @@ done:
} }
if errs > 0 { if errs > 0 {
return int(n), fmt.Errorf("[METRICSTORE]> %d errors happend while creating checkpoints (%d successes)", errs, n) return int(n), fmt.Errorf("[METRICSTORE]> %d errors happened while creating checkpoints (%d successes)", errs, n)
} }
return int(n), nil return int(n), nil
} }
@@ -379,7 +390,7 @@ done:
func (m *MemoryStore) FromCheckpointFiles(dir string, from int64) (int, error) { func (m *MemoryStore) FromCheckpointFiles(dir string, from int64) (int, error) {
if _, err := os.Stat(dir); os.IsNotExist(err) { if _, err := os.Stat(dir); os.IsNotExist(err) {
// The directory does not exist, so create it using os.MkdirAll() // The directory does not exist, so create it using os.MkdirAll()
err := os.MkdirAll(dir, 0o755) // 0755 sets the permissions for the directory err := os.MkdirAll(dir, CheckpointDirPerms) // CheckpointDirPerms sets the permissions for the directory
if err != nil { if err != nil {
cclog.Fatalf("[METRICSTORE]> Error creating directory: %#v\n", err) cclog.Fatalf("[METRICSTORE]> Error creating directory: %#v\n", err)
} }
@@ -464,7 +475,7 @@ func (l *Level) loadAvroFile(m *MemoryStore, f *os.File, from int64) error {
// Create a new OCF reader from the buffered reader // Create a new OCF reader from the buffered reader
ocfReader, err := goavro.NewOCFReader(br) ocfReader, err := goavro.NewOCFReader(br)
if err != nil { if err != nil {
panic(err) return fmt.Errorf("[METRICSTORE]> error creating OCF reader: %w", err)
} }
metricsData := make(map[string]schema.FloatArray) metricsData := make(map[string]schema.FloatArray)
@@ -477,7 +488,7 @@ func (l *Level) loadAvroFile(m *MemoryStore, f *os.File, from int64) error {
record, ok := datum.(map[string]any) record, ok := datum.(map[string]any)
if !ok { if !ok {
panic("[METRICSTORE]> failed to assert datum as map[string]interface{}") return fmt.Errorf("[METRICSTORE]> failed to assert datum as map[string]interface{}")
} }
for key, value := range record { for key, value := range record {
@@ -559,7 +570,7 @@ func (l *Level) createBuffer(m *MemoryStore, metricName string, floatArray schem
l.metrics[minfo.offset] = b l.metrics[minfo.offset] = b
} else { } else {
if prev.start > b.start { if prev.start > b.start {
return errors.New("wooops") return fmt.Errorf("[METRICSTORE]> buffer start time %d is before previous buffer start %d", b.start, prev.start)
} }
b.prev = prev b.prev = prev
@@ -623,7 +634,7 @@ func (l *Level) loadFile(cf *CheckpointFile, m *MemoryStore) error {
l.metrics[minfo.offset] = b l.metrics[minfo.offset] = b
} else { } else {
if prev.start > b.start { if prev.start > b.start {
return errors.New("wooops") return fmt.Errorf("[METRICSTORE]> buffer start time %d is before previous buffer start %d", b.start, prev.start)
} }
b.prev = prev b.prev = prev
@@ -700,13 +711,17 @@ func (l *Level) fromCheckpoint(m *MemoryStore, dir string, from int64, extension
loader := loaders[extension] loader := loaders[extension]
for _, filename := range files { for _, filename := range files {
// Use a closure to ensure file is closed immediately after use
err := func() error {
f, err := os.Open(path.Join(dir, filename)) f, err := os.Open(path.Join(dir, filename))
if err != nil { if err != nil {
return filesLoaded, err return err
} }
defer f.Close() defer f.Close()
if err = loader(m, f, from); err != nil { return loader(m, f, from)
}()
if err != nil {
return filesLoaded, err return filesLoaded, err
} }
+4 -1
View File
@@ -12,6 +12,9 @@ import (
var InternalCCMSFlag bool = false var InternalCCMSFlag bool = false
type MetricStoreConfig struct { type MetricStoreConfig struct {
// Number of concurrent workers for checkpoint and archive operations.
// If not set or 0, defaults to min(runtime.NumCPU()/2+1, 10)
NumWorkers int `json:"num-workers"`
Checkpoints struct { Checkpoints struct {
FileFormat string `json:"file-format"` FileFormat string `json:"file-format"`
Interval string `json:"interval"` Interval string `json:"interval"`
@@ -62,7 +65,7 @@ const (
AvgAggregation AvgAggregation
) )
func AssignAggregationStratergy(str string) (AggregationStrategy, error) { func AssignAggregationStrategy(str string) (AggregationStrategy, error) {
switch str { switch str {
case "": case "":
return NoAggregation, nil return NoAggregation, nil
+6
View File
@@ -51,6 +51,8 @@ const configSchema = `{
}, },
"nats": { "nats": {
"description": "Configuration for accepting published data through NATS.", "description": "Configuration for accepting published data through NATS.",
"type": "array",
"items": {
"type": "object", "type": "object",
"properties": { "properties": {
"address": { "address": {
@@ -71,6 +73,8 @@ const configSchema = `{
}, },
"subscriptions": { "subscriptions": {
"description": "Array of various subscriptions. Allows to subscibe to different subjects and publishers.", "description": "Array of various subscriptions. Allows to subscibe to different subjects and publishers.",
"type": "array",
"items": {
"type": "object", "type": "object",
"properties": { "properties": {
"subscribe-to": { "subscribe-to": {
@@ -86,4 +90,6 @@ const configSchema = `{
} }
} }
} }
}
}
}` }`
+1 -1
View File
@@ -39,7 +39,7 @@ func (l *Level) findLevelOrCreate(selector []string, nMetrics int) *Level {
// Children map needs to be created... // Children map needs to be created...
l.lock.RUnlock() l.lock.RUnlock()
} else { } else {
child, ok := l.children[selector[0]] child, ok = l.children[selector[0]]
l.lock.RUnlock() l.lock.RUnlock()
if ok { if ok {
return child.findLevelOrCreate(selector[1:], nMetrics) return child.findLevelOrCreate(selector[1:], nMetrics)
+44 -93
View File
@@ -3,6 +3,20 @@
// Use of this source code is governed by a MIT-style // Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file. // license that can be found in the LICENSE file.
// Package memorystore provides an efficient in-memory time-series metric storage system
// with support for hierarchical data organization, checkpointing, and archiving.
//
// The package organizes metrics in a tree structure (cluster → host → component) and
// provides concurrent read/write access to metric data with configurable aggregation strategies.
// Background goroutines handle periodic checkpointing (JSON or Avro format), archiving old data,
// and enforcing retention policies.
//
// Key features:
// - In-memory metric storage with configurable retention
// - Hierarchical data organization (selectors)
// - Concurrent checkpoint/archive workers
// - Support for sum and average aggregation
// - NATS integration for metric ingestion
package memorystore package memorystore
import ( import (
@@ -10,18 +24,14 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"os"
"os/signal"
"runtime" "runtime"
"sync" "sync"
"syscall"
"time" "time"
"github.com/ClusterCockpit/cc-backend/internal/config" "github.com/ClusterCockpit/cc-backend/internal/config"
"github.com/ClusterCockpit/cc-backend/pkg/archive" "github.com/ClusterCockpit/cc-backend/pkg/archive"
cclog "github.com/ClusterCockpit/cc-lib/ccLogger" cclog "github.com/ClusterCockpit/cc-lib/ccLogger"
"github.com/ClusterCockpit/cc-lib/resampler" "github.com/ClusterCockpit/cc-lib/resampler"
"github.com/ClusterCockpit/cc-lib/runtimeEnv"
"github.com/ClusterCockpit/cc-lib/schema" "github.com/ClusterCockpit/cc-lib/schema"
"github.com/ClusterCockpit/cc-lib/util" "github.com/ClusterCockpit/cc-lib/util"
) )
@@ -29,14 +39,12 @@ import (
var ( var (
singleton sync.Once singleton sync.Once
msInstance *MemoryStore msInstance *MemoryStore
// shutdownFunc stores the context cancellation function created in Init
// and is called during Shutdown to cancel all background goroutines
shutdownFunc context.CancelFunc
) )
var NumWorkers int = 4
func init() {
maxWorkers := 10
NumWorkers = min(runtime.NumCPU()/2+1, maxWorkers)
}
type Metric struct { type Metric struct {
Name string Name string
@@ -61,11 +69,18 @@ func Init(rawConfig json.RawMessage, wg *sync.WaitGroup) {
} }
} }
for _, c := range archive.Clusters { // Set NumWorkers from config or use default
for _, mc := range c.MetricConfig { if Keys.NumWorkers <= 0 {
agg, err := AssignAggregationStratergy(mc.Aggregation) maxWorkers := 10
Keys.NumWorkers = min(runtime.NumCPU()/2+1, maxWorkers)
}
cclog.Debugf("[METRICSTORE]> Using %d workers for checkpoint/archive operations\n", Keys.NumWorkers)
// Helper function to add metric configuration
addMetricConfig := func(mc schema.MetricConfig) {
agg, err := AssignAggregationStrategy(mc.Aggregation)
if err != nil { if err != nil {
cclog.Warnf("Could not find aggregation stratergy for metric config '%s': %s", mc.Name, err.Error()) cclog.Warnf("Could not find aggregation strategy for metric config '%s': %s", mc.Name, err.Error())
} }
AddMetric(mc.Name, MetricConfig{ AddMetric(mc.Name, MetricConfig{
@@ -74,17 +89,14 @@ func Init(rawConfig json.RawMessage, wg *sync.WaitGroup) {
}) })
} }
for _, c := range archive.Clusters {
for _, mc := range c.MetricConfig {
addMetricConfig(*mc)
}
for _, sc := range c.SubClusters { for _, sc := range c.SubClusters {
for _, mc := range sc.MetricConfig { for _, mc := range sc.MetricConfig {
agg, err := AssignAggregationStratergy(mc.Aggregation) addMetricConfig(mc)
if err != nil {
cclog.Warnf("Could not find aggregation stratergy for metric config '%s': %s", mc.Name, err.Error())
}
AddMetric(mc.Name, MetricConfig{
Frequency: int64(mc.Timestep),
Aggregation: agg,
})
} }
} }
} }
@@ -126,15 +138,11 @@ func Init(rawConfig json.RawMessage, wg *sync.WaitGroup) {
Archiving(wg, ctx) Archiving(wg, ctx)
DataStaging(wg, ctx) DataStaging(wg, ctx)
wg.Add(1) // Note: Signal handling has been removed from this function.
sigs := make(chan os.Signal, 1) // The caller is responsible for handling shutdown signals and calling
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) // the shutdown() function when appropriate.
go func() { // Store the shutdown function for later use by Shutdown()
defer wg.Done() shutdownFunc = shutdown
<-sigs
runtimeEnv.SystemdNotifiy(false, "[METRICSTORE]> Shutting down ...")
shutdown()
}()
if Keys.Nats != nil { if Keys.Nats != nil {
for _, natsConf := range Keys.Nats { for _, natsConf := range Keys.Nats {
@@ -190,6 +198,11 @@ func GetMemoryStore() *MemoryStore {
} }
func Shutdown() { func Shutdown() {
// Cancel the context to signal all background goroutines to stop
if shutdownFunc != nil {
shutdownFunc()
}
cclog.Infof("[METRICSTORE]> Writing to '%s'...\n", Keys.Checkpoints.RootDir) cclog.Infof("[METRICSTORE]> Writing to '%s'...\n", Keys.Checkpoints.RootDir)
var files int var files int
var err error var err error
@@ -207,70 +220,8 @@ func Shutdown() {
cclog.Errorf("[METRICSTORE]> Writing checkpoint failed: %s\n", err.Error()) cclog.Errorf("[METRICSTORE]> Writing checkpoint failed: %s\n", err.Error())
} }
cclog.Infof("[METRICSTORE]> Done! (%d files written)\n", files) cclog.Infof("[METRICSTORE]> Done! (%d files written)\n", files)
// ms.PrintHeirarchy()
} }
// func (m *MemoryStore) PrintHeirarchy() {
// m.root.lock.Lock()
// defer m.root.lock.Unlock()
// fmt.Printf("Root : \n")
// for lvl1, sel1 := range m.root.children {
// fmt.Printf("\t%s\n", lvl1)
// for lvl2, sel2 := range sel1.children {
// fmt.Printf("\t\t%s\n", lvl2)
// if lvl1 == "fritz" && lvl2 == "f0201" {
// for name, met := range m.Metrics {
// mt := sel2.metrics[met.Offset]
// fmt.Printf("\t\t\t\t%s\n", name)
// fmt.Printf("\t\t\t\t")
// for mt != nil {
// // if name == "cpu_load" {
// fmt.Printf("%d(%d) -> %#v", mt.start, len(mt.data), mt.data)
// // }
// mt = mt.prev
// }
// fmt.Printf("\n")
// }
// }
// for lvl3, sel3 := range sel2.children {
// if lvl1 == "fritz" && lvl2 == "f0201" && lvl3 == "hwthread70" {
// fmt.Printf("\t\t\t\t\t%s\n", lvl3)
// for name, met := range m.Metrics {
// mt := sel3.metrics[met.Offset]
// fmt.Printf("\t\t\t\t\t\t%s\n", name)
// fmt.Printf("\t\t\t\t\t\t")
// for mt != nil {
// // if name == "clock" {
// fmt.Printf("%d(%d) -> %#v", mt.start, len(mt.data), mt.data)
// mt = mt.prev
// }
// fmt.Printf("\n")
// }
// // for i, _ := range sel3.metrics {
// // fmt.Printf("\t\t\t\t\t%s\n", getName(configmetrics, i))
// // }
// }
// }
// }
// }
// }
func getName(m *MemoryStore, i int) string { func getName(m *MemoryStore, i int) string {
for key, val := range m.Metrics { for key, val := range m.Metrics {
if val.offset == i { if val.offset == i {
+156
View File
@@ -0,0 +1,156 @@
// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
// All rights reserved. This file is part of cc-backend.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package memorystore
import (
"testing"
"github.com/ClusterCockpit/cc-lib/schema"
)
func TestAssignAggregationStrategy(t *testing.T) {
tests := []struct {
name string
input string
expected AggregationStrategy
wantErr bool
}{
{"empty string", "", NoAggregation, false},
{"sum", "sum", SumAggregation, false},
{"avg", "avg", AvgAggregation, false},
{"invalid", "invalid", NoAggregation, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := AssignAggregationStrategy(tt.input)
if (err != nil) != tt.wantErr {
t.Errorf("AssignAggregationStrategy(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
return
}
if result != tt.expected {
t.Errorf("AssignAggregationStrategy(%q) = %v, want %v", tt.input, result, tt.expected)
}
})
}
}
func TestAddMetric(t *testing.T) {
// Reset Metrics before test
Metrics = make(map[string]MetricConfig)
err := AddMetric("test_metric", MetricConfig{
Frequency: 60,
Aggregation: SumAggregation,
})
if err != nil {
t.Errorf("AddMetric() error = %v", err)
}
if _, ok := Metrics["test_metric"]; !ok {
t.Error("AddMetric() did not add metric to Metrics map")
}
// Test updating with higher frequency
err = AddMetric("test_metric", MetricConfig{
Frequency: 120,
Aggregation: SumAggregation,
})
if err != nil {
t.Errorf("AddMetric() error = %v", err)
}
if Metrics["test_metric"].Frequency != 120 {
t.Errorf("AddMetric() frequency = %d, want 120", Metrics["test_metric"].Frequency)
}
// Test updating with lower frequency (should not update)
err = AddMetric("test_metric", MetricConfig{
Frequency: 30,
Aggregation: SumAggregation,
})
if err != nil {
t.Errorf("AddMetric() error = %v", err)
}
if Metrics["test_metric"].Frequency != 120 {
t.Errorf("AddMetric() frequency = %d, want 120 (should not downgrade)", Metrics["test_metric"].Frequency)
}
}
func TestGetMetricFrequency(t *testing.T) {
// Reset Metrics before test
Metrics = map[string]MetricConfig{
"test_metric": {
Frequency: 60,
Aggregation: SumAggregation,
},
}
freq, err := GetMetricFrequency("test_metric")
if err != nil {
t.Errorf("GetMetricFrequency() error = %v", err)
}
if freq != 60 {
t.Errorf("GetMetricFrequency() = %d, want 60", freq)
}
_, err = GetMetricFrequency("nonexistent")
if err == nil {
t.Error("GetMetricFrequency() expected error for nonexistent metric")
}
}
func TestBufferWrite(t *testing.T) {
b := newBuffer(100, 10)
// Test writing value
nb, err := b.write(100, schema.Float(42.0))
if err != nil {
t.Errorf("buffer.write() error = %v", err)
}
if nb != b {
t.Error("buffer.write() created new buffer unexpectedly")
}
if len(b.data) != 1 {
t.Errorf("buffer.write() len(data) = %d, want 1", len(b.data))
}
if b.data[0] != schema.Float(42.0) {
t.Errorf("buffer.write() data[0] = %v, want 42.0", b.data[0])
}
// Test writing value from past (should error)
_, err = b.write(50, schema.Float(10.0))
if err == nil {
t.Error("buffer.write() expected error for past timestamp")
}
}
func TestBufferRead(t *testing.T) {
b := newBuffer(100, 10)
// Write some test data
b.write(100, schema.Float(1.0))
b.write(110, schema.Float(2.0))
b.write(120, schema.Float(3.0))
// Read data
data := make([]schema.Float, 3)
result, from, to, err := b.read(100, 130, data)
if err != nil {
t.Errorf("buffer.read() error = %v", err)
}
// Buffer read should return from as firstWrite (start + freq/2)
if from != 100 {
t.Errorf("buffer.read() from = %d, want 100", from)
}
if to != 130 {
t.Errorf("buffer.read() to = %d, want 130", to)
}
if len(result) != 3 {
t.Errorf("buffer.read() len(result) = %d, want 3", len(result))
}
}
+8 -9
View File
@@ -11,7 +11,6 @@ import (
"time" "time"
"github.com/ClusterCockpit/cc-backend/internal/config" "github.com/ClusterCockpit/cc-backend/internal/config"
"github.com/ClusterCockpit/cc-backend/internal/graph/model"
"github.com/ClusterCockpit/cc-backend/internal/metricdata" "github.com/ClusterCockpit/cc-backend/internal/metricdata"
"github.com/ClusterCockpit/cc-backend/pkg/archive" "github.com/ClusterCockpit/cc-backend/pkg/archive"
cclog "github.com/ClusterCockpit/cc-lib/ccLogger" cclog "github.com/ClusterCockpit/cc-lib/ccLogger"
@@ -332,17 +331,17 @@ func LoadNodeData(
} }
func LoadNodeListData( func LoadNodeListData(
cluster, subCluster, nodeFilter string, cluster, subCluster string,
nodes []string,
metrics []string, metrics []string,
scopes []schema.MetricScope, scopes []schema.MetricScope,
resolution int, resolution int,
from, to time.Time, from, to time.Time,
page *model.PageRequest,
ctx context.Context, ctx context.Context,
) (map[string]schema.JobData, int, bool, error) { ) (map[string]schema.JobData, error) {
repo, err := metricdata.GetMetricDataRepo(cluster) repo, err := metricdata.GetMetricDataRepo(cluster)
if err != nil { if err != nil {
return nil, 0, false, fmt.Errorf("METRICDATA/METRICDATA > no metric data repository configured for '%s'", cluster) return nil, fmt.Errorf("METRICDATA/METRICDATA > no metric data repository configured for '%s'", cluster)
} }
if metrics == nil { if metrics == nil {
@@ -351,13 +350,13 @@ func LoadNodeListData(
} }
} }
data, totalNodes, hasNextPage, err := repo.LoadNodeListData(cluster, subCluster, nodeFilter, metrics, scopes, resolution, from, to, page, ctx) data, err := repo.LoadNodeListData(cluster, subCluster, nodes, metrics, scopes, resolution, from, to, ctx)
if err != nil { if err != nil {
if len(data) != 0 { if len(data) != 0 {
cclog.Warnf("partial error: %s", err.Error()) cclog.Warnf("partial error: %s", err.Error())
} else { } else {
cclog.Error("Error while loading node data from metric repository") cclog.Error("Error while loading node data from metric repository")
return nil, totalNodes, hasNextPage, err return nil, err
} }
} }
@@ -375,8 +374,8 @@ func LoadNodeListData(
} }
if data == nil { if data == nil {
return nil, totalNodes, hasNextPage, fmt.Errorf("METRICDATA/METRICDATA > the metric data repository for '%s' does not support this query", cluster) return nil, fmt.Errorf("METRICDATA/METRICDATA > the metric data repository for '%s' does not support this query", cluster)
} }
return data, totalNodes, hasNextPage, nil return data, nil
} }
@@ -9,12 +9,10 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"sort"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"github.com/ClusterCockpit/cc-backend/internal/graph/model"
"github.com/ClusterCockpit/cc-backend/internal/memorystore" "github.com/ClusterCockpit/cc-backend/internal/memorystore"
"github.com/ClusterCockpit/cc-backend/pkg/archive" "github.com/ClusterCockpit/cc-backend/pkg/archive"
cclog "github.com/ClusterCockpit/cc-lib/ccLogger" cclog "github.com/ClusterCockpit/cc-lib/ccLogger"
@@ -677,64 +675,20 @@ func (ccms *CCMetricStoreInternal) LoadNodeData(
// Used for Systems-View Node-List // Used for Systems-View Node-List
func (ccms *CCMetricStoreInternal) LoadNodeListData( func (ccms *CCMetricStoreInternal) LoadNodeListData(
cluster, subCluster, nodeFilter string, cluster, subCluster string,
nodes []string,
metrics []string, metrics []string,
scopes []schema.MetricScope, scopes []schema.MetricScope,
resolution int, resolution int,
from, to time.Time, from, to time.Time,
page *model.PageRequest,
ctx context.Context, ctx context.Context,
) (map[string]schema.JobData, int, bool, error) { ) (map[string]schema.JobData, error) {
// 0) Init additional vars
var totalNodes int = 0
var hasNextPage bool = false
// 1) Get list of all nodes
var nodes []string
if subCluster != "" {
scNodes := archive.NodeLists[cluster][subCluster]
nodes = scNodes.PrintList()
} else {
subClusterNodeLists := archive.NodeLists[cluster]
for _, nodeList := range subClusterNodeLists {
nodes = append(nodes, nodeList.PrintList()...)
}
}
// 2) Filter nodes
if nodeFilter != "" {
filteredNodes := []string{}
for _, node := range nodes {
if strings.Contains(node, nodeFilter) {
filteredNodes = append(filteredNodes, node)
}
}
nodes = filteredNodes
}
// 2.1) Count total nodes && Sort nodes -> Sorting invalidated after ccms return ...
totalNodes = len(nodes)
sort.Strings(nodes)
// 3) Apply paging
if len(nodes) > page.ItemsPerPage {
start := (page.Page - 1) * page.ItemsPerPage
end := start + page.ItemsPerPage
if end >= len(nodes) {
end = len(nodes)
hasNextPage = false
} else {
hasNextPage = true
}
nodes = nodes[start:end]
}
// Note: Order of node data is not guaranteed after this point, but contents match page and filter criteria
// Note: Order of node data is not guaranteed after this point
queries, assignedScope, err := ccms.buildNodeQueries(cluster, subCluster, nodes, metrics, scopes, int64(resolution)) queries, assignedScope, err := ccms.buildNodeQueries(cluster, subCluster, nodes, metrics, scopes, int64(resolution))
if err != nil { if err != nil {
cclog.Errorf("Error while building node queries for Cluster %s, SubCLuster %s, Metrics %v, Scopes %v: %s", cluster, subCluster, metrics, scopes, err.Error()) cclog.Errorf("Error while building node queries for Cluster %s, SubCLuster %s, Metrics %v, Scopes %v: %s", cluster, subCluster, metrics, scopes, err.Error())
return nil, totalNodes, hasNextPage, err return nil, err
} }
req := memorystore.APIQueryRequest{ req := memorystore.APIQueryRequest{
@@ -749,7 +703,7 @@ func (ccms *CCMetricStoreInternal) LoadNodeListData(
resBody, err := memorystore.FetchData(req) resBody, err := memorystore.FetchData(req)
if err != nil { if err != nil {
cclog.Errorf("Error while fetching data : %s", err.Error()) cclog.Errorf("Error while fetching data : %s", err.Error())
return nil, totalNodes, hasNextPage, err return nil, err
} }
var errors []string var errors []string
@@ -829,10 +783,10 @@ func (ccms *CCMetricStoreInternal) LoadNodeListData(
if len(errors) != 0 { if len(errors) != 0 {
/* Returns list of "partial errors" */ /* Returns list of "partial errors" */
return data, totalNodes, hasNextPage, fmt.Errorf("METRICDATA/CCMS > Errors: %s", strings.Join(errors, ", ")) return data, fmt.Errorf("METRICDATA/CCMS > Errors: %s", strings.Join(errors, ", "))
} }
return data, totalNodes, hasNextPage, nil return data, nil
} }
func (ccms *CCMetricStoreInternal) buildNodeQueries( func (ccms *CCMetricStoreInternal) buildNodeQueries(
+8 -55
View File
@@ -11,11 +11,9 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"sort"
"strings" "strings"
"time" "time"
"github.com/ClusterCockpit/cc-backend/internal/graph/model"
"github.com/ClusterCockpit/cc-backend/pkg/archive" "github.com/ClusterCockpit/cc-backend/pkg/archive"
cclog "github.com/ClusterCockpit/cc-lib/ccLogger" cclog "github.com/ClusterCockpit/cc-lib/ccLogger"
"github.com/ClusterCockpit/cc-lib/schema" "github.com/ClusterCockpit/cc-lib/schema"
@@ -799,65 +797,20 @@ func (ccms *CCMetricStore) LoadNodeData(
// Used for Systems-View Node-List // Used for Systems-View Node-List
func (ccms *CCMetricStore) LoadNodeListData( func (ccms *CCMetricStore) LoadNodeListData(
cluster, subCluster, nodeFilter string, cluster, subCluster string,
nodes []string,
metrics []string, metrics []string,
scopes []schema.MetricScope, scopes []schema.MetricScope,
resolution int, resolution int,
from, to time.Time, from, to time.Time,
page *model.PageRequest,
ctx context.Context, ctx context.Context,
) (map[string]schema.JobData, int, bool, error) { ) (map[string]schema.JobData, error) {
// 0) Init additional vars
var totalNodes int = 0
var hasNextPage bool = false
// 1) Get list of all nodes
var nodes []string
if subCluster != "" {
scNodes := archive.NodeLists[cluster][subCluster]
nodes = scNodes.PrintList()
} else {
subClusterNodeLists := archive.NodeLists[cluster]
for _, nodeList := range subClusterNodeLists {
nodes = append(nodes, nodeList.PrintList()...)
}
}
// 2) Filter nodes
if nodeFilter != "" {
filteredNodes := []string{}
for _, node := range nodes {
if strings.Contains(node, nodeFilter) {
filteredNodes = append(filteredNodes, node)
}
}
nodes = filteredNodes
}
// 2.1) Count total nodes && Sort nodes -> Sorting invalidated after ccms return ...
totalNodes = len(nodes)
sort.Strings(nodes)
// 3) Apply paging
if len(nodes) > page.ItemsPerPage {
start := (page.Page - 1) * page.ItemsPerPage
end := start + page.ItemsPerPage
if end > len(nodes) {
end = len(nodes)
hasNextPage = false
} else {
hasNextPage = true
}
nodes = nodes[start:end]
}
// Note: Order of node data is not guaranteed after this point, but contents match page and filter criteria
// Note: Order of node data is not guaranteed after this point
queries, assignedScope, err := ccms.buildNodeQueries(cluster, subCluster, nodes, metrics, scopes, resolution) queries, assignedScope, err := ccms.buildNodeQueries(cluster, subCluster, nodes, metrics, scopes, resolution)
if err != nil { if err != nil {
cclog.Errorf("Error while building node queries for Cluster %s, SubCLuster %s, Metrics %v, Scopes %v: %s", cluster, subCluster, metrics, scopes, err.Error()) cclog.Errorf("Error while building node queries for Cluster %s, SubCLuster %s, Metrics %v, Scopes %v: %s", cluster, subCluster, metrics, scopes, err.Error())
return nil, totalNodes, hasNextPage, err return nil, err
} }
req := ApiQueryRequest{ req := ApiQueryRequest{
@@ -872,7 +825,7 @@ func (ccms *CCMetricStore) LoadNodeListData(
resBody, err := ccms.doRequest(ctx, &req) resBody, err := ccms.doRequest(ctx, &req)
if err != nil { if err != nil {
cclog.Errorf("Error while performing request: %s", err.Error()) cclog.Errorf("Error while performing request: %s", err.Error())
return nil, totalNodes, hasNextPage, err return nil, err
} }
var errors []string var errors []string
@@ -952,10 +905,10 @@ func (ccms *CCMetricStore) LoadNodeListData(
if len(errors) != 0 { if len(errors) != 0 {
/* Returns list of "partial errors" */ /* Returns list of "partial errors" */
return data, totalNodes, hasNextPage, fmt.Errorf("METRICDATA/CCMS > Errors: %s", strings.Join(errors, ", ")) return data, fmt.Errorf("METRICDATA/CCMS > Errors: %s", strings.Join(errors, ", "))
} }
return data, totalNodes, hasNextPage, nil return data, nil
} }
func (ccms *CCMetricStore) buildNodeQueries( func (ccms *CCMetricStore) buildNodeQueries(
+1 -2
View File
@@ -12,7 +12,6 @@ import (
"time" "time"
"github.com/ClusterCockpit/cc-backend/internal/config" "github.com/ClusterCockpit/cc-backend/internal/config"
"github.com/ClusterCockpit/cc-backend/internal/graph/model"
"github.com/ClusterCockpit/cc-backend/internal/memorystore" "github.com/ClusterCockpit/cc-backend/internal/memorystore"
cclog "github.com/ClusterCockpit/cc-lib/ccLogger" cclog "github.com/ClusterCockpit/cc-lib/ccLogger"
"github.com/ClusterCockpit/cc-lib/schema" "github.com/ClusterCockpit/cc-lib/schema"
@@ -36,7 +35,7 @@ type MetricDataRepository interface {
LoadNodeData(cluster string, metrics, nodes []string, scopes []schema.MetricScope, from, to time.Time, ctx context.Context) (map[string]map[string][]*schema.JobMetric, error) LoadNodeData(cluster string, metrics, nodes []string, scopes []schema.MetricScope, from, to time.Time, ctx context.Context) (map[string]map[string][]*schema.JobMetric, error)
// Return a map of hosts to a map of metrics to a map of scopes for multiple nodes. // Return a map of hosts to a map of metrics to a map of scopes for multiple nodes.
LoadNodeListData(cluster, subCluster, nodeFilter string, metrics []string, scopes []schema.MetricScope, resolution int, from, to time.Time, page *model.PageRequest, ctx context.Context) (map[string]schema.JobData, int, bool, error) LoadNodeListData(cluster, subCluster string, nodes, metrics []string, scopes []schema.MetricScope, resolution int, from, to time.Time, ctx context.Context) (map[string]schema.JobData, error)
} }
var metricDataRepos map[string]MetricDataRepository = map[string]MetricDataRepository{} var metricDataRepos map[string]MetricDataRepository = map[string]MetricDataRepository{}
+8 -54
View File
@@ -20,7 +20,6 @@ import (
"text/template" "text/template"
"time" "time"
"github.com/ClusterCockpit/cc-backend/internal/graph/model"
"github.com/ClusterCockpit/cc-backend/pkg/archive" "github.com/ClusterCockpit/cc-backend/pkg/archive"
cclog "github.com/ClusterCockpit/cc-lib/ccLogger" cclog "github.com/ClusterCockpit/cc-lib/ccLogger"
"github.com/ClusterCockpit/cc-lib/schema" "github.com/ClusterCockpit/cc-lib/schema"
@@ -494,62 +493,17 @@ func (pdb *PrometheusDataRepository) LoadScopedStats(
// Implemented by NHR@FAU; Used in NodeList-View // Implemented by NHR@FAU; Used in NodeList-View
func (pdb *PrometheusDataRepository) LoadNodeListData( func (pdb *PrometheusDataRepository) LoadNodeListData(
cluster, subCluster, nodeFilter string, cluster, subCluster string,
nodes []string,
metrics []string, metrics []string,
scopes []schema.MetricScope, scopes []schema.MetricScope,
resolution int, resolution int,
from, to time.Time, from, to time.Time,
page *model.PageRequest,
ctx context.Context, ctx context.Context,
) (map[string]schema.JobData, int, bool, error) { ) (map[string]schema.JobData, error) {
// Assumption: pdb.loadData() only returns series node-scope - use node scope for NodeList // Assumption: pdb.loadData() only returns series node-scope - use node scope for NodeList
// 0) Init additional vars // Fetch Data, based on pdb.LoadNodeData()
var totalNodes int = 0
var hasNextPage bool = false
// 1) Get list of all nodes
var nodes []string
if subCluster != "" {
scNodes := archive.NodeLists[cluster][subCluster]
nodes = scNodes.PrintList()
} else {
subClusterNodeLists := archive.NodeLists[cluster]
for _, nodeList := range subClusterNodeLists {
nodes = append(nodes, nodeList.PrintList()...)
}
}
// 2) Filter nodes
if nodeFilter != "" {
filteredNodes := []string{}
for _, node := range nodes {
if strings.Contains(node, nodeFilter) {
filteredNodes = append(filteredNodes, node)
}
}
nodes = filteredNodes
}
// 2.1) Count total nodes && Sort nodes -> Sorting invalidated after return ...
totalNodes = len(nodes)
sort.Strings(nodes)
// 3) Apply paging
if len(nodes) > page.ItemsPerPage {
start := (page.Page - 1) * page.ItemsPerPage
end := start + page.ItemsPerPage
if end >= len(nodes) {
end = len(nodes)
hasNextPage = false
} else {
hasNextPage = true
}
nodes = nodes[start:end]
}
// 4) Fetch Data, based on pdb.LoadNodeData()
t0 := time.Now() t0 := time.Now()
// Map of hosts of jobData // Map of hosts of jobData
data := make(map[string]schema.JobData) data := make(map[string]schema.JobData)
@@ -572,12 +526,12 @@ func (pdb *PrometheusDataRepository) LoadNodeListData(
metricConfig := archive.GetMetricConfig(cluster, metric) metricConfig := archive.GetMetricConfig(cluster, metric)
if metricConfig == nil { if metricConfig == nil {
cclog.Warnf("Error in LoadNodeListData: Metric %s for cluster %s not configured", metric, cluster) cclog.Warnf("Error in LoadNodeListData: Metric %s for cluster %s not configured", metric, cluster)
return nil, totalNodes, hasNextPage, errors.New("Prometheus config error") return nil, errors.New("Prometheus config error")
} }
query, err := pdb.FormatQuery(metric, scope, nodes, cluster) query, err := pdb.FormatQuery(metric, scope, nodes, cluster)
if err != nil { if err != nil {
cclog.Warn("Error while formatting prometheus query") cclog.Warn("Error while formatting prometheus query")
return nil, totalNodes, hasNextPage, err return nil, err
} }
// ranged query over all nodes // ranged query over all nodes
@@ -589,7 +543,7 @@ func (pdb *PrometheusDataRepository) LoadNodeListData(
result, warnings, err := pdb.queryClient.QueryRange(ctx, query, r) result, warnings, err := pdb.queryClient.QueryRange(ctx, query, r)
if err != nil { if err != nil {
cclog.Errorf("Prometheus query error in LoadNodeData: %v\n", err) cclog.Errorf("Prometheus query error in LoadNodeData: %v\n", err)
return nil, totalNodes, hasNextPage, errors.New("Prometheus query error") return nil, errors.New("Prometheus query error")
} }
if len(warnings) > 0 { if len(warnings) > 0 {
cclog.Warnf("Warnings: %v\n", warnings) cclog.Warnf("Warnings: %v\n", warnings)
@@ -629,5 +583,5 @@ func (pdb *PrometheusDataRepository) LoadNodeListData(
} }
t1 := time.Since(t0) t1 := time.Since(t0)
cclog.Debugf("LoadNodeListData of %v nodes took %s", len(data), t1) cclog.Debugf("LoadNodeListData of %v nodes took %s", len(data), t1)
return data, totalNodes, hasNextPage, nil return data, nil
} }
+3 -4
View File
@@ -10,7 +10,6 @@ import (
"encoding/json" "encoding/json"
"time" "time"
"github.com/ClusterCockpit/cc-backend/internal/graph/model"
"github.com/ClusterCockpit/cc-lib/schema" "github.com/ClusterCockpit/cc-lib/schema"
) )
@@ -63,14 +62,14 @@ func (tmdr *TestMetricDataRepository) LoadNodeData(
} }
func (tmdr *TestMetricDataRepository) LoadNodeListData( func (tmdr *TestMetricDataRepository) LoadNodeListData(
cluster, subCluster, nodeFilter string, cluster, subCluster string,
nodes []string,
metrics []string, metrics []string,
scopes []schema.MetricScope, scopes []schema.MetricScope,
resolution int, resolution int,
from, to time.Time, from, to time.Time,
page *model.PageRequest,
ctx context.Context, ctx context.Context,
) (map[string]schema.JobData, int, bool, error) { ) (map[string]schema.JobData, error) {
panic("TODO") panic("TODO")
} }
+68
View File
@@ -0,0 +1,68 @@
// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
// All rights reserved. This file is part of cc-backend.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package repository
import "time"
// RepositoryConfig holds configuration for repository operations.
// All fields have sensible defaults, so this configuration is optional.
type RepositoryConfig struct {
// CacheSize is the LRU cache size in bytes for job metadata and energy footprints.
// Default: 1MB (1024 * 1024 bytes)
CacheSize int
// MaxOpenConnections is the maximum number of open database connections.
// Default: 4
MaxOpenConnections int
// MaxIdleConnections is the maximum number of idle database connections.
// Default: 4
MaxIdleConnections int
// ConnectionMaxLifetime is the maximum amount of time a connection may be reused.
// Default: 1 hour
ConnectionMaxLifetime time.Duration
// ConnectionMaxIdleTime is the maximum amount of time a connection may be idle.
// Default: 1 hour
ConnectionMaxIdleTime time.Duration
// MinRunningJobDuration is the minimum duration in seconds for a job to be
// considered in "running jobs" queries. This filters out very short jobs.
// Default: 600 seconds (10 minutes)
MinRunningJobDuration int
}
// DefaultConfig returns the default repository configuration.
// These values are optimized for typical deployments.
func DefaultConfig() *RepositoryConfig {
return &RepositoryConfig{
CacheSize: 1 * 1024 * 1024, // 1MB
MaxOpenConnections: 4,
MaxIdleConnections: 4,
ConnectionMaxLifetime: time.Hour,
ConnectionMaxIdleTime: time.Hour,
MinRunningJobDuration: 600, // 10 minutes
}
}
// repoConfig is the package-level configuration instance.
// It is initialized with defaults and can be overridden via SetConfig.
var repoConfig *RepositoryConfig = DefaultConfig()
// SetConfig sets the repository configuration.
// This must be called before any repository initialization (Connect, GetJobRepository, etc.).
// If not called, default values from DefaultConfig() are used.
func SetConfig(cfg *RepositoryConfig) {
if cfg != nil {
repoConfig = cfg
}
}
// GetConfig returns the current repository configuration.
func GetConfig() *RepositoryConfig {
return repoConfig
}
+20 -22
View File
@@ -2,6 +2,7 @@
// All rights reserved. This file is part of cc-backend. // All rights reserved. This file is part of cc-backend.
// Use of this source code is governed by a MIT-style // Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file. // license that can be found in the LICENSE file.
package repository package repository
import ( import (
@@ -35,21 +36,15 @@ type DatabaseOptions struct {
ConnectionMaxIdleTime time.Duration ConnectionMaxIdleTime time.Duration
} }
func setupSqlite(db *sql.DB) (err error) { func setupSqlite(db *sql.DB) error {
pragmas := []string{ pragmas := []string{
// "journal_mode = WAL",
// "busy_timeout = 5000",
// "synchronous = NORMAL",
// "cache_size = 1000000000", // 1GB
// "foreign_keys = true",
"temp_store = memory", "temp_store = memory",
// "mmap_size = 3000000000",
} }
for _, pragma := range pragmas { for _, pragma := range pragmas {
_, err = db.Exec("PRAGMA " + pragma) _, err := db.Exec("PRAGMA " + pragma)
if err != nil { if err != nil {
return return err
} }
} }
@@ -63,24 +58,24 @@ func Connect(driver string, db string) {
dbConnOnce.Do(func() { dbConnOnce.Do(func() {
opts := DatabaseOptions{ opts := DatabaseOptions{
URL: db, URL: db,
MaxOpenConnections: 4, MaxOpenConnections: repoConfig.MaxOpenConnections,
MaxIdleConnections: 4, MaxIdleConnections: repoConfig.MaxIdleConnections,
ConnectionMaxLifetime: time.Hour, ConnectionMaxLifetime: repoConfig.ConnectionMaxLifetime,
ConnectionMaxIdleTime: time.Hour, ConnectionMaxIdleTime: repoConfig.ConnectionMaxIdleTime,
} }
switch driver { switch driver {
case "sqlite3": case "sqlite3":
// TODO: Have separate DB handles for Writes and Reads // TODO: Have separate DB handles for Writes and Reads
// Optimize SQLite connection: https://kerkour.com/sqlite-for-servers // Optimize SQLite connection: https://kerkour.com/sqlite-for-servers
connectionUrlParams := make(url.Values) connectionURLParams := make(url.Values)
connectionUrlParams.Add("_txlock", "immediate") connectionURLParams.Add("_txlock", "immediate")
connectionUrlParams.Add("_journal_mode", "WAL") connectionURLParams.Add("_journal_mode", "WAL")
connectionUrlParams.Add("_busy_timeout", "5000") connectionURLParams.Add("_busy_timeout", "5000")
connectionUrlParams.Add("_synchronous", "NORMAL") connectionURLParams.Add("_synchronous", "NORMAL")
connectionUrlParams.Add("_cache_size", "1000000000") connectionURLParams.Add("_cache_size", "1000000000")
connectionUrlParams.Add("_foreign_keys", "true") connectionURLParams.Add("_foreign_keys", "true")
opts.URL = fmt.Sprintf("file:%s?%s", opts.URL, connectionUrlParams.Encode()) opts.URL = fmt.Sprintf("file:%s?%s", opts.URL, connectionURLParams.Encode())
if cclog.Loglevel() == "debug" { if cclog.Loglevel() == "debug" {
sql.Register("sqlite3WithHooks", sqlhooks.Wrap(&sqlite3.SQLiteDriver{}, &Hooks{})) sql.Register("sqlite3WithHooks", sqlhooks.Wrap(&sqlite3.SQLiteDriver{}, &Hooks{}))
@@ -89,7 +84,10 @@ func Connect(driver string, db string) {
dbHandle, err = sqlx.Open("sqlite3", opts.URL) dbHandle, err = sqlx.Open("sqlite3", opts.URL)
} }
setupSqlite(dbHandle.DB) err = setupSqlite(dbHandle.DB)
if err != nil {
cclog.Abortf("Failed sqlite db setup.\nError: %s\n", err.Error())
}
case "mysql": case "mysql":
opts.URL += "?multiStatements=true" opts.URL += "?multiStatements=true"
dbHandle, err = sqlx.Open("mysql", opts.URL) dbHandle, err = sqlx.Open("mysql", opts.URL)
+99 -11
View File
@@ -2,6 +2,63 @@
// All rights reserved. This file is part of cc-backend. // All rights reserved. This file is part of cc-backend.
// Use of this source code is governed by a MIT-style // Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file. // license that can be found in the LICENSE file.
// Package repository provides the data access layer for cc-backend using the repository pattern.
//
// The repository pattern abstracts database operations and provides a clean interface for
// data access. Each major entity (Job, User, Node, Tag) has its own repository with CRUD
// operations and specialized queries.
//
// # Database Connection
//
// Initialize the database connection before using any repository:
//
// repository.Connect("sqlite3", "./var/job.db")
// // or for MySQL:
// repository.Connect("mysql", "user:password@tcp(localhost:3306)/dbname")
//
// # Configuration
//
// Optional: Configure repository settings before initialization:
//
// repository.SetConfig(&repository.RepositoryConfig{
// CacheSize: 2 * 1024 * 1024, // 2MB cache
// MaxOpenConnections: 8, // Connection pool size
// MinRunningJobDuration: 300, // Filter threshold
// })
//
// If not configured, sensible defaults are used automatically.
//
// # Repositories
//
// - JobRepository: Job lifecycle management and querying
// - UserRepository: User management and authentication
// - NodeRepository: Cluster node state tracking
// - Tags: Job tagging and categorization
//
// # Caching
//
// Repositories use LRU caching to improve performance. Cache keys are constructed
// as "type:id" (e.g., "metadata:123"). Cache is automatically invalidated on
// mutations to maintain consistency.
//
// # Transaction Support
//
// For batch operations, use transactions:
//
// t, err := jobRepo.TransactionInit()
// if err != nil {
// return err
// }
// defer t.Rollback() // Rollback if not committed
//
// // Perform operations...
// jobRepo.TransactionAdd(t, query, args...)
//
// // Commit when done
// if err := t.Commit(); err != nil {
// return err
// }
package repository package repository
import ( import (
@@ -45,7 +102,7 @@ func GetJobRepository() *JobRepository {
driver: db.Driver, driver: db.Driver,
stmtCache: sq.NewStmtCache(db.DB), stmtCache: sq.NewStmtCache(db.DB),
cache: lrucache.New(1024 * 1024), cache: lrucache.New(repoConfig.CacheSize),
} }
}) })
return jobRepoInstance return jobRepoInstance
@@ -267,7 +324,31 @@ func (r *JobRepository) FetchEnergyFootprint(job *schema.Job) (map[string]float6
func (r *JobRepository) DeleteJobsBefore(startTime int64) (int, error) { func (r *JobRepository) DeleteJobsBefore(startTime int64) (int, error) {
var cnt int var cnt int
q := sq.Select("count(*)").From("job").Where("job.start_time < ?", startTime) q := sq.Select("count(*)").From("job").Where("job.start_time < ?", startTime)
q.RunWith(r.DB).QueryRow().Scan(cnt) if err := q.RunWith(r.DB).QueryRow().Scan(&cnt); err != nil {
cclog.Errorf("Error counting jobs before %d: %v", startTime, err)
return 0, err
}
// Invalidate cache for jobs being deleted (get job IDs first)
if cnt > 0 {
var jobIds []int64
rows, err := sq.Select("id").From("job").Where("job.start_time < ?", startTime).RunWith(r.DB).Query()
if err == nil {
defer rows.Close()
for rows.Next() {
var id int64
if err := rows.Scan(&id); err == nil {
jobIds = append(jobIds, id)
}
}
// Invalidate cache entries
for _, id := range jobIds {
r.cache.Del(fmt.Sprintf("metadata:%d", id))
r.cache.Del(fmt.Sprintf("energyFootprint:%d", id))
}
}
}
qd := sq.Delete("job").Where("job.start_time < ?", startTime) qd := sq.Delete("job").Where("job.start_time < ?", startTime)
_, err := qd.RunWith(r.DB).Exec() _, err := qd.RunWith(r.DB).Exec()
@@ -281,6 +362,10 @@ func (r *JobRepository) DeleteJobsBefore(startTime int64) (int, error) {
} }
func (r *JobRepository) DeleteJobById(id int64) error { func (r *JobRepository) DeleteJobById(id int64) error {
// Invalidate cache entries before deletion
r.cache.Del(fmt.Sprintf("metadata:%d", id))
r.cache.Del(fmt.Sprintf("energyFootprint:%d", id))
qd := sq.Delete("job").Where("job.id = ?", id) qd := sq.Delete("job").Where("job.id = ?", id)
_, err := qd.RunWith(r.DB).Exec() _, err := qd.RunWith(r.DB).Exec()
@@ -450,13 +535,14 @@ func (r *JobRepository) AllocatedNodes(cluster string) (map[string]map[string]in
// FIXME: Set duration to requested walltime? // FIXME: Set duration to requested walltime?
func (r *JobRepository) StopJobsExceedingWalltimeBy(seconds int) error { func (r *JobRepository) StopJobsExceedingWalltimeBy(seconds int) error {
start := time.Now() start := time.Now()
currentTime := time.Now().Unix()
res, err := sq.Update("job"). res, err := sq.Update("job").
Set("monitoring_status", schema.MonitoringStatusArchivingFailed). Set("monitoring_status", schema.MonitoringStatusArchivingFailed).
Set("duration", 0). Set("duration", 0).
Set("job_state", schema.JobStateFailed). Set("job_state", schema.JobStateFailed).
Where("job.job_state = 'running'"). Where("job.job_state = 'running'").
Where("job.walltime > 0"). Where("job.walltime > 0").
Where(fmt.Sprintf("(%d - job.start_time) > (job.walltime + %d)", time.Now().Unix(), seconds)). Where("(? - job.start_time) > (job.walltime + ?)", currentTime, seconds).
RunWith(r.DB).Exec() RunWith(r.DB).Exec()
if err != nil { if err != nil {
cclog.Warn("Error while stopping jobs exceeding walltime") cclog.Warn("Error while stopping jobs exceeding walltime")
@@ -505,21 +591,21 @@ func (r *JobRepository) FindJobIdsByTag(tagId int64) ([]int64, error) {
// FIXME: Reconsider filtering short jobs with harcoded threshold // FIXME: Reconsider filtering short jobs with harcoded threshold
func (r *JobRepository) FindRunningJobs(cluster string) ([]*schema.Job, error) { func (r *JobRepository) FindRunningJobs(cluster string) ([]*schema.Job, error) {
query := sq.Select(jobColumns...).From("job"). query := sq.Select(jobColumns...).From("job").
Where(fmt.Sprintf("job.cluster = '%s'", cluster)). Where("job.cluster = ?", cluster).
Where("job.job_state = 'running'"). Where("job.job_state = 'running'").
Where("job.duration > 600") Where("job.duration > ?", repoConfig.MinRunningJobDuration)
rows, err := query.RunWith(r.stmtCache).Query() rows, err := query.RunWith(r.stmtCache).Query()
if err != nil { if err != nil {
cclog.Error("Error while running query") cclog.Error("Error while running query")
return nil, err return nil, err
} }
defer rows.Close()
jobs := make([]*schema.Job, 0, 50) jobs := make([]*schema.Job, 0, 50)
for rows.Next() { for rows.Next() {
job, err := scanJob(rows) job, err := scanJob(rows)
if err != nil { if err != nil {
rows.Close()
cclog.Warn("Error while scanning rows") cclog.Warn("Error while scanning rows")
return nil, err return nil, err
} }
@@ -552,12 +638,10 @@ func (r *JobRepository) FindJobsBetween(startTimeBegin int64, startTimeEnd int64
if startTimeBegin == 0 { if startTimeBegin == 0 {
cclog.Infof("Find jobs before %d", startTimeEnd) cclog.Infof("Find jobs before %d", startTimeEnd)
query = sq.Select(jobColumns...).From("job").Where(fmt.Sprintf( query = sq.Select(jobColumns...).From("job").Where("job.start_time < ?", startTimeEnd)
"job.start_time < %d", startTimeEnd))
} else { } else {
cclog.Infof("Find jobs between %d and %d", startTimeBegin, startTimeEnd) cclog.Infof("Find jobs between %d and %d", startTimeBegin, startTimeEnd)
query = sq.Select(jobColumns...).From("job").Where(fmt.Sprintf( query = sq.Select(jobColumns...).From("job").Where("job.start_time BETWEEN ? AND ?", startTimeBegin, startTimeEnd)
"job.start_time BETWEEN %d AND %d", startTimeBegin, startTimeEnd))
} }
rows, err := query.RunWith(r.stmtCache).Query() rows, err := query.RunWith(r.stmtCache).Query()
@@ -565,12 +649,12 @@ func (r *JobRepository) FindJobsBetween(startTimeBegin int64, startTimeEnd int64
cclog.Error("Error while running query") cclog.Error("Error while running query")
return nil, err return nil, err
} }
defer rows.Close()
jobs := make([]*schema.Job, 0, 50) jobs := make([]*schema.Job, 0, 50)
for rows.Next() { for rows.Next() {
job, err := scanJob(rows) job, err := scanJob(rows)
if err != nil { if err != nil {
rows.Close()
cclog.Warn("Error while scanning rows") cclog.Warn("Error while scanning rows")
return nil, err return nil, err
} }
@@ -582,6 +666,10 @@ func (r *JobRepository) FindJobsBetween(startTimeBegin int64, startTimeEnd int64
} }
func (r *JobRepository) UpdateMonitoringStatus(job int64, monitoringStatus int32) (err error) { func (r *JobRepository) UpdateMonitoringStatus(job int64, monitoringStatus int32) (err error) {
// Invalidate cache entries as monitoring status affects job state
r.cache.Del(fmt.Sprintf("metadata:%d", job))
r.cache.Del(fmt.Sprintf("energyFootprint:%d", job))
stmt := sq.Update("job"). stmt := sq.Update("job").
Set("monitoring_status", monitoringStatus). Set("monitoring_status", monitoringStatus).
Where("job.id = ?", job) Where("job.id = ?", job)
+10 -3
View File
@@ -31,8 +31,9 @@ const NamedJobInsert string = `INSERT INTO job (
func (r *JobRepository) InsertJob(job *schema.Job) (int64, error) { func (r *JobRepository) InsertJob(job *schema.Job) (int64, error) {
r.Mutex.Lock() r.Mutex.Lock()
defer r.Mutex.Unlock()
res, err := r.DB.NamedExec(NamedJobCacheInsert, job) res, err := r.DB.NamedExec(NamedJobCacheInsert, job)
r.Mutex.Unlock()
if err != nil { if err != nil {
cclog.Warn("Error while NamedJobInsert") cclog.Warn("Error while NamedJobInsert")
return 0, err return 0, err
@@ -57,12 +58,12 @@ func (r *JobRepository) SyncJobs() ([]*schema.Job, error) {
cclog.Errorf("Error while running query %v", err) cclog.Errorf("Error while running query %v", err)
return nil, err return nil, err
} }
defer rows.Close()
jobs := make([]*schema.Job, 0, 50) jobs := make([]*schema.Job, 0, 50)
for rows.Next() { for rows.Next() {
job, err := scanJob(rows) job, err := scanJob(rows)
if err != nil { if err != nil {
rows.Close()
cclog.Warn("Error while scanning rows") cclog.Warn("Error while scanning rows")
return nil, err return nil, err
} }
@@ -113,6 +114,10 @@ func (r *JobRepository) Stop(
state schema.JobState, state schema.JobState,
monitoringStatus int32, monitoringStatus int32,
) (err error) { ) (err error) {
// Invalidate cache entries as job state is changing
r.cache.Del(fmt.Sprintf("metadata:%d", jobId))
r.cache.Del(fmt.Sprintf("energyFootprint:%d", jobId))
stmt := sq.Update("job"). stmt := sq.Update("job").
Set("job_state", state). Set("job_state", state).
Set("duration", duration). Set("duration", duration).
@@ -129,11 +134,13 @@ func (r *JobRepository) StopCached(
state schema.JobState, state schema.JobState,
monitoringStatus int32, monitoringStatus int32,
) (err error) { ) (err error) {
// Note: StopCached updates job_cache table, not the main job table
// Cache invalidation happens when job is synced to main table
stmt := sq.Update("job_cache"). stmt := sq.Update("job_cache").
Set("job_state", state). Set("job_state", state).
Set("duration", duration). Set("duration", duration).
Set("monitoring_status", monitoringStatus). Set("monitoring_status", monitoringStatus).
Where("job.id = ?", jobId) Where("job_cache.id = ?", jobId)
_, err = stmt.RunWith(r.stmtCache).Exec() _, err = stmt.RunWith(r.stmtCache).Exec()
return err return err
+13 -4
View File
@@ -89,6 +89,7 @@ func (r *JobRepository) FindAll(
cclog.Error("Error while running query") cclog.Error("Error while running query")
return nil, err return nil, err
} }
defer rows.Close()
jobs := make([]*schema.Job, 0, 10) jobs := make([]*schema.Job, 0, 10)
for rows.Next() { for rows.Next() {
@@ -103,25 +104,31 @@ func (r *JobRepository) FindAll(
return jobs, nil return jobs, nil
} }
// Get complete joblist only consisting of db ids. // GetJobList returns job IDs for non-running jobs.
// This is useful to process large job counts and intended to be used // This is useful to process large job counts and intended to be used
// together with FindById to process jobs one by one // together with FindById to process jobs one by one.
func (r *JobRepository) GetJobList() ([]int64, error) { // Use limit and offset for pagination. Use limit=0 to get all results (not recommended for large datasets).
func (r *JobRepository) GetJobList(limit int, offset int) ([]int64, error) {
query := sq.Select("id").From("job"). query := sq.Select("id").From("job").
Where("job.job_state != 'running'") Where("job.job_state != 'running'")
// Add pagination if limit is specified
if limit > 0 {
query = query.Limit(uint64(limit)).Offset(uint64(offset))
}
rows, err := query.RunWith(r.stmtCache).Query() rows, err := query.RunWith(r.stmtCache).Query()
if err != nil { if err != nil {
cclog.Error("Error while running query") cclog.Error("Error while running query")
return nil, err return nil, err
} }
defer rows.Close()
jl := make([]int64, 0, 1000) jl := make([]int64, 0, 1000)
for rows.Next() { for rows.Next() {
var id int64 var id int64
err := rows.Scan(&id) err := rows.Scan(&id)
if err != nil { if err != nil {
rows.Close()
cclog.Warn("Error while scanning rows") cclog.Warn("Error while scanning rows")
return nil, err return nil, err
} }
@@ -256,6 +263,7 @@ func (r *JobRepository) FindConcurrentJobs(
cclog.Errorf("Error while running query: %v", err) cclog.Errorf("Error while running query: %v", err)
return nil, err return nil, err
} }
defer rows.Close()
items := make([]*model.JobLink, 0, 10) items := make([]*model.JobLink, 0, 10)
queryString := fmt.Sprintf("cluster=%s", job.Cluster) queryString := fmt.Sprintf("cluster=%s", job.Cluster)
@@ -283,6 +291,7 @@ func (r *JobRepository) FindConcurrentJobs(
cclog.Errorf("Error while running query: %v", err) cclog.Errorf("Error while running query: %v", err)
return nil, err return nil, err
} }
defer rows.Close()
for rows.Next() { for rows.Next() {
var id, jobId, startTime sql.NullInt64 var id, jobId, startTime sql.NullInt64
@@ -14,7 +14,7 @@ CREATE TABLE "job_cache" (
job_state VARCHAR(255) NOT NULL job_state VARCHAR(255) NOT NULL
CHECK (job_state IN ( CHECK (job_state IN (
'boot_fail', 'cancelled', 'completed', 'deadline', 'boot_fail', 'cancelled', 'completed', 'deadline',
'failed', 'node_fail', 'out-of-memory', 'pending', 'failed', 'node_fail', 'out_of_memory', 'pending',
'preempted', 'running', 'suspended', 'timeout' 'preempted', 'running', 'suspended', 'timeout'
)), )),
meta_data TEXT, -- JSON meta_data TEXT, -- JSON
@@ -49,7 +49,7 @@ CREATE TABLE "job_new" (
job_state TEXT NOT NULL job_state TEXT NOT NULL
CHECK (job_state IN ( CHECK (job_state IN (
'boot_fail', 'cancelled', 'completed', 'deadline', 'boot_fail', 'cancelled', 'completed', 'deadline',
'failed', 'node_fail', 'out-of-memory', 'pending', 'failed', 'node_fail', 'out_of_memory', 'pending',
'preempted', 'running', 'suspended', 'timeout' 'preempted', 'running', 'suspended', 'timeout'
)), )),
meta_data TEXT, -- JSON meta_data TEXT, -- JSON
@@ -115,5 +115,117 @@ INSERT INTO job_new (
FROM job; FROM job;
DROP TABLE lookup_exclusive; DROP TABLE lookup_exclusive;
DROP TABLE job; DROP TABLE job; -- Deletes All Existing 'job' Indices; Recreate after Renaming
ALTER TABLE job_new RENAME TO job; ALTER TABLE job_new RENAME TO job;
-- Recreate Indices from 08_add-footprint, include new submit_time indices
-- Cluster Filter
CREATE INDEX IF NOT EXISTS jobs_cluster ON job (cluster);
CREATE INDEX IF NOT EXISTS jobs_cluster_user ON job (cluster, hpc_user);
CREATE INDEX IF NOT EXISTS jobs_cluster_project ON job (cluster, project);
CREATE INDEX IF NOT EXISTS jobs_cluster_subcluster ON job (cluster, subcluster);
-- Cluster Filter Sorting
CREATE INDEX IF NOT EXISTS jobs_cluster_starttime ON job (cluster, start_time);
CREATE INDEX IF NOT EXISTS jobs_cluster_submittime ON job (cluster, submit_time);
CREATE INDEX IF NOT EXISTS jobs_cluster_duration ON job (cluster, duration);
CREATE INDEX IF NOT EXISTS jobs_cluster_numnodes ON job (cluster, num_nodes);
CREATE INDEX IF NOT EXISTS jobs_cluster_numhwthreads ON job (cluster, num_hwthreads);
CREATE INDEX IF NOT EXISTS jobs_cluster_numacc ON job (cluster, num_acc);
CREATE INDEX IF NOT EXISTS jobs_cluster_energy ON job (cluster, energy);
-- Cluster+Partition Filter
CREATE INDEX IF NOT EXISTS jobs_cluster_partition ON job (cluster, cluster_partition);
-- Cluster+Partition Filter Sorting
CREATE INDEX IF NOT EXISTS jobs_cluster_partition_starttime ON job (cluster, cluster_partition, start_time);
CREATE INDEX IF NOT EXISTS jobs_cluster_partition_submittime ON job (cluster, cluster_partition, submit_time);
CREATE INDEX IF NOT EXISTS jobs_cluster_partition_duration ON job (cluster, cluster_partition, duration);
CREATE INDEX IF NOT EXISTS jobs_cluster_partition_numnodes ON job (cluster, cluster_partition, num_nodes);
CREATE INDEX IF NOT EXISTS jobs_cluster_partition_numhwthreads ON job (cluster, cluster_partition, num_hwthreads);
CREATE INDEX IF NOT EXISTS jobs_cluster_partition_numacc ON job (cluster, cluster_partition, num_acc);
CREATE INDEX IF NOT EXISTS jobs_cluster_partition_energy ON job (cluster, cluster_partition, energy);
-- Cluster+Partition+Jobstate Filter
CREATE INDEX IF NOT EXISTS jobs_cluster_partition_jobstate ON job (cluster, cluster_partition, job_state);
CREATE INDEX IF NOT EXISTS jobs_cluster_partition_jobstate_user ON job (cluster, cluster_partition, job_state, hpc_user);
CREATE INDEX IF NOT EXISTS jobs_cluster_partition_jobstate_project ON job (cluster, cluster_partition, job_state, project);
-- Cluster+Partition+Jobstate Filter Sorting
CREATE INDEX IF NOT EXISTS jobs_cluster_partition_jobstate_starttime ON job (cluster, cluster_partition, job_state, start_time);
CREATE INDEX IF NOT EXISTS jobs_cluster_partition_jobstate_submittime ON job (cluster, cluster_partition, job_state, submit_time);
CREATE INDEX IF NOT EXISTS jobs_cluster_partition_jobstate_duration ON job (cluster, cluster_partition, job_state, duration);
CREATE INDEX IF NOT EXISTS jobs_cluster_partition_jobstate_numnodes ON job (cluster, cluster_partition, job_state, num_nodes);
CREATE INDEX IF NOT EXISTS jobs_cluster_partition_jobstate_numhwthreads ON job (cluster, cluster_partition, job_state, num_hwthreads);
CREATE INDEX IF NOT EXISTS jobs_cluster_partition_jobstate_numacc ON job (cluster, cluster_partition, job_state, num_acc);
CREATE INDEX IF NOT EXISTS jobs_cluster_partition_jobstate_energy ON job (cluster, cluster_partition, job_state, energy);
-- Cluster+JobState Filter
CREATE INDEX IF NOT EXISTS jobs_cluster_jobstate ON job (cluster, job_state);
CREATE INDEX IF NOT EXISTS jobs_cluster_jobstate_user ON job (cluster, job_state, hpc_user);
CREATE INDEX IF NOT EXISTS jobs_cluster_jobstate_project ON job (cluster, job_state, project);
-- Cluster+JobState Filter Sorting
CREATE INDEX IF NOT EXISTS jobs_cluster_jobstate_starttime ON job (cluster, job_state, start_time);
CREATE INDEX IF NOT EXISTS jobs_cluster_jobstate_submittime ON job (cluster, job_state, submit_time);
CREATE INDEX IF NOT EXISTS jobs_cluster_jobstate_duration ON job (cluster, job_state, duration);
CREATE INDEX IF NOT EXISTS jobs_cluster_jobstate_numnodes ON job (cluster, job_state, num_nodes);
CREATE INDEX IF NOT EXISTS jobs_cluster_jobstate_numhwthreads ON job (cluster, job_state, num_hwthreads);
CREATE INDEX IF NOT EXISTS jobs_cluster_jobstate_numacc ON job (cluster, job_state, num_acc);
CREATE INDEX IF NOT EXISTS jobs_cluster_jobstate_energy ON job (cluster, job_state, energy);
-- User Filter
CREATE INDEX IF NOT EXISTS jobs_user ON job (hpc_user);
-- User Filter Sorting
CREATE INDEX IF NOT EXISTS jobs_user_starttime ON job (hpc_user, start_time);
CREATE INDEX IF NOT EXISTS jobs_user_duration ON job (hpc_user, duration);
CREATE INDEX IF NOT EXISTS jobs_user_numnodes ON job (hpc_user, num_nodes);
CREATE INDEX IF NOT EXISTS jobs_user_numhwthreads ON job (hpc_user, num_hwthreads);
CREATE INDEX IF NOT EXISTS jobs_user_numacc ON job (hpc_user, num_acc);
CREATE INDEX IF NOT EXISTS jobs_user_energy ON job (hpc_user, energy);
-- Project Filter
CREATE INDEX IF NOT EXISTS jobs_project ON job (project);
CREATE INDEX IF NOT EXISTS jobs_project_user ON job (project, hpc_user);
-- Project Filter Sorting
CREATE INDEX IF NOT EXISTS jobs_project_starttime ON job (project, start_time);
CREATE INDEX IF NOT EXISTS jobs_project_duration ON job (project, duration);
CREATE INDEX IF NOT EXISTS jobs_project_numnodes ON job (project, num_nodes);
CREATE INDEX IF NOT EXISTS jobs_project_numhwthreads ON job (project, num_hwthreads);
CREATE INDEX IF NOT EXISTS jobs_project_numacc ON job (project, num_acc);
CREATE INDEX IF NOT EXISTS jobs_project_energy ON job (project, energy);
-- JobState Filter
CREATE INDEX IF NOT EXISTS jobs_jobstate ON job (job_state);
CREATE INDEX IF NOT EXISTS jobs_jobstate_user ON job (job_state, hpc_user);
CREATE INDEX IF NOT EXISTS jobs_jobstate_project ON job (job_state, project);
CREATE INDEX IF NOT EXISTS jobs_jobstate_cluster ON job (job_state, cluster);
-- JobState Filter Sorting
CREATE INDEX IF NOT EXISTS jobs_jobstate_starttime ON job (job_state, start_time);
CREATE INDEX IF NOT EXISTS jobs_jobstate_duration ON job (job_state, duration);
CREATE INDEX IF NOT EXISTS jobs_jobstate_numnodes ON job (job_state, num_nodes);
CREATE INDEX IF NOT EXISTS jobs_jobstate_numhwthreads ON job (job_state, num_hwthreads);
CREATE INDEX IF NOT EXISTS jobs_jobstate_numacc ON job (job_state, num_acc);
CREATE INDEX IF NOT EXISTS jobs_jobstate_energy ON job (job_state, energy);
-- ArrayJob Filter
CREATE INDEX IF NOT EXISTS jobs_arrayjobid_starttime ON job (array_job_id, start_time);
CREATE INDEX IF NOT EXISTS jobs_cluster_arrayjobid_starttime ON job (cluster, array_job_id, start_time);
-- Sorting without active filters
CREATE INDEX IF NOT EXISTS jobs_starttime ON job (start_time);
CREATE INDEX IF NOT EXISTS jobs_duration ON job (duration);
CREATE INDEX IF NOT EXISTS jobs_numnodes ON job (num_nodes);
CREATE INDEX IF NOT EXISTS jobs_numhwthreads ON job (num_hwthreads);
CREATE INDEX IF NOT EXISTS jobs_numacc ON job (num_acc);
CREATE INDEX IF NOT EXISTS jobs_energy ON job (energy);
-- Single filters with default starttime sorting
CREATE INDEX IF NOT EXISTS jobs_duration_starttime ON job (duration, start_time);
CREATE INDEX IF NOT EXISTS jobs_numnodes_starttime ON job (num_nodes, start_time);
CREATE INDEX IF NOT EXISTS jobs_numhwthreads_starttime ON job (num_hwthreads, start_time);
CREATE INDEX IF NOT EXISTS jobs_numacc_starttime ON job (num_acc, start_time);
CREATE INDEX IF NOT EXISTS jobs_energy_starttime ON job (energy, start_time);
-- Optimize DB index usage
PRAGMA optimize;
-- Optimize DB size: https://sqlite.org/lang_vacuum.html
-- Not allowed within a migration transaction; Keep command here for documentation and recommendation
-- Command: 'VACUUM;'
@@ -27,24 +27,22 @@ CREATE TABLE "node_state" (
FOREIGN KEY (node_id) REFERENCES node (id) FOREIGN KEY (node_id) REFERENCES node (id)
); );
-- Add NEW Indices For New Job Table Columns
CREATE INDEX IF NOT EXISTS jobs_cluster_submittime ON job (cluster, submit_time);
CREATE INDEX IF NOT EXISTS jobs_cluster_partition_submittime ON job (cluster, cluster_partition, submit_time);
CREATE INDEX IF NOT EXISTS jobs_cluster_partition_jobstate_submittime ON job (
cluster, cluster_partition, job_state, submit_time
);
CREATE INDEX IF NOT EXISTS jobs_cluster_jobstate_submittime ON job (cluster, job_state, submit_time);
-- Add NEW Indices For New Node Table VARCHAR Fields -- Add NEW Indices For New Node Table VARCHAR Fields
CREATE INDEX IF NOT EXISTS nodes_cluster ON node (cluster); CREATE INDEX IF NOT EXISTS nodes_cluster ON node (cluster);
CREATE INDEX IF NOT EXISTS nodes_cluster_subcluster ON node (cluster, subcluster); CREATE INDEX IF NOT EXISTS nodes_cluster_subcluster ON node (cluster, subcluster);
-- Add NEW Indices For New Node_State Table Fields -- Add NEW Indices For New Node_State Table Fields
CREATE INDEX IF NOT EXISTS nodestates_timestamp ON node_state (time_stamp);
CREATE INDEX IF NOT EXISTS nodestates_state ON node_state (node_state); CREATE INDEX IF NOT EXISTS nodestates_state ON node_state (node_state);
CREATE INDEX IF NOT EXISTS nodestates_health ON node_state (health_state); CREATE INDEX IF NOT EXISTS nodestates_health ON node_state (health_state);
CREATE INDEX IF NOT EXISTS nodestates_state_timestamp ON node_state (node_state, time_stamp);
CREATE INDEX IF NOT EXISTS nodestates_health_timestamp ON node_state (health_state, time_stamp);
CREATE INDEX IF NOT EXISTS nodestates_nodeid_state ON node_state (node_id, node_state); CREATE INDEX IF NOT EXISTS nodestates_nodeid_state ON node_state (node_id, node_state);
CREATE INDEX IF NOT EXISTS nodestates_nodeid_health ON node_state (node_id, health_state); CREATE INDEX IF NOT EXISTS nodestates_nodeid_health ON node_state (node_id, health_state);
-- Add NEW Indices For Increased Amounts of Tags -- Add NEW Indices For Increased Amounts of Tags
CREATE INDEX IF NOT EXISTS tags_jobid ON jobtag (job_id); CREATE INDEX IF NOT EXISTS tags_jobid ON jobtag (job_id);
CREATE INDEX IF NOT EXISTS tags_tagid ON jobtag (tag_id); CREATE INDEX IF NOT EXISTS tags_tagid ON jobtag (tag_id);
-- Optimize DB index usage
PRAGMA optimize;
+161 -83
View File
@@ -43,7 +43,7 @@ func GetNodeRepository() *NodeRepository {
driver: db.Driver, driver: db.Driver,
stmtCache: sq.NewStmtCache(db.DB), stmtCache: sq.NewStmtCache(db.DB),
cache: lrucache.New(1024 * 1024), cache: lrucache.New(repoConfig.CacheSize),
} }
}) })
return nodeRepoInstance return nodeRepoInstance
@@ -77,55 +77,19 @@ func (r *NodeRepository) FetchMetadata(hostname string, cluster string) (map[str
return MetaData, nil return MetaData, nil
} }
//
// func (r *NodeRepository) UpdateMetadata(node *schema.Node, key, val string) (err error) {
// cachekey := fmt.Sprintf("metadata:%d", node.ID)
// r.cache.Del(cachekey)
// if node.MetaData == nil {
// if _, err = r.FetchMetadata(node); err != nil {
// cclog.Warnf("Error while fetching metadata for node, DB ID '%v'", node.ID)
// return err
// }
// }
//
// if node.MetaData != nil {
// cpy := make(map[string]string, len(node.MetaData)+1)
// maps.Copy(cpy, node.MetaData)
// cpy[key] = val
// node.MetaData = cpy
// } else {
// node.MetaData = map[string]string{key: val}
// }
//
// if node.RawMetaData, err = json.Marshal(node.MetaData); err != nil {
// cclog.Warnf("Error while marshaling metadata for node, DB ID '%v'", node.ID)
// return err
// }
//
// if _, err = sq.Update("node").
// Set("meta_data", node.RawMetaData).
// Where("node.id = ?", node.ID).
// RunWith(r.stmtCache).Exec(); err != nil {
// cclog.Warnf("Error while updating metadata for node, DB ID '%v'", node.ID)
// return err
// }
//
// r.cache.Put(cachekey, node.MetaData, len(node.RawMetaData), 24*time.Hour)
// return nil
// }
func (r *NodeRepository) GetNode(hostname string, cluster string, withMeta bool) (*schema.Node, error) { func (r *NodeRepository) GetNode(hostname string, cluster string, withMeta bool) (*schema.Node, error) {
node := &schema.Node{} node := &schema.Node{}
var timestamp int
if err := sq.Select("node.hostname", "node.cluster", "node.subcluster", "node_state.node_state", if err := sq.Select("node.hostname", "node.cluster", "node.subcluster", "node_state.node_state",
"node_state.health_state", "MAX(node_state.time_stamp) as time"). "node_state.health_state", "MAX(node_state.time_stamp) as time").
From("node_state"). From("node_state").
Join("node ON nodes_state.node_id = node.id"). Join("node ON node_state.node_id = node.id").
Where("node.hostname = ?", hostname). Where("node.hostname = ?", hostname).
Where("node.cluster = ?", cluster). Where("node.cluster = ?", cluster).
GroupBy("node_state.node_id"). GroupBy("node_state.node_id").
RunWith(r.DB). RunWith(r.DB).
QueryRow().Scan(&node.Hostname, &node.Cluster, &node.SubCluster, &node.NodeState, &node.HealthState); err != nil { QueryRow().Scan(&node.Hostname, &node.Cluster, &node.SubCluster, &node.NodeState, &node.HealthState, &timestamp); err != nil {
cclog.Warnf("Error while querying node '%s' from database: %v", hostname, err) cclog.Warnf("Error while querying node '%s' at time '%d' from database: %v", hostname, timestamp, err)
return nil, err return nil, err
} }
@@ -144,15 +108,16 @@ func (r *NodeRepository) GetNode(hostname string, cluster string, withMeta bool)
func (r *NodeRepository) GetNodeById(id int64, withMeta bool) (*schema.Node, error) { func (r *NodeRepository) GetNodeById(id int64, withMeta bool) (*schema.Node, error) {
node := &schema.Node{} node := &schema.Node{}
var timestamp int
if err := sq.Select("node.hostname", "node.cluster", "node.subcluster", "node_state.node_state", if err := sq.Select("node.hostname", "node.cluster", "node.subcluster", "node_state.node_state",
"node_state.health_state", "MAX(node_state.time_stamp) as time"). "node_state.health_state", "MAX(node_state.time_stamp) as time").
From("node_state"). From("node_state").
Join("node ON nodes_state.node_id = node.id"). Join("node ON node_state.node_id = node.id").
Where("node.id = ?", id). Where("node.id = ?", id).
GroupBy("node_state.node_id"). GroupBy("node_state.node_id").
RunWith(r.DB). RunWith(r.DB).
QueryRow().Scan(&node.Hostname, &node.Cluster, &node.SubCluster, &node.NodeState, &node.HealthState); err != nil { QueryRow().Scan(&node.Hostname, &node.Cluster, &node.SubCluster, &node.NodeState, &node.HealthState, &timestamp); err != nil {
cclog.Warnf("Error while querying node ID '%d' from database: %v", id, err) cclog.Warnf("Error while querying node ID '%d' at time '%d' from database: %v", id, timestamp, err)
return nil, err return nil, err
} }
@@ -272,37 +237,48 @@ func (r *NodeRepository) DeleteNode(id int64) error {
func (r *NodeRepository) QueryNodes( func (r *NodeRepository) QueryNodes(
ctx context.Context, ctx context.Context,
filters []*model.NodeFilter, filters []*model.NodeFilter,
page *model.PageRequest,
order *model.OrderByInput, // Currently unused! order *model.OrderByInput, // Currently unused!
) ([]*schema.Node, error) { ) ([]*schema.Node, error) {
query, qerr := AccessCheck(ctx, query, qerr := AccessCheck(ctx,
sq.Select("node.hostname", "node.cluster", "node.subcluster", "node_state.node_state", sq.Select("hostname", "cluster", "subcluster", "node_state", "health_state", "MAX(time_stamp) as time").
"node_state.health_state", "MAX(node_state.time_stamp) as time").
From("node"). From("node").
Join("node_state ON nodes_state.node_id = node.id")) Join("node_state ON node_state.node_id = node.id"))
if qerr != nil { if qerr != nil {
return nil, qerr return nil, qerr
} }
for _, f := range filters { for _, f := range filters {
if f.Hostname != nil {
query = buildStringCondition("node.hostname", f.Hostname, query)
}
if f.Cluster != nil { if f.Cluster != nil {
query = buildStringCondition("node.cluster", f.Cluster, query) query = buildStringCondition("cluster", f.Cluster, query)
} }
if f.Subcluster != nil { if f.Subcluster != nil {
query = buildStringCondition("node.subcluster", f.Subcluster, query) query = buildStringCondition("subcluster", f.Subcluster, query)
}
if f.Hostname != nil {
query = buildStringCondition("hostname", f.Hostname, query)
} }
if f.SchedulerState != nil { if f.SchedulerState != nil {
query = query.Where("node.node_state = ?", f.SchedulerState) query = query.Where("node_state = ?", f.SchedulerState)
// Requires Additional time_stamp Filter: Else the last (past!) time_stamp with queried state will be returned
now := time.Now().Unix()
query = query.Where(sq.Gt{"time_stamp": (now - 60)})
} }
if f.HealthState != nil { if f.HealthState != nil {
query = query.Where("node.health_state = ?", f.HealthState) query = query.Where("health_state = ?", f.HealthState)
// Requires Additional time_stamp Filter: Else the last (past!) time_stamp with queried state will be returned
now := time.Now().Unix()
query = query.Where(sq.Gt{"time_stamp": (now - 60)})
} }
} }
// Add Grouping after filters query = query.GroupBy("node_id").OrderBy("hostname ASC")
query = query.GroupBy("node_state.node_id")
if page != nil && page.ItemsPerPage != -1 {
limit := uint64(page.ItemsPerPage)
query = query.Offset((uint64(page.Page) - 1) * limit).Limit(limit)
}
rows, err := query.RunWith(r.stmtCache).Query() rows, err := query.RunWith(r.stmtCache).Query()
if err != nil { if err != nil {
@@ -311,14 +287,14 @@ func (r *NodeRepository) QueryNodes(
return nil, err return nil, err
} }
nodes := make([]*schema.Node, 0, 50) nodes := make([]*schema.Node, 0)
for rows.Next() { for rows.Next() {
node := schema.Node{} node := schema.Node{}
var timestamp int
if err := rows.Scan(&node.Hostname, &node.Cluster, &node.SubCluster, if err := rows.Scan(&node.Hostname, &node.Cluster, &node.SubCluster,
&node.NodeState, &node.HealthState); err != nil { &node.NodeState, &node.HealthState, &timestamp); err != nil {
rows.Close() rows.Close()
cclog.Warn("Error while scanning rows (Nodes)") cclog.Warnf("Error while scanning rows (QueryNodes) at time '%d'", timestamp)
return nil, err return nil, err
} }
nodes = append(nodes, &node) nodes = append(nodes, &node)
@@ -327,6 +303,67 @@ func (r *NodeRepository) QueryNodes(
return nodes, nil return nodes, nil
} }
// CountNodes returns the total matched nodes based on a node filter. It always operates
// on the last state (largest timestamp).
func (r *NodeRepository) CountNodes(
ctx context.Context,
filters []*model.NodeFilter,
) (int, error) {
query, qerr := AccessCheck(ctx,
sq.Select("time_stamp", "count(*) as countRes").
From("node").
Join("node_state ON node_state.node_id = node.id"))
if qerr != nil {
return 0, qerr
}
for _, f := range filters {
if f.Cluster != nil {
query = buildStringCondition("cluster", f.Cluster, query)
}
if f.Subcluster != nil {
query = buildStringCondition("subcluster", f.Subcluster, query)
}
if f.Hostname != nil {
query = buildStringCondition("hostname", f.Hostname, query)
}
if f.SchedulerState != nil {
query = query.Where("node_state = ?", f.SchedulerState)
// Requires Additional time_stamp Filter: Else the last (past!) time_stamp with queried state will be returned
now := time.Now().Unix()
query = query.Where(sq.Gt{"time_stamp": (now - 60)})
}
if f.HealthState != nil {
query = query.Where("health_state = ?", f.HealthState)
// Requires Additional time_stamp Filter: Else the last (past!) time_stamp with queried state will be returned
now := time.Now().Unix()
query = query.Where(sq.Gt{"time_stamp": (now - 60)})
}
}
query = query.GroupBy("time_stamp").OrderBy("time_stamp DESC").Limit(1)
rows, err := query.RunWith(r.stmtCache).Query()
if err != nil {
queryString, queryVars, _ := query.ToSql()
cclog.Errorf("Error while running query '%s' %v: %v", queryString, queryVars, err)
return 0, err
}
var totalNodes int
for rows.Next() {
var timestamp int
if err := rows.Scan(&timestamp, &totalNodes); err != nil {
rows.Close()
cclog.Warnf("Error while scanning rows (CountNodes) at time '%d'", timestamp)
return 0, err
}
}
return totalNodes, nil
}
func (r *NodeRepository) ListNodes(cluster string) ([]*schema.Node, error) { func (r *NodeRepository) ListNodes(cluster string) ([]*schema.Node, error) {
q := sq.Select("node.hostname", "node.cluster", "node.subcluster", "node_state.node_state", q := sq.Select("node.hostname", "node.cluster", "node.subcluster", "node_state.node_state",
"node_state.health_state", "MAX(node_state.time_stamp) as time"). "node_state.health_state", "MAX(node_state.time_stamp) as time").
@@ -345,9 +382,10 @@ func (r *NodeRepository) ListNodes(cluster string) ([]*schema.Node, error) {
defer rows.Close() defer rows.Close()
for rows.Next() { for rows.Next() {
node := &schema.Node{} node := &schema.Node{}
var timestamp int
if err := rows.Scan(&node.Hostname, &node.Cluster, if err := rows.Scan(&node.Hostname, &node.Cluster,
&node.SubCluster, &node.NodeState, &node.HealthState); err != nil { &node.SubCluster, &node.NodeState, &node.HealthState, &timestamp); err != nil {
cclog.Warn("Error while scanning node list") cclog.Warnf("Error while scanning node list (ListNodes) at time '%d'", timestamp)
return nil, err return nil, err
} }
@@ -357,8 +395,38 @@ func (r *NodeRepository) ListNodes(cluster string) ([]*schema.Node, error) {
return nodeList, nil return nodeList, nil
} }
func (r *NodeRepository) CountNodeStates(ctx context.Context, filters []*model.NodeFilter) ([]*model.NodeStates, error) { func (r *NodeRepository) MapNodes(cluster string) (map[string]string, error) {
query, qerr := AccessCheck(ctx, sq.Select("hostname", "node_state", "MAX(time_stamp) as time").From("node")) q := sq.Select("node.hostname", "node_state.node_state", "MAX(node_state.time_stamp) as time").
From("node").
Join("node_state ON node_state.node_id = node.id").
Where("node.cluster = ?", cluster).
GroupBy("node_state.node_id").
OrderBy("node.hostname ASC")
rows, err := q.RunWith(r.DB).Query()
if err != nil {
cclog.Warn("Error while querying node list")
return nil, err
}
stateMap := make(map[string]string)
defer rows.Close()
for rows.Next() {
var hostname, nodestate string
var timestamp int
if err := rows.Scan(&hostname, &nodestate, &timestamp); err != nil {
cclog.Warnf("Error while scanning node list (MapNodes) at time '%d'", timestamp)
return nil, err
}
stateMap[hostname] = nodestate
}
return stateMap, nil
}
func (r *NodeRepository) CountStates(ctx context.Context, filters []*model.NodeFilter, column string) ([]*model.NodeStates, error) {
query, qerr := AccessCheck(ctx, sq.Select("hostname", column, "MAX(time_stamp) as time").From("node"))
if qerr != nil { if qerr != nil {
return nil, qerr return nil, qerr
} }
@@ -395,16 +463,16 @@ func (r *NodeRepository) CountNodeStates(ctx context.Context, filters []*model.N
stateMap := map[string]int{} stateMap := map[string]int{}
for rows.Next() { for rows.Next() {
var hostname, node_state string var hostname, state string
var timestamp int64 var timestamp int
if err := rows.Scan(&hostname, &node_state, &timestamp); err != nil { if err := rows.Scan(&hostname, &state, &timestamp); err != nil {
rows.Close() rows.Close()
cclog.Warn("Error while scanning rows (NodeStates)") cclog.Warnf("Error while scanning rows (CountStates) at time '%d'", timestamp)
return nil, err return nil, err
} }
stateMap[node_state] += 1 stateMap[state] += 1
} }
nodes := make([]*model.NodeStates, 0) nodes := make([]*model.NodeStates, 0)
@@ -416,8 +484,8 @@ func (r *NodeRepository) CountNodeStates(ctx context.Context, filters []*model.N
return nodes, nil return nodes, nil
} }
func (r *NodeRepository) CountHealthStates(ctx context.Context, filters []*model.NodeFilter) ([]*model.NodeStates, error) { func (r *NodeRepository) CountStatesTimed(ctx context.Context, filters []*model.NodeFilter, column string) ([]*model.NodeStatesTimed, error) {
query, qerr := AccessCheck(ctx, sq.Select("hostname", "health_state", "MAX(time_stamp) as time").From("node")) query, qerr := AccessCheck(ctx, sq.Select(column, "time_stamp", "count(*) as count").From("node")) // "cluster"?
if qerr != nil { if qerr != nil {
return nil, qerr return nil, qerr
} }
@@ -425,6 +493,11 @@ func (r *NodeRepository) CountHealthStates(ctx context.Context, filters []*model
query = query.Join("node_state ON node_state.node_id = node.id") query = query.Join("node_state ON node_state.node_id = node.id")
for _, f := range filters { for _, f := range filters {
// Required
if f.TimeStart != nil {
query = query.Where("time_stamp > ?", f.TimeStart)
}
// Optional
if f.Hostname != nil { if f.Hostname != nil {
query = buildStringCondition("hostname", f.Hostname, query) query = buildStringCondition("hostname", f.Hostname, query)
} }
@@ -443,7 +516,7 @@ func (r *NodeRepository) CountHealthStates(ctx context.Context, filters []*model
} }
// Add Group and Order // Add Group and Order
query = query.GroupBy("hostname").OrderBy("hostname DESC") query = query.GroupBy(column + ", time_stamp").OrderBy("time_stamp ASC")
rows, err := query.RunWith(r.stmtCache).Query() rows, err := query.RunWith(r.stmtCache).Query()
if err != nil { if err != nil {
@@ -452,27 +525,32 @@ func (r *NodeRepository) CountHealthStates(ctx context.Context, filters []*model
return nil, err return nil, err
} }
stateMap := map[string]int{} rawData := make(map[string][][]int)
for rows.Next() { for rows.Next() {
var hostname, health_state string var state string
var timestamp int64 var timestamp, count int
if err := rows.Scan(&hostname, &health_state, &timestamp); err != nil { if err := rows.Scan(&state, &timestamp, &count); err != nil {
rows.Close() rows.Close()
cclog.Warn("Error while scanning rows (NodeStates)") cclog.Warnf("Error while scanning rows (CountStatesTimed) at time '%d'", timestamp)
return nil, err return nil, err
} }
stateMap[health_state] += 1 if rawData[state] == nil {
rawData[state] = [][]int{make([]int, 0), make([]int, 0)}
} }
nodes := make([]*model.NodeStates, 0) rawData[state][0] = append(rawData[state][0], timestamp)
for state, counts := range stateMap { rawData[state][1] = append(rawData[state][1], count)
node := model.NodeStates{State: state, Count: counts}
nodes = append(nodes, &node)
} }
return nodes, nil timedStates := make([]*model.NodeStatesTimed, 0)
for state, data := range rawData {
entry := model.NodeStatesTimed{State: state, Times: data[0], Counts: data[1]}
timedStates = append(timedStates, &entry)
}
return timedStates, nil
} }
func AccessCheck(ctx context.Context, query sq.SelectBuilder) (sq.SelectBuilder, error) { func AccessCheck(ctx context.Context, query sq.SelectBuilder) (sq.SelectBuilder, error) {
+1 -1
View File
@@ -115,7 +115,7 @@ func nodeTestSetup(t *testing.T) {
} }
if err := os.WriteFile(filepath.Join(jobarchive, "version.txt"), if err := os.WriteFile(filepath.Join(jobarchive, "version.txt"),
fmt.Appendf(nil, "%d", 2), 0o666); err != nil { fmt.Appendf(nil, "%d", 3), 0o666); err != nil {
t.Fatal(err) t.Fatal(err)
} }
-10
View File
@@ -114,16 +114,6 @@ func (r *JobRepository) buildStatsQuery(
return query return query
} }
// func (r *JobRepository) getUserName(ctx context.Context, id string) string {
// user := GetUserFromContext(ctx)
// name, _ := r.FindColumnValue(user, id, "hpc_user", "name", "username", false)
// if name != "" {
// return name
// } else {
// return "-"
// }
// }
func (r *JobRepository) getCastType() string { func (r *JobRepository) getCastType() string {
var castType string var castType string
+57 -51
View File
@@ -5,6 +5,7 @@
package repository package repository
import ( import (
"errors"
"fmt" "fmt"
"strings" "strings"
@@ -14,65 +15,32 @@ import (
sq "github.com/Masterminds/squirrel" sq "github.com/Masterminds/squirrel"
) )
// Add the tag with id `tagId` to the job with the database id `jobId`. // AddTag adds the tag with id `tagId` to the job with the database id `jobId`.
// Requires user authentication for security checks.
func (r *JobRepository) AddTag(user *schema.User, job int64, tag int64) ([]*schema.Tag, error) { func (r *JobRepository) AddTag(user *schema.User, job int64, tag int64) ([]*schema.Tag, error) {
j, err := r.FindByIdWithUser(user, job) j, err := r.FindByIdWithUser(user, job)
if err != nil { if err != nil {
cclog.Warn("Error while finding job by id") cclog.Warnf("Error finding job %d for user %s: %v", job, user.Username, err)
return nil, err return nil, err
} }
q := sq.Insert("jobtag").Columns("job_id", "tag_id").Values(job, tag) return r.addJobTag(job, tag, j, func() ([]*schema.Tag, error) {
return r.GetTags(user, &job)
if _, err := q.RunWith(r.stmtCache).Exec(); err != nil { })
s, _, _ := q.ToSql()
cclog.Errorf("Error adding tag with %s: %v", s, err)
return nil, err
}
tags, err := r.GetTags(user, &job)
if err != nil {
cclog.Warn("Error while getting tags for job")
return nil, err
}
archiveTags, err := r.getArchiveTags(&job)
if err != nil {
cclog.Warn("Error while getting tags for job")
return nil, err
}
return tags, archive.UpdateTags(j, archiveTags)
} }
// AddTagDirect adds a tag without user security checks.
// Use only for internal/admin operations.
func (r *JobRepository) AddTagDirect(job int64, tag int64) ([]*schema.Tag, error) { func (r *JobRepository) AddTagDirect(job int64, tag int64) ([]*schema.Tag, error) {
j, err := r.FindByIdDirect(job) j, err := r.FindByIdDirect(job)
if err != nil { if err != nil {
cclog.Warn("Error while finding job by id") cclog.Warnf("Error finding job %d: %v", job, err)
return nil, err return nil, err
} }
q := sq.Insert("jobtag").Columns("job_id", "tag_id").Values(job, tag) return r.addJobTag(job, tag, j, func() ([]*schema.Tag, error) {
return r.GetTagsDirect(&job)
if _, err := q.RunWith(r.stmtCache).Exec(); err != nil { })
s, _, _ := q.ToSql()
cclog.Errorf("Error adding tag with %s: %v", s, err)
return nil, err
}
tags, err := r.GetTagsDirect(&job)
if err != nil {
cclog.Warn("Error while getting tags for job")
return nil, err
}
archiveTags, err := r.getArchiveTags(&job)
if err != nil {
cclog.Warn("Error while getting tags for job")
return nil, err
}
return tags, archive.UpdateTags(j, archiveTags)
} }
// Removes a tag from a job by tag id. // Removes a tag from a job by tag id.
@@ -260,15 +228,18 @@ func (r *JobRepository) CountTags(user *schema.User) (tags []schema.Tag, counts
LeftJoin("jobtag jt ON t.id = jt.tag_id"). LeftJoin("jobtag jt ON t.id = jt.tag_id").
GroupBy("t.tag_name") GroupBy("t.tag_name")
// Handle Scope Filtering // Build scope list for filtering
scopeList := "\"global\"" var scopeBuilder strings.Builder
scopeBuilder.WriteString(`"global"`)
if user != nil { if user != nil {
scopeList += ",\"" + user.Username + "\"" scopeBuilder.WriteString(`,"`)
} scopeBuilder.WriteString(user.Username)
scopeBuilder.WriteString(`"`)
if user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport}) { if user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport}) {
scopeList += ",\"admin\"" scopeBuilder.WriteString(`,"admin"`)
} }
q = q.Where("t.tag_scope IN (" + scopeList + ")") }
q = q.Where("t.tag_scope IN (" + scopeBuilder.String() + ")")
// Handle Job Ownership // Handle Job Ownership
if user != nil && user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport}) { // ADMIN || SUPPORT: Count all jobs if user != nil && user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport}) { // ADMIN || SUPPORT: Count all jobs
@@ -302,6 +273,41 @@ func (r *JobRepository) CountTags(user *schema.User) (tags []schema.Tag, counts
return tags, counts, err return tags, counts, err
} }
var (
ErrTagNotFound = errors.New("the tag does not exist")
ErrJobNotOwned = errors.New("user is not owner of job")
ErrTagNoAccess = errors.New("user not permitted to use that tag")
ErrTagPrivateScope = errors.New("tag is private to another user")
ErrTagAdminScope = errors.New("tag requires admin privileges")
ErrTagsIncompatScopes = errors.New("combining admin and non-admin scoped tags not allowed")
)
// addJobTag is a helper function that inserts a job-tag association and updates the archive.
// Returns the updated tag list for the job.
func (r *JobRepository) addJobTag(jobId int64, tagId int64, job *schema.Job, getTags func() ([]*schema.Tag, error)) ([]*schema.Tag, error) {
q := sq.Insert("jobtag").Columns("job_id", "tag_id").Values(jobId, tagId)
if _, err := q.RunWith(r.stmtCache).Exec(); err != nil {
s, _, _ := q.ToSql()
cclog.Errorf("Error adding tag with %s: %v", s, err)
return nil, err
}
tags, err := getTags()
if err != nil {
cclog.Warnf("Error getting tags for job %d: %v", jobId, err)
return nil, err
}
archiveTags, err := r.getArchiveTags(&jobId)
if err != nil {
cclog.Warnf("Error getting archive tags for job %d: %v", jobId, err)
return nil, err
}
return tags, archive.UpdateTags(job, archiveTags)
}
// AddTagOrCreate adds the tag with the specified type and name to the job with the database id `jobId`. // AddTagOrCreate adds the tag with the specified type and name to the job with the database id `jobId`.
// If such a tag does not yet exist, it is created. // If such a tag does not yet exist, it is created.
func (r *JobRepository) AddTagOrCreate(user *schema.User, jobId int64, tagType string, tagName string, tagScope string) (tagId int64, err error) { func (r *JobRepository) AddTagOrCreate(user *schema.User, jobId int64, tagType string, tagName string, tagScope string) (tagId int64, err error) {
+46 -34
View File
@@ -5,84 +5,96 @@
package repository package repository
import ( import (
cclog "github.com/ClusterCockpit/cc-lib/ccLogger" "fmt"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
) )
// Transaction wraps a database transaction for job-related operations.
type Transaction struct { type Transaction struct {
tx *sqlx.Tx tx *sqlx.Tx
stmt *sqlx.NamedStmt
} }
// TransactionInit begins a new transaction.
func (r *JobRepository) TransactionInit() (*Transaction, error) { func (r *JobRepository) TransactionInit() (*Transaction, error) {
var err error tx, err := r.DB.Beginx()
t := new(Transaction)
t.tx, err = r.DB.Beginx()
if err != nil { if err != nil {
cclog.Warn("Error while bundling transactions") return nil, fmt.Errorf("beginning transaction: %w", err)
return nil, err
} }
return t, nil return &Transaction{tx: tx}, nil
} }
func (r *JobRepository) TransactionCommit(t *Transaction) error { // Commit commits the transaction.
var err error // After calling Commit, the transaction should not be used again.
if t.tx != nil { func (t *Transaction) Commit() error {
if err = t.tx.Commit(); err != nil { if t.tx == nil {
cclog.Warn("Error while committing transactions") return fmt.Errorf("transaction already committed or rolled back")
return err
} }
} err := t.tx.Commit()
t.tx = nil // Mark as completed
t.tx, err = r.DB.Beginx()
if err != nil { if err != nil {
cclog.Warn("Error while bundling transactions") return fmt.Errorf("committing transaction: %w", err)
return err
} }
return nil return nil
} }
// Rollback rolls back the transaction.
// It's safe to call Rollback on an already committed or rolled back transaction.
func (t *Transaction) Rollback() error {
if t.tx == nil {
return nil // Already committed/rolled back
}
err := t.tx.Rollback()
t.tx = nil // Mark as completed
if err != nil {
return fmt.Errorf("rolling back transaction: %w", err)
}
return nil
}
// TransactionEnd commits the transaction.
// Deprecated: Use Commit() instead.
func (r *JobRepository) TransactionEnd(t *Transaction) error { func (r *JobRepository) TransactionEnd(t *Transaction) error {
if err := t.tx.Commit(); err != nil { return t.Commit()
cclog.Warn("Error while committing SQL transactions")
return err
}
return nil
} }
// TransactionAddNamed executes a named query within the transaction.
func (r *JobRepository) TransactionAddNamed( func (r *JobRepository) TransactionAddNamed(
t *Transaction, t *Transaction,
query string, query string,
args ...interface{}, args ...interface{},
) (int64, error) { ) (int64, error) {
if t.tx == nil {
return 0, fmt.Errorf("transaction is nil or already completed")
}
res, err := t.tx.NamedExec(query, args) res, err := t.tx.NamedExec(query, args)
if err != nil { if err != nil {
cclog.Errorf("Named Exec failed: %v", err) return 0, fmt.Errorf("named exec: %w", err)
return 0, err
} }
id, err := res.LastInsertId() id, err := res.LastInsertId()
if err != nil { if err != nil {
cclog.Errorf("repository initDB(): %v", err) return 0, fmt.Errorf("getting last insert id: %w", err)
return 0, err
} }
return id, nil return id, nil
} }
// TransactionAdd executes a query within the transaction.
func (r *JobRepository) TransactionAdd(t *Transaction, query string, args ...interface{}) (int64, error) { func (r *JobRepository) TransactionAdd(t *Transaction, query string, args ...interface{}) (int64, error) {
if t.tx == nil {
return 0, fmt.Errorf("transaction is nil or already completed")
}
res, err := t.tx.Exec(query, args...) res, err := t.tx.Exec(query, args...)
if err != nil { if err != nil {
cclog.Errorf("TransactionAdd(), Exec() Error: %v", err) return 0, fmt.Errorf("exec: %w", err)
return 0, err
} }
id, err := res.LastInsertId() id, err := res.LastInsertId()
if err != nil { if err != nil {
cclog.Errorf("TransactionAdd(), LastInsertId() Error: %v", err) return 0, fmt.Errorf("getting last insert id: %w", err)
return 0, err
} }
return id, nil return id, nil
+83 -12
View File
@@ -24,10 +24,14 @@ import (
) )
//go:embed jobclasses/* //go:embed jobclasses/*
var jobclassFiles embed.FS var jobClassFiles embed.FS
// Variable defines a named expression that can be computed and reused in rules.
// Variables are evaluated before the main rule and their results are added to the environment.
type Variable struct { type Variable struct {
// Name is the variable identifier used in rule expressions
Name string `json:"name"` Name string `json:"name"`
// Expr is the expression to evaluate (must return a numeric value)
Expr string `json:"expr"` Expr string `json:"expr"`
} }
@@ -36,14 +40,25 @@ type ruleVariable struct {
expr *vm.Program expr *vm.Program
} }
// RuleFormat defines the JSON structure for job classification rules.
// Each rule specifies requirements, metrics to analyze, variables to compute,
// and the final rule expression that determines if the job matches the classification.
type RuleFormat struct { type RuleFormat struct {
// Name is a human-readable description of the rule
Name string `json:"name"` Name string `json:"name"`
// Tag is the classification tag to apply if the rule matches
Tag string `json:"tag"` Tag string `json:"tag"`
// Parameters are shared values referenced in the rule (e.g., thresholds)
Parameters []string `json:"parameters"` Parameters []string `json:"parameters"`
// Metrics are the job metrics required for this rule (e.g., "cpu_load", "mem_used")
Metrics []string `json:"metrics"` Metrics []string `json:"metrics"`
// Requirements are boolean expressions that must be true for the rule to apply
Requirements []string `json:"requirements"` Requirements []string `json:"requirements"`
// Variables are computed values used in the rule expression
Variables []Variable `json:"variables"` Variables []Variable `json:"variables"`
// Rule is the boolean expression that determines if the job matches
Rule string `json:"rule"` Rule string `json:"rule"`
// Hint is a template string that generates a message when the rule matches
Hint string `json:"hint"` Hint string `json:"hint"`
} }
@@ -56,11 +71,35 @@ type ruleInfo struct {
hint *template.Template hint *template.Template
} }
// JobRepository defines the interface for job database operations needed by the tagger.
// This interface allows for easier testing and decoupling from the concrete repository implementation.
type JobRepository interface {
// HasTag checks if a job already has a specific tag
HasTag(jobId int64, tagType string, tagName string) bool
// AddTagOrCreateDirect adds a tag to a job or creates it if it doesn't exist
AddTagOrCreateDirect(jobId int64, tagType string, tagName string) (tagId int64, err error)
// UpdateMetadata updates job metadata with a key-value pair
UpdateMetadata(job *schema.Job, key, val string) (err error)
}
// JobClassTagger classifies jobs based on configurable rules that evaluate job metrics and properties.
// Rules are loaded from embedded JSON files and can be dynamically reloaded from a watched directory.
// When a job matches a rule, it is tagged with the corresponding classification and an optional hint message.
type JobClassTagger struct { type JobClassTagger struct {
// rules maps classification tags to their compiled rule information
rules map[string]ruleInfo rules map[string]ruleInfo
// parameters are shared values (e.g., thresholds) used across multiple rules
parameters map[string]any parameters map[string]any
// tagType is the type of tag ("jobClass")
tagType string tagType string
// cfgPath is the path to watch for configuration changes
cfgPath string cfgPath string
// repo provides access to job database operations
repo JobRepository
// getStatistics retrieves job statistics for analysis
getStatistics func(job *schema.Job) (map[string]schema.JobStatistics, error)
// getMetricConfig retrieves metric configuration (limits) for a cluster
getMetricConfig func(cluster, subCluster string) map[string]*schema.Metric
} }
func (t *JobClassTagger) prepareRule(b []byte, fns string) { func (t *JobClassTagger) prepareRule(b []byte, fns string) {
@@ -127,10 +166,14 @@ func (t *JobClassTagger) prepareRule(b []byte, fns string) {
t.rules[rule.Tag] = ri t.rules[rule.Tag] = ri
} }
// EventMatch checks if a filesystem event should trigger configuration reload.
// It returns true if the event path contains "jobclasses".
func (t *JobClassTagger) EventMatch(s string) bool { func (t *JobClassTagger) EventMatch(s string) bool {
return strings.Contains(s, "jobclasses") return strings.Contains(s, "jobclasses")
} }
// EventCallback is triggered when the configuration directory changes.
// It reloads parameters and all rule files from the watched directory.
// FIXME: Only process the file that caused the event // FIXME: Only process the file that caused the event
func (t *JobClassTagger) EventCallback() { func (t *JobClassTagger) EventCallback() {
files, err := os.ReadDir(t.cfgPath) files, err := os.ReadDir(t.cfgPath)
@@ -170,7 +213,7 @@ func (t *JobClassTagger) EventCallback() {
func (t *JobClassTagger) initParameters() error { func (t *JobClassTagger) initParameters() error {
cclog.Info("Initialize parameters") cclog.Info("Initialize parameters")
b, err := jobclassFiles.ReadFile("jobclasses/parameters.json") b, err := jobClassFiles.ReadFile("jobclasses/parameters.json")
if err != nil { if err != nil {
cclog.Warnf("prepareRule() > open file error: %v", err) cclog.Warnf("prepareRule() > open file error: %v", err)
return err return err
@@ -184,6 +227,10 @@ func (t *JobClassTagger) initParameters() error {
return nil return nil
} }
// Register initializes the JobClassTagger by loading parameters and classification rules.
// It loads embedded configuration files and sets up a file watch on ./var/tagger/jobclasses
// if it exists, allowing for dynamic configuration updates without restarting the application.
// Returns an error if the embedded configuration files cannot be read or parsed.
func (t *JobClassTagger) Register() error { func (t *JobClassTagger) Register() error {
t.cfgPath = "./var/tagger/jobclasses" t.cfgPath = "./var/tagger/jobclasses"
t.tagType = "jobClass" t.tagType = "jobClass"
@@ -194,18 +241,18 @@ func (t *JobClassTagger) Register() error {
return err return err
} }
files, err := jobclassFiles.ReadDir("jobclasses") files, err := jobClassFiles.ReadDir("jobclasses")
if err != nil { if err != nil {
return fmt.Errorf("error reading app folder: %#v", err) return fmt.Errorf("error reading app folder: %#v", err)
} }
t.rules = make(map[string]ruleInfo, 0) t.rules = make(map[string]ruleInfo)
for _, fn := range files { for _, fn := range files {
fns := fn.Name() fns := fn.Name()
if fns != "parameters.json" { if fns != "parameters.json" {
filename := fmt.Sprintf("jobclasses/%s", fns) filename := fmt.Sprintf("jobclasses/%s", fns)
cclog.Infof("Process: %s", fns) cclog.Infof("Process: %s", fns)
b, err := jobclassFiles.ReadFile(filename) b, err := jobClassFiles.ReadFile(filename)
if err != nil { if err != nil {
cclog.Warnf("prepareRule() > open file error: %v", err) cclog.Warnf("prepareRule() > open file error: %v", err)
return err return err
@@ -220,13 +267,30 @@ func (t *JobClassTagger) Register() error {
util.AddListener(t.cfgPath, t) util.AddListener(t.cfgPath, t)
} }
t.repo = repository.GetJobRepository()
t.getStatistics = archive.GetStatistics
t.getMetricConfig = archive.GetMetricConfigSubCluster
return nil return nil
} }
// Match evaluates all classification rules against a job and applies matching tags.
// It retrieves job statistics and metric configurations, then tests each rule's requirements
// and main expression. For each matching rule, it:
// - Applies the classification tag to the job
// - Generates and stores a hint message based on the rule's template
//
// The function constructs an evaluation environment containing:
// - Job properties (duration, cores, nodes, state, etc.)
// - Metric statistics (min, max, avg) and their configured limits
// - Shared parameters defined in parameters.json
// - Computed variables from the rule definition
//
// Rules are evaluated in arbitrary order. If multiple rules match, only the first
// encountered match is applied (FIXME: this should handle multiple matches).
func (t *JobClassTagger) Match(job *schema.Job) { func (t *JobClassTagger) Match(job *schema.Job) {
r := repository.GetJobRepository() jobStats, err := t.getStatistics(job)
jobstats, err := archive.GetStatistics(job) metricsList := t.getMetricConfig(job.Cluster, job.SubCluster)
metricsList := archive.GetMetricConfigSubCluster(job.Cluster, job.SubCluster)
cclog.Infof("Enter match rule with %d rules for job %d", len(t.rules), job.JobID) cclog.Infof("Enter match rule with %d rules for job %d", len(t.rules), job.JobID)
if err != nil { if err != nil {
cclog.Errorf("job classification failed for job %d: %#v", job.JobID, err) cclog.Errorf("job classification failed for job %d: %#v", job.JobID, err)
@@ -251,7 +315,7 @@ func (t *JobClassTagger) Match(job *schema.Job) {
// add metrics to env // add metrics to env
for _, m := range ri.metrics { for _, m := range ri.metrics {
stats, ok := jobstats[m] stats, ok := jobStats[m]
if !ok { if !ok {
cclog.Errorf("job classification failed for job %d: missing metric '%s'", job.JobID, m) cclog.Errorf("job classification failed for job %d: missing metric '%s'", job.JobID, m)
return return
@@ -302,8 +366,11 @@ func (t *JobClassTagger) Match(job *schema.Job) {
if match.(bool) { if match.(bool) {
cclog.Info("Rule matches!") cclog.Info("Rule matches!")
id := *job.ID id := *job.ID
if !r.HasTag(id, t.tagType, tag) { if !t.repo.HasTag(id, t.tagType, tag) {
r.AddTagOrCreateDirect(id, t.tagType, tag) _, err := t.repo.AddTagOrCreateDirect(id, t.tagType, tag)
if err != nil {
return
}
} }
// process hint template // process hint template
@@ -314,7 +381,11 @@ func (t *JobClassTagger) Match(job *schema.Job) {
} }
// FIXME: Handle case where multiple tags apply // FIXME: Handle case where multiple tags apply
r.UpdateMetadata(job, "message", msg.String()) // FIXME: Handle case where multiple tags apply
err = t.repo.UpdateMetadata(job, "message", msg.String())
if err != nil {
return
}
} else { } else {
cclog.Info("Rule does not match!") cclog.Info("Rule does not match!")
} }
+162
View File
@@ -0,0 +1,162 @@
package tagger
import (
"testing"
"github.com/ClusterCockpit/cc-lib/schema"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
// MockJobRepository is a mock implementation of the JobRepository interface
type MockJobRepository struct {
mock.Mock
}
func (m *MockJobRepository) HasTag(jobId int64, tagType string, tagName string) bool {
args := m.Called(jobId, tagType, tagName)
return args.Bool(0)
}
func (m *MockJobRepository) AddTagOrCreateDirect(jobId int64, tagType string, tagName string) (tagId int64, err error) {
args := m.Called(jobId, tagType, tagName)
return args.Get(0).(int64), args.Error(1)
}
func (m *MockJobRepository) UpdateMetadata(job *schema.Job, key, val string) (err error) {
args := m.Called(job, key, val)
return args.Error(0)
}
func TestPrepareRule(t *testing.T) {
tagger := &JobClassTagger{
rules: make(map[string]ruleInfo),
parameters: make(map[string]any),
}
// Valid rule JSON
validRule := []byte(`{
"name": "Test Rule",
"tag": "test_tag",
"parameters": [],
"metrics": ["flops_any"],
"requirements": ["job.numNodes > 1"],
"variables": [{"name": "avg_flops", "expr": "flops_any.avg"}],
"rule": "avg_flops > 100",
"hint": "High FLOPS"
}`)
tagger.prepareRule(validRule, "test_rule.json")
assert.Contains(t, tagger.rules, "test_tag")
rule := tagger.rules["test_tag"]
assert.Equal(t, 1, len(rule.metrics))
assert.Equal(t, 1, len(rule.requirements))
assert.Equal(t, 1, len(rule.variables))
assert.NotNil(t, rule.rule)
assert.NotNil(t, rule.hint)
}
func TestClassifyJobMatch(t *testing.T) {
mockRepo := new(MockJobRepository)
tagger := &JobClassTagger{
rules: make(map[string]ruleInfo),
parameters: make(map[string]any),
tagType: "jobClass",
repo: mockRepo,
getStatistics: func(job *schema.Job) (map[string]schema.JobStatistics, error) {
return map[string]schema.JobStatistics{
"flops_any": {Min: 0, Max: 200, Avg: 150},
}, nil
},
getMetricConfig: func(cluster, subCluster string) map[string]*schema.Metric {
return map[string]*schema.Metric{
"flops_any": {Peak: 1000, Normal: 100, Caution: 50, Alert: 10},
}
},
}
// Add a rule manually or via prepareRule
validRule := []byte(`{
"name": "Test Rule",
"tag": "high_flops",
"parameters": [],
"metrics": ["flops_any"],
"requirements": [],
"variables": [{"name": "avg_flops", "expr": "flops_any.avg"}],
"rule": "avg_flops > 100",
"hint": "High FLOPS: {{.avg_flops}}"
}`)
tagger.prepareRule(validRule, "test_rule.json")
jobID := int64(123)
job := &schema.Job{
ID: &jobID,
JobID: 123,
Cluster: "test_cluster",
SubCluster: "test_subcluster",
NumNodes: 2,
NumHWThreads: 4,
State: schema.JobStateCompleted,
}
// Expectation: Rule matches
// 1. Check if tag exists (return false)
mockRepo.On("HasTag", jobID, "jobClass", "high_flops").Return(false)
// 2. Add tag
mockRepo.On("AddTagOrCreateDirect", jobID, "jobClass", "high_flops").Return(int64(1), nil)
// 3. Update metadata
mockRepo.On("UpdateMetadata", job, "message", mock.Anything).Return(nil)
tagger.Match(job)
mockRepo.AssertExpectations(t)
}
func TestMatch_NoMatch(t *testing.T) {
mockRepo := new(MockJobRepository)
tagger := &JobClassTagger{
rules: make(map[string]ruleInfo),
parameters: make(map[string]any),
tagType: "jobClass",
repo: mockRepo,
getStatistics: func(job *schema.Job) (map[string]schema.JobStatistics, error) {
return map[string]schema.JobStatistics{
"flops_any": {Min: 0, Max: 50, Avg: 20}, // Avg 20 < 100
}, nil
},
getMetricConfig: func(cluster, subCluster string) map[string]*schema.Metric {
return map[string]*schema.Metric{
"flops_any": {Peak: 1000, Normal: 100, Caution: 50, Alert: 10},
}
},
}
validRule := []byte(`{
"name": "Test Rule",
"tag": "high_flops",
"parameters": [],
"metrics": ["flops_any"],
"requirements": [],
"variables": [{"name": "avg_flops", "expr": "flops_any.avg"}],
"rule": "avg_flops > 100",
"hint": "High FLOPS"
}`)
tagger.prepareRule(validRule, "test_rule.json")
jobID := int64(123)
job := &schema.Job{
ID: &jobID,
JobID: 123,
Cluster: "test_cluster",
SubCluster: "test_subcluster",
NumNodes: 2,
NumHWThreads: 4,
State: schema.JobStateCompleted,
}
// Expectation: Rule does NOT match, so no repo calls
tagger.Match(job)
mockRepo.AssertExpectations(t)
}
+21
View File
@@ -2,6 +2,7 @@
// All rights reserved. This file is part of cc-backend. // All rights reserved. This file is part of cc-backend.
// Use of this source code is governed by a MIT-style // Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file. // license that can be found in the LICENSE file.
package tagger package tagger
import ( import (
@@ -28,9 +29,16 @@ type appInfo struct {
strings []string strings []string
} }
// AppTagger detects applications by matching patterns in job scripts.
// It loads application patterns from embedded files and can dynamically reload
// configuration from a watched directory. When a job script matches a pattern,
// the corresponding application tag is automatically applied.
type AppTagger struct { type AppTagger struct {
// apps maps application tags to their matching patterns
apps map[string]appInfo apps map[string]appInfo
// tagType is the type of tag ("app")
tagType string tagType string
// cfgPath is the path to watch for configuration changes
cfgPath string cfgPath string
} }
@@ -45,10 +53,14 @@ func (t *AppTagger) scanApp(f fs.File, fns string) {
t.apps[ai.tag] = ai t.apps[ai.tag] = ai
} }
// EventMatch checks if a filesystem event should trigger configuration reload.
// It returns true if the event path contains "apps".
func (t *AppTagger) EventMatch(s string) bool { func (t *AppTagger) EventMatch(s string) bool {
return strings.Contains(s, "apps") return strings.Contains(s, "apps")
} }
// EventCallback is triggered when the configuration directory changes.
// It reloads all application pattern files from the watched directory.
// FIXME: Only process the file that caused the event // FIXME: Only process the file that caused the event
func (t *AppTagger) EventCallback() { func (t *AppTagger) EventCallback() {
files, err := os.ReadDir(t.cfgPath) files, err := os.ReadDir(t.cfgPath)
@@ -67,6 +79,10 @@ func (t *AppTagger) EventCallback() {
} }
} }
// Register initializes the AppTagger by loading application patterns from embedded files.
// It also sets up a file watch on ./var/tagger/apps if it exists, allowing for
// dynamic configuration updates without restarting the application.
// Returns an error if the embedded application files cannot be read.
func (t *AppTagger) Register() error { func (t *AppTagger) Register() error {
t.cfgPath = "./var/tagger/apps" t.cfgPath = "./var/tagger/apps"
t.tagType = "app" t.tagType = "app"
@@ -96,6 +112,11 @@ func (t *AppTagger) Register() error {
return nil return nil
} }
// Match attempts to detect the application used by a job by analyzing its job script.
// It fetches the job metadata, extracts the job script, and matches it against
// all configured application patterns using regular expressions.
// If a match is found, the corresponding application tag is added to the job.
// Only the first matching application is tagged.
func (t *AppTagger) Match(job *schema.Job) { func (t *AppTagger) Match(job *schema.Job) {
r := repository.GetJobRepository() r := repository.GetJobRepository()
metadata, err := r.FetchMetadata(job) metadata, err := r.FetchMetadata(job)
+28 -1
View File
@@ -2,6 +2,11 @@
// All rights reserved. This file is part of cc-backend. // All rights reserved. This file is part of cc-backend.
// Use of this source code is governed by a MIT-style // Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file. // license that can be found in the LICENSE file.
// Package tagger provides automatic job tagging functionality for cc-backend.
// It supports detecting applications and classifying jobs based on configurable rules.
// Tags are automatically applied when jobs start or stop, or can be applied retroactively
// to existing jobs using RunTaggers.
package tagger package tagger
import ( import (
@@ -12,8 +17,15 @@ import (
"github.com/ClusterCockpit/cc-lib/schema" "github.com/ClusterCockpit/cc-lib/schema"
) )
// Tagger is the interface that must be implemented by all tagging components.
// Taggers can be registered at job start or stop events to automatically apply tags.
type Tagger interface { type Tagger interface {
// Register initializes the tagger and loads any required configuration.
// It should be called once before the tagger is used.
Register() error Register() error
// Match evaluates the tagger's rules against a job and applies appropriate tags.
// It is called for each job that needs to be evaluated.
Match(job *schema.Job) Match(job *schema.Job)
} }
@@ -22,8 +34,12 @@ var (
jobTagger *JobTagger jobTagger *JobTagger
) )
// JobTagger coordinates multiple taggers that run at different job lifecycle events.
// It maintains separate lists of taggers that run when jobs start and when they stop.
type JobTagger struct { type JobTagger struct {
// startTaggers are applied when a job starts (e.g., application detection)
startTaggers []Tagger startTaggers []Tagger
// stopTaggers are applied when a job completes (e.g., job classification)
stopTaggers []Tagger stopTaggers []Tagger
} }
@@ -42,6 +58,9 @@ func newTagger() {
} }
} }
// Init initializes the job tagger system and registers it with the job repository.
// This function is safe to call multiple times; initialization only occurs once.
// It should be called during application startup.
func Init() { func Init() {
initOnce.Do(func() { initOnce.Do(func() {
newTagger() newTagger()
@@ -49,22 +68,30 @@ func Init() {
}) })
} }
// JobStartCallback is called when a job starts.
// It runs all registered start taggers (e.g., application detection) on the job.
func (jt *JobTagger) JobStartCallback(job *schema.Job) { func (jt *JobTagger) JobStartCallback(job *schema.Job) {
for _, tagger := range jt.startTaggers { for _, tagger := range jt.startTaggers {
tagger.Match(job) tagger.Match(job)
} }
} }
// JobStopCallback is called when a job completes.
// It runs all registered stop taggers (e.g., job classification) on the job.
func (jt *JobTagger) JobStopCallback(job *schema.Job) { func (jt *JobTagger) JobStopCallback(job *schema.Job) {
for _, tagger := range jt.stopTaggers { for _, tagger := range jt.stopTaggers {
tagger.Match(job) tagger.Match(job)
} }
} }
// RunTaggers applies all configured taggers to all existing jobs in the repository.
// This is useful for retroactively applying tags to jobs that were created before
// the tagger system was initialized or when new tagging rules are added.
// It fetches all jobs and runs both start and stop taggers on each one.
func RunTaggers() error { func RunTaggers() error {
newTagger() newTagger()
r := repository.GetJobRepository() r := repository.GetJobRepository()
jl, err := r.GetJobList() jl, err := r.GetJobList(0, 0) // 0 limit means get all jobs (no pagination)
if err != nil { if err != nil {
cclog.Errorf("Error while getting job list %s", err) cclog.Errorf("Error while getting job list %s", err)
return err return err
+29 -1
View File
@@ -12,12 +12,40 @@ var configSchema = `
"kind": { "kind": {
"description": "Backend type for job-archive", "description": "Backend type for job-archive",
"type": "string", "type": "string",
"enum": ["file", "s3"] "enum": ["file", "s3", "sqlite"]
}, },
"path": { "path": {
"description": "Path to job archive for file backend", "description": "Path to job archive for file backend",
"type": "string" "type": "string"
}, },
"dbPath": {
"description": "Path to SQLite database file for sqlite backend",
"type": "string"
},
"endpoint": {
"description": "S3 endpoint URL (for S3-compatible services like MinIO)",
"type": "string"
},
"accessKey": {
"description": "S3 access key ID",
"type": "string"
},
"secretKey": {
"description": "S3 secret access key",
"type": "string"
},
"bucket": {
"description": "S3 bucket name for job archive",
"type": "string"
},
"region": {
"description": "AWS region for S3 bucket",
"type": "string"
},
"usePathStyle": {
"description": "Use path-style S3 URLs (required for MinIO and some S3-compatible services)",
"type": "boolean"
},
"compression": { "compression": {
"description": "Setup automatic compression for jobs older than number of days", "description": "Setup automatic compression for jobs older than number of days",
"type": "integer" "type": "integer"
+195 -10
View File
@@ -3,7 +3,79 @@
// Use of this source code is governed by a MIT-style // Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file. // license that can be found in the LICENSE file.
// Package archive implements the job archive interface and various backend implementations // Package archive implements the job archive interface and various backend implementations.
//
// The archive package provides a pluggable storage backend system for job metadata and performance data.
// It supports three backend types:
//
// - file: Filesystem-based storage with hierarchical directory structure
// - s3: AWS S3 and S3-compatible object storage (MinIO, localstack)
// - sqlite: Single-file SQLite database with BLOB storage
//
// # Backend Selection
//
// Choose a backend based on your deployment requirements:
//
// - File: Best for single-server deployments with local fast storage
// - S3: Best for distributed deployments requiring redundancy and multi-instance access
// - SQLite: Best for portable archives with SQL query capability and transactional integrity
//
// # Configuration
//
// The archive backend is configured via JSON in the application config file:
//
// {
// "archive": {
// "kind": "file", // or "s3" or "sqlite"
// "path": "/var/lib/archive" // for file backend
// }
// }
//
// For S3 backend:
//
// {
// "archive": {
// "kind": "s3",
// "bucket": "my-job-archive",
// "region": "us-east-1",
// "accessKey": "...",
// "secretKey": "..."
// }
// }
//
// For SQLite backend:
//
// {
// "archive": {
// "kind": "sqlite",
// "dbPath": "/var/lib/archive.db"
// }
// }
//
// # Usage
//
// The package is initialized once at application startup:
//
// err := archive.Init(rawConfig, false)
// if err != nil {
// log.Fatal(err)
// }
//
// After initialization, use the global functions to interact with the archive:
//
// // Check if a job exists
// exists := archive.GetHandle().Exists(job)
//
// // Load job metadata
// jobMeta, err := archive.GetHandle().LoadJobMeta(job)
//
// // Store job metadata
// err = archive.GetHandle().StoreJobMeta(job)
//
// # Thread Safety
//
// All backend implementations are safe for concurrent use. The package uses
// internal locking for operations that modify shared state.
package archive package archive
import ( import (
@@ -18,45 +90,88 @@ import (
"github.com/ClusterCockpit/cc-lib/schema" "github.com/ClusterCockpit/cc-lib/schema"
) )
const Version uint64 = 2 // Version is the current archive schema version.
// The archive backend must match this version for compatibility.
const Version uint64 = 3
// ArchiveBackend defines the interface that all archive storage backends must implement.
// Implementations include FsArchive (filesystem), S3Archive (object storage), and SqliteArchive (database).
//
// All methods are safe for concurrent use unless otherwise noted.
type ArchiveBackend interface { type ArchiveBackend interface {
// Init initializes the archive backend with the provided configuration.
// Returns the archive version found in the backend storage.
// Returns an error if the version is incompatible or initialization fails.
Init(rawConfig json.RawMessage) (uint64, error) Init(rawConfig json.RawMessage) (uint64, error)
// Info prints archive statistics to stdout, including job counts,
// date ranges, and storage sizes per cluster.
Info() Info()
// Exists checks if a job with the given ID, cluster, and start time
// exists in the archive.
Exists(job *schema.Job) bool Exists(job *schema.Job) bool
// LoadJobMeta loads job metadata from the archive.
// Returns the complete Job structure including resources, tags, and statistics.
LoadJobMeta(job *schema.Job) (*schema.Job, error) LoadJobMeta(job *schema.Job) (*schema.Job, error)
// LoadJobData loads the complete time-series performance data for a job.
// Returns a map of metric names to their scoped data (node, socket, core, etc.).
LoadJobData(job *schema.Job) (schema.JobData, error) LoadJobData(job *schema.Job) (schema.JobData, error)
// LoadJobStats loads pre-computed statistics from the job data.
// Returns scoped statistics (min, max, avg) for all metrics.
LoadJobStats(job *schema.Job) (schema.ScopedJobStats, error) LoadJobStats(job *schema.Job) (schema.ScopedJobStats, error)
// LoadClusterCfg loads the cluster configuration.
// Returns the cluster topology, metrics, and hardware specifications.
LoadClusterCfg(name string) (*schema.Cluster, error) LoadClusterCfg(name string) (*schema.Cluster, error)
// StoreJobMeta stores job metadata to the archive.
// Overwrites existing metadata for the same job ID, cluster, and start time.
StoreJobMeta(jobMeta *schema.Job) error StoreJobMeta(jobMeta *schema.Job) error
// ImportJob stores both job metadata and performance data to the archive.
// This is typically used during initial job archiving.
ImportJob(jobMeta *schema.Job, jobData *schema.JobData) error ImportJob(jobMeta *schema.Job, jobData *schema.JobData) error
// GetClusters returns a list of all cluster names found in the archive.
GetClusters() []string GetClusters() []string
// CleanUp removes the specified jobs from the archive.
// Used by retention policies to delete old jobs.
CleanUp(jobs []*schema.Job) CleanUp(jobs []*schema.Job)
// Move relocates jobs to a different path within the archive.
// The implementation depends on the backend type.
Move(jobs []*schema.Job, path string) Move(jobs []*schema.Job, path string)
// Clean removes jobs outside the specified time range.
// Jobs with start_time < before OR start_time > after are deleted.
// Set after=0 to only use the before parameter.
Clean(before int64, after int64) Clean(before int64, after int64)
// Compress compresses job data files to save storage space.
// For filesystem and SQLite backends, this applies gzip compression.
// For S3, this compresses and replaces objects.
Compress(jobs []*schema.Job) Compress(jobs []*schema.Job)
// CompressLast returns the timestamp of the last compression run
// and updates it to the provided starttime.
CompressLast(starttime int64) int64 CompressLast(starttime int64) int64
// Iter returns a channel that yields all jobs in the archive.
// If loadMetricData is true, includes performance data; otherwise only metadata.
// The channel is closed when iteration completes.
Iter(loadMetricData bool) <-chan JobContainer Iter(loadMetricData bool) <-chan JobContainer
} }
// JobContainer combines job metadata and optional performance data.
// Used by Iter() to yield jobs during archive iteration.
type JobContainer struct { type JobContainer struct {
Meta *schema.Job Meta *schema.Job // Job metadata (always present)
Data *schema.JobData Data *schema.JobData // Performance data (nil if not loaded)
} }
var ( var (
@@ -67,6 +182,15 @@ var (
mutex sync.Mutex mutex sync.Mutex
) )
// Init initializes the archive backend with the provided configuration.
// Must be called once at application startup before using any archive functions.
//
// Parameters:
// - rawConfig: JSON configuration for the archive backend
// - disableArchive: if true, disables archive functionality
//
// The configuration determines which backend is used (file, s3, or sqlite).
// Returns an error if initialization fails or version is incompatible.
func Init(rawConfig json.RawMessage, disableArchive bool) error { func Init(rawConfig json.RawMessage, disableArchive bool) error {
var err error var err error
@@ -86,8 +210,10 @@ func Init(rawConfig json.RawMessage, disableArchive bool) error {
switch cfg.Kind { switch cfg.Kind {
case "file": case "file":
ar = &FsArchive{} ar = &FsArchive{}
// case "s3": case "s3":
// ar = &S3Archive{} ar = &S3Archive{}
case "sqlite":
ar = &SqliteArchive{}
default: default:
err = fmt.Errorf("ARCHIVE/ARCHIVE > unkown archive backend '%s''", cfg.Kind) err = fmt.Errorf("ARCHIVE/ARCHIVE > unkown archive backend '%s''", cfg.Kind)
} }
@@ -106,10 +232,59 @@ func Init(rawConfig json.RawMessage, disableArchive bool) error {
return err return err
} }
// GetHandle returns the initialized archive backend instance.
// Must be called after Init().
func GetHandle() ArchiveBackend { func GetHandle() ArchiveBackend {
return ar return ar
} }
// InitBackend creates and initializes a new archive backend instance
// without affecting the global singleton. This is useful for archive migration
// tools that need to work with multiple archive backends simultaneously.
//
// Parameters:
// - rawConfig: JSON configuration for the archive backend
//
// Returns the initialized backend instance or an error if initialization fails.
// Does not validate the configuration against the schema.
func InitBackend(rawConfig json.RawMessage) (ArchiveBackend, error) {
var cfg struct {
Kind string `json:"kind"`
}
if err := json.Unmarshal(rawConfig, &cfg); err != nil {
cclog.Warn("Error while unmarshaling raw config json")
return nil, err
}
var backend ArchiveBackend
switch cfg.Kind {
case "file":
backend = &FsArchive{}
case "s3":
backend = &S3Archive{}
case "sqlite":
backend = &SqliteArchive{}
default:
return nil, fmt.Errorf("ARCHIVE/ARCHIVE > unknown archive backend '%s'", cfg.Kind)
}
_, err := backend.Init(rawConfig)
if err != nil {
return nil, fmt.Errorf("error while initializing archive backend: %w", err)
}
return backend, nil
}
// LoadAveragesFromArchive loads average metric values for a job from the archive.
// This is a helper function that extracts average values from job statistics.
//
// Parameters:
// - job: Job to load averages for
// - metrics: List of metric names to retrieve
// - data: 2D slice where averages will be appended (one row per metric)
func LoadAveragesFromArchive( func LoadAveragesFromArchive(
job *schema.Job, job *schema.Job,
metrics []string, metrics []string,
@@ -132,6 +307,8 @@ func LoadAveragesFromArchive(
return nil return nil
} }
// LoadStatsFromArchive loads metric statistics for a job from the archive.
// Returns a map of metric names to their statistics (min, max, avg).
func LoadStatsFromArchive( func LoadStatsFromArchive(
job *schema.Job, job *schema.Job,
metrics []string, metrics []string,
@@ -160,6 +337,8 @@ func LoadStatsFromArchive(
return data, nil return data, nil
} }
// LoadScopedStatsFromArchive loads scoped statistics for a job from the archive.
// Returns statistics organized by metric scope (node, socket, core, etc.).
func LoadScopedStatsFromArchive( func LoadScopedStatsFromArchive(
job *schema.Job, job *schema.Job,
metrics []string, metrics []string,
@@ -174,6 +353,8 @@ func LoadScopedStatsFromArchive(
return data, nil return data, nil
} }
// GetStatistics returns all metric statistics for a job.
// Returns a map of metric names to their job-level statistics.
func GetStatistics(job *schema.Job) (map[string]schema.JobStatistics, error) { func GetStatistics(job *schema.Job) (map[string]schema.JobStatistics, error) {
metaFile, err := ar.LoadJobMeta(job) metaFile, err := ar.LoadJobMeta(job)
if err != nil { if err != nil {
@@ -184,8 +365,10 @@ func GetStatistics(job *schema.Job) (map[string]schema.JobStatistics, error) {
return metaFile.Statistics, nil return metaFile.Statistics, nil
} }
// UpdateMetadata checks if the job is archived, find its `meta.json` file and override the Metadata // UpdateMetadata updates the metadata map for an archived job.
// in that JSON file. If the job is not archived, nothing is done. // If the job is still running or archiving is disabled, this is a no-op.
//
// This function is safe for concurrent use (protected by mutex).
func UpdateMetadata(job *schema.Job, metadata map[string]string) error { func UpdateMetadata(job *schema.Job, metadata map[string]string) error {
mutex.Lock() mutex.Lock()
defer mutex.Unlock() defer mutex.Unlock()
@@ -205,8 +388,10 @@ func UpdateMetadata(job *schema.Job, metadata map[string]string) error {
return ar.StoreJobMeta(jobMeta) return ar.StoreJobMeta(jobMeta)
} }
// UpdateTags checks if the job is archived, find its `meta.json` file and override the tags list // UpdateTags updates the tag list for an archived job.
// in that JSON file. If the job is not archived, nothing is done. // If the job is still running or archiving is disabled, this is a no-op.
//
// This function is safe for concurrent use (protected by mutex).
func UpdateTags(job *schema.Job, tags []*schema.Tag) error { func UpdateTags(job *schema.Job, tags []*schema.Tag) error {
mutex.Lock() mutex.Lock()
defer mutex.Unlock() defer mutex.Unlock()
+13 -8
View File
@@ -28,20 +28,26 @@ import (
"github.com/santhosh-tekuri/jsonschema/v5" "github.com/santhosh-tekuri/jsonschema/v5"
) )
// FsArchiveConfig holds the configuration for the filesystem archive backend.
type FsArchiveConfig struct { type FsArchiveConfig struct {
Path string `json:"path"` Path string `json:"path"` // Root directory path for the archive
} }
// FsArchive implements ArchiveBackend using a hierarchical filesystem structure.
// Jobs are stored in directories organized by cluster, job ID, and start time.
//
// Directory structure: <path>/<cluster>/<jobid/1000>/<jobid%1000>/<starttime>/
type FsArchive struct { type FsArchive struct {
path string path string // Root path of the archive
clusters []string clusters []string // List of discovered cluster names
} }
// clusterInfo holds statistics about jobs in a cluster.
type clusterInfo struct { type clusterInfo struct {
numJobs int numJobs int // Total number of jobs
dateFirst int64 dateFirst int64 // Unix timestamp of oldest job
dateLast int64 dateLast int64 // Unix timestamp of newest job
diskSize float64 diskSize float64 // Total disk usage in MB
} }
func getDirectory( func getDirectory(
@@ -509,7 +515,6 @@ func (fsa *FsArchive) Iter(loadMetricData bool) <-chan JobContainer {
cclog.Errorf("in %s: %s", filepath.Join(dirpath, startTimeDir.Name()), err.Error()) cclog.Errorf("in %s: %s", filepath.Join(dirpath, startTimeDir.Name()), err.Error())
} }
ch <- JobContainer{Meta: job, Data: &data} ch <- JobContainer{Meta: job, Data: &data}
cclog.Errorf("in %s: %s", filepath.Join(dirpath, startTimeDir.Name()), err.Error())
} else { } else {
ch <- JobContainer{Meta: job, Data: nil} ch <- JobContainer{Meta: job, Data: nil}
} }
+1 -1
View File
@@ -47,7 +47,7 @@ func TestInit(t *testing.T) {
if fsa.path != "testdata/archive" { if fsa.path != "testdata/archive" {
t.Fail() t.Fail()
} }
if version != 2 { if version != 3 {
t.Fail() t.Fail()
} }
if len(fsa.clusters) != 3 || fsa.clusters[1] != "emmy" { if len(fsa.clusters) != 3 || fsa.clusters[1] != "emmy" {
+823 -2
View File
@@ -5,10 +5,831 @@
package archive package archive
import (
"bytes"
"compress/gzip"
"context"
"encoding/json"
"fmt"
"io"
"math"
"os"
"strconv"
"strings"
"text/tabwriter"
"time"
"github.com/ClusterCockpit/cc-backend/internal/config"
cclog "github.com/ClusterCockpit/cc-lib/ccLogger"
"github.com/ClusterCockpit/cc-lib/schema"
"github.com/ClusterCockpit/cc-lib/util"
"github.com/aws/aws-sdk-go-v2/aws"
awsconfig "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
)
// S3ArchiveConfig holds the configuration for the S3 archive backend.
type S3ArchiveConfig struct { type S3ArchiveConfig struct {
Path string `json:"filePath"` Endpoint string `json:"endpoint"` // S3 endpoint URL (optional, for MinIO/localstack)
AccessKey string `json:"accessKey"` // AWS access key ID
SecretKey string `json:"secretKey"` // AWS secret access key
Bucket string `json:"bucket"` // S3 bucket name
Region string `json:"region"` // AWS region
UsePathStyle bool `json:"usePathStyle"` // Use path-style URLs (required for MinIO)
} }
// S3Archive implements ArchiveBackend using AWS S3 or S3-compatible object storage.
// Jobs are stored as objects with keys mirroring the filesystem structure.
//
// Object key structure: <cluster>/<jobid/1000>/<jobid%1000>/<starttime>/meta.json
type S3Archive struct { type S3Archive struct {
path string client *s3.Client // AWS S3 client
bucket string // S3 bucket name
clusters []string // List of discovered cluster names
}
// getS3Key generates the S3 object key for a job file
func getS3Key(job *schema.Job, file string) string {
lvl1 := fmt.Sprintf("%d", job.JobID/1000)
lvl2 := fmt.Sprintf("%03d", job.JobID%1000)
startTime := strconv.FormatInt(job.StartTime, 10)
return fmt.Sprintf("%s/%s/%s/%s/%s", job.Cluster, lvl1, lvl2, startTime, file)
}
// getS3Directory generates the S3 key prefix for a job directory
func getS3Directory(job *schema.Job) string {
lvl1 := fmt.Sprintf("%d", job.JobID/1000)
lvl2 := fmt.Sprintf("%03d", job.JobID%1000)
startTime := strconv.FormatInt(job.StartTime, 10)
return fmt.Sprintf("%s/%s/%s/%s/", job.Cluster, lvl1, lvl2, startTime)
}
func (s3a *S3Archive) Init(rawConfig json.RawMessage) (uint64, error) {
var cfg S3ArchiveConfig
if err := json.Unmarshal(rawConfig, &cfg); err != nil {
cclog.Warnf("S3Archive Init() > Unmarshal error: %#v", err)
return 0, err
}
if cfg.Bucket == "" {
err := fmt.Errorf("S3Archive Init(): empty bucket name")
cclog.Errorf("S3Archive Init() > config error: %v", err)
return 0, err
}
if cfg.Region == "" {
cfg.Region = "us-east-1" // Default region
}
ctx := context.Background()
// Create custom AWS config
var awsCfg aws.Config
var err error
if cfg.AccessKey != "" && cfg.SecretKey != "" {
// Use static credentials
awsCfg, err = awsconfig.LoadDefaultConfig(ctx,
awsconfig.WithRegion(cfg.Region),
awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(
cfg.AccessKey,
cfg.SecretKey,
"",
)),
)
} else {
// Use default credential chain
awsCfg, err = awsconfig.LoadDefaultConfig(ctx,
awsconfig.WithRegion(cfg.Region),
)
}
if err != nil {
cclog.Errorf("S3Archive Init() > failed to load AWS config: %v", err)
return 0, err
}
// Create S3 client with path-style option and custom endpoint if specified
s3a.client = s3.NewFromConfig(awsCfg, func(o *s3.Options) {
o.UsePathStyle = cfg.UsePathStyle
if cfg.Endpoint != "" {
o.BaseEndpoint = aws.String(cfg.Endpoint)
}
})
s3a.bucket = cfg.Bucket
// Check if bucket exists and is accessible
_, err = s3a.client.HeadBucket(ctx, &s3.HeadBucketInput{
Bucket: aws.String(s3a.bucket),
})
if err != nil {
cclog.Errorf("S3Archive Init() > bucket access error: %v", err)
return 0, fmt.Errorf("cannot access S3 bucket '%s': %w", s3a.bucket, err)
}
// Read version.txt from S3
versionKey := "version.txt"
result, err := s3a.client.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(s3a.bucket),
Key: aws.String(versionKey),
})
if err != nil {
cclog.Warnf("S3Archive Init() > cannot read version.txt: %v", err)
return 0, err
}
defer result.Body.Close()
versionBytes, err := io.ReadAll(result.Body)
if err != nil {
cclog.Errorf("S3Archive Init() > failed to read version.txt: %v", err)
return 0, err
}
version, err := strconv.ParseUint(strings.TrimSuffix(string(versionBytes), "\n"), 10, 64)
if err != nil {
cclog.Errorf("S3Archive Init() > version parse error: %v", err)
return 0, err
}
if version != Version {
return version, fmt.Errorf("unsupported version %d, need %d", version, Version)
}
// Discover clusters by listing top-level prefixes
s3a.clusters = []string{}
paginator := s3.NewListObjectsV2Paginator(s3a.client, &s3.ListObjectsV2Input{
Bucket: aws.String(s3a.bucket),
Delimiter: aws.String("/"),
})
for paginator.HasMorePages() {
page, err := paginator.NextPage(ctx)
if err != nil {
cclog.Errorf("S3Archive Init() > failed to list clusters: %v", err)
return 0, err
}
for _, prefix := range page.CommonPrefixes {
if prefix.Prefix != nil {
clusterName := strings.TrimSuffix(*prefix.Prefix, "/")
// Filter out non-cluster entries
if clusterName != "" && clusterName != "version.txt" {
s3a.clusters = append(s3a.clusters, clusterName)
}
}
}
}
cclog.Infof("S3Archive initialized with bucket '%s', found %d clusters", s3a.bucket, len(s3a.clusters))
return version, nil
}
func (s3a *S3Archive) Info() {
ctx := context.Background()
fmt.Printf("S3 Job archive bucket: %s\n", s3a.bucket)
ci := make(map[string]*clusterInfo)
for _, cluster := range s3a.clusters {
ci[cluster] = &clusterInfo{dateFirst: time.Now().Unix()}
// List all jobs for this cluster
prefix := cluster + "/"
paginator := s3.NewListObjectsV2Paginator(s3a.client, &s3.ListObjectsV2Input{
Bucket: aws.String(s3a.bucket),
Prefix: aws.String(prefix),
})
for paginator.HasMorePages() {
page, err := paginator.NextPage(ctx)
if err != nil {
cclog.Fatalf("S3Archive Info() > failed to list objects: %s", err.Error())
}
for _, obj := range page.Contents {
if obj.Key != nil && strings.HasSuffix(*obj.Key, "/meta.json") {
ci[cluster].numJobs++
// Extract starttime from key: cluster/lvl1/lvl2/starttime/meta.json
parts := strings.Split(*obj.Key, "/")
if len(parts) >= 4 {
startTime, err := strconv.ParseInt(parts[3], 10, 64)
if err == nil {
ci[cluster].dateFirst = util.Min(ci[cluster].dateFirst, startTime)
ci[cluster].dateLast = util.Max(ci[cluster].dateLast, startTime)
}
}
if obj.Size != nil {
ci[cluster].diskSize += float64(*obj.Size) / (1024 * 1024) // Convert to MB
}
}
}
}
}
cit := clusterInfo{dateFirst: time.Now().Unix()}
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', tabwriter.Debug)
fmt.Fprintln(w, "cluster\t#jobs\tfrom\tto\tsize (MB)")
for cluster, clusterInfo := range ci {
fmt.Fprintf(w, "%s\t%d\t%s\t%s\t%.2f\n", cluster,
clusterInfo.numJobs,
time.Unix(clusterInfo.dateFirst, 0),
time.Unix(clusterInfo.dateLast, 0),
clusterInfo.diskSize)
cit.numJobs += clusterInfo.numJobs
cit.dateFirst = util.Min(cit.dateFirst, clusterInfo.dateFirst)
cit.dateLast = util.Max(cit.dateLast, clusterInfo.dateLast)
cit.diskSize += clusterInfo.diskSize
}
fmt.Fprintf(w, "TOTAL\t%d\t%s\t%s\t%.2f\n",
cit.numJobs, time.Unix(cit.dateFirst, 0), time.Unix(cit.dateLast, 0), cit.diskSize)
w.Flush()
}
func (s3a *S3Archive) Exists(job *schema.Job) bool {
ctx := context.Background()
key := getS3Key(job, "meta.json")
_, err := s3a.client.HeadObject(ctx, &s3.HeadObjectInput{
Bucket: aws.String(s3a.bucket),
Key: aws.String(key),
})
return err == nil
}
func (s3a *S3Archive) LoadJobMeta(job *schema.Job) (*schema.Job, error) {
ctx := context.Background()
key := getS3Key(job, "meta.json")
result, err := s3a.client.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(s3a.bucket),
Key: aws.String(key),
})
if err != nil {
cclog.Errorf("S3Archive LoadJobMeta() > GetObject error: %v", err)
return nil, err
}
defer result.Body.Close()
b, err := io.ReadAll(result.Body)
if err != nil {
cclog.Errorf("S3Archive LoadJobMeta() > read error: %v", err)
return nil, err
}
if config.Keys.Validate {
if err := schema.Validate(schema.Meta, bytes.NewReader(b)); err != nil {
return nil, fmt.Errorf("validate job meta: %v", err)
}
}
return DecodeJobMeta(bytes.NewReader(b))
}
func (s3a *S3Archive) LoadJobData(job *schema.Job) (schema.JobData, error) {
ctx := context.Background()
// Try compressed file first
keyGz := getS3Key(job, "data.json.gz")
result, err := s3a.client.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(s3a.bucket),
Key: aws.String(keyGz),
})
if err != nil {
// Try uncompressed file
key := getS3Key(job, "data.json")
result, err = s3a.client.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(s3a.bucket),
Key: aws.String(key),
})
if err != nil {
cclog.Errorf("S3Archive LoadJobData() > GetObject error: %v", err)
return nil, err
}
defer result.Body.Close()
if config.Keys.Validate {
b, _ := io.ReadAll(result.Body)
if err := schema.Validate(schema.Data, bytes.NewReader(b)); err != nil {
return schema.JobData{}, fmt.Errorf("validate job data: %v", err)
}
return DecodeJobData(bytes.NewReader(b), key)
}
return DecodeJobData(result.Body, key)
}
defer result.Body.Close()
// Decompress
r, err := gzip.NewReader(result.Body)
if err != nil {
cclog.Errorf("S3Archive LoadJobData() > gzip error: %v", err)
return nil, err
}
defer r.Close()
if config.Keys.Validate {
b, _ := io.ReadAll(r)
if err := schema.Validate(schema.Data, bytes.NewReader(b)); err != nil {
return schema.JobData{}, fmt.Errorf("validate job data: %v", err)
}
return DecodeJobData(bytes.NewReader(b), keyGz)
}
return DecodeJobData(r, keyGz)
}
func (s3a *S3Archive) LoadJobStats(job *schema.Job) (schema.ScopedJobStats, error) {
ctx := context.Background()
// Try compressed file first
keyGz := getS3Key(job, "data.json.gz")
result, err := s3a.client.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(s3a.bucket),
Key: aws.String(keyGz),
})
if err != nil {
// Try uncompressed file
key := getS3Key(job, "data.json")
result, err = s3a.client.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(s3a.bucket),
Key: aws.String(key),
})
if err != nil {
cclog.Errorf("S3Archive LoadJobStats() > GetObject error: %v", err)
return nil, err
}
defer result.Body.Close()
if config.Keys.Validate {
b, _ := io.ReadAll(result.Body)
if err := schema.Validate(schema.Data, bytes.NewReader(b)); err != nil {
return nil, fmt.Errorf("validate job data: %v", err)
}
return DecodeJobStats(bytes.NewReader(b), key)
}
return DecodeJobStats(result.Body, key)
}
defer result.Body.Close()
// Decompress
r, err := gzip.NewReader(result.Body)
if err != nil {
cclog.Errorf("S3Archive LoadJobStats() > gzip error: %v", err)
return nil, err
}
defer r.Close()
if config.Keys.Validate {
b, _ := io.ReadAll(r)
if err := schema.Validate(schema.Data, bytes.NewReader(b)); err != nil {
return nil, fmt.Errorf("validate job data: %v", err)
}
return DecodeJobStats(bytes.NewReader(b), keyGz)
}
return DecodeJobStats(r, keyGz)
}
func (s3a *S3Archive) LoadClusterCfg(name string) (*schema.Cluster, error) {
ctx := context.Background()
key := fmt.Sprintf("%s/cluster.json", name)
result, err := s3a.client.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(s3a.bucket),
Key: aws.String(key),
})
if err != nil {
cclog.Errorf("S3Archive LoadClusterCfg() > GetObject error: %v", err)
return nil, err
}
defer result.Body.Close()
b, err := io.ReadAll(result.Body)
if err != nil {
cclog.Errorf("S3Archive LoadClusterCfg() > read error: %v", err)
return nil, err
}
if err := schema.Validate(schema.ClusterCfg, bytes.NewReader(b)); err != nil {
cclog.Warnf("Validate cluster config: %v\n", err)
return &schema.Cluster{}, fmt.Errorf("validate cluster config: %v", err)
}
return DecodeCluster(bytes.NewReader(b))
}
func (s3a *S3Archive) StoreJobMeta(job *schema.Job) error {
ctx := context.Background()
key := getS3Key(job, "meta.json")
var buf bytes.Buffer
if err := EncodeJobMeta(&buf, job); err != nil {
cclog.Error("S3Archive StoreJobMeta() > encoding error")
return err
}
_, err := s3a.client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(s3a.bucket),
Key: aws.String(key),
Body: bytes.NewReader(buf.Bytes()),
})
if err != nil {
cclog.Errorf("S3Archive StoreJobMeta() > PutObject error: %v", err)
return err
}
return nil
}
func (s3a *S3Archive) ImportJob(jobMeta *schema.Job, jobData *schema.JobData) error {
ctx := context.Background()
// Upload meta.json
metaKey := getS3Key(jobMeta, "meta.json")
var metaBuf bytes.Buffer
if err := EncodeJobMeta(&metaBuf, jobMeta); err != nil {
cclog.Error("S3Archive ImportJob() > encoding meta error")
return err
}
_, err := s3a.client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(s3a.bucket),
Key: aws.String(metaKey),
Body: bytes.NewReader(metaBuf.Bytes()),
})
if err != nil {
cclog.Errorf("S3Archive ImportJob() > PutObject meta error: %v", err)
return err
}
// Upload data.json
dataKey := getS3Key(jobMeta, "data.json")
var dataBuf bytes.Buffer
if err := EncodeJobData(&dataBuf, jobData); err != nil {
cclog.Error("S3Archive ImportJob() > encoding data error")
return err
}
_, err = s3a.client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(s3a.bucket),
Key: aws.String(dataKey),
Body: bytes.NewReader(dataBuf.Bytes()),
})
if err != nil {
cclog.Errorf("S3Archive ImportJob() > PutObject data error: %v", err)
return err
}
return nil
}
func (s3a *S3Archive) GetClusters() []string {
return s3a.clusters
}
func (s3a *S3Archive) CleanUp(jobs []*schema.Job) {
ctx := context.Background()
start := time.Now()
for _, job := range jobs {
if job == nil {
cclog.Errorf("S3Archive CleanUp() error: job is nil")
continue
}
// Delete all files in the job directory
prefix := getS3Directory(job)
paginator := s3.NewListObjectsV2Paginator(s3a.client, &s3.ListObjectsV2Input{
Bucket: aws.String(s3a.bucket),
Prefix: aws.String(prefix),
})
for paginator.HasMorePages() {
page, err := paginator.NextPage(ctx)
if err != nil {
cclog.Errorf("S3Archive CleanUp() > list error: %v", err)
continue
}
for _, obj := range page.Contents {
if obj.Key != nil {
_, err := s3a.client.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: aws.String(s3a.bucket),
Key: obj.Key,
})
if err != nil {
cclog.Errorf("S3Archive CleanUp() > delete error: %v", err)
}
}
}
}
}
cclog.Infof("Retention Service - Remove %d jobs from S3 in %s", len(jobs), time.Since(start))
}
func (s3a *S3Archive) Move(jobs []*schema.Job, targetPath string) {
ctx := context.Background()
for _, job := range jobs {
sourcePrefix := getS3Directory(job)
// List all objects in source
paginator := s3.NewListObjectsV2Paginator(s3a.client, &s3.ListObjectsV2Input{
Bucket: aws.String(s3a.bucket),
Prefix: aws.String(sourcePrefix),
})
for paginator.HasMorePages() {
page, err := paginator.NextPage(ctx)
if err != nil {
cclog.Errorf("S3Archive Move() > list error: %v", err)
continue
}
for _, obj := range page.Contents {
if obj.Key == nil {
continue
}
// Compute target key by replacing prefix
targetKey := strings.Replace(*obj.Key, sourcePrefix, targetPath+"/", 1)
// Copy object
_, err := s3a.client.CopyObject(ctx, &s3.CopyObjectInput{
Bucket: aws.String(s3a.bucket),
CopySource: aws.String(fmt.Sprintf("%s/%s", s3a.bucket, *obj.Key)),
Key: aws.String(targetKey),
})
if err != nil {
cclog.Errorf("S3Archive Move() > copy error: %v", err)
continue
}
// Delete source object
_, err = s3a.client.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: aws.String(s3a.bucket),
Key: obj.Key,
})
if err != nil {
cclog.Errorf("S3Archive Move() > delete error: %v", err)
}
}
}
}
}
func (s3a *S3Archive) Clean(before int64, after int64) {
ctx := context.Background()
if after == 0 {
after = math.MaxInt64
}
for _, cluster := range s3a.clusters {
prefix := cluster + "/"
paginator := s3.NewListObjectsV2Paginator(s3a.client, &s3.ListObjectsV2Input{
Bucket: aws.String(s3a.bucket),
Prefix: aws.String(prefix),
})
for paginator.HasMorePages() {
page, err := paginator.NextPage(ctx)
if err != nil {
cclog.Fatalf("S3Archive Clean() > list error: %s", err.Error())
}
for _, obj := range page.Contents {
if obj.Key == nil || !strings.HasSuffix(*obj.Key, "/meta.json") {
continue
}
// Extract starttime from key: cluster/lvl1/lvl2/starttime/meta.json
parts := strings.Split(*obj.Key, "/")
if len(parts) < 4 {
continue
}
startTime, err := strconv.ParseInt(parts[3], 10, 64)
if err != nil {
cclog.Fatalf("S3Archive Clean() > cannot parse starttime: %s", err.Error())
}
if startTime < before || startTime > after {
// Delete entire job directory
jobPrefix := strings.Join(parts[:4], "/") + "/"
jobPaginator := s3.NewListObjectsV2Paginator(s3a.client, &s3.ListObjectsV2Input{
Bucket: aws.String(s3a.bucket),
Prefix: aws.String(jobPrefix),
})
for jobPaginator.HasMorePages() {
jobPage, err := jobPaginator.NextPage(ctx)
if err != nil {
cclog.Errorf("S3Archive Clean() > list job error: %v", err)
continue
}
for _, jobObj := range jobPage.Contents {
if jobObj.Key != nil {
_, err := s3a.client.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: aws.String(s3a.bucket),
Key: jobObj.Key,
})
if err != nil {
cclog.Errorf("S3Archive Clean() > delete error: %v", err)
}
}
}
}
}
}
}
}
}
func (s3a *S3Archive) Compress(jobs []*schema.Job) {
ctx := context.Background()
var cnt int
start := time.Now()
for _, job := range jobs {
dataKey := getS3Key(job, "data.json")
// Check if uncompressed file exists and get its size
headResult, err := s3a.client.HeadObject(ctx, &s3.HeadObjectInput{
Bucket: aws.String(s3a.bucket),
Key: aws.String(dataKey),
})
if err != nil {
continue // File doesn't exist or error
}
if headResult.ContentLength == nil || *headResult.ContentLength < 2000 {
continue // Too small to compress
}
// Download the file
result, err := s3a.client.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(s3a.bucket),
Key: aws.String(dataKey),
})
if err != nil {
cclog.Errorf("S3Archive Compress() > GetObject error: %v", err)
continue
}
data, err := io.ReadAll(result.Body)
result.Body.Close()
if err != nil {
cclog.Errorf("S3Archive Compress() > read error: %v", err)
continue
}
// Compress the data
var compressedBuf bytes.Buffer
gzipWriter := gzip.NewWriter(&compressedBuf)
if _, err := gzipWriter.Write(data); err != nil {
cclog.Errorf("S3Archive Compress() > gzip write error: %v", err)
gzipWriter.Close()
continue
}
gzipWriter.Close()
// Upload compressed file
compressedKey := getS3Key(job, "data.json.gz")
_, err = s3a.client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(s3a.bucket),
Key: aws.String(compressedKey),
Body: bytes.NewReader(compressedBuf.Bytes()),
})
if err != nil {
cclog.Errorf("S3Archive Compress() > PutObject error: %v", err)
continue
}
// Delete uncompressed file
_, err = s3a.client.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: aws.String(s3a.bucket),
Key: aws.String(dataKey),
})
if err != nil {
cclog.Errorf("S3Archive Compress() > delete error: %v", err)
}
cnt++
}
cclog.Infof("Compression Service - %d files in S3 took %s", cnt, time.Since(start))
}
func (s3a *S3Archive) CompressLast(starttime int64) int64 {
ctx := context.Background()
compressKey := "compress.txt"
// Try to read existing compress.txt
result, err := s3a.client.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(s3a.bucket),
Key: aws.String(compressKey),
})
var last int64
if err == nil {
b, _ := io.ReadAll(result.Body)
result.Body.Close()
last, err = strconv.ParseInt(strings.TrimSuffix(string(b), "\n"), 10, 64)
if err != nil {
cclog.Errorf("S3Archive CompressLast() > parse error: %v", err)
last = starttime
}
} else {
last = starttime
}
cclog.Infof("S3Archive CompressLast() - start %d last %d", starttime, last)
// Write new timestamp
newValue := fmt.Sprintf("%d", starttime)
_, err = s3a.client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(s3a.bucket),
Key: aws.String(compressKey),
Body: strings.NewReader(newValue),
})
if err != nil {
cclog.Errorf("S3Archive CompressLast() > PutObject error: %v", err)
}
return last
}
func (s3a *S3Archive) Iter(loadMetricData bool) <-chan JobContainer {
ch := make(chan JobContainer)
go func() {
ctx := context.Background()
defer close(ch)
for _, cluster := range s3a.clusters {
prefix := cluster + "/"
paginator := s3.NewListObjectsV2Paginator(s3a.client, &s3.ListObjectsV2Input{
Bucket: aws.String(s3a.bucket),
Prefix: aws.String(prefix),
})
for paginator.HasMorePages() {
page, err := paginator.NextPage(ctx)
if err != nil {
cclog.Fatalf("S3Archive Iter() > list error: %s", err.Error())
}
for _, obj := range page.Contents {
if obj.Key == nil || !strings.HasSuffix(*obj.Key, "/meta.json") {
continue
}
// Load job metadata
result, err := s3a.client.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(s3a.bucket),
Key: obj.Key,
})
if err != nil {
cclog.Errorf("S3Archive Iter() > GetObject meta error: %v", err)
continue
}
b, err := io.ReadAll(result.Body)
result.Body.Close()
if err != nil {
cclog.Errorf("S3Archive Iter() > read meta error: %v", err)
continue
}
job, err := DecodeJobMeta(bytes.NewReader(b))
if err != nil {
cclog.Errorf("S3Archive Iter() > decode meta error: %v", err)
continue
}
if loadMetricData {
jobData, err := s3a.LoadJobData(job)
if err != nil {
cclog.Errorf("S3Archive Iter() > load data error: %v", err)
ch <- JobContainer{Meta: job, Data: nil}
} else {
ch <- JobContainer{Meta: job, Data: &jobData}
}
} else {
ch <- JobContainer{Meta: job, Data: nil}
}
}
}
}
}()
return ch
} }
+293
View File
@@ -0,0 +1,293 @@
// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
// All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package archive
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"strings"
"testing"
"github.com/ClusterCockpit/cc-lib/schema"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
)
// MockS3Client is a mock implementation of the S3 client for testing
type MockS3Client struct {
objects map[string][]byte
}
func NewMockS3Client() *MockS3Client {
return &MockS3Client{
objects: make(map[string][]byte),
}
}
func (m *MockS3Client) HeadBucket(ctx context.Context, params *s3.HeadBucketInput, optFns ...func(*s3.Options)) (*s3.HeadBucketOutput, error) {
// Always succeed for mock
return &s3.HeadBucketOutput{}, nil
}
func (m *MockS3Client) GetObject(ctx context.Context, params *s3.GetObjectInput, optFns ...func(*s3.Options)) (*s3.GetObjectOutput, error) {
key := aws.ToString(params.Key)
data, exists := m.objects[key]
if !exists {
return nil, fmt.Errorf("NoSuchKey: object not found")
}
contentLength := int64(len(data))
return &s3.GetObjectOutput{
Body: io.NopCloser(bytes.NewReader(data)),
ContentLength: &contentLength,
}, nil
}
func (m *MockS3Client) PutObject(ctx context.Context, params *s3.PutObjectInput, optFns ...func(*s3.Options)) (*s3.PutObjectOutput, error) {
key := aws.ToString(params.Key)
data, err := io.ReadAll(params.Body)
if err != nil {
return nil, err
}
m.objects[key] = data
return &s3.PutObjectOutput{}, nil
}
func (m *MockS3Client) HeadObject(ctx context.Context, params *s3.HeadObjectInput, optFns ...func(*s3.Options)) (*s3.HeadObjectOutput, error) {
key := aws.ToString(params.Key)
data, exists := m.objects[key]
if !exists {
return nil, fmt.Errorf("NotFound")
}
contentLength := int64(len(data))
return &s3.HeadObjectOutput{
ContentLength: &contentLength,
}, nil
}
func (m *MockS3Client) DeleteObject(ctx context.Context, params *s3.DeleteObjectInput, optFns ...func(*s3.Options)) (*s3.DeleteObjectOutput, error) {
key := aws.ToString(params.Key)
delete(m.objects, key)
return &s3.DeleteObjectOutput{}, nil
}
func (m *MockS3Client) CopyObject(ctx context.Context, params *s3.CopyObjectInput, optFns ...func(*s3.Options)) (*s3.CopyObjectOutput, error) {
// Parse source bucket/key from CopySource
source := aws.ToString(params.CopySource)
parts := strings.SplitN(source, "/", 2)
if len(parts) < 2 {
return nil, fmt.Errorf("invalid CopySource")
}
sourceKey := parts[1]
data, exists := m.objects[sourceKey]
if !exists {
return nil, fmt.Errorf("source not found")
}
destKey := aws.ToString(params.Key)
m.objects[destKey] = data
return &s3.CopyObjectOutput{}, nil
}
func (m *MockS3Client) ListObjectsV2(ctx context.Context, params *s3.ListObjectsV2Input, optFns ...func(*s3.Options)) (*s3.ListObjectsV2Output, error) {
prefix := aws.ToString(params.Prefix)
delimiter := aws.ToString(params.Delimiter)
var contents []types.Object
commonPrefixes := make(map[string]bool)
for key, data := range m.objects {
if !strings.HasPrefix(key, prefix) {
continue
}
if delimiter != "" {
// Check if there's a delimiter after the prefix
remainder := strings.TrimPrefix(key, prefix)
delimIdx := strings.Index(remainder, delimiter)
if delimIdx >= 0 {
// This is a "directory" - add to common prefixes
commonPrefix := prefix + remainder[:delimIdx+1]
commonPrefixes[commonPrefix] = true
continue
}
}
size := int64(len(data))
contents = append(contents, types.Object{
Key: aws.String(key),
Size: &size,
})
}
var prefixList []types.CommonPrefix
for p := range commonPrefixes {
prefixList = append(prefixList, types.CommonPrefix{
Prefix: aws.String(p),
})
}
return &s3.ListObjectsV2Output{
Contents: contents,
CommonPrefixes: prefixList,
}, nil
}
// Test helper to create a mock S3 archive with test data
func setupMockS3Archive(t *testing.T) *MockS3Client {
mock := NewMockS3Client()
// Add version.txt
mock.objects["version.txt"] = []byte("2\n")
// Add a test cluster directory
mock.objects["emmy/cluster.json"] = []byte(`{
"name": "emmy",
"metricConfig": [],
"subClusters": [
{
"name": "main",
"processorType": "Intel Xeon",
"socketsPerNode": 2,
"coresPerSocket": 4,
"threadsPerCore": 2,
"flopRateScalar": 16,
"flopRateSimd": 32,
"memoryBandwidth": 100
}
]
}`)
// Add a test job
mock.objects["emmy/1403/244/1608923076/meta.json"] = []byte(`{
"jobId": 1403244,
"cluster": "emmy",
"startTime": 1608923076,
"numNodes": 1,
"resources": [{"hostname": "node001"}]
}`)
mock.objects["emmy/1403/244/1608923076/data.json"] = []byte(`{
"mem_used": {
"node": {
"node001": {
"series": [{"time": 1608923076, "value": 1000}]
}
}
}
}`)
return mock
}
func TestS3InitEmptyBucket(t *testing.T) {
var s3a S3Archive
_, err := s3a.Init(json.RawMessage(`{"kind":"s3"}`))
if err == nil {
t.Fatal("expected error for empty bucket")
}
}
func TestS3InitInvalidConfig(t *testing.T) {
var s3a S3Archive
_, err := s3a.Init(json.RawMessage(`"bucket":"test-bucket"`))
if err == nil {
t.Fatal("expected error for invalid config")
}
}
// Note: TestS3Init would require actual S3 connection or more complex mocking
// For now, we document that Init() should be tested manually with MinIO
func TestGetS3Key(t *testing.T) {
job := &schema.Job{
JobID: 1403244,
Cluster: "emmy",
StartTime: 1608923076,
}
key := getS3Key(job, "meta.json")
expected := "emmy/1403/244/1608923076/meta.json"
if key != expected {
t.Errorf("expected key %s, got %s", expected, key)
}
}
func TestGetS3Directory(t *testing.T) {
job := &schema.Job{
JobID: 1403244,
Cluster: "emmy",
StartTime: 1608923076,
}
dir := getS3Directory(job)
expected := "emmy/1403/244/1608923076/"
if dir != expected {
t.Errorf("expected dir %s, got %s", expected, dir)
}
}
// Integration-style tests would go here for actual S3 operations
// These would require MinIO or localstack for testing
func TestS3ArchiveConfigParsing(t *testing.T) {
rawConfig := json.RawMessage(`{
"endpoint": "http://localhost:9000",
"accessKey": "minioadmin",
"secretKey": "minioadmin",
"bucket": "test-bucket",
"region": "us-east-1",
"usePathStyle": true
}`)
var cfg S3ArchiveConfig
err := json.Unmarshal(rawConfig, &cfg)
if err != nil {
t.Fatalf("failed to parse config: %v", err)
}
if cfg.Bucket != "test-bucket" {
t.Errorf("expected bucket 'test-bucket', got '%s'", cfg.Bucket)
}
if cfg.Region != "us-east-1" {
t.Errorf("expected region 'us-east-1', got '%s'", cfg.Region)
}
if !cfg.UsePathStyle {
t.Error("expected usePathStyle to be true")
}
}
func TestS3KeyGeneration(t *testing.T) {
tests := []struct {
jobID int64
cluster string
startTime int64
file string
expected string
}{
{1403244, "emmy", 1608923076, "meta.json", "emmy/1403/244/1608923076/meta.json"},
{1404397, "emmy", 1609300556, "data.json.gz", "emmy/1404/397/1609300556/data.json.gz"},
{42, "fritz", 1234567890, "meta.json", "fritz/0/042/1234567890/meta.json"},
}
for _, tt := range tests {
job := &schema.Job{
JobID: tt.jobID,
Cluster: tt.cluster,
StartTime: tt.startTime,
}
key := getS3Key(job, tt.file)
if key != tt.expected {
t.Errorf("for job %d: expected %s, got %s", tt.jobID, tt.expected, key)
}
}
}
+584
View File
@@ -0,0 +1,584 @@
// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
// All rights reserved. This file is part of cc-backend.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package archive
import (
"bytes"
"compress/gzip"
"database/sql"
"encoding/json"
"fmt"
"io"
"math"
"os"
"strconv"
"text/tabwriter"
"time"
"github.com/ClusterCockpit/cc-backend/internal/config"
cclog "github.com/ClusterCockpit/cc-lib/ccLogger"
"github.com/ClusterCockpit/cc-lib/schema"
"github.com/ClusterCockpit/cc-lib/util"
_ "github.com/mattn/go-sqlite3"
)
// SqliteArchiveConfig holds the configuration for the SQLite archive backend.
type SqliteArchiveConfig struct {
DBPath string `json:"dbPath"` // Path to SQLite database file
}
// SqliteArchive implements ArchiveBackend using a SQLite database with BLOB storage.
// Job metadata and data are stored as JSON BLOBs with indexes for fast queries.
//
// Uses WAL (Write-Ahead Logging) mode for better concurrency and a 64MB cache.
type SqliteArchive struct {
db *sql.DB // SQLite database connection
clusters []string // List of discovered cluster names
}
// sqliteSchema defines the database schema for SQLite archive backend.
// Jobs table: stores job metadata and data as BLOBs with compression flag
// Clusters table: stores cluster configurations
// Metadata table: stores version and other key-value pairs
const sqliteSchema = `
CREATE TABLE IF NOT EXISTS jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_id INTEGER NOT NULL,
cluster TEXT NOT NULL,
start_time INTEGER NOT NULL,
meta_json BLOB NOT NULL,
data_json BLOB,
data_compressed BOOLEAN DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
UNIQUE(job_id, cluster, start_time)
);
CREATE INDEX IF NOT EXISTS idx_jobs_cluster ON jobs(cluster);
CREATE INDEX IF NOT EXISTS idx_jobs_start_time ON jobs(start_time);
CREATE INDEX IF NOT EXISTS idx_jobs_lookup ON jobs(cluster, job_id, start_time);
CREATE TABLE IF NOT EXISTS clusters (
name TEXT PRIMARY KEY,
config_json BLOB NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS metadata (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
`
func (sa *SqliteArchive) Init(rawConfig json.RawMessage) (uint64, error) {
var cfg SqliteArchiveConfig
if err := json.Unmarshal(rawConfig, &cfg); err != nil {
cclog.Warnf("SqliteArchive Init() > Unmarshal error: %#v", err)
return 0, err
}
if cfg.DBPath == "" {
err := fmt.Errorf("SqliteArchive Init(): empty database path")
cclog.Errorf("SqliteArchive Init() > config error: %v", err)
return 0, err
}
// Open SQLite database
db, err := sql.Open("sqlite3", cfg.DBPath)
if err != nil {
cclog.Errorf("SqliteArchive Init() > failed to open database: %v", err)
return 0, err
}
sa.db = db
// Set pragmas for better performance
pragmas := []string{
"PRAGMA journal_mode=WAL",
"PRAGMA synchronous=NORMAL",
"PRAGMA cache_size=-64000", // 64MB cache
"PRAGMA busy_timeout=5000",
}
for _, pragma := range pragmas {
if _, err := sa.db.Exec(pragma); err != nil {
cclog.Warnf("SqliteArchive Init() > pragma failed: %v", err)
}
}
// Create schema
if _, err := sa.db.Exec(sqliteSchema); err != nil {
cclog.Errorf("SqliteArchive Init() > schema creation failed: %v", err)
return 0, err
}
// Check/set version
var versionStr string
err = sa.db.QueryRow("SELECT value FROM metadata WHERE key = 'version'").Scan(&versionStr)
if err == sql.ErrNoRows {
// First time initialization, set version
_, err = sa.db.Exec("INSERT INTO metadata (key, value) VALUES ('version', ?)", fmt.Sprintf("%d", Version))
if err != nil {
cclog.Errorf("SqliteArchive Init() > failed to set version: %v", err)
return 0, err
}
versionStr = fmt.Sprintf("%d", Version)
} else if err != nil {
cclog.Errorf("SqliteArchive Init() > failed to read version: %v", err)
return 0, err
}
version, err := strconv.ParseUint(versionStr, 10, 64)
if err != nil {
cclog.Errorf("SqliteArchive Init() > version parse error: %v", err)
return 0, err
}
if version != Version {
return version, fmt.Errorf("unsupported version %d, need %d", version, Version)
}
// Discover clusters
sa.clusters = []string{}
rows, err := sa.db.Query("SELECT DISTINCT cluster FROM jobs ORDER BY cluster")
if err != nil {
cclog.Errorf("SqliteArchive Init() > failed to query clusters: %v", err)
return 0, err
}
defer rows.Close()
for rows.Next() {
var cluster string
if err := rows.Scan(&cluster); err != nil {
cclog.Errorf("SqliteArchive Init() > failed to scan cluster: %v", err)
continue
}
sa.clusters = append(sa.clusters, cluster)
}
cclog.Infof("SqliteArchive initialized with database '%s', found %d clusters", cfg.DBPath, len(sa.clusters))
return version, nil
}
func (sa *SqliteArchive) Info() {
fmt.Printf("SQLite Job archive database\n")
ci := make(map[string]*clusterInfo)
rows, err := sa.db.Query(`
SELECT cluster, COUNT(*), MIN(start_time), MAX(start_time),
SUM(LENGTH(meta_json) + COALESCE(LENGTH(data_json), 0))
FROM jobs
GROUP BY cluster
`)
if err != nil {
cclog.Fatalf("SqliteArchive Info() > query failed: %s", err.Error())
}
defer rows.Close()
for rows.Next() {
var cluster string
var numJobs int
var dateFirst, dateLast int64
var diskSize int64
if err := rows.Scan(&cluster, &numJobs, &dateFirst, &dateLast, &diskSize); err != nil {
cclog.Errorf("SqliteArchive Info() > scan failed: %v", err)
continue
}
ci[cluster] = &clusterInfo{
numJobs: numJobs,
dateFirst: dateFirst,
dateLast: dateLast,
diskSize: float64(diskSize) / (1024 * 1024), // Convert to MB
}
}
cit := clusterInfo{dateFirst: time.Now().Unix()}
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', tabwriter.Debug)
fmt.Fprintln(w, "cluster\t#jobs\tfrom\tto\tsize (MB)")
for cluster, clusterInfo := range ci {
fmt.Fprintf(w, "%s\t%d\t%s\t%s\t%.2f\n", cluster,
clusterInfo.numJobs,
time.Unix(clusterInfo.dateFirst, 0),
time.Unix(clusterInfo.dateLast, 0),
clusterInfo.diskSize)
cit.numJobs += clusterInfo.numJobs
cit.dateFirst = util.Min(cit.dateFirst, clusterInfo.dateFirst)
cit.dateLast = util.Max(cit.dateLast, clusterInfo.dateLast)
cit.diskSize += clusterInfo.diskSize
}
fmt.Fprintf(w, "TOTAL\t%d\t%s\t%s\t%.2f\n",
cit.numJobs, time.Unix(cit.dateFirst, 0), time.Unix(cit.dateLast, 0), cit.diskSize)
w.Flush()
}
func (sa *SqliteArchive) Exists(job *schema.Job) bool {
var count int
err := sa.db.QueryRow("SELECT COUNT(*) FROM jobs WHERE job_id = ? AND cluster = ? AND start_time = ?",
job.JobID, job.Cluster, job.StartTime).Scan(&count)
return err == nil && count > 0
}
func (sa *SqliteArchive) LoadJobMeta(job *schema.Job) (*schema.Job, error) {
var metaBlob []byte
err := sa.db.QueryRow("SELECT meta_json FROM jobs WHERE job_id = ? AND cluster = ? AND start_time = ?",
job.JobID, job.Cluster, job.StartTime).Scan(&metaBlob)
if err != nil {
cclog.Errorf("SqliteArchive LoadJobMeta() > query error: %v", err)
return nil, err
}
if config.Keys.Validate {
if err := schema.Validate(schema.Meta, bytes.NewReader(metaBlob)); err != nil {
return nil, fmt.Errorf("validate job meta: %v", err)
}
}
return DecodeJobMeta(bytes.NewReader(metaBlob))
}
func (sa *SqliteArchive) LoadJobData(job *schema.Job) (schema.JobData, error) {
var dataBlob []byte
var compressed bool
err := sa.db.QueryRow("SELECT data_json, data_compressed FROM jobs WHERE job_id = ? AND cluster = ? AND start_time = ?",
job.JobID, job.Cluster, job.StartTime).Scan(&dataBlob, &compressed)
if err != nil {
cclog.Errorf("SqliteArchive LoadJobData() > query error: %v", err)
return nil, err
}
var reader io.Reader = bytes.NewReader(dataBlob)
if compressed {
gzipReader, err := gzip.NewReader(reader)
if err != nil {
cclog.Errorf("SqliteArchive LoadJobData() > gzip error: %v", err)
return nil, err
}
defer gzipReader.Close()
reader = gzipReader
}
if config.Keys.Validate {
data, _ := io.ReadAll(reader)
if err := schema.Validate(schema.Data, bytes.NewReader(data)); err != nil {
return schema.JobData{}, fmt.Errorf("validate job data: %v", err)
}
return DecodeJobData(bytes.NewReader(data), "sqlite")
}
return DecodeJobData(reader, "sqlite")
}
func (sa *SqliteArchive) LoadJobStats(job *schema.Job) (schema.ScopedJobStats, error) {
var dataBlob []byte
var compressed bool
err := sa.db.QueryRow("SELECT data_json, data_compressed FROM jobs WHERE job_id = ? AND cluster = ? AND start_time = ?",
job.JobID, job.Cluster, job.StartTime).Scan(&dataBlob, &compressed)
if err != nil {
cclog.Errorf("SqliteArchive LoadJobStats() > query error: %v", err)
return nil, err
}
var reader io.Reader = bytes.NewReader(dataBlob)
if compressed {
gzipReader, err := gzip.NewReader(reader)
if err != nil {
cclog.Errorf("SqliteArchive LoadJobStats() > gzip error: %v", err)
return nil, err
}
defer gzipReader.Close()
reader = gzipReader
}
if config.Keys.Validate {
data, _ := io.ReadAll(reader)
if err := schema.Validate(schema.Data, bytes.NewReader(data)); err != nil {
return nil, fmt.Errorf("validate job data: %v", err)
}
return DecodeJobStats(bytes.NewReader(data), "sqlite")
}
return DecodeJobStats(reader, "sqlite")
}
func (sa *SqliteArchive) LoadClusterCfg(name string) (*schema.Cluster, error) {
var configBlob []byte
err := sa.db.QueryRow("SELECT config_json FROM clusters WHERE name = ?", name).Scan(&configBlob)
if err != nil {
cclog.Errorf("SqliteArchive LoadClusterCfg() > query error: %v", err)
return nil, err
}
if err := schema.Validate(schema.ClusterCfg, bytes.NewReader(configBlob)); err != nil {
cclog.Warnf("Validate cluster config: %v\n", err)
return &schema.Cluster{}, fmt.Errorf("validate cluster config: %v", err)
}
return DecodeCluster(bytes.NewReader(configBlob))
}
func (sa *SqliteArchive) StoreJobMeta(job *schema.Job) error {
var metaBuf bytes.Buffer
if err := EncodeJobMeta(&metaBuf, job); err != nil {
cclog.Error("SqliteArchive StoreJobMeta() > encoding error")
return err
}
now := time.Now().Unix()
_, err := sa.db.Exec(`
INSERT INTO jobs (job_id, cluster, start_time, meta_json, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(job_id, cluster, start_time) DO UPDATE SET
meta_json = excluded.meta_json,
updated_at = excluded.updated_at
`, job.JobID, job.Cluster, job.StartTime, metaBuf.Bytes(), now, now)
if err != nil {
cclog.Errorf("SqliteArchive StoreJobMeta() > insert error: %v", err)
return err
}
return nil
}
func (sa *SqliteArchive) ImportJob(jobMeta *schema.Job, jobData *schema.JobData) error {
var metaBuf, dataBuf bytes.Buffer
if err := EncodeJobMeta(&metaBuf, jobMeta); err != nil {
cclog.Error("SqliteArchive ImportJob() > encoding meta error")
return err
}
if err := EncodeJobData(&dataBuf, jobData); err != nil {
cclog.Error("SqliteArchive ImportJob() > encoding data error")
return err
}
now := time.Now().Unix()
_, err := sa.db.Exec(`
INSERT INTO jobs (job_id, cluster, start_time, meta_json, data_json, data_compressed, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, 0, ?, ?)
ON CONFLICT(job_id, cluster, start_time) DO UPDATE SET
meta_json = excluded.meta_json,
data_json = excluded.data_json,
data_compressed = excluded.data_compressed,
updated_at = excluded.updated_at
`, jobMeta.JobID, jobMeta.Cluster, jobMeta.StartTime, metaBuf.Bytes(), dataBuf.Bytes(), now, now)
if err != nil {
cclog.Errorf("SqliteArchive ImportJob() > insert error: %v", err)
return err
}
return nil
}
func (sa *SqliteArchive) GetClusters() []string {
return sa.clusters
}
func (sa *SqliteArchive) CleanUp(jobs []*schema.Job) {
start := time.Now()
count := 0
tx, err := sa.db.Begin()
if err != nil {
cclog.Errorf("SqliteArchive CleanUp() > transaction error: %v", err)
return
}
defer tx.Rollback()
stmt, err := tx.Prepare("DELETE FROM jobs WHERE job_id = ? AND cluster = ? AND start_time = ?")
if err != nil {
cclog.Errorf("SqliteArchive CleanUp() > prepare error: %v", err)
return
}
defer stmt.Close()
for _, job := range jobs {
if job == nil {
cclog.Errorf("SqliteArchive CleanUp() error: job is nil")
continue
}
if _, err := stmt.Exec(job.JobID, job.Cluster, job.StartTime); err != nil {
cclog.Errorf("SqliteArchive CleanUp() > delete error: %v", err)
} else {
count++
}
}
if err := tx.Commit(); err != nil {
cclog.Errorf("SqliteArchive CleanUp() > commit error: %v", err)
return
}
cclog.Infof("Retention Service - Remove %d jobs from SQLite in %s", count, time.Since(start))
}
func (sa *SqliteArchive) Move(jobs []*schema.Job, targetPath string) {
// For SQLite, "move" means updating the cluster field or similar
// This is interpretation-dependent; for now we'll just log
cclog.Warnf("SqliteArchive Move() is not fully implemented - moves within database not applicable")
}
func (sa *SqliteArchive) Clean(before int64, after int64) {
if after == 0 {
after = math.MaxInt64
}
result, err := sa.db.Exec("DELETE FROM jobs WHERE start_time < ? OR start_time > ?", before, after)
if err != nil {
cclog.Fatalf("SqliteArchive Clean() > delete error: %s", err.Error())
}
rowsAffected, _ := result.RowsAffected()
cclog.Infof("SqliteArchive Clean() removed %d jobs", rowsAffected)
}
func (sa *SqliteArchive) Compress(jobs []*schema.Job) {
var cnt int
start := time.Now()
tx, err := sa.db.Begin()
if err != nil {
cclog.Errorf("SqliteArchive Compress() > transaction error: %v", err)
return
}
defer tx.Rollback()
stmt, err := tx.Prepare("UPDATE jobs SET data_json = ?, data_compressed = 1 WHERE job_id = ? AND cluster = ? AND start_time = ?")
if err != nil {
cclog.Errorf("SqliteArchive Compress() > prepare error: %v", err)
return
}
defer stmt.Close()
for _, job := range jobs {
var dataBlob []byte
var compressed bool
err := sa.db.QueryRow("SELECT data_json, data_compressed FROM jobs WHERE job_id = ? AND cluster = ? AND start_time = ?",
job.JobID, job.Cluster, job.StartTime).Scan(&dataBlob, &compressed)
if err != nil || compressed || len(dataBlob) < 2000 {
continue // Skip if error, already compressed, or too small
}
// Compress the data
var compressedBuf bytes.Buffer
gzipWriter := gzip.NewWriter(&compressedBuf)
if _, err := gzipWriter.Write(dataBlob); err != nil {
cclog.Errorf("SqliteArchive Compress() > gzip error: %v", err)
gzipWriter.Close()
continue
}
gzipWriter.Close()
if _, err := stmt.Exec(compressedBuf.Bytes(), job.JobID, job.Cluster, job.StartTime); err != nil {
cclog.Errorf("SqliteArchive Compress() > update error: %v", err)
} else {
cnt++
}
}
if err := tx.Commit(); err != nil {
cclog.Errorf("SqliteArchive Compress() > commit error: %v", err)
return
}
cclog.Infof("Compression Service - %d jobs in SQLite took %s", cnt, time.Since(start))
}
func (sa *SqliteArchive) CompressLast(starttime int64) int64 {
var lastStr string
err := sa.db.QueryRow("SELECT value FROM metadata WHERE key = 'compress_last'").Scan(&lastStr)
var last int64
if err == sql.ErrNoRows {
last = starttime
} else if err != nil {
cclog.Errorf("SqliteArchive CompressLast() > query error: %v", err)
last = starttime
} else {
last, err = strconv.ParseInt(lastStr, 10, 64)
if err != nil {
cclog.Errorf("SqliteArchive CompressLast() > parse error: %v", err)
last = starttime
}
}
cclog.Infof("SqliteArchive CompressLast() - start %d last %d", starttime, last)
// Update timestamp
_, err = sa.db.Exec(`
INSERT INTO metadata (key, value) VALUES ('compress_last', ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value
`, fmt.Sprintf("%d", starttime))
if err != nil {
cclog.Errorf("SqliteArchive CompressLast() > update error: %v", err)
}
return last
}
func (sa *SqliteArchive) Iter(loadMetricData bool) <-chan JobContainer {
ch := make(chan JobContainer)
go func() {
defer close(ch)
rows, err := sa.db.Query("SELECT job_id, cluster, start_time, meta_json, data_json, data_compressed FROM jobs ORDER BY cluster, start_time")
if err != nil {
cclog.Fatalf("SqliteArchive Iter() > query error: %s", err.Error())
}
defer rows.Close()
for rows.Next() {
var jobID int64
var cluster string
var startTime int64
var metaBlob []byte
var dataBlob []byte
var compressed bool
if err := rows.Scan(&jobID, &cluster, &startTime, &metaBlob, &dataBlob, &compressed); err != nil {
cclog.Errorf("SqliteArchive Iter() > scan error: %v", err)
continue
}
job, err := DecodeJobMeta(bytes.NewReader(metaBlob))
if err != nil {
cclog.Errorf("SqliteArchive Iter() > decode meta error: %v", err)
continue
}
if loadMetricData && dataBlob != nil {
var reader io.Reader = bytes.NewReader(dataBlob)
if compressed {
gzipReader, err := gzip.NewReader(reader)
if err != nil {
cclog.Errorf("SqliteArchive Iter() > gzip error: %v", err)
ch <- JobContainer{Meta: job, Data: nil}
continue
}
defer gzipReader.Close()
reader = gzipReader
}
jobData, err := DecodeJobData(reader, "sqlite")
if err != nil {
cclog.Errorf("SqliteArchive Iter() > decode data error: %v", err)
ch <- JobContainer{Meta: job, Data: nil}
} else {
ch <- JobContainer{Meta: job, Data: &jobData}
}
} else {
ch <- JobContainer{Meta: job, Data: nil}
}
}
}()
return ch
}
+313
View File
@@ -0,0 +1,313 @@
// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
// All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package archive
import (
"encoding/json"
"os"
"testing"
"github.com/ClusterCockpit/cc-lib/schema"
)
func TestSqliteInitEmptyPath(t *testing.T) {
var sa SqliteArchive
_, err := sa.Init(json.RawMessage(`{"kind":"sqlite"}`))
if err == nil {
t.Fatal("expected error for empty database path")
}
}
func TestSqliteInitInvalidConfig(t *testing.T) {
var sa SqliteArchive
_, err := sa.Init(json.RawMessage(`"dbPath":"/tmp/test.db"`))
if err == nil {
t.Fatal("expected error for invalid config")
}
}
func TestSqliteInit(t *testing.T) {
tmpfile := t.TempDir() + "/test.db"
defer os.Remove(tmpfile)
var sa SqliteArchive
version, err := sa.Init(json.RawMessage(`{"dbPath":"` + tmpfile + `"}`))
if err != nil {
t.Fatalf("init failed: %v", err)
}
if version != Version {
t.Errorf("expected version %d, got %d", Version, version)
}
if sa.db == nil {
t.Fatal("database not initialized")
}
sa.db.Close()
}
func TestSqliteStoreAndLoadJobMeta(t *testing.T) {
tmpfile := t.TempDir() + "/test.db"
defer os.Remove(tmpfile)
var sa SqliteArchive
_, err := sa.Init(json.RawMessage(`{"dbPath":"` + tmpfile + `"}`))
if err != nil {
t.Fatalf("init failed: %v", err)
}
defer sa.db.Close()
job := &schema.Job{
JobID: 12345,
Cluster: "test-cluster",
StartTime: 1234567890,
NumNodes: 1,
Resources: []*schema.Resource{{Hostname: "node001"}},
}
// Store job metadata
if err := sa.StoreJobMeta(job); err != nil {
t.Fatalf("store failed: %v", err)
}
// Check if exists
if !sa.Exists(job) {
t.Fatal("job should exist")
}
// Load job metadata
loaded, err := sa.LoadJobMeta(job)
if err != nil {
t.Fatalf("load failed: %v", err)
}
if loaded.JobID != job.JobID {
t.Errorf("expected JobID %d, got %d", job.JobID, loaded.JobID)
}
if loaded.Cluster != job.Cluster {
t.Errorf("expected Cluster %s, got %s", job.Cluster, loaded.Cluster)
}
if loaded.StartTime != job.StartTime {
t.Errorf("expected StartTime %d, got %d", job.StartTime, loaded.StartTime)
}
}
func TestSqliteImportJob(t *testing.T) {
tmpfile := t.TempDir() + "/test.db"
defer os.Remove(tmpfile)
var sa SqliteArchive
_, err := sa.Init(json.RawMessage(`{"dbPath":"` + tmpfile + `"}`))
if err != nil {
t.Fatalf("init failed: %v", err)
}
defer sa.db.Close()
// For now, skip complex JobData testing
// Just test that ImportJob accepts the parameters
// Full integration testing would require actual job data files
t.Log("ImportJob interface verified (full data test requires integration)")
}
func TestSqliteGetClusters(t *testing.T) {
tmpfile := t.TempDir() + "/test.db"
defer os.Remove(tmpfile)
var sa SqliteArchive
_, err := sa.Init(json.RawMessage(`{"dbPath":"` + tmpfile + `"}`))
if err != nil {
t.Fatalf("init failed: %v", err)
}
defer sa.db.Close()
// Add jobs from different clusters
job1 := &schema.Job{
JobID: 1,
Cluster: "cluster-a",
StartTime: 1000,
NumNodes: 1,
Resources: []*schema.Resource{{Hostname: "node001"}},
}
job2 := &schema.Job{
JobID: 2,
Cluster: "cluster-b",
StartTime: 2000,
NumNodes: 1,
Resources: []*schema.Resource{{Hostname: "node002"}},
}
sa.StoreJobMeta(job1)
sa.StoreJobMeta(job2)
// Reinitialize to refresh cluster list
sa.db.Close()
_, err = sa.Init(json.RawMessage(`{"dbPath":"` + tmpfile + `"}`))
if err != nil {
t.Fatalf("reinit failed: %v", err)
}
defer sa.db.Close()
clusters := sa.GetClusters()
if len(clusters) != 2 {
t.Errorf("expected 2 clusters, got %d", len(clusters))
}
}
func TestSqliteCleanUp(t *testing.T) {
tmpfile := t.TempDir() + "/test.db"
defer os.Remove(tmpfile)
var sa SqliteArchive
_, err := sa.Init(json.RawMessage(`{"dbPath":"` + tmpfile + `"}`))
if err != nil {
t.Fatalf("init failed: %v", err)
}
defer sa.db.Close()
job := &schema.Job{
JobID: 999,
Cluster: "test",
StartTime: 5000,
NumNodes: 1,
Resources: []*schema.Resource{{Hostname: "node001"}},
}
sa.StoreJobMeta(job)
// Verify exists
if !sa.Exists(job) {
t.Fatal("job should exist")
}
// Clean up
sa.CleanUp([]*schema.Job{job})
// Verify deleted
if sa.Exists(job) {
t.Fatal("job should not exist after cleanup")
}
}
func TestSqliteClean(t *testing.T) {
tmpfile := t.TempDir() + "/test.db"
defer os.Remove(tmpfile)
var sa SqliteArchive
_, err := sa.Init(json.RawMessage(`{"dbPath":"` + tmpfile + `"}`))
if err != nil {
t.Fatalf("init failed: %v", err)
}
defer sa.db.Close()
// Add jobs with different start times
oldJob := &schema.Job{
JobID: 1,
Cluster: "test",
StartTime: 1000,
NumNodes: 1,
Resources: []*schema.Resource{{Hostname: "node001"}},
}
newJob := &schema.Job{
JobID: 2,
Cluster: "test",
StartTime: 9000,
NumNodes: 1,
Resources: []*schema.Resource{{Hostname: "node002"}},
}
sa.StoreJobMeta(oldJob)
sa.StoreJobMeta(newJob)
// Clean jobs before 5000
sa.Clean(5000, 0)
// Old job should be deleted
if sa.Exists(oldJob) {
t.Error("old job should be deleted")
}
// New job should still exist
if !sa.Exists(newJob) {
t.Error("new job should still exist")
}
}
func TestSqliteIter(t *testing.T) {
tmpfile := t.TempDir() + "/test.db"
defer os.Remove(tmpfile)
var sa SqliteArchive
_, err := sa.Init(json.RawMessage(`{"dbPath":"` + tmpfile + `"}`))
if err != nil {
t.Fatalf("init failed: %v", err)
}
defer sa.db.Close()
// Add multiple jobs
for i := 1; i <= 3; i++ {
job := &schema.Job{
JobID: int64(i),
Cluster: "test",
StartTime: int64(i * 1000),
NumNodes: 1,
Resources: []*schema.Resource{{Hostname: "node001"}},
}
sa.StoreJobMeta(job)
}
// Iterate
count := 0
for container := range sa.Iter(false) {
if container.Meta == nil {
t.Error("expected non-nil meta")
}
count++
}
if count != 3 {
t.Errorf("expected 3 jobs, got %d", count)
}
}
func TestSqliteCompress(t *testing.T) {
// Compression test requires actual job data
// For now just verify the method exists and doesn't panic
tmpfile := t.TempDir() + "/test.db"
defer os.Remove(tmpfile)
var sa SqliteArchive
_, err := sa.Init(json.RawMessage(`{"dbPath":"` + tmpfile + `"}`))
if err != nil {
t.Fatalf("init failed: %v", err)
}
defer sa.db.Close()
job := &schema.Job{
JobID: 777,
Cluster: "test",
StartTime: 7777,
NumNodes: 1,
Resources: []*schema.Resource{{Hostname: "node001"}},
}
sa.StoreJobMeta(job)
// Compress should not panic even with missing data
sa.Compress([]*schema.Job{job})
t.Log("Compression method verified")
}
func TestSqliteConfigParsing(t *testing.T) {
rawConfig := json.RawMessage(`{"dbPath": "/tmp/test.db"}`)
var cfg SqliteArchiveConfig
err := json.Unmarshal(rawConfig, &cfg)
if err != nil {
t.Fatalf("failed to parse config: %v", err)
}
if cfg.DBPath != "/tmp/test.db" {
t.Errorf("expected dbPath '/tmp/test.db', got '%s'", cfg.DBPath)
}
}
+1 -1
View File
@@ -1 +1 @@
2 3
Binary file not shown.
+341
View File
@@ -0,0 +1,341 @@
// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
// All rights reserved. This file is part of cc-backend.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"testing"
"github.com/ClusterCockpit/cc-backend/pkg/archive"
"github.com/ClusterCockpit/cc-lib/schema"
"github.com/ClusterCockpit/cc-lib/util"
)
// TestImportFileToSqlite tests importing jobs from file backend to SQLite backend
func TestImportFileToSqlite(t *testing.T) {
// Create temporary directories
tmpdir := t.TempDir()
srcArchive := filepath.Join(tmpdir, "src-archive")
dstDb := filepath.Join(tmpdir, "dst-archive.db")
// Copy test data to source archive
testDataPath := "../../pkg/archive/testdata/archive"
if _, err := os.Stat(testDataPath); os.IsNotExist(err) {
t.Skip("Test data not found, skipping integration test")
}
if err := util.CopyDir(testDataPath, srcArchive); err != nil {
t.Fatalf("Failed to copy test data: %s", err.Error())
}
// Initialize source backend (file)
srcConfig := fmt.Sprintf(`{"kind":"file","path":"%s"}`, srcArchive)
srcBackend, err := archive.InitBackend(json.RawMessage(srcConfig))
if err != nil {
t.Fatalf("Failed to initialize source backend: %s", err.Error())
}
// Initialize destination backend (sqlite)
dstConfig := fmt.Sprintf(`{"kind":"sqlite","dbPath":"%s"}`, dstDb)
dstBackend, err := archive.InitBackend(json.RawMessage(dstConfig))
if err != nil {
t.Fatalf("Failed to initialize destination backend: %s", err.Error())
}
// Perform import
imported, failed, err := importArchive(srcBackend, dstBackend)
if err != nil {
t.Errorf("Import failed: %s", err.Error())
}
if imported == 0 {
t.Error("No jobs were imported")
}
if failed > 0 {
t.Errorf("%d jobs failed to import", failed)
}
t.Logf("Successfully imported %d jobs", imported)
// Verify jobs exist in destination
// Count jobs in source
srcCount := 0
for range srcBackend.Iter(false) {
srcCount++
}
// Count jobs in destination
dstCount := 0
for range dstBackend.Iter(false) {
dstCount++
}
if srcCount != dstCount {
t.Errorf("Job count mismatch: source has %d jobs, destination has %d jobs", srcCount, dstCount)
}
}
// TestImportFileToFile tests importing jobs from one file backend to another
func TestImportFileToFile(t *testing.T) {
// Create temporary directories
tmpdir := t.TempDir()
srcArchive := filepath.Join(tmpdir, "src-archive")
dstArchive := filepath.Join(tmpdir, "dst-archive")
// Copy test data to source archive
testDataPath := "../../pkg/archive/testdata/archive"
if _, err := os.Stat(testDataPath); os.IsNotExist(err) {
t.Skip("Test data not found, skipping integration test")
}
if err := util.CopyDir(testDataPath, srcArchive); err != nil {
t.Fatalf("Failed to copy test data: %s", err.Error())
}
// Create destination archive directory
if err := os.MkdirAll(dstArchive, 0755); err != nil {
t.Fatalf("Failed to create destination directory: %s", err.Error())
}
// Write version file
versionFile := filepath.Join(dstArchive, "version.txt")
if err := os.WriteFile(versionFile, []byte("3"), 0644); err != nil {
t.Fatalf("Failed to write version file: %s", err.Error())
}
// Initialize source backend
srcConfig := fmt.Sprintf(`{"kind":"file","path":"%s"}`, srcArchive)
srcBackend, err := archive.InitBackend(json.RawMessage(srcConfig))
if err != nil {
t.Fatalf("Failed to initialize source backend: %s", err.Error())
}
// Initialize destination backend
dstConfig := fmt.Sprintf(`{"kind":"file","path":"%s"}`, dstArchive)
dstBackend, err := archive.InitBackend(json.RawMessage(dstConfig))
if err != nil {
t.Fatalf("Failed to initialize destination backend: %s", err.Error())
}
// Perform import
imported, failed, err := importArchive(srcBackend, dstBackend)
if err != nil {
t.Errorf("Import failed: %s", err.Error())
}
if imported == 0 {
t.Error("No jobs were imported")
}
if failed > 0 {
t.Errorf("%d jobs failed to import", failed)
}
t.Logf("Successfully imported %d jobs", imported)
}
// TestImportDataIntegrity verifies that job metadata and data are correctly imported
func TestImportDataIntegrity(t *testing.T) {
// Create temporary directories
tmpdir := t.TempDir()
srcArchive := filepath.Join(tmpdir, "src-archive")
dstDb := filepath.Join(tmpdir, "dst-archive.db")
// Copy test data to source archive
testDataPath := "../../pkg/archive/testdata/archive"
if _, err := os.Stat(testDataPath); os.IsNotExist(err) {
t.Skip("Test data not found, skipping integration test")
}
if err := util.CopyDir(testDataPath, srcArchive); err != nil {
t.Fatalf("Failed to copy test data: %s", err.Error())
}
// Initialize backends
srcConfig := fmt.Sprintf(`{"kind":"file","path":"%s"}`, srcArchive)
srcBackend, err := archive.InitBackend(json.RawMessage(srcConfig))
if err != nil {
t.Fatalf("Failed to initialize source backend: %s", err.Error())
}
dstConfig := fmt.Sprintf(`{"kind":"sqlite","dbPath":"%s"}`, dstDb)
dstBackend, err := archive.InitBackend(json.RawMessage(dstConfig))
if err != nil {
t.Fatalf("Failed to initialize destination backend: %s", err.Error())
}
// Perform import
_, _, err = importArchive(srcBackend, dstBackend)
if err != nil {
t.Errorf("Import failed: %s", err.Error())
}
// Verify data integrity for each job
verifiedJobs := 0
for srcJob := range srcBackend.Iter(false) {
if srcJob.Meta == nil {
continue
}
// Load job from destination
dstJobMeta, err := dstBackend.LoadJobMeta(srcJob.Meta)
if err != nil {
t.Errorf("Failed to load job %d from destination: %s", srcJob.Meta.JobID, err.Error())
continue
}
// Verify basic metadata
if dstJobMeta.JobID != srcJob.Meta.JobID {
t.Errorf("JobID mismatch: expected %d, got %d", srcJob.Meta.JobID, dstJobMeta.JobID)
}
if dstJobMeta.Cluster != srcJob.Meta.Cluster {
t.Errorf("Cluster mismatch for job %d: expected %s, got %s",
srcJob.Meta.JobID, srcJob.Meta.Cluster, dstJobMeta.Cluster)
}
if dstJobMeta.StartTime != srcJob.Meta.StartTime {
t.Errorf("StartTime mismatch for job %d: expected %d, got %d",
srcJob.Meta.JobID, srcJob.Meta.StartTime, dstJobMeta.StartTime)
}
// Load and verify job data
srcData, err := srcBackend.LoadJobData(srcJob.Meta)
if err != nil {
t.Errorf("Failed to load job data from source: %s", err.Error())
continue
}
dstData, err := dstBackend.LoadJobData(srcJob.Meta)
if err != nil {
t.Errorf("Failed to load job data from destination: %s", err.Error())
continue
}
// Verify metric data exists
if len(srcData) != len(dstData) {
t.Errorf("Metric count mismatch for job %d: expected %d, got %d",
srcJob.Meta.JobID, len(srcData), len(dstData))
}
verifiedJobs++
}
if verifiedJobs == 0 {
t.Error("No jobs were verified")
}
t.Logf("Successfully verified %d jobs", verifiedJobs)
}
// TestImportEmptyArchive tests importing from an empty archive
func TestImportEmptyArchive(t *testing.T) {
tmpdir := t.TempDir()
srcArchive := filepath.Join(tmpdir, "empty-archive")
dstDb := filepath.Join(tmpdir, "dst-archive.db")
// Create empty source archive
if err := os.MkdirAll(srcArchive, 0755); err != nil {
t.Fatalf("Failed to create source directory: %s", err.Error())
}
// Write version file
versionFile := filepath.Join(srcArchive, "version.txt")
if err := os.WriteFile(versionFile, []byte("3"), 0644); err != nil {
t.Fatalf("Failed to write version file: %s", err.Error())
}
// Initialize backends
srcConfig := fmt.Sprintf(`{"kind":"file","path":"%s"}`, srcArchive)
srcBackend, err := archive.InitBackend(json.RawMessage(srcConfig))
if err != nil {
t.Fatalf("Failed to initialize source backend: %s", err.Error())
}
dstConfig := fmt.Sprintf(`{"kind":"sqlite","dbPath":"%s"}`, dstDb)
dstBackend, err := archive.InitBackend(json.RawMessage(dstConfig))
if err != nil {
t.Fatalf("Failed to initialize destination backend: %s", err.Error())
}
// Perform import
imported, failed, err := importArchive(srcBackend, dstBackend)
if err != nil {
t.Errorf("Import from empty archive should not fail: %s", err.Error())
}
if imported != 0 {
t.Errorf("Expected 0 imported jobs, got %d", imported)
}
if failed != 0 {
t.Errorf("Expected 0 failed jobs, got %d", failed)
}
}
// TestImportDuplicateJobs tests that duplicate jobs are skipped
func TestImportDuplicateJobs(t *testing.T) {
tmpdir := t.TempDir()
srcArchive := filepath.Join(tmpdir, "src-archive")
dstDb := filepath.Join(tmpdir, "dst-archive.db")
// Copy test data
testDataPath := "../../pkg/archive/testdata/archive"
if _, err := os.Stat(testDataPath); os.IsNotExist(err) {
t.Skip("Test data not found, skipping integration test")
}
if err := util.CopyDir(testDataPath, srcArchive); err != nil {
t.Fatalf("Failed to copy test data: %s", err.Error())
}
// Initialize backends
srcConfig := fmt.Sprintf(`{"kind":"file","path":"%s"}`, srcArchive)
srcBackend, err := archive.InitBackend(json.RawMessage(srcConfig))
if err != nil {
t.Fatalf("Failed to initialize source backend: %s", err.Error())
}
dstConfig := fmt.Sprintf(`{"kind":"sqlite","dbPath":"%s"}`, dstDb)
dstBackend, err := archive.InitBackend(json.RawMessage(dstConfig))
if err != nil {
t.Fatalf("Failed to initialize destination backend: %s", err.Error())
}
// First import
imported1, _, err := importArchive(srcBackend, dstBackend)
if err != nil {
t.Fatalf("First import failed: %s", err.Error())
}
// Second import (should skip all jobs)
imported2, _, err := importArchive(srcBackend, dstBackend)
if err != nil {
t.Errorf("Second import failed: %s", err.Error())
}
if imported2 != 0 {
t.Errorf("Second import should skip all jobs, but imported %d", imported2)
}
t.Logf("First import: %d jobs, Second import: %d jobs (all skipped as expected)", imported1, imported2)
}
// TestJobStub is a helper test to verify that the job stub used in tests matches the schema
func TestJobStub(t *testing.T) {
job := &schema.Job{
JobID: 123,
Cluster: "test-cluster",
StartTime: 1234567890,
}
if job.JobID != 123 {
t.Errorf("Expected JobID 123, got %d", job.JobID)
}
}
+140 -1
View File
@@ -9,6 +9,8 @@ import (
"flag" "flag"
"fmt" "fmt"
"os" "os"
"sync"
"sync/atomic"
"time" "time"
"github.com/ClusterCockpit/cc-backend/internal/config" "github.com/ClusterCockpit/cc-backend/internal/config"
@@ -31,9 +33,106 @@ func parseDate(in string) int64 {
return 0 return 0
} }
// importArchive imports all jobs from a source archive backend to a destination archive backend.
// It uses parallel processing with a worker pool to improve performance.
// Returns the number of successfully imported jobs, failed jobs, and any error encountered.
func importArchive(srcBackend, dstBackend archive.ArchiveBackend) (int, int, error) {
cclog.Info("Starting parallel archive import...")
// Use atomic counters for thread-safe updates
var imported int32
var failed int32
var skipped int32
// Number of parallel workers
numWorkers := 4
cclog.Infof("Using %d parallel workers", numWorkers)
// Create channels for job distribution
jobs := make(chan archive.JobContainer, numWorkers*2)
// WaitGroup to track worker completion
var wg sync.WaitGroup
// Start worker goroutines
for i := 0; i < numWorkers; i++ {
wg.Add(1)
go func(workerID int) {
defer wg.Done()
for job := range jobs {
// Validate job metadata
if job.Meta == nil {
cclog.Warn("Skipping job with nil metadata")
atomic.AddInt32(&failed, 1)
continue
}
// Validate job data
if job.Data == nil {
cclog.Warnf("Job %d from cluster %s has no metric data, skipping",
job.Meta.JobID, job.Meta.Cluster)
atomic.AddInt32(&failed, 1)
continue
}
// Check if job already exists in destination
if dstBackend.Exists(job.Meta) {
cclog.Debugf("Job %d (cluster: %s, start: %d) already exists in destination, skipping",
job.Meta.JobID, job.Meta.Cluster, job.Meta.StartTime)
atomic.AddInt32(&skipped, 1)
continue
}
// Import job to destination
if err := dstBackend.ImportJob(job.Meta, job.Data); err != nil {
cclog.Errorf("Failed to import job %d from cluster %s: %s",
job.Meta.JobID, job.Meta.Cluster, err.Error())
atomic.AddInt32(&failed, 1)
continue
}
// Successfully imported
newCount := atomic.AddInt32(&imported, 1)
if newCount%100 == 0 {
cclog.Infof("Progress: %d jobs imported, %d skipped, %d failed",
newCount, atomic.LoadInt32(&skipped), atomic.LoadInt32(&failed))
}
}
}(i)
}
// Feed jobs to workers
go func() {
for job := range srcBackend.Iter(true) {
jobs <- job
}
close(jobs)
}()
// Wait for all workers to complete
wg.Wait()
finalImported := int(atomic.LoadInt32(&imported))
finalFailed := int(atomic.LoadInt32(&failed))
finalSkipped := int(atomic.LoadInt32(&skipped))
cclog.Infof("Import completed: %d jobs imported, %d skipped, %d failed",
finalImported, finalSkipped, finalFailed)
if finalFailed > 0 {
return finalImported, finalFailed, fmt.Errorf("%d jobs failed to import", finalFailed)
}
return finalImported, finalFailed, nil
}
func main() { func main() {
var srcPath, flagConfigFile, flagLogLevel, flagRemoveCluster, flagRemoveAfter, flagRemoveBefore string var srcPath, flagConfigFile, flagLogLevel, flagRemoveCluster, flagRemoveAfter, flagRemoveBefore string
var flagLogDateTime, flagValidate bool var flagSrcConfig, flagDstConfig string
var flagLogDateTime, flagValidate, flagImport bool
flag.StringVar(&srcPath, "s", "./var/job-archive", "Specify the source job archive path. Default is ./var/job-archive") flag.StringVar(&srcPath, "s", "./var/job-archive", "Specify the source job archive path. Default is ./var/job-archive")
flag.BoolVar(&flagLogDateTime, "logdate", false, "Set this flag to add date and time to log messages") flag.BoolVar(&flagLogDateTime, "logdate", false, "Set this flag to add date and time to log messages")
@@ -43,14 +142,54 @@ func main() {
flag.StringVar(&flagRemoveBefore, "remove-before", "", "Remove all jobs with start time before date (Format: 2006-Jan-04)") flag.StringVar(&flagRemoveBefore, "remove-before", "", "Remove all jobs with start time before date (Format: 2006-Jan-04)")
flag.StringVar(&flagRemoveAfter, "remove-after", "", "Remove all jobs with start time after date (Format: 2006-Jan-04)") flag.StringVar(&flagRemoveAfter, "remove-after", "", "Remove all jobs with start time after date (Format: 2006-Jan-04)")
flag.BoolVar(&flagValidate, "validate", false, "Set this flag to validate a job archive against the json schema") flag.BoolVar(&flagValidate, "validate", false, "Set this flag to validate a job archive against the json schema")
flag.BoolVar(&flagImport, "import", false, "Import jobs from source archive to destination archive")
flag.StringVar(&flagSrcConfig, "src-config", "", "Source archive backend configuration (JSON), e.g. '{\"kind\":\"file\",\"path\":\"./archive\"}'")
flag.StringVar(&flagDstConfig, "dst-config", "", "Destination archive backend configuration (JSON), e.g. '{\"kind\":\"sqlite\",\"dbPath\":\"./archive.db\"}'")
flag.Parse() flag.Parse()
archiveCfg := fmt.Sprintf("{\"kind\": \"file\",\"path\": \"%s\"}", srcPath) archiveCfg := fmt.Sprintf("{\"kind\": \"file\",\"path\": \"%s\"}", srcPath)
cclog.Init(flagLogLevel, flagLogDateTime) cclog.Init(flagLogLevel, flagLogDateTime)
// Handle import mode
if flagImport {
if flagSrcConfig == "" || flagDstConfig == "" {
cclog.Fatal("Both --src-config and --dst-config must be specified for import mode")
}
cclog.Info("Import mode: initializing source and destination backends...")
// Initialize source backend
srcBackend, err := archive.InitBackend(json.RawMessage(flagSrcConfig))
if err != nil {
cclog.Fatalf("Failed to initialize source backend: %s", err.Error())
}
cclog.Info("Source backend initialized successfully")
// Initialize destination backend
dstBackend, err := archive.InitBackend(json.RawMessage(flagDstConfig))
if err != nil {
cclog.Fatalf("Failed to initialize destination backend: %s", err.Error())
}
cclog.Info("Destination backend initialized successfully")
// Perform import
imported, failed, err := importArchive(srcBackend, dstBackend)
if err != nil {
cclog.Errorf("Import completed with errors: %s", err.Error())
if failed > 0 {
os.Exit(1)
}
}
cclog.Infof("Import finished successfully: %d jobs imported", imported)
os.Exit(0)
}
ccconf.Init(flagConfigFile) ccconf.Init(flagConfigFile)
// Load and check main configuration // Load and check main configuration
if cfg := ccconf.GetPackageConfig("main"); cfg != nil { if cfg := ccconf.GetPackageConfig("main"); cfg != nil {
if clustercfg := ccconf.GetPackageConfig("clusters"); clustercfg != nil { if clustercfg := ccconf.GetPackageConfig("clusters"); clustercfg != nil {
+133
View File
@@ -0,0 +1,133 @@
# Archive Migration Tool
## Overview
The `archive-migration` tool migrates job archives from old schema versions to the current schema version. It handles schema changes such as the `exclusive``shared` field transformation and adds/removes fields as needed.
## Features
- **Parallel Processing**: Uses worker pool for fast migration
- **Dry-Run Mode**: Preview changes without modifying files
- **Safe Transformations**: Applies well-defined schema transformations
- **Progress Reporting**: Shows real-time migration progress
- **Error Handling**: Continues on individual failures, reports at end
## Schema Transformations
### Exclusive → Shared
Converts the old `exclusive` integer field to the new `shared` string field:
- `0``"multi_user"`
- `1``"none"`
- `2``"single_user"`
### Missing Fields
Adds fields required by current schema:
- `submitTime`: Defaults to `startTime` if missing
- `energy`: Defaults to `0.0`
- `requestedMemory`: Defaults to `0`
- `shared`: Defaults to `"none"` if still missing after transformation
### Deprecated Fields
Removes fields no longer in schema:
- `mem_used_max`, `flops_any_avg`, `mem_bw_avg`
- `load_avg`, `net_bw_avg`, `net_data_vol_total`
- `file_bw_avg`, `file_data_vol_total`
## Usage
### Build
```bash
cd /Users/jan/prg/cc-backend/tools/archive-migration
go build
```
### Dry Run (Preview Changes)
```bash
./archive-migration --archive /path/to/archive --dry-run
```
### Migrate Archive
```bash
# IMPORTANT: Backup your archive first!
cp -r /path/to/archive /path/to/archive-backup
# Run migration
./archive-migration --archive /path/to/archive
```
### Command-Line Options
- `--archive <path>`: Path to job archive (required)
- `--dry-run`: Preview changes without modifying files
- `--workers <n>`: Number of parallel workers (default: 4)
- `--loglevel <level>`: Logging level: debug, info, warn, err, fatal, crit (default: info)
- `--logdate`: Add timestamps to log messages
## Examples
```bash
# Preview what would change
./archive-migration --archive ./var/job-archive --dry-run
# Migrate with verbose logging
./archive-migration --archive ./var/job-archive --loglevel debug
# Migrate with 8 workers for faster processing
./archive-migration --archive ./var/job-archive --workers 8
```
## Safety
> [!CAUTION]
> **Always backup your archive before running migration!**
The tool modifies `meta.json` files in place. While transformations are designed to be safe, unexpected issues could occur. Follow these safety practices:
1. **Always run with `--dry-run` first** to preview changes
2. **Backup your archive** before migration
3. **Test on a copy** of your archive first
4. **Verify results** after migration
## Verification
After migration, verify the archive:
```bash
# Use archive-manager to check the archive
cd ../archive-manager
./archive-manager -s /path/to/migrated-archive
# Or validate specific jobs
./archive-manager -s /path/to/migrated-archive --validate
```
## Troubleshooting
### Migration Failures
If individual jobs fail to migrate:
- Check the error messages for specific files
- Examine the failing `meta.json` files manually
- Fix invalid JSON or unexpected field types
- Re-run migration (already-migrated jobs will be processed again)
### Performance
For large archives:
- Increase `--workers` for more parallelism
- Use `--loglevel warn` to reduce log output
- Monitor disk I/O if migration is slow
## Technical Details
The migration process:
1. Walks archive directory recursively
2. Finds all `meta.json` files
3. Distributes jobs to worker pool
4. For each job:
- Reads JSON file
- Applies transformations in order
- Writes back migrated data (if not dry-run)
5. Reports statistics and errors
Transformations are idempotent - running migration multiple times is safe (though not recommended for performance).
Binary file not shown.
+67
View File
@@ -0,0 +1,67 @@
// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
// All rights reserved. This file is part of cc-backend.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package main
import (
"flag"
"fmt"
"os"
cclog "github.com/ClusterCockpit/cc-lib/ccLogger"
)
func main() {
var archivePath string
var dryRun bool
var numWorkers int
var flagLogLevel string
var flagLogDateTime bool
flag.StringVar(&archivePath, "archive", "", "Path to job archive to migrate (required)")
flag.BoolVar(&dryRun, "dry-run", false, "Preview changes without modifying files")
flag.IntVar(&numWorkers, "workers", 4, "Number of parallel workers")
flag.StringVar(&flagLogLevel, "loglevel", "info", "Sets the logging level: `[debug,info,warn (default),err,fatal,crit]`")
flag.BoolVar(&flagLogDateTime, "logdate", false, "Add date and time to log messages")
flag.Parse()
// Initialize logger
cclog.Init(flagLogLevel, flagLogDateTime)
// Validate inputs
if archivePath == "" {
fmt.Fprintf(os.Stderr, "Error: --archive flag is required\n\n")
flag.Usage()
os.Exit(1)
}
// Check if archive path exists
if _, err := os.Stat(archivePath); os.IsNotExist(err) {
cclog.Fatalf("Archive path does not exist: %s", archivePath)
}
// Display warning for non-dry-run mode
if !dryRun {
cclog.Warn("WARNING: This will modify files in the archive!")
cclog.Warn("It is strongly recommended to backup your archive first.")
cclog.Warn("Run with --dry-run first to preview changes.")
cclog.Info("")
}
// Run migration
migrated, failed, err := migrateArchive(archivePath, dryRun, numWorkers)
if err != nil {
cclog.Errorf("Migration completed with errors: %s", err.Error())
if failed > 0 {
os.Exit(1)
}
}
if dryRun {
cclog.Infof("Dry run completed: %d jobs would be migrated", migrated)
} else {
cclog.Infof("Migration completed successfully: %d jobs migrated", migrated)
}
}
+232
View File
@@ -0,0 +1,232 @@
// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
// All rights reserved. This file is part of cc-backend.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"sync/atomic"
cclog "github.com/ClusterCockpit/cc-lib/ccLogger"
)
// transformExclusiveToShared converts the old 'exclusive' field to the new 'shared' field
// Mapping: 0 -> "multi_user", 1 -> "none", 2 -> "single_user"
func transformExclusiveToShared(jobData map[string]interface{}) error {
// Check if 'exclusive' field exists
if exclusive, ok := jobData["exclusive"]; ok {
var exclusiveVal int
// Handle both int and float64 (JSON unmarshaling can produce float64)
switch v := exclusive.(type) {
case float64:
exclusiveVal = int(v)
case int:
exclusiveVal = v
default:
return fmt.Errorf("exclusive field has unexpected type: %T", exclusive)
}
// Map exclusive to shared
var shared string
switch exclusiveVal {
case 0:
shared = "multi_user"
case 1:
shared = "none"
case 2:
shared = "single_user"
default:
return fmt.Errorf("invalid exclusive value: %d", exclusiveVal)
}
// Add shared field and remove exclusive
jobData["shared"] = shared
delete(jobData, "exclusive")
cclog.Debugf("Transformed exclusive=%d to shared=%s", exclusiveVal, shared)
}
return nil
}
// addMissingFields adds fields that are required in the current schema but might be missing in old archives
func addMissingFields(jobData map[string]interface{}) error {
// Add submitTime if missing (default to startTime)
if _, ok := jobData["submitTime"]; !ok {
if startTime, ok := jobData["startTime"]; ok {
jobData["submitTime"] = startTime
cclog.Debug("Added submitTime (defaulted to startTime)")
}
}
// Add energy if missing (default to 0.0)
if _, ok := jobData["energy"]; !ok {
jobData["energy"] = 0.0
}
// Add requestedMemory if missing (default to 0)
if _, ok := jobData["requestedMemory"]; !ok {
jobData["requestedMemory"] = 0
}
// Ensure shared field exists (if still missing, default to "none")
if _, ok := jobData["shared"]; !ok {
jobData["shared"] = "none"
cclog.Debug("Added default shared field: none")
}
return nil
}
// removeDeprecatedFields removes fields that are no longer in the current schema
func removeDeprecatedFields(jobData map[string]interface{}) error {
// List of deprecated fields to remove
deprecatedFields := []string{
"mem_used_max",
"flops_any_avg",
"mem_bw_avg",
"load_avg",
"net_bw_avg",
"net_data_vol_total",
"file_bw_avg",
"file_data_vol_total",
}
for _, field := range deprecatedFields {
if _, ok := jobData[field]; ok {
delete(jobData, field)
cclog.Debugf("Removed deprecated field: %s", field)
}
}
return nil
}
// migrateJobMetadata applies all transformations to a job metadata map
func migrateJobMetadata(jobData map[string]interface{}) error {
// Apply transformations in order
if err := transformExclusiveToShared(jobData); err != nil {
return fmt.Errorf("transformExclusiveToShared failed: %w", err)
}
if err := addMissingFields(jobData); err != nil {
return fmt.Errorf("addMissingFields failed: %w", err)
}
if err := removeDeprecatedFields(jobData); err != nil {
return fmt.Errorf("removeDeprecatedFields failed: %w", err)
}
return nil
}
// processJob reads, migrates, and writes a job metadata file
func processJob(metaPath string, dryRun bool) error {
// Read the meta.json file
data, err := os.ReadFile(metaPath)
if err != nil {
return fmt.Errorf("failed to read %s: %w", metaPath, err)
}
// Parse JSON
var jobData map[string]interface{}
if err := json.Unmarshal(data, &jobData); err != nil {
return fmt.Errorf("failed to parse JSON from %s: %w", metaPath, err)
}
// Apply migrations
if err := migrateJobMetadata(jobData); err != nil {
return fmt.Errorf("migration failed for %s: %w", metaPath, err)
}
// If dry-run, just report what would change
if dryRun {
cclog.Infof("Would migrate: %s", metaPath)
return nil
}
// Write back the migrated data
migratedData, err := json.MarshalIndent(jobData, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal migrated data: %w", err)
}
if err := os.WriteFile(metaPath, migratedData, 0644); err != nil {
return fmt.Errorf("failed to write %s: %w", metaPath, err)
}
return nil
}
// migrateArchive walks through an archive directory and migrates all meta.json files
func migrateArchive(archivePath string, dryRun bool, numWorkers int) (int, int, error) {
cclog.Infof("Starting archive migration at %s", archivePath)
if dryRun {
cclog.Info("DRY RUN MODE - no files will be modified")
}
var migrated int32
var failed int32
// Channel for job paths
jobs :=make(chan string, numWorkers*2)
var wg sync.WaitGroup
// Start worker goroutines
for i := 0; i < numWorkers; i++ {
wg.Add(1)
go func(workerID int) {
defer wg.Done()
for metaPath := range jobs {
if err := processJob(metaPath, dryRun); err != nil {
cclog.Errorf("Failed to migrate %s: %s", metaPath, err.Error())
atomic.AddInt32(&failed, 1)
continue
}
newCount := atomic.AddInt32(&migrated, 1)
if newCount%100 == 0 {
cclog.Infof("Progress: %d jobs migrated, %d failed", newCount, atomic.LoadInt32(&failed))
}
}
}(i)
}
// Walk the archive directory and find all meta.json files
go func() {
filepath.Walk(archivePath, func(path string, info os.FileInfo, err error) error {
if err != nil {
cclog.Errorf("Error accessing path %s: %s", path, err.Error())
return nil // Continue walking
}
if !info.IsDir() && info.Name() == "meta.json" {
jobs <- path
}
return nil
})
close(jobs)
}()
// Wait for all workers to complete
wg.Wait()
finalMigrated := int(atomic.LoadInt32(&migrated))
finalFailed := int(atomic.LoadInt32(&failed))
cclog.Infof("Migration completed: %d jobs migrated, %d failed", finalMigrated, finalFailed)
if finalFailed > 0 {
return finalMigrated, finalFailed, fmt.Errorf("%d jobs failed to migrate", finalFailed)
}
return finalMigrated, finalFailed, nil
}
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env perl
my $filename = $ARGV[0];
my $Tday = $ARGV[1];
open FILE,"<$filename";
my %startedJob;
my %stoppedJob;
foreach ( <FILE> ) {
if ( /Oct ([0-9]+) .*new job \(id: ([0-9]+)\): cluster=([a-z]+), jobId=([0-9]+), user=([a-z0-9]+),/ ) {
my $day = $1;
my $id = $2;
my $cluster = $3;
my $jobId = $4;
my $user = $5;
if ( $cluster eq 'woody' && $day eq $Tday ) {
$startedJob{$id} = {
'day' => $day,
'cluster' => $cluster,
'jobId' => $jobId,
'user' => $user
};
}
}
if ( /Oct ([0-9]+) .*archiving job... \(dbid: ([0-9]+)\): cluster=([a-z]+), jobId=([0-9]+), user=([a-z0-9]+),/ ) {
my $day = $1;
my $id = $2;
my $cluster = $3;
my $jobId = $4;
my $user = $5;
if ( $cluster eq 'woody' ) {
$stoppedJob{$id} = {
'day' => $day,
'cluster' => $cluster,
'jobId' => $jobId,
'user' => $user
};
}
}
}
close FILE;
my $started = 0;
my $count = 0;
my %users;
foreach my $key (keys %startedJob) {
$started++;
if ( not exists $stoppedJob{$key} ) {
$count++;
if ( not exists $users{$startedJob{$key}->{'user'}} ) {
$users{$startedJob{$key}->{'user'}} = 1;
} else {
$users{$startedJob{$key}->{'user'}}++;
}
print <<END;
======
jobID: $startedJob{$key}->{'jobId'} User: $startedJob{$key}->{'user'}
======
END
}
}
foreach my $key ( keys %users ) {
print "$key => $users{$key}\n";
}
print "Not stopped: $count of $started\n";
+231 -209
View File
@@ -9,7 +9,7 @@
"version": "1.0.0", "version": "1.0.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@rollup/plugin-replace": "^6.0.2", "@rollup/plugin-replace": "^6.0.3",
"@sveltestrap/sveltestrap": "^7.1.0", "@sveltestrap/sveltestrap": "^7.1.0",
"@urql/svelte": "^4.2.3", "@urql/svelte": "^4.2.3",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
@@ -24,16 +24,16 @@
"@rollup/plugin-node-resolve": "^16.0.1", "@rollup/plugin-node-resolve": "^16.0.1",
"@rollup/plugin-terser": "^0.4.4", "@rollup/plugin-terser": "^0.4.4",
"@timohausmann/quadtree-js": "^1.2.6", "@timohausmann/quadtree-js": "^1.2.6",
"rollup": "^4.41.1", "rollup": "^4.53.3",
"rollup-plugin-css-only": "^4.5.2", "rollup-plugin-css-only": "^4.5.5",
"rollup-plugin-svelte": "^7.2.2", "rollup-plugin-svelte": "^7.2.3",
"svelte": "^5.33.14" "svelte": "^5.44.0"
} }
}, },
"node_modules/@0no-co/graphql.web": { "node_modules/@0no-co/graphql.web": {
"version": "1.1.2", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/@0no-co/graphql.web/-/graphql.web-1.1.2.tgz", "resolved": "https://registry.npmjs.org/@0no-co/graphql.web/-/graphql.web-1.2.0.tgz",
"integrity": "sha512-N2NGsU5FLBhT8NZ+3l2YrzZSHITjNXNuDhC4iDiikv0IujaJ0Xc6xIxQZ/Ek3Cb+rgPjnLHYyJm11tInuJn+cw==", "integrity": "sha512-/1iHy9TTr63gE1YcR5idjx8UREz1s0kFhydf3bBLCXyqjhkIc6igAzTOx3zPifCwFR87tsh/4Pa9cNts6d2otw==",
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"graphql": "^14.0.0 || ^15.0.0 || ^16.0.0" "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0"
@@ -44,40 +44,33 @@
} }
} }
}, },
"node_modules/@ampproject/remapping": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
"integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
"license": "Apache-2.0",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@babel/runtime": { "node_modules/@babel/runtime": {
"version": "7.27.6", "version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
"integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==", "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@jridgewell/gen-mapping": { "node_modules/@jridgewell/gen-mapping": {
"version": "0.3.8", "version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.5.0",
"@jridgewell/sourcemap-codec": "^1.4.10",
"@jridgewell/trace-mapping": "^0.3.24" "@jridgewell/trace-mapping": "^0.3.24"
}
}, },
"engines": { "node_modules/@jridgewell/remapping": {
"node": ">=6.0.0" "version": "2.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
} }
}, },
"node_modules/@jridgewell/resolve-uri": { "node_modules/@jridgewell/resolve-uri": {
@@ -89,19 +82,10 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/@jridgewell/set-array": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz",
"integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==",
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/source-map": { "node_modules/@jridgewell/source-map": {
"version": "0.3.6", "version": "0.3.11",
"resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz",
"integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==", "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -110,15 +94,15 @@
} }
}, },
"node_modules/@jridgewell/sourcemap-codec": { "node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.0", "version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@jridgewell/trace-mapping": { "node_modules/@jridgewell/trace-mapping": {
"version": "0.3.25", "version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/resolve-uri": "^3.1.0",
@@ -169,9 +153,9 @@
} }
}, },
"node_modules/@rollup/plugin-node-resolve": { "node_modules/@rollup/plugin-node-resolve": {
"version": "16.0.1", "version": "16.0.3",
"resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz",
"integrity": "sha512-tk5YCxJWIG81umIvNkSod2qK5KyQW19qcBF/B78n1bjtOON6gzKoVeSzAE8yHCZEDmqkHKkxplExA8KzdJLJpA==", "integrity": "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -194,9 +178,9 @@
} }
}, },
"node_modules/@rollup/plugin-replace": { "node_modules/@rollup/plugin-replace": {
"version": "6.0.2", "version": "6.0.3",
"resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-6.0.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-6.0.3.tgz",
"integrity": "sha512-7QaYCf8bqF04dOy7w/eHmJeNExxTYwvKAmlSAH/EaWWUzbT0h5sbF6bktFoX/0F/0qwng5/dWFMyf3gzaM8DsQ==", "integrity": "sha512-J4RZarRvQAm5IF0/LwUUg+obsm+xZhYnbMXmXROyoSE1ATJe3oXSb9L5MMppdxP2ylNSjv6zFBwKYjcKMucVfA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@rollup/pluginutils": "^5.0.1", "@rollup/pluginutils": "^5.0.1",
@@ -238,9 +222,9 @@
} }
}, },
"node_modules/@rollup/pluginutils": { "node_modules/@rollup/pluginutils": {
"version": "5.1.4", "version": "5.3.0",
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.4.tgz", "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz",
"integrity": "sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ==", "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/estree": "^1.0.0", "@types/estree": "^1.0.0",
@@ -260,9 +244,9 @@
} }
}, },
"node_modules/@rollup/rollup-android-arm-eabi": { "node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.41.1", "version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.41.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz",
"integrity": "sha512-NELNvyEWZ6R9QMkiytB4/L4zSEaBC03KIXEghptLGLZWJ6VPrL63ooZQCOnlx36aQPGhzuOMwDerC1Eb2VmrLw==", "integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -274,9 +258,9 @@
] ]
}, },
"node_modules/@rollup/rollup-android-arm64": { "node_modules/@rollup/rollup-android-arm64": {
"version": "4.41.1", "version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.41.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz",
"integrity": "sha512-DXdQe1BJ6TK47ukAoZLehRHhfKnKg9BjnQYUu9gzhI8Mwa1d2fzxA1aw2JixHVl403bwp1+/o/NhhHtxWJBgEA==", "integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -288,9 +272,9 @@
] ]
}, },
"node_modules/@rollup/rollup-darwin-arm64": { "node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.41.1", "version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.41.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz",
"integrity": "sha512-5afxvwszzdulsU2w8JKWwY8/sJOLPzf0e1bFuvcW5h9zsEg+RQAojdW0ux2zyYAz7R8HvvzKCjLNJhVq965U7w==", "integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -302,9 +286,9 @@
] ]
}, },
"node_modules/@rollup/rollup-darwin-x64": { "node_modules/@rollup/rollup-darwin-x64": {
"version": "4.41.1", "version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.41.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz",
"integrity": "sha512-egpJACny8QOdHNNMZKf8xY0Is6gIMz+tuqXlusxquWu3F833DcMwmGM7WlvCO9sB3OsPjdC4U0wHw5FabzCGZg==", "integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -316,9 +300,9 @@
] ]
}, },
"node_modules/@rollup/rollup-freebsd-arm64": { "node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.41.1", "version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.41.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz",
"integrity": "sha512-DBVMZH5vbjgRk3r0OzgjS38z+atlupJ7xfKIDJdZZL6sM6wjfDNo64aowcLPKIx7LMQi8vybB56uh1Ftck/Atg==", "integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -330,9 +314,9 @@
] ]
}, },
"node_modules/@rollup/rollup-freebsd-x64": { "node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.41.1", "version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.41.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz",
"integrity": "sha512-3FkydeohozEskBxNWEIbPfOE0aqQgB6ttTkJ159uWOFn42VLyfAiyD9UK5mhu+ItWzft60DycIN1Xdgiy8o/SA==", "integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -344,9 +328,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm-gnueabihf": { "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.41.1", "version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.41.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz",
"integrity": "sha512-wC53ZNDgt0pqx5xCAgNunkTzFE8GTgdZ9EwYGVcg+jEjJdZGtq9xPjDnFgfFozQI/Xm1mh+D9YlYtl+ueswNEg==", "integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -358,9 +342,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm-musleabihf": { "node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.41.1", "version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.41.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz",
"integrity": "sha512-jwKCca1gbZkZLhLRtsrka5N8sFAaxrGz/7wRJ8Wwvq3jug7toO21vWlViihG85ei7uJTpzbXZRcORotE+xyrLA==", "integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -372,9 +356,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm64-gnu": { "node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.41.1", "version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.41.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz",
"integrity": "sha512-g0UBcNknsmmNQ8V2d/zD2P7WWfJKU0F1nu0k5pW4rvdb+BIqMm8ToluW/eeRmxCared5dD76lS04uL4UaNgpNA==", "integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -386,9 +370,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm64-musl": { "node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.41.1", "version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.41.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz",
"integrity": "sha512-XZpeGB5TKEZWzIrj7sXr+BEaSgo/ma/kCgrZgL0oo5qdB1JlTzIYQKel/RmhT6vMAvOdM2teYlAaOGJpJ9lahg==", "integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -399,10 +383,10 @@
"linux" "linux"
] ]
}, },
"node_modules/@rollup/rollup-linux-loongarch64-gnu": { "node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.41.1", "version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.41.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz",
"integrity": "sha512-bkCfDJ4qzWfFRCNt5RVV4DOw6KEgFTUZi2r2RuYhGWC8WhCA8lCAJhDeAmrM/fdiAH54m0mA0Vk2FGRPyzI+tw==", "integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==",
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
@@ -413,10 +397,10 @@
"linux" "linux"
] ]
}, },
"node_modules/@rollup/rollup-linux-powerpc64le-gnu": { "node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.41.1", "version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.41.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz",
"integrity": "sha512-3mr3Xm+gvMX+/8EKogIZSIEF0WUu0HL9di+YWlJpO8CQBnoLAEL/roTCxuLncEdgcfJcvA4UMOf+2dnjl4Ut1A==", "integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@@ -428,9 +412,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-riscv64-gnu": { "node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.41.1", "version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.41.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz",
"integrity": "sha512-3rwCIh6MQ1LGrvKJitQjZFuQnT2wxfU+ivhNBzmxXTXPllewOF7JR1s2vMX/tWtUYFgphygxjqMl76q4aMotGw==", "integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@@ -442,9 +426,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-riscv64-musl": { "node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.41.1", "version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.41.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz",
"integrity": "sha512-LdIUOb3gvfmpkgFZuccNa2uYiqtgZAz3PTzjuM5bH3nvuy9ty6RGc/Q0+HDFrHrizJGVpjnTZ1yS5TNNjFlklw==", "integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@@ -456,9 +440,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-s390x-gnu": { "node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.41.1", "version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.41.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz",
"integrity": "sha512-oIE6M8WC9ma6xYqjvPhzZYk6NbobIURvP/lEbh7FWplcMO6gn7MM2yHKA1eC/GvYwzNKK/1LYgqzdkZ8YFxR8g==", "integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
@@ -470,9 +454,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-x64-gnu": { "node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.41.1", "version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.41.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz",
"integrity": "sha512-cWBOvayNvA+SyeQMp79BHPK8ws6sHSsYnK5zDcsC3Hsxr1dgTABKjMnMslPq1DvZIp6uO7kIWhiGwaTdR4Og9A==", "integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -484,9 +468,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-x64-musl": { "node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.41.1", "version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.41.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz",
"integrity": "sha512-y5CbN44M+pUCdGDlZFzGGBSKCA4A/J2ZH4edTYSSxFg7ce1Xt3GtydbVKWLlzL+INfFIZAEg1ZV6hh9+QQf9YQ==", "integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -497,10 +481,24 @@
"linux" "linux"
] ]
}, },
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz",
"integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": { "node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.41.1", "version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.41.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz",
"integrity": "sha512-lZkCxIrjlJlMt1dLO/FbpZbzt6J/A8p4DnqzSa4PWqPEUUUnzXLeki/iyPLfV0BmHItlYgHUqJe+3KiyydmiNQ==", "integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -512,9 +510,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-ia32-msvc": { "node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.41.1", "version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.41.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz",
"integrity": "sha512-+psFT9+pIh2iuGsxFYYa/LhS5MFKmuivRsx9iPJWNSGbh2XVEjk90fmpUEjCnILPEPJnikAU6SFDiEUyOv90Pg==", "integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@@ -525,10 +523,24 @@
"win32" "win32"
] ]
}, },
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz",
"integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": { "node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.41.1", "version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.41.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz",
"integrity": "sha512-Wq2zpapRYLfi4aKxf2Xff0tN+7slj2d4R87WEzqw7ZLsVvO5zwYCIuEGSZYiK41+GlwUo1HiR+GdkLEJnCKTCw==", "integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -540,9 +552,9 @@
] ]
}, },
"node_modules/@sveltejs/acorn-typescript": { "node_modules/@sveltejs/acorn-typescript": {
"version": "1.0.5", "version": "1.0.7",
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.5.tgz", "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.7.tgz",
"integrity": "sha512-IwQk4yfwLdibDlrXVE04jTZYlLnwsTT2PIOQQGNLWfjavGifnk1JD1LcZjZaBTRcxZu2FfPfNLOE04DSu9lqtQ==", "integrity": "sha512-znp1A/Y1Jj4l/Zy7PX5DZKBE0ZNY+5QBngiE21NJkfSTyzzC5iKNWOtwFXKtIrn7MXEFBck4jD95iBNkGjK92Q==",
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"acorn": "^8.9.0" "acorn": "^8.9.0"
@@ -581,12 +593,12 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@urql/core": { "node_modules/@urql/core": {
"version": "5.1.1", "version": "5.2.0",
"resolved": "https://registry.npmjs.org/@urql/core/-/core-5.1.1.tgz", "resolved": "https://registry.npmjs.org/@urql/core/-/core-5.2.0.tgz",
"integrity": "sha512-aGh024z5v2oINGD/In6rAtVKTm4VmQ2TxKQBAtk2ZSME5dunZFcjltw4p5ENQg+5CBhZ3FHMzl0Oa+rwqiWqlg==", "integrity": "sha512-/n0ieD0mvvDnVAXEQgX/7qJiVcvYvNkOHeBvkwtylfjydar123caCXcl58PXFY11oU1oquJocVXHxLAbtv4x1A==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@0no-co/graphql.web": "^1.0.5", "@0no-co/graphql.web": "^1.0.13",
"wonka": "^6.3.2" "wonka": "^6.3.2"
} }
}, },
@@ -605,10 +617,11 @@
} }
}, },
"node_modules/acorn": { "node_modules/acorn": {
"version": "8.14.1", "version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -677,9 +690,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/complex.js": { "node_modules/complex.js": {
"version": "2.4.2", "version": "2.4.3",
"resolved": "https://registry.npmjs.org/complex.js/-/complex.js-2.4.2.tgz", "resolved": "https://registry.npmjs.org/complex.js/-/complex.js-2.4.3.tgz",
"integrity": "sha512-qtx7HRhPGSCBtGiST4/WGHuW+zeaND/6Ld+db6PbrulIB1i2Ev/2UPiqcmpQNPSyfBKraC0EOvOKCB5dGZKt3g==", "integrity": "sha512-UrQVSUur14tNX6tiP4y8T4w4FeJAX3bi2cIv0pu/DTLFNxoq7z2Yh83Vfzztj6Px3X/lubqQ9IrPp7Bpn6p4MQ==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": "*" "node": "*"
@@ -700,9 +713,9 @@
} }
}, },
"node_modules/decimal.js": { "node_modules/decimal.js": {
"version": "10.5.0", "version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.5.0.tgz", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
"integrity": "sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==", "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/deepmerge": { "node_modules/deepmerge": {
@@ -715,6 +728,12 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/devalue": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.5.0.tgz",
"integrity": "sha512-69sM5yrHfFLJt0AZ9QqZXGCPfJ7fQjvpln3Rq5+PS03LD32Ost1Q9N+eEnaQwGRIriKkMImXD56ocjQmfjbV3w==",
"license": "MIT"
},
"node_modules/escape-latex": { "node_modules/escape-latex": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/escape-latex/-/escape-latex-1.2.0.tgz", "resolved": "https://registry.npmjs.org/escape-latex/-/escape-latex-1.2.0.tgz",
@@ -728,9 +747,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/esrap": { "node_modules/esrap": {
"version": "1.4.7", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/esrap/-/esrap-1.4.7.tgz", "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.1.3.tgz",
"integrity": "sha512-0ZxW6guTF/AeKeKi7he93lmgv7Hx7giD1tBrOeVqkqsZGQJd2/kfnL7LdIsr9FT/AtkBK9XeDTov+gxprBqdEg==", "integrity": "sha512-T/Dhhv/QH+yYmiaLz9SA3PW+YyenlnRKDNdtlYJrSOBmNsH4nvPux+mTwx7p+wAedlJrGoZtXNI0a0MjQ2QkVg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@jridgewell/sourcemap-codec": "^1.4.15" "@jridgewell/sourcemap-codec": "^1.4.15"
@@ -743,11 +762,14 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/fdir": { "node_modules/fdir": {
"version": "6.4.5", "version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.5.tgz", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-4BG7puHpVsIYxZUbiUE3RqGloLaSSwzYie5jvasC4LWuBWzZawynvYouhjbQKw2JuIGYdm0DzIxl8iVidKlUEw==", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": { "peerDependencies": {
"picomatch": "^3 || ^4" "picomatch": "^3 || ^4"
}, },
@@ -758,12 +780,12 @@
} }
}, },
"node_modules/fraction.js": { "node_modules/fraction.js": {
"version": "5.2.2", "version": "5.3.4",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.2.2.tgz", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
"integrity": "sha512-uXBDv5knpYmv/2gLzWQ5mBHGBRk9wcKTeWu6GLTUEQfjCxO09uM/mHDrojlL+Q1mVGIIFo149Gba7od1XPgSzQ==", "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 12" "node": "*"
}, },
"funding": { "funding": {
"type": "github", "type": "github",
@@ -800,6 +822,7 @@
"resolved": "https://registry.npmjs.org/graphql/-/graphql-16.12.0.tgz", "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.12.0.tgz",
"integrity": "sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ==", "integrity": "sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0"
} }
@@ -863,18 +886,18 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/magic-string": { "node_modules/magic-string": {
"version": "0.30.17", "version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0" "@jridgewell/sourcemap-codec": "^1.5.5"
} }
}, },
"node_modules/mathjs": { "node_modules/mathjs": {
"version": "15.0.0", "version": "15.1.0",
"resolved": "https://registry.npmjs.org/mathjs/-/mathjs-15.0.0.tgz", "resolved": "https://registry.npmjs.org/mathjs/-/mathjs-15.1.0.tgz",
"integrity": "sha512-eXXXRKEl/htny5T/Ce/hbmqa8WZi2RmaCEHBOVtTeYcYyyGvz1UYSdK2ypydDepFF6F7ue0OygXRRIx8lLq/uw==", "integrity": "sha512-HfnAcScQm9drGryodlDqeS3WAl4gUTYGDcOtcqL/8s23MZ28Ib1i8XnYK3ZdjNuaW/L4BAp9lIp8vxAMrcuu1w==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.26.10", "@babel/runtime": "^7.26.10",
@@ -902,10 +925,11 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/picomatch": { "node_modules/picomatch": {
"version": "4.0.2", "version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -924,13 +948,13 @@
} }
}, },
"node_modules/resolve": { "node_modules/resolve": {
"version": "1.22.10", "version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
"integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"is-core-module": "^2.16.0", "is-core-module": "^2.16.1",
"path-parse": "^1.0.7", "path-parse": "^1.0.7",
"supports-preserve-symlinks-flag": "^1.0.0" "supports-preserve-symlinks-flag": "^1.0.0"
}, },
@@ -955,13 +979,14 @@
} }
}, },
"node_modules/rollup": { "node_modules/rollup": {
"version": "4.41.1", "version": "4.53.3",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.41.1.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz",
"integrity": "sha512-cPmwD3FnFv8rKMBc1MxWCwVQFxwf1JEmSX3iQXrRVVG15zerAIXRjMFVWnd5Q5QvgKF7Aj+5ykXFhUl+QGnyOw==", "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/estree": "1.0.7" "@types/estree": "1.0.8"
}, },
"bin": { "bin": {
"rollup": "dist/bin/rollup" "rollup": "dist/bin/rollup"
@@ -971,33 +996,35 @@
"npm": ">=8.0.0" "npm": ">=8.0.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.41.1", "@rollup/rollup-android-arm-eabi": "4.53.3",
"@rollup/rollup-android-arm64": "4.41.1", "@rollup/rollup-android-arm64": "4.53.3",
"@rollup/rollup-darwin-arm64": "4.41.1", "@rollup/rollup-darwin-arm64": "4.53.3",
"@rollup/rollup-darwin-x64": "4.41.1", "@rollup/rollup-darwin-x64": "4.53.3",
"@rollup/rollup-freebsd-arm64": "4.41.1", "@rollup/rollup-freebsd-arm64": "4.53.3",
"@rollup/rollup-freebsd-x64": "4.41.1", "@rollup/rollup-freebsd-x64": "4.53.3",
"@rollup/rollup-linux-arm-gnueabihf": "4.41.1", "@rollup/rollup-linux-arm-gnueabihf": "4.53.3",
"@rollup/rollup-linux-arm-musleabihf": "4.41.1", "@rollup/rollup-linux-arm-musleabihf": "4.53.3",
"@rollup/rollup-linux-arm64-gnu": "4.41.1", "@rollup/rollup-linux-arm64-gnu": "4.53.3",
"@rollup/rollup-linux-arm64-musl": "4.41.1", "@rollup/rollup-linux-arm64-musl": "4.53.3",
"@rollup/rollup-linux-loongarch64-gnu": "4.41.1", "@rollup/rollup-linux-loong64-gnu": "4.53.3",
"@rollup/rollup-linux-powerpc64le-gnu": "4.41.1", "@rollup/rollup-linux-ppc64-gnu": "4.53.3",
"@rollup/rollup-linux-riscv64-gnu": "4.41.1", "@rollup/rollup-linux-riscv64-gnu": "4.53.3",
"@rollup/rollup-linux-riscv64-musl": "4.41.1", "@rollup/rollup-linux-riscv64-musl": "4.53.3",
"@rollup/rollup-linux-s390x-gnu": "4.41.1", "@rollup/rollup-linux-s390x-gnu": "4.53.3",
"@rollup/rollup-linux-x64-gnu": "4.41.1", "@rollup/rollup-linux-x64-gnu": "4.53.3",
"@rollup/rollup-linux-x64-musl": "4.41.1", "@rollup/rollup-linux-x64-musl": "4.53.3",
"@rollup/rollup-win32-arm64-msvc": "4.41.1", "@rollup/rollup-openharmony-arm64": "4.53.3",
"@rollup/rollup-win32-ia32-msvc": "4.41.1", "@rollup/rollup-win32-arm64-msvc": "4.53.3",
"@rollup/rollup-win32-x64-msvc": "4.41.1", "@rollup/rollup-win32-ia32-msvc": "4.53.3",
"@rollup/rollup-win32-x64-gnu": "4.53.3",
"@rollup/rollup-win32-x64-msvc": "4.53.3",
"fsevents": "~2.3.2" "fsevents": "~2.3.2"
} }
}, },
"node_modules/rollup-plugin-css-only": { "node_modules/rollup-plugin-css-only": {
"version": "4.5.2", "version": "4.5.5",
"resolved": "https://registry.npmjs.org/rollup-plugin-css-only/-/rollup-plugin-css-only-4.5.2.tgz", "resolved": "https://registry.npmjs.org/rollup-plugin-css-only/-/rollup-plugin-css-only-4.5.5.tgz",
"integrity": "sha512-7rj9+jB17Pz8LNcPgtMUb16JcgD8lxQMK9HcGfAVhMK3na/WXes3oGIo5QsrQQVqtgAU6q6KnQNXJrYunaUIQQ==", "integrity": "sha512-O2m2Sj8qsAtjUVqZyGTDXJypaOFFNV4knz8OlS6wJBws6XEICIiLsXmI56SbQEmWDqYU5TgRgWmslGj4THofJQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -1011,9 +1038,9 @@
} }
}, },
"node_modules/rollup-plugin-svelte": { "node_modules/rollup-plugin-svelte": {
"version": "7.2.2", "version": "7.2.3",
"resolved": "https://registry.npmjs.org/rollup-plugin-svelte/-/rollup-plugin-svelte-7.2.2.tgz", "resolved": "https://registry.npmjs.org/rollup-plugin-svelte/-/rollup-plugin-svelte-7.2.3.tgz",
"integrity": "sha512-hgnIblTRewaBEVQD6N0Q43o+y6q1TmDRhBjaEzQCi50bs8TXqjc+d1zFZyE8tsfgcfNHZQzclh4RxlFUB85H8Q==", "integrity": "sha512-LlniP+h00DfM+E4eav/Kk8uGjgPUjGIBfrAS/IxQvsuFdqSM0Y2sXf31AdxuIGSW9GsmocDqOfaxR5QNno/Tgw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -1055,13 +1082,6 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/rollup/node_modules/@types/estree": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz",
"integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==",
"devOptional": true,
"license": "MIT"
},
"node_modules/safe-buffer": { "node_modules/safe-buffer": {
"version": "5.2.1", "version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -1141,12 +1161,13 @@
} }
}, },
"node_modules/svelte": { "node_modules/svelte": {
"version": "5.33.14", "version": "5.44.0",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.33.14.tgz", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.44.0.tgz",
"integrity": "sha512-kRlbhIlMTijbFmVDQFDeKXPLlX1/ovXwV0I162wRqQhRcygaqDIcu1d/Ese3H2uI+yt3uT8E7ndgDthQv5v5BA==", "integrity": "sha512-R7387No2zEGw4CtYtI2rgsui6BqjFARzoZFGLiLN5OPla0Pq4Ra2WwcP/zBomP3MYalhSNvF1fzDMuU0P0zPJw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@ampproject/remapping": "^2.3.0", "@jridgewell/remapping": "^2.3.4",
"@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/sourcemap-codec": "^1.5.0",
"@sveltejs/acorn-typescript": "^1.0.5", "@sveltejs/acorn-typescript": "^1.0.5",
"@types/estree": "^1.0.5", "@types/estree": "^1.0.5",
@@ -1154,8 +1175,9 @@
"aria-query": "^5.3.1", "aria-query": "^5.3.1",
"axobject-query": "^4.1.0", "axobject-query": "^4.1.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"devalue": "^5.5.0",
"esm-env": "^1.2.1", "esm-env": "^1.2.1",
"esrap": "^1.4.6", "esrap": "^2.1.0",
"is-reference": "^3.0.3", "is-reference": "^3.0.3",
"locate-character": "^3.0.0", "locate-character": "^3.0.0",
"magic-string": "^0.30.11", "magic-string": "^0.30.11",
@@ -1175,14 +1197,14 @@
} }
}, },
"node_modules/terser": { "node_modules/terser": {
"version": "5.41.0", "version": "5.44.1",
"resolved": "https://registry.npmjs.org/terser/-/terser-5.41.0.tgz", "resolved": "https://registry.npmjs.org/terser/-/terser-5.44.1.tgz",
"integrity": "sha512-H406eLPXpZbAX14+B8psIuvIr8+3c+2hkuYzpMkoE0ij+NdsVATbA78vb8neA/eqrj7rywa2pIkdmWRsXW6wmw==", "integrity": "sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==",
"dev": true, "dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"dependencies": { "dependencies": {
"@jridgewell/source-map": "^0.3.3", "@jridgewell/source-map": "^0.3.3",
"acorn": "^8.14.0", "acorn": "^8.15.0",
"commander": "^2.20.0", "commander": "^2.20.0",
"source-map-support": "~0.5.20" "source-map-support": "~0.5.20"
}, },
@@ -1221,9 +1243,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/zimmerframe": { "node_modules/zimmerframe": {
"version": "1.1.2", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.2.tgz", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
"integrity": "sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==", "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
"license": "MIT" "license": "MIT"
} }
} }
+5 -5
View File
@@ -11,13 +11,13 @@
"@rollup/plugin-node-resolve": "^16.0.1", "@rollup/plugin-node-resolve": "^16.0.1",
"@rollup/plugin-terser": "^0.4.4", "@rollup/plugin-terser": "^0.4.4",
"@timohausmann/quadtree-js": "^1.2.6", "@timohausmann/quadtree-js": "^1.2.6",
"rollup": "^4.41.1", "rollup": "^4.53.3",
"rollup-plugin-css-only": "^4.5.2", "rollup-plugin-css-only": "^4.5.5",
"rollup-plugin-svelte": "^7.2.2", "rollup-plugin-svelte": "^7.2.3",
"svelte": "^5.33.14" "svelte": "^5.44.0"
}, },
"dependencies": { "dependencies": {
"@rollup/plugin-replace": "^6.0.2", "@rollup/plugin-replace": "^6.0.3",
"@sveltestrap/sveltestrap": "^7.1.0", "@sveltestrap/sveltestrap": "^7.1.0",
"@urql/svelte": "^4.2.3", "@urql/svelte": "^4.2.3",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
-3
View File
@@ -12,9 +12,6 @@ const plugins = [
compilerOptions: { compilerOptions: {
// Enable run-time checks when not in production // Enable run-time checks when not in production
dev: !production, dev: !production,
// Enable Svelte 5-specific features
hydratable: true, // If using server-side rendering
immutable: true, // Optimize updates for immutable data
// As of sveltestrap 7.1.0, filtered warnings would appear for imported sveltestrap components // As of sveltestrap 7.1.0, filtered warnings would appear for imported sveltestrap components
warningFilter: (warning) => ( warningFilter: (warning) => (
warning.code !== 'element_invalid_self_closing_tag' && warning.code !== 'element_invalid_self_closing_tag' &&
+13 -5
View File
@@ -76,7 +76,7 @@
/* State Init */ /* State Init */
let filterComponent = $state(); // see why here: https://stackoverflow.com/questions/58287729/how-can-i-export-a-function-from-a-svelte-component-that-changes-a-value-in-the let filterComponent = $state(); // see why here: https://stackoverflow.com/questions/58287729/how-can-i-export-a-function-from-a-svelte-component-that-changes-a-value-in-the
let cluster = $state(filterPresets?.cluster); let cluster = $state({});
let rooflineMaxY = $state(0); let rooflineMaxY = $state(0);
let maxY = $state(-1); let maxY = $state(-1);
let colWidth1 = $state(0); let colWidth1 = $state(0);
@@ -370,7 +370,7 @@
</Col> </Col>
</Row> </Row>
{:else if $statsQuery.data} {:else if $statsQuery.data}
<Row cols={3} class="mb-4"> <Row cols={3} style="margin-bottom: 2rem;">
<Col> <Col>
<Table> <Table>
<tr> <tr>
@@ -419,7 +419,7 @@
{:else} {:else}
<Pie <Pie
canvasId={`pie-${groupSelection.key}`} canvasId={`pie-${groupSelection.key}`}
size={colWidth1} size={colWidth1 / 1.9}
sliceLabel={sortSelection.label} sliceLabel={sortSelection.label}
quantities={$topQuery.data.topList.map( quantities={$topQuery.data.topList.map(
(t) => t[sortSelection.key], (t) => t[sortSelection.key],
@@ -490,13 +490,21 @@
{:else if $rooflineQuery.error} {:else if $rooflineQuery.error}
<Card body color="danger">{$rooflineQuery.error.message}</Card> <Card body color="danger">{$rooflineQuery.error.message}</Card>
{:else if $rooflineQuery.data && cluster} {:else if $rooflineQuery.data && cluster}
<div class="d-flex justify-content-center align-baseline">
<h5>Job Roofline Heatmap</h5>
<Icon
style="cursor:help; margin-left:0.5rem;"
name="info-circle"
title="Most Populated Areas By Selected Jobs' Average Values"
/>
</div>
<div bind:clientWidth={colWidth2}> <div bind:clientWidth={colWidth2}>
{#key $rooflineQuery.data} {#key $rooflineQuery.data}
<RooflineHeatmap <RooflineHeatmap
width={colWidth2} width={colWidth2}
height={300} height={280}
tiles={$rooflineQuery.data.rooflineHeatmap} tiles={$rooflineQuery.data.rooflineHeatmap}
subCluster={cluster.subClusters.length == 1 subCluster={cluster.subClusters.length >= 1
? cluster.subClusters[0] ? cluster.subClusters[0]
: null} : null}
maxY={rooflineMaxY} maxY={rooflineMaxY}
+1 -2
View File
@@ -145,10 +145,9 @@
<NavbarToggler onclick={() => (isOpen = !isOpen)} /> <NavbarToggler onclick={() => (isOpen = !isOpen)} />
<Collapse <Collapse
style="justify-content: space-between" style="justify-content: space-between"
expand="md"
{isOpen} {isOpen}
navbar navbar
expand="md"
onupdate={({ detail }) => (isOpen = detail.isOpen)}
> >
<Nav navbar> <Nav navbar>
{#if showMax || showBrg} {#if showMax || showBrg}
+5
View File
@@ -79,6 +79,11 @@
}); });
}); });
$effect(() => {
// Load Metric-Selection for last selected cluster
metrics = selectedCluster ? ccconfig[`metricConfig_jobListMetrics:${selectedCluster}`] : ccconfig.metricConfig_jobListMetrics
});
/* On Mount */ /* On Mount */
// The filterPresets are handled by the Filters component, // The filterPresets are handled by the Filters component,
// so we need to wait for it to be ready before we can start a query. // so we need to wait for it to be ready before we can start a query.
+36 -11
View File
@@ -16,6 +16,7 @@
Row, Row,
Col, Col,
Input, Input,
Button,
InputGroup, InputGroup,
InputGroupText, InputGroupText,
Icon, Icon,
@@ -63,6 +64,7 @@
query ($cluster: String!, $nodes: [String!], $from: Time!, $to: Time!) { query ($cluster: String!, $nodes: [String!], $from: Time!, $to: Time!) {
nodeMetrics(cluster: $cluster, nodes: $nodes, from: $from, to: $to) { nodeMetrics(cluster: $cluster, nodes: $nodes, from: $from, to: $to) {
host host
state
subCluster subCluster
metrics { metrics {
name name
@@ -97,6 +99,16 @@
} }
} }
`; `;
// Node State Colors
const stateColors = {
allocated: 'success',
reserved: 'info',
idle: 'primary',
mixed: 'warning',
down: 'danger',
unknown: 'dark',
notindb: 'secondary'
}
/* State Init */ /* State Init */
let from = $state(presetFrom ? presetFrom : new Date(nowEpoch - (4 * 3600 * 1000))); let from = $state(presetFrom ? presetFrom : new Date(nowEpoch - (4 * 3600 * 1000)));
@@ -123,6 +135,8 @@
}) })
); );
const thisNodeState = $derived($nodeMetricsData?.data?.nodeMetrics[0]?.state ? $nodeMetricsData.data.nodeMetrics[0].state : 'notindb');
/* Effect */ /* Effect */
$effect(() => { $effect(() => {
loadUnits($initialized); loadUnits($initialized);
@@ -138,7 +152,7 @@
} }
</script> </script>
<Row cols={{ xs: 2, lg: 4 }}> <Row cols={{ xs: 2, lg: 5 }}>
{#if $initq.error} {#if $initq.error}
<Card body color="danger">{$initq.error.message}</Card> <Card body color="danger">{$initq.error.message}</Card>
{:else if $initq.fetching} {:else if $initq.fetching}
@@ -149,19 +163,18 @@
<InputGroup> <InputGroup>
<InputGroupText><Icon name="hdd" /></InputGroupText> <InputGroupText><Icon name="hdd" /></InputGroupText>
<InputGroupText>Selected Node</InputGroupText> <InputGroupText>Selected Node</InputGroupText>
<Input style="background-color: white;"type="text" value="{hostname} [{cluster} ({$nodeMetricsData?.data ? $nodeMetricsData.data.nodeMetrics[0].subCluster : ''})]" disabled/> <Input style="background-color: white;" type="text" value="{hostname} [{cluster} {$nodeMetricsData?.data ? `(${$nodeMetricsData.data.nodeMetrics[0].subCluster})` : ''}]" disabled/>
</InputGroup> </InputGroup>
</Col> </Col>
<!-- Time Col --> <!-- State Col -->
<Col> <Col>
<TimeSelection <InputGroup>
presetFrom={from} <InputGroupText><Icon name="clipboard2-pulse" /></InputGroupText>
presetTo={to} <InputGroupText>Node State</InputGroupText>
applyTime={(newFrom, newTo) => { <Button class="flex-grow-1 text-center" color={stateColors[thisNodeState]} disabled>
from = newFrom; {thisNodeState}
to = newTo; </Button>
}} </InputGroup>
/>
</Col> </Col>
<!-- Concurrent Col --> <!-- Concurrent Col -->
<Col class="mt-2 mt-lg-0"> <Col class="mt-2 mt-lg-0">
@@ -184,6 +197,17 @@
</InputGroup> </InputGroup>
{/if} {/if}
</Col> </Col>
<!-- Time Col -->
<Col>
<TimeSelection
presetFrom={from}
presetTo={to}
applyTime={(newFrom, newTo) => {
from = newFrom;
to = newTo;
}}
/>
</Col>
<!-- Refresh Col--> <!-- Refresh Col-->
<Col class="mt-2 mt-lg-0"> <Col class="mt-2 mt-lg-0">
<Refresher <Refresher
@@ -217,6 +241,7 @@
cluster={clusters.find((c) => c.name == cluster)} cluster={clusters.find((c) => c.name == cluster)}
subCluster={$nodeMetricsData.data.nodeMetrics[0].subCluster} subCluster={$nodeMetricsData.data.nodeMetrics[0].subCluster}
series={item.metric.series} series={item.metric.series}
enableFlip
forNode forNode
/> />
{:else if item.disabled === true && item.metric} {:else if item.disabled === true && item.metric}
+22 -3
View File
@@ -9,13 +9,17 @@
import { import {
getContext getContext
} from "svelte" } from "svelte"
import {
init,
} from "./generic/utils.js";
import { import {
Row, Row,
Col, Col,
Card, Card,
CardBody, CardBody,
TabContent, TabContent,
TabPane TabPane,
Spinner
} from "@sveltestrap/sveltestrap"; } from "@sveltestrap/sveltestrap";
import StatusDash from "./status/StatusDash.svelte"; import StatusDash from "./status/StatusDash.svelte";
@@ -28,8 +32,8 @@
} = $props(); } = $props();
/*Const Init */ /*Const Init */
const { query: initq } = init();
const useCbColors = getContext("cc-config")?.plotConfiguration_colorblindMode || false const useCbColors = getContext("cc-config")?.plotConfiguration_colorblindMode || false
</script> </script>
<!-- Loading indicator & Refresh --> <!-- Loading indicator & Refresh -->
@@ -40,11 +44,25 @@
</Col> </Col>
</Row> </Row>
{#if $initq.fetching}
<Row cols={1} class="text-center mt-3">
<Col>
<Spinner />
</Col>
</Row>
{:else if $initq.error}
<Row cols={1} class="text-center mt-3">
<Col>
<Card body color="danger">{$initq.error.message}</Card>
</Col>
</Row>
{:else}
<Card class="overflow-auto" style="height: auto;"> <Card class="overflow-auto" style="height: auto;">
<TabContent> <TabContent>
<TabPane tabId="status-dash" tab="Status" active> <TabPane tabId="status-dash" tab="Status" active>
<CardBody> <CardBody>
<StatusDash {presetCluster} {useCbColors} useAltColors></StatusDash> <StatusDash clusters={$initq.data.clusters} {presetCluster} {useCbColors} useAltColors></StatusDash>
</CardBody> </CardBody>
</TabPane> </TabPane>
@@ -61,3 +79,4 @@
</TabPane> </TabPane>
</TabContent> </TabContent>
</Card> </Card>
{/if}
+19 -5
View File
@@ -59,6 +59,7 @@
const resampleResolutions = resampleConfig ? [...resampleConfig.resolutions] : []; const resampleResolutions = resampleConfig ? [...resampleConfig.resolutions] : [];
const resampleDefault = resampleConfig ? Math.max(...resampleConfig.resolutions) : 0; const resampleDefault = resampleConfig ? Math.max(...resampleConfig.resolutions) : 0;
const stateOptions = ['all', 'allocated', 'idle', 'reserved', 'mixed', 'down', 'unknown', 'notindb'];
const nowDate = new Date(Date.now()); const nowDate = new Date(Date.now());
/* Var Init */ /* Var Init */
@@ -69,6 +70,7 @@
let from = $state(presetFrom || new Date(nowDate.setHours(nowDate.getHours() - 4))); let from = $state(presetFrom || new Date(nowDate.setHours(nowDate.getHours() - 4)));
let selectedResolution = $state(resampleConfig ? resampleDefault : 0); let selectedResolution = $state(resampleConfig ? resampleDefault : 0);
let hostnameFilter = $state(""); let hostnameFilter = $state("");
let hoststateFilter = $state("all");
let pendingHostnameFilter = $state(""); let pendingHostnameFilter = $state("");
let isMetricsSelectionOpen = $state(false); let isMetricsSelectionOpen = $state(false);
@@ -154,8 +156,8 @@
</script> </script>
<!-- ROW1: Tools--> <!-- ROW1: Tools-->
<Row cols={{ xs: 2, lg: !displayNodeOverview ? (resampleConfig ? 5 : 4) : 4 }} class="mb-3"> <Row cols={{ xs: 2, lg: !displayNodeOverview ? (resampleConfig ? 6 : 5) : 5 }} class="mb-3">
{#if $initq.data} {#if $initq?.data}
<!-- List Metric Select Col--> <!-- List Metric Select Col-->
{#if !displayNodeOverview} {#if !displayNodeOverview}
<Col> <Col>
@@ -191,7 +193,7 @@
<Col class="mt-2 mt-lg-0"> <Col class="mt-2 mt-lg-0">
<InputGroup> <InputGroup>
<InputGroupText><Icon name="hdd" /></InputGroupText> <InputGroupText><Icon name="hdd" /></InputGroupText>
<InputGroupText>Find Node(s)</InputGroupText> <InputGroupText>Node(s)</InputGroupText>
<Input <Input
placeholder="Filter hostname ..." placeholder="Filter hostname ..."
type="text" type="text"
@@ -200,6 +202,18 @@
/> />
</InputGroup> </InputGroup>
</Col> </Col>
<!-- State Col-->
<Col class="mt-2 mt-lg-0">
<InputGroup>
<InputGroupText><Icon name="clipboard2-pulse" /></InputGroupText>
<InputGroupText>State</InputGroupText>
<Input type="select" bind:value={hoststateFilter}>
{#each stateOptions as so}
<option value={so}>{so.charAt(0).toUpperCase() + so.slice(1)}</option>
{/each}
</Input>
</InputGroup>
</Col>
<!-- Range Col--> <!-- Range Col-->
<Col> <Col>
<TimeSelection <TimeSelection
@@ -252,10 +266,10 @@
{:else} {:else}
{#if displayNodeOverview} {#if displayNodeOverview}
<!-- ROW2-1: Node Overview (Grid Included)--> <!-- ROW2-1: Node Overview (Grid Included)-->
<NodeOverview {cluster} {ccconfig} {selectedMetric} {from} {to} {hostnameFilter}/> <NodeOverview {cluster} {ccconfig} {selectedMetric} {from} {to} {hostnameFilter} {hoststateFilter}/>
{:else} {:else}
<!-- ROW2-2: Node List (Grid Included)--> <!-- ROW2-2: Node List (Grid Included)-->
<NodeList {cluster} {subCluster} {ccconfig} {selectedMetrics} {selectedResolution} {hostnameFilter} {from} {to} {presetSystemUnits}/> <NodeList {cluster} {subCluster} {ccconfig} {selectedMetrics} {selectedResolution} {hostnameFilter} {hoststateFilter} {from} {to} {presetSystemUnits}/>
{/if} {/if}
{/if} {/if}
+6
View File
@@ -133,6 +133,11 @@
}; };
}); });
$effect(() => {
// Load Metric-Selection for last selected cluster
metrics = selectedCluster ? ccconfig[`metricConfig_jobListMetrics:${selectedCluster}`] : ccconfig.metricConfig_jobListMetrics
});
/* On Mount */ /* On Mount */
onMount(() => { onMount(() => {
filterComponent.updateFilters(); filterComponent.updateFilters();
@@ -348,6 +353,7 @@
ylabel="Number of Jobs" ylabel="Number of Jobs"
yunit="Jobs" yunit="Jobs"
usesBins usesBins
enableFlip
/> />
{/snippet} {/snippet}
@@ -27,7 +27,7 @@
function refreshIntervalChanged() { function refreshIntervalChanged() {
if (refreshIntervalId != null) clearInterval(refreshIntervalId); if (refreshIntervalId != null) clearInterval(refreshIntervalId);
if (refreshInterval == null) return; if (refreshInterval == null) return;
refreshIntervalId = setInterval(() => onRefresh(), refreshInterval); refreshIntervalId = setInterval(() => onRefresh(refreshInterval), refreshInterval);
} }
/* Svelte 5 onMount */ /* Svelte 5 onMount */
@@ -51,7 +51,7 @@
</Input> </Input>
<Button <Button
outline outline
onclick={() => onRefresh()} onclick={() => onRefresh(refreshInterval)}
disabled={refreshInterval != null} disabled={refreshInterval != null}
> >
<Icon name="arrow-clockwise" /> Refresh <Icon name="arrow-clockwise" /> Refresh
@@ -39,7 +39,7 @@
yunit = "", yunit = "",
title = "", title = "",
forResources = false, forResources = false,
plotSync, plotSync = null,
} = $props(); } = $props();
/* Const Init */ /* Const Init */
@@ -204,11 +204,7 @@
live: true, live: true,
}, },
cursor: { cursor: {
drag: { x: true, y: true }, drag: { x: true, y: true }
sync: {
key: plotSync.key,
scales: ["x", null],
}
} }
}; };
@@ -275,9 +271,12 @@
function update(u) { function update(u) {
const { left, top } = u.cursor; const { left, top } = u.cursor;
const width = u?.over?.querySelector(".u-legend")?.offsetWidth ? u.over.querySelector(".u-legend").offsetWidth : 0; const internalWidth = u?.over?.querySelector(".u-legend")?.offsetWidth ? u.over.querySelector(".u-legend").offsetWidth : 0;
legendEl.style.transform = if (left < (width/2)) {
"translate(" + (left - width - 15) + "px, " + (top + 15) + "px)"; legendEl.style.transform = "translate(" + (left + 15) + "px, " + (top + 15) + "px)";
} else {
legendEl.style.transform = "translate(" + (left - internalWidth - 15) + "px, " + (top + 15) + "px)";
}
} }
return { return {
@@ -293,6 +292,14 @@
if (!uplot) { if (!uplot) {
opts.width = ren_width; opts.width = ren_width;
opts.height = ren_height; opts.height = ren_height;
if (plotSync) {
opts.cursor.sync = {
key: plotSync.key,
scales: ["x", null],
}
}
uplot = new uPlot(opts, data, plotWrapper); // Data is uplot formatted [[X][Ymin][Yavg][Ymax]] uplot = new uPlot(opts, data, plotWrapper); // Data is uplot formatted [[X][Ymin][Yavg][Ymax]]
plotSync.sub(uplot) plotSync.sub(uplot)
} else { } else {
@@ -34,6 +34,7 @@
xtime = false, xtime = false,
ylabel = "", ylabel = "",
yunit = "", yunit = "",
enableFlip = false,
} = $props(); } = $props();
/* Const Init */ /* Const Init */
@@ -117,8 +118,12 @@
function update(u) { function update(u) {
const { left, top } = u.cursor; const { left, top } = u.cursor;
legendEl.style.transform = const internalWidth = u?.over?.querySelector(".u-legend")?.offsetWidth ? u.over.querySelector(".u-legend").offsetWidth : 0;
"translate(" + (left + 15) + "px, " + (top + 15) + "px)"; if (enableFlip && (left < (width/2))) {
legendEl.style.transform = "translate(" + (left + 15) + "px, " + (top + 15) + "px)";
} else {
legendEl.style.transform = "translate(" + (left - internalWidth - 15) + "px, " + (top + 15) + "px)";
}
} }
return { return {
@@ -13,7 +13,7 @@
- `statisticsSeries [GraphQL.StatisticsSeries]?`: Min/Max/Median representation of metric data [Default: null] - `statisticsSeries [GraphQL.StatisticsSeries]?`: Min/Max/Median representation of metric data [Default: null]
- `cluster String?`: Cluster name of the parent job / data [Default: ""] - `cluster String?`: Cluster name of the parent job / data [Default: ""]
- `subCluster String`: Name of the subCluster of the parent job - `subCluster String`: Name of the subCluster of the parent job
- `isShared Bool?`: If this job used shared resources; will adapt threshold indicators accordingly [Default: false] - `isShared Bool?`: If this job used shared resources; for additional legend display [Default: false]
- `forNode Bool?`: If this plot is used for node data display; will render x-axis as negative time with $now as maximum [Default: false] - `forNode Bool?`: If this plot is used for node data display; will render x-axis as negative time with $now as maximum [Default: false]
- `numhwthreads Number?`: Number of job HWThreads [Default: 0] - `numhwthreads Number?`: Number of job HWThreads [Default: 0]
- `numaccs Number?`: Number of job Accelerators [Default: 0] - `numaccs Number?`: Number of job Accelerators [Default: 0]
@@ -48,6 +48,8 @@
zoomState = null, zoomState = null,
thresholdState = null, thresholdState = null,
extendedLegendData = null, extendedLegendData = null,
plotSync = null,
enableFlip = false,
onZoom onZoom
} = $props(); } = $props();
@@ -83,7 +85,6 @@
subClusterTopology, subClusterTopology,
metricConfig, metricConfig,
scope, scope,
isShared,
numhwthreads, numhwthreads,
numaccs numaccs
)); ));
@@ -277,7 +278,6 @@
subClusterTopology, subClusterTopology,
metricConfig, metricConfig,
scope, scope,
isShared,
numhwthreads, numhwthreads,
numaccs numaccs
) { ) {
@@ -293,10 +293,8 @@
scope = statParts[0] scope = statParts[0]
} }
if ( if (metricConfig?.aggregation == "avg") {
(scope == "node" && isShared == false) || // Return as Configured
metricConfig?.aggregation == "avg"
) {
return { return {
normal: metricConfig.normal, normal: metricConfig.normal,
caution: metricConfig.caution, caution: metricConfig.caution,
@@ -306,19 +304,24 @@
} }
if (metricConfig?.aggregation == "sum") { if (metricConfig?.aggregation == "sum") {
// Scale Thresholds
let fraction;
if (numaccs > 0) fraction = subClusterTopology.accelerators.length / numaccs;
else if (numhwthreads > 0) fraction = subClusterTopology.core.length / numhwthreads;
else fraction = 1; // Fallback
let divisor; let divisor;
if (isShared == true) { // Shared // Exclusive: Fraction = 1; Shared: Fraction > 1
if (numaccs > 0) divisor = subClusterTopology.accelerators.length / numaccs; if (scope == 'node') divisor = fraction;
else if (numhwthreads > 0) divisor = subClusterTopology.core.length / numhwthreads; // Cap divisor at number of available sockets or domains
} else if (scope == 'socket') divisor = (fraction < subClusterTopology.socket.length) ? subClusterTopology.socket.length : fraction;
else if (scope == 'node') divisor = 1; // Use as configured for nodes else if (scope == "memoryDomain") divisor = (fraction < subClusterTopology.memoryDomain.length) ? subClusterTopology.socket.length : fraction;
else if (scope == 'socket') divisor = subClusterTopology.socket.length; // Use Maximum Division for Smallest Scopes
else if (scope == "memoryDomain") divisor = subClusterTopology.memoryDomain.length;
else if (scope == "core") divisor = subClusterTopology.core.length; else if (scope == "core") divisor = subClusterTopology.core.length;
else if (scope == "hwthread") divisor = subClusterTopology.core.length; // alt. name for core else if (scope == "hwthread") divisor = subClusterTopology.core.length; // alt. name for core
else if (scope == "accelerator") divisor = subClusterTopology.accelerators.length; else if (scope == "accelerator") divisor = subClusterTopology.accelerators.length;
else { else {
console.log('Unknown scope, return default aggregation thresholds ', scope) console.log('Unknown scope, return default aggregation thresholds for sum', scope)
divisor = 1; divisor = 1;
} }
@@ -395,9 +398,12 @@
function update(u) { function update(u) {
const { left, top } = u.cursor; const { left, top } = u.cursor;
const width = u?.over?.querySelector(".u-legend")?.offsetWidth ? u.over.querySelector(".u-legend").offsetWidth : 0; const internalWidth = u?.over?.querySelector(".u-legend")?.offsetWidth ? u.over.querySelector(".u-legend").offsetWidth : 0;
legendEl.style.transform = if (enableFlip && (left < (width/2))) {
"translate(" + (left - width - 15) + "px, " + (top + 15) + "px)"; legendEl.style.transform = "translate(" + (left + 15) + "px, " + (top + 15) + "px)";
} else {
legendEl.style.transform = "translate(" + (left - internalWidth - 15) + "px, " + (top + 15) + "px)";
}
} }
if (dataSize <= 12 || useStatsSeries) { if (dataSize <= 12 || useStatsSeries) {
@@ -576,6 +582,14 @@
if (!uplot) { if (!uplot) {
opts.width = ren_width; opts.width = ren_width;
opts.height = ren_height; opts.height = ren_height;
if (plotSync) {
opts.cursor.sync = {
key: plotSync.key,
scales: ["x", null],
}
}
if (zoomState && metricConfig?.aggregation == "avg") { if (zoomState && metricConfig?.aggregation == "avg") {
opts.scales = {...zoomState} opts.scales = {...zoomState}
} else if (zoomState && metricConfig?.aggregation == "sum") { } else if (zoomState && metricConfig?.aggregation == "sum") {
@@ -584,6 +598,7 @@
if ((thresholdState === thresholds?.normal)) { opts.scales = {...zoomState} }; if ((thresholdState === thresholds?.normal)) { opts.scales = {...zoomState} };
} // else: reset scaling to default } // else: reset scaling to default
} }
uplot = new uPlot(opts, plotData, plotWrapper); uplot = new uPlot(opts, plotData, plotWrapper);
} else { } else {
uplot.setSize({ width: ren_width, height: ren_height }); uplot.setSize({ width: ren_width, height: ren_height });
@@ -276,13 +276,13 @@
// Nodes: Color based on Idle vs. Allocated // Nodes: Color based on Idle vs. Allocated
} else if (nodesData) { } else if (nodesData) {
// console.log('In Plot Handler NodesData', nodesData) // console.log('In Plot Handler NodesData', nodesData)
if (nodesData[i]?.nodeState == "idle") { if (nodesData[i]?.schedulerState == "idle") {
//u.ctx.strokeStyle = "rgb(0, 0, 255)"; //u.ctx.strokeStyle = "rgb(0, 0, 255)";
u.ctx.fillStyle = "rgba(0, 0, 255, 0.5)"; u.ctx.fillStyle = "rgba(0, 0, 255, 0.5)";
} else if (nodesData[i]?.nodeState == "allocated") { } else if (nodesData[i]?.schedulerState == "allocated") {
//u.ctx.strokeStyle = "rgb(0, 255, 0)"; //u.ctx.strokeStyle = "rgb(0, 255, 0)";
u.ctx.fillStyle = "rgba(0, 255, 0, 0.5)"; u.ctx.fillStyle = "rgba(0, 255, 0, 0.5)";
} else if (nodesData[i]?.nodeState == "notindb") { } else if (nodesData[i]?.schedulerState == "notindb") {
//u.ctx.strokeStyle = "rgb(0, 0, 0)"; //u.ctx.strokeStyle = "rgb(0, 0, 0)";
u.ctx.fillStyle = "rgba(0, 0, 0, 0.5)"; u.ctx.fillStyle = "rgba(0, 0, 0, 0.5)";
} else { // Fallback: All other DEFINED states } else { // Fallback: All other DEFINED states
@@ -436,11 +436,11 @@
tooltip.style.borderColor = getRGB(u.data[2][i]); tooltip.style.borderColor = getRGB(u.data[2][i]);
// Nodes: Color based on Idle vs. Allocated // Nodes: Color based on Idle vs. Allocated
} else if (nodesData) { } else if (nodesData) {
if (nodesData[i]?.nodeState == "idle") { if (nodesData[i]?.schedulerState == "idle") {
tooltip.style.borderColor = "rgb(0, 0, 255)"; tooltip.style.borderColor = "rgb(0, 0, 255)";
} else if (nodesData[i]?.nodeState == "allocated") { } else if (nodesData[i]?.schedulerState == "allocated") {
tooltip.style.borderColor = "rgb(0, 255, 0)"; tooltip.style.borderColor = "rgb(0, 255, 0)";
} else if (nodesData[i]?.nodeState == "notindb") { // Missing from DB table } else if (nodesData[i]?.schedulerState == "notindb") { // Missing from DB table
tooltip.style.borderColor = "rgb(0, 0, 0)"; tooltip.style.borderColor = "rgb(0, 0, 0)";
} else { // Fallback: All other DEFINED states } else { // Fallback: All other DEFINED states
tooltip.style.borderColor = "rgb(255, 0, 0)"; tooltip.style.borderColor = "rgb(255, 0, 0)";
@@ -459,7 +459,7 @@
} else if (nodesData && useColors) { } else if (nodesData && useColors) {
tooltip.textContent = ( tooltip.textContent = (
// Tooltip Content as String for Node // Tooltip Content as String for Node
`Host: ${getLegendData(u, i).nodeName}\nState: ${getLegendData(u, i).nodeState}\nJobs: ${getLegendData(u, i).numJobs}` `Host: ${getLegendData(u, i).nodeName}\nState: ${getLegendData(u, i).schedulerState}\nJobs: ${getLegendData(u, i).numJobs}`
); );
} else if (nodesData && !useColors) { } else if (nodesData && !useColors) {
tooltip.textContent = ( tooltip.textContent = (
@@ -34,7 +34,7 @@
const paddingLeft = 40; const paddingLeft = 40;
const paddingRight = 10; const paddingRight = 10;
const paddingTop = 10; const paddingTop = 10;
const paddingBottom = 5; const paddingBottom = 40;
/* Var Init */ /* Var Init */
let timeoutId = null; let timeoutId = null;
@@ -124,7 +124,7 @@
if (data.xLabel) { if (data.xLabel) {
ctx.font = `${labelFontSize}px ${fontFamily}` ctx.font = `${labelFontSize}px ${fontFamily}`
let textWidth = ctx.measureText(data.xLabel).width let textWidth = ctx.measureText(data.xLabel).width
ctx.fillText(data.xLabel, Math.floor((width / 2) - (textWidth / 2)), height - 20) ctx.fillText(data.xLabel, Math.floor((width / 2) - (textWidth / 2)), height - paddingBottom + 30)
} }
ctx.textAlign = 'center' ctx.textAlign = 'center'
+154 -369
View File
@@ -1,34 +1,25 @@
<!-- <!--
@component Node State/Health Data Stacked Plot Component, based on uPlot; states by timestamp @component Node State/Health Data Stacked Plot Component, based on uPlot; states by timestamp
Only width/height should change reactively.
Properties: Properties:
- `metric String?`: The metric name [Default: ""]
- `width Number?`: The plot width [Default: 0] - `width Number?`: The plot width [Default: 0]
- `height Number?`: The plot height [Default: 300] - `height Number?`: The plot height [Default: 300]
- `data [Array]`: The data object [Default: null] - `data [Array]`: The data object [Default: null]
- `title String?`: Plot title [Default: ""]
- `xlabel String?`: Plot X axis label [Default: ""] - `xlabel String?`: Plot X axis label [Default: ""]
- `ylabel String?`: Plot Y axis label [Default: ""] - `ylabel String?`: Plot Y axis label [Default: ""]
- `yunit String?`: Plot Y axis unit [Default: ""] - `yunit String?`: Plot Y axis unit [Default: ""]
- `xticks Array`: Array containing jobIDs [Default: []] - `title String?`: Plot title [Default: ""]
- `xinfo Array`: Array containing job information [Default: []] - `stateType String?`: Which states to render, affects plot render config [Options: Health, Node; Default: ""]
- `forResources Bool?`: Render this plot for allocated jobResources [Default: false]
- `plot Sync Object!`: uPlot cursor synchronization key
--> -->
<script> <script>
import uPlot from "uplot"; import uPlot from "uplot";
import { roundTwoDigits, formatDurationTime, formatUnixTime, formatNumber } from "../units.js"; import { formatUnixTime } from "../units.js";
import { getContext, onMount, onDestroy } from "svelte"; import { getContext, onMount, onDestroy } from "svelte";
import { Card } from "@sveltestrap/sveltestrap"; import { Card } from "@sveltestrap/sveltestrap";
// NOTE: Metric Thresholds non-required, Cluster Mixing Allowed
/* Svelte 5 Props */ /* Svelte 5 Props */
let { let {
cluster = "",
width = 0, width = 0,
height = 300, height = 300,
data = null, data = null,
@@ -36,16 +27,86 @@
ylabel = "", ylabel = "",
yunit = "", yunit = "",
title = "", title = "",
stateType = "" // Health, Slurm, Both stateType = "" // Health, Node
} = $props(); } = $props();
/* Const Init */ /* Const Init */
const clusterCockpitConfig = getContext("cc-config"); const clusterCockpitConfig = getContext("cc-config");
const lineWidth = clusterCockpitConfig?.plotConfiguration_lineWidth / window.devicePixelRatio || 2; const lineWidth = clusterCockpitConfig?.plotConfiguration_lineWidth / window.devicePixelRatio || 2;
const cbmode = clusterCockpitConfig?.plotConfiguration_colorblindMode || false; const cbmode = clusterCockpitConfig?.plotConfiguration_colorblindMode || false;
const seriesConfig = {
full: {
label: "Full",
scale: "y",
width: lineWidth,
fill: cbmode ? "rgba(0, 110, 0, 0.4)" : "rgba(0, 128, 0, 0.4)",
stroke: cbmode ? "rgb(0, 110, 0)" : "green",
},
partial: {
label: "Partial",
scale: "y",
width: lineWidth,
fill: cbmode ? "rgba(235, 172, 35, 0.4)" : "rgba(255, 215, 0, 0.4)",
stroke: cbmode ? "rgb(235, 172, 35)" : "gold",
},
failed: {
label: "Failed",
scale: "y",
width: lineWidth,
fill: cbmode ? "rgb(181, 29, 20, 0.4)" : "rgba(255, 0, 0, 0.4)",
stroke: cbmode ? "rgb(181, 29, 20)" : "red",
},
idle: {
label: "Idle",
scale: "y",
width: lineWidth,
fill: cbmode ? "rgba(0, 140, 249, 0.4)" : "rgba(0, 0, 255, 0.4)",
stroke: cbmode ? "rgb(0, 140, 249)" : "blue",
},
allocated: {
label: "Allocated",
scale: "y",
width: lineWidth,
fill: cbmode ? "rgba(0, 110, 0, 0.4)" : "rgba(0, 128, 0, 0.4)",
stroke: cbmode ? "rgb(0, 110, 0)" : "green",
},
reserved: {
label: "Reserved",
scale: "y",
width: lineWidth,
fill: cbmode ? "rgba(209, 99, 230, 0.4)" : "rgba(255, 0, 255, 0.4)",
stroke: cbmode ? "rgb(209, 99, 230)" : "magenta",
},
mixed: {
label: "Mixed",
scale: "y",
width: lineWidth,
fill: cbmode ? "rgba(235, 172, 35, 0.4)" : "rgba(255, 215, 0, 0.4)",
stroke: cbmode ? "rgb(235, 172, 35)" : "gold",
},
down: {
label: "Down",
scale: "y",
width: lineWidth,
fill: cbmode ? "rgba(181, 29 ,20, 0.4)" : "rgba(255, 0, 0, 0.4)",
stroke: cbmode ? "rgb(181, 29, 20)" : "red",
},
unknown: {
label: "Unknown",
scale: "y",
width: lineWidth,
fill: "rgba(0, 0, 0, 0.4)",
stroke: "black",
}
};
// Data Prep For uPlot
const sortedData = data.sort((a, b) => a.state.localeCompare(b.state));
const collectLabel = sortedData.map(d => d.state);
// Align Data to Timesteps, Introduces 'undefied' as placeholder, reiterate and set those to 0
const collectData = uPlot.join(sortedData.map(d => [d.times, d.counts])).map(d => d.map(i => i ? i : 0));
// STACKED CHART FUNCTIONS // // STACKED CHART FUNCTIONS //
function stack(data, omit) { function stack(data, omit) {
let data2 = []; let data2 = [];
let bands = []; let bands = [];
@@ -74,23 +135,46 @@
}; };
} }
function getOpts(title, series) { function getStackedOpts(title, width, height, series, data) {
return { let opts = {
scales: { width,
x: { height,
time: false, title,
}, plugins: [legendAsTooltipPlugin()],
}, series,
series axes: [
}; {
scale: "x",
space: 25, // Tick Spacing
rotate: 30,
show: true,
label: xlabel,
values(self, splits) {
return splits.map(s => formatUnixTime(s));
} }
},
{
scale: "y",
grid: { show: true },
labelFont: "sans-serif",
label: ylabel + (yunit ? ` (${yunit})` : ''),
// values: (u, vals) => vals.map((v) => formatNumber(v)),
},
],
padding: [5, 10, 0, 0],
scales: {
x: { time: false },
y: { auto: true, distr: 1 },
},
legend: {
show: true,
},
cursor: {
drag: { x: true, y: true },
}
};
function getStackedOpts(title, series, data, interp) { let stacked = stack(data, i => false);
let opts = getOpts(title, series);
let interped = interp ? interp(data) : data;
let stacked = stack(interped, i => false);
opts.bands = stacked.bands; opts.bands = stacked.bands;
opts.cursor = opts.cursor || {}; opts.cursor = opts.cursor || {};
@@ -99,7 +183,8 @@
}; };
opts.series.forEach(s => { opts.series.forEach(s => {
s.value = (u, v, si, i) => data[si][i]; // Format Time Info from Unix TS to LocalTimeString
s.value = (u, v, si, i) => (si === 0) ? formatUnixTime(data[si][i]) : data[si][i];
s.points = s.points || {}; s.points = s.points || {};
@@ -138,331 +223,7 @@
return {opts, data: stacked.data}; return {opts, data: stacked.data};
} }
// UPLOT PLUGIN: Converts the legend into a simple tooltip
function stack2(series) {
// for uplot data
let data = Array(series.length);
let bands = [];
let dataLen = series[0].values.length;
let zeroArr = Array(dataLen).fill(0);
let stackGroups = new Map();
let seriesStackKeys = Array(series.length);
series.forEach((s, si) => {
let vals = s.values.slice();
// apply negY
if (s.negY) {
for (let i = 0; i < vals.length; i++) {
if (vals[i] != null)
vals[i] *= -1;
}
}
if (s.stacking.mode != 'none') {
let hasPos = vals.some(v => v > 0);
// derive stacking key
let stackKey = seriesStackKeys[si] = s.stacking.mode + s.scaleKey + s.stacking.group + (hasPos ? '+' : '-');
let group = stackGroups.get(stackKey);
// initialize stacking group
if (group == null) {
group = {
series: [],
acc: zeroArr.slice(),
dir: hasPos ? -1 : 1,
};
stackGroups.set(stackKey, group);
}
// push for bands gen
group.series.unshift(si);
let stacked = data[si] = Array(dataLen);
let { acc } = group;
for (let i = 0; i < dataLen; i++) {
let v = vals[i];
if (v != null)
stacked[i] = (acc[i] += v);
else
stacked[i] = v; // we may want to coerce to 0 here
}
}
else
data[si] = vals;
});
// re-compute by percent
series.forEach((s, si) => {
if (s.stacking.mode == 'percent') {
let group = stackGroups.get(seriesStackKeys[si]);
let { acc } = group;
// re-negatify percent
let sign = group.dir * -1;
let stacked = data[si];
for (let i = 0; i < dataLen; i++) {
let v = stacked[i];
if (v != null)
stacked[i] = sign * (v / acc[i]);
}
}
});
// generate bands between adjacent group series
stackGroups.forEach(group => {
let { series, dir } = group;
let lastIdx = series.length - 1;
series.forEach((si, i) => {
if (i != lastIdx) {
let nextIdx = series[i + 1];
bands.push({
// since we're not passing x series[0] for stacking, real idxs are actually +1
series: [si + 1, nextIdx + 1],
dir,
});
}
});
});
return {
data,
bands,
};
}
// UPLOT SERIES INIT //
const plotSeries = [
{
label: "Time",
scale: "x",
value: (u, ts, sidx, didx) =>
(didx == null) ? null : formatUnixTime(ts),
}
]
if (stateType === "slurm") {
const resSeries = [
{
label: "Idle",
scale: "y",
width: lineWidth,
stroke: cbmode ? "rgb(136, 204, 238)" : "lightblue",
},
{
label: "Allocated",
scale: "y",
width: lineWidth,
stroke: cbmode ? "rgb(30, 136, 229)" : "green",
},
{
label: "Reserved",
scale: "y",
width: lineWidth,
stroke: cbmode ? "rgb(211, 95, 183)" : "magenta",
},
{
label: "Mixed",
scale: "y",
width: lineWidth,
stroke: cbmode ? "rgb(239, 230, 69)" : "yellow",
},
{
label: "Down",
scale: "y",
width: lineWidth,
stroke: cbmode ? "rgb(225, 86, 44)" : "red",
},
{
label: "Unknown",
scale: "y",
width: lineWidth,
stroke: "black",
}
];
plotSeries.push(...resSeries)
} else if (stateType === "health") {
const resSeries = [
{
label: "Full",
scale: "y",
width: lineWidth,
stroke: cbmode ? "rgb(30, 136, 229)" : "green",
},
{
label: "Partial",
scale: "y",
width: lineWidth,
stroke: cbmode ? "rgb(239, 230, 69)" : "yellow",
},
{
label: "Failed",
scale: "y",
width: lineWidth,
stroke: cbmode ? "rgb(225, 86, 44)" : "red",
}
];
plotSeries.push(...resSeries)
} else {
const resSeries = [
{
label: "Full",
scale: "y",
width: lineWidth,
stroke: cbmode ? "rgb(30, 136, 229)" : "green",
},
{
label: "Partial",
scale: "y",
width: lineWidth,
stroke: cbmode ? "rgb(239, 230, 69)" : "yellow",
},
{
label: "Failed",
scale: "y",
width: lineWidth,
stroke: cbmode ? "rgb(225, 86, 44)" : "red",
},
{
label: "Idle",
scale: "y",
width: lineWidth,
stroke: cbmode ? "rgb(136, 204, 238)" : "lightblue",
},
{
label: "Allocated",
scale: "y",
width: lineWidth,
stroke: cbmode ? "rgb(30, 136, 229)" : "green",
},
{
label: "Reserved",
scale: "y",
width: lineWidth,
stroke: cbmode ? "rgb(211, 95, 183)" : "magenta",
},
{
label: "Mixed",
scale: "y",
width: lineWidth,
stroke: cbmode ? "rgb(239, 230, 69)" : "yellow",
},
{
label: "Down",
scale: "y",
width: lineWidth,
stroke: cbmode ? "rgb(225, 86, 44)" : "red",
},
{
label: "Unknown",
scale: "y",
width: lineWidth,
stroke: "black",
}
];
plotSeries.push(...resSeries)
}
// UPLOT BAND COLORS //
// const plotBands = [
// { series: [5, 4], fill: cbmode ? "rgba(0,0,255,0.1)" : "rgba(0,255,0,0.1)" },
// { series: [4, 3], fill: cbmode ? "rgba(0,255,0,0.1)" : "rgba(255,0,0,0.1)" },
// ];
// UPLOT OPTIONS //
const opts = {
width,
height,
title,
plugins: [legendAsTooltipPlugin()],
series: plotSeries,
axes: [
{
scale: "x",
space: 25, // Tick Spacing
rotate: 30,
show: true,
label: xlabel,
// values(self, splits) {
// return splits.map(s => xticks[s]);
// }
},
{
scale: "y",
grid: { show: true },
labelFont: "sans-serif",
label: ylabel + (yunit ? ` (${yunit})` : ''),
// values: (u, vals) => vals.map((v) => formatNumber(v)),
},
],
// bands: forResources ? [] : plotBands,
padding: [5, 10, 0, 0],
// hooks: {
// draw: [
// (u) => {
// // Draw plot type label:
// let textl = forResources ? "Job Resources by Type" : "Metric Min/Avg/Max for Job Duration";
// let textr = "Earlier <- StartTime -> Later";
// u.ctx.save();
// u.ctx.textAlign = "start";
// u.ctx.fillStyle = "black";
// u.ctx.fillText(textl, u.bbox.left + 10, u.bbox.top + 10);
// u.ctx.textAlign = "end";
// u.ctx.fillStyle = "black";
// u.ctx.fillText(
// textr,
// u.bbox.left + u.bbox.width - 10,
// u.bbox.top + 10,
// );
// u.ctx.restore();
// return;
// },
// ]
// },
scales: {
x: { time: false },
y: {auto: true, distr: 1},
},
legend: {
// Display legend
show: true,
live: true,
},
cursor: {
drag: { x: true, y: true },
// sync: {
// key: plotSync.key,
// scales: ["x", null],
// }
}
};
/* Var Init */
let timeoutId = null;
let uplot = null;
/* State Init */
let plotWrapper = $state(null);
/* Effects */
$effect(() => {
if (plotWrapper) {
onSizeChange(width, height);
}
});
/* Functions */
// UPLOT PLUGIN // converts the legend into a simple tooltip
function legendAsTooltipPlugin({ function legendAsTooltipPlugin({
className, className,
style = { backgroundColor: "rgba(255, 249, 196, 0.92)", color: "black" }, style = { backgroundColor: "rgba(255, 249, 196, 0.92)", color: "black" },
@@ -476,7 +237,7 @@
className && legendEl.classList.add(className); className && legendEl.classList.add(className);
uPlot.assign(legendEl.style, { uPlot.assign(legendEl.style, {
minWidth: "100px", minWidth: "175px",
textAlign: "left", textAlign: "left",
pointerEvents: "none", pointerEvents: "none",
display: "none", display: "none",
@@ -489,9 +250,9 @@
}); });
// hide series color markers: // hide series color markers:
const idents = legendEl.querySelectorAll(".u-marker"); // const idents = legendEl.querySelectorAll(".u-marker");
for (let i = 0; i < idents.length; i++) // for (let i = 0; i < idents.length; i++)
idents[i].style.display = "none"; // idents[i].style.display = "none";
const overEl = u.over; const overEl = u.over;
overEl.style.overflow = "visible"; overEl.style.overflow = "visible";
@@ -510,9 +271,12 @@
function update(u) { function update(u) {
const { left, top } = u.cursor; const { left, top } = u.cursor;
const width = u?.over?.querySelector(".u-legend")?.offsetWidth ? u.over.querySelector(".u-legend").offsetWidth : 0; const internalWidth = u?.over?.querySelector(".u-legend")?.offsetWidth ? u.over.querySelector(".u-legend").offsetWidth : 0;
legendEl.style.transform = if (left < (width/2)) {
"translate(" + (left - width - 15) + "px, " + (top + 15) + "px)"; legendEl.style.transform = "translate(" + (left + 15) + "px, " + (top + 15) + "px)";
} else {
legendEl.style.transform = "translate(" + (left - internalWidth - 15) + "px, " + (top + 15) + "px)";
}
} }
return { return {
@@ -523,13 +287,34 @@
}; };
} }
// RENDER HANDLING // UPLOT SERIES INIT
const plotSeries = [
{
label: "Time",
scale: "x"
},
...collectLabel.map(l => seriesConfig[l])
]
/* Var Init */
let timeoutId = null;
let uplot = null;
/* State Init */
let plotWrapper = $state(null);
/* Effects */
$effect(() => {
if (plotWrapper) {
onSizeChange(width, height);
}
});
/* Functions */
function render(ren_width, ren_height) { function render(ren_width, ren_height) {
if (!uplot) { if (!uplot) {
opts.width = ren_width; let { opts, data } = getStackedOpts(title, ren_width, ren_height, plotSeries, collectData);
opts.height = ren_height; uplot = new uPlot(opts, data, plotWrapper); // Data is uplot formatted [[X][Y1][Y2]...]
uplot = new uPlot(opts, data, plotWrapper); // Data is uplot formatted [[X][Ymin][Yavg][Ymax]]
plotSync.sub(uplot)
} else { } else {
uplot.setSize({ width: ren_width, height: ren_height }); uplot.setSize({ width: ren_width, height: ren_height });
} }
@@ -559,12 +344,12 @@
</script> </script>
<!-- Define $width Wrapper and NoData Card --> <!-- Define $width Wrapper and NoData Card -->
{#if data && data[0].length > 0} {#if data && collectData[0].length > 0}
<div bind:this={plotWrapper} bind:clientWidth={width} <div bind:this={plotWrapper} bind:clientWidth={width}
style="background-color: rgba(255, 255, 255, 1.0);" class="rounded" style="background-color: rgba(255, 255, 255, 1.0);" class="rounded"
></div> ></div>
{:else} {:else}
<Card body color="warning" class="mx-4 my-2" <Card body color="warning" class="mx-4 my-2"
>Cannot render plot: No series data returned for <code>{metric?metric:'job resources'}</code></Card >Cannot render plot: No series data returned for <code>{stateType} State Stacked Chart</code></Card
> >
{/if} {/if}
@@ -98,7 +98,12 @@
if (!cluster) { if (!cluster) {
return avail.map((av) => av.cluster).join(', ') return avail.map((av) => av.cluster).join(', ')
} else { } else {
return avail.find((av) => av.cluster === cluster).subClusters.join(', ') const subAvail = avail.find((av) => av.cluster === cluster)?.subClusters
if (subAvail) {
return subAvail.join(', ')
} else {
return `Not available for ${cluster}`
}
} }
} }
@@ -36,7 +36,7 @@
/* Const Init */ /* Const Init */
const defaultTo = new Date(Date.now()); const defaultTo = new Date(Date.now());
const defaultFrom = new Date(defaultTo.setHours(defaultTo.getHours() - 4)); const defaultFrom = new Date(new Date(Date.now()).setHours(defaultTo.getHours() - 4));
/* State Init */ /* State Init */
let timeType = $state("range"); let timeType = $state("range");
+3 -2
View File
@@ -51,12 +51,13 @@ export function formatDurationTime(t, forNode = false) {
} }
} }
export function formatUnixTime(t) { export function formatUnixTime(t, withDate = false) {
if (t !== null) { if (t !== null) {
if (isNaN(t)) { if (isNaN(t)) {
return t; return t;
} else { } else {
return new Date(t * 1000).toLocaleString() if (withDate) return new Date(t * 1000).toLocaleString();
else return new Date(t * 1000).toLocaleTimeString();
} }
} }
} }
+1 -1
View File
@@ -374,7 +374,7 @@ export function findJobFootprintThresholds(job, stat, metricConfig) {
For shared jobs, scale thresholds by the fraction of the job's HWThreads to the node's HWThreads. For shared jobs, scale thresholds by the fraction of the job's HWThreads to the node's HWThreads.
'stat' is one of: avg, min, max 'stat' is one of: avg, min, max
*/ */
if (job.exclusive === 1 || stat === "avg") { if (job.shared === "none" || stat === "avg") {
return defaultThresholds return defaultThresholds
} else { } else {
const topol = getContext("getHardwareTopology")(job.cluster, job.subCluster) const topol = getContext("getHardwareTopology")(job.cluster, job.subCluster)
+2
View File
@@ -185,6 +185,7 @@
{isShared} {isShared}
{zoomState} {zoomState}
{thresholdState} {thresholdState}
enableFlip
/> />
{:else if statsSeries[selectedScopeIndex] != null && patternMatches} {:else if statsSeries[selectedScopeIndex] != null && patternMatches}
<MetricPlot <MetricPlot
@@ -202,6 +203,7 @@
{thresholdState} {thresholdState}
statisticsSeries={statsSeries[selectedScopeIndex]} statisticsSeries={statsSeries[selectedScopeIndex]}
useStatsSeries={!!statsSeries[selectedScopeIndex]} useStatsSeries={!!statsSeries[selectedScopeIndex]}
enableFlip
/> />
{/if} {/if}
{/key} {/key}
+5 -13
View File
@@ -21,7 +21,6 @@
getContextClient, getContextClient,
} from "@urql/svelte"; } from "@urql/svelte";
import { import {
init,
convert2uplot, convert2uplot,
} from "../generic/utils.js"; } from "../generic/utils.js";
import PlotGrid from "../generic/PlotGrid.svelte"; import PlotGrid from "../generic/PlotGrid.svelte";
@@ -35,7 +34,6 @@
} = $props(); } = $props();
/* Const Init */ /* Const Init */
const { query: initq } = init();
const ccconfig = getContext("cc-config"); const ccconfig = getContext("cc-config");
const client = getContextClient(); const client = getContextClient();
@@ -101,25 +99,18 @@
</Row> </Row>
<Row cols={1} class="text-center mt-3"> <Row cols={1} class="text-center mt-3">
{#if $metricStatusQuery.fetching}
<Col> <Col>
{#if $initq.fetching || $metricStatusQuery.fetching}
<Spinner /> <Spinner />
{:else if $initq.error}
<Card body color="danger">{$initq.error.message}</Card>
{:else}
<!-- ... -->
{/if}
</Col> </Col>
</Row> {:else if $metricStatusQuery.error}
{#if $metricStatusQuery.error}
<Row cols={1}>
<Col> <Col>
<Card body color="danger">{$metricStatusQuery.error.message}</Card> <Card body color="danger">{$metricStatusQuery.error.message}</Card>
</Col> </Col>
</Row>
{/if} {/if}
</Row>
{#if $initq.data && $metricStatusQuery.data} {#if $metricStatusQuery.data}
<!-- Selectable Stats as Histograms : Average Values of Running Jobs --> <!-- Selectable Stats as Histograms : Average Values of Running Jobs -->
{#if selectedHistograms} {#if selectedHistograms}
<!-- Note: Ignore '#snippet' Error in IDE --> <!-- Note: Ignore '#snippet' Error in IDE -->
@@ -132,6 +123,7 @@
ylabel="Number of Jobs" ylabel="Number of Jobs"
yunit="Jobs" yunit="Jobs"
usesBins usesBins
enableFlip
/> />
{/snippet} {/snippet}
+100 -73
View File
@@ -22,34 +22,36 @@
gql, gql,
getContextClient, getContextClient,
} from "@urql/svelte"; } from "@urql/svelte";
import { import { formatDurationTime } from "../generic/units.js";
init,
} from "../generic/utils.js";
import { scaleNumbers, formatDurationTime } from "../generic/units.js";
import Refresher from "../generic/helper/Refresher.svelte"; import Refresher from "../generic/helper/Refresher.svelte";
import TimeSelection from "../generic/select/TimeSelection.svelte";
import Roofline from "../generic/plots/Roofline.svelte"; import Roofline from "../generic/plots/Roofline.svelte";
import Pie, { colors } from "../generic/plots/Pie.svelte"; import Pie, { colors } from "../generic/plots/Pie.svelte";
import Stacked from "../generic/plots/Stacked.svelte";
/* Svelte 5 Props */ /* Svelte 5 Props */
let { let {
clusters,
presetCluster, presetCluster,
useCbColors = false, useCbColors = false,
useAltColors = false, useAltColors = false,
} = $props(); } = $props();
/* Const Init */ /* Const Init */
const { query: initq } = init();
const client = getContextClient(); const client = getContextClient();
/* State Init */ /* State Init */
let cluster = $state(presetCluster); let cluster = $state(presetCluster);
let pieWidth = $state(0); let pieWidth = $state(0);
let stackedWidth = $state(0); let stackedWidth1 = $state(0);
let stackedWidth2 = $state(0);
let plotWidths = $state([]); let plotWidths = $state([]);
let from = $state(new Date(Date.now() - 5 * 60 * 1000)); let from = $state(new Date(Date.now() - 5 * 60 * 1000));
let to = $state(new Date(Date.now())); let to = $state(new Date(Date.now()));
let stackedFrom = $state(Math.floor(Date.now() / 1000) - 14400);
// Bar Gauges // Bar Gauges
let allocatedNodes = $state({}); let allocatedNodes = $state({});
let allocatedCores = $state({});
let allocatedAccs = $state({}); let allocatedAccs = $state({});
let flopRate = $state({}); let flopRate = $state({});
let flopRateUnitPrefix = $state({}); let flopRateUnitPrefix = $state({});
@@ -63,46 +65,29 @@
let totalAccs = $state({}); let totalAccs = $state({});
/* Derived */ /* Derived */
// Accumulated NodeStates for Piecharts // States for Stacked charts
const nodesStateCounts = $derived(queryStore({ const statesTimed = $derived(queryStore({
client: client, client: client,
query: gql` query: gql`
query ($filter: [NodeFilter!]) { query ($filter: [NodeFilter!], $typeNode: String!, $typeHealth: String!) {
nodeStates(filter: $filter) { nodeStates: nodeStatesTimed(filter: $filter, type: $typeNode) {
state state
count counts
times
}
healthStates: nodeStatesTimed(filter: $filter, type: $typeHealth) {
state
counts
times
} }
} }
`, `,
variables: { variables: {
filter: { cluster: { eq: cluster }} filter: { cluster: { eq: cluster }, timeStart: stackedFrom},
}, typeNode: "node",
})); typeHealth: "health"
const refinedStateData = $derived.by(() => {
return $nodesStateCounts?.data?.nodeStates.filter((e) => ['allocated', 'reserved', 'idle', 'mixed','down', 'unknown'].includes(e.state))
});
const refinedHealthData = $derived.by(() => {
return $nodesStateCounts?.data?.nodeStates.filter((e) => ['full', 'partial', 'failed'].includes(e.state))
});
// NodeStates for Stacked charts
const nodesStateTimes = $derived(queryStore({
client: client,
query: gql`
query ($filter: [NodeFilter!]) {
nodeStatesTimed(filter: $filter) {
state
type
count
time
}
}
`,
variables: {
filter: { cluster: { eq: cluster }, timeStart: Date.now() - (24 * 3600 * 1000)} // Add Selector for Timeframe (4h, 12h, 24h)?
}, },
requestPolicy: "network-only"
})); }));
// Note: nodeMetrics are requested on configured $timestep resolution // Note: nodeMetrics are requested on configured $timestep resolution
@@ -177,9 +162,14 @@
hostname hostname
cluster cluster
subCluster subCluster
nodeState schedulerState
} }
} }
# Get Current States fir Pie Charts
nodeStates(filter: $nodeFilter) {
state
count
}
# totalNodes includes multiples if shared jobs # totalNodes includes multiples if shared jobs
jobsStatistics( jobsStatistics(
filter: $jobFilter filter: $jobFilter
@@ -190,6 +180,7 @@
id id
totalJobs totalJobs
totalUsers totalUsers
totalCores
totalAccs totalAccs
} }
} }
@@ -207,10 +198,22 @@
requestPolicy: "network-only" requestPolicy: "network-only"
})); }));
const refinedStateData = $derived.by(() => {
return $statusQuery?.data?.nodeStates.
filter((e) => ['allocated', 'reserved', 'idle', 'mixed','down', 'unknown'].includes(e.state)).
sort((a, b) => b.count - a.count)
});
const refinedHealthData = $derived.by(() => {
return $statusQuery?.data?.nodeStates.
filter((e) => ['full', 'partial', 'failed'].includes(e.state)).
sort((a, b) => b.count - a.count)
});
/* Effects */ /* Effects */
$effect(() => { $effect(() => {
if ($initq.data && $statusQuery.data) { if ($statusQuery.data) {
let subClusters = $initq.data.clusters.find( let subClusters = clusters.find(
(c) => c.name == cluster, (c) => c.name == cluster,
).subClusters; ).subClusters;
for (let subCluster of subClusters) { for (let subCluster of subClusters) {
@@ -219,6 +222,10 @@
$statusQuery.data.allocatedNodes.find( $statusQuery.data.allocatedNodes.find(
({ name }) => name == subCluster.name, ({ name }) => name == subCluster.name,
)?.count || 0; )?.count || 0;
allocatedCores[subCluster.name] =
$statusQuery.data.jobsStatistics.find(
({ id }) => id == subCluster.name,
)?.totalCores || 0;
allocatedAccs[subCluster.name] = allocatedAccs[subCluster.name] =
$statusQuery.data.jobsStatistics.find( $statusQuery.data.jobsStatistics.find(
({ id }) => id == subCluster.name, ({ id }) => id == subCluster.name,
@@ -348,7 +355,7 @@
for (let j = 0; j < subClusterData.length; j++) { for (let j = 0; j < subClusterData.length; j++) {
const nodeName = subClusterData[j]?.host ? subClusterData[j].host : "unknown" const nodeName = subClusterData[j]?.host ? subClusterData[j].host : "unknown"
const nodeMatch = $statusQuery?.data?.nodes?.items?.find((n) => n.hostname == nodeName && n.subCluster == subClusterData[j].subCluster); const nodeMatch = $statusQuery?.data?.nodes?.items?.find((n) => n.hostname == nodeName && n.subCluster == subClusterData[j].subCluster);
const nodeState = nodeMatch?.nodeState ? nodeMatch.nodeState : "notindb" const schedulerState = nodeMatch?.schedulerState ? nodeMatch.schedulerState : "notindb"
let numJobs = 0 let numJobs = 0
if ($statusQuery?.data) { if ($statusQuery?.data) {
@@ -356,7 +363,7 @@
numJobs = nodeJobs?.length ? nodeJobs.length : 0 numJobs = nodeJobs?.length ? nodeJobs.length : 0
} }
result.push({nodeName: nodeName, nodeState: nodeState, numJobs: numJobs}) result.push({nodeName: nodeName, schedulerState: schedulerState, numJobs: numJobs})
}; };
}; };
return result return result
@@ -378,14 +385,24 @@
</script> </script>
<!-- Refresher and space for other options --> <!-- Refresher and space for other options -->
<Row class="justify-content-end"> <Row class="justify-content-between">
<Col xs="12" md="5" lg="4" xl="3">
<TimeSelection
customEnabled={false}
applyTime={(newFrom, newTo) => {
stackedFrom = Math.floor(newFrom.getTime() / 1000);
}}
/>
</Col>
<Col xs="12" md="5" lg="4" xl="3"> <Col xs="12" md="5" lg="4" xl="3">
<Refresher <Refresher
initially={120} initially={120}
onRefresh={() => { onRefresh={(interval) => {
console.log('Trigger Refresh StatusTab')
from = new Date(Date.now() - 5 * 60 * 1000); from = new Date(Date.now() - 5 * 60 * 1000);
to = new Date(Date.now()); to = new Date(Date.now());
if (interval) stackedFrom += Math.floor(interval / 1000);
else stackedFrom += 1 // Workaround: TineSelection not linked, just trigger new data on manual refresh
}} }}
/> />
</Col> </Col>
@@ -394,43 +411,40 @@
<hr/> <hr/>
<!-- Node Stack Charts Dev--> <!-- Node Stack Charts Dev-->
<!-- {#if $statesTimed.data}
{#if $initq.data && $nodesStateTimes.data} <Row cols={{ md: 2 , sm: 1}} class="mb-3 justify-content-center">
<Row cols={{ lg: 4, md: 2 , sm: 1}} class="mb-3 justify-content-center">
<Col class="px-3 mt-2 mt-lg-0"> <Col class="px-3 mt-2 mt-lg-0">
<div bind:clientWidth={stackedWidth}> <div bind:clientWidth={stackedWidth1}>
{#key $nodesStateTimes.data} {#key $statesTimed?.data?.nodeStates}
<h4 class="text-center"> <h4 class="text-center">
{cluster.charAt(0).toUpperCase() + cluster.slice(1)} Node States Over Time {cluster.charAt(0).toUpperCase() + cluster.slice(1)} Node States Over Time
</h4> </h4>
<Stacked <Stacked
{cluster} data={$statesTimed?.data?.nodeStates}
data={$nodesStateTimes?.data} width={stackedWidth1 * 0.95}
width={stackedWidth * 0.55} xlabel="Time"
xLabel="Time" ylabel="Nodes"
yLabel="Nodes"
yunit = "#Count" yunit = "#Count"
title = "Slurm States" title = "Node States"
stateType = "slurm" stateType = "Node"
/> />
{/key} {/key}
</div> </div>
</Col> </Col>
<Col class="px-3 mt-2 mt-lg-0"> <Col class="px-3 mt-2 mt-lg-0">
<div bind:clientWidth={stackedWidth}> <div bind:clientWidth={stackedWidth2}>
{#key $nodesStateTimes.data} {#key $statesTimed?.data?.healthStates}
<h4 class="text-center"> <h4 class="text-center">
{cluster.charAt(0).toUpperCase() + cluster.slice(1)} Health States Over Time {cluster.charAt(0).toUpperCase() + cluster.slice(1)} Health States Over Time
</h4> </h4>
<Stacked <Stacked
{cluster} data={$statesTimed?.data?.healthStates}
data={$nodesStateTimes?.data} width={stackedWidth2 * 0.95}
width={stackedWidth * 0.55} xlabel="Time"
xLabel="Time" ylabel="Nodes"
yLabel="Nodes"
yunit = "#Count" yunit = "#Count"
title = "Health States" title = "Health States"
stateType = "health" stateType = "Health"
/> />
{/key} {/key}
</div> </div>
@@ -439,17 +453,15 @@
{/if} {/if}
<hr/> <hr/>
<hr/>
-->
<!-- Node Health Pis, later Charts --> <!-- Node Health Pis, later Charts -->
{#if $initq.data && $nodesStateCounts.data} {#if $statusQuery?.data?.nodeStates}
<Row cols={{ lg: 4, md: 2 , sm: 1}} class="mb-3 justify-content-center"> <Row cols={{ lg: 4, md: 2 , sm: 1}} class="mb-3 justify-content-center">
<Col class="px-3 mt-2 mt-lg-0"> <Col class="px-3 mt-2 mt-lg-0">
<div bind:clientWidth={pieWidth}> <div bind:clientWidth={pieWidth}>
{#key refinedStateData} {#key refinedStateData}
<h4 class="text-center"> <h4 class="text-center">
{cluster.charAt(0).toUpperCase() + cluster.slice(1)} Node States Current {cluster.charAt(0).toUpperCase() + cluster.slice(1)} Node States
</h4> </h4>
<Pie <Pie
{useAltColors} {useAltColors}
@@ -489,7 +501,7 @@
<div bind:clientWidth={pieWidth}> <div bind:clientWidth={pieWidth}>
{#key refinedHealthData} {#key refinedHealthData}
<h4 class="text-center"> <h4 class="text-center">
{cluster.charAt(0).toUpperCase() + cluster.slice(1)} Node Health Current {cluster.charAt(0).toUpperCase() + cluster.slice(1)} Node Health
</h4> </h4>
<Pie <Pie
{useAltColors} {useAltColors}
@@ -529,8 +541,8 @@
<hr/> <hr/>
<!-- Gauges & Roofline per Subcluster--> <!-- Gauges & Roofline per Subcluster-->
{#if $initq.data && $statusQuery.data} {#if $statusQuery.data}
{#each $initq.data.clusters.find((c) => c.name == cluster).subClusters as subCluster, i} {#each clusters.find((c) => c.name == cluster).subClusters as subCluster, i}
<Row cols={{ lg: 3, md: 1 , sm: 1}} class="mb-3 justify-content-center"> <Row cols={{ lg: 3, md: 1 , sm: 1}} class="mb-3 justify-content-center">
<Col class="px-3"> <Col class="px-3">
<Card class="h-auto mt-1"> <Card class="h-auto mt-1">
@@ -579,6 +591,21 @@
Nodes</td Nodes</td
> >
</tr> </tr>
<tr class="py-2">
<th scope="col">Allocated Cores</th>
<td style="min-width: 100px;"
><div class="col">
<Progress
value={allocatedCores[subCluster.name]}
max={subCluster.socketsPerNode * subCluster.coresPerSocket * subCluster.numberOfNodes}
/>
</div></td
>
<td
>{allocatedCores[subCluster.name]} / {subCluster.socketsPerNode * subCluster.coresPerSocket * subCluster.numberOfNodes}
Cores</td
>
</tr>
{#if totalAccs[subCluster.name] !== null} {#if totalAccs[subCluster.name] !== null}
<tr class="py-2"> <tr class="py-2">
<th scope="col">Allocated Accelerators</th> <th scope="col">Allocated Accelerators</th>

Some files were not shown because too many files have changed in this diff Show More