From d3eb728c2f37511fb51ee88d365f453eab1e8d59 Mon Sep 17 00:00:00 2001 From: dailz Date: Mon, 13 Apr 2026 17:10:09 +0800 Subject: [PATCH] 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 --- internal/service/application_service.go | 198 ++++++++++++ internal/service/application_service_test.go | 300 +++++++++++++++++++ 2 files changed, 498 insertions(+) create mode 100644 internal/service/application_service.go create mode 100644 internal/service/application_service_test.go diff --git a/internal/service/application_service.go b/internal/service/application_service.go new file mode 100644 index 0000000..80cee2d --- /dev/null +++ b/internal/service/application_service.go @@ -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) +} diff --git a/internal/service/application_service_test.go b/internal/service/application_service_test.go new file mode 100644 index 0000000..409258f --- /dev/null +++ b/internal/service/application_service_test.go @@ -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) + } +}