Projects STRLCPY cdebug Commits 2a43eae3
🤬
  • CLI framework overhaul

    Human-readable top-level errors. Quiet mode support. Less logrus, more CLI.Say|Grumble|Wisper.
  • Loading...
  • Ivan Velichko committed 2 years ago
    2a43eae3
    1 parent f4455eab
  • ■ ■ ■ ■ ■
    README.md
    1  -# cdebug - experimental container debugger (WIP)
     1 +# cdebug - a swiss army knife of container debugging (WIP)
     2 + 
     3 +TODO: Add table command (exec, port-forward, export, explore) x runtime (docker, containerd, k8s, etc).
    2 4   
    3 5  A handy way to troubleshoot containers lacking a shell and/or debugging tools
    4 6  (e.g, scratch, slim, or distroless):
    skipped 173 lines
  • ■ ■ ■ ■ ■ ■
    cmd/exec/exec.go
    skipped 2 lines
    3 3  import (
    4 4   "bytes"
    5 5   "context"
     6 + "errors"
    6 7   "fmt"
    7 8   "io"
    8 9   "strings"
    skipped 6 lines
    15 16   "github.com/sirupsen/logrus"
    16 17   "github.com/spf13/cobra"
    17 18   
    18  - "github.com/iximiuz/cdebug/pkg/cmd"
     19 + "github.com/iximiuz/cdebug/pkg/cliutil"
    19 20   "github.com/iximiuz/cdebug/pkg/tty"
    20 21   "github.com/iximiuz/cdebug/pkg/util"
    21 22  )
    skipped 11 lines
    33 34   cmd []string
    34 35   privileged bool
    35 36   autoRemove bool
     37 + quiet bool
    36 38  }
    37 39   
    38  -func NewCommand(cli cmd.CLI) *cobra.Command {
     40 +func NewCommand(cli cliutil.CLI) *cobra.Command {
    39 41   var opts options
    40 42   
    41 43   cmd := &cobra.Command{
    skipped 1 lines
    43 45   Short: "Start a debugger shell in the target container",
    44 46   Args: cobra.MinimumNArgs(1),
    45 47   RunE: func(cmd *cobra.Command, args []string) error {
     48 + cli.SetQuiet(opts.quiet)
     49 + 
    46 50   opts.target = args[0]
    47 51   if len(args) > 1 {
    48 52   opts.cmd = args[1:]
    49 53   }
    50  - return runDebugger(context.Background(), cli, &opts)
     54 + return cliutil.WrapStatusError(runDebugger(context.Background(), cli, &opts))
    51 55   },
    52 56   }
    53 57   
    54 58   flags := cmd.Flags()
    55 59   flags.SetInterspersed(false) // Instead of relying on --
    56 60   
     61 + flags.BoolVarP(
     62 + &opts.quiet,
     63 + "quiet",
     64 + "q",
     65 + false,
     66 + `Suppress verbose output`,
     67 + )
     68 + 
    57 69   flags.StringVar(
    58 70   &opts.name,
    59 71   "name",
    60 72   "",
    61  - "Assign a name to the debugger container",
     73 + `Assign a name to the debugger container`,
    62 74   )
    63 75   flags.StringVar(
    64 76   &opts.image,
    65 77   "image",
    66 78   defaultToolkitImage,
    67  - "Debugging toolkit image (hint: use 'busybox' or 'nixery.dev/shell/tool1/tool2/etc...')",
     79 + `Debugging toolkit image (hint: use "busybox" or "nixery.dev/shell/tool1/tool2/etc...")`,
    68 80   )
    69 81   flags.BoolVarP(
    70 82   &opts.stdin,
    71 83   "interactive",
    72 84   "i",
    73 85   false,
    74  - "Keep the STDIN open (as in `docker exec -i`)",
     86 + `Keep the STDIN open (as in "docker exec -i")`,
    75 87   )
    76 88   flags.BoolVarP(
    77 89   &opts.tty,
    78 90   "tty",
    79 91   "t",
    80 92   false,
    81  - "Allocate a pseudo-TTY (as in `docker exec -t`)",
     93 + `Allocate a pseudo-TTY (as in "docker exec -t")`,
    82 94   )
    83 95   flags.BoolVar(
    84 96   &opts.privileged,
    85 97   "privileged",
    86 98   false,
    87  - "God mode for the debugger container (as in `docker run --privileged`)",
     99 + `God mode for the debugger container (as in "docker run --privileged")`,
    88 100   )
    89 101   flags.BoolVar(
    90 102   &opts.autoRemove,
    91 103   "rm",
    92 104   false,
    93  - "Automatically remove the debugger container when it exits (as in `docker run --rm`)",
     105 + `Automatically remove the debugger container when it exits (as in "docker run --rm")`,
    94 106   )
    95 107   
    96 108   return cmd
    skipped 21 lines
    118 130  `))
    119 131  )
    120 132   
    121  -func runDebugger(ctx context.Context, cli cmd.CLI, opts *options) error {
     133 +func runDebugger(ctx context.Context, cli cliutil.CLI, opts *options) error {
    122 134   if err := cli.InputStream().CheckTty(opts.stdin, opts.tty); err != nil {
    123 135   return err
    124 136   }
    skipped 3 lines
    128 140   dockerclient.WithAPIVersionNegotiation(),
    129 141   )
    130 142   if err != nil {
    131  - return fmt.Errorf("cannot initialize Docker client: %w", err)
     143 + return err
    132 144   }
    133 145   
    134 146   target, err := client.ContainerInspect(ctx, opts.target)
    135 147   if err != nil {
    136  - return fmt.Errorf("cannot inspect target container: %w", err)
     148 + return err
    137 149   }
    138 150   
    139 151   if target.State == nil || !target.State.Running {
    140  - return fmt.Errorf("target container found but it's not running")
     152 + return errors.New("target container found but it's not running")
    141 153   }
    142 154   
    143 155   if err := pullImage(ctx, cli, client, opts.image); err != nil {
    skipped 79 lines
    223 235   
    224 236  func pullImage(
    225 237   ctx context.Context,
    226  - cli cmd.CLI,
     238 + cli cliutil.CLI,
    227 239   client *dockerclient.Client,
    228 240   image string,
    229 241  ) error {
    skipped 9 lines
    239 251   
    240 252  func attachDebugger(
    241 253   ctx context.Context,
    242  - cli cmd.CLI,
     254 + cli cliutil.CLI,
    243 255   client *dockerclient.Client,
    244 256   opts *options,
    245 257   contID string,
    skipped 39 lines
    285 297  }
    286 298   
    287 299  type ioStreamer struct {
    288  - streams cmd.Streams
     300 + streams cliutil.Streams
    289 301   
    290 302   inputStream io.ReadCloser
    291 303   outputStream io.Writer
    skipped 81 lines
  • ■ ■ ■ ■ ■ ■
    cmd/portforward/portforward.go
    skipped 17 lines
    18 18   "github.com/sirupsen/logrus"
    19 19   "github.com/spf13/cobra"
    20 20   
    21  - "github.com/iximiuz/cdebug/pkg/cmd"
     21 + "github.com/iximiuz/cdebug/pkg/cliutil"
     22 + "github.com/iximiuz/cdebug/pkg/jsonutil"
    22 23   "github.com/iximiuz/cdebug/pkg/util"
    23 24  )
    24 25   
    skipped 18 lines
    43 44   
    44 45  const (
    45 46   helperImage = "nixery.dev/shell/socat:latest"
     47 + 
     48 + outFormatText = "text"
     49 + outFormatJSON = "json"
    46 50  )
    47 51   
    48 52  type options struct {
    49 53   target string
    50 54   forwardings []string
     55 + output string
     56 + quiet bool
    51 57  }
    52 58   
    53  -func NewCommand(cli cmd.CLI) *cobra.Command {
     59 +func NewCommand(cli cliutil.CLI) *cobra.Command {
    54 60   var opts options
    55 61   
    56 62   cmd := &cobra.Command{
    skipped 1 lines
    58 64   Short: `"Publish" one or more ports of an already running container`,
    59 65   Args: cobra.MinimumNArgs(2),
    60 66   RunE: func(cmd *cobra.Command, args []string) error {
     67 + cli.SetQuiet(opts.quiet)
     68 + 
    61 69   opts.target = args[0]
    62 70   opts.forwardings = args[1:]
    63  - return runPortForward(context.Background(), cli, &opts)
     71 + return cliutil.WrapStatusError(runPortForward(context.Background(), cli, &opts))
    64 72   },
    65 73   }
    66 74   
     75 + flags := cmd.Flags()
     76 + flags.SetInterspersed(false) // Instead of relying on --
     77 + 
     78 + flags.BoolVarP(
     79 + &opts.quiet,
     80 + "quiet",
     81 + "q",
     82 + false,
     83 + `Suppress verbose output`,
     84 + )
     85 + 
     86 + flags.StringVarP(
     87 + &opts.output,
     88 + "output",
     89 + "o",
     90 + outFormatText,
     91 + `Output format (plain text or JSON)`,
     92 + )
     93 + 
    67 94   return cmd
    68 95  }
    69 96   
    70  -func runPortForward(ctx context.Context, cli cmd.CLI, opts *options) error {
     97 +func runPortForward(ctx context.Context, cli cliutil.CLI, opts *options) error {
    71 98   client, err := dockerclient.NewClientWithOpts(
    72 99   dockerclient.FromEnv,
    73 100   dockerclient.WithAPIVersionNegotiation(),
    skipped 21 lines
    95 122   return err
    96 123   }
    97 124   
    98  - fmt.Println("forwardings")
    99  - util.PrettyPrint(forwardings)
    100  - 
    101  - fmt.Println("forwardings.toDockerPortSpecs()")
    102  - util.PrettyPrint(forwardings.toDockerPortSpecs())
    103  - 
    104  - fmt.Println("exposedPorts")
    105  - util.PrettyPrint(exposedPorts)
    106  - 
    107  - fmt.Println("portBindings")
    108  - util.PrettyPrint(portBindings)
    109  - 
    110 125   // TODO: Iterate over all forwardings.
    111 126   resp, err := client.ContainerCreate(
    112 127   ctx,
    skipped 26 lines
    139 154   if err != nil {
    140 155   return fmt.Errorf("cannot inspect forwarder container: %w", err)
    141 156   }
     157 + 
    142 158   // TODO: Multi-network support.
    143 159   targetIP := target.NetworkSettings.Networks["bridge"].IPAddress
    144  - for from, frontends := range forwarder.NetworkSettings.Ports {
    145  - for _, frontend := range frontends {
    146  - fmt.Printf("Forwarding %s to %s's %s:%s\n", net.JoinHostPort(frontend.HostIP, frontend.HostPort), target.Name[1:], targetIP, from)
     160 + for remotePort, localBindings := range forwarder.NetworkSettings.Ports {
     161 + for _, binding := range localBindings {
     162 + switch opts.output {
     163 + case outFormatText:
     164 + local := net.JoinHostPort(binding.HostIP, binding.HostPort)
     165 + remote := targetIP + ":" + string(remotePort)
     166 + cli.Say("Forwarding %s to %s's %s\n", local, target.Name[1:], remote)
     167 + case outFormatJSON:
     168 + cli.Say(jsonutil.Dump(map[string]string{
     169 + "localHost": binding.HostIP,
     170 + "localPort": binding.HostPort,
     171 + "remoteHost": targetIP,
     172 + "remotePort": string(remotePort),
     173 + }))
     174 + default:
     175 + panic("unreachable!")
     176 + }
    147 177   }
    148 178   }
    149 179   
    skipped 3 lines
    153 183   
    154 184   go func() {
    155 185   for _ = range sigCh {
    156  - fmt.Println("Exiting...")
     186 + cli.Wisper("Exiting...")
     187 + 
    157 188   if err := client.ContainerKill(ctx, resp.ID, "KILL"); err != nil {
    158  - logrus.WithError(err).Warn("Cannot kill forwarder container")
     189 + logrus.Debugf("Cannot kill forwarder container: %s", err)
    159 190   }
    160 191   break
    161 192   }
    skipped 14 lines
    176 207   
    177 208  func pullImage(
    178 209   ctx context.Context,
    179  - cli cmd.CLI,
     210 + cli cliutil.CLI,
    180 211   client *dockerclient.Client,
    181 212   image string,
    182 213  ) error {
    skipped 89 lines
  • ■ ■ ■ ■ ■
    go.mod
    skipped 10 lines
    11 11   github.com/moby/term v0.0.0-20220808134915-39b0c02b01ae
    12 12   github.com/sirupsen/logrus v1.9.0
    13 13   github.com/spf13/cobra v1.6.0
     14 + gotest.tools v2.2.0+incompatible
     15 + gotest.tools/v3 v3.4.0
    14 16  )
    15 17   
    16 18  require (
    skipped 14 lines
    31 33   golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f // indirect
    32 34   golang.org/x/time v0.1.0 // indirect
    33 35   golang.org/x/tools v0.1.12 // indirect
    34  - gotest.tools/v3 v3.4.0 // indirect
    35 36  )
    36 37   
  • ■ ■ ■ ■ ■ ■
    go.sum
    skipped 98 lines
    99 99  golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
    100 100  golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
    101 101  golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
     102 +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
    102 103  golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
    103 104  gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
    104 105  gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
    105 106  gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
    106 107  gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
     108 +gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo=
     109 +gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=
    107 110  gotest.tools/v3 v3.0.2/go.mod h1:3SzNCllyD9/Y+b5r9JIKQ474KzkZyqLqEfYqMsX94Bk=
    108 111  gotest.tools/v3 v3.4.0 h1:ZazjZUfuVeZGLAmlKKuyv3IKP5orXcwtOwDQH6YVr6o=
    109 112  gotest.tools/v3 v3.4.0/go.mod h1:CtbdzLSsqVhDgMtKsx03ird5YTGB3ar27v0u/yKBW5g=
    skipped 1 lines
  • ■ ■ ■ ■ ■
    main.go
    skipped 4 lines
    5 5   "os"
    6 6   
    7 7   "github.com/moby/term"
     8 + "github.com/sirupsen/logrus"
    8 9   "github.com/spf13/cobra"
    9 10   
    10 11   "github.com/iximiuz/cdebug/cmd/exec"
    11 12   "github.com/iximiuz/cdebug/cmd/portforward"
    12  - "github.com/iximiuz/cdebug/pkg/cmd"
     13 + "github.com/iximiuz/cdebug/pkg/cliutil"
    13 14  )
    14 15   
    15 16  var (
    skipped 4 lines
    20 21   
    21 22  func main() {
    22 23   stdin, stdout, stderr := term.StdStreams()
    23  - cli := cmd.NewCLI(stdin, stdout, stderr)
     24 + cli := cliutil.NewCLI(stdin, stdout, stderr)
     25 + 
     26 + var logLevel string
     27 + logrus.SetOutput(cli.ErrorStream())
    24 28   
    25 29   cmd := &cobra.Command{
    26 30   Use: "cdebug [OPTIONS] COMMAND [ARG...]",
    27  - Short: "The base command for the cdebug CLI.",
     31 + Short: "cdebug - a swiss army knife of container debugging",
    28 32   Version: fmt.Sprintf("%s (built: %s commit: %s)", version, date, commit),
     33 + PersistentPreRun: func(cmd *cobra.Command, args []string) {
     34 + setLogLevel(cli, logLevel)
     35 + cmd.SilenceUsage = true
     36 + cmd.SilenceErrors = true
     37 + },
    29 38   }
     39 + cmd.SetOut(cli.OutputStream())
     40 + cmd.SetErr(cli.ErrorStream())
    30 41   
    31 42   cmd.AddCommand(
    32 43   exec.NewCommand(cli),
    skipped 1 lines
    34 45   // TODO: other commands
    35 46   )
    36 47   
     48 + flags := cmd.PersistentFlags()
     49 + flags.SetInterspersed(false) // Instead of relying on --
     50 + 
     51 + flags.StringVarP(
     52 + &logLevel,
     53 + "log-level",
     54 + "l",
     55 + "info",
     56 + `log level for cdebug ("debug" | "info" | "warn" | "error" | "fatal")`,
     57 + )
     58 + 
    37 59   if err := cmd.Execute(); err != nil {
    38  - fmt.Fprintln(stderr, err.Error())
     60 + if sterr, ok := err.(cliutil.StatusError); ok {
     61 + cli.Grumble("cdebug: %s\n", sterr)
     62 + os.Exit(sterr.Code())
     63 + }
     64 + 
     65 + // Hopefully, only usage errors.
     66 + logrus.WithError(err).Debug("Exit error")
    39 67   os.Exit(1)
    40 68   }
    41 69  }
    42 70   
     71 +func setLogLevel(cli cliutil.CLI, logLevel string) {
     72 + lvl, err := logrus.ParseLevel(logLevel)
     73 + if err != nil {
     74 + cli.Grumble("Unable to parse log level: %s\n", logLevel)
     75 + os.Exit(1)
     76 + }
     77 + logrus.SetLevel(lvl)
     78 +}
     79 + 
  • ■ ■ ■ ■ ■ ■
    pkg/cliutil/cliutil.go
     1 +package cliutil
     2 + 
     3 +import (
     4 + "fmt"
     5 + "io"
     6 + "strings"
     7 + 
     8 + "github.com/docker/cli/cli/streams"
     9 +)
     10 + 
     11 +type Streams interface {
     12 + InputStream() *streams.In
     13 + OutputStream() *streams.Out
     14 + ErrorStream() io.Writer
     15 +}
     16 + 
     17 +type CLI interface {
     18 + Streams
     19 + 
     20 + SetQuiet(bool)
     21 + 
     22 + // Regular print to stdout.
     23 + Say(string, ...any)
     24 + 
     25 + // Regular print to stderr.
     26 + Grumble(string, ...any)
     27 + 
     28 + // Optional print to stderr (unless quiet).
     29 + Wisper(string, ...any)
     30 +}
     31 + 
     32 +type cli struct {
     33 + inputStream *streams.In
     34 + outputStream *streams.Out
     35 + errorStream io.Writer
     36 + 
     37 + quiet bool
     38 +}
     39 + 
     40 +var _ CLI = &cli{}
     41 + 
     42 +func NewCLI(cin io.ReadCloser, cout io.Writer, cerr io.Writer) CLI {
     43 + return &cli{
     44 + inputStream: streams.NewIn(cin),
     45 + outputStream: streams.NewOut(cout),
     46 + errorStream: cerr,
     47 + }
     48 +}
     49 + 
     50 +func (c *cli) InputStream() *streams.In {
     51 + return c.inputStream
     52 +}
     53 + 
     54 +func (c *cli) OutputStream() *streams.Out {
     55 + return c.outputStream
     56 +}
     57 + 
     58 +func (c *cli) ErrorStream() io.Writer {
     59 + return c.errorStream
     60 +}
     61 + 
     62 +func (c *cli) SetQuiet(v bool) {
     63 + c.quiet = v
     64 +}
     65 + 
     66 +func (c *cli) Say(format string, a ...any) {
     67 + fmt.Fprintf(c.OutputStream(), format, a...)
     68 +}
     69 + 
     70 +func (c *cli) Grumble(format string, a ...any) {
     71 + fmt.Fprintf(c.ErrorStream(), format, a...)
     72 +}
     73 + 
     74 +func (c *cli) Wisper(format string, a ...any) {
     75 + if !c.quiet {
     76 + fmt.Fprintf(c.ErrorStream(), format, a...)
     77 + }
     78 +}
     79 + 
     80 +type StatusError struct {
     81 + status string
     82 + code int
     83 +}
     84 + 
     85 +var _ error = StatusError{}
     86 + 
     87 +func NewStatusError(code int, format string, a ...any) StatusError {
     88 + status := strings.TrimSuffix(fmt.Sprintf(format, a...), ".") + "."
     89 + return StatusError{
     90 + code: code,
     91 + status: strings.ToUpper(status[:1]) + status[1:],
     92 + }
     93 +}
     94 + 
     95 +func WrapStatusError(err error) error {
     96 + if err == nil {
     97 + return nil
     98 + }
     99 + return NewStatusError(1, err.Error())
     100 +}
     101 + 
     102 +func (e StatusError) Error() string {
     103 + return e.status
     104 +}
     105 + 
     106 +func (e StatusError) Code() int {
     107 + return e.code
     108 +}
     109 + 
  • ■ ■ ■ ■ ■ ■
    pkg/cmd/cli.go
    1  -package cmd
    2  - 
    3  -import (
    4  - "io"
    5  - 
    6  - "github.com/docker/cli/cli/streams"
    7  -)
    8  - 
    9  -type Streams interface {
    10  - InputStream() *streams.In
    11  - OutputStream() *streams.Out
    12  - ErrorStream() io.Writer
    13  -}
    14  - 
    15  -type CLI interface {
    16  - Streams
    17  -}
    18  - 
    19  -type cli struct {
    20  - inputStream *streams.In
    21  - outputStream *streams.Out
    22  - errorStream io.Writer
    23  -}
    24  - 
    25  -var _ CLI = &cli{}
    26  - 
    27  -func NewCLI(cin io.ReadCloser, cout io.Writer, cerr io.Writer) CLI {
    28  - return &cli{
    29  - inputStream: streams.NewIn(cin),
    30  - outputStream: streams.NewOut(cout),
    31  - errorStream: cerr,
    32  - }
    33  -}
    34  - 
    35  -func (c *cli) InputStream() *streams.In {
    36  - return c.inputStream
    37  -}
    38  - 
    39  -func (c *cli) OutputStream() *streams.Out {
    40  - return c.outputStream
    41  -}
    42  - 
    43  -func (c *cli) ErrorStream() io.Writer {
    44  - return c.errorStream
    45  -}
    46  - 
  • ■ ■ ■ ■ ■ ■
    pkg/jsonutil/jsonutil.go
     1 +package jsonutil
     2 + 
     3 +import (
     4 + "encoding/json"
     5 +)
     6 + 
     7 +func Dump(v any) string {
     8 + b, err := json.Marshal(v)
     9 + if err != nil {
     10 + panic(err)
     11 + }
     12 + return string(b)
     13 +}
     14 + 
     15 +func DumpIndent(v any) string {
     16 + b, _ := json.MarshalIndent(v, "", " ")
     17 + return string(b)
     18 +}
     19 + 
  • ■ ■ ■ ■ ■ ■
    pkg/util/util.go
    1 1  package util
    2 2   
    3 3  import (
    4  - "encoding/json"
    5  - "fmt"
    6 4   "strings"
    7 5   
    8 6   "github.com/google/uuid"
    skipped 3 lines
    12 10   return strings.Split(uuid.NewString(), "-")[0]
    13 11  }
    14 12   
    15  -func PrettyPrint(v any) {
    16  - b, _ := json.MarshalIndent(v, "", " ")
    17  - fmt.Println(string(b))
    18  -}
    19  - 
Please wait...
Page is in error, reload to recover