Skip to content

Commit 5b111a1

Browse files
authored
Merge pull request #15 from onetimesecret/delano/next
Enhance restart reliability, add shell options, improve secrets validation
2 parents d71ac97 + f5e283f commit 5b111a1

File tree

17 files changed

+371
-93
lines changed

17 files changed

+371
-93
lines changed

.pre-commit-config.yaml

Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -82,11 +82,12 @@ repos:
8282
- "--template=[#{}]"
8383

8484
# Pyright - static type checking (matches IDE)
85+
# Only checks changed files rather than all of src/ for speed
8586
- repo: https://github.com/RobertCraigie/pyright-python
8687
rev: v1.1.390
8788
hooks:
8889
- id: pyright
89-
args: [src/]
90+
files: ^src/
9091
additional_dependencies:
9192
- cyclopts>=3.9
9293
- pytest>=8.0
@@ -99,23 +100,13 @@ repos:
99100
args: [--fix]
100101
- id: ruff-format
101102

102-
# pytest - run tests before commit (fast, no coverage)
103-
# Only runs when Python files in src/ or tests/ are modified
103+
# pytest - run before push with coverage and last-failed optimization
104+
# Skipped on commit for speed; full suite gates push instead
104105
- repo: local
105106
hooks:
106-
- id: pytest
107-
name: pytest
108-
entry: .venv/bin/pytest tests/ -x -q
109-
language: system
110-
pass_filenames: false
111-
files: ^(src/|tests/).*\.py$
112-
stages: [pre-commit]
113-
114-
# pytest with coverage - run before push
115-
# Only runs when Python files in src/ or tests/ are modified
116107
- id: pytest-cov
117108
name: pytest-cov
118-
entry: .venv/bin/pytest tests/ -x -q --cov=ots_containers --cov-report=term-missing --cov-fail-under=70
109+
entry: .venv/bin/pytest tests/ -x -q --ff --cov=ots_containers --cov-report=term-missing --cov-fail-under=70
119110
language: system
120111
pass_filenames: false
121112
files: ^(src/|tests/).*\.py$

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ build-backend = "hatchling.build"
1313

1414
[project]
1515
name = "ots-containers"
16-
version = "0.3.2"
16+
version = "0.3.3"
1717
description = "Service orchestration for OneTimeSecret: Podman Quadlets and systemd service management"
1818
readme = "README.md"
1919
requires-python = ">=3.11"

src/ots_containers/commands/cloudinit/app.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,8 @@ def generate(
4343
bool, cyclopts.Parameter(help="Include Valkey apt repository")
4444
] = False,
4545
include_xcaddy: Annotated[
46-
bool, cyclopts.Parameter(help="Include xcaddy repo and build custom Caddy (web profile)")
46+
bool,
47+
cyclopts.Parameter(help="Include xcaddy repo and build custom Caddy (web profile)"),
4748
] = False,
4849
caddy_version: Annotated[
4950
str,

src/ots_containers/commands/instance/_helpers.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@
33
"""Internal helper functions for instance commands."""
44

55
import shlex
6+
import sys
67
import time
78
from collections.abc import Callable, Sequence
89
from pathlib import Path
910

1011
from ots_containers import systemd
1112
from ots_containers.environment_file import get_secrets_from_env_file
13+
from ots_containers.systemd import SystemctlError
1214

1315
from .annotations import InstanceType
1416

@@ -61,7 +63,12 @@ def build_secret_args(env_file: Path) -> list[str]:
6163
secret_specs = get_secrets_from_env_file(env_file)
6264
args: list[str] = []
6365
for spec in secret_specs:
64-
args.extend(["--secret", f"{spec.secret_name},type=env,target={spec.env_var_name}"])
66+
args.extend(
67+
[
68+
"--secret",
69+
f"{spec.secret_name},type=env,target={spec.env_var_name}",
70+
]
71+
)
6572
return args
6673

6774

@@ -171,7 +178,15 @@ def for_each_instance(
171178
for i, (itype, id_) in enumerate(items, 1):
172179
unit = systemd.unit_name(itype.value, id_)
173180
print(f"[{i}/{total}] {verb} {unit}...")
174-
action(itype, id_)
181+
try:
182+
action(itype, id_)
183+
except SystemctlError as exc:
184+
print(f"Error: {exc}", file=sys.stderr)
185+
if exc.journal:
186+
print(f"\nRecent journal output for {exc.unit}:", file=sys.stderr)
187+
for line in exc.journal.splitlines():
188+
print(f" {line}", file=sys.stderr)
189+
raise SystemExit(1) from None
175190
if i < total and delay > 0:
176191
print(f"Waiting {delay}s...")
177192
time.sleep(delay)

src/ots_containers/commands/instance/app.py

Lines changed: 47 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,9 @@ def _list_instances_impl(
7171
else:
7272
# Worker/scheduler: query by notes containing instance ID
7373
deployments = db.get_deployments(
74-
cfg.db_path, limit=1, notes_like=f"%{inst_type.value}_id={id_}%"
74+
cfg.db_path,
75+
limit=1,
76+
notes_like=f"%{inst_type.value}_id={id_}%",
7577
)
7678
if deployments:
7779
dep = deployments[0]
@@ -132,7 +134,9 @@ def _list_instances_impl(
132134
else:
133135
# Worker/scheduler: query by notes containing instance ID
134136
deployments = db.get_deployments(
135-
cfg.db_path, limit=1, notes_like=f"%{inst_type.value}_id={id_}%"
137+
cfg.db_path,
138+
limit=1,
139+
notes_like=f"%{inst_type.value}_id={id_}%",
136140
)
137141
if deployments:
138142
dep = deployments[0]
@@ -737,17 +741,20 @@ def restart(
737741
web: WebFlag = False,
738742
worker: WorkerFlag = False,
739743
scheduler: SchedulerFlag = False,
744+
delay: Delay = 30,
740745
):
741746
"""Restart systemd unit(s) for instance(s).
742747
743748
Does NOT regenerate quadlet - use 'redeploy' for that.
744749
Only restarts running instances; stopped instances are skipped.
750+
Waits between instances to allow startup before Caddy health checks.
745751
746752
Examples:
747753
ots instances restart # Restart all running
748754
ots instances restart --web # Restart web instances
749755
ots instances restart --web 7043 7044 # Restart specific web
750756
ots instances restart --scheduler main # Restart specific scheduler
757+
ots instances restart --delay 10 # Longer wait between restarts
751758
"""
752759
itype = resolve_instance_type(instance_type, web, worker, scheduler)
753760
instances = resolve_identifiers(identifiers, itype, running_only=True)
@@ -756,15 +763,11 @@ def restart(
756763
print("No running instances found")
757764
return
758765

759-
for inst_type, ids in instances.items():
760-
for id_ in ids:
761-
unit = systemd.unit_name(inst_type.value, id_)
762-
systemd.restart(unit)
763-
print(f"Restarted {unit}")
766+
def do_restart(inst_type: InstanceType, id_: str) -> None:
767+
unit = systemd.unit_name(inst_type.value, id_)
768+
systemd.restart(unit)
764769

765-
hint = format_journalctl_hint(instances)
766-
if hint:
767-
print(f"\nView logs: {hint}")
770+
for_each_instance(instances, delay, do_restart, "Restarting", show_logs_hint=True)
768771

769772

770773
@app.command
@@ -1019,6 +1022,20 @@ def shell(
10191022
help="Named volume for persistent data (survives exit)",
10201023
),
10211024
] = None,
1025+
volume: Annotated[
1026+
str | None,
1027+
cyclopts.Parameter(
1028+
name=["--volume", "-v"],
1029+
help="Host path to bind-mount at /app/data (rw, user-mapped)",
1030+
),
1031+
] = None,
1032+
env: Annotated[
1033+
tuple[str, ...],
1034+
cyclopts.Parameter(
1035+
name=["--env", "-e"],
1036+
help="Set container env var (KEY=VALUE, repeatable)",
1037+
),
1038+
] = (),
10221039
command: Annotated[
10231040
str | None,
10241041
cyclopts.Parameter(
@@ -1046,14 +1063,24 @@ def shell(
10461063
10471064
By default uses tmpfs at /app/data (data destroyed on exit).
10481065
Use --persistent to create a named volume that survives exit.
1066+
Use --volume to bind-mount a host directory at /app/data.
10491067
Config is mounted read-only at /app/etc.
10501068
10511069
Examples:
10521070
ots instance shell # tmpfs, interactive bash
10531071
ots instance shell --persistent upgrade-v024 # named volume survives exit
10541072
ots instance shell -c "bin/ots migrate" # run command and exit
10551073
ots instance shell --tag v0.24.0 # specific image tag
1074+
ots instance shell -v ./data # bind-mount ./data at /app/data
1075+
ots instance shell -v ./data -e REDIS_URL=redis://10.0.0.5:6379/0 # with env
1076+
ots instance shell -e FOO=bar -e BAZ=qux # multiple env vars, tmpfs default
10561077
"""
1078+
from pathlib import Path
1079+
1080+
if persistent and volume:
1081+
print("Error: --persistent and --volume are mutually exclusive")
1082+
raise SystemExit(1)
1083+
10571084
cfg = Config()
10581085

10591086
# Resolve image/tag (same pattern as run command)
@@ -1084,13 +1111,21 @@ def shell(
10841111
cmd.extend(["--env-file", str(env_file)])
10851112
cmd.extend(build_secret_args(env_file))
10861113

1087-
# Data volume: tmpfs (default) or persistent named volume
1088-
if persistent:
1114+
# Data volume: bind-mount, persistent named volume, or tmpfs (default)
1115+
if volume:
1116+
host_path = Path(volume).resolve()
1117+
host_path.mkdir(parents=True, exist_ok=True)
1118+
cmd.extend(["-v", f"{host_path}:/app/data:rw,U"])
1119+
elif persistent:
10891120
volume_name = f"ots-migration-{persistent}"
10901121
cmd.extend(["-v", f"{volume_name}:/app/data"])
10911122
else:
10921123
cmd.extend(["--tmpfs", "/app/data"])
10931124

1125+
# Ad-hoc environment variables
1126+
for entry in env:
1127+
cmd.extend(["-e", entry])
1128+
10941129
# Config overrides (per-file, if any exist on host)
10951130
for f in cfg.existing_config_files:
10961131
resolved = f.resolve() # symlink resolution for macOS podman VM

src/ots_containers/commands/proxy/_helpers.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,14 @@ def validate_caddy_config(content: str) -> None:
6969

7070
try:
7171
result = subprocess.run(
72-
["caddy", "validate", "--config", temp_path, "--adapter", "caddyfile"],
72+
[
73+
"caddy",
74+
"validate",
75+
"--config",
76+
temp_path,
77+
"--adapter",
78+
"caddyfile",
79+
],
7380
capture_output=True,
7481
text=True,
7582
)

src/ots_containers/commands/proxy/app.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
Path | None,
3333
cyclopts.Parameter(
3434
name=["--template", "-t"],
35-
help="Template file path (default: /etc/onetimesecret/Caddyfile.template)",
35+
help="Template file path (e.g. /etc/onetimesecret/Caddyfile.template)",
3636
),
3737
]
3838

src/ots_containers/config.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,12 @@
1010

1111
# Config files that ship as defaults in the container image (etc/defaults/*.defaults.yaml).
1212
# Only files present on the host override the container's built-in defaults.
13-
CONFIG_FILES: tuple[str, ...] = ("config.yaml", "auth.yaml", "logging.yaml")
13+
CONFIG_FILES: tuple[str, ...] = (
14+
"config.yaml",
15+
"auth.yaml",
16+
"logging.yaml",
17+
"billing.yaml",
18+
)
1419

1520

1621
@dataclass

src/ots_containers/environment_file.py

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -320,9 +320,8 @@ def extract_secrets(env_file: EnvFile) -> tuple[list[SecretSpec], list[str]]:
320320
continue
321321

322322
# No entry found - neither VARNAME nor _VARNAME exists
323+
# Skip adding to secrets list so quadlet won't reference a non-existent secret
323324
messages.append(f"Warning: {var_name} listed in SECRET_VARIABLE_NAMES but not found")
324-
# Still include in secrets list so quadlet line is generated
325-
secrets.append(SecretSpec.from_env_var(var_name, value=None))
326325

327326
return secrets, messages
328327

@@ -406,12 +405,16 @@ def ensure_podman_secret(secret_name: str, value: str) -> str:
406405

407406

408407
def secret_exists(secret_name: str) -> bool:
409-
"""Check if a podman secret exists."""
410-
result = subprocess.run(
411-
["podman", "secret", "exists", secret_name],
412-
capture_output=True,
413-
)
414-
return result.returncode == 0
408+
"""Check if a podman secret exists. Returns False if podman is unavailable."""
409+
try:
410+
result = subprocess.run(
411+
["podman", "secret", "exists", secret_name],
412+
capture_output=True,
413+
timeout=10,
414+
)
415+
return result.returncode == 0
416+
except (subprocess.SubprocessError, OSError):
417+
return False
415418

416419

417420
def generate_quadlet_secret_lines(secrets: list[SecretSpec]) -> str:

src/ots_containers/quadlet.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from .environment_file import (
1515
generate_quadlet_secret_lines,
1616
get_secrets_from_env_file,
17+
secret_exists,
1718
)
1819

1920
# Default environment file path
@@ -119,7 +120,23 @@ def get_secrets_section(env_file_path: Path | None = None) -> str:
119120
if not secrets:
120121
return "# No secrets configured (SECRET_VARIABLE_NAMES not set in env file)"
121122

122-
return generate_quadlet_secret_lines(secrets)
123+
# Defense-in-depth: only include secrets that actually exist as podman secrets.
124+
# A secret may have been processed in the env file but later deleted from the
125+
# podman secret store. Warn rather than letting podman fail at container start.
126+
verified = []
127+
for spec in secrets:
128+
if secret_exists(spec.secret_name):
129+
verified.append(spec)
130+
else:
131+
print(
132+
f"Warning: podman secret '{spec.secret_name}' not found, "
133+
f"skipping Secret= line for {spec.env_var_name}"
134+
)
135+
136+
if not verified:
137+
return "# No secrets configured (none found in podman secret store)"
138+
139+
return generate_quadlet_secret_lines(verified)
123140

124141

125142
def get_config_volumes_section(cfg: Config) -> str:

0 commit comments

Comments
 (0)