diff --git a/internal/slurm/slurm_reservations.go b/internal/slurm/slurm_reservations.go new file mode 100644 index 0000000..9242a28 --- /dev/null +++ b/internal/slurm/slurm_reservations.go @@ -0,0 +1,71 @@ +package slurm + +import ( + "context" + "fmt" + "net/url" + "strconv" +) + +// GetReservationsOptions specifies optional parameters for GetReservations. +type GetReservationsOptions struct { + UpdateTime *int64 `url:"update_time,omitempty"` +} + +// GetReservations lists all reservations. +func (s *ReservationsService) GetReservations(ctx context.Context, opts *GetReservationsOptions) (*OpenapiReservationResp, *Response, error) { + path := "slurm/v0.0.40/reservations" + req, err := s.client.NewRequest("GET", path, nil) + if err != nil { + return nil, nil, err + } + + if opts != nil { + u, parseErr := url.Parse(req.URL.String()) + if parseErr != nil { + return nil, nil, parseErr + } + q := u.Query() + if opts.UpdateTime != nil { + q.Set("update_time", strconv.FormatInt(*opts.UpdateTime, 10)) + } + u.RawQuery = q.Encode() + req.URL = u + } + + var result OpenapiReservationResp + resp, err := s.client.Do(ctx, req, &result) + if err != nil { + return nil, resp, err + } + return &result, resp, nil +} + +// GetReservation gets a single reservation by name. +func (s *ReservationsService) GetReservation(ctx context.Context, reservationName string, opts *GetReservationsOptions) (*OpenapiReservationResp, *Response, error) { + path := fmt.Sprintf("slurm/v0.0.40/reservation/%s", reservationName) + req, err := s.client.NewRequest("GET", path, nil) + if err != nil { + return nil, nil, err + } + + if opts != nil { + u, parseErr := url.Parse(req.URL.String()) + if parseErr != nil { + return nil, nil, parseErr + } + q := u.Query() + if opts.UpdateTime != nil { + q.Set("update_time", strconv.FormatInt(*opts.UpdateTime, 10)) + } + u.RawQuery = q.Encode() + req.URL = u + } + + var result OpenapiReservationResp + resp, err := s.client.Do(ctx, req, &result) + if err != nil { + return nil, resp, err + } + return &result, resp, nil +} diff --git a/internal/slurm/slurm_reservations_test.go b/internal/slurm/slurm_reservations_test.go new file mode 100644 index 0000000..9dbe100 --- /dev/null +++ b/internal/slurm/slurm_reservations_test.go @@ -0,0 +1,86 @@ +package slurm + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestReservationsService_GetReservations(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/slurm/v0.0.40/reservations", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + fmt.Fprint(w, `{"reservations": []}`) + }) + server := httptest.NewServer(mux) + defer server.Close() + + client, _ := NewClient(server.URL, nil) + resp, _, err := client.Reservations.GetReservations(context.Background(), nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp == nil { + t.Fatal("expected non-nil response") + } +} + +func TestReservationsService_GetReservation(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/slurm/v0.0.40/reservation/test-reservation", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + fmt.Fprint(w, `{"reservations": [{"name": "test-reservation"}]}`) + }) + server := httptest.NewServer(mux) + defer server.Close() + + client, _ := NewClient(server.URL, nil) + resp, _, err := client.Reservations.GetReservation(context.Background(), "test-reservation", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp == nil { + t.Fatal("expected non-nil response") + } + if resp.Reservations == nil { + t.Fatal("expected non-nil reservations") + } +} + +func TestReservationsService_GetReservations_Error(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/slurm/v0.0.40/reservations", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprint(w, `{"errors": [{"error": "internal error"}]}`) + }) + server := httptest.NewServer(mux) + defer server.Close() + + client, _ := NewClient(server.URL, nil) + _, _, err := client.Reservations.GetReservations(context.Background(), nil) + if err == nil { + t.Fatal("expected error for 500 response") + } + if !strings.Contains(err.Error(), "500") { + t.Errorf("expected error to contain 500, got %v", err) + } +} + +func TestReservationsService_GetReservation_Error(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/slurm/v0.0.40/reservation/nonexistent", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + fmt.Fprint(w, `{"errors": [{"error": "reservation not found"}]}`) + }) + server := httptest.NewServer(mux) + defer server.Close() + + client, _ := NewClient(server.URL, nil) + _, _, err := client.Reservations.GetReservation(context.Background(), "nonexistent", nil) + if err == nil { + t.Fatal("expected error for 404 response") + } +} diff --git a/internal/slurm/types_reservation.go b/internal/slurm/types_reservation.go new file mode 100644 index 0000000..9eb5dac --- /dev/null +++ b/internal/slurm/types_reservation.go @@ -0,0 +1,54 @@ +package slurm + +// --------------------------------------------------------------------------- +// Reservation types — v0.0.40 reservation schemas +// --------------------------------------------------------------------------- + +// ReservationCoreSpec represents a single core specialization entry (v0.0.40_reservation_core_spec). +type ReservationCoreSpec struct { + Node *string `json:"node,omitempty"` + Core *string `json:"core,omitempty"` +} + +// ReservationInfoCoreSpec is a collection of ReservationCoreSpec objects (v0.0.40_reservation_info_core_spec). +type ReservationInfoCoreSpec []ReservationCoreSpec + +// ReservationPurgeCompleted represents purge_completed settings for a reservation. +type ReservationPurgeCompleted struct { + Time *Uint32NoVal `json:"time,omitempty"` +} + +// ReservationInfo represents a Slurm reservation (v0.0.40_reservation_info). +type ReservationInfo struct { + Accounts *string `json:"accounts,omitempty"` + BurstBuffer *string `json:"burst_buffer,omitempty"` + CoreCount *int32 `json:"core_count,omitempty"` + CoreSpecializations *ReservationInfoCoreSpec `json:"core_specializations,omitempty"` + EndTime *Uint64NoVal `json:"end_time,omitempty"` + Features *string `json:"features,omitempty"` + Flags []string `json:"flags,omitempty"` + Groups *string `json:"groups,omitempty"` + Licenses *string `json:"licenses,omitempty"` + MaxStartDelay *int32 `json:"max_start_delay,omitempty"` + Name *string `json:"name,omitempty"` + NodeCount *int32 `json:"node_count,omitempty"` + NodeList *string `json:"node_list,omitempty"` + Partition *string `json:"partition,omitempty"` + PurgeCompleted *ReservationPurgeCompleted `json:"purge_completed,omitempty"` + StartTime *Uint64NoVal `json:"start_time,omitempty"` + Watts *Uint32NoVal `json:"watts,omitempty"` + TRES *string `json:"tres,omitempty"` + Users *string `json:"users,omitempty"` +} + +// ReservationInfoMsg is a collection of ReservationInfo objects (v0.0.40_reservation_info_msg). +type ReservationInfoMsg []ReservationInfo + +// OpenapiReservationResp represents the response for reservation queries (v0.0.40_openapi_reservation_resp). +type OpenapiReservationResp struct { + Reservations *ReservationInfoMsg `json:"reservations,omitempty"` + LastUpdate *Uint64NoVal `json:"last_update,omitempty"` + Meta *OpenapiMeta `json:"meta,omitempty"` + Errors OpenapiErrors `json:"errors,omitempty"` + Warnings OpenapiWarnings `json:"warnings,omitempty"` +} diff --git a/internal/slurm/types_reservation_test.go b/internal/slurm/types_reservation_test.go new file mode 100644 index 0000000..8a4d481 --- /dev/null +++ b/internal/slurm/types_reservation_test.go @@ -0,0 +1,273 @@ +package slurm + +import ( + "encoding/json" + "testing" +) + +func TestReservationCoreSpecRoundTrip(t *testing.T) { + orig := ReservationCoreSpec{ + Node: Ptr("node01"), + Core: Ptr("0-3"), + } + data, err := json.Marshal(orig) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var decoded ReservationCoreSpec + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if decoded.Node == nil || *decoded.Node != "node01" { + t.Fatalf("node mismatch: %v", decoded.Node) + } + if decoded.Core == nil || *decoded.Core != "0-3" { + t.Fatalf("core mismatch: %v", decoded.Core) + } +} + +func TestReservationCoreSpecEmptyRoundTrip(t *testing.T) { + orig := ReservationCoreSpec{} + data, err := json.Marshal(orig) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if string(data) != "{}" { + t.Fatalf("empty ReservationCoreSpec should marshal to {}, got %s", data) + } + var decoded ReservationCoreSpec + if err := json.Unmarshal([]byte(`{}`), &decoded); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if decoded.Node != nil || decoded.Core != nil { + t.Fatal("all fields should be nil for empty object") + } +} + +func TestReservationInfoCoreSpecRoundTrip(t *testing.T) { + orig := ReservationInfoCoreSpec{ + {Node: Ptr("node01"), Core: Ptr("0-3")}, + {Node: Ptr("node02"), Core: Ptr("4-7")}, + } + data, err := json.Marshal(orig) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var decoded ReservationInfoCoreSpec + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(decoded) != 2 { + t.Fatalf("expected 2 entries, got %d", len(decoded)) + } + if decoded[0].Node == nil || *decoded[0].Node != "node01" { + t.Fatalf("entry[0].node mismatch: %v", decoded[0].Node) + } + if decoded[1].Core == nil || *decoded[1].Core != "4-7" { + t.Fatalf("entry[1].core mismatch: %v", decoded[1].Core) + } +} + +func TestReservationInfoRoundTrip(t *testing.T) { + orig := ReservationInfo{ + Accounts: Ptr("admin,dev"), + BurstBuffer: Ptr("bb_test"), + CoreCount: Ptr(int32(16)), + CoreSpecializations: &ReservationInfoCoreSpec{ + {Node: Ptr("node01"), Core: Ptr("0-3")}, + }, + EndTime: &Uint64NoVal{ + Set: Ptr(true), + Number: Ptr(int64(1700000000)), + }, + Features: Ptr("gpu,nvme"), + Flags: []string{"MAINT", "DAILY", "IGNORE_JOBS"}, + Groups: Ptr("slurm"), + Licenses: Ptr("matlab:2"), + MaxStartDelay: Ptr(int32(300)), + Name: Ptr("test_resv"), + NodeCount: Ptr(int32(4)), + NodeList: Ptr("node[01-04]"), + Partition: Ptr("normal"), + PurgeCompleted: &ReservationPurgeCompleted{ + Time: &Uint32NoVal{Set: Ptr(true), Number: Ptr(int64(3600))}, + }, + StartTime: &Uint64NoVal{ + Set: Ptr(true), + Number: Ptr(int64(1699990000)), + }, + Watts: &Uint32NoVal{Set: Ptr(true), Infinite: Ptr(true)}, + TRES: Ptr("billing=16"), + Users: Ptr("user1,user2"), + } + data, err := json.Marshal(orig) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var decoded ReservationInfo + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if decoded.Name == nil || *decoded.Name != "test_resv" { + t.Fatalf("name mismatch: %v", decoded.Name) + } + if decoded.Accounts == nil || *decoded.Accounts != "admin,dev" { + t.Fatalf("accounts mismatch: %v", decoded.Accounts) + } + if decoded.CoreCount == nil || *decoded.CoreCount != 16 { + t.Fatalf("core_count mismatch: %v", decoded.CoreCount) + } + if decoded.CoreSpecializations == nil || len(*decoded.CoreSpecializations) != 1 { + t.Fatalf("core_specializations mismatch: %v", decoded.CoreSpecializations) + } + if decoded.EndTime == nil || decoded.EndTime.Number == nil || *decoded.EndTime.Number != 1700000000 { + t.Fatalf("end_time mismatch: %v", decoded.EndTime) + } + if len(decoded.Flags) != 3 || decoded.Flags[0] != "MAINT" || decoded.Flags[2] != "IGNORE_JOBS" { + t.Fatalf("flags mismatch: %v", decoded.Flags) + } + if decoded.MaxStartDelay == nil || *decoded.MaxStartDelay != 300 { + t.Fatalf("max_start_delay mismatch: %v", decoded.MaxStartDelay) + } + if decoded.NodeCount == nil || *decoded.NodeCount != 4 { + t.Fatalf("node_count mismatch: %v", decoded.NodeCount) + } + if decoded.PurgeCompleted == nil || decoded.PurgeCompleted.Time == nil || decoded.PurgeCompleted.Time.Number == nil || *decoded.PurgeCompleted.Time.Number != 3600 { + t.Fatalf("purge_completed.time mismatch: %v", decoded.PurgeCompleted) + } + if decoded.StartTime == nil || decoded.StartTime.Number == nil || *decoded.StartTime.Number != 1699990000 { + t.Fatalf("start_time mismatch: %v", decoded.StartTime) + } + if decoded.Watts == nil || decoded.Watts.Infinite == nil || !*decoded.Watts.Infinite { + t.Fatalf("watts mismatch: %v", decoded.Watts) + } + if decoded.TRES == nil || *decoded.TRES != "billing=16" { + t.Fatalf("tres mismatch: %v", decoded.TRES) + } + if decoded.Users == nil || *decoded.Users != "user1,user2" { + t.Fatalf("users mismatch: %v", decoded.Users) + } +} + +func TestReservationInfoEmptyRoundTrip(t *testing.T) { + orig := ReservationInfo{} + data, err := json.Marshal(orig) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if string(data) != "{}" { + t.Fatalf("empty ReservationInfo should marshal to {}, got %s", data) + } + var decoded ReservationInfo + if err := json.Unmarshal([]byte(`{}`), &decoded); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if decoded.Name != nil { + t.Fatal("name should be nil for empty object") + } +} + +func TestReservationInfoMsgRoundTrip(t *testing.T) { + orig := ReservationInfoMsg{ + {Name: Ptr("resv1"), NodeCount: Ptr(int32(2))}, + {Name: Ptr("resv2"), NodeCount: Ptr(int32(4)), Flags: []string{"MAINT", "FLEX"}}, + } + data, err := json.Marshal(orig) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var decoded ReservationInfoMsg + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(decoded) != 2 { + t.Fatalf("expected 2 reservations, got %d", len(decoded)) + } + if decoded[0].Name == nil || *decoded[0].Name != "resv1" { + t.Fatalf("reservations[0].name mismatch: %v", decoded[0].Name) + } + if decoded[1].Name == nil || *decoded[1].Name != "resv2" { + t.Fatalf("reservations[1].name mismatch: %v", decoded[1].Name) + } + if len(decoded[1].Flags) != 2 || decoded[1].Flags[1] != "FLEX" { + t.Fatalf("reservations[1].flags mismatch: %v", decoded[1].Flags) + } +} + +func TestOpenapiReservationRespRoundTrip(t *testing.T) { + reservations := ReservationInfoMsg{ + { + Name: Ptr("maint_window"), + Partition: Ptr("normal"), + NodeList: Ptr("node[01-10]"), + Flags: []string{"MAINT", "IGNORE_JOBS"}, + EndTime: &Uint64NoVal{ + Set: Ptr(true), + Number: Ptr(int64(1700050000)), + }, + StartTime: &Uint64NoVal{ + Set: Ptr(true), + Number: Ptr(int64(1700000000)), + }, + CoreCount: Ptr(int32(160)), + NodeCount: Ptr(int32(10)), + }, + { + Name: Ptr("gpu_resv"), + TRES: Ptr("gres/gpu=8"), + Users: Ptr("researcher1"), + Watts: &Uint32NoVal{Set: Ptr(true), Infinite: Ptr(true)}, + }, + } + orig := OpenapiReservationResp{ + Reservations: &reservations, + LastUpdate: &Uint64NoVal{ + Set: Ptr(true), + Number: Ptr(int64(1700010000)), + }, + Meta: &OpenapiMeta{ + Slurm: &MetaSlurm{ + Version: &MetaSlurmVersion{ + Major: Ptr("24"), + Micro: Ptr("5"), + Minor: Ptr("05"), + }, + }, + }, + Errors: OpenapiErrors{}, + Warnings: OpenapiWarnings{}, + } + data, err := json.Marshal(orig) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var decoded OpenapiReservationResp + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if decoded.Reservations == nil || len(*decoded.Reservations) != 2 { + t.Fatalf("expected 2 reservations, got %d", len(*decoded.Reservations)) + } + rs := *decoded.Reservations + if rs[0].Name == nil || *rs[0].Name != "maint_window" { + t.Fatalf("reservations[0].name mismatch") + } + if rs[0].EndTime == nil || rs[0].EndTime.Number == nil || *rs[0].EndTime.Number != 1700050000 { + t.Fatalf("reservations[0].end_time mismatch") + } + if len(rs[0].Flags) != 2 || rs[0].Flags[0] != "MAINT" { + t.Fatalf("reservations[0].flags mismatch: %v", rs[0].Flags) + } + if rs[1].TRES == nil || *rs[1].TRES != "gres/gpu=8" { + t.Fatalf("reservations[1].tres mismatch: %v", rs[1].TRES) + } + if rs[1].Watts == nil || rs[1].Watts.Infinite == nil || !*rs[1].Watts.Infinite { + t.Fatalf("reservations[1].watts.infinite mismatch") + } + if decoded.LastUpdate == nil || decoded.LastUpdate.Number == nil || *decoded.LastUpdate.Number != 1700010000 { + t.Fatalf("last_update mismatch: %v", decoded.LastUpdate) + } + if decoded.Meta == nil || decoded.Meta.Slurm == nil || decoded.Meta.Slurm.Version == nil { + t.Fatal("meta.slurm.version should not be nil") + } +}