Files
hpc/internal/service/script_utils.go
dailz 5591b67f75 feat(task): auto-inject scheduling params into script template via scheduling_map
Add scheduling_map field to ParameterSchema so Application creators can
declare that a parameter (e.g. NP) maps to a scheduling field (e.g. cpus).
The backend auto-injects the scheduling value into script template variables
before rendering, eliminating duplicate user input. The frontend hides
mapped parameters from the form and injects their values on submit.
2026-04-22 10:26:52 +08:00

151 lines
4.3 KiB
Go

package service
import (
"fmt"
"math/rand"
"regexp"
"sort"
"strconv"
"strings"
"gcy_hpc_server/internal/model"
)
var paramNameRegex = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
// ValidateParams checks that all required parameters are present and values match their types.
// Parameters not in the schema are silently ignored.
func ValidateParams(params []model.ParameterSchema, values map[string]string) error {
var errs []string
for _, p := range params {
if !paramNameRegex.MatchString(p.Name) {
errs = append(errs, fmt.Sprintf("invalid parameter name %q: must match ^[A-Za-z_][A-Za-z0-9_]*$", p.Name))
continue
}
val, ok := values[p.Name]
if p.Required && !ok {
errs = append(errs, fmt.Sprintf("required parameter %q is missing", p.Name))
continue
}
if !ok {
continue
}
switch p.Type {
case model.ParamTypeInteger:
if _, err := strconv.Atoi(val); err != nil {
errs = append(errs, fmt.Sprintf("parameter %q must be an integer, got %q", p.Name, val))
}
case model.ParamTypeBoolean:
if val != "true" && val != "false" && val != "1" && val != "0" {
errs = append(errs, fmt.Sprintf("parameter %q must be a boolean (true/false/1/0), got %q", p.Name, val))
}
case model.ParamTypeEnum:
if len(p.Options) > 0 {
found := false
for _, opt := range p.Options {
if val == opt {
found = true
break
}
}
if !found {
errs = append(errs, fmt.Sprintf("parameter %q must be one of %v, got %q", p.Name, p.Options, val))
}
}
case model.ParamTypeFile, model.ParamTypeDirectory:
if _, err := strconv.ParseInt(val, 10, 64); err != nil {
errs = append(errs, fmt.Sprintf("parameter %q must be a valid file ID (integer), got %q", p.Name, val))
}
case model.ParamTypeString:
}
}
if len(errs) > 0 {
return fmt.Errorf("parameter validation failed: %s", strings.Join(errs, "; "))
}
return nil
}
// RenderScript replaces $PARAM tokens in the template with user-provided values.
// Only tokens defined in the schema are replaced. Replacement is done longest-name-first
// to avoid partial matches (e.g., $JOB_NAME before $JOB).
// String, integer, boolean, and enum values are shell-escaped using single-quote wrapping.
// File and directory values (including WORK_DIR) are inserted raw (no escaping) because
// they are paths used in SBATCH directives and command arguments.
func RenderScript(template string, params []model.ParameterSchema, values map[string]string) string {
sorted := make([]model.ParameterSchema, len(params))
copy(sorted, params)
sort.Slice(sorted, func(i, j int) bool {
return len(sorted[i].Name) > len(sorted[j].Name)
})
// Add virtual "WORK_DIR" entry so $WORK_DIR is replaced without escaping.
sorted = append(sorted, model.ParameterSchema{Name: "WORK_DIR", Type: model.ParamTypeFile})
result := template
for _, p := range sorted {
val, ok := values[p.Name]
if !ok {
if p.Default != "" {
val = p.Default
} else {
continue
}
}
if p.Type == model.ParamTypeFile || p.Type == model.ParamTypeDirectory {
result = strings.ReplaceAll(result, "$"+p.Name, val)
} else {
escaped := "'" + strings.ReplaceAll(val, "'", "'\\''") + "'"
result = strings.ReplaceAll(result, "$"+p.Name, escaped)
}
}
return result
}
// SanitizeDirName sanitizes a directory name.
func SanitizeDirName(name string) string {
replacer := strings.NewReplacer(" ", "_", "/", "_", "\\", "_", ":", "_", "*", "_", "?", "_", "\"", "_", "<", "_", ">", "_", "|", "_")
return replacer.Replace(name)
}
// RandomSuffix generates a random suffix of length n.
func RandomSuffix(n int) string {
const charset = "abcdefghijklmnopqrstuvwxyz0123456789"
b := make([]byte, n)
for i := range b {
b[i] = charset[rand.Intn(len(charset))]
}
return string(b)
}
func ResolveSchedulingMap(field string, task *model.Task) string {
switch field {
case "cpus":
return derefInt32ToStr(task.Cpus)
case "memory_per_node":
return derefInt64ToStr(task.MemoryPerNode)
case "memory_per_cpu":
return derefInt64ToStr(task.MemoryPerCpu)
case "nodes":
return derefStr(task.Nodes)
case "tasks":
return derefInt32ToStr(task.Tasks)
case "cpus_per_task":
return derefInt32ToStr(task.CpusPerTask)
case "partition":
return task.Partition
case "time_limit":
return derefInt32ToStr(task.TimeLimit)
case "qos":
return derefStr(task.QOS)
default:
return ""
}
}