From 52e150037ea57fccb319317c7e8789d7e26e384b Mon Sep 17 00:00:00 2001 From: chlins Date: Sun, 10 May 2026 15:32:06 +0800 Subject: [PATCH] feat(service): add layer cache for cross-mount dedup Signed-off-by: chlins --- .github/workflows/coverage.yml | 3 +- .github/workflows/lint.yml | 4 +- build/Dockerfile | 4 +- charts/model-csi-driver/values.yaml | 5 +- go.mod | 96 ++-- go.sum | 196 +++---- pkg/config/config.go | 3 +- pkg/server/server_test.go | 4 +- pkg/service/layercache.go | 563 ++++++++++++++++++ pkg/service/layercache_hook.go | 159 ++++++ pkg/service/layercache_hook_test.go | 203 +++++++ pkg/service/layercache_test.go | 854 ++++++++++++++++++++++++++++ pkg/service/puller.go | 22 +- pkg/service/puller_test.go | 138 +++++ pkg/service/pullmodel_test.go | 2 +- pkg/service/worker.go | 19 +- pkg/service/worker_test.go | 13 + pkg/status/hook.go | 14 +- pkg/status/status_test.go | 6 +- 19 files changed, 2136 insertions(+), 172 deletions(-) create mode 100644 pkg/service/layercache.go create mode 100644 pkg/service/layercache_hook.go create mode 100644 pkg/service/layercache_hook_test.go create mode 100644 pkg/service/layercache_test.go diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index ea4f838..cc8169a 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -31,7 +31,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '1.24.2' + go-version: '1.25' - name: Cache Go modules uses: actions/cache@v4 @@ -209,4 +209,3 @@ jobs: run: | echo "❌ Changed-line coverage ${{ steps.diff_coverage.outputs.diff_display }} is below the required ${{ env.THRESHOLD_DIFF }}%" exit 1 - diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 0504e4e..6a11143 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -25,6 +25,4 @@ jobs: fetch-depth: '0' - name: Golangci lint - uses: golangci/golangci-lint-action@1481404843c368bc19ca9406f87d6e0fc97bdcfd - with: - version: v2.0 + uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 diff --git a/build/Dockerfile b/build/Dockerfile index 28c5a0a..c4d295c 100644 --- a/build/Dockerfile +++ b/build/Dockerfile @@ -1,11 +1,11 @@ # syntax=docker/dockerfile:1 -FROM --platform=$BUILDPLATFORM golang:1.24 AS builder +FROM --platform=$BUILDPLATFORM golang:1.25 AS builder ARG TARGETOS ARG TARGETARCH WORKDIR /app COPY . . -RUN make -e GOARCH=${TARGETARCH} release +RUN GOPROXY=https://goproxy.io,direct make -e GOARCH=${TARGETARCH} release FROM ubuntu:24.04 COPY --from=builder /app/model-csi-driver /usr/bin/model-csi-driver diff --git a/charts/model-csi-driver/values.yaml b/charts/model-csi-driver/values.yaml index f77f69b..e759a28 100644 --- a/charts/model-csi-driver/values.yaml +++ b/charts/model-csi-driver/values.yaml @@ -16,6 +16,9 @@ config: # # Number of concurrent downloads. # concurrency: 5 # + # # Maximum number of concurrent layer pulls across all volumes. + # node_layer_concurrency: 0 + # # # Path to the directory containing Docker config.json file. # # Defaults to /root/.docker # docker_config_dir: "" @@ -63,4 +66,4 @@ podAnnotations: {} nodeSelector: {} tolerations: [] affinity: {} -hostAliases: {} \ No newline at end of file +hostAliases: {} diff --git a/go.mod b/go.mod index 356c2ba..8b70121 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/modelpack/model-csi-driver -go 1.24.2 +go 1.25.5 require ( github.com/agiledragon/gomonkey/v2 v2.13.0 @@ -9,11 +9,11 @@ require ( github.com/dragonflyoss/model-spec v0.0.6 github.com/dustin/go-humanize v1.0.1 github.com/fsnotify/fsnotify v1.9.0 - github.com/go-git/go-git/v5 v5.16.4 + github.com/go-git/go-git/v5 v5.18.0 github.com/google/uuid v1.6.0 github.com/labstack/echo/v4 v4.13.3 github.com/moby/sys/mountinfo v0.7.2 - github.com/modelpack/modctl v0.1.2-alpha.0 + github.com/modelpack/modctl v0.2.1 github.com/modelpack/model-spec v0.0.7 github.com/opencontainers/go-digest v1.0.0 github.com/opencontainers/image-spec v1.1.1 @@ -25,22 +25,23 @@ require ( github.com/stretchr/testify v1.11.1 github.com/urfave/cli/v2 v2.27.6 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 - go.opentelemetry.io/otel v1.38.0 - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 - go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.38.0 - go.opentelemetry.io/otel/sdk v1.38.0 - go.opentelemetry.io/otel/trace v1.38.0 + go.opentelemetry.io/otel v1.42.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0 + go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0 + go.opentelemetry.io/otel/sdk v1.42.0 + go.opentelemetry.io/otel/trace v1.42.0 golang.org/x/sync v0.19.0 - golang.org/x/sys v0.40.0 - google.golang.org/grpc v1.78.0 + golang.org/x/sys v0.43.0 + google.golang.org/grpc v1.80.0 gopkg.in/yaml.v2 v2.4.0 k8s.io/api v0.28.4 k8s.io/apimachinery v0.28.4 k8s.io/client-go v0.28.4 + oras.land/oras-go/v2 v2.6.0 ) require ( - d7y.io/api/v2 v2.2.8 // indirect + d7y.io/api/v2 v2.2.28 // indirect dario.cat/mergo v1.0.2 // indirect github.com/BurntSushi/toml v1.5.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect @@ -50,24 +51,24 @@ require ( github.com/antgroup/hugescm v0.18.3 // indirect github.com/avast/retry-go/v4 v4.7.0 // indirect github.com/beorn7/perks v1.0.1 // indirect + github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/clipperhouse/stringish v0.1.1 // indirect - github.com/clipperhouse/uax29/v2 v2.3.0 // indirect - github.com/cloudflare/circl v1.6.1 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/cloudflare/circl v1.6.3 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect github.com/cyphar/filepath-securejoin v0.4.1 // indirect github.com/danieljoos/wincred v1.2.2 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dgraph-io/ristretto/v2 v2.2.0 // indirect - github.com/distribution/distribution/v3 v3.0.0 // indirect + github.com/distribution/distribution/v3 v3.1.0 // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/go-metrics v0.0.1 // indirect github.com/emicklei/go-restful/v3 v3.10.1 // indirect github.com/emirpasic/gods v1.18.1 // indirect - github.com/envoyproxy/protoc-gen-validate v1.3.0 // indirect + github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect - github.com/go-git/go-billy/v5 v5.6.2 // indirect + github.com/go-git/go-billy/v5 v5.8.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/jsonpointer v0.19.6 // indirect @@ -82,12 +83,12 @@ require ( github.com/google/go-cmp v0.7.0 // indirect github.com/google/gofuzz v1.2.0 // indirect github.com/gorilla/mux v1.8.2-0.20240619235004-db9d1d0073d2 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/kevinburke/ssh_config v1.2.0 // indirect - github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/compress v1.18.4 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/labstack/gommon v0.4.2 // indirect @@ -95,7 +96,7 @@ require ( github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/mattn/go-runewidth v0.0.20 // indirect github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect github.com/minio/sha256-simd v1.0.1 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect @@ -103,45 +104,45 @@ require ( github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pjbgf/sha1cd v0.3.2 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/prometheus/common v0.67.2 // indirect + github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/otlptranslator v1.0.0 // indirect - github.com/prometheus/procfs v0.19.2 // indirect + github.com/prometheus/procfs v0.20.1 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect github.com/skeema/knownhosts v1.3.1 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect - github.com/vbauerster/mpb/v8 v8.11.3 // indirect + github.com/vbauerster/mpb/v8 v8.12.0 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect github.com/zeebo/blake3 v0.2.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/bridges/prometheus v0.63.0 // indirect - go.opentelemetry.io/contrib/exporters/autoexport v0.63.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.14.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.14.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.38.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.38.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0 // indirect - go.opentelemetry.io/otel/exporters/prometheus v0.60.0 // indirect - go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.14.0 // indirect - go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.38.0 // indirect - go.opentelemetry.io/otel/log v0.14.0 // indirect - go.opentelemetry.io/otel/metric v1.38.0 // indirect - go.opentelemetry.io/otel/sdk/log v0.14.0 // indirect - go.opentelemetry.io/otel/sdk/metric v1.38.0 // indirect - go.opentelemetry.io/proto/otlp v1.8.0 // indirect + go.opentelemetry.io/contrib/bridges/prometheus v0.67.0 // indirect + go.opentelemetry.io/contrib/exporters/autoexport v0.67.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.18.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.18.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.42.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0 // indirect + go.opentelemetry.io/otel/exporters/prometheus v0.64.0 // indirect + go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.18.0 // indirect + go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0 // indirect + go.opentelemetry.io/otel/log v0.18.0 // indirect + go.opentelemetry.io/otel/metric v1.42.0 // indirect + go.opentelemetry.io/otel/sdk/log v0.18.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.42.0 // indirect + go.opentelemetry.io/proto/otlp v1.9.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect - golang.org/x/crypto v0.47.0 // indirect - golang.org/x/net v0.48.0 // indirect - golang.org/x/oauth2 v0.32.0 // indirect - golang.org/x/term v0.39.0 // indirect - golang.org/x/text v0.33.0 // indirect - golang.org/x/time v0.8.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20251029180050-ab9386a59fda // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/net v0.51.0 // indirect + golang.org/x/oauth2 v0.35.0 // indirect + golang.org/x/term v0.40.0 // indirect + golang.org/x/text v0.34.0 // indirect + golang.org/x/time v0.14.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect @@ -149,7 +150,6 @@ require ( k8s.io/klog/v2 v2.100.1 // indirect k8s.io/kube-openapi v0.0.0-20230717233707-2695361300d9 // indirect k8s.io/utils v0.0.0-20230406110748-d93618cff8a2 // indirect - oras.land/oras-go/v2 v2.6.0 // indirect sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect sigs.k8s.io/structured-merge-diff/v4 v4.2.3 // indirect sigs.k8s.io/yaml v1.3.0 // indirect diff --git a/go.sum b/go.sum index fe58007..4a678ed 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,6 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -d7y.io/api/v2 v2.2.8 h1:XNgIVHgij3VbNRri74cW2eLV4hIkN5v8FE6yB1YQEXs= -d7y.io/api/v2 v2.2.8/go.mod h1:TtW9UE0CebRB/CWIEbWfRnljmpKf/mNoe2qIUO+PoP0= +d7y.io/api/v2 v2.2.28 h1:cSkW6VKhNn+noMZGJJ4/H0fECrp+sLLo19tep/Ib5CQ= +d7y.io/api/v2 v2.2.28/go.mod h1:s/G9W8VxUkpsRqqzTuctQmLRb8srFbUUTOXF7Ms3khc= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= @@ -34,17 +34,17 @@ github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24 github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= 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/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= +github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= -github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= -github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= -github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4= -github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= -github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= -github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= +github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/container-storage-interface/spec v1.2.0 h1:bD9KIVgaVKKkQ/UbVUY9kCaH/CJbhNxe0eeB4JeJV2s= github.com/container-storage-interface/spec v1.2.0/go.mod h1:6URME8mwIBbpVyZV93Ce5St17xBiQJQY67NDsuohiy4= github.com/containerd/containerd v1.7.27 h1:yFyEyojddO3MIGVER2xJLWoCIn+Up4GaHFquP7hsFII= @@ -66,8 +66,8 @@ github.com/dgraph-io/ristretto/v2 v2.2.0 h1:bkY3XzJcXoMuELV8F+vS8kzNgicwQFAaGINA github.com/dgraph-io/ristretto/v2 v2.2.0/go.mod h1:RZrm63UmcBAaYWC1DotLYBmTvgkrs0+XhBd7Npn7/zI= github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38= github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= -github.com/distribution/distribution/v3 v3.0.0 h1:q4R8wemdRQDClzoNNStftB2ZAfqOiN6UX90KJc4HjyM= -github.com/distribution/distribution/v3 v3.0.0/go.mod h1:tRNuFoZsUdyRVegq8xGNeds4KLjwLCRin/tTo6i1DhU= +github.com/distribution/distribution/v3 v3.1.0 h1:u1v788HreKTLGdNY6s7px8Exgrs9mZ9UrCDjSrpCM8g= +github.com/distribution/distribution/v3 v3.1.0/go.mod h1:73BuF5/ziMHNVt7nnL1roYpH4Eg/FgUlKZm3WryIx/o= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/docker/go-metrics v0.0.1 h1:AgB/0SvBxihN0X8OR4SjsblXkbMvalQ8cjmtKQ2rQV8= @@ -82,20 +82,20 @@ github.com/emicklei/go-restful/v3 v3.10.1 h1:rc42Y5YTp7Am7CS630D7JmhRjq4UlEUuEKf github.com/emicklei/go-restful/v3 v3.10.1/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= -github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4= -github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= +github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= +github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= -github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM= -github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU= +github.com/go-git/go-billy/v5 v5.8.0 h1:I8hjc3LbBlXTtVuFNJuwYuMiHvQJDq1AT6u4DwDzZG0= +github.com/go-git/go-billy/v5 v5.8.0/go.mod h1:RpvI/rw4Vr5QA+Z60c6d6LXH0rYJo0uD5SqfmrrheCY= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= -github.com/go-git/go-git/v5 v5.16.4 h1:7ajIEZHZJULcyJebDLo99bGgS0jRrOxzZG4uCk2Yb2Y= -github.com/go-git/go-git/v5 v5.16.4/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8= +github.com/go-git/go-git/v5 v5.18.0 h1:O831KI+0PR51hM2kep6T8k+w0/LIAD490gvqMCvL5hM= +github.com/go-git/go-git/v5 v5.18.0/go.mod h1:pW/VmeqkanRFqR6AljLcs7EA7FbZaN5MQqO7oZADXpo= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= @@ -147,8 +147,8 @@ github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+ github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gorilla/mux v1.8.2-0.20240619235004-db9d1d0073d2 h1:oZRjfKe/6Qh676XFYvylkCWd0gu8KVZeZYZwkNw6NAU= github.com/gorilla/mux v1.8.2-0.20240619235004-db9d1d0073d2/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= github.com/hashicorp/golang-lru/arc/v2 v2.0.5 h1:l2zaLDubNhW4XO3LnliVj0GXO3+/CGNJAg1dcN2Fpfw= github.com/hashicorp/golang-lru/arc/v2 v2.0.5/go.mod h1:ny6zBSQZi2JxIeYcv7kt2sH2PXJtirBN7RDhRpxPkxU= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= @@ -172,8 +172,8 @@ github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4 github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= -github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= +github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= @@ -200,8 +200,8 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= -github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ= +github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= @@ -209,8 +209,8 @@ github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dz github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= github.com/moby/sys/mountinfo v0.7.2 h1:1shs6aH5s4o5H2zQLn796ADW1wMrIwHsyJ2v9KouLrg= github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4= -github.com/modelpack/modctl v0.1.2-alpha.0 h1:a9uC8zH/WjCOsfDu7f20t7Frm77kQ6kqZ8b69xFiyWg= -github.com/modelpack/modctl v0.1.2-alpha.0/go.mod h1:fleyB0h2217Lr8/GnI5R5VByKTK7YsKO+2qcT7NW0qA= +github.com/modelpack/modctl v0.2.1 h1:jYCe2LNb12IrqwVOCZH6cADV95xgOAxHTb28J+4TnJs= +github.com/modelpack/modctl v0.2.1/go.mod h1:s65spJNOH2Kk7ErTa7wMwx/fajJk4ZeYiIVUTnANY6o= github.com/modelpack/model-spec v0.0.7 h1:3fAxau4xUqF0Pf1zzFC5lItF0gEaiXLxaCcPAH8PW8I= github.com/modelpack/model-spec v0.0.7/go.mod h1:5Go37og1RmvcTdVI5Remd+PpQRNLlKSNwSNbXmEqu50= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -253,15 +253,15 @@ github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNw github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= github.com/prometheus/common v0.6.0/go.mod h1:eBmuwkDJBwy6iBfxCBob6t6dR6ENT/y+J+Zk0j9GMYc= -github.com/prometheus/common v0.67.2 h1:PcBAckGFTIHt2+L3I33uNRTlKTplNzFctXcWhPyAEN8= -github.com/prometheus/common v0.67.2/go.mod h1:63W3KZb1JOKgcjlIr64WW/LvFGAqKPj0atm+knVGEko= +github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= +github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos= github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM= github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= github.com/prometheus/procfs v0.0.3/go.mod h1:4A/X28fw3Fc593LaREMrKMqOKvUAntwMDaekg4FpcdQ= -github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= -github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= +github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= +github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= @@ -302,8 +302,8 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= -github.com/vbauerster/mpb/v8 v8.11.3 h1:iniBmO4ySXCl4gVdmJpgrtormH5uvjpxcx/dMyVU9Jw= -github.com/vbauerster/mpb/v8 v8.11.3/go.mod h1:n9M7WbP0NFjpgKS5XdEC3tMRgZTNM/xtC8zWGkiMuy0= +github.com/vbauerster/mpb/v8 v8.12.0 h1:+gneY3ifzc88tKDzOtfG8k8gfngCx615S2ZmFM4liWg= +github.com/vbauerster/mpb/v8 v8.12.0/go.mod h1:V02YIuMVo301Y1VE9VtZlD8s84OMsk+EKN6mwvf/588= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= @@ -318,52 +318,52 @@ github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo= github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/bridges/prometheus v0.63.0 h1:/Rij/t18Y7rUayNg7Id6rPrEnHgorxYabm2E6wUdPP4= -go.opentelemetry.io/contrib/bridges/prometheus v0.63.0/go.mod h1:AdyDPn6pkbkt2w01n3BubRVk7xAsCRq1Yg1mpfyA/0E= -go.opentelemetry.io/contrib/exporters/autoexport v0.63.0 h1:NLnZybb9KkfMXPwZhd5diBYJoVxiO9Qa06dacEA7ySY= -go.opentelemetry.io/contrib/exporters/autoexport v0.63.0/go.mod h1:OvRg7gm5WRSCtxzGSsrFHbDLToYlStHNZQ+iPNIyD6g= +go.opentelemetry.io/contrib/bridges/prometheus v0.67.0 h1:dkBzNEAIKADEaFnuESzcXvpd09vxvDZsOjx11gjUqLk= +go.opentelemetry.io/contrib/bridges/prometheus v0.67.0/go.mod h1:Z5RIwRkZgauOIfnG5IpidvLpERjhTninpP1dTG2jTl4= +go.opentelemetry.io/contrib/exporters/autoexport v0.67.0 h1:4fnRcNpc6YFtG3zsFw9achKn3XgmxPxuMuqIL5rE8e8= +go.opentelemetry.io/contrib/exporters/autoexport v0.67.0/go.mod h1:qTvIHMFKoxW7HXg02gm6/Wofhq5p3Ib/A/NNt1EoBSQ= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 h1:r6I7RJCN86bpD/FQwedZ0vSixDpwuWREjW9oRMsmqDc= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0/go.mod h1:B9yO6b04uB80CzjedvewuqDhxJxi11s7/GtiGa8bAjI= -go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= -go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= -go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.14.0 h1:OMqPldHt79PqWKOMYIAQs3CxAi7RLgPxwfFSwr4ZxtM= -go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.14.0/go.mod h1:1biG4qiqTxKiUCtoWDPpL3fB3KxVwCiGw81j3nKMuHE= -go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.14.0 h1:QQqYw3lkrzwVsoEX0w//EhH/TCnpRdEenKBOOEIMjWc= -go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.14.0/go.mod h1:gSVQcr17jk2ig4jqJ2DX30IdWH251JcNAecvrqTxH1s= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.38.0 h1:vl9obrcoWVKp/lwl8tRE33853I8Xru9HFbw/skNeLs8= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.38.0/go.mod h1:GAXRxmLJcVM3u22IjTg74zWBrRCKq8BnOqUVLodpcpw= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.38.0 h1:Oe2z/BCg5q7k4iXC3cqJxKYg0ieRiOqF0cecFYdPTwk= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.38.0/go.mod h1:ZQM5lAJpOsKnYagGg/zV2krVqTtaVdYdDkhMoX6Oalg= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0/go.mod h1:ri3aaHSmCTVYu2AWv44YMauwAQc0aqI9gHKIcSbI1pU= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0 h1:lwI4Dc5leUqENgGuQImwLo4WnuXFPetmPpkLi2IrX54= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0/go.mod h1:Kz/oCE7z5wuyhPxsXDuaPteSWqjSBD5YaSdbxZYGbGk= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 h1:aTL7F04bJHUlztTsNGJ2l+6he8c+y/b//eR0jjjemT4= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0/go.mod h1:kldtb7jDTeol0l3ewcmd8SDvx3EmIE7lyvqbasU3QC4= -go.opentelemetry.io/otel/exporters/prometheus v0.60.0 h1:cGtQxGvZbnrWdC2GyjZi0PDKVSLWP/Jocix3QWfXtbo= -go.opentelemetry.io/otel/exporters/prometheus v0.60.0/go.mod h1:hkd1EekxNo69PTV4OWFGZcKQiIqg0RfuWExcPKFvepk= -go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.14.0 h1:B/g+qde6Mkzxbry5ZZag0l7QrQBCtVm7lVjaLgmpje8= -go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.14.0/go.mod h1:mOJK8eMmgW6ocDJn6Bn11CcZ05gi3P8GylBXEkZtbgA= -go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.38.0 h1:wm/Q0GAAykXv83wzcKzGGqAnnfLFyFe7RslekZuv+VI= -go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.38.0/go.mod h1:ra3Pa40+oKjvYh+ZD3EdxFZZB0xdMfuileHAm4nNN7w= -go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.38.0 h1:kJxSDN4SgWWTjG/hPp3O7LCGLcHXFlvS2/FFOrwL+SE= -go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.38.0/go.mod h1:mgIOzS7iZeKJdeB8/NYHrJ48fdGc71Llo5bJ1J4DWUE= -go.opentelemetry.io/otel/log v0.14.0 h1:2rzJ+pOAZ8qmZ3DDHg73NEKzSZkhkGIua9gXtxNGgrM= -go.opentelemetry.io/otel/log v0.14.0/go.mod h1:5jRG92fEAgx0SU/vFPxmJvhIuDU9E1SUnEQrMlJpOno= -go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= -go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= -go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= -go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= -go.opentelemetry.io/otel/sdk/log v0.14.0 h1:JU/U3O7N6fsAXj0+CXz21Czg532dW2V4gG1HE/e8Zrg= -go.opentelemetry.io/otel/sdk/log v0.14.0/go.mod h1:imQvII+0ZylXfKU7/wtOND8Hn4OpT3YUoIgqJVksUkM= -go.opentelemetry.io/otel/sdk/log/logtest v0.14.0 h1:Ijbtz+JKXl8T2MngiwqBlPaHqc4YCaP/i13Qrow6gAM= -go.opentelemetry.io/otel/sdk/log/logtest v0.14.0/go.mod h1:dCU8aEL6q+L9cYTqcVOk8rM9Tp8WdnHOPLiBgp0SGOA= -go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= -go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= -go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= -go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= -go.opentelemetry.io/proto/otlp v1.8.0 h1:fRAZQDcAFHySxpJ1TwlA1cJ4tvcrw7nXl9xWWC8N5CE= -go.opentelemetry.io/proto/otlp v1.8.0/go.mod h1:tIeYOeNBU4cvmPqpaji1P+KbB4Oloai8wN4rWzRrFF0= +go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho= +go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.18.0 h1:deI9UQMoGFgrg5iLPgzueqFPHevDl+28YKfSpPTI6rY= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.18.0/go.mod h1:PFx9NgpNUKXdf7J4Q3agRxMs3Y07QhTCVipKmLsMKnU= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.18.0 h1:icqq3Z34UrEFk2u+HMhTtRsvo7Ues+eiJVjaJt62njs= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.18.0/go.mod h1:W2m8P+d5Wn5kipj4/xmbt9uMqezEKfBjzVJadfABSBE= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0 h1:MdKucPl/HbzckWWEisiNqMPhRrAOQX8r4jTuGr636gk= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0/go.mod h1:RolT8tWtfHcjajEH5wFIZ4Dgh5jpPdFXYV9pTAk/qjc= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.42.0 h1:H7O6RlGOMTizyl3R08Kn5pdM06bnH8oscSj7o11tmLA= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.42.0/go.mod h1:mBFWu/WOVDkWWsR7Tx7h6EpQB8wsv7P0Yrh0Pb7othc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 h1:THuZiwpQZuHPul65w4WcwEnkX2QIuMT+UFoOrygtoJw= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0/go.mod h1:J2pvYM5NGHofZ2/Ru6zw/TNWnEQp5crgyDeSrYpXkAw= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0 h1:zWWrB1U6nqhS/k6zYB74CjRpuiitRtLLi68VcgmOEto= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0/go.mod h1:2qXPNBX1OVRC0IwOnfo1ljoid+RD0QK3443EaqVlsOU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0 h1:uLXP+3mghfMf7XmV4PkGfFhFKuNWoCvvx5wP/wOXo0o= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0/go.mod h1:v0Tj04armyT59mnURNUJf7RCKcKzq+lgJs6QSjHjaTc= +go.opentelemetry.io/otel/exporters/prometheus v0.64.0 h1:g0LRDXMX/G1SEZtK8zl8Chm4K6GBwRkjPKE36LxiTYs= +go.opentelemetry.io/otel/exporters/prometheus v0.64.0/go.mod h1:UrgcjnarfdlBDP3GjDIJWe6HTprwSazNjwsI+Ru6hro= +go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.18.0 h1:KJVjPD3rcPb98rIs3HznyJlrfx9ge5oJvxxlGR+P/7s= +go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.18.0/go.mod h1:K3kRa2ckmHWQaTWQdPRHc7qGXASuVuoEQXzrvlA98Ws= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0 h1:lSZHgNHfbmQTPfuTmWVkEu8J8qXaQwuV30pjCcAUvP8= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0/go.mod h1:so9ounLcuoRDu033MW/E0AD4hhUjVqswrMF5FoZlBcw= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0 h1:s/1iRkCKDfhlh1JF26knRneorus8aOwVIDhvYx9WoDw= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0/go.mod h1:UI3wi0FXg1Pofb8ZBiBLhtMzgoTm1TYkMvn71fAqDzs= +go.opentelemetry.io/otel/log v0.18.0 h1:XgeQIIBjZZrliksMEbcwMZefoOSMI1hdjiLEiiB0bAg= +go.opentelemetry.io/otel/log v0.18.0/go.mod h1:KEV1kad0NofR3ycsiDH4Yjcoj0+8206I6Ox2QYFSNgI= +go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4= +go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI= +go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo= +go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts= +go.opentelemetry.io/otel/sdk/log v0.18.0 h1:n8OyZr7t7otkeTnPTbDNom6rW16TBYGtvyy2Gk6buQw= +go.opentelemetry.io/otel/sdk/log v0.18.0/go.mod h1:C0+wxkTwKpOCZLrlJ3pewPiiQwpzycPI/u6W0Z9fuYk= +go.opentelemetry.io/otel/sdk/log/logtest v0.18.0 h1:l3mYuPsuBx6UKE47BVcPrZoZ0q/KER57vbj2qkgDLXA= +go.opentelemetry.io/otel/sdk/log/logtest v0.18.0/go.mod h1:7cHtiVJpZebB3wybTa4NG+FUo5NPe3PROz1FqB0+qdw= +go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA= +go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc= +go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY= +go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc= +go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= +go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= @@ -374,8 +374,8 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= -golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= @@ -391,11 +391,11 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= -golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= -golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -419,42 +419,42 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= -golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= -golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= -golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg= -golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= -golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= -gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= -google.golang.org/genproto/googleapis/api v0.0.0-20251029180050-ab9386a59fda h1:+2XxjfsAu6vqFxwGBRcHiMaDCuZiqXGDUDVWVtrFAnE= -google.golang.org/genproto/googleapis/api v0.0.0-20251029180050-ab9386a59fda/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda h1:i/Q+bfisr7gq6feoJnS/DlpdwEL4ihp41fvRiM3Ork0= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 h1:tu/dtnW1o3wfaxCOjSLn5IRX4YDcJrtlpzYkhHhGaC4= +google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171/go.mod h1:M5krXqk4GhBKvB596udGL3UyjL4I1+cTbK0orROM9ng= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= -google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= -google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= +google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= +google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= diff --git a/pkg/config/config.go b/pkg/config/config.go index fad6758..6954a39 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -63,7 +63,8 @@ type PullConfig struct { DockerConfigDir string `yaml:"docker_config_dir"` ProxyURL string `yaml:"proxy_url"` DragonflyEndpoint string `yaml:"dragonfly_endpoint"` - Concurrency uint `yaml:"concurrency"` + Concurrency uint `yaml:"concurrency"` // per-pull layer download parallelism + NodeLayerConcurrency uint `yaml:"node_layer_concurrency"` // node-wide max concurrent layer pulls across all volumes PullLayerTimeoutInSeconds uint `yaml:"pull_layer_timeout_in_seconds"` } diff --git a/pkg/server/server_test.go b/pkg/server/server_test.go index e6eb103..03ce138 100644 --- a/pkg/server/server_test.go +++ b/pkg/server/server_test.go @@ -64,7 +64,7 @@ func (puller *mockPuller) Pull( }) fileName := fmt.Sprintf("model-%d.safetensor", i) err := os.WriteFile(filepath.Join(targetDir, fileName), []byte(fmt.Sprintf("test-%d", i)), 0644) - puller.hook.AfterPullLayer(layerDesc, err) + puller.hook.AfterPullLayer(layerDesc, false, err) return err }) } @@ -560,7 +560,7 @@ func TestServer(t *testing.T) { cfg.Get().PullConfig.ProxyURL = "" service.CacheScanInterval = 1 * time.Second - service.NewPuller = func(ctx context.Context, pullCfg *config.PullConfig, hook *status.Hook, diskQuotaChecker *service.DiskQuotaChecker) service.Puller { + service.NewPuller = func(ctx context.Context, pullCfg *config.PullConfig, hook *status.Hook, diskQuotaChecker *service.DiskQuotaChecker, layerCache *service.LayerCache) service.Puller { return &mockPuller{ pullCfg: pullCfg, duration: time.Second * 2, diff --git a/pkg/service/layercache.go b/pkg/service/layercache.go new file mode 100644 index 0000000..9e6e8c8 --- /dev/null +++ b/pkg/service/layercache.go @@ -0,0 +1,563 @@ +package service + +import ( + "context" + "encoding/json" + "maps" + "math" + "os" + "path/filepath" + "slices" + "sort" + "strings" + "sync" + "time" + + "github.com/modelpack/model-csi-driver/pkg/config" + "github.com/modelpack/model-csi-driver/pkg/logger" + "github.com/opencontainers/go-digest" + "github.com/pkg/errors" + "golang.org/x/sync/semaphore" +) + +// Action describes the result of LayerCache.Acquire. +type Action int + +const ( + // ActionPull means the caller should perform the actual pull and then + // notify the cache via Publish/Fail. + ActionPull Action = iota + // ActionHit means the cache hardlinked an existing copy into the target + // path; the caller can skip the download. + ActionHit +) + +// LayersFileName is the on-disk file used to persist the digest -> path +// mapping per volume / per dynamic mount, so the cache can be rebuilt after +// the daemonset restarts. +const LayersFileName = "layers.json" + +type layerState int + +const ( + stateIdle layerState = iota + statePulling + stateDone +) + +type layerEntry struct { + mu sync.Mutex + state layerState + paths []string + waiters []chan struct{} // per-waiter notification channels +} + +func newLayerEntry() *layerEntry { + return &layerEntry{state: stateIdle} +} + +// notifyWaiters wakes all waiters and clears the slice. Caller must hold e.mu. +func (e *layerEntry) notifyWaiters() { + for _, ch := range e.waiters { + select { + case ch <- struct{}{}: + default: + } + } + e.waiters = nil +} + +// LayerCache is a process-wide registry of OCI layer digests that have been +// materialized somewhere under the volumes root. It serves two purposes: +// +// 1. Layer-level singleflight: when multiple pullers race on the same +// digest, only the first one downloads while the others wait and then +// hardlink the result. +// 2. Cross-mount disk dedup: when a previously pulled layer is requested +// again from a different mount, the cache hardlinks the existing file +// into the new target instead of re-downloading. +type LayerCache struct { + cfg *config.Config + sem *semaphore.Weighted + + mu sync.Mutex + items map[digest.Digest]*layerEntry + + // volume dir -> digest -> target path. Used to remove paths from the + // in-memory index when a volume is deleted, and to drive the persisted + // layers.json. + perVolume map[string]map[digest.Digest]string + + dirtyMu sync.Mutex + dirtyVols map[string]struct{} + flushTimer *time.Timer +} + +// NewLayerCache constructs an empty LayerCache. +func NewLayerCache(cfg *config.Config) *LayerCache { + concurrency := cfg.Get().PullConfig.NodeLayerConcurrency + if concurrency == 0 { + concurrency = math.MaxInt64 + } + + return &LayerCache{ + cfg: cfg, + sem: semaphore.NewWeighted(int64(concurrency)), + items: make(map[digest.Digest]*layerEntry), + perVolume: make(map[string]map[digest.Digest]string), + } +} + +// getOrCreateEntry returns the entry for digest, creating it under the cache +// lock to avoid races between concurrent Acquire calls. +func (c *LayerCache) getOrCreateEntry(d digest.Digest) *layerEntry { + c.mu.Lock() + defer c.mu.Unlock() + e, ok := c.items[d] + if !ok { + e = newLayerEntry() + c.items[d] = e + } + return e +} + +// Acquire is the entry point used by the layerCacheHook in BeforePullLayer. +// +// It returns ActionHit when the target was successfully hardlinked from an +// existing copy, in which case the caller must NOT pull. It returns +// ActionPull to instruct the caller to perform the pull and then notify the +// cache via Publish/Fail. +func (c *LayerCache) Acquire(ctx context.Context, d digest.Digest, target string) (Action, error) { + if d == "" || target == "" { + return ActionPull, nil + } + e := c.getOrCreateEntry(d) + + e.mu.Lock() + for { + // Try to hardlink from any known path first. + if action := c.tryHardlinkLocked(e, target); action == ActionHit { + e.mu.Unlock() + return ActionHit, nil + } + + switch e.state { + case stateIdle, stateDone: + // Nothing usable on disk and no in-flight pull: become the + // owner. Acquire the global concurrency semaphore first. + e.mu.Unlock() + if err := c.sem.Acquire(ctx, 1); err != nil { + return ActionPull, err + } + e.mu.Lock() + // Re-check state after re-acquiring lock; another goroutine + // may have completed the pull while we waited on the semaphore. + if action := c.tryHardlinkLocked(e, target); action == ActionHit { + e.mu.Unlock() + c.sem.Release(1) + return ActionHit, nil + } + if e.state == statePulling { + // Someone else took ownership while we waited on the semaphore. + c.sem.Release(1) + // Fall through to the waiting path below. + goto wait + } + e.state = statePulling + e.mu.Unlock() + return ActionPull, nil + case statePulling: + goto wait + } + wait: + // Another puller is downloading this digest; wait for it. + ch := make(chan struct{}, 1) + e.waiters = append(e.waiters, ch) + e.mu.Unlock() + select { + case <-ch: + case <-ctx.Done(): + // Remove our channel from waiters. + e.mu.Lock() + for i, w := range e.waiters { + if w == ch { + e.waiters = append(e.waiters[:i], e.waiters[i+1:]...) + break + } + } + e.mu.Unlock() + return ActionPull, ctx.Err() + } + e.mu.Lock() + // Loop and re-evaluate. + } +} + +// tryHardlinkLocked attempts to hardlink one of the known paths for the +// entry into target. Stale paths are pruned from the entry. Caller must hold +// e.mu. +func (c *LayerCache) tryHardlinkLocked(e *layerEntry, target string) Action { + srcPaths := append([]string(nil), e.paths...) + kept := make([]string, 0, len(srcPaths)+1) + hit := false + for _, p := range srcPaths { + if p == target { + if _, err := os.Stat(p); err == nil { + kept = append(kept, p) + hit = true + } + continue + } + if hit { + if _, err := os.Stat(p); err == nil { + kept = append(kept, p) + } + continue + } + if _, err := os.Stat(p); err != nil { + // Stale; drop it. + continue + } + if err := hardlink(p, target); err != nil { + logger.Logger().WithError(err).Debugf("hardlink fallback for digest path: %s -> %s", p, target) + // Keep the source; just couldn't link to this target. + kept = append(kept, p) + continue + } + kept = append(kept, p, target) + hit = true + } + e.paths = uniqueStrings(kept) + if hit { + return ActionHit + } + return ActionPull +} + +// Publish records that target now contains the blob for digest d, persists +// the mapping for the owning volume, and wakes any waiters. +func (c *LayerCache) Publish(d digest.Digest, target string) { + if d == "" || target == "" { + return + } + e := c.getOrCreateEntry(d) + + e.mu.Lock() + if !slices.Contains(e.paths, target) { + e.paths = append(e.paths, target) + } + wasPulling := e.state == statePulling + e.state = stateDone + e.notifyWaiters() + e.mu.Unlock() + + // Release the semaphore only if this Publish corresponds to an actual + // pull (not a cache-hit re-registration). + if wasPulling { + c.sem.Release(1) + } + + c.recordVolumeMapping(d, target) +} + +// Fail is called by the owner when the underlying pull failed; waiters are +// woken so they can either retry hardlinking from another path or take over +// as the new owner. +func (c *LayerCache) Fail(d digest.Digest) { + if d == "" { + return + } + c.mu.Lock() + e, ok := c.items[d] + c.mu.Unlock() + if !ok { + return + } + + e.mu.Lock() + wasPulling := e.state == statePulling + if len(e.paths) == 0 { + e.state = stateIdle + } else { + e.state = stateDone + } + e.notifyWaiters() + e.mu.Unlock() + + if wasPulling { + c.sem.Release(1) + } +} + +// OnVolumeRemoved is called by the worker after it removed the volume +// directory from disk. All in-memory paths under that directory are +// dropped, and the persisted layers.json is removed. +func (c *LayerCache) OnVolumeRemoved(volumeDir string) { + if volumeDir == "" { + return + } + c.mu.Lock() + owned := c.perVolume[volumeDir] + delete(c.perVolume, volumeDir) + // Snapshot entries to mutate outside the cache lock. + items := make(map[digest.Digest]*layerEntry, len(owned)) + for d := range owned { + if e, ok := c.items[d]; ok { + items[d] = e + } + } + c.mu.Unlock() + + for d, p := range owned { + e := items[d] + if e == nil { + continue + } + e.mu.Lock() + out := e.paths[:0] + for _, existing := range e.paths { + if existing == p { + continue + } + out = append(out, existing) + } + e.paths = out + if len(e.paths) == 0 && e.state == stateDone { + e.state = stateIdle + } + e.mu.Unlock() + } +} + +// Rebuild scans the configured volumes directory for previously persisted +// layers.json files and reconstructs the in-memory index. Stale entries +// (file no longer exists on disk) are dropped. It is safe to call once at +// process start before serving any pulls. +func (c *LayerCache) Rebuild(ctx context.Context) error { + root := c.cfg.Get().GetVolumesDir() + entries, err := os.ReadDir(root) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return errors.Wrapf(err, "read volumes dir: %s", root) + } + for _, ent := range entries { + if !ent.IsDir() { + continue + } + volRoot := filepath.Join(root, ent.Name()) + c.loadLayersFile(ctx, volRoot) + // Also visit dynamic per-mount dirs, where each mountID has its own + // layers.json next to status.json. + modelsDir := filepath.Join(volRoot, "models") + if subEntries, err := os.ReadDir(modelsDir); err == nil { + for _, sub := range subEntries { + if !sub.IsDir() { + continue + } + c.loadLayersFile(ctx, filepath.Join(modelsDir, sub.Name())) + } + } + } + return nil +} + +func (c *LayerCache) loadLayersFile(ctx context.Context, dir string) { + path := filepath.Join(dir, LayersFileName) + data, err := os.ReadFile(path) + if err != nil { + return + } + + var f layersFile + if err := json.Unmarshal(data, &f); err != nil { + logger.WithContext(ctx).WithError(err).Warnf("invalid layers.json: %s", path) + return + } + + for _, item := range f.Items { + if item.Digest == "" || item.Path == "" { + continue + } + if _, err := os.Stat(item.Path); err != nil { + continue + } + e := c.getOrCreateEntry(item.Digest) + e.mu.Lock() + if !slices.Contains(e.paths, item.Path) { + e.paths = append(e.paths, item.Path) + } + e.state = stateDone + e.mu.Unlock() + c.indexVolumeMapping(dir, item.Digest, item.Path) + } +} + +// recordVolumeMapping derives the owning volume directory for a path and +// schedules persistence of the mapping to disk. +func (c *LayerCache) recordVolumeMapping(d digest.Digest, target string) { + volDir := c.volumeDirFor(target) + if volDir == "" { + return + } + c.indexVolumeMapping(volDir, d, target) + c.schedulePersist(volDir) +} + +func (c *LayerCache) schedulePersist(volDir string) { + c.dirtyMu.Lock() + defer c.dirtyMu.Unlock() + if c.dirtyVols == nil { + c.dirtyVols = make(map[string]struct{}) + } + c.dirtyVols[volDir] = struct{}{} + if c.flushTimer == nil { + c.flushTimer = time.AfterFunc(500*time.Millisecond, c.flushDirtyVolumes) + } else { + c.flushTimer.Reset(500 * time.Millisecond) + } +} + +func (c *LayerCache) flushDirtyVolumes() { + c.dirtyMu.Lock() + dirty := c.dirtyVols + c.dirtyVols = nil + c.flushTimer = nil + c.dirtyMu.Unlock() + + for volDir := range dirty { + c.mu.Lock() + owned := c.perVolume[volDir] + snapshot := make(map[digest.Digest]string, len(owned)) + maps.Copy(snapshot, owned) + c.mu.Unlock() + + if err := writeLayersFile(volDir, snapshot); err != nil { + logger.Logger().WithError(err).Warnf("persist layers.json for %s", volDir) + } + } +} + +// FlushPersist forces an immediate write of all pending layers.json files. +// Useful for tests and graceful shutdown. +func (c *LayerCache) FlushPersist() { + c.dirtyMu.Lock() + if c.flushTimer != nil { + c.flushTimer.Stop() + } + c.flushTimer = nil + dirty := c.dirtyVols + c.dirtyVols = nil + c.dirtyMu.Unlock() + + for volDir := range dirty { + c.mu.Lock() + owned := c.perVolume[volDir] + snapshot := make(map[digest.Digest]string, len(owned)) + maps.Copy(snapshot, owned) + c.mu.Unlock() + + if err := writeLayersFile(volDir, snapshot); err != nil { + logger.Logger().WithError(err).Warnf("persist layers.json for %s", volDir) + } + } +} + +func (c *LayerCache) indexVolumeMapping(volDir string, d digest.Digest, target string) { + c.mu.Lock() + defer c.mu.Unlock() + m, ok := c.perVolume[volDir] + if !ok { + m = make(map[digest.Digest]string) + c.perVolume[volDir] = m + } + m[d] = target +} + +// volumeDirFor returns the directory whose layers.json owns target. For a +// static volume that is `/`; for a dynamic volume that +// is `//models/`. An empty string is returned +// when target is not under the configured volumes root. +func (c *LayerCache) volumeDirFor(target string) string { + root := c.cfg.Get().GetVolumesDir() + rel, err := filepath.Rel(root, target) + if err != nil || strings.HasPrefix(rel, "..") { + return "" + } + parts := strings.Split(rel, string(filepath.Separator)) + if len(parts) == 0 { + return "" + } + volume := parts[0] + if len(parts) >= 3 && parts[1] == "models" { + return filepath.Join(root, volume, "models", parts[2]) + } + return filepath.Join(root, volume) +} + +// ---- helpers --------------------------------------------------------------- + +type layersFileItem struct { + Digest digest.Digest `json:"digest"` + Path string `json:"path"` +} + +type layersFile struct { + Schema int `json:"schema"` + Items []layersFileItem `json:"items"` +} + +func writeLayersFile(dir string, items map[digest.Digest]string) error { + if err := os.MkdirAll(dir, 0755); err != nil { + return errors.Wrap(err, "mkdir layers dir") + } + out := layersFile{Schema: 1} + for d, p := range items { + out.Items = append(out.Items, layersFileItem{Digest: d, Path: p}) + } + sort.Slice(out.Items, func(i, j int) bool { return out.Items[i].Digest < out.Items[j].Digest }) + data, err := json.MarshalIndent(out, "", " ") + if err != nil { + return errors.Wrap(err, "marshal layers.json") + } + tmp := filepath.Join(dir, LayersFileName+".tmp") + if err := os.WriteFile(tmp, data, 0644); err != nil { + return errors.Wrap(err, "write tmp layers.json") + } + if err := os.Rename(tmp, filepath.Join(dir, LayersFileName)); err != nil { + return errors.Wrap(err, "rename layers.json") + } + return nil +} + +// hardlink links src to dst, ensuring the target directory exists and that +// no stale file occupies the destination. +func hardlink(src, dst string) error { + if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil { + return errors.Wrap(err, "mkdir hardlink target") + } + if err := os.Remove(dst); err != nil && !os.IsNotExist(err) { + return errors.Wrap(err, "remove stale hardlink target") + } + if err := os.Link(src, dst); err != nil { + return errors.Wrap(err, "create hardlink") + } + return nil +} + +func uniqueStrings(in []string) []string { + if len(in) <= 1 { + return in + } + seen := make(map[string]struct{}, len(in)) + out := in[:0] + for _, v := range in { + if _, ok := seen[v]; ok { + continue + } + seen[v] = struct{}{} + out = append(out, v) + } + return out +} diff --git a/pkg/service/layercache_hook.go b/pkg/service/layercache_hook.go new file mode 100644 index 0000000..1f0d924 --- /dev/null +++ b/pkg/service/layercache_hook.go @@ -0,0 +1,159 @@ +package service + +import ( + "context" + "path/filepath" + "strings" + "sync" + + oldModelspec "github.com/dragonflyoss/model-spec/specs-go/v1" + modctlConfig "github.com/modelpack/modctl/pkg/config" + "github.com/modelpack/model-csi-driver/pkg/logger" + "github.com/modelpack/model-csi-driver/pkg/status" + modelspec "github.com/modelpack/model-spec/specs-go/v1" + "github.com/opencontainers/go-digest" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" +) + +// combinedHook composes the progress-tracking status.Hook with an optional +// layerCacheHook. The layerCacheHook controls whether the layer is skipped; +// status.Hook only observes events. +type combinedHook struct { + status *status.Hook + lc *layerCacheHook +} + +var _ modctlConfig.PullHooks = (*combinedHook)(nil) + +func (h *combinedHook) BeforePullLayer(desc ocispec.Descriptor, manifest ocispec.Manifest) bool { + if h.status != nil { + // Always record the layer in progress so that "skipped" events still + // surface in the status report. + h.status.BeforePullLayer(desc, manifest) + } + if h.lc == nil { + return false + } + return h.lc.BeforePullLayer(desc, manifest) +} + +func (h *combinedHook) AfterPullLayer(desc ocispec.Descriptor, skipped bool, err error) { + if h.lc != nil { + h.lc.AfterPullLayer(desc, skipped, err) + } + if h.status != nil { + h.status.AfterPullLayer(desc, skipped, err) + } +} + +// layerCacheHook bridges the modctl PullHooks interface to LayerCache. It +// is constructed per pull operation because the hardlink target depends on +// the per-pull output directory. +type layerCacheHook struct { + ctx context.Context + cache *LayerCache + targetDir string + + mu sync.Mutex + owned map[digest.Digest]string // digests this puller is responsible for publishing +} + +func newLayerCacheHook(ctx context.Context, cache *LayerCache, targetDir string) *layerCacheHook { + return &layerCacheHook{ + ctx: ctx, + cache: cache, + targetDir: targetDir, + owned: make(map[digest.Digest]string), + } +} + +func (h *layerCacheHook) BeforePullLayer(desc ocispec.Descriptor, _ ocispec.Manifest) bool { + target := h.targetForDesc(desc) + if target == "" { + // Layer has no filepath annotation (e.g. config blobs); cannot + // hardlink, so let modctl handle it normally. + return false + } + + action, err := h.cache.Acquire(h.ctx, desc.Digest, target) + if err != nil { + logger.WithContext(h.ctx).WithError(err).Debugf( + "layer cache acquire failed for %s, falling back to pull", desc.Digest, + ) + h.markOwned(desc.Digest, target) + return false + } + // Track the target regardless of hit/pull so AfterPullLayer can record + // it in this volume's layers.json. Without this, a volume that fully + // hits the cache would never write its own layers.json, which would + // (a) make Rebuild miss its paths after a restart and (b) cause cache + // state loss when the original owner volume is deleted. + h.markOwned(desc.Digest, target) + return action == ActionHit +} + +func (h *layerCacheHook) AfterPullLayer(desc ocispec.Descriptor, skipped bool, err error) { + target := h.takeOwned(desc.Digest) + if target == "" { + // We weren't tracking this layer (e.g. no filepath annotation). + return + } + if skipped { + // Cache hit: the file was hardlinked into target by Acquire. Register + // the path so this volume contributes to the index and persists its + // own layers.json reflecting what is actually on disk. + logger.WithContext(h.ctx).Debugf("layer %s served from cache (reused)", desc.Digest) + h.cache.Publish(desc.Digest, target) + return + } + if err != nil { + h.cache.Fail(desc.Digest) + return + } + logger.WithContext(h.ctx).Debugf("layer %s pulled from remote", desc.Digest) + h.cache.Publish(desc.Digest, target) +} + +// targetForDesc derives the absolute on-disk path that modctl will produce +// for desc. Returns empty string when no filepath annotation is available. +func (h *layerCacheHook) targetForDesc(desc ocispec.Descriptor) string { + if desc.Annotations == nil { + return "" + } + rel := desc.Annotations[modelspec.AnnotationFilepath] + if rel == "" { + rel = desc.Annotations[oldModelspec.AnnotationFilepath] + } + if rel == "" { + return "" + } + joined := filepath.Join(h.targetDir, rel) + // Resolve to absolute and verify it stays within targetDir to prevent path traversal. + absTarget, err := filepath.Abs(joined) + if err != nil { + return "" + } + absBase, err := filepath.Abs(h.targetDir) + if err != nil { + return "" + } + // Ensure the resolved path is under the target directory. + if !strings.HasPrefix(absTarget, absBase+string(filepath.Separator)) && absTarget != absBase { + return "" + } + return absTarget +} + +func (h *layerCacheHook) markOwned(d digest.Digest, target string) { + h.mu.Lock() + defer h.mu.Unlock() + h.owned[d] = target +} + +func (h *layerCacheHook) takeOwned(d digest.Digest) string { + h.mu.Lock() + defer h.mu.Unlock() + t := h.owned[d] + delete(h.owned, d) + return t +} diff --git a/pkg/service/layercache_hook_test.go b/pkg/service/layercache_hook_test.go new file mode 100644 index 0000000..0e0ab31 --- /dev/null +++ b/pkg/service/layercache_hook_test.go @@ -0,0 +1,203 @@ +package service + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/modelpack/model-csi-driver/pkg/status" + modelspec "github.com/modelpack/model-spec/specs-go/v1" + "github.com/opencontainers/go-digest" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/stretchr/testify/require" +) + +func makeDesc(t *testing.T, dgst digest.Digest, file string) ocispec.Descriptor { + t.Helper() + return ocispec.Descriptor{ + Digest: dgst, + MediaType: "application/octet-stream", + Size: int64(len(file)), + Annotations: map[string]string{ + modelspec.AnnotationFilepath: file, + }, + } +} + +func TestCombinedHook_NilLayerCache_NoSkip(t *testing.T) { + statusHook := status.NewHook(context.Background()) + h := &combinedHook{status: statusHook, lc: nil} + skip := h.BeforePullLayer(makeDesc(t, digest.FromString("a"), "a.bin"), ocispec.Manifest{}) + require.False(t, skip) + h.AfterPullLayer(makeDesc(t, digest.FromString("a"), "a.bin"), false, nil) +} + +func TestCombinedHook_NoFilepath_NoSkip(t *testing.T) { + c, _ := newTestCache(t) + statusHook := status.NewHook(context.Background()) + h := &combinedHook{status: statusHook, lc: newLayerCacheHook(context.Background(), c, "/tmp")} + + desc := ocispec.Descriptor{Digest: digest.FromString("nofp"), MediaType: "x"} + require.False(t, h.BeforePullLayer(desc, ocispec.Manifest{})) + h.AfterPullLayer(desc, false, nil) +} + +func TestCombinedHook_HitPath_PullerOwnsThenAnotherSkips(t *testing.T) { + c, tmp := newTestCache(t) + + dir1 := filepath.Join(tmp, "volumes/pvc-1/model") + dir2 := filepath.Join(tmp, "volumes/pvc-2/model") + desc := makeDesc(t, digest.FromString("hit"), "f.bin") + + // Puller A: own + write + publish. + hookA := &combinedHook{status: status.NewHook(context.Background()), lc: newLayerCacheHook(context.Background(), c, dir1)} + skipA := hookA.BeforePullLayer(desc, ocispec.Manifest{}) + require.False(t, skipA) + writeFile(t, filepath.Join(dir1, "f.bin"), []byte("data")) + hookA.AfterPullLayer(desc, false, nil) + + // Puller B: target in different dir; should skip and have file + // hardlinked already. + hookB := &combinedHook{status: status.NewHook(context.Background()), lc: newLayerCacheHook(context.Background(), c, dir2)} + skipB := hookB.BeforePullLayer(desc, ocispec.Manifest{}) + require.True(t, skipB) + _, err := os.Stat(filepath.Join(dir2, "f.bin")) + require.NoError(t, err) + hookB.AfterPullLayer(desc, true, nil) + + c.FlushPersist() + + // Both volumes must persist their own layers.json so dedup survives + // owner-volume deletion and daemonset restarts. + _, err = os.Stat(filepath.Join(tmp, "volumes/pvc-1", LayersFileName)) + require.NoError(t, err, "owner volume must have layers.json") + _, err = os.Stat(filepath.Join(tmp, "volumes/pvc-2", LayersFileName)) + require.NoError(t, err, "hit volume must also have layers.json") + + // The cache must know about both paths. + c.mu.Lock() + entry := c.items[desc.Digest] + c.mu.Unlock() + entry.mu.Lock() + defer entry.mu.Unlock() + require.ElementsMatch(t, + []string{filepath.Join(dir1, "f.bin"), filepath.Join(dir2, "f.bin")}, + entry.paths, + ) +} + +// Deleting the owner volume must not lose dedup state, because the hit +// volume registered itself in the cache. +func TestCombinedHook_DedupSurvivesOwnerDeletion(t *testing.T) { + c, tmp := newTestCache(t) + + dir1 := filepath.Join(tmp, "volumes/pvc-1/model") + dir2 := filepath.Join(tmp, "volumes/pvc-2/model") + dir3 := filepath.Join(tmp, "volumes/pvc-3/model") + desc := makeDesc(t, digest.FromString("survive"), "f.bin") + + hookA := &combinedHook{status: status.NewHook(context.Background()), lc: newLayerCacheHook(context.Background(), c, dir1)} + require.False(t, hookA.BeforePullLayer(desc, ocispec.Manifest{})) + writeFile(t, filepath.Join(dir1, "f.bin"), []byte("data")) + hookA.AfterPullLayer(desc, false, nil) + + hookB := &combinedHook{status: status.NewHook(context.Background()), lc: newLayerCacheHook(context.Background(), c, dir2)} + require.True(t, hookB.BeforePullLayer(desc, ocispec.Manifest{})) + hookB.AfterPullLayer(desc, true, nil) + + // Simulate owner-volume removal: clear A's path from the cache and + // delete A's file. B's hardlink (independent inode reference) and + // cache registration must keep dedup alive. + c.OnVolumeRemoved(filepath.Join(tmp, "volumes/pvc-1")) + require.NoError(t, os.Remove(filepath.Join(dir1, "f.bin"))) + + hookC := &combinedHook{status: status.NewHook(context.Background()), lc: newLayerCacheHook(context.Background(), c, dir3)} + skipC := hookC.BeforePullLayer(desc, ocispec.Manifest{}) + require.True(t, skipC, "C should still hit via B's path") + _, err := os.Stat(filepath.Join(dir3, "f.bin")) + require.NoError(t, err) + hookC.AfterPullLayer(desc, true, nil) +} + +func TestCombinedHook_OwnerFailureClearsOwnership(t *testing.T) { + c, tmp := newTestCache(t) + dir := filepath.Join(tmp, "volumes/pvc-x/model") + desc := makeDesc(t, digest.FromString("fail"), "f.bin") + + h := &combinedHook{status: status.NewHook(context.Background()), lc: newLayerCacheHook(context.Background(), c, dir)} + require.False(t, h.BeforePullLayer(desc, ocispec.Manifest{})) + h.AfterPullLayer(desc, false, os.ErrPermission) + + // Entry should have been Fail'd; another puller can now become owner. + hookB := &combinedHook{status: status.NewHook(context.Background()), lc: newLayerCacheHook(context.Background(), c, dir)} + require.False(t, hookB.BeforePullLayer(desc, ocispec.Manifest{})) +} + +func TestLayerCacheHook_TakeOwnedReturnsEmptyForUntracked(t *testing.T) { + c, _ := newTestCache(t) + h := newLayerCacheHook(context.Background(), c, "/tmp") + require.Equal(t, "", h.takeOwned(digest.FromString("untracked"))) +} + +// Covers BeforePullLayer's Acquire-error branch: when Acquire returns an +// error (e.g. ctx cancelled while waiting), the hook must still markOwned +// the target and return false so the caller falls back to pulling. +func TestCombinedHook_AcquireError_FallsBackAndOwns(t *testing.T) { + c, tmp := newTestCache(t) + dir := filepath.Join(tmp, "volumes/pvc-a/model") + desc := makeDesc(t, digest.FromString("acq-err"), "f.bin") + + // First hook becomes the pulling owner so the second one will wait. + hookA := &combinedHook{lc: newLayerCacheHook(context.Background(), c, dir)} + require.False(t, hookA.BeforePullLayer(desc, ocispec.Manifest{})) + + // Second hook uses an already-cancelled context: Acquire will return + // ctx.Err() immediately on entering the wait loop. + ctx, cancel := context.WithCancel(context.Background()) + cancel() + dir2 := filepath.Join(tmp, "volumes/pvc-b/model") + hookB := &combinedHook{lc: newLayerCacheHook(ctx, c, dir2)} + skip := hookB.BeforePullLayer(desc, ocispec.Manifest{}) + require.False(t, skip) + + // markOwned must have stored the target. + hookB.lc.mu.Lock() + got := hookB.lc.owned[desc.Digest] + hookB.lc.mu.Unlock() + require.Equal(t, filepath.Join(dir2, "f.bin"), got) +} + +// Covers targetForDesc when annotations are present but neither the +// modern nor the legacy filepath annotation has a value. +func TestLayerCacheHook_TargetForDesc_AnnotationsWithoutFilepath(t *testing.T) { + c, _ := newTestCache(t) + h := newLayerCacheHook(context.Background(), c, "/tmp") + desc := ocispec.Descriptor{ + Digest: digest.FromString("no-fp"), + Annotations: map[string]string{"other": "x"}, + } + require.Equal(t, "", h.targetForDesc(desc)) +} + +func TestLayerCacheHook_TargetForDesc_LegacyAnnotation(t *testing.T) { + c, _ := newTestCache(t) + h := newLayerCacheHook(context.Background(), c, "/tmp") + desc := ocispec.Descriptor{ + Digest: digest.FromString("legacy"), + Annotations: map[string]string{ + "org.cnai.model.filepath": "weights/x.bin", + }, + } + got := h.targetForDesc(desc) + require.Equal(t, "/tmp/weights/x.bin", got) +} + +func TestLayerCacheHook_TargetForDesc_RejectsPathTraversal(t *testing.T) { + c, tmp := newTestCache(t) + targetDir := filepath.Join(tmp, "volumes/pvc-a/model") + h := newLayerCacheHook(context.Background(), c, targetDir) + desc := makeDesc(t, digest.FromString("traversal"), "../escape.bin") + + require.Equal(t, "", h.targetForDesc(desc)) +} diff --git a/pkg/service/layercache_test.go b/pkg/service/layercache_test.go new file mode 100644 index 0000000..9c68706 --- /dev/null +++ b/pkg/service/layercache_test.go @@ -0,0 +1,854 @@ +package service + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/modelpack/model-csi-driver/pkg/config" + "github.com/opencontainers/go-digest" + "github.com/stretchr/testify/require" +) + +func newTestCache(t *testing.T) (*LayerCache, string) { + t.Helper() + tmp := t.TempDir() + rawCfg := &config.RawConfig{ServiceName: "test", RootDir: tmp} + cfg := config.NewWithRaw(rawCfg) + require.NoError(t, os.MkdirAll(cfg.Get().GetVolumesDir(), 0755)) + return NewLayerCache(cfg), tmp +} + +func writeFile(t *testing.T, p string, data []byte) { + t.Helper() + require.NoError(t, os.MkdirAll(filepath.Dir(p), 0755)) + require.NoError(t, os.WriteFile(p, data, 0644)) +} + +func TestLayerCache_Acquire_NoEntry_ReturnsPull(t *testing.T) { + c, tmp := newTestCache(t) + target := filepath.Join(tmp, "volumes/pvc-1/model/a.bin") + + action, err := c.Acquire(context.Background(), digest.FromString("a"), target) + require.NoError(t, err) + require.Equal(t, ActionPull, action) + + c.mu.Lock() + e := c.items[digest.FromString("a")] + c.mu.Unlock() + require.NotNil(t, e) + e.mu.Lock() + require.Equal(t, statePulling, e.state) + e.mu.Unlock() +} + +func TestLayerCache_Acquire_EmptyDigestOrTarget(t *testing.T) { + c, _ := newTestCache(t) + a, err := c.Acquire(context.Background(), "", "/x") + require.NoError(t, err) + require.Equal(t, ActionPull, a) + a, err = c.Acquire(context.Background(), digest.FromString("a"), "") + require.NoError(t, err) + require.Equal(t, ActionPull, a) +} + +func TestLayerCache_PublishAndHardlinkHit(t *testing.T) { + c, tmp := newTestCache(t) + d := digest.FromString("layer1") + + src := filepath.Join(tmp, "volumes/pvc-a/model/file.bin") + writeFile(t, src, []byte("hello")) + + // Owner publishes. + _, _ = c.Acquire(context.Background(), d, src) + c.Publish(d, src) + c.FlushPersist() + + // Persisted layers.json contains the path. + data, err := os.ReadFile(filepath.Join(tmp, "volumes/pvc-a/layers.json")) + require.NoError(t, err) + var f layersFile + require.NoError(t, json.Unmarshal(data, &f)) + require.Len(t, f.Items, 1) + require.Equal(t, src, f.Items[0].Path) + + // New target on same digest hits and gets hardlinked. + dst := filepath.Join(tmp, "volumes/pvc-b/model/file.bin") + action, err := c.Acquire(context.Background(), d, dst) + require.NoError(t, err) + require.Equal(t, ActionHit, action) + + srcStat, err := os.Stat(src) + require.NoError(t, err) + dstStat, err := os.Stat(dst) + require.NoError(t, err) + require.True(t, os.SameFile(srcStat, dstStat)) +} + +func TestLayerCache_AcquireSameTargetTwice_NoOp(t *testing.T) { + c, tmp := newTestCache(t) + d := digest.FromString("same") + target := filepath.Join(tmp, "volumes/pvc-a/model/x.bin") + + _, _ = c.Acquire(context.Background(), d, target) + writeFile(t, target, []byte("x")) + c.Publish(d, target) + + action, err := c.Acquire(context.Background(), d, target) + require.NoError(t, err) + require.Equal(t, ActionHit, action) +} + +func TestLayerCache_StalePathDropped(t *testing.T) { + c, tmp := newTestCache(t) + d := digest.FromString("stale") + gone := filepath.Join(tmp, "volumes/pvc-x/model/gone.bin") + writeFile(t, gone, []byte("g")) + + _, _ = c.Acquire(context.Background(), d, gone) + c.Publish(d, gone) + + require.NoError(t, os.Remove(gone)) + + target := filepath.Join(tmp, "volumes/pvc-y/model/x.bin") + action, err := c.Acquire(context.Background(), d, target) + require.NoError(t, err) + require.Equal(t, ActionPull, action) + + c.mu.Lock() + e := c.items[d] + c.mu.Unlock() + e.mu.Lock() + require.Empty(t, e.paths) + e.mu.Unlock() +} + +func TestLayerCache_Wait_ThenHit(t *testing.T) { + c, tmp := newTestCache(t) + d := digest.FromString("wait-hit") + src := filepath.Join(tmp, "volumes/pvc-a/model/w.bin") + + // A acquires as owner. + a, err := c.Acquire(context.Background(), d, src) + require.NoError(t, err) + require.Equal(t, ActionPull, a) + + // B starts and should block until A publishes. + type result struct { + action Action + err error + } + dst := filepath.Join(tmp, "volumes/pvc-b/model/w.bin") + resCh := make(chan result, 1) + go func() { + action, err := c.Acquire(context.Background(), d, dst) + resCh <- result{action, err} + }() + + time.Sleep(50 * time.Millisecond) + select { + case <-resCh: + t.Fatal("B should be waiting") + default: + } + + writeFile(t, src, []byte("w")) + c.Publish(d, src) + + select { + case r := <-resCh: + require.NoError(t, r.err) + require.Equal(t, ActionHit, r.action) + case <-time.After(2 * time.Second): + t.Fatal("B did not wake up") + } +} + +func TestLayerCache_Wait_ThenFail_Fallback(t *testing.T) { + c, _ := newTestCache(t) + d := digest.FromString("wait-fail") + + // A becomes owner. + a, err := c.Acquire(context.Background(), d, "/tmp/never/a.bin") + require.NoError(t, err) + require.Equal(t, ActionPull, a) + + resCh := make(chan Action, 1) + go func() { + action, _ := c.Acquire(context.Background(), d, "/tmp/never/b.bin") + resCh <- action + }() + + time.Sleep(50 * time.Millisecond) + c.Fail(d) + + select { + case action := <-resCh: + require.Equal(t, ActionPull, action) + case <-time.After(2 * time.Second): + t.Fatal("B did not wake up") + } + + // Entry should be in pulling state again, owned by B. + c.mu.Lock() + e := c.items[d] + c.mu.Unlock() + e.mu.Lock() + require.Equal(t, statePulling, e.state) + e.mu.Unlock() +} + +func TestLayerCache_Wait_CtxCancel(t *testing.T) { + c, _ := newTestCache(t) + d := digest.FromString("wait-ctx") + + _, err := c.Acquire(context.Background(), d, "/tmp/x/a.bin") + require.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + resCh := make(chan error, 1) + go func() { + _, err := c.Acquire(ctx, d, "/tmp/x/b.bin") + resCh <- err + }() + + time.Sleep(50 * time.Millisecond) + cancel() + + select { + case err := <-resCh: + require.ErrorIs(t, err, context.Canceled) + case <-time.After(2 * time.Second): + t.Fatal("B did not return after ctx cancel") + } +} + +func TestLayerCache_Acquire_CtxAlreadyDone(t *testing.T) { + c, _ := newTestCache(t) + d := digest.FromString("ctx-pre") + _, err := c.Acquire(context.Background(), d, "/tmp/y/a.bin") + require.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err = c.Acquire(ctx, d, "/tmp/y/b.bin") + require.ErrorIs(t, err, context.Canceled) +} + +func TestLayerCache_Acquire_ContextCanceledBeforeSemaphore(t *testing.T) { + c, tmp := newTestCache(t) + d := digest.FromString("ctx-sem") + target := filepath.Join(tmp, "volumes/pvc-a/model/x.bin") + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + action, err := c.Acquire(ctx, d, target) + require.ErrorIs(t, err, context.Canceled) + require.Equal(t, ActionPull, action) +} + +func TestLayerCache_Acquire_RechecksHardlinkAfterSemaphoreWait(t *testing.T) { + tmp := t.TempDir() + rawCfg := &config.RawConfig{ + ServiceName: "test", + RootDir: tmp, + PullConfig: config.PullConfig{NodeLayerConcurrency: 1}, + } + cfg := config.NewWithRaw(rawCfg) + require.NoError(t, os.MkdirAll(cfg.Get().GetVolumesDir(), 0755)) + c := NewLayerCache(cfg) + d := digest.FromString("sem-recheck-hit") + + require.NoError(t, c.sem.Acquire(context.Background(), 1)) + src := filepath.Join(tmp, "volumes/pvc-a/model/layer.bin") + dst := filepath.Join(tmp, "volumes/pvc-b/model/layer.bin") + + type result struct { + action Action + err error + } + resCh := make(chan result, 1) + go func() { + action, err := c.Acquire(context.Background(), d, dst) + resCh <- result{action: action, err: err} + }() + + // Give the goroutine time to block on the exhausted semaphore, then make a + // usable source path appear before the semaphore is released. + time.Sleep(50 * time.Millisecond) + writeFile(t, src, []byte("layer")) + e := c.getOrCreateEntry(d) + e.mu.Lock() + e.paths = []string{src} + e.state = stateDone + e.mu.Unlock() + + c.sem.Release(1) + + select { + case r := <-resCh: + require.NoError(t, r.err) + require.Equal(t, ActionHit, r.action) + case <-time.After(2 * time.Second): + t.Fatal("Acquire did not resume after semaphore release") + } +} + +func TestLayerCache_Acquire_WaitsWhenAnotherOwnerWinsSemaphoreRace(t *testing.T) { + tmp := t.TempDir() + rawCfg := &config.RawConfig{ + ServiceName: "test", + RootDir: tmp, + PullConfig: config.PullConfig{NodeLayerConcurrency: 1}, + } + cfg := config.NewWithRaw(rawCfg) + require.NoError(t, os.MkdirAll(cfg.Get().GetVolumesDir(), 0755)) + c := NewLayerCache(cfg) + d := digest.FromString("sem-recheck-pulling") + target := filepath.Join(tmp, "volumes/pvc-a/model/layer.bin") + + require.NoError(t, c.sem.Acquire(context.Background(), 1)) + type result struct { + action Action + err error + } + resCh := make(chan result, 1) + go func() { + action, err := c.Acquire(context.Background(), d, target) + resCh <- result{action: action, err: err} + }() + + // While the goroutine is waiting for semaphore capacity, simulate another + // puller becoming the owner for the same digest. + time.Sleep(50 * time.Millisecond) + e := c.getOrCreateEntry(d) + e.mu.Lock() + e.state = statePulling + e.mu.Unlock() + c.sem.Release(1) + + require.Eventually(t, func() bool { + e.mu.Lock() + defer e.mu.Unlock() + return len(e.waiters) == 1 + }, 2*time.Second, 10*time.Millisecond) + + // Wake the waiter without releasing the semaphore; the waiter should loop, + // take ownership itself, and return ActionPull. + e.mu.Lock() + e.state = stateIdle + e.notifyWaiters() + e.mu.Unlock() + + select { + case r := <-resCh: + require.NoError(t, r.err) + require.Equal(t, ActionPull, r.action) + case <-time.After(2 * time.Second): + t.Fatal("Acquire did not resume after waiter notification") + } + c.Fail(d) +} + +func TestLayerCache_OnVolumeRemoved(t *testing.T) { + c, tmp := newTestCache(t) + d := digest.FromString("rm") + volA := filepath.Join(tmp, "volumes/pvc-a") + srcA := filepath.Join(volA, "model/r.bin") + writeFile(t, srcA, []byte("r")) + _, _ = c.Acquire(context.Background(), d, srcA) + c.Publish(d, srcA) + + dstB := filepath.Join(tmp, "volumes/pvc-b/model/r.bin") + _, err := c.Acquire(context.Background(), d, dstB) + require.NoError(t, err) + + // Remove A. + c.OnVolumeRemoved(volA) + c.mu.Lock() + e := c.items[d] + owned := c.perVolume[volA] + c.mu.Unlock() + require.Nil(t, owned) + e.mu.Lock() + for _, p := range e.paths { + require.NotEqual(t, srcA, p) + } + e.mu.Unlock() +} + +func TestLayerCache_OnVolumeRemoved_EmptyArgs(t *testing.T) { + c, _ := newTestCache(t) + c.OnVolumeRemoved("") // must not panic +} + +func TestLayerCache_OnVolumeRemoved_LastPathResetsDoneEntry(t *testing.T) { + c, tmp := newTestCache(t) + d := digest.FromString("rm-last") + volA := filepath.Join(tmp, "volumes/pvc-last") + src := filepath.Join(volA, "model/layer.bin") + writeFile(t, src, []byte("layer")) + + _, err := c.Acquire(context.Background(), d, src) + require.NoError(t, err) + c.Publish(d, src) + + c.OnVolumeRemoved(volA) + + c.mu.Lock() + e := c.items[d] + _, stillOwned := c.perVolume[volA] + c.mu.Unlock() + require.False(t, stillOwned) + require.NotNil(t, e) + e.mu.Lock() + defer e.mu.Unlock() + require.Empty(t, e.paths) + require.Equal(t, stateIdle, e.state) +} + +func TestLayerCache_Rebuild_FromDisk(t *testing.T) { + c, tmp := newTestCache(t) + dir := filepath.Join(tmp, "volumes/pvc-r") + good := filepath.Join(dir, "model/good.bin") + gone := filepath.Join(dir, "model/gone.bin") + writeFile(t, good, []byte("g")) + + items := map[digest.Digest]string{ + digest.FromString("good"): good, + digest.FromString("gone"): gone, + } + require.NoError(t, writeLayersFile(dir, items)) + + require.NoError(t, c.Rebuild(context.Background())) + + c.mu.Lock() + defer c.mu.Unlock() + require.NotNil(t, c.items[digest.FromString("good")]) + require.Nil(t, c.items[digest.FromString("gone")]) +} + +func TestLayerCache_Rebuild_DynamicMounts(t *testing.T) { + c, tmp := newTestCache(t) + dyn := filepath.Join(tmp, "volumes/csi-1/models/m1") + good := filepath.Join(dyn, "model/d.bin") + writeFile(t, good, []byte("d")) + require.NoError(t, writeLayersFile(dyn, map[digest.Digest]string{ + digest.FromString("d"): good, + })) + + require.NoError(t, c.Rebuild(context.Background())) + c.mu.Lock() + defer c.mu.Unlock() + require.NotNil(t, c.items[digest.FromString("d")]) + require.NotNil(t, c.perVolume[dyn]) +} + +func TestLayerCache_Rebuild_MissingDir(t *testing.T) { + tmp := t.TempDir() + rawCfg := &config.RawConfig{ServiceName: "test", RootDir: filepath.Join(tmp, "absent")} + cfg := config.NewWithRaw(rawCfg) + c := NewLayerCache(cfg) + require.NoError(t, c.Rebuild(context.Background())) +} + +func TestLayerCache_Rebuild_MalformedFileIgnored(t *testing.T) { + c, tmp := newTestCache(t) + dir := filepath.Join(tmp, "volumes/pvc-bad") + writeFile(t, filepath.Join(dir, LayersFileName), []byte("not-json")) + require.NoError(t, c.Rebuild(context.Background())) + c.mu.Lock() + require.Empty(t, c.items) + c.mu.Unlock() +} + +func TestLayerCache_Rebuild_SkipsEmptyDigestOrPathItems(t *testing.T) { + c, tmp := newTestCache(t) + dir := filepath.Join(tmp, "volumes/pvc-invalid-items") + goodDigest := digest.FromString("good-item") + goodPath := filepath.Join(dir, "model/good.bin") + writeFile(t, goodPath, []byte("good")) + + f := layersFile{Schema: 1, Items: []layersFileItem{ + {Digest: "", Path: filepath.Join(dir, "model/empty-digest.bin")}, + {Digest: digest.FromString("empty-path"), Path: ""}, + {Digest: goodDigest, Path: goodPath}, + }} + data, err := json.Marshal(f) + require.NoError(t, err) + writeFile(t, filepath.Join(dir, LayersFileName), data) + + require.NoError(t, c.Rebuild(context.Background())) + + c.mu.Lock() + defer c.mu.Unlock() + require.Len(t, c.items, 1) + require.NotNil(t, c.items[goodDigest]) + require.Nil(t, c.items[digest.FromString("empty-path")]) +} + +func TestLayerCache_VolumeDirFor(t *testing.T) { + c, tmp := newTestCache(t) + root := filepath.Join(tmp, "volumes") + + require.Equal(t, filepath.Join(root, "pvc-1"), c.volumeDirFor(filepath.Join(root, "pvc-1/model/a.bin"))) + require.Equal(t, filepath.Join(root, "csi-1/models/m1"), c.volumeDirFor(filepath.Join(root, "csi-1/models/m1/model/a.bin"))) + require.Equal(t, "", c.volumeDirFor("/elsewhere/x")) +} + +func TestLayerCache_Concurrent_OneOwnerRestHit(t *testing.T) { + c, tmp := newTestCache(t) + d := digest.FromString("conc") + + const N = 8 + var owners atomic.Int32 + var hits atomic.Int32 + + wg := sync.WaitGroup{} + wg.Add(N) + for i := 0; i < N; i++ { + i := i + go func() { + defer wg.Done() + target := filepath.Join(tmp, "volumes/pvc-c", "model", "f.bin") + if i > 0 { + target = filepath.Join(tmp, "volumes/pvc-d-"+string(rune('a'+i)), "model/f.bin") + } + action, err := c.Acquire(context.Background(), d, target) + require.NoError(t, err) + if action == ActionPull { + owners.Add(1) + writeFile(t, target, []byte("f")) + c.Publish(d, target) + } else { + hits.Add(1) + } + }() + } + wg.Wait() + + require.EqualValues(t, 1, owners.Load(), "exactly one owner expected") + require.EqualValues(t, N-1, hits.Load(), "rest must hit") +} + +func TestWriteLayersFile_TmpRenameVisible(t *testing.T) { + tmp := t.TempDir() + dir := filepath.Join(tmp, "vol") + items := map[digest.Digest]string{digest.FromString("z"): "/path/z"} + require.NoError(t, writeLayersFile(dir, items)) + _, err := os.Stat(filepath.Join(dir, LayersFileName)) + require.NoError(t, err) + _, err = os.Stat(filepath.Join(dir, LayersFileName+".tmp")) + require.True(t, os.IsNotExist(err)) +} + +func TestHardlink_OverwritesExistingTarget(t *testing.T) { + tmp := t.TempDir() + src := filepath.Join(tmp, "src") + dst := filepath.Join(tmp, "sub", "dst") + require.NoError(t, os.WriteFile(src, []byte("s"), 0644)) + require.NoError(t, os.MkdirAll(filepath.Dir(dst), 0755)) + require.NoError(t, os.WriteFile(dst, []byte("old"), 0644)) + + require.NoError(t, hardlink(src, dst)) + got, err := os.ReadFile(dst) + require.NoError(t, err) + require.Equal(t, "s", string(got)) +} + +func TestHardlink_MissingSourceFails(t *testing.T) { + tmp := t.TempDir() + err := hardlink(filepath.Join(tmp, "missing"), filepath.Join(tmp, "x")) + require.Error(t, err) +} + +func TestUniqueStrings(t *testing.T) { + require.Equal(t, []string{"a"}, uniqueStrings([]string{"a"})) + require.Equal(t, []string{"a", "b"}, uniqueStrings([]string{"a", "b", "a", "b"})) +} + +func TestLayerCache_Fail_UnknownDigest(t *testing.T) { + c, _ := newTestCache(t) + c.Fail(digest.FromString("nope")) // must not panic + c.Fail("") // must not panic +} + +func TestLayerCache_Publish_EmptyArgs(t *testing.T) { + c, _ := newTestCache(t) + c.Publish("", "/x") // must not panic + c.Publish(digest.FromString("a"), "") +} + +func TestLayerCache_Fail_PreservesPathsWhenSomeRemain(t *testing.T) { + c, tmp := newTestCache(t) + d := digest.FromString("mix") + src := filepath.Join(tmp, "volumes/pvc-a/model/m.bin") + writeFile(t, src, []byte("m")) + _, _ = c.Acquire(context.Background(), d, src) + c.Publish(d, src) + + // Drive a manual transition into stateIdle by removing src and + // having a new acquire become the owner that subsequently fails. + require.NoError(t, os.Remove(src)) + _, _ = c.Acquire(context.Background(), d, filepath.Join(tmp, "volumes/pvc-b/model/m.bin")) + c.Fail(d) + + c.mu.Lock() + e := c.items[d] + c.mu.Unlock() + e.mu.Lock() + require.Equal(t, stateIdle, e.state) + e.mu.Unlock() +} + +// Ensure error returned by writeLayersFile when dir cannot be created +// surfaces (e.g. when path collides with file). +func TestWriteLayersFile_MkdirError(t *testing.T) { + tmp := t.TempDir() + clash := filepath.Join(tmp, "clash") + require.NoError(t, os.WriteFile(clash, []byte("x"), 0644)) + err := writeLayersFile(clash, nil) + require.Error(t, err) +} + +// Covers tryHardlinkLocked fallback when hardlink fails: source path is +// preserved, no kept target, action stays ActionPull. +func TestLayerCache_Acquire_HardlinkFails_PreservesSource(t *testing.T) { + c, tmp := newTestCache(t) + d := digest.FromString("hl-fail") + + src := filepath.Join(tmp, "volumes/pvc-a/model/src.bin") + writeFile(t, src, []byte("x")) + + // Pre-seed the entry with the source path so tryHardlinkLocked has + // something to attempt linking from. + e := c.getOrCreateEntry(d) + e.mu.Lock() + e.paths = []string{src} + e.state = stateDone + e.mu.Unlock() + + // Make the parent path of the destination a regular file so MkdirAll + // inside hardlink() fails. + parentAsFile := filepath.Join(tmp, "parent-file") + require.NoError(t, os.WriteFile(parentAsFile, []byte("f"), 0644)) + target := filepath.Join(parentAsFile, "dst.bin") + + action, err := c.Acquire(context.Background(), d, target) + require.NoError(t, err) + require.Equal(t, ActionPull, action) + + // Source path must be preserved on fallback. + e.mu.Lock() + require.Contains(t, e.paths, src) + require.NotContains(t, e.paths, target) + e.mu.Unlock() +} + +// Covers Fail() when the entry has remaining paths (state stays stateDone). +func TestLayerCache_Fail_KeepsStateDoneWhenPathsRemain(t *testing.T) { + c, tmp := newTestCache(t) + d := digest.FromString("fail-keep") + src := filepath.Join(tmp, "volumes/pvc-a/model/k.bin") + writeFile(t, src, []byte("k")) + + _, _ = c.Acquire(context.Background(), d, src) + c.Publish(d, src) + + c.Fail(d) + + c.mu.Lock() + e := c.items[d] + c.mu.Unlock() + e.mu.Lock() + require.Equal(t, stateDone, e.state) + require.Contains(t, e.paths, src) + e.mu.Unlock() +} + +// Covers OnVolumeRemoved when c.items[d] is nil (digest is in perVolume but +// not in items). Must not panic. +func TestLayerCache_OnVolumeRemoved_GhostDigest(t *testing.T) { + c, tmp := newTestCache(t) + volDir := filepath.Join(tmp, "volumes/pvc-ghost") + + c.mu.Lock() + c.perVolume[volDir] = map[digest.Digest]string{ + digest.FromString("ghost"): "/x", + } + c.mu.Unlock() + + c.OnVolumeRemoved(volDir) + + c.mu.Lock() + _, exists := c.perVolume[volDir] + c.mu.Unlock() + require.False(t, exists) +} + +// Covers Rebuild() returning an error when the volumes dir cannot be read. +func TestLayerCache_Rebuild_ReadDirError(t *testing.T) { + tmp := t.TempDir() + // Make the volumes dir actually be a regular file so ReadDir returns + // a non-IsNotExist error. + require.NoError(t, os.MkdirAll(tmp, 0755)) + volumesPath := filepath.Join(tmp, "volumes") + require.NoError(t, os.WriteFile(volumesPath, []byte("f"), 0644)) + + rawCfg := &config.RawConfig{ServiceName: "test", RootDir: tmp} + cfg := config.NewWithRaw(rawCfg) + c := NewLayerCache(cfg) + + err := c.Rebuild(context.Background()) + require.Error(t, err) +} + +// Covers Rebuild() skipping a non-directory entry at the volumes root. +func TestLayerCache_Rebuild_SkipsNonDirEntries(t *testing.T) { + c, tmp := newTestCache(t) + root := filepath.Join(tmp, "volumes") + require.NoError(t, os.WriteFile(filepath.Join(root, "stray.txt"), []byte("x"), 0644)) + + require.NoError(t, c.Rebuild(context.Background())) +} + +// Covers Rebuild() skipping a non-directory entry inside the dynamic +// `models/` subtree. +func TestLayerCache_Rebuild_SkipsNonDirInsideModels(t *testing.T) { + c, tmp := newTestCache(t) + modelsDir := filepath.Join(tmp, "volumes/csi-1/models") + require.NoError(t, os.MkdirAll(modelsDir, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(modelsDir, "stray"), []byte("x"), 0644)) + + require.NoError(t, c.Rebuild(context.Background())) +} + +// Covers recordVolumeMapping when volumeDirFor returns "" (target outside +// the volumes root): nothing is persisted. +func TestLayerCache_Publish_OutsideVolumesRoot_NoLayersFile(t *testing.T) { + c, _ := newTestCache(t) + extTmp := t.TempDir() + target := filepath.Join(extTmp, "elsewhere", "x.bin") + writeFile(t, target, []byte("x")) + + d := digest.FromString("outside") + c.Publish(d, target) + + // No layers.json next to the target. + _, err := os.Stat(filepath.Join(filepath.Dir(target), LayersFileName)) + require.True(t, os.IsNotExist(err)) + + // Cache still tracks the entry, but it isn't in perVolume. + c.mu.Lock() + require.NotNil(t, c.items[d]) + require.Empty(t, c.perVolume) + c.mu.Unlock() +} + +// Covers volumeDirFor when target equals the volumes root: rel == ".". +func TestLayerCache_VolumeDirFor_RootItself(t *testing.T) { + c, tmp := newTestCache(t) + root := filepath.Join(tmp, "volumes") + // rel is ".", parts=["."], not prefixed with "..", and len>0 so it + // returns filepath.Join(root, ".") == root. + require.Equal(t, root, c.volumeDirFor(root)) +} + +// Covers hardlink() returning an error when the destination's parent path +// already exists as a regular file (MkdirAll fails). +func TestHardlink_TargetParentIsFile(t *testing.T) { + tmp := t.TempDir() + src := filepath.Join(tmp, "src") + require.NoError(t, os.WriteFile(src, []byte("s"), 0644)) + + parent := filepath.Join(tmp, "parent") + require.NoError(t, os.WriteFile(parent, []byte("f"), 0644)) + dst := filepath.Join(parent, "dst") + + err := hardlink(src, dst) + require.Error(t, err) +} + +func TestLayerEntry_NotifyWaiters_DropsNotificationWhenChannelFull(t *testing.T) { + e := newLayerEntry() + full := make(chan struct{}, 1) + full <- struct{}{} + ready := make(chan struct{}, 1) + e.waiters = []chan struct{}{full, ready} + + e.notifyWaiters() + + require.Empty(t, e.waiters) + require.Len(t, full, 1) + select { + case <-ready: + default: + t.Fatal("expected ready waiter to be notified") + } +} + +func TestLayerCache_FlushDirtyVolumes_WriteError(t *testing.T) { + c, tmp := newTestCache(t) + clash := filepath.Join(tmp, "vol-file") + require.NoError(t, os.WriteFile(clash, []byte("not a directory"), 0644)) + + c.indexVolumeMapping(clash, digest.FromString("flush-error"), filepath.Join(clash, "layer.bin")) + c.dirtyMu.Lock() + c.dirtyVols = map[string]struct{}{clash: {}} + c.dirtyMu.Unlock() + + c.flushDirtyVolumes() +} + +func TestLayerCache_FlushPersist_WriteError(t *testing.T) { + c, tmp := newTestCache(t) + clash := filepath.Join(tmp, "persist-file") + require.NoError(t, os.WriteFile(clash, []byte("not a directory"), 0644)) + + c.indexVolumeMapping(clash, digest.FromString("persist-error"), filepath.Join(clash, "layer.bin")) + c.dirtyMu.Lock() + c.dirtyVols = map[string]struct{}{clash: {}} + c.dirtyMu.Unlock() + + c.FlushPersist() +} + +func TestWriteLayersFile_WriteTmpError(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root can write to read-only directories") + } + tmp := t.TempDir() + dir := filepath.Join(tmp, "readonly") + require.NoError(t, os.MkdirAll(dir, 0755)) + require.NoError(t, os.Chmod(dir, 0555)) + defer func() { require.NoError(t, os.Chmod(dir, 0755)) }() + + err := writeLayersFile(dir, map[digest.Digest]string{digest.FromString("tmp-error"): "/path/layer.bin"}) + require.Error(t, err) +} + +func TestWriteLayersFile_RenameError(t *testing.T) { + tmp := t.TempDir() + dir := filepath.Join(tmp, "rename-error") + require.NoError(t, os.MkdirAll(filepath.Join(dir, LayersFileName), 0755)) + + err := writeLayersFile(dir, map[digest.Digest]string{digest.FromString("rename-error"): "/path/layer.bin"}) + require.Error(t, err) +} + +func TestHardlink_RemoveStaleTargetError(t *testing.T) { + tmp := t.TempDir() + src := filepath.Join(tmp, "src") + require.NoError(t, os.WriteFile(src, []byte("s"), 0644)) + dst := filepath.Join(tmp, "dst") + require.NoError(t, os.MkdirAll(dst, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(dst, "child"), []byte("x"), 0644)) + + err := hardlink(src, dst) + require.Error(t, err) +} diff --git a/pkg/service/puller.go b/pkg/service/puller.go index 3e23f30..b7c26d1 100644 --- a/pkg/service/puller.go +++ b/pkg/service/puller.go @@ -17,19 +17,20 @@ import ( ) type PullHook interface { - BeforePullLayer(desc ocispec.Descriptor, manifest ocispec.Manifest) - AfterPullLayer(desc ocispec.Descriptor, err error) + BeforePullLayer(desc ocispec.Descriptor, manifest ocispec.Manifest) bool + AfterPullLayer(desc ocispec.Descriptor, skipped bool, err error) } type Puller interface { Pull(ctx context.Context, reference, targetDir string, excludeModelWeights bool, excludeFilePatterns []string) error } -var NewPuller = func(ctx context.Context, pullCfg *config.PullConfig, hook *status.Hook, diskQuotaChecker *DiskQuotaChecker) Puller { +var NewPuller = func(ctx context.Context, pullCfg *config.PullConfig, hook *status.Hook, diskQuotaChecker *DiskQuotaChecker, layerCache *LayerCache) Puller { return &puller{ pullCfg: pullCfg, hook: hook, diskQuotaChecker: diskQuotaChecker, + layerCache: layerCache, } } @@ -37,6 +38,17 @@ type puller struct { pullCfg *config.PullConfig hook *status.Hook diskQuotaChecker *DiskQuotaChecker + layerCache *LayerCache +} + +// combinedHook returns a PullHooks that drives both the status.Hook (progress +// reporting) and the LayerCache (layer-level dedup via hardlinks). +func (p *puller) combinedHook(ctx context.Context, targetDir string) modctlConfig.PullHooks { + var lcHook *layerCacheHook + if p.layerCache != nil { + lcHook = newLayerCacheHook(ctx, p.layerCache, targetDir) + } + return &combinedHook{status: p.hook, lc: lcHook} } func (p *puller) Pull(ctx context.Context, reference, targetDir string, excludeModelWeights bool, excludeFilePatterns []string) error { @@ -72,7 +84,7 @@ func (p *puller) Pull(ctx context.Context, reference, targetDir string, excludeM pullConfig.Insecure = true pullConfig.ExtractDir = targetDir pullConfig.ExtractFromRemote = true - pullConfig.Hooks = p.hook + pullConfig.Hooks = p.combinedHook(ctx, targetDir) pullConfig.ProgressWriter = io.Discard pullConfig.DisableProgress = true @@ -107,7 +119,7 @@ func (p *puller) Pull(ctx context.Context, reference, targetDir string, excludeM fetchConfig.DragonflyEndpoint = p.pullCfg.DragonflyEndpoint fetchConfig.Insecure = true fetchConfig.Output = targetDir - fetchConfig.Hooks = p.hook + fetchConfig.Hooks = p.combinedHook(ctx, targetDir) fetchConfig.ProgressWriter = io.Discard fetchConfig.DisableProgress = true fetchConfig.Patterns = patterns diff --git a/pkg/service/puller_test.go b/pkg/service/puller_test.go index 753b16f..4e38a20 100644 --- a/pkg/service/puller_test.go +++ b/pkg/service/puller_test.go @@ -9,8 +9,11 @@ import ( "github.com/agiledragon/gomonkey/v2" modctlBackend "github.com/modelpack/modctl/pkg/backend" + modctlConfig "github.com/modelpack/modctl/pkg/config" "github.com/modelpack/model-csi-driver/pkg/config" "github.com/modelpack/model-csi-driver/pkg/config/auth" + "github.com/modelpack/model-csi-driver/pkg/status" + "github.com/pkg/errors" "github.com/stretchr/testify/require" ) @@ -38,3 +41,138 @@ func TestPullerPull_NoPatternsReturnsEarly(t *testing.T) { require.NoError(t, statErr) require.True(t, stat.IsDir()) } + +func TestPullerCombinedHook_NoLayerCache(t *testing.T) { + p := &puller{} + h := p.combinedHook(context.Background(), "/tmp") + require.NotNil(t, h) + ch, ok := h.(*combinedHook) + require.True(t, ok) + require.Nil(t, ch.lc) +} + +func TestPullerCombinedHook_WithLayerCache(t *testing.T) { + lc, _ := newTestCache(t) + p := &puller{layerCache: lc} + h := p.combinedHook(context.Background(), t.TempDir()) + require.NotNil(t, h) + ch, ok := h.(*combinedHook) + require.True(t, ok) + require.NotNil(t, ch.lc) +} + +func TestPullerPull_FullPull_InvokesBackendPull(t *testing.T) { + tmpDir := t.TempDir() + b, err := modctlBackend.New(filepath.Join(tmpDir, "modctl")) + require.NoError(t, err) + + patches := gomonkey.NewPatches() + defer patches.Reset() + + patches.ApplyFunc(auth.GetKeyChainByRef, func(string) (*auth.PassKeyChain, error) { + return &auth.PassKeyChain{ServerScheme: "https"}, nil + }) + patches.ApplyFunc(modctlBackend.New, func(string) (modctlBackend.Backend, error) { + return b, nil + }) + called := false + patches.ApplyMethod(b, "Pull", func(modctlBackend.Backend, context.Context, string, *modctlConfig.Pull) error { + called = true + return nil + }) + + targetDir := filepath.Join(tmpDir, "model") + p := &puller{pullCfg: &config.PullConfig{Concurrency: 1}} + + err = p.Pull(context.Background(), "example.com/ns/model:latest", targetDir, false, nil) + require.NoError(t, err) + require.True(t, called) +} + +func TestPullerPull_FullPull_BackendPullError(t *testing.T) { + tmpDir := t.TempDir() + b, err := modctlBackend.New(filepath.Join(tmpDir, "modctl")) + require.NoError(t, err) + + patches := gomonkey.NewPatches() + defer patches.Reset() + + patches.ApplyFunc(auth.GetKeyChainByRef, func(string) (*auth.PassKeyChain, error) { + return &auth.PassKeyChain{ServerScheme: "https"}, nil + }) + patches.ApplyFunc(modctlBackend.New, func(string) (modctlBackend.Backend, error) { + return b, nil + }) + patches.ApplyMethod(b, "Pull", func(modctlBackend.Backend, context.Context, string, *modctlConfig.Pull) error { + return errors.New("boom") + }) + + targetDir := filepath.Join(tmpDir, "model") + p := &puller{pullCfg: &config.PullConfig{Concurrency: 1}} + + err = p.Pull(context.Background(), "example.com/ns/model:latest", targetDir, false, nil) + require.Error(t, err) + require.Contains(t, err.Error(), "pull model image") +} + +func TestPullerPull_Fetch_Success(t *testing.T) { + tmpDir := t.TempDir() + b, err := modctlBackend.New(filepath.Join(tmpDir, "modctl")) + require.NoError(t, err) + + ctx := context.Background() + patches := gomonkey.NewPatches() + defer patches.Reset() + + patches.ApplyFunc(auth.GetKeyChainByRef, func(string) (*auth.PassKeyChain, error) { + return &auth.PassKeyChain{ServerScheme: "https"}, nil + }) + patches.ApplyFunc(modctlBackend.New, func(string) (modctlBackend.Backend, error) { + return b, nil + }) + patches.ApplyMethod(reflect.TypeOf(&ModelArtifact{}), "GetPatterns", func(*ModelArtifact, context.Context, bool, []string) ([]string, int, error) { + return []string{"README.md"}, 2, nil + }) + called := false + patches.ApplyMethod(b, "Fetch", func(modctlBackend.Backend, context.Context, string, *modctlConfig.Fetch) error { + called = true + return nil + }) + + targetDir := filepath.Join(tmpDir, "model") + p := &puller{pullCfg: &config.PullConfig{Concurrency: 1}, hook: status.NewHook(ctx)} + + err = p.Pull(ctx, "example.com/ns/model:latest", targetDir, true, nil) + require.NoError(t, err) + require.True(t, called) +} + +func TestPullerPull_Fetch_Error(t *testing.T) { + tmpDir := t.TempDir() + b, err := modctlBackend.New(filepath.Join(tmpDir, "modctl")) + require.NoError(t, err) + + ctx := context.Background() + patches := gomonkey.NewPatches() + defer patches.Reset() + + patches.ApplyFunc(auth.GetKeyChainByRef, func(string) (*auth.PassKeyChain, error) { + return &auth.PassKeyChain{ServerScheme: "https"}, nil + }) + patches.ApplyFunc(modctlBackend.New, func(string) (modctlBackend.Backend, error) { + return b, nil + }) + patches.ApplyMethod(reflect.TypeOf(&ModelArtifact{}), "GetPatterns", func(*ModelArtifact, context.Context, bool, []string) ([]string, int, error) { + return []string{"README.md"}, 2, nil + }) + patches.ApplyMethod(b, "Fetch", func(modctlBackend.Backend, context.Context, string, *modctlConfig.Fetch) error { + return errors.New("boom") + }) + + targetDir := filepath.Join(tmpDir, "model") + p := &puller{pullCfg: &config.PullConfig{Concurrency: 1}, hook: status.NewHook(ctx)} + + err = p.Pull(ctx, "example.com/ns/model:latest", targetDir, true, nil) + require.Error(t, err) + require.Contains(t, err.Error(), "fetch model") +} diff --git a/pkg/service/pullmodel_test.go b/pkg/service/pullmodel_test.go index 32d32ba..90bad82 100644 --- a/pkg/service/pullmodel_test.go +++ b/pkg/service/pullmodel_test.go @@ -30,7 +30,7 @@ func newWorkerWithMockPuller(t *testing.T, pullErr error) *Worker { worker, err := NewWorker(cfg, sm) require.NoError(t, err) - worker.newPuller = func(ctx context.Context, pullCfg *config.PullConfig, hook *status.Hook, diskQuotaChecker *DiskQuotaChecker) Puller { + worker.newPuller = func(ctx context.Context, pullCfg *config.PullConfig, hook *status.Hook, diskQuotaChecker *DiskQuotaChecker, layerCache *LayerCache) Puller { return &mockPuller{err: pullErr} } return worker diff --git a/pkg/service/worker.go b/pkg/service/worker.go index 8628736..731ef25 100644 --- a/pkg/service/worker.go +++ b/pkg/service/worker.go @@ -52,14 +52,22 @@ func (cm *ContextMap) Get(key string) *context.CancelFunc { type Worker struct { cfg *config.Config - newPuller func(ctx context.Context, pullCfg *config.PullConfig, hook *status.Hook, diskQuotaChecker *DiskQuotaChecker) Puller + newPuller func(ctx context.Context, pullCfg *config.PullConfig, hook *status.Hook, diskQuotaChecker *DiskQuotaChecker, layerCache *LayerCache) Puller sm *status.StatusManager inflight singleflight.Group contextMap *ContextMap kmutex kmutex.KeyedLocker + layerCache *LayerCache } func NewWorker(cfg *config.Config, sm *status.StatusManager) (*Worker, error) { + layerCache := NewLayerCache(cfg) + // Best-effort rebuild of the in-memory index from previously persisted + // layers.json files. Failure here only degrades dedup hit rate, never + // correctness, so we log and continue. + if err := layerCache.Rebuild(context.Background()); err != nil { + logger.Logger().WithError(err).Warnf("rebuild layer cache") + } return &Worker{ cfg: cfg, newPuller: NewPuller, @@ -67,6 +75,7 @@ func NewWorker(cfg *config.Config, sm *status.StatusManager) (*Worker, error) { inflight: singleflight.Group{}, contextMap: NewContextMap(), kmutex: kmutex.New(), + layerCache: layerCache, }, nil } @@ -100,6 +109,12 @@ func (worker *Worker) deleteModel(ctx context.Context, isStaticVolume bool, volu } logger.WithContext(ctx).Infof("removed volume dir: %s", volumeDir) + if worker.layerCache != nil { + // volumeDir is the per-mount dir for dynamic volumes already, so a + // single OnVolumeRemoved is sufficient for both modes. + worker.layerCache.OnVolumeRemoved(volumeDir) + } + statusPath := filepath.Join(volumeDir, "status.json") worker.sm.HookManager.Delete(statusPath) @@ -193,7 +208,7 @@ func (worker *Worker) pullModel(ctx context.Context, statusPath, volumeName, mou if checkDiskQuota { diskQuotaChecker = NewDiskQuotaChecker(worker.cfg) } - puller := worker.newPuller(ctx, &worker.cfg.Get().PullConfig, hook, diskQuotaChecker) + puller := worker.newPuller(ctx, &worker.cfg.Get().PullConfig, hook, diskQuotaChecker, worker.layerCache) _, err := setStatus(status.StatePullRunning) if err != nil { return nil, errors.Wrapf(err, "set status before pull model") diff --git a/pkg/service/worker_test.go b/pkg/service/worker_test.go index 1608cee..cc4c5f1 100644 --- a/pkg/service/worker_test.go +++ b/pkg/service/worker_test.go @@ -55,6 +55,19 @@ func TestNewWorker(t *testing.T) { require.NotNil(t, worker) } +func TestNewWorker_RebuildErrorStillSucceeds(t *testing.T) { + tmpDir := t.TempDir() + rawCfg := &config.RawConfig{ServiceName: "test", RootDir: tmpDir} + cfg := config.NewWithRaw(rawCfg) + require.NoError(t, os.WriteFile(cfg.Get().GetVolumesDir(), []byte("not a directory"), 0644)) + sm, err := status.NewStatusManager() + require.NoError(t, err) + + worker, err := NewWorker(cfg, sm) + require.NoError(t, err) + require.NotNil(t, worker) +} + // ─── isModelExisted ─────────────────────────────────────────────────────────── func TestIsModelExisted_EmptyDir(t *testing.T) { diff --git a/pkg/status/hook.go b/pkg/status/hook.go index 9ff40ff..9022d7f 100644 --- a/pkg/status/hook.go +++ b/pkg/status/hook.go @@ -104,7 +104,7 @@ func (h *Hook) SetTotal(total int) { h.total = total } -func (h *Hook) BeforePullLayer(desc ocispec.Descriptor, manifest ocispec.Manifest) { +func (h *Hook) BeforePullLayer(desc ocispec.Descriptor, manifest ocispec.Manifest) bool { h.mutex.Lock() defer h.mutex.Unlock() @@ -134,9 +134,11 @@ func (h *Hook) BeforePullLayer(desc ocispec.Descriptor, manifest ocispec.Manifes Error: nil, Span: span, } + + return false } -func (h *Hook) AfterPullLayer(desc ocispec.Descriptor, err error) { +func (h *Hook) AfterPullLayer(desc ocispec.Descriptor, skipped bool, err error) { h.mutex.Lock() defer h.mutex.Unlock() @@ -155,9 +157,13 @@ func (h *Hook) AfterPullLayer(desc ocispec.Descriptor, err error) { finishedAt = &now h.pulled.Add(1) duration := time.Since(progress.StartedAt) + action := "pulled" + if skipped { + action = "reused" + } logger.WithContext(h.ctx).Infof( - "pulled layer: %s %s %s %s (%s) %s", - desc.MediaType, progress.Digest, progress.Path, humanize.Bytes(uint64(progress.Size)), h.getProgressDesc(), duration, + "%s layer: %s %s %s %s (%s) %s", + action, desc.MediaType, progress.Digest, progress.Path, humanize.Bytes(uint64(progress.Size)), h.getProgressDesc(), duration, ) } diff --git a/pkg/status/status_test.go b/pkg/status/status_test.go index 20796bf..6b8841b 100644 --- a/pkg/status/status_test.go +++ b/pkg/status/status_test.go @@ -186,7 +186,7 @@ func TestHook_BeforeAndAfterPullLayer_Success(t *testing.T) { require.Len(t, p.Items, 1) require.Nil(t, p.Items[0].FinishedAt) - h.AfterPullLayer(desc, nil) + h.AfterPullLayer(desc, false, nil) p = h.GetProgress() require.Len(t, p.Items, 1) @@ -203,7 +203,7 @@ func TestHook_AfterPullLayer_WithError(t *testing.T) { } manifest := ocispec.Manifest{} h.BeforePullLayer(desc, manifest) - h.AfterPullLayer(desc, os.ErrInvalid) + h.AfterPullLayer(desc, false, os.ErrInvalid) p := h.GetProgress() require.Len(t, p.Items, 1) @@ -215,7 +215,7 @@ func TestHook_AfterPullLayer_UnknownDigest(t *testing.T) { h := NewHook(context.Background()) desc := ocispec.Descriptor{Digest: digest.Digest("sha256:unknown")} // Should not panic. - h.AfterPullLayer(desc, nil) + h.AfterPullLayer(desc, false, nil) } func TestHook_GetProgress_Sorted(t *testing.T) {