Ansible schedule form: structured playbook-variable fields + first-item dropdown defaults #2
+65
-23
@@ -1,6 +1,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
import stat
|
||||||
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -48,6 +51,7 @@ def get_sources(ansible_cfg: dict) -> list[dict]:
|
|||||||
"url": src.get("url"),
|
"url": src.get("url"),
|
||||||
"branch": src.get("branch", "main"),
|
"branch": src.get("branch", "main"),
|
||||||
"pull_interval_seconds": int(src.get("pull_interval_seconds", 3600)),
|
"pull_interval_seconds": int(src.get("pull_interval_seconds", 3600)),
|
||||||
|
"http_token": src.get("http_token", "") if src_type == "git" else "",
|
||||||
})
|
})
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -99,27 +103,65 @@ def read_playbook(source_path: str, relative_path: str) -> str | None:
|
|||||||
|
|
||||||
|
|
||||||
async def git_pull(source: dict) -> 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"])
|
path = Path(source["path"])
|
||||||
if not (path / ".git").exists():
|
token = source.get("http_token", "")
|
||||||
path.mkdir(parents=True, exist_ok=True)
|
|
||||||
proc = await asyncio.create_subprocess_exec(
|
env = None
|
||||||
"git", "clone",
|
askpass_path = None
|
||||||
"--branch", source["branch"],
|
if token:
|
||||||
"--single-branch",
|
fd, askpass_path = tempfile.mkstemp(prefix="steward-askpass-", suffix=".sh")
|
||||||
source["url"], str(path),
|
try:
|
||||||
stdout=asyncio.subprocess.PIPE,
|
script = (
|
||||||
stderr=asyncio.subprocess.PIPE,
|
"#!/bin/sh\n"
|
||||||
)
|
'case "$1" in\n'
|
||||||
_, stderr = await proc.communicate()
|
' Username*) echo "oauth2" ;;\n'
|
||||||
if proc.returncode != 0:
|
f' Password*) echo "{token}" ;;\n'
|
||||||
logger.error("git clone failed for %r: %s", source["name"], stderr.decode(errors="replace"))
|
"esac\n"
|
||||||
else:
|
)
|
||||||
proc = await asyncio.create_subprocess_exec(
|
os.write(fd, script.encode())
|
||||||
"git", "-C", str(path), "pull",
|
finally:
|
||||||
stdout=asyncio.subprocess.PIPE,
|
os.close(fd)
|
||||||
stderr=asyncio.subprocess.PIPE,
|
os.chmod(askpass_path, stat.S_IRWXU)
|
||||||
)
|
env = {**os.environ, "GIT_ASKPASS": askpass_path}
|
||||||
_, stderr = await proc.communicate()
|
|
||||||
if proc.returncode != 0:
|
try:
|
||||||
logger.error("git pull failed for %r: %s", source["name"], stderr.decode(errors="replace"))
|
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))
|
entry["pull_interval_seconds"] = int(form.get("pull_interval_seconds", 3600))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
entry["pull_interval_seconds"] = 3600
|
entry["pull_interval_seconds"] = 3600
|
||||||
|
token = form.get("http_token", "").strip()
|
||||||
|
if token:
|
||||||
|
entry["http_token"] = token
|
||||||
else:
|
else:
|
||||||
entry["path"] = form.get("path", "").strip()
|
entry["path"] = form.get("path", "").strip()
|
||||||
sources = await _get_ansible_sources()
|
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))
|
entry["pull_interval_seconds"] = int(form.get("pull_interval_seconds", 3600))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
entry["pull_interval_seconds"] = 3600
|
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:
|
else:
|
||||||
entry["path"] = form.get("path", "").strip()
|
entry["path"] = form.get("path", "").strip()
|
||||||
sources[idx] = entry
|
sources[idx] = entry
|
||||||
|
|||||||
@@ -94,6 +94,15 @@
|
|||||||
<label style="font-size:0.8rem;">Pull interval (seconds)</label>
|
<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">
|
<input type="number" name="pull_interval_seconds" value="{{ src.pull_interval_seconds or 3600 }}" min="60">
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div id="ansible-edit-local-fields-{{ idx }}"
|
<div id="ansible-edit-local-fields-{{ idx }}"
|
||||||
@@ -165,6 +174,12 @@
|
|||||||
<label style="font-size:0.8rem;">Pull interval (seconds)</label>
|
<label style="font-size:0.8rem;">Pull interval (seconds)</label>
|
||||||
<input type="number" name="pull_interval_seconds" value="3600" min="60">
|
<input type="number" name="pull_interval_seconds" value="3600" min="60">
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div id="ansible-add-local-fields"
|
<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