Skip to content

Commit

Permalink
feature: Detect ecosystem mismatches (#45)
Browse files Browse the repository at this point in the history
Ecosystem language and version is added to both lock file manifests and
to environment state files. This allows detecting when configuration is
changed and there are mismatches between either state and manifest, or
between config and state.

When a mismatch is discovered, the existing environment is deleted and
rebuilt. When a mismatch between manifest and config is discovered, this
requires running `goose upgrade`.

This breaks existing configurations, and requires rebuilding
configuration as well as manually deleting existing environments.
  • Loading branch information
antonagestam authored Oct 19, 2024
1 parent aaf27f8 commit 97adecf
Show file tree
Hide file tree
Showing 8 changed files with 61 additions and 18 deletions.
2 changes: 1 addition & 1 deletion .goose/node/manifest.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"source_dependencies":["prettier"],"lock_files":[{"path":"package-lock.json","checksum":"sha256:c72f3206d345d7285c175a9363dc4d7efbb56729dd02e4828c9356ecfaaad0ca"},{"path":"package.json","checksum":"sha256:c5008cb00d6009f6d901cfe35523169a3deb91979b71fce65d6ac79fdace548b"}],"checksum":"sha256:e130609d6f3cb3681a612f936c5fac5c409019253d18071772d86949766d0bbb"}
{"source_ecosystem":{"language":"node","version":"23.0.0"},"source_dependencies":["prettier"],"lock_files":[{"path":"package-lock.json","checksum":"sha256:c72f3206d345d7285c175a9363dc4d7efbb56729dd02e4828c9356ecfaaad0ca"},{"path":"package.json","checksum":"sha256:c5008cb00d6009f6d901cfe35523169a3deb91979b71fce65d6ac79fdace548b"}],"checksum":"sha256:e130609d6f3cb3681a612f936c5fac5c409019253d18071772d86949766d0bbb"}
2 changes: 1 addition & 1 deletion .goose/python/manifest.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"source_dependencies":["pre-commit-hooks","ruff"],"lock_files":[{"path":"requirements.txt","checksum":"sha256:f649a1f943c1cfbd71571de02aedcf7d88e7427c0e985ec26f399d8c40970c8e"}],"checksum":"sha256:2087aaf2b95093aae8f2cfef2b6a55aaddd9e94d1814b268ea2fd699f0a10f11"}
{"source_ecosystem":{"language":"python","version":"3.13"},"source_dependencies":["pre-commit-hooks","ruff"],"lock_files":[{"path":"requirements.txt","checksum":"sha256:f649a1f943c1cfbd71571de02aedcf7d88e7427c0e985ec26f399d8c40970c8e"}],"checksum":"sha256:2087aaf2b95093aae8f2cfef2b6a55aaddd9e94d1814b268ea2fd699f0a10f11"}
2 changes: 1 addition & 1 deletion .goose/type-check/manifest.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"source_dependencies":["identify==2.6.0","mypy","pydantic==2.8.2","rich==13.7.1","typer==0.12.3","types-pyyaml==6.0.12.20240724"],"lock_files":[{"path":"requirements.txt","checksum":"sha256:30a32dcd426ab99471b1d43f5a575db3d8df2b5b64ce3dc756ce730f7f0b388c"}],"checksum":"sha256:40b81307cd69b710976f92453cdf92ef743fdd9882eabea9c6b6b6688779a516"}
{"source_ecosystem":{"language":"python","version":"3.13"},"source_dependencies":["identify==2.6.0","mypy","pydantic==2.8.2","rich==13.7.1","typer==0.12.3","types-pyyaml==6.0.12.20240724"],"lock_files":[{"path":"requirements.txt","checksum":"sha256:30a32dcd426ab99471b1d43f5a575db3d8df2b5b64ce3dc756ce730f7f0b388c"}],"checksum":"sha256:40b81307cd69b710976f92453cdf92ef743fdd9882eabea9c6b6b6688779a516"}
1 change: 1 addition & 0 deletions src/goose/backend/node.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ async def freeze(

package_lock_json_path = lock_files_path / "package-lock.json"
manifest = build_manifest(
source_ecosystem=config.ecosystem,
source_dependencies=config.dependencies,
lock_files=(
package_json_path,
Expand Down
1 change: 1 addition & 0 deletions src/goose/backend/python.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ async def freeze(
raise RuntimeError("Failed freezing dependencies {process.returncode=}")

manifest = build_manifest(
source_ecosystem=config.ecosystem,
source_dependencies=config.dependencies,
lock_files=(requirements_txt,),
lock_files_path=lock_files_path,
Expand Down
54 changes: 45 additions & 9 deletions src/goose/environment.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import asyncio
import enum
import os
import shutil
import sys
from collections.abc import Mapping
from pathlib import Path
Expand All @@ -15,6 +17,7 @@
from .backend.base import RunResult
from .backend.index import load_backend
from .config import Config
from .config import EcosystemConfig
from .config import EnvironmentConfig
from .config import EnvironmentId
from .executable_unit import ExecutableUnit
Expand All @@ -27,7 +30,6 @@ class NeedsFreeze(Exception): ...


class InitialStage(enum.Enum):
new = "new"
bootstrapped = "bootstrapped"
frozen = "frozen"

Expand All @@ -39,23 +41,30 @@ class SyncedStage(enum.Enum):
class SyncedState(BaseModel):
stage: SyncedStage
checksum: str
ecosystem: EcosystemConfig


class InitialState(BaseModel):
stage: InitialStage
ecosystem: EcosystemConfig


_PersistedState = RootModel[InitialState | SyncedState]
class UninitializedState: ...


def read_state(env_dir: Path) -> InitialState | SyncedState:
type State = SyncedState | InitialState | UninitializedState

_PersistedState = RootModel[SyncedState | InitialState]


def read_state(env_dir: Path) -> State:
state_file = env_dir / "goose-state.json"
if not state_file.exists():
return InitialState(stage=InitialStage.new)
return UninitializedState()
return _PersistedState.model_validate_json(state_file.read_bytes()).root


def write_state(env_dir: Path, state: InitialState | SyncedState) -> None:
def write_state(env_dir: Path, state: SyncedState | InitialState) -> None:
state_file = env_dir / "goose-state.json"
state_file.write_text(state.model_dump_json())

Expand All @@ -67,7 +76,7 @@ def __init__(
config: EnvironmentConfig,
path: Path,
lock_files_path: Path,
discovered_state: InitialState | SyncedState,
discovered_state: State,
) -> None:
self.config: Final = config
self._backend: Final = load_backend(config.ecosystem)
Expand All @@ -80,8 +89,13 @@ def __init__(
def __repr__(self) -> str:
return f"Environment(id={self.config.id}, ecosystem={self._backend.ecosystem})"

def check_should_teardown(self) -> bool:
if isinstance(self.state, UninitializedState):
return False
return self.config.ecosystem != self.state.ecosystem

def check_should_bootstrap(self) -> bool:
if self.state.stage is InitialStage.new:
if isinstance(self.state, UninitializedState):
return True
if not self._path.exists():
print("State mismatch: environment does not exist")
Expand Down Expand Up @@ -153,12 +167,19 @@ def check_should_sync(self) -> bool:
else:
assert_never(state)

async def teardown(self) -> None:
await asyncio.to_thread(shutil.rmtree, self._path)
self.state = UninitializedState()

async def bootstrap(self) -> None:
await self._backend.bootstrap(
env_path=self._path,
config=self.config,
)
self.state = InitialState(stage=InitialStage.bootstrapped)
self.state = InitialState(
stage=InitialStage.bootstrapped,
ecosystem=self.config.ecosystem,
)
write_state(self._path, self.state)

async def freeze(self) -> None:
Expand All @@ -167,7 +188,10 @@ async def freeze(self) -> None:
config=self.config,
lock_files_path=self.lock_files_path,
)
self.state = InitialState(stage=InitialStage.frozen)
self.state = InitialState(
stage=InitialStage.frozen,
ecosystem=self.config.ecosystem,
)
write_state(self._path, self.state)
os.sync()

Expand All @@ -181,6 +205,7 @@ async def sync(self) -> None:
self.state = SyncedState(
stage=SyncedStage.synced,
checksum=manifest.checksum,
ecosystem=self.config.ecosystem,
)
write_state(self._path, self.state)

Expand Down Expand Up @@ -228,6 +253,17 @@ async def prepare_environment(
) -> None:
log_prefix = f"[{environment.config.id}] "

if environment.check_should_teardown():
print(
f"{log_prefix}Environment needs rebuilding, tearing down ...",
file=sys.stderr,
)
await environment.teardown()
print(
f"{log_prefix}Environment deleted.",
file=sys.stderr,
)

if environment.check_should_bootstrap():
print(f"{log_prefix}Bootstrapping environment ...", file=sys.stderr)
await environment.bootstrap()
Expand Down
7 changes: 7 additions & 0 deletions src/goose/manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from pydantic import field_validator

from ._utils.pydantic import BaseModel
from .config import EcosystemConfig
from .config import EnvironmentConfig


Expand All @@ -35,6 +36,7 @@ def __lt__(self, other: Self) -> bool:


class LockManifest(BaseModel):
source_ecosystem: EcosystemConfig
source_dependencies: tuple[str, ...]
lock_files: tuple[LockFile, ...]
checksum: str
Expand Down Expand Up @@ -94,6 +96,7 @@ def read_lock_file(lock_files_path: Path, path: Path) -> LockFile:


def build_manifest(
source_ecosystem: EcosystemConfig,
source_dependencies: Iterable[str],
lock_files: Iterable[Path],
lock_files_path: Path,
Expand All @@ -102,6 +105,7 @@ def build_manifest(
sorted(read_lock_file(lock_files_path, path) for path in lock_files)
)
return LockManifest(
source_ecosystem=source_ecosystem,
source_dependencies=tuple(sorted(source_dependencies)),
lock_files=lock_file_instances,
checksum=_get_accumulated_checksum(lock_file_instances),
Expand Down Expand Up @@ -140,6 +144,9 @@ def check_lock_files(
except FileNotFoundError:
return LockFileState.config_manifest_mismatch

if config.ecosystem != manifest.source_ecosystem:
return LockFileState.config_manifest_mismatch

if set(config.dependencies) ^ set(manifest.source_dependencies):
return LockFileState.config_manifest_mismatch

Expand Down
10 changes: 4 additions & 6 deletions src/goose/scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
from itertools import chain
from typing import Final
from typing import NamedTuple
from typing import TypeAlias

from .backend.base import RunResult
from .config import HookConfig
Expand All @@ -26,11 +25,10 @@ class UnitFinished(NamedTuple):
result: RunResult


SchedulerEvent: TypeAlias = UnitScheduled | UnitFinished


SchedulerState: TypeAlias = Mapping[
HookConfig, Mapping[ExecutableUnit, RunResult | asyncio.Task[RunResult] | None]
type SchedulerEvent = UnitScheduled | UnitFinished
type SchedulerState = Mapping[
HookConfig,
Mapping[ExecutableUnit, RunResult | asyncio.Task[RunResult] | None],
]


Expand Down

0 comments on commit 97adecf

Please sign in to comment.