Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion deploy/node/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@ These modules are packaged as `node-install.pyz`, which Core serves through [nod

| Module | Role |
| --- | --- |
| `node_install.py`, `node_spec.py`, `node_generations.py` | Node installer and generation helper. `node_spec.py` is generated by `go run ./services/core/cmd/specification-contract -write` |
| `node_install.py`, `node_spec.py`, `node_generations.py` | Node installer and generation helper. The contract block in `node_spec.py` is generated by `go run ./services/core/cmd/specification-contract -write` |
| `distribution.py` | Verified artifacts, private temporary files, release metadata and Docker image identity |
| `install_display.py`, `node_output.py` | Terminal output |
| `provider_assets.py` | Node provider artifact names, generated by `go run ./services/core/cmd/provider-artifacts` |
| `node_payload.py` | Publish a release's node files without replacing bytes already installed |

The generated deployment contract identifies Providers that support nodes. `node_install.py` registers their host and service-user checks, native configuration, Runtime preparation and service dependencies in one table; artifact names come from the generated `provider_assets.py` catalog.

## Accounts and permissions

The node installer runs as root and prepares the host for one node per installation.
Expand Down
2 changes: 1 addition & 1 deletion deploy/node/node_generations.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ def generation_marker(root, generation, suffix, digest, installation, installer)
keys.add("configuration")
plan = value["configuration"]
if (not isinstance(plan, dict) or plan.get("installation_id") != installation or plan.get("generation") != generation
or plan.get("provider") not in ("docker", "microsandbox")
or plan.get("provider") not in installer.node_spec.NODE_PROVIDERS
or installer.node_spec.digest(plan["provider"], plan.get("specification")) != digest):
raise installer.InstallError("Invalid generation preparation plan")
if type(value.get("import_started")) is not bool:
Expand Down
267 changes: 151 additions & 116 deletions deploy/node/node_install.py

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion deploy/node/node_spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ def release(provider, manifest):
_CONTRACT = json.loads("{\"resources\":[{\"name\":\"cpus\",\"min\":1,\"max\":255,\"omit_zero\":false},{\"name\":\"memory_mib\",\"min\":512,\"max\":1048576,\"omit_zero\":false},{\"name\":\"root_disk_mib\",\"min\":0,\"max\":4294967295,\"omit_zero\":true},{\"name\":\"environment_disk_mib\",\"min\":0,\"max\":4294967295,\"omit_zero\":true}],\"source_commit_pattern\":\"[0-9a-f]{40}\",\"providers\":{\"docker\":{\"mode\":\"nodes\",\"disk\":false,\"artifacts\":{\"image_id\":{\"pattern\":\"sha256:[0-9a-f]{64}\",\"manifest_path\":[\"images\",\"runtime\"]},\"image_manifest_digest\":{\"pattern\":\"sha256:[0-9a-f]{64}\",\"manifest_path\":[\"image_manifest_digests\",\"runtime\"]}},\"default_resources\":{\"cpus\":2,\"memory_mib\":2048}},\"e2b\":{\"mode\":\"direct\",\"disk\":false,\"artifacts\":{},\"default_resources\":null},\"microsandbox\":{\"mode\":\"nodes\",\"disk\":true,\"artifacts\":{\"firmware_sha256\":{\"pattern\":\"[0-9a-f]{64}\",\"manifest_path\":[\"microsandbox\",\"firmware_sha256\"]},\"microsandbox_ref\":{\"pattern\":\"oac-runtime@sha256:[0-9a-f]{64}\",\"manifest_path\":[\"runtime_ref\"]},\"runtime_sha256\":{\"pattern\":\"[0-9a-f]{64}\",\"manifest_path\":[\"microsandbox\",\"runtime_sha256\"]}},\"default_resources\":{\"cpus\":2,\"memory_mib\":4096,\"root_disk_mib\":8192,\"environment_disk_mib\":8192}}},\"minimum_disk\":1024}")
# END GENERATED DEPLOYMENT CONTRACT

NODE_PROVIDERS = tuple(name for name, rules in _CONTRACT["providers"].items() if rules["mode"] == "nodes")


def canonical_spec(provider, specification):
rules = _CONTRACT["providers"][provider]
Expand Down Expand Up @@ -89,7 +91,7 @@ def validate(data, args):
raise SpecificationError(PUBLIC_URL_CHANGED)
try:
provider, spec = data["provider"], data["specification"]
if (provider not in ("docker", "microsandbox") or data["installation_id"] != args.installation_id
if (provider not in NODE_PROVIDERS or data["installation_id"] != args.installation_id
or data["core_url"] != args.core_url or type(data["generation"]) is not int or data["generation"] < 1
or getattr(args, "provider", None) not in (None, provider)
or set(spec) != {"resources", "runtime"}
Expand Down
13 changes: 13 additions & 0 deletions deploy/node/test_node_generations.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,19 @@ def test_old_release_shape_rejects_before_preparation_or_collection_mutates_stat
if location != "provider":
path.unlink()

def test_preparation_rejects_direct_and_unknown_providers_without_mutation(self):
path = self.directory / "1.preparing"
for provider in ("e2b", "unknown"):
plan = dict(self.value, provider=provider)
journal = dict(node_generations.marker_identity(self.args), import_started=False, configuration=plan)
node_generations.atomic_json(path, journal)
before = path.read_bytes()
with self.subTest(provider=provider), self.assertRaisesRegex(installer.InstallError, "Invalid generation preparation plan"):
node_generations.generation_marker(self.root, 1, ".preparing", self.args.specification_digest,
self.args.installation_id, installer)
self.assertEqual(path.read_bytes(), before)
installer.checked.assert_not_called()

def test_busy_helper_refuses_all_mutations_then_same_inode_collects(self):
lease = self.directory / "1.lease"
descriptor = os.open(lease, os.O_CREAT | os.O_RDWR, 0o600)
Expand Down
68 changes: 65 additions & 3 deletions deploy/node/test_node_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ def setUp(self):
self.fail_registration = False
self.register_stderr = None
for patch in (mock.patch.object(installer.Path, "home", return_value=self.home),
mock.patch.object(installer, "preflight"),
mock.patch.object(installer, "preflight", side_effect=self.service_check),
mock.patch.object(installer, "wait_ready"),
mock.patch.object(installer, "open_request", side_effect=self.configuration_response),
mock.patch.object(installer.distribution.urllib.request, "build_opener", return_value=mock.Mock(open=self.artifact_response)),
Expand All @@ -89,6 +89,11 @@ def setUp(self):
patch.start()
self.addCleanup(patch.stop)

def service_check(self, args):
# Stub process/device admission without suppressing native store setup.
with mock.patch.object(installer.os, "access", return_value=True), mock.patch.object(installer, "checked", return_value=""):
installer.provider_installation(args.provider)["service_check"](args)

def configuration_response(self, request, **kwargs):
resources = {"cpus": 3, "memory_mib": 6144}
if self.args.provider == "microsandbox":
Expand Down Expand Up @@ -853,6 +858,22 @@ def test_host_lock_is_private_and_exclusive(self):
fcntl.flock(other, fcntl.LOCK_EX | fcntl.LOCK_NB)
self.assertEqual(stat.S_IMODE((locks / "oac-node.lock").stat().st_mode), 0o600)

def test_docker_network_conflict_precedes_capacity_and_host_mutation(self):
system = self.sudo_host()
def checked(arguments, failure, **kwargs):
if "{{json .}}" in arguments:
return json.dumps({"MemoryLimit": True, "CpuCfsQuota": True, "NCPU": 0, "MemTotal": 0})
if "network" in arguments:
return "oac-node-" + self.args.installation_id
return self.checked(arguments, failure, **kwargs)
with mock.patch.object(installer, "checked", side_effect=checked), mock.patch.object(installer, "host_lock") as lock:
with self.assertRaisesRegex(installer.InstallError, "Another node.*Nothing was changed"):
installer.install_system(self.args, "synthetic-once-token")
lock.assert_not_called()
self.assertFalse((system / "etc").exists())
self.assertFalse((system / "units").exists())
self.assertFalse(installer.SERVICE_HOME.exists())

def test_sudo_mode_refusals_change_nothing(self):
foreign = SimpleNamespace(pw_name="oac-node", pw_uid=4242, pw_gid=4242, pw_dir="/home/oac-node", pw_shell="/bin/bash")
for case, message in (("selinux", "SELinux is enforcing"), ("docker", "Docker Engine is not installed"),
Expand Down Expand Up @@ -1114,15 +1135,56 @@ def test_origin_rejects_other_schemes_credentials_paths_and_redirects(self):


class NodePrerequisiteTests(unittest.TestCase):
def test_installer_implementations_match_declared_node_providers(self):
declared = {name for name, rules in node_spec._CONTRACT["providers"].items() if rules["mode"] == "nodes"}
self.assertEqual(set(node_spec.NODE_PROVIDERS), declared)
self.assertEqual(set(installer.PROVIDERS), declared)
self.assertEqual(set(installer.provider_assets.CATALOG), declared)

def test_direct_and_unknown_providers_never_reach_native_installation(self):
for provider in ("e2b", "unknown"):
args = SimpleNamespace(provider=provider, installation_id="fixture", core_url="https://core.example",
configuration={"specification": {}, "generation": 1})
with self.subTest(provider=provider), \
mock.patch.object(installer.os, "getuid", return_value=1000), \
mock.patch.object(installer.platform, "system", return_value="Linux"), \
mock.patch.object(installer.platform, "machine", return_value="x86_64"), \
mock.patch.object(installer, "checked") as checked, \
mock.patch.object(installer.os, "access") as access:
for operation in (lambda: installer.preflight(args),
lambda: installer.prepare_runtime(Path("/unused"), args, {}),
lambda: installer.provider_config(Path("/unused"), args, None),
lambda: installer.system_unit(Path("/unused"), provider)):
with self.assertRaisesRegex(installer.InstallError, "Unsupported node provider"):
operation()
checked.assert_not_called()
access.assert_not_called()

def test_preflight_rejects_missing_kvm_before_downloads(self):
with mock.patch.object(installer.platform, "system", return_value="Linux"), \
mock.patch.object(installer.platform, "machine", return_value="x86_64"), \
mock.patch.object(installer.os, "getuid", return_value=1000), \
mock.patch.object(installer.os, "access", return_value=False), \
mock.patch.object(installer, "fetch") as fetch:
mock.patch.object(installer, "fetch") as fetch, \
mock.patch.object(installer, "micro_home") as home:
with self.assertRaisesRegex(installer.InstallError, "/dev/kvm"):
installer.preflight("microsandbox")
installer.preflight(SimpleNamespace(provider="microsandbox"))
fetch.assert_not_called()
home.assert_not_called()

def test_service_check_refuses_unowned_micro_store(self):
base = Path.home() / ".oac/tests/node-install"
base.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(dir=base) as directory:
home = Path(directory)
unknown = home / "unknown"
unknown.write_bytes(b"preserve")
with mock.patch.object(installer.os, "access", return_value=True), \
mock.patch.object(installer, "micro_home", return_value=home):
with self.assertRaisesRegex(installer.InstallError, "unowned state"):
installer.micro_service_check(SimpleNamespace(installation_id="fixture"))
self.assertEqual(unknown.read_bytes(), b"preserve")
self.assertFalse((home / "oac-installation.json").exists())

def test_microsandbox_short_home_is_stable_and_rejects_long_user_home(self):
with mock.patch.object(installer.Path, "home", return_value=Path("/home/node")):
Expand Down
6 changes: 6 additions & 0 deletions deploy/node/test_node_spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,12 @@ def test_invalid_limits_digests_and_provider_assertions_are_rejected(self):
with self.assertRaises(node_spec.SpecificationError):
node_spec.validate(self.data, self.args)

def test_direct_and_unknown_providers_are_not_node_configurations(self):
for provider in ("e2b", "unknown"):
data = dict(self.data, provider=provider)
with self.subTest(provider=provider), self.assertRaises(node_spec.SpecificationError):
node_spec.validate(data, self.args)

def test_capacity_requires_approved_bounded_integers(self):
for key, value in (("max_active", None), ("max_active", True), ("max_active", 0),
("max_retained", 1), ("max_retained", 1000001)):
Expand Down