feat: release Dever 0.1.5 with direct workers and long transactions
This commit is contained in:
1 parent
89629c2197
commit
a900d7e8db
153 files changed
+3676
-3708
No files matched your search
@@ -6,6 +6,7 @@ import hashlib
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tarfile
|
||||
@@ -17,6 +18,7 @@ from install_support import load_installer
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
ZSTD = Path(os.environ.get("DEVER_TEST_ZSTD", "/usr/bin/zstd"))
|
||||
|
||||
|
||||
def load_module(name, path):
|
||||
@@ -86,7 +88,7 @@ class InstallerTests(unittest.TestCase):
|
||||
self.platform, "1.2.3")
|
||||
|
||||
def create_assets(self, output):
|
||||
return assets.create(self.release, output, Path("/usr/bin/zstd"))
|
||||
return assets.create(self.release, output, ZSTD)
|
||||
|
||||
def release_record(self, directory, version="1.2.3"):
|
||||
return {
|
||||
@@ -98,7 +100,7 @@ class InstallerTests(unittest.TestCase):
|
||||
}
|
||||
|
||||
def archive_payloads(self, path):
|
||||
contents = subprocess.run(["/usr/bin/zstd", "-q", "-d", "-c", str(path)],
|
||||
contents = subprocess.run([str(ZSTD), "-q", "-d", "-c", str(path)],
|
||||
env={}, capture_output=True, check=True, timeout=15).stdout
|
||||
with tarfile.open(fileobj=io.BytesIO(contents)) as archive:
|
||||
return {member.name: archive.extractfile(member).read() for member in archive}
|
||||
@@ -135,24 +137,32 @@ class InstallerTests(unittest.TestCase):
|
||||
self.create_assets(first)
|
||||
|
||||
def test_extension_assets_are_separate_and_cannot_overlap_base(self):
|
||||
base_only = self.root / "base-only"
|
||||
self.create_assets(base_only)
|
||||
manifest_path = self.release / "manifest.json"
|
||||
manifest = json.loads(manifest_path.read_text())
|
||||
name = f"runtime/pip/{self.platform}/runtime.pack"
|
||||
path = self.release / name
|
||||
path.parent.mkdir(parents=True)
|
||||
contents = b"optional Python bytes"
|
||||
path.write_bytes(contents)
|
||||
entry = {"path": name, "bytes": len(contents), "sha256": hashlib.sha256(contents).hexdigest()}
|
||||
manifest["extensions"] = [{"kind": {"type": "runtime", "ecosystem": "pip"},
|
||||
"target": self.platform, "artifacts": [entry]}]
|
||||
expected = {}
|
||||
for ecosystem in ("pip", "npm", "go"):
|
||||
name = f"runtime/{ecosystem}/{self.platform}/runtime.pack"
|
||||
path = self.release / name
|
||||
path.parent.mkdir(parents=True)
|
||||
contents = f"optional {ecosystem} bytes".encode()
|
||||
path.write_bytes(contents)
|
||||
entry = {"path": name, "bytes": len(contents), "sha256": hashlib.sha256(contents).hexdigest()}
|
||||
manifest["extensions"].append({"kind": {"type": "runtime", "ecosystem": ecosystem},
|
||||
"target": self.platform, "artifacts": [entry]})
|
||||
expected[ecosystem] = {name: contents}
|
||||
manifest_path.write_text(json.dumps(manifest))
|
||||
self.sign_manifest()
|
||||
self.authenticate()
|
||||
output = self.root / "split"
|
||||
self.create_assets(output)
|
||||
self.assertEqual(self.archive_payloads(output / f"dever-{self.platform}.tar.zst"), self.payloads)
|
||||
extension = output / f"dever-{self.platform}.ext-runtime-pip-{self.platform}.tar.zst"
|
||||
self.assertEqual(self.archive_payloads(extension), {name: contents})
|
||||
base_name = f"dever-{self.platform}.tar.zst"
|
||||
self.assertEqual(self.archive_payloads(output / base_name), self.payloads)
|
||||
self.assertEqual((output / base_name).read_bytes(), (base_only / base_name).read_bytes())
|
||||
for ecosystem, payloads in expected.items():
|
||||
extension = output / f"dever-{self.platform}.ext-runtime-{ecosystem}-{self.platform}.tar.zst"
|
||||
self.assertEqual(self.archive_payloads(extension), payloads)
|
||||
manifest["extensions"][0]["artifacts"].append(self.catalog["dever-core"])
|
||||
manifest_path.write_text(json.dumps(manifest))
|
||||
with self.assertRaisesRegex(ValueError, "resource namespace"):
|
||||
|
||||
@@ -78,8 +78,11 @@ def worker_project(image, ecosystem):
|
||||
|
||||
|
||||
def isolated(image, init):
|
||||
isolator = Path(os.environ["DEVER_TEST_ISOLATOR"])
|
||||
if not isolator.is_absolute() or not isolator.is_file():
|
||||
raise ValueError("DEVER_TEST_ISOLATOR requires an absolute test-only executable")
|
||||
return [
|
||||
WORKSPACE / "target/sandbox-inputs/assets/bin/bwrap",
|
||||
isolator,
|
||||
"--unshare-all", "--die-with-parent", "--new-session", "--bind", image,
|
||||
"/", "--proc", "/proc", "--dev", "/dev", "--chdir", "/", "--ro-bind",
|
||||
init, "/fixture-proc-init",
|
||||
@@ -195,7 +198,7 @@ def ordinary_uid_target_reuse(image):
|
||||
child.stderr.close()
|
||||
|
||||
|
||||
def install_assets(assets, image, manifest):
|
||||
def install_assets(assets, image, manifest, trusted_key):
|
||||
# Only transport is local: signature verification, full tar extraction and
|
||||
# the final bootstrap executable are the real first-install implementation.
|
||||
installer = load_installer()
|
||||
@@ -215,7 +218,7 @@ def install_assets(assets, image, manifest):
|
||||
installer.download = download
|
||||
installer.install(argparse.Namespace(
|
||||
version="latest", release=None, system_root=image, no_start=True,
|
||||
trusted_key=WORKSPACE / "skills/assets/release.pub",
|
||||
trusted_key=trusted_key,
|
||||
))
|
||||
|
||||
|
||||
@@ -238,7 +241,7 @@ def accept(release, assets, image, report, init, extension_helper=None):
|
||||
try:
|
||||
minimal_os(image)
|
||||
if assets:
|
||||
install_assets(assets, image, manifest)
|
||||
install_assets(assets, image, manifest, WORKSPACE / "skills/assets/release.pub")
|
||||
else:
|
||||
execute([
|
||||
"/bin/bash", WORKSPACE / "install",
|
||||
|
||||
@@ -1,113 +1,244 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Actual-asset update transaction in an owned image; never publishes the fixture version."""
|
||||
"""Actual-version install, upgrade and rollback in an owned container directory."""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
from verify_prepared_release import InstalledDaemon, execute, install_assets, minimal_os, response
|
||||
from install_support import load_installer
|
||||
from verify_prepared_release import execute, install_assets, response, worker_project
|
||||
|
||||
|
||||
def prepare_update(image, manifest, key, helper):
|
||||
# Reuse real payloads under a newly signed, acceptance-only catalog identity.
|
||||
# This tests the update owner without claiming a second public release exists.
|
||||
next_manifest = dict(manifest)
|
||||
major, minor, patch = map(int, manifest["version"].split("."))
|
||||
version = f"{major}.{minor}.{patch + 1}"
|
||||
next_manifest["version"] = version
|
||||
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"
|
||||
package = machine / "cache/downloads" / version
|
||||
package.mkdir()
|
||||
installed = machine / "versions" / manifest["version"]
|
||||
for artifact in manifest["artifacts"]:
|
||||
destination = package / artifact["path"]
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
os.link(installed / artifact["path"], destination)
|
||||
catalog = package / "manifest.json"
|
||||
catalog.write_text(json.dumps(next_manifest, separators=(",", ":")))
|
||||
execute([helper, "--sign-catalog", catalog, key])
|
||||
(machine / "cache/downloads/latest").write_text(version)
|
||||
return version, hashlib.sha256(catalog.read_bytes()).hexdigest()
|
||||
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())
|
||||
result = {"version": manifest["version"], "checks": [],
|
||||
"transport": "local actual assets; acceptance-only next-version catalog",
|
||||
"online_update": "not_run"}
|
||||
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)
|
||||
args.report.write_text(json.dumps(result, indent=2) + "\n")
|
||||
print(f"PASS {name}", flush=True)
|
||||
|
||||
try:
|
||||
args.image.mkdir()
|
||||
minimal_os(args.image)
|
||||
install_assets(args.assets, args.image, manifest)
|
||||
machine = args.image / "opt/dever"
|
||||
extension = f"runtime-pip-{manifest['platform']}"
|
||||
execute([args.extension_helper, machine, args.assets, manifest["version"], extension])
|
||||
records = args.report.parent / f"{args.report.stem}-process"
|
||||
with InstalledDaemon(args.image, records, args.init) as daemon:
|
||||
daemon.command("new", "/project")
|
||||
response(daemon.command("run", "/project", "--", "hello.greeting.greet", '{"name":"Dever"}'))
|
||||
passed("actual_release_and_selected_extension_before_update")
|
||||
version, catalog = prepare_update(args.image, manifest, args.key, args.extension_helper)
|
||||
result["fixture_version"] = version
|
||||
previous_active = (machine / "state/active-version").read_bytes()
|
||||
empty_assets = args.image / "missing-extension-assets"
|
||||
empty_assets.mkdir()
|
||||
for name, stage in (
|
||||
("fresh", lambda: fresh_install(args, manifest, passed)),
|
||||
("upgrade", lambda: upgrade(args, manifest, previous, passed)),
|
||||
):
|
||||
try:
|
||||
execute([args.extension_helper, machine, empty_assets, version, "--update"])
|
||||
except RuntimeError as error:
|
||||
if "cannot open actual extension asset" not in str(error):
|
||||
raise
|
||||
else:
|
||||
raise RuntimeError("update with a missing extension should fail")
|
||||
if (machine / "state/active-version").read_bytes() != previous_active:
|
||||
raise RuntimeError("failed update changed active version")
|
||||
if daemon.command("version") != manifest["version"]:
|
||||
raise RuntimeError("failed update changed the public launcher selection")
|
||||
response(daemon.command("run", "/project", "--", "hello.greeting.greet", '{"name":"Dever"}'))
|
||||
passed("failed_extension_update_keeps_old_active_and_application_working")
|
||||
execute([args.extension_helper, machine, args.assets, version, "--update"])
|
||||
if daemon.command("version") != version:
|
||||
raise RuntimeError("successful update did not activate the new version")
|
||||
if daemon.command("skill", "path") != f"/opt/dever/versions/{version}/skills/dever-language":
|
||||
raise RuntimeError("skill did not follow the updated core")
|
||||
extensions = machine / "cache/extensions" / version / catalog
|
||||
if {path.name for path in extensions.iterdir()} != {extension}:
|
||||
raise RuntimeError("update did not preserve exactly the installed extension selection")
|
||||
passed("successful_update_activates_core_skill_and_only_installed_extensions")
|
||||
# Only the catalog version changes in this transaction fixture.
|
||||
# The managed compiler correctly requires its real build version;
|
||||
# execute it again only after selecting the actual release.
|
||||
daemon.command("use", manifest["version"])
|
||||
response(daemon.command("run", "/project", "--", "hello.greeting.greet", '{"name":"Dever"}'))
|
||||
passed("actual_release_remains_usable_after_update_and_explicit_switch_back")
|
||||
result["status"] = "passed"
|
||||
except BaseException as error:
|
||||
result["status"] = "failed"
|
||||
result["error"] = str(error)
|
||||
raise
|
||||
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 ("release", "assets", "image", "report", "init", "extension-helper", "key"):
|
||||
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 for an owned image; never installs host services")
|
||||
parser.error("requires root in an owned validation container; never touches its installed service")
|
||||
for name, value in vars(args).items():
|
||||
setattr(args, name, value.absolute())
|
||||
args.report.parent.mkdir(parents=True, exist_ok=True)
|
||||
accept(args)
|
||||
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__":
|
||||
|
||||
Reference in new issue
Block a user