mirror of
https://github.com/ClusterCockpit/cc-backend
synced 2026-06-17 17:07:29 +02:00
Merge branch 'main' into feature/526-average-resample
This commit is contained in:
@@ -63,10 +63,10 @@ func DefaultConfig() *RepositoryConfig {
|
||||
MaxIdleConnections: 4,
|
||||
ConnectionMaxLifetime: time.Hour,
|
||||
ConnectionMaxIdleTime: 10 * time.Minute,
|
||||
MinRunningJobDuration: 600, // 10 minutes
|
||||
DbCacheSizeMB: 2048, // 2GB per connection
|
||||
DbSoftHeapLimitMB: 16384, // 16GB process-wide
|
||||
BusyTimeoutMs: 60000, // 60 seconds
|
||||
MinRunningJobDuration: 600, // 10 minutes
|
||||
DbCacheSizeMB: 2048, // 2GB per connection
|
||||
DbSoftHeapLimitMB: 16384, // 16GB process-wide
|
||||
BusyTimeoutMs: 60000, // 60 seconds
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -76,8 +76,15 @@ func (r *JobRepository) QueryJobs(
|
||||
}
|
||||
|
||||
if page != nil && page.ItemsPerPage != -1 {
|
||||
// -1 is the only valid non-positive value ("load all"); reject other
|
||||
// non-positive values so that uint64(page.ItemsPerPage) cannot underflow
|
||||
// into a huge limit. Clamp Page to >= 1 to avoid the same on the offset.
|
||||
if page.ItemsPerPage < 1 {
|
||||
return nil, fmt.Errorf("invalid items-per-page value: %d", page.ItemsPerPage)
|
||||
}
|
||||
p := max(page.Page, 1)
|
||||
limit := uint64(page.ItemsPerPage)
|
||||
query = query.Offset((uint64(page.Page) - 1) * limit).Limit(limit)
|
||||
query = query.Offset((uint64(p) - 1) * limit).Limit(limit)
|
||||
}
|
||||
|
||||
for _, f := range filters {
|
||||
@@ -280,11 +287,11 @@ func BuildWhereClause(filter *model.JobFilter, query sq.SelectBuilder) sq.Select
|
||||
|
||||
// buildIntCondition creates clauses for integer range filters, using BETWEEN only if required.
|
||||
func buildIntCondition(field string, cond *config.IntRange, query sq.SelectBuilder) sq.SelectBuilder {
|
||||
if cond.From != 1 && cond.To != 0 {
|
||||
if cond.From > 0 && cond.To > 0 {
|
||||
return query.Where(field+" BETWEEN ? AND ?", cond.From, cond.To)
|
||||
} else if cond.From != 1 && cond.To == 0 {
|
||||
} else if cond.From > 0 && cond.To == 0 {
|
||||
return query.Where(field+" >= ?", cond.From)
|
||||
} else if cond.From == 1 && cond.To != 0 {
|
||||
} else if cond.From == 0 && cond.To > 0 {
|
||||
return query.Where(field+" <= ?", cond.To)
|
||||
} else {
|
||||
return query
|
||||
@@ -293,11 +300,11 @@ func buildIntCondition(field string, cond *config.IntRange, query sq.SelectBuild
|
||||
|
||||
// buildFloatCondition creates a clauses for float range filters, using BETWEEN only if required.
|
||||
func buildFloatCondition(field string, cond *model.FloatRange, query sq.SelectBuilder) sq.SelectBuilder {
|
||||
if cond.From != 1.0 && cond.To != 0.0 {
|
||||
if cond.From > 0.0 && cond.To > 0.0 {
|
||||
return query.Where(field+" BETWEEN ? AND ?", cond.From, cond.To)
|
||||
} else if cond.From != 1.0 && cond.To == 0.0 {
|
||||
} else if cond.From > 0.0 && cond.To == 0.0 {
|
||||
return query.Where(field+" >= ?", cond.From)
|
||||
} else if cond.From == 1.0 && cond.To != 0.0 {
|
||||
} else if cond.From == 0.0 && cond.To > 0.0 {
|
||||
return query.Where(field+" <= ?", cond.To)
|
||||
} else {
|
||||
return query
|
||||
@@ -336,14 +343,24 @@ func buildTimeCondition(field string, cond *config.TimeRange, query sq.SelectBui
|
||||
}
|
||||
}
|
||||
|
||||
// validMetricName guards metric/footprint names that are interpolated into the
|
||||
// json_extract() path of footprint queries. SQLite treats double-quoted strings
|
||||
// as string literals, so an unescaped name (e.g. containing a `"`) would allow
|
||||
// SQL injection. Legitimate metric names only use these characters.
|
||||
var validMetricName = regexp.MustCompile(`^[a-zA-Z0-9_]+$`)
|
||||
|
||||
// buildFloatJSONCondition creates a filter on a numeric field within the footprint JSON column, using BETWEEN only if required.
|
||||
func buildFloatJSONCondition(jsonField string, cond *model.FloatRange, query sq.SelectBuilder) sq.SelectBuilder {
|
||||
if !validMetricName.MatchString(jsonField) {
|
||||
cclog.Warnf("buildFloatJSONCondition: rejecting invalid metric name %q", jsonField)
|
||||
return query.Where("0 = 1")
|
||||
}
|
||||
query = query.Where("JSON_VALID(footprint)")
|
||||
if cond.From != 1.0 && cond.To != 0.0 {
|
||||
if cond.From > 0.0 && cond.To > 0.0 {
|
||||
return query.Where("JSON_EXTRACT(footprint, \"$."+jsonField+"\") BETWEEN ? AND ?", cond.From, cond.To)
|
||||
} else if cond.From != 1.0 && cond.To == 0.0 {
|
||||
} else if cond.From > 0.0 && cond.To == 0.0 {
|
||||
return query.Where("JSON_EXTRACT(footprint, \"$."+jsonField+"\") >= ?", cond.From)
|
||||
} else if cond.From == 1.0 && cond.To != 0.0 {
|
||||
} else if cond.From == 0.0 && cond.To > 0.0 {
|
||||
return query.Where("JSON_EXTRACT(footprint, \"$."+jsonField+"\") <= ?", cond.To)
|
||||
} else {
|
||||
return query
|
||||
|
||||
@@ -909,6 +909,13 @@ func (r *JobRepository) jobsMetricStatisticsHistogram(
|
||||
filters []*model.JobFilter,
|
||||
bins *int,
|
||||
) (*model.MetricHistoPoints, error) {
|
||||
// The metric name is interpolated into the json_extract() path of the SQL
|
||||
// below. SQLite parses double-quoted strings as literals, so reject anything
|
||||
// that is not a plain metric identifier to prevent SQL injection.
|
||||
if !validMetricName.MatchString(metric) {
|
||||
return nil, fmt.Errorf("invalid metric name: %q", metric)
|
||||
}
|
||||
|
||||
// Peak value defines the upper bound for binning: values are distributed across
|
||||
// bins from 0 to peak. First try to get peak from filtered cluster, otherwise
|
||||
// scan all clusters to find the maximum peak value.
|
||||
|
||||
@@ -311,26 +311,33 @@ func (r *JobRepository) CountTags(user *schema.User) (tags []schema.Tag, counts
|
||||
LeftJoin("jobtag jt ON t.id = jt.tag_id").
|
||||
GroupBy("t.tag_type, t.tag_name")
|
||||
|
||||
// Build scope list for filtering
|
||||
var scopeBuilder strings.Builder
|
||||
scopeBuilder.WriteString(`"global"`)
|
||||
// Build scope list for filtering. Values are parameterized rather than
|
||||
// interpolated because user.Username originates from external identity
|
||||
// providers (OIDC/LDAP) and must not be trusted as SQL.
|
||||
scopes := []string{"global"}
|
||||
if user != nil {
|
||||
scopeBuilder.WriteString(`,"`)
|
||||
scopeBuilder.WriteString(user.Username)
|
||||
scopeBuilder.WriteString(`"`)
|
||||
scopes = append(scopes, user.Username)
|
||||
if user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport}) {
|
||||
scopeBuilder.WriteString(`,"admin"`)
|
||||
scopes = append(scopes, "admin")
|
||||
}
|
||||
}
|
||||
q = q.Where("t.tag_scope IN (" + scopeBuilder.String() + ")")
|
||||
q = q.Where(sq.Eq{"t.tag_scope": scopes})
|
||||
|
||||
// Handle Job Ownership
|
||||
if user != nil && user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport}) { // ADMIN || SUPPORT: Count all jobs
|
||||
// cclog.Debug("CountTags: User Admin or Support -> Count all Jobs for Tags")
|
||||
// Unchanged: Needs to be own case still, due to UserRole/NoRole compatibility handling in else case
|
||||
} else if user != nil && user.HasRole(schema.RoleManager) { // MANAGER: Count own jobs plus project's jobs
|
||||
// Build ("project1", "project2", ...) list of variable length directly in SQL string
|
||||
q = q.Where("jt.job_id IN (SELECT id FROM job WHERE job.hpc_user = ? OR job.project IN (\""+strings.Join(user.Projects, "\",\"")+"\"))", user.Username)
|
||||
} else if user != nil && user.HasRole(schema.RoleManager) && len(user.Projects) > 0 { // MANAGER: Count own jobs plus project's jobs
|
||||
// Build a parameterized ("?", "?", ...) placeholder list for the
|
||||
// variable-length project set instead of interpolating values into SQL.
|
||||
args := make([]any, 0, len(user.Projects)+1)
|
||||
args = append(args, user.Username)
|
||||
placeholders := make([]string, len(user.Projects))
|
||||
for i, p := range user.Projects {
|
||||
placeholders[i] = "?"
|
||||
args = append(args, p)
|
||||
}
|
||||
q = q.Where("jt.job_id IN (SELECT id FROM job WHERE job.hpc_user = ? OR job.project IN ("+strings.Join(placeholders, ",")+"))", args...)
|
||||
} else if user != nil { // USER OR NO ROLE (Compatibility): Only count own jobs
|
||||
q = q.Where("jt.job_id IN (SELECT id FROM job WHERE job.hpc_user = ?)", user.Username)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
@@ -210,6 +211,12 @@ func (r *UserRepository) AddUserIfNotExists(user *schema.User) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func sortedRoles(roles []string) []string {
|
||||
cp := append([]string{}, roles...)
|
||||
sort.Strings(cp)
|
||||
return cp
|
||||
}
|
||||
|
||||
func (r *UserRepository) UpdateUser(dbUser *schema.User, user *schema.User) error {
|
||||
// user contains updated info -> Apply to dbUser
|
||||
// --- Simple Name Update ---
|
||||
@@ -279,6 +286,15 @@ func (r *UserRepository) UpdateUser(dbUser *schema.User, user *schema.User) erro
|
||||
}
|
||||
}
|
||||
|
||||
// --- Fallback: sync any remaining role differences not covered above ---
|
||||
// This handles admin role assignment/removal and any other combinations that
|
||||
// the specific branches above do not cover (e.g. user→admin, admin→user).
|
||||
if !reflect.DeepEqual(sortedRoles(dbUser.Roles), sortedRoles(user.Roles)) {
|
||||
if err := updateRoles(user.Roles); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user