kin: auto-commit after pipeline
This commit is contained in:
parent
88e855dc9e
commit
f9a9af3271
2 changed files with 142 additions and 2 deletions
|
|
@ -7,6 +7,7 @@ Business logic for project deployments:
|
||||||
- Dependency chain traversal via project_links
|
- Dependency chain traversal via project_links
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import shlex
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
|
|
@ -50,7 +51,7 @@ def _build_ssh_cmd(project: dict, command: str) -> list[str]:
|
||||||
proxy_jump = project.get("ssh_proxy_jump")
|
proxy_jump = project.get("ssh_proxy_jump")
|
||||||
deploy_path = project.get("deploy_path")
|
deploy_path = project.get("deploy_path")
|
||||||
|
|
||||||
full_cmd = f"cd {deploy_path} && {command}" if deploy_path else command
|
full_cmd = f"cd {shlex.quote(deploy_path)} && {command}" if deploy_path else command
|
||||||
|
|
||||||
cmd = ["ssh"]
|
cmd = ["ssh"]
|
||||||
if ssh_key:
|
if ssh_key:
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,16 @@ Covers:
|
||||||
- core/db.py: deploy columns migration
|
- core/db.py: deploy columns migration
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import shlex
|
||||||
import pytest
|
import pytest
|
||||||
from unittest.mock import patch, MagicMock
|
from unittest.mock import patch, MagicMock
|
||||||
|
|
||||||
from core.db import init_db, _migrate
|
from core.db import init_db, _migrate
|
||||||
from core import models
|
from core import models
|
||||||
from core.deploy import build_deploy_steps, execute_deploy, get_deploy_chain, deploy_with_dependents
|
from core.deploy import (
|
||||||
|
build_deploy_steps, execute_deploy, get_deploy_chain, deploy_with_dependents,
|
||||||
|
_build_ssh_cmd,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -452,3 +456,138 @@ class TestDeployColumnsMigration:
|
||||||
tables = {row["name"] for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()}
|
tables = {row["name"] for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()}
|
||||||
assert "project_links" in tables
|
assert "project_links" in tables
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 8. _build_ssh_cmd — security: shlex.quote on deploy_path (fix #426)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestSSHBuildCmd:
|
||||||
|
def test_deploy_path_is_shlex_quoted(self):
|
||||||
|
"""deploy_path must be quoted via shlex.quote to prevent command injection."""
|
||||||
|
project = {
|
||||||
|
"deploy_host": "10.0.0.1",
|
||||||
|
"deploy_path": "/srv/my app/v2",
|
||||||
|
}
|
||||||
|
cmd = _build_ssh_cmd(project, "git pull")
|
||||||
|
full_cmd_arg = cmd[-1]
|
||||||
|
assert shlex.quote("/srv/my app/v2") in full_cmd_arg
|
||||||
|
|
||||||
|
def test_deploy_path_with_spaces_no_raw_unquoted(self):
|
||||||
|
"""Path with spaces must NOT appear unquoted after 'cd '."""
|
||||||
|
project = {
|
||||||
|
"deploy_host": "myserver",
|
||||||
|
"deploy_path": "/srv/app with spaces",
|
||||||
|
}
|
||||||
|
cmd = _build_ssh_cmd(project, "git pull")
|
||||||
|
full_cmd_arg = cmd[-1]
|
||||||
|
assert "cd /srv/app with spaces " not in full_cmd_arg
|
||||||
|
|
||||||
|
def test_deploy_path_with_dollar_sign_is_quoted(self):
|
||||||
|
"""Path containing $ must be quoted to prevent shell variable expansion."""
|
||||||
|
project = {
|
||||||
|
"deploy_host": "myserver",
|
||||||
|
"deploy_path": "/srv/$PROJECT",
|
||||||
|
}
|
||||||
|
cmd = _build_ssh_cmd(project, "git pull")
|
||||||
|
full_cmd_arg = cmd[-1]
|
||||||
|
assert "cd /srv/$PROJECT " not in full_cmd_arg
|
||||||
|
assert shlex.quote("/srv/$PROJECT") in full_cmd_arg
|
||||||
|
|
||||||
|
def test_normal_path_still_works(self):
|
||||||
|
"""Standard path without special chars should still produce valid cd command."""
|
||||||
|
project = {
|
||||||
|
"deploy_host": "10.0.0.5",
|
||||||
|
"deploy_path": "/srv/api",
|
||||||
|
}
|
||||||
|
cmd = _build_ssh_cmd(project, "git pull")
|
||||||
|
full_cmd_arg = cmd[-1]
|
||||||
|
assert "/srv/api" in full_cmd_arg
|
||||||
|
assert "git pull" in full_cmd_arg
|
||||||
|
|
||||||
|
def test_no_deploy_path_uses_command_directly(self):
|
||||||
|
"""When deploy_path is None, command is used as-is without cd."""
|
||||||
|
project = {
|
||||||
|
"deploy_host": "myserver",
|
||||||
|
"deploy_path": None,
|
||||||
|
}
|
||||||
|
cmd = _build_ssh_cmd(project, "git pull")
|
||||||
|
full_cmd_arg = cmd[-1]
|
||||||
|
assert "cd" not in full_cmd_arg
|
||||||
|
assert full_cmd_arg == "git pull"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 9. deploy_with_dependents — cascade deploy unit tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestDeployWithDependents:
|
||||||
|
def _setup_chain(self, conn):
|
||||||
|
"""Create api + frontend where frontend depends_on api."""
|
||||||
|
models.create_project(conn, "api", "API", "/srv/api")
|
||||||
|
models.create_project(conn, "fe", "Frontend", "/srv/fe")
|
||||||
|
models.update_project(conn, "api", deploy_runtime="docker", deploy_path="/srv/api")
|
||||||
|
models.update_project(conn, "fe", deploy_runtime="static", deploy_path="/srv/fe")
|
||||||
|
models.create_project_link(conn, "fe", "api", "depends_on")
|
||||||
|
|
||||||
|
def test_deploys_main_and_dependent_on_success(self, conn):
|
||||||
|
"""If main project deploys successfully, dependent is also deployed."""
|
||||||
|
self._setup_chain(conn)
|
||||||
|
with patch("core.deploy.subprocess.run", return_value=_make_proc()):
|
||||||
|
result = deploy_with_dependents(conn, "api")
|
||||||
|
assert result["success"] is True
|
||||||
|
assert "fe" in result["dependents_deployed"]
|
||||||
|
|
||||||
|
def test_main_failure_skips_dependents(self, conn):
|
||||||
|
"""If main project deployment fails, dependents are NOT deployed."""
|
||||||
|
self._setup_chain(conn)
|
||||||
|
with patch("core.deploy.subprocess.run", return_value=_make_proc(returncode=1)):
|
||||||
|
result = deploy_with_dependents(conn, "api")
|
||||||
|
assert result["success"] is False
|
||||||
|
assert result["dependents_deployed"] == []
|
||||||
|
|
||||||
|
def test_unknown_project_returns_error(self, conn):
|
||||||
|
"""Deploying non-existent project returns success=False with error key."""
|
||||||
|
result = deploy_with_dependents(conn, "nonexistent")
|
||||||
|
assert result["success"] is False
|
||||||
|
assert "error" in result
|
||||||
|
|
||||||
|
def test_no_dependents_returns_empty_list(self, conn):
|
||||||
|
"""Project with no dependents returns empty dependents_deployed list."""
|
||||||
|
models.create_project(conn, "solo", "Solo", "/srv/solo")
|
||||||
|
models.update_project(conn, "solo", deploy_runtime="python", deploy_path="/srv/solo")
|
||||||
|
with patch("core.deploy.subprocess.run", return_value=_make_proc()):
|
||||||
|
result = deploy_with_dependents(conn, "solo")
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["dependents_deployed"] == []
|
||||||
|
|
||||||
|
def test_result_contains_main_steps_and_results(self, conn):
|
||||||
|
"""Result always includes 'steps' and 'results' keys from main project."""
|
||||||
|
models.create_project(conn, "api2", "API2", "/srv/api2")
|
||||||
|
models.update_project(conn, "api2", deploy_runtime="node", deploy_path="/srv/api2")
|
||||||
|
with patch("core.deploy.subprocess.run", return_value=_make_proc()):
|
||||||
|
result = deploy_with_dependents(conn, "api2")
|
||||||
|
assert "steps" in result
|
||||||
|
assert "results" in result
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 10. build_deploy_steps — python runtime full steps with restart_cmd
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestBuildDeployStepsPythonRestartCmd:
|
||||||
|
def test_python_with_restart_cmd_full_steps(self):
|
||||||
|
"""Python runtime + restart_cmd yields full 3-step list."""
|
||||||
|
p = {"deploy_runtime": "python", "deploy_restart_cmd": "systemctl restart myapp"}
|
||||||
|
steps = build_deploy_steps(p)
|
||||||
|
assert steps == ["git pull", "pip install -r requirements.txt", "systemctl restart myapp"]
|
||||||
|
|
||||||
|
def test_node_with_custom_restart_cmd_appends_as_fourth_step(self):
|
||||||
|
"""Node runtime default ends with pm2 restart all; custom cmd is appended after."""
|
||||||
|
p = {"deploy_runtime": "node", "deploy_restart_cmd": "pm2 restart myservice"}
|
||||||
|
steps = build_deploy_steps(p)
|
||||||
|
assert steps[0] == "git pull"
|
||||||
|
assert steps[1] == "npm install --production"
|
||||||
|
assert steps[2] == "pm2 restart all"
|
||||||
|
assert steps[3] == "pm2 restart myservice"
|
||||||
|
assert len(steps) == 4
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue