Publish curated Unraid-AppOps-Gateway source from ba66029223865607deddc234e283c11127324eb8
This commit is contained in:
1 parent
820244dbb3
commit
8dd4b40252
52 files changed
+11814
-288
No files matched your search
@@ -10,16 +10,17 @@ import httpx
|
||||
|
||||
from ..connectors.portainer import PortainerConnector
|
||||
from ..core.config import settings
|
||||
from ..core.redaction import redact_message
|
||||
from .controller_artifact_helper_spec import (
|
||||
_IMAGE_ID,
|
||||
_REVISION,
|
||||
CONTROLLER_VERSION,
|
||||
HELPER_LABEL,
|
||||
HELPER_NAME,
|
||||
SOURCE_REPOSITORY as SOURCE_REPOSITORY,
|
||||
_IMAGE_ID,
|
||||
_REVISION,
|
||||
helper_container_spec,
|
||||
)
|
||||
from .controller_artifact_helper_spec import (
|
||||
SOURCE_REPOSITORY as SOURCE_REPOSITORY,
|
||||
)
|
||||
from .deployment_broker import DeploymentBrokerClient
|
||||
|
||||
GITEA_OWNER = "Jens"
|
||||
@@ -28,6 +29,132 @@ REQUIRED_PUSH_CONTEXT = "Managed validation / full (push)"
|
||||
CONTROLLER_CONTAINER = "gitea-deploy-control"
|
||||
_HELPER_TIMEOUT_SECONDS = 240
|
||||
_TIMESTAMP = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$")
|
||||
_MAX_HELPER_LOG_CHARS = 4000
|
||||
_MAX_HELPER_LOG_LINES = 200
|
||||
_MAX_HELPER_LOG_WIRE_BYTES = _MAX_HELPER_LOG_CHARS + (8 * _MAX_HELPER_LOG_LINES)
|
||||
_HELPER_DIAGNOSTIC_PREFIX = "APPOPS_CONTROLLER_PROMOTION_DIAGNOSTIC"
|
||||
_HELPER_DIAGNOSTIC_CODES = frozenset({"helper_failed"})
|
||||
_HELPER_DIAGNOSTIC_PHASES = frozenset(
|
||||
{
|
||||
"shell_setup",
|
||||
"tmp_root",
|
||||
"workspace_create",
|
||||
"workspace_validate",
|
||||
"tool_git",
|
||||
"tool_docker",
|
||||
"tool_compose",
|
||||
"tool_python",
|
||||
"tool_tar",
|
||||
"tool_curl",
|
||||
"source_commit",
|
||||
"source_tree",
|
||||
"source_archive",
|
||||
"archive_permissions",
|
||||
"archive_digest",
|
||||
"upgrade_script",
|
||||
"upgrade_permissions",
|
||||
"upgrade",
|
||||
"cleanup",
|
||||
}
|
||||
)
|
||||
_HELPER_DIAGNOSTIC_LINE = re.compile(
|
||||
rf"^{_HELPER_DIAGNOSTIC_PREFIX} code=([a-z_]+) phase=([a-z_]+) "
|
||||
r"exitCode=(0|[1-9][0-9]{0,2})$"
|
||||
)
|
||||
|
||||
|
||||
def helper_failure_diagnostics(exit_code: Any) -> dict[str, Any]:
|
||||
safe_exit_code = exit_code if type(exit_code) is int and 0 <= exit_code <= 255 else None
|
||||
return {"code": "helper_failed", "phase": "unknown", "exitCode": safe_exit_code}
|
||||
|
||||
|
||||
def safe_helper_diagnostics(value: Any) -> dict[str, Any] | None:
|
||||
if not isinstance(value, dict) or set(value) != {"code", "phase", "exitCode"}:
|
||||
return None
|
||||
code = value.get("code")
|
||||
phase = value.get("phase")
|
||||
exit_code = value.get("exitCode")
|
||||
if not isinstance(code, str) or code not in _HELPER_DIAGNOSTIC_CODES:
|
||||
return None
|
||||
if not isinstance(phase, str) or (
|
||||
phase != "unknown" and phase not in _HELPER_DIAGNOSTIC_PHASES
|
||||
):
|
||||
return None
|
||||
if exit_code is not None and (
|
||||
type(exit_code) is not int or not 0 <= exit_code <= 255
|
||||
):
|
||||
return None
|
||||
return {"code": code, "phase": phase, "exitCode": exit_code}
|
||||
|
||||
|
||||
def parse_helper_diagnostics(log_text: Any, exit_code: Any) -> dict[str, Any]:
|
||||
fallback = helper_failure_diagnostics(exit_code)
|
||||
if (
|
||||
fallback["exitCode"] is None
|
||||
or not isinstance(log_text, str)
|
||||
or len(log_text) > _MAX_HELPER_LOG_CHARS
|
||||
):
|
||||
return fallback
|
||||
|
||||
candidates = [
|
||||
line
|
||||
for line in log_text.splitlines()
|
||||
if line.startswith(_HELPER_DIAGNOSTIC_PREFIX)
|
||||
]
|
||||
if len(candidates) != 1:
|
||||
return fallback
|
||||
match = _HELPER_DIAGNOSTIC_LINE.fullmatch(candidates[0])
|
||||
if match is None:
|
||||
return fallback
|
||||
code, phase, marker_exit_code = match.groups()
|
||||
parsed_exit_code = int(marker_exit_code)
|
||||
if (
|
||||
code not in _HELPER_DIAGNOSTIC_CODES
|
||||
or phase not in _HELPER_DIAGNOSTIC_PHASES
|
||||
or parsed_exit_code != fallback["exitCode"]
|
||||
):
|
||||
return fallback
|
||||
return {"code": code, "phase": phase, "exitCode": parsed_exit_code}
|
||||
|
||||
|
||||
def _decode_docker_log_stream(content: Any) -> str | None:
|
||||
if (
|
||||
not isinstance(content, bytes)
|
||||
or not content
|
||||
or len(content) > _MAX_HELPER_LOG_WIRE_BYTES
|
||||
):
|
||||
return None
|
||||
|
||||
offset = 0
|
||||
frame_count = 0
|
||||
payload_size = 0
|
||||
payloads: list[bytes] = []
|
||||
while offset < len(content):
|
||||
if frame_count >= _MAX_HELPER_LOG_LINES or len(content) - offset < 8:
|
||||
return None
|
||||
|
||||
header = content[offset : offset + 8]
|
||||
stream_type = header[0]
|
||||
if stream_type not in {1, 2} or header[1:4] != b"\x00\x00\x00":
|
||||
return None
|
||||
frame_size = int.from_bytes(header[4:8], "big")
|
||||
if frame_size == 0 or frame_size > _MAX_HELPER_LOG_CHARS - payload_size:
|
||||
return None
|
||||
|
||||
payload_start = offset + 8
|
||||
payload_end = payload_start + frame_size
|
||||
if payload_end > len(content):
|
||||
return None
|
||||
|
||||
payloads.append(content[payload_start:payload_end])
|
||||
payload_size += frame_size
|
||||
frame_count += 1
|
||||
offset = payload_end
|
||||
|
||||
try:
|
||||
return b"".join(payloads).decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return None
|
||||
|
||||
|
||||
class ControllerArtifactPromotionError(RuntimeError):
|
||||
@@ -257,10 +384,29 @@ class ControllerArtifactPromoter:
|
||||
}
|
||||
|
||||
exit_code = state.get("ExitCode")
|
||||
logs = self.portainer.get_container_logs(helper_id, tail=200)
|
||||
log_lines = logs.get("logs") if isinstance(logs, dict) and isinstance(logs.get("logs"), list) else []
|
||||
tail = "\n".join(str(line) for line in log_lines[-40:])[-4000:]
|
||||
if exit_code != 0:
|
||||
if type(exit_code) is not int or exit_code != 0:
|
||||
try:
|
||||
logs_response = self.portainer._request(
|
||||
"GET",
|
||||
f"/api/endpoints/{endpoint}/docker/containers/{quote(helper_id, safe='')}/logs",
|
||||
params={
|
||||
"stdout": 1,
|
||||
"stderr": 1,
|
||||
"tail": _MAX_HELPER_LOG_LINES,
|
||||
},
|
||||
)
|
||||
log_text = (
|
||||
_decode_docker_log_stream(logs_response.content)
|
||||
if isinstance(logs_response, httpx.Response)
|
||||
and logs_response.status_code == 200
|
||||
else None
|
||||
)
|
||||
except (httpx.HTTPError, RuntimeError, ValueError):
|
||||
log_text = None
|
||||
diagnostics = parse_helper_diagnostics(
|
||||
log_text,
|
||||
exit_code,
|
||||
)
|
||||
try:
|
||||
self._remove_helper(helper_id)
|
||||
except RuntimeError:
|
||||
@@ -269,15 +415,14 @@ class ControllerArtifactPromoter:
|
||||
"status": "failed",
|
||||
"revision": revision,
|
||||
"reason": "controller_promoter_helper_failed",
|
||||
"exitCode": exit_code,
|
||||
"logTail": redact_message(tail),
|
||||
"diagnostics": diagnostics,
|
||||
}
|
||||
|
||||
try:
|
||||
self._remove_helper(helper_id)
|
||||
except RuntimeError:
|
||||
pass
|
||||
return {"status": "promoted", "revision": revision, "logTail": redact_message(tail)}
|
||||
return {"status": "promoted", "revision": revision}
|
||||
|
||||
def run(self) -> dict[str, Any]:
|
||||
revision = self._validated_main_revision()
|
||||
|
||||
Reference in new issue
Block a user