diff --git a/cmd/common_test.go b/cmd/common_test.go index aa6c9cc..81826ad 100644 --- a/cmd/common_test.go +++ b/cmd/common_test.go @@ -164,14 +164,15 @@ func mustMarshalToMultiYAML[R any](t *testing.T, data []R) []byte { return []byte(strings.Join(parts, "\n---\n")) } -func mustJsonDeepCopy[O any](t *testing.T, object O) O { - raw, err := json.Marshal(&object) - require.NoError(t, err) - var copy O - err = json.Unmarshal(raw, ©) - require.NoError(t, err) - return copy -} +// might come in handy later: +// func mustJsonDeepCopy[O any](t *testing.T, object O) O { +// raw, err := json.Marshal(&object) +// require.NoError(t, err) +// var copy O +// err = json.Unmarshal(raw, ©) +// require.NoError(t, err) +// return copy +// } func outputFormats[R any](c *test[R]) []outputFormat[R] { var formats []outputFormat[R] diff --git a/cmd/project-reservations.go b/cmd/project-reservations.go index 3cd7925..7e5aec5 100644 --- a/cmd/project-reservations.go +++ b/cmd/project-reservations.go @@ -1,6 +1,7 @@ package cmd import ( + "errors" "fmt" "github.com/fi-ts/cloud-go/api/client/project" @@ -135,6 +136,10 @@ func (m machineReservationsCmd) Create(rq *models.V1MachineReservationCreateRequ WithBody(rq). WithForce(pointer.Pointer(viper.GetBool("force"))), nil) if err != nil { + var r *project.CreateMachineReservationConflict + if errors.As(err, &r) { + return nil, genericcli.AlreadyExistsError() + } return nil, err } diff --git a/cmd/project-reservations_test.go b/cmd/project-reservations_test.go index 39c89d2..585b9ec 100644 --- a/cmd/project-reservations_test.go +++ b/cmd/project-reservations_test.go @@ -1,6 +1,8 @@ package cmd import ( + "strconv" + "strings" "testing" "github.com/fi-ts/cloud-go/api/client/project" @@ -8,7 +10,9 @@ import ( testclient "github.com/fi-ts/cloud-go/test/client" "github.com/metal-stack/metal-lib/pkg/pointer" "github.com/metal-stack/metal-lib/pkg/testcommon" + "github.com/spf13/afero" "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" ) var ( @@ -81,220 +85,216 @@ size-b project-b | fits | project-b | size-b | 3 | partition-a,partition-b | for machines | `), }, - // { - // name: "list with filters", - // cmd: func(want []*models.V1MachineReservationResponse) []string { - // args := []string{"project", "list", "--name", "project-1", "--tenant", "metal-stack", "--id", want[0].Meta.ID} - // assertExhaustiveArgs(t, args, "sort-by") - // return args - // }, - // mocks: &client.MetalMockFns{ - // Project: func(mock *mock.Mock) { - // mock.On("FindProjects", testcommon.MatchIgnoreContext(t, project.NewFindProjectsParams().WithBody(&models.V1ProjectFindRequest{ - // Name: "project-1", - // TenantID: "metal-stack", - // ID: "1", - // })), nil).Return(&project.FindProjectsOK{ - // Payload: []*models.V1ProjectResponse{ - // project1, - // }, - // }, nil) - // }, - // }, - // want: []*models.V1ProjectResponse{ - // project1, - // }, - // wantTable: pointer.Pointer(` - // UID TENANT NAME DESCRIPTION LABELS ANNOTATIONS - // 1 metal-stack project-1 project 1 c a=b - // `), - // wantWideTable: pointer.Pointer(` - // UID TENANT NAME DESCRIPTION QUOTAS CLUSTERS/MACHINES/IPS LABELS ANNOTATIONS - // 1 metal-stack project-1 project 1 1/3/2 c a=b - // `), - // template: pointer.Pointer("{{ .meta.id }} {{ .name }}"), - // wantTemplate: pointer.Pointer(` - // 1 project-1 - // `), - // wantMarkdown: pointer.Pointer(` - // | UID | TENANT | NAME | DESCRIPTION | LABELS | ANNOTATIONS | - // |-----|-------------|-----------|-------------|--------|-------------| - // | 1 | metal-stack | project-1 | project 1 | c | a=b | - // `), - // }, - // { - // name: "apply", - // cmd: func(want []*models.V1ProjectResponse) []string { - // return appendFromFileCommonArgs("project", "apply") - // }, - // fsMocks: func(fs afero.Fs, want []*models.V1ProjectResponse) { - // require.NoError(t, afero.WriteFile(fs, "/file.yaml", mustMarshalToMultiYAML(t, want), 0755)) - // }, - // mocks: &client.MetalMockFns{ - // Project: func(mock *mock.Mock) { - // mock.On("CreateProject", testcommon.MatchIgnoreContext(t, project.NewCreateProjectParams().WithBody(projectResponseToCreate(project1))), nil).Return(nil, &project.CreateProjectConflict{}).Once() - // mock.On("FindProject", testcommon.MatchIgnoreContext(t, project.NewFindProjectParams().WithID(project1.Meta.ID)), nil).Return(&project.FindProjectOK{ - // Payload: project1, - // }, nil) - // mock.On("UpdateProject", testcommon.MatchIgnoreContext(t, project.NewUpdateProjectParams().WithBody(projectResponseToUpdate(project1))), nil).Return(&project.UpdateProjectOK{ - // Payload: project1, - // }, nil) - // mock.On("CreateProject", testcommon.MatchIgnoreContext(t, project.NewCreateProjectParams().WithBody(projectResponseToCreate(project2))), nil).Return(&project.CreateProjectCreated{ - // Payload: project2, - // }, nil) - // }, - // }, - // want: []*models.V1ProjectResponse{ - // project1, - // project2, - // }, - // }, - // { - // name: "create from file", - // cmd: func(want []*models.V1ProjectResponse) []string { - // return appendFromFileCommonArgs("project", "create") - // }, - // fsMocks: func(fs afero.Fs, want []*models.V1ProjectResponse) { - // require.NoError(t, afero.WriteFile(fs, "/file.yaml", mustMarshalToMultiYAML(t, want), 0755)) - // }, - // mocks: &client.MetalMockFns{ - // Project: func(mock *mock.Mock) { - // mock.On("CreateProject", testcommon.MatchIgnoreContext(t, project.NewCreateProjectParams().WithBody(projectResponseToCreate(project1))), nil).Return(&project.CreateProjectCreated{ - // Payload: project1, - // }, nil) - // }, - // }, - // want: []*models.V1ProjectResponse{ - // project1, - // }, - // }, - // { - // name: "update from file", - // cmd: func(want []*models.V1ProjectResponse) []string { - // return appendFromFileCommonArgs("project", "update") - // }, - // fsMocks: func(fs afero.Fs, want []*models.V1ProjectResponse) { - // require.NoError(t, afero.WriteFile(fs, "/file.yaml", mustMarshalToMultiYAML(t, want), 0755)) - // }, - // mocks: &client.MetalMockFns{ - // Project: func(mock *mock.Mock) { - // mock.On("FindProject", testcommon.MatchIgnoreContext(t, project.NewFindProjectParams().WithID(project1.Meta.ID)), nil).Return(&project.FindProjectOK{ - // Payload: project1, - // }, nil) - // mock.On("UpdateProject", testcommon.MatchIgnoreContext(t, project.NewUpdateProjectParams().WithBody(projectResponseToUpdate(project1))), nil).Return(&project.UpdateProjectOK{ - // Payload: project1, - // }, nil) - // }, - // }, - // want: []*models.V1ProjectResponse{ - // project1, - // }, - // }, - // { - // name: "delete from file", - // cmd: func(want []*models.V1ProjectResponse) []string { - // return appendFromFileCommonArgs("project", "delete") - // }, - // fsMocks: func(fs afero.Fs, want []*models.V1ProjectResponse) { - // require.NoError(t, afero.WriteFile(fs, "/file.yaml", mustMarshalToMultiYAML(t, want), 0755)) - // }, - // mocks: &client.MetalMockFns{ - // Project: func(mock *mock.Mock) { - // mock.On("DeleteProject", testcommon.MatchIgnoreContext(t, project.NewDeleteProjectParams().WithID(project1.Meta.ID)), nil).Return(&project.DeleteProjectOK{ - // Payload: project1, - // }, nil) - // }, - // }, - // want: []*models.V1ProjectResponse{ - // project1, - // }, - // }, + { + name: "list with filters", + cmd: func(want []*models.V1MachineReservationResponse) []string { + args := []string{"project", "machine-reservation", "list", "--tenant", *want[0].Tenant, "--project", *want[0].Projectid, "--size", *want[0].Sizeid} + assertExhaustiveArgs(t, args, "sort-by") + return args + }, + mocks: &testclient.CloudMockFns{ + Project: func(mock *mock.Mock) { + mock.On("ListMachineReservations", testcommon.MatchIgnoreContext(t, project.NewListMachineReservationsParams().WithBody(&models.V1MachineReservationFindRequest{ + Projectid: pointer.Pointer("project-a"), + Sizeid: pointer.Pointer("size-a"), + Tenant: pointer.Pointer("fits"), + })), nil).Return(&project.ListMachineReservationsOK{ + Payload: []*models.V1MachineReservationResponse{ + machineReservation1, + }, + }, nil) + }, + }, + want: []*models.V1MachineReservationResponse{ + machineReservation1, + }, + wantTable: pointer.Pointer(` +TENANT PROJECT SIZE AMOUNT PARTITIONS DESCRIPTION +fits project-a size-a 3 partition-a for firewalls + `), + wantWideTable: pointer.Pointer(` +TENANT PROJECT SIZE AMOUNT PARTITIONS DESCRIPTION LABELS +fits project-a size-a 3 partition-a for firewalls for firewalls size.metal-stack.io/reserved-at=2024-09-19T08:57:40Z + size.metal-stack.io/reserved-by=fits + + `), + template: pointer.Pointer("{{ .sizeid }} {{ .projectid }}"), + wantTemplate: pointer.Pointer(` +size-a project-a + `), + wantMarkdown: pointer.Pointer(` +| TENANT | PROJECT | SIZE | AMOUNT | PARTITIONS | DESCRIPTION | +|--------|-----------|--------|--------|-------------|---------------| +| fits | project-a | size-a | 3 | partition-a | for firewalls | + `), + }, + { + name: "apply", + cmd: func(want []*models.V1MachineReservationResponse) []string { + return appendFromFileCommonArgs("project", "machine-reservation", "apply") + }, + fsMocks: func(fs afero.Fs, want []*models.V1MachineReservationResponse) { + require.NoError(t, afero.WriteFile(fs, "/file.yaml", mustMarshalToMultiYAML(t, want), 0755)) + }, + mocks: &testclient.CloudMockFns{ + Project: func(mock *mock.Mock) { + mock.On("CreateMachineReservation", testcommon.MatchIgnoreContext(t, project.NewCreateMachineReservationParams(). + WithBody(toMachineReservationCreateRequest(machineReservation1)).WithForce(pointer.Pointer(false))), nil). + Return(nil, &project.CreateMachineReservationConflict{}).Once() + mock.On("UpdateMachineReservation", testcommon.MatchIgnoreContext(t, project.NewUpdateMachineReservationParams(). + WithBody(toMachineReservationUpdateRequest(machineReservation1)).WithForce(pointer.Pointer(false))), nil). + Return(&project.UpdateMachineReservationOK{Payload: machineReservation1}, nil) + + mock.On("CreateMachineReservation", testcommon.MatchIgnoreContext(t, project.NewCreateMachineReservationParams(). + WithBody(toMachineReservationCreateRequest(machineReservation2)).WithForce(pointer.Pointer(false))), nil). + Return(&project.CreateMachineReservationCreated{Payload: machineReservation2}, nil) + }, + }, + want: []*models.V1MachineReservationResponse{ + machineReservation1, + machineReservation2, + }, + }, + { + name: "create from file", + cmd: func(want []*models.V1MachineReservationResponse) []string { + return appendFromFileCommonArgs("project", "machine-reservation", "create") + }, + fsMocks: func(fs afero.Fs, want []*models.V1MachineReservationResponse) { + require.NoError(t, afero.WriteFile(fs, "/file.yaml", mustMarshalToMultiYAML(t, want), 0755)) + }, + mocks: &testclient.CloudMockFns{ + Project: func(mock *mock.Mock) { + mock.On("CreateMachineReservation", testcommon.MatchIgnoreContext(t, project.NewCreateMachineReservationParams(). + WithBody(toMachineReservationCreateRequest(machineReservation1)).WithForce(pointer.Pointer(false))), nil). + Return(&project.CreateMachineReservationCreated{Payload: machineReservation1}, nil) + }, + }, + want: []*models.V1MachineReservationResponse{ + machineReservation1, + }, + }, + { + name: "update from file", + cmd: func(want []*models.V1MachineReservationResponse) []string { + return appendFromFileCommonArgs("project", "machine-reservation", "update") + }, + fsMocks: func(fs afero.Fs, want []*models.V1MachineReservationResponse) { + require.NoError(t, afero.WriteFile(fs, "/file.yaml", mustMarshalToMultiYAML(t, want), 0755)) + }, + mocks: &testclient.CloudMockFns{ + Project: func(mock *mock.Mock) { + mock.On("UpdateMachineReservation", testcommon.MatchIgnoreContext(t, project.NewUpdateMachineReservationParams(). + WithBody(toMachineReservationUpdateRequest(machineReservation1)).WithForce(pointer.Pointer(false))), nil). + Return(&project.UpdateMachineReservationOK{Payload: machineReservation1}, nil) + }, + }, + want: []*models.V1MachineReservationResponse{ + machineReservation1, + }, + }, + { + name: "delete from file", + cmd: func(want []*models.V1MachineReservationResponse) []string { + return appendFromFileCommonArgs("project", "machine-reservation", "delete") + }, + fsMocks: func(fs afero.Fs, want []*models.V1MachineReservationResponse) { + require.NoError(t, afero.WriteFile(fs, "/file.yaml", mustMarshalToMultiYAML(t, want), 0755)) + }, + mocks: &testclient.CloudMockFns{ + Project: func(mock *mock.Mock) { + mock.On("DeleteMachineReservation", testcommon.MatchIgnoreContext(t, project.NewDeleteMachineReservationParams(). + WithProject(machineReservation1.Projectid).WithSize(machineReservation1.Sizeid)), nil). + Return(&project.DeleteMachineReservationOK{Payload: machineReservation1}, nil) + }, + }, + want: []*models.V1MachineReservationResponse{ + machineReservation1, + }, + }, } for _, tt := range tests { tt.testCmd(t) } } -// func Test_ProjectCmd_SingleResult(t *testing.T) { -// tests := []*test[*models.V1ProjectResponse]{ -// { -// name: "describe", -// cmd: func(want *models.V1ProjectResponse) []string { -// return []string{"project", "describe", want.Meta.ID} -// }, -// mocks: &client.MetalMockFns{ -// Project: func(mock *mock.Mock) { -// mock.On("FindProject", testcommon.MatchIgnoreContext(t, project.NewFindProjectParams().WithID(project1.Meta.ID)), nil).Return(&project.FindProjectOK{ -// Payload: project1, -// }, nil) -// }, -// }, -// want: project1, -// wantTable: pointer.Pointer(` -// UID TENANT NAME DESCRIPTION LABELS ANNOTATIONS -// 1 metal-stack project-1 project 1 c a=b -// `), -// wantWideTable: pointer.Pointer(` -// UID TENANT NAME DESCRIPTION QUOTAS CLUSTERS/MACHINES/IPS LABELS ANNOTATIONS -// 1 metal-stack project-1 project 1 1/3/2 c a=b -// `), -// template: pointer.Pointer("{{ .meta.id }} {{ .name }}"), -// wantTemplate: pointer.Pointer(` -// 1 project-1 -// `), -// wantMarkdown: pointer.Pointer(` -// | UID | TENANT | NAME | DESCRIPTION | LABELS | ANNOTATIONS | -// |-----|-------------|-----------|-------------|--------|-------------| -// | 1 | metal-stack | project-1 | project 1 | c | a=b | -// `), -// }, -// { -// name: "delete", -// cmd: func(want *models.V1ProjectResponse) []string { -// return []string{"project", "rm", want.Meta.ID} -// }, -// mocks: &client.MetalMockFns{ -// Project: func(mock *mock.Mock) { -// mock.On("DeleteProject", testcommon.MatchIgnoreContext(t, project.NewDeleteProjectParams().WithID(project1.Meta.ID)), nil).Return(&project.DeleteProjectOK{ -// Payload: project1, -// }, nil) -// }, -// }, -// want: project1, -// }, -// { -// name: "create", -// cmd: func(want *models.V1ProjectResponse) []string { -// args := []string{"project", "create", -// "--name", want.Name, -// "--description", want.Description, -// "--tenant", want.TenantID, -// "--label", strings.Join(want.Meta.Labels, ","), -// "--annotation", strings.Join(genericcli.MapToLabels(want.Meta.Annotations), ","), -// "--cluster-quota", strconv.FormatInt(int64(want.Quotas.Cluster.Quota), 10), -// "--machine-quota", strconv.FormatInt(int64(want.Quotas.Machine.Quota), 10), -// "--ip-quota", strconv.FormatInt(int64(want.Quotas.IP.Quota), 10), -// } -// assertExhaustiveArgs(t, args, commonExcludedFileArgs()...) -// return args -// }, -// mocks: &client.MetalMockFns{ -// Project: func(mock *mock.Mock) { -// p := project1 -// p.Meta.ID = "" -// p.Meta.Version = 0 -// p.Quotas.Cluster.Used = 0 -// p.Quotas.IP.Used = 0 -// p.Quotas.Machine.Used = 0 -// mock.On("CreateProject", testcommon.MatchIgnoreContext(t, project.NewCreateProjectParams().WithBody(projectResponseToCreate(p))), nil).Return(&project.CreateProjectCreated{ -// Payload: project1, -// }, nil) -// }, -// }, -// want: project1, -// }, -// } -// for _, tt := range tests { -// tt.testCmd(t) -// } -// } +func Test_ProjectMachineReservationsCmd_SingleResult(t *testing.T) { + tests := []*test[*models.V1MachineReservationResponse]{ + { + name: "describe", + cmd: func(want *models.V1MachineReservationResponse) []string { + return []string{"project", "machine-reservation", "describe", *want.Projectid, *want.Sizeid} + }, + mocks: &testclient.CloudMockFns{ + Project: func(mock *mock.Mock) { + mock.On("ListMachineReservations", testcommon.MatchIgnoreContext(t, project.NewListMachineReservationsParams().WithBody(&models.V1MachineReservationFindRequest{})), nil).Return(&project.ListMachineReservationsOK{ + Payload: []*models.V1MachineReservationResponse{ + machineReservation2, + machineReservation1, + }, + }, nil) + }, + }, + want: machineReservation1, + wantTable: pointer.Pointer(` +TENANT PROJECT SIZE AMOUNT PARTITIONS DESCRIPTION +fits project-a size-a 3 partition-a for firewalls +`), + wantWideTable: pointer.Pointer(` +TENANT PROJECT SIZE AMOUNT PARTITIONS DESCRIPTION LABELS +fits project-a size-a 3 partition-a for firewalls for firewalls size.metal-stack.io/reserved-at=2024-09-19T08:57:40Z + size.metal-stack.io/reserved-by=fits +`), + template: pointer.Pointer("{{ .sizeid }} {{ .projectid }}"), + wantTemplate: pointer.Pointer(` +size-a project-a +`), + wantMarkdown: pointer.Pointer(` +| TENANT | PROJECT | SIZE | AMOUNT | PARTITIONS | DESCRIPTION | +|--------|-----------|--------|--------|-------------|---------------| +| fits | project-a | size-a | 3 | partition-a | for firewalls | +`), + }, + { + name: "delete", + cmd: func(want *models.V1MachineReservationResponse) []string { + return []string{"project", "machine-reservation", "rm", *want.Projectid, *want.Sizeid} + }, + mocks: &testclient.CloudMockFns{ + Project: func(mock *mock.Mock) { + mock.On("DeleteMachineReservation", testcommon.MatchIgnoreContext(t, project.NewDeleteMachineReservationParams(). + WithProject(machineReservation1.Projectid).WithSize(machineReservation1.Sizeid)), nil). + Return(&project.DeleteMachineReservationOK{Payload: machineReservation1}, nil) + }, + }, + want: machineReservation1, + }, + { + name: "create", + cmd: func(want *models.V1MachineReservationResponse) []string { + args := []string{"project", "machine-reservation", "create", + "--amount", strconv.Itoa(int(*want.Amount)), //nolint:gosec + "--description", want.Description, + "--project", *want.Projectid, + "--force", + "--partitions", strings.Join(want.Partitionids, ","), + "--size", *want.Sizeid, + } + + assertExhaustiveArgs(t, args, commonExcludedFileArgs()...) + return args + }, + mocks: &testclient.CloudMockFns{ + Project: func(mock *mock.Mock) { + mock.On("CreateMachineReservation", testcommon.MatchIgnoreContext(t, project.NewCreateMachineReservationParams(). + WithBody(toMachineReservationCreateRequest(machineReservation1)).WithForce(pointer.Pointer(true))), nil). + Return(&project.CreateMachineReservationCreated{Payload: machineReservation1}, nil) + }, + }, + want: machineReservation1, + }, + } + for _, tt := range tests { + tt.testCmd(t) + } +} diff --git a/cmd/tableprinters/project-reservations.go b/cmd/tableprinters/project-reservations.go index 28e97c7..4a018a8 100644 --- a/cmd/tableprinters/project-reservations.go +++ b/cmd/tableprinters/project-reservations.go @@ -2,6 +2,7 @@ package tableprinters import ( "fmt" + "sort" "strconv" "strings" @@ -22,6 +23,8 @@ func (t *TablePrinter) MachineReservationsTable(data []*models.V1MachineReservat } for _, p := range data { + sort.Strings(p.Partitionids) + row := []string{ pointer.SafeDeref(p.Tenant), pointer.SafeDeref(p.Projectid), @@ -32,13 +35,13 @@ func (t *TablePrinter) MachineReservationsTable(data []*models.V1MachineReservat } if wide { - labels := []string{} + var labels []string for k, v := range p.Labels { labels = append(labels, k+"="+v) } - lbls := strings.Join(labels, "\n") + sort.Strings(labels) - row = append(row, p.Description, lbls) + row = append(row, p.Description, strings.Join(labels, "\n")) } rows = append(rows, row)