mirror of
https://github.com/containers/podman.git
synced 2025-09-22 12:14:26 +08:00
v2 bloat pruning phase 2
this is second phase of removing unneeded bloat in the remote client. this is important to be able to reduce the client size as well as possible native compilation for windows/mac. Signed-off-by: Brent Baude <bbaude@redhat.com>
This commit is contained in:
@ -13,11 +13,11 @@ import (
|
||||
"github.com/containers/libpod/cmd/podman/shared"
|
||||
"github.com/containers/libpod/libpod/define"
|
||||
"github.com/containers/libpod/libpod/image"
|
||||
"github.com/containers/libpod/pkg/util"
|
||||
"github.com/containers/libpod/utils"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
var (
|
||||
@ -157,7 +157,7 @@ func runlabelCmd(c *cliconfig.RunlabelValues) error {
|
||||
return errors.Errorf("%s does not have a label of %s", runlabelImage, label)
|
||||
}
|
||||
|
||||
globalOpts := util.GetGlobalOpts(c)
|
||||
globalOpts := GetGlobalOpts(c)
|
||||
cmd, env, err := shared.GenerateRunlabelCommand(runLabel, imageName, c.Name, opts, extraArgs, globalOpts)
|
||||
if err != nil {
|
||||
return err
|
||||
@ -193,3 +193,32 @@ func runlabelCmd(c *cliconfig.RunlabelValues) error {
|
||||
|
||||
return utils.ExecCmdWithStdStreams(stdIn, stdOut, stdErr, env, cmd[0], cmd[1:]...)
|
||||
}
|
||||
|
||||
// GetGlobalOpts checks all global flags and generates the command string
|
||||
func GetGlobalOpts(c *cliconfig.RunlabelValues) string {
|
||||
globalFlags := map[string]bool{
|
||||
"cgroup-manager": true, "cni-config-dir": true, "conmon": true, "default-mounts-file": true,
|
||||
"hooks-dir": true, "namespace": true, "root": true, "runroot": true,
|
||||
"runtime": true, "storage-driver": true, "storage-opt": true, "syslog": true,
|
||||
"trace": true, "network-cmd-path": true, "config": true, "cpu-profile": true,
|
||||
"log-level": true, "tmpdir": true}
|
||||
const stringSliceType string = "stringSlice"
|
||||
|
||||
var optsCommand []string
|
||||
c.PodmanCommand.Command.Flags().VisitAll(func(f *pflag.Flag) {
|
||||
if !f.Changed {
|
||||
return
|
||||
}
|
||||
if _, exist := globalFlags[f.Name]; exist {
|
||||
if f.Value.Type() == stringSliceType {
|
||||
flagValue := strings.TrimSuffix(strings.TrimPrefix(f.Value.String(), "["), "]")
|
||||
for _, value := range strings.Split(flagValue, ",") {
|
||||
optsCommand = append(optsCommand, fmt.Sprintf("--%s %s", f.Name, value))
|
||||
}
|
||||
} else {
|
||||
optsCommand = append(optsCommand, fmt.Sprintf("--%s %s", f.Name, f.Value.String()))
|
||||
}
|
||||
}
|
||||
})
|
||||
return strings.Join(optsCommand, " ")
|
||||
}
|
||||
|
@ -6,7 +6,6 @@ import (
|
||||
|
||||
buildahcli "github.com/containers/buildah/pkg/cli"
|
||||
"github.com/containers/common/pkg/config"
|
||||
"github.com/containers/libpod/cmd/podman/cliconfig"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
@ -214,22 +213,22 @@ func GetCreateFlags(cf *ContainerCLIOpts) *pflag.FlagSet {
|
||||
)
|
||||
createFlags.StringVar(
|
||||
&cf.HealthInterval,
|
||||
"health-interval", cliconfig.DefaultHealthCheckInterval,
|
||||
"health-interval", DefaultHealthCheckInterval,
|
||||
"set an interval for the healthchecks (a value of disable results in no automatic timer setup)",
|
||||
)
|
||||
createFlags.UintVar(
|
||||
&cf.HealthRetries,
|
||||
"health-retries", cliconfig.DefaultHealthCheckRetries,
|
||||
"health-retries", DefaultHealthCheckRetries,
|
||||
"the number of retries allowed before a healthcheck is considered to be unhealthy",
|
||||
)
|
||||
createFlags.StringVar(
|
||||
&cf.HealthStartPeriod,
|
||||
"health-start-period", cliconfig.DefaultHealthCheckStartPeriod,
|
||||
"health-start-period", DefaultHealthCheckStartPeriod,
|
||||
"the initialization time needed for a container to bootstrap",
|
||||
)
|
||||
createFlags.StringVar(
|
||||
&cf.HealthTimeout,
|
||||
"health-timeout", cliconfig.DefaultHealthCheckTimeout,
|
||||
"health-timeout", DefaultHealthCheckTimeout,
|
||||
"the maximum time allowed to complete the healthcheck before an interval is considered failed",
|
||||
)
|
||||
createFlags.StringVarP(
|
||||
@ -244,7 +243,7 @@ func GetCreateFlags(cf *ContainerCLIOpts) *pflag.FlagSet {
|
||||
)
|
||||
createFlags.StringVar(
|
||||
&cf.ImageVolume,
|
||||
"image-volume", cliconfig.DefaultImageVolume,
|
||||
"image-volume", DefaultImageVolume,
|
||||
`Tells podman how to handle the builtin image volumes ("bind"|"tmpfs"|"ignore")`,
|
||||
)
|
||||
createFlags.BoolVar(
|
||||
|
@ -12,6 +12,19 @@ import (
|
||||
"github.com/opencontainers/selinux/go-selinux"
|
||||
)
|
||||
|
||||
var (
|
||||
// DefaultHealthCheckInterval default value
|
||||
DefaultHealthCheckInterval = "30s"
|
||||
// DefaultHealthCheckRetries default value
|
||||
DefaultHealthCheckRetries uint = 3
|
||||
// DefaultHealthCheckStartPeriod default value
|
||||
DefaultHealthCheckStartPeriod = "0s"
|
||||
// DefaultHealthCheckTimeout default value
|
||||
DefaultHealthCheckTimeout = "30s"
|
||||
// DefaultImageVolume default value
|
||||
DefaultImageVolume = "bind"
|
||||
)
|
||||
|
||||
// TODO these options are directly embedded into many of the CLI cobra values, as such
|
||||
// this approach will not work in a remote client. so we will need to likely do something like a
|
||||
// supported and unsupported approach here and backload these options into the specgen
|
||||
|
@ -4,6 +4,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/containers/libpod/libpod"
|
||||
lpfilters "github.com/containers/libpod/libpod/filters"
|
||||
"github.com/containers/libpod/pkg/api/handlers/utils"
|
||||
"github.com/containers/libpod/pkg/domain/entities"
|
||||
"github.com/docker/docker/api/types"
|
||||
@ -15,6 +16,7 @@ func PruneContainers(w http.ResponseWriter, r *http.Request) {
|
||||
var (
|
||||
delContainers []string
|
||||
space int64
|
||||
filterFuncs []libpod.ContainerFilter
|
||||
)
|
||||
runtime := r.Context().Value("runtime").(*libpod.Runtime)
|
||||
decoder := r.Context().Value("decoder").(*schema.Decoder)
|
||||
@ -26,12 +28,16 @@ func PruneContainers(w http.ResponseWriter, r *http.Request) {
|
||||
utils.Error(w, "Something went wrong.", http.StatusBadRequest, errors.Wrapf(err, "Failed to parse parameters for %s", r.URL.String()))
|
||||
return
|
||||
}
|
||||
|
||||
filterFuncs, err := utils.GenerateFilterFuncsFromMap(runtime, query.Filters)
|
||||
for k, v := range query.Filters {
|
||||
for _, val := range v {
|
||||
generatedFunc, err := lpfilters.GenerateContainerFilterFuncs(k, val, runtime)
|
||||
if err != nil {
|
||||
utils.InternalServerError(w, err)
|
||||
return
|
||||
}
|
||||
filterFuncs = append(filterFuncs, generatedFunc)
|
||||
}
|
||||
}
|
||||
prunedContainers, pruneErrors, err := runtime.PruneContainers(filterFuncs)
|
||||
if err != nil {
|
||||
utils.InternalServerError(w, err)
|
||||
|
@ -4,12 +4,12 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/containers/libpod/cmd/podman/shared"
|
||||
"github.com/containers/libpod/libpod"
|
||||
"github.com/containers/libpod/libpod/define"
|
||||
"github.com/containers/libpod/pkg/api/handlers/utils"
|
||||
"github.com/containers/libpod/pkg/domain/entities"
|
||||
"github.com/containers/libpod/pkg/domain/filters"
|
||||
"github.com/containers/libpod/pkg/domain/infra/abi/parse"
|
||||
"github.com/gorilla/schema"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
@ -46,7 +46,7 @@ func CreateVolume(w http.ResponseWriter, r *http.Request) {
|
||||
volumeOptions = append(volumeOptions, libpod.WithVolumeLabels(input.Label))
|
||||
}
|
||||
if len(input.Options) > 0 {
|
||||
parsedOptions, err := shared.ParseVolumeOptions(input.Options)
|
||||
parsedOptions, err := parse.ParseVolumeOptions(input.Options)
|
||||
if err != nil {
|
||||
utils.InternalServerError(w, err)
|
||||
return
|
||||
|
@ -6,9 +6,10 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/containers/libpod/cmd/podman/shared"
|
||||
createconfig "github.com/containers/libpod/pkg/spec"
|
||||
|
||||
"github.com/containers/libpod/libpod"
|
||||
"github.com/containers/libpod/libpod/define"
|
||||
createconfig "github.com/containers/libpod/pkg/spec"
|
||||
"github.com/gorilla/schema"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
@ -68,24 +69,6 @@ func WaitContainer(w http.ResponseWriter, r *http.Request) (int32, error) {
|
||||
return con.WaitForConditionWithInterval(interval, condition)
|
||||
}
|
||||
|
||||
// GenerateFilterFuncsFromMap is used to generate un-executed functions that can be used to filter
|
||||
// containers. It is specifically designed for the RESTFUL API input.
|
||||
func GenerateFilterFuncsFromMap(r *libpod.Runtime, filters map[string][]string) ([]libpod.ContainerFilter, error) {
|
||||
var (
|
||||
filterFuncs []libpod.ContainerFilter
|
||||
)
|
||||
for k, v := range filters {
|
||||
for _, val := range v {
|
||||
f, err := shared.GenerateContainerFilterFuncs(k, val, r)
|
||||
if err != nil {
|
||||
return filterFuncs, err
|
||||
}
|
||||
filterFuncs = append(filterFuncs, f)
|
||||
}
|
||||
}
|
||||
return filterFuncs, nil
|
||||
}
|
||||
|
||||
func CreateContainer(ctx context.Context, w http.ResponseWriter, runtime *libpod.Runtime, cc *createconfig.CreateConfig) {
|
||||
var pod *libpod.Pod
|
||||
ctr, err := shared.CreateContainerFromCreateConfig(runtime, cc, ctx, pod)
|
||||
|
@ -1,11 +1,10 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/containers/libpod/cmd/podman/shared"
|
||||
"github.com/containers/libpod/libpod"
|
||||
lpfilters "github.com/containers/libpod/libpod/filters"
|
||||
"github.com/containers/libpod/pkg/domain/entities"
|
||||
"github.com/gorilla/schema"
|
||||
)
|
||||
@ -14,7 +13,7 @@ func GetPods(w http.ResponseWriter, r *http.Request) ([]*entities.ListPodsReport
|
||||
var (
|
||||
lps []*entities.ListPodsReport
|
||||
pods []*libpod.Pod
|
||||
podErr error
|
||||
filters []libpod.PodFilter
|
||||
)
|
||||
runtime := r.Context().Value("runtime").(*libpod.Runtime)
|
||||
decoder := r.Context().Value("decoder").(*schema.Decoder)
|
||||
@ -28,28 +27,24 @@ func GetPods(w http.ResponseWriter, r *http.Request) ([]*entities.ListPodsReport
|
||||
if err := decoder.Decode(&query, r.URL.Query()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var filters = []string{}
|
||||
if _, found := r.URL.Query()["digests"]; found && query.Digests {
|
||||
UnSupportedParameter("digests")
|
||||
}
|
||||
|
||||
if len(query.Filters) > 0 {
|
||||
for k, v := range query.Filters {
|
||||
for _, val := range v {
|
||||
filters = append(filters, fmt.Sprintf("%s=%s", k, val))
|
||||
}
|
||||
}
|
||||
filterFuncs, err := shared.GenerateFilterFunction(runtime, filters)
|
||||
for _, filter := range v {
|
||||
f, err := lpfilters.GeneratePodFilterFunc(k, filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pods, podErr = shared.FilterAllPodsWithFilterFunc(runtime, filterFuncs...)
|
||||
} else {
|
||||
pods, podErr = runtime.GetAllPods()
|
||||
filters = append(filters, f)
|
||||
}
|
||||
if podErr != nil {
|
||||
return nil, podErr
|
||||
}
|
||||
pods, err := runtime.Pods(filters...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, pod := range pods {
|
||||
status, err := pod.GetPodStatus()
|
||||
if err != nil {
|
||||
|
@ -4,8 +4,8 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/containers/libpod/cmd/podman/shared"
|
||||
"github.com/containers/libpod/libpod"
|
||||
"github.com/containers/libpod/pkg/ps/define"
|
||||
"github.com/cri-o/ocicni/pkg/ocicni"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
@ -48,7 +48,7 @@ type ListContainer struct {
|
||||
// Port mappings
|
||||
Ports []ocicni.PortMapping
|
||||
// Size of the container rootfs. Requires the size boolean to be true
|
||||
Size *shared.ContainerSize
|
||||
Size *define.ContainerSize
|
||||
// Time when container started
|
||||
StartedAt int64
|
||||
// State of container
|
||||
|
@ -11,6 +11,8 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
lpfilters "github.com/containers/libpod/libpod/filters"
|
||||
|
||||
"github.com/containers/buildah"
|
||||
"github.com/containers/common/pkg/config"
|
||||
"github.com/containers/image/v5/manifest"
|
||||
@ -19,7 +21,6 @@ import (
|
||||
"github.com/containers/libpod/libpod/events"
|
||||
"github.com/containers/libpod/libpod/image"
|
||||
"github.com/containers/libpod/libpod/logs"
|
||||
"github.com/containers/libpod/pkg/api/handlers/utils"
|
||||
"github.com/containers/libpod/pkg/checkpoint"
|
||||
"github.com/containers/libpod/pkg/domain/entities"
|
||||
"github.com/containers/libpod/pkg/domain/infra/abi/terminal"
|
||||
@ -175,10 +176,16 @@ func (ic *ContainerEngine) ContainerStop(ctx context.Context, namesOrIds []strin
|
||||
}
|
||||
|
||||
func (ic *ContainerEngine) ContainerPrune(ctx context.Context, options entities.ContainerPruneOptions) (*entities.ContainerPruneReport, error) {
|
||||
filterFuncs, err := utils.GenerateFilterFuncsFromMap(ic.Libpod, options.Filters)
|
||||
var filterFuncs []libpod.ContainerFilter
|
||||
for k, v := range options.Filters {
|
||||
for _, val := range v {
|
||||
generatedFunc, err := lpfilters.GenerateContainerFilterFuncs(k, val, ic.Libpod)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
filterFuncs = append(filterFuncs, generatedFunc)
|
||||
}
|
||||
}
|
||||
prunedContainers, pruneErrors, err := ic.Libpod.PruneContainers(filterFuncs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
8
pkg/ps/define/types.go
Normal file
8
pkg/ps/define/types.go
Normal file
@ -0,0 +1,8 @@
|
||||
package define
|
||||
|
||||
// ContainerSize holds the size of the container's root filesystem and top
|
||||
// read-write layer.
|
||||
type ContainerSize struct {
|
||||
RootFsSize int64 `json:"rootFsSize"`
|
||||
RwSize int64 `json:"rwSize"`
|
||||
}
|
38
pkg/ps/ps.go
38
pkg/ps/ps.go
@ -1,16 +1,19 @@
|
||||
package ps
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/containers/libpod/cmd/podman/shared"
|
||||
"github.com/containers/libpod/libpod"
|
||||
"github.com/containers/libpod/libpod/define"
|
||||
lpfilters "github.com/containers/libpod/libpod/filters"
|
||||
"github.com/containers/libpod/pkg/domain/entities"
|
||||
psdefine "github.com/containers/libpod/pkg/ps/define"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
@ -80,7 +83,7 @@ func ListContainerBatch(rt *libpod.Runtime, ctr *libpod.Container, opts entities
|
||||
exitCode int32
|
||||
exited bool
|
||||
pid int
|
||||
size *shared.ContainerSize
|
||||
size *psdefine.ContainerSize
|
||||
startedTime time.Time
|
||||
exitedTime time.Time
|
||||
cgroup, ipc, mnt, net, pidns, user, uts string
|
||||
@ -116,16 +119,16 @@ func ListContainerBatch(rt *libpod.Runtime, ctr *libpod.Container, opts entities
|
||||
return errors.Wrapf(err, "unable to obtain container pid")
|
||||
}
|
||||
ctrPID := strconv.Itoa(pid)
|
||||
cgroup, _ = shared.GetNamespaceInfo(filepath.Join("/proc", ctrPID, "ns", "cgroup"))
|
||||
ipc, _ = shared.GetNamespaceInfo(filepath.Join("/proc", ctrPID, "ns", "ipc"))
|
||||
mnt, _ = shared.GetNamespaceInfo(filepath.Join("/proc", ctrPID, "ns", "mnt"))
|
||||
net, _ = shared.GetNamespaceInfo(filepath.Join("/proc", ctrPID, "ns", "net"))
|
||||
pidns, _ = shared.GetNamespaceInfo(filepath.Join("/proc", ctrPID, "ns", "pid"))
|
||||
user, _ = shared.GetNamespaceInfo(filepath.Join("/proc", ctrPID, "ns", "user"))
|
||||
uts, _ = shared.GetNamespaceInfo(filepath.Join("/proc", ctrPID, "ns", "uts"))
|
||||
cgroup, _ = getNamespaceInfo(filepath.Join("/proc", ctrPID, "ns", "cgroup"))
|
||||
ipc, _ = getNamespaceInfo(filepath.Join("/proc", ctrPID, "ns", "ipc"))
|
||||
mnt, _ = getNamespaceInfo(filepath.Join("/proc", ctrPID, "ns", "mnt"))
|
||||
net, _ = getNamespaceInfo(filepath.Join("/proc", ctrPID, "ns", "net"))
|
||||
pidns, _ = getNamespaceInfo(filepath.Join("/proc", ctrPID, "ns", "pid"))
|
||||
user, _ = getNamespaceInfo(filepath.Join("/proc", ctrPID, "ns", "user"))
|
||||
uts, _ = getNamespaceInfo(filepath.Join("/proc", ctrPID, "ns", "uts"))
|
||||
}
|
||||
if opts.Size {
|
||||
size = new(shared.ContainerSize)
|
||||
size = new(psdefine.ContainerSize)
|
||||
|
||||
rootFsSize, err := c.RootFsSize()
|
||||
if err != nil {
|
||||
@ -187,3 +190,18 @@ func ListContainerBatch(rt *libpod.Runtime, ctr *libpod.Container, opts entities
|
||||
}
|
||||
return ps, nil
|
||||
}
|
||||
|
||||
func getNamespaceInfo(path string) (string, error) {
|
||||
val, err := os.Readlink(path)
|
||||
if err != nil {
|
||||
return "", errors.Wrapf(err, "error getting info from %q", path)
|
||||
}
|
||||
return getStrFromSquareBrackets(val), nil
|
||||
}
|
||||
|
||||
// getStrFromSquareBrackets gets the string inside [] from a string.
|
||||
func getStrFromSquareBrackets(cmd string) string {
|
||||
reg := regexp.MustCompile(`.*\[|\].*`)
|
||||
arr := strings.Split(reg.ReplaceAllLiteralString(cmd, ""), ",")
|
||||
return strings.Join(arr, ",")
|
||||
}
|
||||
|
@ -14,7 +14,6 @@ import (
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
"github.com/containers/image/v5/types"
|
||||
"github.com/containers/libpod/cmd/podman/cliconfig"
|
||||
"github.com/containers/libpod/pkg/errorhandling"
|
||||
"github.com/containers/libpod/pkg/namespaces"
|
||||
"github.com/containers/libpod/pkg/rootless"
|
||||
@ -24,7 +23,6 @@ import (
|
||||
v1 "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/spf13/pflag"
|
||||
"golang.org/x/crypto/ssh/terminal"
|
||||
)
|
||||
|
||||
@ -515,37 +513,6 @@ func ParseInputTime(inputTime string) (time.Time, error) {
|
||||
return time.Now().Add(-duration), nil
|
||||
}
|
||||
|
||||
// GetGlobalOpts checks all global flags and generates the command string
|
||||
// FIXME: Port input to config.Config
|
||||
// TODO: Is there a "better" way to reverse values to flags? This seems brittle.
|
||||
func GetGlobalOpts(c *cliconfig.RunlabelValues) string {
|
||||
globalFlags := map[string]bool{
|
||||
"cgroup-manager": true, "cni-config-dir": true, "conmon": true, "default-mounts-file": true,
|
||||
"hooks-dir": true, "namespace": true, "root": true, "runroot": true,
|
||||
"runtime": true, "storage-driver": true, "storage-opt": true, "syslog": true,
|
||||
"trace": true, "network-cmd-path": true, "config": true, "cpu-profile": true,
|
||||
"log-level": true, "tmpdir": true}
|
||||
const stringSliceType string = "stringSlice"
|
||||
|
||||
var optsCommand []string
|
||||
c.PodmanCommand.Command.Flags().VisitAll(func(f *pflag.Flag) {
|
||||
if !f.Changed {
|
||||
return
|
||||
}
|
||||
if _, exist := globalFlags[f.Name]; exist {
|
||||
if f.Value.Type() == stringSliceType {
|
||||
flagValue := strings.TrimSuffix(strings.TrimPrefix(f.Value.String(), "["), "]")
|
||||
for _, value := range strings.Split(flagValue, ",") {
|
||||
optsCommand = append(optsCommand, fmt.Sprintf("--%s %s", f.Name, value))
|
||||
}
|
||||
} else {
|
||||
optsCommand = append(optsCommand, fmt.Sprintf("--%s %s", f.Name, f.Value.String()))
|
||||
}
|
||||
}
|
||||
})
|
||||
return strings.Join(optsCommand, " ")
|
||||
}
|
||||
|
||||
// OpenExclusiveFile opens a file for writing and ensure it doesn't already exist
|
||||
func OpenExclusiveFile(path string) (*os.File, error) {
|
||||
baseDir := filepath.Dir(path)
|
||||
|
Reference in New Issue
Block a user