summaryrefslogtreecommitdiffstats
path: root/classes
diff options
context:
space:
mode:
authorBruce Ashfield <bruce.ashfield@gmail.com>2025-12-04 22:19:59 +0000
committerBruce Ashfield <bruce.ashfield@gmail.com>2025-12-08 20:57:44 -0500
commit9f40ce9b277a677ad3cddd8bf1c1d15fbd035251 (patch)
tree109cdd94bb9fdbb5437240cc15fa9bc5371f0874 /classes
parent44c4e44db7300f241e6740b389489e84ef7c1f65 (diff)
downloadmeta-virtualization-9f40ce9b277a677ad3cddd8bf1c1d15fbd035251.tar.gz
classes: add go-mod-vcs and go-mod-discovery for Go module builds
Add two new bbclass files that enable building Go applications using git-based module resolution instead of network proxy fetches: go-mod-vcs.bbclass: - Provides do_create_module_cache task to build GOMODCACHE from git sources - Implements pure Python h1: hash calculation with go-dirhash-native fallback - Creates properly structured module zip files and hash files - Handles module path transformations and case encoding go-mod-discovery.bbclass: - Runs module discovery using the oe-go-mod-fetcher tool - Generates go-mod-git.inc and go-mod-cache.inc files - Supports bootstrap mode for initial recipe conversion Together these classes enable fully offline, reproducible Go builds by fetching module sources via git and constructing the module cache during the build rather than relying on network access to module proxies. Signed-off-by: Bruce Ashfield <bruce.ashfield@gmail.com>
Diffstat (limited to 'classes')
-rw-r--r--classes/go-mod-discovery.bbclass441
-rw-r--r--classes/go-mod-vcs.bbclass1107
2 files changed, 1548 insertions, 0 deletions
diff --git a/classes/go-mod-discovery.bbclass b/classes/go-mod-discovery.bbclass
new file mode 100644
index 00000000..0d703d0a
--- /dev/null
+++ b/classes/go-mod-discovery.bbclass
@@ -0,0 +1,441 @@
1#
2# Copyright OpenEmbedded Contributors
3#
4# SPDX-License-Identifier: MIT
5#
6
7# go-mod-discovery.bbclass
8#
9# Provides a do_discover_modules task for Go projects that downloads complete
10# module metadata from proxy.golang.org for use with the bootstrap strategy.
11#
12# USAGE:
13# 1. Add to recipe: inherit go-mod-discovery
14# 2. Set required variables (see CONFIGURATION below)
15# 3. Run discovery: bitbake <recipe> -c discover_modules
16# (This automatically: downloads modules, extracts metadata, regenerates recipe)
17# 4. Build normally: bitbake <recipe>
18#
19# CONFIGURATION:
20#
21# Required (must be set by recipe):
22#
23# GO_MOD_DISCOVERY_BUILD_TARGET - Build target for go build
24# Example: "./cmd/server" or "./..."
25#
26# Optional (have sensible defaults):
27#
28# GO_MOD_DISCOVERY_SRCDIR - Directory containing go.mod
29# Default: "${S}/src/import" (standard Go recipe layout)
30#
31# GO_MOD_DISCOVERY_BUILD_TAGS - Build tags for go build
32# Default: "${TAGS}" (uses recipe's TAGS variable if set)
33# Example: "netcgo osusergo static_build"
34#
35# GO_MOD_DISCOVERY_LDFLAGS - Linker flags for go build
36# Default: "-w -s"
37# Example: "-X main.version=${PV} -w -s"
38#
39# GO_MOD_DISCOVERY_GOPATH - GOPATH for discovery build
40# Default: "${S}/src/import/.gopath:${S}/src/import/vendor"
41#
42# GO_MOD_DISCOVERY_OUTPUT - Output binary path
43# Default: "${WORKDIR}/discovery-build-output"
44#
45# GO_MOD_DISCOVERY_DIR - Persistent cache location
46# Default: "${TOPDIR}/go-mod-discovery/${PN}/${PV}"
47#
48# GO_MOD_DISCOVERY_MODULES_JSON - Output path for extracted module metadata
49# Default: "${GO_MOD_DISCOVERY_DIR}/modules.json"
50#
51# GO_MOD_DISCOVERY_SKIP_EXTRACT - Set to "1" to skip automatic extraction
52# Default: "0" (extraction runs automatically)
53#
54# GO_MOD_DISCOVERY_SKIP_GENERATE - Set to "1" to skip automatic recipe generation
55# Default: "0" (generation runs automatically)
56#
57# GO_MOD_DISCOVERY_GIT_REPO - Git repository URL for recipe generation
58# Example: "https://github.com/rancher/k3s.git"
59# Required for automatic generation
60#
61# GO_MOD_DISCOVERY_GIT_REF - Git ref (commit/tag) for recipe generation
62# Default: "${SRCREV}" (uses recipe's SRCREV)
63#
64# GO_MOD_DISCOVERY_RECIPEDIR - Output directory for generated .inc files
65# Default: "${FILE_DIRNAME}" (recipe's directory)
66#
67# MINIMAL EXAMPLE (manual generation - no GIT_REPO set):
68#
69# TAGS = "netcgo osusergo"
70# GO_MOD_DISCOVERY_BUILD_TARGET = "./cmd/myapp"
71# inherit go-mod-discovery
72# # Run: bitbake myapp -c discover_modules
73# # Then manually: oe-go-mod-fetcher.py --discovered-modules ... --git-repo ...
74#
75# FULL AUTOMATIC EXAMPLE (all-in-one discovery + generation):
76#
77# TAGS = "netcgo osusergo"
78# GO_MOD_DISCOVERY_BUILD_TARGET = "./cmd/myapp"
79# GO_MOD_DISCOVERY_GIT_REPO = "https://github.com/example/myapp.git"
80# inherit go-mod-discovery
81# # Run: bitbake myapp -c discover_modules
82# # Recipe files are automatically regenerated!
83#
84# See: meta-virtualization/scripts/BOOTSTRAP-STRATEGY.md (Approach B)
85#
86# This task is NOT part of the normal build - it must be explicitly invoked
87# via bitbake <recipe> -c discover_modules
88#
89# PERSISTENT CACHE: The discovery cache is stored in ${TOPDIR}/go-mod-discovery/${PN}/${PV}/
90# instead of ${WORKDIR}. This ensures the cache survives `bitbake <recipe> -c cleanall`
91# since TOPDIR is the build directory root (e.g., /path/to/build/).
92# To clean the discovery cache, run: rm -rf ${TOPDIR}/go-mod-discovery/${PN}/${PV}/
93
94# Required variable (must be set by recipe)
95GO_MOD_DISCOVERY_BUILD_TARGET ?= ""
96
97# Optional variables with sensible defaults for standard Go recipe layout
98GO_MOD_DISCOVERY_SRCDIR ?= "${S}/src/import"
99GO_MOD_DISCOVERY_BUILD_TAGS ?= "${TAGS}"
100GO_MOD_DISCOVERY_LDFLAGS ?= "-w -s"
101GO_MOD_DISCOVERY_GOPATH ?= "${S}/src/import/.gopath:${S}/src/import/vendor"
102GO_MOD_DISCOVERY_OUTPUT ?= "${WORKDIR}/discovery-build-output"
103
104# Persistent discovery cache location - survives cleanall
105GO_MOD_DISCOVERY_DIR ?= "${TOPDIR}/go-mod-discovery/${PN}/${PV}"
106
107# Output JSON file for discovered modules (used by oe-go-mod-fetcher.py --discovered-modules)
108GO_MOD_DISCOVERY_MODULES_JSON ?= "${GO_MOD_DISCOVERY_DIR}/modules.json"
109
110# Set to "1" to skip automatic extraction (only download modules, don't extract metadata)
111GO_MOD_DISCOVERY_SKIP_EXTRACT ?= "0"
112
113# Set to "1" to skip automatic recipe regeneration (only discover and extract)
114GO_MOD_DISCOVERY_SKIP_GENERATE ?= "0"
115
116# Git repository URL for recipe generation (required if SKIP_GENERATE != "1")
117# Example: "https://github.com/rancher/k3s.git"
118GO_MOD_DISCOVERY_GIT_REPO ?= ""
119
120# Git ref (commit/tag) for recipe generation - defaults to recipe's SRCREV
121GO_MOD_DISCOVERY_GIT_REF ?= "${SRCREV}"
122
123# Recipe directory for generated .inc files - defaults to recipe's directory
124GO_MOD_DISCOVERY_RECIPEDIR ?= "${FILE_DIRNAME}"
125
126# Empty default for TAGS if not set by recipe (avoids undefined variable errors)
127TAGS ?= ""
128
129# Shell task that mirrors do_compile but with network access and discovery GOMODCACHE
130do_discover_modules() {
131 # Validate required variable
132 if [ -z "${GO_MOD_DISCOVERY_BUILD_TARGET}" ]; then
133 bbfatal "GO_MOD_DISCOVERY_BUILD_TARGET must be set (e.g., './cmd/server' or './...')"
134 fi
135
136 # Validate source directory exists and contains go.mod
137 if [ ! -d "${GO_MOD_DISCOVERY_SRCDIR}" ]; then
138 bbfatal "GO_MOD_DISCOVERY_SRCDIR does not exist: ${GO_MOD_DISCOVERY_SRCDIR}
139Hint: Set GO_MOD_DISCOVERY_SRCDIR in your recipe if go.mod is not in \${S}/src/import"
140 fi
141 if [ ! -f "${GO_MOD_DISCOVERY_SRCDIR}/go.mod" ]; then
142 bbfatal "go.mod not found in GO_MOD_DISCOVERY_SRCDIR: ${GO_MOD_DISCOVERY_SRCDIR}
143Hint: Set GO_MOD_DISCOVERY_SRCDIR to the directory containing go.mod"
144 fi
145
146 # Use PERSISTENT cache location outside WORKDIR to survive cleanall
147 # This is stored in ${TOPDIR}/go-mod-discovery/${PN}/${PV}/ so it persists
148 DISCOVERY_CACHE="${GO_MOD_DISCOVERY_DIR}/cache"
149
150 # Create required directories first
151 mkdir -p "${DISCOVERY_CACHE}"
152 mkdir -p "${WORKDIR}/go-tmp"
153 mkdir -p "$(dirname "${GO_MOD_DISCOVERY_OUTPUT}")"
154
155 # Use discovery-cache instead of the normal GOMODCACHE
156 export GOMODCACHE="${DISCOVERY_CACHE}"
157
158 # Enable network access to proxy.golang.org
159 export GOPROXY="https://proxy.golang.org,direct"
160 export GOSUMDB="sum.golang.org"
161
162 # Standard Go environment - use recipe-provided GOPATH or default
163 export GOPATH="${GO_MOD_DISCOVERY_GOPATH}:${STAGING_DIR_TARGET}/${prefix}/local/go"
164 export CGO_ENABLED="1"
165 export GOTOOLCHAIN="local"
166
167 # Use system temp directory for Go's work files
168 export GOTMPDIR="${WORKDIR}/go-tmp"
169
170 # Disable excessive debug output from BitBake environment
171 unset GODEBUG
172
173 # Build tags from recipe configuration
174 TAGS="${GO_MOD_DISCOVERY_BUILD_TAGS}"
175
176 # Change to source directory
177 cd "${GO_MOD_DISCOVERY_SRCDIR}"
178
179 echo "======================================================================"
180 echo "MODULE DISCOVERY: ${PN} ${PV}"
181 echo "======================================================================"
182 echo "GOMODCACHE: ${GOMODCACHE}"
183 echo "GOPROXY: ${GOPROXY}"
184 echo "Source dir: ${GO_MOD_DISCOVERY_SRCDIR}"
185 echo "Build target: ${GO_MOD_DISCOVERY_BUILD_TARGET}"
186 echo "Build tags: ${TAGS:-<none>}"
187 echo "LDFLAGS: ${GO_MOD_DISCOVERY_LDFLAGS}"
188 echo ""
189
190 # Use native go binary (not cross-compiler)
191 GO_NATIVE="${STAGING_DIR_NATIVE}${bindir_native}/go"
192
193 # NOTE: Do NOT run go mod tidy during discovery - it can upgrade versions in go.mod
194 # without adding checksums to go.sum, causing version mismatches.
195 # The source's go.mod/go.sum should already be correct for the commit.
196 # echo "Running: go mod tidy"
197 # ${GO_NATIVE} mod tidy
198 # ${GO_NATIVE} mod download # If tidy is re-enabled, this ensures go.sum gets all checksums
199
200 echo ""
201 echo "Running: go build (to discover all modules)..."
202
203 # Build to discover ALL modules that would be used at compile time
204 # This is better than 'go mod download' because it handles build tags correctly
205 BUILD_CMD="${GO_NATIVE} build -v -trimpath"
206 if [ -n "${TAGS}" ]; then
207 BUILD_CMD="${BUILD_CMD} -tags \"${TAGS}\""
208 fi
209 BUILD_CMD="${BUILD_CMD} -ldflags \"${GO_MOD_DISCOVERY_LDFLAGS}\""
210 BUILD_CMD="${BUILD_CMD} -o \"${GO_MOD_DISCOVERY_OUTPUT}\" ${GO_MOD_DISCOVERY_BUILD_TARGET}"
211
212 echo "Executing: ${BUILD_CMD}"
213 eval ${BUILD_CMD}
214
215 echo ""
216 echo "Fetching ALL modules referenced in go.sum..."
217 # go build downloads .zip files but not always .info files
218 # We need .info files for VCS metadata (Origin.URL, Origin.Hash)
219 # Extract unique module@version pairs from go.sum and download each
220 # go.sum format: "module version/go.mod hash" or "module version hash"
221 #
222 # IMPORTANT: We must download ALL versions, including /go.mod-only entries!
223 # When GOPROXY=off during compile, Go may need these for dependency resolution.
224 # Strip the /go.mod suffix to get the base version, then download it.
225 awk '{gsub(/\/go\.mod$/, "", $2); print $1 "@" $2}' go.sum | sort -u | while read modver; do
226 ${GO_NATIVE} mod download "$modver" 2>/dev/null || true
227 done
228
229 # Download ALL modules in the complete dependency graph.
230 # The go.sum loop above only gets direct dependencies. Replace directives
231 # can introduce transitive deps that aren't in go.sum but are needed at
232 # compile time when GOPROXY=off. `go mod download all` resolves and
233 # downloads the entire module graph, including transitive dependencies.
234 echo ""
235 echo "Downloading complete module graph (including transitive deps)..."
236 ${GO_NATIVE} mod download all 2>&1 || echo "Warning: some modules may have failed to download"
237
238 # Additionally scan for any modules that go build downloaded but don't have .info
239 # This ensures we capture everything that was fetched dynamically
240 echo ""
241 echo "Ensuring .info files for all cached modules..."
242 find "${GOMODCACHE}/cache/download" -name "*.zip" 2>/dev/null | while read zipfile; do
243 # Extract module@version from path like: .../module/@v/version.zip
244 version=$(basename "$zipfile" .zip)
245 moddir=$(dirname "$zipfile")
246 infofile="${moddir}/${version}.info"
247 if [ ! -f "$infofile" ]; then
248 # Reconstruct module path from directory structure
249 # cache/download/github.com/foo/bar/@v/v1.0.0.zip -> github.com/foo/bar@v1.0.0
250 modpath=$(echo "$moddir" | sed "s|${GOMODCACHE}/cache/download/||" | sed 's|/@v$||')
251 echo " Fetching .info for: ${modpath}@${version}"
252 ${GO_NATIVE} mod download "${modpath}@${version}" 2>/dev/null || true
253 fi
254 done
255
256 # Download transitive deps of REPLACED modules.
257 # Replace directives can point to older versions whose deps aren't in the MVS
258 # graph. At compile time with GOPROXY=off, Go validates the replaced version's
259 # go.mod. We parse replace directives and download each replacement version,
260 # which fetches all its transitive dependencies.
261 echo ""
262 echo "Downloading dependencies of replaced modules..."
263
264 # Extract replace directives: "old_module => new_module new_version"
265 awk '/^replace \($/,/^\)$/ {if ($0 !~ /^replace|^\)/) print}' go.mod | \
266 grep "=>" | while read line; do
267 # Parse: github.com/foo/bar => github.com/baz/qux v1.2.3
268 new_module=$(echo "$line" | awk '{print $(NF-1)}')
269 new_version=$(echo "$line" | awk '{print $NF}')
270
271 if [ -n "$new_module" ] && [ -n "$new_version" ] && [ "$new_version" != "=>" ]; then
272 echo " Replace target: ${new_module}@${new_version}"
273 # Download this specific version - Go will fetch all its dependencies
274 ${GO_NATIVE} mod download "${new_module}@${new_version}" 2>/dev/null || true
275 fi
276 done
277
278 # Count modules discovered
279 MODULE_COUNT=$(find "${GOMODCACHE}/cache/download" -name "*.info" 2>/dev/null | wc -l)
280
281 echo ""
282 echo "======================================================================"
283 echo "DISCOVERY COMPLETE"
284 echo "======================================================================"
285 echo "Modules discovered: ${MODULE_COUNT}"
286 echo "Cache location: ${GOMODCACHE}"
287
288 # Extract module metadata automatically (unless skipped)
289 if [ "${GO_MOD_DISCOVERY_SKIP_EXTRACT}" != "1" ]; then
290 echo ""
291 echo "Extracting module metadata..."
292
293 # Find the extraction script relative to this class file
294 EXTRACT_SCRIPT="${COREBASE}/../meta-virtualization/scripts/extract-discovered-modules.py"
295 if [ ! -f "${EXTRACT_SCRIPT}" ]; then
296 # Try alternate location
297 EXTRACT_SCRIPT="$(dirname "${COREBASE}")/meta-virtualization/scripts/extract-discovered-modules.py"
298 fi
299 if [ ! -f "${EXTRACT_SCRIPT}" ]; then
300 # Last resort - search in layer path
301 for layer in ${BBLAYERS}; do
302 if [ -f "${layer}/scripts/extract-discovered-modules.py" ]; then
303 EXTRACT_SCRIPT="${layer}/scripts/extract-discovered-modules.py"
304 break
305 fi
306 done
307 fi
308
309 if [ -f "${EXTRACT_SCRIPT}" ]; then
310 python3 "${EXTRACT_SCRIPT}" \
311 --gomodcache "${GOMODCACHE}" \
312 --output "${GO_MOD_DISCOVERY_MODULES_JSON}"
313 EXTRACT_RC=$?
314 if [ $EXTRACT_RC -eq 0 ]; then
315 echo ""
316 echo "✓ Module metadata extracted to: ${GO_MOD_DISCOVERY_MODULES_JSON}"
317 else
318 bbwarn "Module extraction failed (exit code $EXTRACT_RC)"
319 bbwarn "You can run manually: python3 ${EXTRACT_SCRIPT} --gomodcache ${GOMODCACHE} --output ${GO_MOD_DISCOVERY_MODULES_JSON}"
320 EXTRACT_RC=1 # Mark as failed for generation check
321 fi
322 else
323 bbwarn "Could not find extract-discovered-modules.py script"
324 bbwarn "Run manually: extract-discovered-modules.py --gomodcache ${GOMODCACHE} --output ${GO_MOD_DISCOVERY_MODULES_JSON}"
325 EXTRACT_RC=1 # Mark as failed for generation check
326 fi
327 else
328 echo ""
329 echo "Skipping automatic extraction (GO_MOD_DISCOVERY_SKIP_EXTRACT=1)"
330 EXTRACT_RC=1 # Skip generation too if extraction skipped
331 fi
332
333 # Step 3: Generate recipe .inc files (unless skipped or extraction failed)
334 if [ "${GO_MOD_DISCOVERY_SKIP_GENERATE}" != "1" ] && [ "${EXTRACT_RC:-0}" = "0" ]; then
335 # Validate required git repo
336 if [ -z "${GO_MOD_DISCOVERY_GIT_REPO}" ]; then
337 bbwarn "GO_MOD_DISCOVERY_GIT_REPO not set - skipping recipe generation"
338 bbwarn "Set GO_MOD_DISCOVERY_GIT_REPO in your recipe to enable automatic generation"
339 echo ""
340 echo "NEXT STEP: Regenerate recipe manually:"
341 echo ""
342 echo " ./meta-virtualization/scripts/oe-go-mod-fetcher.py \\"
343 echo " --discovered-modules ${GO_MOD_DISCOVERY_MODULES_JSON} \\"
344 echo " --git-repo <your-git-repo-url> \\"
345 echo " --git-ref ${GO_MOD_DISCOVERY_GIT_REF} \\"
346 echo " --recipedir ${GO_MOD_DISCOVERY_RECIPEDIR}"
347 else
348 echo ""
349 echo "Generating recipe .inc files..."
350
351 # Find the fetcher script (same search as extraction script)
352 FETCHER_SCRIPT="${COREBASE}/../meta-virtualization/scripts/oe-go-mod-fetcher.py"
353 if [ ! -f "${FETCHER_SCRIPT}" ]; then
354 FETCHER_SCRIPT="$(dirname "${COREBASE}")/meta-virtualization/scripts/oe-go-mod-fetcher.py"
355 fi
356 if [ ! -f "${FETCHER_SCRIPT}" ]; then
357 for layer in ${BBLAYERS}; do
358 if [ -f "${layer}/scripts/oe-go-mod-fetcher.py" ]; then
359 FETCHER_SCRIPT="${layer}/scripts/oe-go-mod-fetcher.py"
360 break
361 fi
362 done
363 fi
364
365 if [ -f "${FETCHER_SCRIPT}" ]; then
366 python3 "${FETCHER_SCRIPT}" \
367 --discovered-modules "${GO_MOD_DISCOVERY_MODULES_JSON}" \
368 --git-repo "${GO_MOD_DISCOVERY_GIT_REPO}" \
369 --git-ref "${GO_MOD_DISCOVERY_GIT_REF}" \
370 --recipedir "${GO_MOD_DISCOVERY_RECIPEDIR}"
371 GENERATE_RC=$?
372 if [ $GENERATE_RC -eq 0 ]; then
373 echo ""
374 echo "✓ Recipe files regenerated in: ${GO_MOD_DISCOVERY_RECIPEDIR}"
375 else
376 bbwarn "Recipe generation failed (exit code $GENERATE_RC)"
377 bbwarn "Check the output above for errors"
378 fi
379 else
380 bbwarn "Could not find oe-go-mod-fetcher.py script"
381 bbwarn "Run manually: oe-go-mod-fetcher.py --discovered-modules ${GO_MOD_DISCOVERY_MODULES_JSON} --git-repo ${GO_MOD_DISCOVERY_GIT_REPO} --git-ref ${GO_MOD_DISCOVERY_GIT_REF} --recipedir ${GO_MOD_DISCOVERY_RECIPEDIR}"
382 fi
383 fi
384 elif [ "${GO_MOD_DISCOVERY_SKIP_GENERATE}" = "1" ]; then
385 echo ""
386 echo "Skipping automatic generation (GO_MOD_DISCOVERY_SKIP_GENERATE=1)"
387 echo ""
388 echo "NEXT STEP: Regenerate recipe manually:"
389 echo ""
390 echo " ./meta-virtualization/scripts/oe-go-mod-fetcher.py \\"
391 echo " --discovered-modules ${GO_MOD_DISCOVERY_MODULES_JSON} \\"
392 echo " --git-repo <your-git-repo-url> \\"
393 echo " --git-ref <your-git-ref> \\"
394 echo " --recipedir ${GO_MOD_DISCOVERY_RECIPEDIR}"
395 fi
396
397 echo ""
398 echo "NOTE: Cache is stored OUTSIDE WORKDIR in a persistent location."
399 echo " This cache survives 'bitbake ${PN} -c cleanall'!"
400 echo " To clean: rm -rf ${GO_MOD_DISCOVERY_DIR}"
401 echo ""
402 echo "======================================================================"
403}
404
405# Make this task manually runnable (not part of default build)
406# Run after unpack and patch so source is available
407addtask discover_modules after do_patch
408
409# Task dependencies - need source unpacked and full toolchain available
410# Depend on do_prepare_recipe_sysroot to get cross-compiler for CGO
411do_discover_modules[depends] = "${PN}:do_prepare_recipe_sysroot"
412
413# Enable network access for this task ONLY
414do_discover_modules[network] = "1"
415
416# Don't create stamp file - allow running multiple times
417do_discover_modules[nostamp] = "1"
418
419# Track all configuration variables for proper task hashing
420do_discover_modules[vardeps] += "GO_MOD_DISCOVERY_DIR GO_MOD_DISCOVERY_SRCDIR \
421 GO_MOD_DISCOVERY_BUILD_TARGET GO_MOD_DISCOVERY_BUILD_TAGS \
422 GO_MOD_DISCOVERY_LDFLAGS GO_MOD_DISCOVERY_GOPATH GO_MOD_DISCOVERY_OUTPUT \
423 GO_MOD_DISCOVERY_MODULES_JSON GO_MOD_DISCOVERY_SKIP_EXTRACT \
424 GO_MOD_DISCOVERY_SKIP_GENERATE GO_MOD_DISCOVERY_GIT_REPO \
425 GO_MOD_DISCOVERY_GIT_REF GO_MOD_DISCOVERY_RECIPEDIR"
426
427# Task to clean the persistent discovery cache
428# Usage: bitbake <recipe> -c clean_discovery
429do_clean_discovery() {
430 if [ -d "${GO_MOD_DISCOVERY_DIR}" ]; then
431 echo "Removing discovery cache: ${GO_MOD_DISCOVERY_DIR}"
432 rm -rf "${GO_MOD_DISCOVERY_DIR}"
433 echo "Discovery cache removed."
434 else
435 echo "Discovery cache not found: ${GO_MOD_DISCOVERY_DIR}"
436 fi
437}
438
439addtask clean_discovery
440do_clean_discovery[nostamp] = "1"
441do_clean_discovery[vardeps] += "GO_MOD_DISCOVERY_DIR"
diff --git a/classes/go-mod-vcs.bbclass b/classes/go-mod-vcs.bbclass
new file mode 100644
index 00000000..26c8c7f3
--- /dev/null
+++ b/classes/go-mod-vcs.bbclass
@@ -0,0 +1,1107 @@
1#
2# Copyright OpenEmbedded Contributors
3#
4# SPDX-License-Identifier: MIT
5#
6
7# go-mod-vcs.bbclass
8#
9# Provides tasks for building Go module cache from VCS (git) sources.
10# This enables fully offline Go builds using modules fetched via BitBake's
11# git fetcher instead of the Go proxy.
12#
13# USAGE:
14# 1. Add to recipe: inherit go-mod-vcs
15# 2. Define GO_MODULE_CACHE_DATA as JSON array of module metadata
16# 3. Include go-mod-git.inc for SRC_URI git entries
17# 4. Include go-mod-cache.inc for GO_MODULE_CACHE_DATA
18#
19# DEPENDENCIES:
20# - Works with oe-core's go.bbclass and go-mod.bbclass
21# - h1: checksums calculated in pure Python (with go-dirhash-native fallback)
22# - Optional: go-dirhash-native for fallback checksum calculation
23#
24# TASKS PROVIDED:
25# - do_create_module_cache: Builds module cache from git repos
26# - do_sync_go_files: Synchronizes go.sum with cache checksums
27#
28# GENERATED FILES:
29# The oe-go-mod-fetcher.py script generates two .inc files per recipe:
30# - go-mod-git.inc: SRC_URI and SRCREV entries for git fetching
31# - go-mod-cache.inc: GO_MODULE_CACHE_DATA JSON + inherit go-mod-vcs
32#
33# This class extracts the reusable Python task code, so generated .inc files
34# only contain recipe-specific data (SRC_URI entries and module metadata).
35#
36# ARCHITECTURE NOTES:
37# - assemble_zip() must create zips INSIDE TemporaryDirectory context
38# - synthesize_go_mod() preserves go version directive from original go.mod
39#
40# CONFIGURATION:
41# GO_MOD_SKIP_ZIP_EXTRACTION - Set to "1" to skip extracting zips to pkg/mod
42# Go can extract on-demand from cache (experimental)
43#
44
45python do_create_module_cache() {
46 """
47 Build Go module cache from downloaded git repositories.
48 This creates the same cache structure as oe-core's gomod.bbclass.
49
50 NOTE: h1: checksums are calculated in pure Python during zip creation.
51 Falls back to go-dirhash-native if Python hash fails.
52 """
53 import hashlib
54 import json
55 import os
56 import shutil
57 import subprocess
58 import zipfile
59 import stat
60 import base64
61 from pathlib import Path
62 from datetime import datetime
63
64 # Check for optional go-dirhash-native fallback tool
65 go_dirhash_helper = Path(d.getVar('STAGING_BINDIR_NATIVE') or '') / "dirhash"
66 if not go_dirhash_helper.exists():
67 go_dirhash_helper = None
68 bb.debug(1, "go-dirhash-native not available, using pure Python for h1: checksums")
69
70 def calculate_h1_hash_python(zip_path):
71 """Calculate Go module h1: hash in pure Python."""
72 lines = []
73 with zipfile.ZipFile(zip_path, 'r') as zf:
74 for info in sorted(zf.infolist(), key=lambda x: x.filename):
75 if info.is_dir():
76 continue
77 file_data = zf.read(info.filename)
78 file_hash = hashlib.sha256(file_data).hexdigest()
79 lines.append(f"{file_hash} {info.filename}\n")
80 summary = "".join(lines).encode('utf-8')
81 final_hash = hashlib.sha256(summary).digest()
82 return "h1:" + base64.b64encode(final_hash).decode('ascii')
83
84 def calculate_h1_hash_native(zip_path):
85 """Calculate Go module h1: hash using go-dirhash-native (fallback)."""
86 if go_dirhash_helper is None:
87 return None
88 result = subprocess.run(
89 [str(go_dirhash_helper), str(zip_path)],
90 capture_output=True, text=True, check=False, timeout=60
91 )
92 if result.returncode != 0:
93 return None
94 hash_value = result.stdout.strip()
95 if not hash_value.startswith("h1:"):
96 return None
97 return hash_value
98
99 # Define helper functions BEFORE they are used
100 def escape_module_path(path):
101 """Escape capital letters using exclamation points (same as BitBake gomod.py)"""
102 import re
103 return re.sub(r'([A-Z])', lambda m: '!' + m.group(1).lower(), path)
104
105 def sanitize_module_name(name):
106 """Remove quotes from module names"""
107 if not name:
108 return name
109 stripped = name.strip()
110 if len(stripped) >= 2 and stripped[0] == '"' and stripped[-1] == '"':
111 return stripped[1:-1]
112 return stripped
113
114 go_sum_hashes = {}
115 go_sum_entries = {}
116 go_sum_path = Path(d.getVar('S')) / "src" / "import" / "go.sum"
117 if go_sum_path.exists():
118 with open(go_sum_path, 'r') as f:
119 for line in f:
120 parts = line.strip().split()
121 if len(parts) != 3:
122 continue
123 mod, ver, hash_value = parts
124 mod = sanitize_module_name(mod)
125 go_sum_entries[(mod, ver)] = hash_value
126 if mod.endswith('/go.mod') or not hash_value.startswith('h1:'):
127 continue
128 key = f"{mod}@{ver}"
129 go_sum_hashes.setdefault(key, hash_value)
130
131 def load_require_versions(go_mod_path):
132 versions = {}
133 if not go_mod_path.exists():
134 return versions
135
136 in_block = False
137 with go_mod_path.open('r', encoding='utf-8') as f:
138 for raw_line in f:
139 line = raw_line.strip()
140
141 if line.startswith('require ('):
142 in_block = True
143 continue
144 if in_block and line == ')':
145 in_block = False
146 continue
147
148 if line.startswith('require ') and '(' not in line:
149 parts = line.split()
150 if len(parts) >= 3:
151 versions[sanitize_module_name(parts[1])] = parts[2]
152 continue
153
154 if in_block and line and not line.startswith('//'):
155 parts = line.split()
156 if len(parts) >= 2:
157 versions[sanitize_module_name(parts[0])] = parts[1]
158
159 return versions
160
161 def load_replacements(go_mod_path):
162 replacements = {}
163 if not go_mod_path.exists():
164 return replacements
165
166 def parse_replace_line(content):
167 if '//' in content:
168 content = content.split('//', 1)[0].strip()
169 if '=>' not in content:
170 return
171 left, right = [part.strip() for part in content.split('=>', 1)]
172 left_parts = left.split()
173 right_parts = right.split()
174 if not left_parts or not right_parts:
175 return
176 old_module = sanitize_module_name(left_parts[0])
177 old_version = left_parts[1] if len(left_parts) > 1 else None
178 new_module = sanitize_module_name(right_parts[0])
179 new_version = right_parts[1] if len(right_parts) > 1 else None
180 replacements[old_module] = {
181 "old_version": old_version,
182 "new_module": new_module,
183 "new_version": new_version,
184 }
185
186 in_block = False
187 with go_mod_path.open('r', encoding='utf-8') as f:
188 for raw_line in f:
189 line = raw_line.strip()
190
191 if line.startswith('replace ('):
192 in_block = True
193 continue
194 if in_block and line == ')':
195 in_block = False
196 continue
197
198 if line.startswith('replace ') and '(' not in line:
199 parse_replace_line(line[len('replace '):])
200 continue
201
202 if in_block and line and not line.startswith('//'):
203 parse_replace_line(line)
204
205 return replacements
206
207 go_mod_path = Path(d.getVar('S')) / "src" / "import" / "go.mod"
208 require_versions = load_require_versions(go_mod_path)
209 replacements = load_replacements(go_mod_path)
210
211 def duplicate_module_version(module_path, source_version, alias_version, timestamp):
212 if alias_version == source_version:
213 return
214
215 escaped_module = escape_module_path(module_path)
216 cache_dir = Path(d.getVar('S')) / "pkg" / "mod" / "cache" / "download"
217 download_dir = cache_dir / escaped_module / "@v"
218 download_dir.mkdir(parents=True, exist_ok=True)
219
220 escaped_source_version = escape_module_path(source_version)
221 escaped_alias_version = escape_module_path(alias_version)
222
223 source_base = download_dir / escaped_source_version
224 alias_base = download_dir / escaped_alias_version
225
226 if not (source_base.with_suffix('.zip').exists() and source_base.with_suffix('.mod').exists()):
227 return
228
229 if alias_base.with_suffix('.zip').exists():
230 return
231
232 import shutil
233 shutil.copy2(source_base.with_suffix('.zip'), alias_base.with_suffix('.zip'))
234 shutil.copy2(source_base.with_suffix('.mod'), alias_base.with_suffix('.mod'))
235 ziphash_src = source_base.with_suffix('.ziphash')
236 if ziphash_src.exists():
237 shutil.copy2(ziphash_src, alias_base.with_suffix('.ziphash'))
238
239 info_path = alias_base.with_suffix('.info')
240 info_data = {
241 "Version": alias_version,
242 "Time": timestamp
243 }
244 with open(info_path, 'w') as f:
245 json.dump(info_data, f)
246
247 bb.note(f"Duplicated module version {module_path}@{alias_version} from {source_version} for replace directive")
248
249 def create_module_zip(module_path, version, vcs_path, subdir, timestamp):
250 """Create module zip file from git repository"""
251 module_path = sanitize_module_name(module_path)
252
253 # Detect canonical module path FIRST from go.mod.
254 # This prevents creating duplicate cache entries for replace directives.
255 # For "github.com/google/cadvisor => github.com/k3s-io/cadvisor", the
256 # k3s-io fork declares "module github.com/google/cadvisor" in its go.mod,
257 # so we create the cache ONLY at github.com/google/cadvisor.
258 def detect_canonical_module_path(vcs_path, subdir_hint, requested_module):
259 """
260 Read go.mod file to determine the canonical module path.
261 This is critical for replace directives - always use the path declared
262 in the module's own go.mod, not the replacement path.
263 """
264 path = Path(vcs_path)
265
266 # Build list of candidate subdirs to check
267 candidates = []
268 if subdir_hint:
269 candidates.append(subdir_hint)
270
271 # Also try deriving subdir from module path
272 parts = requested_module.split('/')
273 if len(parts) > 3:
274 guess = '/'.join(parts[3:])
275 if guess and guess not in candidates:
276 candidates.append(guess)
277
278 # Always check root directory last
279 if '' not in candidates:
280 candidates.append('')
281
282 # Search for go.mod file and read its module declaration
283 for candidate in candidates:
284 gomod_file = path / candidate / "go.mod" if candidate else path / "go.mod"
285 if not gomod_file.exists():
286 continue
287
288 try:
289 with gomod_file.open('r', encoding='utf-8') as fh:
290 first_line = fh.readline().strip()
291 # Parse: "module github.com/example/repo"
292 if first_line.startswith('module '):
293 canonical = first_line[7:].strip() # Skip "module "
294 # Remove any inline comments
295 if '//' in canonical:
296 canonical = canonical.split('//')[0].strip()
297 # CRITICAL: Remove quotes from module names
298 canonical = sanitize_module_name(canonical)
299 return canonical, candidate
300 except (UnicodeDecodeError, IOError):
301 continue
302
303 # Fallback: if no go.mod found, use requested path
304 bb.warn(f"No go.mod found for {requested_module} in {vcs_path}, using requested path")
305 return requested_module, ''
306
307 canonical_module_path, detected_subdir = detect_canonical_module_path(vcs_path, subdir, module_path)
308
309 # Keep track of the original (requested) module path for replaced modules
310 # We'll need to create symlinks from requested -> canonical after cache creation
311 requested_module_path = module_path
312
313 # If canonical path differs from requested path, this is a replace directive
314 if canonical_module_path != module_path:
315 bb.note(f"Replace directive detected: {module_path} -> canonical {canonical_module_path}")
316 bb.note(f"Creating cache at canonical path, will symlink from requested path")
317 module_path = canonical_module_path
318
319 escaped_module = escape_module_path(module_path)
320 escaped_version = escape_module_path(version)
321
322 # Create cache directory structure using CANONICAL module path
323 workdir = Path(d.getVar('WORKDIR'))
324 s = Path(d.getVar('S'))
325 cache_dir = s / "pkg" / "mod" / "cache" / "download"
326 download_dir = cache_dir / escaped_module / "@v"
327 download_dir.mkdir(parents=True, exist_ok=True)
328
329 bb.note(f"Creating cache for {module_path}@{version}")
330
331 # Override subdir with detected subdir from canonical path detection
332 if detected_subdir:
333 subdir = detected_subdir
334
335 def detect_subdir() -> str:
336 hinted = subdir or ""
337 path = Path(vcs_path)
338
339 def path_exists(rel: str) -> bool:
340 if not rel:
341 return True
342 return (path / rel).exists()
343
344 candidate_order = []
345 if hinted and hinted not in candidate_order:
346 candidate_order.append(hinted)
347
348 module_parts = module_path.split('/')
349 if len(module_parts) > 3:
350 guess = '/'.join(module_parts[3:])
351 if guess and guess not in candidate_order:
352 candidate_order.append(guess)
353
354 target_header = f"module {module_path}\n"
355 found = None
356 try:
357 for go_mod in path.rglob('go.mod'):
358 rel = go_mod.relative_to(path)
359 if any(part.startswith('.') and part != '.' for part in rel.parts):
360 continue
361 if 'vendor' in rel.parts:
362 continue
363 try:
364 with go_mod.open('r', encoding='utf-8') as fh:
365 first_line = fh.readline()
366 except UnicodeDecodeError:
367 continue
368 if first_line.strip() == target_header.strip():
369 rel_dir = go_mod.parent.relative_to(path).as_posix()
370 found = rel_dir
371 break
372 except Exception:
373 pass
374
375 if found is not None and found not in candidate_order:
376 candidate_order.insert(0, found)
377
378 candidate_order.append('')
379
380 for candidate in candidate_order:
381 if path_exists(candidate):
382 return candidate
383 return ''
384
385 subdir_resolved = detect_subdir()
386
387 # 1. Create .info file
388 info_path = download_dir / f"{escaped_version}.info"
389 info_data = {
390 "Version": version,
391 "Time": timestamp
392 }
393 with open(info_path, 'w') as f:
394 json.dump(info_data, f)
395 bb.debug(1, f"Created {info_path}")
396
397 # 2. Create .mod file
398 mod_path = download_dir / f"{escaped_version}.mod"
399 effective_subdir = subdir_resolved
400
401 def candidate_subdirs():
402 candidates = []
403 parts = module_path.split('/')
404 if len(parts) >= 4:
405 extra = '/'.join(parts[3:])
406 if extra:
407 candidates.append(extra)
408
409 if effective_subdir:
410 candidates.insert(0, effective_subdir)
411 else:
412 candidates.append('')
413
414 suffix = parts[-1]
415 if suffix.startswith('v') and suffix[1:].isdigit():
416 suffix_path = f"{effective_subdir}/{suffix}" if effective_subdir else suffix
417 if suffix_path not in candidates:
418 candidates.insert(0, suffix_path)
419
420 if '' not in candidates:
421 candidates.append('')
422 return candidates
423
424 gomod_file = None
425 for candidate in candidate_subdirs():
426 path_candidate = Path(vcs_path) / candidate / "go.mod" if candidate else Path(vcs_path) / "go.mod"
427 if path_candidate.exists():
428 gomod_file = path_candidate
429 if candidate != effective_subdir:
430 effective_subdir = candidate
431 break
432
433 subdir_resolved = effective_subdir
434
435 if gomod_file is None:
436 gomod_file = Path(vcs_path) / effective_subdir / "go.mod" if effective_subdir else Path(vcs_path) / "go.mod"
437
438 def synthesize_go_mod(modname, go_version=None):
439 sanitized = sanitize_module_name(modname)
440 if go_version:
441 return f"module {sanitized}\n\ngo {go_version}\n".encode('utf-8')
442 return f"module {sanitized}\n".encode('utf-8')
443
444 mod_content = None
445
446 def is_vendored_package(rel_path):
447 if rel_path.startswith("vendor/"):
448 prefix_len = len("vendor/")
449 else:
450 idx = rel_path.find("/vendor/")
451 if idx < 0:
452 return False
453 prefix_len = len("/vendor/")
454 return "/" in rel_path[prefix_len:]
455
456 if '+incompatible' in version:
457 mod_content = synthesize_go_mod(module_path)
458 bb.debug(1, f"Synthesizing go.mod for +incompatible module {module_path}@{version}")
459 elif gomod_file.exists():
460 # Read the existing go.mod and check if module declaration matches
461 mod_content = gomod_file.read_bytes()
462
463 # Parse the module declaration to check for mismatch
464 import re
465 match = re.search(rb'^\s*module\s+(\S+)', mod_content, re.MULTILINE)
466 if match:
467 declared_module = match.group(1).decode('utf-8', errors='ignore')
468 if declared_module != module_path:
469 # Extract go version directive from original go.mod before synthesizing
470 go_version = None
471 go_match = re.search(rb'^\s*go\s+(\d+\.\d+(?:\.\d+)?)', mod_content, re.MULTILINE)
472 if go_match:
473 go_version = go_match.group(1).decode('utf-8', errors='ignore')
474 # Module declaration doesn't match import path - synthesize correct one
475 bb.warn(f"Module {module_path}@{version}: go.mod declares '{declared_module}' but should be '{module_path}', synthesizing correct go.mod (preserving go {go_version})")
476 mod_content = synthesize_go_mod(module_path, go_version)
477 else:
478 bb.debug(1, f"go.mod not found at {gomod_file}")
479 mod_content = synthesize_go_mod(module_path)
480
481 with open(mod_path, 'wb') as f:
482 f.write(mod_content)
483 bb.debug(1, f"Created {mod_path}")
484
485 license_blobs = []
486 if effective_subdir:
487 license_candidates = [
488 "LICENSE",
489 "LICENSE.txt",
490 "LICENSE.md",
491 "LICENCE",
492 "COPYING",
493 "COPYING.txt",
494 "COPYING.md",
495 ]
496 for candidate in license_candidates:
497 try:
498 content = subprocess.check_output(
499 ["git", "show", f"HEAD:{candidate}"],
500 cwd=vcs_path,
501 stderr=subprocess.DEVNULL,
502 )
503 except subprocess.CalledProcessError:
504 continue
505 license_blobs.append((Path(candidate).name, content))
506 break
507
508 # 3. Create .zip file using git archive + filtering
509 zip_path = download_dir / f"{escaped_version}.zip"
510 # IMPORTANT: For replaced modules, zip internal paths must use the REQUESTED module path,
511 # not the canonical path. Go expects to unzip files to requested_module@version/ directory.
512 zip_prefix = f"{requested_module_path}@{version}/"
513 module_key = f"{module_path}@{version}"
514 expected_hash = go_sum_hashes.get(module_key)
515
516 import tarfile
517 import tempfile
518
519 # IMPORTANT: assemble_zip() must run INSIDE TemporaryDirectory context.
520 # The add_zip_entry() and zipfile.ZipFile code MUST be indented inside the
521 # 'with tempfile.TemporaryDirectory()' block. If placed outside, the temp
522 # directory is deleted before files are added, resulting in empty zips.
523 def assemble_zip(include_vendor_modules: bool) -> str:
524 """
525 Create module zip and compute h1: hash in single pass.
526 Returns h1: hash string on success, None on failure.
527
528 This avoids re-reading the zip file after creation by tracking
529 file hashes during the zip creation process.
530 """
531 import base64
532
533 try:
534 with tempfile.TemporaryDirectory(dir=str(download_dir)) as tmpdir:
535 tar_path = Path(tmpdir) / "archive.tar"
536 archive_cmd = ["git", "archive", "--format=tar", "-o", str(tar_path), "HEAD"]
537 if subdir_resolved:
538 archive_cmd.append(subdir_resolved)
539
540 subprocess.run(archive_cmd, cwd=str(vcs_path), check=True, capture_output=True)
541
542 with tarfile.open(tar_path, 'r') as tf:
543 tf.extractall(tmpdir)
544 tar_path.unlink(missing_ok=True)
545
546 extract_root = Path(tmpdir)
547 if subdir_resolved:
548 extract_root = extract_root / subdir_resolved
549
550 excluded_prefixes = []
551 for gomod_file in extract_root.rglob("go.mod"):
552 rel_path = gomod_file.relative_to(extract_root).as_posix()
553 if rel_path != "go.mod":
554 prefix = gomod_file.parent.relative_to(extract_root).as_posix()
555 if prefix and not prefix.endswith("/"):
556 prefix += "/"
557 excluded_prefixes.append(prefix)
558
559 if zip_path.exists():
560 zip_path.unlink()
561
562 # Track file hashes for h1: calculation during zip creation
563 hash_entries = [] # List of (arcname, sha256_hex)
564
565 def add_zip_entry(zf, arcname, data, mode=None):
566 info = zipfile.ZipInfo(arcname)
567 info.date_time = (1980, 1, 1, 0, 0, 0)
568 info.compress_type = zipfile.ZIP_DEFLATED
569 info.create_system = 3 # Unix
570 if mode is None:
571 mode = stat.S_IFREG | 0o644
572 info.external_attr = ((mode & 0xFFFF) << 16)
573 zf.writestr(info, data)
574 # Track hash for h1: calculation
575 hash_entries.append((arcname, hashlib.sha256(data).hexdigest()))
576
577 with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zf:
578 for file_path in sorted(extract_root.rglob("*")):
579 if file_path.is_dir():
580 continue
581
582 rel_path = file_path.relative_to(extract_root).as_posix()
583
584 if file_path.is_symlink():
585 continue
586
587 if is_vendored_package(rel_path):
588 continue
589
590 if rel_path == "vendor/modules.txt" and not include_vendor_modules:
591 continue
592
593 if any(rel_path.startswith(prefix) for prefix in excluded_prefixes):
594 continue
595 if rel_path.endswith("go.mod") and rel_path != "go.mod":
596 continue
597
598 if rel_path == "go.mod":
599 data = mod_content
600 mode = stat.S_IFREG | 0o644
601 else:
602 data = file_path.read_bytes()
603 try:
604 mode = file_path.stat().st_mode
605 except FileNotFoundError:
606 mode = stat.S_IFREG | 0o644
607
608 add_zip_entry(zf, zip_prefix + rel_path, data, mode)
609
610 for license_name, content in license_blobs:
611 if (extract_root / license_name).exists():
612 continue
613 add_zip_entry(zf, zip_prefix + license_name, content, stat.S_IFREG | 0o644)
614
615 # Calculate h1: hash from tracked entries (sorted by filename)
616 hash_entries.sort(key=lambda x: x[0])
617 lines = [f"{h} {name}\n" for name, h in hash_entries]
618 summary = "".join(lines).encode('utf-8')
619 final_hash = hashlib.sha256(summary).digest()
620 inline_hash = "h1:" + base64.b64encode(final_hash).decode('ascii')
621 return inline_hash
622
623 except subprocess.CalledProcessError as e:
624 bb.error(f"Failed to create zip for {module_path}@{version}: {e.stderr.decode()}")
625 return None
626 except Exception as e:
627 bb.error(f"Failed to assemble zip for {module_path}@{version}: {e}")
628 # Fallback: try native tool if zip was created but hash calculation failed
629 if zip_path.exists():
630 fallback_hash = calculate_h1_hash_native(zip_path)
631 if fallback_hash:
632 bb.warn(f"Using go-dirhash-native fallback for {module_path}@{version}")
633 return fallback_hash
634 return None
635
636 hash_value = assemble_zip(include_vendor_modules=True)
637 if hash_value is None:
638 return None
639
640 if expected_hash and hash_value and hash_value != expected_hash:
641 bb.debug(1, f"Hash mismatch for {module_key} ({hash_value} != {expected_hash}), retrying without vendor/modules.txt")
642 retry_hash = assemble_zip(include_vendor_modules=False)
643 if retry_hash is None:
644 return None
645 hash_value = retry_hash
646
647 if hash_value and hash_value != expected_hash:
648 bb.warn(f"{module_key} still mismatches expected hash after retry ({hash_value} != {expected_hash})")
649
650 if hash_value:
651 ziphash_path = download_dir / f"{escaped_version}.ziphash"
652 with open(ziphash_path, 'w') as f:
653 f.write(f"{hash_value}\n")
654 bb.debug(1, f"Created {ziphash_path}")
655 else:
656 bb.warn(f"Skipping ziphash for {module_key} due to calculation errors")
657
658 # 5. Extract zip to pkg/mod for offline builds
659 # This step can be skipped if Go extracts on-demand from cache (experimental)
660 skip_extraction = d.getVar('GO_MOD_SKIP_ZIP_EXTRACTION') == "1"
661 if not skip_extraction:
662 extract_dir = s / "pkg" / "mod"
663 try:
664 with zipfile.ZipFile(zip_path, 'r') as zip_ref:
665 zip_ref.extractall(extract_dir)
666 bb.debug(1, f"Extracted {module_path}@{version} to {extract_dir}")
667 except Exception as e:
668 bb.error(f"Failed to extract {module_path}@{version}: {e}")
669 return None
670
671 # 6. If this was a replaced module, create symlinks from requested path to canonical path
672 # This ensures Go can find the module by either name
673 if requested_module_path != module_path:
674 import os
675 escaped_requested = escape_module_path(requested_module_path)
676 requested_download_dir = cache_dir / escaped_requested / "@v"
677 requested_download_dir.mkdir(parents=True, exist_ok=True)
678
679 # Create symlinks for all cache files (.info, .mod, .zip, .ziphash)
680 for suffix in ['.info', '.mod', '.zip', '.ziphash']:
681 canonical_file = download_dir / f"{escaped_version}{suffix}"
682 requested_file = requested_download_dir / f"{escaped_version}{suffix}"
683
684 if canonical_file.exists() and not requested_file.exists():
685 try:
686 # Calculate relative path from requested to canonical
687 rel_path = os.path.relpath(canonical_file, requested_file.parent)
688 os.symlink(rel_path, requested_file)
689 bb.debug(1, f"Created symlink: {requested_file} -> {rel_path}")
690 except Exception as e:
691 bb.warn(f"Failed to create symlink for {requested_module_path}: {e}")
692
693 bb.note(f"Created symlinks for replaced module: {requested_module_path} -> {module_path}")
694
695 # Return the canonical module path for post-processing (e.g., duplicate version handling)
696 return module_path
697
698 def regenerate_go_sum():
699 s_path = Path(d.getVar('S'))
700 cache_dir = s_path / "pkg" / "mod" / "cache" / "download"
701 go_sum_path = s_path / "src" / "import" / "go.sum"
702
703 if not cache_dir.exists():
704 bb.warn("Module cache directory not found - skipping go.sum regeneration")
705 return
706
707 def calculate_zip_checksum(zip_file):
708 """Calculate h1: hash for a module zip file (pure Python with native fallback)"""
709 try:
710 result = calculate_h1_hash_python(zip_file)
711 if result:
712 return result
713 except Exception as e:
714 bb.debug(1, f"Python hash failed for {zip_file}: {e}")
715
716 # Fallback to native tool
717 fallback = calculate_h1_hash_native(zip_file)
718 if fallback:
719 return fallback
720
721 bb.warn(f"Failed to calculate zip checksum for {zip_file}")
722 return None
723
724 def calculate_mod_checksum(mod_path):
725 try:
726 mod_bytes = mod_path.read_bytes()
727 except FileNotFoundError:
728 return None
729
730 import base64
731
732 file_hash = hashlib.sha256(mod_bytes).hexdigest()
733 summary = f"{file_hash} go.mod\n".encode('ascii')
734 digest = hashlib.sha256(summary).digest()
735 return "h1:" + base64.b64encode(digest).decode('ascii')
736
737 def unescape(value):
738 import re
739
740 return re.sub(r'!([a-z])', lambda m: m.group(1).upper(), value)
741
742 existing_entries = {}
743
744 if go_sum_path.exists():
745 with open(go_sum_path, 'r') as f:
746 for line in f:
747 parts = line.strip().split()
748 if len(parts) != 3:
749 continue
750 mod, ver, hash_value = parts
751 mod = sanitize_module_name(mod)
752 existing_entries[(mod, ver)] = hash_value
753
754 new_entries = {}
755
756 for zip_file in sorted(cache_dir.rglob("*.zip")):
757 zip_hash = calculate_zip_checksum(zip_file)
758 if not zip_hash:
759 continue
760
761 parts = zip_file.parts
762 try:
763 v_index = parts.index('@v')
764 download_index = parts.index('download')
765 except ValueError:
766 bb.warn(f"Unexpected cache layout for {zip_file}")
767 continue
768
769 escaped_module_parts = parts[download_index + 1:v_index]
770 escaped_module = '/'.join(escaped_module_parts)
771 escaped_version = zip_file.stem
772
773 module_path = unescape(escaped_module)
774 version = unescape(escaped_version)
775
776 new_entries[(module_path, version)] = zip_hash
777
778 mod_checksum = calculate_mod_checksum(zip_file.with_suffix('.mod'))
779 if mod_checksum:
780 new_entries[(module_path, f"{version}/go.mod")] = mod_checksum
781
782 if not new_entries and not existing_entries:
783 bb.warn("No go.sum entries available - skipping regeneration")
784 return
785
786 final_entries = existing_entries.copy()
787 final_entries.update(new_entries)
788
789 go_sum_path.parent.mkdir(parents=True, exist_ok=True)
790 with open(go_sum_path, 'w') as f:
791 for (mod, ver) in sorted(final_entries.keys()):
792 f.write(f"{mod} {ver} {final_entries[(mod, ver)]}\n")
793
794 bb.debug(1, f"Regenerated go.sum with {len(final_entries)} entries")
795
796 # Process modules sequentially - I/O bound workload, parallelization causes disk thrashing
797 workdir = Path(d.getVar('WORKDIR'))
798 modules_data = json.loads(d.getVar('GO_MODULE_CACHE_DATA'))
799
800 bb.note(f"Building module cache for {len(modules_data)} modules")
801
802 # Track results from processing
803 results = [] # List of (module_info, success, actual_module_path)
804 success_count = 0
805 fail_count = 0
806
807 for i, module in enumerate(modules_data, 1):
808 vcs_hash = module['vcs_hash']
809 vcs_path = workdir / "sources" / "vcs_cache" / vcs_hash
810
811 # Create module cache files
812 actual_module_path = create_module_zip(
813 module['module'],
814 module['version'],
815 vcs_path,
816 module.get('subdir', ''),
817 module['timestamp'],
818 )
819
820 if actual_module_path is not None:
821 success_count += 1
822 results.append((module, True, actual_module_path))
823 else:
824 fail_count += 1
825 results.append((module, False, None))
826
827 # Progress update every 100 modules
828 if i % 100 == 0:
829 bb.note(f"Progress: {i}/{len(modules_data)} modules processed")
830
831 bb.note(f"Module processing complete: {success_count} succeeded, {fail_count} failed")
832
833 # Post-processing: handle duplicate versions for replace directives (must be sequential)
834 for module_info, success, actual_module_path in results:
835 if success and actual_module_path:
836 alias_info = replacements.get(actual_module_path)
837 if alias_info:
838 alias_version = alias_info.get("old_version") or require_versions.get(actual_module_path)
839 if alias_version is None:
840 for (mod, ver), _hash in go_sum_entries.items():
841 if mod == actual_module_path and not ver.endswith('/go.mod'):
842 alias_version = ver
843 break
844 if alias_version and alias_version != module_info['version']:
845 duplicate_module_version(actual_module_path, module_info['version'], alias_version, module_info['timestamp'])
846
847 if fail_count == 0:
848 regenerate_go_sum()
849 else:
850 bb.warn("Skipping go.sum regeneration due to module cache failures")
851
852 bb.note(f"Module cache complete: {success_count} succeeded, {fail_count} failed")
853
854 if fail_count > 0:
855 bb.fatal(f"Failed to create cache for {fail_count} modules")
856}
857
858addtask create_module_cache after do_unpack do_prepare_recipe_sysroot before do_configure
859
860
861python do_sync_go_files() {
862 """
863 Synchronize go.mod and go.sum with the module cache we built from git sources.
864
865 This task solves the "go: updates to go.mod needed" error by ensuring go.mod
866 declares ALL modules present in our module cache, and go.sum has checksums
867 matching our git-built modules.
868
869 Architecture: Option 2 (Rewrite go.mod/go.sum approach)
870 - Scans pkg/mod/cache/download/ for ALL modules we built
871 - Regenerates go.mod with complete require block
872 - Regenerates go.sum with our h1: checksums from .ziphash files
873 """
874 import json
875 import hashlib
876 import re
877 from pathlib import Path
878
879 bb.note("Synchronizing go.mod and go.sum with module cache")
880
881 s = Path(d.getVar('S'))
882 cache_dir = s / "pkg" / "mod" / "cache" / "download"
883 go_mod_path = s / "src" / "import" / "go.mod"
884 go_sum_path = s / "src" / "import" / "go.sum"
885
886 if not cache_dir.exists():
887 bb.fatal("Module cache directory not found - run do_create_module_cache first")
888
889 def unescape(escaped):
890 """Unescape capital letters (reverse of escape_module_path)"""
891 import re
892 return re.sub(r'!([a-z])', lambda m: m.group(1).upper(), escaped)
893
894 def sanitize_module_name(name):
895 """Remove surrounding quotes added by legacy tools"""
896 if not name:
897 return name
898 stripped = name.strip()
899 if len(stripped) >= 2 and stripped[0] == '"' and stripped[-1] == '"':
900 return stripped[1:-1]
901 return stripped
902
903 def load_require_versions(go_mod_path):
904 versions = {}
905 if not go_mod_path.exists():
906 return versions
907
908 in_block = False
909 with go_mod_path.open('r', encoding='utf-8') as f:
910 for raw_line in f:
911 line = raw_line.strip()
912
913 if line.startswith('require ('):
914 in_block = True
915 continue
916 if in_block and line == ')':
917 in_block = False
918 continue
919
920 if line.startswith('require ') and '(' not in line:
921 parts = line.split()
922 if len(parts) >= 3:
923 versions[sanitize_module_name(parts[1])] = parts[2]
924 continue
925
926 if in_block and line and not line.startswith('//'):
927 parts = line.split()
928 if len(parts) >= 2:
929 versions[sanitize_module_name(parts[0])] = parts[1]
930
931 return versions
932
933 def load_replacements(go_mod_path):
934 replacements = {}
935 if not go_mod_path.exists():
936 return replacements
937
938 def parse_replace_line(line):
939 if '//' in line:
940 line = line.split('//', 1)[0].strip()
941 if not line or '=>' not in line:
942 return
943 left, right = [part.strip() for part in line.split('=>', 1)]
944 left_parts = left.split()
945 right_parts = right.split()
946 if not left_parts or not right_parts:
947 return
948 old_module = sanitize_module_name(left_parts[0])
949 old_version = left_parts[1] if len(left_parts) > 1 else None
950 new_module = sanitize_module_name(right_parts[0])
951 new_version = right_parts[1] if len(right_parts) > 1 else None
952 replacements[old_module] = {
953 "old_version": old_version,
954 "new_module": new_module,
955 "new_version": new_version,
956 }
957
958 in_block = False
959 with go_mod_path.open('r', encoding='utf-8') as f:
960 for raw_line in f:
961 line = raw_line.strip()
962
963 if line.startswith('replace ('):
964 in_block = True
965 continue
966 if in_block and line == ')':
967 in_block = False
968 continue
969
970 if line.startswith('replace ') and '(' not in line:
971 parse_replace_line(line[len('replace '):])
972 continue
973
974 if in_block and line and not line.startswith('//'):
975 parse_replace_line(line)
976
977 return replacements
978
979 require_versions = load_require_versions(go_mod_path)
980 replacements = load_replacements(go_mod_path)
981
982 # 1. Scan module cache to discover ALL modules we built
983 # Map: (module_path, version) -> {"zip_checksum": str, "mod_path": Path}
984 our_modules = {}
985
986 bb.note("Scanning module cache...")
987 for zip_file in sorted(cache_dir.rglob("*.zip")):
988 parts = zip_file.parts
989 try:
990 v_index = parts.index('@v')
991 download_index = parts.index('download')
992 except ValueError:
993 continue
994
995 escaped_module_parts = parts[download_index + 1:v_index]
996 escaped_module = '/'.join(escaped_module_parts)
997 escaped_version = zip_file.stem
998
999 module_path = unescape(escaped_module)
1000 module_path = sanitize_module_name(module_path)
1001 version = unescape(escaped_version)
1002
1003 # Read checksum from .ziphash file
1004 ziphash_file = zip_file.with_suffix('.ziphash')
1005 if ziphash_file.exists():
1006 checksum = ziphash_file.read_text().strip()
1007 # Some .ziphash files have literal \\n at the end - remove it
1008 if checksum.endswith('\\\\n'):
1009 checksum = checksum[:-2]
1010 our_modules[(module_path, version)] = {
1011 "zip_checksum": checksum,
1012 "mod_path": zip_file.with_suffix('.mod'),
1013 }
1014
1015 if not our_modules:
1016 bb.fatal("No modules found in cache - cannot synchronize go.mod/go.sum")
1017
1018 bb.note(f"Found {len(our_modules)} modules in cache")
1019
1020 # 2. DO NOT modify go.mod - keep the original module declarations
1021 # The real problem is go.sum has wrong checksums (proxy vs git), not missing modules
1022 bb.note("Leaving go.mod unchanged - only updating go.sum with git-based checksums")
1023
1024 # 3. Read original go.sum to preserve entries for modules not in our cache
1025 original_sum_entries = {}
1026 if go_sum_path.exists():
1027 for line in go_sum_path.read_text().splitlines():
1028 line = line.strip()
1029 if not line:
1030 continue
1031 parts = line.split()
1032 if len(parts) >= 3:
1033 module = sanitize_module_name(parts[0])
1034 version = parts[1]
1035 checksum = parts[2]
1036 original_sum_entries[(module, version)] = checksum
1037
1038 # 4. Build new go.sum by updating checksums for modules we built
1039 sum_entries_dict = original_sum_entries.copy() # Start with original
1040
1041 for (module, version), entry in our_modules.items():
1042 # Update .zip checksum
1043 sum_entries_dict[(module, version)] = entry["zip_checksum"]
1044
1045 # Also update /go.mod entry if we have .mod file
1046 mod_file = entry["mod_path"]
1047 if mod_file.exists():
1048 # Calculate h1: checksum for .mod file
1049 mod_bytes = mod_file.read_bytes()
1050 file_hash = hashlib.sha256(mod_bytes).hexdigest()
1051 summary = f"{file_hash} go.mod\n".encode('ascii')
1052 h1_bytes = hashlib.sha256(summary).digest()
1053 mod_checksum = "h1:" + __import__('base64').b64encode(h1_bytes).decode('ascii')
1054 sum_entries_dict[(module, f"{version}/go.mod")] = mod_checksum
1055
1056 # 5. Duplicate checksums for modules that use replace directives so the original
1057 # module path (e.g., github.com/Mirantis/...) keeps matching go.sum entries.
1058 for alias_module, repl in replacements.items():
1059 alias_module = sanitize_module_name(alias_module)
1060 alias_version = repl.get("old_version")
1061 if alias_version is None:
1062 alias_version = require_versions.get(alias_module)
1063 if alias_version is None:
1064 # If go.mod didn't pin a replacement version, derive from go.sum
1065 for (mod, version) in list(original_sum_entries.keys()):
1066 if mod == alias_module and not version.endswith('/go.mod'):
1067 alias_version = version
1068 break
1069 if not alias_version:
1070 continue
1071
1072 target_module = repl.get("new_module")
1073 target_version = repl.get("new_version")
1074 if target_version is None:
1075 target_version = require_versions.get(target_module)
1076 if not target_module or not target_version:
1077 continue
1078
1079 entry = our_modules.get((target_module, target_version))
1080 if not entry and alias_module != target_module:
1081 entry = our_modules.get((alias_module, target_version))
1082 if not entry:
1083 continue
1084
1085 sum_entries_dict[(alias_module, alias_version)] = entry["zip_checksum"]
1086
1087 mod_file = entry["mod_path"]
1088 if mod_file.exists():
1089 mod_bytes = mod_file.read_bytes()
1090 file_hash = hashlib.sha256(mod_bytes).hexdigest()
1091 summary = f"{file_hash} go.mod\n".encode('ascii')
1092 h1_bytes = hashlib.sha256(summary).digest()
1093 mod_checksum = "h1:" + __import__('base64').b64encode(h1_bytes).decode('ascii')
1094 sum_entries_dict[(alias_module, f"{alias_version}/go.mod")] = mod_checksum
1095
1096 # Write merged go.sum
1097 sum_lines = []
1098 for (module, version), checksum in sorted(sum_entries_dict.items()):
1099 sum_lines.append(f"{module} {version} {checksum}")
1100
1101 go_sum_path.write_text('\n'.join(sum_lines) + '\n')
1102 bb.note(f"Updated go.sum: {len(sum_entries_dict)} total entries, {len(our_modules)} updated from cache")
1103
1104 bb.note("go.mod and go.sum synchronized successfully")
1105}
1106
1107addtask sync_go_files after do_create_module_cache before do_compile