Architecture
This document describes the internal architecture of nopher.
Overview
Nopher consists of two main components:
- CLI Tool (Go): Generates lockfiles from Go modules
- Nix Builder: Consumes lockfiles to build Go applications
CLI Architecture
cmd/nopher/
├── main.go # CLI entry point
└── cmd/
├── root.go # Root command setup
├── generate.go # Generate lockfile command
├── verify.go # Verify lockfile command
└── update.go # Update single module command
internal/
├── mod/
│ └── parser.go # go.mod and go.sum parsing
├── fetch/
│ └── module.go # Module fetching with multi-source support
│ # (proxy.golang.org, GitHub, BSR, go list)
├── hash/
│ ├── convert.go # Hash computation and conversion
│ └── nar.go # NAR hash support
└── lockfile/
├── schema.go # Lockfile type definitions
└── yaml.go # YAML marshaling/unmarshaling
Command Flow
┌──────────────────────────────────────────────────────────────┐
│ nopher generate │
├──────────────────────────────────────────────────────────────┤
│ │
│ 1. Parse go.mod │
│ └─► Extract module path, Go version, requires, replaces │
│ │
│ 2. Parse go.sum │
│ └─► Extract all module versions with h1: hashes │
│ │
│ 3. For each module: │
│ ├─► Check if private (GOPRIVATE) │
│ ├─► Fetch via proxy or direct │
│ ├─► Compute SHA256 hash of zip │
│ └─► Add to lockfile │
│ │
│ 4. Handle replacements │
│ ├─► Remote: fetch and hash replacement module │
│ └─► Local: record path (no hash needed) │
│ │
│ 5. Write nopher.lock.yaml │
│ │
└──────────────────────────────────────────────────────────────┘
Module Fetching
type Fetcher struct {
Proxy string // GOPROXY URL
Private string // GOPRIVATE patterns
CacheDir string // Local cache directory
Netrc *Netrc // Authentication credentials
Verbose bool // Enable verbose output
}
The fetcher:
- Checks if module matches GOPRIVATE patterns
- For public modules: fetches from GOPROXY (proxy.golang.org)
- For private GitHub modules:
- Calls
go list -m -jsonto get full commit hash and accurate tag/ref - Fetches from GitHub archive URLs with netrc authentication
- Stores both URL and full 40-char commit hash in lockfile
- Calls
- For BSR modules: fetches with full module path in URL
- Caches downloaded modules, URLs, and git revs locally
Hash Computation
Nopher computes the SHA256 hash of the module zip file:
func computeZipHash(path string) (string, error) {
f, err := os.Open(path)
// ...
h := sha256.New()
io.Copy(h, f)
return "sha256-" + base64.StdEncoding.EncodeToString(h.Sum(nil)), nil
}
This hash format (SRI) is compatible with Nix's fetchurl.
Nix Builder Architecture
nix/
├── default.nix # Main entry, exports
├── fetch-module.nix # fetchGoModule function
├── build-nopher-go-app.nix # buildNopherGoApp function
├── lib.nix # Helper functions
└── overlay.nix # nixpkgs overlay
Build Flow
┌──────────────────────────────────────────────────────────────┐
│ buildNopherGoApp │
├──────────────────────────────────────────────────────────────┤
│ │
│ 1. Parse lockfile (YAML → JSON via IFD) │
│ └─► Uses yj tool at eval time │
│ │
│ 2. Fetch modules (fetchGoModule for each) │
│ └─► Each module is a separate fixed-output derivation │
│ │
│ 3. Assemble vendor directory │
│ ├─► Create directory structure │
│ ├─► Symlink fetched modules │
│ └─► Generate vendor/modules.txt │
│ │
│ 4. Build application │
│ ├─► Copy vendor (dereference symlinks) │
│ ├─► Remove go.mod files from vendor │
│ ├─► go build -mod=vendor │
│ └─► Install binaries │
│ │
└──────────────────────────────────────────────────────────────┘
fetchGoModule
Fetches a single Go module as a fixed-output derivation:
fetchGoModule {
modulePath = "github.com/sirupsen/logrus";
version = "v1.9.3";
hash = "sha256-E5GnOMrWPCJLof4UFRJ9sLQKLpALbstsrqHmnWpnn5w=";
url = "https://github.com/sirupsen/logrus/archive/refs/tags/v1.9.3.zip"; # Optional
rev = "3d4380f53a34dcdc95f0c1db702615992b38d9a4"; # Optional
}
Fetching Strategy:
The fetchGoModule function intelligently selects the fetching method based on module type:
-
GitHub modules with full
rev: Usesbuiltins.fetchGit- Authenticates via netrc configured in
/etc/nix/nix.conf(netrc-file setting) - Works in pure evaluation mode with full 40-character commit hash
- Supports multi-module repositories (extracts subdirectories)
- Example: Private GitHub repos, forks, submodules
- Authenticates via netrc configured in
-
GitHub modules without full
rev: Falls back tofetchurlBoot- Used when rev is missing or truncated
- Downloads from proxy.golang.org or GitHub archive URL
-
BSR modules: Uses
builtins.fetchurl- Authenticates via netrc-file setting in nix.conf
- Constructs URLs with full module path
- Example:
https://bsr.host.com/gen/go/bsr.host.com/gen/go/org/repo/@v/version.zip
-
Other modules: Uses
fetchurlBoot- Downloads from proxy.golang.org
- Standard public Go modules
Vendor Directory Assembly
The vendor directory is built via symlinks for efficiency:
vendorDir = stdenv.mkDerivation {
name = "${pname}-vendor";
installPhase = ''
mkdir -p $out
# Symlink each fetched module
# For modules with children (e.g., github.com/aws/aws-sdk-go-v2 and
# github.com/aws/aws-sdk-go-v2/config), copy the parent instead of
# symlinking to avoid permission issues
${lib.concatStringsSep "\n" (lib.mapAttrsToList (path: drv: ''
mkdir -p $out/${dirOf path}
ln -s ${drv} $out/${path}
'') fetchedModules)}
# Generate modules.txt with replace directives
# Format: # original@version => replacement@version
# ...
'';
};
Nested Module Handling:
- Detects parent-child module relationships (e.g.,
aws/sdkandaws/sdk/config) - Parent modules are copied (not symlinked) to allow child symlinks
- Ensures permissions are correct for nested structures
modules.txt Generation
Go's vendor mode requires vendor/modules.txt:
# github.com/sirupsen/logrus v1.9.3
## explicit; go 1.22
github.com/sirupsen/logrus
github.com/sirupsen/logrus/hooks/syslog
# sigs.k8s.io/controller-runtime v0.23.0 => sigs.k8s.io/controller-runtime v0.22.4
## explicit; go 1.22
sigs.k8s.io/controller-runtime
sigs.k8s.io/controller-runtime/pkg/client
Key points:
## explicitmarks the module as explicitly required; go X.Ysets the language version (prevents go1.16 default)- Replace directives show
original@version => replacement@version - Original version comes from
oldVersionfield in lockfile - Package paths are discovered by scanning for
.gofiles in each module
Design Decisions
Per-Module Fetching
Why: Fine-grained caching. When one dependency updates, only that module is re-fetched.
Trade-off: More derivations, potentially slower initial builds.
YAML Lockfile
Why: Human-readable, easy to review in PRs, can be manually edited if needed.
Trade-off: Requires YAML→JSON conversion in Nix (uses IFD with yj).
Impure Lockfile Generation
Why: Authentication for private repos is handled outside Nix, keeping builds pure.
Trade-off: Lockfile must be regenerated manually after dependency changes.
Vendor Mode
Why: Offline builds, no network during Nix build phase.
Trade-off: Larger vendor directories, go.mod file removal needed.
SRI Hashes
Why: Compatible with Nix's fetchurl, standard format.
Trade-off: Different from Go's h1: hashes, requires download to compute.
Caching Strategy
~/.cache/nopher/ (or ~/Library/Caches/nopher on macOS)
├── github.com%2Fsirupsen%2Flogrus@v1.9.3/ # Extracted module
├── github.com%2Fsirupsen%2Flogrus@v1.9.3.hash # Cached SRI hash
├── github.com%2Fsirupsen%2Flogrus@v1.9.3.url # Cached source URL
├── github.com%2Fsirupsen%2Flogrus@v1.9.3.rev # Cached git commit hash
└── ...
- Modules are cached after first fetch
- Hash, URL, and git rev are cached alongside module
- Speeds up lockfile regeneration for unchanged dependencies
- Cache location:
~/.cache/nopher(Linux) or~/Library/Caches/nopher(macOS) - Cache can be cleared:
rm -rf ~/.cache/nopherorrm -rf ~/Library/Caches/nopher
Error Handling
The CLI provides detailed error messages:
Error: fetching module github.com/private/repo@v1.0.0:
unexpected status: 404 Not Found
Hint: This module may be private. Ensure:
1. GOPRIVATE includes github.com/private/*
2. ~/.netrc contains credentials for github.com