#!/usr/bin/env python3 """Actual-version install, upgrade and rollback in an owned container directory.""" import argparse import json import os from pathlib import Path import shutil import subprocess import tempfile import time from install_support import load_installer from verify_prepared_release import execute, install_assets, response, worker_project class LocalDaemon: def __init__(self, machine): self.machine = machine self.launcher = machine / "bin/dever" self.child = None self.errors = None def __enter__(self): self.errors = tempfile.TemporaryFile() self.child = subprocess.Popen( [str(self.machine / "bin/deverd"), "--root", str(self.machine)], env={}, stdout=subprocess.DEVNULL, stderr=self.errors, ) try: deadline = time.monotonic() + 10 while time.monotonic() < deadline: if self.child.poll() is not None: self.errors.seek(0) raise RuntimeError(self.errors.read(65536).decode(errors="replace")) if (self.machine / "state/deverd.sock").is_socket(): self.command("cache", "status") return self time.sleep(0.05) raise RuntimeError("owned compiler daemon did not become ready") except BaseException: self.__exit__(None, None, None) raise def command(self, *arguments): return execute([self.launcher, *arguments], timeout=240) def __exit__(self, *_): if self.child is not None and self.child.poll() is None: self.child.terminate() try: self.child.wait(timeout=10) except subprocess.TimeoutExpired: self.child.kill() self.child.wait(timeout=10) if self.errors is not None: self.errors.close() def write(root, relative, contents): path = root / relative path.parent.mkdir(parents=True, exist_ok=True) path.write_text(contents) def profile_project(root, profile): sqlite = {"type": "sqlite", "path": "data/owned.sqlite", "max_connections": 1} postgres = {"type": "postgres", "url": "postgres://fixture@127.0.0.1:1/unused", "tls": "disabled"} settings = { "base": {}, "sqlite": {"database": {"default": sqlite}}, "postgres": {"database": {"default": postgres}}, "both": {"database": {"default": sqlite, "unused": postgres}}, }[profile] write(root, "config/setting.json", json.dumps(settings)) if profile in ("sqlite", "both"): write(root, "module/sample/value/model.dever", "type Value { name: Text(1, 32) }\n") write(root, "module/sample/value/api.dever", "cmd exercise = app.exercise\nlong cmd stages = app.stages\n") source = '''exercise() (okay: Bool) { saved = model.create({ name = "pack" }) count = model.count() removed = model.delete(saved.id) okay = count == 1 and removed == 1 } stages() (okay: Bool) { id = store() dever.task.sleep(1) okay = finish(id) } transaction store() (id: model.id) { saved = model.create({ name = "stage" }) id = saved.id } transaction finish(id: model.id) (okay: Bool) { count = model.count() removed = model.delete(id) okay = count == 1 and removed == 1 } ''' else: write(root, "module/sample/value/api.dever", "cmd exercise = app.exercise\n") source = "exercise() (okay: Bool) { dever.task.sleep(1)\n okay = true }\n" write(root, "module/sample/value/app.dever", source) def fresh_install(args, manifest, passed): image = args.work / "fresh" image.mkdir() install_assets(args.assets, image, manifest, args.trusted_key) machine = image / "opt/dever" programs = [] with LocalDaemon(machine) as daemon: if daemon.command("version") != manifest["version"]: raise RuntimeError("fresh installation selected the wrong version") skill = machine / "versions" / manifest["version"] / "skills/dever-language" if daemon.command("skill", "path") != str(skill): raise RuntimeError("installed skill is not paired with the compiler") if not (skill / "assets/template/module/hello/greeting/app.dever").is_file(): raise RuntimeError("release has no complete project template") extensions = machine / "cache/extensions" if extensions.exists() and any(extensions.iterdir()): raise RuntimeError("fresh base installation installed optional extensions") project = image / "hello" daemon.command("new", project) response(daemon.command("run", project, "--", "hello.greeting.greet", '{"name":"Dever"}')) passed("fresh_split_base_install_and_full_skill_template") for profile in ("base", "sqlite", "postgres", "both"): project = image / f"profile-{profile}" profile_project(project, profile) response(daemon.command("run", project, "--", "sample.value.exercise", "{}"), True) if profile in ("sqlite", "both"): response(daemon.command("run", project, "--", "sample.value.stages", "{}"), True) program = project / "program" daemon.command("build", project, "--output", program) shutil.rmtree(project / "module") programs.append((program, "sample.value.exercise", True)) passed("four_runtime_profiles_sqlite_and_long_transactions") for ecosystem in ("pip", "npm", "go"): execute([args.extension_helper, machine, args.assets, manifest["version"], f"runtime-{ecosystem}-{manifest['platform']}"]) project = worker_project(image, ecosystem) daemon.command("lib", "add", project) response(daemon.command("run", project, "--", "sample.worker.probe", "{}"), True) program = project / "program" daemon.command("build", project, "--output", program) shutil.rmtree(project / "module") programs.append((program, "sample.worker.probe", True)) passed(f"signed_optional_{ecosystem}_managed_run_and_build") for program, command, expected in programs: response(execute([program, command, "{}"]), expected) passed("source_free_standalone_programs_without_running_daemon") shutil.rmtree(image) def upgrade(args, manifest, previous, passed): image = args.work / "upgrade" image.mkdir() original_umask = os.umask(0o022) try: load_installer().install(argparse.Namespace( version=previous["version"], release=args.previous_release, system_root=image, no_start=True, trusted_key=args.trusted_key, )) finally: os.umask(original_umask) machine = image / "opt/dever" project = image / "existing-project" with LocalDaemon(machine) as daemon: if daemon.command("version") != previous["version"]: raise RuntimeError("previous installation did not select its actual binary version") daemon.command("new", project) response(daemon.command("run", project, "--", "hello.greeting.greet", '{"name":"Dever"}')) original = (project / "config/setting.json").read_bytes() passed("actual_previous_release_installed_and_program_runs") install_assets(args.assets, image, manifest, args.trusted_key) with LocalDaemon(machine) as daemon: if daemon.command("version") != manifest["version"]: raise RuntimeError("upgrade did not select the actual new compiler version") response(daemon.command("run", project, "--", "hello.greeting.greet", '{"name":"Dever"}')) if (project / "config/setting.json").read_bytes() != original: raise RuntimeError("upgrade modified project configuration") passed("actual_new_release_upgrade_preserves_existing_project") daemon.command("use", previous["version"]) if daemon.command("version") != previous["version"]: raise RuntimeError("rollback did not restore the previous compiler") response(daemon.command("run", project, "--", "hello.greeting.greet", '{"name":"Dever"}')) passed("rollback_runs_actual_previous_compiler") daemon.command("use", manifest["version"]) if daemon.command("version") != manifest["version"]: raise RuntimeError("switch after rollback did not restore the new compiler") response(daemon.command("run", project, "--", "hello.greeting.greet", '{"name":"Dever"}')) passed("new_release_runs_again_after_rollback") shutil.rmtree(image) def accept(args): manifest = json.loads((args.release / "manifest.json").read_bytes()) previous = json.loads((args.previous_release / "manifest.json").read_bytes()) if tuple(map(int, manifest["version"].split("."))) <= tuple(map(int, previous["version"].split("."))): raise ValueError("upgrade requires distinct increasing actual release versions") args.work.mkdir(mode=0o700) result = { "previous_version": previous["version"], "version": manifest["version"], "checks": [], "errors": {}, "transport": "local signed split assets", "trust": "explicit acceptance key; not proof of production signing", "host_language_absence": "not_verified", "gpu": "not_run", "postgres_server": "not_run", "online_update": "not_run", "global_service_actions": "not_run", } def passed(name): result["checks"].append(name) print(f"PASS {name}", flush=True) try: for name, stage in ( ("fresh", lambda: fresh_install(args, manifest, passed)), ("upgrade", lambda: upgrade(args, manifest, previous, passed)), ): try: stage() except (OSError, ValueError, RuntimeError, subprocess.SubprocessError) as error: result["errors"][name] = str(error) print(f"FAIL {name}: {error}", flush=True) result["status"] = "failed" if result["errors"] else "passed" return not result["errors"] finally: args.report.write_text(json.dumps(result, indent=2) + "\n") def main(): parser = argparse.ArgumentParser(description=__doc__) for name in ("previous-release", "release", "assets", "trusted-key", "work", "report", "extension-helper"): parser.add_argument(f"--{name}", required=True, type=Path) args = parser.parse_args() if os.geteuid() != 0: parser.error("requires root in an owned validation container; never touches its installed service") for name, value in vars(args).items(): if not value.is_absolute(): parser.error(f"{name} must be an explicit absolute path") if args.work.exists() or args.work.is_symlink() or args.report.exists(): parser.error("acceptance work/report must be new owned paths") raise SystemExit(0 if accept(args) else 1) if __name__ == "__main__": main()