Ansible schedule form: structured playbook-variable fields + first-item dropdown defaults #2
+65
-23
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
"""Unit tests for GIT_ASKPASS injection in git_pull()."""
|
||||
import asyncio
|
||||
import os
|
||||
import stat
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
import pytest
|
||||
|
||||
|
||||
def test_get_sources_includes_http_token(tmp_path):
|
||||
"""get_sources() must pass http_token through from config."""
|
||||
from steward.ansible.sources import get_sources
|
||||
|
||||
cfg = {
|
||||
"cache_dir": str(tmp_path),
|
||||
"sources": [
|
||||
{
|
||||
"name": "repo",
|
||||
"type": "git",
|
||||
"url": "https://git.example.com/org/repo.git",
|
||||
"http_token": "mytoken123",
|
||||
}
|
||||
],
|
||||
}
|
||||
sources = get_sources(cfg)
|
||||
assert sources[0]["http_token"] == "mytoken123"
|
||||
|
||||
|
||||
def test_get_sources_missing_http_token_defaults_empty(tmp_path):
|
||||
"""http_token defaults to empty string when not in source config."""
|
||||
from steward.ansible.sources import get_sources
|
||||
|
||||
cfg = {
|
||||
"cache_dir": str(tmp_path),
|
||||
"sources": [
|
||||
{"name": "r", "type": "git", "url": "https://example.com/r.git"}
|
||||
],
|
||||
}
|
||||
sources = get_sources(cfg)
|
||||
assert sources[0]["http_token"] == ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_git_pull_creates_askpass_when_token_set(tmp_path):
|
||||
"""git_pull() sets GIT_ASKPASS env when source has http_token."""
|
||||
from steward.ansible.sources import git_pull
|
||||
|
||||
# Simulate repo already cloned (has .git dir) so we hit the pull path.
|
||||
repo_path = tmp_path / "repo"
|
||||
(repo_path / ".git").mkdir(parents=True)
|
||||
|
||||
source = {
|
||||
"name": "repo",
|
||||
"path": str(repo_path),
|
||||
"url": "https://git.example.com/org/repo.git",
|
||||
"branch": "main",
|
||||
"http_token": "secret-token",
|
||||
}
|
||||
|
||||
captured_env = {}
|
||||
|
||||
async def fake_subprocess(*args, **kwargs):
|
||||
captured_env.update(kwargs.get("env", {}))
|
||||
proc = MagicMock()
|
||||
proc.returncode = 0
|
||||
proc.communicate = AsyncMock(return_value=(b"", b""))
|
||||
return proc
|
||||
|
||||
with patch("asyncio.create_subprocess_exec", side_effect=fake_subprocess):
|
||||
await git_pull(source)
|
||||
|
||||
assert "GIT_ASKPASS" in captured_env
|
||||
askpass_path = captured_env["GIT_ASKPASS"]
|
||||
# Script should be cleaned up after the call
|
||||
assert not os.path.exists(askpass_path), "askpass script should be deleted in finally"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_git_pull_askpass_contains_token(tmp_path):
|
||||
"""The askpass script content includes the token before cleanup."""
|
||||
from steward.ansible.sources import git_pull
|
||||
|
||||
repo_path = tmp_path / "repo"
|
||||
(repo_path / ".git").mkdir(parents=True)
|
||||
|
||||
source = {
|
||||
"name": "repo",
|
||||
"path": str(repo_path),
|
||||
"url": "https://git.example.com/org/repo.git",
|
||||
"branch": "main",
|
||||
"http_token": "secret-token",
|
||||
}
|
||||
|
||||
script_content_captured = {}
|
||||
|
||||
async def fake_subprocess(*args, **kwargs):
|
||||
askpass = kwargs.get("env", {}).get("GIT_ASKPASS", "")
|
||||
if askpass and os.path.exists(askpass):
|
||||
script_content_captured["content"] = Path(askpass).read_text()
|
||||
mode = stat.S_IMODE(os.stat(askpass).st_mode)
|
||||
script_content_captured["executable"] = bool(mode & stat.S_IXUSR)
|
||||
proc = MagicMock()
|
||||
proc.returncode = 0
|
||||
proc.communicate = AsyncMock(return_value=(b"", b""))
|
||||
return proc
|
||||
|
||||
with patch("asyncio.create_subprocess_exec", side_effect=fake_subprocess):
|
||||
await git_pull(source)
|
||||
|
||||
assert "secret-token" in script_content_captured.get("content", "")
|
||||
assert "oauth2" in script_content_captured.get("content", "")
|
||||
assert script_content_captured.get("executable") is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_git_pull_no_askpass_without_token(tmp_path):
|
||||
"""git_pull() does NOT set GIT_ASKPASS when http_token is empty."""
|
||||
from steward.ansible.sources import git_pull
|
||||
|
||||
repo_path = tmp_path / "repo"
|
||||
(repo_path / ".git").mkdir(parents=True)
|
||||
|
||||
source = {
|
||||
"name": "repo",
|
||||
"path": str(repo_path),
|
||||
"url": "https://git.example.com/org/repo.git",
|
||||
"branch": "main",
|
||||
"http_token": "",
|
||||
}
|
||||
|
||||
captured_env = {}
|
||||
|
||||
async def fake_subprocess(*args, **kwargs):
|
||||
captured_env.update(kwargs.get("env") or {})
|
||||
proc = MagicMock()
|
||||
proc.returncode = 0
|
||||
proc.communicate = AsyncMock(return_value=(b"", b""))
|
||||
return proc
|
||||
|
||||
with patch("asyncio.create_subprocess_exec", side_effect=fake_subprocess):
|
||||
await git_pull(source)
|
||||
|
||||
assert "GIT_ASKPASS" not in captured_env
|
||||
Reference in New Issue
Block a user