diff options
| -rw-r--r-- | remote/config.example.toml | 4 | ||||
| -rwxr-xr-x | remote/sbb-remote | 244 | ||||
| -rw-r--r-- | tests/test_remote.py | 159 |
3 files changed, 388 insertions, 19 deletions
diff --git a/remote/config.example.toml b/remote/config.example.toml index d7fcd7f..e9b76d0 100644 --- a/remote/config.example.toml +++ b/remote/config.example.toml @@ -7,3 +7,7 @@ upstream_branch = "current" slackrepo_db = "/var/lib/slackrepo/SBo-danix/database_SBo-danix_x86_64.sqlite3" sbpull_script = "/usr/local/share/slackrepo_setup/sbpull.bash" overlays = ["personal", "pentesting"] +slackrepo_repo = "SBo-danix" +hintdir = "/etc/slackrepo/SBo-danix/hintfiles" +default_hintdir = "" # searched after hintdir when set +genreprc = "/root/.genreprc" # REPOSOWNERGPG names the signing key diff --git a/remote/sbb-remote b/remote/sbb-remote index 2d877d6..b26fa9f 100755 --- a/remote/sbb-remote +++ b/remote/sbb-remote @@ -18,7 +18,7 @@ """Helper run over ssh by the sb-builder GUI. Every command prints one JSON object on stdout, except job-follow, which -streams raw log bytes. Jobs run detached (setsid, stdin=/dev/null), one at a +streams raw log bytes. gpg-unlock reads the passphrase from stdin. Jobs run detached (setsid, stdin=/dev/null), one at a time under a flock, from a fixed table of kinds: the GUI sends a kind and its arguments, this file validates them and builds the argv itself. """ @@ -28,15 +28,17 @@ import glob import json import os import re +import shlex import shutil import signal import sqlite3 import subprocess import sys +import tempfile import time import tomllib -VERSION = "0.1.0" +VERSION = "0.2.0" DEFAULTS = { "state_dir": "/var/lib/sb-builder", "repo_dir": "/var/lib/sbopkg/SBo-danix", @@ -44,10 +46,17 @@ DEFAULTS = { "slackrepo_db": "/var/lib/slackrepo/SBo-danix/database_SBo-danix_x86_64.sqlite3", "sbpull_script": "/usr/local/share/slackrepo_setup/sbpull.bash", "overlays": ["personal", "pentesting"], + "slackrepo_repo": "SBo-danix", + "hintdir": "/etc/slackrepo/SBo-danix/hintfiles", + "default_hintdir": "", + "genreprc": "/root/.genreprc", } FLAGS = ("--yes", "--no") JOB_ID = re.compile(r"^\d{8}-\d{6}-[a-z]+(-\d+)?$") PKG = re.compile(r"^[^/]+/([^/]+)/\1\.SlackBuild$") +PKGNAME = re.compile(r"^[A-Za-z0-9._+-]+$") +ACTIONS = ("build", "rebuild", "update", "remove") +PRESET = "/usr/libexec/gpg-preset-passphrase" ACTIVE = ("starting", "running") START_GRACE = 10 # seconds a job may sit in "starting" before it counts as lost @@ -77,7 +86,7 @@ def load_config(path=None): return cfg -# ── job kinds: (cfg, args) -> argv, raising Error on bad args ─────────────── +# ── job kinds: (cfg, args) -> (argv, env), raising Error on bad args ──────── def _flag(args): if len(args) != 1 or args[0] not in FLAGS: @@ -89,14 +98,49 @@ def kind_sbpull(cfg, args): if not args or args[0] not in cfg["overlays"]: raise Error(f"sbpull: overlay must be one of {cfg['overlays']}") return ["bash", "-c", '. "$1" && sbpull "$2" "$3"', "_", - cfg["sbpull_script"], args[0], _flag(args[1:])] + cfg["sbpull_script"], args[0], _flag(args[1:])], {} def kind_sbshadow(cfg, args): - return ["sbshadow", _flag(args)] + return ["sbshadow", _flag(args)], {} -KINDS = {"sbpull": kind_sbpull, "sbshadow": kind_sbshadow} +def kind_slackrepo(cfg, args): + if not args or args[0] not in ACTIONS: + raise Error(f"slackrepo: action must be one of {', '.join(ACTIONS)}") + action, rest = args[0], args[1:] + opts = [a for a in rest if a.startswith("--")] + for o in opts: + if o not in ("--preview", "--no-publish"): + raise Error(f"slackrepo: unknown option {o}") + known = {os.path.basename(d) for d in _pkgdirs(cfg["repo_dir"], "*/*")} + pkgs = [] + for p in (a for a in rest if not a.startswith("--")): + if not PKGNAME.match(p) or p not in known: + raise Error(f"slackrepo: no such package: {p}") + if p not in pkgs: + pkgs.append(p) + if not pkgs and action != "update": + raise Error(f"slackrepo: {action} needs at least one package") + argv = ["slackrepo", f"--repo={cfg['slackrepo_repo']}", action] + if "--preview" in opts: + argv.append("--preview") + return argv + pkgs, {"SBB_PUBLISH": "n"} if "--no-publish" in opts else {} + + +def kind_publish(cfg, args): + if args: + raise Error("publish takes no arguments") + return ["sb-publish"], {} + + +KINDS = {"sbpull": kind_sbpull, "sbshadow": kind_sbshadow, + "slackrepo": kind_slackrepo, "publish": kind_publish} + + +def signs(kind, args): + """A real slackrepo run ends in gen_repos_files.sh, which signs.""" + return kind == "slackrepo" and "--preview" not in args # ── job state ─────────────────────────────────────────────────────────────── @@ -173,7 +217,9 @@ def cmd_jobs(cfg): def cmd_job_start(cfg, kind=None, *args): if kind not in KINDS: raise Error(f"unknown kind: {kind}") - argv = KINDS[kind](cfg, list(args)) + argv, env = KINDS[kind](cfg, list(args)) + if signs(kind, args) and gpg_unlocked(cfg) is False: + raise Error("signing key locked") base = time.strftime("%Y%m%d-%H%M%S-") + kind jid, n = base, 1 while True: @@ -185,7 +231,7 @@ def cmd_job_start(cfg, kind=None, *args): n += 1 jid = f"{base}-{n}" write_meta(d, {"id": jid, "kind": kind, "args": list(args), "argv": argv, - "status": "starting", "created": time.time()}) + "env": env, "status": "starting", "created": time.time()}) open(os.path.join(d, "log"), "wb").close() subprocess.Popen([sys.executable, os.path.abspath(__file__), "_run", jid], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, @@ -215,7 +261,8 @@ def cmd_run(cfg, jid=None): meta["start"] = time.time() try: p = subprocess.Popen(meta["argv"], stdin=subprocess.DEVNULL, stdout=log, - stderr=subprocess.STDOUT, start_new_session=True) + stderr=subprocess.STDOUT, start_new_session=True, + env={**os.environ, **meta.get("env", {})}) except OSError as e: log.write(f"sbb-remote: {e}\n".encode()) rc = 127 @@ -268,20 +315,24 @@ def _try(fn): def _git(cfg, *args): - return subprocess.run(["git", "-C", cfg["repo_dir"], *args], + return subprocess.run(["git", "-C", cfg["repo_dir"], *args], stdin=subprocess.DEVNULL, capture_output=True, text=True, check=True).stdout -def _pkgs(root, pattern): - """Names of the <pkg>/<pkg>.SlackBuild dirs matching pattern under root.""" +def _pkgdirs(root, pattern): + """Paths of the <pkg>/<pkg>.SlackBuild dirs matching pattern under root.""" out = [] for d in glob.glob(os.path.join(root, pattern, "")): - name = os.path.basename(os.path.dirname(d)) - if os.path.isfile(os.path.join(d, name + ".SlackBuild")): - out.append(name) + d = os.path.dirname(d) + if os.path.isfile(os.path.join(d, os.path.basename(d) + ".SlackBuild")): + out.append(d) return out +def _pkgs(root, pattern): + return [os.path.basename(d) for d in _pkgdirs(root, pattern)] + + def _packages(cfg): r = cfg["repo_dir"] mine = {o: _pkgs(r, o + "/*") for o in cfg["overlays"]} @@ -320,6 +371,166 @@ def cmd_stats(cfg): } +# ── inventory ─────────────────────────────────────────────────────────────── + +def parse_assignments(path): + """KEY=value assignments of a shell-style file, without running it. + + Values with a $ are returned as {"value": raw, "shell": True}: the shell + would expand them, this parser does not. + """ + with open(path, errors="replace") as f: + text = f.read().replace("\\\n", " ") + out = {} + for line in text.splitlines(): + try: + tokens = shlex.split(line, comments=True) + except ValueError: + continue # unbalanced quotes: slackrepo would choke too, skip the line + raw = line.split("=", 1)[1] if "=" in line else "" + for t in tokens: + name, eq, value = t.partition("=") + if eq and re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", name): + out[name] = {"value": raw.strip(), "shell": True} if "$" in raw else value + return out + + +def _value(v): + return v["value"] if isinstance(v, dict) else v + + +def find_hint(cfg, path): + """slackrepo's search order (parsefunctions.sh): first <pkg>.hint found.""" + pkg = os.path.basename(path) + dirs = [os.path.join(cfg["repo_dir"], path), cfg["hintdir"], + os.path.join(cfg["hintdir"], path)] + if cfg["default_hintdir"]: + dirs += [cfg["default_hintdir"], os.path.join(cfg["default_hintdir"], path)] + for d in dirs: + f = os.path.join(d, pkg + ".hint") + if os.path.isfile(f): + return f + return None + + +def _summary(pkgdir, pkg): + try: + with open(os.path.join(pkgdir, "slack-desc"), errors="replace") as f: + for line in f: + m = re.match(rf"^{re.escape(pkg)}: {re.escape(pkg)} \((.*)\)\s*$", line) + if m: + return m.group(1) + except OSError: + pass + return None + + +def _builds(cfg): + """{path: (built_version, built_time)}, {path: result} from the slackrepo DB.""" + try: + db = sqlite3.connect(f"file:{cfg['slackrepo_db']}?mode=ro", uri=True) + try: + built = {i: (v, int(t)) for i, v, t in + db.execute("select itemid, version, built from revisions where dep = '/'")} + results = dict(db.execute("select itemid, result from buildresults")) + finally: + db.close() + except sqlite3.Error: + return {}, {} + return built, results + + +COLUMNS = ["path", "version", "info_version", "hint_version", "built_version", "built_time", + "result", "maintainer", "requires", "summary", "hint_path", "hints"] + + +def cmd_inventory(cfg): + built, results = _builds(cfg) + rows = [] + for d in sorted(_pkgdirs(cfg["repo_dir"], "*/*")): + path = os.path.relpath(d, cfg["repo_dir"]) + pkg = os.path.basename(d) + try: + info = parse_assignments(os.path.join(d, pkg + ".info")) + except OSError: + info = {} + hint_path = find_hint(cfg, path) + hints = parse_assignments(hint_path) if hint_path else None + info_version = _value(info.get("VERSION")) + hint_version = _value(hints.get("VERSION")) if hints else None + bv, bt = built.get(path, (None, None)) + rows.append([path, hint_version or info_version, info_version, hint_version, bv, bt, + results.get(path), _value(info.get("MAINTAINER")), + (_value(info.get("REQUIRES")) or "").split(), _summary(d, pkg), + hint_path, hints]) + return {"columns": COLUMNS, "rows": rows} + + +# ── signing key ───────────────────────────────────────────────────────────── + +def _signing_key(cfg): + try: + with open(cfg["genreprc"]) as f: + m = re.search(r'^\s*REPOSOWNERGPG=["\']?([^"\'\s]+)', f.read(), re.M) + except OSError: + return None + return m and m.group(1) + + +def _keygrips(key): + """Keygrips of the signing-capable (sub)keys of key.""" + out = subprocess.run(["gpg", "--batch", "--with-colons", "--with-keygrip", + "--list-secret-keys", key], stdin=subprocess.DEVNULL, + capture_output=True, text=True).stdout + grips, can_sign = [], False + for line in out.splitlines(): + f = line.split(":") + if f[0] in ("sec", "ssb"): + can_sign = "s" in f[11] + elif f[0] == "grp" and can_sign: + grips.append(f[9]) + return grips + + +def gpg_unlocked(cfg): + """True/False, or None when no signing key is configured or found.""" + key = _signing_key(cfg) + grips = _keygrips(key) if key else [] + if not grips: + return None + for g in grips: + out = subprocess.run(["gpg-connect-agent", f"keyinfo {g}", "/bye"], + stdin=subprocess.DEVNULL, capture_output=True, text=True).stdout + info = next((ln.split() for ln in out.splitlines() if ln.startswith("S KEYINFO")), []) + if len(info) < 7 or info[6] != "1": + return False + return True + + +def cmd_gpg_status(cfg): + return {"unlocked": gpg_unlocked(cfg), "key": _signing_key(cfg)} + + +def cmd_gpg_unlock(cfg, stdin=None): + key = _signing_key(cfg) + grips = _keygrips(key) if key else [] + if not grips: + raise Error("no signing key configured") + passphrase = (stdin or sys.stdin.buffer).read().rstrip(b"\n") + for g in grips: + subprocess.run([PRESET, "--preset", g], input=passphrase, check=True, + capture_output=True) + with tempfile.NamedTemporaryFile() as t: + ok = subprocess.run(["gpg", "--batch", "--pinentry-mode", "error", "-u", key, + "-bas", "-o", "-", t.name], stdin=subprocess.DEVNULL, + capture_output=True).returncode == 0 + if not ok: + for g in grips: + subprocess.run([PRESET, "--forget", g], stdin=subprocess.DEVNULL, capture_output=True) + raise Error("bad passphrase") + return {"unlocked": True} + + COMMANDS = { "version": cmd_version, "stats": cmd_stats, @@ -327,6 +538,9 @@ COMMANDS = { "job-start": cmd_job_start, "job-follow": cmd_job_follow, "job-kill": cmd_job_kill, + "inventory": cmd_inventory, + "gpg-status": cmd_gpg_status, + "gpg-unlock": cmd_gpg_unlock, "_run": cmd_run, } diff --git a/tests/test_remote.py b/tests/test_remote.py index 31d0597..c2b9857 100644 --- a/tests/test_remote.py +++ b/tests/test_remote.py @@ -45,7 +45,9 @@ class Base(unittest.TestCase): with open(conf, "w") as f: f.write(f'state_dir = "{self.tmp}"\n' f'repo_dir = "{self.tmp}/repo"\n' - f'slackrepo_db = "{self.tmp}/slackrepo.sqlite3"\n') + f'slackrepo_db = "{self.tmp}/slackrepo.sqlite3"\n' + f'hintdir = "{self.tmp}/hints"\n' + f'genreprc = "{self.tmp}/genreprc"\n') patcher = mock.patch.dict(os.environ, {"SBB_CONFIG": conf}) patcher.start() self.addCleanup(patcher.stop) @@ -61,6 +63,34 @@ class Base(unittest.TestCase): open(os.path.join(d, "log"), "wb").close() return jid, d + def make_pkg(self, path, info="", hint=None, desc=None): + d = os.path.join(self.cfg["repo_dir"], path) + pkg = os.path.basename(path) + os.makedirs(d, exist_ok=True) + open(os.path.join(d, pkg + ".SlackBuild"), "w").close() + with open(os.path.join(d, pkg + ".info"), "w") as f: + f.write(info) + if desc: + with open(os.path.join(d, "slack-desc"), "w") as f: + f.write(f"{pkg}: {pkg} ({desc})\n{pkg}:\n") + if hint is not None: + os.makedirs(self.cfg["hintdir"], exist_ok=True) + with open(os.path.join(self.cfg["hintdir"], pkg + ".hint"), "w") as f: + f.write(hint) + + def fake_bin(self, name, script): + bindir = os.path.join(self.tmp, "bin") + os.makedirs(bindir, exist_ok=True) + path = os.path.join(bindir, name) + with open(path, "w") as f: + f.write("#!/bin/sh\n" + script) + os.chmod(path, 0o755) + if bindir not in os.environ["PATH"]: + patcher = mock.patch.dict(os.environ, {"PATH": bindir + os.pathsep + os.environ["PATH"]}) + patcher.start() + self.addCleanup(patcher.stop) + return path + def hold_lock(self): f = open(os.path.join(self.tmp, "lock"), "a") fcntl.flock(f, fcntl.LOCK_EX) @@ -109,11 +139,33 @@ class ConfigTest(Base): class KindsTest(Base): def test_sbpull_argv(self): self.assertEqual(sbb.KINDS["sbpull"](self.cfg, ["personal", "--no"]), - ["bash", "-c", '. "$1" && sbpull "$2" "$3"', "_", - self.cfg["sbpull_script"], "personal", "--no"]) + (["bash", "-c", '. "$1" && sbpull "$2" "$3"', "_", + self.cfg["sbpull_script"], "personal", "--no"], {})) def test_sbshadow_argv(self): - self.assertEqual(sbb.KINDS["sbshadow"](self.cfg, ["--yes"]), ["sbshadow", "--yes"]) + self.assertEqual(sbb.KINDS["sbshadow"](self.cfg, ["--yes"]), (["sbshadow", "--yes"], {})) + + def test_slackrepo_argv(self): + self.make_pkg("misc/nuclei") + self.make_pkg("personal/foo") + kind = sbb.KINDS["slackrepo"] + self.assertEqual(kind(self.cfg, ["update", "--preview", "nuclei", "foo", "nuclei"]), + (["slackrepo", "--repo=SBo-danix", "update", "--preview", "nuclei", "foo"], {})) + self.assertEqual(kind(self.cfg, ["build", "--no-publish", "foo"]), + (["slackrepo", "--repo=SBo-danix", "build", "foo"], {"SBB_PUBLISH": "n"})) + self.assertEqual(kind(self.cfg, ["update"]), (["slackrepo", "--repo=SBo-danix", "update"], {})) + + def test_slackrepo_rejects(self): + self.make_pkg("misc/nuclei") + for args in (["install", "nuclei"], ["build"], ["remove"], ["build", "nope"], + ["build", "../etc"], ["build", "--force", "nuclei"]): + with self.subTest(args=args), self.assertRaises(sbb.Error): + sbb.KINDS["slackrepo"](self.cfg, args) + + def test_publish(self): + self.assertEqual(sbb.KINDS["publish"](self.cfg, []), (["sb-publish"], {})) + with self.assertRaises(sbb.Error): + sbb.KINDS["publish"](self.cfg, ["x"]) def test_rejects_bad_args(self): for kind, args in (("sbpull", ["evil", "--no"]), ("sbpull", ["personal"]), @@ -138,6 +190,100 @@ class MainTest(Base): (0, {"version": sbb.VERSION, "overlays": ["personal", "pentesting"]})) +class InventoryTest(Base): + def test_rows(self): + self.make_pkg("misc/nuclei", 'VERSION="3.11.0"\nMAINTAINER="Some One"\n' + 'REQUIRES="go \\\n git"\n', desc="Fast scanner") + self.make_pkg("personal/foo", 'VERSION="1.0"\n', + hint='VERSION="1.1" # bumped\nDELREQUIRES="a b"\nOPTIONS="X=$(uname -m)"\n') + os.makedirs(os.path.join(self.cfg["repo_dir"], "misc", "notapkg")) + db = sqlite3.connect(self.cfg["slackrepo_db"]) + db.executescript(""" + create table buildresults (itemid text primary key, time text, result text); + create table revisions (itemid text, dep text, deplist text, version text, + built text, rev text, os text, hintcksum text, primary key (itemid, dep)); + insert into buildresults values ('misc/nuclei', '5', 'ok'), ('nuclei', '1', 'bad'); + insert into revisions values ('misc/nuclei', '/', '/', '3.11.0', '1788978222', 'r', 'o', '/'); + """) + db.close() + inv = sbb.cmd_inventory(self.cfg) + rows = [dict(zip(inv["columns"], r)) for r in inv["rows"]] + self.assertEqual([r["path"] for r in rows], ["misc/nuclei", "personal/foo"]) + nuclei, foo = rows + self.assertEqual((nuclei["version"], nuclei["built_version"], nuclei["built_time"], + nuclei["result"], nuclei["maintainer"], nuclei["requires"], + nuclei["summary"], nuclei["hint_path"]), + ("3.11.0", "3.11.0", 1788978222, "ok", "Some One", ["go", "git"], + "Fast scanner", None)) + self.assertEqual((foo["version"], foo["info_version"], foo["hint_version"], foo["result"]), + ("1.1", "1.0", "1.1", None)) + self.assertEqual(foo["hints"]["DELREQUIRES"], "a b") + self.assertEqual(foo["hints"]["OPTIONS"], {"value": '"X=$(uname -m)"', "shell": True}) + json.dumps(inv) + + def test_hint_search_order(self): + self.make_pkg("misc/foo") + sub = os.path.join(self.cfg["hintdir"], "misc", "foo") + os.makedirs(sub) + with open(os.path.join(sub, "foo.hint"), "w") as f: + f.write('VERSION="2"\n') + self.assertEqual(sbb.find_hint(self.cfg, "misc/foo"), os.path.join(sub, "foo.hint")) + with open(os.path.join(self.cfg["hintdir"], "foo.hint"), "w") as f: + f.write('VERSION="3"\n') + self.assertEqual(sbb.find_hint(self.cfg, "misc/foo"), + os.path.join(self.cfg["hintdir"], "foo.hint")) + in_repo = os.path.join(self.cfg["repo_dir"], "misc", "foo", "foo.hint") + open(in_repo, "w").close() + self.assertEqual(sbb.find_hint(self.cfg, "misc/foo"), in_repo) + + def test_missing_db(self): + self.make_pkg("misc/foo", 'VERSION="1"\n') + row = sbb.cmd_inventory(self.cfg)["rows"][0] + self.assertEqual(row[4:7], [None, None, None]) + + +class GpgTest(Base): + def setup_key(self, cached): + with open(self.cfg["genreprc"], "w") as f: + f.write('REPOSOWNERGPG="ABCD1234"\nUSE_GPGAGENT=1\n') + self.fake_bin("gpg", 'case "$*" in\n' + ' *--list-secret-keys*) printf "sec:u:255:22:K:1::::::scESC:\\n' + 'grp:::::::::GRIPSIGN:\\nssb:u:255:18:S:1::::::e:\\ngrp:::::::::GRIPENC:\\n" ;;\n' + f' *-bas*) [ -f "{self.tmp}/good" ] ;;\n' + 'esac\n') + flag = "1" if cached else "-" + self.fake_bin("gpg-connect-agent", f'echo "S KEYINFO GRIPSIGN D - - {flag} P - - -"; echo OK\n') + + def test_status(self): + self.assertEqual(sbb.cmd_gpg_status(self.cfg), {"unlocked": None, "key": None}) + self.setup_key(cached=False) + self.assertEqual(sbb.cmd_gpg_status(self.cfg), {"unlocked": False, "key": "ABCD1234"}) + self.setup_key(cached=True) + self.assertEqual(sbb.cmd_gpg_status(self.cfg)["unlocked"], True) + + def test_locked_refuses_real_run_only(self): + self.setup_key(cached=False) + self.make_pkg("misc/foo") + with self.assertRaisesRegex(sbb.Error, "signing key locked"): + sbb.cmd_job_start(self.cfg, "slackrepo", "build", "foo") + self.fake_bin("slackrepo", "exit 0\n") + self.assertIn("id", sbb.cmd_job_start(self.cfg, "slackrepo", "build", "--preview", "foo")) + + def test_unlock(self): + self.setup_key(cached=False) + log = os.path.join(self.tmp, "preset.log") + preset = self.fake_bin("preset", f'echo "$* $(cat)" >> {log}\n') + with mock.patch.object(sbb, "PRESET", preset): + with self.assertRaisesRegex(sbb.Error, "bad passphrase"): + sbb.cmd_gpg_unlock(self.cfg, stdin=io.BytesIO(b"wrong\n")) + open(os.path.join(self.tmp, "good"), "w").close() + self.assertEqual(sbb.cmd_gpg_unlock(self.cfg, stdin=io.BytesIO(b"right\n")), + {"unlocked": True}) + with open(log) as f: + self.assertEqual(f.read().splitlines(), + ["--preset GRIPSIGN wrong", "--forget GRIPSIGN ", "--preset GRIPSIGN right"]) + + class JobsTest(Base): def log(self, d): with open(os.path.join(d, "log"), "rb") as f: @@ -220,6 +366,11 @@ class JobsTest(Base): patcher.start() self.addCleanup(patcher.stop) + def test_env_reaches_child(self): + jid, d = self.make_job(["sh", "-c", 'echo "publish=$SBB_PUBLISH"'], env={"SBB_PUBLISH": "n"}) + sbb.cmd_run(self.cfg, jid) + self.assertEqual(self.log(d), b"publish=n\n") + def test_start_end_to_end(self): self.fake_sbshadow() rc, data = self.run_main("job-start", "sbshadow", "--no") |
