github.com/panekj/cli@v0.0.0-20230304125325-467dd2f3797e/cli/command/service/logs.go (about) 1 package service 2 3 import ( 4 "bytes" 5 "context" 6 "fmt" 7 "io" 8 "sort" 9 "strconv" 10 "strings" 11 12 "github.com/docker/cli/cli" 13 "github.com/docker/cli/cli/command" 14 "github.com/docker/cli/cli/command/idresolver" 15 "github.com/docker/cli/service/logs" 16 "github.com/docker/docker/api/types" 17 "github.com/docker/docker/api/types/swarm" 18 "github.com/docker/docker/client" 19 "github.com/docker/docker/pkg/stdcopy" 20 "github.com/docker/docker/pkg/stringid" 21 "github.com/pkg/errors" 22 "github.com/spf13/cobra" 23 ) 24 25 type logsOptions struct { 26 noResolve bool 27 noTrunc bool 28 noTaskIDs bool 29 follow bool 30 since string 31 timestamps bool 32 tail string 33 details bool 34 raw bool 35 36 target string 37 } 38 39 func newLogsCommand(dockerCli command.Cli) *cobra.Command { 40 var opts logsOptions 41 42 cmd := &cobra.Command{ 43 Use: "logs [OPTIONS] SERVICE|TASK", 44 Short: "Fetch the logs of a service or task", 45 Args: cli.ExactArgs(1), 46 RunE: func(cmd *cobra.Command, args []string) error { 47 opts.target = args[0] 48 return runLogs(dockerCli, &opts) 49 }, 50 Annotations: map[string]string{"version": "1.29"}, 51 ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { 52 return CompletionFn(dockerCli)(cmd, args, toComplete) 53 }, 54 } 55 56 flags := cmd.Flags() 57 // options specific to service logs 58 flags.BoolVar(&opts.noResolve, "no-resolve", false, "Do not map IDs to Names in output") 59 flags.BoolVar(&opts.noTrunc, "no-trunc", false, "Do not truncate output") 60 flags.BoolVar(&opts.raw, "raw", false, "Do not neatly format logs") 61 flags.SetAnnotation("raw", "version", []string{"1.30"}) 62 flags.BoolVar(&opts.noTaskIDs, "no-task-ids", false, "Do not include task IDs in output") 63 // options identical to container logs 64 flags.BoolVarP(&opts.follow, "follow", "f", false, "Follow log output") 65 flags.StringVar(&opts.since, "since", "", `Show logs since timestamp (e.g. "2013-01-02T13:23:37Z") or relative (e.g. "42m" for 42 minutes)`) 66 flags.BoolVarP(&opts.timestamps, "timestamps", "t", false, "Show timestamps") 67 flags.BoolVar(&opts.details, "details", false, "Show extra details provided to logs") 68 flags.SetAnnotation("details", "version", []string{"1.30"}) 69 flags.StringVarP(&opts.tail, "tail", "n", "all", "Number of lines to show from the end of the logs") 70 return cmd 71 } 72 73 func runLogs(dockerCli command.Cli, opts *logsOptions) error { 74 ctx := context.Background() 75 76 options := types.ContainerLogsOptions{ 77 ShowStdout: true, 78 ShowStderr: true, 79 Since: opts.since, 80 Timestamps: opts.timestamps, 81 Follow: opts.follow, 82 Tail: opts.tail, 83 // get the details if we request it OR if we're not doing raw mode 84 // (we need them for the context to pretty print) 85 Details: opts.details || !opts.raw, 86 } 87 88 cli := dockerCli.Client() 89 90 var ( 91 maxLength = 1 92 responseBody io.ReadCloser 93 tty bool 94 // logfunc is used to delay the call to logs so that we can do some 95 // processing before we actually get the logs 96 logfunc func(context.Context, string, types.ContainerLogsOptions) (io.ReadCloser, error) 97 ) 98 99 service, _, err := cli.ServiceInspectWithRaw(ctx, opts.target, types.ServiceInspectOptions{}) 100 if err != nil { 101 // if it's any error other than service not found, it's Real 102 if !client.IsErrNotFound(err) { 103 return err 104 } 105 task, _, err := cli.TaskInspectWithRaw(ctx, opts.target) 106 if err != nil { 107 if client.IsErrNotFound(err) { 108 // if the task isn't found, rewrite the error to be clear 109 // that we looked for services AND tasks and found none 110 err = fmt.Errorf("no such task or service: %v", opts.target) 111 } 112 return err 113 } 114 115 tty = task.Spec.ContainerSpec.TTY 116 maxLength = getMaxLength(task.Slot) 117 118 // use the TaskLogs api function 119 logfunc = cli.TaskLogs 120 } else { 121 // use ServiceLogs api function 122 logfunc = cli.ServiceLogs 123 tty = service.Spec.TaskTemplate.ContainerSpec.TTY 124 if service.Spec.Mode.Replicated != nil && service.Spec.Mode.Replicated.Replicas != nil { 125 // if replicas are initialized, figure out if we need to pad them 126 replicas := *service.Spec.Mode.Replicated.Replicas 127 maxLength = getMaxLength(int(replicas)) 128 } 129 } 130 131 // we can't prettify tty logs. tell the user that this is the case. 132 // this is why we assign the logs function to a variable and delay calling 133 // it. we want to check this before we make the call and checking twice in 134 // each branch is even sloppier than this CLI disaster already is 135 if tty && !opts.raw { 136 return errors.New("tty service logs only supported with --raw") 137 } 138 139 // now get the logs 140 responseBody, err = logfunc(ctx, opts.target, options) 141 if err != nil { 142 return err 143 } 144 defer responseBody.Close() 145 146 // tty logs get straight copied. they're not muxed with stdcopy 147 if tty { 148 _, err = io.Copy(dockerCli.Out(), responseBody) 149 return err 150 } 151 152 // otherwise, logs are multiplexed. if we're doing pretty printing, also 153 // create a task formatter. 154 var stdout, stderr io.Writer 155 stdout = dockerCli.Out() 156 stderr = dockerCli.Err() 157 if !opts.raw { 158 taskFormatter := newTaskFormatter(cli, opts, maxLength) 159 160 stdout = &logWriter{ctx: ctx, opts: opts, f: taskFormatter, w: stdout} 161 stderr = &logWriter{ctx: ctx, opts: opts, f: taskFormatter, w: stderr} 162 } 163 164 _, err = stdcopy.StdCopy(stdout, stderr, responseBody) 165 return err 166 } 167 168 // getMaxLength gets the maximum length of the number in base 10 169 func getMaxLength(i int) int { 170 return len(strconv.Itoa(i)) 171 } 172 173 type taskFormatter struct { 174 client client.APIClient 175 opts *logsOptions 176 padding int 177 178 r *idresolver.IDResolver 179 // cache saves a pre-cooked logContext formatted string based on a 180 // logcontext object, so we don't have to resolve names every time 181 cache map[logContext]string 182 } 183 184 func newTaskFormatter(client client.APIClient, opts *logsOptions, padding int) *taskFormatter { 185 return &taskFormatter{ 186 client: client, 187 opts: opts, 188 padding: padding, 189 r: idresolver.New(client, opts.noResolve), 190 cache: make(map[logContext]string), 191 } 192 } 193 194 func (f *taskFormatter) format(ctx context.Context, logCtx logContext) (string, error) { 195 if cached, ok := f.cache[logCtx]; ok { 196 return cached, nil 197 } 198 199 nodeName, err := f.r.Resolve(ctx, swarm.Node{}, logCtx.nodeID) 200 if err != nil { 201 return "", err 202 } 203 204 serviceName, err := f.r.Resolve(ctx, swarm.Service{}, logCtx.serviceID) 205 if err != nil { 206 return "", err 207 } 208 209 task, _, err := f.client.TaskInspectWithRaw(ctx, logCtx.taskID) 210 if err != nil { 211 return "", err 212 } 213 214 taskName := fmt.Sprintf("%s.%d", serviceName, task.Slot) 215 if !f.opts.noTaskIDs { 216 if f.opts.noTrunc { 217 taskName += fmt.Sprintf(".%s", task.ID) 218 } else { 219 taskName += fmt.Sprintf(".%s", stringid.TruncateID(task.ID)) 220 } 221 } 222 223 paddingCount := f.padding - getMaxLength(task.Slot) 224 padding := "" 225 if paddingCount > 0 { 226 padding = strings.Repeat(" ", paddingCount) 227 } 228 formatted := taskName + "@" + nodeName + padding 229 f.cache[logCtx] = formatted 230 return formatted, nil 231 } 232 233 type logWriter struct { 234 ctx context.Context 235 opts *logsOptions 236 f *taskFormatter 237 w io.Writer 238 } 239 240 func (lw *logWriter) Write(buf []byte) (int, error) { 241 // this works but ONLY because stdcopy calls write a whole line at a time. 242 // if this ends up horribly broken or panics, check to see if stdcopy has 243 // reneged on that assumption. (@god forgive me) 244 // also this only works because the logs format is, like, barely parsable. 245 // if something changes in the logs format, this is gonna break 246 247 // there should always be at least 2 parts: details and message. if there 248 // is no timestamp, details will be first (index 0) when we split on 249 // spaces. if there is a timestamp, details will be 2nd (`index 1) 250 detailsIndex := 0 251 numParts := 2 252 if lw.opts.timestamps { 253 detailsIndex++ 254 numParts++ 255 } 256 257 // break up the log line into parts. 258 parts := bytes.SplitN(buf, []byte(" "), numParts) 259 if len(parts) != numParts { 260 return 0, errors.Errorf("invalid context in log message: %v", string(buf)) 261 } 262 // parse the details out 263 details, err := logs.ParseLogDetails(string(parts[detailsIndex])) 264 if err != nil { 265 return 0, err 266 } 267 // and then create a context from the details 268 // this removes the context-specific details from the details map, so we 269 // can more easily print the details later 270 logCtx, err := lw.parseContext(details) 271 if err != nil { 272 return 0, err 273 } 274 275 output := []byte{} 276 // if we included timestamps, add them to the front 277 if lw.opts.timestamps { 278 output = append(output, parts[0]...) 279 output = append(output, ' ') 280 } 281 // add the context, nice and formatted 282 formatted, err := lw.f.format(lw.ctx, logCtx) 283 if err != nil { 284 return 0, err 285 } 286 output = append(output, []byte(formatted+" | ")...) 287 // if the user asked for details, add them to be log message 288 if lw.opts.details { 289 // ugh i hate this it's basically a dupe of api/server/httputils/write_log_stream.go:stringAttrs() 290 // ok but we're gonna do it a bit different 291 292 // there are optimizations that can be made here. for starters, i'd 293 // suggest caching the details keys. then, we can maybe draw maps and 294 // slices from a pool to avoid alloc overhead on them. idk if it's 295 // worth the time yet. 296 297 // first we need a slice 298 d := make([]string, 0, len(details)) 299 // then let's add all the pairs 300 for k := range details { 301 d = append(d, k+"="+details[k]) 302 } 303 // then sort em 304 sort.Strings(d) 305 // then join and append 306 output = append(output, []byte(strings.Join(d, ","))...) 307 output = append(output, ' ') 308 } 309 310 // add the log message itself, finally 311 output = append(output, parts[detailsIndex+1]...) 312 313 _, err = lw.w.Write(output) 314 if err != nil { 315 return 0, err 316 } 317 318 return len(buf), nil 319 } 320 321 // parseContext returns a log context and REMOVES the context from the details map 322 func (lw *logWriter) parseContext(details map[string]string) (logContext, error) { 323 nodeID, ok := details["com.docker.swarm.node.id"] 324 if !ok { 325 return logContext{}, errors.Errorf("missing node id in details: %v", details) 326 } 327 delete(details, "com.docker.swarm.node.id") 328 329 serviceID, ok := details["com.docker.swarm.service.id"] 330 if !ok { 331 return logContext{}, errors.Errorf("missing service id in details: %v", details) 332 } 333 delete(details, "com.docker.swarm.service.id") 334 335 taskID, ok := details["com.docker.swarm.task.id"] 336 if !ok { 337 return logContext{}, errors.Errorf("missing task id in details: %s", details) 338 } 339 delete(details, "com.docker.swarm.task.id") 340 341 return logContext{ 342 nodeID: nodeID, 343 serviceID: serviceID, 344 taskID: taskID, 345 }, nil 346 } 347 348 type logContext struct { 349 nodeID string 350 serviceID string 351 taskID string 352 }