squarepeg 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.
- squarepeg-0.1.0/PKG-INFO +52 -0
- squarepeg-0.1.0/README.md +38 -0
- squarepeg-0.1.0/pyproject.toml +41 -0
- squarepeg-0.1.0/setup.cfg +4 -0
- squarepeg-0.1.0/squarepeg/__init__.py +1 -0
- squarepeg-0.1.0/squarepeg/__main__.py +4 -0
- squarepeg-0.1.0/squarepeg/cli.py +345 -0
- squarepeg-0.1.0/squarepeg/config.py +190 -0
- squarepeg-0.1.0/squarepeg/dockerargs.py +60 -0
- squarepeg-0.1.0/squarepeg/env_interpolation.py +89 -0
- squarepeg-0.1.0/squarepeg/errors.py +34 -0
- squarepeg-0.1.0/squarepeg/k8s/__init__.py +0 -0
- squarepeg-0.1.0/squarepeg/k8s/apierrors.py +80 -0
- squarepeg-0.1.0/squarepeg/k8s/dryrun.py +47 -0
- squarepeg-0.1.0/squarepeg/k8s/events.py +19 -0
- squarepeg-0.1.0/squarepeg/k8s/logs.py +126 -0
- squarepeg-0.1.0/squarepeg/k8s/orphans.py +198 -0
- squarepeg-0.1.0/squarepeg/k8s/podstate.py +93 -0
- squarepeg-0.1.0/squarepeg/k8s/runner.py +233 -0
- squarepeg-0.1.0/squarepeg/k8s/session.py +79 -0
- squarepeg-0.1.0/squarepeg/labels.py +23 -0
- squarepeg-0.1.0/squarepeg/log.py +6 -0
- squarepeg-0.1.0/squarepeg/manifest.py +175 -0
- squarepeg-0.1.0/squarepeg/merge.py +81 -0
- squarepeg-0.1.0/squarepeg/naming.py +55 -0
- squarepeg-0.1.0/squarepeg/quantities.py +58 -0
- squarepeg-0.1.0/squarepeg/runspec.py +41 -0
- squarepeg-0.1.0/squarepeg/volumes.py +112 -0
- squarepeg-0.1.0/squarepeg.egg-info/PKG-INFO +52 -0
- squarepeg-0.1.0/squarepeg.egg-info/SOURCES.txt +50 -0
- squarepeg-0.1.0/squarepeg.egg-info/dependency_links.txt +1 -0
- squarepeg-0.1.0/squarepeg.egg-info/entry_points.txt +2 -0
- squarepeg-0.1.0/squarepeg.egg-info/requires.txt +8 -0
- squarepeg-0.1.0/squarepeg.egg-info/top_level.txt +1 -0
- squarepeg-0.1.0/tests/test_apierrors.py +43 -0
- squarepeg-0.1.0/tests/test_cli.py +283 -0
- squarepeg-0.1.0/tests/test_config.py +422 -0
- squarepeg-0.1.0/tests/test_dockerargs.py +53 -0
- squarepeg-0.1.0/tests/test_dryrun.py +137 -0
- squarepeg-0.1.0/tests/test_env_interpolation.py +137 -0
- squarepeg-0.1.0/tests/test_events.py +49 -0
- squarepeg-0.1.0/tests/test_integration.py +113 -0
- squarepeg-0.1.0/tests/test_logs.py +144 -0
- squarepeg-0.1.0/tests/test_manifest.py +223 -0
- squarepeg-0.1.0/tests/test_merge.py +147 -0
- squarepeg-0.1.0/tests/test_naming.py +47 -0
- squarepeg-0.1.0/tests/test_orphans.py +377 -0
- squarepeg-0.1.0/tests/test_podstate.py +143 -0
- squarepeg-0.1.0/tests/test_quantities.py +55 -0
- squarepeg-0.1.0/tests/test_runner.py +540 -0
- squarepeg-0.1.0/tests/test_session.py +121 -0
- squarepeg-0.1.0/tests/test_volumes.py +142 -0
squarepeg-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: squarepeg
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Run a docker-run-style command as a Kubernetes Pod or Job, streaming its output like a local process
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: click>=8.1
|
|
8
|
+
Requires-Dist: kubernetes>=31
|
|
9
|
+
Requires-Dist: PyYAML>=6
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
12
|
+
Requires-Dist: pytest-cov>=5; extra == "dev"
|
|
13
|
+
Requires-Dist: ruff>=0.4; extra == "dev"
|
|
14
|
+
|
|
15
|
+
# squarepeg
|
|
16
|
+
|
|
17
|
+
Run a `docker run`-style command as a Kubernetes Pod or Job, streaming its
|
|
18
|
+
output like a local process and exiting with the container's exit code.
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
squarepeg run alpine echo "hello from a pod"
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
pip install squarepeg
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Docs
|
|
31
|
+
|
|
32
|
+
Full documentation lives in the [wiki](https://github.com/BioWilko/squarepeg/wiki):
|
|
33
|
+
|
|
34
|
+
- [Getting Started](https://github.com/BioWilko/squarepeg/wiki/Getting-Started)
|
|
35
|
+
- [CLI Reference](https://github.com/BioWilko/squarepeg/wiki/CLI-Reference)
|
|
36
|
+
- [Configuration](https://github.com/BioWilko/squarepeg/wiki/Configuration)
|
|
37
|
+
- [Environment Variable Interpolation](https://github.com/BioWilko/squarepeg/wiki/Environment-Variable-Interpolation)
|
|
38
|
+
- [Docker Compatibility](https://github.com/BioWilko/squarepeg/wiki/Docker-Compatibility)
|
|
39
|
+
- [Execution Lifecycle](https://github.com/BioWilko/squarepeg/wiki/Execution-Lifecycle)
|
|
40
|
+
- [Troubleshooting](https://github.com/BioWilko/squarepeg/wiki/Troubleshooting)
|
|
41
|
+
- [Development](https://github.com/BioWilko/squarepeg/wiki/Development)
|
|
42
|
+
|
|
43
|
+
See also [`examples/config.yaml`](examples/config.yaml) for a fully worked
|
|
44
|
+
config file.
|
|
45
|
+
|
|
46
|
+
## Development
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
pytest # unit tests
|
|
50
|
+
pytest -m integration # integration tests, requires SQUAREPEG_INTEGRATION=1 and a kind cluster
|
|
51
|
+
ruff check . # lint
|
|
52
|
+
```
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# squarepeg
|
|
2
|
+
|
|
3
|
+
Run a `docker run`-style command as a Kubernetes Pod or Job, streaming its
|
|
4
|
+
output like a local process and exiting with the container's exit code.
|
|
5
|
+
|
|
6
|
+
```
|
|
7
|
+
squarepeg run alpine echo "hello from a pod"
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
pip install squarepeg
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Docs
|
|
17
|
+
|
|
18
|
+
Full documentation lives in the [wiki](https://github.com/BioWilko/squarepeg/wiki):
|
|
19
|
+
|
|
20
|
+
- [Getting Started](https://github.com/BioWilko/squarepeg/wiki/Getting-Started)
|
|
21
|
+
- [CLI Reference](https://github.com/BioWilko/squarepeg/wiki/CLI-Reference)
|
|
22
|
+
- [Configuration](https://github.com/BioWilko/squarepeg/wiki/Configuration)
|
|
23
|
+
- [Environment Variable Interpolation](https://github.com/BioWilko/squarepeg/wiki/Environment-Variable-Interpolation)
|
|
24
|
+
- [Docker Compatibility](https://github.com/BioWilko/squarepeg/wiki/Docker-Compatibility)
|
|
25
|
+
- [Execution Lifecycle](https://github.com/BioWilko/squarepeg/wiki/Execution-Lifecycle)
|
|
26
|
+
- [Troubleshooting](https://github.com/BioWilko/squarepeg/wiki/Troubleshooting)
|
|
27
|
+
- [Development](https://github.com/BioWilko/squarepeg/wiki/Development)
|
|
28
|
+
|
|
29
|
+
See also [`examples/config.yaml`](examples/config.yaml) for a fully worked
|
|
30
|
+
config file.
|
|
31
|
+
|
|
32
|
+
## Development
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
pytest # unit tests
|
|
36
|
+
pytest -m integration # integration tests, requires SQUAREPEG_INTEGRATION=1 and a kind cluster
|
|
37
|
+
ruff check . # lint
|
|
38
|
+
```
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "squarepeg"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Run a docker-run-style command as a Kubernetes Pod or Job, streaming its output like a local process"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"click>=8.1",
|
|
13
|
+
"kubernetes>=31",
|
|
14
|
+
"PyYAML>=6",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.optional-dependencies]
|
|
18
|
+
dev = [
|
|
19
|
+
"pytest>=8",
|
|
20
|
+
"pytest-cov>=5",
|
|
21
|
+
"ruff>=0.4",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.scripts]
|
|
25
|
+
squarepeg = "squarepeg.cli:main"
|
|
26
|
+
|
|
27
|
+
[tool.setuptools.packages.find]
|
|
28
|
+
include = ["squarepeg*"]
|
|
29
|
+
|
|
30
|
+
[tool.ruff]
|
|
31
|
+
line-length = 120
|
|
32
|
+
|
|
33
|
+
[tool.ruff.lint]
|
|
34
|
+
select = ["E", "F", "I"]
|
|
35
|
+
|
|
36
|
+
[tool.pytest.ini_options]
|
|
37
|
+
testpaths = ["tests"]
|
|
38
|
+
markers = [
|
|
39
|
+
"integration: requires a reachable kubernetes cluster (opt in with SQUAREPEG_INTEGRATION=1)",
|
|
40
|
+
]
|
|
41
|
+
addopts = "-m 'not integration'"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
import click
|
|
2
|
+
import yaml
|
|
3
|
+
|
|
4
|
+
from squarepeg import __version__
|
|
5
|
+
from squarepeg.config import coerce_bool, coerce_int, load_effective_config, select_profile
|
|
6
|
+
from squarepeg.dockerargs import check_supported_image, parse_env_entries, reject_unsupported
|
|
7
|
+
from squarepeg.errors import SquarepegError, UsageError
|
|
8
|
+
from squarepeg.k8s.dryrun import server_dry_run
|
|
9
|
+
from squarepeg.k8s.runner import run_manifest
|
|
10
|
+
from squarepeg.k8s.session import Session
|
|
11
|
+
from squarepeg.manifest import build_job, build_pod
|
|
12
|
+
from squarepeg.naming import generate_name, validate_rfc1123
|
|
13
|
+
from squarepeg.quantities import docker_cpus_to_k8s, docker_memory_to_k8s
|
|
14
|
+
from squarepeg.runspec import ResourceSpec, RunSpec
|
|
15
|
+
from squarepeg.volumes import auto_mounts_from_config, parse_volume_flag
|
|
16
|
+
|
|
17
|
+
_CONFIG_OPTIONS = [
|
|
18
|
+
click.option(
|
|
19
|
+
"--config", "config_paths", multiple=True, type=click.Path(), help="repeatable, layered left-to-right"
|
|
20
|
+
),
|
|
21
|
+
click.option("--no-default-config", "no_default_config", is_flag=True),
|
|
22
|
+
click.option("--profile", "profile", default=None),
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _add_config_options(cmd):
|
|
27
|
+
for option in _CONFIG_OPTIONS:
|
|
28
|
+
cmd = option(cmd)
|
|
29
|
+
return cmd
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _resolve_config(config_paths, no_default_config, profile):
|
|
33
|
+
effective, sources = load_effective_config(config_paths, no_default_config=no_default_config)
|
|
34
|
+
resolved = select_profile(effective, profile)
|
|
35
|
+
return resolved, sources
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _unsupported_callback(ctx, param, value):
|
|
39
|
+
if value:
|
|
40
|
+
reject_unsupported(param.opts[0])
|
|
41
|
+
return value
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# flag names -> whether the flag takes a value (False) or is a boolean switch (True)
|
|
45
|
+
_UNSUPPORTED_GROUPS: list[tuple[list[str], bool]] = [
|
|
46
|
+
(["--network"], False),
|
|
47
|
+
(["--privileged"], True),
|
|
48
|
+
(["--gpus"], False),
|
|
49
|
+
(["-p", "--publish"], False),
|
|
50
|
+
(["-d", "--detach"], True),
|
|
51
|
+
(["--restart"], False),
|
|
52
|
+
(["-u", "--user"], False),
|
|
53
|
+
(["--mount"], False),
|
|
54
|
+
(["--cap-add"], False),
|
|
55
|
+
(["--cap-drop"], False),
|
|
56
|
+
(["--add-host"], False),
|
|
57
|
+
(["--link"], False),
|
|
58
|
+
(["--platform"], False),
|
|
59
|
+
(["--env-file"], False),
|
|
60
|
+
(["--hostname"], False),
|
|
61
|
+
(["--tmpfs"], False),
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _add_unsupported_options(cmd):
|
|
66
|
+
for flag_names, is_flag in _UNSUPPORTED_GROUPS:
|
|
67
|
+
cmd = click.option(
|
|
68
|
+
*flag_names,
|
|
69
|
+
is_flag=is_flag,
|
|
70
|
+
hidden=True,
|
|
71
|
+
expose_value=False,
|
|
72
|
+
callback=_unsupported_callback,
|
|
73
|
+
)(cmd)
|
|
74
|
+
return cmd
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@click.group()
|
|
78
|
+
@click.version_option(__version__, prog_name="squarepeg")
|
|
79
|
+
def cli():
|
|
80
|
+
"""Run a docker-run-style command as a Kubernetes Pod or Job."""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@cli.command(
|
|
84
|
+
context_settings={"allow_interspersed_args": False, "ignore_unknown_options": True},
|
|
85
|
+
)
|
|
86
|
+
@click.option("-e", "--env", "env_entries", multiple=True, help="KEY=VALUE, repeatable")
|
|
87
|
+
@click.option("-v", "--volume", "volume_entries", multiple=True, help="[NAME|/host]:/container[:ro|rw], repeatable")
|
|
88
|
+
@click.option("--name", "name", default=None, help="Pod/Job name")
|
|
89
|
+
@click.option("-w", "--workdir", "workdir", default=None)
|
|
90
|
+
@click.option("--entrypoint", "entrypoint", default=None, help="Override the image ENTRYPOINT (-> k8s 'command')")
|
|
91
|
+
@click.option("-i", "--interactive", "interactive", is_flag=True, help="accepted; stdin is not forwarded")
|
|
92
|
+
@click.option("-t", "--tty", "tty", is_flag=True)
|
|
93
|
+
@click.option("--rm", "rm_flag", is_flag=True, help="accepted no-op; cleanup is already the default")
|
|
94
|
+
@click.option("--pull", "pull", type=click.Choice(["always", "missing", "never"]), default=None)
|
|
95
|
+
@click.option("--cpus", "cpus", default=None)
|
|
96
|
+
@click.option("-m", "--memory", "memory", default=None)
|
|
97
|
+
@click.option("--request-cpu", "request_cpu", default=None)
|
|
98
|
+
@click.option("--limit-cpu", "limit_cpu", default=None)
|
|
99
|
+
@click.option("--request-memory", "request_memory", default=None)
|
|
100
|
+
@click.option("--limit-memory", "limit_memory", default=None)
|
|
101
|
+
@click.option("--mode", "mode", type=click.Choice(["pod", "job"]), default=None)
|
|
102
|
+
@click.option("-n", "--namespace", "namespace", default=None)
|
|
103
|
+
@click.option("--keep", "keep", is_flag=True, help="do not delete the pod/job after it finishes")
|
|
104
|
+
@click.option("--dry-run", "dry_run", is_flag=True, help="render the manifest without creating it")
|
|
105
|
+
@click.option(
|
|
106
|
+
"--dry-run-server",
|
|
107
|
+
"dry_run_server",
|
|
108
|
+
is_flag=True,
|
|
109
|
+
help="like --dry-run, but additionally validates the manifest against the apiserver",
|
|
110
|
+
)
|
|
111
|
+
@click.option("--timeout", "timeout", type=int, default=None)
|
|
112
|
+
@click.option("--quiet", "quiet", is_flag=True)
|
|
113
|
+
@click.option("--context", "context", default=None, help="kubeconfig context to use")
|
|
114
|
+
@click.option("--container-name", "container_name", default="main")
|
|
115
|
+
@click.argument("image")
|
|
116
|
+
@click.argument("command", nargs=-1, type=click.UNPROCESSED)
|
|
117
|
+
@_add_config_options
|
|
118
|
+
@_add_unsupported_options
|
|
119
|
+
def run(
|
|
120
|
+
env_entries,
|
|
121
|
+
volume_entries,
|
|
122
|
+
name,
|
|
123
|
+
workdir,
|
|
124
|
+
entrypoint,
|
|
125
|
+
interactive,
|
|
126
|
+
tty,
|
|
127
|
+
rm_flag,
|
|
128
|
+
pull,
|
|
129
|
+
cpus,
|
|
130
|
+
memory,
|
|
131
|
+
request_cpu,
|
|
132
|
+
limit_cpu,
|
|
133
|
+
request_memory,
|
|
134
|
+
limit_memory,
|
|
135
|
+
mode,
|
|
136
|
+
namespace,
|
|
137
|
+
keep,
|
|
138
|
+
dry_run,
|
|
139
|
+
dry_run_server,
|
|
140
|
+
config_paths,
|
|
141
|
+
no_default_config,
|
|
142
|
+
profile,
|
|
143
|
+
timeout,
|
|
144
|
+
quiet,
|
|
145
|
+
context,
|
|
146
|
+
container_name,
|
|
147
|
+
image,
|
|
148
|
+
command,
|
|
149
|
+
):
|
|
150
|
+
"""Run IMAGE [COMMAND...] as a Kubernetes Pod or Job."""
|
|
151
|
+
check_supported_image(image)
|
|
152
|
+
if rm_flag and keep:
|
|
153
|
+
raise UsageError("--rm and --keep are mutually exclusive")
|
|
154
|
+
if dry_run and dry_run_server:
|
|
155
|
+
raise UsageError("--dry-run and --dry-run-server are mutually exclusive")
|
|
156
|
+
|
|
157
|
+
resolved_config, _sources = _resolve_config(config_paths, no_default_config, profile)
|
|
158
|
+
config_defaults = resolved_config.get("defaults") or {}
|
|
159
|
+
config_volumes = resolved_config.get("volumes") or {}
|
|
160
|
+
allow_host_path_mounts = coerce_bool(resolved_config.get("allow_host_path_mounts", False), "allow_host_path_mounts")
|
|
161
|
+
|
|
162
|
+
claims: set[str] = set()
|
|
163
|
+
|
|
164
|
+
env = {**config_defaults.get("env", {}), **parse_env_entries(env_entries)}
|
|
165
|
+
for key in parse_env_entries(env_entries):
|
|
166
|
+
claims.add(f"/spec/containers/[name={container_name}]/env/[name={key}]")
|
|
167
|
+
|
|
168
|
+
cli_volumes = [
|
|
169
|
+
parse_volume_flag(v, config_volumes=config_volumes, allow_host_path_mounts=allow_host_path_mounts)
|
|
170
|
+
for v in volume_entries
|
|
171
|
+
]
|
|
172
|
+
for vol in cli_volumes:
|
|
173
|
+
claims.add(f"/spec/volumes/[name={vol.volume_name}]")
|
|
174
|
+
claims.add(f"/spec/containers/[name={container_name}]/volumeMounts/[name={vol.volume_name}]")
|
|
175
|
+
|
|
176
|
+
# config-declared auto-mounts (volumes.NAME with a mount_path) are not claimed -- they're
|
|
177
|
+
# config-driven, not CLI-driven, so kubernetes.spec passthrough in the same config can still
|
|
178
|
+
# override them, same as any other config default (e.g. defaults.cpus).
|
|
179
|
+
volumes = auto_mounts_from_config(config_volumes) + cli_volumes
|
|
180
|
+
|
|
181
|
+
resources = ResourceSpec()
|
|
182
|
+
cli_set_cpus, cli_set_memory = cpus is not None, memory is not None
|
|
183
|
+
if cpus is None and "cpus" in config_defaults:
|
|
184
|
+
cpus = config_defaults["cpus"]
|
|
185
|
+
if memory is None and "memory" in config_defaults:
|
|
186
|
+
memory = config_defaults["memory"]
|
|
187
|
+
if cpus is not None:
|
|
188
|
+
converted = docker_cpus_to_k8s(cpus)
|
|
189
|
+
resources.request_cpu = converted
|
|
190
|
+
resources.limit_cpu = converted
|
|
191
|
+
if memory is not None:
|
|
192
|
+
converted = docker_memory_to_k8s(memory)
|
|
193
|
+
resources.request_memory = converted
|
|
194
|
+
resources.limit_memory = converted
|
|
195
|
+
if request_cpu is not None:
|
|
196
|
+
resources.request_cpu = request_cpu
|
|
197
|
+
claims.add(f"/spec/containers/[name={container_name}]/resources/requests/cpu")
|
|
198
|
+
if limit_cpu is not None:
|
|
199
|
+
resources.limit_cpu = limit_cpu
|
|
200
|
+
claims.add(f"/spec/containers/[name={container_name}]/resources/limits/cpu")
|
|
201
|
+
if request_memory is not None:
|
|
202
|
+
resources.request_memory = request_memory
|
|
203
|
+
claims.add(f"/spec/containers/[name={container_name}]/resources/requests/memory")
|
|
204
|
+
if limit_memory is not None:
|
|
205
|
+
resources.limit_memory = limit_memory
|
|
206
|
+
claims.add(f"/spec/containers/[name={container_name}]/resources/limits/memory")
|
|
207
|
+
if cli_set_cpus and request_cpu is None:
|
|
208
|
+
claims.add(f"/spec/containers/[name={container_name}]/resources/requests/cpu")
|
|
209
|
+
if cli_set_cpus and limit_cpu is None:
|
|
210
|
+
claims.add(f"/spec/containers/[name={container_name}]/resources/limits/cpu")
|
|
211
|
+
if cli_set_memory and request_memory is None:
|
|
212
|
+
claims.add(f"/spec/containers/[name={container_name}]/resources/requests/memory")
|
|
213
|
+
if cli_set_memory and limit_memory is None:
|
|
214
|
+
claims.add(f"/spec/containers/[name={container_name}]/resources/limits/memory")
|
|
215
|
+
|
|
216
|
+
if name is not None:
|
|
217
|
+
validate_rfc1123(name, what="--name")
|
|
218
|
+
claims.add("/metadata/name")
|
|
219
|
+
else:
|
|
220
|
+
name = generate_name(image)
|
|
221
|
+
|
|
222
|
+
if namespace is not None:
|
|
223
|
+
claims.add("/metadata/namespace")
|
|
224
|
+
else:
|
|
225
|
+
namespace = resolved_config.get("namespace")
|
|
226
|
+
|
|
227
|
+
pull_policy = {"always": "Always", "missing": "IfNotPresent", "never": "Never"}.get(pull) if pull else None
|
|
228
|
+
if pull_policy is not None:
|
|
229
|
+
claims.add(f"/spec/containers/[name={container_name}]/imagePullPolicy")
|
|
230
|
+
elif "image_pull_policy" in config_defaults:
|
|
231
|
+
pull_policy = config_defaults["image_pull_policy"]
|
|
232
|
+
|
|
233
|
+
if workdir is not None:
|
|
234
|
+
claims.add(f"/spec/containers/[name={container_name}]/workingDir")
|
|
235
|
+
elif "workdir" in config_defaults:
|
|
236
|
+
workdir = config_defaults["workdir"]
|
|
237
|
+
|
|
238
|
+
mode = mode or resolved_config.get("mode", "pod")
|
|
239
|
+
timeout = timeout if timeout is not None else coerce_int(resolved_config.get("timeout", 300), "timeout")
|
|
240
|
+
quiet = quiet or coerce_bool(resolved_config.get("quiet", False), "quiet")
|
|
241
|
+
cleanup = False if keep else coerce_bool(resolved_config.get("cleanup", True), "cleanup")
|
|
242
|
+
orphan_sweep = coerce_bool(resolved_config.get("orphan_sweep", True), "orphan_sweep")
|
|
243
|
+
orphan_sweep_min_age = coerce_int(resolved_config.get("orphan_sweep_min_age", 300), "orphan_sweep_min_age")
|
|
244
|
+
if orphan_sweep_min_age < 0:
|
|
245
|
+
raise UsageError(f"'orphan_sweep_min_age' must not be negative, got {orphan_sweep_min_age}")
|
|
246
|
+
|
|
247
|
+
entrypoint_tuple = (entrypoint,) if entrypoint is not None else None
|
|
248
|
+
if entrypoint_tuple is not None:
|
|
249
|
+
claims.add(f"/spec/containers/[name={container_name}]/command")
|
|
250
|
+
if command:
|
|
251
|
+
claims.add(f"/spec/containers/[name={container_name}]/args")
|
|
252
|
+
|
|
253
|
+
if tty:
|
|
254
|
+
claims.add(f"/spec/containers/[name={container_name}]/tty")
|
|
255
|
+
if not interactive:
|
|
256
|
+
# some clusters reject tty=true with stdin=false; -i is accepted but never
|
|
257
|
+
# forwards real stdin, so this can hang a container that then blocks reading it.
|
|
258
|
+
click.echo(
|
|
259
|
+
"squarepeg: -t implies stdin is opened on the container (stdinOnce), but stdin is "
|
|
260
|
+
"never actually forwarded; a process that blocks reading stdin will hang. Pass -i "
|
|
261
|
+
"explicitly to acknowledge this.",
|
|
262
|
+
err=True,
|
|
263
|
+
)
|
|
264
|
+
interactive = True
|
|
265
|
+
if interactive:
|
|
266
|
+
claims.add(f"/spec/containers/[name={container_name}]/stdin")
|
|
267
|
+
|
|
268
|
+
spec = RunSpec(
|
|
269
|
+
image=image,
|
|
270
|
+
args=tuple(command),
|
|
271
|
+
entrypoint=entrypoint_tuple,
|
|
272
|
+
env=env,
|
|
273
|
+
workdir=workdir,
|
|
274
|
+
volumes=volumes,
|
|
275
|
+
tty=tty,
|
|
276
|
+
stdin=interactive,
|
|
277
|
+
pull_policy=pull_policy,
|
|
278
|
+
mode=mode,
|
|
279
|
+
namespace=namespace,
|
|
280
|
+
name=name,
|
|
281
|
+
container_name=container_name,
|
|
282
|
+
resources=resources,
|
|
283
|
+
cleanup=cleanup,
|
|
284
|
+
timeout=timeout,
|
|
285
|
+
quiet=quiet,
|
|
286
|
+
orphan_sweep=orphan_sweep,
|
|
287
|
+
orphan_sweep_min_age=orphan_sweep_min_age,
|
|
288
|
+
claims=claims,
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
kubernetes_passthrough = resolved_config.get("kubernetes")
|
|
292
|
+
job_passthrough = resolved_config.get("job")
|
|
293
|
+
if spec.mode == "job":
|
|
294
|
+
manifest = build_job(spec, kubernetes_passthrough, job_passthrough)
|
|
295
|
+
else:
|
|
296
|
+
manifest = build_pod(spec, kubernetes_passthrough)
|
|
297
|
+
|
|
298
|
+
if dry_run_server:
|
|
299
|
+
session = Session(namespace=spec.namespace, context=context, quiet=spec.quiet)
|
|
300
|
+
spec.namespace = session.namespace
|
|
301
|
+
validated = server_dry_run(session, spec, manifest)
|
|
302
|
+
click.echo(yaml.safe_dump(validated, sort_keys=False), nl=False)
|
|
303
|
+
return
|
|
304
|
+
if dry_run:
|
|
305
|
+
click.echo(yaml.safe_dump(manifest, sort_keys=False), nl=False)
|
|
306
|
+
return
|
|
307
|
+
|
|
308
|
+
session = Session(namespace=spec.namespace, context=context, quiet=spec.quiet)
|
|
309
|
+
spec.namespace = session.namespace
|
|
310
|
+
exit_code = run_manifest(session, spec, manifest)
|
|
311
|
+
raise SystemExit(exit_code)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
@cli.group("config")
|
|
315
|
+
def config_group():
|
|
316
|
+
"""Inspect the resolved config."""
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
@config_group.command("show")
|
|
320
|
+
@_add_config_options
|
|
321
|
+
def config_show(config_paths, no_default_config, profile):
|
|
322
|
+
"""Print the merged effective config (stdout) and its sources (stderr)."""
|
|
323
|
+
resolved_config, sources = _resolve_config(config_paths, no_default_config, profile)
|
|
324
|
+
for path, origin in sources:
|
|
325
|
+
click.echo(f"[squarepeg] {path} ({origin})", err=True)
|
|
326
|
+
if not sources:
|
|
327
|
+
click.echo("[squarepeg] no config files loaded", err=True)
|
|
328
|
+
click.echo(yaml.safe_dump(resolved_config, sort_keys=False), nl=False)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def main():
|
|
332
|
+
try:
|
|
333
|
+
cli(standalone_mode=False)
|
|
334
|
+
except click.ClickException as exc:
|
|
335
|
+
exc.show()
|
|
336
|
+
raise SystemExit(exc.exit_code) from exc
|
|
337
|
+
except click.exceptions.Exit as exc:
|
|
338
|
+
raise SystemExit(exc.exit_code) from exc
|
|
339
|
+
except SquarepegError as exc:
|
|
340
|
+
click.echo(f"squarepeg: {exc}", err=True)
|
|
341
|
+
raise SystemExit(exc.exit_code) from exc
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
if __name__ == "__main__":
|
|
345
|
+
main()
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Nextflow-style layered config loading.
|
|
2
|
+
|
|
3
|
+
Resolution order, lowest to highest precedence:
|
|
4
|
+
1. built-in defaults (not a file; handled by callers)
|
|
5
|
+
2. the single default path ~/.config/squarepeg/config.yaml, if present
|
|
6
|
+
3. $SQUAREPEG_CONFIG: a colon-separated list of paths, left-to-right
|
|
7
|
+
4. each --config PATH given on the CLI, in order, left-to-right
|
|
8
|
+
5. individual CLI flags (applied by callers, not here)
|
|
9
|
+
|
|
10
|
+
Every file in 2-4 is merged with the same key-level deep_merge used for k8s
|
|
11
|
+
passthrough (with an empty claim set) -- see merge.py for why whole-file
|
|
12
|
+
override would defeat the point of layering.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
import yaml
|
|
19
|
+
|
|
20
|
+
from squarepeg.env_interpolation import interpolate_document
|
|
21
|
+
from squarepeg.errors import ConfigError
|
|
22
|
+
from squarepeg.merge import deep_merge
|
|
23
|
+
|
|
24
|
+
ENV_VAR = "SQUAREPEG_CONFIG"
|
|
25
|
+
DEFAULT_CONFIG_PATH = Path.home() / ".config" / "squarepeg" / "config.yaml"
|
|
26
|
+
|
|
27
|
+
ALLOWED_TOP_KEYS = {
|
|
28
|
+
"namespace",
|
|
29
|
+
"mode",
|
|
30
|
+
"cleanup",
|
|
31
|
+
"timeout",
|
|
32
|
+
"quiet",
|
|
33
|
+
"allow_host_path_mounts",
|
|
34
|
+
"split_streams",
|
|
35
|
+
"orphan_sweep",
|
|
36
|
+
"orphan_sweep_min_age",
|
|
37
|
+
"defaults",
|
|
38
|
+
"volumes",
|
|
39
|
+
"kubernetes",
|
|
40
|
+
"job",
|
|
41
|
+
"profiles",
|
|
42
|
+
}
|
|
43
|
+
ALLOWED_DEFAULTS_KEYS = {"cpus", "memory", "image_pull_policy", "workdir", "env"}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _resolve_env_paths() -> list[tuple[Path, str]]:
|
|
47
|
+
raw = os.environ.get(ENV_VAR, "")
|
|
48
|
+
resolved = []
|
|
49
|
+
for segment in raw.split(":"):
|
|
50
|
+
if not segment:
|
|
51
|
+
continue
|
|
52
|
+
path = Path(segment).expanduser()
|
|
53
|
+
if not path.exists():
|
|
54
|
+
raise ConfigError(f"config file {path} (from ${ENV_VAR}) does not exist")
|
|
55
|
+
resolved.append((path, f"${ENV_VAR}"))
|
|
56
|
+
return resolved
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _resolve_cli_paths(cli_config_paths: tuple[str, ...]) -> list[tuple[Path, str]]:
|
|
60
|
+
resolved = []
|
|
61
|
+
for raw in cli_config_paths:
|
|
62
|
+
path = Path(raw).expanduser()
|
|
63
|
+
if not path.exists():
|
|
64
|
+
raise ConfigError(f"config file {path} (from --config {raw}) does not exist")
|
|
65
|
+
resolved.append((path, f"--config {raw}"))
|
|
66
|
+
return resolved
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def resolve_config_sources(
|
|
70
|
+
cli_config_paths: tuple[str, ...] = (), *, no_default_config: bool = False
|
|
71
|
+
) -> list[tuple[Path, str]]:
|
|
72
|
+
"""Return [(path, origin_label)] in the order they should be merged, lowest precedence first."""
|
|
73
|
+
sources: list[tuple[Path, str]] = []
|
|
74
|
+
if not no_default_config and DEFAULT_CONFIG_PATH.exists():
|
|
75
|
+
sources.append((DEFAULT_CONFIG_PATH, "default location"))
|
|
76
|
+
sources.extend(_resolve_env_paths())
|
|
77
|
+
sources.extend(_resolve_cli_paths(cli_config_paths))
|
|
78
|
+
return sources
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def load_yaml_document(path: Path) -> dict:
|
|
82
|
+
try:
|
|
83
|
+
text = path.read_text()
|
|
84
|
+
except OSError as exc:
|
|
85
|
+
raise ConfigError(f"cannot read config file {path}: {exc}") from exc
|
|
86
|
+
try:
|
|
87
|
+
data = yaml.safe_load(text)
|
|
88
|
+
except yaml.YAMLError as exc:
|
|
89
|
+
raise ConfigError(f"malformed YAML in {path}: {exc}") from exc
|
|
90
|
+
if data is None:
|
|
91
|
+
return {}
|
|
92
|
+
if not isinstance(data, dict):
|
|
93
|
+
raise ConfigError(f"config file {path} must be a YAML mapping at the top level")
|
|
94
|
+
return data
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def validate_document(doc: dict, source: str, *, allow_profiles: bool = True) -> None:
|
|
98
|
+
for key in doc:
|
|
99
|
+
if key not in ALLOWED_TOP_KEYS:
|
|
100
|
+
raise ConfigError(f"unknown config key {key!r} in {source}")
|
|
101
|
+
defaults = doc.get("defaults")
|
|
102
|
+
if defaults:
|
|
103
|
+
for key in defaults:
|
|
104
|
+
if key not in ALLOWED_DEFAULTS_KEYS:
|
|
105
|
+
raise ConfigError(f"unknown key 'defaults.{key}' in {source}")
|
|
106
|
+
profiles = doc.get("profiles")
|
|
107
|
+
if profiles:
|
|
108
|
+
if not allow_profiles:
|
|
109
|
+
raise ConfigError(f"nested 'profiles' is not allowed inside a profile (in {source})")
|
|
110
|
+
for name, body in profiles.items():
|
|
111
|
+
if not isinstance(body, dict):
|
|
112
|
+
raise ConfigError(f"profile {name!r} in {source} must be a mapping")
|
|
113
|
+
validate_document(body, f"{source} (profile {name!r})", allow_profiles=False)
|
|
114
|
+
volumes = doc.get("volumes")
|
|
115
|
+
if volumes:
|
|
116
|
+
if not isinstance(volumes, dict):
|
|
117
|
+
raise ConfigError(f"'volumes' in {source} must be a mapping")
|
|
118
|
+
for name, entry in volumes.items():
|
|
119
|
+
if not isinstance(entry, dict):
|
|
120
|
+
raise ConfigError(f"volume {name!r} in {source} must be a mapping")
|
|
121
|
+
# squarepeg only validates its own keys here; the rest of the entry is an
|
|
122
|
+
# opaque k8s volume source (persistentVolumeClaim, emptyDir, ...) that the
|
|
123
|
+
# apiserver validates -- same exemption as the 'kubernetes'/'job' sections.
|
|
124
|
+
if "mount_path" in entry:
|
|
125
|
+
mount_path = entry["mount_path"]
|
|
126
|
+
if not isinstance(mount_path, str) or not mount_path.startswith("/"):
|
|
127
|
+
raise ConfigError(
|
|
128
|
+
f"volumes.{name}.mount_path in {source} must be an absolute container "
|
|
129
|
+
f"path (got {mount_path!r})"
|
|
130
|
+
)
|
|
131
|
+
if "read_only" in entry:
|
|
132
|
+
coerce_bool(entry["read_only"], f"volumes.{name}.read_only (in {source})")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def load_effective_config(
|
|
136
|
+
cli_config_paths: tuple[str, ...] = (), *, no_default_config: bool = False
|
|
137
|
+
) -> tuple[dict, list[tuple[Path, str]]]:
|
|
138
|
+
"""Load and merge every config layer. Returns (effective_config, sources)."""
|
|
139
|
+
sources = resolve_config_sources(cli_config_paths, no_default_config=no_default_config)
|
|
140
|
+
effective: dict = {}
|
|
141
|
+
for path, origin in sources:
|
|
142
|
+
source = f"{path} ({origin})"
|
|
143
|
+
doc = load_yaml_document(path)
|
|
144
|
+
doc = interpolate_document(doc, source)
|
|
145
|
+
validate_document(doc, source)
|
|
146
|
+
effective = deep_merge(effective, doc)
|
|
147
|
+
return effective, sources
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
_TRUE_STRINGS = {"true", "1", "yes", "on"}
|
|
151
|
+
_FALSE_STRINGS = {"false", "0", "no", "off"}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def coerce_int(value, key: str) -> int:
|
|
155
|
+
"""Coerce a config value to int, accepting an int-looking string (e.g. from ${VAR} interpolation)."""
|
|
156
|
+
if isinstance(value, bool): # bool is an int subclass; reject it explicitly
|
|
157
|
+
raise ConfigError(f"expected an integer for {key!r}, got {value!r}")
|
|
158
|
+
if isinstance(value, int):
|
|
159
|
+
return value
|
|
160
|
+
if isinstance(value, str):
|
|
161
|
+
try:
|
|
162
|
+
return int(value.strip())
|
|
163
|
+
except ValueError:
|
|
164
|
+
pass
|
|
165
|
+
raise ConfigError(f"expected an integer for {key!r}, got {value!r}")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def coerce_bool(value, key: str) -> bool:
|
|
169
|
+
"""Coerce a config value to bool, accepting a bool-looking string (e.g. from ${VAR} interpolation)."""
|
|
170
|
+
if isinstance(value, bool):
|
|
171
|
+
return value
|
|
172
|
+
if isinstance(value, str):
|
|
173
|
+
lowered = value.strip().lower()
|
|
174
|
+
if lowered in _TRUE_STRINGS:
|
|
175
|
+
return True
|
|
176
|
+
if lowered in _FALSE_STRINGS:
|
|
177
|
+
return False
|
|
178
|
+
raise ConfigError(f"expected a boolean for {key!r}, got {value!r}")
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def select_profile(effective_config: dict, profile_name: str | None) -> dict:
|
|
182
|
+
"""Fold the named profile over the rest of the effective config (L1+L2)."""
|
|
183
|
+
if profile_name is None:
|
|
184
|
+
return {k: v for k, v in effective_config.items() if k != "profiles"}
|
|
185
|
+
profiles = effective_config.get("profiles") or {}
|
|
186
|
+
if profile_name not in profiles:
|
|
187
|
+
available = ", ".join(sorted(profiles)) or "(none defined)"
|
|
188
|
+
raise ConfigError(f"unknown profile {profile_name!r}; available profiles: {available}")
|
|
189
|
+
base = {k: v for k, v in effective_config.items() if k != "profiles"}
|
|
190
|
+
return deep_merge(base, profiles[profile_name])
|