feat: 添加 Reservation 领域类型和 ReservationsService

包含 ReservationInfo、ReservationCoreSpec 等类型。ReservationsService 提供 GetReservations 和 GetReservation 2 个方法。

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
dailz
2026-04-08 18:29:53 +08:00
parent 18ebad8f8f
commit 02ed2e7b38
4 changed files with 484 additions and 0 deletions

View File

@@ -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
}

View File

@@ -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")
}
}

View File

@@ -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"`
}

View File

@@ -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")
}
}