diff --git a/README.md b/README.md index 10394587..145006cc 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,49 @@ docker run overlaybd-convertor -r registry.hub.docker.com/library/redis -i 6.2.1 * See how to use TurboOCIv1 at [TurboOCIv1](docs/TURBO_OCI.md). -* Welcome to contribute! [CONTRIBUTING](docs/CONTRIBUTING.md) +* See how to use OpenTelemetry tracing at [TRACING](docs/TRACING.md). + +## Testing + +The project uses Go's standard testing framework. To run the tests: + +```bash +# Run all tests +go test ./... + +# Run tests for a specific package +go test ./pkg/tracing/... + +# Run tests with verbose output +go test -v ./... + +# Run tests and show code coverage +go test -cover ./... + +# Run tests and generate coverage report +go test -coverprofile=coverage.out ./... +go tool cover -html=coverage.out # View coverage in browser +``` + +### Test Requirements + +Some tests require specific setup: + +- **Tracing Tests**: No external setup needed. Tests use in-memory tracing. +- **Integration Tests**: Uses in-memory gRPC server, no external setup needed. +- **Snapshotter Tests**: Requires root privileges for some tests. Run with `sudo` if needed. + +### Writing Tests + +When contributing new code: + +1. Add unit tests for new packages in `*_test.go` files +2. Add integration tests for new features +3. Follow existing test patterns in the codebase +4. Use table-driven tests where appropriate +5. Ensure tests are deterministic and don't depend on external services + +For more details on contributing, see [CONTRIBUTING](docs/CONTRIBUTING.md). ## Release Version Support diff --git a/cmd/convertor/main.go b/cmd/convertor/main.go index 367947e8..5cc4217a 100644 --- a/cmd/convertor/main.go +++ b/cmd/convertor/main.go @@ -25,6 +25,7 @@ import ( "github.com/containerd/accelerated-container-image/cmd/convertor/builder" "github.com/containerd/accelerated-container-image/cmd/convertor/database" + "github.com/containerd/accelerated-container-image/pkg/tracing" "github.com/containerd/containerd/v2/core/remotes" _ "github.com/go-sql-driver/mysql" "github.com/sirupsen/logrus" @@ -359,11 +360,28 @@ func init() { } func main() { + ctx := context.Background() + + // Initialize OpenTelemetry + shutdown, err := tracing.InitTracer(ctx) + if err != nil { + logrus.Errorf("Failed to initialize tracer: %v", err) + os.Exit(1) + } + defer func() { + if err := shutdown(ctx); err != nil { + logrus.Errorf("Failed to shutdown tracer: %v", err) + } + }() + sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, os.Interrupt) go func() { <-sigChan + if err := shutdown(context.Background()); err != nil { + logrus.Errorf("Failed to shutdown tracer: %v", err) + } os.Exit(0) }() diff --git a/cmd/overlaybd-snapshotter/main.go b/cmd/overlaybd-snapshotter/main.go index 2150a2e2..2533eab7 100644 --- a/cmd/overlaybd-snapshotter/main.go +++ b/cmd/overlaybd-snapshotter/main.go @@ -31,6 +31,7 @@ import ( mylog "github.com/containerd/accelerated-container-image/internal/log" "github.com/containerd/accelerated-container-image/pkg/metrics" overlaybd "github.com/containerd/accelerated-container-image/pkg/snapshot" + "github.com/containerd/accelerated-container-image/pkg/tracing" snapshotsapi "github.com/containerd/containerd/api/services/snapshots/v1" "github.com/containerd/containerd/v2/contrib/snapshotservice" @@ -72,6 +73,20 @@ func parseConfig(fpath string) error { // TODO: use github.com/urfave/cli/v2 func main() { + ctx := context.Background() + + // Initialize OpenTelemetry + shutdown, err := tracing.InitTracer(ctx) + if err != nil { + logrus.Errorf("Failed to initialize tracer: %v", err) + os.Exit(1) + } + defer func() { + if err := shutdown(ctx); err != nil { + logrus.Errorf("Failed to shutdown tracer: %v", err) + } + }() + pconfig = overlaybd.DefaultBootConfig() fnConfig := defaultConfigPath if len(os.Args) == 2 { @@ -122,7 +137,7 @@ func main() { rand.Seed(time.Now().UnixNano()) srv := grpc.NewServer(grpc.UnaryInterceptor(requestIDInterceptor)) - snapshotsapi.RegisterSnapshotsServer(srv, snapshotservice.FromSnapshotter(sn)) + snapshotsapi.RegisterSnapshotsServer(srv, tracing.WithTracing(snapshotservice.FromSnapshotter(sn))) address := strings.TrimSpace(pconfig.Address) diff --git a/docs/TRACING.md b/docs/TRACING.md new file mode 100644 index 00000000..d8897bda --- /dev/null +++ b/docs/TRACING.md @@ -0,0 +1,80 @@ +# OpenTelemetry Tracing Support + +The accelerated-container-image project includes OpenTelemetry (OTEL) tracing support to help monitor and debug image conversion operations. This document describes how to use and configure tracing in the project. + +## Overview + +Tracing is implemented using OpenTelemetry, which provides detailed insights into the image conversion process. Key operations that are traced include: + +- Overall image conversion process +- Layer conversion operations +- Individual layer application and processing +- Remote layer operations (when using remote storage) + +## Configuration + +Tracing can be configured using standard OpenTelemetry environment variables: + +- `OTEL_SERVICE_NAME`: Sets the service name for traces (default: "accelerated-container-image") +- `OTEL_EXPORTER_OTLP_ENDPOINT`: The endpoint where traces should be sent (e.g., "http://localhost:4317") +- `OTEL_EXPORTER_OTLP_PROTOCOL`: The protocol to use (default: "grpc") +- `ENVIRONMENT`: The environment name to be included in traces (e.g., "production", "staging") + +## Key Spans and Attributes + +The following spans are created during image conversion: + +### Convert Operation +- Name: `Convert` +- Attributes: + - `fsType`: The filesystem type being used + - `layerCount`: Number of layers in the source image + +### Layer Conversion +- Name: `convertLayers` +- Attributes: + - `fsType`: The filesystem type being used + - `layerCount`: Number of layers to convert + +### Layer Application +- Name: `applyOCIV1LayerInObd` +- Attributes: + - `layerDigest`: The digest of the layer being applied + - `layerSize`: Size of the layer in bytes + - `parentID`: ID of the parent snapshot + +## Example Usage + +1. Start your OpenTelemetry collector: + ```bash + docker run -d --name otel-collector \ + -p 4317:4317 \ + -p 4318:4318 \ + otel/opentelemetry-collector-contrib + ``` + +2. Set the required environment variables: + ```bash + export OTEL_SERVICE_NAME="accelerated-container-image" + export OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4317" + export ENVIRONMENT="development" + ``` + +3. Run the image conversion as normal. Traces will be automatically collected and sent to your configured endpoint. + +## Viewing Traces + +Traces can be viewed in any OpenTelemetry-compatible tracing backend, such as: +- Jaeger +- Zipkin +- Grafana Tempo +- Cloud provider tracing services (e.g., AWS X-Ray, Google Cloud Trace) + +## Troubleshooting + +If you're not seeing traces: + +1. Verify your OpenTelemetry collector is running and accessible +2. Check that the `OTEL_EXPORTER_OTLP_ENDPOINT` is correctly configured +3. Enable debug logging with the `--verbose` flag to see more detailed output +4. Check your collector's logs for any connection or configuration issues \ No newline at end of file diff --git a/go.mod b/go.mod index 63c092cc..6146ad7c 100644 --- a/go.mod +++ b/go.mod @@ -36,6 +36,7 @@ require ( github.com/Microsoft/go-winio v0.6.2 // indirect github.com/Microsoft/hcsshim v0.13.0 // indirect github.com/beorn7/perks v1.0.1 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cilium/ebpf v0.16.0 // indirect github.com/containerd/cgroups/v3 v3.0.5 // indirect @@ -61,6 +62,8 @@ require ( github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/google/go-cmp v0.7.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/intel/goresctrl v0.8.0 // indirect github.com/klauspost/compress v1.18.0 // indirect @@ -92,12 +95,17 @@ require ( go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect go.opentelemetry.io/otel v1.35.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0 // indirect go.opentelemetry.io/otel/metric v1.35.0 // indirect + go.opentelemetry.io/otel/sdk v1.35.0 // indirect go.opentelemetry.io/otel/trace v1.35.0 // indirect + go.opentelemetry.io/proto/otlp v1.5.0 // indirect golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f // indirect golang.org/x/mod v0.24.0 // indirect golang.org/x/net v0.38.0 // indirect golang.org/x/text v0.23.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a // indirect google.golang.org/protobuf v1.36.6 // indirect gopkg.in/inf.v0 v0.9.1 // indirect diff --git a/go.sum b/go.sum index 639cef9e..cbf391a4 100644 --- a/go.sum +++ b/go.sum @@ -12,6 +12,8 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= @@ -127,6 +129,8 @@ github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+ github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1 h1:e9Rjr40Z98/clHv5Yg79Is0NtosR5LXRvdr7o/6NwbA= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1/go.mod h1:tIxuGz/9mpox++sgp9fJjHO0+q1X9/UOWd798aAm22M= github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= @@ -264,6 +268,10 @@ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRND go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ= go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 h1:1fTNlAIJZGWLP5FVu0fikVry1IsiUnXjf7QFvoNN3Xw= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0/go.mod h1:zjPK58DtkqQFn+YUMbx0M2XV3QgKU0gS9LeGohREyK4= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0 h1:m639+BofXTvcY1q8CGs4ItwQarYtJPOWmVobfM1HpVI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0/go.mod h1:LjReUci/F4BUyv+y4dwnq3h/26iNOeC3wAIqgvTIZVo= go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY= @@ -272,6 +280,8 @@ go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5J go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w= go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= +go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4= +go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4= go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= go.uber.org/goleak v1.1.12 h1:gZAh5/EyT/HQwlpkCy6wTpqfH9H8Lz8zbm3dZh+OyzA= @@ -341,6 +351,9 @@ google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7 google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto v0.0.0-20240227224415-6ceb2ff114de h1:F6qOa9AZTYJXOUEr4jDysRDLrm4PHePlge4v4TGAlxY= +google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a h1:nwKuGPlUAt+aR+pcrkfFRrTU1BVrSmYyYMxYbUIVHr0= +google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a/go.mod h1:3kWAYMk1I75K4vykHtKt2ycnOgpA6974V7bREqbsenU= google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a h1:51aaUVRocpvUOSQKM6Q7VuoaktNIaMCLuhZB6DKksq4= google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a/go.mod h1:uRxBH1mhmO8PGhU89cMcHaXKZqO+OfakD8QQO0oYwlQ= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= diff --git a/pkg/convertor/convertor.go b/pkg/convertor/convertor.go index 82b7e1a4..5de6975e 100644 --- a/pkg/convertor/convertor.go +++ b/pkg/convertor/convertor.go @@ -32,6 +32,10 @@ import ( "strings" "time" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" + "github.com/containerd/accelerated-container-image/pkg/label" "github.com/containerd/accelerated-container-image/pkg/utils" "github.com/containerd/accelerated-container-image/pkg/version" @@ -67,6 +71,8 @@ var ( convSnapshotNameFormat = "overlaybd-conv-%s" ConvContentNameFormat = convSnapshotNameFormat + + tracer = otel.Tracer("accelerated-container-image/pkg/convertor") ) type ZFileConfig struct { @@ -108,6 +114,22 @@ type contentLoader struct { } func (loader *contentLoader) Load(ctx context.Context, cs content.Store) (l Layer, err error) { + startTime := time.Now() + ctx, span := tracer.Start(ctx, "content_load", + trace.WithAttributes( + attribute.Bool("isAccelLayer", loader.isAccelLayer), + attribute.String("fsType", loader.fsType), + attribute.Int("fileCount", len(loader.files)), + )) + defer func() { + if err != nil { + addErrorEvent(span, err, "content_load", l.Desc) + } + span.SetAttributes( + attribute.Int64("load_duration_ms", time.Since(startTime).Milliseconds()), + ) + span.End() + }() refName := fmt.Sprintf(ConvContentNameFormat, UniquePart()) contentWriter, err := content.OpenWriter(ctx, cs, content.WithRef(refName)) if err != nil { @@ -181,6 +203,9 @@ func (loader *contentLoader) Load(ctx context.Context, cs content.Store) (l Laye return emptyLayer, errors.Wrapf(err, "failed to close tar file") } + // Add progress event before commit + addProgressEvent(span, countWriter.c, countWriter.c, len(loader.files)) + labels := map[string]string{ labelBuildLayerFrom: strings.Join(srcPathList, ","), } @@ -261,10 +286,18 @@ func NewOverlaybdConvertor(ctx context.Context, cs content.Store, sn snapshots.S } func (c *overlaybdConvertor) Convert(ctx context.Context, srcManifest ocispec.Manifest, fsType string) (ocispec.Descriptor, error) { + ctx, span := tracer.Start(ctx, "Convert", + trace.WithAttributes( + attribute.String("fsType", fsType), + attribute.Int("layerCount", len(srcManifest.Layers)), + )) + defer span.End() + fmt.Printf("Convert: Reading config blob\n") configData, err := content.ReadBlob(ctx, c.cs, srcManifest.Config) if err != nil { fmt.Printf("Convert: ERROR reading config blob: %v\n", err) + span.RecordError(err) return emptyDesc, err } @@ -466,6 +499,23 @@ func (c *overlaybdConvertor) sentToRemote(ctx context.Context, desc ocispec.Desc // convertLayers applys image layers on overlaybd with specified filesystem and // exports the layers based on zfile. func (c *overlaybdConvertor) convertLayers(ctx context.Context, srcDescs []ocispec.Descriptor, srcDiffIDs []digest.Digest, fsType string) ([]Layer, error) { + startTime := time.Now() + ctx, span := tracer.Start(ctx, "convertLayers", + trace.WithAttributes( + attribute.String("fsType", fsType), + attribute.Int("layerCount", len(srcDescs)), + attribute.Int64("total_size", calculateTotalSize(srcDescs)), + )) + defer func() { + if root, ok := c.sn.(interface{ Root() string }); ok { + addResourceAttributes(span, root.Root()) + } + span.SetAttributes( + attribute.Int64("conversion_duration_ms", time.Since(startTime).Milliseconds()), + ) + span.End() + }() + fmt.Printf("convertLayers: Starting conversion of %d layers\n", len(srcDescs)) var ( lastParentID string = "" @@ -493,6 +543,14 @@ func (c *overlaybdConvertor) convertLayers(ctx context.Context, srcDescs []ocisp log.G(ctx).Infof("Skipping provenance layer: %s", desc.MediaType) continue } + + addLayerAttributes(span, desc, idx) + span.AddEvent("processing_layer", trace.WithAttributes( + attribute.Int("layer_index", idx), + attribute.String("digest", desc.Digest.String()), + attribute.Int64("size", desc.Size), + )) + fmt.Printf("convertLayers: Processing layer %d/%d (digest: %s)\n", idx+1, len(srcDescs), desc.Digest) chain = append(chain, srcDiffIDs[idx]) chainID := identity.ChainID(chain).String() @@ -500,20 +558,32 @@ func (c *overlaybdConvertor) convertLayers(ctx context.Context, srcDescs []ocisp var remoteDesc ocispec.Descriptor - if c.remote { - fmt.Printf("convertLayers: Looking for remote layer\n") - remoteDesc, err = c.findRemote(ctx, chainID) - if err != nil { - if !errdefs.IsNotFound(err) { - fmt.Printf("convertLayers: ERROR finding remote: %v\n", err) - return nil, err - } - fmt.Printf("convertLayers: Remote layer not found, will process locally\n") - } else { - fmt.Printf("convertLayers: Found remote layer\n") + if c.remote { + fmt.Printf("convertLayers: Looking for remote layer\n") + span.AddEvent("remote_check_start", trace.WithAttributes( + attribute.String("host", c.host), + attribute.String("repo", c.repo), + attribute.String("chainID", chainID), + )) + + remoteDesc, err = c.findRemote(ctx, chainID) + if err != nil { + if !errdefs.IsNotFound(err) { + fmt.Printf("convertLayers: ERROR finding remote: %v\n", err) + addErrorEvent(span, err, "remote_check", desc) + return nil, err } + fmt.Printf("convertLayers: Remote layer not found, will process locally\n") + } else { + fmt.Printf("convertLayers: Found remote layer\n") } + span.AddEvent("remote_check_complete", trace.WithAttributes( + attribute.Bool("found", err == nil), + attribute.String("chainID", chainID), + )) + } + if c.remote && err == nil { key := fmt.Sprintf(convSnapshotNameFormat, chainID) opts := []snapshots.Opt{ @@ -607,6 +677,22 @@ func (c *overlaybdConvertor) applyOCIV1LayerInObd( snOpts []snapshots.Opt, // apply for the commit snapshotter afterApply func(root string) error, // do something after apply tar stream ) (string, error) { + startTime := time.Now() + ctx, span := tracer.Start(ctx, "applyOCIV1LayerInObd", + trace.WithAttributes( + attribute.String("parent_id", parentID), + attribute.String("digest", desc.Digest.String()), + attribute.Int64("size", desc.Size), + attribute.String("media_type", desc.MediaType), + )) + defer func() { + addConfigAttributes(span, c.zfileCfg, c.vsize) + span.SetAttributes( + attribute.Int64("apply_duration_ms", time.Since(startTime).Milliseconds()), + ) + span.End() + }() + // Start applying the layer fmt.Printf("applyOCIV1LayerInObd: Starting layer application (digest: %s, size: %d)\n", desc.Digest, desc.Size) fmt.Printf("applyOCIV1LayerInObd: Getting reader for layer content\n") @@ -661,12 +747,50 @@ func (c *overlaybdConvertor) applyOCIV1LayerInObd( } if err = mount.WithTempMount(ctx, mounts, func(root string) error { - _, err := archive.Apply(ctx, root, rc) - if err == nil && afterApply != nil { - err = afterApply(root) + // Create a wrapper to track progress + var bytesProcessed int64 + progressReader := &readCountWrapper{ + r: rc, + c: 0, + } + + // Start a goroutine to report progress + done := make(chan struct{}) + go func() { + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + for { + select { + case <-ticker.C: + addProgressEvent(span, bytesProcessed, desc.Size, 0) + case <-done: + return + } + } + }() + + // Apply the layer and track progress + _, err := archive.Apply(ctx, root, progressReader) + close(done) + bytesProcessed = progressReader.c + + if err != nil { + addErrorEvent(span, err, "layer_apply", desc) + return errors.Wrapf(err, "failed to extract layer into snapshot") } - return err + + // Add final progress event + addProgressEvent(span, bytesProcessed, desc.Size, 0) + + if afterApply != nil { + if err := afterApply(root); err != nil { + addErrorEvent(span, err, "after_apply", desc) + return err + } + } + return nil }); err != nil { + addErrorEvent(span, err, "mount_operation", desc) return emptyString, errors.Wrapf(err, "failed to apply layer in snapshot %s", key) } @@ -695,6 +819,17 @@ func UniquePart() string { return fmt.Sprintf("%d-%s", t.Nanosecond(), strings.Replace(base64.URLEncoding.EncodeToString(b[:]), "_", "-", -1)) } +type readCountWrapper struct { + r io.Reader + c int64 +} + +func (rc *readCountWrapper) Read(p []byte) (n int, err error) { + n, err = rc.r.Read(p) + rc.c += int64(n) + return +} + type writeCountWrapper struct { w io.Writer c int64 diff --git a/pkg/convertor/trace_helpers.go b/pkg/convertor/trace_helpers.go new file mode 100644 index 00000000..8c5c003f --- /dev/null +++ b/pkg/convertor/trace_helpers.go @@ -0,0 +1,133 @@ +/* + Copyright The Accelerated Container Image Authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package convertor + +import ( + "runtime" + "syscall" + "time" + + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" +) + +// calculateTotalSize returns the total size of all descriptors +func calculateTotalSize(descs []ocispec.Descriptor) int64 { + var total int64 + for _, desc := range descs { + total += desc.Size + } + return total +} + +// getCompressionType determines the compression type from media type +func getCompressionType(desc ocispec.Descriptor) string { + switch desc.MediaType { + case ocispec.MediaTypeImageLayerGzip: + return "gzip" + case ocispec.MediaTypeImageLayerZstd: + return "zstd" + default: + return "none" + } +} + +// getMemoryUsage returns the current memory usage in bytes +func getMemoryUsage() int64 { + var m runtime.MemStats + runtime.ReadMemStats(&m) + return int64(m.Alloc) +} + +// getDiskUsage returns the current disk usage for the given path +func getDiskUsage(path string) int64 { + var stat syscall.Statfs_t + if err := syscall.Statfs(path, &stat); err != nil { + return 0 + } + return int64(stat.Blocks-stat.Bfree) * int64(stat.Bsize) +} + +// calculateThroughput calculates bytes per second +func calculateThroughput(bytesProcessed int64, duration time.Duration) int64 { + if duration.Seconds() == 0 { + return 0 + } + return int64(float64(bytesProcessed) / duration.Seconds()) +} + +// getErrorType returns a string representation of the error type +func getErrorType(err error) string { + if err == nil { + return "" + } + return err.Error() +} + +// addLayerAttributes adds common layer attributes to a span +func addLayerAttributes(span trace.Span, desc ocispec.Descriptor, idx int) { + span.SetAttributes( + attribute.Int("layer_index", idx), + attribute.String("digest", desc.Digest.String()), + attribute.Int64("size", desc.Size), + attribute.String("media_type", desc.MediaType), + attribute.String("compression", getCompressionType(desc)), + ) +} + +// addResourceAttributes adds resource usage attributes to a span +func addResourceAttributes(span trace.Span, path string) { + span.SetAttributes( + attribute.Int64("memory_used_bytes", getMemoryUsage()), + attribute.Int64("disk_used_bytes", getDiskUsage(path)), + ) +} + +// addConfigAttributes adds configuration attributes to a span +func addConfigAttributes(span trace.Span, cfg ZFileConfig, vsize int) { + span.SetAttributes( + attribute.String("algorithm", cfg.Algorithm), + attribute.Int("block_size", cfg.BlockSize), + attribute.Int("vsize", vsize), + ) +} + +// addProgressEvent adds a progress event to a span +func addProgressEvent(span trace.Span, processed, total int64, blocks int) { + var percent float64 + if total > 0 { + percent = float64(processed) / float64(total) * 100 + } + span.AddEvent("conversion_progress", trace.WithAttributes( + attribute.Int64("bytes_processed", processed), + attribute.Float64("percent_complete", percent), + attribute.Int("blocks_converted", blocks), + )) +} + +// addErrorEvent adds detailed error information to a span +func addErrorEvent(span trace.Span, err error, operation string, desc ocispec.Descriptor) { + if err == nil { + return + } + span.RecordError(err, trace.WithAttributes( + attribute.String("operation_type", operation), + attribute.String("layer_digest", desc.Digest.String()), + attribute.String("error_type", getErrorType(err)), + )) +} \ No newline at end of file diff --git a/pkg/convertor/trace_helpers_test.go b/pkg/convertor/trace_helpers_test.go new file mode 100644 index 00000000..95c3367c --- /dev/null +++ b/pkg/convertor/trace_helpers_test.go @@ -0,0 +1,151 @@ +/* + Copyright The Accelerated Container Image Authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package convertor + +import ( + "errors" + "os" + "testing" + "time" + + ocispec "github.com/opencontainers/image-spec/specs-go/v1" +) + +func TestCalculateTotalSize(t *testing.T) { + descs := []ocispec.Descriptor{ + {Size: 100}, + {Size: 200}, + {Size: 300}, + } + if size := calculateTotalSize(descs); size != 600 { + t.Errorf("Expected total size 600, got %d", size) + } +} + +func TestGetCompressionType(t *testing.T) { + tests := []struct { + name string + desc ocispec.Descriptor + expected string + }{ + { + name: "gzip", + desc: ocispec.Descriptor{MediaType: ocispec.MediaTypeImageLayerGzip}, + expected: "gzip", + }, + { + name: "zstd", + desc: ocispec.Descriptor{MediaType: ocispec.MediaTypeImageLayerZstd}, + expected: "zstd", + }, + { + name: "none", + desc: ocispec.Descriptor{MediaType: "unknown"}, + expected: "none", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if comp := getCompressionType(tt.desc); comp != tt.expected { + t.Errorf("Expected compression %s, got %s", tt.expected, comp) + } + }) + } +} + +func TestCalculateThroughput(t *testing.T) { + tests := []struct { + name string + bytes int64 + duration time.Duration + expected int64 + }{ + { + name: "normal case", + bytes: 1000, + duration: time.Second, + expected: 1000, + }, + { + name: "zero duration", + bytes: 1000, + duration: 0, + expected: 0, + }, + { + name: "zero bytes", + bytes: 0, + duration: time.Second, + expected: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if throughput := calculateThroughput(tt.bytes, tt.duration); throughput != tt.expected { + t.Errorf("Expected throughput %d, got %d", tt.expected, throughput) + } + }) + } +} + +func TestGetErrorType(t *testing.T) { + tests := []struct { + name string + err error + expected string + }{ + { + name: "nil error", + err: nil, + expected: "", + }, + { + name: "simple error", + err: errors.New("test error"), + expected: "test error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if errType := getErrorType(tt.err); errType != tt.expected { + t.Errorf("Expected error type %q, got %q", tt.expected, errType) + } + }) + } +} + +func TestGetDiskUsage(t *testing.T) { + // Create a temporary directory + dir, err := os.MkdirTemp("", "test-disk-usage") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(dir) + + // Write some data + if err := os.WriteFile(dir+"/testfile", []byte("test data"), 0644); err != nil { + t.Fatal(err) + } + + usage := getDiskUsage(dir) + if usage <= 0 { + t.Errorf("Expected positive disk usage, got %d", usage) + } +} \ No newline at end of file diff --git a/pkg/tracing/integration_test.go b/pkg/tracing/integration_test.go new file mode 100644 index 00000000..1982d8d2 --- /dev/null +++ b/pkg/tracing/integration_test.go @@ -0,0 +1,210 @@ +/* + Copyright The Accelerated Container Image Authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package tracing_test + +import ( + "context" + "net" + "testing" + "time" + + snapshotsapi "github.com/containerd/containerd/api/services/snapshots/v1" + "github.com/containerd/containerd/v2/contrib/snapshotservice" + "github.com/containerd/containerd/v2/core/mount" + "github.com/containerd/containerd/v2/core/snapshots" + "google.golang.org/grpc" + "google.golang.org/grpc/test/bufconn" + + "github.com/containerd/accelerated-container-image/pkg/tracing" +) + +const bufSize = 1024 * 1024 + +type mockSnapshotter struct { + snapshots.Snapshotter +} + +func (m *mockSnapshotter) Prepare(ctx context.Context, key, parent string, opts ...snapshots.Opt) ([]mount.Mount, error) { + return []mount.Mount{{Type: "mock"}}, nil +} + +func (m *mockSnapshotter) Commit(ctx context.Context, name, key string, opts ...snapshots.Opt) error { + return nil +} + +func (m *mockSnapshotter) View(ctx context.Context, key, parent string, opts ...snapshots.Opt) ([]mount.Mount, error) { + return []mount.Mount{{Type: "mock"}}, nil +} + +func (m *mockSnapshotter) Mounts(ctx context.Context, key string) ([]mount.Mount, error) { + return []mount.Mount{{Type: "mock"}}, nil +} + +func (m *mockSnapshotter) List(ctx context.Context, filters ...string) ([]snapshots.Info, error) { + return []snapshots.Info{{Name: "test-commit"}}, nil +} + +func (m *mockSnapshotter) Remove(ctx context.Context, key string) error { + return nil +} + +func (m *mockSnapshotter) Walk(ctx context.Context, fn snapshots.WalkFunc, filters ...string) error { + info := snapshots.Info{Name: "test-commit"} + return fn(ctx, info) +} + +func (m *mockSnapshotter) Close() error { + return nil +} + +func newGRPCTestEnv(t *testing.T, snapshotter snapshotsapi.SnapshotsServer) (context.Context, *grpc.ClientConn, func()) { + t.Helper() + + lis := bufconn.Listen(bufSize) + srv := grpc.NewServer() + snapshotsapi.RegisterSnapshotsServer(srv, snapshotter) + + go func() { + if err := srv.Serve(lis); err != nil { + // Only log if the error is not due to server being stopped + if err != grpc.ErrServerStopped { + t.Logf("Server error: %v", err) + } + } + }() + + dialer := func(context.Context, string) (net.Conn, error) { + return lis.Dial() + } + + ctx := context.Background() + conn, err := grpc.DialContext(ctx, "bufnet", + grpc.WithContextDialer(dialer), + grpc.WithInsecure(), + ) + if err != nil { + t.Fatalf("Failed to dial bufnet: %v", err) + } + + cleanup := func() { + conn.Close() + srv.Stop() + lis.Close() + } + + return ctx, conn, cleanup +} + +func TestTracingSnapshotterIntegration(t *testing.T) { + // Create and wrap the snapshotter + snapshotter := &mockSnapshotter{} + service := snapshotservice.FromSnapshotter(snapshotter) + tracedService := tracing.WithTracing(service) + + // Setup gRPC server and client + ctx, conn, cleanup := newGRPCTestEnv(t, tracedService) + defer cleanup() + + // Create client + client := snapshotsapi.NewSnapshotsClient(conn) + + tests := []struct { + name string + run func(t *testing.T) + }{ + { + name: "prepare and commit", + run: func(t *testing.T) { + resetTestSpans() + + prepareReq := &snapshotsapi.PrepareSnapshotRequest{ + Key: "test-snapshot", + Parent: "", + } + prepareResp, err := client.Prepare(ctx, prepareReq) + if err != nil { + t.Fatalf("Failed to prepare snapshot: %v", err) + } + + if len(prepareResp.Mounts) == 0 { + t.Error("No mounts returned from prepare") + } + + commitReq := &snapshotsapi.CommitSnapshotRequest{ + Name: "test-commit", + Key: "test-snapshot", + } + _, err = client.Commit(ctx, commitReq) + if err != nil { + t.Fatalf("Failed to commit snapshot: %v", err) + } + + // Verify spans + spans := getTestSpans() + if len(spans) != 2 { + t.Errorf("got %d spans, want 2", len(spans)) + } + }, + }, + { + name: "list snapshots", + run: func(t *testing.T) { + resetTestSpans() + + listReq := &snapshotsapi.ListSnapshotsRequest{} + listClient, err := client.List(ctx, listReq) + if err != nil { + t.Fatalf("Failed to list snapshots: %v", err) + } + + var snapshots []*snapshotsapi.Info + for { + resp, err := listClient.Recv() + if err != nil { + break + } + snapshots = append(snapshots, resp.Info...) + } + + found := false + for _, info := range snapshots { + if info.Name == "test-commit" { + found = true + break + } + } + if !found { + t.Error("Committed snapshot not found in list") + } + + // Verify spans + spans := getTestSpans() + if len(spans) != 1 { + t.Errorf("got %d spans, want 1", len(spans)) + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.run(t) + // Give time for spans to be processed + time.Sleep(10 * time.Millisecond) + }) + } +} \ No newline at end of file diff --git a/pkg/tracing/snapshotter.go b/pkg/tracing/snapshotter.go new file mode 100644 index 00000000..07701d6f --- /dev/null +++ b/pkg/tracing/snapshotter.go @@ -0,0 +1,192 @@ +/* + Copyright The Accelerated Container Image Authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package tracing + +import ( + "context" + + snapshotsapi "github.com/containerd/containerd/api/services/snapshots/v1" + ptypes "github.com/containerd/containerd/v2/pkg/protobuf/types" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" +) + +const tracerName = "accelerated-container-image/snapshotter" + +// TracingSnapshotter wraps a snapshotter service with OpenTelemetry tracing +type TracingSnapshotter struct { + server snapshotsapi.SnapshotsServer + snapshotsapi.UnimplementedSnapshotsServer +} + +// Server returns the underlying snapshotter server for testing +func (s *TracingSnapshotter) Server() snapshotsapi.SnapshotsServer { + return s.server +} + +// WithTracing wraps a snapshotter service with OpenTelemetry tracing +func WithTracing(server snapshotsapi.SnapshotsServer) snapshotsapi.SnapshotsServer { + return &TracingSnapshotter{server: server} +} + +func (s *TracingSnapshotter) Prepare(ctx context.Context, pr *snapshotsapi.PrepareSnapshotRequest) (*snapshotsapi.PrepareSnapshotResponse, error) { + ctx, span := otel.GetTracerProvider().Tracer(tracerName).Start(ctx, "snapshotter.Prepare", trace.WithAttributes( + attribute.String("key", pr.Key), + attribute.String("parent", pr.Parent), + )) + defer span.End() + + resp, err := s.server.Prepare(ctx, pr) + if err != nil { + span.RecordError(err) + } + return resp, err +} + +func (s *TracingSnapshotter) View(ctx context.Context, pr *snapshotsapi.ViewSnapshotRequest) (*snapshotsapi.ViewSnapshotResponse, error) { + ctx, span := otel.GetTracerProvider().Tracer(tracerName).Start(ctx, "snapshotter.View", trace.WithAttributes( + attribute.String("key", pr.Key), + attribute.String("parent", pr.Parent), + )) + defer span.End() + + resp, err := s.server.View(ctx, pr) + if err != nil { + span.RecordError(err) + } + return resp, err +} + +func (s *TracingSnapshotter) Mounts(ctx context.Context, mr *snapshotsapi.MountsRequest) (*snapshotsapi.MountsResponse, error) { + ctx, span := otel.GetTracerProvider().Tracer(tracerName).Start(ctx, "snapshotter.Mounts", trace.WithAttributes( + attribute.String("key", mr.Key), + )) + defer span.End() + + resp, err := s.server.Mounts(ctx, mr) + if err != nil { + span.RecordError(err) + } + return resp, err +} + +func (s *TracingSnapshotter) Commit(ctx context.Context, cr *snapshotsapi.CommitSnapshotRequest) (*ptypes.Empty, error) { + ctx, span := otel.GetTracerProvider().Tracer(tracerName).Start(ctx, "snapshotter.Commit", trace.WithAttributes( + attribute.String("name", cr.Name), + attribute.String("key", cr.Key), + )) + defer span.End() + + resp, err := s.server.Commit(ctx, cr) + if err != nil { + span.RecordError(err) + } + return resp, err +} + +func (s *TracingSnapshotter) Remove(ctx context.Context, rr *snapshotsapi.RemoveSnapshotRequest) (*ptypes.Empty, error) { + ctx, span := otel.GetTracerProvider().Tracer(tracerName).Start(ctx, "snapshotter.Remove", trace.WithAttributes( + attribute.String("key", rr.Key), + )) + defer span.End() + + resp, err := s.server.Remove(ctx, rr) + if err != nil { + span.RecordError(err) + } + return resp, err +} + +func (s *TracingSnapshotter) Stat(ctx context.Context, sr *snapshotsapi.StatSnapshotRequest) (*snapshotsapi.StatSnapshotResponse, error) { + ctx, span := otel.GetTracerProvider().Tracer(tracerName).Start(ctx, "snapshotter.Stat", trace.WithAttributes( + attribute.String("key", sr.Key), + )) + defer span.End() + + resp, err := s.server.Stat(ctx, sr) + if err != nil { + span.RecordError(err) + } + return resp, err +} + +func (s *TracingSnapshotter) Update(ctx context.Context, sr *snapshotsapi.UpdateSnapshotRequest) (*snapshotsapi.UpdateSnapshotResponse, error) { + ctx, span := otel.GetTracerProvider().Tracer(tracerName).Start(ctx, "snapshotter.Update", trace.WithAttributes( + attribute.String("name", sr.Info.Name), + )) + defer span.End() + + resp, err := s.server.Update(ctx, sr) + if err != nil { + span.RecordError(err) + } + return resp, err +} + +func (s *TracingSnapshotter) List(sr *snapshotsapi.ListSnapshotsRequest, ss snapshotsapi.Snapshots_ListServer) error { + ctx, span := otel.GetTracerProvider().Tracer(tracerName).Start(ss.Context(), "snapshotter.List") + defer span.End() + + err := s.server.List(sr, &tracingListServer{ + Snapshots_ListServer: ss, + ctx: ctx, + }) + if err != nil { + span.RecordError(err) + } + return err +} + +type tracingListServer struct { + snapshotsapi.Snapshots_ListServer + ctx context.Context +} + +func (t *tracingListServer) Context() context.Context { + return t.ctx +} + +func (s *TracingSnapshotter) Usage(ctx context.Context, ur *snapshotsapi.UsageRequest) (*snapshotsapi.UsageResponse, error) { + ctx, span := otel.GetTracerProvider().Tracer(tracerName).Start(ctx, "snapshotter.Usage", trace.WithAttributes( + attribute.String("key", ur.Key), + )) + defer span.End() + + resp, err := s.server.Usage(ctx, ur) + if err != nil { + span.RecordError(err) + } + if resp != nil { + span.SetAttributes( + attribute.Int64("inodes", resp.Inodes), + attribute.Int64("size", resp.Size), + ) + } + return resp, err +} + +func (s *TracingSnapshotter) Cleanup(ctx context.Context, cr *snapshotsapi.CleanupRequest) (*ptypes.Empty, error) { + ctx, span := otel.GetTracerProvider().Tracer(tracerName).Start(ctx, "snapshotter.Cleanup") + defer span.End() + + resp, err := s.server.Cleanup(ctx, cr) + if err != nil { + span.RecordError(err) + } + return resp, err +} \ No newline at end of file diff --git a/pkg/tracing/snapshotter_test.go b/pkg/tracing/snapshotter_test.go new file mode 100644 index 00000000..5f058086 --- /dev/null +++ b/pkg/tracing/snapshotter_test.go @@ -0,0 +1,208 @@ +/* + Copyright The Accelerated Container Image Authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package tracing_test + +import ( + "context" + "errors" + "testing" + + snapshotsapi "github.com/containerd/containerd/api/services/snapshots/v1" + "github.com/containerd/containerd/v2/core/mount" + "go.opentelemetry.io/otel" + + "github.com/containerd/accelerated-container-image/pkg/tracing" +) + +type mockSnapshotsServer struct { + snapshotsapi.UnimplementedSnapshotsServer + prepareCalled bool + prepareError error + viewCalled bool + viewError error + mountsCalled bool + mountsError error +} + +func (m *mockSnapshotsServer) Prepare(ctx context.Context, req *snapshotsapi.PrepareSnapshotRequest) (*snapshotsapi.PrepareSnapshotResponse, error) { + m.prepareCalled = true + if m.prepareError != nil { + return nil, m.prepareError + } + return &snapshotsapi.PrepareSnapshotResponse{ + Mounts: mount.ToProto([]mount.Mount{{Type: "test"}}), + }, nil +} + +func (m *mockSnapshotsServer) View(ctx context.Context, req *snapshotsapi.ViewSnapshotRequest) (*snapshotsapi.ViewSnapshotResponse, error) { + m.viewCalled = true + if m.viewError != nil { + return nil, m.viewError + } + return &snapshotsapi.ViewSnapshotResponse{ + Mounts: mount.ToProto([]mount.Mount{{Type: "test"}}), + }, nil +} + +func (m *mockSnapshotsServer) Mounts(ctx context.Context, req *snapshotsapi.MountsRequest) (*snapshotsapi.MountsResponse, error) { + m.mountsCalled = true + if m.mountsError != nil { + return nil, m.mountsError + } + return &snapshotsapi.MountsResponse{ + Mounts: mount.ToProto([]mount.Mount{{Type: "test"}}), + }, nil +} + +func TestTracingSnapshotter_Operations(t *testing.T) { + tests := []struct { + name string + operation string + runTest func(context.Context, snapshotsapi.SnapshotsServer) error + withError bool + attributes map[string]string + }{ + { + name: "prepare success", + operation: "snapshotter.Prepare", + runTest: func(ctx context.Context, s snapshotsapi.SnapshotsServer) error { + _, err := s.Prepare(ctx, &snapshotsapi.PrepareSnapshotRequest{ + Key: "test-key", + Parent: "test-parent", + }) + return err + }, + attributes: map[string]string{ + "key": "test-key", + "parent": "test-parent", + }, + }, + { + name: "prepare error", + operation: "snapshotter.Prepare", + runTest: func(ctx context.Context, s snapshotsapi.SnapshotsServer) error { + mock := s.(*tracing.TracingSnapshotter).Server().(*mockSnapshotsServer) + mock.prepareError = errors.New("prepare failed") + _, err := s.Prepare(ctx, &snapshotsapi.PrepareSnapshotRequest{ + Key: "test-key", + Parent: "test-parent", + }) + return err + }, + withError: true, + attributes: map[string]string{ + "key": "test-key", + "parent": "test-parent", + }, + }, + { + name: "view success", + operation: "snapshotter.View", + runTest: func(ctx context.Context, s snapshotsapi.SnapshotsServer) error { + _, err := s.View(ctx, &snapshotsapi.ViewSnapshotRequest{ + Key: "test-key", + Parent: "test-parent", + }) + return err + }, + attributes: map[string]string{ + "key": "test-key", + "parent": "test-parent", + }, + }, + { + name: "mounts success", + operation: "snapshotter.Mounts", + runTest: func(ctx context.Context, s snapshotsapi.SnapshotsServer) error { + _, err := s.Mounts(ctx, &snapshotsapi.MountsRequest{ + Key: "test-key", + }) + return err + }, + attributes: map[string]string{ + "key": "test-key", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resetTestSpans() + mock := &mockSnapshotsServer{} + sut := tracing.WithTracing(mock) + + err := tt.runTest(context.Background(), sut) + + if (err != nil) != tt.withError { + t.Errorf("got error = %v, wantError = %v", err, tt.withError) + } + + spans := getTestSpans() + if len(spans) != 1 { + t.Fatalf("got %d spans, want 1", len(spans)) + } + + span := spans[0] + if span.Name() != tt.operation { + t.Errorf("got span name = %s, want %s", span.Name(), tt.operation) + } + + attrs := make(map[string]string) + for _, attr := range span.Attributes() { + attrs[string(attr.Key)] = attr.Value.AsString() + } + + for k, v := range tt.attributes { + if got := attrs[k]; got != v { + t.Errorf("attribute %s = %s, want %s", k, got, v) + } + } + + if tt.withError && len(span.Events()) != 1 { + t.Error("error event not recorded in span") + } + }) + } +} + +func TestTracingContextPropagation(t *testing.T) { + resetTestSpans() + mock := &mockSnapshotsServer{} + sut := tracing.WithTracing(mock) + + tracer := otel.GetTracerProvider().Tracer("test") + ctx, parentSpan := tracer.Start(context.Background(), "parent") + defer parentSpan.End() + + _, err := sut.Prepare(ctx, &snapshotsapi.PrepareSnapshotRequest{ + Key: "test-key", + Parent: "test-parent", + }) + if err != nil { + t.Fatalf("Prepare failed: %v", err) + } + + spans := getTestSpans() + if len(spans) != 1 { + t.Fatalf("got %d spans, want 1", len(spans)) + } + + span := spans[0] + if span.Parent().TraceID() != parentSpan.SpanContext().TraceID() { + t.Error("trace context not properly propagated") + } +} \ No newline at end of file diff --git a/pkg/tracing/testing.go b/pkg/tracing/testing.go new file mode 100644 index 00000000..4590a724 --- /dev/null +++ b/pkg/tracing/testing.go @@ -0,0 +1,42 @@ +/* + Copyright The Accelerated Container Image Authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package tracing + +import ( + "context" + + "go.opentelemetry.io/otel" + sdktrace "go.opentelemetry.io/otel/sdk/trace" +) + +// setupTestTracer sets up a test-only tracer that doesn't try to export spans +func setupTestTracer(processor sdktrace.SpanProcessor) func() { + tp := sdktrace.NewTracerProvider( + sdktrace.WithSpanProcessor(processor), + sdktrace.WithSampler(sdktrace.AlwaysSample()), + ) + + // Set global tracer provider + oldProvider := otel.GetTracerProvider() + otel.SetTracerProvider(tp) + + // Return cleanup function + return func() { + _ = tp.Shutdown(context.Background()) + otel.SetTracerProvider(oldProvider) + } +} \ No newline at end of file diff --git a/pkg/tracing/tracing.go b/pkg/tracing/tracing.go new file mode 100644 index 00000000..1e3215d7 --- /dev/null +++ b/pkg/tracing/tracing.go @@ -0,0 +1,57 @@ +package tracing + +import ( + "context" + "fmt" + "os" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" + "go.opentelemetry.io/otel/sdk/resource" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + semconv "go.opentelemetry.io/otel/semconv/v1.24.0" +) + +var ( + serviceName = os.Getenv("OTEL_SERVICE_NAME") +) + +// InitTracer initializes the OpenTelemetry tracer +func InitTracer(ctx context.Context) (func(context.Context) error, error) { + if serviceName == "" { + serviceName = "accelerated-container-image" + } + + // Create OTLP exporter + client := otlptracegrpc.NewClient() + exporter, err := otlptrace.New(ctx, client) + if err != nil { + return nil, fmt.Errorf("creating OTLP trace exporter: %w", err) + } + + // Create resource with service information + res, err := resource.New(ctx, + resource.WithAttributes( + semconv.ServiceName(serviceName), + attribute.String("environment", os.Getenv("ENVIRONMENT")), + ), + ) + if err != nil { + return nil, fmt.Errorf("creating resource: %w", err) + } + + // Create trace provider + tp := sdktrace.NewTracerProvider( + sdktrace.WithBatcher(exporter), + sdktrace.WithResource(res), + sdktrace.WithSampler(sdktrace.AlwaysSample()), + ) + + // Set global trace provider + otel.SetTracerProvider(tp) + + // Return shutdown function + return tp.Shutdown, nil +} \ No newline at end of file diff --git a/pkg/tracing/tracing_test.go b/pkg/tracing/tracing_test.go new file mode 100644 index 00000000..2466f2c0 --- /dev/null +++ b/pkg/tracing/tracing_test.go @@ -0,0 +1,78 @@ +/* + Copyright The Accelerated Container Image Authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package tracing_test + +import ( + "context" + "os" + "testing" + + "go.opentelemetry.io/otel" + sdktrace "go.opentelemetry.io/otel/sdk/trace" +) + +var ( + testProcessor *mockSpanProcessor + origProvider = otel.GetTracerProvider() +) + +func TestMain(m *testing.M) { + // Setup test environment + testProcessor = &mockSpanProcessor{} + tp := sdktrace.NewTracerProvider( + sdktrace.WithSpanProcessor(testProcessor), + sdktrace.WithSampler(sdktrace.AlwaysSample()), + ) + otel.SetTracerProvider(tp) + + // Run tests + code := m.Run() + + // Cleanup + _ = tp.Shutdown(context.Background()) + otel.SetTracerProvider(origProvider) + + os.Exit(code) +} + +type mockSpanProcessor struct { + spans []sdktrace.ReadOnlySpan +} + +func (p *mockSpanProcessor) OnStart(parent context.Context, s sdktrace.ReadWriteSpan) {} + +func (p *mockSpanProcessor) OnEnd(s sdktrace.ReadOnlySpan) { + p.spans = append(p.spans, s) +} + +func (p *mockSpanProcessor) Shutdown(context.Context) error { return nil } + +func (p *mockSpanProcessor) ForceFlush(context.Context) error { return nil } + +func (p *mockSpanProcessor) Reset() { + p.spans = nil +} + +// getTestSpans returns all spans collected since the last Reset +func getTestSpans() []sdktrace.ReadOnlySpan { + return testProcessor.spans +} + +// resetTestSpans clears all collected spans +func resetTestSpans() { + testProcessor.Reset() +} \ No newline at end of file