package controllers import ( "apiserver/models" "apiserver/store" "apiserver/utils" "sync" "testing" "go.uber.org/zap" ) func resetHostControlState() { store.InitStores() utils.Log = zap.NewNop() } // TestPreCheckHostControlRequest_AllFlag verifies that when hostname is ["all"], // the returned bool is true. func TestPreCheckHostControlRequest_AllFlag(t *testing.T) { resetHostControlState() req := &models.HostControlRequest{ ControlAction: "close", Hostname: []string{"all"}, } allFlag, err := preCheckHostControlRequest(req) if err != nil { t.Fatalf("unexpected error: %v", err) } if !allFlag { t.Error("expected allFlag=true when hostname is [\"all\"]") } } // TestPreCheckHostControlRequest_NotAll verifies that when hostname is a // specific host, the returned bool is false. func TestPreCheckHostControlRequest_NotAll(t *testing.T) { resetHostControlState() req := &models.HostControlRequest{ ControlAction: "open", Hostname: []string{"node01"}, } allFlag, err := preCheckHostControlRequest(req) if err != nil { t.Fatalf("unexpected error: %v", err) } if allFlag { t.Error("expected allFlag=false when hostname is [\"node01\"]") } } // TestPreCheckHostControlRequest_NoRace verifies that concurrent calls to // preCheckHostControlRequest do not share state via a package-level variable. func TestPreCheckHostControlRequest_NoRace(t *testing.T) { t.Parallel() resetHostControlState() var wg sync.WaitGroup const goroutines = 50 results := make([]bool, goroutines) errors := make([]error, goroutines) for i := 0; i < goroutines; i++ { wg.Add(1) go func(idx int) { defer wg.Done() var req models.HostControlRequest if idx%2 == 0 { req = models.HostControlRequest{ ControlAction: "close", Hostname: []string{"all"}, } } else { req = models.HostControlRequest{ ControlAction: "open", Hostname: []string{"node01"}, } } results[idx], errors[idx] = preCheckHostControlRequest(&req) }(i) } wg.Wait() for i := 0; i < goroutines; i++ { if errors[i] != nil { t.Errorf("goroutine %d: unexpected error: %v", i, errors[i]) continue } if i%2 == 0 && !results[i] { t.Errorf("goroutine %d: expected allFlag=true for [\"all\"]", i) } if i%2 != 0 && results[i] { t.Errorf("goroutine %d: expected allFlag=false for [\"node01\"]", i) } } } // TestPreCheckHostControlRequest_InvalidAction verifies that an unsupported // action returns an error. func TestPreCheckHostControlRequest_InvalidAction(t *testing.T) { resetHostControlState() req := &models.HostControlRequest{ ControlAction: "invalid_action", Hostname: []string{"node01"}, } _, err := preCheckHostControlRequest(req) if err == nil { t.Error("expected error for invalid action, got nil") } } // TestPreCheckHostControlRequest_CommentTooLong verifies that a comment // exceeding 512 characters returns an error. func TestPreCheckHostControlRequest_CommentTooLong(t *testing.T) { resetHostControlState() longComment := make([]byte, 513) for i := range longComment { longComment[i] = 'a' } req := &models.HostControlRequest{ ControlAction: "close", Hostname: []string{"node01"}, Comment: string(longComment), } _, err := preCheckHostControlRequest(req) if err == nil { t.Error("expected error for comment exceeding 512 chars, got nil") } } // TestProcessHostData_ComputesSlotUsageFromJobs is a regression guard for // bug-1743 (gitea #46): models.HostInfo.NumRun/.NumSSusp/.NumUSusp exist in // the schema and are already shown by `xcecli hosts list`, but nothing in // the apiserver ever assigned them -- they were permanently stuck at Go's // zero value, so `hosts list` always showed NUM_RUN=0 regardless of how many // jobs were actually running on a host. processHostData must now derive // these counts from store.Jobs' live exec_hosts/job_status data (the same // JSONB shape backend/convert_pure.go's job-conversion path populates: // {"host_slots":[{"host_name":..,"num_slots":N}],"num_hosts":N}), counting // slots (not just job count) so the numbers are comparable to max_slot. func TestProcessHostData_ComputesSlotUsageFromJobs(t *testing.T) { resetHostControlState() store.Hosts.Put("test01", models.HostInfo{ HostName: "test01", NumCPUs: 16, MaxSlot: 16, }) putJob := func(id int64, execHosts string, status int) { store.Jobs.Put(uint64(id), models.JobInfo{ Unique_JobID: id, JobID: id, JobStatus: status, ExecHosts: models.Jsonb{String: execHosts}, }) } // 6 plain RUN jobs, 1 slot each on test01. for i := int64(1); i <= 6; i++ { putJob(i, `{"host_slots":[{"host_name":"test01","num_slots":1}],"num_hosts":1}`, models.JOBSTAT["RUN"]) } // 1 user-suspended job (RUN|USUSP) -- must count as USUSP, not RUN. putJob(7, `{"host_slots":[{"host_name":"test01","num_slots":1}],"num_hosts":1}`, models.JOBSTAT["RUN"]|models.JOBSTAT["USUSP"]) // 1 system-suspended job (RUN|SSUSP) using 2 slots on test01. putJob(8, `{"host_slots":[{"host_name":"test01","num_slots":2}],"num_hosts":1}`, models.JOBSTAT["RUN"]|models.JOBSTAT["SSUSP"]) // 1 PEND job with no exec host yet -- must not be counted anywhere. putJob(9, "", models.JOBSTAT["PEND"]) // 1 job on a different host -- must not count toward test01. putJob(10, `{"host_slots":[{"host_name":"other-host","num_slots":1}],"num_hosts":1}`, models.JOBSTAT["RUN"]) rows := store.StoreToOrderMaps(store.Hosts) result := processHostData(rows, "") if result.Count != 1 { t.Fatalf("expected 1 host row, got %d", result.Count) } row := result.Rows[0] if got := row.Data["num_run"]; got != 6 { t.Errorf("num_run = %v, want 6", got) } if got := row.Data["num_ssusp"]; got != 2 { t.Errorf("num_ssusp = %v, want 2 (2-slot SSUSP job)", got) } if got := row.Data["num_ususp"]; got != 1 { t.Errorf("num_ususp = %v, want 1", got) } }