Skip to content

Commit 0bc00c4

Browse files
authored
Merge pull request #27 from onetimesecret/feature/proxy-cofig-imports
Extend proxy push to accept files/directories; bump to v0.5.0
2 parents b8bf3f5 + bf98881 commit 0bc00c4

File tree

6 files changed

+559
-51
lines changed

6 files changed

+559
-51
lines changed

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,4 @@ Thumbs.db
2727

2828
.claude
2929
.serena
30-
.coverage
30+
.coverage*

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
[project]
1111
name = "rots"
12-
version = "0.4.0"
12+
version = "0.5.0"
1313
description = "Service orchestration for OneTimeSecret: Podman Quadlets and systemd service management"
1414
readme = "README.md"
1515
requires-python = ">=3.11"

src/rots/commands/proxy/_helpers.py

Lines changed: 89 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,87 @@ def render_template(template_path: Path, *, executor: Executor | None = None) ->
132132
raise ProxyError("envsubst not found - install gettext package") from e
133133

134134

135+
def collect_local_files(source_dir: Path) -> list[tuple[Path, Path]]:
136+
"""Walk *source_dir* recursively and return ``(absolute, relative)`` pairs.
137+
138+
Skips hidden files and directories (any path component starting with
139+
``.``). Results are sorted by relative path for deterministic output.
140+
"""
141+
results: list[tuple[Path, Path]] = []
142+
for abs_path in source_dir.rglob("*"):
143+
if not abs_path.is_file():
144+
continue
145+
rel_path = abs_path.relative_to(source_dir)
146+
if any(part.startswith(".") for part in rel_path.parts):
147+
continue
148+
results.append((abs_path, rel_path))
149+
return sorted(results, key=lambda pair: pair[1])
150+
151+
152+
def push_files_to_remote(
153+
source_dir: Path,
154+
remote_base: Path,
155+
*,
156+
executor: Executor,
157+
dry_run: bool = False,
158+
) -> list[tuple[Path, Path]]:
159+
"""Push all files under *source_dir* to *remote_base* on the remote host.
160+
161+
Uses ``tee`` via the executor (which supports ``sudo``) rather than
162+
SFTP ``put_file``, so writes to root-owned directories like
163+
``/etc/onetimesecret/`` succeed without extra privilege escalation.
164+
165+
Creates remote parent directories via ``mkdir -p`` before transfers.
166+
Returns the list of ``(local_path, remote_path)`` pairs transferred.
167+
168+
Note: reads files with ``read_text()`` — intended for text-based
169+
config files (Caddy templates, snippets, etc.), not binary content.
170+
"""
171+
files = collect_local_files(source_dir)
172+
transferred: list[tuple[Path, Path]] = []
173+
174+
if dry_run:
175+
for local, rel in files:
176+
remote_path = remote_base / rel
177+
print(f" {rel} -> {remote_path}")
178+
return [(local, remote_base / rel) for local, rel in files]
179+
180+
# Collect unique remote parent directories
181+
remote_dirs: set[Path] = set()
182+
for _, rel in files:
183+
remote_dirs.add((remote_base / rel).parent)
184+
185+
for d in sorted(remote_dirs):
186+
executor.run(["mkdir", "-p", str(d)], timeout=15)
187+
188+
for local, rel in files:
189+
remote_path = remote_base / rel
190+
content = local.read_text()
191+
result = executor.run(["tee", str(remote_path)], input=content, timeout=15)
192+
if not result.ok:
193+
raise ProxyError(f"Failed to write {remote_path}: {result.stderr}")
194+
print(f" {rel} -> {remote_path}")
195+
transferred.append((local, remote_path))
196+
197+
return transferred
198+
199+
200+
def find_template_in_dir(source_dir: Path) -> Path | None:
201+
"""Find ``*.template`` files at the top level of *source_dir*.
202+
203+
Returns the single template path, ``None`` when none are found,
204+
or raises :class:`ProxyError` when multiple are found (suggests
205+
``--template`` to disambiguate).
206+
"""
207+
templates = sorted(source_dir.glob("*.template"))
208+
if not templates:
209+
return None
210+
if len(templates) > 1:
211+
names = ", ".join(t.name for t in templates)
212+
raise ProxyError(f"Multiple template files found: {names}. Use --template to specify one.")
213+
return templates[0]
214+
215+
135216
def validate_caddy_config(
136217
content: str,
137218
*,
@@ -152,9 +233,15 @@ def validate_caddy_config(
152233
ProxyError: If validation fails.
153234
"""
154235
if _is_remote(executor):
155-
# Create unique temp file on remote host (CWE-377: avoid predictable paths)
236+
# Create unique temp file on remote host (CWE-377: avoid predictable paths).
237+
# When source_dir is provided, create the temp file there so Caddy can
238+
# resolve relative ``import`` paths from the same directory.
239+
if source_dir:
240+
mktemp_template = f"{source_dir}/ots-caddy-validate.XXXXXXXXXX"
241+
else:
242+
mktemp_template = "/tmp/ots-caddy-validate.XXXXXXXXXX"
156243
mktemp_result = executor.run( # type: ignore[union-attr]
157-
["mktemp", "/tmp/ots-caddy-validate.XXXXXXXXXX"],
244+
["mktemp", mktemp_template],
158245
timeout=10,
159246
)
160247
if not mktemp_result.ok:

src/rots/commands/proxy/app.py

Lines changed: 162 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -9,27 +9,35 @@
99
All commands support remote execution via the global ``--host`` flag.
1010
"""
1111

12+
from __future__ import annotations
13+
1214
import contextlib
1315
import json
1416
import logging
1517
import time
1618
from pathlib import Path
17-
from typing import Annotated
19+
from typing import TYPE_CHECKING, Annotated
1820

1921
import cyclopts
2022

2123
from rots import context
2224
from rots.config import Config
2325

26+
if TYPE_CHECKING:
27+
from ots_shared.ssh import Executor
28+
2429
from ..common import DryRun, JsonOutput
2530
from ._helpers import (
2631
ProbeResult,
2732
ProxyError,
2833
adapt_to_json,
34+
collect_local_files,
2935
evaluate_assertions,
3036
find_free_port,
37+
find_template_in_dir,
3138
parse_trace_url,
3239
patch_caddy_json,
40+
push_files_to_remote,
3341
reload_caddy,
3442
render_template,
3543
run_caddy,
@@ -93,11 +101,13 @@ def render(
93101
print(rendered)
94102
return
95103

96-
# Validate before writing
97-
validate_caddy_config(rendered, executor=ex)
104+
# Validate before writing — pass source_dir so relative imports resolve
105+
from ots_shared.ssh import is_remote
106+
107+
source_dir = tpl.parent
108+
validate_caddy_config(rendered, executor=ex, source_dir=source_dir)
98109

99110
# Write to output path
100-
from ots_shared.ssh import is_remote
101111

102112
if is_remote(ex):
103113
ex.run(["mkdir", "-p", str(out.parent)], timeout=15)
@@ -116,27 +126,53 @@ def render(
116126

117127
@app.command
118128
def push(
119-
template_file: Annotated[
129+
source: Annotated[
120130
Path,
121131
cyclopts.Parameter(
122-
help="Local Caddyfile.template to push to the remote host",
132+
help="Local file or directory to push to the remote host",
123133
),
124134
],
125135
output: Output = None,
126136
dry_run: DryRun = False,
137+
remote_dir: Annotated[
138+
Path | None,
139+
cyclopts.Parameter(
140+
name="--remote-dir",
141+
help="Override remote destination directory",
142+
),
143+
] = None,
144+
template: Annotated[
145+
str | None,
146+
cyclopts.Parameter(
147+
name="--template",
148+
help="Template file within directory to render (auto-detected from *.template)",
149+
),
150+
] = None,
151+
no_render: Annotated[
152+
bool,
153+
cyclopts.Parameter(
154+
name="--no-render",
155+
negative=[],
156+
help="Skip render/validate/reload after pushing",
157+
),
158+
] = False,
127159
) -> None:
128-
"""Push a local Caddyfile.template, render it, and reload Caddy.
160+
"""Push a local file or directory, render template, and reload Caddy.
161+
162+
When *source* is a single file, pushes it to the remote template path,
163+
renders with envsubst, validates, and reloads Caddy.
129164
130-
Combines three steps into one:
131-
1. Push local template to remote /etc/onetimesecret/Caddyfile.template
132-
2. Render with envsubst using HOST environment
133-
3. Reload Caddy to apply
165+
When *source* is a directory, pushes all files (recursively, skipping
166+
hidden files) to the remote destination, then optionally renders a
167+
``*.template`` file found within and reloads Caddy.
134168
135169
Requires --host (pushing to localhost is not useful).
136170
137171
Examples:
138172
ots --host eu-web-01 proxy push Caddyfile.template
139-
ots --host eu-web-01 proxy push Caddyfile.template --dry-run
173+
ots --host eu-web-01 proxy push caddy/ --remote-dir /etc/onetimesecret/
174+
ots --host eu-web-01 proxy push caddy/ --template Caddyfile.template
175+
ots --host eu-web-01 proxy push caddy/ --no-render
140176
"""
141177
from ots_shared.ssh import is_remote
142178

@@ -146,43 +182,129 @@ def push(
146182
if not is_remote(ex):
147183
raise SystemExit("proxy push requires a remote host. Use --host to specify one.")
148184

149-
if not template_file.exists():
150-
raise SystemExit(f"Local template not found: {template_file}")
185+
if not source.exists():
186+
raise SystemExit(f"Local source not found: {source}")
187+
188+
try:
189+
if source.is_dir():
190+
_push_directory(
191+
source,
192+
cfg=cfg,
193+
executor=ex,
194+
output=output,
195+
dry_run=dry_run,
196+
remote_dir=remote_dir,
197+
template_name=template,
198+
no_render=no_render,
199+
)
200+
else:
201+
_push_file(source, cfg=cfg, executor=ex, output=output, dry_run=dry_run)
202+
except ProxyError as e:
203+
raise SystemExit(str(e)) from e
204+
151205

206+
def _push_file(
207+
source: Path,
208+
*,
209+
cfg: Config,
210+
executor: Executor,
211+
output: Path | None,
212+
dry_run: bool,
213+
) -> None:
214+
"""Push a single template file, render, validate, and reload."""
152215
tpl_dest = cfg.proxy_template
153216
out = output or cfg.proxy_config
217+
content = source.read_text()
154218

155-
try:
156-
content = template_file.read_text()
219+
if dry_run:
220+
print(f"Would push: {source} -> {tpl_dest}")
221+
print(f"Would render: {tpl_dest} -> {out}")
222+
print("Would reload Caddy")
223+
return
157224

158-
if dry_run:
159-
print(f"Would push: {template_file} -> {tpl_dest}")
160-
print(f"Would render: {tpl_dest} -> {out}")
225+
# Step 1: Push template to remote
226+
result = executor.run(["mkdir", "-p", str(tpl_dest.parent)], timeout=15)
227+
result = executor.run(["tee", str(tpl_dest)], input=content, timeout=15)
228+
if not result.ok:
229+
raise ProxyError(f"Failed to write {tpl_dest}: {result.stderr}")
230+
print(f"[ok] Pushed {source} -> {tpl_dest}")
231+
232+
# Step 2: Render template on remote
233+
rendered = render_template(tpl_dest, executor=executor)
234+
validate_caddy_config(rendered, executor=executor, source_dir=tpl_dest.parent)
235+
result = executor.run(["mkdir", "-p", str(out.parent)], timeout=15)
236+
result = executor.run(["tee", str(out)], input=rendered, timeout=15)
237+
if not result.ok:
238+
raise ProxyError(f"Failed to write {out}: {result.stderr}")
239+
print(f"[ok] Rendered {tpl_dest} -> {out}")
240+
241+
# Step 3: Reload Caddy
242+
reload_caddy(executor=executor)
243+
print("[ok] Caddy reloaded")
244+
245+
246+
def _push_directory(
247+
source: Path,
248+
*,
249+
cfg: Config,
250+
executor: Executor,
251+
output: Path | None,
252+
dry_run: bool,
253+
remote_dir: Path | None,
254+
template_name: str | None,
255+
no_render: bool,
256+
) -> None:
257+
"""Push a directory of files, optionally render and reload."""
258+
dest = remote_dir or cfg.proxy_template.parent
259+
out = output or cfg.proxy_config
260+
261+
files = collect_local_files(source)
262+
if not files:
263+
raise ProxyError(f"No files found in {source}")
264+
265+
# Determine template file once for both dry_run and real execution
266+
tpl_name: str | None = None
267+
if not no_render:
268+
if template_name:
269+
tpl_name = template_name
270+
else:
271+
found = find_template_in_dir(source)
272+
if found:
273+
tpl_name = found.name
274+
275+
if dry_run:
276+
print(f"Would push {len(files)} file(s) to {dest}:")
277+
push_files_to_remote(source, dest, executor=executor, dry_run=True)
278+
if tpl_name:
279+
print(f"Would render: {dest / tpl_name} -> {out}")
161280
print("Would reload Caddy")
162-
return
281+
elif not no_render:
282+
print("No template found; skipping render/reload")
283+
return
163284

164-
# Step 1: Push template to remote
165-
result = ex.run(["mkdir", "-p", str(tpl_dest.parent)], timeout=15)
166-
result = ex.run(["tee", str(tpl_dest)], input=content, timeout=15)
167-
if not result.ok:
168-
raise ProxyError(f"Failed to write {tpl_dest}: {result.stderr}")
169-
print(f"[ok] Pushed {template_file} -> {tpl_dest}")
170-
171-
# Step 2: Render template on remote
172-
rendered = render_template(tpl_dest, executor=ex)
173-
validate_caddy_config(rendered, executor=ex)
174-
result = ex.run(["mkdir", "-p", str(out.parent)], timeout=15)
175-
result = ex.run(["tee", str(out)], input=rendered, timeout=15)
176-
if not result.ok:
177-
raise ProxyError(f"Failed to write {out}: {result.stderr}")
178-
print(f"[ok] Rendered {tpl_dest} -> {out}")
285+
# Push all files
286+
print(f"Pushing {len(files)} file(s) to {dest}:")
287+
push_files_to_remote(source, dest, executor=executor)
288+
print(f"[ok] Pushed {len(files)} file(s)")
179289

180-
# Step 3: Reload Caddy
181-
reload_caddy(executor=ex)
182-
print("[ok] Caddy reloaded")
290+
if not tpl_name:
291+
if not no_render:
292+
print("No template found; skipping render/reload")
293+
return
183294

184-
except ProxyError as e:
185-
raise SystemExit(str(e)) from e
295+
tpl_path = dest / tpl_name
296+
297+
# Render, validate, reload
298+
rendered = render_template(tpl_path, executor=executor)
299+
validate_caddy_config(rendered, executor=executor, source_dir=dest)
300+
result = executor.run(["mkdir", "-p", str(out.parent)], timeout=15)
301+
result = executor.run(["tee", str(out)], input=rendered, timeout=15)
302+
if not result.ok:
303+
raise ProxyError(f"Failed to write {out}: {result.stderr}")
304+
print(f"[ok] Rendered {tpl_path} -> {out}")
305+
306+
reload_caddy(executor=executor)
307+
print("[ok] Caddy reloaded")
186308

187309

188310
@app.command

0 commit comments

Comments
 (0)