slopbox-env 0.1.0__py3-none-any.whl

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/__init__.py ADDED
File without changes
slopbox/config.py ADDED
@@ -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
slopbox/const.py ADDED
@@ -0,0 +1,10 @@
1
+ from pathlib import Path
2
+
3
+ VERSION = "0.1.0"
4
+
5
+ PKG_ROOT = Path(__file__).parent.absolute()
6
+
7
+ NIX_ROOT = PKG_ROOT / "nix"
8
+ NIX_LIB_PATH = NIX_ROOT / "lib"
9
+
10
+ NIXPKGS_FETCH_URL = "github:nixos/nixpkgs/nixos-unstable"
@@ -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
+ )
slopbox/environment.py ADDED
@@ -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
+ """
slopbox/main.py ADDED
@@ -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()
@@ -0,0 +1,12 @@
1
+ {
2
+ nixpkgsUrl,
3
+ slopboxEnvPath,
4
+ profile,
5
+ engine,
6
+ component,
7
+ }:
8
+ let
9
+ lib = import ./lib;
10
+ cfg = (lib.config.eval { inherit nixpkgsUrl slopboxEnvPath; }).config;
11
+ in
12
+ cfg.profiles.${profile}.build.${component}.container-loader.${engine}
@@ -0,0 +1,11 @@
1
+ {
2
+ nixpkgsUrl,
3
+ slopboxEnvPath,
4
+ profile,
5
+ engine,
6
+ }:
7
+ let
8
+ lib = import ./lib;
9
+ cfg = (lib.config.eval { inherit nixpkgsUrl slopboxEnvPath; }).config;
10
+ in
11
+ cfg.profiles.${profile}.run.container.${engine}