From 9a7b403c0385a820d87306a298b7bfa6b1129f3d Mon Sep 17 00:00:00 2001 From: Quirin Vetterl Date: Tue, 12 Aug 2025 15:26:38 +0200 Subject: [PATCH 1/2] Project command wit genericcli --- cmd/project.go | 411 +++++++++-------------------------- cmd/project_test.go | 329 ++++++++++++++++++++++++++++ cmd/sorters/project.go | 24 ++ cmd/tableprinters/printer.go | 4 + cmd/tableprinters/project.go | 60 +++++ 5 files changed, 519 insertions(+), 309 deletions(-) create mode 100644 cmd/project_test.go create mode 100644 cmd/sorters/project.go create mode 100644 cmd/tableprinters/project.go diff --git a/cmd/project.go b/cmd/project.go index 0a6efac0..a86aec76 100644 --- a/cmd/project.go +++ b/cmd/project.go @@ -3,363 +3,156 @@ package cmd import ( "errors" "fmt" - "net/http" - "strings" "github.com/fi-ts/cloud-go/api/models" "github.com/metal-stack/metal-lib/pkg/genericcli" - "gopkg.in/yaml.v3" + "github.com/metal-stack/metal-lib/pkg/genericcli/printers" "github.com/fi-ts/cloud-go/api/client/project" - "github.com/fi-ts/cloudctl/cmd/helper" + "github.com/fi-ts/cloudctl/cmd/sorters" "github.com/spf13/cobra" "github.com/spf13/viper" ) -func newProjectCmd(c *config) *cobra.Command { - projectCmd := &cobra.Command{ - Use: "project", - Short: "manage projects", - Long: "a project organizes cloud resources regarding tenancy, quotas, billing and authentication", - } - projectCreateCmd := &cobra.Command{ - Use: "create", - Short: "create a project", - RunE: func(cmd *cobra.Command, args []string) error { - return c.projectCreate() - }, - } - projectDescribeCmd := &cobra.Command{ - Use: "describe ", - Short: "describe a project", - RunE: func(cmd *cobra.Command, args []string) error { - return c.projectDescribe(args) - }, - ValidArgsFunction: c.comp.ProjectListCompletion, - } - projectDeleteCmd := &cobra.Command{ - Use: "delete ", - Aliases: []string{"destroy", "rm", "remove"}, - Short: "delete a project", - RunE: func(cmd *cobra.Command, args []string) error { - return c.projectDelete(args) - }, - ValidArgsFunction: c.comp.ProjectListCompletion, - } - projectApplyCmd := &cobra.Command{ - Use: "apply", - Short: "create/update a project", - RunE: func(cmd *cobra.Command, args []string) error { - return c.projectApply() - }, - } - projectEditCmd := &cobra.Command{ - Use: "edit ", - Short: "edit a project", - RunE: func(cmd *cobra.Command, args []string) error { - return c.projectEdit(args) - }, - ValidArgsFunction: c.comp.ProjectListCompletion, - } - projectListCmd := &cobra.Command{ - Use: "list", - Short: "list projects", - Aliases: []string{"ls"}, - RunE: func(cmd *cobra.Command, args []string) error { - return c.projectList() - }, - } - - projectCreateCmd.Flags().String("name", "", "name of the project, max 10 characters. [required]") - projectCreateCmd.Flags().String("description", "", "description of the project. [required]") - projectCreateCmd.Flags().String("tenant", "", "create project for given tenant") - projectCreateCmd.Flags().StringSlice("label", nil, "add initial label, can be given multiple times to add multiple labels, e.g. --label=foo --label=bar") - projectCreateCmd.Flags().StringSlice("annotation", nil, "add initial annotation, must be in the form of key=value, can be given multiple times to add multiple annotations, e.g. --annotation key=value --annotation foo=bar") - projectCreateCmd.Flags().Int32("cluster-quota", 0, "cluster quota") - projectCreateCmd.Flags().Int32("machine-quota", 0, "machine quota") - projectCreateCmd.Flags().Int32("ip-quota", 0, "ip quota") - genericcli.Must(projectCreateCmd.MarkFlagRequired("name")) - genericcli.Must(projectCreateCmd.RegisterFlagCompletionFunc("tenant", c.comp.TenantListCompletion)) - - projectListCmd.Flags().String("id", "", "show projects of given id") - projectListCmd.Flags().String("name", "", "show projects of given name") - projectListCmd.Flags().String("tenant", "", "show projects of given tenant") - genericcli.Must(projectListCmd.RegisterFlagCompletionFunc("id", c.comp.ProjectListCompletion)) - genericcli.Must(projectListCmd.RegisterFlagCompletionFunc("tenant", c.comp.TenantListCompletion)) - - projectApplyCmd.Flags().StringP("file", "f", "", `filename of the create or update request in yaml format, or - for stdin. - Example project update: - - # cloudctl project describe project1 -o yaml > project1.yaml - # vi project1.yaml - ## either via stdin - # cat project1.yaml | cloudctl project apply -f - - ## or via file - # cloudctl project apply -f project1.yaml - `) - - projectCmd.AddCommand(projectCreateCmd) - projectCmd.AddCommand(projectDescribeCmd) - projectCmd.AddCommand(projectDeleteCmd) - projectCmd.AddCommand(projectListCmd) - projectCmd.AddCommand(projectApplyCmd) - projectCmd.AddCommand(projectEditCmd) - projectCmd.AddCommand(newMachineReservationsCmd(c)) - - return projectCmd +type projectCmd struct { + *config } -func (c *config) projectCreate() error { - tenant := viper.GetString("tenant") - name := viper.GetString("name") - desc := viper.GetString("description") - labels := viper.GetStringSlice("label") - as := viper.GetStringSlice("annotation") - var ( - clusterQuota, machineQuota, ipQuota *models.V1Quota - ) - if viper.IsSet("cluster-quota") { - clusterQuota = &models.V1Quota{Quota: viper.GetInt32("cluster-quota")} - } - if viper.IsSet("machine-quota") { - machineQuota = &models.V1Quota{Quota: viper.GetInt32("machine-quota")} - } - if viper.IsSet("ip-quota") { - ipQuota = &models.V1Quota{Quota: viper.GetInt32("ip-quota")} - } - - annotations, err := annotationsAsMap(as) - if err != nil { - return err - } - - pcr := &models.V1ProjectCreateRequest{ - Name: name, - Description: desc, - TenantID: tenant, - Quotas: &models.V1QuotaSet{ - Cluster: clusterQuota, - Machine: machineQuota, - IP: ipQuota, +func newProjectCmd(c *config) *cobra.Command { + w := projectCmd{ + config: c, + } + + cmdsConfig := &genericcli.CmdsConfig[*models.V1ProjectCreateRequest, *models.V1ProjectUpdateRequest, *models.V1ProjectResponse]{ + BinaryName: binaryName, + GenericCLI: genericcli.NewGenericCLI(w).WithFS(c.fs), + Singular: "project", + Plural: "projects", + Description: "a project organizes cloud resources regarding tenancy, quotas, billing and authentication", + Sorter: sorters.ProjectSorter(), + ValidArgsFn: c.comp.ProjectListCompletion, + DescribePrinter: func() printers.Printer { return c.describePrinter }, + ListPrinter: func() printers.Printer { return c.listPrinter }, + CreateCmdMutateFn: func(cmd *cobra.Command) { + cmd.Flags().String("name", "", "name of the project, max 10 characters. [required]") + cmd.Flags().String("description", "", "description of the project. [required]") + cmd.Flags().String("tenant", "", "create project for given tenant") + cmd.Flags().StringSlice("label", nil, "add initial label, can be given multiple times to add multiple labels, e.g. --label=foo --label=bar") + cmd.Flags().StringSlice("annotation", nil, "add initial annotation, must be in the form of key=value, can be given multiple times to add multiple annotations, e.g. --annotation key=value --annotation foo=bar") + cmd.Flags().Int32("cluster-quota", 0, "cluster quota") + cmd.Flags().Int32("machine-quota", 0, "machine quota") + cmd.Flags().Int32("ip-quota", 0, "ip quota") + + cmd.MarkFlagsMutuallyExclusive("file", "name") + cmd.MarkFlagsRequiredTogether("name", "description") }, - Meta: &models.V1Meta{ - Kind: "Project", - Apiversion: "v1", - Annotations: annotations, - Labels: labels, + ListCmdMutateFn: func(cmd *cobra.Command) { + cmd.Flags().StringP("name", "", "", "show projects of given name") + cmd.Flags().StringP("id", "", "", "show projects of given id") + cmd.Flags().StringP("tenant", "", "", "show projects of given id") }, } - request := project.NewCreateProjectParams() - request.SetBody(pcr) + return genericcli.NewCmds(cmdsConfig) +} - response, err := c.cloud.Project.CreateProject(request, nil) +func (c projectCmd) Get(id string) (*models.V1ProjectResponse, error) { + resp, err := c.cloud.Project.FindProject(project.NewFindProjectParams().WithID(id), nil) if err != nil { - return err + return nil, err } - return c.listPrinter.Print(response.Payload) + return resp.Payload, nil } -func (c *config) projectDescribe(args []string) error { - id, err := c.projectID("describe", args) - if err != nil { - return err - } - - request := project.NewFindProjectParams() - request.SetID(id) - p, err := c.cloud.Project.FindProject(request, nil) +func (c projectCmd) List() ([]*models.V1ProjectResponse, error) { + resp, err := c.cloud.Project.FindProjects(project.NewFindProjectsParams().WithBody(&models.V1ProjectFindRequest{ + ID: viper.GetString("id"), + Name: viper.GetString("name"), + TenantID: viper.GetString("tenant"), + }), nil) if err != nil { - return err + return nil, err } - return c.listPrinter.Print(p.Payload) + return resp.Payload.Projects, nil } -func (c *config) projectDelete(args []string) error { - id, err := c.projectID("delete", args) +func (c projectCmd) Delete(id string) (*models.V1ProjectResponse, error) { + resp, err := c.cloud.Project.DeleteProject(project.NewDeleteProjectParams().WithID(id), nil) if err != nil { - return err + return nil, err } - request := project.NewDeleteProjectParams().WithID(id) + return resp.Payload, nil +} - response, err := c.cloud.Project.DeleteProject(request, nil) +func (c projectCmd) Create(rq *models.V1ProjectCreateRequest) (*models.V1ProjectResponse, error) { + resp, err := c.cloud.Project.CreateProject(project.NewCreateProjectParams().WithBody(rq), nil) if err != nil { - return err + var r *project.CreateProjectConflict + if errors.As(err, &r) { + return nil, genericcli.AlreadyExistsError() + } + return nil, err } - return c.listPrinter.Print(response.Payload) + return resp.Payload, nil } -func (c *config) projectList() error { - id := viper.GetString("id") - name := viper.GetString("name") - tenant := viper.GetString("tenant") - if id != "" || name != "" || tenant != "" { - pfr := project.NewFindProjectsParams().WithBody(&models.V1ProjectFindRequest{ - ID: id, - Name: name, - TenantID: tenant, - }) - - response, err := c.cloud.Project.FindProjects(pfr, nil) - if err != nil { - return err - } - - return c.listPrinter.Print(response.Payload.Projects) - } - - request := project.NewListProjectsParams() - response, err := c.cloud.Project.ListProjects(request, nil) +func (c projectCmd) Update(rq *models.V1ProjectUpdateRequest) (*models.V1ProjectResponse, error) { + resp, err := c.cloud.Project.FindProject(project.NewFindProjectParams().WithID(rq.Meta.ID), nil) if err != nil { - return err + return nil, err } - return c.listPrinter.Print(response.Payload.Projects) -} -func (c *config) projectID(verb string, args []string) (string, error) { - if len(args) == 0 { - return "", fmt.Errorf("project %s requires projectID as argument", verb) - } - if len(args) == 1 { - return args[0], nil - } - return "", fmt.Errorf("project %s requires exactly one projectID as argument", verb) -} + rq.Meta.Version = resp.Payload.Meta.Version -func (c *config) projectApply() error { - var pars []models.V1ProjectCreateRequest - var par models.V1ProjectCreateRequest - err := helper.ReadFrom(viper.GetString("file"), &par, func(data interface{}) { - doc := data.(*models.V1ProjectCreateRequest) - pars = append(pars, *doc) - // the request needs to be renewed as otherwise the pointers in the request struct will - // always point to same last value in the multi-document loop - par = models.V1ProjectCreateRequest{} - }) + updateResp, err := c.cloud.Project.UpdateProject(project.NewUpdateProjectParams().WithBody(rq), nil) if err != nil { - return err - } - var response []*models.V1ProjectResponse - for i, par := range pars { - request := project.NewFindProjectParams() - request.SetID(par.Meta.ID) - p, err := c.cloud.Project.FindProject(request, nil) - if err != nil { - var r *project.FindProjectDefault - if !errors.As(err, &r) { - return err - } - if r.Code() != http.StatusNotFound { - return err - } - } - if p == nil || p.Payload == nil { - params := project.NewCreateProjectParams() - params.SetBody(&pars[i]) - resp, err := c.cloud.Project.CreateProject(params, nil) - if err != nil { - return err - } - response = append(response, resp.Payload) - continue - } - if p.Payload.Meta != nil { - params := project.NewUpdateProjectParams() - pur := &models.V1ProjectUpdateRequest{} - if par.Description != "" { - pur.Description = par.Description - } - if par.Name != "" { - pur.Name = par.Name - } - if par.Quotas != nil { - pur.Quotas = par.Quotas - } - if par.Meta != nil { - pur.Meta = par.Meta - } - if par.TenantID != "" { - pur.TenantID = par.TenantID - } - params.SetBody(pur) - resp, err := c.cloud.Project.UpdateProject(params, nil) - if err != nil { - return err - } - response = append(response, resp.Payload) - continue - } + return nil, err } - return c.listPrinter.Print(response) -} -func (c *config) projectEdit(args []string) error { - id, err := c.projectID("edit", args) - if err != nil { - return err - } + return updateResp.Payload, nil +} - getFunc := func(id string) ([]byte, error) { - request := project.NewFindProjectParams() - request.SetID(id) - resp, err := c.cloud.Project.FindProject(request, nil) - if err != nil { - return nil, fmt.Errorf("project describe error:%w", err) - } - content, err := yaml.Marshal(resp.Payload) - if err != nil { - return nil, err - } - return content, nil - } - updateFunc := func(filename string) error { - purs, err := readProjectUpdateRequests(filename) - if err != nil { - return err - } - if len(purs) != 1 { - return fmt.Errorf("project update error more or less than one project given:%d", len(purs)) - } - pup := project.NewUpdateProjectParams() - pup.Body = &purs[0] - uresp, err := c.cloud.Project.UpdateProject(pup, nil) - if err != nil { - return err - } - return c.listPrinter.Print(uresp.Payload) +func (c projectCmd) Convert(r *models.V1ProjectResponse) (string, *models.V1ProjectCreateRequest, *models.V1ProjectUpdateRequest, error) { + if r.Meta == nil { + return "", nil, nil, fmt.Errorf("meta is nil") } - - return helper.Edit(id, getFunc, updateFunc) + return r.Meta.ID, projectResponseToCreate(r), projectResponseToUpdate(r), nil } -func readProjectUpdateRequests(filename string) ([]models.V1ProjectUpdateRequest, error) { - var purs []models.V1ProjectUpdateRequest - var pur models.V1ProjectUpdateRequest - err := helper.ReadFrom(filename, &pur, func(data interface{}) { - doc := data.(*models.V1ProjectUpdateRequest) - purs = append(purs, *doc) - }) - if err != nil { - return purs, err - } - if len(purs) != 1 { - return purs, fmt.Errorf("project update error more or less than one project given:%d", len(purs)) +func projectResponseToCreate(r *models.V1ProjectResponse) *models.V1ProjectCreateRequest { + return &models.V1ProjectCreateRequest{ + Meta: &models.V1Meta{ + Apiversion: r.Meta.Apiversion, + Kind: r.Meta.Kind, + ID: r.Meta.ID, + Annotations: r.Meta.Annotations, + Labels: r.Meta.Labels, + Version: r.Meta.Version, + }, + Description: r.Description, + Name: r.Name, + Quotas: r.Quotas, + TenantID: r.TenantID, } - return purs, nil } -func annotationsAsMap(annotations []string) (map[string]string, error) { - result := make(map[string]string) - for _, a := range annotations { - parts := strings.Split(strings.TrimSpace(a), "=") - if len(parts) != 2 { - return result, fmt.Errorf("given annotation %s does not contain exactly one =", a) - } - result[parts[0]] = parts[1] +func projectResponseToUpdate(r *models.V1ProjectResponse) *models.V1ProjectUpdateRequest { + return &models.V1ProjectUpdateRequest{ + Meta: &models.V1Meta{ + Apiversion: r.Meta.Apiversion, + Kind: r.Meta.Kind, + ID: r.Meta.ID, + Annotations: r.Meta.Annotations, + Labels: r.Meta.Labels, + Version: r.Meta.Version, + }, + Description: r.Description, + Name: r.Name, + Quotas: r.Quotas, + TenantID: r.TenantID, } - return result, nil } diff --git a/cmd/project_test.go b/cmd/project_test.go new file mode 100644 index 00000000..3f091444 --- /dev/null +++ b/cmd/project_test.go @@ -0,0 +1,329 @@ +package cmd + +import ( + "strconv" + "strings" + "testing" + + "github.com/fi-ts/cloud-go/api/client/project" + "github.com/fi-ts/cloud-go/api/models" + "github.com/fi-ts/cloud-go/test/client" + "github.com/metal-stack/metal-lib/pkg/genericcli" + "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 ( + project1 = &models.V1ProjectResponse{ + Meta: &models.V1Meta{ + Kind: "Project", + Apiversion: "v1", + ID: "1", + Annotations: map[string]string{ + "a": "b", + }, + Labels: []string{"c"}, + Version: 1, + }, + Description: "project 1", + Name: "project-1", + Quotas: &models.V1QuotaSet{ + Cluster: &models.V1Quota{ + Quota: 1, + Used: 1, + }, + IP: &models.V1Quota{ + Quota: 2, + Used: 2, + }, + Machine: &models.V1Quota{ + Quota: 3, + Used: 3, + }, + }, + TenantID: "metal-stack", + } + project2 = &models.V1ProjectResponse{ + Meta: &models.V1Meta{ + Kind: "Project", + Apiversion: "v1", + ID: "2", + Annotations: map[string]string{ + "a": "b", + }, + Labels: []string{"c"}, + Version: 1, + }, + Description: "project 2", + Name: "project-2", + Quotas: &models.V1QuotaSet{ + Cluster: &models.V1Quota{}, + IP: &models.V1Quota{}, + Machine: &models.V1Quota{}, + }, + TenantID: "metal-stack", + } +) + +func Test_ProjectCmd_MultiResult(t *testing.T) { + tests := []*test[[]*models.V1ProjectResponse]{ + { + name: "list", + cmd: func(want []*models.V1ProjectResponse) []string { + return []string{"project", "list"} + }, + mocks: &client.CloudMockFns{ + Project: func(mock *mock.Mock) { + mock.On("FindProjects", testcommon.MatchIgnoreContext(t, project.NewFindProjectsParams().WithBody(&models.V1ProjectFindRequest{})), nil).Return(&project.FindProjectsOK{ + Payload: &models.V1ProjectListResponse{ + Projects: []*models.V1ProjectResponse{project1, project2}, + }, + }, nil) + }, + }, + want: []*models.V1ProjectResponse{ + project1, + project2, + }, + wantTable: pointer.Pointer(` +UID TENANT NAME DESCRIPTION LABELS ANNOTATIONS +1 metal-stack project-1 project 1 c a=b +2 metal-stack project-2 project 2 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 +2 metal-stack project-2 project 2 ∞ / ∞ / ∞ c a=b +`), + template: pointer.Pointer("{{ .meta.id }} {{ .name }}"), + wantTemplate: pointer.Pointer(` +1 project-1 +2 project-2 +`), + wantMarkdown: pointer.Pointer(` +| UID | TENANT | NAME | DESCRIPTION | LABELS | ANNOTATIONS | +|-----|-------------|-----------|-------------|--------|-------------| +| 1 | metal-stack | project-1 | project 1 | c | a=b | +| 2 | metal-stack | project-2 | project 2 | c | a=b | +`), + }, + { + name: "list with filters", + cmd: func(want []*models.V1ProjectResponse) []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.CloudMockFns{ + 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.V1ProjectListResponse{ + Projects: []*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.CloudMockFns{ + 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.CloudMockFns{ + 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.CloudMockFns{ + 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.CloudMockFns{ + 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, + }, + }, + } + 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.CloudMockFns{ + 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.CloudMockFns{ + 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.CloudMockFns{ + 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) + } +} diff --git a/cmd/sorters/project.go b/cmd/sorters/project.go new file mode 100644 index 00000000..fe361338 --- /dev/null +++ b/cmd/sorters/project.go @@ -0,0 +1,24 @@ +package sorters + +import ( + "github.com/fi-ts/cloud-go/api/models" + "github.com/metal-stack/metal-lib/pkg/multisort" + p "github.com/metal-stack/metal-lib/pkg/pointer" +) + +func ProjectSorter() *multisort.Sorter[*models.V1ProjectResponse] { + return multisort.New(multisort.FieldMap[*models.V1ProjectResponse]{ + "id": func(a, b *models.V1ProjectResponse, descending bool) multisort.CompareResult { + return multisort.Compare(p.SafeDeref(a.Meta).ID, p.SafeDeref(b.Meta).ID, descending) + }, + "name": func(a, b *models.V1ProjectResponse, descending bool) multisort.CompareResult { + return multisort.Compare(a.Name, b.Name, descending) + }, + "description": func(a, b *models.V1ProjectResponse, descending bool) multisort.CompareResult { + return multisort.Compare(a.Description, b.Description, descending) + }, + "tenant": func(a, b *models.V1ProjectResponse, descending bool) multisort.CompareResult { + return multisort.Compare(a.TenantID, b.TenantID, descending) + }, + }, multisort.Keys{{ID: "id"}}) +} diff --git a/cmd/tableprinters/printer.go b/cmd/tableprinters/printer.go index 179f4dc6..a30c30f6 100644 --- a/cmd/tableprinters/printer.go +++ b/cmd/tableprinters/printer.go @@ -45,6 +45,10 @@ func (t *TablePrinter) ToHeaderAndRows(data any, wide bool) ([]string, [][]strin return t.MachineReservationsUsageTable(d, wide) case *models.V1MachineReservationBillingUsageResponse: return t.MachineReservationsBillingTable(d, wide) + case *models.V1ProjectResponse: + return t.ProjectTable(pointer.WrapInSlice(d), wide) + case []*models.V1ProjectResponse: + return t.ProjectTable(d, wide) default: // fallback to old printer for as long as the migration takes: diff --git a/cmd/tableprinters/project.go b/cmd/tableprinters/project.go new file mode 100644 index 00000000..2265d99d --- /dev/null +++ b/cmd/tableprinters/project.go @@ -0,0 +1,60 @@ +package tableprinters + +import ( + "fmt" + "strconv" + "strings" + + "github.com/fi-ts/cloud-go/api/models" +) + +func (t *TablePrinter) ProjectTable(data []*models.V1ProjectResponse, wide bool) ([]string, [][]string, error) { + var ( + rows [][]string + ) + + header := []string{"UID", "Tenant", "Name", "Description", "Labels", "Annotations"} + if wide { + header = []string{"UID", "Tenant", "Name", "Description", "Quotas Clusters/Machines/Ips", "Labels", "Annotations"} + } + + for _, pr := range data { + quotas := "∞ / ∞ / ∞" + if pr.Quotas != nil { + clusterQuota := "∞" + machineQuota := "∞" + ipQuota := "∞" + qs := pr.Quotas + if qs.Cluster != nil { + if qs.Cluster.Quota != 0 { + clusterQuota = strconv.FormatInt(int64(qs.Cluster.Quota), 10) + } + } + if qs.Machine != nil { + if qs.Machine.Quota != 0 { + machineQuota = strconv.FormatInt(int64(qs.Machine.Quota), 10) + } + } + if qs.IP != nil { + if qs.IP.Quota != 0 { + ipQuota = strconv.FormatInt(int64(qs.IP.Quota), 10) + } + } + quotas = fmt.Sprintf("%s / %s / %s", clusterQuota, machineQuota, ipQuota) + } + labels := strings.Join(pr.Meta.Labels, "\n") + as := []string{} + for k, v := range pr.Meta.Annotations { + as = append(as, k+"="+v) + } + annotations := strings.Join(as, "\n") + + if wide { + rows = append(rows, []string{pr.Meta.ID, pr.TenantID, pr.Name, pr.Description, quotas, labels, annotations}) + } else { + rows = append(rows, []string{pr.Meta.ID, pr.TenantID, pr.Name, pr.Description, labels, annotations}) + } + } + + return header, rows, nil +} From c0adce669148d013c7f134660b5a0bd192f1119b Mon Sep 17 00:00:00 2001 From: Quirin Vetterl Date: Thu, 4 Sep 2025 18:30:32 +0200 Subject: [PATCH 2/2] Rebuilt the old commands with genericcli --- cmd/project.go | 227 +++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 202 insertions(+), 25 deletions(-) diff --git a/cmd/project.go b/cmd/project.go index a86aec76..c4745eb3 100644 --- a/cmd/project.go +++ b/cmd/project.go @@ -3,12 +3,15 @@ package cmd import ( "errors" "fmt" + "net/http" "github.com/fi-ts/cloud-go/api/models" "github.com/metal-stack/metal-lib/pkg/genericcli" "github.com/metal-stack/metal-lib/pkg/genericcli/printers" + "gopkg.in/yaml.v3" "github.com/fi-ts/cloud-go/api/client/project" + "github.com/fi-ts/cloudctl/cmd/helper" "github.com/fi-ts/cloudctl/cmd/sorters" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -28,7 +31,7 @@ func newProjectCmd(c *config) *cobra.Command { GenericCLI: genericcli.NewGenericCLI(w).WithFS(c.fs), Singular: "project", Plural: "projects", - Description: "a project organizes cloud resources regarding tenancy, quotas, billing and authentication", + Description: "manage projects, a project organizes cloud resources regarding tenancy, quotas, billing and authentication", Sorter: sorters.ProjectSorter(), ValidArgsFn: c.comp.ProjectListCompletion, DescribePrinter: func() printers.Printer { return c.describePrinter }, @@ -42,18 +45,31 @@ func newProjectCmd(c *config) *cobra.Command { cmd.Flags().Int32("cluster-quota", 0, "cluster quota") cmd.Flags().Int32("machine-quota", 0, "machine quota") cmd.Flags().Int32("ip-quota", 0, "ip quota") - - cmd.MarkFlagsMutuallyExclusive("file", "name") - cmd.MarkFlagsRequiredTogether("name", "description") + genericcli.Must(cmd.MarkFlagRequired("name")) + genericcli.Must(cmd.RegisterFlagCompletionFunc("tenant", c.comp.TenantListCompletion)) }, ListCmdMutateFn: func(cmd *cobra.Command) { - cmd.Flags().StringP("name", "", "", "show projects of given name") - cmd.Flags().StringP("id", "", "", "show projects of given id") - cmd.Flags().StringP("tenant", "", "", "show projects of given id") + cmd.Flags().String("id", "", "show projects of given id") + cmd.Flags().String("name", "", "show projects of given name") + cmd.Flags().String("tenant", "", "show projects of given tenant") + genericcli.Must(cmd.RegisterFlagCompletionFunc("id", c.comp.ProjectListCompletion)) + genericcli.Must(cmd.RegisterFlagCompletionFunc("tenant", c.comp.TenantListCompletion)) + }, + ApplyCmdMutateFn: func(cmd *cobra.Command) { + cmd.Flags().StringP("file", "f", "", `filename of the create or update request in yaml format, or - for stdin. + Example project update: + + # cloudctl project describe project1 -o yaml > project1.yaml + # vi project1.yaml + ## either via stdin + # cat project1.yaml | cloudctl project apply -f - + ## or via file + # cloudctl project apply -f project1.yaml + `) }, } - return genericcli.NewCmds(cmdsConfig) + return genericcli.NewCmds(cmdsConfig, newMachineReservationsCmd(c)) } func (c projectCmd) Get(id string) (*models.V1ProjectResponse, error) { @@ -65,39 +81,200 @@ func (c projectCmd) Get(id string) (*models.V1ProjectResponse, error) { return resp.Payload, nil } -func (c projectCmd) List() ([]*models.V1ProjectResponse, error) { - resp, err := c.cloud.Project.FindProjects(project.NewFindProjectsParams().WithBody(&models.V1ProjectFindRequest{ - ID: viper.GetString("id"), - Name: viper.GetString("name"), - TenantID: viper.GetString("tenant"), - }), nil) +func (c projectCmd) Create(rq *models.V1ProjectCreateRequest) (*models.V1ProjectResponse, error) { + resp, err := c.cloud.Project.CreateProject(project.NewCreateProjectParams().WithBody(rq), nil) if err != nil { + var r *project.CreateProjectConflict + if errors.As(err, &r) { + return nil, genericcli.AlreadyExistsError() + } return nil, err } - return resp.Payload.Projects, nil + return resp.Payload, nil +} + +func (c projectCmd) Describe(id string) (*models.V1ProjectResponse, error) { + request := project.NewFindProjectParams() + request.SetID(id) + resp, err := c.cloud.Project.FindProject(request, nil) + return resp.Payload, err } func (c projectCmd) Delete(id string) (*models.V1ProjectResponse, error) { - resp, err := c.cloud.Project.DeleteProject(project.NewDeleteProjectParams().WithID(id), nil) - if err != nil { - return nil, err + request := project.NewDeleteProjectParams().WithID(id) + response, err := c.cloud.Project.DeleteProject(request, nil) + return response.Payload, err +} + +func (c projectCmd) List() ([]*models.V1ProjectResponse, error) { + id := viper.GetString("id") + name := viper.GetString("name") + tenant := viper.GetString("tenant") + if id != "" || name != "" || tenant != "" { + pfr := project.NewFindProjectsParams().WithBody(&models.V1ProjectFindRequest{ + ID: id, + Name: name, + TenantID: tenant, + }) + + response, err := c.cloud.Project.FindProjects(pfr, nil) + return response.Payload.Projects, err } - return resp.Payload, nil + request := project.NewListProjectsParams() + response, err := c.cloud.Project.ListProjects(request, nil) + return response.Payload.Projects, err } -func (c projectCmd) Create(rq *models.V1ProjectCreateRequest) (*models.V1ProjectResponse, error) { - resp, err := c.cloud.Project.CreateProject(project.NewCreateProjectParams().WithBody(rq), nil) +func (c *config) projectID(verb string, args []string) (string, error) { + if len(args) == 0 { + return "", fmt.Errorf("project %s requires projectID as argument", verb) + } + if len(args) == 1 { + return args[0], nil + } + return "", fmt.Errorf("project %s requires exactly one projectID as argument", verb) +} + +func (c projectCmd) ApplyFromFile(from string) (genericcli.BulkResults[*models.V1ProjectResponse], error) { + var pars []models.V1ProjectCreateRequest + var par models.V1ProjectCreateRequest + err := helper.ReadFrom(viper.GetString("file"), &par, func(data interface{}) { + doc := data.(*models.V1ProjectCreateRequest) + pars = append(pars, *doc) + // the request needs to be renewed as otherwise the pointers in the request struct will + // always point to same last value in the multi-document loop + par = models.V1ProjectCreateRequest{} + }) if err != nil { - var r *project.CreateProjectConflict - if errors.As(err, &r) { - return nil, genericcli.AlreadyExistsError() + return nil, err + } + var response genericcli.BulkResults[*models.V1ProjectResponse] + for i, par := range pars { + request := project.NewFindProjectParams() + request.SetID(par.Meta.ID) + p, err := c.cloud.Project.FindProject(request, nil) + if err != nil { + var r *project.FindProjectDefault + if !errors.As(err, &r) { + return response, err + } + if r.Code() != http.StatusNotFound { + return response, err + } + } + if p == nil || p.Payload == nil { + params := project.NewCreateProjectParams() + params.SetBody(&pars[i]) + resp, err := c.cloud.Project.CreateProject(params, nil) + if err != nil { + response = append(response, genericcli.BulkResult[*models.V1ProjectResponse]{ + Result: resp.Payload, + Action: genericcli.BulkErrorOnCreate, + Error: err, + }) + return response, err + } + response = append(response, genericcli.BulkResult[*models.V1ProjectResponse]{ + Result: resp.Payload, + Action: genericcli.BulkCreated, + Error: nil, + }) + continue + } + if p.Payload.Meta != nil { + params := project.NewUpdateProjectParams() + pur := &models.V1ProjectUpdateRequest{} + if par.Description != "" { + pur.Description = par.Description + } + if par.Name != "" { + pur.Name = par.Name + } + if par.Quotas != nil { + pur.Quotas = par.Quotas + } + if par.Meta != nil { + pur.Meta = par.Meta + } + if par.TenantID != "" { + pur.TenantID = par.TenantID + } + params.SetBody(pur) + resp, err := c.cloud.Project.UpdateProject(params, nil) + if err != nil { + response = append(response, genericcli.BulkResult[*models.V1ProjectResponse]{ + Result: resp.Payload, + Action: genericcli.BulkErrorOnUpdate, + Error: err, + }) + return response, err + } + response = append(response, genericcli.BulkResult[*models.V1ProjectResponse]{ + Result: resp.Payload, + Action: genericcli.BulkUpdated, + Error: nil, + }) + continue } + } + return response, err +} + +func (c projectCmd) Edit(args []string) (*models.V1ProjectResponse, error) { + id, err := c.projectID("edit", args) + if err != nil { return nil, err } - return resp.Payload, nil + getFunc := func(id string) ([]byte, error) { + request := project.NewFindProjectParams() + request.SetID(id) + resp, err := c.cloud.Project.FindProject(request, nil) + if err != nil { + return nil, fmt.Errorf("project describe error:%w", err) + } + content, err := yaml.Marshal(resp.Payload) + if err != nil { + return nil, err + } + return content, nil + } + updateFunc := func(filename string) error { + purs, err := readProjectUpdateRequests(filename) + if err != nil { + return err + } + if len(purs) != 1 { + return fmt.Errorf("project update error more or less than one project given:%d", len(purs)) + } + pup := project.NewUpdateProjectParams() + pup.Body = &purs[0] + uresp, err := c.cloud.Project.UpdateProject(pup, nil) + if err != nil { + return err + } + return c.listPrinter.Print(uresp.Payload) + } + + return nil, helper.Edit(id, getFunc, updateFunc) +} + +func readProjectUpdateRequests(filename string) ([]models.V1ProjectUpdateRequest, error) { + var purs []models.V1ProjectUpdateRequest + var pur models.V1ProjectUpdateRequest + err := helper.ReadFrom(filename, &pur, func(data interface{}) { + doc := data.(*models.V1ProjectUpdateRequest) + purs = append(purs, *doc) + }) + if err != nil { + return purs, err + } + if len(purs) != 1 { + return purs, fmt.Errorf("project update error more or less than one project given:%d", len(purs)) + } + return purs, nil } func (c projectCmd) Update(rq *models.V1ProjectUpdateRequest) (*models.V1ProjectResponse, error) {