diff --git a/.github/workflows/coverage-comment.yml b/.github/workflows/coverage-comment.yml
new file mode 100644
index 0000000..3f055c5
--- /dev/null
+++ b/.github/workflows/coverage-comment.yml
@@ -0,0 +1,52 @@
+name: Coverage Comment
+
+# This workflow runs AFTER "Code Coverage" completes and posts the coverage
+# report as a PR comment. It is intentionally separated because:
+# - The "Code Coverage" workflow runs in the context of the PR head commit
+# and therefore has NO write access to pull-requests (GitHub security model
+# for fork PRs).
+# - This workflow runs in the context of the base branch and therefore CAN
+# write PR comments, even for PRs opened from forks.
+on:
+ workflow_run:
+ workflows: ["Code Coverage"]
+ types: [completed]
+
+permissions:
+ contents: read
+ pull-requests: write # needed to create / update the comment
+
+jobs:
+ comment:
+ runs-on: ubuntu-latest
+ # Only run when the triggering workflow succeeded or failed (not skipped).
+ if: >
+ github.event.workflow_run.event == 'pull_request' &&
+ (github.event.workflow_run.conclusion == 'success' ||
+ github.event.workflow_run.conclusion == 'failure')
+ steps:
+ - name: Download coverage artifacts
+ uses: actions/download-artifact@v4
+ with:
+ name: coverage-report
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ # Download from the triggering workflow run, not the current one.
+ run-id: ${{ github.event.workflow_run.id }}
+
+ - name: Read PR number
+ id: pr
+ run: |
+ if [ -f pr-number.txt ]; then
+ echo "number=$(cat pr-number.txt)" >> "$GITHUB_OUTPUT"
+ else
+ echo "number=" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Post coverage comment on PR
+ if: steps.pr.outputs.number != ''
+ uses: marocchino/sticky-pull-request-comment@v2
+ with:
+ header: code-coverage
+ number: ${{ steps.pr.outputs.number }}
+ path: coverage-report.md
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml
index 30d8c1c..688d5f0 100644
--- a/.github/workflows/coverage.yml
+++ b/.github/workflows/coverage.yml
@@ -8,9 +8,16 @@ on:
branches: [main, release-*]
paths-ignore: ['**.md', '**.png', '**.jpg', '**.svg', '**/docs/**']
+# Only read-only permissions needed here.
+# PR comments are posted by coverage-comment.yml via workflow_run
+# so that fork PRs can also receive comments with write access.
permissions:
contents: read
+env:
+ THRESHOLD_TOTAL: 70 # overall coverage threshold (%)
+ THRESHOLD_DIFF: 90 # changed-line coverage threshold (%)
+
jobs:
coverage:
runs-on: ubuntu-latest
@@ -18,6 +25,8 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
+ with:
+ fetch-depth: 0 # full history needed for diff coverage
- name: Set up Go
uses: actions/setup-go@v5
@@ -34,29 +43,116 @@ jobs:
restore-keys: |
${{ runner.os }}-go-
- - name: Run tests with coverage
+ # ── Run tests ────────────────────────────────────────────────────────────
+ - name: Run tests with coverage (excluding pkg/server)
+ run: make test-coverage-ci
+
+ # ── Convert coverage format for diff-cover ────────────────────────────
+ - name: Install gocover-cobertura
+ run: go install github.com/t-yuki/gocover-cobertura@latest
+
+ - name: Convert to Cobertura XML
+ run: gocover-cobertura < coverage.out > coverage.xml
+
+ - name: Install diff-cover
+ run: pip install diff-cover
+
+ # ── Check total coverage threshold ───────────────────────────────────
+ - name: Check total coverage (>= ${{ env.THRESHOLD_TOTAL }}%)
+ id: total_coverage
run: |
- sudo make test-coverage
+ TOTAL=$(go tool cover -func=coverage.out | tail -1 | awk '{print $3}' | sed 's/%//')
+ echo "total=${TOTAL}" >> "$GITHUB_OUTPUT"
+ echo "Total coverage: ${TOTAL}% (threshold: ${{ env.THRESHOLD_TOTAL }}%)"
+ if awk "BEGIN { exit !( ${TOTAL} + 0 < ${{ env.THRESHOLD_TOTAL }}) }"; then
+ echo "status=fail" >> "$GITHUB_OUTPUT"
+ echo "❌ Total coverage ${TOTAL}% is below the required ${{ env.THRESHOLD_TOTAL }}%"
+ else
+ echo "status=pass" >> "$GITHUB_OUTPUT"
+ echo "✅ Total coverage ${TOTAL}% meets the threshold of ${{ env.THRESHOLD_TOTAL }}%"
+ fi
- - name: Generate coverage report
+ # ── Check diff (changed-line) coverage threshold ─────────────────────
+ - name: Fetch base branch for diff
+ if: github.event_name == 'pull_request'
+ run: git fetch origin ${{ github.base_ref }} --depth=1
+
+ - name: Check diff coverage (>= ${{ env.THRESHOLD_DIFF }}%)
+ id: diff_coverage
+ if: github.event_name == 'pull_request'
run: |
- go tool cover -func=coverage.out > coverage.txt
+ BASE=origin/${{ github.base_ref }}
+ DIFF_OUTPUT=$(diff-cover coverage.xml \
+ --compare-branch="${BASE}" \
+ --fail-under=${{ env.THRESHOLD_DIFF }} \
+ 2>&1) && DIFF_EXIT=0 || DIFF_EXIT=$?
+
+ DIFF_PCT=$(echo "$DIFF_OUTPUT" | grep -oP 'Diff coverage is \K[0-9.]+' || echo "N/A")
+ echo "diff_pct=${DIFF_PCT}" >> "$GITHUB_OUTPUT"
+ echo "$DIFF_OUTPUT"
+
+ if [ "$DIFF_EXIT" -ne 0 ]; then
+ echo "diff_status=fail" >> "$GITHUB_OUTPUT"
+ else
+ echo "diff_status=pass" >> "$GITHUB_OUTPUT"
+ fi
- - name: Display coverage summary
+ # ── Generate per-package coverage table ──────────────────────────────
+ - name: Generate coverage report markdown
run: |
- echo "## Code Coverage Report" >> $GITHUB_STEP_SUMMARY
- echo "" >> $GITHUB_STEP_SUMMARY
- echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
- tail -1 coverage.txt >> $GITHUB_STEP_SUMMARY
- echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
- echo "" >> $GITHUB_STEP_SUMMARY
- echo "Detailed Coverage by Package
" >> $GITHUB_STEP_SUMMARY
- echo "" >> $GITHUB_STEP_SUMMARY
- echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
- cat coverage.txt >> $GITHUB_STEP_SUMMARY
- echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
- echo "" >> $GITHUB_STEP_SUMMARY
- echo " " >> $GITHUB_STEP_SUMMARY
+ {
+ echo "## 📊 Code Coverage Report"
+ echo ""
+
+ TOTAL="${{ steps.total_coverage.outputs.total }}"
+ STATUS="${{ steps.total_coverage.outputs.status }}"
+ [ "$STATUS" = "pass" ] && ICON="✅" || ICON="❌"
+
+ echo "| Metric | Coverage | Threshold | Status |"
+ echo "|--------|----------|-----------|--------|"
+ echo "| Overall | **${TOTAL}%** | ${{ env.THRESHOLD_TOTAL }}% | ${ICON} |"
+
+ if [ "${{ github.event_name }}" = "pull_request" ]; then
+ DIFF_PCT="${{ steps.diff_coverage.outputs.diff_pct }}"
+ DIFF_STATUS="${{ steps.diff_coverage.outputs.diff_status }}"
+ [ "$DIFF_STATUS" = "pass" ] && DIFF_ICON="✅" || DIFF_ICON="❌"
+ echo "| Changed lines | **${DIFF_PCT}%** | ${{ env.THRESHOLD_DIFF }}% | ${DIFF_ICON} |"
+ fi
+
+ echo ""
+ echo ""
+ echo "📦 Per-package breakdown
"
+ echo ""
+ echo '```'
+ go tool cover -func=coverage.out | grep -v "^total:" | \
+ awk '{printf "%-80s %s\n", $1, $3}' | sort
+ echo ""
+ go tool cover -func=coverage.out | grep "^total:"
+ echo '```'
+ echo ""
+ echo " "
+ } > coverage-report.md
+
+ # ── Write step summary ────────────────────────────────────────────────
+ - name: Write job summary
+ run: cat coverage-report.md >> "$GITHUB_STEP_SUMMARY"
+
+ # ── Save PR number so the comment workflow can find the right PR ──────
+ - name: Save PR number
+ if: github.event_name == 'pull_request'
+ run: echo "${{ github.event.pull_request.number }}" > pr-number.txt
+
+ # ── Upload artifacts (report + pr-number for comment workflow) ────────
+ - name: Upload coverage artifacts
+ uses: actions/upload-artifact@v4
+ with:
+ name: coverage-report
+ path: |
+ coverage.out
+ coverage.xml
+ coverage-report.md
+ pr-number.txt
+ retention-days: 14
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
@@ -67,3 +163,19 @@ jobs:
fail_ci_if_error: false
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
+
+ # ── Enforce thresholds (after artifacts uploaded so report is always saved) ──
+ - name: Enforce total coverage threshold
+ if: steps.total_coverage.outputs.status == 'fail'
+ run: |
+ echo "❌ Total coverage ${{ steps.total_coverage.outputs.total }}% is below the required ${{ env.THRESHOLD_TOTAL }}%"
+ exit 1
+
+ - name: Enforce diff coverage threshold
+ if: >
+ github.event_name == 'pull_request' &&
+ steps.diff_coverage.outputs.diff_status == 'fail'
+ run: |
+ echo "❌ Changed-line coverage ${{ steps.diff_coverage.outputs.diff_pct }}% is below the required ${{ env.THRESHOLD_DIFF }}%"
+ exit 1
+
diff --git a/Makefile b/Makefile
index ddff6a2..860367b 100644
--- a/Makefile
+++ b/Makefile
@@ -13,7 +13,7 @@ REVISION=$(shell git rev-parse HEAD)$(shell if ! git diff --no-ext-diff --quiet
RELEASE_INFO = -X main.revision=${REVISION} -X main.gitVersion=${VERSION} -X main.buildTime=${BUILD_TIMESTAMP}
-.PHONY: release test test-coverage
+.PHONY: release test test-coverage test-coverage-ci
release:
@CGO_ENABLED=0 ${PROXY} GOOS=linux GOARCH=${GOARCH} go vet -tags disable_libgit2 $(PACKAGES)
@@ -48,3 +48,17 @@ test-coverage:
fi
@rm -f ./server.test
@echo "Coverage report generated: coverage.out"
+
+# CI-friendly coverage: no sudo required, excludes pkg/server, uses -coverpkg for
+# cross-package coverage so every package's contribution is reflected in the report.
+test-coverage-ci:
+ @echo "Running CI coverage (excluding pkg/server)..."
+ $(eval PKGS := $(shell go list -tags disable_libgit2 ./pkg/... | grep -v pkg/server))
+ $(eval COVERPKG := $(shell go list -tags disable_libgit2 ./pkg/... | grep -v pkg/server | tr '\n' ',' | sed 's/,$$//'))
+ @go test -tags disable_libgit2 -race \
+ -coverprofile=coverage.out \
+ -coverpkg=$(COVERPKG) \
+ -covermode=atomic \
+ -timeout 10m \
+ $(PKGS)
+ @echo "Coverage report generated: coverage.out"
diff --git a/go.mod b/go.mod
index 94b8f15..356c2ba 100644
--- a/go.mod
+++ b/go.mod
@@ -9,30 +9,30 @@ 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/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.0-alpha.3
+ github.com/modelpack/modctl v0.1.2-alpha.0
github.com/modelpack/model-spec v0.0.7
github.com/opencontainers/go-digest v1.0.0
github.com/opencontainers/image-spec v1.1.1
github.com/pkg/errors v0.9.1
- github.com/prometheus/client_golang v1.22.0
+ github.com/prometheus/client_golang v1.23.2
github.com/prometheus/client_model v0.6.2
github.com/rexray/gocsi v1.2.2
- github.com/sirupsen/logrus v1.9.3
+ github.com/sirupsen/logrus v1.9.4
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.37.0
- go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0
- go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.37.0
- go.opentelemetry.io/otel/sdk v1.37.0
- go.opentelemetry.io/otel/trace v1.37.0
- golang.org/x/net v0.45.0
- golang.org/x/sync v0.17.0
- golang.org/x/sys v0.37.0
- google.golang.org/grpc v1.75.1
+ 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
+ golang.org/x/sync v0.19.0
+ golang.org/x/sys v0.40.0
+ google.golang.org/grpc v1.78.0
gopkg.in/yaml.v2 v2.4.0
k8s.io/api v0.28.4
k8s.io/apimachinery v0.28.4
@@ -40,7 +40,7 @@ require (
)
require (
- d7y.io/api/v2 v2.1.81 // indirect
+ d7y.io/api/v2 v2.2.8 // 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,8 +50,10 @@ 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/cenkalti/backoff/v4 v4.3.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/cpuguy83/go-md2man/v2 v2.0.6 // indirect
github.com/cyphar/filepath-securejoin v0.4.1 // indirect
@@ -63,16 +65,16 @@ require (
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.2.1 // indirect
+ github.com/envoyproxy/protoc-gen-validate v1.3.0 // 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-git/v5 v5.16.3 // 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
github.com/go-openapi/jsonreference v0.20.2 // indirect
github.com/go-openapi/swag v0.22.3 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
+ github.com/gofrs/flock v0.13.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/golang/protobuf v1.5.4 // indirect
@@ -80,7 +82,7 @@ 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.26.3 // indirect
+ github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // 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
@@ -93,7 +95,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.16 // indirect
+ github.com/mattn/go-runewidth v0.0.19 // 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
@@ -101,43 +103,46 @@ 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.63.0 // indirect
- github.com/prometheus/procfs v0.16.1 // indirect
+ github.com/prometheus/common v0.67.2 // indirect
+ github.com/prometheus/otlptranslator v1.0.0 // indirect
+ github.com/prometheus/procfs v0.19.2 // 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.10.2 // indirect
+ github.com/vbauerster/mpb/v8 v8.11.3 // 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.1.0 // indirect
- go.opentelemetry.io/contrib/bridges/prometheus v0.60.0 // indirect
- go.opentelemetry.io/contrib/exporters/autoexport v0.60.0 // indirect
- go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.11.0 // indirect
- go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.11.0 // indirect
- go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.35.0 // indirect
- go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.35.0 // indirect
- go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 // indirect
- go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0 // indirect
- go.opentelemetry.io/otel/exporters/prometheus v0.57.0 // indirect
- go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.13.0 // indirect
- go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.37.0 // indirect
- go.opentelemetry.io/otel/log v0.13.0 // indirect
- go.opentelemetry.io/otel/metric v1.37.0 // indirect
- go.opentelemetry.io/otel/sdk/log v0.13.0 // indirect
- go.opentelemetry.io/otel/sdk/metric v1.37.0 // indirect
- go.opentelemetry.io/proto/otlp v1.6.0 // indirect
- golang.org/x/crypto v0.43.0 // indirect
- golang.org/x/oauth2 v0.30.0 // indirect
- golang.org/x/term v0.36.0 // indirect
- golang.org/x/text v0.30.0 // 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.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-20250707201910-8d1bb00bc6a7 // indirect
- google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b // indirect
- google.golang.org/protobuf v1.36.10 // 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
+ google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
diff --git a/go.sum b/go.sum
index 6ad2119..fe58007 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.1.81 h1:mWgHHDFa9iwsKqMIPhQZNYOjSW5EMqdjS9HEzcnF9/I=
-d7y.io/api/v2 v2.1.81/go.mod h1:t6k27g8dFyH6sp3y2J1qHyk2YmoySd88qSbsEaqJ2Sw=
+d7y.io/api/v2 v2.2.8 h1:XNgIVHgij3VbNRri74cW2eLV4hIkN5v8FE6yB1YQEXs=
+d7y.io/api/v2 v2.2.8/go.mod h1:TtW9UE0CebRB/CWIEbWfRnljmpKf/mNoe2qIUO+PoP0=
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,11 +34,15 @@ 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/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
-github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
+github.com/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/container-storage-interface/spec v1.2.0 h1:bD9KIVgaVKKkQ/UbVUY9kCaH/CJbhNxe0eeB4JeJV2s=
@@ -78,8 +82,8 @@ 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.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8=
-github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU=
+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/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=
@@ -90,8 +94,8 @@ github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UN
github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU=
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.3 h1:Z8BtvxZ09bYm/yYNgPKCzgWtaRqDTgIKRgIRHBfU6Z8=
-github.com/go-git/go-git/v5 v5.16.3/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8=
+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-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=
@@ -112,6 +116,8 @@ github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEe
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
+github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
+github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
@@ -141,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.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo=
-github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI=
+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/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=
@@ -194,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.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
-github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
+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/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=
@@ -203,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.0-alpha.3 h1:EnusvWpDxpJFuqxhP8QSQFommKQJ1/FMm7cIJH3nSjk=
-github.com/modelpack/modctl v0.1.0-alpha.3/go.mod h1:XimqHQWsXRgEBLt0tfIt64u9zgUxnqLtSV1ZpnTyY7M=
+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/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=
@@ -239,22 +245,23 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
github.com/prometheus/client_golang v1.1.0/go.mod h1:I1FGZT9+L76gKKOs5djB6ezCbFQP1xR9D75/vuwEF3g=
-github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
-github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
+github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
+github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
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.63.0 h1:YR/EIY1o3mEFP/kZCD7iDMnLPlGyuU2Gb3HIcXnA98k=
-github.com/prometheus/common v0.63.0/go.mod h1:VVFF/fBIoToEnWRVkYoXEkq3R3paCoxG9PXP74SnV18=
+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/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.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
-github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
-github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
+github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
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=
@@ -265,8 +272,8 @@ github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
-github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
-github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
+github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
+github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8=
github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
@@ -284,7 +291,6 @@ github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
-github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
@@ -296,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.10.2 h1:2uBykSHAYHekE11YvJhKxYmLATKHAGorZwFlyNw4hHM=
-github.com/vbauerster/mpb/v8 v8.10.2/go.mod h1:+Ja4P92E3/CorSZgfDtK46D7AVbDqmBQRTmyTqPElo0=
+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/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=
@@ -310,64 +316,66 @@ github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI=
github.com/zeebo/blake3 v0.2.4/go.mod h1:7eeQ6d2iXWRGF6npfaxl2CU+xy2Fjo2gxeyZGCRUjcE=
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.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
-go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
-go.opentelemetry.io/contrib/bridges/prometheus v0.60.0 h1:x7sPooQCwSg27SjtQee8GyIIRTQcF4s7eSkac6F2+VA=
-go.opentelemetry.io/contrib/bridges/prometheus v0.60.0/go.mod h1:4K5UXgiHxV484efGs42ejD7E2J/sIlepYgdGoPXe7hE=
-go.opentelemetry.io/contrib/exporters/autoexport v0.60.0 h1:GuQXpvSXNjpswpweIem84U9BNauqHHi2w1GtNAalvpM=
-go.opentelemetry.io/contrib/exporters/autoexport v0.60.0/go.mod h1:CkmxekdHco4d7thFJNPQ7Mby4jMBgZUclnrxT4e+ryk=
+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/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.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
-go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
-go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.11.0 h1:HMUytBT3uGhPKYY/u/G5MR9itrlSO2SMOsSD3Tk3k7A=
-go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.11.0/go.mod h1:hdDXsiNLmdW/9BF2jQpnHHlhFajpWCEYfM6e5m2OAZg=
-go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.11.0 h1:C/Wi2F8wEmbxJ9Kuzw/nhP+Z9XaHYMkyDmXy6yR2cjw=
-go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.11.0/go.mod h1:0Lr9vmGKzadCTgsiBydxr6GEZ8SsZ7Ks53LzjWG5Ar4=
-go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.35.0 h1:QcFwRrZLc82r8wODjvyCbP7Ifp3UANaBSmhDSFjnqSc=
-go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.35.0/go.mod h1:CXIWhUomyWBG/oY2/r/kLp6K/cmx9e/7DLpBuuGdLCA=
-go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.35.0 h1:0NIXxOCFx+SKbhCVxwl3ETG8ClLPAa0KuKV6p3yhxP8=
-go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.35.0/go.mod h1:ChZSJbbfbl/DcRZNc9Gqh6DYGlfjw4PvO1pEOZH1ZsE=
-go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 h1:1fTNlAIJZGWLP5FVu0fikVry1IsiUnXjf7QFvoNN3Xw=
-go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0/go.mod h1:zjPK58DtkqQFn+YUMbx0M2XV3QgKU0gS9LeGohREyK4=
-go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0 h1:m639+BofXTvcY1q8CGs4ItwQarYtJPOWmVobfM1HpVI=
-go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0/go.mod h1:LjReUci/F4BUyv+y4dwnq3h/26iNOeC3wAIqgvTIZVo=
-go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0 h1:xJ2qHD0C1BeYVTLLR9sX12+Qb95kfeD/byKj6Ky1pXg=
-go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0/go.mod h1:u5BF1xyjstDowA1R5QAO9JHzqK+ublenEW/dyqTjBVk=
-go.opentelemetry.io/otel/exporters/prometheus v0.57.0 h1:AHh/lAP1BHrY5gBwk8ncc25FXWm/gmmY3BX258z5nuk=
-go.opentelemetry.io/otel/exporters/prometheus v0.57.0/go.mod h1:QpFWz1QxqevfjwzYdbMb4Y1NnlJvqSGwyuU0B4iuc9c=
-go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.13.0 h1:yEX3aC9KDgvYPhuKECHbOlr5GLwH6KTjLJ1sBSkkxkc=
-go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.13.0/go.mod h1:/GXR0tBmmkxDaCUGahvksvp66mx4yh5+cFXgSlhg0vQ=
-go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.37.0 h1:6VjV6Et+1Hd2iLZEPtdV7vie80Yyqf7oikJLjQ/myi0=
-go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.37.0/go.mod h1:u8hcp8ji5gaM/RfcOo8z9NMnf1pVLfVY7lBY2VOGuUU=
-go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.37.0 h1:SNhVp/9q4Go/XHBkQ1/d5u9P/U+L1yaGPoi0x+mStaI=
-go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.37.0/go.mod h1:tx8OOlGH6R4kLV67YaYO44GFXloEjGPZuMjEkaaqIp4=
-go.opentelemetry.io/otel/log v0.13.0 h1:yoxRoIZcohB6Xf0lNv9QIyCzQvrtGZklVbdCoyb7dls=
-go.opentelemetry.io/otel/log v0.13.0/go.mod h1:INKfG4k1O9CL25BaM1qLe0zIedOpvlS5Z7XgSbmN83E=
-go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
-go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
-go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
-go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
-go.opentelemetry.io/otel/sdk/log v0.13.0 h1:I3CGUszjM926OphK8ZdzF+kLqFvfRY/IIoFq/TjwfaQ=
-go.opentelemetry.io/otel/sdk/log v0.13.0/go.mod h1:lOrQyCCXmpZdN7NchXb6DOZZa1N5G1R2tm5GMMTpDBw=
-go.opentelemetry.io/otel/sdk/log/logtest v0.13.0 h1:9yio6AFZ3QD9j9oqshV1Ibm9gPLlHNxurno5BreMtIA=
-go.opentelemetry.io/otel/sdk/log/logtest v0.13.0/go.mod h1:QOGiAJHl+fob8Nu85ifXfuQYmJTFAvcrxL6w5/tu168=
-go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
-go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
-go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
-go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
-go.opentelemetry.io/proto/otlp v1.6.0 h1:jQjP+AQyTf+Fe7OKj/MfkDrmK4MNVtw2NpXsf9fefDI=
-go.opentelemetry.io/proto/otlp v1.6.0/go.mod h1:cicgGehlFuNdgZkcALOCh3VE6K/u2tAjzlRhDwmVpZc=
+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.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=
+go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
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.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
-golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
+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/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=
@@ -383,19 +391,19 @@ 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.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM=
-golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
+golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
+golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
-golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
-golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
+golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
+golang.org/x/oauth2 v0.32.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=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
-golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
+golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
+golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -411,17 +419,17 @@ 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.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
-golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
+golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
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.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
-golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
+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/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.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
-golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
+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/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -430,8 +438,8 @@ golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3
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.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
-golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
+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/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=
@@ -440,15 +448,15 @@ gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
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-20250707201910-8d1bb00bc6a7 h1:FiusG7LWj+4byqhbvmB+Q93B/mOxJLN2DTozDuZm4EU=
-google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7/go.mod h1:kXqgZtrWaf6qS3jZOCnCH7WYfrvFjkC51bM8fz3RsCA=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b h1:zPKJod4w6F1+nRGDI9ubnXYhU9NSWoFAijkHkUXeTK8=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
+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/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
-google.golang.org/grpc v1.75.1 h1:/ODCNEuf9VghjgO3rqLcfg8fiOP0nSluljWFlDxELLI=
-google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
-google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
-google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
+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/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=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
diff --git a/pkg/client/client_test.go b/pkg/client/client_test.go
new file mode 100644
index 0000000..a45f668
--- /dev/null
+++ b/pkg/client/client_test.go
@@ -0,0 +1,108 @@
+package client
+
+import (
+ "context"
+ "testing"
+
+ "github.com/modelpack/model-csi-driver/pkg/config"
+ "github.com/stretchr/testify/require"
+)
+
+func newTestConfig(t *testing.T) *config.Config {
+ t.Helper()
+ raw := &config.RawConfig{
+ ServiceName: "test.csi.example.com",
+ RootDir: t.TempDir(),
+ ExternalCSIAuthorization: "test-token",
+ }
+ return config.NewWithRaw(raw)
+}
+
+func TestNewGRPCClient_TCP(t *testing.T) {
+ cfg := newTestConfig(t)
+ c, err := NewGRPCClient(cfg, "127.0.0.1:19999")
+ require.NoError(t, err)
+ require.NotNil(t, c)
+ _ = c.Close()
+}
+
+func TestNewGRPCClient_TCPPrefix(t *testing.T) {
+ cfg := newTestConfig(t)
+ c, err := NewGRPCClient(cfg, "tcp://127.0.0.1:19999")
+ require.NoError(t, err)
+ require.NotNil(t, c)
+ _ = c.Close()
+}
+
+func TestGRPCClient_CloseNilConn(t *testing.T) {
+ c := &GRPCClient{}
+ err := c.Close()
+ require.NoError(t, err)
+}
+
+func TestNewHTTPClient_Unix(t *testing.T) {
+ c, err := NewHTTPClient("unix:///tmp/test.sock")
+ require.NoError(t, err)
+ require.NotNil(t, c)
+}
+
+func TestNewHTTPClient_InvalidURL(t *testing.T) {
+ // url.Parse rarely errors; test with a valid-looking addr to reach the second parse
+ c, err := NewHTTPClient("unix:///tmp/handler.sock")
+ require.NoError(t, err)
+ require.NotNil(t, c)
+}
+
+func TestDumpPayload_ValidObject(t *testing.T) {
+ obj := map[string]string{"key": "value"}
+ r, err := dumpPayload(obj)
+ require.NoError(t, err)
+ require.NotNil(t, r)
+}
+
+func TestDumpPayload_UnmarshalableObject(t *testing.T) {
+ // channels cannot be marshaled to JSON
+ ch := make(chan int)
+ _, err := dumpPayload(ch)
+ require.Error(t, err)
+}
+// newUnavailableGRPCClient creates a client pointing to a non-existent server.
+// gRPC dials are lazy so construction succeeds, but actual calls fail.
+func newUnavailableGRPCClient(t *testing.T) *GRPCClient {
+ t.Helper()
+ cfg := newTestConfig(t)
+ c, err := NewGRPCClient(cfg, "127.0.0.1:19998")
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = c.Close() })
+ return c
+}
+
+func TestGRPCClient_CreateVolume_Error(t *testing.T) {
+ c := newUnavailableGRPCClient(t)
+ _, err := c.CreateVolume(context.Background(), "vol1", map[string]string{})
+ require.Error(t, err)
+}
+
+func TestGRPCClient_DeleteVolume_Error(t *testing.T) {
+ c := newUnavailableGRPCClient(t)
+ _, err := c.DeleteVolume(context.Background(), "vol1")
+ require.Error(t, err)
+}
+
+func TestGRPCClient_PublishVolume_Error(t *testing.T) {
+ c := newUnavailableGRPCClient(t)
+ _, err := c.PublishVolume(context.Background(), "vol1", "/tmp/target")
+ require.Error(t, err)
+}
+
+func TestGRPCClient_UnpublishVolume_Error(t *testing.T) {
+ c := newUnavailableGRPCClient(t)
+ _, err := c.UnpublishVolume(context.Background(), "vol1", "/tmp/target")
+ require.Error(t, err)
+}
+
+func TestGRPCClient_PublishStaticInlineVolume_Error(t *testing.T) {
+ c := newUnavailableGRPCClient(t)
+ _, err := c.PublishStaticInlineVolume(context.Background(), "vol1", "/tmp/target", "registry.example.com/model:v1")
+ require.Error(t, err)
+}
diff --git a/pkg/client/http_test.go b/pkg/client/http_test.go
new file mode 100644
index 0000000..ec736ff
--- /dev/null
+++ b/pkg/client/http_test.go
@@ -0,0 +1,155 @@
+package client
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "testing"
+
+ "github.com/modelpack/model-csi-driver/pkg/status"
+ "github.com/stretchr/testify/require"
+)
+
+// setupUnixServer creates a test unix socket HTTP server using httptest and returns the socket path.
+func setupTestHTTPServer(t *testing.T, mux *http.ServeMux) string {
+ t.Helper()
+ sockPath := fmt.Sprintf("%s/test-%d.sock", t.TempDir(), os.Getpid())
+
+ ln, err := net.Listen("unix", sockPath)
+ require.NoError(t, err)
+
+ srv := &http.Server{Handler: mux}
+ go func() {
+ _ = srv.Serve(ln)
+ }()
+ t.Cleanup(func() { _ = srv.Close() })
+ return sockPath
+}
+
+func TestHTTPClient_CreateMount(t *testing.T) {
+ mux := http.NewServeMux()
+ expectedStatus := status.Status{
+ VolumeName: "vol1",
+ MountID: "m1",
+ Reference: "test/model:latest",
+ State: status.StateMounted,
+ }
+ mux.HandleFunc("/api/v1/volumes/vol1/mounts", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ _ = json.NewEncoder(w).Encode(expectedStatus)
+ })
+
+ sockPath := setupTestHTTPServer(t, mux)
+ client, err := NewHTTPClient("unix://" + sockPath)
+ require.NoError(t, err)
+
+ result, err := client.CreateMount(context.Background(), "vol1", "m1", "test/model:latest", false)
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ require.Equal(t, "vol1", result.VolumeName)
+}
+
+func TestHTTPClient_GetMount(t *testing.T) {
+ mux := http.NewServeMux()
+ expectedStatus := status.Status{
+ VolumeName: "vol1",
+ MountID: "m1",
+ Reference: "test/model:latest",
+ }
+ mux.HandleFunc("/api/v1/volumes/vol1/mounts/m1", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ _ = json.NewEncoder(w).Encode(expectedStatus)
+ })
+
+ sockPath := setupTestHTTPServer(t, mux)
+ client, err := NewHTTPClient("unix://" + sockPath)
+ require.NoError(t, err)
+
+ result, err := client.GetMount(context.Background(), "vol1", "m1")
+ require.NoError(t, err)
+ require.NotNil(t, result)
+}
+
+func TestHTTPClient_DeleteMount(t *testing.T) {
+ mux := http.NewServeMux()
+ mux.HandleFunc("/api/v1/volumes/vol1/mounts/m1", func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusNoContent)
+ })
+
+ sockPath := setupTestHTTPServer(t, mux)
+ client, err := NewHTTPClient("unix://" + sockPath)
+ require.NoError(t, err)
+
+ err = client.DeleteMount(context.Background(), "vol1", "m1")
+ require.NoError(t, err)
+}
+
+func TestHTTPClient_ListMounts(t *testing.T) {
+ mux := http.NewServeMux()
+ mux.HandleFunc("/api/v1/volumes/vol1/mounts", func(w http.ResponseWriter, r *http.Request) {
+ items := []status.Status{{VolumeName: "vol1", MountID: "m1"}}
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ _ = json.NewEncoder(w).Encode(items)
+ })
+
+ sockPath := setupTestHTTPServer(t, mux)
+ client, err := NewHTTPClient("unix://" + sockPath)
+ require.NoError(t, err)
+
+ items, err := client.ListMounts(context.Background(), "vol1")
+ require.NoError(t, err)
+ require.Len(t, items, 1)
+}
+
+func TestHTTPClient_ServerError(t *testing.T) {
+ mux := http.NewServeMux()
+ mux.HandleFunc("/api/v1/volumes/vol1/mounts", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ http.Error(w, "internal server error", http.StatusInternalServerError)
+ })
+
+ sockPath := setupTestHTTPServer(t, mux)
+ client, err := NewHTTPClient("unix://" + sockPath)
+ require.NoError(t, err)
+
+ // CreateMount returning an error from the server should propagate
+ _, err = client.CreateMount(context.Background(), "vol1", "m1", "ref", false)
+ require.Error(t, err)
+}
+
+// Test request() with HTML content-type response (broken api endpoint)
+func TestHTTPClient_Request_HTMLResponse(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(http.StatusOK)
+ _, _ = fmt.Fprintln(w, "broken")
+ }))
+ defer srv.Close()
+
+ baseURL, err := url.Parse("http://unix")
+ require.NoError(t, err)
+
+ httpClient := &HTTPClient{
+ baseURL: *baseURL,
+ client: &http.Client{
+ Transport: &http.Transport{
+ DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
+ return net.Dial("tcp", srv.Listener.Addr().String())
+ },
+ },
+ },
+ }
+
+ _, err = httpClient.request(context.Background(), http.MethodGet, "/test", nil, nil, nil)
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "broken api endpoint")
+}
+
diff --git a/pkg/config/auth/auth_test.go b/pkg/config/auth/auth_test.go
new file mode 100644
index 0000000..6d89eff
--- /dev/null
+++ b/pkg/config/auth/auth_test.go
@@ -0,0 +1,206 @@
+package auth
+
+import (
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+// ─── decodeAuth ───────────────────────────────────────────────────────────────
+
+func TestDecodeAuth_Empty(t *testing.T) {
+ user, pass, err := decodeAuth("")
+ require.NoError(t, err)
+ require.Empty(t, user)
+ require.Empty(t, pass)
+}
+
+func TestDecodeAuth_Valid(t *testing.T) {
+ raw := base64.StdEncoding.EncodeToString([]byte("myuser:mypass"))
+ user, pass, err := decodeAuth(raw)
+ require.NoError(t, err)
+ require.Equal(t, "myuser", user)
+ require.Equal(t, "mypass", pass)
+}
+
+func TestDecodeAuth_InvalidBase64(t *testing.T) {
+ _, _, err := decodeAuth("!!!not-base64!!!")
+ require.Error(t, err)
+}
+
+func TestDecodeAuth_NoColon(t *testing.T) {
+ raw := base64.StdEncoding.EncodeToString([]byte("nocolon"))
+ _, _, err := decodeAuth(raw)
+ require.Error(t, err)
+}
+
+// ─── loadFromReader ───────────────────────────────────────────────────────────
+
+func TestLoadFromReader_Valid(t *testing.T) {
+ user := "testuser"
+ pass := "testpass"
+ auth := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", user, pass)))
+
+ cfg := ConfigFile{
+ AuthConfigs: map[string]AuthConfig{
+ "registry.example.com": {Auth: auth},
+ },
+ }
+ data, err := json.Marshal(cfg)
+ require.NoError(t, err)
+
+ result, err := loadFromReader(strings.NewReader(string(data)))
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ ac := result.GetAuthConfig("registry.example.com")
+ require.NotNil(t, ac)
+ require.Equal(t, user, ac.Username)
+ require.Equal(t, pass, ac.Password)
+}
+
+func TestLoadFromReader_InvalidJSON(t *testing.T) {
+ _, err := loadFromReader(strings.NewReader("{invalid json"))
+ require.Error(t, err)
+}
+
+func TestLoadFromReader_Empty(t *testing.T) {
+ result, err := loadFromReader(strings.NewReader(""))
+ require.NoError(t, err)
+ require.NotNil(t, result)
+}
+
+// ─── ConfigFile.GetAuthConfig ─────────────────────────────────────────────────
+
+func TestGetAuthConfig_Found(t *testing.T) {
+ cf := &ConfigFile{
+ AuthConfigs: map[string]AuthConfig{
+ "registry.example.com": {Username: "user", Password: "pass"},
+ },
+ }
+ ac := cf.GetAuthConfig("registry.example.com")
+ require.NotNil(t, ac)
+ require.Equal(t, "user", ac.Username)
+}
+
+func TestGetAuthConfig_NotFound(t *testing.T) {
+ cf := &ConfigFile{
+ AuthConfigs: map[string]AuthConfig{},
+ }
+ ac := cf.GetAuthConfig("nonexistent.registry.io")
+ require.Nil(t, ac)
+}
+
+func TestGetAuthConfig_NilMap(t *testing.T) {
+ cf := &ConfigFile{}
+ ac := cf.GetAuthConfig("registry.example.com")
+ require.Nil(t, ac)
+}
+
+// ─── PassKeyChain.ToBase64 ────────────────────────────────────────────────────
+
+func TestToBase64_Empty(t *testing.T) {
+ kc := &PassKeyChain{}
+ result := kc.ToBase64()
+ require.Empty(t, result)
+}
+
+func TestToBase64_WithCredentials(t *testing.T) {
+ kc := &PassKeyChain{Username: "admin", Password: "secret"}
+ result := kc.ToBase64()
+ require.NotEmpty(t, result)
+
+ decoded, err := base64.StdEncoding.DecodeString(result)
+ require.NoError(t, err)
+ require.Equal(t, "admin:secret", string(decoded))
+}
+
+// ─── FromDockerConfig / GetKeyChainByRef ──────────────────────────────────────
+
+func TestFromDockerConfig_EmptyHost(t *testing.T) {
+ _, err := FromDockerConfig("")
+ require.Error(t, err)
+}
+
+func TestFromDockerConfig_WithDockerConfigFile(t *testing.T) {
+ tmpDir := t.TempDir()
+ dockerConfigPath := filepath.Join(tmpDir, "config.json")
+
+ auth := base64.StdEncoding.EncodeToString([]byte("user1:pass1"))
+ configContent := fmt.Sprintf(`{"auths":{"registry.test.io":{"auth":"%s"}}}`, auth)
+ require.NoError(t, os.WriteFile(dockerConfigPath, []byte(configContent), 0600))
+
+ // Reset cache for this host.
+ keyChainCache.mutex.Lock()
+ delete(keyChainCache.data, "registry.test.io")
+ keyChainCache.mutex.Unlock()
+
+ t.Setenv("DOCKER_CONFIG", tmpDir)
+
+ kc, err := FromDockerConfig("registry.test.io")
+ require.NoError(t, err)
+ require.Equal(t, "user1", kc.Username)
+ require.Equal(t, "pass1", kc.Password)
+}
+
+func TestFromDockerConfig_MissingConfigFile(t *testing.T) {
+ // Reset cache for host.
+ host := "missing.registry.io"
+ keyChainCache.mutex.Lock()
+ delete(keyChainCache.data, host)
+ keyChainCache.mutex.Unlock()
+
+ t.Setenv("DOCKER_CONFIG", "/nonexistent/dir")
+ _, err := FromDockerConfig(host)
+ require.Error(t, err)
+}
+
+func TestFromDockerConfig_DockerHubConversion(t *testing.T) {
+ tmpDir := t.TempDir()
+ dockerConfigPath := filepath.Join(tmpDir, "config.json")
+
+ auth := base64.StdEncoding.EncodeToString([]byte("dockeruser:dockerpass"))
+ configContent := fmt.Sprintf(`{"auths":{"%s":{"auth":"%s"}}}`, dockerHost, auth)
+ require.NoError(t, os.WriteFile(dockerConfigPath, []byte(configContent), 0600))
+
+ // Clear cache.
+ keyChainCache.mutex.Lock()
+ delete(keyChainCache.data, dockerHost)
+ delete(keyChainCache.data, convertedDockerHost)
+ keyChainCache.mutex.Unlock()
+
+ t.Setenv("DOCKER_CONFIG", tmpDir)
+
+ // registry-1.docker.io should be converted to dockerHost internally.
+ kc, err := FromDockerConfig(convertedDockerHost)
+ require.NoError(t, err)
+ require.Equal(t, "dockeruser", kc.Username)
+}
+
+func TestGetKeyChainByRef_Valid(t *testing.T) {
+ tmpDir := t.TempDir()
+ dockerConfigPath := filepath.Join(tmpDir, "config.json")
+ auth := base64.StdEncoding.EncodeToString([]byte("refuser:refpass"))
+ configContent := fmt.Sprintf(`{"auths":{"ghcr.io":{"auth":"%s"}}}`, auth)
+ require.NoError(t, os.WriteFile(dockerConfigPath, []byte(configContent), 0600))
+
+ keyChainCache.mutex.Lock()
+ delete(keyChainCache.data, "ghcr.io")
+ keyChainCache.mutex.Unlock()
+
+ t.Setenv("DOCKER_CONFIG", tmpDir)
+
+ kc, err := GetKeyChainByRef("ghcr.io/my-org/model:v1")
+ require.NoError(t, err)
+ require.NotNil(t, kc)
+}
+
+func TestGetKeyChainByRef_InvalidRef(t *testing.T) {
+ _, err := GetKeyChainByRef(":::invalid:::")
+ require.Error(t, err)
+}
diff --git a/pkg/config/config.go b/pkg/config/config.go
index 78cdee5..fad6758 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -99,6 +99,10 @@ func (cfg *RawConfig) ParameterKeyExcludeModelWeights() string {
return cfg.ServiceName + "/exclude-model-weights"
}
+func (cfg *RawConfig) ParameterKeyExcludeFilePatterns() string {
+ return cfg.ServiceName + "/exclude-file-patterns"
+}
+
// /var/lib/dragonfly/model-csi/volumes
func (cfg *RawConfig) GetVolumesDir() string {
return filepath.Join(cfg.RootDir, "volumes")
diff --git a/pkg/config/config_extra_test.go b/pkg/config/config_extra_test.go
new file mode 100644
index 0000000..368d198
--- /dev/null
+++ b/pkg/config/config_extra_test.go
@@ -0,0 +1,79 @@
+package config
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestRawConfig_ParameterKeys(t *testing.T) {
+ cfg := &RawConfig{ServiceName: "test.csi.example.com"}
+
+ require.Equal(t, "test.csi.example.com/type", cfg.ParameterKeyType())
+ require.Equal(t, "test.csi.example.com/reference", cfg.ParameterKeyReference())
+ require.Equal(t, "test.csi.example.com/mount-id", cfg.ParameterKeyMountID())
+ require.Equal(t, "test.csi.example.com/status/state", cfg.ParameterKeyStatusState())
+ require.Equal(t, "test.csi.example.com/status/progress", cfg.ParameterKeyStatusProgress())
+ require.Equal(t, "test.csi.example.com/node-ip", cfg.ParameterVolumeContextNodeIP())
+ require.Equal(t, "test.csi.example.com/check-disk-quota", cfg.ParameterKeyCheckDiskQuota())
+ require.Equal(t, "test.csi.example.com/exclude-model-weights", cfg.ParameterKeyExcludeModelWeights())
+ require.Equal(t, "test.csi.example.com/exclude-file-patterns", cfg.ParameterKeyExcludeFilePatterns())
+}
+
+func TestRawConfig_PathHelpers(t *testing.T) {
+ cfg := &RawConfig{
+ ServiceName: "test.csi.example.com",
+ RootDir: "/var/lib/model-csi",
+ }
+
+ require.Equal(t, "/var/lib/model-csi/volumes", cfg.GetVolumesDir())
+ require.Equal(t, "/var/lib/model-csi/volumes/pvc-vol", cfg.GetVolumeDir("pvc-vol"))
+ require.Equal(t, "/var/lib/model-csi/volumes/pvc-vol/model", cfg.GetModelDir("pvc-vol"))
+ require.Equal(t, "/var/lib/model-csi/volumes/csi-vol", cfg.GetVolumeDirForDynamic("csi-vol"))
+ require.Equal(t, "/var/lib/model-csi/volumes/csi-vol/models", cfg.GetModelsDirForDynamic("csi-vol"))
+ require.Equal(t, "/var/lib/model-csi/volumes/csi-vol/models/mnt-1", cfg.GetMountIDDirForDynamic("csi-vol", "mnt-1"))
+ require.Equal(t, "/var/lib/model-csi/volumes/csi-vol/models/mnt-1/model", cfg.GetModelDirForDynamic("csi-vol", "mnt-1"))
+ require.Equal(t, "/var/lib/model-csi/volumes/csi-vol/csi", cfg.GetCSISockDirForDynamic("csi-vol"))
+ require.Equal(t, "/var/lib/model-csi/volumes/csi-vol/csi/csi.sock", cfg.GetCSISockPathForDynamic("csi-vol"))
+}
+
+func TestRawConfig_ModeHelpers(t *testing.T) {
+ controller := &RawConfig{Mode: "controller"}
+ require.True(t, controller.IsControllerMode())
+ require.False(t, controller.IsNodeMode())
+
+ node := &RawConfig{Mode: "node"}
+ require.False(t, node.IsControllerMode())
+ require.True(t, node.IsNodeMode())
+
+ empty := &RawConfig{}
+ require.False(t, empty.IsControllerMode())
+ require.False(t, empty.IsNodeMode())
+}
+
+func TestHumanizeSize_UnmarshalYAML(t *testing.T) {
+ // Direct test of the HumanizeSize type.
+ var hs HumanizeSize
+ err := hs.UnmarshalYAML(func(v interface{}) error {
+ *(v.(*string)) = "1GiB"
+ return nil
+ })
+ require.NoError(t, err)
+ require.Equal(t, HumanizeSize(1073741824), hs)
+}
+
+func TestHumanizeSize_UnmarshalYAML_Invalid(t *testing.T) {
+ var hs HumanizeSize
+ err := hs.UnmarshalYAML(func(v interface{}) error {
+ *(v.(*string)) = "not-a-size"
+ return nil
+ })
+ require.Error(t, err)
+}
+
+func TestNewWithRaw(t *testing.T) {
+ rawCfg := &RawConfig{ServiceName: "test-svc", RootDir: "/tmp/test"}
+ cfg := NewWithRaw(rawCfg)
+ require.NotNil(t, cfg)
+ require.Equal(t, "test-svc", cfg.Get().ServiceName)
+}
diff --git a/pkg/logger/logger_test.go b/pkg/logger/logger_test.go
new file mode 100644
index 0000000..ca0dcdb
--- /dev/null
+++ b/pkg/logger/logger_test.go
@@ -0,0 +1,49 @@
+package logger
+
+import (
+ "context"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestNewContext(t *testing.T) {
+ ctx := context.Background()
+ ctx = NewContext(ctx, "NodePublishVolume", "pvc-volume-1", "/var/lib/kubelet/pods/target")
+
+ require.NotNil(t, ctx.Value(RequestIDKey{}))
+ require.Equal(t, "NodePublishVolume", ctx.Value(RequestOpKey{}))
+ require.Equal(t, "pvc-volume-1", ctx.Value(RequestVolumeNameKey{}))
+ require.Equal(t, "/var/lib/kubelet/pods/target", ctx.Value(RequestTargetPathKey{}))
+}
+
+func TestNewContext_EmptyTargetPath(t *testing.T) {
+ ctx := context.Background()
+ ctx = NewContext(ctx, "NodeUnpublishVolume", "pvc-volume-2", "")
+
+ require.Nil(t, ctx.Value(RequestTargetPathKey{}))
+ require.Equal(t, "NodeUnpublishVolume", ctx.Value(RequestOpKey{}))
+}
+
+func TestWithContext_Basic(t *testing.T) {
+ ctx := NewContext(context.Background(), "op", "vol", "")
+ entry := WithContext(ctx)
+ require.NotNil(t, entry)
+}
+
+func TestWithContext_WithTargetPath(t *testing.T) {
+ ctx := NewContext(context.Background(), "op", "vol", "/target")
+ entry := WithContext(ctx)
+ require.NotNil(t, entry)
+}
+
+func TestWithContext_NoRequestFields(t *testing.T) {
+ // plain context without any request fields — should not panic.
+ entry := WithContext(context.Background())
+ require.NotNil(t, entry)
+}
+
+func TestLogger_NotNil(t *testing.T) {
+ l := Logger()
+ require.NotNil(t, l)
+}
diff --git a/pkg/metrics/registry_extra_test.go b/pkg/metrics/registry_extra_test.go
new file mode 100644
index 0000000..22dcdcf
--- /dev/null
+++ b/pkg/metrics/registry_extra_test.go
@@ -0,0 +1,33 @@
+package metrics
+
+import (
+ "errors"
+ "testing"
+ "time"
+)
+
+var errTest = errors.New("test error")
+
+func TestNodeOpObserve_Success(t *testing.T) {
+ NodeOpObserve("test_op", time.Now().Add(-time.Second), nil)
+}
+
+func TestNodeOpObserve_Error(t *testing.T) {
+ NodeOpObserve("test_op_err", time.Now().Add(-time.Second), errTest)
+}
+
+func TestControllerOpObserve_Success(t *testing.T) {
+ ControllerOpObserve("ctrl_op", time.Now().Add(-time.Second), nil)
+}
+
+func TestControllerOpObserve_Error(t *testing.T) {
+ ControllerOpObserve("ctrl_op_err", time.Now().Add(-time.Second), errTest)
+}
+
+func TestNodePullOpObserve_Success(t *testing.T) {
+ NodePullOpObserve("pull_layer", 1024*1024, time.Now().Add(-time.Second), nil)
+}
+
+func TestNodePullOpObserve_Error(t *testing.T) {
+ NodePullOpObserve("pull_layer_err", 512, time.Now().Add(-time.Second), errTest)
+}
diff --git a/pkg/metrics/serve_test.go b/pkg/metrics/serve_test.go
new file mode 100644
index 0000000..7b4d0a3
--- /dev/null
+++ b/pkg/metrics/serve_test.go
@@ -0,0 +1,105 @@
+package metrics
+
+import (
+ "os"
+ "testing"
+ "time"
+
+ "github.com/prometheus/client_golang/prometheus"
+ "github.com/stretchr/testify/require"
+)
+
+// ─── GetAddrByEnv ─────────────────────────────────────────────────────────────
+
+func TestGetAddrByEnv_Local(t *testing.T) {
+ addr := GetAddrByEnv("tcp://$POD_IP:5244", true)
+ require.Equal(t, "tcp://127.0.0.1:5244", addr)
+}
+
+func TestGetAddrByEnv_WithPodIPEnv(t *testing.T) {
+ require.NoError(t, os.Setenv(EnvPodIP, "10.0.0.1"))
+ defer func() { _ = os.Unsetenv(EnvPodIP) }()
+
+ addr := GetAddrByEnv("tcp://$POD_IP:5244", false)
+ require.Equal(t, "tcp://10.0.0.1:5244", addr)
+}
+
+func TestGetAddrByEnv_DefaultHost(t *testing.T) {
+ _ = os.Unsetenv(EnvPodIP)
+ addr := GetAddrByEnv("tcp://$POD_IP:5244", false)
+ require.Equal(t, "tcp://0.0.0.0:5244", addr)
+}
+
+func TestGetAddrByEnv_NoPlaceholder(t *testing.T) {
+ addr := GetAddrByEnv("tcp://127.0.0.1:9090", false)
+ require.Equal(t, "tcp://127.0.0.1:9090", addr)
+}
+
+// ─── NewServer ────────────────────────────────────────────────────────────────
+
+func TestNewServer_EmptyAddr(t *testing.T) {
+ _, err := NewServer("")
+ require.Error(t, err)
+}
+
+func TestNewServer_ValidAddr(t *testing.T) {
+ srv, err := NewServer("tcp://127.0.0.1:0")
+ require.NoError(t, err)
+ require.NotNil(t, srv)
+
+ stop := make(chan struct{})
+ go srv.Serve(stop)
+ time.Sleep(10 * time.Millisecond)
+ close(stop)
+ time.Sleep(50 * time.Millisecond)
+}
+
+func TestNewServer_InvalidPort(t *testing.T) {
+ // port 99999 is out of range.
+ _, err := NewServer("tcp://127.0.0.1:99999")
+ require.Error(t, err)
+}
+
+// ─── MountItemCollector ───────────────────────────────────────────────────────
+
+func TestMountItemCollector_SetAndCollect(t *testing.T) {
+ c := NewMountItemCollector()
+ items := []MountItem{
+ {Reference: "reg/model:v1", Type: "pvc", VolumeName: "pvc-vol", MountID: ""},
+ {Reference: "reg/model:v2", Type: "dynamic", VolumeName: "csi-vol", MountID: "mount-1"},
+ }
+ c.Set(items)
+
+ // Describe
+ descCh := make(chan *prometheus.Desc, 5)
+ c.Describe(descCh)
+ close(descCh)
+ var descs []*prometheus.Desc
+ for d := range descCh {
+ descs = append(descs, d)
+ }
+ require.Len(t, descs, 1)
+
+ // Collect
+ metricCh := make(chan prometheus.Metric, 10)
+ c.Collect(metricCh)
+ close(metricCh)
+ var mets []prometheus.Metric
+ for m := range metricCh {
+ mets = append(mets, m)
+ }
+ require.Len(t, mets, 2)
+}
+
+func TestMountItemCollector_Empty(t *testing.T) {
+ c := NewMountItemCollector()
+ // Collect on empty should not produce any metrics, not panic.
+ metricCh := make(chan prometheus.Metric, 5)
+ c.Collect(metricCh)
+ close(metricCh)
+ var mets []prometheus.Metric
+ for m := range metricCh {
+ mets = append(mets, m)
+ }
+ require.Empty(t, mets)
+}
diff --git a/pkg/mounter/mounter_extra_test.go b/pkg/mounter/mounter_extra_test.go
new file mode 100644
index 0000000..3452b1e
--- /dev/null
+++ b/pkg/mounter/mounter_extra_test.go
@@ -0,0 +1,52 @@
+package mounter
+
+import (
+ "context"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+// Test execCmd with a harmless command
+func TestExecCmd_Success(t *testing.T) {
+ out, err := execCmd(context.Background(), "echo", "hello")
+ require.NoError(t, err)
+ require.Contains(t, out, "hello")
+}
+
+func TestExecCmd_Failure(t *testing.T) {
+ _, err := execCmd(context.Background(), "false")
+ require.Error(t, err)
+}
+
+// Test UMount with empty mountpoint
+func TestUMount_EmptyMountPoint(t *testing.T) {
+ err := UMount(context.Background(), "", false)
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "not specified")
+}
+
+// Test UMount on a directory that is definitely not mounted (expects no error from swallowed "not mounted" message)
+func TestUMount_NotMounted(t *testing.T) {
+ tmpDir := t.TempDir()
+ // umount will return "not mounted" which UMount swallows; just ensure no panic
+ _ = UMount(context.Background(), tmpDir, false)
+}
+
+// Test that UMount lazy path is reachable
+func TestUMount_LazyPath_EmptyMountPoint(t *testing.T) {
+ err := UMount(context.Background(), "", true)
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "not specified")
+}
+
+// Test Mount actually runs execCmd (will fail without root but covers the function)
+func TestMount_ExecFails_CoversFunction(t *testing.T) {
+ tmpDir := t.TempDir()
+ // Mount will call execCmd("mount", "--bind", "/source", tmpDir) which fails (no root)
+ // This exercises the Mount function including the error return path
+ err := Mount(context.Background(), NewBuilder().Bind().From("/nonexistent/source/path").MountPoint(tmpDir))
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "mount failed")
+}
+
diff --git a/pkg/mounter/mounter_test.go b/pkg/mounter/mounter_test.go
new file mode 100644
index 0000000..89fe4fe
--- /dev/null
+++ b/pkg/mounter/mounter_test.go
@@ -0,0 +1,101 @@
+package mounter
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+// ─── EnsureMountPoint ─────────────────────────────────────────────────────────
+
+func TestEnsureMountPoint_NonExistent(t *testing.T) {
+ tmpDir := t.TempDir()
+ target := filepath.Join(tmpDir, "a", "b", "c")
+
+ err := EnsureMountPoint(context.Background(), target)
+ require.NoError(t, err)
+
+ info, err := os.Stat(target)
+ require.NoError(t, err)
+ require.True(t, info.IsDir())
+}
+
+func TestEnsureMountPoint_AlreadyExists(t *testing.T) {
+ tmpDir := t.TempDir()
+ // Calling twice should not fail.
+ err := EnsureMountPoint(context.Background(), tmpDir)
+ require.NoError(t, err)
+}
+
+// ─── IsMounted ────────────────────────────────────────────────────────────────
+
+func TestIsMounted_NonExistentPath(t *testing.T) {
+ mounted, err := IsMounted(context.Background(), "/non/existent/path/12345")
+ require.NoError(t, err)
+ require.False(t, mounted)
+}
+
+func TestIsMounted_ExistingNotMounted(t *testing.T) {
+ tmpDir := t.TempDir()
+ mounted, err := IsMounted(context.Background(), tmpDir)
+ require.NoError(t, err)
+ // tmpDir is not a mount point (usually).
+ _ = mounted // could be true on CI if tmpDir is itself a mount point; just no error.
+}
+
+// ─── MountBuilder ─────────────────────────────────────────────────────────────
+
+func TestMountBuilder_Bind_Build(t *testing.T) {
+ tmpDir := t.TempDir()
+ target := filepath.Join(tmpDir, "target")
+
+ cmd, err := NewBuilder().Bind().From("/source").MountPoint(target).Build()
+ require.NoError(t, err)
+ require.NotEmpty(t, cmd.String())
+}
+
+func TestMountBuilder_RBind_Build(t *testing.T) {
+ tmpDir := t.TempDir()
+ target := filepath.Join(tmpDir, "target")
+
+ cmd, err := NewBuilder().RBind().From("/source").MountPoint(target).Build()
+ require.NoError(t, err)
+ require.NotEmpty(t, cmd.String())
+}
+
+func TestMountBuilder_Tmpfs_Build(t *testing.T) {
+ tmpDir := t.TempDir()
+ target := filepath.Join(tmpDir, "tmpfs-target")
+
+ cmd, err := NewBuilder().Tmpfs().Size("1073741824").MountPoint(target).Build()
+ require.NoError(t, err)
+ require.Contains(t, cmd.String(), "tmpfs")
+}
+
+func TestMountBuilder_MissingMountPoint(t *testing.T) {
+ b := &MountBuilder{command: "mount"}
+ _, err := b.Build()
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "mountPoint is required")
+}
+
+func TestMountCmd_String(t *testing.T) {
+ cmd := MountCmd{command: "mount", args: []string{"--bind", "/src", "/dst"}}
+ s := cmd.String()
+ require.Contains(t, s, "mount")
+ require.Contains(t, s, "--bind")
+}
+
+func TestMountBuilder_Size_Capped(t *testing.T) {
+ tmpDir := t.TempDir()
+ target := filepath.Join(tmpDir, "t")
+
+ // One very large size should be capped to 2<<30
+ cmd, err := NewBuilder().Tmpfs().Size("99999999999").MountPoint(target).Build()
+ require.NoError(t, err)
+ // Size should be capped at 2*1024*1024*1024 = 2147483648
+ require.Contains(t, cmd.String(), "2147483648")
+}
diff --git a/pkg/provider/provider_test.go b/pkg/provider/provider_test.go
new file mode 100644
index 0000000..3ce2b44
--- /dev/null
+++ b/pkg/provider/provider_test.go
@@ -0,0 +1,53 @@
+package provider
+
+import (
+ "context"
+ "net"
+ "testing"
+
+ "github.com/modelpack/model-csi-driver/pkg/config"
+ "github.com/modelpack/model-csi-driver/pkg/service"
+ "github.com/rexray/gocsi"
+ "github.com/stretchr/testify/require"
+ nooptrace "go.opentelemetry.io/otel/trace/noop"
+
+ "github.com/modelpack/model-csi-driver/pkg/tracing"
+)
+
+func TestNew(t *testing.T) {
+ // Initialize noop tracer so tracing.Tracer is not nil
+ tracing.Tracer = nooptrace.NewTracerProvider().Tracer("provider-test")
+
+ raw := &config.RawConfig{
+ ServiceName: "test.csi.example.com",
+ Mode: "node",
+ RootDir: t.TempDir(),
+ }
+ cfg := config.NewWithRaw(raw)
+
+ svc := &service.Service{}
+ provider, err := New(cfg, svc)
+ require.NoError(t, err)
+ require.NotNil(t, provider)
+}
+
+func TestNew_BeforeServe(t *testing.T) {
+ tracing.Tracer = nooptrace.NewTracerProvider().Tracer("provider-test")
+
+ raw := &config.RawConfig{
+ ServiceName: "test.csi.example.com",
+ Mode: "node",
+ RootDir: t.TempDir(),
+ }
+ cfg := config.NewWithRaw(raw)
+
+ svc := &service.Service{}
+ p, err := New(cfg, svc)
+ require.NoError(t, err)
+
+ sp := p.(*gocsi.StoragePlugin)
+ // Call BeforeServe directly using a nil listener (the callback only logs)
+ err = sp.BeforeServe(context.Background(), sp, (net.Listener)(nil))
+ require.NoError(t, err)
+}
+
diff --git a/pkg/server/server_test.go b/pkg/server/server_test.go
index 528fff8..e6eb103 100644
--- a/pkg/server/server_test.go
+++ b/pkg/server/server_test.go
@@ -33,7 +33,7 @@ type mockPuller struct {
}
func (puller *mockPuller) Pull(
- ctx context.Context, reference, targetDir string, excludeModelWeights bool,
+ ctx context.Context, reference, targetDir string, excludeModelWeights bool, excludeFilePatterns []string,
) error {
if err := os.MkdirAll(targetDir, 0755); err != nil {
return err
diff --git a/pkg/service/cache_extra_test.go b/pkg/service/cache_extra_test.go
new file mode 100644
index 0000000..089240f
--- /dev/null
+++ b/pkg/service/cache_extra_test.go
@@ -0,0 +1,96 @@
+package service
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/container-storage-interface/spec/lib/go/csi"
+ "github.com/modelpack/model-csi-driver/pkg/config"
+ "github.com/modelpack/model-csi-driver/pkg/status"
+ "github.com/stretchr/testify/require"
+)
+
+func TestNewCacheManager(t *testing.T) {
+ tmpDir := t.TempDir()
+ rawCfg := &config.RawConfig{
+ ServiceName: "test.csi.extra.com",
+ RootDir: tmpDir,
+ }
+ cfg := config.NewWithRaw(rawCfg)
+ sm, err := status.NewStatusManager()
+ require.NoError(t, err)
+
+ // Do not modify CacheScanInterval here: the background goroutine started by
+ // NewCacheManager reads it concurrently, so writing and then restoring the
+ // global would cause a data race under -race.
+ cm, err := NewCacheManager(cfg, sm)
+ require.NoError(t, err)
+ require.NotNil(t, cm)
+}
+
+func TestCacheManager_getCacheSize(t *testing.T) {
+ tmpDir := t.TempDir()
+ rawCfg := &config.RawConfig{
+ ServiceName: "test.csi.extra.com",
+ RootDir: tmpDir,
+ }
+ cfg := config.NewWithRaw(rawCfg)
+ sm, err := status.NewStatusManager()
+ require.NoError(t, err)
+
+ cm := &CacheManager{cfg: cfg, sm: sm}
+ size, err := cm.getCacheSize()
+ require.NoError(t, err)
+ require.GreaterOrEqual(t, size, int64(0))
+}
+
+func TestCacheManager_Scan_EmptyDir(t *testing.T) {
+ tmpDir := t.TempDir()
+ rawCfg := &config.RawConfig{
+ ServiceName: "test.csi.extra.com",
+ RootDir: tmpDir,
+ }
+ cfg := config.NewWithRaw(rawCfg)
+ sm, err := status.NewStatusManager()
+ require.NoError(t, err)
+
+ cm := &CacheManager{cfg: cfg, sm: sm}
+ err = cm.Scan()
+ require.NoError(t, err)
+}
+
+// --- localListVolumes ---
+
+func TestLocalListVolumes_EmptyVolumesDir(t *testing.T) {
+ svc, tmpDir := newNodeService(t)
+ ctx := context.Background()
+ require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, "volumes"), 0750))
+ _, err := svc.localListVolumes(ctx, &csi.ListVolumesRequest{})
+ require.NoError(t, err)
+}
+
+func TestLocalListVolumes_NoVolumesDir(t *testing.T) {
+ svc, _ := newNodeService(t)
+ ctx := context.Background()
+ _, err := svc.localListVolumes(ctx, &csi.ListVolumesRequest{})
+ require.Error(t, err)
+}
+
+// --- localCreateVolume dynamic path ---
+
+func TestLocalCreateVolume_DynamicPath_VolumeDirNotExist(t *testing.T) {
+ svc, _ := newNodeService(t)
+ ctx := context.Background()
+ _, _, err := svc.localCreateVolume(ctx, &csi.CreateVolumeRequest{
+ Name: "csi-vol",
+ Parameters: map[string]string{
+ svc.cfg.Get().ParameterKeyType(): "image",
+ svc.cfg.Get().ParameterKeyReference(): "test/model:latest",
+ svc.cfg.Get().ParameterKeyMountID(): "mount-1",
+ },
+ })
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "volume directory does not exist")
+}
diff --git a/pkg/service/controller.go b/pkg/service/controller.go
index 532ce7a..ef488a1 100644
--- a/pkg/service/controller.go
+++ b/pkg/service/controller.go
@@ -1,13 +1,13 @@
package service
import (
+ "context"
"os"
"path/filepath"
"time"
"go.opentelemetry.io/otel/attribute"
otelCodes "go.opentelemetry.io/otel/codes"
- "golang.org/x/net/context"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
diff --git a/pkg/service/controller_local.go b/pkg/service/controller_local.go
index 4a02997..b13cd3d 100644
--- a/pkg/service/controller_local.go
+++ b/pkg/service/controller_local.go
@@ -1,6 +1,8 @@
package service
import (
+ "context"
+ "encoding/json"
"fmt"
"os"
"path/filepath"
@@ -16,7 +18,6 @@ import (
"go.opentelemetry.io/otel/attribute"
otelCodes "go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
- "golang.org/x/net/context"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
@@ -67,6 +68,12 @@ func (s *Service) localCreateVolume(ctx context.Context, req *csi.CreateVolumeRe
return nil, isStaticVolume, status.Errorf(codes.InvalidArgument, "invalid parameter:%s: %v", s.cfg.Get().ParameterKeyExcludeModelWeights(), err)
}
}
+ excludeFilePatterns := []string{}
+ if excludeFilePatternsParam := strings.TrimSpace(parameters[s.cfg.Get().ParameterKeyExcludeFilePatterns()]); excludeFilePatternsParam != "" {
+ if err := json.Unmarshal([]byte(excludeFilePatternsParam), &excludeFilePatterns); err != nil {
+ return nil, isStaticVolume, status.Errorf(codes.InvalidArgument, "invalid parameter:%s: %v", s.cfg.Get().ParameterKeyExcludeFilePatterns(), err)
+ }
+ }
parentSpan := trace.SpanFromContext(ctx)
parentSpan.SetAttributes(attribute.String("volume_name", volumeName))
@@ -78,7 +85,7 @@ func (s *Service) localCreateVolume(ctx context.Context, req *csi.CreateVolumeRe
startedAt := time.Now()
ctx, span := tracing.Tracer.Start(ctx, "PullModel")
span.SetAttributes(attribute.String("model_dir", modelDir))
- if err := s.worker.PullModel(ctx, isStaticVolume, volumeName, "", modelReference, modelDir, checkDiskQuota, excludeModelWeights); err != nil {
+ if err := s.worker.PullModel(ctx, isStaticVolume, volumeName, "", modelReference, modelDir, checkDiskQuota, excludeModelWeights, excludeFilePatterns); err != nil {
span.SetStatus(otelCodes.Error, "failed to pull model")
span.RecordError(err)
span.End()
@@ -111,7 +118,7 @@ func (s *Service) localCreateVolume(ctx context.Context, req *csi.CreateVolumeRe
startedAt := time.Now()
ctx, span := tracing.Tracer.Start(ctx, "PullModel")
span.SetAttributes(attribute.String("model_dir", modelDir))
- if err := s.worker.PullModel(ctx, isStaticVolume, volumeName, mountID, modelReference, modelDir, checkDiskQuota, excludeModelWeights); err != nil {
+ if err := s.worker.PullModel(ctx, isStaticVolume, volumeName, mountID, modelReference, modelDir, checkDiskQuota, excludeModelWeights, excludeFilePatterns); err != nil {
span.SetStatus(otelCodes.Error, "failed to pull model")
span.RecordError(err)
span.End()
diff --git a/pkg/service/controller_remote.go b/pkg/service/controller_remote.go
index 134b5af..89a644f 100644
--- a/pkg/service/controller_remote.go
+++ b/pkg/service/controller_remote.go
@@ -1,6 +1,7 @@
package service
import (
+ "context"
"fmt"
"time"
@@ -8,7 +9,6 @@ import (
"go.opentelemetry.io/otel/attribute"
otelCodes "go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
- "golang.org/x/net/context"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure"
diff --git a/pkg/service/controller_test.go b/pkg/service/controller_test.go
new file mode 100644
index 0000000..41cc9ee
--- /dev/null
+++ b/pkg/service/controller_test.go
@@ -0,0 +1,360 @@
+package service
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/container-storage-interface/spec/lib/go/csi"
+ "github.com/modelpack/model-csi-driver/pkg/config"
+ "github.com/modelpack/model-csi-driver/pkg/status"
+ "github.com/stretchr/testify/require"
+ "google.golang.org/grpc/codes"
+ grpcStatus "google.golang.org/grpc/status"
+)
+
+// newNodeService creates a Service wired up for node-mode testing.
+func newNodeService(t *testing.T) (*Service, string) {
+ t.Helper()
+ tmpDir := t.TempDir()
+ rawCfg := &config.RawConfig{
+ ServiceName: "test.csi.example.com",
+ NodeID: "test-node-1",
+ RootDir: tmpDir,
+ }
+ cfg := config.NewWithRaw(rawCfg)
+ sm, err := status.NewStatusManager()
+ require.NoError(t, err)
+ worker, err := NewWorker(cfg, sm)
+ require.NoError(t, err)
+ return &Service{cfg: cfg, sm: sm, worker: worker}, tmpDir
+}
+
+// ─── Simple stub controller methods ────────────────────────────────────────────
+
+func TestControllerPublishVolume(t *testing.T) {
+ svc := newTestService(t)
+ resp, err := svc.ControllerPublishVolume(context.Background(), &csi.ControllerPublishVolumeRequest{})
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+}
+
+func TestControllerUnpublishVolume(t *testing.T) {
+ svc := newTestService(t)
+ resp, err := svc.ControllerUnpublishVolume(context.Background(), &csi.ControllerUnpublishVolumeRequest{})
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+}
+
+func TestValidateVolumeCapabilities_Unimplemented(t *testing.T) {
+ svc := newTestService(t)
+ _, err := svc.ValidateVolumeCapabilities(context.Background(), &csi.ValidateVolumeCapabilitiesRequest{})
+ require.Error(t, err)
+ st, _ := grpcStatus.FromError(err)
+ require.Equal(t, codes.Unimplemented, st.Code())
+}
+
+func TestGetCapacity_Unimplemented(t *testing.T) {
+ svc := newTestService(t)
+ _, err := svc.GetCapacity(context.Background(), &csi.GetCapacityRequest{})
+ require.Error(t, err)
+ st, _ := grpcStatus.FromError(err)
+ require.Equal(t, codes.Unimplemented, st.Code())
+}
+
+func TestControllerGetCapabilities(t *testing.T) {
+ svc := newTestService(t)
+ resp, err := svc.ControllerGetCapabilities(context.Background(), &csi.ControllerGetCapabilitiesRequest{})
+ require.NoError(t, err)
+ require.NotEmpty(t, resp.Capabilities)
+}
+
+func TestCreateSnapshot_Unimplemented(t *testing.T) {
+ svc := newTestService(t)
+ _, err := svc.CreateSnapshot(context.Background(), &csi.CreateSnapshotRequest{})
+ require.Error(t, err)
+ st, _ := grpcStatus.FromError(err)
+ require.Equal(t, codes.Unimplemented, st.Code())
+}
+
+func TestDeleteSnapshot_Unimplemented(t *testing.T) {
+ svc := newTestService(t)
+ _, err := svc.DeleteSnapshot(context.Background(), &csi.DeleteSnapshotRequest{})
+ require.Error(t, err)
+ st, _ := grpcStatus.FromError(err)
+ require.Equal(t, codes.Unimplemented, st.Code())
+}
+
+func TestListSnapshots_Unimplemented(t *testing.T) {
+ svc := newTestService(t)
+ _, err := svc.ListSnapshots(context.Background(), &csi.ListSnapshotsRequest{})
+ require.Error(t, err)
+ st, _ := grpcStatus.FromError(err)
+ require.Equal(t, codes.Unimplemented, st.Code())
+}
+
+func TestControllerExpandVolume_Unimplemented(t *testing.T) {
+ svc := newTestService(t)
+ _, err := svc.ControllerExpandVolume(context.Background(), &csi.ControllerExpandVolumeRequest{})
+ require.Error(t, err)
+ st, _ := grpcStatus.FromError(err)
+ require.Equal(t, codes.Unimplemented, st.Code())
+}
+
+// ─── getDynamicVolume / ListDynamicVolumes ─────────────────────────────────────
+
+func TestGetDynamicVolume_NotFound(t *testing.T) {
+ svc, _ := newNodeService(t)
+ _, err := svc.GetDynamicVolume(context.Background(), "csi-vol", "mount-1")
+ require.Error(t, err)
+}
+
+func TestGetDynamicVolume_Found(t *testing.T) {
+ svc, tmpDir := newNodeService(t)
+
+ volumeName := "csi-vol-1"
+ mountID := "mount-abc"
+ _ = tmpDir
+
+ mountIDDir := svc.cfg.Get().GetMountIDDirForDynamic(volumeName, mountID)
+ require.NoError(t, os.MkdirAll(mountIDDir, 0755))
+ statusPath := filepath.Join(mountIDDir, "status.json")
+ _, err := svc.sm.Set(statusPath, status.Status{
+ Reference: "registry/model:v1",
+ State: status.StatePullSucceeded,
+ })
+ require.NoError(t, err)
+
+ st, err := svc.GetDynamicVolume(context.Background(), volumeName, mountID)
+ require.NoError(t, err)
+ require.Equal(t, "registry/model:v1", st.Reference)
+}
+
+func TestListDynamicVolumes_EmptyDir(t *testing.T) {
+ svc, _ := newNodeService(t)
+
+ // Non-existent directory should return error.
+ _, err := svc.ListDynamicVolumes(context.Background(), "csi-vol-nonexistent")
+ require.Error(t, err)
+}
+
+func TestListDynamicVolumes_WithItems(t *testing.T) {
+ svc, _ := newNodeService(t)
+
+ volumeName := "csi-list-vol"
+
+ // Create two mounts.
+ for _, mountID := range []string{"mount-1", "mount-2"} {
+ mountIDDir := svc.cfg.Get().GetMountIDDirForDynamic(volumeName, mountID)
+ require.NoError(t, os.MkdirAll(mountIDDir, 0755))
+ statusPath := filepath.Join(mountIDDir, "status.json")
+ _, err := svc.sm.Set(statusPath, status.Status{
+ MountID: mountID,
+ Reference: "reg/model:v1",
+ State: status.StatePullSucceeded,
+ })
+ require.NoError(t, err)
+ }
+
+ statuses, err := svc.ListDynamicVolumes(context.Background(), volumeName)
+ require.NoError(t, err)
+ require.Len(t, statuses, 2)
+}
+
+// ─── localCreateVolume validation ──────────────────────────────────────────────
+
+func TestLocalCreateVolume_MissingVolumeName(t *testing.T) {
+ svc, _ := newNodeService(t)
+ _, _, err := svc.localCreateVolume(context.Background(), &csi.CreateVolumeRequest{
+ Name: "",
+ })
+ require.Error(t, err)
+ st, _ := grpcStatus.FromError(err)
+ require.Equal(t, codes.InvalidArgument, st.Code())
+}
+
+func TestLocalCreateVolume_MissingModelType(t *testing.T) {
+ svc, _ := newNodeService(t)
+ _, _, err := svc.localCreateVolume(context.Background(), &csi.CreateVolumeRequest{
+ Name: "pvc-test",
+ Parameters: map[string]string{},
+ })
+ require.Error(t, err)
+ st, _ := grpcStatus.FromError(err)
+ require.Equal(t, codes.InvalidArgument, st.Code())
+}
+
+func TestLocalCreateVolume_MissingReference(t *testing.T) {
+ svc, _ := newNodeService(t)
+ _, _, err := svc.localCreateVolume(context.Background(), &csi.CreateVolumeRequest{
+ Name: "pvc-test",
+ Parameters: map[string]string{
+ svc.cfg.Get().ParameterKeyType(): "image",
+ },
+ })
+ require.Error(t, err)
+ st, _ := grpcStatus.FromError(err)
+ require.Equal(t, codes.InvalidArgument, st.Code())
+}
+
+func TestLocalCreateVolume_UnsupportedModelType(t *testing.T) {
+ svc, _ := newNodeService(t)
+ _, _, err := svc.localCreateVolume(context.Background(), &csi.CreateVolumeRequest{
+ Name: "pvc-test",
+ Parameters: map[string]string{
+ svc.cfg.Get().ParameterKeyType(): "oci",
+ svc.cfg.Get().ParameterKeyReference(): "registry/model:v1",
+ },
+ })
+ require.Error(t, err)
+ st, _ := grpcStatus.FromError(err)
+ require.Equal(t, codes.InvalidArgument, st.Code())
+}
+
+func TestLocalCreateVolume_InvalidCheckDiskQuota(t *testing.T) {
+ svc, _ := newNodeService(t)
+ _, _, err := svc.localCreateVolume(context.Background(), &csi.CreateVolumeRequest{
+ Name: "pvc-test",
+ Parameters: map[string]string{
+ svc.cfg.Get().ParameterKeyType(): "image",
+ svc.cfg.Get().ParameterKeyReference(): "registry/model:v1",
+ svc.cfg.Get().ParameterKeyCheckDiskQuota(): "invalid",
+ },
+ })
+ require.Error(t, err)
+ st, _ := grpcStatus.FromError(err)
+ require.Equal(t, codes.InvalidArgument, st.Code())
+}
+
+func TestLocalCreateVolume_InvalidExcludeModelWeights(t *testing.T) {
+ svc, _ := newNodeService(t)
+ _, _, err := svc.localCreateVolume(context.Background(), &csi.CreateVolumeRequest{
+ Name: "pvc-test",
+ Parameters: map[string]string{
+ svc.cfg.Get().ParameterKeyType(): "image",
+ svc.cfg.Get().ParameterKeyReference(): "registry/model:v1",
+ svc.cfg.Get().ParameterKeyExcludeModelWeights(): "notabool",
+ },
+ })
+ require.Error(t, err)
+ st, _ := grpcStatus.FromError(err)
+ require.Equal(t, codes.InvalidArgument, st.Code())
+}
+
+func TestLocalCreateVolume_InvalidExcludeFilePatterns(t *testing.T) {
+ svc, _ := newNodeService(t)
+ _, _, err := svc.localCreateVolume(context.Background(), &csi.CreateVolumeRequest{
+ Name: "pvc-test",
+ Parameters: map[string]string{
+ svc.cfg.Get().ParameterKeyType(): "image",
+ svc.cfg.Get().ParameterKeyReference(): "registry/model:v1",
+ svc.cfg.Get().ParameterKeyExcludeFilePatterns(): `not-valid-json`,
+ },
+ })
+ require.Error(t, err)
+ st, _ := grpcStatus.FromError(err)
+ require.Equal(t, codes.InvalidArgument, st.Code())
+}
+
+// ─── localDeleteVolume validation ──────────────────────────────────────────────
+
+func TestLocalDeleteVolume_EmptyVolumeID(t *testing.T) {
+ svc, _ := newNodeService(t)
+ _, _, err := svc.localDeleteVolume(context.Background(), &csi.DeleteVolumeRequest{VolumeId: ""})
+ require.Error(t, err)
+ st, _ := grpcStatus.FromError(err)
+ require.Equal(t, codes.InvalidArgument, st.Code())
+}
+
+func TestLocalDeleteVolume_StaticVolume_NonExistent(t *testing.T) {
+ svc, _ := newNodeService(t)
+ resp, _, err := svc.localDeleteVolume(context.Background(), &csi.DeleteVolumeRequest{VolumeId: "pvc-nonexistent"})
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+}
+
+func TestLocalDeleteVolume_DynamicVolume_NonExistent(t *testing.T) {
+ svc, _ := newNodeService(t)
+ resp, _, err := svc.localDeleteVolume(context.Background(), &csi.DeleteVolumeRequest{
+ VolumeId: "csi-vol/mount-abc",
+ })
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+}
+
+func TestLocalDeleteVolume_InvalidFormat(t *testing.T) {
+ svc, _ := newNodeService(t)
+ _, _, err := svc.localDeleteVolume(context.Background(), &csi.DeleteVolumeRequest{
+ VolumeId: "a/b/c",
+ })
+ require.Error(t, err)
+ st, _ := grpcStatus.FromError(err)
+ require.Equal(t, codes.InvalidArgument, st.Code())
+}
+
+// ─── StatusManager helper ────────────────────────────────────────────────────
+
+func TestService_StatusManager(t *testing.T) {
+ svc, _ := newNodeService(t)
+ require.NotNil(t, svc.StatusManager())
+}
+
+// --- CreateVolume / DeleteVolume / ListVolumes (controller.go) ---
+
+func TestCreateVolume_NodeMode_MissingParams(t *testing.T) {
+ svc, _ := newNodeService(t)
+ ctx := context.Background()
+ // Missing type parameter – localCreateVolume should return error
+ _, err := svc.CreateVolume(ctx, &csi.CreateVolumeRequest{
+ Name: "pvc-test",
+ Parameters: map[string]string{},
+ })
+ require.Error(t, err)
+}
+
+func TestCreateVolume_NodeMode_InvalidType(t *testing.T) {
+ svc, _ := newNodeService(t)
+ ctx := context.Background()
+ _, err := svc.CreateVolume(ctx, &csi.CreateVolumeRequest{
+ Name: "pvc-test",
+ Parameters: map[string]string{
+ svc.cfg.Get().ParameterKeyType(): "unsupported",
+ svc.cfg.Get().ParameterKeyReference(): "test/model:latest",
+ },
+ })
+ require.Error(t, err)
+}
+
+func TestDeleteVolume_NodeMode_EmptyID(t *testing.T) {
+ svc, _ := newNodeService(t)
+ ctx := context.Background()
+ _, err := svc.DeleteVolume(ctx, &csi.DeleteVolumeRequest{VolumeId: ""})
+ require.Error(t, err)
+}
+
+func TestDeleteVolume_NodeMode_StaticVolume(t *testing.T) {
+ svc, tmpDir := newNodeService(t)
+ ctx := context.Background()
+ volDir := filepath.Join(tmpDir, "volumes", "pvc-del-static")
+ require.NoError(t, os.MkdirAll(volDir, 0750))
+ _, err := svc.DeleteVolume(ctx, &csi.DeleteVolumeRequest{VolumeId: "pvc-del-static"})
+ require.NoError(t, err)
+}
+
+func TestListVolumes_NodeMode(t *testing.T) {
+ svc, _ := newNodeService(t)
+ ctx := context.Background()
+ // Node mode always returns Unimplemented for ListVolumes
+ _, err := svc.ListVolumes(ctx, &csi.ListVolumesRequest{})
+ require.Error(t, err)
+}
+
+// --- NewDynamicServerManager ---
+
+func TestNewDynamicServerManager(t *testing.T) {
+ svc, _ := newNodeService(t)
+ mgr := NewDynamicServerManager(svc.cfg, svc)
+ require.NotNil(t, mgr)
+}
+
diff --git a/pkg/service/dynamic_server_handler.go b/pkg/service/dynamic_server_handler.go
index 9a8dbac..4f6af6e 100644
--- a/pkg/service/dynamic_server_handler.go
+++ b/pkg/service/dynamic_server_handler.go
@@ -1,6 +1,7 @@
package service
import (
+ "encoding/json"
"errors"
"fmt"
"net/http"
@@ -86,14 +87,23 @@ func (h *DynamicServerHandler) CreateVolume(c echo.Context) error {
})
}
- _, err := h.svc.CreateVolume(c.Request().Context(), &csi.CreateVolumeRequest{
+ excludeFilePatternsJSON, err := json.Marshal(req.ExcludeFilePatterns)
+ if err != nil {
+ return c.JSON(http.StatusBadRequest, ErrorResponse{
+ Code: ERR_CODE_INVALID_ARGUMENT,
+ Message: "invalid exclude_file_patterns",
+ })
+ }
+
+ _, err = h.svc.CreateVolume(c.Request().Context(), &csi.CreateVolumeRequest{
Name: volumeName,
Parameters: map[string]string{
- h.cfg.Get().ParameterKeyType(): "image",
- h.cfg.Get().ParameterKeyReference(): req.Reference,
- h.cfg.Get().ParameterKeyMountID(): req.MountID,
- h.cfg.Get().ParameterKeyCheckDiskQuota(): strconv.FormatBool(req.CheckDiskQuota),
- h.cfg.Get().ParameterKeyExcludeModelWeights(): strconv.FormatBool(req.ExcludeModelWeights),
+ h.cfg.Get().ParameterKeyType(): "image",
+ h.cfg.Get().ParameterKeyReference(): req.Reference,
+ h.cfg.Get().ParameterKeyMountID(): req.MountID,
+ h.cfg.Get().ParameterKeyCheckDiskQuota(): strconv.FormatBool(req.CheckDiskQuota),
+ h.cfg.Get().ParameterKeyExcludeModelWeights(): strconv.FormatBool(req.ExcludeModelWeights),
+ h.cfg.Get().ParameterKeyExcludeFilePatterns(): string(excludeFilePatternsJSON),
},
})
if err != nil {
diff --git a/pkg/service/dynamic_server_test.go b/pkg/service/dynamic_server_test.go
new file mode 100644
index 0000000..6d40fc1
--- /dev/null
+++ b/pkg/service/dynamic_server_test.go
@@ -0,0 +1,125 @@
+package service
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/modelpack/model-csi-driver/pkg/config"
+ modelStatus "github.com/modelpack/model-csi-driver/pkg/status"
+ "github.com/stretchr/testify/require"
+)
+
+func newTestDynamicServerManager(t *testing.T) (*DynamicServerManager, string) {
+ t.Helper()
+ svc, tmpDir := newNodeService(t)
+ rawCfg := &config.RawConfig{
+ ServiceName: "test.csi.example.com",
+ RootDir: tmpDir,
+ }
+ cfg := config.NewWithRaw(rawCfg)
+ mgr := NewDynamicServerManager(cfg, svc)
+ return mgr, tmpDir
+}
+
+func TestDynamicServerManager_CloseServer_NotExists(t *testing.T) {
+ mgr, _ := newTestDynamicServerManager(t)
+ // Closing a server that doesn't exist should return nil
+ err := mgr.CloseServer(context.Background(), "/tmp/nonexistent.sock")
+ require.NoError(t, err)
+}
+
+func TestDynamicServerManager_CreateServer(t *testing.T) {
+ mgr, tmpDir := newTestDynamicServerManager(t)
+ sockPath := filepath.Join(tmpDir, "test.sock")
+
+ server, err := mgr.CreateServer(context.Background(), sockPath)
+ require.NoError(t, err)
+ require.NotNil(t, server)
+
+ // cleanup
+ _ = mgr.CloseServer(context.Background(), sockPath)
+}
+
+func TestDynamicServerManager_CloseServer_AfterCreate(t *testing.T) {
+ mgr, tmpDir := newTestDynamicServerManager(t)
+ sockPath := filepath.Join(tmpDir, "close-test.sock")
+
+ _, err := mgr.CreateServer(context.Background(), sockPath)
+ require.NoError(t, err)
+
+ err = mgr.CloseServer(context.Background(), sockPath)
+ require.NoError(t, err)
+
+ // Closing again should be a no-op
+ err = mgr.CloseServer(context.Background(), sockPath)
+ require.NoError(t, err)
+}
+
+func TestDynamicServerManager_CreateServer_ExistingReplaced(t *testing.T) {
+ mgr, tmpDir := newTestDynamicServerManager(t)
+ sockPath := filepath.Join(tmpDir, "replace-test.sock")
+
+ // Create server twice - should replace the existing one
+ _, err := mgr.CreateServer(context.Background(), sockPath)
+ require.NoError(t, err)
+
+ _, err = mgr.CreateServer(context.Background(), sockPath)
+ require.NoError(t, err)
+
+ _ = mgr.CloseServer(context.Background(), sockPath)
+}
+
+func TestDynamicServerManager_RecoverServers_NoVolumesDir(t *testing.T) {
+ mgr, _ := newTestDynamicServerManager(t)
+ // No volumes dir → should handle gracefully (empty or error)
+ // RecoverServers reads volumes dir - if it doesn't exist, it should return error or nil
+ err := mgr.RecoverServers(context.Background())
+ // Error is expected since volumes dir doesn't exist
+ _ = err // may or may not error; just ensure no panic
+}
+
+func TestDynamicServerManager_RecoverServers_EmptyDir(t *testing.T) {
+ mgr, tmpDir := newTestDynamicServerManager(t)
+ // Create empty volumes dir
+ volumesDir := filepath.Join(tmpDir, "volumes")
+ require.NoError(t, os.MkdirAll(volumesDir, 0750))
+
+ err := mgr.RecoverServers(context.Background())
+ require.NoError(t, err)
+}
+
+func TestDynamicServerManager_RecoverServers_WithDynamicVolume(t *testing.T) {
+ mgr, tmpDir := newTestDynamicServerManager(t)
+ volumesDir := filepath.Join(tmpDir, "volumes")
+ require.NoError(t, os.MkdirAll(volumesDir, 0750))
+
+ // Create a dynamic volume dir structure
+ volumeName := fmt.Sprintf("csi-dyn-test-%d", os.Getpid())
+ sockDir := filepath.Join(volumesDir, volumeName, "csi")
+ require.NoError(t, os.MkdirAll(sockDir, 0750))
+
+ // Create a status.json in models dir to simulate a running mount
+ mountID := "mount-1"
+ mountIDDir := mgr.cfg.Get().GetMountIDDirForDynamic(volumeName, mountID)
+ require.NoError(t, os.MkdirAll(mountIDDir, 0750))
+ statusPath := filepath.Join(mountIDDir, "status.json")
+ _, err := mgr.svc.sm.Set(statusPath, modelStatus.Status{
+ VolumeName: volumeName,
+ MountID: mountID,
+ Reference: "test/model:latest",
+ State: modelStatus.StatePullSucceeded,
+ })
+ require.NoError(t, err)
+
+ // RecoverServers - may create a dynamic server
+ err = mgr.RecoverServers(context.Background())
+ // May succeed or fail depending on socket creation; just ensure no panic
+ _ = err
+
+ // Cleanup any created servers
+ sockPath := mgr.cfg.Get().GetCSISockPathForDynamic(volumeName)
+ _ = mgr.CloseServer(context.Background(), sockPath)
+}
diff --git a/pkg/service/handler_test.go b/pkg/service/handler_test.go
new file mode 100644
index 0000000..43e5df0
--- /dev/null
+++ b/pkg/service/handler_test.go
@@ -0,0 +1,201 @@
+package service
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/labstack/echo/v4"
+ "github.com/modelpack/model-csi-driver/pkg/config"
+ "github.com/stretchr/testify/require"
+ "google.golang.org/grpc/codes"
+ grpcStatus "google.golang.org/grpc/status"
+)
+
+// --- checkIdentifier ---
+
+func TestCheckIdentifier_Empty(t *testing.T) {
+ require.False(t, checkIdentifier(""))
+}
+
+func TestCheckIdentifier_Valid(t *testing.T) {
+ require.True(t, checkIdentifier("my-volume_123"))
+}
+
+func TestCheckIdentifier_InvalidChars(t *testing.T) {
+ require.False(t, checkIdentifier("vol/invalid"))
+ require.False(t, checkIdentifier("vol invalid"))
+ require.False(t, checkIdentifier("vol.name"))
+}
+
+// --- handleError ---
+
+func newEchoContext(t *testing.T, body string) (echo.Context, *httptest.ResponseRecorder) {
+ t.Helper()
+ e := echo.New()
+ req := httptest.NewRequest(http.MethodPost, "/", bytes.NewBufferString(body))
+ req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
+ rec := httptest.NewRecorder()
+ return e.NewContext(req, rec), rec
+}
+
+func TestHandleError_InvalidArgument(t *testing.T) {
+ c, rec := newEchoContext(t, "")
+ err := grpcStatus.Error(codes.InvalidArgument, "bad param")
+ _ = handleError(c, err)
+ require.Equal(t, http.StatusBadRequest, rec.Code)
+
+ var resp ErrorResponse
+ require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
+ require.Equal(t, ERR_CODE_INVALID_ARGUMENT, resp.Code)
+}
+
+func TestHandleError_ResourceExhausted(t *testing.T) {
+ c, rec := newEchoContext(t, "")
+ err := grpcStatus.Error(codes.ResourceExhausted, "disk full")
+ _ = handleError(c, err)
+ require.Equal(t, http.StatusNotAcceptable, rec.Code)
+
+ var resp ErrorResponse
+ require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
+ require.Equal(t, ERR_CODE_INSUFFICIENT_DISK_QUOTA, resp.Code)
+}
+
+func TestHandleError_Internal(t *testing.T) {
+ c, rec := newEchoContext(t, "")
+ err := grpcStatus.Error(codes.Internal, "boom")
+ _ = handleError(c, err)
+ require.Equal(t, http.StatusInternalServerError, rec.Code)
+
+ var resp ErrorResponse
+ require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
+ require.Equal(t, ERR_CODE_INTERNAL, resp.Code)
+}
+
+// --- DynamicServerHandler ---
+
+func newHandler(t *testing.T) (*DynamicServerHandler, *Service) {
+ t.Helper()
+ svc, _ := newNodeService(t)
+ rawCfg := &config.RawConfig{
+ ServiceName: "test.csi.example.com",
+ RootDir: t.TempDir(),
+ }
+ cfg := config.NewWithRaw(rawCfg)
+ return &DynamicServerHandler{cfg: cfg, svc: svc}, svc
+}
+
+func newHandlerContextWithParam(t *testing.T, method, url, body string, paramNames, paramValues []string) (echo.Context, *httptest.ResponseRecorder) {
+ t.Helper()
+ e := echo.New()
+ req := httptest.NewRequest(method, url, bytes.NewBufferString(body))
+ req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
+ rec := httptest.NewRecorder()
+ c := e.NewContext(req, rec)
+ c.SetParamNames(paramNames...)
+ c.SetParamValues(paramValues...)
+ return c, rec
+}
+
+func TestDynamicServerHandler_CreateVolume_InvalidVolumeName(t *testing.T) {
+ h, _ := newHandler(t)
+ c, rec := newHandlerContextWithParam(t, http.MethodPost, "/", `{"mount_id":"m1","reference":"test/model:latest"}`,
+ []string{"volume_name"}, []string{"invalid/name"})
+ _ = h.CreateVolume(c)
+ require.Equal(t, http.StatusBadRequest, rec.Code)
+}
+
+func TestDynamicServerHandler_CreateVolume_InvalidMountID(t *testing.T) {
+ h, _ := newHandler(t)
+ c, rec := newHandlerContextWithParam(t, http.MethodPost, "/", `{"mount_id":"bad/mount","reference":"test/model:latest"}`,
+ []string{"volume_name"}, []string{"my-volume"})
+ _ = h.CreateVolume(c)
+ require.Equal(t, http.StatusBadRequest, rec.Code)
+}
+
+func TestDynamicServerHandler_CreateVolume_MissingReference(t *testing.T) {
+ h, _ := newHandler(t)
+ c, rec := newHandlerContextWithParam(t, http.MethodPost, "/", `{"mount_id":"m1","reference":""}`,
+ []string{"volume_name"}, []string{"my-volume"})
+ _ = h.CreateVolume(c)
+ require.Equal(t, http.StatusBadRequest, rec.Code)
+}
+
+func TestDynamicServerHandler_CreateVolume_CreatesVolume(t *testing.T) {
+ h, _ := newHandler(t)
+ body := `{"mount_id":"m1","reference":"test/model:latest","check_disk_quota":false}`
+ c, rec := newHandlerContextWithParam(t, http.MethodPost, "/", body,
+ []string{"volume_name"}, []string{"my-volume"})
+ _ = h.CreateVolume(c)
+ // Either Created (201) or error from localCreateVolume (e.g. InvalidArgument > 400)
+ require.Contains(t, []int{http.StatusCreated, http.StatusBadRequest, http.StatusInternalServerError}, rec.Code)
+}
+
+func TestDynamicServerHandler_GetVolume_InvalidVolumeName(t *testing.T) {
+ h, _ := newHandler(t)
+ c, rec := newHandlerContextWithParam(t, http.MethodGet, "/", "",
+ []string{"volume_name", "mount_id"}, []string{"bad/vol", "m1"})
+ _ = h.GetVolume(c)
+ require.Equal(t, http.StatusBadRequest, rec.Code)
+}
+
+func TestDynamicServerHandler_GetVolume_InvalidMountID(t *testing.T) {
+ h, _ := newHandler(t)
+ c, rec := newHandlerContextWithParam(t, http.MethodGet, "/", "",
+ []string{"volume_name", "mount_id"}, []string{"my-volume", "bad/mount"})
+ _ = h.GetVolume(c)
+ require.Equal(t, http.StatusBadRequest, rec.Code)
+}
+
+func TestDynamicServerHandler_GetVolume_NotFound(t *testing.T) {
+ h, _ := newHandler(t)
+ c, rec := newHandlerContextWithParam(t, http.MethodGet, "/", "",
+ []string{"volume_name", "mount_id"}, []string{"my-volume", "m1"})
+ _ = h.GetVolume(c)
+ require.Equal(t, http.StatusNotFound, rec.Code)
+}
+
+func TestDynamicServerHandler_DeleteVolume_InvalidVolumeName(t *testing.T) {
+ h, _ := newHandler(t)
+ c, rec := newHandlerContextWithParam(t, http.MethodDelete, "/", "",
+ []string{"volume_name", "mount_id"}, []string{"bad/vol", "m1"})
+ _ = h.DeleteVolume(c)
+ require.Equal(t, http.StatusBadRequest, rec.Code)
+}
+
+func TestDynamicServerHandler_DeleteVolume_InvalidMountID(t *testing.T) {
+ h, _ := newHandler(t)
+ c, rec := newHandlerContextWithParam(t, http.MethodDelete, "/", "",
+ []string{"volume_name", "mount_id"}, []string{"my-volume", "bad/mount"})
+ _ = h.DeleteVolume(c)
+ require.Equal(t, http.StatusBadRequest, rec.Code)
+}
+
+func TestDynamicServerHandler_DeleteVolume_Success(t *testing.T) {
+ h, _ := newHandler(t)
+ // volume doesn't exist → localDeleteVolume returns OK (removes, even if not there)
+ c, rec := newHandlerContextWithParam(t, http.MethodDelete, "/", "",
+ []string{"volume_name", "mount_id"}, []string{"my-volume", "m1"})
+ _ = h.DeleteVolume(c)
+ // NoContent or InternalServerError depending on vol state
+ require.Contains(t, []int{http.StatusNoContent, http.StatusInternalServerError}, rec.Code)
+}
+
+func TestDynamicServerHandler_ListVolumes_InvalidVolumeName(t *testing.T) {
+ h, _ := newHandler(t)
+ c, rec := newHandlerContextWithParam(t, http.MethodGet, "/", "",
+ []string{"volume_name"}, []string{"bad/vol"})
+ _ = h.ListVolumes(c)
+ require.Equal(t, http.StatusBadRequest, rec.Code)
+}
+
+func TestDynamicServerHandler_ListVolumes_EmptyDir(t *testing.T) {
+ h, _ := newHandler(t)
+ c, rec := newHandlerContextWithParam(t, http.MethodGet, "/", "",
+ []string{"volume_name"}, []string{"my-volume"})
+ _ = h.ListVolumes(c)
+ // Non-existent models dir → error
+ require.Equal(t, http.StatusInternalServerError, rec.Code)
+}
diff --git a/pkg/service/identity.go b/pkg/service/identity.go
index ad356a3..8f71dd6 100644
--- a/pkg/service/identity.go
+++ b/pkg/service/identity.go
@@ -1,7 +1,7 @@
package service
import (
- "golang.org/x/net/context"
+ "context"
"github.com/container-storage-interface/spec/lib/go/csi"
)
diff --git a/pkg/service/main_test.go b/pkg/service/main_test.go
new file mode 100644
index 0000000..f9e8b0f
--- /dev/null
+++ b/pkg/service/main_test.go
@@ -0,0 +1,16 @@
+package service
+
+import (
+ "os"
+ "testing"
+
+ "go.opentelemetry.io/otel/trace/noop"
+
+ "github.com/modelpack/model-csi-driver/pkg/tracing"
+)
+
+func TestMain(m *testing.M) {
+ // Initialize a noop tracer so tests that call tracing.Tracer.Start don't panic.
+ tracing.Tracer = noop.NewTracerProvider().Tracer("service-test")
+ os.Exit(m.Run())
+}
diff --git a/pkg/service/model.go b/pkg/service/model.go
index 1879046..0b8a949 100644
--- a/pkg/service/model.go
+++ b/pkg/service/model.go
@@ -1,16 +1,17 @@
package service
import (
+ "context"
"path/filepath"
"sync"
"time"
+ gitignore "github.com/go-git/go-git/v5/plumbing/format/gitignore"
"github.com/modelpack/modctl/pkg/backend"
modctlConfig "github.com/modelpack/modctl/pkg/config"
"github.com/modelpack/model-csi-driver/pkg/logger"
"github.com/modelpack/model-csi-driver/pkg/utils"
"github.com/pkg/errors"
- "golang.org/x/net/context"
)
type ModelArtifact struct {
@@ -37,6 +38,25 @@ func isWeightLayer(layer backend.InspectedModelArtifactLayer) bool {
return false
}
+// matchFilePatterns matches filename against gitignore-style patterns using
+// github.com/go-git/go-git/v5/plumbing/format/gitignore.
+// Patterns are processed in order; the last matching pattern wins.
+// A pattern prefixed with "!" negates the match (i.e. forces inclusion).
+// Returns (matched=true, excluded=true) when the file should be excluded,
+// (matched=true, excluded=false) when a negation pattern overrides, and
+// (matched=false, _) when no pattern matches (caller applies fallback logic).
+func matchFilePatterns(filename string, patterns []string) (matched bool, excluded bool) {
+ for _, p := range patterns {
+ result := gitignore.ParsePattern(p, nil).Match([]string{filename}, false)
+ if result == gitignore.NoMatch {
+ continue
+ }
+ matched = true
+ excluded = result == gitignore.Exclude
+ }
+ return
+}
+
func NewModelArtifact(b backend.Backend, reference string, plainHTTP bool) *ModelArtifact {
return &ModelArtifact{
Reference: reference,
@@ -81,34 +101,51 @@ func (m *ModelArtifact) inspect(ctx context.Context) error {
return nil
}
-func (m *ModelArtifact) getLayers(ctx context.Context, excludeWeights bool) ([]backend.InspectedModelArtifactLayer, error) {
+func (m *ModelArtifact) getLayers(ctx context.Context, excludeWeights bool, excludeFilePatterns []string) (
+ []backend.InspectedModelArtifactLayer, int, error,
+) {
if err := m.inspect(ctx); err != nil {
- return nil, errors.Wrapf(err, "inspect model: %s", m.Reference)
+ return nil, 0, errors.Wrapf(err, "inspect model: %s", m.Reference)
}
layers := []backend.InspectedModelArtifactLayer{}
for idx := range m.artifact.Layers {
layer := m.artifact.Layers[idx]
- if excludeWeights {
- if layer.Filepath == "" {
- logger.Logger().WithContext(ctx).Warnf(
- "layer %s has no file path, skip", layer.Digest,
- )
- continue
- }
- if !isWeightLayer(layer) {
+
+ // If no filtering is requested, include all layers without further checks.
+ if !excludeWeights && len(excludeFilePatterns) == 0 {
+ layers = append(layers, layer)
+ continue
+ }
+
+ if layer.Filepath == "" {
+ logger.Logger().WithContext(ctx).Warnf(
+ "layer %s has no file path, skip", layer.Digest,
+ )
+ continue
+ }
+
+ filename := filepath.Base(layer.Filepath)
+
+ // exclude_file_patterns takes precedence over exclude_model_weights.
+ if matched, excluded := matchFilePatterns(filename, excludeFilePatterns); matched {
+ if !excluded {
layers = append(layers, layer)
}
- } else {
+ continue
+ }
+
+ // Fallback: apply weight-based exclusion.
+ if !excludeWeights || !isWeightLayer(layer) {
layers = append(layers, layer)
}
}
- return layers, nil
+ return layers, len(m.artifact.Layers), nil
}
-func (m *ModelArtifact) GetSize(ctx context.Context, excludeWeights bool) (int64, error) {
- layers, err := m.getLayers(ctx, excludeWeights)
+func (m *ModelArtifact) GetSize(ctx context.Context, excludeWeights bool, excludeFilePatterns []string) (int64, error) {
+ layers, _, err := m.getLayers(ctx, excludeWeights, excludeFilePatterns)
if err != nil {
return 0, errors.Wrapf(err, "get layers for model: %s", m.Reference)
}
@@ -127,10 +164,10 @@ func (m *ModelArtifact) GetSize(ctx context.Context, excludeWeights bool) (int64
return totalSize, nil
}
-func (m *ModelArtifact) GetPatterns(ctx context.Context, excludeWeights bool) ([]string, error) {
- layers, err := m.getLayers(ctx, excludeWeights)
+func (m *ModelArtifact) GetPatterns(ctx context.Context, excludeWeights bool, excludeFilePatterns []string) ([]string, int, error) {
+ layers, total, err := m.getLayers(ctx, excludeWeights, excludeFilePatterns)
if err != nil {
- return nil, errors.Wrapf(err, "get layers for model: %s", m.Reference)
+ return nil, 0, errors.Wrapf(err, "get layers for model: %s", m.Reference)
}
paths := []string{}
@@ -138,5 +175,5 @@ func (m *ModelArtifact) GetPatterns(ctx context.Context, excludeWeights bool) ([
paths = append(paths, layers[idx].Filepath)
}
- return paths, nil
+ return paths, total, nil
}
diff --git a/pkg/service/model_test.go b/pkg/service/model_test.go
index 0999a46..2c76514 100644
--- a/pkg/service/model_test.go
+++ b/pkg/service/model_test.go
@@ -50,19 +50,99 @@ func TestModelArtifact(t *testing.T) {
modelArtifact := NewModelArtifact(b, "test/model:latest", true)
- size, err := modelArtifact.GetSize(ctx, false)
+ size, err := modelArtifact.GetSize(ctx, false, nil)
require.NoError(t, err)
require.Equal(t, int64(5*1024*1024), size)
- size, err = modelArtifact.GetSize(ctx, true)
+ size, err = modelArtifact.GetSize(ctx, true, nil)
require.NoError(t, err)
require.Equal(t, int64(2*1024*1024), size)
- paths, err := modelArtifact.GetPatterns(ctx, false)
+ paths, total, err := modelArtifact.GetPatterns(ctx, false, nil)
require.NoError(t, err)
+ require.Equal(t, 3, total)
require.Equal(t, []string{"foo.safetensors", "README.md", "bar.zoo.safetensors"}, paths)
- paths, err = modelArtifact.GetPatterns(ctx, true)
+ paths, total, err = modelArtifact.GetPatterns(ctx, true, nil)
require.NoError(t, err)
+ require.Equal(t, 3, total)
require.Equal(t, []string{"README.md"}, paths)
+
+ // exclude_file_patterns > exclude_model_weights:
+ // negation pattern "!foo.safetensors" forces inclusion of that weight file
+ // even though exclude_model_weights=true would normally omit it.
+ paths, _, err = modelArtifact.GetPatterns(ctx, true, []string{"!foo.safetensors"})
+ require.NoError(t, err)
+ require.Equal(t, []string{"foo.safetensors", "README.md"}, paths)
+
+ // Exclude by glob pattern only (no exclude_model_weights)
+ paths, _, err = modelArtifact.GetPatterns(ctx, false, []string{"*.safetensors"})
+ require.NoError(t, err)
+ require.Equal(t, []string{"README.md"}, paths)
+
+ // Exclude by glob, then negate a specific file: last match wins.
+ paths, _, err = modelArtifact.GetPatterns(ctx, false, []string{"*.safetensors", "!foo.safetensors"})
+ require.NoError(t, err)
+ require.Equal(t, []string{"foo.safetensors", "README.md"}, paths)
+}
+
+func TestMatchFilePatterns(t *testing.T) {
+ tests := []struct {
+ name string
+ filename string
+ patterns []string
+ wantMatched bool
+ wantExcluded bool
+ }{
+ {
+ name: "no patterns",
+ filename: "model.safetensors",
+ patterns: nil,
+ wantMatched: false,
+ wantExcluded: false,
+ },
+ {
+ name: "exact match excludes",
+ filename: "model.safetensors.index.json",
+ patterns: []string{"model.safetensors.index.json"},
+ wantMatched: true,
+ wantExcluded: true,
+ },
+ {
+ name: "glob match excludes",
+ filename: "model-00001-of-00003.safetensors",
+ patterns: []string{"*.safetensors"},
+ wantMatched: true,
+ wantExcluded: true,
+ },
+ {
+ name: "negation overrides earlier exclude",
+ filename: "tiktoken.model",
+ patterns: []string{"*.model", "!tiktoken.model"},
+ wantMatched: true,
+ wantExcluded: false,
+ },
+ {
+ name: "last matching pattern wins (exclude after negate)",
+ filename: "tiktoken.model",
+ patterns: []string{"!tiktoken.model", "*.model"},
+ wantMatched: true,
+ wantExcluded: true,
+ },
+ {
+ name: "no match returns unmatched",
+ filename: "README.md",
+ patterns: []string{"*.safetensors"},
+ wantMatched: false,
+ wantExcluded: false,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ matched, excluded := matchFilePatterns(tc.filename, tc.patterns)
+ require.Equal(t, tc.wantMatched, matched)
+ require.Equal(t, tc.wantExcluded, excluded)
+ })
+ }
}
diff --git a/pkg/service/node.go b/pkg/service/node.go
index 6a0ec6c..4fd9baf 100644
--- a/pkg/service/node.go
+++ b/pkg/service/node.go
@@ -1,6 +1,8 @@
package service
import (
+ "context"
+ "encoding/json"
"path/filepath"
"strconv"
"strings"
@@ -9,7 +11,6 @@ import (
"go.opentelemetry.io/otel/attribute"
otelCodes "go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
- "golang.org/x/net/context"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
@@ -103,9 +104,15 @@ func (s *Service) nodePublishVolume(
return nil, isStaticVolume, status.Errorf(codes.InvalidArgument, "invalid parameter:%s: %v", s.cfg.Get().ParameterKeyExcludeModelWeights(), err)
}
}
+ excludeFilePatterns := []string{}
+ if excludeFilePatternsParam := strings.TrimSpace(volumeAttributes[s.cfg.Get().ParameterKeyExcludeFilePatterns()]); excludeFilePatternsParam != "" {
+ if err := json.Unmarshal([]byte(excludeFilePatternsParam), &excludeFilePatterns); err != nil {
+ return nil, isStaticVolume, status.Errorf(codes.InvalidArgument, "invalid parameter:%s: %v", s.cfg.Get().ParameterKeyExcludeFilePatterns(), err)
+ }
+ }
logger.WithContext(ctx).Infof("publishing static inline volume: %s", staticInlineModelReference)
- resp, err := s.nodePublishVolumeStaticInlineVolume(ctx, volumeID, targetPath, staticInlineModelReference, excludeModelWeights)
+ resp, err := s.nodePublishVolumeStaticInlineVolume(ctx, volumeID, targetPath, staticInlineModelReference, excludeModelWeights, excludeFilePatterns)
return resp, isStaticVolume, err
}
diff --git a/pkg/service/node_dynamic.go b/pkg/service/node_dynamic.go
index c8c33cb..1c22638 100644
--- a/pkg/service/node_dynamic.go
+++ b/pkg/service/node_dynamic.go
@@ -1,6 +1,7 @@
package service
import (
+ "context"
"os"
"path/filepath"
@@ -10,7 +11,6 @@ import (
modelStatus "github.com/modelpack/model-csi-driver/pkg/status"
"github.com/modelpack/model-csi-driver/pkg/utils"
"github.com/pkg/errors"
- "golang.org/x/net/context"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
diff --git a/pkg/service/node_internal_test.go b/pkg/service/node_internal_test.go
new file mode 100644
index 0000000..de42642
--- /dev/null
+++ b/pkg/service/node_internal_test.go
@@ -0,0 +1,199 @@
+package service
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/agiledragon/gomonkey/v2"
+ "github.com/container-storage-interface/spec/lib/go/csi"
+ "github.com/modelpack/model-csi-driver/pkg/mounter"
+ modelStatus "github.com/modelpack/model-csi-driver/pkg/status"
+ "github.com/stretchr/testify/require"
+ "google.golang.org/grpc"
+)
+
+// nodeUnPublishVolumeStatic with isMounted=false and non-existent status.json
+func TestNodeUnPublishVolumeStatic_NotMounted_NoStatus(t *testing.T) {
+ svc, _ := newNodeService(t)
+ ctx := context.Background()
+ resp, err := svc.nodeUnPublishVolumeStatic(ctx, "pvc-test-vol", "/tmp/target", false)
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+}
+
+// nodeUnPublishVolumeStatic with isMounted=false and existing status.json
+func TestNodeUnPublishVolumeStatic_NotMounted_WithStatus(t *testing.T) {
+ svc, tmpDir := newNodeService(t)
+ ctx := context.Background()
+ volumeName := "pvc-unmount-test"
+ volumeDir := filepath.Join(tmpDir, "volumes", volumeName)
+ require.NoError(t, os.MkdirAll(volumeDir, 0755))
+ statusPath := filepath.Join(volumeDir, "status.json")
+ _, err := svc.sm.Set(statusPath, modelStatus.Status{
+ VolumeName: volumeName,
+ Reference: "test/model:latest",
+ State: modelStatus.StateMounted,
+ })
+ require.NoError(t, err)
+
+ resp, err := svc.nodeUnPublishVolumeStatic(ctx, volumeName, "/tmp/target", false)
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+}
+
+// nodePublishVolumeStatic with gomonkey mocking mounter.Mount
+func TestNodePublishVolumeStatic_MockMount(t *testing.T) {
+ svc, tmpDir := newNodeService(t)
+ ctx := context.Background()
+ volumeName := "pvc-mount-test"
+ volumeDir := filepath.Join(tmpDir, "volumes", volumeName)
+ require.NoError(t, os.MkdirAll(volumeDir, 0755))
+ statusPath := filepath.Join(volumeDir, "status.json")
+ _, err := svc.sm.Set(statusPath, modelStatus.Status{
+ VolumeName: volumeName,
+ Reference: "test/model:latest",
+ State: modelStatus.StatePullSucceeded,
+ })
+ require.NoError(t, err)
+
+ // Mock mounter.Mount to return nil
+ patch := gomonkey.ApplyFunc(mounter.Mount, func(ctx context.Context, builder mounter.Builder) error {
+ return nil
+ })
+ defer patch.Reset()
+
+ resp, err := svc.nodePublishVolumeStatic(ctx, volumeName, t.TempDir())
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+}
+
+// Test NodePublishVolume via full path with mocked IsMounted
+func TestNodePublishVolume_WithMockedMounter(t *testing.T) {
+ svc, tmpDir := newNodeService(t)
+ ctx := context.Background()
+ volumeName := "pvc-publish-test"
+ volumeDir := filepath.Join(tmpDir, "volumes", volumeName)
+ require.NoError(t, os.MkdirAll(volumeDir, 0755))
+ statusPath := filepath.Join(volumeDir, "status.json")
+ _, err := svc.sm.Set(statusPath, modelStatus.Status{
+ VolumeName: volumeName,
+ Reference: "test/model:latest",
+ State: modelStatus.StatePullSucceeded,
+ })
+ require.NoError(t, err)
+
+ // Mock IsMounted to return false (no existing mount)
+ patchIsMounted := gomonkey.ApplyFunc(mounter.IsMounted, func(ctx context.Context, mountPoint string) (bool, error) {
+ return false, nil
+ })
+ defer patchIsMounted.Reset()
+
+ // Mock EnsureMountPoint to succeed
+ patchEnsure := gomonkey.ApplyFunc(mounter.EnsureMountPoint, func(ctx context.Context, mountPoint string) error {
+ return nil
+ })
+ defer patchEnsure.Reset()
+
+ // Mock mounter.Mount to return nil (bind mount)
+ patchMount := gomonkey.ApplyFunc(mounter.Mount, func(ctx context.Context, builder mounter.Builder) error {
+ return nil
+ })
+ defer patchMount.Reset()
+
+ targetPath := t.TempDir()
+ _, err = svc.NodePublishVolume(ctx, &csi.NodePublishVolumeRequest{
+ VolumeId: volumeName,
+ TargetPath: targetPath,
+ })
+ require.NoError(t, err)
+}
+
+// NodeUnpublishVolume with mocked IsMounted
+func TestNodeUnpublishVolume_WithMockedMounter(t *testing.T) {
+ svc, _ := newNodeService(t)
+ ctx := context.Background()
+
+ patchIsMounted := gomonkey.ApplyFunc(mounter.IsMounted, func(ctx context.Context, mountPoint string) (bool, error) {
+ return false, nil
+ })
+ defer patchIsMounted.Reset()
+
+ targetPath := t.TempDir()
+ _, err := svc.NodeUnpublishVolume(ctx, &csi.NodeUnpublishVolumeRequest{
+ VolumeId: "pvc-unmount-mock",
+ TargetPath: targetPath,
+ })
+ require.NoError(t, err)
+}
+// tokenAuthInterceptor covers the grpc interceptor method
+func TestTokenAuthInterceptor(t *testing.T) {
+ svc, _ := newNodeService(t)
+ ctx := context.Background()
+
+ called := false
+ fakeInvoker := grpc.UnaryInvoker(func(ctx context.Context, method string, req, reply interface{}, cc *grpc.ClientConn, opts ...grpc.CallOption) error {
+ called = true
+ return nil
+ })
+
+ err := svc.tokenAuthInterceptor(ctx, "/test.Service/Method", nil, nil, nil, fakeInvoker)
+ require.NoError(t, err)
+ require.True(t, called)
+}
+
+// nodeUnPublishVolumeStaticInlineVolume with isMounted=false
+func TestNodeUnPublishVolumeStaticInlineVolume_NotMounted(t *testing.T) {
+ svc, _ := newNodeService(t)
+ ctx := context.Background()
+ volumeName := "inline-test-vol"
+ targetPath := t.TempDir()
+
+ // Create the volume dir so RemoveAll has something to remove
+ volumeDir := svc.cfg.Get().GetVolumeDir(volumeName)
+ require.NoError(t, os.MkdirAll(volumeDir, 0755))
+
+ resp, err := svc.nodeUnPublishVolumeStaticInlineVolume(ctx, volumeName, targetPath, false)
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+
+ // Volume dir should be removed
+ _, statErr := os.Stat(volumeDir)
+ require.True(t, os.IsNotExist(statErr))
+}
+
+// nodeUnPublishVolumeDynamic with isMounted=false
+func TestNodeUnPublishVolumeDynamic_NotMounted(t *testing.T) {
+ svc, _ := newNodeService(t)
+ ctx := context.Background()
+ volumeName := "dynamic-test-vol"
+ targetPath := t.TempDir()
+
+ // Paths don't exist: IsInSameDevice will error (warning logged), sameDevice=false
+ // UMount on sourceCSIDir will be called and its "not mounted"-style error swallowed
+ // RemoveAll on non-existent sourceVolumeDir is a no-op
+ resp, err := svc.nodeUnPublishVolumeDynamic(ctx, volumeName, targetPath, false)
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+}
+
+// nodePublishVolumeDynamicForRootMount - covers the early mkdir path
+// It will attempt to create dirs, create a CSI server, and then try to bind mount
+func TestNodePublishVolumeDynamicForRootMount_ServerError(t *testing.T) {
+ svc, _ := newNodeService(t)
+ // Initialize DynamicServerManager so it's not nil
+ svc.DynamicServerManager = NewDynamicServerManager(svc.cfg, svc)
+ ctx := context.Background()
+ volumeName := "dynamic-pub-vol"
+ targetPath := t.TempDir()
+
+ // Mock mounter.Mount to return nil so we can reach the status.json creation
+ patchMount := gomonkey.ApplyFunc(mounter.Mount, func(ctx context.Context, builder mounter.Builder) error {
+ return nil
+ })
+ defer patchMount.Reset()
+
+ _, _ = svc.nodePublishVolumeDynamicForRootMount(ctx, volumeName, targetPath)
+ // Just ensure no panic; the function will attempt dirs/server creation
+}
diff --git a/pkg/service/node_static.go b/pkg/service/node_static.go
index b822587..ac4a7fe 100644
--- a/pkg/service/node_static.go
+++ b/pkg/service/node_static.go
@@ -1,6 +1,7 @@
package service
import (
+ "context"
"os"
"path/filepath"
@@ -8,7 +9,6 @@ import (
"github.com/modelpack/model-csi-driver/pkg/mounter"
modelStatus "github.com/modelpack/model-csi-driver/pkg/status"
"github.com/pkg/errors"
- "golang.org/x/net/context"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
diff --git a/pkg/service/node_static_inline.go b/pkg/service/node_static_inline.go
index 4882b72..cc9add1 100644
--- a/pkg/service/node_static_inline.go
+++ b/pkg/service/node_static_inline.go
@@ -1,6 +1,7 @@
package service
import (
+ "context"
"os"
"path/filepath"
"time"
@@ -10,16 +11,15 @@ import (
"github.com/modelpack/model-csi-driver/pkg/mounter"
modelStatus "github.com/modelpack/model-csi-driver/pkg/status"
"github.com/pkg/errors"
- "golang.org/x/net/context"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
-func (s *Service) nodePublishVolumeStaticInlineVolume(ctx context.Context, volumeName, targetPath, reference string, excludeModelWeights bool) (*csi.NodePublishVolumeResponse, error) {
+func (s *Service) nodePublishVolumeStaticInlineVolume(ctx context.Context, volumeName, targetPath, reference string, excludeModelWeights bool, excludeFilePatterns []string) (*csi.NodePublishVolumeResponse, error) {
modelDir := s.cfg.Get().GetModelDir(volumeName)
startedAt := time.Now()
- if err := s.worker.PullModel(ctx, true, volumeName, "", reference, modelDir, false, excludeModelWeights); err != nil {
+ if err := s.worker.PullModel(ctx, true, volumeName, "", reference, modelDir, false, excludeModelWeights, excludeFilePatterns); err != nil {
return nil, status.Error(codes.Internal, errors.Wrap(err, "pull model").Error())
}
duration := time.Since(startedAt)
diff --git a/pkg/service/node_test.go b/pkg/service/node_test.go
new file mode 100644
index 0000000..c0b2dfc
--- /dev/null
+++ b/pkg/service/node_test.go
@@ -0,0 +1,62 @@
+package service
+
+import (
+ "context"
+ "testing"
+
+ "github.com/container-storage-interface/spec/lib/go/csi"
+ "github.com/stretchr/testify/require"
+)
+
+func TestNodePublishVolume_EmptyVolumeID(t *testing.T) {
+ svc, _ := newNodeService(t)
+ ctx := context.Background()
+ _, err := svc.NodePublishVolume(ctx, &csi.NodePublishVolumeRequest{
+ VolumeId: "",
+ TargetPath: "/tmp/target",
+ })
+ require.Error(t, err)
+}
+
+func TestNodePublishVolume_EmptyTargetPath(t *testing.T) {
+ svc, _ := newNodeService(t)
+ ctx := context.Background()
+ _, err := svc.NodePublishVolume(ctx, &csi.NodePublishVolumeRequest{
+ VolumeId: "pvc-test-vol",
+ TargetPath: "",
+ })
+ require.Error(t, err)
+}
+
+func TestNodePublishVolume_StaticVolume_NonExistentTarget(t *testing.T) {
+ svc, _ := newNodeService(t)
+ ctx := context.Background()
+ // mounter.IsMounted will check targetPath existence
+ _, err := svc.NodePublishVolume(ctx, &csi.NodePublishVolumeRequest{
+ VolumeId: "pvc-test-vol",
+ TargetPath: "/tmp/nonexistent/mount/point",
+ })
+ // Either error from IsMounted or EnsureMountPoint since path is deep-nonexistent
+ // The result will be an error (internal) or success depending on OS state
+ _ = err // may or may not error; just ensure no panic
+}
+
+func TestNodeUnpublishVolume_EmptyVolumeID(t *testing.T) {
+ svc, _ := newNodeService(t)
+ ctx := context.Background()
+ _, err := svc.NodeUnpublishVolume(ctx, &csi.NodeUnpublishVolumeRequest{
+ VolumeId: "",
+ TargetPath: "/tmp/target",
+ })
+ require.Error(t, err)
+}
+
+func TestNodeUnpublishVolume_EmptyTargetPath(t *testing.T) {
+ svc, _ := newNodeService(t)
+ ctx := context.Background()
+ _, err := svc.NodeUnpublishVolume(ctx, &csi.NodeUnpublishVolumeRequest{
+ VolumeId: "pvc-test-vol",
+ TargetPath: "",
+ })
+ require.Error(t, err)
+}
diff --git a/pkg/service/puller.go b/pkg/service/puller.go
index 1f28726..65543ec 100644
--- a/pkg/service/puller.go
+++ b/pkg/service/puller.go
@@ -22,7 +22,7 @@ type PullHook interface {
}
type Puller interface {
- Pull(ctx context.Context, reference, targetDir string, excludeModelWeights bool) error
+ 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 {
@@ -39,7 +39,7 @@ type puller struct {
diskQuotaChecker *DiskQuotaChecker
}
-func (p *puller) Pull(ctx context.Context, reference, targetDir string, excludeModelWeights bool) error {
+func (p *puller) Pull(ctx context.Context, reference, targetDir string, excludeModelWeights bool, excludeFilePatterns []string) error {
keyChain, err := auth.GetKeyChainByRef(reference)
if err != nil {
return errors.Wrapf(err, "get auth for model: %s", reference)
@@ -54,7 +54,7 @@ func (p *puller) Pull(ctx context.Context, reference, targetDir string, excludeM
modelArtifact := NewModelArtifact(b, reference, plainHTTP)
if p.diskQuotaChecker != nil {
- if err := p.diskQuotaChecker.Check(ctx, modelArtifact, excludeModelWeights); err != nil {
+ if err := p.diskQuotaChecker.Check(ctx, modelArtifact, excludeModelWeights, excludeFilePatterns); err != nil {
return errors.Wrap(err, "check disk quota")
}
}
@@ -63,7 +63,7 @@ func (p *puller) Pull(ctx context.Context, reference, targetDir string, excludeM
return errors.Wrapf(err, "create model dir: %s", targetDir)
}
- if !excludeModelWeights {
+ if !excludeModelWeights && len(excludeFilePatterns) == 0 {
pullConfig := modctlConfig.NewPull()
pullConfig.Concurrency = int(p.pullCfg.Concurrency)
pullConfig.PlainHTTP = plainHTTP
@@ -84,22 +84,27 @@ func (p *puller) Pull(ctx context.Context, reference, targetDir string, excludeM
return nil
}
- patterns, err := modelArtifact.GetPatterns(ctx, excludeModelWeights)
+ patterns, total, err := modelArtifact.GetPatterns(ctx, excludeModelWeights, excludeFilePatterns)
if err != nil {
return errors.Wrap(err, "get model file patterns without weights")
}
logger.WithContext(ctx).Infof(
- "fetching model without weights: %s, file patterns: %s",
- reference, strings.Join(patterns, ", "),
+ "fetching partial files from model: %s, files: %s (%d/%d)",
+ reference, strings.Join(patterns, ", "), len(patterns), total,
)
+ p.hook.SetTotal(len(patterns))
fetchConfig := modctlConfig.NewFetch()
fetchConfig.Concurrency = int(p.pullCfg.Concurrency)
fetchConfig.PlainHTTP = plainHTTP
fetchConfig.Proxy = p.pullCfg.ProxyURL
+ fetchConfig.DragonflyEndpoint = p.pullCfg.DragonflyEndpoint
fetchConfig.Insecure = true
fetchConfig.Output = targetDir
+ fetchConfig.Hooks = p.hook
+ fetchConfig.ProgressWriter = io.Discard
+ fetchConfig.DisableProgress = true
fetchConfig.Patterns = patterns
if err := b.Fetch(ctx, reference, fetchConfig); err != nil {
diff --git a/pkg/service/pullmodel_test.go b/pkg/service/pullmodel_test.go
new file mode 100644
index 0000000..32d32ba
--- /dev/null
+++ b/pkg/service/pullmodel_test.go
@@ -0,0 +1,79 @@
+package service
+
+import (
+ "context"
+ "path/filepath"
+ "testing"
+
+ "github.com/modelpack/model-csi-driver/pkg/config"
+ "github.com/modelpack/model-csi-driver/pkg/status"
+ pkgerrors "github.com/pkg/errors"
+ "github.com/stretchr/testify/require"
+)
+
+type mockPuller struct {
+ err error
+}
+
+func (m *mockPuller) Pull(ctx context.Context, reference, targetDir string, excludeModelWeights bool, excludeFilePatterns []string) error {
+ return m.err
+}
+
+func newWorkerWithMockPuller(t *testing.T, pullErr error) *Worker {
+ t.Helper()
+ tmpDir := t.TempDir()
+ rawCfg := &config.RawConfig{ServiceName: "test", RootDir: tmpDir}
+ cfg := config.NewWithRaw(rawCfg)
+ sm, err := status.NewStatusManager()
+ require.NoError(t, err)
+
+ worker, err := NewWorker(cfg, sm)
+ require.NoError(t, err)
+
+ worker.newPuller = func(ctx context.Context, pullCfg *config.PullConfig, hook *status.Hook, diskQuotaChecker *DiskQuotaChecker) Puller {
+ return &mockPuller{err: pullErr}
+ }
+ return worker
+}
+
+func TestPullModel_Success(t *testing.T) {
+ worker := newWorkerWithMockPuller(t, nil)
+ ctx := context.Background()
+ volumeName := "pvc-pull-test"
+ modelDir := filepath.Join(worker.cfg.Get().GetVolumeDir(volumeName), "model")
+
+ err := worker.PullModel(ctx, true, volumeName, "", "test/model:latest", modelDir, false, false, nil)
+ require.NoError(t, err)
+}
+
+func TestPullModel_Failure(t *testing.T) {
+ worker := newWorkerWithMockPuller(t, pkgerrors.New("pull failed"))
+ ctx := context.Background()
+ volumeName := "pvc-pull-fail"
+ modelDir := filepath.Join(worker.cfg.Get().GetVolumeDir(volumeName), "model")
+
+ err := worker.PullModel(ctx, true, volumeName, "", "test/model:latest", modelDir, false, false, nil)
+ require.Error(t, err)
+}
+
+func TestPullModel_DynamicVolume_Success(t *testing.T) {
+ worker := newWorkerWithMockPuller(t, nil)
+ ctx := context.Background()
+ volumeName := "csi-dyn-pull"
+ mountID := "mount-1"
+ modelDir := worker.cfg.Get().GetModelDirForDynamic(volumeName, mountID)
+
+ err := worker.PullModel(ctx, false, volumeName, mountID, "test/model:latest", modelDir, false, false, nil)
+ require.NoError(t, err)
+}
+
+func TestPullModel_DynamicVolume_Failure(t *testing.T) {
+ worker := newWorkerWithMockPuller(t, pkgerrors.New("network error"))
+ ctx := context.Background()
+ volumeName := "csi-dyn-fail"
+ mountID := "mount-2"
+ modelDir := worker.cfg.Get().GetModelDirForDynamic(volumeName, mountID)
+
+ err := worker.PullModel(ctx, false, volumeName, mountID, "test/model:latest", modelDir, false, false, nil)
+ require.Error(t, err)
+}
diff --git a/pkg/service/quota.go b/pkg/service/quota.go
index f2c3866..344cf48 100644
--- a/pkg/service/quota.go
+++ b/pkg/service/quota.go
@@ -1,6 +1,7 @@
package service
import (
+ "context"
"fmt"
"os"
"path/filepath"
@@ -11,7 +12,6 @@ import (
"github.com/modelpack/model-csi-driver/pkg/config"
"github.com/modelpack/model-csi-driver/pkg/logger"
"github.com/pkg/errors"
- "golang.org/x/net/context"
)
type DiskQuotaChecker struct {
@@ -64,7 +64,7 @@ func humanizeBytes(size int64) string {
// If cfg.Features.CheckDiskQuota is enabled and the Mount request specifies checkDiskQuota = true:
// - When cfg.Features.DiskUsageLimit == 0: reject if available disk space < model size;
// - When cfg.Features.DiskUsageLimit > 0: reject if (cfg.Features.DiskUsageLimit - used space) < model size;
-func (d *DiskQuotaChecker) Check(ctx context.Context, modelArtifact *ModelArtifact, excludeModelWeights bool) error {
+func (d *DiskQuotaChecker) Check(ctx context.Context, modelArtifact *ModelArtifact, excludeModelWeights bool, excludeFilePatterns []string) error {
availSize := int64(0)
if d.cfg.Get().Features.DiskUsageLimit > 0 {
@@ -82,7 +82,7 @@ func (d *DiskQuotaChecker) Check(ctx context.Context, modelArtifact *ModelArtifa
}
start := time.Now()
- modelSize, err := modelArtifact.GetSize(ctx, excludeModelWeights)
+ modelSize, err := modelArtifact.GetSize(ctx, excludeModelWeights, excludeFilePatterns)
if err != nil {
return errors.Wrap(err, "get model size")
}
diff --git a/pkg/service/quota_test.go b/pkg/service/quota_test.go
index 4afe1be..6a74409 100644
--- a/pkg/service/quota_test.go
+++ b/pkg/service/quota_test.go
@@ -147,7 +147,7 @@ func TestDiskQuotaChecker(t *testing.T) {
modelArtifact := NewModelArtifact(b, "test/model:latest", true)
checker := NewDiskQuotaChecker(cfg)
- err = checker.Check(ctx, modelArtifact, false)
+ err = checker.Check(ctx, modelArtifact, false, nil)
require.NoError(t, err)
// Test case 2: Failed quota check with insufficient space
@@ -155,12 +155,12 @@ func TestDiskQuotaChecker(t *testing.T) {
// The used size is 8MiB
err = os.WriteFile(filepath.Join(tmpDir, "file-2"), make([]byte, 7*1024*1024), 0644)
require.NoError(t, err)
- err = checker.Check(ctx, modelArtifact, false)
+ err = checker.Check(ctx, modelArtifact, false, nil)
require.True(t, errors.Is(err, syscall.ENOSPC))
// Update the DiskUsageLimit to 13MiB + 4096KiB
cfg.Get().Features.DiskUsageLimit = 13*1024*1024 + 4096
- err = checker.Check(ctx, modelArtifact, false)
+ err = checker.Check(ctx, modelArtifact, false, nil)
require.NoError(t, err)
// Test case 3: Check with DiskUsageLimit = 0 (use available disk space)
@@ -177,7 +177,7 @@ func TestDiskQuotaChecker(t *testing.T) {
})
defer patchStatfs.Reset()
- err = checker.Check(ctx, modelArtifact, false)
+ err = checker.Check(ctx, modelArtifact, false, nil)
require.True(t, errors.Is(err, syscall.ENOSPC))
// Mock syscall.Statfs to 5MiB available space
@@ -189,6 +189,6 @@ func TestDiskQuotaChecker(t *testing.T) {
})
defer patchStatfs.Reset()
- err = checker.Check(ctx, modelArtifact, false)
+ err = checker.Check(ctx, modelArtifact, false, nil)
require.NoError(t, err)
}
diff --git a/pkg/service/request.go b/pkg/service/request.go
index 7b182bf..e3c6a38 100644
--- a/pkg/service/request.go
+++ b/pkg/service/request.go
@@ -1,8 +1,9 @@
package service
type MountRequest struct {
- MountID string `json:"mount_id"`
- Reference string `json:"reference"`
- CheckDiskQuota bool `json:"check_disk_quota"`
- ExcludeModelWeights bool `json:"exclude_model_weights"`
+ MountID string `json:"mount_id"`
+ Reference string `json:"reference"`
+ CheckDiskQuota bool `json:"check_disk_quota"`
+ ExcludeModelWeights bool `json:"exclude_model_weights"`
+ ExcludeFilePatterns []string `json:"exclude_file_patterns"`
}
diff --git a/pkg/service/service_node_test.go b/pkg/service/service_node_test.go
new file mode 100644
index 0000000..6f9124c
--- /dev/null
+++ b/pkg/service/service_node_test.go
@@ -0,0 +1,117 @@
+package service
+
+import (
+ "context"
+ "testing"
+
+ "github.com/container-storage-interface/spec/lib/go/csi"
+ "github.com/modelpack/model-csi-driver/pkg/config"
+ "github.com/stretchr/testify/require"
+ "google.golang.org/grpc/codes"
+ grpcStatus "google.golang.org/grpc/status"
+)
+
+func newTestService(t *testing.T) *Service {
+ t.Helper()
+ cfg := config.NewWithRaw(&config.RawConfig{
+ ServiceName: "test.csi.example.com",
+ NodeID: "test-node-1",
+ })
+ return &Service{cfg: cfg}
+}
+
+// Identity
+
+func TestGetPluginInfo(t *testing.T) {
+ svc := newTestService(t)
+ resp, err := svc.GetPluginInfo(context.Background(), &csi.GetPluginInfoRequest{})
+ require.NoError(t, err)
+ require.Equal(t, "test.csi.example.com", resp.Name)
+ require.Equal(t, VendorVersion, resp.VendorVersion)
+}
+
+func TestGetPluginCapabilities(t *testing.T) {
+ svc := newTestService(t)
+ resp, err := svc.GetPluginCapabilities(context.Background(), &csi.GetPluginCapabilitiesRequest{})
+ require.NoError(t, err)
+ require.Len(t, resp.Capabilities, 1)
+ require.NotNil(t, resp.Capabilities[0].GetService())
+ require.Equal(t,
+ csi.PluginCapability_Service_CONTROLLER_SERVICE,
+ resp.Capabilities[0].GetService().Type,
+ )
+}
+
+func TestProbe(t *testing.T) {
+ svc := newTestService(t)
+ resp, err := svc.Probe(context.Background(), &csi.ProbeRequest{})
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+}
+
+// Node stubs
+
+func TestNodeStageVolume(t *testing.T) {
+ svc := newTestService(t)
+ resp, err := svc.NodeStageVolume(context.Background(), &csi.NodeStageVolumeRequest{})
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+}
+
+func TestNodeUnstageVolume(t *testing.T) {
+ svc := newTestService(t)
+ resp, err := svc.NodeUnstageVolume(context.Background(), &csi.NodeUnstageVolumeRequest{})
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+}
+
+func TestNodeGetCapabilities(t *testing.T) {
+ svc := newTestService(t)
+ resp, err := svc.NodeGetCapabilities(context.Background(), &csi.NodeGetCapabilitiesRequest{})
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+ require.Len(t, resp.Capabilities, 1)
+}
+
+func TestNodeGetInfo(t *testing.T) {
+ svc := newTestService(t)
+ resp, err := svc.NodeGetInfo(context.Background(), &csi.NodeGetInfoRequest{})
+ require.NoError(t, err)
+ require.Equal(t, "test-node-1", resp.NodeId)
+}
+
+func TestNodeGetVolumeStats_Unimplemented(t *testing.T) {
+ svc := newTestService(t)
+ _, err := svc.NodeGetVolumeStats(context.Background(), &csi.NodeGetVolumeStatsRequest{})
+ require.Error(t, err)
+ st, ok := grpcStatus.FromError(err)
+ require.True(t, ok)
+ require.Equal(t, codes.Unimplemented, st.Code())
+}
+
+func TestNodeExpandVolume_Unimplemented(t *testing.T) {
+ svc := newTestService(t)
+ _, err := svc.NodeExpandVolume(context.Background(), &csi.NodeExpandVolumeRequest{})
+ require.Error(t, err)
+ st, ok := grpcStatus.FromError(err)
+ require.True(t, ok)
+ require.Equal(t, codes.Unimplemented, st.Code())
+}
+
+// isStaticVolume / isDynamicVolume
+
+func TestIsStaticVolume(t *testing.T) {
+ require.True(t, isStaticVolume("pvc-12345"))
+ require.True(t, isStaticVolume("pvc-"))
+ require.False(t, isStaticVolume("csi-12345"))
+ require.False(t, isStaticVolume("vol-12345"))
+ require.False(t, isStaticVolume(""))
+}
+
+func TestIsDynamicVolume(t *testing.T) {
+ require.True(t, isDynamicVolume("csi-abcdef"))
+ require.True(t, isDynamicVolume("csi-"))
+ require.False(t, isDynamicVolume("pvc-abcdef"))
+ require.False(t, isDynamicVolume("vol-abcdef"))
+ require.False(t, isDynamicVolume(""))
+}
diff --git a/pkg/service/worker.go b/pkg/service/worker.go
index 8678655..8628736 100644
--- a/pkg/service/worker.go
+++ b/pkg/service/worker.go
@@ -126,11 +126,12 @@ func (worker *Worker) PullModel(
modelDir string,
checkDiskQuota bool,
excludeModelWeights bool,
+ excludeFilePatterns []string,
) error {
start := time.Now()
statusPath := filepath.Join(filepath.Dir(modelDir), "status.json")
- err := worker.pullModel(ctx, statusPath, volumeName, mountID, reference, modelDir, checkDiskQuota, excludeModelWeights)
+ err := worker.pullModel(ctx, statusPath, volumeName, mountID, reference, modelDir, checkDiskQuota, excludeModelWeights, excludeFilePatterns)
metrics.NodeOpObserve("pull_image", start, err)
if err != nil && !errors.Is(err, ErrConflict) {
@@ -142,7 +143,7 @@ func (worker *Worker) PullModel(
return err
}
-func (worker *Worker) pullModel(ctx context.Context, statusPath, volumeName, mountID, reference, modelDir string, checkDiskQuota, excludeModelWeights bool) error {
+func (worker *Worker) pullModel(ctx context.Context, statusPath, volumeName, mountID, reference, modelDir string, checkDiskQuota, excludeModelWeights bool, excludeFilePatterns []string) error {
setStatus := func(state status.State) (*status.Status, error) {
status, err := worker.sm.Set(statusPath, status.Status{
VolumeName: volumeName,
@@ -197,7 +198,7 @@ func (worker *Worker) pullModel(ctx context.Context, statusPath, volumeName, mou
if err != nil {
return nil, errors.Wrapf(err, "set status before pull model")
}
- if err := puller.Pull(ctx, reference, modelDir, excludeModelWeights); err != nil {
+ if err := puller.Pull(ctx, reference, modelDir, excludeModelWeights, excludeFilePatterns); err != nil {
if errors.Is(err, context.Canceled) {
err = errors.Wrapf(err, "pull model canceled")
if _, err2 := setStatus(status.StatePullCanceled); err2 != nil {
diff --git a/pkg/service/worker_test.go b/pkg/service/worker_test.go
new file mode 100644
index 0000000..1608cee
--- /dev/null
+++ b/pkg/service/worker_test.go
@@ -0,0 +1,195 @@
+package service
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/modelpack/model-csi-driver/pkg/config"
+ "github.com/modelpack/model-csi-driver/pkg/status"
+ "github.com/stretchr/testify/require"
+)
+
+// ─── ContextMap ───────────────────────────────────────────────────────────────
+
+func TestContextMap_SetAndGet(t *testing.T) {
+ cm := NewContextMap()
+
+ var cancel context.CancelFunc
+ _, cancel = context.WithCancel(context.Background())
+
+ cm.Set("key1", &cancel)
+ got := cm.Get("key1")
+ require.NotNil(t, got)
+}
+
+func TestContextMap_DeleteByNil(t *testing.T) {
+ cm := NewContextMap()
+
+ var cancel context.CancelFunc
+ _, cancel = context.WithCancel(context.Background())
+ cm.Set("key1", &cancel)
+
+ // setting nil deletes the entry
+ cm.Set("key1", nil)
+ require.Nil(t, cm.Get("key1"))
+}
+
+func TestContextMap_GetMissing(t *testing.T) {
+ cm := NewContextMap()
+ require.Nil(t, cm.Get("nonexistent"))
+}
+
+// ─── Worker ───────────────────────────────────────────────────────────────────
+
+func TestNewWorker(t *testing.T) {
+ tmpDir := t.TempDir()
+ rawCfg := &config.RawConfig{ServiceName: "test", RootDir: tmpDir}
+ cfg := config.NewWithRaw(rawCfg)
+ 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) {
+ tmpDir := t.TempDir()
+ rawCfg := &config.RawConfig{ServiceName: "test", RootDir: tmpDir}
+ cfg := config.NewWithRaw(rawCfg)
+ sm, err := status.NewStatusManager()
+ require.NoError(t, err)
+
+ worker, err := NewWorker(cfg, sm)
+ require.NoError(t, err)
+
+ // volumes dir doesn't exist yet, should return false without error.
+ exists := worker.isModelExisted(context.Background(), "registry/model:v1")
+ require.False(t, exists)
+}
+
+func TestIsModelExisted_StaticVolumeMatch(t *testing.T) {
+ tmpDir := t.TempDir()
+ rawCfg := &config.RawConfig{ServiceName: "test", RootDir: tmpDir}
+ cfg := config.NewWithRaw(rawCfg)
+ sm, err := status.NewStatusManager()
+ require.NoError(t, err)
+
+ worker, err := NewWorker(cfg, sm)
+ require.NoError(t, err)
+
+ // Create a static volume with a matching reference and model directory.
+ volumeName := "pvc-static-vol-1"
+ volumeDir := cfg.Get().GetVolumeDir(volumeName)
+ modelDir := cfg.Get().GetModelDir(volumeName)
+ require.NoError(t, os.MkdirAll(modelDir, 0755))
+
+ statusPath := filepath.Join(volumeDir, "status.json")
+ _, err = sm.Set(statusPath, status.Status{
+ VolumeName: volumeName,
+ Reference: "registry/model:v1",
+ State: status.StatePullSucceeded,
+ })
+ require.NoError(t, err)
+
+ exists := worker.isModelExisted(context.Background(), "registry/model:v1")
+ require.True(t, exists)
+}
+
+func TestIsModelExisted_StaticVolume_NoMatch(t *testing.T) {
+ tmpDir := t.TempDir()
+ rawCfg := &config.RawConfig{ServiceName: "test", RootDir: tmpDir}
+ cfg := config.NewWithRaw(rawCfg)
+ sm, err := status.NewStatusManager()
+ require.NoError(t, err)
+
+ worker, err := NewWorker(cfg, sm)
+ require.NoError(t, err)
+
+ volumeName := "pvc-static-vol-2"
+ volumeDir := cfg.Get().GetVolumeDir(volumeName)
+ modelDir := cfg.Get().GetModelDir(volumeName)
+ require.NoError(t, os.MkdirAll(modelDir, 0755))
+
+ statusPath := filepath.Join(volumeDir, "status.json")
+ _, err = sm.Set(statusPath, status.Status{
+ VolumeName: volumeName,
+ Reference: "registry/other-model:v2",
+ State: status.StatePullSucceeded,
+ })
+ require.NoError(t, err)
+
+ // Looking for a different reference.
+ exists := worker.isModelExisted(context.Background(), "registry/model:v1")
+ require.False(t, exists)
+}
+
+func TestIsModelExisted_DynamicVolume(t *testing.T) {
+ tmpDir := t.TempDir()
+ rawCfg := &config.RawConfig{ServiceName: "test", RootDir: tmpDir}
+ cfg := config.NewWithRaw(rawCfg)
+ sm, err := status.NewStatusManager()
+ require.NoError(t, err)
+
+ worker, err := NewWorker(cfg, sm)
+ require.NoError(t, err)
+
+ // Dynamic volume: csi-/models//model
+ volumeName := "csi-dyn-vol-1"
+ mountID := "mount-abc"
+ modelDir := cfg.Get().GetModelDirForDynamic(volumeName, mountID)
+ require.NoError(t, os.MkdirAll(modelDir, 0755))
+
+ mountIDDir := cfg.Get().GetMountIDDirForDynamic(volumeName, mountID)
+ statusPath := filepath.Join(mountIDDir, "status.json")
+ _, err = sm.Set(statusPath, status.Status{
+ Reference: "registry/model:dyn",
+ State: status.StatePullSucceeded,
+ })
+ require.NoError(t, err)
+
+ exists := worker.isModelExisted(context.Background(), "registry/model:dyn")
+ require.True(t, exists)
+}
+
+// ─── DeleteModel ──────────────────────────────────────────────────────────────
+
+func TestDeleteModel_NonExistentDir(t *testing.T) {
+ tmpDir := t.TempDir()
+ rawCfg := &config.RawConfig{ServiceName: "test", RootDir: tmpDir}
+ cfg := config.NewWithRaw(rawCfg)
+ sm, err := status.NewStatusManager()
+ require.NoError(t, err)
+
+ worker, err := NewWorker(cfg, sm)
+ require.NoError(t, err)
+
+ // DeleteModel on a non-existent dir should succeed (RemoveAll is idempotent).
+ err = worker.DeleteModel(context.Background(), true, "pvc-nonexistent", "")
+ require.NoError(t, err)
+}
+
+func TestDeleteModel_ExistingDir(t *testing.T) {
+ tmpDir := t.TempDir()
+ rawCfg := &config.RawConfig{ServiceName: "test", RootDir: tmpDir}
+ cfg := config.NewWithRaw(rawCfg)
+ sm, err := status.NewStatusManager()
+ require.NoError(t, err)
+
+ worker, err := NewWorker(cfg, sm)
+ require.NoError(t, err)
+
+ volumeName := "pvc-del-test"
+ volumeDir := cfg.Get().GetVolumeDir(volumeName)
+ require.NoError(t, os.MkdirAll(volumeDir, 0755))
+
+ err = worker.DeleteModel(context.Background(), true, volumeName, "")
+ require.NoError(t, err)
+
+ _, statErr := os.Stat(volumeDir)
+ require.True(t, os.IsNotExist(statErr))
+}
diff --git a/pkg/status/hook.go b/pkg/status/hook.go
index b54f634..9ff40ff 100644
--- a/pkg/status/hook.go
+++ b/pkg/status/hook.go
@@ -61,6 +61,7 @@ type Hook struct {
ctx context.Context
mutex sync.RWMutex
manifest *ocispec.Manifest
+ total int
pulled atomic.Uint32
progress map[digest.Digest]*ProgressItem
}
@@ -78,11 +79,31 @@ func (h *Hook) getProgressDesc() string {
return fmt.Sprintf("%d/unknown", finished)
}
- total := len(h.manifest.Layers)
+ total := h.getTotal()
return fmt.Sprintf("%d/%d", finished, total)
}
+func (h *Hook) getTotal() int {
+ // Prefer using the total set externally (SetTotal) first.
+ if h.total > 0 {
+ return h.total
+ }
+
+ if h.manifest != nil {
+ return len(h.manifest.Layers)
+ }
+
+ return 0
+}
+
+func (h *Hook) SetTotal(total int) {
+ h.mutex.Lock()
+ defer h.mutex.Unlock()
+
+ h.total = total
+}
+
func (h *Hook) BeforePullLayer(desc ocispec.Descriptor, manifest ocispec.Manifest) {
h.mutex.Lock()
defer h.mutex.Unlock()
@@ -163,15 +184,7 @@ func (h *Hook) getProgress() Progress {
return items[i].StartedAt.Before(items[j].StartedAt)
})
- total := 0
- if h.manifest != nil {
- digestMap := make(map[digest.Digest]bool)
- for idx := range h.manifest.Layers {
- layer := h.manifest.Layers[idx]
- digestMap[layer.Digest] = true
- }
- total = len(digestMap)
- }
+ total := h.getTotal()
return Progress{
Total: total,
diff --git a/pkg/status/status_test.go b/pkg/status/status_test.go
new file mode 100644
index 0000000..20796bf
--- /dev/null
+++ b/pkg/status/status_test.go
@@ -0,0 +1,270 @@
+package status
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/opencontainers/go-digest"
+ ocispec "github.com/opencontainers/image-spec/specs-go/v1"
+ "github.com/stretchr/testify/require"
+ "go.opentelemetry.io/otel/trace/noop"
+
+ "github.com/modelpack/model-csi-driver/pkg/tracing"
+)
+
+func TestMain(m *testing.M) {
+ // Initialize a noop tracer so hook_test doesn't panic.
+ tracing.Tracer = noop.NewTracerProvider().Tracer("test")
+ os.Exit(m.Run())
+}
+
+// ─── StatusManager ────────────────────────────────────────────────────────────
+
+func TestNewStatusManager(t *testing.T) {
+ sm, err := NewStatusManager()
+ require.NoError(t, err)
+ require.NotNil(t, sm)
+ require.NotNil(t, sm.HookManager)
+}
+
+func TestStatusManager_SetAndGet(t *testing.T) {
+ tmpDir := t.TempDir()
+ statusPath := filepath.Join(tmpDir, "status.json")
+
+ sm, err := NewStatusManager()
+ require.NoError(t, err)
+
+ s := Status{
+ VolumeName: "pvc-vol-1",
+ MountID: "mount-1",
+ Reference: "registry/model:v1",
+ State: StatePullRunning,
+ }
+
+ written, err := sm.Set(statusPath, s)
+ require.NoError(t, err)
+ require.Equal(t, StatePullRunning, written.State)
+
+ got, err := sm.Get(statusPath)
+ require.NoError(t, err)
+ require.Equal(t, "pvc-vol-1", got.VolumeName)
+ require.Equal(t, StatePullRunning, got.State)
+}
+
+func TestStatusManager_GetNotExists(t *testing.T) {
+ sm, err := NewStatusManager()
+ require.NoError(t, err)
+
+ _, err = sm.Get("/non/existent/path/status.json")
+ require.Error(t, err)
+ require.ErrorIs(t, err, os.ErrNotExist)
+}
+
+func TestStatusManager_GetEmptyFile(t *testing.T) {
+ tmpDir := t.TempDir()
+ statusPath := filepath.Join(tmpDir, "status.json")
+ require.NoError(t, os.WriteFile(statusPath, []byte(" "), 0644))
+
+ sm, err := NewStatusManager()
+ require.NoError(t, err)
+
+ _, err = sm.Get(statusPath)
+ require.Error(t, err)
+}
+
+func TestStatusManager_GetInvalidJSON(t *testing.T) {
+ tmpDir := t.TempDir()
+ statusPath := filepath.Join(tmpDir, "status.json")
+ require.NoError(t, os.WriteFile(statusPath, []byte("not-json"), 0644))
+
+ sm, err := NewStatusManager()
+ require.NoError(t, err)
+
+ _, err = sm.Get(statusPath)
+ require.Error(t, err)
+}
+
+func TestStatusManager_OverwriteStatus(t *testing.T) {
+ tmpDir := t.TempDir()
+ statusPath := filepath.Join(tmpDir, "status.json")
+
+ sm, err := NewStatusManager()
+ require.NoError(t, err)
+
+ _, err = sm.Set(statusPath, Status{State: StatePullRunning})
+ require.NoError(t, err)
+
+ _, err = sm.Set(statusPath, Status{State: StatePullSucceeded})
+ require.NoError(t, err)
+
+ got, err := sm.Get(statusPath)
+ require.NoError(t, err)
+ require.Equal(t, StatePullSucceeded, got.State)
+}
+
+func TestStatusManager_GetWithHookProgress(t *testing.T) {
+ tmpDir := t.TempDir()
+ statusPath := filepath.Join(tmpDir, "status.json")
+
+ sm, err := NewStatusManager()
+ require.NoError(t, err)
+
+ _, err = sm.Set(statusPath, Status{State: StatePullRunning, VolumeName: "vol"})
+ require.NoError(t, err)
+
+ // Register a hook and add some progress.
+ hook := NewHook(context.Background())
+ hook.SetTotal(2)
+ sm.HookManager.Set(statusPath, hook)
+
+ got, err := sm.Get(statusPath)
+ require.NoError(t, err)
+ require.Equal(t, 2, got.Progress.Total)
+}
+
+// ─── Progress ─────────────────────────────────────────────────────────────────
+
+func TestProgress_String(t *testing.T) {
+ p := Progress{Total: 3, Items: []ProgressItem{
+ {Digest: "sha256:abc", Path: "/model.safetensors", Size: 1024},
+ }}
+ s, err := p.String()
+ require.NoError(t, err)
+ require.Contains(t, s, "sha256:abc")
+}
+
+// ─── HookManager ──────────────────────────────────────────────────────────────
+
+func TestHookManager_SetGetDelete(t *testing.T) {
+ hm := NewHookManager()
+
+ // GetProgress on missing key returns empty.
+ p := hm.GetProgress("k1")
+ require.Equal(t, 0, p.Total)
+
+ hook := NewHook(context.Background())
+ hook.SetTotal(5)
+ hm.Set("k1", hook)
+
+ p = hm.GetProgress("k1")
+ require.Equal(t, 5, p.Total)
+
+ hm.Delete("k1")
+ p = hm.GetProgress("k1")
+ require.Equal(t, 0, p.Total)
+}
+
+// ─── Hook ─────────────────────────────────────────────────────────────────────
+
+func TestHook_SetTotal(t *testing.T) {
+ h := NewHook(context.Background())
+ h.SetTotal(10)
+ p := h.GetProgress()
+ require.Equal(t, 10, p.Total)
+}
+
+func TestHook_BeforeAndAfterPullLayer_Success(t *testing.T) {
+ h := NewHook(context.Background())
+ h.SetTotal(1)
+
+ desc := ocispec.Descriptor{
+ Digest: digest.Digest("sha256:aabbcc"),
+ MediaType: "application/octet-stream",
+ Size: 2048,
+ Annotations: map[string]string{
+ "org.modelpack.model.filepath": "weights.safetensors",
+ },
+ }
+ manifest := ocispec.Manifest{}
+
+ h.BeforePullLayer(desc, manifest)
+
+ p := h.GetProgress()
+ require.Len(t, p.Items, 1)
+ require.Nil(t, p.Items[0].FinishedAt)
+
+ h.AfterPullLayer(desc, nil)
+
+ p = h.GetProgress()
+ require.Len(t, p.Items, 1)
+ require.NotNil(t, p.Items[0].FinishedAt)
+}
+
+func TestHook_AfterPullLayer_WithError(t *testing.T) {
+ h := NewHook(context.Background())
+
+ desc := ocispec.Descriptor{
+ Digest: digest.Digest("sha256:fail"),
+ MediaType: "application/octet-stream",
+ Size: 512,
+ }
+ manifest := ocispec.Manifest{}
+ h.BeforePullLayer(desc, manifest)
+ h.AfterPullLayer(desc, os.ErrInvalid)
+
+ p := h.GetProgress()
+ require.Len(t, p.Items, 1)
+ require.Equal(t, os.ErrInvalid, p.Items[0].Error)
+}
+
+func TestHook_AfterPullLayer_UnknownDigest(t *testing.T) {
+ // AfterPullLayer on an unregistered digest should not panic.
+ h := NewHook(context.Background())
+ desc := ocispec.Descriptor{Digest: digest.Digest("sha256:unknown")}
+ // Should not panic.
+ h.AfterPullLayer(desc, nil)
+}
+
+func TestHook_GetProgress_Sorted(t *testing.T) {
+ h := NewHook(context.Background())
+
+ now := time.Now()
+ manifest := ocispec.Manifest{}
+
+ for _, d := range []struct {
+ dgst digest.Digest
+ delay time.Duration
+ }{
+ {"sha256:cc", 10 * time.Millisecond},
+ {"sha256:aa", 0},
+ {"sha256:bb", 5 * time.Millisecond},
+ } {
+ d := d
+ _ = ocispec.Descriptor{Digest: d.dgst, Size: 100}
+ // Manually insert to control StartedAt.
+ h.mutex.Lock()
+ at := now.Add(d.delay)
+ h.progress[d.dgst] = &ProgressItem{
+ Digest: d.dgst,
+ StartedAt: at,
+ }
+ h.manifest = &manifest
+ h.mutex.Unlock()
+ }
+
+ p := h.GetProgress()
+ require.Len(t, p.Items, 3)
+ // Items should be sorted by StartedAt ascending.
+ require.True(t, p.Items[0].StartedAt.Before(p.Items[1].StartedAt) || p.Items[0].StartedAt.Equal(p.Items[1].StartedAt))
+ require.True(t, p.Items[1].StartedAt.Before(p.Items[2].StartedAt) || p.Items[1].StartedAt.Equal(p.Items[2].StartedAt))
+}
+
+func TestHook_GetTotal_FromManifestLayers(t *testing.T) {
+ h := NewHook(context.Background())
+
+ manifest := ocispec.Manifest{
+ Layers: []ocispec.Descriptor{
+ {Digest: "sha256:l1"},
+ {Digest: "sha256:l2"},
+ },
+ }
+ desc := ocispec.Descriptor{Digest: "sha256:l1", Size: 100}
+ h.BeforePullLayer(desc, manifest)
+
+ p := h.GetProgress()
+ // total comes from manifest.Layers
+ require.Equal(t, 2, p.Total)
+}
diff --git a/pkg/tracing/tracing_test.go b/pkg/tracing/tracing_test.go
new file mode 100644
index 0000000..7b71901
--- /dev/null
+++ b/pkg/tracing/tracing_test.go
@@ -0,0 +1,71 @@
+package tracing
+
+import (
+ "context"
+ "testing"
+
+ "github.com/modelpack/model-csi-driver/pkg/config"
+ "github.com/stretchr/testify/require"
+)
+
+func TestInit_EmptyEndpoint(t *testing.T) {
+ cfg := config.NewWithRaw(&config.RawConfig{
+ ServiceName: "test-service",
+ })
+ err := Init(cfg)
+ require.NoError(t, err)
+ require.NotNil(t, Tracer)
+}
+
+func TestInit_CalledTwice(t *testing.T) {
+ cfg := config.NewWithRaw(&config.RawConfig{
+ ServiceName: "test-service-2",
+ })
+ err := Init(cfg)
+ require.NoError(t, err)
+
+ err = Init(cfg)
+ require.NoError(t, err)
+ require.NotNil(t, Tracer)
+}
+
+func TestInit_WithEndpoint(t *testing.T) {
+ cfg := config.NewWithRaw(&config.RawConfig{
+ ServiceName: "test-service-3",
+ TraceEndpoint: "http://localhost:4318",
+ })
+ // otlptracehttp.New does not connect immediately, so this should succeed
+ err := Init(cfg)
+ require.NoError(t, err)
+ require.NotNil(t, Tracer)
+}
+
+func TestNewPropagator(t *testing.T) {
+ p := newPropagator()
+ require.NotNil(t, p)
+}
+
+func TestNewTracerProvider_EmptyEndpoint(t *testing.T) {
+ tp, err := newTracerProvider("")
+ require.NoError(t, err)
+ require.NotNil(t, tp)
+}
+
+func TestNewTracerProvider_WithEndpoint(t *testing.T) {
+ // otlptracehttp.New is lazy - no actual connection until spans are flushed
+ tp, err := newTracerProvider("http://localhost:4318")
+ require.NoError(t, err)
+ require.NotNil(t, tp)
+}
+
+func TestSetupOTelSDK_EmptyEndpoint(t *testing.T) {
+ shutdown, err := setupOTelSDK(context.TODO(), "")
+ require.NoError(t, err)
+ require.NotNil(t, shutdown)
+}
+
+func TestSetupOTelSDK_WithEndpoint(t *testing.T) {
+ shutdown, err := setupOTelSDK(context.TODO(), "http://localhost:4318")
+ require.NoError(t, err)
+ require.NotNil(t, shutdown)
+}
diff --git a/pkg/utils/utils_test.go b/pkg/utils/utils_test.go
new file mode 100644
index 0000000..25dbadf
--- /dev/null
+++ b/pkg/utils/utils_test.go
@@ -0,0 +1,115 @@
+package utils
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestWithRetry_Success(t *testing.T) {
+ ctx := context.Background()
+ calls := 0
+ err := WithRetry(ctx, func() error {
+ calls++
+ return nil
+ }, 3, 0)
+ require.NoError(t, err)
+ require.Equal(t, 1, calls)
+}
+
+func TestWithRetry_EventualSuccess(t *testing.T) {
+ ctx := context.Background()
+ calls := 0
+ err := WithRetry(ctx, func() error {
+ calls++
+ if calls < 3 {
+ return errors.New("transient error")
+ }
+ return nil
+ }, 5, time.Millisecond)
+ require.NoError(t, err)
+ require.Equal(t, 3, calls)
+}
+
+func TestWithRetry_ExhaustedRetries(t *testing.T) {
+ ctx := context.Background()
+ calls := 0
+ err := WithRetry(ctx, func() error {
+ calls++
+ return errors.New("permanent error")
+ }, 3, time.Millisecond)
+ require.Error(t, err)
+ require.Equal(t, 3, calls)
+}
+
+func TestWithRetry_BreakRetry(t *testing.T) {
+ ctx := context.Background()
+ calls := 0
+ err := WithRetry(ctx, func() error {
+ calls++
+ return ErrBreakRetry
+ }, 5, time.Millisecond)
+ require.ErrorIs(t, err, ErrBreakRetry)
+ require.Equal(t, 1, calls)
+}
+
+func TestEnsureSockNotExists_NonExistent(t *testing.T) {
+ tmpDir := t.TempDir()
+ sockPath := filepath.Join(tmpDir, "subdir", "csi.sock")
+ ctx := context.Background()
+ err := EnsureSockNotExists(ctx, sockPath)
+ require.NoError(t, err)
+ // The parent directory should have been created.
+ _, statErr := os.Stat(filepath.Dir(sockPath))
+ require.NoError(t, statErr)
+}
+
+func TestEnsureSockNotExists_ExistingFile(t *testing.T) {
+ tmpDir := t.TempDir()
+ sockPath := filepath.Join(tmpDir, "csi.sock")
+ // Create a regular file at sockPath.
+ require.NoError(t, os.WriteFile(sockPath, []byte("data"), 0644))
+ ctx := context.Background()
+ err := EnsureSockNotExists(ctx, sockPath)
+ require.NoError(t, err)
+ // The file should have been removed.
+ _, statErr := os.Stat(sockPath)
+ require.True(t, os.IsNotExist(statErr))
+}
+
+func TestEnsureSockNotExists_IsDirectory(t *testing.T) {
+ tmpDir := t.TempDir()
+ sockPath := filepath.Join(tmpDir, "dirissock")
+ require.NoError(t, os.Mkdir(sockPath, 0755))
+ ctx := context.Background()
+ err := EnsureSockNotExists(ctx, sockPath)
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "sock path is a directory")
+}
+
+func TestIsInSameDevice_SamePath(t *testing.T) {
+ tmpDir := t.TempDir()
+ same, err := IsInSameDevice(tmpDir, tmpDir)
+ require.NoError(t, err)
+ require.True(t, same)
+}
+
+func TestIsInSameDevice_ChildPath(t *testing.T) {
+ tmpDir := t.TempDir()
+ subDir := filepath.Join(tmpDir, "sub")
+ require.NoError(t, os.Mkdir(subDir, 0755))
+ same, err := IsInSameDevice(tmpDir, subDir)
+ require.NoError(t, err)
+ // tmpDir and subDir should be on same device.
+ require.True(t, same)
+}
+
+func TestIsInSameDevice_NonExistent(t *testing.T) {
+ _, err := IsInSameDevice("/non/existent/path1", "/non/existent/path2")
+ require.Error(t, err)
+}