slopbox-env 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- slopbox_env-0.1.0/PKG-INFO +36 -0
- slopbox_env-0.1.0/README.md +27 -0
- slopbox_env-0.1.0/pyproject.toml +38 -0
- slopbox_env-0.1.0/pyproject.toml.orig +40 -0
- slopbox_env-0.1.0/src/slopbox/__init__.py +0 -0
- slopbox_env-0.1.0/src/slopbox/config.py +91 -0
- slopbox_env-0.1.0/src/slopbox/const.py +10 -0
- slopbox_env-0.1.0/src/slopbox/container_engine.py +75 -0
- slopbox_env-0.1.0/src/slopbox/environment.py +24 -0
- slopbox_env-0.1.0/src/slopbox/main.py +341 -0
- slopbox_env-0.1.0/src/slopbox/nix/build-container-loader.nix +12 -0
- slopbox_env-0.1.0/src/slopbox/nix/build-container-runner.nix +11 -0
- slopbox_env-0.1.0/src/slopbox/nix/lib/build.nix +170 -0
- slopbox_env-0.1.0/src/slopbox/nix/lib/default.nix +20 -0
- slopbox_env-0.1.0/src/slopbox/nix/lib/eval.nix +36 -0
- slopbox_env-0.1.0/src/slopbox/nix/lib/minimal.nix +21 -0
- slopbox_env-0.1.0/src/slopbox/nix/lib/run.nix +137 -0
- slopbox_env-0.1.0/src/slopbox/nix/lib/schema.nix +103 -0
- slopbox_env-0.1.0/src/slopbox/nix/profile-info.nix +40 -0
- slopbox_env-0.1.0/src/slopbox/nix.py +213 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: slopbox-env
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A secure-ish environment for running AI agents.
|
|
5
|
+
Requires-Dist: click>=8.4.2
|
|
6
|
+
Requires-Dist: pydantic>=2.13.4
|
|
7
|
+
Requires-Python: >=3.14
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
|
|
10
|
+
# Slopbox
|
|
11
|
+
**A secure-ish environment for running AI agents.**
|
|
12
|
+
|
|
13
|
+
The goal of this project is to make it very easy to _set up
|
|
14
|
+
an isolated runtime_ (container or virtual machine) for an AI agent.
|
|
15
|
+
|
|
16
|
+
What you can expect:
|
|
17
|
+
* intuitive configuration with sane defaults
|
|
18
|
+
* principle of least privilege
|
|
19
|
+
* include only the packages that are neccessary for the agent to operate
|
|
20
|
+
* restrict agent's network access through a customizable proxy
|
|
21
|
+
* grant access only to a subset of directories
|
|
22
|
+
* just works™
|
|
23
|
+
|
|
24
|
+
**DISCLAIMER**: this project is on a very early stage of development -- things will break, things will change.
|
|
25
|
+
|
|
26
|
+
## Requirements
|
|
27
|
+
- Nix
|
|
28
|
+
- Podman or Docker
|
|
29
|
+
- rootless mode required
|
|
30
|
+
- guide for docker on NixOS: https://wiki.nixos.org/wiki/Docker#Rootless_Docker
|
|
31
|
+
|
|
32
|
+
## Docs
|
|
33
|
+
No documentation is available at this point.
|
|
34
|
+
|
|
35
|
+
## What's next
|
|
36
|
+
See the list of [planned features](/issues?q=is%3Aissue+state%3Aopen+label%3Aenhancement+author%3AjkbSeven)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Slopbox
|
|
2
|
+
**A secure-ish environment for running AI agents.**
|
|
3
|
+
|
|
4
|
+
The goal of this project is to make it very easy to _set up
|
|
5
|
+
an isolated runtime_ (container or virtual machine) for an AI agent.
|
|
6
|
+
|
|
7
|
+
What you can expect:
|
|
8
|
+
* intuitive configuration with sane defaults
|
|
9
|
+
* principle of least privilege
|
|
10
|
+
* include only the packages that are neccessary for the agent to operate
|
|
11
|
+
* restrict agent's network access through a customizable proxy
|
|
12
|
+
* grant access only to a subset of directories
|
|
13
|
+
* just works™
|
|
14
|
+
|
|
15
|
+
**DISCLAIMER**: this project is on a very early stage of development -- things will break, things will change.
|
|
16
|
+
|
|
17
|
+
## Requirements
|
|
18
|
+
- Nix
|
|
19
|
+
- Podman or Docker
|
|
20
|
+
- rootless mode required
|
|
21
|
+
- guide for docker on NixOS: https://wiki.nixos.org/wiki/Docker#Rootless_Docker
|
|
22
|
+
|
|
23
|
+
## Docs
|
|
24
|
+
No documentation is available at this point.
|
|
25
|
+
|
|
26
|
+
## What's next
|
|
27
|
+
See the list of [planned features](/issues?q=is%3Aissue+state%3Aopen+label%3Aenhancement+author%3AjkbSeven)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "slopbox-env"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A secure-ish environment for running AI agents."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.14"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"click>=8.4.2",
|
|
9
|
+
"pydantic>=2.13.4",
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
[project.scripts]
|
|
13
|
+
slopbox = "slopbox.main:main"
|
|
14
|
+
|
|
15
|
+
[dependency-groups]
|
|
16
|
+
dev = ["pytest>=9.1.1"]
|
|
17
|
+
|
|
18
|
+
[build-system]
|
|
19
|
+
requires = ["uv_build>=0.11.22,<0.12"]
|
|
20
|
+
build-backend = "uv_build"
|
|
21
|
+
|
|
22
|
+
[tool.uv.build-backend]
|
|
23
|
+
module-name = "slopbox"
|
|
24
|
+
|
|
25
|
+
[tool.ruff]
|
|
26
|
+
line-length = 88
|
|
27
|
+
indent-width = 4
|
|
28
|
+
target-version = "py314"
|
|
29
|
+
|
|
30
|
+
[tool.ruff.lint]
|
|
31
|
+
select = [
|
|
32
|
+
"E",
|
|
33
|
+
"F",
|
|
34
|
+
"I",
|
|
35
|
+
"UP",
|
|
36
|
+
"B",
|
|
37
|
+
"SIM",
|
|
38
|
+
]
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "slopbox-env"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A secure-ish environment for running AI agents."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.14"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"click>=8.4.2",
|
|
9
|
+
"pydantic>=2.13.4",
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
[dependency-groups]
|
|
13
|
+
dev = [
|
|
14
|
+
"pytest>=9.1.1",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[build-system]
|
|
18
|
+
requires = ["uv_build>=0.11.22,<0.12"]
|
|
19
|
+
build-backend = "uv_build"
|
|
20
|
+
|
|
21
|
+
[tool.uv.build-backend]
|
|
22
|
+
module-name = "slopbox"
|
|
23
|
+
|
|
24
|
+
[project.scripts]
|
|
25
|
+
slopbox = "slopbox.main:main"
|
|
26
|
+
|
|
27
|
+
[tool.ruff]
|
|
28
|
+
line-length = 88
|
|
29
|
+
indent-width = 4
|
|
30
|
+
target-version = "py314"
|
|
31
|
+
|
|
32
|
+
[tool.ruff.lint]
|
|
33
|
+
select = [
|
|
34
|
+
"E", # pycodestyle
|
|
35
|
+
"F", # pyflakes
|
|
36
|
+
"I", # isort
|
|
37
|
+
"UP", # pyupgrade
|
|
38
|
+
"B", # flake8 bugbear
|
|
39
|
+
"SIM", # flake8 simplify
|
|
40
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Configuration for slopbox as a CLI tool,
|
|
3
|
+
not for runtime container or vm configuration -- for that use environment module
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import tomllib
|
|
7
|
+
import typing
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import pydantic
|
|
11
|
+
|
|
12
|
+
MAX_CONFIG_BYTES = 4096
|
|
13
|
+
DEFAULT_CONFIG_PATH = Path.home() / ".config" / "slopbox" / "config.toml"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ConfigError(Exception):
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SlopboxPaths(typing.NamedTuple):
|
|
21
|
+
env: Path
|
|
22
|
+
lock: Path
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Config(pydantic.BaseModel):
|
|
26
|
+
slopbox_env_dir: Path = pydantic.Field(
|
|
27
|
+
default_factory=lambda: Path.home() / ".config" / "slopbox"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
model_config = pydantic.ConfigDict(
|
|
31
|
+
extra="forbid",
|
|
32
|
+
validate_assignment=True,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def slopbox_paths(self) -> SlopboxPaths:
|
|
37
|
+
return SlopboxPaths(
|
|
38
|
+
env=self.slopbox_env_dir / "slopbox.nix",
|
|
39
|
+
lock=self.slopbox_env_dir / "slopbox.lock",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
@pydantic.field_validator("slopbox_env_dir", mode="after")
|
|
43
|
+
@classmethod
|
|
44
|
+
def __expand_slopbox_env_dir(cls, v: Path) -> Path:
|
|
45
|
+
return v.expanduser().resolve()
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def load(cls, config_path: Path | None = None) -> Config:
|
|
49
|
+
if config_path is None:
|
|
50
|
+
config_path = DEFAULT_CONFIG_PATH
|
|
51
|
+
|
|
52
|
+
if not config_path.is_file():
|
|
53
|
+
return cls()
|
|
54
|
+
|
|
55
|
+
filesize = config_path.lstat().st_size
|
|
56
|
+
|
|
57
|
+
if filesize > MAX_CONFIG_BYTES:
|
|
58
|
+
raise ConfigError(
|
|
59
|
+
f"config file {config_path} has size of {filesize} bytes, "
|
|
60
|
+
f"maximum allowed size is {MAX_CONFIG_BYTES} bytes"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
with config_path.open(mode="rb") as fp:
|
|
64
|
+
try:
|
|
65
|
+
toml_data = tomllib.load(fp)
|
|
66
|
+
except tomllib.TOMLDecodeError as err:
|
|
67
|
+
raise ConfigError(f"failed to load TOML file: {err}") from err
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
c = cls.model_validate(toml_data)
|
|
71
|
+
|
|
72
|
+
except pydantic.ValidationError as err:
|
|
73
|
+
errors = err.errors(include_url=False)
|
|
74
|
+
errors_pretty = map(
|
|
75
|
+
lambda e: str({"field": e["loc"][0], "error": e["msg"]}), errors
|
|
76
|
+
)
|
|
77
|
+
raise ConfigError(
|
|
78
|
+
f"invalid configuration:\n{'\n'.join(errors_pretty)}"
|
|
79
|
+
) from err
|
|
80
|
+
|
|
81
|
+
except Exception as err:
|
|
82
|
+
raise ConfigError(f"invalid configuration: {err}") from err
|
|
83
|
+
|
|
84
|
+
return c
|
|
85
|
+
|
|
86
|
+
def pretty_print(self) -> str:
|
|
87
|
+
# assumes flat config struct
|
|
88
|
+
return "\n".join([f"{k} = {v}" for k, v in self])
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
load = Config.load
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import shutil
|
|
2
|
+
import subprocess
|
|
3
|
+
from typing import Protocol
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ContainerEngineError(Exception):
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ContainerEngine(Protocol):
|
|
11
|
+
name: str
|
|
12
|
+
|
|
13
|
+
@classmethod
|
|
14
|
+
def is_available(cls) -> bool: ...
|
|
15
|
+
|
|
16
|
+
@classmethod
|
|
17
|
+
def is_rootless(cls) -> bool: ...
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Docker:
|
|
21
|
+
name = "docker"
|
|
22
|
+
|
|
23
|
+
@classmethod
|
|
24
|
+
def is_available(cls) -> bool:
|
|
25
|
+
return shutil.which("docker") is not None
|
|
26
|
+
|
|
27
|
+
@classmethod
|
|
28
|
+
def is_rootless(cls) -> bool:
|
|
29
|
+
if not cls.is_available():
|
|
30
|
+
raise ContainerEngineError("docker is not installed")
|
|
31
|
+
|
|
32
|
+
ret = subprocess.run(
|
|
33
|
+
["docker", "info", "--format", "{{ .SecurityOptions }}"],
|
|
34
|
+
capture_output=True,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
if ret.returncode != 0:
|
|
38
|
+
raise ContainerEngineError(
|
|
39
|
+
"error while checking if docker is rootless: "
|
|
40
|
+
"command 'docker info' failed: "
|
|
41
|
+
f"{ret.stderr.decode(encoding='utf-8')}"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
clean = ret.stdout.decode(encoding="utf-8").strip("[]")
|
|
45
|
+
|
|
46
|
+
return "name=rootless" in clean
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Podman:
|
|
50
|
+
name = "podman"
|
|
51
|
+
|
|
52
|
+
@classmethod
|
|
53
|
+
def is_available(cls) -> bool:
|
|
54
|
+
return shutil.which("podman") is not None
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def is_rootless(cls) -> bool:
|
|
58
|
+
if not cls.is_available():
|
|
59
|
+
raise ContainerEngineError("podman is not installed")
|
|
60
|
+
|
|
61
|
+
# TODO: check if having non-rootless podman is even possible
|
|
62
|
+
return True
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def get_container_engine() -> ContainerEngine:
|
|
66
|
+
# in case user has both, we prefer podman
|
|
67
|
+
if Podman.is_available():
|
|
68
|
+
return Podman()
|
|
69
|
+
|
|
70
|
+
if Docker.is_available():
|
|
71
|
+
return Docker()
|
|
72
|
+
|
|
73
|
+
raise ContainerEngineError(
|
|
74
|
+
"neither podman nor docker is installed (not found in $PATH)"
|
|
75
|
+
)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
EXAMPLE_ENV_CONFIG = """{
|
|
2
|
+
pkgs,
|
|
3
|
+
...
|
|
4
|
+
}:
|
|
5
|
+
{
|
|
6
|
+
default = {
|
|
7
|
+
agent = pkgs.opencode;
|
|
8
|
+
workspace = "/workspace";
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
profiles = {
|
|
12
|
+
default = {
|
|
13
|
+
pkgs = [
|
|
14
|
+
pkgs.python314
|
|
15
|
+
pkgs.uv
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
runHook = "echo 'Hello, world!'";
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
schemaVersion = 1;
|
|
23
|
+
}
|
|
24
|
+
"""
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import subprocess
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from slopbox import config, const, container_engine, environment
|
|
8
|
+
from slopbox.nix import Nix, NixError, NixFetchTreeResult
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class fmt:
|
|
12
|
+
esc = "\x1b"
|
|
13
|
+
checkmark = "\u2713"
|
|
14
|
+
|
|
15
|
+
@staticmethod
|
|
16
|
+
def red(msg: str, **kwargs) -> str:
|
|
17
|
+
return click.style(msg, fg="red", **kwargs)
|
|
18
|
+
|
|
19
|
+
@staticmethod
|
|
20
|
+
def green(msg: str, **kwargs) -> str:
|
|
21
|
+
return click.style(msg, fg="green", **kwargs)
|
|
22
|
+
|
|
23
|
+
@staticmethod
|
|
24
|
+
def up(count: int, *, goto_beg: bool) -> str:
|
|
25
|
+
return f"{fmt.esc}[{count}A" + ("\r" if goto_beg else "")
|
|
26
|
+
|
|
27
|
+
@staticmethod
|
|
28
|
+
def clear_line() -> str:
|
|
29
|
+
return f"{fmt.esc}[2K"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _init_config_dir(config_dir: Path):
|
|
33
|
+
try:
|
|
34
|
+
config_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
35
|
+
except (OSError, FileNotFoundError) as err:
|
|
36
|
+
raise click.ClickException(
|
|
37
|
+
f"unable to create or read the config directory ({config_dir}): {err}"
|
|
38
|
+
) from err
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _err_if_unhealthy() -> None:
|
|
42
|
+
if not Nix.is_available():
|
|
43
|
+
raise click.ClickException(
|
|
44
|
+
"You have to install Nix prior to using slopbox; "
|
|
45
|
+
"Nix main page: https://nixos.org/"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
cengine = container_engine.get_container_engine()
|
|
50
|
+
|
|
51
|
+
except container_engine.ContainerEngineError as err:
|
|
52
|
+
raise click.ClickException(
|
|
53
|
+
f"unable to resolve container engine: {err}"
|
|
54
|
+
) from err
|
|
55
|
+
|
|
56
|
+
if not cengine.is_rootless():
|
|
57
|
+
raise click.ClickException(
|
|
58
|
+
f"{cengine.name} is not running in rootless mode, "
|
|
59
|
+
"this has security implications and slopbox cannot proceed"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@click.group()
|
|
64
|
+
@click.version_option(version=const.VERSION, message="%(version)s")
|
|
65
|
+
@click.option(
|
|
66
|
+
"--config",
|
|
67
|
+
type=click.Path(exists=True, dir_okay=False, path_type=Path),
|
|
68
|
+
help="Path to a config file (.toml)",
|
|
69
|
+
)
|
|
70
|
+
@click.pass_context
|
|
71
|
+
def cli(ctx: click.Context, config: Path | None):
|
|
72
|
+
"""secure-ish environment for running AI agents"""
|
|
73
|
+
ctx.ensure_object(dict)
|
|
74
|
+
ctx.obj["config"] = config
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@cli.command("health")
|
|
78
|
+
def cli_health():
|
|
79
|
+
"""validate runtime health"""
|
|
80
|
+
nix_status: str
|
|
81
|
+
if not Nix.is_available():
|
|
82
|
+
nix_status = f"Nix: {fmt.red('NOT INSTALLED', bold=True)}"
|
|
83
|
+
else:
|
|
84
|
+
nix_status = f"Nix: {fmt.green('OK', bold=True)}"
|
|
85
|
+
click.echo(nix_status)
|
|
86
|
+
|
|
87
|
+
cengine_status: str
|
|
88
|
+
try:
|
|
89
|
+
cengine = container_engine.get_container_engine()
|
|
90
|
+
except container_engine.ContainerEngineError as err:
|
|
91
|
+
cengine_status = (
|
|
92
|
+
f"Container engine: {fmt.red('MISSING', bold=True)} (err: {err})"
|
|
93
|
+
)
|
|
94
|
+
else:
|
|
95
|
+
rootless_status = (
|
|
96
|
+
"(rootless)" if cengine.is_rootless() else fmt.red("(non-rootless)")
|
|
97
|
+
)
|
|
98
|
+
cengine_status = (
|
|
99
|
+
f"Container engine: {fmt.green(cengine.name, bold=True)} {rootless_status}"
|
|
100
|
+
)
|
|
101
|
+
click.echo(cengine_status)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@cli.command("build")
|
|
105
|
+
@click.option("--profile", "-p", default="default")
|
|
106
|
+
@click.option(
|
|
107
|
+
"--runtime",
|
|
108
|
+
"-r",
|
|
109
|
+
default="container",
|
|
110
|
+
type=click.Choice(["container", "vm"]),
|
|
111
|
+
)
|
|
112
|
+
@click.pass_context
|
|
113
|
+
def cli_build(ctx: click.Context, profile: str, runtime: str):
|
|
114
|
+
"""build profile"""
|
|
115
|
+
if runtime != "container":
|
|
116
|
+
raise click.ClickException(f"'{runtime}' runtime is not supported yet")
|
|
117
|
+
|
|
118
|
+
_err_if_unhealthy()
|
|
119
|
+
|
|
120
|
+
c = config.load(ctx.obj["config"])
|
|
121
|
+
|
|
122
|
+
env_file, lock_file = c.slopbox_paths
|
|
123
|
+
|
|
124
|
+
if not lock_file.exists():
|
|
125
|
+
raise click.ClickException(
|
|
126
|
+
f"You haven't locked slopbox dependencies: {lock_file} does not exist. "
|
|
127
|
+
"Run `slopbox init` first, then rerun this command"
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
if not env_file.exists():
|
|
131
|
+
raise click.ClickException(
|
|
132
|
+
"You haven't created a slopbox environment: "
|
|
133
|
+
f"{env_file} does not exist. "
|
|
134
|
+
"Run `slopbox init`, adjust the environment to your needs, "
|
|
135
|
+
"then rerun this command"
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
lock = NixFetchTreeResult.model_validate_json(lock_file.read_text(encoding="utf-8"))
|
|
139
|
+
nixpkgs_url = lock.fetch_tree_url()
|
|
140
|
+
|
|
141
|
+
click.echo(f"Building profile '{profile}':")
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
profile_data = Nix.profile_data(str(env_file), nixpkgs_url, profile)
|
|
145
|
+
except NixError as err:
|
|
146
|
+
raise click.ClickException(
|
|
147
|
+
f"failed to evaluate profile with nix: {err}"
|
|
148
|
+
) from err
|
|
149
|
+
|
|
150
|
+
engine = container_engine.get_container_engine().name
|
|
151
|
+
|
|
152
|
+
def _build(component: str):
|
|
153
|
+
click.echo(f"-> building {component}...")
|
|
154
|
+
try:
|
|
155
|
+
loader_path = Nix.build_container_loader(
|
|
156
|
+
env_path=str(env_file),
|
|
157
|
+
nixpkgs_url=nixpkgs_url,
|
|
158
|
+
profile=profile,
|
|
159
|
+
engine=engine,
|
|
160
|
+
component=component,
|
|
161
|
+
)
|
|
162
|
+
except NixError as err:
|
|
163
|
+
raise click.ClickException(f"build failed: {err}") from err
|
|
164
|
+
|
|
165
|
+
result = subprocess.run(f"{loader_path}/bin/loader", capture_output=True)
|
|
166
|
+
|
|
167
|
+
if result.returncode != 0:
|
|
168
|
+
raise click.ClickException(
|
|
169
|
+
"failed to load the container image: "
|
|
170
|
+
+ result.stderr.decode(encoding="utf-8")
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
click.echo(
|
|
174
|
+
fmt.up(1, goto_beg=True)
|
|
175
|
+
+ fmt.clear_line()
|
|
176
|
+
+ fmt.green(fmt.checkmark)
|
|
177
|
+
+ f" built {component} ({profile_data['containers'][component]['image']})"
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
_build("runtime")
|
|
181
|
+
if profile_data["profile"]["proxy"]["enable"] is True:
|
|
182
|
+
_build("proxy")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@cli.command("init")
|
|
186
|
+
@click.pass_context
|
|
187
|
+
def cli_init(ctx: click.Context):
|
|
188
|
+
"""initialize slopbox environment (user-wide)"""
|
|
189
|
+
if not Nix.is_available():
|
|
190
|
+
raise click.ClickException(
|
|
191
|
+
"You have to install Nix prior to using slopbox; "
|
|
192
|
+
"Nix main page: https://nixos.org/"
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
c = config.load(ctx.obj["config"])
|
|
196
|
+
|
|
197
|
+
_init_config_dir(c.slopbox_env_dir)
|
|
198
|
+
|
|
199
|
+
env_file, lock_file = c.slopbox_paths
|
|
200
|
+
|
|
201
|
+
if not lock_file.exists():
|
|
202
|
+
try:
|
|
203
|
+
nixpkgs_tree = Nix.fetch_tree(const.NIXPKGS_FETCH_URL)
|
|
204
|
+
except NixError as err:
|
|
205
|
+
raise click.ClickException(
|
|
206
|
+
f"failed to fetch and pin nixpkgs revision: {err}"
|
|
207
|
+
) from err
|
|
208
|
+
|
|
209
|
+
lock_file.write_text(
|
|
210
|
+
nixpkgs_tree.model_dump_json(indent=2, by_alias=True),
|
|
211
|
+
encoding="utf-8",
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
if not env_file.exists():
|
|
215
|
+
env_file.write_text(environment.EXAMPLE_ENV_CONFIG, encoding="utf-8")
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@cli.command("profile")
|
|
219
|
+
@click.argument("profile", default="default")
|
|
220
|
+
@click.pass_context
|
|
221
|
+
def cli_profile(ctx: click.Context, profile: str):
|
|
222
|
+
"""print profile data and build metadata"""
|
|
223
|
+
_err_if_unhealthy()
|
|
224
|
+
|
|
225
|
+
c = config.load(ctx.obj["config"])
|
|
226
|
+
|
|
227
|
+
env_file, lock_file = c.slopbox_paths
|
|
228
|
+
|
|
229
|
+
if not lock_file.exists():
|
|
230
|
+
raise click.ClickException(
|
|
231
|
+
f"You haven't locked slopbox dependencies: {lock_file} does not exist. "
|
|
232
|
+
"Run `slopbox init` first, then rerun this command"
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
if not env_file.exists():
|
|
236
|
+
raise click.ClickException(
|
|
237
|
+
"You haven't created a slopbox environment: "
|
|
238
|
+
f"{env_file} does not exist. "
|
|
239
|
+
"Run `slopbox init`, adjust the environment to your needs, "
|
|
240
|
+
"then rerun this command"
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
lock = NixFetchTreeResult.model_validate_json(lock_file.read_text(encoding="utf-8"))
|
|
244
|
+
try:
|
|
245
|
+
profile_data = Nix.profile_data(str(env_file), lock.fetch_tree_url(), profile)
|
|
246
|
+
except NixError as err:
|
|
247
|
+
raise click.ClickException(
|
|
248
|
+
f"failed to evaluate profile with nix: {err}"
|
|
249
|
+
) from err
|
|
250
|
+
|
|
251
|
+
click.echo(json.dumps(profile_data, indent=2))
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
@cli.command("run")
|
|
255
|
+
@click.option("--profile", "-p", default="default")
|
|
256
|
+
@click.option(
|
|
257
|
+
"--runtime",
|
|
258
|
+
"-r",
|
|
259
|
+
default="container",
|
|
260
|
+
type=click.Choice(["container", "vm"]),
|
|
261
|
+
)
|
|
262
|
+
@click.pass_context
|
|
263
|
+
def cli_run(ctx: click.Context, profile: str, runtime: str):
|
|
264
|
+
"""run profile"""
|
|
265
|
+
if runtime != "container":
|
|
266
|
+
raise click.ClickException(f"'{runtime}' runtime is not supported yet")
|
|
267
|
+
|
|
268
|
+
_err_if_unhealthy()
|
|
269
|
+
|
|
270
|
+
c = config.load(ctx.obj["config"])
|
|
271
|
+
env_file, lock_file = c.slopbox_paths
|
|
272
|
+
|
|
273
|
+
if not lock_file.exists():
|
|
274
|
+
raise click.ClickException(
|
|
275
|
+
f"You haven't locked slopbox dependencies: {lock_file} does not exist. "
|
|
276
|
+
"Run `slopbox init` first, then rerun this command"
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
if not env_file.exists():
|
|
280
|
+
raise click.ClickException(
|
|
281
|
+
"You haven't created a slopbox environment: "
|
|
282
|
+
f"{env_file} does not exist. "
|
|
283
|
+
"Run `slopbox init`, adjust the environment to your needs, "
|
|
284
|
+
"then rerun this command"
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
lock = NixFetchTreeResult.model_validate_json(lock_file.read_text(encoding="utf-8"))
|
|
288
|
+
nixpkgs_url = lock.fetch_tree_url()
|
|
289
|
+
engine = container_engine.get_container_engine().name
|
|
290
|
+
|
|
291
|
+
runner_path = Nix.build_container_runner(
|
|
292
|
+
str(env_file), nixpkgs_url, profile, engine
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
result = subprocess.run(f"{runner_path}/bin/run")
|
|
296
|
+
|
|
297
|
+
if result.returncode != 0:
|
|
298
|
+
raise click.ClickException(
|
|
299
|
+
f"error during runtime, please see the {engine} logs above"
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
@cli.group("config")
|
|
304
|
+
def cli_config():
|
|
305
|
+
"""manage tool configuration (slopbox CLI)"""
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
@cli_config.command("show")
|
|
309
|
+
@click.pass_context
|
|
310
|
+
def cli_config_show(ctx: click.Context):
|
|
311
|
+
"""show resolved tool configuration"""
|
|
312
|
+
try:
|
|
313
|
+
c = config.load(ctx.obj["config"])
|
|
314
|
+
|
|
315
|
+
except config.ConfigError as err:
|
|
316
|
+
raise click.ClickException(f"error while loading tool config: {err}") from err
|
|
317
|
+
|
|
318
|
+
click.echo(c.pretty_print())
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
@cli_config.command("edit")
|
|
322
|
+
@click.pass_context
|
|
323
|
+
def cli_config_edit(ctx: click.Context):
|
|
324
|
+
"""edit tool configuration in your text editor ($EDITOR)"""
|
|
325
|
+
p = ctx.obj["config"] or config.DEFAULT_CONFIG_PATH
|
|
326
|
+
p.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
327
|
+
|
|
328
|
+
if p.exists() and not p.is_file():
|
|
329
|
+
raise click.ClickException(
|
|
330
|
+
f"config path exists but does not point to a file: {p}"
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
click.edit(filename=str(p))
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def main():
|
|
337
|
+
cli()
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
if __name__ == "__main__":
|
|
341
|
+
main()
|