feat(local-service): allow exact worktree migration
This commit is contained in:
@@ -72,6 +72,49 @@ waits until `bootout` has fully removed the old label, bootstraps the new
|
|||||||
declaration and accepts only the exact Mission Core health document. Any
|
declaration and accepts only the exact Mission Core health document. Any
|
||||||
failure restores the previous plist and repeats the same health acceptance.
|
failure restores the previous plist and repeats the same health acceptance.
|
||||||
|
|
||||||
|
### Explicit worktree migration
|
||||||
|
|
||||||
|
A plan from a different checkout is rejected by default. To migrate the
|
||||||
|
canonical service between these two exact worktrees, authorize the installed
|
||||||
|
predecessor explicitly on both plan and apply:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE_m5_observatory
|
||||||
|
|
||||||
|
uv run python scripts/manage_mission_core_launch_agent.py plan \
|
||||||
|
--repository-root /Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE_m5_observatory \
|
||||||
|
--expected-current-repository-root /Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE
|
||||||
|
```
|
||||||
|
|
||||||
|
The plan is admissible only when `current_working_directory` is exactly
|
||||||
|
`/Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE`,
|
||||||
|
`desired_working_directory` is exactly
|
||||||
|
`/Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE_m5_observatory`
|
||||||
|
and `changes.repository_migration` is `true`. The same plan must report
|
||||||
|
`preserved_data_directory` and `changes.preserved_data_directory` as exactly
|
||||||
|
`/Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE/.runtime/mission-core`,
|
||||||
|
with `changes.data_directory_preserved=true`. The desired plist then carries
|
||||||
|
that exact path as `MISSIONCORE_DATA_DIR`; an already configured nonblank,
|
||||||
|
private canonical `MISSIONCORE_DATA_DIR` is retained instead. Copy the two
|
||||||
|
exact hashes from that same output:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run python scripts/manage_mission_core_launch_agent.py apply \
|
||||||
|
--repository-root /Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE_m5_observatory \
|
||||||
|
--expected-current-repository-root /Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE \
|
||||||
|
--expected-current-sha256 <current-sha256> \
|
||||||
|
--expected-desired-sha256 <desired-sha256>
|
||||||
|
```
|
||||||
|
|
||||||
|
The migration option is not a general cross-repository override. The planner
|
||||||
|
resolves it to one exact path and rejects any installed `WorkingDirectory`
|
||||||
|
that differs. Apply recomputes the same path-bound plan and still requires both
|
||||||
|
plist hashes, so a changed predecessor or candidate must be planned again.
|
||||||
|
Do not symlink either worktree's `.runtime/mission-core` to the other. The
|
||||||
|
checkout-local singleton lock requires a real private directory; durable data
|
||||||
|
continuity is expressed only by the path-bound `MISSIONCORE_DATA_DIR` in this
|
||||||
|
migration plan.
|
||||||
|
|
||||||
Read-only status:
|
Read-only status:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -28,6 +28,14 @@ def main() -> int:
|
|||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
parser.add_argument("action", choices=("plan", "apply", "status"))
|
parser.add_argument("action", choices=("plan", "apply", "status"))
|
||||||
parser.add_argument("--repository-root", type=Path, required=True)
|
parser.add_argument("--repository-root", type=Path, required=True)
|
||||||
|
parser.add_argument(
|
||||||
|
"--expected-current-repository-root",
|
||||||
|
type=Path,
|
||||||
|
help=(
|
||||||
|
"authorize migration only from this exact installed WorkingDirectory; "
|
||||||
|
"cross-repository plans remain denied when omitted"
|
||||||
|
),
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--agent-path",
|
"--agent-path",
|
||||||
type=Path,
|
type=Path,
|
||||||
@@ -43,6 +51,7 @@ def main() -> int:
|
|||||||
plan = plan_mission_core_launch_agent(
|
plan = plan_mission_core_launch_agent(
|
||||||
repository_root=arguments.repository_root,
|
repository_root=arguments.repository_root,
|
||||||
agent_path=arguments.agent_path,
|
agent_path=arguments.agent_path,
|
||||||
|
expected_current_repository_root=arguments.expected_current_repository_root,
|
||||||
)
|
)
|
||||||
if arguments.action == "plan":
|
if arguments.action == "plan":
|
||||||
print(json.dumps(plan.to_dict(), indent=2, sort_keys=True))
|
print(json.dumps(plan.to_dict(), indent=2, sort_keys=True))
|
||||||
|
|||||||
@@ -3,10 +3,12 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import os
|
||||||
import plistlib
|
import plistlib
|
||||||
|
import stat
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Final
|
from typing import Final, cast
|
||||||
|
|
||||||
MISSION_CORE_LAUNCH_AGENT_LABEL: Final = "com.nodedc.mission-core.local"
|
MISSION_CORE_LAUNCH_AGENT_LABEL: Final = "com.nodedc.mission-core.local"
|
||||||
MISSION_CORE_LAUNCH_AGENT_SCHEMA: Final = "missioncore.local-launch-agent-plan/v1"
|
MISSION_CORE_LAUNCH_AGENT_SCHEMA: Final = "missioncore.local-launch-agent-plan/v1"
|
||||||
@@ -21,6 +23,9 @@ class MissionCoreLaunchAgentPlan:
|
|||||||
agent_path: Path
|
agent_path: Path
|
||||||
current_sha256: str
|
current_sha256: str
|
||||||
desired_sha256: str
|
desired_sha256: str
|
||||||
|
current_working_directory: Path
|
||||||
|
desired_working_directory: Path
|
||||||
|
preserved_data_directory: Path | None
|
||||||
current_program_arguments: tuple[str, ...]
|
current_program_arguments: tuple[str, ...]
|
||||||
desired_program_arguments: tuple[str, ...]
|
desired_program_arguments: tuple[str, ...]
|
||||||
desired_payload: bytes
|
desired_payload: bytes
|
||||||
@@ -32,9 +37,24 @@ class MissionCoreLaunchAgentPlan:
|
|||||||
"agent_path": str(self.agent_path),
|
"agent_path": str(self.agent_path),
|
||||||
"current_sha256": self.current_sha256,
|
"current_sha256": self.current_sha256,
|
||||||
"desired_sha256": self.desired_sha256,
|
"desired_sha256": self.desired_sha256,
|
||||||
|
"current_working_directory": str(self.current_working_directory),
|
||||||
|
"desired_working_directory": str(self.desired_working_directory),
|
||||||
|
"preserved_data_directory": (
|
||||||
|
str(self.preserved_data_directory)
|
||||||
|
if self.preserved_data_directory is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
"current_program_arguments": list(self.current_program_arguments),
|
"current_program_arguments": list(self.current_program_arguments),
|
||||||
"desired_program_arguments": list(self.desired_program_arguments),
|
"desired_program_arguments": list(self.desired_program_arguments),
|
||||||
"changes": {
|
"changes": {
|
||||||
|
"repository_migration": self.current_working_directory
|
||||||
|
!= self.desired_working_directory,
|
||||||
|
"data_directory_preserved": self.preserved_data_directory is not None,
|
||||||
|
"preserved_data_directory": (
|
||||||
|
str(self.preserved_data_directory)
|
||||||
|
if self.preserved_data_directory is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
"dependency_sync_disabled": "--no-sync"
|
"dependency_sync_disabled": "--no-sync"
|
||||||
in self.desired_program_arguments,
|
in self.desired_program_arguments,
|
||||||
"self_health_watchdog": True,
|
"self_health_watchdog": True,
|
||||||
@@ -49,8 +69,14 @@ def plan_mission_core_launch_agent(
|
|||||||
*,
|
*,
|
||||||
repository_root: Path,
|
repository_root: Path,
|
||||||
agent_path: Path,
|
agent_path: Path,
|
||||||
|
expected_current_repository_root: Path | None = None,
|
||||||
) -> MissionCoreLaunchAgentPlan:
|
) -> MissionCoreLaunchAgentPlan:
|
||||||
repository = repository_root.expanduser().resolve(strict=True)
|
repository = repository_root.expanduser().resolve(strict=True)
|
||||||
|
expected_current_repository = (
|
||||||
|
expected_current_repository_root.expanduser().resolve(strict=True)
|
||||||
|
if expected_current_repository_root is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
path = agent_path.expanduser().absolute()
|
path = agent_path.expanduser().absolute()
|
||||||
current_payload = _read_private_regular_file(path)
|
current_payload = _read_private_regular_file(path)
|
||||||
try:
|
try:
|
||||||
@@ -61,14 +87,24 @@ def plan_mission_core_launch_agent(
|
|||||||
raise MissionCoreLaunchAgentError("current launch agent identity changed")
|
raise MissionCoreLaunchAgentError("current launch agent identity changed")
|
||||||
current_arguments = _program_arguments(current)
|
current_arguments = _program_arguments(current)
|
||||||
current_working_directory = current.get("WorkingDirectory")
|
current_working_directory = current.get("WorkingDirectory")
|
||||||
if current_working_directory != str(repository):
|
if not isinstance(current_working_directory, str):
|
||||||
|
raise MissionCoreLaunchAgentError("current launch agent working directory is invalid")
|
||||||
|
if expected_current_repository is None and current_working_directory != str(repository):
|
||||||
raise MissionCoreLaunchAgentError("current launch agent targets another repository")
|
raise MissionCoreLaunchAgentError("current launch agent targets another repository")
|
||||||
environment = current.get("EnvironmentVariables")
|
if (
|
||||||
if not isinstance(environment, dict) or any(
|
expected_current_repository is not None
|
||||||
|
and current_working_directory != str(expected_current_repository)
|
||||||
|
):
|
||||||
|
raise MissionCoreLaunchAgentError(
|
||||||
|
"current launch agent does not target the expected current repository"
|
||||||
|
)
|
||||||
|
environment_document = current.get("EnvironmentVariables")
|
||||||
|
if not isinstance(environment_document, dict) or any(
|
||||||
not isinstance(key, str) or not isinstance(value, str)
|
not isinstance(key, str) or not isinstance(value, str)
|
||||||
for key, value in environment.items()
|
for key, value in environment_document.items()
|
||||||
):
|
):
|
||||||
raise MissionCoreLaunchAgentError("current launch agent environment is invalid")
|
raise MissionCoreLaunchAgentError("current launch agent environment is invalid")
|
||||||
|
environment = cast(dict[str, str], environment_document)
|
||||||
# A LaunchAgent started directly from this repository's venv is denied
|
# A LaunchAgent started directly from this repository's venv is denied
|
||||||
# access to ``.venv/pyvenv.cfg`` by macOS privacy controls because the
|
# access to ``.venv/pyvenv.cfg`` by macOS privacy controls because the
|
||||||
# checkout is below Downloads. The Homebrew uv launcher is already the
|
# checkout is below Downloads. The Homebrew uv launcher is already the
|
||||||
@@ -81,18 +117,31 @@ def plan_mission_core_launch_agent(
|
|||||||
or not uv_entrypoint.exists()
|
or not uv_entrypoint.exists()
|
||||||
):
|
):
|
||||||
raise MissionCoreLaunchAgentError("Mission Core uv entrypoint is unavailable")
|
raise MissionCoreLaunchAgentError("Mission Core uv entrypoint is unavailable")
|
||||||
|
current_repository = Path(current_working_directory)
|
||||||
|
repository_migration = current_repository != repository
|
||||||
|
preserved_data_directory = (
|
||||||
|
_preserved_migration_data_directory(
|
||||||
|
current_repository=current_repository,
|
||||||
|
environment=environment,
|
||||||
|
)
|
||||||
|
if repository_migration
|
||||||
|
else None
|
||||||
|
)
|
||||||
desired_environment = dict(environment)
|
desired_environment = dict(environment)
|
||||||
desired_environment["MISSIONCORE_SERVICE_WATCHDOG"] = "1"
|
desired_environment["MISSIONCORE_SERVICE_WATCHDOG"] = "1"
|
||||||
|
if preserved_data_directory is not None:
|
||||||
|
desired_environment["MISSIONCORE_DATA_DIR"] = str(preserved_data_directory)
|
||||||
log_path = repository / ".runtime/mission-core/k1link-serve-launchd.log"
|
log_path = repository / ".runtime/mission-core/k1link-serve-launchd.log"
|
||||||
|
desired_program_arguments = (
|
||||||
|
str(uv_entrypoint),
|
||||||
|
"run",
|
||||||
|
"--no-sync",
|
||||||
|
"k1link",
|
||||||
|
"serve",
|
||||||
|
)
|
||||||
desired: dict[str, object] = {
|
desired: dict[str, object] = {
|
||||||
"Label": MISSION_CORE_LAUNCH_AGENT_LABEL,
|
"Label": MISSION_CORE_LAUNCH_AGENT_LABEL,
|
||||||
"ProgramArguments": [
|
"ProgramArguments": list(desired_program_arguments),
|
||||||
str(uv_entrypoint),
|
|
||||||
"run",
|
|
||||||
"--no-sync",
|
|
||||||
"k1link",
|
|
||||||
"serve",
|
|
||||||
],
|
|
||||||
"WorkingDirectory": str(repository),
|
"WorkingDirectory": str(repository),
|
||||||
"EnvironmentVariables": desired_environment,
|
"EnvironmentVariables": desired_environment,
|
||||||
"KeepAlive": True,
|
"KeepAlive": True,
|
||||||
@@ -109,8 +158,11 @@ def plan_mission_core_launch_agent(
|
|||||||
agent_path=path,
|
agent_path=path,
|
||||||
current_sha256=_sha256(current_payload),
|
current_sha256=_sha256(current_payload),
|
||||||
desired_sha256=_sha256(desired_payload),
|
desired_sha256=_sha256(desired_payload),
|
||||||
|
current_working_directory=current_repository,
|
||||||
|
desired_working_directory=repository,
|
||||||
|
preserved_data_directory=preserved_data_directory,
|
||||||
current_program_arguments=current_arguments,
|
current_program_arguments=current_arguments,
|
||||||
desired_program_arguments=tuple(desired["ProgramArguments"]),
|
desired_program_arguments=desired_program_arguments,
|
||||||
desired_payload=desired_payload,
|
desired_payload=desired_payload,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -122,6 +174,42 @@ def _program_arguments(document: dict[str, object]) -> tuple[str, ...]:
|
|||||||
return tuple(value)
|
return tuple(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _preserved_migration_data_directory(
|
||||||
|
*,
|
||||||
|
current_repository: Path,
|
||||||
|
environment: dict[str, str],
|
||||||
|
) -> Path:
|
||||||
|
configured = environment.get("MISSIONCORE_DATA_DIR", "")
|
||||||
|
if configured.strip():
|
||||||
|
if configured != configured.strip():
|
||||||
|
raise MissionCoreLaunchAgentError(
|
||||||
|
"current Mission Core data directory is not a canonical absolute path"
|
||||||
|
)
|
||||||
|
candidate = Path(configured)
|
||||||
|
else:
|
||||||
|
candidate = current_repository / ".runtime" / "mission-core"
|
||||||
|
if not candidate.is_absolute():
|
||||||
|
raise MissionCoreLaunchAgentError(
|
||||||
|
"current Mission Core data directory is not a canonical absolute path"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
resolved = candidate.resolve(strict=True)
|
||||||
|
metadata = candidate.lstat()
|
||||||
|
except OSError as exc:
|
||||||
|
raise MissionCoreLaunchAgentError(
|
||||||
|
"current Mission Core data directory is unavailable"
|
||||||
|
) from exc
|
||||||
|
if resolved != candidate or (
|
||||||
|
not stat.S_ISDIR(metadata.st_mode)
|
||||||
|
or stat.S_IMODE(metadata.st_mode) != 0o700
|
||||||
|
or metadata.st_uid != os.getuid()
|
||||||
|
):
|
||||||
|
raise MissionCoreLaunchAgentError(
|
||||||
|
"current Mission Core data directory is not a private canonical directory"
|
||||||
|
)
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
def _read_private_regular_file(path: Path) -> bytes:
|
def _read_private_regular_file(path: Path) -> bytes:
|
||||||
if path.is_symlink() or not path.is_file():
|
if path.is_symlink() or not path.is_file():
|
||||||
raise MissionCoreLaunchAgentError("Mission Core launch agent is unavailable")
|
raise MissionCoreLaunchAgentError("Mission Core launch agent is unavailable")
|
||||||
|
|||||||
@@ -3,7 +3,32 @@ from __future__ import annotations
|
|||||||
import plistlib
|
import plistlib
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from k1link.local_service_launchd import plan_mission_core_launch_agent
|
import pytest
|
||||||
|
|
||||||
|
from k1link.local_service_launchd import (
|
||||||
|
MissionCoreLaunchAgentError,
|
||||||
|
plan_mission_core_launch_agent,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_agent(
|
||||||
|
*,
|
||||||
|
path: Path,
|
||||||
|
repository: Path,
|
||||||
|
uv_entrypoint: Path,
|
||||||
|
environment: dict[str, str] | None = None,
|
||||||
|
) -> None:
|
||||||
|
path.write_bytes(
|
||||||
|
plistlib.dumps(
|
||||||
|
{
|
||||||
|
"Label": "com.nodedc.mission-core.local",
|
||||||
|
"ProgramArguments": [str(uv_entrypoint), "run", "k1link", "serve"],
|
||||||
|
"WorkingDirectory": str(repository),
|
||||||
|
"EnvironmentVariables": environment or {"PATH": "/usr/bin:/bin"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
path.chmod(0o600)
|
||||||
|
|
||||||
|
|
||||||
def test_launch_agent_plan_disables_sync_and_enables_watchdog(tmp_path: Path) -> None:
|
def test_launch_agent_plan_disables_sync_and_enables_watchdog(tmp_path: Path) -> None:
|
||||||
@@ -13,17 +38,7 @@ def test_launch_agent_plan_disables_sync_and_enables_watchdog(tmp_path: Path) ->
|
|||||||
uv_entrypoint.write_text("#!/bin/sh\n")
|
uv_entrypoint.write_text("#!/bin/sh\n")
|
||||||
uv_entrypoint.chmod(0o700)
|
uv_entrypoint.chmod(0o700)
|
||||||
agent = tmp_path / "agent.plist"
|
agent = tmp_path / "agent.plist"
|
||||||
agent.write_bytes(
|
_write_agent(path=agent, repository=repository, uv_entrypoint=uv_entrypoint)
|
||||||
plistlib.dumps(
|
|
||||||
{
|
|
||||||
"Label": "com.nodedc.mission-core.local",
|
|
||||||
"ProgramArguments": [str(uv_entrypoint), "run", "k1link", "serve"],
|
|
||||||
"WorkingDirectory": str(repository),
|
|
||||||
"EnvironmentVariables": {"PATH": "/usr/bin:/bin"},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
agent.chmod(0o600)
|
|
||||||
|
|
||||||
plan = plan_mission_core_launch_agent(
|
plan = plan_mission_core_launch_agent(
|
||||||
repository_root=repository,
|
repository_root=repository,
|
||||||
@@ -44,3 +59,190 @@ def test_launch_agent_plan_disables_sync_and_enables_watchdog(tmp_path: Path) ->
|
|||||||
assert desired["AbandonProcessGroup"] is False
|
assert desired["AbandonProcessGroup"] is False
|
||||||
assert desired["ExitTimeOut"] == 20
|
assert desired["ExitTimeOut"] == 20
|
||||||
assert plan.current_sha256 != plan.desired_sha256
|
assert plan.current_sha256 != plan.desired_sha256
|
||||||
|
assert plan.current_working_directory == repository
|
||||||
|
assert plan.desired_working_directory == repository
|
||||||
|
assert plan.preserved_data_directory is None
|
||||||
|
assert "MISSIONCORE_DATA_DIR" not in desired["EnvironmentVariables"]
|
||||||
|
assert plan.to_dict()["changes"]["repository_migration"] is False
|
||||||
|
assert plan.to_dict()["changes"]["data_directory_preserved"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_launch_agent_plan_rejects_cross_repository_migration_by_default(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
current_repository = tmp_path / "current-repo"
|
||||||
|
current_repository.mkdir()
|
||||||
|
desired_repository = tmp_path / "desired-repo"
|
||||||
|
desired_repository.mkdir()
|
||||||
|
uv_entrypoint = tmp_path / "uv"
|
||||||
|
uv_entrypoint.write_text("#!/bin/sh\n")
|
||||||
|
uv_entrypoint.chmod(0o700)
|
||||||
|
agent = tmp_path / "agent.plist"
|
||||||
|
_write_agent(
|
||||||
|
path=agent,
|
||||||
|
repository=current_repository,
|
||||||
|
uv_entrypoint=uv_entrypoint,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
MissionCoreLaunchAgentError,
|
||||||
|
match="current launch agent targets another repository",
|
||||||
|
):
|
||||||
|
plan_mission_core_launch_agent(
|
||||||
|
repository_root=desired_repository,
|
||||||
|
agent_path=agent,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_launch_agent_plan_binds_explicit_cross_repository_migration(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
current_repository = tmp_path / "current-repo"
|
||||||
|
current_repository.mkdir()
|
||||||
|
desired_repository = tmp_path / "desired-repo"
|
||||||
|
desired_repository.mkdir()
|
||||||
|
other_repository = tmp_path / "other-repo"
|
||||||
|
other_repository.mkdir()
|
||||||
|
data_directory = current_repository / ".runtime" / "mission-core"
|
||||||
|
data_directory.mkdir(parents=True, mode=0o700)
|
||||||
|
uv_entrypoint = tmp_path / "uv"
|
||||||
|
uv_entrypoint.write_text("#!/bin/sh\n")
|
||||||
|
uv_entrypoint.chmod(0o700)
|
||||||
|
agent = tmp_path / "agent.plist"
|
||||||
|
_write_agent(
|
||||||
|
path=agent,
|
||||||
|
repository=current_repository,
|
||||||
|
uv_entrypoint=uv_entrypoint,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
MissionCoreLaunchAgentError,
|
||||||
|
match="does not target the expected current repository",
|
||||||
|
):
|
||||||
|
plan_mission_core_launch_agent(
|
||||||
|
repository_root=desired_repository,
|
||||||
|
agent_path=agent,
|
||||||
|
expected_current_repository_root=other_repository,
|
||||||
|
)
|
||||||
|
|
||||||
|
plan = plan_mission_core_launch_agent(
|
||||||
|
repository_root=desired_repository,
|
||||||
|
agent_path=agent,
|
||||||
|
expected_current_repository_root=current_repository,
|
||||||
|
)
|
||||||
|
desired = plistlib.loads(plan.desired_payload)
|
||||||
|
document = plan.to_dict()
|
||||||
|
|
||||||
|
assert plan.current_working_directory == current_repository
|
||||||
|
assert plan.desired_working_directory == desired_repository
|
||||||
|
assert plan.preserved_data_directory == data_directory
|
||||||
|
assert desired["WorkingDirectory"] == str(desired_repository)
|
||||||
|
assert desired["EnvironmentVariables"]["MISSIONCORE_DATA_DIR"] == str(data_directory)
|
||||||
|
assert document["current_working_directory"] == str(current_repository)
|
||||||
|
assert document["desired_working_directory"] == str(desired_repository)
|
||||||
|
assert document["preserved_data_directory"] == str(data_directory)
|
||||||
|
assert document["changes"]["repository_migration"] is True
|
||||||
|
assert document["changes"]["data_directory_preserved"] is True
|
||||||
|
assert document["changes"]["preserved_data_directory"] == str(data_directory)
|
||||||
|
|
||||||
|
|
||||||
|
def test_launch_agent_migration_retains_existing_private_data_directory(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
current_repository = tmp_path / "current-repo"
|
||||||
|
current_repository.mkdir()
|
||||||
|
desired_repository = tmp_path / "desired-repo"
|
||||||
|
desired_repository.mkdir()
|
||||||
|
configured_data_directory = tmp_path / "canonical-data"
|
||||||
|
configured_data_directory.mkdir(mode=0o700)
|
||||||
|
uv_entrypoint = tmp_path / "uv"
|
||||||
|
uv_entrypoint.write_text("#!/bin/sh\n")
|
||||||
|
uv_entrypoint.chmod(0o700)
|
||||||
|
agent = tmp_path / "agent.plist"
|
||||||
|
_write_agent(
|
||||||
|
path=agent,
|
||||||
|
repository=current_repository,
|
||||||
|
uv_entrypoint=uv_entrypoint,
|
||||||
|
environment={
|
||||||
|
"PATH": "/usr/bin:/bin",
|
||||||
|
"MISSIONCORE_DATA_DIR": str(configured_data_directory),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
plan = plan_mission_core_launch_agent(
|
||||||
|
repository_root=desired_repository,
|
||||||
|
agent_path=agent,
|
||||||
|
expected_current_repository_root=current_repository,
|
||||||
|
)
|
||||||
|
desired = plistlib.loads(plan.desired_payload)
|
||||||
|
|
||||||
|
assert plan.preserved_data_directory == configured_data_directory
|
||||||
|
assert (
|
||||||
|
desired["EnvironmentVariables"]["MISSIONCORE_DATA_DIR"]
|
||||||
|
== str(configured_data_directory)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_launch_agent_migration_rejects_symlinked_data_directory(tmp_path: Path) -> None:
|
||||||
|
current_repository = tmp_path / "current-repo"
|
||||||
|
current_repository.mkdir()
|
||||||
|
desired_repository = tmp_path / "desired-repo"
|
||||||
|
desired_repository.mkdir()
|
||||||
|
private_directory = tmp_path / "private-data"
|
||||||
|
private_directory.mkdir(mode=0o700)
|
||||||
|
symlinked_directory = tmp_path / "data-link"
|
||||||
|
symlinked_directory.symlink_to(private_directory, target_is_directory=True)
|
||||||
|
uv_entrypoint = tmp_path / "uv"
|
||||||
|
uv_entrypoint.write_text("#!/bin/sh\n")
|
||||||
|
uv_entrypoint.chmod(0o700)
|
||||||
|
agent = tmp_path / "agent.plist"
|
||||||
|
_write_agent(
|
||||||
|
path=agent,
|
||||||
|
repository=current_repository,
|
||||||
|
uv_entrypoint=uv_entrypoint,
|
||||||
|
environment={
|
||||||
|
"PATH": "/usr/bin:/bin",
|
||||||
|
"MISSIONCORE_DATA_DIR": str(symlinked_directory),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
MissionCoreLaunchAgentError,
|
||||||
|
match="data directory is not a private canonical directory",
|
||||||
|
):
|
||||||
|
plan_mission_core_launch_agent(
|
||||||
|
repository_root=desired_repository,
|
||||||
|
agent_path=agent,
|
||||||
|
expected_current_repository_root=current_repository,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_launch_agent_migration_rejects_nonprivate_default_data_directory(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
current_repository = tmp_path / "current-repo"
|
||||||
|
current_repository.mkdir()
|
||||||
|
desired_repository = tmp_path / "desired-repo"
|
||||||
|
desired_repository.mkdir()
|
||||||
|
data_directory = current_repository / ".runtime" / "mission-core"
|
||||||
|
data_directory.mkdir(parents=True, mode=0o755)
|
||||||
|
data_directory.chmod(0o755)
|
||||||
|
uv_entrypoint = tmp_path / "uv"
|
||||||
|
uv_entrypoint.write_text("#!/bin/sh\n")
|
||||||
|
uv_entrypoint.chmod(0o700)
|
||||||
|
agent = tmp_path / "agent.plist"
|
||||||
|
_write_agent(
|
||||||
|
path=agent,
|
||||||
|
repository=current_repository,
|
||||||
|
uv_entrypoint=uv_entrypoint,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
MissionCoreLaunchAgentError,
|
||||||
|
match="data directory is not a private canonical directory",
|
||||||
|
):
|
||||||
|
plan_mission_core_launch_agent(
|
||||||
|
repository_root=desired_repository,
|
||||||
|
agent_path=agent,
|
||||||
|
expected_current_repository_root=current_repository,
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import importlib.util
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import plistlib
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -15,6 +18,57 @@ manager = importlib.util.module_from_spec(_SPEC)
|
|||||||
_SPEC.loader.exec_module(manager)
|
_SPEC.loader.exec_module(manager)
|
||||||
|
|
||||||
|
|
||||||
|
def test_plan_cli_authorizes_only_the_declared_current_repository(
|
||||||
|
monkeypatch: Any,
|
||||||
|
capsys: Any,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
current_repository = tmp_path / "current-repo"
|
||||||
|
current_repository.mkdir()
|
||||||
|
desired_repository = tmp_path / "desired-repo"
|
||||||
|
desired_repository.mkdir()
|
||||||
|
data_directory = current_repository / ".runtime" / "mission-core"
|
||||||
|
data_directory.mkdir(parents=True, mode=0o700)
|
||||||
|
uv_entrypoint = tmp_path / "uv"
|
||||||
|
uv_entrypoint.write_text("#!/bin/sh\n")
|
||||||
|
uv_entrypoint.chmod(0o700)
|
||||||
|
agent = tmp_path / "agent.plist"
|
||||||
|
agent.write_bytes(
|
||||||
|
plistlib.dumps(
|
||||||
|
{
|
||||||
|
"Label": "com.nodedc.mission-core.local",
|
||||||
|
"ProgramArguments": [str(uv_entrypoint), "run", "k1link", "serve"],
|
||||||
|
"WorkingDirectory": str(current_repository),
|
||||||
|
"EnvironmentVariables": {"PATH": "/usr/bin:/bin"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
agent.chmod(0o600)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
sys,
|
||||||
|
"argv",
|
||||||
|
[
|
||||||
|
str(_SCRIPT),
|
||||||
|
"plan",
|
||||||
|
"--repository-root",
|
||||||
|
str(desired_repository),
|
||||||
|
"--expected-current-repository-root",
|
||||||
|
str(current_repository),
|
||||||
|
"--agent-path",
|
||||||
|
str(agent),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert manager.main() == 0
|
||||||
|
|
||||||
|
document = json.loads(capsys.readouterr().out)
|
||||||
|
assert document["current_working_directory"] == str(current_repository)
|
||||||
|
assert document["desired_working_directory"] == str(desired_repository)
|
||||||
|
assert document["preserved_data_directory"] == str(data_directory)
|
||||||
|
assert document["changes"]["repository_migration"] is True
|
||||||
|
assert document["changes"]["data_directory_preserved"] is True
|
||||||
|
|
||||||
|
|
||||||
def test_reload_waits_for_launchd_transition_before_bootstrap(
|
def test_reload_waits_for_launchd_transition_before_bootstrap(
|
||||||
monkeypatch: Any,
|
monkeypatch: Any,
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
|
|||||||
Reference in New Issue
Block a user