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) +}