246 lines
11 KiB
Python
246 lines
11 KiB
Python
#!/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()
|