From 210cc9dd4c633e0232c136bf54b836aea0852642 Mon Sep 17 00:00:00 2001 From: Long Tran Date: Mon, 6 May 2024 08:53:51 -0500 Subject: [PATCH 1/4] issue #718, added customizable columns to incus storage list --- cmd/incus/storage.go | 142 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 122 insertions(+), 20 deletions(-) diff --git a/cmd/incus/storage.go b/cmd/incus/storage.go index 06a2794f706..86d8ea8fc9e 100644 --- a/cmd/incus/storage.go +++ b/cmd/incus/storage.go @@ -17,6 +17,7 @@ import ( "github.com/lxc/incus/v6/shared/api" "github.com/lxc/incus/v6/shared/termios" "github.com/lxc/incus/v6/shared/units" + "github.com/lxc/incus/v6/shared/util" ) type cmdStorage struct { @@ -25,6 +26,11 @@ type cmdStorage struct { flagTarget string } +type storageColumn struct { + Name string + Data func(api.StoragePool) string +} + func (c *cmdStorage) Command() *cobra.Command { cmd := &cobra.Command{} cmd.Use = usage("storage") @@ -617,8 +623,8 @@ func (c *cmdStorageInfo) Run(cmd *cobra.Command, args []string) error { type cmdStorageList struct { global *cmdGlobal storage *cmdStorage - flagFormat string + flagColumns string } func (c *cmdStorageList) Command() *cobra.Command { @@ -627,7 +633,29 @@ func (c *cmdStorageList) Command() *cobra.Command { cmd.Aliases = []string{"ls"} cmd.Short = i18n.G("List available storage pools") cmd.Long = cli.FormatSection(i18n.G("Description"), i18n.G( - `List available storage pools`)) + `List available storage pools + + Default column layout: nDSdus + + == Columns == +The -c option takes a comma separated list of arguments that control +which instance attributes to output when displaying in table or csv +format. + +Column arguments are either pre-defined shorthand chars (see below), +or (extended) config keys. + +Commas between consecutive shorthand chars are optional. + +Pre-defined column shorthand chars: + n - Name + D - Driver + d - Description + S - Source + u - used by + s - state`)) + cmd.Flags().StringVarP(&c.flagColumns, "columns", "c", defaultStorageColumns, i18n.G("Columns")+"``") + cmd.Flags().StringVarP(&c.flagFormat, "format", "f", "table", i18n.G("Format (csv|json|table|yaml|compact)")+"``") cmd.RunE = c.Run @@ -643,6 +671,85 @@ func (c *cmdStorageList) Command() *cobra.Command { return cmd } +const defaultStorageColumns = "nDSdus" + +func (c *cmdStorageList) parseColumns() ([]storageColumn, error) { + columnsShorthandMap := map[rune]storageColumn{ + 'n': {i18n.G("NAME"), c.storageNameColumnData}, + 'd': {i18n.G("DRIVER"), c.driverColumnData}, + 'D': {i18n.G("DESCRIPTION"), c.descriptionColumnData}, + 'S': {i18n.G("SOURCE"), c.sourceColumnData}, + 'u': {i18n.G("USED BY"), c.usedByColumnData}, + 's': {i18n.G("STATE"), c.stateColumnData}, + } + + columnList := strings.Split(c.flagColumns, ",") + + columns := []storageColumn{} + + for _, columnEntry := range columnList { + if columnEntry == "" { + return nil, fmt.Errorf(i18n.G("Empty column entry (redundant, leading or trailing command) in '%s'"), c.flagColumns) + } + + for _, columnRune := range columnEntry { + column, ok := columnsShorthandMap[columnRune] + if !ok { + return nil, fmt.Errorf(i18n.G("Unknown column shorthand char '%c' in '%s'"), columnRune, columnEntry) + } + + columns = append(columns, column) + } + } + + return columns, nil +} + +func (c *cmdStorageList) storageNameColumnData(storage api.StoragePool) string { + return storage.Name +} + +func (c *cmdStorageList) driverColumnData(storage api.StoragePool) string { + driver := i18n.G("NO") + if util.IsTrue(storage.Config["storage.Driver"]) { + driver = i18n.G("YES") + } + + return driver +} + +func (c *cmdStorageList) descriptionColumnData(storage api.StoragePool) string { + return storage.Description +} + +func (c *cmdStorageList) sourceColumnData(storage api.StoragePool) string { + source := i18n.G("NO") + if util.IsTrue(storage.Config["storage.Source"]) { + source = i18n.G("YES") + } + + return source +} + +func (c *cmdStorageList) usedByColumnData(storage api.StoragePool) string { + usedBy := i18n.G("NO") + if util.IsTrue(storage.Config["storage.UsedBy"]) { + usedBy = i18n.G("YES") + } + + return usedBy +} + +func (c *cmdStorageList) stateColumnData(storage api.StoragePool) string { + state := i18n.G("NO") + if util.IsTrue(storage.Config["storage.State"]) { + state = i18n.G("YES") + } + + return state +} + + func (c *cmdStorageList) Run(cmd *cobra.Command, args []string) error { // Quick checks. exit, err := c.global.CheckArgs(cmd, args, 0, 1) @@ -669,35 +776,30 @@ func (c *cmdStorageList) Run(cmd *cobra.Command, args []string) error { return err } + //parse column flags + columns, err := c.parseColumns() + if err != nil { + return err + } + data := [][]string{} for _, pool := range pools { - usedby := strconv.Itoa(len(pool.UsedBy)) - details := []string{pool.Name, pool.Driver} - if !resource.server.IsClustered() { - details = append(details, pool.Config["source"]) + line := []string{} + for _, column := range columns { + line = append(line, column.Data(pool)) } - details = append(details, pool.Description) - details = append(details, usedby) - details = append(details, strings.ToUpper(pool.Status)) - data = append(data, details) + data = append(data, line) } sort.Sort(cli.SortColumnsNaturally(data)) - header := []string{ - i18n.G("NAME"), - i18n.G("DRIVER"), - } - if !resource.server.IsClustered() { - header = append(header, i18n.G("SOURCE")) + header := []string{} + for _, column := range columns { + header = append(header, column.Name) } - header = append(header, i18n.G("DESCRIPTION")) - header = append(header, i18n.G("USED BY")) - header = append(header, i18n.G("STATE")) - return cli.RenderTable(c.flagFormat, header, data, pools) } From 2a75af2ac4069ca958f236eb8e4865825991cd0e Mon Sep 17 00:00:00 2001 From: Long Tran Date: Mon, 6 May 2024 13:20:17 -0500 Subject: [PATCH 2/4] api: add projects_force_delete extension, contains doc/api-extensions.md and internal/version/api.go --- doc/api-extensions.md | 7 +++++++ internal/version/api.go | 1 + 2 files changed, 8 insertions(+) diff --git a/doc/api-extensions.md b/doc/api-extensions.md index e857360c790..b6c7eb153e0 100644 --- a/doc/api-extensions.md +++ b/doc/api-extensions.md @@ -704,6 +704,13 @@ Add a new project API, supporting creation, update and deletion of projects. Projects can hold containers, profiles or images at this point and let you get a separate view of your Incus resources by switching to it. +## `projects_force_delete` + +This allows force deletion of projects and related artifacts. + +This will remove any instances, volumes and buckets, profiles and networks +(starting with acls, zones, peering before networks themselves). + ## `network_vxlan_ttl` This adds a new `tunnel.NAME.ttl` network configuration option which diff --git a/internal/version/api.go b/internal/version/api.go index 7f29688c324..3457a872930 100644 --- a/internal/version/api.go +++ b/internal/version/api.go @@ -123,6 +123,7 @@ var APIExtensions = []string{ "storage_api_volume_snapshots", "storage_unmapped", "projects", + "projects_force_delete", "network_vxlan_ttl", "container_incremental_copy", "usb_optional_vendorid", From bff87e1cbde1e9e3304dcd5a5e086ee0951c1a3d Mon Sep 17 00:00:00 2001 From: Long Tran Date: Mon, 6 May 2024 17:07:40 -0500 Subject: [PATCH 3/4] Partial implementation of project force delete. Server is unable to find project when the interface is changed to include the force flag. --- client/incus_projects.go | 12 +++++++++--- client/interfaces.go | 2 +- cmd/incus-user/server.go | 2 +- cmd/incus/project.go | 31 ++++++++++++++++++++++++++++--- cmd/incus/storage.go | 2 +- cmd/incusd/api_project.go | 36 +++++++++++++++++++++++++----------- 6 files changed, 65 insertions(+), 20 deletions(-) diff --git a/client/incus_projects.go b/client/incus_projects.go index f0e21542dce..42c7bdd4b9b 100644 --- a/client/incus_projects.go +++ b/client/incus_projects.go @@ -123,14 +123,20 @@ func (r *ProtocolIncus) RenameProject(name string, project api.ProjectPost) (Ope return op, nil } -// DeleteProject deletes a project. -func (r *ProtocolIncus) DeleteProject(name string) error { +// DeleteProject deletes a project gracefully or not, +// depending on the force flag). +func (r *ProtocolIncus) DeleteProject(name string, force bool) error { if !r.HasExtension("projects") { return fmt.Errorf("The server is missing the required \"projects\" API extension") } + params := "" + if force { + params += "?force=1" + } + // Send the request - _, _, err := r.query("DELETE", fmt.Sprintf("/projects/%s", url.PathEscape(name)), nil, "") + _, _, err := r.query("DELETE", fmt.Sprintf("/projects/%s/%s", url.PathEscape(name), params), nil, "") if err != nil { return err } diff --git a/client/interfaces.go b/client/interfaces.go index b5cdce3875c..da8cb415168 100644 --- a/client/interfaces.go +++ b/client/interfaces.go @@ -287,7 +287,7 @@ type InstanceServer interface { CreateProject(project api.ProjectsPost) (err error) UpdateProject(name string, project api.ProjectPut, ETag string) (err error) RenameProject(name string, project api.ProjectPost) (op Operation, err error) - DeleteProject(name string) (err error) + DeleteProject(name string, force bool) (err error) // Storage pool functions ("storage" API extension) GetStoragePoolNames() (names []string, err error) diff --git a/cmd/incus-user/server.go b/cmd/incus-user/server.go index 9b6d6e64edb..82d695ee791 100644 --- a/cmd/incus-user/server.go +++ b/cmd/incus-user/server.go @@ -218,7 +218,7 @@ func serverSetupUser(uid uint32) error { return fmt.Errorf("Unable to create project: %w", err) } - revert.Add(func() { _ = client.DeleteProject(projectName) }) + revert.Add(func() { _ = client.DeleteProject(projectName, true) }) } // Parse the certificate. diff --git a/cmd/incus/project.go b/cmd/incus/project.go index 4628fec6ae3..a3e7b6c1445 100644 --- a/cmd/incus/project.go +++ b/cmd/incus/project.go @@ -1,6 +1,7 @@ package main import ( + "bufio" "fmt" "io" "os" @@ -185,6 +186,7 @@ func (c *cmdProjectCreate) Run(cmd *cobra.Command, args []string) error { type cmdProjectDelete struct { global *cmdGlobal project *cmdProject + flagForce bool } func (c *cmdProjectDelete) Command() *cobra.Command { @@ -193,8 +195,9 @@ func (c *cmdProjectDelete) Command() *cobra.Command { cmd.Aliases = []string{"rm"} cmd.Short = i18n.G("Delete projects") cmd.Long = cli.FormatSection(i18n.G("Description"), i18n.G( - `Delete projects`)) + `Delete projects. Use flag -f to force delete a project.`)) + cmd.Flags().BoolVarP(&c.flagForce, "force", "f", false, i18n.G("Force delete the project and all its attributes.")) cmd.RunE = c.Run cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { @@ -208,6 +211,20 @@ func (c *cmdProjectDelete) Command() *cobra.Command { return cmd } +func (c *cmdProjectDelete) promptConfirmation(name string) error { + reader := bufio.NewReader(os.Stdin) + fmt.Printf(i18n.G(`Careful, force removal may not be properly implemented. + This message is a placeholder. Are you really sure you want to force removing %s? (yes/no): `), name) + input, _ := reader.ReadString('\n') + input = strings.TrimSuffix(input, "\n") + + if !slices.Contains([]string{i18n.G("yes")}, strings.ToLower(input)) { + return fmt.Errorf(i18n.G("User aborted delete operation")) + } + + return nil +} + func (c *cmdProjectDelete) Run(cmd *cobra.Command, args []string) error { // Quick checks. exit, err := c.global.CheckArgs(cmd, args, 1, 1) @@ -228,12 +245,20 @@ func (c *cmdProjectDelete) Run(cmd *cobra.Command, args []string) error { resource := resources[0] + // Prompt for confirmation if --force is used. + if c.flagForce { + err := c.promptConfirmation(resource.name) + if err != nil { + return err + } + } + if resource.name == "" { return fmt.Errorf(i18n.G("Missing project name")) } - // Delete the project - err = resource.server.DeleteProject(resource.name) + // Delete the project, server is unable to find the project here. + err = resource.server.DeleteProject(resource.name, c.flagForce) if err != nil { return err } diff --git a/cmd/incus/storage.go b/cmd/incus/storage.go index f9c49108dc8..7ac5c7a631f 100644 --- a/cmd/incus/storage.go +++ b/cmd/incus/storage.go @@ -17,7 +17,7 @@ import ( "github.com/lxc/incus/v6/shared/api" "github.com/lxc/incus/v6/shared/termios" "github.com/lxc/incus/v6/shared/units" - "github.com/lxc/incus/v6/shared/util" + //"github.com/lxc/incus/v6/shared/util" ) type cmdStorage struct { diff --git a/cmd/incusd/api_project.go b/cmd/incusd/api_project.go index 8be5ace42c6..ddd6a7934ed 100644 --- a/cmd/incusd/api_project.go +++ b/cmd/incusd/api_project.go @@ -10,6 +10,7 @@ import ( "net/http" "net/url" "slices" + "strconv" "strings" "github.com/gorilla/mux" @@ -877,6 +878,11 @@ func projectPost(d *Daemon, r *http.Request) response.Response { func projectDelete(d *Daemon, r *http.Request) response.Response { s := d.State() + force, err := strconv.Atoi(r.FormValue("force")) + if err != nil { + force = 0 + } + name, err := url.PathUnescape(mux.Vars(r)["name"]) if err != nil { return response.SmartError(err) @@ -894,21 +900,29 @@ func projectDelete(d *Daemon, r *http.Request) response.Response { return fmt.Errorf("Fetch project %q: %w", name, err) } - empty, err := projectIsEmpty(ctx, project, tx) - if err != nil { - return err + if force==1 { + //testing: project force delete + fmt.Printf("FORCE DELETING PROJECT \n") } + //default delete behavior, may change when force delete is implemented + if force==0 { + empty, err := projectIsEmpty(ctx, project, tx) + if err != nil { + return err + } - if !empty { - return fmt.Errorf("Only empty projects can be removed") - } + if !empty { + return fmt.Errorf("Only empty projects can be removed") + } - id, err = cluster.GetProjectID(ctx, tx.Tx(), name) - if err != nil { - return fmt.Errorf("Fetch project id %q: %w", name, err) - } + id, err = cluster.GetProjectID(ctx, tx.Tx(), name) + if err != nil { + return fmt.Errorf("Fetch project id %q: %w", name, err) + } - return cluster.DeleteProject(ctx, tx.Tx(), name) + return cluster.DeleteProject(ctx, tx.Tx(), name) + } + return err }) if err != nil { From afedb97795373ddc4fb0ce557d188f69cc858211 Mon Sep 17 00:00:00 2001 From: Long Tran Date: Mon, 6 May 2024 18:37:16 -0500 Subject: [PATCH 4/4] Partial implementation of project force delete. Implemented force flags, warning msg, and updated the api and interfaces. Server is unable to find project when the interface is changed to include the force flag. Maybe need to update the server api? All would be left would be to forcefully delete the project and all objects in the UsedBy field. --- cmd/incus/project.go | 2 +- cmd/incus/storage.go | 1 - cmd/incusd/api_project.go | 7 +++++-- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/cmd/incus/project.go b/cmd/incus/project.go index a3e7b6c1445..f0da9596371 100644 --- a/cmd/incus/project.go +++ b/cmd/incus/project.go @@ -259,7 +259,7 @@ func (c *cmdProjectDelete) Run(cmd *cobra.Command, args []string) error { // Delete the project, server is unable to find the project here. err = resource.server.DeleteProject(resource.name, c.flagForce) - if err != nil { + if err != nil { return err } diff --git a/cmd/incus/storage.go b/cmd/incus/storage.go index 7ac5c7a631f..dbb4a908b79 100644 --- a/cmd/incus/storage.go +++ b/cmd/incus/storage.go @@ -17,7 +17,6 @@ import ( "github.com/lxc/incus/v6/shared/api" "github.com/lxc/incus/v6/shared/termios" "github.com/lxc/incus/v6/shared/units" - //"github.com/lxc/incus/v6/shared/util" ) type cmdStorage struct { diff --git a/cmd/incusd/api_project.go b/cmd/incusd/api_project.go index ddd6a7934ed..ca1aee3ae84 100644 --- a/cmd/incusd/api_project.go +++ b/cmd/incusd/api_project.go @@ -901,8 +901,11 @@ func projectDelete(d *Daemon, r *http.Request) response.Response { } if force==1 { - //testing: project force delete - fmt.Printf("FORCE DELETING PROJECT \n") + //project force delete untested method + //todo: call the incus daemon and use it to DeleteInstance, DeleteProfile, UpdateProfile, etc. + //unsure how to access the UsedBy field via GetProject call. + + } //default delete behavior, may change when force delete is implemented if force==0 {