Add subcommand prune to the container, volume, image and system commands
Signed-off-by: Kenfe-Mickael Laventure <mickael.laventure@gmail.com>
This commit is contained in:
parent
33f4d68f4d
commit
280c872366
16 changed files with 478 additions and 3 deletions
|
@ -44,6 +44,7 @@ func NewContainerCommand(dockerCli *command.DockerCli) *cobra.Command {
|
|||
NewWaitCommand(dockerCli),
|
||||
newListCommand(dockerCli),
|
||||
newInspectCommand(dockerCli),
|
||||
NewPruneCommand(dockerCli),
|
||||
)
|
||||
return cmd
|
||||
}
|
||||
|
|
74
cli/command/container/prune.go
Normal file
74
cli/command/container/prune.go
Normal file
|
@ -0,0 +1,74 @@
|
|||
package container
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"golang.org/x/net/context"
|
||||
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/cli"
|
||||
"github.com/docker/docker/cli/command"
|
||||
units "github.com/docker/go-units"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type pruneOptions struct {
|
||||
force bool
|
||||
}
|
||||
|
||||
// NewPruneCommand returns a new cobra prune command for containers
|
||||
func NewPruneCommand(dockerCli *command.DockerCli) *cobra.Command {
|
||||
var opts pruneOptions
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "prune",
|
||||
Short: "Remove all stopped containers",
|
||||
Args: cli.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
spaceReclaimed, output, err := runPrune(dockerCli, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if output != "" {
|
||||
fmt.Fprintln(dockerCli.Out(), output)
|
||||
}
|
||||
fmt.Fprintln(dockerCli.Out(), "Total reclaimed space:", units.HumanSize(float64(spaceReclaimed)))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
flags := cmd.Flags()
|
||||
flags.BoolVarP(&opts.force, "force", "f", false, "Do not prompt for confirmation")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
const warning = `WARNING! This will remove all stopped containers.
|
||||
Are you sure you want to continue? [y/N] `
|
||||
|
||||
func runPrune(dockerCli *command.DockerCli, opts pruneOptions) (spaceReclaimed uint64, output string, err error) {
|
||||
if !opts.force && !command.PromptForConfirmation(dockerCli.In(), dockerCli.Out(), warning) {
|
||||
return
|
||||
}
|
||||
|
||||
report, err := dockerCli.Client().ContainersPrune(context.Background(), types.ContainersPruneConfig{})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if len(report.ContainersDeleted) > 0 {
|
||||
output = "Deleted Containers:"
|
||||
for _, id := range report.ContainersDeleted {
|
||||
output += id + "\n"
|
||||
}
|
||||
spaceReclaimed = report.SpaceReclaimed
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// RunPrune call the Container Prune API
|
||||
// This returns the amount of space reclaimed and a detailed output string
|
||||
func RunPrune(dockerCli *command.DockerCli) (uint64, string, error) {
|
||||
return runPrune(dockerCli, pruneOptions{force: true})
|
||||
}
|
|
@ -15,7 +15,6 @@ import (
|
|||
"github.com/docker/docker/cli"
|
||||
"github.com/docker/docker/cli/command"
|
||||
"github.com/docker/docker/cli/command/formatter"
|
||||
"github.com/docker/docker/cli/command/system"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
|
@ -110,7 +109,7 @@ func runStats(dockerCli *command.DockerCli, opts *statsOptions) error {
|
|||
// retrieving the list of running containers to avoid a race where we
|
||||
// would "miss" a creation.
|
||||
started := make(chan struct{})
|
||||
eh := system.InitEventHandler()
|
||||
eh := command.InitEventHandler()
|
||||
eh.Handle("create", func(e events.Message) {
|
||||
if opts.all {
|
||||
s := formatter.NewContainerStats(e.ID[:12], daemonOSType)
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
package system
|
||||
package command
|
||||
|
||||
import (
|
||||
"sync"
|
|
@ -31,6 +31,8 @@ func NewImageCommand(dockerCli *command.DockerCli) *cobra.Command {
|
|||
newListCommand(dockerCli),
|
||||
newRemoveCommand(dockerCli),
|
||||
newInspectCommand(dockerCli),
|
||||
NewPruneCommand(dockerCli),
|
||||
)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
|
90
cli/command/image/prune.go
Normal file
90
cli/command/image/prune.go
Normal file
|
@ -0,0 +1,90 @@
|
|||
package image
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"golang.org/x/net/context"
|
||||
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/cli"
|
||||
"github.com/docker/docker/cli/command"
|
||||
units "github.com/docker/go-units"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type pruneOptions struct {
|
||||
force bool
|
||||
all bool
|
||||
}
|
||||
|
||||
// NewPruneCommand returns a new cobra prune command for images
|
||||
func NewPruneCommand(dockerCli *command.DockerCli) *cobra.Command {
|
||||
var opts pruneOptions
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "prune",
|
||||
Short: "Remove unused images",
|
||||
Args: cli.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
spaceReclaimed, output, err := runPrune(dockerCli, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if output != "" {
|
||||
fmt.Fprintln(dockerCli.Out(), output)
|
||||
}
|
||||
fmt.Fprintln(dockerCli.Out(), "Total reclaimed space:", units.HumanSize(float64(spaceReclaimed)))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
flags := cmd.Flags()
|
||||
flags.BoolVarP(&opts.force, "force", "f", false, "Do not prompt for confirmation")
|
||||
flags.BoolVarP(&opts.all, "all", "a", false, "Remove all unused images, not just dangling ones")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
const (
|
||||
allImageWarning = `WARNING! This will remove all images without at least one container associated to them.
|
||||
Are you sure you want to continue?`
|
||||
danglingWarning = `WARNING! This will remove all dangling images.
|
||||
Are you sure you want to continue?`
|
||||
)
|
||||
|
||||
func runPrune(dockerCli *command.DockerCli, opts pruneOptions) (spaceReclaimed uint64, output string, err error) {
|
||||
warning := danglingWarning
|
||||
if opts.all {
|
||||
warning = allImageWarning
|
||||
}
|
||||
if !opts.force && !command.PromptForConfirmation(dockerCli.In(), dockerCli.Out(), warning) {
|
||||
return
|
||||
}
|
||||
|
||||
report, err := dockerCli.Client().ImagesPrune(context.Background(), types.ImagesPruneConfig{
|
||||
DanglingOnly: !opts.all,
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if len(report.ImagesDeleted) > 0 {
|
||||
output = "Deleted Images:\n"
|
||||
for _, st := range report.ImagesDeleted {
|
||||
if st.Untagged != "" {
|
||||
output += fmt.Sprintln("untagged:", st.Untagged)
|
||||
} else {
|
||||
output += fmt.Sprintln("deleted:", st.Deleted)
|
||||
}
|
||||
}
|
||||
spaceReclaimed = report.SpaceReclaimed
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// RunPrune call the Image Prune API
|
||||
// This returns the amount of space reclaimed and a detailed output string
|
||||
func RunPrune(dockerCli *command.DockerCli, all bool) (uint64, string, error) {
|
||||
return runPrune(dockerCli, pruneOptions{force: true, all: all})
|
||||
}
|
39
cli/command/prune/prune.go
Normal file
39
cli/command/prune/prune.go
Normal file
|
@ -0,0 +1,39 @@
|
|||
package prune
|
||||
|
||||
import (
|
||||
"github.com/docker/docker/cli/command"
|
||||
"github.com/docker/docker/cli/command/container"
|
||||
"github.com/docker/docker/cli/command/image"
|
||||
"github.com/docker/docker/cli/command/volume"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// NewContainerPruneCommand return a cobra prune command for containers
|
||||
func NewContainerPruneCommand(dockerCli *command.DockerCli) *cobra.Command {
|
||||
return container.NewPruneCommand(dockerCli)
|
||||
}
|
||||
|
||||
// NewVolumePruneCommand return a cobra prune command for volumes
|
||||
func NewVolumePruneCommand(dockerCli *command.DockerCli) *cobra.Command {
|
||||
return volume.NewPruneCommand(dockerCli)
|
||||
}
|
||||
|
||||
// NewImagePruneCommand return a cobra prune command for images
|
||||
func NewImagePruneCommand(dockerCli *command.DockerCli) *cobra.Command {
|
||||
return image.NewPruneCommand(dockerCli)
|
||||
}
|
||||
|
||||
// RunContainerPrune execute a prune command for containers
|
||||
func RunContainerPrune(dockerCli *command.DockerCli) (uint64, string, error) {
|
||||
return container.RunPrune(dockerCli)
|
||||
}
|
||||
|
||||
// RunVolumePrune execute a prune command for volumes
|
||||
func RunVolumePrune(dockerCli *command.DockerCli) (uint64, string, error) {
|
||||
return volume.RunPrune(dockerCli)
|
||||
}
|
||||
|
||||
// RunImagePrune execute a prune command for images
|
||||
func RunImagePrune(dockerCli *command.DockerCli, all bool) (uint64, string, error) {
|
||||
return image.RunPrune(dockerCli, all)
|
||||
}
|
|
@ -22,6 +22,7 @@ func NewSystemCommand(dockerCli *command.DockerCli) *cobra.Command {
|
|||
cmd.AddCommand(
|
||||
NewEventsCommand(dockerCli),
|
||||
NewInfoCommand(dockerCli),
|
||||
NewPruneCommand(dockerCli),
|
||||
)
|
||||
return cmd
|
||||
}
|
||||
|
|
90
cli/command/system/prune.go
Normal file
90
cli/command/system/prune.go
Normal file
|
@ -0,0 +1,90 @@
|
|||
package system
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/docker/docker/cli"
|
||||
"github.com/docker/docker/cli/command"
|
||||
"github.com/docker/docker/cli/command/prune"
|
||||
units "github.com/docker/go-units"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type pruneOptions struct {
|
||||
force bool
|
||||
all bool
|
||||
}
|
||||
|
||||
// NewPruneCommand creates a new cobra.Command for `docker du`
|
||||
func NewPruneCommand(dockerCli *command.DockerCli) *cobra.Command {
|
||||
var opts pruneOptions
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "prune [COMMAND]",
|
||||
Short: "Remove unused data.",
|
||||
Args: cli.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runPrune(dockerCli, opts)
|
||||
},
|
||||
}
|
||||
|
||||
flags := cmd.Flags()
|
||||
flags.BoolVarP(&opts.force, "force", "f", false, "Do not prompt for confirmation")
|
||||
flags.BoolVarP(&opts.all, "all", "a", false, "Remove all unused images not just dangling ones")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
const (
|
||||
warning = `WARNING! This will remove:
|
||||
- all stopped containers
|
||||
- all volumes not used by at least one container
|
||||
%s
|
||||
Are you sure you want to continue?`
|
||||
|
||||
danglingImageDesc = "- all dangling images"
|
||||
allImageDesc = `- all images without at least one container associated to them`
|
||||
)
|
||||
|
||||
func runPrune(dockerCli *command.DockerCli, opts pruneOptions) error {
|
||||
var message string
|
||||
|
||||
if opts.all {
|
||||
message = fmt.Sprintf(warning, allImageDesc)
|
||||
} else {
|
||||
message = fmt.Sprintf(warning, danglingImageDesc)
|
||||
}
|
||||
|
||||
if !opts.force && !command.PromptForConfirmation(dockerCli.In(), dockerCli.Out(), message) {
|
||||
return nil
|
||||
}
|
||||
|
||||
var spaceReclaimed uint64
|
||||
|
||||
for _, pruneFn := range []func(dockerCli *command.DockerCli) (uint64, string, error){
|
||||
prune.RunContainerPrune,
|
||||
prune.RunVolumePrune,
|
||||
} {
|
||||
spc, output, err := pruneFn(dockerCli)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if spc > 0 {
|
||||
spaceReclaimed += spc
|
||||
fmt.Fprintln(dockerCli.Out(), output)
|
||||
}
|
||||
}
|
||||
|
||||
spc, output, err := prune.RunImagePrune(dockerCli, opts.all)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if spc > 0 {
|
||||
spaceReclaimed += spc
|
||||
fmt.Fprintln(dockerCli.Out(), output)
|
||||
}
|
||||
|
||||
fmt.Fprintln(dockerCli.Out(), "Total reclaimed space:", units.HumanSize(float64(spaceReclaimed)))
|
||||
|
||||
return nil
|
||||
}
|
|
@ -57,3 +57,25 @@ func PrettyPrint(i interface{}) string {
|
|||
return capitalizeFirst(fmt.Sprintf("%s", t))
|
||||
}
|
||||
}
|
||||
|
||||
// PromptForConfirmation request and check confirmation from user.
|
||||
// This will display the provided message followed by ' [y/N] '. If
|
||||
// the user input 'y' or 'Y' it returns true other false. If no
|
||||
// message is provided "Are you sure you want to proceeed? [y/N] "
|
||||
// will be used instead.
|
||||
func PromptForConfirmation(ins *InStream, outs *OutStream, message string) bool {
|
||||
if message == "" {
|
||||
message = "Are you sure you want to proceeed?"
|
||||
}
|
||||
message += " [y/N] "
|
||||
|
||||
fmt.Fprintf(outs, message)
|
||||
|
||||
answer := ""
|
||||
n, _ := fmt.Fscan(ins, &answer)
|
||||
if n != 1 || (answer != "y" && answer != "Y") {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
|
|
@ -25,6 +25,7 @@ func NewVolumeCommand(dockerCli *command.DockerCli) *cobra.Command {
|
|||
newInspectCommand(dockerCli),
|
||||
newListCommand(dockerCli),
|
||||
newRemoveCommand(dockerCli),
|
||||
NewPruneCommand(dockerCli),
|
||||
)
|
||||
return cmd
|
||||
}
|
||||
|
|
74
cli/command/volume/prune.go
Normal file
74
cli/command/volume/prune.go
Normal file
|
@ -0,0 +1,74 @@
|
|||
package volume
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"golang.org/x/net/context"
|
||||
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/cli"
|
||||
"github.com/docker/docker/cli/command"
|
||||
units "github.com/docker/go-units"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type pruneOptions struct {
|
||||
force bool
|
||||
}
|
||||
|
||||
// NewPruneCommand returns a new cobra prune command for volumes
|
||||
func NewPruneCommand(dockerCli *command.DockerCli) *cobra.Command {
|
||||
var opts pruneOptions
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "prune",
|
||||
Short: "Remove all unused volumes",
|
||||
Args: cli.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
spaceReclaimed, output, err := runPrune(dockerCli, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if output != "" {
|
||||
fmt.Fprintln(dockerCli.Out(), output)
|
||||
}
|
||||
fmt.Fprintln(dockerCli.Out(), "Total reclaimed space:", units.HumanSize(float64(spaceReclaimed)))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
flags := cmd.Flags()
|
||||
flags.BoolVarP(&opts.force, "force", "f", false, "Do not prompt for confirmation")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
const warning = `WARNING! This will remove all volumes not used by at least one container.
|
||||
Are you sure you want to continue?`
|
||||
|
||||
func runPrune(dockerCli *command.DockerCli, opts pruneOptions) (spaceReclaimed uint64, output string, err error) {
|
||||
if !opts.force && !command.PromptForConfirmation(dockerCli.In(), dockerCli.Out(), warning) {
|
||||
return
|
||||
}
|
||||
|
||||
report, err := dockerCli.Client().VolumesPrune(context.Background(), types.VolumesPruneConfig{})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if len(report.VolumesDeleted) > 0 {
|
||||
output = "Deleted Volumes:\n"
|
||||
for _, id := range report.VolumesDeleted {
|
||||
output += id + "\n"
|
||||
}
|
||||
spaceReclaimed = report.SpaceReclaimed
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// RunPrune call the Volume Prune API
|
||||
// This returns the amount of space reclaimed and a detailed output string
|
||||
func RunPrune(dockerCli *command.DockerCli) (uint64, string, error) {
|
||||
return runPrune(dockerCli, pruneOptions{force: true})
|
||||
}
|
26
client/container_prune.go
Normal file
26
client/container_prune.go
Normal file
|
@ -0,0 +1,26 @@
|
|||
package client
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/docker/docker/api/types"
|
||||
"golang.org/x/net/context"
|
||||
)
|
||||
|
||||
// ContainersPrune requests the daemon to delete unused data
|
||||
func (cli *Client) ContainersPrune(ctx context.Context, cfg types.ContainersPruneConfig) (types.ContainersPruneReport, error) {
|
||||
var report types.ContainersPruneReport
|
||||
|
||||
serverResp, err := cli.post(ctx, "/containers/prune", nil, cfg, nil)
|
||||
if err != nil {
|
||||
return report, err
|
||||
}
|
||||
defer ensureReaderClosed(serverResp)
|
||||
|
||||
if err := json.NewDecoder(serverResp.body).Decode(&report); err != nil {
|
||||
return report, fmt.Errorf("Error retrieving disk usage: %v", err)
|
||||
}
|
||||
|
||||
return report, nil
|
||||
}
|
26
client/image_prune.go
Normal file
26
client/image_prune.go
Normal file
|
@ -0,0 +1,26 @@
|
|||
package client
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/docker/docker/api/types"
|
||||
"golang.org/x/net/context"
|
||||
)
|
||||
|
||||
// ImagesPrune requests the daemon to delete unused data
|
||||
func (cli *Client) ImagesPrune(ctx context.Context, cfg types.ImagesPruneConfig) (types.ImagesPruneReport, error) {
|
||||
var report types.ImagesPruneReport
|
||||
|
||||
serverResp, err := cli.post(ctx, "/images/prune", nil, cfg, nil)
|
||||
if err != nil {
|
||||
return report, err
|
||||
}
|
||||
defer ensureReaderClosed(serverResp)
|
||||
|
||||
if err := json.NewDecoder(serverResp.body).Decode(&report); err != nil {
|
||||
return report, fmt.Errorf("Error retrieving disk usage: %v", err)
|
||||
}
|
||||
|
||||
return report, nil
|
||||
}
|
|
@ -61,6 +61,7 @@ type ContainerAPIClient interface {
|
|||
ContainerWait(ctx context.Context, container string) (int, error)
|
||||
CopyFromContainer(ctx context.Context, container, srcPath string) (io.ReadCloser, types.ContainerPathStat, error)
|
||||
CopyToContainer(ctx context.Context, container, path string, content io.Reader, options types.CopyToContainerOptions) error
|
||||
ContainersPrune(ctx context.Context, cfg types.ContainersPruneConfig) (types.ContainersPruneReport, error)
|
||||
}
|
||||
|
||||
// ImageAPIClient defines API client methods for the images
|
||||
|
@ -78,6 +79,7 @@ type ImageAPIClient interface {
|
|||
ImageSearch(ctx context.Context, term string, options types.ImageSearchOptions) ([]registry.SearchResult, error)
|
||||
ImageSave(ctx context.Context, images []string) (io.ReadCloser, error)
|
||||
ImageTag(ctx context.Context, image, ref string) error
|
||||
ImagesPrune(ctx context.Context, cfg types.ImagesPruneConfig) (types.ImagesPruneReport, error)
|
||||
}
|
||||
|
||||
// NetworkAPIClient defines API client methods for the networks
|
||||
|
@ -124,6 +126,7 @@ type SystemAPIClient interface {
|
|||
Events(ctx context.Context, options types.EventsOptions) (<-chan events.Message, <-chan error)
|
||||
Info(ctx context.Context) (types.Info, error)
|
||||
RegistryLogin(ctx context.Context, auth types.AuthConfig) (types.AuthResponse, error)
|
||||
DiskUsage(ctx context.Context) (types.DiskUsage, error)
|
||||
}
|
||||
|
||||
// VolumeAPIClient defines API client methods for the volumes
|
||||
|
@ -133,4 +136,5 @@ type VolumeAPIClient interface {
|
|||
VolumeInspectWithRaw(ctx context.Context, volumeID string) (types.Volume, []byte, error)
|
||||
VolumeList(ctx context.Context, filter filters.Args) (types.VolumesListResponse, error)
|
||||
VolumeRemove(ctx context.Context, volumeID string, force bool) error
|
||||
VolumesPrune(ctx context.Context, cfg types.VolumesPruneConfig) (types.VolumesPruneReport, error)
|
||||
}
|
||||
|
|
26
client/volume_prune.go
Normal file
26
client/volume_prune.go
Normal file
|
@ -0,0 +1,26 @@
|
|||
package client
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/docker/docker/api/types"
|
||||
"golang.org/x/net/context"
|
||||
)
|
||||
|
||||
// VolumesPrune requests the daemon to delete unused data
|
||||
func (cli *Client) VolumesPrune(ctx context.Context, cfg types.VolumesPruneConfig) (types.VolumesPruneReport, error) {
|
||||
var report types.VolumesPruneReport
|
||||
|
||||
serverResp, err := cli.post(ctx, "/volumes/prune", nil, cfg, nil)
|
||||
if err != nil {
|
||||
return report, err
|
||||
}
|
||||
defer ensureReaderClosed(serverResp)
|
||||
|
||||
if err := json.NewDecoder(serverResp.body).Decode(&report); err != nil {
|
||||
return report, fmt.Errorf("Error retrieving disk usage: %v", err)
|
||||
}
|
||||
|
||||
return report, nil
|
||||
}
|
Loading…
Reference in a new issue