Skip to content

Commit

Permalink
build: add bundle build
Browse files Browse the repository at this point in the history
A bob-bundle collects all scm's neccessary to build a package in a
tar-file. Next to the tar-file a scmOverrides file is generated to be
able to rebuild the package only with the bundle.

Such a bundle can be used to build on a air-gapped system or to archive
all sources of a build.

ATM only bundling of git and url-scm's is supported.
  • Loading branch information
rhubert committed Sep 17, 2024
1 parent 080fc60 commit 5cad406
Show file tree
Hide file tree
Showing 12 changed files with 223 additions and 11 deletions.
4 changes: 2 additions & 2 deletions contrib/bash-completion/bob
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ __bob_clean()

__bob_cook()
{
if [[ "$prev" = "--destination" ]] ; then
if [[ "$prev" = "--destination" || "$prev" == "--bundle" ]] ; then
__bob_complete_dir "$cur"
elif [[ "$prev" = "--download" ]] ; then
__bob_complete_words "yes no deps forced forced-deps forced-fallback"
Expand All @@ -127,7 +127,7 @@ __bob_cook()
elif [[ "$prev" = "--always-checkout" ]] ; then
COMPREPLY=( )
else
__bob_complete_path "--destination -j --jobs -k --keep-going -f --force -n --no-deps -p --with-provided --without-provided -A --no-audit --audit -b --build-only -B --checkout-only --normal --clean --incremental --always-checkout --resume -q --quiet -v --verbose --no-logfiles -D -c -e -E -M --upload --link-deps --no-link-deps --download --download-layer --shared --no-shared --install --no-install --sandbox --no-sandbox --clean-checkout --attic --no-attic"
__bob_complete_path "--destination -j --jobs -k --keep-going -f --force -n --no-deps -p --with-provided --without-provided -A --no-audit --audit -b --build-only -B --checkout-only --normal --clean --incremental --always-checkout --resume -q --quiet -v --verbose --no-logfiles -D -c -e -E -M --upload --link-deps --no-link-deps --download --download-layer --shared --no-shared --install --no-install --sandbox --no-sandbox --clean-checkout --attic --no-attic --bundle --bundle-exclude"
fi
}

Expand Down
10 changes: 10 additions & 0 deletions doc/manpages/bob-build-dev.rst
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,16 @@ Options

This is the default unless the user changed it in ``default.yaml``.

``--bundle BUNDLE``
Bundle all the sources needed to build the package. The bunlde is a tar-file
containing the sources and a overrides file. To use the bundle call bob
dev/build with ``-c`` pointing to the scmOverrides-file. In addition to this
the ``LOCAL_BUNDLE_BASE`` environment variable needs to be set to point to
the base-directoy where the bundle has been extracted.

``--bundle-exclude RE``
Do not add packages matching RE to the bundle.

``--clean``
Do clean builds by clearing the build directory before executing the build
commands. It will *not* clean all build results (e.g. like ``make clean``)
Expand Down
1 change: 1 addition & 0 deletions doc/manpages/bob-build.rst
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ Synopsis
[--shared | --no-shared] [--install | --no-install]
[--sandbox | --no-sandbox] [--clean-checkout]
[--attic | --no-attic]
[--bundle BUNDLE] [--bundle-exclude BUNDLE_EXCLUDE]
PACKAGE [PACKAGE ...]


Expand Down
1 change: 1 addition & 0 deletions doc/manpages/bob-dev.rst
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ Synopsis
[--shared | --no-shared] [--install | --no-install]
[--sandbox | --no-sandbox] [--clean-checkout]
[--attic | --no-attic]
[--bundle BUNDLE] [--bundle-exclude BUNDLE_EXCLUDE]
PACKAGE [PACKAGE ...]


Expand Down
118 changes: 117 additions & 1 deletion pym/bob/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
from .tty import log, stepMessage, stepAction, stepExec, setProgress, ttyReinit, \
SKIPPED, EXECUTED, INFO, WARNING, DEFAULT, \
ALWAYS, IMPORTANT, NORMAL, INFO, DEBUG, TRACE
from .utils import asHexStr, hashDirectory, removePath, emptyDirectory, \
from .utils import asHexStr, hashDirectory, hashFile, removePath, emptyDirectory, \
isWindows, INVALID_CHAR_TRANS, quoteCmdExe, getPlatformTag, canSymlink
from .share import NullShare
from shlex import quote
Expand All @@ -26,6 +26,7 @@
import concurrent.futures
import datetime
import hashlib
import fnmatch
import io
import locale
import os
Expand All @@ -34,7 +35,9 @@
import signal
import stat
import sys
import tarfile
import tempfile
import yaml

# Output verbosity:
# <= -2: package name
Expand Down Expand Up @@ -311,6 +314,76 @@ def __match(self, nestedScmPath):
return p
return None

class Bundler:
def __init__(self, name, excludes):
self.__name = name
self.__bundleFile = os.path.join(os.getcwd(), self.__name) + ".tar"
self.__excludes = excludes
self.__tempDir = tempfile.TemporaryDirectory()
self.__tempDirPath = os.path.join(self.__tempDir.name, self.__name)
self.__bundled = {}

Check warning on line 324 in pym/bob/builder.py

View check run for this annotation

Codecov / codecov/patch

pym/bob/builder.py#L319-L324

Added lines #L319 - L324 were not covered by tests

if os.path.exists(self.__bundleFile):
raise BuildError(f"Bundle {self.__bundleFile} already exists!")
os.mkdir(self.__tempDirPath)

Check warning on line 328 in pym/bob/builder.py

View check run for this annotation

Codecov / codecov/patch

pym/bob/builder.py#L326-L328

Added lines #L326 - L328 were not covered by tests

def add(self, path, bundle):
self.__bundled[path] = bundle

Check warning on line 331 in pym/bob/builder.py

View check run for this annotation

Codecov / codecov/patch

pym/bob/builder.py#L331

Added line #L331 was not covered by tests

def bundle(self, step):
for e in self.__excludes:
if fnmatch.fnmatch(step.getPackage().getName(), e):
return False
return True

Check warning on line 337 in pym/bob/builder.py

View check run for this annotation

Codecov / codecov/patch

pym/bob/builder.py#L334-L337

Added lines #L334 - L337 were not covered by tests

def getName(self):
return self.__name

Check warning on line 340 in pym/bob/builder.py

View check run for this annotation

Codecov / codecov/patch

pym/bob/builder.py#L340

Added line #L340 was not covered by tests

def getTempDir (self):
return self.__tempDirPath

Check warning on line 343 in pym/bob/builder.py

View check run for this annotation

Codecov / codecov/patch

pym/bob/builder.py#L343

Added line #L343 was not covered by tests

def finalize(self):
def _delFilter(f):
noDel = ["__*", "recipe", "digest*", "extract", "overridden", "dir",

Check warning on line 347 in pym/bob/builder.py

View check run for this annotation

Codecov / codecov/patch

pym/bob/builder.py#L346-L347

Added lines #L346 - L347 were not covered by tests
"stripComponents"]
for d in noDel:
if fnmatch.fnmatch(f, d):
return False
return True

Check warning on line 352 in pym/bob/builder.py

View check run for this annotation

Codecov / codecov/patch

pym/bob/builder.py#L349-L352

Added lines #L349 - L352 were not covered by tests

overrides = {

Check warning on line 354 in pym/bob/builder.py

View check run for this annotation

Codecov / codecov/patch

pym/bob/builder.py#L354

Added line #L354 was not covered by tests
"scmOverrides" : []
}
with tarfile.open(self.__bundleFile, "w") as bundle_tar:
for path, bundles in self.__bundled.items():
for (scm, bundleFile) in bundles:
bundlePath = os.path.join(self.__name,

Check warning on line 360 in pym/bob/builder.py

View check run for this annotation

Codecov / codecov/patch

pym/bob/builder.py#L357-L360

Added lines #L357 - L360 were not covered by tests
os.path.relpath(bundleFile, self.__tempDirPath))
properties = scm.getProperties(False)
data = {

Check warning on line 363 in pym/bob/builder.py

View check run for this annotation

Codecov / codecov/patch

pym/bob/builder.py#L362-L363

Added lines #L362 - L363 were not covered by tests
"match": {
"url": properties.get("url")
},
"del" : list(filter(_delFilter, [name for name, value in properties.items()
if value is not None])),
"set": {
"url": "file://${LOCAL_BUNDLE_BASE}/" + bundlePath,
"scm": "url",
}
}
if properties.get("scm") != "url":
d = hashFile(os.path.join(self.__tempDirPath, bundleFile),

Check warning on line 375 in pym/bob/builder.py

View check run for this annotation

Codecov / codecov/patch

pym/bob/builder.py#L374-L375

Added lines #L374 - L375 were not covered by tests
hashlib.sha256).hex()
data["set"].update({"digestSHA256" : d})
overrides["scmOverrides"].append(data)
bundle_tar.add(os.path.join(self.__tempDirPath, bundleFile),

Check warning on line 379 in pym/bob/builder.py

View check run for this annotation

Codecov / codecov/patch

pym/bob/builder.py#L377-L379

Added lines #L377 - L379 were not covered by tests
arcname=bundlePath)

overridesFile= os.path.join(self.__tempDirPath, self.__name + "_overrides.yaml")
with open(overridesFile, "w") as f:
yaml.dump(overrides, f, default_flow_style=False)
bundle_tar.add(overridesFile, arcname=os.path.join(self.__name, os.path.basename(overridesFile)))

Check warning on line 385 in pym/bob/builder.py

View check run for this annotation

Codecov / codecov/patch

pym/bob/builder.py#L382-L385

Added lines #L382 - L385 were not covered by tests

class LocalBuilder:

RUN_TEMPLATE_POSIX = """#!/bin/bash
Expand Down Expand Up @@ -407,6 +480,7 @@ def __init__(self, verbose, force, skipDeps, buildOnly, preserveEnv,
self.__installSharedPackages = False
self.__executor = None
self.__attic = True
self.__bundler = None

def setExecutor(self, executor):
self.__executor = executor
Expand Down Expand Up @@ -505,6 +579,10 @@ def setAuditMeta(self, keys):
def setAtticEnable(self, enable):
self.__attic = enable

def setBundle(self, dest, excludes):
if dest is not None:
self.__bundler = Bundler(dest, excludes)

Check warning on line 584 in pym/bob/builder.py

View check run for this annotation

Codecov / codecov/patch

pym/bob/builder.py#L584

Added line #L584 was not covered by tests

def setShareHandler(self, handler):
self.__share = handler

Expand Down Expand Up @@ -618,6 +696,10 @@ def __workspaceLock(self, step):
self.__workspaceLocks[path] = ret = asyncio.Lock()
return ret

def bundle(self):
if self.__bundler:
self.__bundler.finalize()

Check warning on line 701 in pym/bob/builder.py

View check run for this annotation

Codecov / codecov/patch

pym/bob/builder.py#L701

Added line #L701 was not covered by tests

async def _generateAudit(self, step, depth, resultHash, buildId, executed=True):
auditPath = os.path.join(os.path.dirname(step.getWorkspacePath()), "audit.json.gz")
if os.path.lexists(auditPath): removePath(auditPath)
Expand Down Expand Up @@ -790,6 +872,35 @@ async def _runShell(self, step, scriptName, logger, cleanWorkspace=None,
help="You may resume at this point with '--resume' after fixing the error.",
returncode=ret)

async def __doBundle(self, checkoutStep, logger):
dest = os.path.join(self.__bundler.getTempDir(), checkoutStep.getPackage().getRecipe().getName())
os.makedirs(dest, exist_ok=True)
logFile = os.path.join(dest, ".bundle.log")
spec = StepSpec.fromStep(checkoutStep, None, self.__envWhiteList, logFile, False)

Check warning on line 879 in pym/bob/builder.py

View check run for this annotation

Codecov / codecov/patch

pym/bob/builder.py#L876-L879

Added lines #L876 - L879 were not covered by tests

bundled = []
for scm in checkoutStep.getScmList():
invoker = Invoker(spec, self.__preserveEnv, self.__noLogFile,

Check warning on line 883 in pym/bob/builder.py

View check run for this annotation

Codecov / codecov/patch

pym/bob/builder.py#L881-L883

Added lines #L881 - L883 were not covered by tests
self.__verbose >= INFO, self.__verbose >= NORMAL,
self.__verbose >= DEBUG, self.__bufferedStdIO,
executor=self.__executor, tmpdir=scm.getProperties(checkoutStep.JENKINS)['scm']=="git")

bundleFile = os.path.join(dest, scm.bundleName)
ret = await invoker.executeScmBundle(scm, bundleFile)
bundled.append((scm, os.path.join(checkoutStep.getPackage().getName(), bundleFile)))

Check warning on line 890 in pym/bob/builder.py

View check run for this annotation

Codecov / codecov/patch

pym/bob/builder.py#L888-L890

Added lines #L888 - L890 were not covered by tests

if not self.__bufferedStdIO: ttyReinit() # work around MSYS2 messing up the console
if ret == -int(signal.SIGINT):
raise BuildError("User aborted while running bundling {}".format(self.__bundler.getName()));
elif ret != 0:
if self.__bufferedStdIO:
logger.setError(invoker.getStdio().strip())
raise BuildError("Bundling {} returned with {}"

Check warning on line 898 in pym/bob/builder.py

View check run for this annotation

Codecov / codecov/patch

pym/bob/builder.py#L892-L898

Added lines #L892 - L898 were not covered by tests
.format(checkoutStep.getPackage().getName(), ret),
returncode=ret)

self.__bundler.add(checkoutStep.getPackage().getName(), bundled)

Check warning on line 902 in pym/bob/builder.py

View check run for this annotation

Codecov / codecov/patch

pym/bob/builder.py#L902

Added line #L902 was not covered by tests

def getStatistic(self):
return self.__statistic

Expand Down Expand Up @@ -1284,6 +1395,11 @@ async def _cookCheckoutStep(self, checkoutStep, depth):
assert predicted, "Non-predicted incorrect Build-Id found!"
self.__handleChangedBuildId(checkoutStep, checkoutHash)

if self.__bundler and self.__bundler.bundle(checkoutStep):
with stepExec(checkoutStep, "BUNDLE",

Check warning on line 1399 in pym/bob/builder.py

View check run for this annotation

Codecov / codecov/patch

pym/bob/builder.py#L1399

Added line #L1399 was not covered by tests
"{} {}".format(checkoutStep.getPackage().getName(), overridesString)) as a:
await self.__doBundle (checkoutStep, a);

Check warning on line 1401 in pym/bob/builder.py

View check run for this annotation

Codecov / codecov/patch

pym/bob/builder.py#L1401

Added line #L1401 was not covered by tests

async def _cookBuildStep(self, buildStep, depth, buildBuildId):
# Add the execution path of the build step to the buildDigest to
# detect changes between sandbox and non-sandbox builds. This is
Expand Down
10 changes: 10 additions & 0 deletions pym/bob/cmds/build/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,10 @@ def _downloadLayerArgument(arg):
help="Move scm to attic if inline switch is not possible (default).")
group.add_argument('--no-attic', action='store_false', default=None, dest='attic',
help="Do not move to attic, instead fail the build.")
parser.add_argument('--bundle', metavar='BUNDLE', default=None,
help="Bundle all matching packages to BUNDLE")
parser.add_argument('--bundle-exclude', action='append', default=[],
help="Do not add matching packages to bundle.")
args = parser.parse_args(argv)

defines = processDefines(args.defines)
Expand Down Expand Up @@ -296,6 +300,9 @@ def _downloadLayerArgument(arg):
packages = recipes.generatePackages(nameFormatter, args.sandbox)
if develop: developPersister.prime(packages)

if args.bundle and args.build_mode == 'build-only':
parser.error("--bundle can't be used with --build-only")

Check warning on line 304 in pym/bob/cmds/build/build.py

View check run for this annotation

Codecov / codecov/patch

pym/bob/cmds/build/build.py#L304

Added line #L304 was not covered by tests

verbosity = cfg.get('verbosity', 0) + args.verbose - args.quiet
setVerbosity(verbosity)
builder = LocalBuilder(verbosity, args.force,
Expand All @@ -319,6 +326,7 @@ def _downloadLayerArgument(arg):
builder.setShareHandler(getShare(recipes.getShareConfig()))
builder.setShareMode(args.shared, args.install)
builder.setAtticEnable(args.attic)
builder.setBundle(args.bundle, args.bundle_exclude)
if args.resume: builder.loadBuildState()

backlog = []
Expand Down Expand Up @@ -380,6 +388,8 @@ def _downloadLayerArgument(arg):
+ " package" + ("s" if (stats.packagesBuilt != 1) else "") + " built, "
+ str(stats.packagesDownloaded) + " downloaded.")

builder.bundle()

# Copy build result if requested. It's ok to overwrite files that are
# already at the destination. Warn if built packages overwrite
# themselves, though.
Expand Down
35 changes: 33 additions & 2 deletions pym/bob/invoker.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,13 @@ class InvocationMode(Enum):

class Invoker:
def __init__(self, spec, preserveEnv, noLogFiles, showStdOut, showStdErr,
trace, redirect, executor=None):
trace, redirect, executor=None, tmpdir=False):
self.__spec = spec
self.__cwd = spec.workspaceWorkspacePath
if not tmpdir:
self.__cwd = spec.workspaceWorkspacePath
else:
self.__tempDir = tempfile.TemporaryDirectory()
self.__cwd = self.__tempDir.name

Check warning on line 90 in pym/bob/invoker.py

View check run for this annotation

Codecov / codecov/patch

pym/bob/invoker.py#L89-L90

Added lines #L89 - L90 were not covered by tests
self.__preserveEnv = preserveEnv
if preserveEnv:
self.__env = os.environ.copy()
Expand Down Expand Up @@ -515,6 +519,33 @@ async def executeScmSwitch(self, scm, oldSpec):

return ret

async def executeScmBundle(self, scm, bundleFile):
ret = 1
try:
self.__openLog("SCM Bundle")
try:
await scm.invoke(self, bundleFile=bundleFile)
except CmdFailedError as e:
self.error(scm.getSource(), "failed")
self.error(e.what)
raise
except Exception:
self.error(scm.getSource(), "failed")
raise

Check warning on line 534 in pym/bob/invoker.py

View check run for this annotation

Codecov / codecov/patch

pym/bob/invoker.py#L523-L534

Added lines #L523 - L534 were not covered by tests

# everything went well
ret = 0

Check warning on line 537 in pym/bob/invoker.py

View check run for this annotation

Codecov / codecov/patch

pym/bob/invoker.py#L537

Added line #L537 was not covered by tests

except OSError as e:
self.error("Something went wrong:", str(e))
ret = 1
except InvocationError as e:
ret = e.returncode

Check warning on line 543 in pym/bob/invoker.py

View check run for this annotation

Codecov / codecov/patch

pym/bob/invoker.py#L539-L543

Added lines #L539 - L543 were not covered by tests
finally:
self.__closeLog(ret)

Check warning on line 545 in pym/bob/invoker.py

View check run for this annotation

Codecov / codecov/patch

pym/bob/invoker.py#L545

Added line #L545 was not covered by tests

return ret

Check warning on line 547 in pym/bob/invoker.py

View check run for this annotation

Codecov / codecov/patch

pym/bob/invoker.py#L547

Added line #L547 was not covered by tests

async def runCommand(self, args, cwd=None, **kwargs):
cwd = os.path.join(self.__cwd, cwd) if cwd else self.__cwd
ret = await self.__runCommand(args, cwd, **kwargs)
Expand Down
4 changes: 3 additions & 1 deletion pym/bob/scm/cvs.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,9 @@ def getProperties(self, isJenkins, pretty=False):
})
return ret

async def invoke(self, invoker):
async def invoke(self, invoker, bundleFile=None):
if bundleFile is not None:
invoker.fail("Bundling of an cvs scm is not implemented!")

Check warning on line 51 in pym/bob/scm/cvs.py

View check run for this annotation

Codecov / codecov/patch

pym/bob/scm/cvs.py#L51

Added line #L51 was not covered by tests
# If given a ":ssh:" cvsroot, translate that to CVS_RSH using ssh, and ":ext:"
# (some versions of CVS do that internally)
m = re.match('^:ssh:(.*)', self.__cvsroot)
Expand Down
31 changes: 29 additions & 2 deletions pym/bob/scm/git.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from ..errors import ParseError, BuildError
from ..stringparser import isTrue, IfExpression
from ..tty import WarnOnce, stepAction, INFO, TRACE, WARNING
from ..utils import check_output, joinLines, run, removeUserFromUrl
from ..utils import check_output, joinLines, run, removeUserFromUrl, hashFile
from .scm import Scm, ScmAudit, ScmStatus, ScmTaint
from shlex import quote
from textwrap import dedent, indent
Expand All @@ -15,10 +15,12 @@
import concurrent.futures
import hashlib
import locale
import gzip
import os, os.path
import re
import schema
import subprocess
import tarfile

def normPath(p):
return os.path.normcase(os.path.normpath(p))
Expand Down Expand Up @@ -163,7 +165,11 @@ def getProperties(self, isJenkins, pretty=False):
properties.update({GitScm.REMOTE_PREFIX+key : val})
return properties

async def invoke(self, invoker, switch=False):
@property
def bundleName(self):
return os.path.join(self.__dir, "-".join(filter(None, (self.__branch, self.__tag, self.__commit))) + ".tgz")

Check warning on line 170 in pym/bob/scm/git.py

View check run for this annotation

Codecov / codecov/patch

pym/bob/scm/git.py#L170

Added line #L170 was not covered by tests

async def invoke(self, invoker, switch=False, bundleFile=None):
alternatesFile = invoker.joinPath(self.__dir, ".git/objects/info/alternates")

# make sure the git directory exists
Expand Down Expand Up @@ -246,6 +252,27 @@ async def invoke(self, invoker, switch=False):
await invoker.checkCommand(["git", "repack", "-a"], cwd=self.__dir)
os.unlink(alternatesFile)

if bundleFile:
def reset(tarinfo):
tarinfo.uid = tarinfo.gid = 0
tarinfo.uname = tarinfo.gname = "root"
tarinfo.mtime = 0
return tarinfo
files = []
for root, dirs, filenames in os.walk(invoker.joinPath(self.__dir)):
if ".git" in root: continue
for f in filenames:
if f == ".bundle.log": continue
files.append(os.path.join(root, f))
files.sort()
os.makedirs(os.path.dirname(bundleFile), exist_ok=True)
with open(bundleFile, 'wb') as outfile:
with gzip.GzipFile(fileobj=outfile, mode='wb', mtime=0) as zipfile:
with tarfile.open(fileobj=zipfile, mode="w:") as bundle:
for f in files:
bundle.add(f, arcname=os.path.relpath(f, invoker.joinPath(self.__dir)),

Check warning on line 273 in pym/bob/scm/git.py

View check run for this annotation

Codecov / codecov/patch

pym/bob/scm/git.py#L256-L273

Added lines #L256 - L273 were not covered by tests
recursive=False, filter=reset)

async def __checkoutTagOnBranch(self, invoker, fetchCmd, switch):
# Only do something if nothing is checked out yet or a forceful switch
# is requested.
Expand Down
Loading

0 comments on commit 5cad406

Please sign in to comment.