diff --git a/README.md b/README.md index 2e5a7225..129dd2ff 100644 --- a/README.md +++ b/README.md @@ -278,7 +278,8 @@ K9s uses aliases to navigate most K8s resources. ## K9s Configuration - K9s keeps its configurations inside of a `k9s` directory and the location depends on your operating system. K9s leverages [XDG](https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html) to load its various configurations files. + K9s keeps its configurations inside of a `k9s` directory and the location depends on your operating system. K9s leverages [XDG](https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html) to load its various configurations files. For information on the default locations for your OS please see [this link](https://github.com/adrg/xdg/blob/master/README.md). If you are still confused a quick `k9s info` will reveal where k9s is loading its configurations from. + | Unix | macOS | Windows | |-----------------|-----------------------------|-----------------------| diff --git a/change_logs/release_v0.25.0.md b/change_logs/release_v0.25.0.md index 5ad5a190..96c0bcda 100644 --- a/change_logs/release_v0.25.0.md +++ b/change_logs/release_v0.25.0.md @@ -14,7 +14,7 @@ On Slack? Please join us [K9slackers](https://join.slack.com/t/k9sers/shared_inv ## ♫ Sounds Behind The Release ♭ -* [High Fidelity - By Elvis Costello (yup! he started as a computer operator. Can u tell?)](https://www.youtube.com/watch?v=DJS-2kacmpU) +* [High Fidelity - By Elvis Costello (Yup! he started is career as a computer operator. Can u tell??)](https://www.youtube.com/watch?v=DJS-2kacmpU) * [Walk With A Big Stick - Foster The People](https://www.youtube.com/watch?v=XMY1VMTyl8s) * [Beirut - Steps Ahead -- Love this band!! with the ever so talented and sadly late Michael Brecker ;(](https://www.youtube.com/watch?v=UExKTZ3veB8) @@ -25,20 +25,31 @@ On Slack? Please join us [K9slackers](https://join.slack.com/t/k9sers/shared_inv I want to recognize the following folks that have been kind enough to join our sponsorship program and opted to `pay it forward`! * [Andrew Regan](https://github.com/poblish) -* [Astraea](https://github.com/s22s) -* [DataRoots](https://github.com/datarootsio) +* [Bruno Brito](https://github.com/brunohbrito) +* [ScubaDrew](https://github.com/ScubaDrew) +* [mike-code](https://github.com/mike-code) +* [Andrew Aadland](https://github.com/DaemonDude23) +* [Michael Albers](https://github.com/michaeljohnalbers) So if you feel K9s is helping with your productivity while administering your Kubernetes clusters, please consider pitching in as it will go a long way in ensuring a thriving environment for this repo and our k9ers community at large. +Also please take some time and give a huge shoot out to all the good folks below that have spent time plowing thru the code to help improve K9s for all of us! + Thank you!! --- -## Forward That! +## Personal Note... -Ever been in a situation where you need to constantly port-forward on a given pod with multiple containers exposing multiple ports? If so it might be cumbersome to have to type in the full container:port specification to activate a forward. If you fall in this use cases, you can now specify which container and port you would rather port-forward to by default. In this drop, we introduce a new annotation that you can use to specify and container/port to forward to by default. If set the port-forward dialog will know default to your settings. +I had so many distractions this cycle so expect some `disturbance in the farce!` on this drop. +To boot rat holed quiet a bit on improving speed. So I might have drop some stuff on the floor in the process... +Please report back if that's the case and we will address shortly. Tx!! -> NOTE: you can either use a port name or number in your annotation. +## Port It Forward?? + +Ever been in a situation where you need to constantly port-forward on a given pod with multiple containers or exposing multiple ports? If so it might be cumbersome to have to type in the full container:port specification to activate a forward. If you fall in this use cases, you can now specify which container and port you would rather port-forward to by default. In this drop, we introduce a new annotation that you can use to specify and container/port to forward to by default. If set, the port-forward dialog will know to default to your settings. + +> NOTE: you can either use a container port name or number in your annotation! ```yaml # Pod fred @@ -47,9 +58,10 @@ kind: Pod metadata: name: fred annotations: - k9s.imhotep.io/default-portforward-container: bozo:p1 # => will default to container bozo port named p1 + k9scli.io/auto-portforwards: zorg::5556 # => will default to container zorg port 5556 and local port 5566. No port-forward dialog will be shown. # Or... - k9s.imhotep.io/default-portforward-container: bozo:8081 # => will default to container bozo port number 8081 + k9scli.io/portforward: bozo::6666:p1 # => launches the port-forward dialog selecting default port-forward on container bozo port named p1(8081) + # mapping to local port 6666. ... spec: containers: @@ -67,18 +79,35 @@ spec: ... ``` +The annotation value must specify a container to forward to as well as a local port and container port. The container port may be specified as either a port number or port name. If the local port is omitted then the local port will default to the container port number. Here are a few examples: + +1. bozo::http - creates a pf on container `bozo` with port name http. If http specifies port number 8080 then the local port will be 8080 as well. +2. bozo::9090:http - creates a pf on container `bozo` mapping local port 9090->http(8080) +3. bozo::9090:8080 - creates a pf on container `bozo` mapping local port 9090->8080 + --- ## Resolved Issues +* [Issue #1299](https://github.com/derailed/k9s/issues/1299) After upgrade to 0.24.15 sorting shortcuts not working +* [Issue #1298](https://github.com/derailed/k9s/issues/1298) Install K9s through go get reporting ambiguous import error +* [Issue #1296](https://github.com/derailed/k9s/issues/1296) Crash when clicking between border of K9s and terminal pane +* [Issue #1289](https://github.com/derailed/k9s/issues/1289) Homebrew calling bottle :unneeded is deprecated! There is no replacement +* [Issue #1273](https://github.com/derailed/k9s/issues/1273) Not loading config from correct default location when XDG_CONFIG_HOME is unset * [Issue #1268](https://github.com/derailed/k9s/issues/1268) Age sorting wrong for years * [Issue #1258](https://github.com/derailed/k9s/issues/1258) Configurable or recent use based port-forward * [Issue #1257](https://github.com/derailed/k9s/issues/1257) Why is the latest chocolatey on 0.24.10 +* [Issue #1243](https://github.com/derailed/k9s/issues/1243) Port forward fails in kind on windows 10 --- ## PRs +* [PR #1300](https://github.com/derailed/k9s/pull/1300) move from io/ioutil to io/os packages +* [PR #1287](https://github.com/derailed/k9s/pull/1287) Add missing styles to kiss +* [PR #1286](https://github.com/derailed/k9s/pull/1286) Some small color modifications +* [PR #1284](https://github.com/derailed/k9s/pull/1284) Fix a small typo which comes from cluster view info +* [PR #1271](https://github.com/derailed/k9s/pull/1271) Removed cursor colors that are too light to read * [PR #1266](https://github.com/derailed/k9s/pull/1266) Skin to preserve your terminal session background color * [PR #1264](https://github.com/derailed/k9s/pull/1205) Adding note on popeye config * [PR #1261](https://github.com/derailed/k9s/pull/1261) Blurry logo diff --git a/cmd/root.go b/cmd/root.go index 4351240d..46f0daca 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -94,25 +94,25 @@ func loadConfiguration() *config.Config { k9sCfg.K9s.OverrideWrite(*k9sFlags.Write) k9sCfg.K9s.OverrideCommand(*k9sFlags.Command) - if err := k9sCfg.Refine(k8sFlags, k9sFlags); err != nil { + if err := k9sCfg.Refine(k8sFlags, k9sFlags, k8sCfg); err != nil { log.Error().Err(err).Msgf("refine failed") } conn, err := client.InitConnection(k8sCfg) k9sCfg.SetConnection(conn) if err != nil { log.Error().Err(err).Msgf("failed to connect to cluster") - } else { - // Try to access server version if that fail. Connectivity issue? - if !k9sCfg.GetConnection().CheckConnectivity() { - log.Panic().Msgf("K9s can't connect to cluster") - } - if !k9sCfg.GetConnection().ConnectionOK() { - panic("No connectivity") - } - log.Info().Msg("✅ Kubernetes connectivity") - if err := k9sCfg.Save(); err != nil { - log.Error().Err(err).Msg("Config save") - } + return k9sCfg + } + // Try to access server version if that fail. Connectivity issue? + if !k9sCfg.GetConnection().CheckConnectivity() { + log.Panic().Msgf("Cannot connect to cluster") + } + if !k9sCfg.GetConnection().ConnectionOK() { + panic("No connectivity") + } + log.Info().Msg("✅ Kubernetes connectivity") + if err := k9sCfg.Save(); err != nil { + log.Error().Err(err).Msg("Config save") } return k9sCfg diff --git a/go.mod b/go.mod index b9ae6990..e4ab6166 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,7 @@ require ( github.com/cenkalti/backoff v2.2.1+incompatible github.com/cenkalti/backoff/v4 v4.1.1 github.com/derailed/popeye v0.9.7 - github.com/derailed/tview v0.6.1 + github.com/derailed/tview v0.6.3 github.com/fatih/color v1.12.0 github.com/fsnotify/fsnotify v1.5.1 github.com/fvbommel/sortorder v1.0.2 @@ -22,10 +22,6 @@ require ( github.com/ghodss/yaml v1.0.0 github.com/kylelemons/godebug v1.1.0 // indirect github.com/mattn/go-runewidth v0.0.13 - // BOZO!! revamp with latest... - // github.com/openfaas/faas v0.0.0-20200207215241-6afae214e3ec - // github.com/openfaas/faas-cli v0.0.0-20200124160744-30b7cec9634c - // github.com/openfaas/faas-provider v0.15.0 github.com/petergtz/pegomock v2.9.0+incompatible github.com/rakyll/hey v0.1.4 github.com/rs/zerolog v1.25.0 diff --git a/go.sum b/go.sum index d5dd4c96..96ed6a53 100644 --- a/go.sum +++ b/go.sum @@ -216,8 +216,8 @@ github.com/derailed/popeye v0.9.7 h1:EnOl8rwvAlN4KJo62+V7J713ZAWFQmKCrTBBBBBkbmQ github.com/derailed/popeye v0.9.7/go.mod h1:Ih3wTG7wBOuxdqz5tlCuCFq/vyB+Te/IpqY5HwgUTEA= github.com/derailed/tcell/v2 v2.3.1-rc.2 h1:9TmZB/IwL3MA1Jf4pC4rfMaPTcVYIN62IwE7X7A9emU= github.com/derailed/tcell/v2 v2.3.1-rc.2/go.mod h1:wegJ+SscH+jPjEQIAV/dI/grLTRm5R4IE2M479NDSL0= -github.com/derailed/tview v0.6.1 h1:dB+9bO7r6a1Yg1HE+XNJj61hioauJnGBFq2biC5bjAk= -github.com/derailed/tview v0.6.1/go.mod h1:5Wjopun0Jw3zxOFtafwc/GlrkFJix1hZz1oQetWpnwE= +github.com/derailed/tview v0.6.3 h1:4GFzcmuVjHYHKlLEpU8lSiUBVfHeYQEC0z5tlBLp4CI= +github.com/derailed/tview v0.6.3/go.mod h1:j2GwRsCb3NZe7lRjKIeplvZLkg8duyNWG6I4y+bZwEE= github.com/dgrijalva/jwt-go v0.0.0-20170104182250-a601269ab70c/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= @@ -1144,7 +1144,6 @@ golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210415045647-66c3f260301c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210426230700-d19ff857e887/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/internal/client/client.go b/internal/client/client.go index 2b6f4f40..c3eb54ec 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -63,7 +63,7 @@ func InitConnection(config *Config) (*APIClient, error) { } err := a.supportsMetricsResources() if err != nil { - log.Error().Err(err).Msgf("Checking metrics-server") + log.Error().Err(err).Msgf("Fail to locate metrics-server") } if errors.Is(err, noMetricServerErr) || errors.Is(err, metricsUnsupportedErr) { return &a, nil @@ -120,11 +120,11 @@ func (a *APIClient) IsActiveNamespace(ns string) bool { // ActiveNamespace returns the current namespace. func (a *APIClient) ActiveNamespace() string { - ns, err := a.CurrentNamespaceName() - if err != nil { - return AllNamespaces + if ns, err := a.CurrentNamespaceName(); err == nil { + return ns } - return ns + + return AllNamespaces } func (a *APIClient) clearCache() { @@ -261,7 +261,7 @@ func (a *APIClient) CheckConnectivity() bool { a.reset() } } else { - log.Error().Err(err).Msgf("K9s can't connect to cluster") + log.Error().Err(err).Msgf("can't connect to cluster") a.connOK = false } @@ -301,11 +301,7 @@ func (a *APIClient) Dial() (kubernetes.Interface, error) { // RestConfig returns a rest api client. func (a *APIClient) RestConfig() (*restclient.Config, error) { - cfg, err := a.config.RESTConfig() - if err != nil { - return nil, err - } - return cfg, nil + return a.config.RESTConfig() } // CachedDiscovery returns a cached discovery client. @@ -430,7 +426,6 @@ func (a *APIClient) supportsMetricsResources() error { } apiGroups, err := dial.ServerGroups() if err != nil { - log.Warn().Err(err).Msgf("Unable to fetch APIGroups") return err } for _, grp := range apiGroups.Groups { diff --git a/internal/client/config.go b/internal/client/config.go index 230244ac..251d40db 100644 --- a/internal/client/config.go +++ b/internal/client/config.go @@ -15,32 +15,30 @@ import ( ) const ( - defaultQPS = 50 - defaultBurst = 50 defaultCallTimeoutDuration time.Duration = 5 * time.Second ) // Config tracks a kubernetes configuration. type Config struct { - flags *genericclioptions.ConfigFlags - clientConfig clientcmd.ClientConfig - rawConfig *clientcmdapi.Config - restConfig *restclient.Config - mutex *sync.RWMutex - OverrideNS bool + flags *genericclioptions.ConfigFlags + clientCfg clientcmd.ClientConfig + rawCfg *clientcmdapi.Config + mutex *sync.RWMutex + OverrideNS bool } // NewConfig returns a new k8s config or an error if the flags are invalid. func NewConfig(f *genericclioptions.ConfigFlags) *Config { return &Config{ flags: f, + // pathOptions: clientcmd.NewDefaultPathOptions(), mutex: &sync.RWMutex{}, } } // CallTimeout returns the call timeout if set or the default if not set. func (c *Config) CallTimeout() time.Duration { - if c.flags.Timeout == nil { + if !isSet(c.flags.Timeout) { return defaultCallTimeoutDuration } dur, err := time.ParseDuration(*c.flags.Timeout) @@ -51,28 +49,63 @@ func (c *Config) CallTimeout() time.Duration { return dur } +func (c *Config) RESTConfig() (*restclient.Config, error) { + return c.clientConfig().ClientConfig() +} + // Flags returns configuration flags. func (c *Config) Flags() *genericclioptions.ConfigFlags { return c.flags } -// SwitchContext changes the kubeconfig context to a new cluster. -func (c *Config) SwitchContext(name string) error { - if c.flags.Context != nil && *c.flags.Context == name { - return nil +func (c *Config) rawConfig() (*clientcmdapi.Config, error) { + if c.rawCfg != nil { + return c.rawCfg, nil } - if _, err := c.GetContext(name); err != nil { + cfg, err := c.clientConfig().RawConfig() + if err != nil { + return nil, err + } + c.rawCfg = &cfg + + return c.rawCfg, nil +} + +func (c *Config) clientConfig() clientcmd.ClientConfig { + if c.clientCfg != nil { + return c.clientCfg + } + c.clientCfg = c.flags.ToRawKubeConfigLoader() + + return c.clientCfg +} + +func (c *Config) reset() { + c.clientCfg = nil +} + +// SwitchContext changes the kubeconfig context to a new cluster. +func (c *Config) SwitchContext(name string) error { + cfg, err := c.rawConfig() + if err != nil { + return err + } + if cfg.CurrentContext == name { + return nil + } + context, err := c.GetContext(name) + if err != nil { return fmt.Errorf("context %s does not exist", name) } - c.reset() c.flags.Context = &name + c.flags.ClusterName = &(context.Cluster) return nil } -func (c *Config) reset() { - c.clientConfig, c.rawConfig, c.restConfig = nil, nil, nil +func (c *Config) RawConfig() *clientcmdapi.Config { + return c.rawCfg } // CurrentContextName returns the currently active config context. @@ -80,17 +113,17 @@ func (c *Config) CurrentContextName() (string, error) { if isSet(c.flags.Context) { return *c.flags.Context, nil } - - cfg, err := c.RawConfig() + cfg, err := c.rawConfig() if err != nil { return "", err } + return cfg.CurrentContext, nil } // GetContext fetch a given context or error if it does not exists. func (c *Config) GetContext(n string) (*clientcmdapi.Context, error) { - cfg, err := c.RawConfig() + cfg, err := c.rawConfig() if err != nil { return nil, err } @@ -103,7 +136,7 @@ func (c *Config) GetContext(n string) (*clientcmdapi.Context, error) { // Contexts fetch all available contexts. func (c *Config) Contexts() (map[string]*clientcmdapi.Context, error) { - cfg, err := c.RawConfig() + cfg, err := c.rawConfig() if err != nil { return nil, err } @@ -113,18 +146,23 @@ func (c *Config) Contexts() (map[string]*clientcmdapi.Context, error) { // DelContext remove a given context from the configuration. func (c *Config) DelContext(n string) error { - cfg, err := c.RawConfig() + cfg, err := c.rawConfig() if err != nil { return err } delete(cfg.Contexts, n) - return clientcmd.ModifyConfig(c.clientConfig.ConfigAccess(), cfg, true) + acc, err := c.ConfigAccess() + if err != nil { + return err + } + + return clientcmd.ModifyConfig(acc, *cfg, true) } // ContextNames fetch all available contexts. func (c *Config) ContextNames() ([]string, error) { - cfg, err := c.RawConfig() + cfg, err := c.rawConfig() if err != nil { return nil, err } @@ -137,16 +175,16 @@ func (c *Config) ContextNames() ([]string, error) { } // ClusterNameFromContext returns the cluster associated with the given context. -func (c *Config) ClusterNameFromContext(ctx string) (string, error) { - cfg, err := c.RawConfig() +func (c *Config) ClusterNameFromContext(context string) (string, error) { + cfg, err := c.rawConfig() if err != nil { return "", err } - if ctx, ok := cfg.Contexts[ctx]; ok { + if ctx, ok := cfg.Contexts[context]; ok { return ctx.Cluster, nil } - return "", fmt.Errorf("unable to locate cluster from context %s", ctx) + return "", fmt.Errorf("unable to locate cluster from context %s", context) } // CurrentClusterName returns the active cluster name. @@ -154,16 +192,11 @@ func (c *Config) CurrentClusterName() (string, error) { if isSet(c.flags.ClusterName) { return *c.flags.ClusterName, nil } - - cfg, err := c.RawConfig() + cfg, err := c.rawConfig() if err != nil { return "", err } - current := cfg.CurrentContext - if isSet(c.flags.Context) { - current = *c.flags.Context - } if ctx, ok := cfg.Contexts[current]; ok { return ctx.Cluster, nil @@ -174,7 +207,7 @@ func (c *Config) CurrentClusterName() (string, error) { // ClusterNames fetch all kubeconfig defined clusters. func (c *Config) ClusterNames() ([]string, error) { - cfg, err := c.RawConfig() + cfg, err := c.rawConfig() if err != nil { return nil, err } @@ -224,7 +257,7 @@ func (c *Config) CurrentUserName() (string, error) { return *c.flags.AuthInfoName, nil } - cfg, err := c.RawConfig() + cfg, err := c.rawConfig() if err != nil { return "", err } @@ -242,25 +275,9 @@ func (c *Config) CurrentUserName() (string, error) { // CurrentNamespaceName retrieves the active namespace. func (c *Config) CurrentNamespaceName() (string, error) { - if isSet(c.flags.Namespace) { - return *c.flags.Namespace, nil - } + ns, _, err := c.clientConfig().Namespace() - cfg, err := c.RawConfig() - if err != nil { - return "", err - } - ctx, err := c.CurrentContextName() - if err != nil { - return "", err - } - if ctx, ok := cfg.Contexts[ctx]; ok { - if isSet(&ctx.Namespace) { - return ctx.Namespace, nil - } - } - - return "", fmt.Errorf("No active namespace specified") + return ns, err } // NamespaceNames fetch all available namespaces on current cluster. @@ -278,51 +295,7 @@ func (c *Config) ConfigAccess() (clientcmd.ConfigAccess, error) { c.mutex.RLock() defer c.mutex.RUnlock() - c.ensureConfig() - return c.clientConfig.ConfigAccess(), nil -} - -// RawConfig fetch the current kubeconfig with no overrides. -func (c *Config) RawConfig() (clientcmdapi.Config, error) { - c.mutex.Lock() - defer c.mutex.Unlock() - - if c.rawConfig == nil { - c.ensureConfig() - cfg, err := c.clientConfig.RawConfig() - if err != nil { - return cfg, err - } - c.rawConfig = &cfg - if c.flags.Context == nil { - c.flags.Context = &c.rawConfig.CurrentContext - } - } - - return *c.rawConfig, nil -} - -// RESTConfig fetch the current REST api service connection. -func (c *Config) RESTConfig() (*restclient.Config, error) { - if c.restConfig != nil { - return c.restConfig, nil - } - - var err error - if c.restConfig, err = c.flags.ToRESTConfig(); err != nil { - return nil, err - } - c.restConfig.QPS = defaultQPS - c.restConfig.Burst = defaultBurst - - return c.restConfig, nil -} - -func (c *Config) ensureConfig() { - if c.clientConfig != nil { - return - } - c.clientConfig = c.flags.ToRawKubeConfigLoader() + return c.clientConfig().ConfigAccess(), nil } // ---------------------------------------------------------------------------- diff --git a/internal/client/config_test.go b/internal/client/config_test.go index 46137574..bb00208c 100644 --- a/internal/client/config_test.go +++ b/internal/client/config_test.go @@ -2,7 +2,6 @@ package client_test import ( "errors" - "fmt" "testing" "github.com/derailed/k9s/internal/client" @@ -18,98 +17,151 @@ func init() { } func TestConfigCurrentContext(t *testing.T) { - name, kubeConfig := "blee", "./testdata/config" - uu := []struct { - flags *genericclioptions.ConfigFlags + var kubeConfig = "./testdata/config" + + uu := map[string]struct { context string + e string }{ - {&genericclioptions.ConfigFlags{KubeConfig: &kubeConfig}, "fred"}, - {&genericclioptions.ConfigFlags{KubeConfig: &kubeConfig, Context: &name}, "blee"}, + "default": { + e: "fred", + }, + "custom": { + context: "blee", + e: "blee", + }, } - for _, u := range uu { - cfg := client.NewConfig(u.flags) - ctx, err := cfg.CurrentContextName() - assert.Nil(t, err) - assert.Equal(t, u.context, ctx) + for k := range uu { + u := uu[k] + t.Run(k, func(t *testing.T) { + flags := genericclioptions.NewConfigFlags(false) + flags.KubeConfig = &kubeConfig + if u.context != "" { + flags.Context = &u.context + } + cfg := client.NewConfig(flags) + ctx, err := cfg.CurrentContextName() + assert.Nil(t, err) + assert.Equal(t, u.e, ctx) + }) } } func TestConfigCurrentCluster(t *testing.T) { name, kubeConfig := "blee", "./testdata/config" - uu := []struct { + uu := map[string]struct { flags *genericclioptions.ConfigFlags cluster string }{ - {&genericclioptions.ConfigFlags{KubeConfig: &kubeConfig}, "fred"}, - {&genericclioptions.ConfigFlags{KubeConfig: &kubeConfig, ClusterName: &name}, "blee"}, + "default": { + flags: &genericclioptions.ConfigFlags{KubeConfig: &kubeConfig}, + cluster: "fred", + }, + "custom": { + flags: &genericclioptions.ConfigFlags{KubeConfig: &kubeConfig, ClusterName: &name}, + cluster: "blee", + }, } - for _, u := range uu { - cfg := client.NewConfig(u.flags) - ctx, err := cfg.CurrentClusterName() - assert.Nil(t, err) - assert.Equal(t, u.cluster, ctx) + for k := range uu { + u := uu[k] + t.Run(k, func(t *testing.T) { + cfg := client.NewConfig(u.flags) + ctx, err := cfg.CurrentClusterName() + assert.Nil(t, err) + assert.Equal(t, u.cluster, ctx) + }) } } func TestConfigCurrentUser(t *testing.T) { name, kubeConfig := "blee", "./testdata/config" - uu := []struct { + uu := map[string]struct { flags *genericclioptions.ConfigFlags user string }{ - {&genericclioptions.ConfigFlags{KubeConfig: &kubeConfig}, "fred"}, - {&genericclioptions.ConfigFlags{KubeConfig: &kubeConfig, AuthInfoName: &name}, "blee"}, + "default": { + flags: &genericclioptions.ConfigFlags{KubeConfig: &kubeConfig}, + user: "fred", + }, + "custom": { + flags: &genericclioptions.ConfigFlags{KubeConfig: &kubeConfig, AuthInfoName: &name}, + user: "blee", + }, } - for _, u := range uu { - cfg := client.NewConfig(u.flags) - ctx, err := cfg.CurrentUserName() - assert.Nil(t, err) - assert.Equal(t, u.user, ctx) + for k := range uu { + u := uu[k] + t.Run(k, func(t *testing.T) { + cfg := client.NewConfig(u.flags) + ctx, err := cfg.CurrentUserName() + assert.Nil(t, err) + assert.Equal(t, u.user, ctx) + }) } } func TestConfigCurrentNamespace(t *testing.T) { - name, kubeConfig := "blee", "./testdata/config" - uu := []struct { + kubeConfig := "./testdata/config" + bleeNS, bleeCTX := "blee", "blee" + uu := map[string]struct { flags *genericclioptions.ConfigFlags namespace string - err error }{ - {&genericclioptions.ConfigFlags{KubeConfig: &kubeConfig}, "", fmt.Errorf("No active namespace specified")}, - {&genericclioptions.ConfigFlags{KubeConfig: &kubeConfig, Namespace: &name}, "blee", nil}, + "default": { + flags: &genericclioptions.ConfigFlags{KubeConfig: &kubeConfig}, + namespace: "default", + }, + "withContext": { + flags: &genericclioptions.ConfigFlags{KubeConfig: &kubeConfig, Context: &bleeCTX}, + namespace: "zorg", + }, + "withNS": { + flags: &genericclioptions.ConfigFlags{KubeConfig: &kubeConfig, Namespace: &bleeNS}, + namespace: "blee", + }, } - for _, u := range uu { - cfg := client.NewConfig(u.flags) - ns, err := cfg.CurrentNamespaceName() - assert.Equal(t, u.err, err) - assert.Equal(t, u.namespace, ns) + for k := range uu { + u := uu[k] + t.Run(k, func(t *testing.T) { + cfg := client.NewConfig(u.flags) + ns, err := cfg.CurrentNamespaceName() + assert.Nil(t, err) + assert.Equal(t, u.namespace, ns) + }) } } func TestConfigGetContext(t *testing.T) { kubeConfig := "./testdata/config" - uu := []struct { - flags *genericclioptions.ConfigFlags + uu := map[string]struct { cluster string err error }{ - {&genericclioptions.ConfigFlags{KubeConfig: &kubeConfig}, "blee", nil}, - {&genericclioptions.ConfigFlags{KubeConfig: &kubeConfig}, "bozo", errors.New("invalid context `bozo specified")}, + "default": { + cluster: "blee", + }, + "custom": { + cluster: "bozo", + err: errors.New("invalid context `bozo specified"), + }, } - for _, u := range uu { - cfg := client.NewConfig(u.flags) - ctx, err := cfg.GetContext(u.cluster) - if err != nil { - assert.Equal(t, u.err, err) - } else { - assert.NotNil(t, ctx) - assert.Equal(t, u.cluster, ctx.Cluster) - } + flags := &genericclioptions.ConfigFlags{KubeConfig: &kubeConfig} + cfg := client.NewConfig(flags) + for k := range uu { + u := uu[k] + t.Run(k, func(t *testing.T) { + ctx, err := cfg.GetContext(u.cluster) + if err != nil { + assert.Equal(t, u.err, err) + } else { + assert.NotNil(t, ctx) + assert.Equal(t, u.cluster, ctx.Cluster) + } + }) } } @@ -205,7 +257,8 @@ func TestConfigDelContext(t *testing.T) { assert.Nil(t, err) cc, err := cfg.ContextNames() assert.Nil(t, err) - assert.Equal(t, 2, len(cc)) + assert.Equal(t, 1, len(cc)) + assert.Equal(t, "blee", cc[0]) } func TestConfigRestConfig(t *testing.T) { diff --git a/internal/client/testdata/config b/internal/client/testdata/config index 5541a687..88e0a0e8 100644 --- a/internal/client/testdata/config +++ b/internal/client/testdata/config @@ -2,42 +2,43 @@ apiVersion: v1 kind: Config preferences: {} clusters: -- cluster: - insecure-skip-tls-verify: true - server: https://localhost:3000 - name: fred -- cluster: - insecure-skip-tls-verify: true - server: https://localhost:3001 - name: blee -- cluster: - insecure-skip-tls-verify: true - server: https://localhost:3002 - name: duh + - cluster: + insecure-skip-tls-verify: true + server: https://localhost:3000 + name: fred + - cluster: + insecure-skip-tls-verify: true + server: https://localhost:3001 + name: blee + - cluster: + insecure-skip-tls-verify: true + server: https://localhost:3002 + name: duh contexts: -- context: - cluster: fred - user: fred - name: fred -- context: - cluster: blee - user: blee - name: blee -- context: - cluster: duh - user: duh - name: duh + - context: + cluster: fred + user: fred + name: fred + - context: + cluster: blee + user: blee + namespace: zorg + name: blee + - context: + cluster: duh + user: duh + name: duh current-context: fred users: -- name: fred - user: - client-certificate-data: ZnJlZA== - client-key-data: ZnJlZA== -- name: blee - user: - client-certificate-data: ZnJlZA== - client-key-data: ZnJlZA== -- name: duh - user: - client-certificate-data: ZnJlZA== - client-key-data: ZnJlZA== + - name: fred + user: + client-certificate-data: ZnJlZA== + client-key-data: ZnJlZA== + - name: blee + user: + client-certificate-data: ZnJlZA== + client-key-data: ZnJlZA== + - name: duh + user: + client-certificate-data: ZnJlZA== + client-key-data: ZnJlZA== diff --git a/internal/client/testdata/config.1 b/internal/client/testdata/config.1 index 9c2ff1e3..640d0ef2 100644 --- a/internal/client/testdata/config.1 +++ b/internal/client/testdata/config.1 @@ -7,33 +7,13 @@ clusters: - cluster: insecure-skip-tls-verify: true server: https://localhost:3002 - name: duh -- cluster: - insecure-skip-tls-verify: true - server: https://localhost:3000 name: fred contexts: - context: cluster: blee user: blee name: blee -- context: - cluster: duh - user: duh - name: duh -current-context: fred +current-context: blee kind: Config preferences: {} -users: -- name: blee - user: - client-certificate-data: ZnJlZA== - client-key-data: ZnJlZA== -- name: duh - user: - client-certificate-data: ZnJlZA== - client-key-data: ZnJlZA== -- name: fred - user: - client-certificate-data: ZnJlZA== - client-key-data: ZnJlZA== +users: null diff --git a/internal/client/tunnel.go b/internal/client/tunnel.go deleted file mode 100644 index 2eba5a37..00000000 --- a/internal/client/tunnel.go +++ /dev/null @@ -1,11 +0,0 @@ -package client - -// PortTunnel represents a host tunnel port mapper. -type PortTunnel struct { - Address, LocalPort, ContainerPort string -} - -// PortMap returns a port mapping. -func (t PortTunnel) PortMap() string { - return t.LocalPort + ":" + t.ContainerPort -} diff --git a/internal/config/alias.go b/internal/config/alias.go index a06b0c7f..8ded5654 100644 --- a/internal/config/alias.go +++ b/internal/config/alias.go @@ -1,7 +1,7 @@ package config import ( - "io/ioutil" + "os" "path/filepath" "sync" @@ -105,7 +105,7 @@ func (a *Aliases) Load() error { // LoadFileAliases loads alias from a given file. func (a *Aliases) LoadFileAliases(path string) error { - f, err := ioutil.ReadFile(path) + f, err := os.ReadFile(path) if err == nil { var aa Aliases if err := yaml.Unmarshal(f, &aa); err != nil { @@ -171,5 +171,5 @@ func (a *Aliases) SaveAliases(path string) error { if err != nil { return err } - return ioutil.WriteFile(path, cfg, 0644) + return os.WriteFile(path, cfg, 0644) } diff --git a/internal/config/bench.go b/internal/config/bench.go index 40999d6d..c3f6c4c9 100644 --- a/internal/config/bench.go +++ b/internal/config/bench.go @@ -1,8 +1,8 @@ package config import ( - "io/ioutil" "net/http" + "os" "gopkg.in/yaml.v2" ) @@ -96,7 +96,7 @@ func (s *Bench) Reload(path string) error { // Load K9s benchmark configs from file. func (s *Bench) load(path string) error { - f, err := ioutil.ReadFile(path) + f, err := os.ReadFile(path) if err != nil { return err } diff --git a/internal/config/config.go b/internal/config/config.go index 0118da7f..10a52708 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -3,8 +3,8 @@ package config import ( "errors" "fmt" - "io/ioutil" "os" + "path" "path/filepath" "github.com/adrg/xdg" @@ -60,6 +60,15 @@ func K9sHome() string { if env := os.Getenv(K9sConfig); env != "" { return env } + if env := os.Getenv("XDG_CONFIG_HOME"); env == "" { + dir, err := os.UserHomeDir() + if err != nil { + log.Error().Err(err).Msgf("user home dir") + return "" + } + return path.Join(dir, ".config", "k9s") + } + xdgK9sHome, err := xdg.ConfigFile("k9s") if err != nil { log.Fatal().Err(err).Msg("Unable to create configuration directory for k9s") @@ -74,21 +83,25 @@ func NewConfig(ks KubeSettings) *Config { } // Refine the configuration based on cli args. -func (c *Config) Refine(flags *genericclioptions.ConfigFlags, k9sFlags *Flags) error { - cfg, err := flags.ToRawKubeConfigLoader().RawConfig() - if err != nil { - return err - } +func (c *Config) Refine(flags *genericclioptions.ConfigFlags, k9sFlags *Flags, cfg *client.Config) error { if isSet(flags.Context) { c.K9s.CurrentContext = *flags.Context } else { - c.K9s.CurrentContext = cfg.CurrentContext + context, err := cfg.CurrentContextName() + if err != nil { + return err + } + c.K9s.CurrentContext = context } log.Debug().Msgf("Active Context %q", c.K9s.CurrentContext) if c.K9s.CurrentContext == "" { return errors.New("Invalid kubeconfig context detected") } - context, ok := cfg.Contexts[c.K9s.CurrentContext] + cc, err := cfg.Contexts() + if err != nil { + return err + } + context, ok := cc[c.K9s.CurrentContext] if !ok { return fmt.Errorf("The specified context %q does not exists in kubeconfig", c.K9s.CurrentContext) } @@ -218,7 +231,7 @@ func (c *Config) SetConnection(conn client.Connection) { // Load K9s configuration from file. func (c *Config) Load(path string) error { - f, err := ioutil.ReadFile(path) + f, err := os.ReadFile(path) if err != nil { return err } @@ -252,7 +265,7 @@ func (c *Config) SaveFile(path string) error { log.Error().Msgf("[Config] Unable to save K9s config file: %v", err) return err } - return ioutil.WriteFile(path, cfg, 0644) + return os.WriteFile(path, cfg, 0644) } // Validate the configuration. diff --git a/internal/config/config_test.go b/internal/config/config_test.go index eb6ec179..f840f6c0 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -2,10 +2,11 @@ package config_test import ( "fmt" - "io/ioutil" + "os" "path/filepath" "testing" + "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/config" m "github.com/petergtz/pegomock" "github.com/rs/zerolog" @@ -63,7 +64,7 @@ func TestConfigRefine(t *testing.T) { m.When(mk.NamespaceNames(namespaces())).ThenReturn([]string{"default"}) cfg := config.NewConfig(mk) - err := cfg.Refine(u.flags, nil) + err := cfg.Refine(u.flags, nil, client.NewConfig(u.flags)) if u.issue { assert.NotNil(t, err) } else { @@ -87,7 +88,6 @@ func TestConfigValidate(t *testing.T) { cfg.SetConnection(mc) assert.Nil(t, cfg.Load("testdata/k9s.yml")) cfg.Validate() - // mc.VerifyWasCalledOnce().ValidNamespaces() } func TestConfigLoad(t *testing.T) { @@ -216,7 +216,7 @@ func TestConfigSaveFile(t *testing.T) { err := cfg.SaveFile(path) assert.Nil(t, err) - raw, err := ioutil.ReadFile(path) + raw, err := os.ReadFile(path) assert.Nil(t, err) assert.Equal(t, expectedConfig, string(raw)) } @@ -242,7 +242,7 @@ func TestConfigReset(t *testing.T) { err := cfg.SaveFile(path) assert.Nil(t, err) - raw, err := ioutil.ReadFile(path) + raw, err := os.ReadFile(path) assert.Nil(t, err) assert.Equal(t, resetConfig, string(raw)) } diff --git a/internal/config/hotkey.go b/internal/config/hotkey.go index 5a6988ea..707bc0c7 100644 --- a/internal/config/hotkey.go +++ b/internal/config/hotkey.go @@ -1,7 +1,7 @@ package config import ( - "io/ioutil" + "os" "path/filepath" "gopkg.in/yaml.v2" @@ -36,7 +36,7 @@ func (h HotKeys) Load() error { // LoadHotKeys loads plugins from a given file. func (h HotKeys) LoadHotKeys(path string) error { - f, err := ioutil.ReadFile(path) + f, err := os.ReadFile(path) if err != nil { return err } diff --git a/internal/config/plugin.go b/internal/config/plugin.go index f4e3a0a4..2e1fbe39 100644 --- a/internal/config/plugin.go +++ b/internal/config/plugin.go @@ -1,8 +1,10 @@ package config import ( - "io/ioutil" + "fmt" + "os" "path/filepath" + "strings" "gopkg.in/yaml.v2" ) @@ -20,12 +22,17 @@ type Plugin struct { Scopes []string `yaml:"scopes"` Args []string `yaml:"args"` ShortCut string `yaml:"shortCut"` + Pipes []string `yaml:"pipes"` Description string `yaml:"description"` Command string `yaml:"command"` Confirm bool `yaml:"confirm"` Background bool `yaml:"background"` } +func (p Plugin) String() string { + return fmt.Sprintf("[%s] %s(%s)", p.ShortCut, p.Command, strings.Join(p.Args, " ")) +} + // NewPlugins returns a new plugin. func NewPlugins() Plugins { return Plugins{ @@ -40,7 +47,7 @@ func (p Plugins) Load() error { // LoadPlugins loads plugins from a given file. func (p Plugins) LoadPlugins(path string) error { - f, err := ioutil.ReadFile(path) + f, err := os.ReadFile(path) if err != nil { return err } diff --git a/internal/config/styles.go b/internal/config/styles.go index e1ca257b..4a856e39 100644 --- a/internal/config/styles.go +++ b/internal/config/styles.go @@ -2,7 +2,7 @@ package config import ( "fmt" - "io/ioutil" + "os" "path/filepath" "github.com/derailed/tview" @@ -541,7 +541,7 @@ func (s *Styles) Views() Views { // Load K9s configuration from file. func (s *Styles) Load(path string) error { - f, err := ioutil.ReadFile(path) + f, err := os.ReadFile(path) if err != nil { return err } diff --git a/internal/config/views.go b/internal/config/views.go index 78664f1a..0d2a8407 100644 --- a/internal/config/views.go +++ b/internal/config/views.go @@ -1,7 +1,7 @@ package config import ( - "io/ioutil" + "os" "path/filepath" "gopkg.in/yaml.v2" @@ -56,7 +56,7 @@ func (v *CustomView) Reset() { // Load loads view configurations. func (v *CustomView) Load(path string) error { - raw, err := ioutil.ReadFile(path) + raw, err := os.ReadFile(path) if err != nil { return err } diff --git a/internal/dao/benchmark.go b/internal/dao/benchmark.go index 0c25af78..65f85a8e 100644 --- a/internal/dao/benchmark.go +++ b/internal/dao/benchmark.go @@ -3,7 +3,6 @@ package dao import ( "context" "errors" - "io/ioutil" "os" "path/filepath" "strings" @@ -41,7 +40,7 @@ func (b *Benchmark) List(ctx context.Context, _ string) ([]runtime.Object, error } path, _ := ctx.Value(internal.KeyPath).(string) - ff, err := ioutil.ReadDir(dir) + ff, err := os.ReadDir(dir) if err != nil { return nil, err } @@ -51,7 +50,10 @@ func (b *Benchmark) List(ctx context.Context, _ string) ([]runtime.Object, error if path != "" && !strings.HasPrefix(f.Name(), strings.Replace(path, "/", "_", 1)) { continue } - oo = append(oo, render.BenchInfo{File: f, Path: filepath.Join(dir, f.Name())}) + + if fi, err := f.Info(); err == nil { + oo = append(oo, render.BenchInfo{File: fi, Path: filepath.Join(dir, f.Name())}) + } } return oo, nil diff --git a/internal/dao/context.go b/internal/dao/context.go index 50cad101..47ec9bb8 100644 --- a/internal/dao/context.go +++ b/internal/dao/context.go @@ -2,6 +2,7 @@ package dao import ( "context" + "errors" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/render" @@ -63,14 +64,14 @@ func (c *Context) Switch(ctx string) error { // KubeUpdate modifies kubeconfig default context. func (c *Context) KubeUpdate(n string) error { - config, err := c.config().RawConfig() - if err != nil { - return err + cfg := c.config().RawConfig() + if cfg == nil { + return errors.New("unable to fetch raw config") } if err := c.Switch(n); err != nil { return err } return clientcmd.ModifyConfig( - clientcmd.NewDefaultPathOptions(), config, true, + clientcmd.NewDefaultPathOptions(), *cfg, true, ) } diff --git a/internal/dao/cruiser_test.go b/internal/dao/cruiser_test.go index 8574d8a4..da8769e7 100644 --- a/internal/dao/cruiser_test.go +++ b/internal/dao/cruiser_test.go @@ -3,7 +3,7 @@ package dao import ( "encoding/json" "fmt" - "io/ioutil" + "os" "testing" "github.com/stretchr/testify/assert" @@ -29,7 +29,7 @@ func TestCruiserSlice(t *testing.T) { // Helpers... func loadJSON(t assert.TestingT, n string) *unstructured.Unstructured { - raw, err := ioutil.ReadFile(fmt.Sprintf("testdata/%s.json", n)) + raw, err := os.ReadFile(fmt.Sprintf("testdata/%s.json", n)) assert.Nil(t, err) var o unstructured.Unstructured diff --git a/internal/dao/dir.go b/internal/dao/dir.go index d395c365..b5a3a456 100644 --- a/internal/dao/dir.go +++ b/internal/dao/dir.go @@ -3,7 +3,7 @@ package dao import ( "context" "errors" - "io/ioutil" + "os" "path/filepath" "regexp" "strings" @@ -37,7 +37,7 @@ func (a *Dir) List(ctx context.Context, _ string) ([]runtime.Object, error) { return nil, errors.New("No dir in context") } - files, err := ioutil.ReadDir(dir) + files, err := os.ReadDir(dir) if err != nil { return nil, err } @@ -48,8 +48,8 @@ func (a *Dir) List(ctx context.Context, _ string) ([]runtime.Object, error) { continue } oo = append(oo, render.DirRes{ - Path: filepath.Join(dir, f.Name()), - Info: f, + Path: filepath.Join(dir, f.Name()), + Entry: f, }) } diff --git a/internal/dao/dp.go b/internal/dao/dp.go index 8138ce55..90dd4ba0 100644 --- a/internal/dao/dp.go +++ b/internal/dao/dp.go @@ -69,20 +69,20 @@ func (d *Deployment) Restart(ctx context.Context, path string) error { return err } - ns, _ := client.Namespaced(path) - auth, err := d.Client().CanI(ns, "apps/v1/deployments", []string{client.PatchVerb}) + auth, err := d.Client().CanI(dp.Namespace, "apps/v1/deployments", []string{client.PatchVerb}) if err != nil { return err } if !auth { return fmt.Errorf("user is not authorized to restart a deployment") } - update, err := polymorphichelpers.ObjectRestarterFn(dp) + + dial, err := d.Client().Dial() if err != nil { return err } - dial, err := d.Client().Dial() + restarter, err := polymorphichelpers.ObjectRestarterFn(dp) if err != nil { return err } @@ -91,7 +91,7 @@ func (d *Deployment) Restart(ctx context.Context, path string) error { ctx, dp.Name, types.StrategicMergePatchType, - update, + restarter, metav1.PatchOptions{}, ) return err diff --git a/internal/dao/ds.go b/internal/dao/ds.go index d46d4f88..761facea 100644 --- a/internal/dao/ds.go +++ b/internal/dao/ds.go @@ -86,7 +86,7 @@ func (d *DaemonSet) TailLogs(ctx context.Context, c LogChan, opts *LogOptions) e return podLogs(ctx, c, ds.Spec.Selector.MatchLabels, opts) } -func podLogs(ctx context.Context, c LogChan, sel map[string]string, opts *LogOptions) error { +func podLogs(ctx context.Context, out LogChan, sel map[string]string, opts *LogOptions) error { f, ok := ctx.Value(internal.KeyFactory).(*watch.Factory) if !ok { return errors.New("expecting a context factory") @@ -110,14 +110,13 @@ func podLogs(ctx context.Context, c LogChan, sel map[string]string, opts *LogOpt po := Pod{} po.Init(f, client.NewGVR("v1/pods")) for _, o := range oo { - var pod v1.Pod - err := runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, &pod) - if err != nil { - return err + u, ok := o.(*unstructured.Unstructured) + if !ok { + return fmt.Errorf("expected unstructured got %t", o) } opts = opts.Clone() - opts.Path = client.FQN(pod.Namespace, pod.Name) - if err := po.TailLogs(ctx, c, opts); err != nil { + opts.Path = client.FQN(u.GetNamespace(), u.GetName()) + if err := po.TailLogs(ctx, out, opts); err != nil { return err } } diff --git a/internal/dao/helm.go b/internal/dao/helm.go index 78f10260..0845e7b8 100644 --- a/internal/dao/helm.go +++ b/internal/dao/helm.go @@ -111,8 +111,7 @@ func (c *Helm) Delete(path string, cascade, force bool) error { // EnsureHelmConfig return a new configuration. func (c *Helm) EnsureHelmConfig(ns string) (*action.Configuration, error) { cfg := new(action.Configuration) - flags := c.Client().Config().Flags() - if err := cfg.Init(flags, ns, os.Getenv("HELM_DRIVER"), helmLogger); err != nil { + if err := cfg.Init(c.Client().Config().Flags(), ns, os.Getenv("HELM_DRIVER"), helmLogger); err != nil { return nil, err } return cfg, nil diff --git a/internal/dao/log_item.go b/internal/dao/log_item.go index 7242b265..0193f2f3 100644 --- a/internal/dao/log_item.go +++ b/internal/dao/log_item.go @@ -2,39 +2,31 @@ package dao import ( "bytes" - "fmt" - "regexp" - "time" - - "github.com/derailed/k9s/internal/color" ) // LogChan represents a channel for logs. type LogChan chan *LogItem +var ItemEOF = new(LogItem) + // LogItem represents a container log line. type LogItem struct { - Pod, Container, Timestamp string - SingleContainer bool - Bytes []byte + Pod, Container string + SingleContainer bool + Bytes []byte } // NewLogItem returns a new item. -func NewLogItem(b []byte) *LogItem { - space := []byte(" ") - cols := bytes.Split(b[:len(b)-1], space) - +func NewLogItem(bb []byte) *LogItem { return &LogItem{ - Timestamp: string(cols[0]), - Bytes: bytes.Join(cols[1:], space), + Bytes: bb, } } // NewLogItemFromString returns a new item. func NewLogItemFromString(s string) *LogItem { return &LogItem{ - Bytes: []byte(s), - Timestamp: time.Now().String(), + Bytes: []byte(s), } } @@ -46,22 +38,18 @@ func (l *LogItem) ID() string { return l.Container } -// Clone copies an item. -func (l *LogItem) Clone() *LogItem { - bytes := make([]byte, len(l.Bytes)) - copy(bytes, l.Bytes) - return &LogItem{ - Container: l.Container, - Pod: l.Pod, - Timestamp: l.Timestamp, - SingleContainer: l.SingleContainer, - Bytes: bytes, +// GetTimestamp fetch log lime timestamp +func (l *LogItem) GetTimestamp() string { + index := bytes.Index(l.Bytes, []byte{' '}) + if index < 0 { + return "" } + return string(l.Bytes[:index]) } // Info returns pod and container information. func (l *LogItem) Info() string { - return fmt.Sprintf("%q::%q", l.Pod, l.Container) + return l.Pod + "::" + l.Container } // IsEmpty checks if the entry is empty. @@ -69,37 +57,39 @@ func (l *LogItem) IsEmpty() bool { return len(l.Bytes) == 0 } -var ( - escPattern = regexp.MustCompile(`(\[[a-zA-Z0-9_,;: \-\."#]+\[*)\]`) - matcher = []byte("$1[]") -) +// Size returns the size of the item. +func (l *LogItem) Size() int { + return 100 + len(l.Bytes) + len(l.Pod) + len(l.Container) +} // Render returns a log line as string. -func (l *LogItem) Render(paint int, showTime bool) []byte { - bb := make([]byte, 0, 200) - if showTime { - t := l.Timestamp - for i := len(t); i < 30; i++ { - t += " " +func (l *LogItem) Render(paint string, showTime bool, bb *bytes.Buffer) { + index := bytes.Index(l.Bytes, []byte{' '}) + if showTime && index > 0 { + bb.WriteString("[gray::]") + bb.Write(l.Bytes[:index]) + bb.WriteString(" ") + for i := len(l.Bytes[:index]); i < 30; i++ { + bb.WriteByte(' ') } - bb = append(bb, color.ANSIColorize(t, 106)...) - bb = append(bb, ' ') } - var hasPod bool if l.Pod != "" { - bb = append(bb, color.ANSIColorize(l.Pod, paint)...) - hasPod = true - } - if !l.SingleContainer && l.Container != "" { - if hasPod { - bb = append(bb, ':') - } - bb = append(bb, color.ANSIColorize(l.Container, paint)...) - bb = append(bb, ' ') - } else if hasPod { - bb = append(bb, ' ') + bb.WriteString("[" + paint + "::]" + l.Pod) } - return append(bb, escPattern.ReplaceAll(l.Bytes, matcher)...) + if !l.SingleContainer && l.Container != "" { + if len(l.Pod) > 0 { + bb.WriteString(" ") + } + bb.WriteString("[" + paint + "::b]" + l.Container + "[-::-] ") + } else if len(l.Pod) > 0 { + bb.WriteString("[-::]") + } + + if index > 0 { + bb.Write(l.Bytes[index+1:]) + } else { + bb.Write(l.Bytes) + } } diff --git a/internal/dao/log_item_test.go b/internal/dao/log_item_test.go index 73452061..63116cd6 100644 --- a/internal/dao/log_item_test.go +++ b/internal/dao/log_item_test.go @@ -1,6 +1,7 @@ package dao_test import ( + "bytes" "fmt" "testing" @@ -34,13 +35,13 @@ func TestLogItemRender(t *testing.T) { }{ "empty": { opts: dao.LogOptions{}, - e: "Testing 1,2,3...", + e: "Testing 1,2,3...\n", }, "container": { opts: dao.LogOptions{ Container: "fred", }, - e: "\x1b[38;5;0mfred\x1b[0m Testing 1,2,3...", + e: "[yellow::b]fred[-::-] Testing 1,2,3...\n", }, "pod": { opts: dao.LogOptions{ @@ -48,7 +49,7 @@ func TestLogItemRender(t *testing.T) { Container: "blee", SingleContainer: true, }, - e: "\x1b[38;5;0mfred\x1b[0m:\x1b[38;5;0mblee\x1b[0m Testing 1,2,3...", + e: "[yellow::]fred [yellow::b]blee[-::-] Testing 1,2,3...\n", }, "full": { opts: dao.LogOptions{ @@ -57,7 +58,7 @@ func TestLogItemRender(t *testing.T) { SingleContainer: true, ShowTimestamp: true, }, - e: "\x1b[38;5;106m2018-12-14T10:36:43.326972-07:00\x1b[0m \x1b[38;5;0mfred\x1b[0m:\x1b[38;5;0mblee\x1b[0m Testing 1,2,3...", + e: "[gray::]2018-12-14T10:36:43.326972-07:00 [yellow::]fred [yellow::b]blee[-::-] Testing 1,2,3...\n", }, } @@ -69,7 +70,9 @@ func TestLogItemRender(t *testing.T) { _, n := client.Namespaced(u.opts.Path) i.Pod, i.Container = n, u.opts.Container - assert.Equal(t, u.e, string(i.Render(0, u.opts.ShowTimestamp))) + bb := bytes.NewBuffer(make([]byte, 0, i.Size())) + i.Render("yellow", u.opts.ShowTimestamp, bb) + assert.Equal(t, u.e, bb.String()) }) } } @@ -78,9 +81,24 @@ func BenchmarkLogItemRender(b *testing.B) { s := []byte(fmt.Sprintf("%s %s\n", "2018-12-14T10:36:43.326972-07:00", "Testing 1,2,3...")) i := dao.NewLogItem(s) i.Pod, i.Container = "fred", "blee" + b.ResetTimer() b.ReportAllocs() for n := 0; n < b.N; n++ { - i.Render(0, true) + bb := bytes.NewBuffer(make([]byte, 0, i.Size())) + i.Render("yellow", true, bb) + } +} + +func BenchmarkLogItemRenderNoTS(b *testing.B) { + s := []byte(fmt.Sprintf("%s %s\n", "2018-12-14T10:36:43.326972-07:00", "Testing 1,2,3...")) + i := dao.NewLogItem(s) + i.Pod, i.Container = "fred", "blee" + + b.ResetTimer() + b.ReportAllocs() + for n := 0; n < b.N; n++ { + bb := bytes.NewBuffer(make([]byte, 0, i.Size())) + i.Render("yellow", false, bb) } } diff --git a/internal/dao/log_items.go b/internal/dao/log_items.go index d50b401a..888c3db3 100644 --- a/internal/dao/log_items.go +++ b/internal/dao/log_items.go @@ -1,37 +1,37 @@ package dao import ( + "bytes" "fmt" "regexp" "strings" "sync" - "github.com/gdamore/tcell/v2" "github.com/sahilm/fuzzy" ) -var colorPalette = []tcell.Color{ - tcell.ColorTeal, - tcell.ColorGreen, - tcell.ColorPurple, - tcell.ColorLime, - tcell.ColorBlue, - tcell.ColorYellow, - tcell.ColorFuchsia, - tcell.ColorAqua, +var podPalette = []string{ + "teal", + "green", + "purple", + "lime", + "blue", + "yellow", + "fushia", + "aqua", } // LogItems represents a collection of log items. type LogItems struct { - items []*LogItem - colors map[string]tcell.Color - mx sync.RWMutex + items []*LogItem + podColors map[string]string + mx sync.RWMutex } // NewLogItems returns a new instance. func NewLogItems() *LogItems { return &LogItems{ - colors: make(map[string]tcell.Color), + podColors: make(map[string]string), } } @@ -56,9 +56,9 @@ func (l *LogItems) Clear() { l.mx.Lock() defer l.mx.Unlock() - l.items = nil - for k := range l.colors { - delete(l.colors, k) + l.items = l.items[:0] + for k := range l.podColors { + delete(l.podColors, k) } } @@ -76,8 +76,8 @@ func (l *LogItems) Subset(index int) *LogItems { defer l.mx.RUnlock() return &LogItems{ - items: l.items[index:], - colors: l.colors, + items: l.items[index:], + podColors: l.podColors, } } @@ -87,8 +87,8 @@ func (l *LogItems) Merge(n *LogItems) { defer l.mx.Unlock() l.items = append(l.items, n.items...) - for k, v := range n.colors { - l.colors[k] = v + for k, v := range n.podColors { + l.podColors[k] = v } } @@ -101,47 +101,60 @@ func (l *LogItems) Add(ii ...*LogItem) { } // Lines returns a collection of log lines. -func (l *LogItems) Lines(showTime bool) [][]byte { +func (l *LogItems) Lines(index int, showTime bool, ll [][]byte) { l.mx.Lock() defer l.mx.Unlock() - ll := make([][]byte, len(l.items)) - for i, item := range l.items { - color := l.colors[item.ID()] - ll[i] = item.Render(int(color-tcell.ColorValid), showTime) + var colorIndex int + for i, item := range l.items[index:] { + id := item.ID() + color, ok := l.podColors[id] + if !ok { + if colorIndex >= len(podPalette) { + colorIndex = 0 + } + color = podPalette[colorIndex] + l.podColors[id] = color + colorIndex++ + } + bb := bytes.NewBuffer(make([]byte, 0, item.Size())) + item.Render(color, showTime, bb) + ll[i] = bb.Bytes() } - - return ll } // StrLines returns a collection of log lines. -func (l *LogItems) StrLines(showTime bool) []string { +func (l *LogItems) StrLines(index int, showTime bool) []string { l.mx.Lock() defer l.mx.Unlock() - ll := make([]string, len(l.items)) - for i, item := range l.items { - ll[i] = string(item.Render(0, showTime)) + ll := make([]string, len(l.items[index:])) + for i, item := range l.items[index:] { + bb := bytes.NewBuffer(make([]byte, 0, item.Size())) + item.Render("white", showTime, bb) + ll[i] = bb.String() } return ll } // Render returns logs as a collection of strings. -func (l *LogItems) Render(showTime bool, ll [][]byte) { - index := len(l.colors) - for i, item := range l.items { +func (l *LogItems) Render(index int, showTime bool, ll [][]byte) { + var colorIndex int + for i, item := range l.items[index:] { id := item.ID() - color, ok := l.colors[id] + color, ok := l.podColors[id] if !ok { - if index >= len(colorPalette) { - index = 0 + if colorIndex >= len(podPalette) { + colorIndex = 0 } - color = colorPalette[index] - l.colors[id] = color - index++ + color = podPalette[colorIndex] + l.podColors[id] = color + colorIndex++ } - ll[i] = item.Render(int(color-tcell.ColorValid), showTime) + bb := bytes.NewBuffer(make([]byte, 0, item.Size())) + item.Render(color, showTime, bb) + ll[i] = bb.Bytes() } } @@ -154,15 +167,15 @@ func (l *LogItems) DumpDebug(m string) { } // Filter filters out log items based on given filter. -func (l *LogItems) Filter(q string, showTime bool) ([]int, [][]int, error) { +func (l *LogItems) Filter(index int, q string, showTime bool) ([]int, [][]int, error) { if q == "" { return nil, nil, nil } if IsFuzzySelector(q) { - mm, ii := l.fuzzyFilter(strings.TrimSpace(q[2:]), showTime) + mm, ii := l.fuzzyFilter(index, strings.TrimSpace(q[2:]), showTime) return mm, ii, nil } - matches, indices, err := l.filterLogs(q, showTime) + matches, indices, err := l.filterLogs(index, q, showTime) if err != nil { return nil, nil, err } @@ -170,10 +183,10 @@ func (l *LogItems) Filter(q string, showTime bool) ([]int, [][]int, error) { return matches, indices, nil } -func (l *LogItems) fuzzyFilter(q string, showTime bool) ([]int, [][]int) { +func (l *LogItems) fuzzyFilter(index int, q string, showTime bool) ([]int, [][]int) { q = strings.TrimSpace(q) matches, indices := make([]int, 0, len(l.items)), make([][]int, 0, 10) - mm := fuzzy.Find(q, l.StrLines(showTime)) + mm := fuzzy.Find(q, l.StrLines(index, showTime)) for _, m := range mm { matches = append(matches, m.Index) indices = append(indices, m.MatchedIndexes) @@ -182,7 +195,7 @@ func (l *LogItems) fuzzyFilter(q string, showTime bool) ([]int, [][]int) { return matches, indices } -func (l *LogItems) filterLogs(q string, showTime bool) ([]int, [][]int, error) { +func (l *LogItems) filterLogs(index int, q string, showTime bool) ([]int, [][]int, error) { var invert bool if IsInverseSelector(q) { invert = true @@ -193,7 +206,9 @@ func (l *LogItems) filterLogs(q string, showTime bool) ([]int, [][]int, error) { return nil, nil, err } matches, indices := make([]int, 0, len(l.items)), make([][]int, 0, 10) - for i, line := range l.Lines(showTime) { + ll := make([][]byte, len(l.items[index:])) + l.Lines(index, showTime, ll) + for i, line := range ll { locs := rx.FindIndex(line) if locs != nil && invert { continue diff --git a/internal/dao/log_items_test.go b/internal/dao/log_items_test.go index b783081f..6914b424 100644 --- a/internal/dao/log_items_test.go +++ b/internal/dao/log_items_test.go @@ -71,7 +71,7 @@ func TestLogItemsFilter(t *testing.T) { for _, i := range ii.Items() { i.Pod, i.Container = n, u.opts.Container } - res, _, err := ii.Filter(u.q, false) + res, _, err := ii.Filter(0, u.q, false) assert.Equal(t, u.err, err) if err == nil { assert.Equal(t, u.e, res) @@ -87,20 +87,20 @@ func TestLogItemsRender(t *testing.T) { }{ "empty": { opts: dao.LogOptions{}, - e: "Testing 1,2,3...", + e: "Testing 1,2,3...\n", }, "container": { opts: dao.LogOptions{ Container: "fred", }, - e: "\x1b[38;5;6mfred\x1b[0m Testing 1,2,3...", + e: "[teal::b]fred[-::-] Testing 1,2,3...\n", }, - "pod": { + "pod-container": { opts: dao.LogOptions{ Path: "blee/fred", Container: "blee", }, - e: "\x1b[38;5;6mfred\x1b[0m:\x1b[38;5;6mblee\x1b[0m Testing 1,2,3...", + e: "[teal::]fred [teal::b]blee[-::-] Testing 1,2,3...\n", }, "full": { opts: dao.LogOptions{ @@ -108,7 +108,7 @@ func TestLogItemsRender(t *testing.T) { Container: "blee", ShowTimestamp: true, }, - e: "\x1b[38;5;106m2018-12-14T10:36:43.326972-07:00\x1b[0m \x1b[38;5;6mfred\x1b[0m:\x1b[38;5;6mblee\x1b[0m Testing 1,2,3...", + e: "[gray::]2018-12-14T10:36:43.326972-07:00 [teal::]fred [teal::b]blee[-::-] Testing 1,2,3...\n", }, } @@ -121,7 +121,7 @@ func TestLogItemsRender(t *testing.T) { ii.Items()[0].Pod, ii.Items()[0].Container = n, u.opts.Container t.Run(k, func(t *testing.T) { res := make([][]byte, 1) - ii.Render(u.opts.ShowTimestamp, res) + ii.Render(0, u.opts.ShowTimestamp, res) assert.Equal(t, u.e, string(res[0])) }) } diff --git a/internal/dao/log_options.go b/internal/dao/log_options.go index 208e13e6..dba05e72 100644 --- a/internal/dao/log_options.go +++ b/internal/dao/log_options.go @@ -2,7 +2,6 @@ package dao import ( "fmt" - "strings" "time" "github.com/derailed/k9s/internal/client" @@ -12,12 +11,14 @@ import ( // LogOptions represents logger options. type LogOptions struct { + CreateDuration time.Duration Path string Container string DefaultContainer string SinceTime string Lines int64 SinceSeconds int64 + Head bool Previous bool SingleContainer bool MultiPods bool @@ -77,6 +78,18 @@ func (o *LogOptions) ToPodLogOptions() *v1.PodLogOptions { Previous: o.Previous, TailLines: &o.Lines, } + if o.Head { + var maxBytes int64 = 1000 + //var defaultTail int64 = -1 + //var defaultSince int64 + + opts.Follow = false + opts.TailLines, opts.SinceSeconds, opts.SinceTime = nil, nil, nil + //opts.TailLines = &defaultTail + //opts.SinceSeconds = &defaultSince + opts.LimitBytes = &maxBytes + return &opts + } if o.SinceSeconds < 0 { return &opts } @@ -96,21 +109,6 @@ func (o *LogOptions) ToPodLogOptions() *v1.PodLogOptions { return &opts } -// FixedSizeName returns a normalize fixed size pod name if possible. -func (o *LogOptions) FixedSizeName() string { - _, n := client.Namespaced(o.Path) - tokens := strings.Split(n, "-") - if len(tokens) < 3 { - return n - } - var s []string - for i := 0; i < len(tokens)-1; i++ { - s = append(s, tokens[i]) - } - - return Truncate(strings.Join(s, "-"), 15) + "-" + tokens[len(tokens)-1] -} - // DecorateLog add a log header to display po/co information along with the log message. func (o *LogOptions) DecorateLog(bytes []byte) *LogItem { item := NewLogItem(bytes) diff --git a/internal/dao/ofaas.go b/internal/dao/ofaas.go index 425d07aa..4716a484 100644 --- a/internal/dao/ofaas.go +++ b/internal/dao/ofaas.go @@ -8,7 +8,7 @@ package dao // "encoding/json" // "errors" // "fmt" -// "io/ioutil" +// "io" // "net/http" // "net/url" // "os" @@ -193,7 +193,7 @@ package dao // case http.StatusUnauthorized: // return fmt.Errorf("unauthorized access, run \"faas-cli login\" to setup authentication for this server") // default: -// bytesOut, err := ioutil.ReadAll(delRes.Body) +// bytesOut, err := io.ReadAll(delRes.Body) // if err != nil { // return err // } diff --git a/internal/dao/pod.go b/internal/dao/pod.go index d4184ba9..4a33d37a 100644 --- a/internal/dao/pod.go +++ b/internal/dao/pod.go @@ -177,7 +177,7 @@ func (p *Pod) GetInstance(fqn string) (*v1.Pod, error) { } // TailLogs tails a given container logs. -func (p *Pod) TailLogs(ctx context.Context, c LogChan, opts *LogOptions) error { +func (p *Pod) TailLogs(ctx context.Context, out LogChan, opts *LogOptions) error { log.Debug().Msgf("TAIL-LOGS for %q:%q", opts.Path, opts.Container) fac, ok := ctx.Value(internal.KeyFactory).(*watch.Factory) if !ok { @@ -198,18 +198,18 @@ func (p *Pod) TailLogs(ctx context.Context, c LogChan, opts *LogOptions) error { if co, ok := GetDefaultLogContainer(po.ObjectMeta, po.Spec); ok && !opts.AllContainers { opts.DefaultContainer = co - return tailLogs(ctx, p, c, opts) + return tailLogs(ctx, p, out, opts) } if opts.HasContainer() && !opts.AllContainers { - return tailLogs(ctx, p, c, opts) + return tailLogs(ctx, p, out, opts) } var tailed bool for _, co := range po.Spec.InitContainers { o := opts.Clone() o.Container = co.Name - if err := tailLogs(ctx, p, c, o); err != nil { + if err := tailLogs(ctx, p, out, o); err != nil { return err } tailed = true @@ -217,7 +217,7 @@ func (p *Pod) TailLogs(ctx context.Context, c LogChan, opts *LogOptions) error { for _, co := range po.Spec.Containers { o := opts.Clone() o.Container = co.Name - if err := tailLogs(ctx, p, c, o); err != nil { + if err := tailLogs(ctx, p, out, o); err != nil { return err } tailed = true @@ -225,7 +225,7 @@ func (p *Pod) TailLogs(ctx context.Context, c LogChan, opts *LogOptions) error { for _, co := range po.Spec.EphemeralContainers { o := opts.Clone() o.Container = co.Name - if err := tailLogs(ctx, p, c, o); err != nil { + if err := tailLogs(ctx, p, out, o); err != nil { return err } tailed = true @@ -326,21 +326,22 @@ func (p *Pod) Scan(ctx context.Context, gvr, fqn string, wait bool) (Refs, error // ---------------------------------------------------------------------------- // Helpers... -func tailLogs(ctx context.Context, logger Logger, c LogChan, opts *LogOptions) error { - log.Debug().Msgf("Tailing logs for %#v", opts) - +func tailLogs(ctx context.Context, logger Logger, out LogChan, opts *LogOptions) error { var ( err error req *restclient.Request stream io.ReadCloser ) + + o := opts.ToPodLogOptions() + log.Debug().Msgf("TAIL_LOGS! %#v", o) done: for r := 0; r < logRetryCount; r++ { - req, err = logger.Logs(opts.Path, opts.ToPodLogOptions()) + req, err = logger.Logs(opts.Path, o) if err == nil { // This call will block if nothing is in the stream!! if stream, err = req.Stream(ctx); err == nil { - go readLogs(stream, c, opts) + go readLogs(ctx, stream, out, opts) break } else { log.Error().Err(err).Msg("Streaming logs") @@ -351,6 +352,7 @@ done: select { case <-ctx.Done(): + log.Debug().Msgf("!!!!TAIL_LOGS CANCELED!!!!") err = ctx.Err() break done default: @@ -358,36 +360,36 @@ done: } } - if err != nil { - log.Error().Err(err).Msgf("Unable to obtain log stream failed for `%s", opts.Path) - c <- opts.DecorateLog([]byte("\n" + err.Error() + "\n")) - return err - } - - return nil + return err } -func readLogs(stream io.ReadCloser, c LogChan, opts *LogOptions) { +func readLogs(ctx context.Context, stream io.ReadCloser, c LogChan, opts *LogOptions) { defer func() { - log.Debug().Msgf(">>> Closing stream %s", opts.Info()) + log.Debug().Msgf("READ_LOGS BAILED!!!") if err := stream.Close(); err != nil { log.Error().Err(err).Msgf("Fail to close stream %s", opts.Info()) } }() + log.Debug().Msgf("READ_LOGS PROCESSING %#v", opts) r := bufio.NewReader(stream) for { bytes, err := r.ReadBytes('\n') if err != nil { if errors.Is(err, io.EOF) { log.Warn().Err(err).Msgf("Stream closed for %s", opts.Info()) + // c <- ItemEOF return } log.Warn().Err(err).Msgf("Stream READ error %s", opts.Info()) - c <- opts.DecorateLog([]byte(fmt.Sprintf("\nlog stream failed: %#v\n", err))) return } - c <- opts.DecorateLog(bytes) + select { + case c <- opts.DecorateLog(bytes): + case <-ctx.Done(): + log.Debug().Msgf("READER CANCELED") + return + } } } diff --git a/internal/dao/port_forwarder.go b/internal/dao/port_forwarder.go index 016a5792..a1681566 100644 --- a/internal/dao/port_forwarder.go +++ b/internal/dao/port_forwarder.go @@ -7,6 +7,7 @@ import ( "time" "github.com/derailed/k9s/internal/client" + "github.com/derailed/k9s/internal/port" "github.com/rs/zerolog/log" v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -28,8 +29,7 @@ type PortForwarder struct { stopChan, readyChan chan struct{} active bool path string - container string - ports []string + tunnel port.PortTunnel age time.Time } @@ -57,58 +57,56 @@ func (p *PortForwarder) SetActive(b bool) { p.active = b } -// Ports returns the forwarded ports mappings. -func (p *PortForwarder) Ports() []string { - return p.ports +// Port returns the port mapping. +func (p *PortForwarder) Port() string { + return p.tunnel.PortMap() +} + +// ContainerPort returns the container port. +func (p *PortForwarder) ContainerPort() string { + return p.tunnel.ContainerPort +} + +// LocalPort returns the local port. +func (p *PortForwarder) LocalPort() string { + return p.tunnel.LocalPort } // Path returns the pod resource path. func (p *PortForwarder) Path() string { - return PortForwardID(p.path, p.container) + return PortForwardID(p.path, p.tunnel.Container, p.tunnel.PortMap()) } -// PortForwardID computes port-forward identifier. -func PortForwardID(path, co string) string { - return path + ":" + co +// ID returns a pf id. +func (p *PortForwarder) ID() string { + return PortForwardID(p.path, p.tunnel.Container, p.tunnel.PortMap()) } // Container returns the target's container. func (p *PortForwarder) Container() string { - return p.container + return p.tunnel.Container } // Stop terminates a port forward. func (p *PortForwarder) Stop() { - log.Debug().Msgf("<<< Stopping PortForward %q %v", p.path, p.ports) + log.Debug().Msgf("<<< Stopping PortForward %s", p.ID()) p.active = false close(p.stopChan) } // FQN returns the portforward unique id. func (p *PortForwarder) FQN() string { - return p.path + ":" + p.container + return p.path + ":" + p.tunnel.Container } // HasPortMapping checks if port mapping is defined for this fwd. -func (p *PortForwarder) HasPortMapping(m string) bool { - for _, mapping := range p.ports { - if mapping == m { - return true - } - } - return false +func (p *PortForwarder) HasPortMapping(portMap string) bool { + return p.tunnel.PortMap() == portMap } // Start initiates a port forward session for a given pod and ports. -func (p *PortForwarder) Start(path, co string, tt []client.PortTunnel) (*portforward.PortForwarder, error) { - if len(tt) == 0 { - return nil, fmt.Errorf("no ports assigned") - } - fwds, addrs := make([]string, 0, len(tt)), make([]string, 0, len(tt)) - for _, t := range tt { - fwds, addrs = append(fwds, t.PortMap()), append(addrs, t.Address) - } - p.path, p.container, p.ports, p.age = path, co, fwds, time.Now() +func (p *PortForwarder) Start(path string, tt port.PortTunnel) (*portforward.PortForwarder, error) { + p.path, p.tunnel, p.age = path, tt, time.Now() ns, n := client.Namespaced(path) auth, err := p.Client().CanI(ns, "v1/pods", []string{client.GetVerb}) @@ -155,10 +153,10 @@ func (p *PortForwarder) Start(path, co string, tt []client.PortTunnel) (*portfor Name(n). SubResource("portforward") - return p.forwardPorts("POST", req.URL(), addrs, fwds) + return p.forwardPorts("POST", req.URL(), tt.Address, tt.PortMap()) } -func (p *PortForwarder) forwardPorts(method string, url *url.URL, addrs, ports []string) (*portforward.PortForwarder, error) { +func (p *PortForwarder) forwardPorts(method string, url *url.URL, addr, portMap string) (*portforward.PortForwarder, error) { cfg, err := p.Client().Config().RESTConfig() if err != nil { return nil, err @@ -169,12 +167,17 @@ func (p *PortForwarder) forwardPorts(method string, url *url.URL, addrs, ports [ } dialer := spdy.NewDialer(upgrader, &http.Client{Transport: transport}, method, url) - return portforward.NewOnAddresses(dialer, addrs, ports, p.stopChan, p.readyChan, p.Out, p.ErrOut) + return portforward.NewOnAddresses(dialer, []string{addr}, []string{portMap}, p.stopChan, p.readyChan, p.Out, p.ErrOut) } // ---------------------------------------------------------------------------- // Helpers... +// PortForwardID computes port-forward identifier. +func PortForwardID(path, co, portMap string) string { + return path + "|" + co + "|" + portMap +} + func codec() (serializer.CodecFactory, runtime.ParameterCodec) { scheme := runtime.NewScheme() gv := schema.GroupVersion{Group: "", Version: "v1"} diff --git a/internal/dao/registry.go b/internal/dao/registry.go index dc41dc13..00d7ee05 100644 --- a/internal/dao/registry.go +++ b/internal/dao/registry.go @@ -341,7 +341,7 @@ func loadRBAC(m ResourceMetas) { func loadPreferred(f Factory, m ResourceMetas) error { if !f.Client().ConnectionOK() { - log.Error().Msgf("PreferredRES - No API server connection") + log.Error().Msgf("Load cluster resources - No API server connection") return nil } @@ -393,6 +393,9 @@ func isDeprecated(gvr client.GVR) bool { } func loadCRDs(f Factory, m ResourceMetas) { + if !f.Client().ConnectionOK() { + return + } const crdGVR = "apiextensions.k8s.io/v1/customresourcedefinitions" oo, err := f.List(crdGVR, client.ClusterScope, false, labels.Everything()) if err != nil { diff --git a/internal/dao/registry_test.go b/internal/dao/registry_test.go index 28ff4ef8..4d28895b 100644 --- a/internal/dao/registry_test.go +++ b/internal/dao/registry_test.go @@ -3,7 +3,7 @@ package dao import ( "encoding/json" "fmt" - "io/ioutil" + "os" "testing" "github.com/stretchr/testify/assert" @@ -89,7 +89,7 @@ func TestExtractString(t *testing.T) { // Helpers... func load(t *testing.T, n string) *unstructured.Unstructured { - raw, err := ioutil.ReadFile(fmt.Sprintf("testdata/%s.json", n)) + raw, err := os.ReadFile(fmt.Sprintf("testdata/%s.json", n)) assert.Nil(t, err) var o unstructured.Unstructured diff --git a/internal/dao/screen_dump.go b/internal/dao/screen_dump.go index 09c0742b..644b958d 100644 --- a/internal/dao/screen_dump.go +++ b/internal/dao/screen_dump.go @@ -3,7 +3,6 @@ package dao import ( "context" "errors" - "io/ioutil" "os" "regexp" @@ -37,14 +36,16 @@ func (d *ScreenDump) List(ctx context.Context, _ string) ([]runtime.Object, erro return nil, errors.New("no screendump dir found in context") } - ff, err := ioutil.ReadDir(SanitizeFilename(dir)) + ff, err := os.ReadDir(SanitizeFilename(dir)) if err != nil { return nil, err } oo := make([]runtime.Object, len(ff)) for i, f := range ff { - oo[i] = render.FileRes{File: f, Dir: dir} + if fi, err := f.Info(); err == nil { + oo[i] = render.FileRes{File: fi, Dir: dir} + } } return oo, nil diff --git a/internal/dao/sts.go b/internal/dao/sts.go index b483592b..da8bd8fe 100644 --- a/internal/dao/sts.go +++ b/internal/dao/sts.go @@ -70,8 +70,7 @@ func (s *StatefulSet) Restart(ctx context.Context, path string) error { return err } - ns, _ := client.Namespaced(path) - auth, err := s.Client().CanI(ns, "apps/v1/statefulsets", []string{client.PatchVerb}) + auth, err := s.Client().CanI(sts.Namespace, "apps/v1/statefulsets", []string{client.PatchVerb}) if err != nil { return err } @@ -95,6 +94,7 @@ func (s *StatefulSet) Restart(ctx context.Context, path string) error { update, metav1.PatchOptions{}, ) + return err } diff --git a/internal/model/cluster_info.go b/internal/model/cluster_info.go index 05cdeb38..961cd952 100644 --- a/internal/model/cluster_info.go +++ b/internal/model/cluster_info.go @@ -4,7 +4,7 @@ import ( "context" "encoding/json" "errors" - "io/ioutil" + "io" "net/http" "time" @@ -55,13 +55,7 @@ func NewClusterMeta() ClusterMeta { // Deltas diffs cluster meta return true if different, false otherwise. func (c ClusterMeta) Deltas(n ClusterMeta) bool { - if c.Cpu != n.Cpu { - return true - } - if c.Mem != n.Mem { - return true - } - if c.Ephemeral != n.Ephemeral { + if c.Cpu != n.Cpu || c.Mem != n.Mem || c.Ephemeral != n.Ephemeral { return true } @@ -76,6 +70,7 @@ func (c ClusterMeta) Deltas(n ClusterMeta) bool { // ClusterInfo models cluster metadata. type ClusterInfo struct { cluster *Cluster + factory dao.Factory data ClusterMeta version string listeners []ClusterInfoListener @@ -83,11 +78,12 @@ type ClusterInfo struct { } // NewClusterInfo returns a new instance. -func NewClusterInfo(f dao.Factory, version string) *ClusterInfo { +func NewClusterInfo(f dao.Factory, v string) *ClusterInfo { c := ClusterInfo{ + factory: f, cluster: NewCluster(f), data: NewClusterMeta(), - version: version, + version: v, cache: cache.NewLRUExpireCache(cacheSize), } @@ -116,28 +112,29 @@ func (c *ClusterInfo) Reset(f dao.Factory) { c.Refresh() } -// Refresh fetches latest cluster meta. +// Refresh fetches the latest cluster meta. func (c *ClusterInfo) Refresh() { data := NewClusterMeta() - data.Context = c.cluster.ContextName() - data.Cluster = c.cluster.ClusterName() - data.User = c.cluster.UserName() + if c.factory.Client().ConnectionOK() { + data.Context = c.cluster.ContextName() + data.Cluster = c.cluster.ClusterName() + data.User = c.cluster.UserName() + data.K8sVer = c.cluster.Version() + ctx, cancel := context.WithTimeout(context.Background(), c.cluster.factory.Client().Config().CallTimeout()) + defer cancel() + var mx client.ClusterMetrics + if err := c.cluster.Metrics(ctx, &mx); err == nil { + data.Cpu, data.Mem, data.Ephemeral = mx.PercCPU, mx.PercMEM, mx.PercEphemeral + } else { + log.Warn().Err(err).Msgf("Cluster metrics failed") + } + } data.K9sVer = c.version v1, v2 := NewSemVer(data.K9sVer), NewSemVer(c.fetchK9sLatestRev()) data.K9sVer, data.K9sLatest = v1.String(), v2.String() if v1.IsCurrent(v2) { data.K9sLatest = "" } - data.K8sVer = c.cluster.Version() - - ctx, cancel := context.WithTimeout(context.Background(), c.cluster.factory.Client().Config().CallTimeout()) - defer cancel() - var mx client.ClusterMetrics - if err := c.cluster.Metrics(ctx, &mx); err == nil { - data.Cpu, data.Mem, data.Ephemeral = mx.PercCPU, mx.PercMEM, mx.PercEphemeral - } else { - log.Warn().Err(err).Msgf("Cluster metrics failed") - } if c.data.Deltas(data) { c.fireMetaChanged(c.data, data) @@ -200,7 +197,7 @@ func fetchLatestRev() (string, error) { } }() - b, err := ioutil.ReadAll(resp.Body) + b, err := io.ReadAll(resp.Body) if err != nil { return "", err } diff --git a/internal/model/cmd_buff.go b/internal/model/cmd_buff.go index af2074df..b4ff0751 100644 --- a/internal/model/cmd_buff.go +++ b/internal/model/cmd_buff.go @@ -91,8 +91,8 @@ func (c *CmdBuff) Add(r rune) { if c.cancel != nil { return } - var ctx context.Context - ctx, c.cancel = context.WithTimeout(context.Background(), keyEntryDelay) + ctx := context.Background() + ctx, c.cancel = context.WithTimeout(ctx, keyEntryDelay) go func() { <-ctx.Done() @@ -118,8 +118,8 @@ func (c *CmdBuff) Delete() { return } - var ctx context.Context - ctx, c.cancel = context.WithTimeout(context.Background(), 800*time.Millisecond) + ctx := context.Background() + ctx, c.cancel = context.WithTimeout(ctx, 800*time.Millisecond) go func() { <-ctx.Done() diff --git a/internal/model/flash.go b/internal/model/flash.go index d7fe8300..150ae8bc 100644 --- a/internal/model/flash.go +++ b/internal/model/flash.go @@ -126,8 +126,8 @@ func (f *Flash) SetMessage(level FlashLevel, msg string) { f.setLevelMessage(LevelMessage{Level: level, Text: msg}) f.fireFlashChanged() - var ctx context.Context - ctx, f.cancel = context.WithCancel(context.Background()) + ctx := context.Background() + ctx, f.cancel = context.WithCancel(ctx) go f.refresh(ctx) } diff --git a/internal/model/log.go b/internal/model/log.go index 3b40fe0a..8e5d22f4 100644 --- a/internal/model/log.go +++ b/internal/model/log.go @@ -24,12 +24,21 @@ type LogsListener interface { // LogFailed indicates a log failure. LogFailed(error) + + // LogStop indicates logging was canceled. + LogStop() + + // LogResume indicates loggings has resumed. + LogResume() + + // LogCanceled indicates no more logs will come. + LogCanceled() } // Log represents a resource logger. type Log struct { factory dao.Factory - items *dao.LogItems + lines *dao.LogItems listeners []LogsListener gvr client.GVR logOptions *dao.LogOptions @@ -45,11 +54,19 @@ func NewLog(gvr client.GVR, opts *dao.LogOptions, flushTimeout time.Duration) *L return &Log{ gvr: gvr, logOptions: opts, - items: dao.NewLogItems(), + lines: dao.NewLogItems(), flushTimeout: flushTimeout, } } +func (l *Log) GVR() client.GVR { + return l.gvr +} + +func (l *Log) LogOptions() *dao.LogOptions { + return l.logOptions +} + // SinceSeconds returns since seconds option. func (l *Log) SinceSeconds() int64 { l.mx.RLock() @@ -58,16 +75,33 @@ func (l *Log) SinceSeconds() int64 { return l.logOptions.SinceSeconds } +// IsHead returns log head option. +func (l *Log) IsHead() bool { + l.mx.RLock() + defer l.mx.RUnlock() + + return l.logOptions.Head +} + // ToggleShowTimestamp toggles to logs timestamps. func (l *Log) ToggleShowTimestamp(b bool) { l.logOptions.ShowTimestamp = b l.Refresh() } +func (l *Log) Head(ctx context.Context, c dao.LogChan) { + l.mx.Lock() + { + l.logOptions.Head = true + } + l.mx.Unlock() + l.Restart(ctx, c, true) +} + // SetSinceSeconds sets the logs retrieval time. -func (l *Log) SetSinceSeconds(i int64) { - l.logOptions.SinceSeconds = i - l.Restart() +func (l *Log) SetSinceSeconds(ctx context.Context, c dao.LogChan, i int64) { + l.logOptions.SinceSeconds, l.logOptions.Head = i, false + l.Restart(ctx, c, true) } // Configure sets logger configuration. @@ -100,7 +134,7 @@ func (l *Log) Init(f dao.Factory) { func (l *Log) Clear() { l.mx.Lock() { - l.items.Clear() + l.lines.Clear() l.lastSent = 0 } l.mx.Unlock() @@ -111,21 +145,24 @@ func (l *Log) Clear() { // Refresh refreshes the logs. func (l *Log) Refresh() { l.fireLogCleared() - ll := make([][]byte, l.items.Len()) - l.items.Render(l.logOptions.ShowTimestamp, ll) + ll := make([][]byte, l.lines.Len()) + l.lines.Render(0, l.logOptions.ShowTimestamp, ll) l.fireLogChanged(ll) } // Restart restarts the logger. -func (l *Log) Restart() { - l.Clear() +func (l *Log) Restart(ctx context.Context, c dao.LogChan, clear bool) { l.Stop() - l.Start() + if clear { + l.Clear() + } + l.fireLogResume() + l.Start(ctx, c) } // Start starts logging. -func (l *Log) Start() { - if err := l.load(); err != nil { +func (l *Log) Start(ctx context.Context, c dao.LogChan) { + if err := l.load(ctx, c); err != nil { log.Error().Err(err).Msgf("Tail logs failed!") l.fireLogError(err) } @@ -134,23 +171,20 @@ func (l *Log) Start() { // Stop terminates logging. func (l *Log) Stop() { defer log.Debug().Msgf("<<<< Logger STOPPED!") - if l.cancelFn != nil { - l.cancelFn() - l.cancelFn = nil - } + l.cancel() } // Set sets the log lines (for testing only!) -func (l *Log) Set(items *dao.LogItems) { +func (l *Log) Set(lines *dao.LogItems) { l.mx.Lock() { - l.items.Merge(items) + l.lines.Merge(lines) } l.mx.Unlock() l.fireLogCleared() - ll := make([][]byte, l.items.Len()) - l.items.Render(l.logOptions.ShowTimestamp, ll) + ll := make([][]byte, l.lines.Len()) + l.lines.Render(0, l.logOptions.ShowTimestamp, ll) l.fireLogChanged(ll) } @@ -163,34 +197,31 @@ func (l *Log) ClearFilter() { l.mx.Unlock() l.fireLogCleared() - ll := make([][]byte, l.items.Len()) - l.items.Render(l.logOptions.ShowTimestamp, ll) + ll := make([][]byte, l.lines.Len()) + l.lines.Render(0, l.logOptions.ShowTimestamp, ll) l.fireLogChanged(ll) } // Filter filters the model using either fuzzy or regexp. func (l *Log) Filter(q string) { l.mx.Lock() - defer l.mx.Unlock() - - if len(q) == 0 { - l.filter = "" - l.fireLogCleared() - l.fireLogBuffChanged(l.items) - return + { + l.filter = q } + l.mx.Unlock() - l.filter = q l.fireLogCleared() - l.fireLogBuffChanged(l.items) + l.fireLogBuffChanged(0) } -func (l *Log) load() error { - var ctx context.Context +func (l *Log) load(ctx context.Context, c dao.LogChan) error { + if l.cancelFn != nil { + l.cancelFn() + l.cancelFn = nil + } + ctx = context.WithValue(context.Background(), internal.KeyFactory, l.factory) ctx, l.cancelFn = context.WithCancel(ctx) - - c := make(dao.LogChan, 10) go l.updateLogs(ctx, c) accessor, err := dao.AccessorFor(l.factory, l.gvr) @@ -205,40 +236,40 @@ func (l *Log) load() error { go func() { if err = loggable.TailLogs(ctx, c, l.logOptions); err != nil { log.Error().Err(err).Msgf("Tail logs failed") - l.mx.Lock() - if l.cancelFn != nil { - l.cancelFn() - } - l.mx.Unlock() + l.cancel() } }() return nil } +func (l *Log) cancel() { + l.mx.Lock() + { + if l.cancelFn == nil { + l.mx.Unlock() + return + } + l.cancelFn() + l.cancelFn = nil + } + l.mx.Unlock() +} + // Append adds a log line. func (l *Log) Append(line *dao.LogItem) { if line == nil || line.IsEmpty() { return } - l.mx.Lock() - { - l.logOptions.SinceTime = line.Timestamp - } - l.mx.Unlock() - - if l.items.Len() == 0 { - l.fireLogCleared() - } - l.mx.Lock() defer l.mx.Unlock() - if l.items.Len() < int(l.logOptions.Lines) { - l.items.Add(line) + l.logOptions.SinceTime = line.GetTimestamp() + if l.lines.Len() < int(l.logOptions.Lines) { + l.lines.Add(line) return } - l.items.Shift(line) + l.lines.Shift(line) l.lastSent-- if l.lastSent < 0 { l.lastSent = 0 @@ -250,36 +281,40 @@ func (l *Log) Notify() { l.mx.Lock() defer l.mx.Unlock() - if l.lastSent < l.items.Len() { - l.fireLogBuffChanged(l.items.Subset(l.lastSent)) - l.lastSent = l.items.Len() + if l.lastSent < l.lines.Len() { + l.fireLogBuffChanged(l.lastSent) + l.lastSent = l.lines.Len() } } // ToggleAllContainers toggles to show all containers logs. -func (l *Log) ToggleAllContainers() { +func (l *Log) ToggleAllContainers(ctx context.Context, c dao.LogChan) { l.logOptions.ToggleAllContainers() - l.Restart() + l.Restart(ctx, c, true) } func (l *Log) updateLogs(ctx context.Context, c dao.LogChan) { - defer func() { - log.Debug().Msgf("updateLogs view bailing out!") - }() + defer log.Debug().Msgf("updateLogs view bailing out!") + for { select { case item, ok := <-c: if !ok { - log.Debug().Msgf("Closed channel detected. Bailing out...") + log.Debug().Msgf("Closed channel detected. Bailing out!") l.Append(item) l.Notify() return } + if item == dao.ItemEOF { + log.Debug().Msgf("!!!!!GOT EOF!!!!!!") + l.fireCanceled() + return + } l.Append(item) var overflow bool l.mx.RLock() { - overflow = int64(l.items.Len()-l.lastSent) > l.logOptions.Lines + overflow = int64(l.lines.Len()-l.lastSent) > l.logOptions.Lines } l.mx.RUnlock() if overflow { @@ -288,6 +323,7 @@ func (l *Log) updateLogs(ctx context.Context, c dao.LogChan) { case <-time.After(l.flushTimeout): l.Notify() case <-ctx.Done(): + log.Debug().Msgf("!!!LOG_MODEL IS CANCELED!!!") return } } @@ -295,11 +331,17 @@ func (l *Log) updateLogs(ctx context.Context, c dao.LogChan) { // AddListener adds a new model listener. func (l *Log) AddListener(listener LogsListener) { + l.mx.Lock() + defer l.mx.Unlock() + l.listeners = append(l.listeners, listener) } // RemoveListener delete a listener from the list. func (l *Log) RemoveListener(listener LogsListener) { + l.mx.Lock() + defer l.mx.Unlock() + victim := -1 for i, lis := range l.listeners { if lis == listener { @@ -313,19 +355,19 @@ func (l *Log) RemoveListener(listener LogsListener) { } } -func (l *Log) applyFilter(q string) ([][]byte, error) { +func (l *Log) applyFilter(index int, q string) ([][]byte, error) { if q == "" { return nil, nil } - matches, indices, err := l.items.Filter(q, l.logOptions.ShowTimestamp) + matches, indices, err := l.lines.Filter(index, q, l.logOptions.ShowTimestamp) if err != nil { return nil, err } // No filter! if matches == nil { - ll := make([][]byte, l.items.Len()) - l.items.Render(l.logOptions.ShowTimestamp, ll) + ll := make([][]byte, l.lines.Len()) + l.lines.Render(index, l.logOptions.ShowTimestamp, ll) return ll, nil } // Blank filter @@ -333,31 +375,45 @@ func (l *Log) applyFilter(q string) ([][]byte, error) { return nil, nil } filtered := make([][]byte, 0, len(matches)) - lines := l.items.Lines(l.logOptions.ShowTimestamp) + ll := make([][]byte, l.lines.Len()) + l.lines.Lines(index, l.logOptions.ShowTimestamp, ll) for i, idx := range matches { - filtered = append(filtered, color.Highlight(lines[idx], indices[i], 209)) + filtered = append(filtered, color.Highlight(ll[idx], indices[i], 209)) } return filtered, nil } -func (l *Log) fireLogBuffChanged(lines *dao.LogItems) { - ll := make([][]byte, lines.Len()) +func (l *Log) fireLogBuffChanged(index int) { + ll := make([][]byte, l.lines.Len()-index) if l.filter == "" { - lines.Render(l.logOptions.ShowTimestamp, ll) + l.lines.Render(index, l.logOptions.ShowTimestamp, ll) } else { - ff, err := l.applyFilter(l.filter) + ff, err := l.applyFilter(index, l.filter) if err != nil { l.fireLogError(err) return } ll = ff } + if len(ll) > 0 { l.fireLogChanged(ll) } } +func (l *Log) fireLogResume() { + for _, lis := range l.listeners { + lis.LogResume() + } +} + +func (l *Log) fireCanceled() { + for _, lis := range l.listeners { + lis.LogCanceled() + } +} + func (l *Log) fireLogError(err error) { for _, lis := range l.listeners { lis.LogFailed(err) @@ -371,7 +427,13 @@ func (l *Log) fireLogChanged(lines [][]byte) { } func (l *Log) fireLogCleared() { - for _, lis := range l.listeners { + var ll []LogsListener + l.mx.RLock() + { + ll = l.listeners + } + l.mx.RUnlock() + for _, lis := range ll { lis.LogCleared() } } diff --git a/internal/model/log_int_test.go b/internal/model/log_int_test.go index 1bb94ca5..c7d17c65 100644 --- a/internal/model/log_int_test.go +++ b/internal/model/log_int_test.go @@ -19,15 +19,14 @@ func TestUpdateLogs(t *testing.T) { v := newMockLogView() m.AddListener(v) - c := make(dao.LogChan) - go func() { - m.updateLogs(context.Background(), c) - }() + c := make(dao.LogChan, 2) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go m.updateLogs(ctx, c) for i := 0; i < 2*size; i++ { c <- dao.NewLogItemFromString("line" + strconv.Itoa(i)) } - close(c) time.Sleep(2 * time.Second) assert.Equal(t, size, v.count) @@ -45,11 +44,12 @@ func BenchmarkUpdateLogs(b *testing.B) { go func() { m.updateLogs(context.Background(), c) }() + item := dao.NewLogItem([]byte("\033[0;38m2018-12-14T10:36:43.326972-07:00 \033[0;32mblee line")) b.ReportAllocs() b.ResetTimer() for n := 0; n < b.N; n++ { - c <- dao.NewLogItemFromString("line" + strconv.Itoa(n)) + c <- item } close(c) } @@ -75,5 +75,8 @@ func newMockLogView() *mockLogView { func (t *mockLogView) LogChanged(ll [][]byte) { t.count += len(ll) } +func (t *mockLogView) LogStop() {} +func (t *mockLogView) LogCanceled() {} +func (t *mockLogView) LogResume() {} func (t *mockLogView) LogCleared() {} func (t *mockLogView) LogFailed(err error) {} diff --git a/internal/model/log_test.go b/internal/model/log_test.go index 06154f09..5ff94f5e 100644 --- a/internal/model/log_test.go +++ b/internal/model/log_test.go @@ -1,6 +1,7 @@ package model_test import ( + "context" "fmt" "strconv" "testing" @@ -32,9 +33,8 @@ func TestLogFullBuffer(t *testing.T) { m.Notify() assert.Equal(t, 1, v.dataCalled) - assert.Equal(t, 1, v.clearCalled) + assert.Equal(t, 0, v.clearCalled) assert.Equal(t, 0, v.errCalled) - // assert.Equal(t, data.Items()[4:].Lines(false), v.data) } func TestLogFilter(t *testing.T) { @@ -79,13 +79,13 @@ func TestLogFilter(t *testing.T) { m.Notify() assert.Equal(t, 1, v.dataCalled) - assert.Equal(t, 2, v.clearCalled) + assert.Equal(t, 1, v.clearCalled) assert.Equal(t, 0, v.errCalled) assert.Equal(t, u.e, len(v.data)) m.ClearFilter() assert.Equal(t, 2, v.dataCalled) - assert.Equal(t, 3, v.clearCalled) + assert.Equal(t, 2, v.clearCalled) assert.Equal(t, 0, v.errCalled) assert.Equal(t, size, len(v.data)) }) @@ -99,7 +99,10 @@ func TestLogStartStop(t *testing.T) { v := newTestView() m.AddListener(v) - m.Start() + c := make(dao.LogChan, 2) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + m.Start(ctx, c) data := dao.NewLogItems() data.Add(dao.NewLogItemFromString("line1"), dao.NewLogItemFromString("line2")) for _, d := range data.Items() { @@ -109,7 +112,7 @@ func TestLogStartStop(t *testing.T) { m.Stop() assert.Equal(t, 1, v.dataCalled) - assert.Equal(t, 1, v.clearCalled) + assert.Equal(t, 0, v.clearCalled) assert.Equal(t, 1, v.errCalled) assert.Equal(t, 2, len(v.data)) } @@ -132,7 +135,7 @@ func TestLogClear(t *testing.T) { m.Clear() assert.Equal(t, 1, v.dataCalled) - assert.Equal(t, 2, v.clearCalled) + assert.Equal(t, 1, v.clearCalled) assert.Equal(t, 0, v.errCalled) assert.Equal(t, 0, len(v.data)) } @@ -151,7 +154,9 @@ func TestLogBasic(t *testing.T) { assert.Equal(t, 1, v.dataCalled) assert.Equal(t, 1, v.clearCalled) assert.Equal(t, 0, v.errCalled) - assert.Equal(t, data.Lines(false), v.data) + ll := make([][]byte, data.Len()) + data.Lines(0, false, ll) + assert.Equal(t, ll, v.data) } func TestLogAppend(t *testing.T) { @@ -163,7 +168,9 @@ func TestLogAppend(t *testing.T) { items := dao.NewLogItems() items.Add(dao.NewLogItemFromString("blah blah")) m.Set(items) - assert.Equal(t, items.Lines(false), v.data) + ll := make([][]byte, items.Len()) + items.Lines(0, false, ll) + assert.Equal(t, ll, v.data) data := dao.NewLogItems() data.Add( @@ -174,7 +181,9 @@ func TestLogAppend(t *testing.T) { m.Append(d) } assert.Equal(t, 1, v.dataCalled) - assert.Equal(t, items.Lines(false), v.data) + ll = make([][]byte, items.Len()) + items.Lines(0, false, ll) + assert.Equal(t, ll, v.data) m.Notify() assert.Equal(t, 2, v.dataCalled) @@ -203,7 +212,7 @@ func TestLogTimedout(t *testing.T) { } m.Notify() assert.Equal(t, 1, v.dataCalled) - assert.Equal(t, 2, v.clearCalled) + assert.Equal(t, 1, v.clearCalled) assert.Equal(t, 0, v.errCalled) const e = "\x1b[38;5;209ml\x1b[0m\x1b[38;5;209mi\x1b[0m\x1b[38;5;209mn\x1b[0m\x1b[38;5;209me\x1b[0m\x1b[38;5;209m1\x1b[0m" assert.Equal(t, e, string(v.data[0])) @@ -215,9 +224,13 @@ func TestToggleAllContainers(t *testing.T) { m := model.NewLog(client.NewGVR(""), opts, 10*time.Millisecond) m.Init(makeFactory()) assert.Equal(t, "blee", m.GetContainer()) - m.ToggleAllContainers() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + c := make(dao.LogChan, 2) + m.ToggleAllContainers(ctx, c) assert.Equal(t, "", m.GetContainer()) - m.ToggleAllContainers() + m.ToggleAllContainers(ctx, c) assert.Equal(t, "blee", m.GetContainer()) } @@ -245,16 +258,17 @@ func newTestView() *testView { return &testView{} } +func (t *testView) LogCanceled() {} +func (t *testView) LogStop() {} +func (t *testView) LogResume() {} func (t *testView) LogChanged(ll [][]byte) { t.data = ll t.dataCalled++ } - func (t *testView) LogCleared() { t.clearCalled++ t.data = nil } - func (t *testView) LogFailed(err error) { fmt.Println("LogErr", err) t.errCalled++ diff --git a/internal/model/table.go b/internal/model/table.go index 33295521..cd45b048 100644 --- a/internal/model/table.go +++ b/internal/model/table.go @@ -164,7 +164,7 @@ func (t *Table) Peek() render.TableData { } func (t *Table) updater(ctx context.Context) { - defer log.Debug().Msgf("TABLE-MODEL canceled -- %q", t.gvr) + defer log.Debug().Msgf("TABLE-UPDATER canceled -- %q", t.gvr) bf := backoff.NewExponentialBackOff() bf.InitialInterval, bf.MaxElapsedTime = initRefreshRate, maxReaderRetryInterval diff --git a/internal/model/table_int_test.go b/internal/model/table_int_test.go index 9247256c..8c6c5891 100644 --- a/internal/model/table_int_test.go +++ b/internal/model/table_int_test.go @@ -4,7 +4,7 @@ import ( "context" "encoding/json" "fmt" - "io/ioutil" + "os" "testing" "github.com/derailed/k9s/internal" @@ -139,7 +139,7 @@ func TestTableGenericHydrate(t *testing.T) { // Helpers... func mustLoad(n string) *unstructured.Unstructured { - raw, err := ioutil.ReadFile(fmt.Sprintf("testdata/%s.json", n)) + raw, err := os.ReadFile(fmt.Sprintf("testdata/%s.json", n)) if err != nil { panic(err) } @@ -151,7 +151,7 @@ func mustLoad(n string) *unstructured.Unstructured { } func load(t *testing.T, n string) *unstructured.Unstructured { - raw, err := ioutil.ReadFile(fmt.Sprintf("testdata/%s.json", n)) + raw, err := os.ReadFile(fmt.Sprintf("testdata/%s.json", n)) assert.Nil(t, err) var o unstructured.Unstructured err = json.Unmarshal(raw, &o) @@ -160,7 +160,7 @@ func load(t *testing.T, n string) *unstructured.Unstructured { } func raw(t *testing.T, n string) []byte { - raw, err := ioutil.ReadFile(fmt.Sprintf("testdata/%s.json", n)) + raw, err := os.ReadFile(fmt.Sprintf("testdata/%s.json", n)) assert.Nil(t, err) return raw } diff --git a/internal/model/table_test.go b/internal/model/table_test.go index e41b584b..08d31f44 100644 --- a/internal/model/table_test.go +++ b/internal/model/table_test.go @@ -4,7 +4,7 @@ import ( "context" "encoding/json" "fmt" - "io/ioutil" + "os" "testing" "github.com/derailed/k9s/internal" @@ -122,7 +122,7 @@ func makeTableFactory() tableFactory { } func mustLoad(n string) *unstructured.Unstructured { - raw, err := ioutil.ReadFile(fmt.Sprintf("testdata/%s.json", n)) + raw, err := os.ReadFile(fmt.Sprintf("testdata/%s.json", n)) if err != nil { panic(err) } diff --git a/internal/perf/benchmark.go b/internal/perf/benchmark.go index f5ec8294..c5fd19d9 100644 --- a/internal/perf/benchmark.go +++ b/internal/perf/benchmark.go @@ -5,7 +5,6 @@ import ( "context" "fmt" "io" - "io/ioutil" "net/http" "os" "path/filepath" @@ -138,7 +137,7 @@ func (b *Benchmark) save(cluster string, r io.Reader) error { } }() - bb, err := ioutil.ReadAll(r) + bb, err := io.ReadAll(r) if err != nil { return err } diff --git a/internal/port/ann.go b/internal/port/ann.go new file mode 100644 index 00000000..8d7b12b4 --- /dev/null +++ b/internal/port/ann.go @@ -0,0 +1,20 @@ +package port + +import ( + "errors" +) + +type Annotations map[string]string + +func (a Annotations) PreferredPorts(specs ContainerPortSpecs) (PFAnns, error) { + if len(specs) == 0 { + return nil, errors.New("no exposed ports") + } + + value, ok := a[K9sPortForwardsKey] + if !ok { + return PFAnns{specs[0].ToPFAnn()}, nil + } + + return specs.MatchAnnotations(value), nil +} diff --git a/internal/port/ann_test.go b/internal/port/ann_test.go new file mode 100644 index 00000000..23518857 --- /dev/null +++ b/internal/port/ann_test.go @@ -0,0 +1,93 @@ +package port_test + +import ( + "errors" + "testing" + + "github.com/derailed/k9s/internal/port" + "github.com/stretchr/testify/assert" +) + +func TestPreferredPorts(t *testing.T) { + uu := map[string]struct { + anns port.Annotations + specs port.ContainerPortSpecs + err error + e string + }{ + "no-ports": { + anns: port.Annotations{ + port.K9sPortForwardsKey: "c1::4321:p1", + }, + err: errors.New("no exposed ports"), + }, + "no-annotations": { + specs: port.ContainerPortSpecs{ + {Container: "c1", PortName: "p1", PortNum: "1234"}, + }, + e: "c1::1234:p1", + }, + "single-numb": { + anns: port.Annotations{ + port.K9sPortForwardsKey: "c1::4321:1234", + }, + specs: port.ContainerPortSpecs{ + {Container: "c1", PortName: "p1", PortNum: "1234"}, + }, + e: "c1::4321:1234/1234", + }, + "single-same": { + anns: port.Annotations{ + port.K9sPortForwardsKey: "c1::1234", + }, + specs: port.ContainerPortSpecs{ + {Container: "c1", PortName: "p1", PortNum: "1234"}, + }, + e: "c1::1234:1234/1234", + }, + "single-mismatch": { + anns: port.Annotations{ + port.K9sPortForwardsKey: "c2::4321:p1", + }, + specs: port.ContainerPortSpecs{ + {Container: "c1", PortName: "p1", PortNum: "1234"}, + }, + }, + "multi": { + anns: port.Annotations{ + port.K9sPortForwardsKey: "c1::4321:1234,c1::5432:2345", + }, + specs: port.ContainerPortSpecs{ + {Container: "c1", PortName: "p1", PortNum: "1234"}, + {Container: "c1", PortName: "p2", PortNum: "2345"}, + }, + e: "c1::4321:1234/1234,c1::5432:2345/2345", + }, + "multi-mismatch": { + anns: port.Annotations{ + port.K9sPortForwardsKey: "c1::4321:1234,c1::5432:2345", + }, + specs: port.ContainerPortSpecs{ + {Container: "c1", PortName: "p1", PortNum: "1234"}, + {Container: "c2", PortName: "p3", PortNum: "2345"}, + }, + e: "c1::4321:1234/1234", + }, + } + + for k := range uu { + u := uu[k] + t.Run(k, func(t *testing.T) { + anns, err := u.anns.PreferredPorts(u.specs) + assert.Equal(t, u.err, err) + if err != nil { + return + } + pfs, err := port.ParsePFs(u.e) + if err != nil { + pfs = port.PFAnns{} + } + assert.Equal(t, pfs, anns) + }) + } +} diff --git a/internal/port/co_portspec.go b/internal/port/co_portspec.go new file mode 100644 index 00000000..3328f14b --- /dev/null +++ b/internal/port/co_portspec.go @@ -0,0 +1,147 @@ +package port + +import ( + "strconv" + "strings" + + v1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/util/intstr" +) + +// ContainerPortSpecs represents a container exposed ports. +type ContainerPortSpecs []ContainerPortSpec + +func (c ContainerPortSpecs) Dump() string { + ss := make([]string, 0, len(c)) + for _, spec := range c { + ss = append(ss, spec.String()) + } + + return strings.Join(ss, "\n") +} + +// ToTunnels convert port specs to tunnels. +func (c ContainerPortSpecs) ToTunnels(address string) PortTunnels { + tt := make(PortTunnels, 0, len(c)) + for _, spec := range c { + tt = append(tt, spec.ToTunnel(address)) + } + + return tt +} + +// Find finds a matching container port. +func (c ContainerPortSpecs) Find(pf *PFAnn) (ContainerPortSpec, bool) { + for _, spec := range c { + if spec.Match(pf) { + return spec, true + } + } + + return ContainerPortSpec{}, false +} + +// Match checks if container ports match a pf annotation. +func (c ContainerPortSpecs) Match(pf *PFAnn) bool { + for _, spec := range c { + if spec.Match(pf) { + return true + } + } + + return false +} + +func (c ContainerPortSpecs) MatchAnnotations(s string) PFAnns { + pfs, err := ParsePFs(s) + if err != nil { + return nil + } + + mm := make(PFAnns, 0, len(c)) + for _, pf := range pfs { + if pf.Match(c) { + mm = append(mm, pf) + } + } + + return mm +} + +// FromContainerPorts hydrates from a pod container specification. +func FromContainerPorts(co string, pp []v1.ContainerPort) ContainerPortSpecs { + specs := make(ContainerPortSpecs, 0, len(pp)) + for _, p := range pp { + if p.Protocol != v1.ProtocolTCP { + continue + } + specs = append(specs, NewPortSpec(co, p.Name, p.ContainerPort)) + } + + return specs +} + +// ContainerPortSpec represents a container port specification. +type ContainerPortSpec struct { + Container string + PortName string + PortNum string +} + +// NewPortSpec returns a new instance. +func NewPortSpec(co, portName string, port int32) ContainerPortSpec { + return ContainerPortSpec{ + Container: co, + PortName: portName, + PortNum: strconv.Itoa(int(port)), + } +} + +func (c ContainerPortSpec) ToTunnel(address string) PortTunnel { + return PortTunnel{ + Address: address, + LocalPort: c.PortNum, + ContainerPort: c.PortNum, + } +} + +func (c ContainerPortSpec) Port() intstr.IntOrString { + if c.PortName != "" { + return intstr.Parse(c.PortName) + } + + return intstr.Parse(c.PortNum) +} + +func (c ContainerPortSpec) ToPFAnn() *PFAnn { + return &PFAnn{ + Container: c.Container, + ContainerPort: c.Port(), + LocalPort: c.PortNum, + } +} + +// Match checks if the container spec matches an annotation. +func (c ContainerPortSpec) Match(ann *PFAnn) bool { + if c.Container != ann.Container { + return false + } + + switch ann.ContainerPort.Type { + case intstr.String: + return c.PortName == ann.ContainerPort.String() + case intstr.Int: + return c.PortNum == ann.ContainerPort.String() + default: + return false + } +} + +// String dumps spec to string. +func (c ContainerPortSpec) String() string { + s := c.Container + "::" + c.PortNum + if c.PortName != "" { + s += "(" + c.PortName + ")" + } + return s +} diff --git a/internal/port/co_portspec_test.go b/internal/port/co_portspec_test.go new file mode 100644 index 00000000..3886477a --- /dev/null +++ b/internal/port/co_portspec_test.go @@ -0,0 +1,138 @@ +package port_test + +import ( + "testing" + + "github.com/derailed/k9s/internal/port" + "github.com/stretchr/testify/assert" +) + +func TestContainerPortSpecMatch(t *testing.T) { + uu := map[string]struct { + ann string + spec port.ContainerPortSpec + e bool + }{ + "full": { + ann: "c1::4321:1234", + spec: port.ContainerPortSpec{ + Container: "c1", + PortNum: "1234", + }, + e: true, + }, + "no-port-name": { + ann: "c1::4321:p1/1234", + spec: port.ContainerPortSpec{ + Container: "c1", + PortName: "p1", + PortNum: "1234", + }, + e: true, + }, + "port-name-hosed": { + ann: "c1::4321:blee/1234", + spec: port.ContainerPortSpec{ + Container: "c1", + PortName: "fred", + PortNum: "1234", + }, + }, + "container-name-hosed": { + ann: "c2::4321:fred/1234", + spec: port.ContainerPortSpec{ + Container: "c1", + PortName: "blee", + PortNum: "1234", + }, + }, + "port-num-hosed": { + ann: "c2::4321:1235", + spec: port.ContainerPortSpec{ + Container: "c1", + PortNum: "1234", + }, + }, + } + + for k := range uu { + u := uu[k] + t.Run(k, func(t *testing.T) { + pf, err := port.ParsePF(u.ann) + assert.Nil(t, err) + + assert.Equal(t, u.e, u.spec.Match(pf)) + }) + } +} + +func TestContainerPortSpecString(t *testing.T) { + uu := map[string]struct { + spec port.ContainerPortSpec + e string + }{ + "full": { + spec: port.NewPortSpec("c1", "p1", 1234), + e: "c1::1234(p1)", + }, + "no-name": { + spec: port.NewPortSpec("c1", "", 1234), + e: "c1::1234", + }, + } + + for k := range uu { + u := uu[k] + t.Run(k, func(t *testing.T) { + assert.Equal(t, u.e, u.spec.String()) + }) + } +} + +func TestContainerPortSpecsMatch(t *testing.T) { + uu := map[string]struct { + ann string + specs port.ContainerPortSpecs + e bool + }{ + "full": { + ann: "c1::4321:p1", + specs: port.ContainerPortSpecs{ + port.NewPortSpec("c1", "p1", 1234), + port.NewPortSpec("c2", "p2", 1235), + }, + e: true, + }, + "no-name": { + ann: "c1::4321", + specs: port.ContainerPortSpecs{ + port.NewPortSpec("c1", "", 4321), + port.NewPortSpec("c2", "p2", 1235), + }, + e: true, + }, + "name-hosed": { + ann: "c1::4321:p4", + specs: port.ContainerPortSpecs{ + port.NewPortSpec("c1", "p1", 1234), + port.NewPortSpec("c2", "p2", 1235), + }, + }, + "numb-hosed": { + ann: "c1::4321:1235", + specs: port.ContainerPortSpecs{ + port.NewPortSpec("c1", "p1", 1234), + port.NewPortSpec("c2", "p2", 1236), + }, + }, + } + + for k := range uu { + u := uu[k] + t.Run(k, func(t *testing.T) { + pf, err := port.ParsePF(u.ann) + assert.Nil(t, err) + assert.Equal(t, u.e, u.specs.Match(pf)) + }) + } +} diff --git a/internal/port/pf.go b/internal/port/pf.go new file mode 100644 index 00000000..96ac3d3f --- /dev/null +++ b/internal/port/pf.go @@ -0,0 +1,102 @@ +package port + +import ( + "errors" + "fmt" + "regexp" + "strings" + + "k8s.io/apimachinery/pkg/util/intstr" +) + +const ( + // K9sAutoPortForwardKey represents an auto portforwards annotation. + K9sAutoPortForwardsKey = "k9scli.io/auto-portforwards" + + // K9sPortForwardKey represents a portforwards annotation. + K9sPortForwardsKey = "k9scli.io/portforwards" +) + +var pfRX = regexp.MustCompile(`\A([\w-]+)::(\d*):?(\d*|[\w-]*)/?(\d+)?\z`) + +// PFAnn represents a portforward annotation value. +// Shape: container/portname|portNum:localPort +type PFAnn struct { + Container string + ContainerPort intstr.IntOrString + LocalPort string + containerPortNum string +} + +// ParsePF hydrate a portforward annotation from string. +func ParsePF(ann string) (*PFAnn, error) { + var pf PFAnn + r := pfRX.FindStringSubmatch(strings.TrimSpace(ann)) + if len(r) < 4 { + return &pf, fmt.Errorf("invalid pf annotation %s", ann) + } + pf.Container = r[1] + pf.LocalPort, pf.ContainerPort = r[2], intstr.Parse(r[3]) + if r[3] == "" { + pf.ContainerPort = intstr.Parse(pf.LocalPort) + } + + // Testing only! + if len(r) == 5 && r[4] != "" { + pf.containerPortNum = r[4] + } + if pf.LocalPort == "" { + pf.LocalPort = pf.containerPortNum + } + + return &pf, nil +} + +// Match checks if annotation matches any of the container ports. +func (p *PFAnn) Match(ss ContainerPortSpecs) bool { + for _, s := range ss { + if s.Match(p) { + p.containerPortNum = s.PortNum + return true + } + } + + return false +} + +func (p *PFAnn) AsSpec() string { + s := p.Container + "::" + if p.containerPortNum != "" { + return s + p.containerPortNum + } + return s + p.LocalPort +} + +// String dumps the annotation. +func (p *PFAnn) String() string { + return p.Container + "::" + p.LocalPort + ":" + p.containerPortNum +} + +func (p *PFAnn) PortNum() (string, error) { + if p.ContainerPort.Type == intstr.Int { + return p.ContainerPort.String(), nil + } + if p.containerPortNum != "" { + return p.containerPortNum, nil + } + + return "", errors.New("no port number assigned") +} + +func (p *PFAnn) ToTunnel(address string) (PortTunnel, error) { + var pt PortTunnel + port, err := p.PortNum() + if err != nil { + return pt, err + } + + pt.Address, pt.Container = address, p.Container + pt.ContainerPort, pt.LocalPort = port, p.LocalPort + + return pt, nil +} diff --git a/internal/port/pf_test.go b/internal/port/pf_test.go new file mode 100644 index 00000000..0505af17 --- /dev/null +++ b/internal/port/pf_test.go @@ -0,0 +1,207 @@ +package port_test + +import ( + "errors" + "testing" + + "github.com/derailed/k9s/internal/port" + "github.com/stretchr/testify/assert" + "k8s.io/apimachinery/pkg/util/intstr" +) + +func TestParsePF(t *testing.T) { + uu := map[string]struct { + exp string + container string + containerPort intstr.IntOrString + localPort string + e error + }{ + "full-numbs": { + exp: "c1::4321:1234", + container: "c1", + containerPort: intstr.Parse("1234"), + localPort: "4321", + }, + "full-named": { + exp: "c1::4321:p1/1234", + container: "c1", + containerPort: intstr.Parse("p1"), + localPort: "4321", + }, + "just-named": { + exp: "c1::p1/1234", + container: "c1", + containerPort: intstr.Parse("p1"), + localPort: "1234", + }, + "just-num": { + exp: "c1::1234", + container: "c1", + containerPort: intstr.Parse("1234"), + localPort: "1234", + }, + "toast": { + exp: "c1:4321:1234", + e: errors.New("invalid pf annotation c1:4321:1234"), + }, + } + + for k := range uu { + u := uu[k] + t.Run(k, func(t *testing.T) { + pf, err := port.ParsePF(u.exp) + assert.Equal(t, u.e, err) + if err != nil { + return + } + assert.Equal(t, u.container, pf.Container) + assert.Equal(t, u.containerPort, pf.ContainerPort) + assert.Equal(t, u.localPort, pf.LocalPort) + }) + } +} + +func TestPFMatch(t *testing.T) { + uu := map[string]struct { + exp string + specs port.ContainerPortSpecs + err error + e bool + }{ + "match": { + exp: "c1::1234", + specs: port.ContainerPortSpecs{ + {Container: "c1", PortNum: "1234"}, + }, + e: true, + }, + "match-portnum": { + exp: "c1::4321:1234", + specs: port.ContainerPortSpecs{ + {Container: "c1", PortNum: "1234"}, + }, + e: true, + }, + "no-match": { + exp: "c1::1235", + specs: port.ContainerPortSpecs{ + {Container: "c1", PortNum: "1234"}, + }, + }, + } + + for k := range uu { + u := uu[k] + t.Run(k, func(t *testing.T) { + pf, err := port.ParsePF(u.exp) + assert.Equal(t, u.err, err) + if err != nil { + return + } + assert.Equal(t, u.e, pf.Match(u.specs)) + }) + } +} + +func TestPFPortNum(t *testing.T) { + uu := map[string]struct { + exp string + err error + e string + }{ + "port-name": { + exp: "c1::4321:1234", + e: "1234", + }, + "port-number": { + exp: "c1::4321:1234", + e: "1234", + }, + "missing-port-number": { + exp: "c1::p1", + err: errors.New("no port number assigned"), + }, + } + + for k := range uu { + u := uu[k] + t.Run(k, func(t *testing.T) { + pf, err := port.ParsePF(u.exp) + assert.Nil(t, err) + n, err := pf.PortNum() + assert.Equal(t, u.err, err) + if err != nil { + return + } + assert.Equal(t, u.e, n) + }) + } +} + +func TestPFToTunnel(t *testing.T) { + uu := map[string]struct { + exp string + err error + e port.PortTunnel + }{ + "port-name": { + exp: "c1::p1/1234", + e: port.PortTunnel{ + Address: "blee", + Container: "c1", + LocalPort: "1234", + ContainerPort: "1234", + }, + }, + "port-numb": { + exp: "c1::4321:1234", + e: port.PortTunnel{ + Address: "blee", + Container: "c1", + LocalPort: "4321", + ContainerPort: "1234", + }, + }, + } + + for k := range uu { + u := uu[k] + t.Run(k, func(t *testing.T) { + pf, err := port.ParsePF(u.exp) + assert.Nil(t, err) + pt, err := pf.ToTunnel("blee") + assert.Equal(t, u.err, err) + if err != nil { + return + } + assert.Equal(t, u.e, pt) + }) + } +} + +func TestPFString(t *testing.T) { + uu := map[string]struct { + exp string + err error + e string + }{ + "port-name": { + exp: "c1::p1/1234", + e: "c1::1234:1234", + }, + "port-numb": { + exp: "c1::4321:1234/1234", + e: "c1::4321:1234", + }, + } + + for k := range uu { + u := uu[k] + t.Run(k, func(t *testing.T) { + pf, err := port.ParsePF(u.exp) + assert.Nil(t, err) + assert.Equal(t, u.e, pf.String()) + }) + } +} diff --git a/internal/port/pfs.go b/internal/port/pfs.go new file mode 100644 index 00000000..70509d01 --- /dev/null +++ b/internal/port/pfs.go @@ -0,0 +1,92 @@ +package port + +import ( + "fmt" + "strings" +) + +// PortCheck checks if port is free on host. +type PortChecker func(PortTunnel) bool + +// PFAnns represents a collection of port forward annotations. +type PFAnns []*PFAnn + +// ToPortSpec returns a container port and local port definitions. +func (aa PFAnns) ToPortSpec(pp ContainerPortSpecs) (string, string) { + specs, lps := make([]string, 0, len(aa)), make([]string, 0, len(aa)) + for _, a := range aa { + specs = append(specs, a.AsSpec()) + if a.LocalPort == "" { + if spec, ok := pp.Find(a); ok { + a.LocalPort = spec.PortNum + } + } + if a.LocalPort != "" { + lps = append(lps, a.LocalPort) + } + } + + return strings.Join(specs, ","), strings.Join(lps, ",") +} + +func (aa PFAnns) ToTunnels(address string, pp ContainerPortSpecs, available PortChecker) (PortTunnels, error) { + pts := make(PortTunnels, 0, len(aa)) + for _, a := range aa { + if !a.Match(pp) { + return nil, fmt.Errorf("ann does not match container port specs") + } + pt, err := a.ToTunnel(address) + if err != nil { + return pts, err + } + if !available(pt) { + return pts, fmt.Errorf("Port %s is not available on host", pt.LocalPort) + } + pts = append(pts, pt) + } + + return pts, nil +} + +// ParsePFs hydrates a collection of portforward annotations. +func ParsePFs(ann string) (PFAnns, error) { + ss := strings.Split(ann, ",") + pp := make(PFAnns, 0, len(ss)) + for _, s := range ss { + f, err := ParsePF(s) + if err != nil { + return nil, err + } + pp = append(pp, f) + } + + return pp, nil +} + +func ToTunnels(address, specs, localPorts string) (PortTunnels, error) { + pp, lps := strings.Split(specs, ","), strings.Split(localPorts, ",") + + if len(pp) != len(lps) { + return nil, fmt.Errorf("spec to local port count mismatch. Expected %d but got %d", len(pp), len(lps)) + } + + pts := make(PortTunnels, 0, len(pp)) + for i, p := range pp { + a, err := ParsePF(p) + if err != nil { + return nil, err + } + n, err := a.PortNum() + if err != nil { + return nil, err + } + pts = append(pts, PortTunnel{ + Address: address, + Container: a.Container, + ContainerPort: n, + LocalPort: lps[i], + }) + } + + return pts, nil +} diff --git a/internal/port/pfs_test.go b/internal/port/pfs_test.go new file mode 100644 index 00000000..200ed451 --- /dev/null +++ b/internal/port/pfs_test.go @@ -0,0 +1,185 @@ +package port_test + +import ( + "errors" + "testing" + + "github.com/derailed/k9s/internal/port" + "github.com/stretchr/testify/assert" + "k8s.io/apimachinery/pkg/util/intstr" +) + +func TestParsePFs(t *testing.T) { + uu := map[string]struct { + exp string + pfs port.PFAnns + e error + }{ + "single": { + exp: "c2::4321:1234", + pfs: port.PFAnns{ + {Container: "c2", ContainerPort: intstr.Parse("1234"), LocalPort: "4321"}, + }, + }, + "multi": { + exp: "c1::4321:1234,c2::6666:6543", + pfs: port.PFAnns{ + {Container: "c1", ContainerPort: intstr.Parse("1234"), LocalPort: "4321"}, + {Container: "c2", ContainerPort: intstr.Parse("6543"), LocalPort: "6666"}, + }, + }, + "spaces": { + exp: " c1::4321:1234 , c2::6666:6543 ", + pfs: port.PFAnns{ + {Container: "c1", ContainerPort: intstr.Parse("1234"), LocalPort: "4321"}, + {Container: "c2", ContainerPort: intstr.Parse("6543"), LocalPort: "6666"}, + }, + }, + "toast": { + exp: "c1::p1:1234,c2::4321", + e: errors.New("invalid pf annotation c1::p1:1234"), + }, + } + + for k := range uu { + u := uu[k] + t.Run(k, func(t *testing.T) { + pfs, err := port.ParsePFs(u.exp) + assert.Equal(t, u.e, err) + if err != nil { + return + } + assert.Equal(t, u.pfs, pfs) + }) + } +} + +func TestPFsToTunnel(t *testing.T) { + uu := map[string]struct { + exp string + specs port.ContainerPortSpecs + pts port.PortTunnels + e error + }{ + "single": { + exp: "c2::4321:1234", + specs: port.ContainerPortSpecs{ + {Container: "c2", PortName: "p1", PortNum: "1234"}, + }, + pts: port.PortTunnels{ + {Address: "fred", Container: "c2", ContainerPort: "1234", LocalPort: "4321"}, + }, + }, + "hosed": { + exp: "c2::p2", + specs: port.ContainerPortSpecs{ + {Container: "c2", PortName: "p1", PortNum: "1234"}, + }, + pts: port.PortTunnels{ + {Address: "fred", Container: "c2", ContainerPort: "1234", LocalPort: "4321"}, + }, + e: errors.New("ann does not match container port specs"), + }, + } + + f := func(port.PortTunnel) bool { + return true + } + + for k := range uu { + u := uu[k] + t.Run(k, func(t *testing.T) { + pfs, err := port.ParsePFs(u.exp) + assert.Nil(t, err) + pts, err := pfs.ToTunnels("fred", u.specs, f) + assert.Equal(t, u.e, err) + if err != nil { + return + } + assert.Equal(t, u.pts, pts) + }) + } +} + +func TestPFsToPortSpec(t *testing.T) { + uu := map[string]struct { + exp string + spec, port string + specs port.ContainerPortSpecs + e error + }{ + "single": { + exp: "c2::4321:p2/1234", + spec: "c2::1234", + port: "4321", + specs: port.ContainerPortSpecs{ + {Container: "c2", PortNum: "1234"}, + }, + }, + } + + for k := range uu { + u := uu[k] + t.Run(k, func(t *testing.T) { + pfs, err := port.ParsePFs(u.exp) + assert.Equal(t, u.e, err) + if err != nil { + return + } + spec, port := pfs.ToPortSpec(u.specs) + assert.Equal(t, u.spec, spec) + assert.Equal(t, u.port, port) + }) + } +} + +func TestToTunnels(t *testing.T) { + uu := map[string]struct { + specs, ports string + tunnels port.PortTunnels + err error + }{ + "single": { + specs: "c2::4321:p2/1234", + ports: "4321", + tunnels: port.PortTunnels{ + { + Address: "blee", + LocalPort: "4321", + Container: "c2", + ContainerPort: "1234", + }, + }, + }, + "multi": { + specs: "c1::5432:2345/2345,c2::4321:p2/1234", + ports: "5432,4321", + tunnels: port.PortTunnels{ + { + Address: "blee", + LocalPort: "5432", + Container: "c1", + ContainerPort: "2345", + }, + { + Address: "blee", + LocalPort: "4321", + Container: "c2", + ContainerPort: "1234", + }, + }, + }, + } + + for k := range uu { + u := uu[k] + t.Run(k, func(t *testing.T) { + tt, err := port.ToTunnels("blee", u.specs, u.ports) + assert.Equal(t, u.err, err) + if err != nil { + return + } + assert.Equal(t, u.tunnels, tt) + }) + } +} diff --git a/internal/port/tunnel.go b/internal/port/tunnel.go new file mode 100644 index 00000000..99b56281 --- /dev/null +++ b/internal/port/tunnel.go @@ -0,0 +1,49 @@ +package port + +import ( + "fmt" + "net" +) + +// PortTunnels represents a collection of tunnels. +type PortTunnels []PortTunnel + +func (t PortTunnels) CheckAvailable() error { + for _, pt := range t { + if !IsPortFree(pt) { + return fmt.Errorf("port %s is not available on host", pt.LocalPort) + } + } + + return nil +} + +// PortTunnel represents a host tunnel port mapper. +type PortTunnel struct { + Address, Container, LocalPort, ContainerPort string +} + +func NewPortTunnel(a, co, lp, cp string) PortTunnel { + return PortTunnel{ + Address: a, + Container: co, + LocalPort: lp, + ContainerPort: cp, + } +} + +// PortMap returns a port mapping. +func (t PortTunnel) PortMap() string { + if t.LocalPort == "" { + t.LocalPort = t.ContainerPort + } + return t.LocalPort + ":" + t.ContainerPort +} + +func IsPortFree(t PortTunnel) bool { + s, err := net.Listen("tcp", fmt.Sprintf("%s:%s", t.Address, t.LocalPort)) + if err != nil { + return false + } + return s.Close() == nil +} diff --git a/internal/port/tunnel_test.go b/internal/port/tunnel_test.go new file mode 100644 index 00000000..d88dab58 --- /dev/null +++ b/internal/port/tunnel_test.go @@ -0,0 +1,32 @@ +package port_test + +import ( + "testing" + + "github.com/derailed/k9s/internal/port" + "github.com/stretchr/testify/assert" +) + +func TestPortTunnelMap(t *testing.T) { + uu := map[string]struct { + pt port.PortTunnel + coPort, locPort string + e string + }{ + "plain": { + pt: port.PortTunnel{ + Address: "localhost", + LocalPort: "1234", + ContainerPort: "4321", + }, + e: "1234:4321", + }, + } + + for k := range uu { + u := uu[k] + t.Run(k, func(t *testing.T) { + assert.Equal(t, u.e, u.pt.PortMap()) + }) + } +} diff --git a/internal/render/benchmark.go b/internal/render/benchmark.go index 4b586262..710660ea 100644 --- a/internal/render/benchmark.go +++ b/internal/render/benchmark.go @@ -3,7 +3,6 @@ package render import ( "errors" "fmt" - "io/ioutil" "os" "regexp" "strconv" @@ -97,7 +96,7 @@ func (Benchmark) diagnose(ns string, ff Fields) error { // Helpers... func (Benchmark) readFile(file string) (string, error) { - data, err := ioutil.ReadFile(file) + data, err := os.ReadFile(file) if err != nil { return "", err } diff --git a/internal/render/benchmark_int_test.go b/internal/render/benchmark_int_test.go index 6c7c5384..4fe09639 100644 --- a/internal/render/benchmark_int_test.go +++ b/internal/render/benchmark_int_test.go @@ -1,7 +1,7 @@ package render import ( - "io/ioutil" + "os" "testing" "github.com/rs/zerolog" @@ -38,7 +38,7 @@ func TestAugmentRow(t *testing.T) { for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { - data, err := ioutil.ReadFile(u.file) + data, err := os.ReadFile(u.file) assert.Nil(t, err) fields := make(Fields, 8) diff --git a/internal/render/dir.go b/internal/render/dir.go index 12a3e941..d955fb98 100644 --- a/internal/render/dir.go +++ b/internal/render/dir.go @@ -35,10 +35,10 @@ func (Dir) Render(o interface{}, ns string, r *Row) error { } name := "🦄 " - if d.Info.IsDir() { + if d.Entry.IsDir() { name = "📁 " } - name += d.Info.Name() + name += d.Entry.Name() r.ID, r.Fields = d.Path, append(r.Fields, name) return nil @@ -49,8 +49,8 @@ func (Dir) Render(o interface{}, ns string, r *Row) error { // DirRes represents an alias resource. type DirRes struct { - Info os.FileInfo - Path string + Entry os.DirEntry + Path string } // GetObjectKind returns a schema object. diff --git a/internal/render/port_forward_test.go b/internal/render/port_forward_test.go index 81c9d7cf..3e422c15 100644 --- a/internal/render/port_forward_test.go +++ b/internal/render/port_forward_test.go @@ -26,7 +26,7 @@ func TestPortForwardRender(t *testing.T) { "blee", "fred", "co", - "p1", + "p1:p2", "http://0.0.0.0:p1/", "1", "1", @@ -47,8 +47,8 @@ func (f fwd) Container() string { return "co" } -func (f fwd) Ports() []string { - return []string{"p1"} +func (f fwd) Port() string { + return "p1:p2" } func (f fwd) Active() bool { diff --git a/internal/render/portforward.go b/internal/render/portforward.go index 75a331c1..4f08a3f7 100644 --- a/internal/render/portforward.go +++ b/internal/render/portforward.go @@ -19,7 +19,7 @@ type Forwarder interface { Container() string // Ports returns container exposed ports. - Ports() []string + Port() string // Active returns forwarder current state. Active() bool @@ -60,7 +60,7 @@ func (f PortForward) Render(o interface{}, gvr string, r *Row) error { return fmt.Errorf("expecting a ForwardRes but got %T", o) } - ports := strings.Split(pf.Ports()[0], ":") + ports := strings.Split(pf.Port(), ":") ns, n := client.Namespaced(pf.Path()) r.ID = pf.Path() @@ -68,7 +68,7 @@ func (f PortForward) Render(o interface{}, gvr string, r *Row) error { ns, trimContainer(n), pf.Container(), - strings.Join(pf.Ports(), ","), + pf.Port(), UrlFor(pf.Config.Host, pf.Config.Path, ports[0]), AsThousands(int64(pf.Config.C)), AsThousands(int64(pf.Config.N)), @@ -82,11 +82,13 @@ func (f PortForward) Render(o interface{}, gvr string, r *Row) error { // Helpers... func trimContainer(n string) string { - tokens := strings.Split(n, ":") + tokens := strings.Split(n, "|") if len(tokens) == 0 { return n } - return tokens[0] + _, name := client.Namespaced(tokens[0]) + + return name } // UrlFor computes fq url for a given benchmark configuration. diff --git a/internal/render/render_test.go b/internal/render/render_test.go index cd2f6492..20a1c331 100644 --- a/internal/render/render_test.go +++ b/internal/render/render_test.go @@ -3,7 +3,7 @@ package render_test import ( "encoding/json" "fmt" - "io/ioutil" + "os" "testing" "github.com/stretchr/testify/assert" @@ -13,7 +13,7 @@ import ( // Helpers... func load(t testing.TB, n string) *unstructured.Unstructured { - raw, err := ioutil.ReadFile(fmt.Sprintf("testdata/%s.json", n)) + raw, err := os.ReadFile(fmt.Sprintf("testdata/%s.json", n)) assert.Nil(t, err) var o unstructured.Unstructured diff --git a/internal/ui/app.go b/internal/ui/app.go index 39f57e12..8a2cfb8e 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -41,7 +41,7 @@ func NewApp(cfg *config.Config, context string) *App { a.views = map[string]tview.Primitive{ "menu": NewMenu(a.Styles), "logo": NewLogo(a.Styles), - "prompt": NewPrompt(a.Config.K9s.NoIcons, a.Styles), + "prompt": NewPrompt(&a, a.Config.K9s.NoIcons, a.Styles), "crumbs": NewCrumbs(a.Styles), } @@ -60,6 +60,9 @@ func (a *App) Init() { // QueueUpdate queues up a ui action. func (a *App) QueueUpdate(f func()) { + if a.Application == nil { + return + } go func() { a.Application.QueueUpdate(f) }() @@ -67,6 +70,9 @@ func (a *App) QueueUpdate(f func()) { // QueueUpdateDraw queues up a ui action and redraw the ui. func (a *App) QueueUpdateDraw(f func()) { + if a.Application == nil { + return + } go func() { a.Application.QueueUpdateDraw(f) }() diff --git a/internal/ui/config.go b/internal/ui/config.go index b1044720..23a6c195 100644 --- a/internal/ui/config.go +++ b/internal/ui/config.go @@ -50,7 +50,7 @@ func (c *Configurator) CustomViewsWatcher(ctx context.Context, s synchronizer) e log.Warn().Err(err).Msg("CustomView watcher failed") return case <-ctx.Done(): - log.Debug().Msgf("CustomViewWatcher Done `%s!!", config.K9sViewConfigFile) + log.Debug().Msgf("CustomViewWatcher CANCELED `%s!!", config.K9sViewConfigFile) if err := w.Close(); err != nil { log.Error().Err(err).Msg("Closing CustomView watcher") } @@ -102,7 +102,7 @@ func (c *Configurator) StylesWatcher(ctx context.Context, s synchronizer) error log.Info().Err(err).Msg("Skin watcher failed") return case <-ctx.Done(): - log.Debug().Msgf("SkinWatcher Done `%s!!", c.skinFile) + log.Debug().Msgf("SkinWatcher CANCELED `%s!!", c.skinFile) if err := w.Close(); err != nil { log.Error().Err(err).Msg("Closing Skin watcher") } diff --git a/internal/ui/dialog/delete.go b/internal/ui/dialog/delete.go index 169270bd..b107560b 100644 --- a/internal/ui/dialog/delete.go +++ b/internal/ui/dialog/delete.go @@ -23,10 +23,10 @@ func ShowDelete(styles config.Dialog, pages *ui.Pages, msg string, ok okFunc, ca SetButtonTextColor(styles.ButtonFgColor.Color()). SetLabelColor(styles.LabelFgColor.Color()). SetFieldTextColor(styles.FieldFgColor.Color()) - f.AddCheckbox("Cascade:", cascade, func(checked bool) { + f.AddCheckbox("Cascade:", cascade, func(_ string, checked bool) { cascade = checked }) - f.AddCheckbox("Force:", force, func(checked bool) { + f.AddCheckbox("Force:", force, func(_ string, checked bool) { force = checked }) f.AddButton("Cancel", func() { diff --git a/internal/ui/flash.go b/internal/ui/flash.go index 1c2f64a6..ba962742 100644 --- a/internal/ui/flash.go +++ b/internal/ui/flash.go @@ -52,7 +52,7 @@ func (f *Flash) StylesChanged(s *config.Styles) { // Watch watches for flash changes. func (f *Flash) Watch(ctx context.Context, c model.FlashChan) { - defer log.Debug().Msgf("Flash Canceled!") + defer log.Debug().Msgf("Flash Watch Canceled!") for { select { case <-ctx.Done(): diff --git a/internal/ui/prompt.go b/internal/ui/prompt.go index b5e5d35f..70362090 100644 --- a/internal/ui/prompt.go +++ b/internal/ui/prompt.go @@ -71,6 +71,7 @@ type PromptModel interface { type Prompt struct { *tview.TextView + app *App noIcons bool icon rune styles *config.Styles @@ -79,8 +80,9 @@ type Prompt struct { } // NewPrompt returns a new command view. -func NewPrompt(noIcons bool, styles *config.Styles) *Prompt { +func NewPrompt(app *App, noIcons bool, styles *config.Styles) *Prompt { p := Prompt{ + app: app, styles: styles, noIcons: noIcons, TextView: tview.NewTextView(), @@ -183,8 +185,15 @@ func (p *Prompt) activate() { } func (p *Prompt) update(s string) { - p.Clear() - p.write(s, "") + f := func() { + p.Clear() + p.write(s, "") + } + if p.app == nil { + f() + return + } + p.app.QueueUpdate(f) } func (p *Prompt) suggest(text, suggestion string) { diff --git a/internal/ui/prompt_test.go b/internal/ui/prompt_test.go index e381cc85..2b8238ab 100644 --- a/internal/ui/prompt_test.go +++ b/internal/ui/prompt_test.go @@ -10,7 +10,7 @@ import ( ) func TestCmdNew(t *testing.T) { - v := ui.NewPrompt(true, config.NewStyles()) + v := ui.NewPrompt(nil, true, config.NewStyles()) model := model.NewFishBuff(':', model.CommandBuffer) v.SetModel(model) model.AddListener(v) @@ -23,7 +23,7 @@ func TestCmdNew(t *testing.T) { func TestCmdUpdate(t *testing.T) { model := model.NewFishBuff(':', model.CommandBuffer) - v := ui.NewPrompt(true, config.NewStyles()) + v := ui.NewPrompt(nil, true, config.NewStyles()) v.SetModel(model) model.AddListener(v) @@ -36,7 +36,7 @@ func TestCmdUpdate(t *testing.T) { func TestCmdMode(t *testing.T) { model := model.NewFishBuff(':', model.CommandBuffer) - v := ui.NewPrompt(true, config.NewStyles()) + v := ui.NewPrompt(&ui.App{}, true, config.NewStyles()) v.SetModel(model) model.AddListener(v) diff --git a/internal/view/actions.go b/internal/view/actions.go index e81de4f3..943c9747 100644 --- a/internal/view/actions.go +++ b/internal/view/actions.go @@ -136,6 +136,7 @@ func pluginAction(r Runner, p config.Plugin) ui.ActionHandler { clear: true, binary: p.Command, background: p.Background, + pipes: p.Pipes, args: args, } if run(r.App(), opts) { diff --git a/internal/view/app.go b/internal/view/app.go index aabc6280..c295b60a 100644 --- a/internal/view/app.go +++ b/internal/view/app.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "os/signal" + "runtime" "sort" "strings" "sync/atomic" @@ -107,8 +108,10 @@ func (a *App) Init(version string, rate int) error { a.clusterModel = model.NewClusterInfo(a.factory, a.version) a.clusterModel.AddListener(a.clusterInfo()) a.clusterModel.AddListener(a.statusIndicator()) - a.clusterModel.Refresh() - a.clusterInfo().Init() + if a.Conn().ConnectionOK() { + a.clusterModel.Refresh() + a.clusterInfo().Init() + } a.command = NewCommand(a) if err := a.command.Init(); err != nil { @@ -185,6 +188,7 @@ func (a *App) keyboard(evt *tcell.EventKey) *tcell.EventKey { func (a *App) bindKeys() { a.AddActions(ui.KeyActions{ + ui.KeyShiftG: ui.NewSharedKeyAction("DumpGOR", a.dumpGOR, false), tcell.KeyCtrlE: ui.NewSharedKeyAction("ToggleHeader", a.toggleHeaderCmd, false), tcell.KeyCtrlG: ui.NewSharedKeyAction("toggleCrumbs", a.toggleCrumbsCmd, false), ui.KeyHelp: ui.NewSharedKeyAction("Help", a.helpCmd, false), @@ -193,6 +197,13 @@ func (a *App) bindKeys() { }) } +func (a *App) dumpGOR(evt *tcell.EventKey) *tcell.EventKey { + bb := make([]byte, 5_000_000) + runtime.Stack(bb, true) + log.Debug().Msgf("GOR\n%s", string(bb)) + return evt +} + // ActiveView returns the currently active view. func (a *App) ActiveView() model.Component { return a.Content.GetPrimitive("main").(model.Component) diff --git a/internal/view/app_test.go b/internal/view/app_test.go index e2b7470a..4ff96086 100644 --- a/internal/view/app_test.go +++ b/internal/view/app_test.go @@ -12,5 +12,5 @@ func TestAppNew(t *testing.T) { a := view.NewApp(config.NewConfig(ks{})) a.Init("blee", 10) - assert.Equal(t, 10, len(a.GetActions())) + assert.Equal(t, 11, len(a.GetActions())) } diff --git a/internal/view/benchmark.go b/internal/view/benchmark.go index 892cdb23..f9b795d0 100644 --- a/internal/view/benchmark.go +++ b/internal/view/benchmark.go @@ -2,7 +2,7 @@ package view import ( "context" - "io/ioutil" + "os" "path/filepath" "strings" @@ -71,7 +71,7 @@ func benchDir(cfg *config.Config) string { } func readBenchFile(cfg *config.Config, n string) (string, error) { - data, err := ioutil.ReadFile(filepath.Join(benchDir(cfg), n)) + data, err := os.ReadFile(filepath.Join(benchDir(cfg), n)) if err != nil { return "", err } diff --git a/internal/view/browser.go b/internal/view/browser.go index 72b62940..133e7a82 100644 --- a/internal/view/browser.go +++ b/internal/view/browser.go @@ -7,6 +7,7 @@ import ( "sort" "strconv" "strings" + "sync" "time" "github.com/derailed/k9s/internal" @@ -31,6 +32,7 @@ type Browser struct { accessor dao.Accessor contextFn ContextFunc cancelFn context.CancelFunc + mx sync.RWMutex } // NewBrowser returns a new browser. @@ -140,10 +142,14 @@ func (b *Browser) Start() { // Stop terminates browser updates. func (b *Browser) Stop() { - if b.cancelFn != nil { - b.cancelFn() - b.cancelFn = nil + b.mx.Lock() + { + if b.cancelFn != nil { + b.cancelFn() + b.cancelFn = nil + } } + b.mx.Unlock() b.GetModel().RemoveListener(b) b.CmdBuff().RemoveListener(b) b.Table.Stop() @@ -213,7 +219,12 @@ func (b *Browser) Aliases() []string { // TableDataChanged notifies view new data is available. func (b *Browser) TableDataChanged(data render.TableData) { - if !b.app.ConOK() || b.cancelFn == nil || !b.app.IsRunning() { + var cancel context.CancelFunc + b.mx.RLock() + cancel = b.cancelFn + b.mx.RUnlock() + + if !b.app.ConOK() || cancel == nil || !b.app.IsRunning() { return } diff --git a/internal/view/cluster_info.go b/internal/view/cluster_info.go index 6baaefa7..ae047759 100644 --- a/internal/view/cluster_info.go +++ b/internal/view/cluster_info.go @@ -2,6 +2,7 @@ package view import ( "fmt" + "runtime" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/config" @@ -102,7 +103,7 @@ func (c *ClusterInfo) ClusterInfoChanged(prev, curr model.ClusterMeta) { c.app.QueueUpdateDraw(func() { c.Clear() c.layout() - row := c.setCell(0, curr.Context) + row := c.setCell(0, fmt.Sprintf("%s [%d]", curr.Context, runtime.NumGoroutine())) row = c.setCell(row, curr.Cluster) row = c.setCell(row, curr.User) if curr.K9sLatest != "" { diff --git a/internal/view/container.go b/internal/view/container.go index 33023d46..c111949f 100644 --- a/internal/view/container.go +++ b/internal/view/container.go @@ -4,11 +4,11 @@ import ( "context" "errors" "fmt" - "strings" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/dao" + "github.com/derailed/k9s/internal/port" "github.com/derailed/k9s/internal/render" "github.com/derailed/k9s/internal/ui" "github.com/gdamore/tcell/v2" @@ -178,89 +178,65 @@ func (c *Container) portFwdCmd(evt *tcell.EventKey) *tcell.EventKey { return nil } - ports, ok := c.isForwardable(path) + ports, ann, ok := c.listForwardable(path) if !ok { return nil } - ShowPortForwards(c, c.GetTable().Path, ports, "", startFwdCB) + ShowPortForwards(c, c.GetTable().Path, ports, ann, startFwdCB) return nil } -func (c *Container) isForwardable(path string) ([]string, bool) { - po, err := fetchPod(c.App().factory, c.GetTable().Path) - if err != nil { - return nil, false - } - - var co *v1.Container - cc := po.Spec.Containers - for i := range cc { - if cc[i].Name == path { - co = &cc[i] - } - } - if co == nil { - log.Error().Err(fmt.Errorf("unable to locate container named %q", path)) - return nil, false - } - +func checkRunningStatus(co string, ss []v1.ContainerStatus) error { var cs *v1.ContainerStatus - ss := po.Status.ContainerStatuses for i := range ss { - if ss[i].Name == path { + if ss[i].Name == co { cs = &ss[i] + break } } if cs == nil { - log.Error().Err(fmt.Errorf("unable to locate container status for %q", path)) - return nil, false + return fmt.Errorf("unable to locate container status for %q", co) } if render.ToContainerState(cs.State) != "Running" { - c.App().Flash().Err(fmt.Errorf("Container %s is not running?", path)) - return nil, false + return fmt.Errorf("Container %s is not running?", co) } - portC := render.ToContainerPorts(co.Ports) - ports := strings.Split(portC, ",") - if len(ports) == 0 { + return nil +} + +func locateContainer(co string, cc []v1.Container) (*v1.Container, error) { + for i := range cc { + if cc[i].Name == co { + return &cc[i], nil + } + } + return nil, fmt.Errorf("unable to locate container named %q", co) +} + +func (c *Container) listForwardable(path string) (port.ContainerPortSpecs, map[string]string, bool) { + po, err := fetchPod(c.App().factory, c.GetTable().Path) + if err != nil { + return nil, nil, false + } + + co, err := locateContainer(path, po.Spec.Containers) + if err != nil { + c.App().Flash().Err(err) + return nil, nil, false + } + + if err := checkRunningStatus(path, po.Status.ContainerStatuses); err != nil { + c.App().Flash().Err(err) + return nil, nil, false + } + + exposedPorts := port.FromContainerPorts(path, co.Ports) + if len(exposedPorts) == 0 { c.App().Flash().Err(errors.New("Container exposes no ports")) - return nil, false + return nil, nil, false } - pp := make([]string, 0, len(ports)) - container, port, ok := parsePFAnn(po.Annotations[AnnDefaultPF]) - if ok && container == path { - if index := indexOfPort(ports, port); index != -1 { - pp = append(pp, path+"/"+port) - ports = append(ports[:index], ports[index+1:]...) - } - } - - for _, p := range ports { - if !isTCPPort(p) { - continue - } - pp = append(pp, path+"/"+p) - } - if len(pp) == 0 { - c.App().Flash().Err(errors.New("No TCP port available on container")) - return nil, false - } - - return pp, true -} - -func indexOfPort(pp []string, port string) int { - for i, p := range pp { - tokens := strings.Split(p, ":") - if len(tokens) == 2 { - if tokens[0] == port || tokens[1] == port { - return i - } - } - } - - return -1 + return port.FromContainerPorts(path, co.Ports), po.Annotations, true } diff --git a/internal/view/dir.go b/internal/view/dir.go index 13c35d8b..c151714b 100644 --- a/internal/view/dir.go +++ b/internal/view/dir.go @@ -4,7 +4,7 @@ import ( "context" "errors" "fmt" - "io/ioutil" + "os" "path" "strings" @@ -90,7 +90,7 @@ func (d *Dir) viewCmd(evt *tcell.EventKey) *tcell.EventKey { return nil } - yaml, err := ioutil.ReadFile(sel) + yaml, err := os.ReadFile(sel) if err != nil { d.App().Flash().Err(err) return nil @@ -157,7 +157,7 @@ func isKustomized(sel string) bool { return false } - ff, err := ioutil.ReadDir(sel) + ff, err := os.ReadDir(sel) if err != nil { return false } @@ -176,7 +176,7 @@ func containsDir(sel string) bool { return false } - ff, err := ioutil.ReadDir(sel) + ff, err := os.ReadDir(sel) if err != nil { return false } diff --git a/internal/view/drain_dialog.go b/internal/view/drain_dialog.go index 17040399..76e0f1bf 100644 --- a/internal/view/drain_dialog.go +++ b/internal/view/drain_dialog.go @@ -45,13 +45,13 @@ func ShowDrain(view ResourceViewer, path string, defaults dao.DrainOptions, okFn view.App().Flash().Clear() opts.Timeout = a }) - f.AddCheckbox("Ignore DaemonSets:", defaults.IgnoreAllDaemonSets, func(v bool) { + f.AddCheckbox("Ignore DaemonSets:", defaults.IgnoreAllDaemonSets, func(_ string, v bool) { opts.IgnoreAllDaemonSets = v }) - f.AddCheckbox("Delete Local Data:", defaults.DeleteEmptyDirData, func(v bool) { + f.AddCheckbox("Delete Local Data:", defaults.DeleteEmptyDirData, func(_ string, v bool) { opts.DeleteEmptyDirData = v }) - f.AddCheckbox("Force:", defaults.Force, func(v bool) { + f.AddCheckbox("Force:", defaults.Force, func(_ string, v bool) { opts.Force = v }) diff --git a/internal/view/exec.go b/internal/view/exec.go index 7bbeb287..82ba29e2 100644 --- a/internal/view/exec.go +++ b/internal/view/exec.go @@ -5,6 +5,7 @@ import ( "context" "errors" "fmt" + "io" "os" "os/exec" "os/signal" @@ -32,6 +33,7 @@ const ( type shellOpts struct { clear, background bool + pipes []string binary string banner string args []string @@ -96,40 +98,41 @@ func execute(opts shellOpts) error { } ctx, cancel := context.WithCancel(context.Background()) defer func() { - cancel() - clearScreen() + if !opts.background { + cancel() + clearScreen() + } }() sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) - go func() { + go func(cancel context.CancelFunc) { + defer log.Debug().Msgf("SIGNAL_GOR - BAILED!!") select { case <-sigChan: - log.Debug().Msg("Command canceled with signal!") + log.Debug().Msgf("Command canceled with signal!") cancel() case <-ctx.Done(): - return + log.Debug().Msgf("SIGNAL Context CANCELED!") } - }() + }(cancel) - log.Debug().Msgf("Running command> %s %s", opts.binary, strings.Join(opts.args, " ")) - cmd := exec.Command(opts.binary, opts.args...) + cmds := make([]*exec.Cmd, 0, 1) + cmd := exec.CommandContext(ctx, opts.binary, opts.args...) + log.Debug().Msgf("RUNNING> %s", cmd) + cmds = append(cmds, cmd) - var err error - if opts.background { - err = cmd.Start() - } else { - cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr - _, _ = cmd.Stdout.Write([]byte(opts.banner)) - err = cmd.Run() + for _, p := range opts.pipes { + tokens := strings.Split(p, " ") + if len(tokens) < 2 { + continue + } + cmd := exec.CommandContext(ctx, tokens[0], tokens[1:]...) + log.Debug().Msgf("\t| %s", cmd) + cmds = append(cmds, cmd) } - select { - case <-ctx.Done(): - return errors.New("canceled by operator") - default: - return err - } + return pipe(ctx, opts, cmds...) } func runKu(a *App, opts shellOpts) (string, error) { @@ -358,3 +361,58 @@ func asResource(r config.Limits) v1.ResourceRequirements { }, } } + +func pipe(ctx context.Context, opts shellOpts, cmds ...*exec.Cmd) error { + if len(cmds) == 0 { + return nil + } + + if len(cmds) == 1 { + cmd := cmds[0] + if opts.background { + cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, log.Logger, log.Logger + return cmd.Start() + } + cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr + //cmd.SysProcAttr = &syscall.SysProcAttr{ + //// //Setpgid: true, + //// //Setctty: true, + // Foreground: true, + //} + _, _ = cmd.Stdout.Write([]byte(opts.banner)) + + log.Debug().Msgf("Running Start") + err := cmd.Run() + log.Debug().Msgf("Running Done") + return err + // select { + // case <-ctx.Done(): + // return errors.New("canceled by operator") + // default: + // log.Debug().Msgf("PIPE RETURN %s", err) + // return err + // } + } + + last := len(cmds) - 1 + for i := 0; i < len(cmds); i++ { + cmds[i].Stderr = os.Stderr + if i+1 < len(cmds) { + r, w := io.Pipe() + cmds[i].Stdout, cmds[i+1].Stdin = w, r + } + } + cmds[last].Stdout = os.Stdout + + for _, cmd := range cmds { + log.Debug().Msgf("Starting CMD %s", cmd) + if err := cmd.Start(); err != nil { + return err + } + } + + log.Debug().Msgf("WAITING!!!") + err := cmds[len(cmds)-1].Wait() + log.Debug().Msgf("DONE WAITING!!!") + return err +} diff --git a/internal/view/helpers.go b/internal/view/helpers.go index 42a972ad..51eb57df 100644 --- a/internal/view/helpers.go +++ b/internal/view/helpers.go @@ -132,7 +132,7 @@ func extractApp(ctx context.Context) (*App, error) { // AsKey maps a string representation of a key to a tcell key. func asKey(key string) (tcell.Key, error) { for k, v := range tcell.KeyNames { - if v == key { + if key == v { return k, nil } } diff --git a/internal/view/helpers_test.go b/internal/view/helpers_test.go index 4827e418..c08289f1 100644 --- a/internal/view/helpers_test.go +++ b/internal/view/helpers_test.go @@ -25,14 +25,14 @@ func TestParsePFAnn(t *testing.T) { ok bool }{ "named-port": { - ann: "fred:blee", - co: "fred", + ann: "c1:blee", + co: "c1", port: "blee", ok: true, }, "port-num": { - ann: "fred:1234", - co: "fred", + ann: "c1:1234", + co: "c1", port: "1234", ok: true, }, diff --git a/internal/view/log.go b/internal/view/log.go index a878aec1..2c6debcc 100644 --- a/internal/view/log.go +++ b/internal/view/log.go @@ -1,14 +1,13 @@ package view import ( - "bytes" "context" "fmt" "io" "os" "path/filepath" - "runtime" "strings" + "sync" "time" "github.com/atotto/clipboard" @@ -25,7 +24,7 @@ import ( const ( logTitle = "logs" - logMessage = "Waiting for logs..." + logMessage = "Waiting for logs...\n" logFmt = " Logs([hilite:bg:]%s[-:bg:-])[[green:bg:b]%s[-:bg:-]] " logCoFmt = " Logs([hilite:bg:]%s:[hilite:bg:b]%s[-:bg:-])[[green:bg:b]%s[-:bg:-]] " flushTimeout = 1 * time.Millisecond @@ -35,11 +34,16 @@ const ( type Log struct { *tview.Flex - app *App - logs *Logger - indicator *LogIndicator - ansiWriter io.Writer - model *model.Log + app *App + logs *Logger + indicator *LogIndicator + ansiWriter io.Writer + model *model.Log + cancelFn context.CancelFunc + cancelUpdates bool + mx sync.Mutex + logChan dao.LogChan + follow bool } var _ model.Component = (*Log)(nil) @@ -47,8 +51,10 @@ var _ model.Component = (*Log)(nil) // NewLog returns a new viewer. func NewLog(gvr client.GVR, opts *dao.LogOptions) *Log { l := Log{ - Flex: tview.NewFlex(), - model: model.NewLog(gvr, opts, flushTimeout), + Flex: tview.NewFlex(), + logChan: make(dao.LogChan, 2), + model: model.NewLog(gvr, opts, flushTimeout), + follow: true, } return &l @@ -76,21 +82,18 @@ func (l *Log) Init(ctx context.Context) (err error) { return err } l.logs.SetBorderPadding(0, 0, 1, 1) - l.logs.SetText(logMessage) + l.logs.SetText("[orange::d]" + logMessage) l.logs.SetWrap(l.app.Config.K9s.Logger.TextWrap) - l.logs.SetMaxBuffer(l.app.Config.K9s.Logger.BufferSize) - l.logs.cmdBuff.AddListener(l) + l.logs.SetMaxLines(l.app.Config.K9s.Logger.BufferSize) l.ansiWriter = tview.ANSIWriter(l.logs, l.app.Styles.Views().Log.FgColor.String(), l.app.Styles.Views().Log.BgColor.String()) l.AddItem(l.logs, 0, 1, true) l.bindKeys() l.StylesChanged(l.app.Styles) - l.app.Styles.AddListener(l) l.goFullScreen() l.model.Init(l.app.factory) - l.model.AddListener(l) l.updateTitle() l.model.ToggleShowTimestamp(l.app.Config.K9s.Logger.ShowTime) @@ -103,6 +106,30 @@ func (l *Log) InCmdMode() bool { return l.logs.cmdBuff.InCmdMode() } +// LogCanceled indicates no more logs are coming. +func (l *Log) LogCanceled() { + log.Debug().Msgf("LOGS_CANCELED!!!") + l.Flush([][]byte{[]byte("\n🏁 [red::b]Stream exited! No more logs...")}) +} + +// LogStop disables log flushes. +func (l *Log) LogStop() { + log.Debug().Msgf("LOG_STOP!!!") + l.mx.Lock() + defer l.mx.Unlock() + + l.cancelUpdates = true +} + +// LogResume resume log flushes. +func (l *Log) LogResume() { + l.mx.Lock() + defer l.mx.Unlock() + + log.Debug().Msgf("LOG_RESUME!!!") + l.cancelUpdates = false +} + // LogCleared clears the logs. func (l *Log) LogCleared() { l.app.QueueUpdateDraw(func() { @@ -126,6 +153,9 @@ func (l *Log) LogFailed(err error) { // LogChanged updates the logs. func (l *Log) LogChanged(lines [][]byte) { l.app.QueueUpdateDraw(func() { + if l.logs.GetText(true) == logMessage { + l.logs.Clear() + } l.Flush(lines) }) } @@ -166,15 +196,43 @@ func (l *Log) ExtraHints() map[string]string { return nil } +func (l *Log) getContext() context.Context { + if l.cancelFn != nil { + l.cancelFn() + } + ctx := context.Background() + ctx, l.cancelFn = context.WithCancel(ctx) + return ctx +} + // Start runs the component. func (l *Log) Start() { - l.model.Start() + log.Debug().Msgf("LOG_VIEW STARTED!!") + + l.model.Restart(l.getContext(), l.logChan, true) + l.model.AddListener(l) + l.app.Styles.AddListener(l) + l.logs.cmdBuff.AddListener(l) + l.logs.cmdBuff.AddListener(l.app.Prompt()) + l.updateTitle() } // Stop terminates the component. func (l *Log) Stop() { - l.model.Stop() + log.Debug().Msgf("LOG_VIEW STOPPED!") l.model.RemoveListener(l) + l.model.Stop() + log.Debug().Msgf("CLOSING LOG_CHANNEL!!!") + l.mx.Lock() + { + if l.cancelFn != nil { + l.cancelFn() + l.cancelFn = nil + } + close(l.logChan) + l.logChan = nil + } + l.mx.Unlock() l.app.Styles.RemoveListener(l) l.logs.cmdBuff.RemoveListener(l) l.logs.cmdBuff.RemoveListener(l.app.Prompt()) @@ -185,12 +243,13 @@ func (l *Log) Name() string { return logTitle } func (l *Log) bindKeys() { l.logs.Actions().Set(ui.KeyActions{ - ui.Key0: ui.NewKeyAction("all", l.sinceCmd(-1), true), - ui.Key1: ui.NewKeyAction("1m", l.sinceCmd(60), true), - ui.Key2: ui.NewKeyAction("5m", l.sinceCmd(5*60), true), - ui.Key3: ui.NewKeyAction("15m", l.sinceCmd(15*60), true), - ui.Key4: ui.NewKeyAction("30m", l.sinceCmd(30*60), true), - ui.Key5: ui.NewKeyAction("1h", l.sinceCmd(60*60), true), + ui.Key0: ui.NewKeyAction("tail", l.sinceCmd(-1), true), + ui.Key1: ui.NewKeyAction("head", l.head(), true), + ui.Key2: ui.NewKeyAction("1m", l.sinceCmd(60), true), + ui.Key3: ui.NewKeyAction("5m", l.sinceCmd(5*60), true), + ui.Key4: ui.NewKeyAction("15m", l.sinceCmd(15*60), true), + ui.Key5: ui.NewKeyAction("30m", l.sinceCmd(30*60), true), + ui.Key6: ui.NewKeyAction("1h", l.sinceCmd(60*60), true), tcell.KeyEnter: ui.NewSharedKeyAction("Filter", l.filterCmd, false), tcell.KeyEscape: ui.NewKeyAction("Back", l.resetCmd, false), ui.KeyShiftC: ui.NewKeyAction("Clear", l.clearCmd, true), @@ -242,13 +301,17 @@ func (l *Log) Indicator() *LogIndicator { } func (l *Log) updateTitle() { - sinceSeconds, since := l.model.SinceSeconds(), "all" + sinceSeconds, since := l.model.SinceSeconds(), "tail" if sinceSeconds > 0 && sinceSeconds < 60*60 { since = fmt.Sprintf("%dm", sinceSeconds/60) } if sinceSeconds >= 60*60 { since = fmt.Sprintf("%dh", sinceSeconds/(60*60)) } + if l.model.IsHead() { + since = "head" + } + var title string path, co := l.model.GetPath(), l.model.GetContainer() if co == "" { @@ -274,25 +337,47 @@ var EOL = []byte{'\n'} // Flush write logs to viewer. func (l *Log) Flush(lines [][]byte) { - log.Debug().Msgf("LINES [%d]%d", runtime.NumGoroutine(), len(strings.Split(l.logs.GetText(true), "\n"))) - if !l.indicator.AutoScroll() { + defer func() { + if l.cancelUpdates { + l.cancelUpdates = false + } + }() + + if len(lines) == 0 || !l.indicator.AutoScroll() || l.cancelUpdates { return } - _, _ = l.ansiWriter.Write(EOL) - if _, err := l.ansiWriter.Write(bytes.Join(lines, EOL)); err != nil { - log.Error().Err(err).Msgf("write logs failed") + for i := 0; i < len(lines); i++ { + if l.cancelUpdates { + break + } + _, _ = l.ansiWriter.Write(lines[i]) + } + if l.follow { + l.logs.ScrollToEnd() } - l.logs.ScrollToEnd() - l.indicator.Refresh() } // ---------------------------------------------------------------------------- // Actions()... +func (l *Log) head() func(evt *tcell.EventKey) *tcell.EventKey { + return func(evt *tcell.EventKey) *tcell.EventKey { + log.Debug().Msgf("!!!!HEAD!!!!") + l.cancelUpdates = true + l.logs.Clear() + l.model.Head(l.getContext(), l.logChan) + l.updateTitle() + + return nil + } +} + func (l *Log) sinceCmd(a int) func(evt *tcell.EventKey) *tcell.EventKey { return func(evt *tcell.EventKey) *tcell.EventKey { - l.model.SetSinceSeconds(int64(a)) + l.logs.Clear() + l.model.SetSinceSeconds(l.getContext(), l.logChan, int64(a)) l.updateTitle() + return nil } } @@ -302,7 +387,7 @@ func (l *Log) toggleAllContainers(evt *tcell.EventKey) *tcell.EventKey { return evt } l.indicator.ToggleAllContainers() - l.model.ToggleAllContainers() + l.model.ToggleAllContainers(l.getContext(), l.logChan) l.updateTitle() return nil @@ -322,7 +407,7 @@ func (l *Log) filterCmd(evt *tcell.EventKey) *tcell.EventKey { // SaveCmd dumps the logs to file. func (l *Log) SaveCmd(*tcell.EventKey) *tcell.EventKey { - if path, err := saveData(l.app.Config.K9s.CurrentCluster, l.model.GetPath(), l.logs.GetText(true)); err != nil { + if path, err := saveData(l.app.Config.K9s.CurrentContext, l.model.GetPath(), l.logs.GetText(true)); err != nil { l.app.Flash().Err(err) } else { l.app.Flash().Infof("Log %s saved successfully!", path) @@ -377,7 +462,9 @@ func (l *Log) clearCmd(*tcell.EventKey) *tcell.EventKey { func (l *Log) markCmd(*tcell.EventKey) *tcell.EventKey { _, _, w, _ := l.GetRect() - fmt.Fprintf(l.ansiWriter, "\n[white::b]%s[::]", strings.Repeat("─", w-4)) + fmt.Fprintf(l.ansiWriter, "\n[white:-:b]%s[-:-:-]", strings.Repeat("─", w-4)) + l.follow = true + return nil } @@ -388,6 +475,7 @@ func (l *Log) toggleTimestampCmd(evt *tcell.EventKey) *tcell.EventKey { l.indicator.ToggleTimestamp() l.model.ToggleShowTimestamp(l.indicator.showTime) + l.indicator.Refresh() return nil } @@ -399,6 +487,8 @@ func (l *Log) toggleTextWrapCmd(evt *tcell.EventKey) *tcell.EventKey { l.indicator.ToggleTextWrap() l.logs.SetWrap(l.indicator.textWrap) + l.indicator.Refresh() + return nil } @@ -409,11 +499,15 @@ func (l *Log) toggleAutoScrollCmd(evt *tcell.EventKey) *tcell.EventKey { } l.indicator.ToggleAutoScroll() - if l.indicator.AutoScroll() { - l.model.Start() - } else { - l.model.Stop() - } + l.follow = l.indicator.AutoScroll() + // if l.indicator.AutoScroll() { + + // // l.model.Restart(l.getContext(), l.logChan, false) + // } else { + // // l.model.Stop() + // } + l.indicator.Refresh() + return nil } @@ -423,6 +517,8 @@ func (l *Log) toggleFullScreenCmd(evt *tcell.EventKey) *tcell.EventKey { } l.indicator.ToggleFullScreen() l.goFullScreen() + l.indicator.Refresh() + return nil } diff --git a/internal/view/log_indicator.go b/internal/view/log_indicator.go index a3dd4f0a..959b51aa 100644 --- a/internal/view/log_indicator.go +++ b/internal/view/log_indicator.go @@ -7,17 +7,7 @@ import ( "github.com/derailed/tview" ) -const ( - autoscroll = "Autoscroll" - fullscreen = "FullScreen" - timestamp = "Timestamps" - wrap = "Wrap" - allContainers = "AllContainers" - on = "[limegreen::]On" - off = "[gray::]Off" - spacer = " " - bold = "[-::b]" -) +const spacer = " " // LogIndicator represents a log view indicator. type LogIndicator struct { @@ -25,6 +15,7 @@ type LogIndicator struct { styles *config.Styles scrollStatus int32 + indicator []byte fullScreen bool textWrap bool showTime bool @@ -33,15 +24,16 @@ type LogIndicator struct { } // NewLogIndicator returns a new indicator. -func NewLogIndicator(cfg *config.Config, styles *config.Styles, isContainerLogView bool) *LogIndicator { +func NewLogIndicator(cfg *config.Config, styles *config.Styles, allContainers bool) *LogIndicator { l := LogIndicator{ styles: styles, TextView: tview.NewTextView(), + indicator: make([]byte, 0, 100), scrollStatus: 1, fullScreen: cfg.K9s.Logger.FullScreenLogs, textWrap: cfg.K9s.Logger.TextWrap, showTime: cfg.K9s.Logger.ShowTime, - shouldDisplayAllContainers: isContainerLogView, + shouldDisplayAllContainers: allContainers, } l.StylesChanged(styles) styles.AddListener(&l) @@ -110,24 +102,46 @@ func (l *LogIndicator) ToggleAllContainers() { l.Refresh() } -// Refresh updates the view. -func (l *LogIndicator) Refresh() { +func (l *LogIndicator) reset() { l.Clear() - if l.shouldDisplayAllContainers { - l.update(allContainers, l.allContainers, spacer) - } - l.update(autoscroll, l.AutoScroll(), spacer) - l.update(fullscreen, l.fullScreen, spacer) - l.update(timestamp, l.showTime, spacer) - l.update(wrap, l.textWrap, "") + l.indicator = l.indicator[:0] } -func (l *LogIndicator) update(title string, state bool, padding string) { - bb := []byte(bold + title + ":") - if state { - bb = append(bb, []byte(on)...) - } else { - bb = append(bb, []byte(off)...) +// Refresh updates the view. +func (l *LogIndicator) Refresh() { + l.reset() + + if l.shouldDisplayAllContainers { + if l.allContainers { + l.indicator = append(l.indicator, "[::b]AllContainers:[limegreen::b]On[-::]"+spacer...) + } else { + l.indicator = append(l.indicator, "[::b]AllContainers:[gray::d]Off[-::]"+spacer...) + } } - _, _ = l.Write(append(bb, []byte(padding)...)) + + if l.AutoScroll() { + l.indicator = append(l.indicator, "[::b]Autoscroll:[limegreen::b]On[-::]"+spacer...) + } else { + l.indicator = append(l.indicator, "[::b]Autoscroll:[gray::d]Off[-::]"+spacer...) + } + + if l.FullScreen() { + l.indicator = append(l.indicator, "[::b]FullScreen:[limegreen::b]On[-::]"+spacer...) + } else { + l.indicator = append(l.indicator, "[::b]FullScreen:[gray::d]Off[-::]"+spacer...) + } + + if l.Timestamp() { + l.indicator = append(l.indicator, "[::b]Timestamps:[limegreen::b]On[-::]"+spacer...) + } else { + l.indicator = append(l.indicator, "[::b]Timestamps:[gray::d]Off[-::]"+spacer...) + } + + if l.TextWrap() { + l.indicator = append(l.indicator, "[::b]Wrap:[limegreen::b]On[-::]"...) + } else { + l.indicator = append(l.indicator, "[::b]Wrap:[gray::d]Off[-::]"...) + } + + _, _ = l.Write(l.indicator) } diff --git a/internal/view/log_indicator_test.go b/internal/view/log_indicator_test.go index d4156ab1..92649d7b 100644 --- a/internal/view/log_indicator_test.go +++ b/internal/view/log_indicator_test.go @@ -14,11 +14,11 @@ func TestLogIndicatorRefresh(t *testing.T) { li *view.LogIndicator e string }{ - "all containers": { - view.NewLogIndicator(config.NewConfig(nil), defaults, true), "[-::b]AllContainers:[gray::]Off [-::b]Autoscroll:[limegreen::]On [-::b]FullScreen:[gray::]Off [-::b]Timestamps:[gray::]Off [-::b]Wrap:[gray::]Off\n", + "all-containers": { + view.NewLogIndicator(config.NewConfig(nil), defaults, true), "[::b]AllContainers:[gray::d]Off[-::] [::b]Autoscroll:[limegreen::b]On[-::] [::b]FullScreen:[gray::d]Off[-::] [::b]Timestamps:[gray::d]Off[-::] [::b]Wrap:[gray::d]Off[-::]\n", }, - "no all containers": { - view.NewLogIndicator(config.NewConfig(nil), defaults, false), "[-::b]Autoscroll:[limegreen::]On [-::b]FullScreen:[gray::]Off [-::b]Timestamps:[gray::]Off [-::b]Wrap:[gray::]Off\n", + "plain": { + view.NewLogIndicator(config.NewConfig(nil), defaults, false), "[::b]Autoscroll:[limegreen::b]On[-::] [::b]FullScreen:[gray::d]Off[-::] [::b]Timestamps:[gray::d]Off[-::] [::b]Wrap:[gray::d]Off[-::]\n", }, } @@ -26,7 +26,18 @@ func TestLogIndicatorRefresh(t *testing.T) { u := uu[k] t.Run(k, func(t *testing.T) { u.li.Refresh() - assert.Equal(t, u.li.GetText(false), u.e) + assert.Equal(t, u.e, u.li.GetText(false)) }) } } + +func BenchmarkLogIndicatorRefresh(b *testing.B) { + defaults := config.NewStyles() + v := view.NewLogIndicator(config.NewConfig(nil), defaults, true) + + b.ReportAllocs() + b.ResetTimer() + for n := 0; n < b.N; n++ { + v.Refresh() + } +} diff --git a/internal/view/log_int_test.go b/internal/view/log_int_test.go index e864bf2d..026a877e 100644 --- a/internal/view/log_int_test.go +++ b/internal/view/log_int_test.go @@ -23,7 +23,7 @@ func TestLogAutoScroll(t *testing.T) { v.GetModel().Set(ii) v.GetModel().Notify() - assert.Equal(t, 15, len(v.Hints())) + assert.Equal(t, 16, len(v.Hints())) v.toggleAutoScrollCmd(nil) assert.Equal(t, "Autoscroll:Off FullScreen:Off Timestamps:Off Wrap:Off", v.Indicator().GetText(true)) @@ -75,8 +75,7 @@ func TestLogTimestamp(t *testing.T) { &dao.LogItem{ Pod: "fred/blee", Container: "c1", - Timestamp: "ttt", - Bytes: []byte("Testing 1, 2, 3"), + Bytes: []byte("ttt Testing 1, 2, 3\n"), }, ) var list logList @@ -84,9 +83,11 @@ func TestLogTimestamp(t *testing.T) { l.GetModel().Set(ii) l.SendKeys(ui.KeyT) l.Logs().Clear() - l.Flush(ii.Lines(true)) + ll := make([][]byte, ii.Len()) + ii.Lines(0, true, ll) + l.Flush(ll) - assert.Equal(t, fmt.Sprintf("\n%-30s %s", "ttt", "fred/blee:c1 Testing 1, 2, 3"), l.Logs().GetText(true)) + assert.Equal(t, fmt.Sprintf("%-30s %s", "ttt", "fred/blee c1 Testing 1, 2, 3\n"), l.Logs().GetText(true)) assert.Equal(t, 2, list.change) assert.Equal(t, 2, list.clear) assert.Equal(t, 0, list.fail) @@ -131,5 +132,8 @@ func (l *logList) LogChanged(ll [][]byte) { l.lines += string(line) } } +func (l *logList) LogCanceled() {} +func (l *logList) LogStop() {} +func (l *logList) LogResume() {} func (l *logList) LogCleared() { l.clear++ } func (l *logList) LogFailed(error) { l.fail++ } diff --git a/internal/view/log_test.go b/internal/view/log_test.go index c0454bb1..9ee08d1c 100644 --- a/internal/view/log_test.go +++ b/internal/view/log_test.go @@ -3,7 +3,7 @@ package view_test import ( "bytes" "fmt" - "io/ioutil" + "os" "path/filepath" "testing" @@ -25,10 +25,32 @@ func TestLog(t *testing.T) { v.Init(makeContext()) ii := dao.NewLogItems() - ii.Add(dao.NewLogItemFromString("blee"), dao.NewLogItemFromString("bozo")) - v.Flush(ii.Lines(false)) + ii.Add(dao.NewLogItemFromString("blee\n"), dao.NewLogItemFromString("bozo\n")) + ll := make([][]byte, ii.Len()) + ii.Lines(0, false, ll) + v.Flush(ll) - assert.Equal(t, 29, len(v.Logs().GetText(true))) + assert.Equal(t, "Waiting for logs...\nblee\nbozo\n", v.Logs().GetText(true)) +} + +func TestLogFlush(t *testing.T) { + opts := dao.LogOptions{ + Path: "fred/p1", + Container: "blee", + } + v := view.NewLog(client.NewGVR("v1/pods"), &opts) + v.Init(makeContext()) + + items := dao.NewLogItems() + items.Add( + dao.NewLogItemFromString("\033[0;30mblee\n"), + dao.NewLogItemFromString("\033[0;32mBozo\n"), + ) + ll := make([][]byte, items.Len()) + items.Lines(0, false, ll) + v.Flush(ll) + + assert.Equal(t, "[orange::d]Waiting for logs...\n[black:]blee\n[green:]Bozo\n\n", v.Logs().GetText(false)) } func BenchmarkLogFlush(b *testing.B) { @@ -41,13 +63,17 @@ func BenchmarkLogFlush(b *testing.B) { items := dao.NewLogItems() items.Add( - dao.NewLogItemFromString("blee"), - dao.NewLogItemFromString("bozo"), + dao.NewLogItemFromString("\033[0;30mblee\n"), + dao.NewLogItemFromString("\033[0;101mBozo\n"), + dao.NewLogItemFromString("\033[0;101mBozo\n"), ) + ll := make([][]byte, items.Len()) + items.Lines(0, false, ll) + b.ReportAllocs() b.ResetTimer() for n := 0; n < b.N; n++ { - v.Flush(items.Lines(false)) + v.Flush(ll) } } @@ -76,12 +102,15 @@ func TestLogViewSave(t *testing.T) { app := makeApp() ii := dao.NewLogItems() ii.Add(dao.NewLogItemFromString("blee"), dao.NewLogItemFromString("bozo")) - v.Flush(ii.Lines(false)) + ll := make([][]byte, ii.Len()) + ii.Lines(0, false, ll) + v.Flush(ll) + config.K9sDumpDir = "/tmp" dir := filepath.Join(config.K9sDumpDir, app.Config.K9s.CurrentCluster) - c1, _ := ioutil.ReadDir(dir) + c1, _ := os.ReadDir(dir) v.SaveCmd(nil) - c2, _ := ioutil.ReadDir(dir) + c2, _ := os.ReadDir(dir) assert.Equal(t, len(c2), len(c1)+1) } diff --git a/internal/view/logger.go b/internal/view/logger.go index c427ac7b..bb05da19 100644 --- a/internal/view/logger.go +++ b/internal/view/logger.go @@ -59,8 +59,7 @@ func (l *Logger) Init(_ context.Context) error { func (l *Logger) BufferChanged(s string) {} // BufferCompleted indicates input was accepted. -func (l *Logger) BufferCompleted(s string) { -} +func (l *Logger) BufferCompleted(s string) {} // BufferActive indicates the buff activity changed. func (l *Logger) BufferActive(state bool, k model.BufferKind) { diff --git a/internal/view/pf.go b/internal/view/pf.go index 5b7556cf..377c0f0f 100644 --- a/internal/view/pf.go +++ b/internal/view/pf.go @@ -3,6 +3,7 @@ package view import ( "context" "fmt" + "regexp" "time" "github.com/derailed/k9s/internal" @@ -138,19 +139,35 @@ func (p *PortForward) deleteCmd(evt *tcell.EventKey) *tcell.EventKey { return nil } - path := p.GetTable().GetSelectedItem() - if path == "" { - return nil + selections := p.GetTable().GetSelectedItems() + if len(selections) == 0 { + return evt } - showModal(p.App().Content.Pages, fmt.Sprintf("Delete PortForward `%s?", path), func() { - var pf dao.PortForward - pf.Init(p.App().factory, client.NewGVR("portforwards")) - if err := pf.Delete(path, true, true); err != nil { + p.Stop() + defer p.Start() + var msg string + if len(selections) > 1 { + msg = fmt.Sprintf("Delete %d marked %s?", len(selections), p.GVR()) + } else { + h, err := pfToHuman(selections[0]) + if err == nil { + msg = fmt.Sprintf("Delete %s %s?", p.GVR().R(), h) + } else { p.App().Flash().Err(err) - return + return nil } - p.App().Flash().Infof("PortForward %s deleted!", path) + } + showModal(p.App().Content.Pages, msg, func() { + for _, s := range selections { + var pf dao.PortForward + pf.Init(p.App().factory, client.NewGVR("portforwards")) + if err := pf.Delete(s, true, true); err != nil { + p.App().Flash().Err(err) + return + } + } + p.App().Flash().Infof("Successfully deleted %d PortForward!", len(selections)) p.GetTable().Refresh() }) @@ -160,6 +177,16 @@ func (p *PortForward) deleteCmd(evt *tcell.EventKey) *tcell.EventKey { // ---------------------------------------------------------------------------- // Helpers... +var selRx = regexp.MustCompile(`\A([\w-]+)/([\w-]+)\|([\w-]+)\|(\d+):(\d+)`) + +func pfToHuman(s string) (string, error) { + mm := selRx.FindStringSubmatch(s) + if len(mm) < 6 { + return "", fmt.Errorf("Unable to parse selection %s", s) + } + return fmt.Sprintf("%s::%s %s->%s", mm[2], mm[3], mm[4], mm[5]), nil +} + func showModal(p *ui.Pages, msg string, ok func()) { m := tview.NewModal(). AddButtons([]string{"Cancel", "OK"}). diff --git a/internal/view/pf_dialog.go b/internal/view/pf_dialog.go index e84e0aee..89921c33 100644 --- a/internal/view/pf_dialog.go +++ b/internal/view/pf_dialog.go @@ -7,18 +7,20 @@ import ( "strings" "github.com/derailed/k9s/internal/client" + "github.com/derailed/k9s/internal/port" "github.com/derailed/k9s/internal/render" "github.com/derailed/k9s/internal/ui" "github.com/derailed/tview" + "github.com/rs/zerolog/log" ) const portForwardKey = "portforward" // PortForwardCB represents a port-forward callback function. -type PortForwardCB func(v ResourceViewer, path, co string, mapper []client.PortTunnel) +type PortForwardCB func(ResourceViewer, string, port.PortTunnels) error // ShowPortForwards pops a port forwarding configuration dialog. -func ShowPortForwards(v ResourceViewer, path string, ports []string, ann string, okFn PortForwardCB) { +func ShowPortForwards(v ResourceViewer, path string, ports port.ContainerPortSpecs, aa port.Annotations, okFn PortForwardCB) { styles := v.App().Styles.Dialog() f := tview.NewForm() @@ -32,37 +34,28 @@ func ShowPortForwards(v ResourceViewer, path string, ports []string, ann string, address := v.App().Config.CurrentCluster().PortForwardAddress - var p1, p2 string - if len(ports) > 0 { - p1, p2 = ports[0], extractPort(ports[0]) - if len(ann) != 0 { - container, port, ok := parsePFAnn(ann) - if ok { - for _, p := range ports { - co, po, portNum := parsePort(p) - if co == container && port == po || port == portNum { - p1, p2 = p, extractPort(p) - break - } - } - } - } + pf, err := aa.PreferredPorts(ports) + if err != nil { + log.Warn().Err(err).Msgf("unable to resolve ports") } + + p1, p2 := pf.ToPortSpec(ports) fieldLen := int(math.Max(30, float64(len(p1)))) - f.AddInputField("Container Port:", p1, fieldLen, nil, func(p string) { - p1 = p - }) - field := f.GetFormItemByLabel("Container Port:").(*tview.InputField) - if field.GetText() == "" { - field.SetPlaceholder("Enter a container name/port") + f.AddInputField("Container Port:", p1, fieldLen, nil, nil) + coField := f.GetFormItemByLabel("Container Port:").(*tview.InputField) + if coField.GetText() == "" { + coField.SetPlaceholder("Enter a container name/port") } - f.AddInputField("Local Port:", p2, fieldLen, nil, func(p string) { - p2 = p - }) - field = f.GetFormItemByLabel("Local Port:").(*tview.InputField) - if field.GetText() == "" { - field.SetPlaceholder("Enter a local port") + f.AddInputField("Local Port:", p2, fieldLen, nil, nil) + poField := f.GetFormItemByLabel("Local Port:").(*tview.InputField) + if poField.GetText() == "" { + poField.SetPlaceholder("Enter a local port") } + coField.SetChangedFunc(func(s string) { + port := extractPort(s) + poField.SetText(port) + p2 = port + }) f.AddInputField("Address:", address, fieldLen, nil, func(h string) { address = h }) @@ -76,21 +69,18 @@ func ShowPortForwards(v ResourceViewer, path string, ports []string, ann string, } f.AddButton("OK", func() { - pp1 := strings.Split(p1, ",") - pp2 := strings.Split(p2, ",") - if len(pp1) == 0 || len(pp1) != len(pp2) { + if coField.GetText() == "" || poField.GetText() == "" { v.App().Flash().Err(fmt.Errorf("container to local port mismatch")) return } - var tt []client.PortTunnel - for i := range pp1 { - tt = append(tt, client.PortTunnel{ - Address: address, - LocalPort: pp2[i], - ContainerPort: extractPort(pp1[i]), - }) + tt, err := port.ToTunnels(address, coField.GetText(), poField.GetText()) + if err != nil { + v.App().Flash().Err(err) + return + } + if err := okFn(v, path, tt); err != nil { + v.App().Flash().Err(err) } - okFn(v, path, extractContainer(pp1[0]), tt) }) pages := v.App().Content.Pages f.AddButton("Cancel", func() { @@ -108,7 +98,7 @@ func ShowPortForwards(v ResourceViewer, path string, ports []string, ann string, modal := tview.NewModalForm("", f) msg := path if len(ports) > 1 { - msg += "\n\nExposed Ports:\n" + strings.Join(ports, "\n") + msg += "\n\nExposed Ports:\n" + ports.Dump() } modal.SetText(msg) modal.SetTextColor(styles.FgColor.Color()) @@ -131,16 +121,6 @@ func DismissPortForwards(v ResourceViewer, p *ui.Pages) { // ---------------------------------------------------------------------------- // Helpers... -func parsePort(p string) (string, string, string) { - rx := regexp.MustCompile(`\A([\w|-]+)/?([\w|-]+)?:?(\d+)?(╱UDP)?\z`) - mm := rx.FindStringSubmatch(p) - if len(mm) != 5 { - return "", "", "" - } - - return mm[1], mm[2], mm[3] -} - func extractPort(p string) string { rx := regexp.MustCompile(`\A([\w|-]+)/?([\w|-]+)?:?(\d+)?(╱UDP)?\z`) mm := rx.FindStringSubmatch(p) diff --git a/internal/view/pf_dialog_test.go b/internal/view/pf_dialog_test.go index 8f8854ab..284af6b1 100644 --- a/internal/view/pf_dialog_test.go +++ b/internal/view/pf_dialog_test.go @@ -6,44 +6,6 @@ import ( "github.com/stretchr/testify/assert" ) -func TestExtractPort(t *testing.T) { - uu := map[string]struct { - port, e string - }{ - "empty": { - "", "", - }, - "full": { - "co/fred:8000", "8000", - }, - "named": { - "fred:8000", "8000", - }, - "port": { - "8000", "8000", - }, - "protocol": { - "dns:53╱UDP", "53", - }, - "unamed": { - "dns/53", "53", - }, - "pod-dashed": { - "blee-fred/:5000", "5000", - }, - "co-dashed": { - "blee/fred-doh:5000", "5000", - }, - } - - for k := range uu { - u := uu[k] - t.Run(k, func(t *testing.T) { - assert.Equal(t, u.e, extractPort(u.port)) - }) - } -} - func TestExtractContainer(t *testing.T) { uu := map[string]struct { port, e string diff --git a/internal/view/pf_extender.go b/internal/view/pf_extender.go index 3740d585..c090e7b4 100644 --- a/internal/view/pf_extender.go +++ b/internal/view/pf_extender.go @@ -1,13 +1,10 @@ package view import ( - "errors" "fmt" - "net" - "strconv" - "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/dao" + "github.com/derailed/k9s/internal/port" "github.com/derailed/k9s/internal/ui" "github.com/derailed/k9s/internal/watch" "github.com/gdamore/tcell/v2" @@ -19,8 +16,6 @@ import ( "k8s.io/client-go/tools/portforward" ) -const AnnDefaultPF = "k9s.imhotep.io/default-portforward-container" - // PortForwardExtender adds port-forward extensions. type PortForwardExtender struct { ResourceViewer @@ -78,19 +73,11 @@ func (p *PortForwardExtender) fetchPodName(path string) (string, error) { // ---------------------------------------------------------------------------- // Helpers... -func tryListenPort(address, port string) error { - server, err := net.Listen("tcp", fmt.Sprintf("%s:%s", address, port)) - if err != nil { - return err - } - return server.Close() -} - func runForward(v ResourceViewer, pf watch.Forwarder, f *portforward.PortForwarder) { v.App().factory.AddForwarder(pf) v.App().QueueUpdateDraw(func() { - v.App().Flash().Infof("PortForward activated %s:%s", pf.Path(), pf.Ports()[0]) + v.App().Flash().Infof("PortForward activated %s", pf.ID()) DismissPortForwards(v, v.App().Content.Pages) }) @@ -106,67 +93,77 @@ func runForward(v ResourceViewer, pf watch.Forwarder, f *portforward.PortForward }) } -func startFwdCB(v ResourceViewer, path, co string, tt []client.PortTunnel) { - for _, t := range tt { - err := tryListenPort(t.Address, t.LocalPort) - if err != nil { - v.App().Flash().Err(err) - return +func startFwdCB(v ResourceViewer, path string, pts port.PortTunnels) error { + if err := pts.CheckAvailable(); err != nil { + return err + } + + for _, pt := range pts { + if _, ok := v.App().factory.ForwarderFor(dao.PortForwardID(path, pt.Container, pt.PortMap())); ok { + return fmt.Errorf("A port-forward is already active on pod %s", path) } + pf := dao.NewPortForwarder(v.App().factory) + fwd, err := pf.Start(path, pt) + if err != nil { + return err + } + log.Debug().Msgf(">>> Starting port forward %q -- %#v", pf.ID(), pt) + go runForward(v, pf, fwd) } - if _, ok := v.App().factory.ForwarderFor(dao.PortForwardID(path, co)); ok { - v.App().Flash().Err(errors.New("A port-forward is already active on this pod")) - return - } - - pf := dao.NewPortForwarder(v.App().factory) - fwd, err := pf.Start(path, co, tt) - if err != nil { - v.App().Flash().Err(err) - return - } - - log.Debug().Msgf(">>> Starting port forward %q %#v", path, tt) - go runForward(v, pf, fwd) + return nil } func showFwdDialog(v ResourceViewer, path string, cb PortForwardCB) error { - mm, coPort, err := fetchPodPorts(v.App().factory, path) + mm, anns, err := fetchPodPorts(v.App().factory, path) if err != nil { return err } - ports := make([]string, 0, len(mm)) + ports := make(port.ContainerPortSpecs, 0, len(mm)) for co, pp := range mm { for _, p := range pp { if p.Protocol != v1.ProtocolTCP { continue } - ports = append(ports, client.FQN(co, p.Name)+":"+strconv.Itoa(int(p.ContainerPort))) + ports = append(ports, port.NewPortSpec(co, p.Name, p.ContainerPort)) } } - ShowPortForwards(v, path, ports, coPort, cb) + if spec, ok := anns[port.K9sAutoPortForwardsKey]; ok { + pfs, err := port.ParsePFs(spec) + if err != nil { + return err + } + + pts, err := pfs.ToTunnels(v.App().Config.CurrentCluster().PortForwardAddress, ports, port.IsPortFree) + if err != nil { + return err + } + + return startFwdCB(v, path, pts) + } + + ShowPortForwards(v, path, ports, anns, cb) return nil } -func fetchPodPorts(f *watch.Factory, path string) (map[string][]v1.ContainerPort, string, error) { +func fetchPodPorts(f *watch.Factory, path string) (map[string][]v1.ContainerPort, map[string]string, error) { log.Debug().Msgf("Fetching ports on pod %q", path) o, err := f.Get("v1/pods", path, true, labels.Everything()) if err != nil { - return nil, "", err + return nil, nil, err } var pod v1.Pod err = runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, &pod) if err != nil { - return nil, "", err + return nil, nil, err } - pp := make(map[string][]v1.ContainerPort) + pp := make(map[string][]v1.ContainerPort, len(pod.Spec.Containers)) for _, co := range pod.Spec.Containers { pp[co.Name] = co.Ports } - return pp, pod.Annotations[AnnDefaultPF], nil + return pp, pod.Annotations, nil } diff --git a/internal/view/pod.go b/internal/view/pod.go index 342e58f7..6146442a 100644 --- a/internal/view/pod.go +++ b/internal/view/pod.go @@ -176,7 +176,6 @@ func (p *Pod) killCmd(evt *tcell.EventKey) *tcell.EventKey { p.App().Flash().Infof("Delete resource %s %s", p.GVR(), selections[0]) } p.GetTable().ShowDeleted() - log.Debug().Msgf("SELS %v", selections) for _, path := range selections { if err := nuker.Delete(path, true, true); err != nil { p.App().Flash().Errf("Delete failed with %s", err) diff --git a/internal/view/restart_extender.go b/internal/view/restart_extender.go index 1d89c754..bc09891b 100644 --- a/internal/view/restart_extender.go +++ b/internal/view/restart_extender.go @@ -36,7 +36,7 @@ func (r *RestartExtender) bindKeys(aa ui.KeyActions) { func (r *RestartExtender) restartCmd(evt *tcell.EventKey) *tcell.EventKey { paths := r.GetTable().GetSelectedItems() - if len(paths) == 0 { + if len(paths) == 0 || paths[0] == "" { return nil } diff --git a/internal/view/scale_extender.go b/internal/view/scale_extender.go index 49816a67..d604ab78 100644 --- a/internal/view/scale_extender.go +++ b/internal/view/scale_extender.go @@ -36,26 +36,30 @@ func (s *ScaleExtender) bindKeys(aa ui.KeyActions) { } func (s *ScaleExtender) scaleCmd(evt *tcell.EventKey) *tcell.EventKey { - path := s.GetTable().GetSelectedItem() - if path == "" { + paths := s.GetTable().GetSelectedItems() + if len(paths) == 0 { return nil } s.Stop() defer s.Start() - s.showScaleDialog(path) + s.showScaleDialog(paths) return nil } -func (s *ScaleExtender) showScaleDialog(path string) { - form, err := s.makeScaleForm(path) +func (s *ScaleExtender) showScaleDialog(paths []string) { + form, err := s.makeScaleForm(paths) if err != nil { s.App().Flash().Err(err) return } confirm := tview.NewModalForm("", form) - confirm.SetText(fmt.Sprintf("Scale %s %s", s.GVR(), path)) + msg := fmt.Sprintf("Scale %s %s?", s.GVR().R(), paths[0]) + if len(paths) > 1 { + msg = fmt.Sprintf("Scale [%d] %s?", len(paths), s.GVR().R()) + } + confirm.SetText(msg) confirm.SetDoneFunc(func(int, string) { s.dismissDialog() }) @@ -71,40 +75,49 @@ func (s *ScaleExtender) valueOf(col string) (string, error) { return s.GetTable().GetSelectedCell(colIdx), nil } -func (s *ScaleExtender) makeScaleForm(sel string) (*tview.Form, error) { +func (s *ScaleExtender) makeScaleForm(sels []string) (*tview.Form, error) { f := s.makeStyledForm() - replicas, err := s.valueOf("READY") - if err != nil { - return nil, err + factor := "0" + if len(sels) == 1 { + replicas, err := s.valueOf("READY") + if err != nil { + return nil, err + } + tokens := strings.Split(replicas, "/") + if len(tokens) < 2 { + return nil, fmt.Errorf("unable to locate replicas from %s", replicas) + } + factor = strings.TrimRight(tokens[1], ui.DeltaSign) } - tokens := strings.Split(replicas, "/") - if len(tokens) < 2 { - return nil, fmt.Errorf("unable to locate replicas from %s", replicas) - } - replicas = strings.TrimRight(tokens[1], ui.DeltaSign) - f.AddInputField("Replicas:", replicas, 4, func(textToCheck string, lastChar rune) bool { + f.AddInputField("Replicas:", factor, 4, func(textToCheck string, lastChar rune) bool { _, err := strconv.Atoi(textToCheck) return err == nil }, func(changed string) { - replicas = changed + factor = changed }) f.AddButton("OK", func() { defer s.dismissDialog() - count, err := strconv.Atoi(replicas) + count, err := strconv.Atoi(factor) if err != nil { s.App().Flash().Err(err) return } ctx, cancel := context.WithTimeout(context.Background(), s.App().Conn().Config().CallTimeout()) defer cancel() - if err := s.scale(ctx, sel, count); err != nil { - log.Error().Err(err).Msgf("DP %s scaling failed", sel) - s.App().Flash().Err(err) - return + for _, sel := range sels { + if err := s.scale(ctx, sel, count); err != nil { + log.Error().Err(err).Msgf("DP %s scaling failed", sel) + s.App().Flash().Err(err) + return + } + } + if len(sels) == 1 { + s.App().Flash().Infof("[%d] %s scaled successfully", len(sels), s.GVR().R()) + } else { + s.App().Flash().Infof("%s %s scaled successfully", s.GVR().R(), sels[0]) } - s.App().Flash().Infof("Resource %s:%s scaled successfully", s.GVR(), sel) }) f.AddButton("Cancel", func() { diff --git a/internal/view/table.go b/internal/view/table.go index d941a07f..b6d2b571 100644 --- a/internal/view/table.go +++ b/internal/view/table.go @@ -2,6 +2,7 @@ package view import ( "context" + "strings" "time" "github.com/atotto/clipboard" @@ -53,9 +54,17 @@ func (t *Table) Init(ctx context.Context) (err error) { } // HeaderIndex returns index of a given column or false if not found. -func (t *Table) HeaderIndex(header string) (int, bool) { +func (t *Table) HeaderIndex(colName string) (int, bool) { for i := 0; i < t.GetColumnCount(); i++ { - if h := t.GetCell(0, i); h != nil && h.Text == header { + h := t.GetCell(0, i) + if h == nil { + continue + } + s := h.Text + if idx := strings.Index(s, "["); idx > 0 { + s = s[:idx] + } + if s == colName { return i, true } } diff --git a/internal/view/table_int_test.go b/internal/view/table_int_test.go index 0ddab5fd..6209f194 100644 --- a/internal/view/table_int_test.go +++ b/internal/view/table_int_test.go @@ -2,7 +2,7 @@ package view import ( "context" - "io/ioutil" + "os" "path/filepath" "testing" "time" @@ -25,10 +25,10 @@ func TestTableSave(t *testing.T) { v.SetTitle("k9s-test") dir := filepath.Join(config.K9sDumpDir, v.app.Config.K9s.CurrentCluster) - c1, _ := ioutil.ReadDir(dir) + c1, _ := os.ReadDir(dir) v.saveCmd(nil) - c2, _ := ioutil.ReadDir(dir) + c2, _ := os.ReadDir(dir) assert.Equal(t, len(c2), len(c1)+1) } diff --git a/internal/watch/factory.go b/internal/watch/factory.go index 300081aa..a5b3d5ef 100644 --- a/internal/watch/factory.go +++ b/internal/watch/factory.go @@ -244,11 +244,8 @@ func (f *Factory) ensureFactory(ns string) (di.DynamicSharedInformerFactory, err func (f *Factory) AddForwarder(pf Forwarder) { f.mx.Lock() defer f.mx.Unlock() - f.forwarders[pf.Path()] = pf - for k, v := range f.forwarders { - log.Debug().Msgf("%q -- %#v", k, v) - } + f.forwarders[pf.Path()] = pf } // DeleteForwarder deletes portforward for a given container. @@ -277,11 +274,20 @@ func (f *Factory) ForwarderFor(path string) (Forwarder, bool) { return fwd, ok } +// BOZO!! Review!!! // ValidatePortForwards check if pods are still around for portforwards. func (f *Factory) ValidatePortForwards() { for k, fwd := range f.forwarders { tokens := strings.Split(k, ":") - _, err := f.Get("v1/pods", tokens[0], false, labels.Everything()) + if len(tokens) != 2 { + log.Error().Msgf("Invalid fwd keys %q", k) + return + } + paths := strings.Split(tokens[0], "|") + if len(paths) < 1 { + log.Error().Msgf("Invalid path %q", tokens[0]) + } + _, err := f.Get("v1/pods", paths[0], false, labels.Everything()) if err != nil { fwd.Stop() delete(f.forwarders, k) diff --git a/internal/watch/forwarders.go b/internal/watch/forwarders.go index d30bb053..c0a0c28b 100644 --- a/internal/watch/forwarders.go +++ b/internal/watch/forwarders.go @@ -3,7 +3,7 @@ package watch import ( "strings" - "github.com/derailed/k9s/internal/client" + "github.com/derailed/k9s/internal/port" "github.com/rs/zerolog/log" "k8s.io/client-go/tools/portforward" ) @@ -11,19 +11,22 @@ import ( // Forwarder represents a port forwarder. type Forwarder interface { // Start starts a port-forward. - Start(path, co string, tt []client.PortTunnel) (*portforward.PortForwarder, error) + Start(path string, tunnel port.PortTunnel) (*portforward.PortForwarder, error) // Stop terminates a port forward. Stop() + // ID returns the pf id. + ID() string + // Path returns a resource FQN. Path() string // Container returns a container name. Container() string - // Ports returns container exposed ports. - Ports() []string + // Ports returns the port mapping. + Port() string // FQN returns the full port-forward name. FQN() string @@ -49,10 +52,11 @@ func NewForwarders() Forwarders { return make(map[string]Forwarder) } +// BOZO!! Review!!! // IsPodForwarded checks if pod has a forward. func (ff Forwarders) IsPodForwarded(path string) bool { for k := range ff { - fqn := strings.Split(k, ":") + fqn := strings.Split(k, "|") if fqn[0] == path { return true } @@ -78,18 +82,14 @@ func (ff Forwarders) DeleteAll() { // Kill stops and delete a port-forwards associated with pod. func (ff Forwarders) Kill(path string) int { - hasContainer := strings.Contains(path, ":") var stats int for k, f := range ff { victim := k - if !hasContainer { - victim = strings.Split(k, ":")[0] - } if victim == path { stats++ - log.Debug().Msgf("Stop + Delete port-forward %s", k) + log.Debug().Msgf("Stop + Delete port-forward %s", victim) f.Stop() - delete(ff, k) + delete(ff, victim) } } diff --git a/internal/xray/container_test.go b/internal/xray/container_test.go index 4e66d0f8..d635525a 100644 --- a/internal/xray/container_test.go +++ b/internal/xray/container_test.go @@ -4,7 +4,7 @@ import ( "context" "encoding/json" "fmt" - "io/ioutil" + "os" "testing" "github.com/derailed/k9s/internal" @@ -238,7 +238,7 @@ func makeDoubleCMKeysContainer(n string, optional bool) *v1.Container { } func load(t *testing.T, n string) *unstructured.Unstructured { - raw, err := ioutil.ReadFile(fmt.Sprintf("testdata/%s.json", n)) + raw, err := os.ReadFile(fmt.Sprintf("testdata/%s.json", n)) assert.Nil(t, err) var o unstructured.Unstructured diff --git a/skins/dracula.yml b/skins/dracula.yml index 38d63278..78253093 100644 --- a/skins/dracula.yml +++ b/skins/dracula.yml @@ -83,8 +83,6 @@ k9s: table: fgColor: *foreground bgColor: *background - cursorFgColor: *foreground - cursorBgColor: *current_line # Header row styles. header: fgColor: *foreground diff --git a/skins/kiss.yml b/skins/kiss.yml index c65829c4..aeffa226 100644 --- a/skins/kiss.yml +++ b/skins/kiss.yml @@ -63,3 +63,6 @@ k9s: logs: fgColor: default bgColor: default + indicator: + fgColor: default + bgColor: default diff --git a/skins/monokai.yml b/skins/monokai.yml index 04605bc6..69cf2709 100644 --- a/skins/monokai.yml +++ b/skins/monokai.yml @@ -104,8 +104,6 @@ k9s: table: fgColor: *foreground bgColor: *background - cursorFgColor: *foreground - cursorBgColor: *backgroundOpaque markColor: *magenta # Header row styles. header: diff --git a/skins/nord.yml b/skins/nord.yml index d481159d..e8e73a5f 100644 --- a/skins/nord.yml +++ b/skins/nord.yml @@ -81,8 +81,6 @@ k9s: table: fgColor: *foreground bgColor: default - cursorFgColor: *foreground - cursorBgColor: *current_line # Header row styles. header: fgColor: *foreground diff --git a/skins/red.yml b/skins/red.yml index 5a89e677..5ace7c5f 100644 --- a/skins/red.yml +++ b/skins/red.yml @@ -29,16 +29,16 @@ k9s: numKeyColor: red crumbs: fgColor: black - bgColor: steelblue + bgColor: red activeColor: red status: - newColor: lightskyblue + newColor: red modifyColor: greenyellow addColor: white - errorColor: redred + errorColor: red pendingColor: darkred highlightcolor: red - killColor: mediumpurple + killColor: red completedColor: gray title: fgColor: red @@ -55,7 +55,7 @@ k9s: - linegreen - redred table: - fgColor: blue + fgColor: red bgColor: black cursorFgColor: black cursorBgColor: red @@ -65,15 +65,15 @@ k9s: bgColor: black sorterColor: red xray: - fgColor: blue + fgColor: red bgColor: black cursorColor: red graphicColor: darkgoldenrod showIcons: false yaml: - keyColor: steelblue + keyColor: red colonColor: white - valueColor: papayawhip + valueColor: red logs: fgColor: white bgColor: black diff --git a/skins/snazzy.yml b/skins/snazzy.yml index b101007a..f6ebf2cb 100644 --- a/skins/snazzy.yml +++ b/skins/snazzy.yml @@ -58,8 +58,6 @@ k9s: table: fgColor: "#57c7ff" bgColor: "#282a36" - cursorFgColor: "#57c7ff" - cursorBgColor: "#5af78e" markColor: darkgoldenrod header: fgColor: white