nuget without internet survival guide

NuCache V2

Заводим следующие папки:

mkdir -p ~/.nuget/nucache/bin
mkdir -p ~/.nuget/nucache/http
mkdir -p ~/.nuget/nucache/packages

В bin складываем скрипт, делаем его executable chmod +x ~/.nuget/nucache/bin/nucache и добавляем эту папку в PATH

В http будут жить json кэши ответов api, а в packages соотв сохраненные пакеты

Поддерживаемые команды:

  • nucache status - показывает текущий режим online/offline
  • nucache offline - переключает в offline режим
  • nucache online - переключает в online режим
  • nucache sync - перепроверяет все что найдет ~/.nuget/packages/**/*.nupkg
  • nucache /path/to/project - выкачивает все что найдет /path/to/project/**/*.csproj
  • nucache Newtonsoft.Json - выкачивает последнюю версию пакета и все его зависимости, алиас nucache add package Newtonsoft.Json
  • nucache Newtonsoft.Json 12.0.3 - выкачивает конретную версию и зависимости, алиас nucache add package Newtonsoft.json --version 12.0.3

Как с этим работать:

  • Always offline вариант - переключаемся в офлайн режим, тем самым вынуждая себя не забывать вызывать nucache, а когда пропадет связь ничего не надо будет делать и все будет работать
  • Online/offline - переключаемся туда сюда, пользуемся dotnet add package как обычно, как появиться связь вызываем nucache sync и nucache /path/to/project что бы вытянуть пикеты

Примечание: проверялось на macos, скорее всего будет работать в linux из коробки, для windows скорее всего нужно сделать маленький cmd враппер и добавить в path его, подсмотреть в репозитории кафки

Примечание: все так же остается прикол с пакетами которые не референсятся по типу Microsoft.NETCore.App.Ref или Microsoft.NETCore.App.Host.osx-x64 потому есть смысл пока есть сесть сделать для пары тройки проектов что то типа:

dotnet nuget locals all --clear | tail -n 1
git clean -fdX > /dev/null
dotnet restore

В ошибках, если таковые будут, найти недостающие пакеты и выкачать их вручную, повторить до тех пор пока рестор не отработает с успехом

Условно в моем случае оно ругалось мол нет Microsoft.NETCore.App.Ref, ну ок, выполнил nucache Microsoft.NETCore.App.Ref, но затем оно начало ругаться мол нет Microsoft.NETCore.App.Ref версии Х, ну ок, выполнил nucache Microsoft.NETCore.App.Ref X, но и на этом не все, с какого то перепугу, какой то транзитивной зависимости или еще чему то, нужна версия Y, и только после ее установки, все отресторилось

worflow проверки

# run me once
dotnet nuget locals all --clear | tail -n 1

# switch offline
nucache offline

# navigate to project
cd ~/github.com/rabotaua/published-vacancies-api

# cleanup
git clean -fdX

# restore
dotnet restore

# for not found packages run:
nucache Foo 1.2.3
nucache Bar 4.5.6

# repeat
git clean -fdX
dotnet restore

# navigate to next project
cd ../apply-api

# repeat cleanup/restore/install steps

NuCache V1

Для полноценной разработки уже есть необходимые мини контейнеры и казалось бы все ок, НО, в случае переключения веток Rider может захотеть обновить пакеты NuGet после чего все превращается в тыкву 🤷‍♂️

Благо в NuGet есть встроенный механизм для подобного сценария, а именно, в качестве источника пакетов можно указать локальную папку и сложить туда все nupkg файлы которые могут понадобиться

На деле это выглядит как то вот так:

echo 'recreate offline directory for demo'
rm -rf offline || true
mkdir offline

echo 'create demo classlib'
rm -rf mylib || true
mkdir mylib
cd mylib
dotnet new console
dotnet pack
cd ..
echo 'pretend like we downloaded some nupkg for offline usage'
mv mylib/bin/Debug/mylib.1.0.0.nupkg ./offline/

echo 'list current nuget sources'
dotnet nuget list source
echo 'remove offline source if any'
dotnet nuget remove source offline
echo 'add offline source'
dotnet nuget add source "${PWD}/offline" --name=offline

echo 'remove original nuget.org source'
dotnet nuget remove source nuget.org

echo 'demo of offline usage'
rm -rf consoleapp || true
mkdir consoleapp
cd consoleapp
dotnet new console
dotnet add package mylib
dotnet restore
cd ..

echo 'add nuget.org back'
dotnet nuget add source https://api.nuget.org/v3/index.json --name=nuget.org

echo 'cleanup'
rm -rf consoleapp mylib offline || true

То есть мы можем полноценно работать даже без интернета, но для этого нам понадобиться выкачать все зависимости которые у нас есть и\или могут понадобиться

Для этого делаем простой скрипт для обхода проектов по типу такого:

$directories = @(
    '~/Downloads/gitsearch/github.com/rabotaua',
    '~/github.com/rabotaua',
    '~/Desktop',
    '~/Downloads',
    '~/github.com/mac2000/notes'
)

foreach($directory in $directories) {
    $projects = Get-ChildItem -Path $directory -Recurse -Depth 4 -Include '\*.csproj'
    foreach($project in $projects) {
        $xml = \[xml\](Get-Content $project.FullName)
        foreach($reference in $xml.Project.ItemGroup.PackageReference) {
            if (-not $reference) { continue }
            $packages += \[PSCustomObject\]@{
                Package = $reference.Include
                Version = $reference.Version
            }
        }
    }
}

$packages = $packages | Select-Object Package, Version -Unique

На выходе у нас будет массив пакетов которые пользуются в найденных проектах

Примечание: есть ряд пакетов которые нужны но не указываются в зависимостях, об этом чуть ниже, но на деле все равно есть смысл перепроверить как все работает пока есть сеть

Теперь нам нужно скачать каждый пакет, по ссылке:

https://api.nuget.org/v3-flatcontainer/{LowerCasedPackageName}/{Version}/{LowerCasedPackageName}.{Version}.nupkg

Примечания:

  • Сразу же следом окажется что у пакетов есть зависимости, и надо бы выкачать и их
  • Префикс v3-flatcontainer в теории может быть другим и документация рекомендуем не предполагать это дело, а забирать из мета данных пакета

NuGet API 101

Точка входа:

https://api.nuget.org/v3/index.json

От сюда нам понадобяться ссылки:

  • RegistrationsBaseUrl - префикс по которому мы будем забирать ссылки на скачивание пакета и его информацию
  • PackageBaseAddress - префикс по которому мы будем забирать список доступных версий пакета

Примеры ссылок для Microsoft.Extensions.Caching.StackExchangeRedis версии 7.0.0:

  • versions - список доступных версий, нам это нужно в случае если версия не задана явно или используется wildcard
  • registration - тут нас интересуют packageContent содержащий ссылку для скачивания и catalogEntry со ссылкой на детали о пакете, откуда мы заберем его зависимости и хэш сумму скачиваемого файла
  • catalog - тут нас интересуют dependencyGroups содержащие список зависимостей и packageHash

How it works

Имея искомый пакет и опционально версию мы хотим:

  • если версия не задана, вытянуть список доступных версий и взять последнюю стабильную
  • если версия задана со звездочкой - вытянуть список версий и взять последнюю подходящую
  • итерируясь по доступным префиксам, забрать registration и catalog для искомой версии пакета
  • проверить наличие файла на локальной файловой системе и совпадения его хэша, в случае необходимости выкачать файл
  • рекурсивно повторить для всех зависимостей пакета

Примечание: поскольку пакетов получается ну очень много и по концовке очень большое кол-во пересечений, дабы не повторять одно и тоже для условного System.Test.Whatever было бы не плохо кэшировать результаты запросов, иначе процесс занимает вечность

PowerShell implementation v0.1

У меня получилось вот такое (не претендую на качество кода, бо делалось на коленке еще и с переменно доступным интернетом что бы проверить работает ли, да и сделать то можно на C# или на чем угодно, тут уже каждый сам себе повар и готовит как захочет)

function NuCache($package, $version) {
    $dir = '/Users/mac/nucache'

    if (-not $global:NuCacheRegistrationsBaseUrls) {
        $global:NuCacheRegistrationsBaseUrls = Invoke-RestMethod 'https://api.nuget.org/v3/index.json' | Select-Object -ExpandProperty resources | Where-Object { $\_.'@type'.StartsWith('RegistrationsBaseUrl') } | Select-Object -ExpandProperty '@id' -Unique
    }
    if (-not $global:NuCachePackageBaseAddress) {
        $global:NuCachePackageBaseAddress = Invoke-RestMethod 'https://api.nuget.org/v3/index.json' | Select-Object -ExpandProperty resources | Where-Object '@type' -EQ 'PackageBaseAddress/3.0.0' | Select-Object -ExpandProperty '@id'
    }
    if (-not $global:NuCacheRegistrations) {
        $global:NuCacheRegistrations = @{}
    }
    if (-not $global:NuCacheVersions) {
        $global:NuCacheVersions = @{}
    }
    if (-not $global:NuCacheCatalogEntries) {
        $global:NuCacheCatalogEntries = @{}
    }

    $package = $package.ToLower()

    if ($version -and $version -match '^\\\[\\d+\\.\\d+\\.\\d+,\\d+\\.\\d+\\.\\d+\\\]$') {
        $version = $version.Split(',')\[0\].Trim('\[')
    }

    if (-not $version -or $version.Contains('\*')) {
        if (-not $global:NuCacheVersions\[$package\]) {
            $global:NuCacheVersions\[$package\] = Invoke-RestMethod "$($global:NuCachePackageBaseAddress)$($package)/index.json" | Select-Object -ExpandProperty versions
            Write-Host "$package - $($global:NuCacheVersions\[$package\].Count) versions retrieved"
        }
        $last = $global:NuCacheVersions\[$package\] | Where-Object { -not $\_.Contains('-') } | Select-Object -Last 1
        if (-not $last) {
            $last = $global:NuCacheVersions\[$package\] | Select-Object -Last 1
        }
        if ($version -and $version.Contains('\*')) {
            $wildcard = $global:NuCacheVersions\[$package\] | Where-Object { $\_ -match $version } | Select-Object -Last 1
            if ($wildcard) {
                $version = $wildcard
            } else {
                $version = $last
            }
        } else {
            $version = $last
        }
    }

    $file = "$($package).$($version).nupkg"
    $target = Join-Path -Path $dir -ChildPath $file

    if (-not $global:NuCacheRegistrations\[$file\]) {
        $registration = $null
        foreach($registrationsBaseUrl in $global:NuCacheRegistrationsBaseUrls) {
            try {
                $registration = Invoke-RestMethod "$($registrationsBaseUrl)$($package)/$($version).json"
                Write-Host "$package $version - registration retrieved"
                break
            } catch {}
        }
        $global:NuCacheRegistrations\[$file\] = $registration
    }
    if (-not $global:NuCacheRegistrations\[$file\]) {
        if (-not $global:NuCacheVersions\[$package\]) {
            $global:NuCacheVersions\[$package\] = Invoke-RestMethod "$($global:NuCachePackageBaseAddress)$($package)/index.json" | Select-Object -ExpandProperty versions
            Write-Host "$package - $($global:NuCacheVersions\[$package\].Count) versions retrieved"
        }
        $v = $global:NuCacheVersions\[$package\] | Where-Object { $\_ -match \[regex\]::Replace($version, "\\.\\d+$", "") } | Select-Object -Last 1
        if (-not $v) {
            $v = $global:NuCacheVersions\[$package\] | Where-Object { $\_ -match \[regex\]::Replace($version, "\\.\\d+\\.\\d+$", "") } | Select-Object -Last 1
        }
        if (-not $v) {
            $v = $global:NuCacheVersions\[$package\] | Where-Object { $\_ -match \[regex\]::Replace($version, "\\d+$", "") } | Select-Object -Last 1
        }
        if ($v) {
            NuCache $package $v
        } else {
            Write-Host "$package $version - not found"
        }
        return
    }

    if (-not $global:NuCacheCatalogEntries.ContainsKey($file)) {
        $global:NuCacheCatalogEntries\[$file\] = Invoke-RestMethod $global:NuCacheRegistrations\[$file\].catalogEntry
    }

    $exists = Test-Path $target
    if ($exists) {
        $alg = $global:NuCacheCatalogEntries\[$file\].packageHashAlgorithm
        $wantedHash = $global:NuCacheCatalogEntries\[$file\].packageHash
        if ($alg -eq 'SHA512') {
            $hasher = \[System.Security.Cryptography.SHA512\]::Create()
        } elseif ($alg -eq 'SHA256') {
            $hasher = \[System.Security.Cryptography.SHA256\]::Create()
        } elseif ($alg -eq 'SHA1') {
            $hasher = \[System.Security.Cryptography.SHA1\]::Create()
        } elseif ($alg -eq 'MD5') {
            $hasher = \[System.Security.Cryptography.MD5\]::Create()
        } else {
            Write-Host "Unknown $alg for $package $version"
        }
        if ($hasher) {
            $stream = \[System.IO.File\]::OpenRead($target)
            $bytes = $hasher.ComputeHash($stream)
            $actualHash = \[System.Convert\]::ToBase64String($bytes)
            $stream.Dispose()
            $hasher.Dispose()
            if ($actualHash -ne $wantedHash) {
                $exists = $false
            }
        }
    }

    if (-not $exists) {
        $ProgressPreference = 'SilentlyContinue'    # Subsequent calls do not display UI.
        Invoke-WebRequest -Uri ($global:NuCacheRegistrations\[$file\].packageContent) -OutFile $target
        $ProgressPreference = 'Continue'            # Subsequent calls do display UI.
        Write-Host "$package $version - downloaded"
    } else {
        # Write-Host "$package $version - exists"
    }

    $deps = $global:NuCacheCatalogEntries\[$file\] | Select-Object -ExpandProperty dependencyGroups -ErrorAction SilentlyContinue | Where-Object dependencies -NE $null | Select-Object -ExpandProperty dependencies | Select-Object id, range -Unique | Select-Object @{n='Package';e={ $\_.id.ToLower() }}, @{n='Version';e={ $\_.range.Split(',')\[0\].TrimStart('\[').TrimStart('(') }}
    foreach($d in $deps) {
        $df = "$($d.Package).$($d.Version).nupkg"
        $dt = Join-Path -Path $dir -ChildPath $df
        if (Test-Path $dt) {
            # Write-Host "$($d.Package) $($d.Version) - exists"
            continue
        }
        NuCache $d.Package $d.Version
    }
}

Соотв с этой ф-ией можно:

# download concrete package version and all its dependencies
NuCache 'Microsoft.Extensions.Caching.StackExchangeRedis' '7.0.0'

# download latest version and all its dependencies
NuCache 'Newtonsoft.Json'

Ну и остается собрать все в кучу по типу такого:

$packages = @(
    # list of wanted low level packages that are somehow were not listed/downloaded
    \[PSCustomObject\]@{ Package = 'Microsoft.AspNetCore.App.Ref'; Version = $null },
    \[PSCustomObject\]@{ Package = 'Microsoft.NETCore.App.Ref'; Version = $null },
    \[PSCustomObject\]@{ Package = 'Microsoft.AspNetCore.App.Ref'; Version = '6.0.11' },
    \[PSCustomObject\]@{ Package = 'Microsoft.NETCore.App.Ref'; Version = '6.0.11' },
    \[PSCustomObject\]@{ Package = 'Microsoft.AspNetCore.App.Ref'; Version = '5.0.0' },
    \[PSCustomObject\]@{ Package = 'Microsoft.NETCore.App.Ref'; Version = '5.0.0' },
    \[PSCustomObject\]@{ Package = 'Microsoft.AspNetCore.App.Ref'; Version = '3.1.0' },
    \[PSCustomObject\]@{ Package = 'Microsoft.NETCore.App.Ref'; Version = '3.1.0' },
    \[PSCustomObject\]@{ Package = 'Microsoft.AspNetCore.App.Ref'; Version = '3.1.10' },
    \[PSCustomObject\]@{ Package = 'Microsoft.NETCore.App.Ref'; Version = '3.1.10' },
    \[PSCustomObject\]@{ Package = 'System.Reflection.Extensions'; Version = '4.3.0' },
    \[PSCustomObject\]@{ Package = 'System.Reflection.Emit'; Version = '4.6.0' },
    \[PSCustomObject\]@{ Package = 'System.Reflection.Emit.Lightweight'; Version = '4.6.0' },
    \[PSCustomObject\]@{ Package = 'System.Reflection.Metadata'; Version = '1.4.1' },
    \[PSCustomObject\]@{ Package = 'System.Reflection.Metadata'; Version = '1.3.0' },
    \[PSCustomObject\]@{ Package = 'Namotion.Reflection'; Version = $null },
    \[PSCustomObject\]@{ Package = 'Namotion.Reflection'; Version = '1.0.8' },
    \[PSCustomObject\]@{ Package = 'Namotion.Reflection'; Version = '1.0.11' },
    \[PSCustomObject\]@{ Package = 'Namotion.Reflection'; Version = '1.0.15' },
    \[PSCustomObject\]@{ Package = 'Namotion.Reflection'; Version = '2.0.9' },
    \[PSCustomObject\]@{ Package = 'Namotion.Reflection'; Version = '2.1.0' },
    \[PSCustomObject\]@{ Package = 'Namotion.Reflection'; Version = '2.1.1' },
    \[PSCustomObject\]@{ Package = 'Microsoft.NETCore.App.Host.osx-x64'; Version = '5.0.17' },
    \[PSCustomObject\]@{ Package = 'Microsoft.NETCore.App.Host.osx-x64'; Version = '3.1.31' },

    \[PSCustomObject\]@{ Package = 'Microsoft.Extensions.Hosting.Abstractions'; Version = '7.0.0' },
    \[PSCustomObject\]@{ Package = 'Microsoft.Extensions.Hosting.Abstractions'; Version = '6.0.0' }
)

$directories = @(
    '~/Downloads/gitsearch/github.com/rabotaua',
    '~/github.com/rabotaua',
    '~/Desktop',
    '~/Downloads',
    '~/github.com/mac2000/notes'
)

foreach($directory in $directories) {
    $projects = Get-ChildItem -Path $directory -Recurse -Depth 4 -Include '\*.csproj'
    foreach($project in $projects) {
        $xml = \[xml\](Get-Content $project.FullName)
        foreach($reference in $xml.Project.ItemGroup.PackageReference) {
            if (-not $reference) { continue }
            $packages += \[PSCustomObject\]@{
                Package = $reference.Include
                Version = $reference.Version
            }
        }
    }
}
$packages = $packages | Select-Object Package, Version -Unique

foreach($item in $packages) {
    NuCache $item.Package $item.Version
}

Примечания:

  • в зависимости от кол-ва проектов процесс может занять час другой, в моем случае выкачалось около 3.7K общим весом в 4.2Gb
  • по каким то причинам пакеты по типу Microsoft.NETCore.App.Ref не числяться в списке зависимостей и вот уже думаешь мол все ок, выключают свет\интернет, а оно не работает потому лучше перепроверять по месту на конретных проектах

How to use

Команды которыми можно туда сюда переключаться

# list sources
dotnet nuget list source

# offline
dotnet nuget remove source nuget.org
dotnet nuget add source "/Users/mac/nucache" --name=nucache

# online
dotnet nuget remove source nucache
dotnet nuget add source https://api.nuget.org/v3/index.json --name=nuget.org

How to check

У себя я делал следующее, в папке произвольного проекта:

# clean nuget cache
dotnet nuget locals all --clear | tail -n 1
# clear all bin, obj, packages, ... in repo
git clean -fdX > /dev/null

dotnet restore

И в случае наличия ошибок в ресторе, просто добавлял “проблемные” пакеты в список выкачивалки и затягивал их скриптом

Alternative approaches

Альтернативно можно попробовать поднять кэширующую проксю, тот же nginx, но там есть сложности с редиректами, плюс все равно нужно будет как то скриптовать выкачку всех зависимостей и прочего.

Можно накалякать свою проксю на C#, Golang но нет смысла т.к. будет та же история что и с nginx

Если переписывать на C# можно заюзать клиента там куча плюшек из коробки.

Side effect observations

Пару любопытных фактов, вытянул все репозитории, из них все пакеты

Всего в выборке 667 проектов и 1302 пакета

Top 10 packages

  1. Newtonsoft.Json - 123
  2. Dapper - 84
  3. Rabota.Data.SqlClient - 84
  4. AutoMapper - 72
  5. Rabota.Kafka.Events - 66
  6. Microsoft.NET.Test.Sdk - 64
  7. Microsoft.ApplicationInsights - 63
  8. xunit - 62
  9. xunit.runner.visualstudio - 62
  10. coverlet.collector - 61

Примечания:

  • любопытно что Newtonsoft.Json на первом месте, хотя казалось бы давно уже все на System.Test.Json
  • в топ 10 попали библиотеки для тестирования, хотя тестов то особо как бы не шибко много

Top 10 versions

  1. Newtonsoft.Json.13.0.1 - 55
  2. xunit.2.4.1 - 43
  3. Rabota.Data.SqlClient.3.0.10 - 40
  4. AutoMapper.10.1.1 - 39
  5. Polly.Extensions.Http.3.0.0 - 38
  6. xunit.runner.visualstudio.2.4.3 - 38
  7. Dapper.2.0.123 - 37
  8. Newtonsoft.Json.12.0.3 - 31
  9. Rabota.Cors.1.* - 28
  10. AutoMapper.Extensions.Microsoft.DependencyInjection.8.1.1 - 25

Примечание: понятное дело что этот списко кажеться сомнительным в качестве пользы, но самое интересное идет прямо следом

Top 10 spread/outdated

  1. Rabota.Kafka.Events - 20
  2. Microsoft.AspNetCore.Authentication.JwtBearer - 18
  3. NEST - 18
  4. AWSSDK.S3 - 18
  5. Microsoft.AspNetCore.Mvc.Testing - 16
  6. NSwag.CodeGeneration.CSharp - 15
  7. NSwag.MSBuild - 15
  8. Microsoft.ApplicationInsights.AspNetCore - 15
  9. StackExchange.Redis - 15
  10. Microsoft.Extensions.Http.Polly - 14

Sample of spreaded/outdated package

  • Rabota.Kafka.Events 1.* - 24
  • Rabota.Kafka.Events 1.4.56 - 2
  • Rabota.Kafka.Events 1.4.* - 14
  • Rabota.Kafka.Events 1.4.10 - 2
  • Rabota.Kafka.Events 1.4.4 - 1
  • Rabota.Kafka.Events 1.2.4 - 4
  • Rabota.Kafka.Events 1.2.59 - 2
  • Rabota.Kafka.Events 1.4.1 - 2
  • Rabota.Kafka.Events 1.4.43 - 1
  • Rabota.Kafka.Events 1.4.55 - 2
  • Rabota.Kafka.Events 1.2.* - 1
  • Rabota.Kafka.Events 1.4.51 - 1
  • Rabota.Kafka.Events 1.2.38 - 2
  • Rabota.Kafka.Events 1.4.54 - 2
  • Rabota.Kafka.Events 1.4.18 - 1
  • Rabota.Kafka.Events 1.3.67 - 1
  • Rabota.Kafka.Events 1.2.57 - 1
  • Rabota.Kafka.Events 1.2.37 - 1
  • Rabota.Kafka.Events 1.4.50 - 1
  • Rabota.Kafka.Events 1.4.25 - 1

Вот такие любопытные пироги