feat(ansible): GIT_ASKPASS per-source HTTP token auth + settings UI

This commit is contained in:
2026-06-05 18:48:00 -04:00
parent ea47169049
commit d906232eb2
4 changed files with 232 additions and 23 deletions
+65 -23
View File
@@ -1,6 +1,9 @@
from __future__ import annotations
import asyncio
import logging
import os
import stat
import tempfile
from pathlib import Path
logger = logging.getLogger(__name__)
@@ -48,6 +51,7 @@ def get_sources(ansible_cfg: dict) -> list[dict]:
"url": src.get("url"),
"branch": src.get("branch", "main"),
"pull_interval_seconds": int(src.get("pull_interval_seconds", 3600)),
"http_token": src.get("http_token", "") if src_type == "git" else "",
})
return result
@@ -99,27 +103,65 @@ def read_playbook(source_path: str, relative_path: str) -> str | None:
async def git_pull(source: dict) -> None:
"""Clone the git repo if absent; pull if already present."""
"""Clone the git repo if absent; pull if already present.
When source['http_token'] is set, injects credentials via a temporary
GIT_ASKPASS script so they never touch .git/config or process args.
"""
path = Path(source["path"])
if not (path / ".git").exists():
path.mkdir(parents=True, exist_ok=True)
proc = await asyncio.create_subprocess_exec(
"git", "clone",
"--branch", source["branch"],
"--single-branch",
source["url"], str(path),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
_, stderr = await proc.communicate()
if proc.returncode != 0:
logger.error("git clone failed for %r: %s", source["name"], stderr.decode(errors="replace"))
else:
proc = await asyncio.create_subprocess_exec(
"git", "-C", str(path), "pull",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
_, stderr = await proc.communicate()
if proc.returncode != 0:
logger.error("git pull failed for %r: %s", source["name"], stderr.decode(errors="replace"))
token = source.get("http_token", "")
env = None
askpass_path = None
if token:
fd, askpass_path = tempfile.mkstemp(prefix="steward-askpass-", suffix=".sh")
try:
script = (
"#!/bin/sh\n"
'case "$1" in\n'
' Username*) echo "oauth2" ;;\n'
f' Password*) echo "{token}" ;;\n'
"esac\n"
)
os.write(fd, script.encode())
finally:
os.close(fd)
os.chmod(askpass_path, stat.S_IRWXU)
env = {**os.environ, "GIT_ASKPASS": askpass_path}
try:
if not (path / ".git").exists():
path.mkdir(parents=True, exist_ok=True)
proc = await asyncio.create_subprocess_exec(
"git", "clone",
"--branch", source["branch"],
"--single-branch",
source["url"], str(path),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=env,
)
_, stderr = await proc.communicate()
if proc.returncode != 0:
logger.error(
"git clone failed for %r: %s",
source["name"],
stderr.decode(errors="replace"),
)
else:
proc = await asyncio.create_subprocess_exec(
"git", "-C", str(path), "pull",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=env,
)
_, stderr = await proc.communicate()
if proc.returncode != 0:
logger.error(
"git pull failed for %r: %s",
source["name"],
stderr.decode(errors="replace"),
)
finally:
if askpass_path and os.path.exists(askpass_path):
os.unlink(askpass_path)
+9
View File
@@ -226,6 +226,9 @@ async def ansible_add_source():
entry["pull_interval_seconds"] = int(form.get("pull_interval_seconds", 3600))
except ValueError:
entry["pull_interval_seconds"] = 3600
token = form.get("http_token", "").strip()
if token:
entry["http_token"] = token
else:
entry["path"] = form.get("path", "").strip()
sources = await _get_ansible_sources()
@@ -263,6 +266,12 @@ async def ansible_save_source(idx: int):
entry["pull_interval_seconds"] = int(form.get("pull_interval_seconds", 3600))
except ValueError:
entry["pull_interval_seconds"] = 3600
token = form.get("http_token", "").strip()
if token:
entry["http_token"] = token
elif "http_token" in sources[idx]:
# Preserve existing token — password fields don't round-trip via browser
entry["http_token"] = sources[idx]["http_token"]
else:
entry["path"] = form.get("path", "").strip()
sources[idx] = entry
@@ -94,6 +94,15 @@
<label style="font-size:0.8rem;">Pull interval (seconds)</label>
<input type="number" name="pull_interval_seconds" value="{{ src.pull_interval_seconds or 3600 }}" min="60">
</div>
<div class="form-group" style="margin:0;grid-column:1/-1;">
<label style="font-size:0.8rem;">
HTTP Token
<span style="color:var(--text-muted);font-weight:normal;">(optional — Gitea PAT for private repos)</span>
</label>
<input type="password" name="http_token"
placeholder="{% if src.http_token %}token saved — enter new to replace{% endif %}"
autocomplete="new-password">
</div>
</div>
<div id="ansible-edit-local-fields-{{ idx }}"
@@ -165,6 +174,12 @@
<label style="font-size:0.8rem;">Pull interval (seconds)</label>
<input type="number" name="pull_interval_seconds" value="3600" min="60">
</div>
<div class="form-group" style="margin:0;grid-column:1/-1;">
<label style="font-size:0.8rem;">HTTP Token
<span style="color:var(--text-muted);font-weight:normal;">(optional)</span>
</label>
<input type="password" name="http_token" autocomplete="new-password">
</div>
</div>
<div id="ansible-add-local-fields"