mirror of
https://github.com/ClusterCockpit/cc-backend
synced 2026-01-15 17:21:46 +01:00
410 lines
12 KiB
Go
410 lines
12 KiB
Go
// 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 provides nodelist parsing functionality for HPC cluster node specifications.
|
|
//
|
|
// # Overview
|
|
//
|
|
// The nodelist package implements parsing and querying of compact node list representations
|
|
// commonly used in HPC job schedulers and cluster management systems. It converts compressed
|
|
// node specifications (e.g., "node[01-10]") into queryable structures that can efficiently
|
|
// test node membership and expand to full node lists.
|
|
//
|
|
// # Node List Format
|
|
//
|
|
// Node lists use a compact syntax with the following rules:
|
|
//
|
|
// 1. Comma-separated terms represent alternative node patterns (OR logic)
|
|
// 2. Each term consists of a string prefix followed by optional numeric ranges
|
|
// 3. Numeric ranges are specified in square brackets with zero-padded start-end format
|
|
// 4. Multiple ranges within brackets are comma-separated
|
|
// 5. Range digits must be zero-padded and of equal length (e.g., "01-99" not "1-99")
|
|
//
|
|
// # Examples
|
|
//
|
|
// "node01" // Single node
|
|
// "node01,node02" // Multiple individual nodes
|
|
// "node[01-10]" // Range: node01 through node10 (zero-padded)
|
|
// "node[01-10,20-30]" // Multiple ranges: node01-10 and node20-30
|
|
// "cn-00[10-20],cn-00[50-60]" // Different prefixes with ranges
|
|
// "login,compute[001-100]" // Mixed individual and range terms
|
|
//
|
|
// # Usage
|
|
//
|
|
// Parse a node list specification:
|
|
//
|
|
// nl, err := ParseNodeList("node[01-10],login")
|
|
// if err != nil {
|
|
// log.Fatal(err)
|
|
// }
|
|
//
|
|
// Check if a node name matches the list:
|
|
//
|
|
// if nl.Contains("node05") {
|
|
// // node05 is in the list
|
|
// }
|
|
//
|
|
// Expand to full list of node names:
|
|
//
|
|
// nodes := nl.PrintList() // ["node01", "node02", ..., "node10", "login"]
|
|
//
|
|
// Count total nodes in the list:
|
|
//
|
|
// count := nl.NodeCount() // 11 (10 from range + 1 individual)
|
|
//
|
|
// # Integration
|
|
//
|
|
// This package is used by:
|
|
// - clusterConfig.go: Parses SubCluster.Nodes field from cluster configuration
|
|
// - schema.resolvers.go: GraphQL resolver for computing numberOfNodes in subclusters
|
|
// - Job archive: Validates node assignments against configured cluster topology
|
|
//
|
|
// # Constraints
|
|
//
|
|
// - Only zero-padded numeric ranges are supported
|
|
// - Range start and end must have identical digit counts
|
|
// - No whitespace allowed in node list specifications
|
|
// - Ranges must be specified as start-end (not individual numbers)
|
|
package archive
|
|
|
|
import (
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
|
|
cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
|
|
)
|
|
|
|
// NodeList represents a parsed node list specification as a collection of node pattern terms.
|
|
// Each term is a sequence of expressions that must match consecutively for a node name to match.
|
|
// Terms are evaluated with OR logic - a node matches if ANY term matches completely.
|
|
//
|
|
// Internal structure:
|
|
// - Outer slice: OR terms (comma-separated in input)
|
|
// - Inner slice: AND expressions (must all match sequentially)
|
|
// - Each expression implements: consume (pattern matching), limits (range info), prefix (string part)
|
|
//
|
|
// Example: "node[01-10],login" becomes:
|
|
// - Term 1: [NLExprString("node"), NLExprIntRanges(01-10)]
|
|
// - Term 2: [NLExprString("login")]
|
|
type NodeList [][]interface {
|
|
consume(input string) (next string, ok bool)
|
|
limits() []map[string]int
|
|
prefix() string
|
|
}
|
|
|
|
// Contains tests whether the given node name matches any pattern in the NodeList.
|
|
// Returns true if the name matches at least one term completely, false otherwise.
|
|
//
|
|
// Matching logic:
|
|
// - Evaluates each term sequentially (OR logic across terms)
|
|
// - Within a term, all expressions must match in order (AND logic)
|
|
// - A match is complete only if the entire input is consumed (str == "")
|
|
//
|
|
// Examples:
|
|
// - NodeList("node[01-10]").Contains("node05") → true
|
|
// - NodeList("node[01-10]").Contains("node11") → false
|
|
// - NodeList("node[01-10]").Contains("node5") → false (missing zero-padding)
|
|
func (nl *NodeList) Contains(name string) bool {
|
|
var ok bool
|
|
for _, term := range *nl {
|
|
str := name
|
|
for _, expr := range term {
|
|
str, ok = expr.consume(str)
|
|
if !ok {
|
|
break
|
|
}
|
|
}
|
|
|
|
if ok && str == "" {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// PrintList expands the NodeList into a full slice of individual node names.
|
|
// This performs the inverse operation of ParseNodeList, expanding all ranges
|
|
// into their constituent node names with proper zero-padding.
|
|
//
|
|
// Returns a slice of node names in the order they appear in the NodeList.
|
|
// For range terms, nodes are expanded in ascending numeric order.
|
|
//
|
|
// Example:
|
|
// - ParseNodeList("node[01-03],login").PrintList() → ["node01", "node02", "node03", "login"]
|
|
func (nl *NodeList) PrintList() []string {
|
|
var out []string
|
|
for _, term := range *nl {
|
|
prefix := term[0].prefix()
|
|
if len(term) == 1 {
|
|
out = append(out, prefix)
|
|
} else {
|
|
limitArr := term[1].limits()
|
|
for _, inner := range limitArr {
|
|
for i := inner["start"]; i < inner["end"]+1; i++ {
|
|
if inner["zeroPadded"] == 1 {
|
|
out = append(out, fmt.Sprintf("%s%0*d", prefix, inner["digits"], i))
|
|
} else {
|
|
cclog.Error("node list: only zero-padded ranges are allowed")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// NodeCount returns the total number of individual nodes represented by the NodeList.
|
|
// This efficiently counts nodes without expanding the full list, making it suitable
|
|
// for large node ranges.
|
|
//
|
|
// Calculation:
|
|
// - Individual node terms contribute 1
|
|
// - Range terms contribute (end - start + 1) for each range
|
|
//
|
|
// Example:
|
|
// - ParseNodeList("node[01-10],login").NodeCount() → 11 (10 from range + 1 individual)
|
|
func (nl *NodeList) NodeCount() int {
|
|
out := 0
|
|
for _, term := range *nl {
|
|
if len(term) == 1 {
|
|
out += 1
|
|
} else {
|
|
limitArr := term[1].limits()
|
|
for _, inner := range limitArr {
|
|
out += (inner["end"] - inner["start"]) + 1
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// NLExprString represents a literal string prefix in a node name pattern.
|
|
// It matches by checking if the input starts with this exact string.
|
|
type NLExprString string
|
|
|
|
func (nle NLExprString) consume(input string) (next string, ok bool) {
|
|
str := string(nle)
|
|
if after, ok0 := strings.CutPrefix(input, str); ok0 {
|
|
return after, true
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
func (nle NLExprString) limits() []map[string]int {
|
|
// Null implementation to fullfill interface requirement
|
|
l := make([]map[string]int, 0)
|
|
return l
|
|
}
|
|
|
|
func (nle NLExprString) prefix() string {
|
|
return string(nle)
|
|
}
|
|
|
|
// NLExprIntRanges represents multiple alternative integer ranges (comma-separated within brackets).
|
|
// A node name matches if it matches ANY of the contained ranges (OR logic).
|
|
type NLExprIntRanges []NLExprIntRange
|
|
|
|
func (nles NLExprIntRanges) consume(input string) (next string, ok bool) {
|
|
for _, nle := range nles {
|
|
if next, ok := nle.consume(input); ok {
|
|
return next, ok
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
func (nles NLExprIntRanges) limits() []map[string]int {
|
|
l := make([]map[string]int, 0)
|
|
for _, nle := range nles {
|
|
inner := nle.limits()
|
|
l = append(l, inner[0])
|
|
}
|
|
return l
|
|
}
|
|
|
|
func (nles NLExprIntRanges) prefix() string {
|
|
// Null implementation to fullfill interface requirement
|
|
var s string
|
|
return s
|
|
}
|
|
|
|
// NLExprIntRange represents a single zero-padded integer range (e.g., "01-99").
|
|
// Fields:
|
|
// - start, end: Numeric range boundaries (inclusive)
|
|
// - zeroPadded: Must be true (non-padded ranges not supported)
|
|
// - digits: Required digit count for zero-padding
|
|
type NLExprIntRange struct {
|
|
start, end int64
|
|
zeroPadded bool
|
|
digits int
|
|
}
|
|
|
|
func (nle NLExprIntRange) consume(input string) (next string, ok bool) {
|
|
if !nle.zeroPadded || nle.digits < 1 {
|
|
cclog.Error("only zero-padded ranges are allowed")
|
|
return "", false
|
|
}
|
|
|
|
if len(input) < nle.digits {
|
|
return "", false
|
|
}
|
|
|
|
numerals, rest := input[:nle.digits], input[nle.digits:]
|
|
for len(numerals) > 1 && numerals[0] == '0' {
|
|
numerals = numerals[1:]
|
|
}
|
|
|
|
x, err := strconv.ParseInt(numerals, 10, 32)
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
|
|
if nle.start <= x && x <= nle.end {
|
|
return rest, true
|
|
}
|
|
|
|
return "", false
|
|
}
|
|
|
|
func (nle NLExprIntRange) limits() []map[string]int {
|
|
l := make([]map[string]int, 0)
|
|
m := make(map[string]int)
|
|
m["start"] = int(nle.start)
|
|
m["end"] = int(nle.end)
|
|
m["digits"] = int(nle.digits)
|
|
if nle.zeroPadded {
|
|
m["zeroPadded"] = 1
|
|
} else {
|
|
m["zeroPadded"] = 0
|
|
}
|
|
l = append(l, m)
|
|
return l
|
|
}
|
|
|
|
func (nles NLExprIntRange) prefix() string {
|
|
// Null implementation to fullfill interface requirement
|
|
var s string
|
|
return s
|
|
}
|
|
|
|
// ParseNodeList parses a compact node list specification into a queryable NodeList structure.
|
|
//
|
|
// Input format rules:
|
|
// - Comma-separated terms (OR logic): "node01,node02" matches either node
|
|
// - Range syntax: "node[01-10]" expands to node01 through node10
|
|
// - Multiple ranges: "node[01-05,10-15]" creates two ranges
|
|
// - Zero-padding required: digits in ranges must be zero-padded and equal length
|
|
// - Mixed formats: "login,compute[001-100]" combines individual and range terms
|
|
//
|
|
// Validation:
|
|
// - Returns error if brackets are unclosed
|
|
// - Returns error if ranges lack '-' separator
|
|
// - Returns error if range digits have unequal length
|
|
// - Returns error if range numbers fail to parse
|
|
// - Returns error on invalid characters
|
|
//
|
|
// Examples:
|
|
// - "node[01-10]" → NodeList with one term (10 nodes)
|
|
// - "node01,node02" → NodeList with two terms (2 nodes)
|
|
// - "cn[01-05,10-15]" → NodeList with ranges 01-05 and 10-15 (11 nodes total)
|
|
// - "a[1-9]" → Error (not zero-padded)
|
|
// - "a[01-9]" → Error (unequal digit counts)
|
|
func ParseNodeList(raw string) (NodeList, error) {
|
|
isLetter := func(r byte) bool { return ('a' <= r && r <= 'z') || ('A' <= r && r <= 'Z') }
|
|
isDigit := func(r byte) bool { return '0' <= r && r <= '9' }
|
|
isDash := func(r byte) bool { return r == '-' }
|
|
|
|
rawterms := []string{}
|
|
prevterm := 0
|
|
for i := 0; i < len(raw); i++ {
|
|
switch raw[i] {
|
|
case '[':
|
|
for i < len(raw) && raw[i] != ']' {
|
|
i++
|
|
}
|
|
if i == len(raw) {
|
|
return nil, fmt.Errorf("ARCHIVE/NODELIST > unclosed '['")
|
|
}
|
|
case ',':
|
|
rawterms = append(rawterms, raw[prevterm:i])
|
|
prevterm = i + 1
|
|
}
|
|
}
|
|
if prevterm != len(raw) {
|
|
rawterms = append(rawterms, raw[prevterm:])
|
|
}
|
|
|
|
nl := NodeList{}
|
|
for _, rawterm := range rawterms {
|
|
exprs := []interface {
|
|
consume(input string) (next string, ok bool)
|
|
limits() []map[string]int
|
|
prefix() string
|
|
}{}
|
|
|
|
for i := 0; i < len(rawterm); i++ {
|
|
c := rawterm[i]
|
|
if isLetter(c) || isDigit(c) {
|
|
j := i
|
|
for j < len(rawterm) &&
|
|
(isLetter(rawterm[j]) ||
|
|
isDigit(rawterm[j]) ||
|
|
isDash(rawterm[j])) {
|
|
j++
|
|
}
|
|
exprs = append(exprs, NLExprString(rawterm[i:j]))
|
|
i = j - 1
|
|
} else if c == '[' {
|
|
end := strings.Index(rawterm[i:], "]")
|
|
|
|
if end == -1 {
|
|
return nil, fmt.Errorf("ARCHIVE/NODELIST > unclosed '['")
|
|
}
|
|
|
|
parts := strings.Split(rawterm[i+1:i+end], ",")
|
|
nles := NLExprIntRanges{}
|
|
|
|
for _, part := range parts {
|
|
before, after, ok := strings.Cut(part, "-")
|
|
if !ok {
|
|
return nil, fmt.Errorf("ARCHIVE/NODELIST > no '-' found inside '[...]'")
|
|
}
|
|
|
|
s1, s2 := before, after
|
|
if len(s1) != len(s2) || len(s1) == 0 {
|
|
return nil, fmt.Errorf("ARCHIVE/NODELIST > %v and %v are not of equal length or of length zero", s1, s2)
|
|
}
|
|
|
|
x1, err := strconv.ParseInt(s1, 10, 32)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("ARCHIVE/NODELIST > could not parse int: %w", err)
|
|
}
|
|
x2, err := strconv.ParseInt(s2, 10, 32)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("ARCHIVE/NODELIST > could not parse int: %w", err)
|
|
}
|
|
|
|
nles = append(nles, NLExprIntRange{
|
|
start: x1,
|
|
end: x2,
|
|
digits: len(s1),
|
|
zeroPadded: true,
|
|
})
|
|
}
|
|
|
|
exprs = append(exprs, nles)
|
|
i += end
|
|
} else {
|
|
return nil, fmt.Errorf("ARCHIVE/NODELIST > invalid character: %#v", rune(c))
|
|
}
|
|
}
|
|
nl = append(nl, exprs)
|
|
}
|
|
|
|
return nl, nil
|
|
}
|