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
18 changes: 4 additions & 14 deletions contracts/agents-api/node-generation-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,6 @@ Neither opener adopts a missing identity, replaces its inode, or erases
it after GC. Initialization interrupted before the identity is durable refuses
re-adoption; preserve the installation for inspection. A removed identity or an
owned replacement 0600 lease still refuses, even when its current fstat/lstat agree.
Historical installations and their `legacy-unfenced` records are retained
for inspection; the current installer does not adopt or upgrade them. Do not
create a new fence to bypass a missing historical lease identity.

A dropped generation cannot be prepared or used again; a future rollback
would require a new generation and a separate policy.
Expand Down Expand Up @@ -173,9 +170,7 @@ metadata refuse cleanup. Interruption resumes under the same collection journal.
The current node executable, preparer, identity, base provider configuration and
manifests remain, so restart can read its enrolled identity and construct a newer
retained provider after the original Runtime bytes have gone. Shared native paths
are compared across all retained configurations before removal. A historical
`legacy-unfenced` marker remains a reason to retain its original payload, not
evidence that upgrading or serving the historical installation is supported.
are compared across all retained configurations before removal.

New preparation has two distinct records. Before downloads, `.preparing` holds the
immutable installation/generation/specification identity, private provider paths
Expand All @@ -198,10 +193,8 @@ An interrupted download repairs only missing bytes at the original paths. If
collection precedes any import attempt, the preparation journal proves that this
generation has no imported native image. An older or interrupted generation whose
native executable is missing and whose import may have started remains retained;
missing files do not prove native absence. Receipt/store history and a
historical `legacy-unfenced` record are never erased using an empty native
inventory. These retention checks do not authorize historical installation
conversion or adoption.
missing files do not prove native absence. Receipt/store history is never
erased using an empty native inventory.

Preparation diagnostics preserve fixed typed causes. Only artifact transfer,
checksum or release-provenance failures report `runtime_download_failed`. A private
Expand All @@ -225,10 +218,7 @@ flow. Reinstallation does not automatically delete or migrate existing data.

Current Runtime generation changes operate within an installation of the current
node program. They do not upgrade that program or establish compatibility with
historical node installations. A historical `legacy-unfenced` marker records that
older helpers did not share the current lease protocol. Preserve its files and
resources for inspection; do not clear it, recreate its lease, or infer safe
collection from an empty allocation list, process absence or a node restart.
historical node installations.

## Qualification boundary

Expand Down
3 changes: 0 additions & 3 deletions deploy/install/node_generations.py
Original file line number Diff line number Diff line change
Expand Up @@ -516,9 +516,6 @@ def collect(args, installer):
if (value["installation_id"] != args.installation_id
or installer.node_spec.digest(value["provider"], value["specification"]) != args.specification_digest):
raise installer.InstallError("Collection grant does not match the local generation")
marker = root / "state/node/generations" / (str(args.generation) + ".legacy-unfenced")
if marker.exists() or marker.is_symlink():
raise installer.InstallError("The original v1 generation retains unfenced legacy helpers; its local payload is kept")
source = value["specification"]["runtime"]["source_commit"]
if not re.fullmatch(r"[a-f0-9]{40}", source):
raise installer.InstallError("Invalid retained release identity")
Expand Down
83 changes: 0 additions & 83 deletions deploy/install/node_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -453,79 +453,6 @@ def register_node(root, args, token, helper_archive=None):
raise InstallError("Registered node identity differs; refusing to replace it")


# These paths are inspected only to refuse an unremoved node from an older
# release. They are never adopted, rewritten or removed by this installer.
LEGACY_RECORDS = Path("/etc/parsar-node")
LEGACY_SERVICE_HOME = Path("/var/lib/parsar-node")


def legacy_path_present(path):
"""Inspect only metadata, without following links in user-controlled directories."""
descriptors = []
try:
descriptors.append(os.open(path.anchor, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW))
for part in path.parts[1:-1]:
descriptors.append(os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
dir_fd=descriptors[-1]))
os.stat(path.name, dir_fd=descriptors[-1], follow_symlinks=False)
return True
except FileNotFoundError:
return False
finally:
for descriptor in reversed(descriptors):
os.close(descriptor)


def refuse_legacy_node(args):
"""Reject pre-rename resources for this installation, leaving every other one alone."""
installation = args.installation_id
unit = "parsar-node-" + installation + ".service"
homes = {Path.home()}
if os.geteuid() == 0:
sudo_user = os.environ.get("SUDO_USER", "")
if sudo_user and sudo_user != "root":
try:
home = Path(pwd.getpwnam(sudo_user).pw_dir)
if home.is_absolute():
homes.add(home)
except KeyError:
pass
paths = [LEGACY_RECORDS / (installation + ".json"), SYSTEM_UNITS / unit]
paths += [home / ".parsar/nodes" / installation for home in homes]
paths += [home / ".config/systemd/user" / unit for home in homes]
# Non-root users cannot inspect the old mode-0700 service home; its matching
# root-owned record and system unit above remain observable without reading it.
if os.geteuid() == 0 or os.access(LEGACY_SERVICE_HOME, os.R_OK | os.X_OK):
paths.append(LEGACY_SERVICE_HOME / ".parsar/nodes" / installation)
found = []
for path in paths:
try:
if legacy_path_present(path): # A dangling final symlink is still retained state.
found.append(str(path))
except OSError:
raise InstallError("Cannot inspect possible legacy node state at " + str(path)
+ "; check this path before installing." + NOTHING_CHANGED) from None
if not found and shutil.which("systemctl") is not None:
result = subprocess.run(["systemctl", "show", unit, "--property=LoadState", "--value"],
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=10)
if result.returncode == 0 and result.stdout.strip() not in ("", "not-found"):
found.append(unit)
# Inspect only the fixed local engine. A microsandbox user without Docker
# access does not need that unrelated host capability to install its node.
if not found and shutil.which("docker") is not None and DOCKER_SOCKET.exists() and (
getattr(args, "provider", None) == "docker" or os.access(DOCKER_SOCKET, os.R_OK | os.W_OK)):
network = "parsar-node-" + installation
networks = checked(list(DOCKER) + ["network", "ls", "--format", "{{.Name}}"],
"Cannot inspect legacy node networks; check the local Docker engine." + NOTHING_CHANGED).splitlines()
if network in networks:
found.append("Docker network " + network)
if found:
raise InstallError("This host still has a node for this installation from before the OpenAgentCore rename ("
+ ", ".join(found) + "). Remove it on the Nodes page, then uninstall it with the previous "
"release's node-install.pyz --uninstall --installation-id " + installation
+ ", or follow \"Remove a node added before the rename\" in the node guide." + NOTHING_CHANGED)


def prepare_service_node(args, token, helper_archive):
"""Sudo mode, as the service user: everything but the root-owned system unit."""
root = open_node(args, token)
Expand Down Expand Up @@ -1052,7 +979,6 @@ def install_system(args, token):
"""Sudo mode: prepare the host, then run the node as a root-owned system service."""
os.environ["PATH"] = SAFE_PATH
host_checks()
refuse_legacy_node(args)
# Checks that change nothing run first, so a refusal leaves no trace, not even a lock.
record = node_record(args.installation_id)
configuration = None
Expand Down Expand Up @@ -1203,7 +1129,6 @@ def uninstall_system(args):
os.environ["PATH"] = SAFE_PATH
if shutil.which("systemctl") is None:
raise InstallError("systemctl is required." + NOTHING_CHANGED)
refuse_legacy_node(args)
record = node_record(args.installation_id)
unit = SYSTEM_UNITS / unit_name(args.installation_id)
# Only root-owned files decide whether a node is installed; the service home is not read here.
Expand Down Expand Up @@ -1321,11 +1246,6 @@ def wait_ready(root, args, timeout=60):
raise InstallError(detail + "; state and service are retained. Inspect " + journal + ", then rerun the installation command")


# A token in the environment could reach sudo's log (`sudo VAR=... python3`) and every
# program the installer starts; it is refused rather than read.
RETIRED_TOKEN_VARIABLE = "PARSAR_NODE_ENROLLMENT_TOKEN"


def read_token(args):
"""The one-time token comes on standard input, never in argv, the environment or a sudo command line."""
if not args.enrollment_token_stdin:
Expand Down Expand Up @@ -1354,9 +1274,6 @@ def main(argv=None):
args = parser.parse_args(argv)
if args.no_color:
os.environ["NO_COLOR"] = "1"
if RETIRED_TOKEN_VARIABLE in os.environ:
parser.exit(2, RETIRED_TOKEN_VARIABLE + " is retired: pass the enrollment token on standard input with "
"--enrollment-token-stdin.\n")
if str(uuid.UUID(args.installation_id)) != args.installation_id:
raise InstallError("Installation ID must be a canonical UUID")
if args.update:
Expand Down
8 changes: 0 additions & 8 deletions deploy/install/test_node_generations.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,14 +164,6 @@ def test_never_imported_generation_can_collect_missing_executable(self):
installer.checked.assert_not_called()
self.assertFalse(self.release.exists())

def test_legacy_unfenced_marker_blocks_before_collection_journal(self):
(self.directory / "1.legacy-unfenced").write_text("retained")
with self.assertRaisesRegex(installer.InstallError, "legacy helpers"):
node_generations.collect(self.args, installer)
installer.checked.assert_not_called()
self.assertFalse((self.directory / "1.collecting").exists())
self.assertTrue(self.release.exists())

def micro_fixture(self):
self.value["provider"] = "microsandbox"
del self.value["docker"]
Expand Down
19 changes: 1 addition & 18 deletions deploy/install/test_node_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,7 @@ def setUp(self):
self.fail_service = False
self.fail_registration = False
self.register_stderr = None
for patch in (mock.patch.object(installer, "refuse_legacy_node"),
mock.patch.object(installer.Path, "home", return_value=self.home),
for patch in (mock.patch.object(installer.Path, "home", return_value=self.home),
mock.patch.object(installer, "preflight"),
mock.patch.object(installer, "wait_ready"),
mock.patch.object(installer, "open_request", side_effect=self.configuration_response),
Expand Down Expand Up @@ -115,7 +114,6 @@ def refresh_manifest(self):
def checked(self, arguments, failure, **kwargs):
self.calls.append((arguments, kwargs))
self.assertNotIn("synthetic-once-token", str(arguments))
self.assertNotIn("PARSAR_NODE_ENROLLMENT_TOKEN", os.environ)
if "register" in arguments:
self.assertNotIn("--max-active", arguments)
self.assertNotIn("--max-retained", arguments)
Expand Down Expand Up @@ -988,21 +986,6 @@ def test_token_comes_on_standard_input_only(self):
installer.main(arguments)
self.assertEqual(install.call_args.args[1], "synthetic-once-token")

def test_the_retired_token_variable_is_refused_in_every_mode(self):
install = ["--source-url", self.args.source_url, "--core-url", self.args.core_url,
"--installation-id", self.args.installation_id, "--enrollment-token-stdin"]
for euid in (1000, 0):
for arguments in (install, install[:-1], ["--uninstall", "--installation-id", self.args.installation_id]):
errors = io.StringIO()
with mock.patch.dict(os.environ, {"PARSAR_NODE_ENROLLMENT_TOKEN": "synthetic-once-token"}), \
mock.patch.object(installer.os, "geteuid", return_value=euid), mock.patch.object(installer.sys, "stderr", errors), \
mock.patch.multiple(installer, install_system=mock.DEFAULT, uninstall_system=mock.DEFAULT) as steps, self.assertRaises(SystemExit):
installer.main(arguments)
self.assertEqual(errors.getvalue().splitlines(), ["PARSAR_NODE_ENROLLMENT_TOKEN is retired: pass the enrollment "
"token on standard input with --enrollment-token-stdin."])
self.assertFalse(any(step.called for step in steps.values()))


def test_offline_bundle_uses_same_bootstrap_and_verified_artifacts(self):
bundle = self.home / "bundle"
bundle.mkdir()
Expand Down
120 changes: 0 additions & 120 deletions deploy/install/test_node_legacy.py

This file was deleted.

Loading