diff --git a/go.mod b/go.mod index f440f8d9..599c7759 100644 --- a/go.mod +++ b/go.mod @@ -2,6 +2,8 @@ module github.com/derailed/k9s go 1.13 +replace github.com/derailed/tview => /Users/fernand/go_wk/derailed/src/github.com/derailed/tview + replace ( github.com/docker/docker => github.com/docker/docker v1.4.2-0.20181221150755-2cb26cfe9cbf k8s.io/api => k8s.io/api v0.0.0-20190918155943-95b840bb6a1f @@ -30,6 +32,8 @@ replace ( require ( fyne.io/fyne v1.2.2 // indirect github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 // indirect + github.com/alexellis/go-execute v0.0.0-20200124154445-8697e4e28c5e // indirect + github.com/alexellis/hmac v0.0.0-20180624211220-5c52ab81c0de // indirect github.com/atotto/clipboard v0.1.2 github.com/derailed/tview v0.3.4 github.com/drone/envsubst v1.0.2 // indirect @@ -42,9 +46,9 @@ require ( github.com/gregjones/httpcache v0.0.0-20190212212710-3befbb6ad0cc // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/mattn/go-runewidth v0.0.5 - github.com/openfaas/faas v0.0.0-20200207215241-6afae214e3ec // indirect + 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 // indirect + github.com/openfaas/faas-provider v0.15.0 github.com/petergtz/pegomock v2.6.0+incompatible github.com/rakyll/hey v0.1.2 github.com/rs/zerolog v1.17.2 diff --git a/go.sum b/go.sum index 38a835e0..07ae4013 100644 --- a/go.sum +++ b/go.sum @@ -67,6 +67,10 @@ github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuy github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alexellis/go-execute v0.0.0-20200124154445-8697e4e28c5e h1:0cv4CUENL7e67/ZlNrvExWqa6oKH/9iv0KQn0/+hYaY= +github.com/alexellis/go-execute v0.0.0-20200124154445-8697e4e28c5e/go.mod h1:zfRbgnPVxXCSpiKrg1CE72hNUWInqxExiaz2D9ppTts= +github.com/alexellis/hmac v0.0.0-20180624211220-5c52ab81c0de h1:jiPEvtW8VT0KwJxRyjW2VAAvlssjj9SfecsQ3Vgv5tk= +github.com/alexellis/hmac v0.0.0-20180624211220-5c52ab81c0de/go.mod h1:uAbpy8G7sjNB4qYdY6ymf5OIQ+TLDPApBYiR0Vc3lhk= github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= github.com/asaskevich/govalidator v0.0.0-20180720115003-f9ffefc3facf/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY= @@ -470,6 +474,7 @@ github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lN github.com/modern-go/reflect2 v1.0.1 h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9AWI= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/mohae/deepcopy v0.0.0-20170603005431-491d3605edfb/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= +github.com/morikuni/aec v0.0.0-20170113033406-39771216ff4c h1:nXxl5PrvVm2L/wCy8dQu6DMTwH4oIuGN8GJDAlqDdVE= github.com/morikuni/aec v0.0.0-20170113033406-39771216ff4c/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/mrunalp/fileutils v0.0.0-20160930181131-4ee1cc9a8058/go.mod h1:x8F1gnqOkIEiO4rqoeEEEqQbo7HjGMTvyoq3gej4iT0= github.com/munnerz/goautoneg v0.0.0-20120707110453-a547fc61f48d h1:7PxY7LVfSZm7PEeBTyK1rj1gABdCO2mbri6GKO1cMDs= diff --git a/internal/dao/chart.go b/internal/dao/chart.go index e69aa050..a407bd0d 100644 --- a/internal/dao/chart.go +++ b/internal/dao/chart.go @@ -90,7 +90,6 @@ func (c *Chart) ToYAML(path string) (string, error) { // Delete uninstall a Chart. func (c *Chart) Delete(path string, cascade, force bool) error { - log.Debug().Msgf("CHART DELETE %q", path) ns, n := client.Namespaced(path) cfg, err := c.EnsureHelmConfig(ns) if err != nil { diff --git a/internal/dao/helpers.go b/internal/dao/helpers.go index e893e3b2..718ad677 100644 --- a/internal/dao/helpers.go +++ b/internal/dao/helpers.go @@ -29,6 +29,7 @@ func ToYAML(o runtime.Object) (string, error) { if o == nil { return "", errors.New("no object to yamlize") } + var ( buff bytes.Buffer p printers.YAMLPrinter diff --git a/internal/dao/ofaas.go b/internal/dao/ofaas.go new file mode 100644 index 00000000..7feb5402 --- /dev/null +++ b/internal/dao/ofaas.go @@ -0,0 +1,216 @@ +package dao + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "net/http" + "net/url" + "os" + "path" + "strings" + "time" + + "github.com/derailed/k9s/internal/client" + "github.com/derailed/k9s/internal/render" + "github.com/openfaas/faas-cli/proxy" + "github.com/openfaas/faas/gateway/requests" + "github.com/rs/zerolog/log" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/yaml" +) + +const ( + oFaasGatewayEnv = "OPENFAAS_GATEWAY" + oFaasJWTTokenEnv = "OPENFAAS_JWT_TOKEN" + oFaasTLSInsecure = "OPENFAAS_TLS_INSECURE" +) + +var ( + _ Accessor = (*OpenFaas)(nil) + _ Nuker = (*OpenFaas)(nil) + _ Describer = (*OpenFaas)(nil) +) + +// OpenFaas represents a faas gateway connection. +type OpenFaas struct { + NonResource +} + +// IsOpenFaasEnabled returns true if a gateway url is set in the environment. +func IsOpenFaasEnabled() bool { + return os.Getenv(oFaasGatewayEnv) != "" +} + +func getOpenFAASFlags() (string, string, bool) { + gw, token := os.Getenv(oFaasGatewayEnv), os.Getenv(oFaasJWTTokenEnv) + tlsInsecure := false + if os.Getenv(oFaasTLSInsecure) == "true" { + tlsInsecure = true + } + + return gw, token, tlsInsecure +} + +// List returns a collection of functions +func (f *OpenFaas) Get(ctx context.Context, path string) (runtime.Object, error) { + ns, n := client.Namespaced(path) + + oo, err := f.List(ctx, ns) + if err != nil { + return nil, err + } + + var found runtime.Object + for _, o := range oo { + r, ok := o.(render.OpenFaasRes) + if !ok { + continue + } + if r.Function.Name == n { + found = o + break + } + } + + if found == nil { + return nil, fmt.Errorf("unable to locate function %q", path) + } + + return found, nil +} + +// List returns a collection of functions +func (f *OpenFaas) List(_ context.Context, ns string) ([]runtime.Object, error) { + if !IsOpenFaasEnabled() { + return nil, errors.New("OpenFAAS is not enabled on this cluster") + } + + gw, token, tls := getOpenFAASFlags() + ff, err := proxy.ListFunctionsToken(gw, tls, token, ns) + if err != nil { + return nil, err + } + + oo := make([]runtime.Object, 0, len(ff)) + for _, f := range ff { + oo = append(oo, render.OpenFaasRes{Function: f}) + } + + return oo, nil +} + +func (f *OpenFaas) Delete(path string, _, _ bool) error { + gw, token, tls := getOpenFAASFlags() + ns, n := client.Namespaced(path) + + // BOZO!! openfaas spews to stdout. Not good for us... + return deleteFunctionToken(gw, n, tls, token, ns) +} + +func (f *OpenFaas) ToYAML(path string) (string, error) { + return f.Describe(path) +} + +func (f *OpenFaas) Describe(path string) (string, error) { + o, err := f.Get(context.Background(), path) + if err != nil { + return "", err + } + + fn, ok := o.(render.OpenFaasRes) + if !ok { + return "", fmt.Errorf("expecting OpenFaasRes but got %T", o) + } + + raw, err := json.Marshal(fn) + if err != nil { + return "", err + } + + bytes, err := yaml.JSONToYAML(raw) + if err != nil { + return "", err + } + + return string(bytes), nil +} + +// BOZO!! Meow! openfaas fn prints to stdout have to dup ;( +func deleteFunctionToken(gateway string, functionName string, tlsInsecure bool, token string, namespace string) error { + defaultCommandTimeout := 60 * time.Second + + gateway = strings.TrimRight(gateway, "/") + delReq := requests.DeleteFunctionRequest{FunctionName: functionName} + reqBytes, _ := json.Marshal(&delReq) + reader := bytes.NewReader(reqBytes) + + c := proxy.MakeHTTPClient(&defaultCommandTimeout, tlsInsecure) + + deleteEndpoint, err := createSystemEndpoint(gateway, namespace) + if err != nil { + return err + } + + req, err := http.NewRequest("DELETE", deleteEndpoint, reader) + if err != nil { + fmt.Println(err) + return err + } + req.Header.Set("Content-Type", "application/json") + + if len(token) > 0 { + proxy.SetToken(req, token) + } else { + proxy.SetAuth(req, gateway) + } + + delRes, delErr := c.Do(req) + + if delErr != nil { + fmt.Printf("Error removing existing function: %s, gateway=%s, functionName=%s\n", delErr.Error(), gateway, functionName) + return delErr + } + + if delRes.Body != nil { + defer func() { + if err := delRes.Body.Close(); err != nil { + log.Error().Err(err).Msgf("closing delete-gtw body") + } + }() + } + + switch delRes.StatusCode { + case http.StatusOK, http.StatusCreated, http.StatusAccepted: + return nil + case http.StatusNotFound: + return fmt.Errorf("no function named %s found", functionName) + 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) + if err != nil { + return err + } + return fmt.Errorf("server returned unexpected status code %d %s", delRes.StatusCode, string(bytesOut)) + } +} + +func createSystemEndpoint(gateway, namespace string) (string, error) { + const systemPath = "/system/functions" + + gatewayURL, err := url.Parse(gateway) + if err != nil { + return "", fmt.Errorf("invalid gateway URL: %s", err.Error()) + } + gatewayURL.Path = path.Join(gatewayURL.Path, systemPath) + if len(namespace) > 0 { + q := gatewayURL.Query() + q.Set("namespace", namespace) + gatewayURL.RawQuery = q.Encode() + } + return gatewayURL.String(), nil +} diff --git a/internal/dao/registry.go b/internal/dao/registry.go index 56799b5b..1436efd7 100644 --- a/internal/dao/registry.go +++ b/internal/dao/registry.go @@ -47,6 +47,7 @@ func AccessorFor(f Factory, gvr client.GVR) (Accessor, error) { client.NewGVR("batch/v1beta1/cronjobs"): &CronJob{}, client.NewGVR("batch/v1/jobs"): &Job{}, client.NewGVR("charts"): &Chart{}, + client.NewGVR("openfaas"): &OpenFaas{}, } r, ok := m[gvr] @@ -96,7 +97,7 @@ func (m *Meta) MetaFor(gvr client.GVR) (metav1.APIResource, error) { // IsK8sMeta checks for non resource meta. func IsK8sMeta(m metav1.APIResource) bool { for _, c := range m.Categories { - if c == "k9s" || c == "helm" { + if c == "k9s" || c == "helm" || c == "faas" { return false } } @@ -135,6 +136,9 @@ func loadNonResource(m ResourceMetas) { loadK9s(m) loadRBAC(m) loadHelm(m) + if IsOpenFaasEnabled() { + loadOpenFaas(m) + } } func loadK9s(m ResourceMetas) { @@ -203,6 +207,17 @@ func loadHelm(m ResourceMetas) { } } +func loadOpenFaas(m ResourceMetas) { + m[client.NewGVR("openfaas")] = metav1.APIResource{ + Name: "openfaas", + Kind: "OpenFaaS", + ShortNames: []string{"ofaas", "ofa"}, + Namespaced: true, + Verbs: []string{"delete"}, + Categories: []string{"faas"}, + } +} + func loadRBAC(m ResourceMetas) { m[client.NewGVR("rbac")] = metav1.APIResource{ Name: "rbacs", diff --git a/internal/model/registry.go b/internal/model/registry.go index 717f8bec..997ddfe2 100644 --- a/internal/model/registry.go +++ b/internal/model/registry.go @@ -14,6 +14,10 @@ var Registry = map[string]ResourceMeta{ DAO: &dao.Chart{}, Renderer: &render.Chart{}, }, + "openfaas": { + DAO: &dao.OpenFaas{}, + Renderer: &render.OpenFaas{}, + }, "containers": { DAO: &dao.Container{}, Renderer: &render.Container{}, diff --git a/internal/render/chart.go b/internal/render/chart.go index cb3aa0c7..f1ab5892 100644 --- a/internal/render/chart.go +++ b/internal/render/chart.go @@ -66,7 +66,7 @@ func (c Chart) Render(o interface{}, ns string, r *Row) error { // ---------------------------------------------------------------------------- // Helpers... -// ChartRes represents an alias resource. +// ChartRes represents an helm chart resource. type ChartRes struct { Release *release.Release } diff --git a/internal/render/ofaas.go b/internal/render/ofaas.go new file mode 100644 index 00000000..c51dd4c7 --- /dev/null +++ b/internal/render/ofaas.go @@ -0,0 +1,102 @@ +package render + +import ( + "fmt" + "strconv" + "time" + + "github.com/derailed/k9s/internal/client" + "github.com/derailed/tview" + "github.com/gdamore/tcell" + ofaas "github.com/openfaas/faas-provider/types" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +const ( + fnStatusReady = "Ready" + fnStatusNotReady = "Not Ready" +) + +// OpenFaas renders an openfaas function to screen. +type OpenFaas struct{} + +// ColorerFunc colors a resource row. +func (OpenFaas) ColorerFunc() ColorerFunc { + return func(ns string, re RowEvent) tcell.Color { + return tcell.ColorPaleTurquoise + } +} + +// Header returns a header row. +func (OpenFaas) Header(ns string) HeaderRow { + var h HeaderRow + if client.IsAllNamespaces(ns) { + h = append(h, Header{Name: "NAMESPACE"}) + } + + return append(h, + Header{Name: "NAME"}, + Header{Name: "STATUS"}, + Header{Name: "IMAGE"}, + Header{Name: "LABELS"}, + Header{Name: "INVOCATIONS", Align: tview.AlignRight}, + Header{Name: "REPLICAS", Align: tview.AlignRight}, + Header{Name: "AVAILABLE", Align: tview.AlignRight}, + Header{Name: "AGE", Decorator: AgeDecorator}, + ) +} + +// Render renders a chart to screen. +func (f OpenFaas) Render(o interface{}, ns string, r *Row) error { + fn, ok := o.(OpenFaasRes) + if !ok { + return fmt.Errorf("expected OpenFaasRes, but got %T", o) + } + + var labels string + if fn.Function.Labels != nil { + labels = mapToStr(*fn.Function.Labels) + } + var status = fnStatusReady + if fn.Function.AvailableReplicas == 0 { + status = fnStatusNotReady + } + + r.ID = client.FQN(fn.Function.Namespace, fn.Function.Name) + r.Fields = make(Fields, 0, len(f.Header(ns))) + if client.IsAllNamespaces(ns) { + r.Fields = append(r.Fields, fn.Function.Namespace) + } + r.Fields = append(r.Fields, + fn.Function.Name, + status, + fn.Function.Image, + labels, + strconv.Itoa(int(fn.Function.InvocationCount)), + strconv.Itoa(int(fn.Function.Replicas)), + strconv.Itoa(int(fn.Function.AvailableReplicas)), + toAge(metav1.Time{Time: time.Now()}), + ) + + return nil +} + +// ---------------------------------------------------------------------------- +// Helpers... + +// OpenFaasRes represents an openfaas function resource. +type OpenFaasRes struct { + Function ofaas.FunctionStatus `json:"function"` +} + +// GetObjectKind returns a schema object. +func (OpenFaasRes) GetObjectKind() schema.ObjectKind { + return nil +} + +// DeepCopyObject returns a container copy. +func (h OpenFaasRes) DeepCopyObject() runtime.Object { + return h +} diff --git a/internal/render/ofaas_test.go b/internal/render/ofaas_test.go new file mode 100644 index 00000000..3deda698 --- /dev/null +++ b/internal/render/ofaas_test.go @@ -0,0 +1,34 @@ +package render_test + +import ( + "testing" + + "github.com/derailed/k9s/internal/render" + ofaas "github.com/openfaas/faas-provider/types" + "github.com/stretchr/testify/assert" +) + +func TestOpenFaasRender(t *testing.T) { + c := render.OpenFaas{} + r := render.NewRow(9) + c.Render(makeFn("blee"), "", &r) + + assert.Equal(t, "default/blee", r.ID) + assert.Equal(t, render.Fields{"default", "blee", "Ready", "nginx:0", "fred=blee", "10", "1", "1"}, r.Fields[:8]) +} + +// Helpers... + +func makeFn(n string) render.OpenFaasRes { + return render.OpenFaasRes{ + Function: ofaas.FunctionStatus{ + Name: n, + Namespace: "default", + Image: "nginx:0", + InvocationCount: 10, + Replicas: 1, + AvailableReplicas: 1, + Labels: &map[string]string{"fred": "blee"}, + }, + } +} diff --git a/internal/ui/dialog/port_forwards.go b/internal/ui/dialog/port_forwards.go new file mode 100644 index 00000000..279e4524 --- /dev/null +++ b/internal/ui/dialog/port_forwards.go @@ -0,0 +1,62 @@ +package dialog + +import ( + "fmt" + + "github.com/derailed/k9s/internal/config" + "github.com/derailed/k9s/internal/ui" + "github.com/derailed/tview" +) + +type PortForwardFunc func(path, address, lport, cport string) + +// ShowPortForwards pops a port forwarding configuration dialog. +func ShowPortForwards(p *ui.Pages, s *config.Styles, path string, ports []string, okFn PortForwardFunc) { + f := tview.NewForm() + f.SetItemPadding(0) + f.SetButtonsAlign(tview.AlignCenter). + SetButtonBackgroundColor(s.BgColor()). + SetButtonTextColor(s.FgColor()). + SetLabelColor(config.AsColor(s.K9s.Info.FgColor)). + SetFieldTextColor(config.AsColor(s.K9s.Info.SectionColor)) + + p1, p2, address := ports[0], ports[0], "localhost" + f.AddDropDown("Container Ports", ports, 0, func(sel string, _ int) { + p1, p2 = sel, stripPort(sel) + }) + + dropD, ok := f.GetFormItem(0).(*tview.DropDown) + if ok { + dropD.SetFieldBackgroundColor(s.BgColor()) + list := dropD.GetList() + list.SetMainTextColor(s.FgColor()) + list.SetSelectedTextColor(s.FgColor()) + list.SetSelectedBackgroundColor(config.AsColor(s.Table().CursorColor)) + list.SetBackgroundColor(s.BgColor() + 100) + } + f.AddInputField("Local Port:", p2, 20, nil, func(p string) { + p2 = p + }) + f.AddInputField("Address:", address, 20, nil, func(h string) { + address = h + }) + + f.AddButton("OK", func() { + okFn(path, address, stripPort(p2), stripPort(p1)) + }) + f.AddButton("Cancel", func() { + DismissPortForward(p) + }) + + modal := tview.NewModalForm(fmt.Sprintf("", path), f) + modal.SetDoneFunc(func(_ int, b string) { + DismissPortForward(p) + }) + p.AddPage(portForwardKey, modal, false, false) + p.ShowPage(portForwardKey) +} + +// DismissPortForward dismiss the port forward dialog. +func DismissPortForwards(p *ui.Pages) { + p.RemovePage(portForwardKey) +} diff --git a/internal/view/app.go b/internal/view/app.go index d41814cb..d36f5745 100644 --- a/internal/view/app.go +++ b/internal/view/app.go @@ -134,7 +134,7 @@ func (a *App) toggleHeader(flag bool) { } if a.showHeader { flex.RemoveItemAtIndex(0) - flex.AddItemAtIndex(0, a.buildHeader(), 8, 1, false) + flex.AddItemAtIndex(0, a.buildHeader(), 7, 1, false) } else { flex.RemoveItemAtIndex(0) flex.AddItemAtIndex(0, a.statusIndicator(), 1, 1, false) diff --git a/internal/view/container.go b/internal/view/container.go index 9b25a00f..724801eb 100644 --- a/internal/view/container.go +++ b/internal/view/container.go @@ -159,31 +159,35 @@ func (c *Container) preparePort(pp []string) string { func (c *Container) portForward(address, lport, cport string) { co := c.GetTable().GetSelectedCell(0) pf := dao.NewPortForwarder(c.App().Conn()) + path := c.GetTable().GetSelectedItem() ports := []string{lport + ":" + cport} - fw, err := pf.Start(c.GetTable().Path, co, address, ports) + fw, err := pf.Start(path, co, address, ports) if err != nil { c.App().Flash().Err(err) return } - log.Debug().Msgf(">>> Starting port forward %q %v", c.GetTable().Path, ports) - go c.runForward(pf, fw) + log.Debug().Msgf(">>> Starting port forward %q %v", path, ports) + go runForward(c.App(), pf, fw) } -func (c *Container) runForward(pf *dao.PortForwarder, f *portforward.PortForwarder) { - c.App().QueueUpdateDraw(func() { - c.App().factory.AddForwarder(pf) - c.App().Flash().Infof("PortForward activated %s:%s", pf.Path(), pf.Ports()[0]) - dialog.DismissPortForward(c.App().Content.Pages) +// ---------------------------------------------------------------------------- +// Helpers... + +func runForward(a *App, pf *dao.PortForwarder, f *portforward.PortForwarder) { + a.QueueUpdateDraw(func() { + a.factory.AddForwarder(pf) + a.Flash().Infof("PortForward activated %s:%s", pf.Path(), pf.Ports()[0]) + dialog.DismissPortForward(a.Content.Pages) }) pf.SetActive(true) if err := f.ForwardPorts(); err != nil { - c.App().Flash().Err(err) + a.Flash().Err(err) return } - c.App().QueueUpdateDraw(func() { - c.App().factory.DeleteForwarder(pf.FQN()) + a.QueueUpdateDraw(func() { + a.factory.DeleteForwarder(pf.FQN()) pf.SetActive(false) }) } diff --git a/internal/view/help_test.go b/internal/view/help_test.go index 18c5ccd3..443633c3 100644 --- a/internal/view/help_test.go +++ b/internal/view/help_test.go @@ -21,7 +21,7 @@ func TestHelp(t *testing.T) { v := view.NewHelp() assert.Nil(t, v.Init(ctx)) - assert.Equal(t, 19, v.GetRowCount()) + assert.Equal(t, 20, v.GetRowCount()) assert.Equal(t, 8, v.GetColumnCount()) assert.Equal(t, "", strings.TrimSpace(v.GetCell(1, 0).Text)) assert.Equal(t, "Kill", strings.TrimSpace(v.GetCell(1, 1).Text)) diff --git a/internal/view/ofaas.go b/internal/view/ofaas.go new file mode 100644 index 00000000..fda3bd62 --- /dev/null +++ b/internal/view/ofaas.go @@ -0,0 +1,46 @@ +package view + +import ( + "strings" + + "github.com/derailed/k9s/internal/client" + "github.com/derailed/k9s/internal/render" + "github.com/derailed/k9s/internal/ui" +) + +// OpenFaas represents an OpenFaaS viewer. +type OpenFaas struct { + ResourceViewer +} + +// NewOpenFaas returns a new viewer. +func NewOpenFaas(gvr client.GVR) ResourceViewer { + o := OpenFaas{ResourceViewer: NewBrowser(gvr)} + o.SetBindKeysFn(o.bindKeys) + o.GetTable().SetEnterFn(o.showPods) + o.GetTable().SetColorerFn(render.OpenFaas{}.ColorerFunc()) + + return &o +} + +func (o *OpenFaas) bindKeys(aa ui.KeyActions) { + aa.Add(ui.KeyActions{ + ui.KeyShiftS: ui.NewKeyAction("Sort Status", o.GetTable().SortColCmd(2, true), false), + ui.KeyShiftT: ui.NewKeyAction("Sort Invocations", o.GetTable().SortColCmd(5, false), false), + ui.KeyShiftC: ui.NewKeyAction("Sort Replicas", o.GetTable().SortColCmd(6, false), false), + ui.KeyShiftM: ui.NewKeyAction("Sort Available", o.GetTable().SortColCmd(7, false), false), + }) +} + +func (o *OpenFaas) showPods(a *App, _ ui.Tabular, _, path string) { + labels := o.GetTable().GetSelectedCell(4) + sels := make(map[string]string) + + tokens := strings.Split(labels, ",") + for _, t := range tokens { + s := strings.Split(t, "=") + sels[s[0]] = s[1] + } + + showPodsWithLabels(a, path, sels) +} diff --git a/internal/view/pod.go b/internal/view/pod.go index 11f50ec0..0a79b7c7 100644 --- a/internal/view/pod.go +++ b/internal/view/pod.go @@ -11,6 +11,7 @@ import ( "github.com/derailed/k9s/internal/model" "github.com/derailed/k9s/internal/render" "github.com/derailed/k9s/internal/ui" + "github.com/derailed/k9s/internal/ui/dialog" "github.com/derailed/k9s/internal/watch" "github.com/fatih/color" "github.com/gdamore/tcell" @@ -49,6 +50,7 @@ func (p *Pod) bindKeys(aa ui.KeyActions) { } aa.Add(ui.KeyActions{ + ui.KeyShiftF: ui.NewKeyAction("Port-Forward", p.portFwdCmd, true), ui.KeyShiftR: ui.NewKeyAction("Sort Ready", p.GetTable().SortColCmd(1, true), false), ui.KeyShiftS: ui.NewKeyAction("Sort Status", p.GetTable().SortColCmd(2, true), false), ui.KeyShiftT: ui.NewKeyAction("Sort Restart", p.GetTable().SortColCmd(3, false), false), @@ -64,7 +66,6 @@ func (p *Pod) bindKeys(aa ui.KeyActions) { } func (p *Pod) showContainers(app *App, model ui.Tabular, gvr, path string) { - log.Debug().Msgf("SHOW CONTAINERS %q -- %q -- %q", gvr, model.GetNamespace(), path) co := NewContainer(client.NewGVR("containers")) co.SetContextFn(p.coContext) if err := app.inject(co); err != nil { @@ -128,6 +129,51 @@ func (p *Pod) shellCmd(evt *tcell.EventKey) *tcell.EventKey { return nil } +func (p *Pod) portFwdCmd(evt *tcell.EventKey) *tcell.EventKey { + path := p.GetTable().GetSelectedItem() + if path == "" { + return evt + } + + pp, err := fetchPodPorts(p.App().factory, path) + if err != nil { + p.App().Flash().Err(err) + return nil + } + ports := make([]string, 0, len(pp)) + for _, p := range pp { + if p.Protocol == v1.ProtocolTCP { + port := fmt.Sprintf("%s:%d", p.Name, p.ContainerPort) + if p.Name == "" { + port = fmt.Sprintf("%d", p.ContainerPort) + } + ports = append(ports, port) + } + } + + if len(ports) == 0 { + p.App().Flash().Err(fmt.Errorf("no tcp ports found on %s", path)) + return nil + } + + dialog.ShowPortForwards(p.App().Content.Pages, p.App().Styles, path, ports, p.portForward) + + return nil +} + +func (p *Pod) portForward(path, address, lport, cport string) { + pf := dao.NewPortForwarder(p.App().Conn()) + ports := []string{lport + ":" + cport} + fw, err := pf.Start(path, "", address, ports) + if err != nil { + p.App().Flash().Err(err) + return + } + + log.Debug().Msgf(">>> Starting port forward %q %v", path, ports) + go runForward(p.App(), pf, fw) +} + // ---------------------------------------------------------------------------- // Helpers... @@ -160,6 +206,7 @@ func containerShellin(a *App, comp model.Component, path, co string) error { func resumeShellIn(a *App, c model.Component, path, co string) { c.Stop() defer c.Start() + shellIn(a, path, co) } @@ -205,10 +252,33 @@ func fetchContainers(f *watch.Factory, path string, includeInit bool) ([]string, for _, c := range pod.Spec.Containers { nn = append(nn, c.Name) } - if includeInit { - for _, c := range pod.Spec.InitContainers { - nn = append(nn, c.Name) - } + if !includeInit { + return nn, nil } + for _, c := range pod.Spec.InitContainers { + nn = append(nn, c.Name) + } + return nn, nil } + +func fetchPodPorts(f *watch.Factory, path string) ([]v1.ContainerPort, error) { + log.Debug().Msgf("Fetching ports on pod %q", path) + o, err := f.Get("v1/pods", path, false, labels.Everything()) + if err != nil { + return nil, err + } + + var pod v1.Pod + err = runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, &pod) + if err != nil { + return nil, err + } + + pp := make([]v1.ContainerPort, 0, len(pod.Spec.Containers)) + for _, c := range pod.Spec.Containers { + pp = append(pp, c.Ports...) + } + + return pp, nil +} diff --git a/internal/view/pod_test.go b/internal/view/pod_test.go index 00878bdb..8588155c 100644 --- a/internal/view/pod_test.go +++ b/internal/view/pod_test.go @@ -16,7 +16,7 @@ func TestPodNew(t *testing.T) { assert.Nil(t, po.Init(makeCtx())) assert.Equal(t, "Pods", po.Name()) - assert.Equal(t, 18, len(po.Hints())) + assert.Equal(t, 19, len(po.Hints())) } // Helpers... diff --git a/internal/view/registrar.go b/internal/view/registrar.go index 40d228a9..9631348d 100644 --- a/internal/view/registrar.go +++ b/internal/view/registrar.go @@ -51,6 +51,9 @@ func miscViewers(vv MetaViewers) { vv[client.NewGVR("contexts")] = MetaViewer{ viewerFn: NewContext, } + vv[client.NewGVR("openfaas")] = MetaViewer{ + viewerFn: NewOpenFaas, + } vv[client.NewGVR("containers")] = MetaViewer{ viewerFn: NewContainer, } diff --git a/internal/view/svc.go b/internal/view/svc.go index 5cd7257b..0a5aff0b 100644 --- a/internal/view/svc.go +++ b/internal/view/svc.go @@ -8,8 +8,10 @@ import ( "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/config" + "github.com/derailed/k9s/internal/dao" "github.com/derailed/k9s/internal/perf" "github.com/derailed/k9s/internal/ui" + "github.com/derailed/k9s/internal/ui/dialog" "github.com/gdamore/tcell" "github.com/rs/zerolog/log" v1 "k8s.io/api/core/v1" @@ -40,19 +42,92 @@ func NewService(gvr client.GVR) ResourceViewer { func (s *Service) bindKeys(aa ui.KeyActions) { aa.Add(ui.KeyActions{ + ui.KeyShiftF: ui.NewKeyAction("Port-Forward", s.portFwdCmd, true), tcell.KeyCtrlB: ui.NewKeyAction("Bench Run/Stop", s.toggleBenchCmd, true), ui.KeyShiftT: ui.NewKeyAction("Sort Type", s.GetTable().SortColCmd(1, true), false), }) } -func (s *Service) showPods(app *App, _ ui.Tabular, gvr, path string) { - o, err := app.factory.Get(gvr, path, true, labels.Everything()) +func podFromSelector(f dao.Factory, ns string, sel map[string]string) (string, error) { + log.Debug().Msgf("Looking for pods %q:%v -- %v", ns, sel, labels.Set(sel).AsSelector()) + oo, err := f.List("v1/pods", ns, true, labels.Set(sel).AsSelector()) if err != nil { - app.Flash().Err(err) + return "", err + } + + if len(oo) == 0 { + return "", fmt.Errorf("no matching pods for %v", sel) + } + + var pod v1.Pod + err = runtime.DefaultUnstructuredConverter.FromUnstructured(oo[0].(*unstructured.Unstructured).Object, &pod) + if err != nil { + return "", err + } + + return client.FQN(pod.Namespace, pod.Name), nil +} + +func (s *Service) portFwdCmd(evt *tcell.EventKey) *tcell.EventKey { + path := s.GetTable().GetSelectedItem() + if path == "" { + return evt + } + + svc, err := fetchService(s.App().factory, s.GVR(), path) + if err != nil { + s.App().Flash().Err(err) + return nil + } + + ns, _ := client.Namespaced(path) + pod, err := podFromSelector(s.App().factory, ns, svc.Spec.Selector) + if err != nil { + s.App().Flash().Err(err) + return nil + } + + pp, err := fetchPodPorts(s.App().factory, pod) + if err != nil { + s.App().Flash().Err(err) + return nil + } + ports := make([]string, 0, len(pp)) + for _, p := range pp { + if p.Protocol == v1.ProtocolTCP { + port := fmt.Sprintf("%s:%d", p.Name, p.ContainerPort) + if p.Name == "" { + port = fmt.Sprintf("%d", p.ContainerPort) + } + ports = append(ports, port) + } + } + + if len(ports) == 0 { + s.App().Flash().Err(fmt.Errorf("no tcp ports found on %s", path)) + return nil + } + + dialog.ShowPortForwards(s.App().Content.Pages, s.App().Styles, pod, ports, s.portForward) + + return nil +} + +func (s *Service) portForward(path, address, lport, cport string) { + pf := dao.NewPortForwarder(s.App().Conn()) + ports := []string{lport + ":" + cport} + fw, err := pf.Start(path, "", address, ports) + if err != nil { + s.App().Flash().Err(err) return } - var svc v1.Service - err = runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, &svc) + + log.Debug().Msgf(">>> Starting port forward %q %v", path, ports) + go runForward(s.App(), pf, fw) +} + +func (s *Service) showPods(app *App, _ ui.Tabular, gvr, path string) { + svc, err := fetchService(app.factory, gvr, path) if err != nil { app.Flash().Err(err) return @@ -170,6 +245,21 @@ func (s *Service) benchDone() { }) } +// ---------------------------------------------------------------------------- +// Helpers... + +func fetchService(f dao.Factory, gvr, path string) (*v1.Service, error) { + o, err := f.Get(gvr, path, true, labels.Everything()) + if err != nil { + return nil, err + } + + var svc v1.Service + err = runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, &svc) + + return &svc, err +} + func benchTimedOut(app *App) { <-time.After(2 * time.Second) app.QueueUpdate(func() { diff --git a/internal/view/svc_test.go b/internal/view/svc_test.go index b09e3dfc..a9a5bd1c 100644 --- a/internal/view/svc_test.go +++ b/internal/view/svc_test.go @@ -132,5 +132,5 @@ func TestServiceNew(t *testing.T) { assert.Nil(t, s.Init(makeCtx())) assert.Equal(t, "Services", s.Name()) - assert.Equal(t, 7, len(s.Hints())) + assert.Equal(t, 8, len(s.Hints())) } diff --git a/internal/watch/factory.go b/internal/watch/factory.go index 4a56f178..3fe01958 100644 --- a/internal/watch/factory.go +++ b/internal/watch/factory.go @@ -229,6 +229,9 @@ func (f *Factory) DeleteForwarder(path string) { // Forwarders returns all portforwards. func (f *Factory) Forwarders() Forwarders { + f.mx.RLock() + defer f.mx.RUnlock() + return f.forwarders } diff --git a/plugins/README.md b/plugins/README.md new file mode 100644 index 00000000..e23d86d7 --- /dev/null +++ b/plugins/README.md @@ -0,0 +1,4 @@ +# K9s community plugins + +These plugins provide for extending the K9s cli to provide for more cluster management fu. + diff --git a/plugins/job_suspend.yml b/plugins/job_suspend.yml new file mode 100644 index 00000000..3d4b4ae7 --- /dev/null +++ b/plugins/job_suspend.yml @@ -0,0 +1,19 @@ +plugin: + # Suspends/Resumes a cronjob + suspendCronsToggle: + shortCut: Ctrl-S + scopes: + - cj + description: Suspend toggle + command: kubectl + background: true + args: + - patch + - cronjobs + - $NAME + - ns + - $NAMESPACE + - --context + - $CONTEXT + - -p + - '{"spec" : {"suspend" : $COL3 }}' diff --git a/plugins/kubectl/kubectl-jq b/plugins/kubectl/kubectl-jq new file mode 100755 index 00000000..5229a1d5 --- /dev/null +++ b/plugins/kubectl/kubectl-jq @@ -0,0 +1,3 @@ +#!/bin/bash + +/usr/local/bin/kubectl logs -f $1 -n $2 --context $3 | jq -r '.message' \ No newline at end of file diff --git a/plugins/log_jq.yml b/plugins/log_jq.yml new file mode 100644 index 00000000..4e6d6d3c --- /dev/null +++ b/plugins/log_jq.yml @@ -0,0 +1,14 @@ +plugin: + # Sends logs over to jq for processing. This leverages kubectl plugin kubectl-jq. + jqlogs: + shortCut: Ctrl-J + description: "Logs (jq)" + scopes: + - po + command: kubectl + background: false + args: + - jq + - $NAME + - $NAMESPACE + - $CONTEXT diff --git a/plugins/log_stern.yml b/plugins/log_stern.yml new file mode 100644 index 00000000..83dbe6f1 --- /dev/null +++ b/plugins/log_stern.yml @@ -0,0 +1,17 @@ +plugin: + # Leverage stern (https://github.com/wercker/stern) to output logs. + stern: + shortCut: Ctrl-L + description: "Logs " + scopes: + - pods + command: stern + background: false + args: + - --tail + - 50 + - $FILTER + - -n + - $NAMESPACE + - --context + - $CONTEXT