feat(service): add Application service with parameter validation and script rendering
Add ApplicationService with ValidateParams, RenderScript, SubmitFromApplication. Includes shell escaping, longest-first parameter replacement, and work directory generation. 15 tests. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
198
internal/service/application_service.go
Normal file
198
internal/service/application_service.go
Normal file
@@ -0,0 +1,198 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gcy_hpc_server/internal/model"
|
||||
"gcy_hpc_server/internal/store"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
var paramNameRegex = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
||||
|
||||
// ApplicationService handles parameter validation, script rendering, and job
|
||||
// submission for parameterized HPC applications.
|
||||
type ApplicationService struct {
|
||||
store *store.ApplicationStore
|
||||
jobSvc *JobService
|
||||
workDirBase string
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
func NewApplicationService(store *store.ApplicationStore, jobSvc *JobService, workDirBase string, logger *zap.Logger) *ApplicationService {
|
||||
return &ApplicationService{store: store, jobSvc: jobSvc, workDirBase: workDirBase, logger: logger}
|
||||
}
|
||||
|
||||
// ValidateParams checks that all required parameters are present and values match their types.
|
||||
// Parameters not in the schema are silently ignored.
|
||||
func (s *ApplicationService) 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:
|
||||
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).
|
||||
// All values are shell-escaped using single-quote wrapping.
|
||||
func (s *ApplicationService) 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)
|
||||
})
|
||||
|
||||
result := template
|
||||
for _, p := range sorted {
|
||||
val, ok := values[p.Name]
|
||||
if !ok {
|
||||
if p.Default != "" {
|
||||
val = p.Default
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
}
|
||||
escaped := "'" + strings.ReplaceAll(val, "'", "'\\''") + "'"
|
||||
result = strings.ReplaceAll(result, "$"+p.Name, escaped)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ListApplications delegates to the store.
|
||||
func (s *ApplicationService) ListApplications(ctx context.Context, page, pageSize int) ([]model.Application, int, error) {
|
||||
return s.store.List(ctx, page, pageSize)
|
||||
}
|
||||
|
||||
// CreateApplication delegates to the store.
|
||||
func (s *ApplicationService) CreateApplication(ctx context.Context, req *model.CreateApplicationRequest) (int64, error) {
|
||||
return s.store.Create(ctx, req)
|
||||
}
|
||||
|
||||
// GetApplication delegates to the store.
|
||||
func (s *ApplicationService) GetApplication(ctx context.Context, id int64) (*model.Application, error) {
|
||||
return s.store.GetByID(ctx, id)
|
||||
}
|
||||
|
||||
// UpdateApplication delegates to the store.
|
||||
func (s *ApplicationService) UpdateApplication(ctx context.Context, id int64, req *model.UpdateApplicationRequest) error {
|
||||
return s.store.Update(ctx, id, req)
|
||||
}
|
||||
|
||||
// DeleteApplication delegates to the store.
|
||||
func (s *ApplicationService) DeleteApplication(ctx context.Context, id int64) error {
|
||||
return s.store.Delete(ctx, id)
|
||||
}
|
||||
|
||||
// SubmitFromApplication orchestrates the full submission flow:
|
||||
// 1. Fetch application by ID
|
||||
// 2. Parse parameters schema
|
||||
// 3. Validate parameter values
|
||||
// 4. Render script template
|
||||
// 5. Submit job via JobService
|
||||
func (s *ApplicationService) SubmitFromApplication(ctx context.Context, applicationID int64, values map[string]string) (*model.JobResponse, error) {
|
||||
app, err := s.store.GetByID(ctx, applicationID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get application: %w", err)
|
||||
}
|
||||
if app == nil {
|
||||
return nil, fmt.Errorf("application %d not found", applicationID)
|
||||
}
|
||||
|
||||
var params []model.ParameterSchema
|
||||
if len(app.Parameters) > 0 {
|
||||
if err := json.Unmarshal(app.Parameters, ¶ms); err != nil {
|
||||
return nil, fmt.Errorf("parse parameters: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.ValidateParams(params, values); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rendered := s.RenderScript(app.ScriptTemplate, params, values)
|
||||
|
||||
workDir := ""
|
||||
if s.workDirBase != "" {
|
||||
safeName := sanitizeDirName(app.Name)
|
||||
subDir := time.Now().Format("20060102_150405") + "_" + randomSuffix(4)
|
||||
workDir = filepath.Join(s.workDirBase, safeName, subDir)
|
||||
if err := os.MkdirAll(workDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("create work directory %s: %w", workDir, err)
|
||||
}
|
||||
}
|
||||
|
||||
req := &model.SubmitJobRequest{Script: rendered, WorkDir: workDir}
|
||||
return s.jobSvc.SubmitJob(ctx, req)
|
||||
}
|
||||
|
||||
func sanitizeDirName(name string) string {
|
||||
replacer := strings.NewReplacer(" ", "_", "/", "_", "\\", "_", ":", "_", "*", "_", "?", "_", "\"", "_", "<", "_", ">", "_", "|", "_")
|
||||
return replacer.Replace(name)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
300
internal/service/application_service_test.go
Normal file
300
internal/service/application_service_test.go
Normal file
@@ -0,0 +1,300 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gcy_hpc_server/internal/model"
|
||||
"gcy_hpc_server/internal/slurm"
|
||||
"gcy_hpc_server/internal/store"
|
||||
|
||||
"go.uber.org/zap"
|
||||
gormlogger "gorm.io/gorm/logger"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func setupApplicationService(t *testing.T, slurmHandler http.HandlerFunc) (*ApplicationService, func()) {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(slurmHandler)
|
||||
client, _ := slurm.NewClient(srv.URL, srv.Client())
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
|
||||
Logger: gormlogger.Default.LogMode(gormlogger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Application{}); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
|
||||
jobSvc := NewJobService(client, zap.NewNop())
|
||||
appStore := store.NewApplicationStore(db)
|
||||
appSvc := NewApplicationService(appStore, jobSvc, "", zap.NewNop())
|
||||
|
||||
return appSvc, srv.Close
|
||||
}
|
||||
|
||||
func TestValidateParams_AllRequired(t *testing.T) {
|
||||
svc := NewApplicationService(nil, nil, "", zap.NewNop())
|
||||
params := []model.ParameterSchema{
|
||||
{Name: "NAME", Type: model.ParamTypeString, Required: true},
|
||||
{Name: "COUNT", Type: model.ParamTypeInteger, Required: true},
|
||||
}
|
||||
values := map[string]string{"NAME": "hello", "COUNT": "5"}
|
||||
if err := svc.ValidateParams(params, values); err != nil {
|
||||
t.Errorf("expected no error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateParams_MissingRequired(t *testing.T) {
|
||||
svc := NewApplicationService(nil, nil, "", zap.NewNop())
|
||||
params := []model.ParameterSchema{
|
||||
{Name: "NAME", Type: model.ParamTypeString, Required: true},
|
||||
}
|
||||
values := map[string]string{}
|
||||
err := svc.ValidateParams(params, values)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing required param")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "NAME") {
|
||||
t.Errorf("error should mention param name, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateParams_InvalidInteger(t *testing.T) {
|
||||
svc := NewApplicationService(nil, nil, "", zap.NewNop())
|
||||
params := []model.ParameterSchema{
|
||||
{Name: "COUNT", Type: model.ParamTypeInteger, Required: true},
|
||||
}
|
||||
values := map[string]string{"COUNT": "abc"}
|
||||
err := svc.ValidateParams(params, values)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid integer")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "integer") {
|
||||
t.Errorf("error should mention integer, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateParams_InvalidEnum(t *testing.T) {
|
||||
svc := NewApplicationService(nil, nil, "", zap.NewNop())
|
||||
params := []model.ParameterSchema{
|
||||
{Name: "MODE", Type: model.ParamTypeEnum, Required: true, Options: []string{"fast", "slow"}},
|
||||
}
|
||||
values := map[string]string{"MODE": "medium"}
|
||||
err := svc.ValidateParams(params, values)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid enum value")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "MODE") {
|
||||
t.Errorf("error should mention param name, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateParams_BooleanValues(t *testing.T) {
|
||||
svc := NewApplicationService(nil, nil, "", zap.NewNop())
|
||||
params := []model.ParameterSchema{
|
||||
{Name: "FLAG", Type: model.ParamTypeBoolean, Required: true},
|
||||
}
|
||||
for _, val := range []string{"true", "false", "1", "0"} {
|
||||
err := svc.ValidateParams(params, map[string]string{"FLAG": val})
|
||||
if err != nil {
|
||||
t.Errorf("boolean value %q should be valid, got error: %v", val, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderScript_SimpleReplacement(t *testing.T) {
|
||||
svc := NewApplicationService(nil, nil, "", zap.NewNop())
|
||||
params := []model.ParameterSchema{{Name: "INPUT", Type: model.ParamTypeString}}
|
||||
values := map[string]string{"INPUT": "data.txt"}
|
||||
result := svc.RenderScript("echo $INPUT", params, values)
|
||||
expected := "echo 'data.txt'"
|
||||
if result != expected {
|
||||
t.Errorf("got %q, want %q", result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderScript_DefaultValues(t *testing.T) {
|
||||
svc := NewApplicationService(nil, nil, "", zap.NewNop())
|
||||
params := []model.ParameterSchema{{Name: "OUTPUT", Type: model.ParamTypeString, Default: "out.log"}}
|
||||
values := map[string]string{}
|
||||
result := svc.RenderScript("cat $OUTPUT", params, values)
|
||||
expected := "cat 'out.log'"
|
||||
if result != expected {
|
||||
t.Errorf("got %q, want %q", result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderScript_PreservesUnknownVars(t *testing.T) {
|
||||
svc := NewApplicationService(nil, nil, "", zap.NewNop())
|
||||
params := []model.ParameterSchema{{Name: "INPUT", Type: model.ParamTypeString}}
|
||||
values := map[string]string{"INPUT": "data.txt"}
|
||||
result := svc.RenderScript("export HOME=$HOME\necho $INPUT\necho $PATH", params, values)
|
||||
if !strings.Contains(result, "$HOME") {
|
||||
t.Error("$HOME should be preserved")
|
||||
}
|
||||
if !strings.Contains(result, "$PATH") {
|
||||
t.Error("$PATH should be preserved")
|
||||
}
|
||||
if !strings.Contains(result, "'data.txt'") {
|
||||
t.Error("$INPUT should be replaced")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderScript_ShellEscaping(t *testing.T) {
|
||||
svc := NewApplicationService(nil, nil, "", zap.NewNop())
|
||||
params := []model.ParameterSchema{{Name: "INPUT", Type: model.ParamTypeString}}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
expected string
|
||||
}{
|
||||
{"semicolon injection", "; rm -rf /", "'; rm -rf /'"},
|
||||
{"command substitution", "$(cat /etc/passwd)", "'$(cat /etc/passwd)'"},
|
||||
{"single quote", "hello'world", "'hello'\\''world'"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := svc.RenderScript("$INPUT", params, map[string]string{"INPUT": tt.value})
|
||||
if result != tt.expected {
|
||||
t.Errorf("got %q, want %q", result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderScript_OverlappingParams(t *testing.T) {
|
||||
svc := NewApplicationService(nil, nil, "", zap.NewNop())
|
||||
template := "$JOB_NAME and $JOB"
|
||||
params := []model.ParameterSchema{
|
||||
{Name: "JOB", Type: model.ParamTypeString},
|
||||
{Name: "JOB_NAME", Type: model.ParamTypeString},
|
||||
}
|
||||
values := map[string]string{"JOB": "myjob", "JOB_NAME": "my-test-job"}
|
||||
result := svc.RenderScript(template, params, values)
|
||||
if strings.Contains(result, "$JOB_NAME") {
|
||||
t.Error("$JOB_NAME was not replaced")
|
||||
}
|
||||
if strings.Contains(result, "$JOB") {
|
||||
t.Error("$JOB was not replaced")
|
||||
}
|
||||
if !strings.Contains(result, "'my-test-job'") {
|
||||
t.Errorf("expected 'my-test-job' in result, got: %s", result)
|
||||
}
|
||||
if !strings.Contains(result, "'myjob'") {
|
||||
t.Errorf("expected 'myjob' in result, got: %s", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderScript_NewlineInValue(t *testing.T) {
|
||||
svc := NewApplicationService(nil, nil, "", zap.NewNop())
|
||||
params := []model.ParameterSchema{{Name: "CMD", Type: model.ParamTypeString}}
|
||||
values := map[string]string{"CMD": "line1\nline2"}
|
||||
result := svc.RenderScript("echo $CMD", params, values)
|
||||
expected := "echo 'line1\nline2'"
|
||||
if result != expected {
|
||||
t.Errorf("got %q, want %q", result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitFromApplication_Success(t *testing.T) {
|
||||
jobID := int32(42)
|
||||
appSvc, cleanup := setupApplicationService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(slurm.OpenapiJobSubmitResponse{
|
||||
Result: &slurm.JobSubmitResponseMsg{JobID: &jobID},
|
||||
})
|
||||
}))
|
||||
defer cleanup()
|
||||
|
||||
id, err := appSvc.store.Create(context.Background(), &model.CreateApplicationRequest{
|
||||
Name: "test-app",
|
||||
ScriptTemplate: "#!/bin/bash\n#SBATCH --job-name=$JOB_NAME\necho $INPUT",
|
||||
Parameters: json.RawMessage(`[{"name":"JOB_NAME","type":"string","required":true},{"name":"INPUT","type":"string","required":true}]`),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create app: %v", err)
|
||||
}
|
||||
|
||||
resp, err := appSvc.SubmitFromApplication(context.Background(), id, map[string]string{
|
||||
"JOB_NAME": "my-job",
|
||||
"INPUT": "hello",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SubmitFromApplication() error = %v", err)
|
||||
}
|
||||
if resp.JobID != 42 {
|
||||
t.Errorf("JobID = %d, want 42", resp.JobID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitFromApplication_AppNotFound(t *testing.T) {
|
||||
appSvc, cleanup := setupApplicationService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer cleanup()
|
||||
|
||||
_, err := appSvc.SubmitFromApplication(context.Background(), 99999, map[string]string{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-existent app")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not found") {
|
||||
t.Errorf("error should mention 'not found', got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitFromApplication_ValidationFail(t *testing.T) {
|
||||
appSvc, cleanup := setupApplicationService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer cleanup()
|
||||
|
||||
_, err := appSvc.store.Create(context.Background(), &model.CreateApplicationRequest{
|
||||
Name: "valid-app",
|
||||
ScriptTemplate: "#!/bin/bash\necho $INPUT",
|
||||
Parameters: json.RawMessage(`[{"name":"INPUT","type":"string","required":true}]`),
|
||||
})
|
||||
|
||||
_, err = appSvc.SubmitFromApplication(context.Background(), 1, map[string]string{})
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for missing required param")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "missing") {
|
||||
t.Errorf("error should mention 'missing', got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitFromApplication_NoParameters(t *testing.T) {
|
||||
jobID := int32(99)
|
||||
appSvc, cleanup := setupApplicationService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(slurm.OpenapiJobSubmitResponse{
|
||||
Result: &slurm.JobSubmitResponseMsg{JobID: &jobID},
|
||||
})
|
||||
}))
|
||||
defer cleanup()
|
||||
|
||||
id, err := appSvc.store.Create(context.Background(), &model.CreateApplicationRequest{
|
||||
Name: "simple-app",
|
||||
ScriptTemplate: "#!/bin/bash\necho hello",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create app: %v", err)
|
||||
}
|
||||
|
||||
resp, err := appSvc.SubmitFromApplication(context.Background(), id, map[string]string{})
|
||||
if err != nil {
|
||||
t.Fatalf("SubmitFromApplication() error = %v", err)
|
||||
}
|
||||
if resp.JobID != 99 {
|
||||
t.Errorf("JobID = %d, want 99", resp.JobID)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user