diff --git a/commands/history/trace.go b/commands/history/trace.go index ae55984d..6393b74f 100644 --- a/commands/history/trace.go +++ b/commands/history/trace.go @@ -1,29 +1,37 @@ package history import ( + "bytes" "context" "encoding/json" + "fmt" "io" - "log" + "net" + "os" "slices" "time" + "github.com/containerd/console" "github.com/containerd/containerd/v2/core/content/proxy" "github.com/docker/buildx/builder" "github.com/docker/buildx/util/cobrautil/completion" "github.com/docker/buildx/util/otelutil" + "github.com/docker/buildx/util/otelutil/jaeger" "github.com/docker/cli/cli/command" controlapi "github.com/moby/buildkit/api/services/control" "github.com/opencontainers/go-digest" ocispecs "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/pkg/browser" "github.com/pkg/errors" "github.com/spf13/cobra" + jaegerui "github.com/tonistiigi/jaeger-ui-rest" ) type traceOptions struct { builder string ref string containerName string + addr string } func runTrace(ctx context.Context, dockerCli command.Cli, opts traceOptions) error { @@ -104,8 +112,6 @@ func runTrace(ctx context.Context, dockerCli command.Cli, opts traceOptions) err } } - log.Printf("trace %+v", rec.Trace) - c, err := rec.node.Driver.Client(ctx) if err != nil { return err @@ -127,12 +133,68 @@ func runTrace(ctx context.Context, dockerCli command.Cli, opts traceOptions) err return err } - // TODO: try to upload build to Jaeger UI - jd := spans.JaegerData().Data + wrapper := struct { + Data []jaeger.Trace `json:"data"` + }{ + Data: spans.JaegerData().Data, + } - enc := json.NewEncoder(dockerCli.Out()) + var term bool + if _, err := console.ConsoleFromFile(os.Stdout); err == nil { + term = true + } + + if len(wrapper.Data) == 0 { + return errors.New("no trace data") + } + + if !term { + enc := json.NewEncoder(dockerCli.Out()) + enc.SetIndent("", " ") + return enc.Encode(wrapper) + } + + srv := jaegerui.NewServer(jaegerui.Config{}) + + buf := &bytes.Buffer{} + enc := json.NewEncoder(buf) enc.SetIndent("", " ") - return enc.Encode(jd) + if err := enc.Encode(wrapper); err != nil { + return err + } + + if err := srv.AddTrace(string(wrapper.Data[0].TraceID), bytes.NewReader(buf.Bytes())); err != nil { + return err + } + + ln, err := net.Listen("tcp", opts.addr) + if err != nil { + return err + } + + url := "http://" + ln.Addr().String() + "/trace/" + string(wrapper.Data[0].TraceID) + + go func() { + time.Sleep(100 * time.Millisecond) + browser.OpenURL(url) + }() + + fmt.Fprintf(dockerCli.Err(), "Trace available at %s\n", url) + + go func() { + <-ctx.Done() + ln.Close() + }() + + err = srv.Serve(ln) + if err != nil { + select { + case <-ctx.Done(): + return nil + default: + } + } + return err } func traceCmd(dockerCli command.Cli, rootOpts RootOptions) *cobra.Command { @@ -154,6 +216,7 @@ func traceCmd(dockerCli command.Cli, rootOpts RootOptions) *cobra.Command { flags := cmd.Flags() flags.StringVar(&options.containerName, "container", "", "Container name") + flags.StringVar(&options.addr, "addr", "127.0.0.1:0", "Address to bind the UI server") return cmd } diff --git a/go.mod b/go.mod index d265b089..6cc18a19 100644 --- a/go.mod +++ b/go.mod @@ -46,6 +46,7 @@ require ( github.com/stretchr/testify v1.10.0 github.com/tonistiigi/fsutil v0.0.0-20250113203817-b14e27f4135a github.com/tonistiigi/go-csvvalue v0.0.0-20240710180619-ddb21b71c0b4 + github.com/tonistiigi/jaeger-ui-rest v0.0.0-20250211190051-7d4944a45bb6 github.com/zclconf/go-cty v1.16.0 go.opentelemetry.io/otel v1.31.0 go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.31.0 diff --git a/go.sum b/go.sum index 70211ada..ced6a276 100644 --- a/go.sum +++ b/go.sum @@ -447,6 +447,8 @@ github.com/tonistiigi/fsutil v0.0.0-20250113203817-b14e27f4135a h1:EfGw4G0x/8qXW github.com/tonistiigi/fsutil v0.0.0-20250113203817-b14e27f4135a/go.mod h1:Dl/9oEjK7IqnjAm21Okx/XIxUCFJzvh+XdVHUlBwXTw= github.com/tonistiigi/go-csvvalue v0.0.0-20240710180619-ddb21b71c0b4 h1:7I5c2Ig/5FgqkYOh/N87NzoyI9U15qUPXhDD8uCupv8= github.com/tonistiigi/go-csvvalue v0.0.0-20240710180619-ddb21b71c0b4/go.mod h1:278M4p8WsNh3n4a1eqiFcV2FGk7wE5fwUpUom9mK9lE= +github.com/tonistiigi/jaeger-ui-rest v0.0.0-20250211190051-7d4944a45bb6 h1:RT/a0RvdX84iwtOrUK45+wjcNpaG+hS7n7XFYqj4axg= +github.com/tonistiigi/jaeger-ui-rest v0.0.0-20250211190051-7d4944a45bb6/go.mod h1:3Ez1Paeg+0Ghu3KwpEGC1HgZ4CHDlg+Ez/5Baeomk54= github.com/tonistiigi/units v0.0.0-20180711220420-6950e57a87ea h1:SXhTLE6pb6eld/v/cCndK0AMpt1wiVFb/YYmqB3/QG0= github.com/tonistiigi/units v0.0.0-20180711220420-6950e57a87ea/go.mod h1:WPnis/6cRcDZSUvVmezrxJPkiO87ThFYsoUiMwWNDJk= github.com/tonistiigi/vt100 v0.0.0-20240514184818-90bafcd6abab h1:H6aJ0yKQ0gF49Qb2z5hI1UHxSQt4JMyxebFR15KnApw= diff --git a/vendor/github.com/tonistiigi/jaeger-ui-rest/Dockerfile b/vendor/github.com/tonistiigi/jaeger-ui-rest/Dockerfile new file mode 100644 index 00000000..19eb4f50 --- /dev/null +++ b/vendor/github.com/tonistiigi/jaeger-ui-rest/Dockerfile @@ -0,0 +1,34 @@ +ARG NODE_VERSION=23.6 +ARG ALPINE_VERSION=3.21 +ARG GOLANG_VERSION=1.23 +ARG JAEGERUI_VERSION=v1.66.0 + +FROM scratch AS jaegerui-src +ARG JAEGERUI_REPO=https://github.com/jaegertracing/jaeger-ui.git +ARG JAEGERUI_VERSION +ADD ${JAEGERUI_REPO}#${JAEGERUI_VERSION} / + +FROM --platform=$BUILDPLATFORM node:${NODE_VERSION}-alpine${ALPINE_VERSION} AS builder +WORKDIR /work/jaeger-ui +COPY --from=jaegerui-src / . +RUN npm install +WORKDIR /work/jaeger-ui/packages/jaeger-ui +RUN NODE_ENVIRONMENT=production npm run build +# failed to find a way to avoid legacy build +RUN rm build/static/*-legacy* && rm build/static/*.png + +FROM scratch AS jaegerui +COPY --from=builder /work/jaeger-ui/packages/jaeger-ui/build / + +FROM alpine AS compressor +RUN --mount=target=/in,from=jaegerui < "$1.tmp" && mv "$1.tmp" "$1"' _ {} \; + # stop +EOT + +FROM scratch AS jaegerui-compressed +COPY --from=compressor /out / diff --git a/vendor/github.com/tonistiigi/jaeger-ui-rest/config.go b/vendor/github.com/tonistiigi/jaeger-ui-rest/config.go new file mode 100644 index 00000000..f2b47cee --- /dev/null +++ b/vendor/github.com/tonistiigi/jaeger-ui-rest/config.go @@ -0,0 +1,42 @@ +package jaegerui + +import ( + "bytes" + "encoding/json" + "slices" +) + +type Menu struct { + Label string `json:"label"` + Items []MenuItem `json:"items"` +} + +type MenuItem struct { + Label string `json:"label"` + URL string `json:"url"` +} + +type Config struct { + Dependencies struct { + MenuEnabled bool `json:"menuEnabled"` + } `json:"dependencies"` + Monitor struct { + MenuEnabled bool `json:"menuEnabled"` + } `json:"monitor"` + ArchiveEnabled bool `json:"archiveEnabled"` + Menu []Menu `json:"menu"` +} + +func (cfg Config) Inject(name string, dt []byte) ([]byte, bool) { + if name != "index.html" { + return dt, false + } + + cfgData, err := json.Marshal(cfg) + if err != nil { + return dt, false + } + + dt = bytes.Replace(dt, []byte("const JAEGER_CONFIG = DEFAULT_CONFIG;"), slices.Concat([]byte(`const JAEGER_CONFIG = `), cfgData, []byte(`;`)), 1) + return dt, true +} diff --git a/vendor/github.com/tonistiigi/jaeger-ui-rest/decompress/decompress.go b/vendor/github.com/tonistiigi/jaeger-ui-rest/decompress/decompress.go new file mode 100644 index 00000000..abed5f19 --- /dev/null +++ b/vendor/github.com/tonistiigi/jaeger-ui-rest/decompress/decompress.go @@ -0,0 +1,117 @@ +package decompress + +import ( + "bytes" + "compress/gzip" + "io" + "io/fs" + "path/filepath" + "sync" +) + +type decompressFS struct { + fs.FS + mu sync.Mutex + data map[string][]byte + inject Injector +} + +type Injector interface { + Inject(name string, dt []byte) ([]byte, bool) +} + +func NewFS(fsys fs.FS, injector Injector) fs.FS { + return &decompressFS{ + FS: fsys, + data: make(map[string][]byte), + inject: injector, + } +} + +func (d *decompressFS) Open(name string) (fs.File, error) { + name = filepath.Clean(name) + + f, err := d.FS.Open(name) + if err != nil { + return nil, err + } + + d.mu.Lock() + defer d.mu.Unlock() + + dt, ok := d.data[name] + if ok { + return &staticFile{ + Reader: bytes.NewReader(dt), + f: f, + }, nil + } + + fi, err := f.Stat() + if err != nil { + f.Close() + return nil, err + } + + if fi.IsDir() { + return f, nil + } + + gzReader, err := gzip.NewReader(f) + if err != nil { + f.Close() + return nil, err + } + + buf := &bytes.Buffer{} + if _, err := io.Copy(buf, gzReader); err != nil { + f.Close() + return nil, err + } + + dt = buf.Bytes() + if d.inject != nil { + newdt, ok := d.inject.Inject(name, dt) + if ok { + dt = newdt + } + } + + d.data[name] = dt + + return &staticFile{ + Reader: bytes.NewReader(dt), + f: f, + }, nil +} + +type staticFile struct { + *bytes.Reader + f fs.File +} + +func (s *staticFile) Stat() (fs.FileInfo, error) { + fi, err := s.f.Stat() + if err != nil { + return nil, err + } + return &fileInfo{ + FileInfo: fi, + size: int64(s.Len()), + }, nil +} + +func (s *staticFile) Close() error { + return s.f.Close() +} + +type fileInfo struct { + fs.FileInfo + size int64 +} + +func (f *fileInfo) Size() int64 { + return f.size +} + +var _ fs.File = &staticFile{} diff --git a/vendor/github.com/tonistiigi/jaeger-ui-rest/docker-bake.hcl b/vendor/github.com/tonistiigi/jaeger-ui-rest/docker-bake.hcl new file mode 100644 index 00000000..6330f94e --- /dev/null +++ b/vendor/github.com/tonistiigi/jaeger-ui-rest/docker-bake.hcl @@ -0,0 +1,3 @@ +target "public" { + output = ["./public"] +} \ No newline at end of file diff --git a/vendor/github.com/tonistiigi/jaeger-ui-rest/fs.go b/vendor/github.com/tonistiigi/jaeger-ui-rest/fs.go new file mode 100644 index 00000000..a02051f5 --- /dev/null +++ b/vendor/github.com/tonistiigi/jaeger-ui-rest/fs.go @@ -0,0 +1,17 @@ +package jaegerui + +import ( + "embed" + "io/fs" + "net/http" + + "github.com/tonistiigi/jaeger-ui-rest/decompress" +) + +//go:embed public +var staticFiles embed.FS + +func FS(cfg Config) http.FileSystem { + files, _ := fs.Sub(staticFiles, "public") + return http.FS(decompress.NewFS(files, cfg)) +} diff --git a/vendor/github.com/tonistiigi/jaeger-ui-rest/public/index.html b/vendor/github.com/tonistiigi/jaeger-ui-rest/public/index.html new file mode 100644 index 00000000..a061038a Binary files /dev/null and b/vendor/github.com/tonistiigi/jaeger-ui-rest/public/index.html differ diff --git a/vendor/github.com/tonistiigi/jaeger-ui-rest/public/static/favicon-BxcVf0am.ico b/vendor/github.com/tonistiigi/jaeger-ui-rest/public/static/favicon-BxcVf0am.ico new file mode 100644 index 00000000..dfbc7532 Binary files /dev/null and b/vendor/github.com/tonistiigi/jaeger-ui-rest/public/static/favicon-BxcVf0am.ico differ diff --git a/vendor/github.com/tonistiigi/jaeger-ui-rest/public/static/index-BBZlPGVK.js b/vendor/github.com/tonistiigi/jaeger-ui-rest/public/static/index-BBZlPGVK.js new file mode 100644 index 00000000..f09a2afc Binary files /dev/null and b/vendor/github.com/tonistiigi/jaeger-ui-rest/public/static/index-BBZlPGVK.js differ diff --git a/vendor/github.com/tonistiigi/jaeger-ui-rest/public/static/index-C4KU8NTf.css b/vendor/github.com/tonistiigi/jaeger-ui-rest/public/static/index-C4KU8NTf.css new file mode 100644 index 00000000..6ef996c0 Binary files /dev/null and b/vendor/github.com/tonistiigi/jaeger-ui-rest/public/static/index-C4KU8NTf.css differ diff --git a/vendor/github.com/tonistiigi/jaeger-ui-rest/public/static/jaeger-logo-CNZsoUdk.svg b/vendor/github.com/tonistiigi/jaeger-ui-rest/public/static/jaeger-logo-CNZsoUdk.svg new file mode 100644 index 00000000..52315bc9 Binary files /dev/null and b/vendor/github.com/tonistiigi/jaeger-ui-rest/public/static/jaeger-logo-CNZsoUdk.svg differ diff --git a/vendor/github.com/tonistiigi/jaeger-ui-rest/readme.md b/vendor/github.com/tonistiigi/jaeger-ui-rest/readme.md new file mode 100644 index 00000000..7f0e7f4f --- /dev/null +++ b/vendor/github.com/tonistiigi/jaeger-ui-rest/readme.md @@ -0,0 +1,3 @@ +### jaeger-ui-rest + +[Jaeger UI](https://github.com/jaegertracing/jaeger-ui) server with only the UI component and simple REST API for loading pregenerated JSON traces. \ No newline at end of file diff --git a/vendor/github.com/tonistiigi/jaeger-ui-rest/server.go b/vendor/github.com/tonistiigi/jaeger-ui-rest/server.go new file mode 100644 index 00000000..c5140be6 --- /dev/null +++ b/vendor/github.com/tonistiigi/jaeger-ui-rest/server.go @@ -0,0 +1,172 @@ +package jaegerui + +import ( + "bytes" + "encoding/json" + "io" + "net" + "net/http" + "os" + "strings" + "sync" + + "github.com/pkg/errors" +) + +func NewServer(cfg Config) *Server { + mux := &http.ServeMux{} + s := &Server{ + config: cfg, + server: &http.Server{ + Handler: mux, + }, + } + + fsHandler := http.FileServer(FS(cfg)) + + mux.HandleFunc("GET /api/services", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"data": [], "total": 0}`)) + }) + mux.HandleFunc("GET /trace/", redirectRoot(fsHandler)) + mux.HandleFunc("GET /search", redirectRoot(fsHandler)) + + mux.HandleFunc("POST /api/traces/", func(w http.ResponseWriter, r *http.Request) { + traceID := strings.TrimPrefix(r.URL.Path, "/api/traces/") + if traceID == "" || strings.Contains(traceID, "/") { + http.Error(w, "Invalid trace ID", http.StatusBadRequest) + return + } + handleHTTPError(s.AddTrace(traceID, r.Body), w) + }) + + mux.HandleFunc("GET /api/traces/", func(w http.ResponseWriter, r *http.Request) { + traceID := strings.TrimPrefix(r.URL.Path, "/api/traces/") + if traceID == "" { + qry := r.URL.Query() + ids := qry["traceID"] + if len(ids) > 0 { + dt, err := s.GetTraces(ids...) + if err != nil { + handleHTTPError(err, w) + return + } + w.Write(dt) + return + } + } + + if traceID == "" || strings.Contains(traceID, "/") { + http.Error(w, "Invalid trace ID", http.StatusBadRequest) + return + } + dt, err := s.GetTraces(traceID) + if err != nil { + handleHTTPError(err, w) + return + } + w.Write(dt) + }) + + mux.Handle("/", fsHandler) + + return s +} + +type Server struct { + config Config + server *http.Server + + mu sync.Mutex + traces map[string][]byte +} + +func (s *Server) AddTrace(traceID string, rdr io.Reader) error { + var payload struct { + Data []struct { + TraceID string `json:"traceID"` + } `json:"data"` + } + buf := &bytes.Buffer{} + if _, err := io.Copy(buf, rdr); err != nil { + return errors.Wrapf(err, "failed to read trace data") + } + dt := buf.Bytes() + + if err := json.Unmarshal(dt, &payload); err != nil { + return errors.Wrapf(err, "failed to unmarshal trace data") + } + + if len(payload.Data) != 1 { + return errors.Errorf("expected 1 trace, got %d", len(payload.Data)) + } + + if payload.Data[0].TraceID != traceID { + return errors.Errorf("trace ID mismatch: %s != %s", payload.Data[0].TraceID, traceID) + } + + s.mu.Lock() + defer s.mu.Unlock() + if s.traces == nil { + s.traces = make(map[string][]byte) + } + s.traces[traceID] = dt + return nil +} + +func (s *Server) GetTraces(traceIDs ...string) ([]byte, error) { + s.mu.Lock() + defer s.mu.Unlock() + + if len(traceIDs) == 0 { + return nil, errors.Errorf("trace ID is required") + } + + if len(traceIDs) == 1 { + dt, ok := s.traces[traceIDs[0]] + if !ok { + return nil, errors.Wrapf(os.ErrNotExist, "trace %s not found", traceIDs[0]) + } + return dt, nil + } + + type payloadT struct { + Data []interface{} `json:"data"` + } + var payload payloadT + + for _, traceID := range traceIDs { + dt, ok := s.traces[traceID] + if !ok { + return nil, errors.Wrapf(os.ErrNotExist, "trace %s not found", traceID) + } + var p payloadT + if err := json.Unmarshal(dt, &p); err != nil { + return nil, errors.Wrapf(err, "failed to unmarshal trace data") + } + payload.Data = append(payload.Data, p.Data...) + } + + return json.MarshalIndent(payload, "", " ") +} + +func (s *Server) Serve(l net.Listener) error { + return s.server.Serve(l) +} + +func redirectRoot(h http.Handler) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + r.URL.Path = "/" + h.ServeHTTP(w, r) + } +} + +func handleHTTPError(err error, w http.ResponseWriter) { + switch { + case err == nil: + return + case errors.Is(err, os.ErrNotExist): + http.Error(w, err.Error(), http.StatusNotFound) + default: + http.Error(w, err.Error(), http.StatusBadRequest) + } +} diff --git a/vendor/modules.txt b/vendor/modules.txt index 280520fc..10f31860 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -737,6 +737,10 @@ github.com/tonistiigi/fsutil/types # github.com/tonistiigi/go-csvvalue v0.0.0-20240710180619-ddb21b71c0b4 ## explicit; go 1.16 github.com/tonistiigi/go-csvvalue +# github.com/tonistiigi/jaeger-ui-rest v0.0.0-20250211190051-7d4944a45bb6 +## explicit; go 1.22.0 +github.com/tonistiigi/jaeger-ui-rest +github.com/tonistiigi/jaeger-ui-rest/decompress # github.com/tonistiigi/units v0.0.0-20180711220420-6950e57a87ea ## explicit github.com/tonistiigi/units