## Talking to an Alpine mirror: index fetch, package download, local caching. ## ## Everything lands in a cache directory first, so a re-run of `muslkit add` is ## offline and a sysroot can be rebuilt without touching the network. import std/[httpclient, os, strutils, times] import ./index const defaultMirror* = "https://dl-cdn.alpinelinux.org/alpine" defaultBranch* = "v3.21" defaultRepos* = @["main", "community"] defaultArch* = "x86_64" indexMaxAge* = initDuration(hours = 24) type Remote* = object mirror*, branch*, arch*: string repos*: seq[string] cacheDir*: string quiet*: bool proc initRemote*(mirror = defaultMirror, branch = defaultBranch, arch = defaultArch, repos = defaultRepos, cacheDir = "", quiet = false): Remote = Remote(mirror: mirror, branch: branch, arch: arch, repos: repos, cacheDir: if cacheDir.len > 0: cacheDir else: getCacheDir() / "muslkit", quiet: quiet) proc repoUrl*(r: Remote, repo: string): string = r.mirror & "/" & r.branch & "/" & repo & "/" & r.arch proc note(r: Remote, msg: string) = if not r.quiet: stderr.writeLine msg proc download(r: Remote, url, dest: string) = createDir dest.parentDir let tmp = dest & ".part" var client = newHttpClient(timeout = 60_000) defer: client.close() try: client.downloadFile(url, tmp) except CatchableError as e: removeFile tmp raise newException(IOError, "fetching " & url & " failed: " & e.msg) moveFile(tmp, dest) proc needsRefresh(path: string, maxAge: Duration): bool = if not fileExists(path): return true getTime() - path.getLastModificationTime() > maxAge proc untar(archive, dest: string, members: openArray[string] = []) = ## Shell out to tar: an .apk is concatenated gzip streams, which GNU tar ## already reads, and vendoring a decompressor would be the larger evil. createDir dest var cmd = "tar -xzf " & quoteShell(archive) & " -C " & quoteShell(dest) for m in members: cmd.add " " & quoteShell(m) # apk metadata entries are not part of the package's file list. cmd.add " --exclude=.PKGINFO --exclude=.SIGN.* --warning=no-unknown-keyword" if execShellCmd(cmd) != 0: raise newException(IOError, "tar failed on " & archive) proc fetchIndex*(r: Remote, refresh = false): Index = ## Fetch (or reuse) every configured repository's APKINDEX, merged. for repo in r.repos: let dir = r.cacheDir / r.branch / repo / r.arch gz = dir / "APKINDEX.tar.gz" plain = dir / "APKINDEX" if refresh or needsRefresh(gz, indexMaxAge): r.note "fetching index: " & repo & "/" & r.arch r.download(r.repoUrl(repo) & "/APKINDEX.tar.gz", gz) removeFile plain if not fileExists(plain): untar(gz, dir, ["APKINDEX"]) result.merge parseIndex(readFile(plain), repo) proc apkFile*(pkg: Pkg): string = pkg.name & "-" & pkg.version & ".apk" proc fetchPackage*(r: Remote, pkg: Pkg): string = ## Download one package if it is not already cached; returns the local path. let dest = r.cacheDir / r.branch / pkg.repo / r.arch / apkFile(pkg) if fileExists(dest) and getFileSize(dest) == pkg.size: return dest r.note "downloading " & pkg.name & " " & pkg.version & " (" & $(pkg.size div 1024) & " KiB)" r.download(r.repoUrl(pkg.repo) & "/" & apkFile(pkg), dest) if pkg.size > 0 and getFileSize(dest) != pkg.size: removeFile dest raise newException(IOError, pkg.name & ": size mismatch against the index") dest proc unpack*(archive, sysroot: string) = untar(archive, sysroot)