Start Magent beta overhaul
Magent CI/CD / verify (push) Successful in 13m40s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 19s

This commit is contained in:
2026-08-29 20:27:17 +12:00
commit 655e2f8158
111 changed files with 41873 additions and 0 deletions
+31
View File
@@ -0,0 +1,31 @@
$ErrorActionPreference = "Stop"
$repoRoot = Resolve-Path "$PSScriptRoot\\.."
Set-Location $repoRoot
powershell -ExecutionPolicy Bypass -File (Join-Path $repoRoot "scripts\run_backend_quality_gate.ps1")
if ($LASTEXITCODE -ne 0) {
throw "scripts/run_backend_quality_gate.ps1 failed with exit code $LASTEXITCODE."
}
$now = Get-Date
$buildNumber = "{0}{1}{2}{3}{4}" -f $now.ToString("dd"), $now.ToString("MM"), $now.ToString("yy"), $now.ToString("HH"), $now.ToString("mm")
Write-Host "Build number: $buildNumber"
git tag $buildNumber
git push origin $buildNumber
$backendImage = "rephl3xnz/magent-backend:$buildNumber"
$frontendImage = "rephl3xnz/magent-frontend:$buildNumber"
docker build -f backend/Dockerfile -t $backendImage --build-arg BUILD_NUMBER=$buildNumber .
docker build -f frontend/Dockerfile -t $frontendImage frontend
docker tag $backendImage rephl3xnz/magent-backend:latest
docker tag $frontendImage rephl3xnz/magent-frontend:latest
docker push $backendImage
docker push $frontendImage
docker push rephl3xnz/magent-backend:latest
docker push rephl3xnz/magent-frontend:latest
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$repo_root"
python_bin="${PYTHON_BIN:-python3}"
echo "Installing backend Python requirements"
"$python_bin" -m pip install -r backend/requirements.txt
echo "Running Python dependency integrity check"
"$python_bin" -m pip check
echo "Running backend unit tests"
"$python_bin" -m unittest discover -s backend/tests -p "test_*.py" -v
echo "Backend quality gate passed"
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$repo_root"
deploy_host="${DEPLOY_HOST:-AMS-DEV01}"
deploy_user="${DEPLOY_USER:-zak}"
deploy_path="${DEPLOY_PATH:-/home/${deploy_user}/magent}"
ssh_opts="${DEPLOY_SSH_OPTS:-"-o StrictHostKeyChecking=accept-new"}"
timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
remote="${deploy_user}@${deploy_host}"
echo "Deploying tracked repository contents to ${remote}:${deploy_path}"
git archive --format=tar HEAD | ssh ${ssh_opts} "${remote}" "
set -e
mkdir -p '${deploy_path}'
backup_root=\"\${HOME}/magent-backups/${timestamp}\"
mkdir -p \"\${backup_root}\"
cd '${deploy_path}'
for path in backend frontend docker-compose.yml docker-compose.hub.yml Dockerfile README.md docker scripts .build_number .gitattributes .gitignore; do
if [ -e \"\$path\" ]; then
cp -a \"\$path\" \"\${backup_root}/\"
fi
done
tar -xf - -C '${deploy_path}'
docker compose up -d --build
"
echo "Running remote smoke checks"
ssh ${ssh_opts} "${remote}" "
set -e
python3 - <<'PY'
from urllib import request
checks = [
('http://127.0.0.1:8000/health', 200),
('http://127.0.0.1:3000/login', 200),
]
for url, expected in checks:
with request.urlopen(url, timeout=20) as response:
if response.status != expected:
raise SystemExit(f'{url} returned {response.status}, expected {expected}')
print(url, response.status)
PY
"
echo "Deployment completed successfully"
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$repo_root"
deploy_host="${DEPLOY_HOST:-AMS-DEV01}"
deploy_user="${DEPLOY_USER:-zak}"
prod_path="${PROD_DEPLOY_PATH:-/home/${deploy_user}/magent}"
deploy_path="${BETA_DEPLOY_PATH:-/home/${deploy_user}/magent-beta}"
beta_frontend_bind="${BETA_FRONTEND_BIND:-10.30.1.32}"
ssh_opts="${DEPLOY_SSH_OPTS:-"-o StrictHostKeyChecking=accept-new"}"
timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
remote="${deploy_user}@${deploy_host}"
echo "Deploying tracked beta repository contents to ${remote}:${deploy_path}"
git archive --format=tar HEAD | ssh ${ssh_opts} "${remote}" "
set -e
mkdir -p '${deploy_path}'
backup_root=\"\${HOME}/magent-beta-backups/${timestamp}\"
mkdir -p \"\${backup_root}\"
cd '${deploy_path}'
for path in backend frontend docker-compose.yml docker-compose.hub.yml docker-compose.beta.yml Dockerfile README.md docker scripts .build_number .gitattributes .gitignore; do
if [ -e \"\$path\" ]; then
cp -a \"\$path\" \"\${backup_root}/\"
fi
done
tar -xf - -C '${deploy_path}'
if [ ! -f '${deploy_path}/.env' ] && [ -f '${prod_path}/.env' ]; then
cp '${prod_path}/.env' '${deploy_path}/.env'
fi
mkdir -p '${deploy_path}/data'
if [ ! -f '${deploy_path}/data/magent.db' ] && [ -d '${prod_path}/data' ]; then
cp -a '${prod_path}/data/.' '${deploy_path}/data/'
fi
cd '${deploy_path}'
docker compose -p magent-beta -f docker-compose.beta.yml build
docker compose -p magent-beta -f docker-compose.beta.yml up -d
"
echo "Running remote beta smoke checks"
ssh ${ssh_opts} "${remote}" "
set -e
python3 - <<'PY'
from urllib import request
checks = [
('http://127.0.0.1:8100/health', 200),
('http://${beta_frontend_bind}:3100/login', 200),
]
for url, expected in checks:
with request.urlopen(url, timeout=20) as response:
if response.status != expected:
raise SystemExit(f'{url} returned {response.status}, expected {expected}')
print(url, response.status)
PY
"
echo "Beta deployment completed successfully"
+153
View File
@@ -0,0 +1,153 @@
from __future__ import annotations
import argparse
import csv
import json
import sqlite3
from collections import Counter
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_CSV_PATH = ROOT / "data" / "jellyfin_users_normalized.csv"
DEFAULT_DB_PATH = ROOT / "data" / "magent.db"
def _normalize_email(value: object) -> str | None:
if not isinstance(value, str):
return None
candidate = value.strip()
if not candidate or "@" not in candidate:
return None
return candidate
def _load_rows(csv_path: Path) -> list[dict[str, str]]:
with csv_path.open("r", encoding="utf-8", newline="") as handle:
return [dict(row) for row in csv.DictReader(handle)]
def _ensure_email_column(conn: sqlite3.Connection) -> None:
try:
conn.execute("ALTER TABLE users ADD COLUMN email TEXT")
except sqlite3.OperationalError:
pass
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_users_email_nocase
ON users (email COLLATE NOCASE)
"""
)
def _lookup_user(conn: sqlite3.Connection, username: str) -> list[sqlite3.Row]:
return conn.execute(
"""
SELECT id, username, email
FROM users
WHERE username = ? COLLATE NOCASE
ORDER BY
CASE WHEN username = ? THEN 0 ELSE 1 END,
id ASC
""",
(username, username),
).fetchall()
def import_user_emails(csv_path: Path, db_path: Path) -> dict[str, object]:
rows = _load_rows(csv_path)
username_counts = Counter(
str(row.get("Username") or "").strip().lower()
for row in rows
if str(row.get("Username") or "").strip()
)
duplicate_usernames = {
username for username, count in username_counts.items() if username and count > 1
}
summary: dict[str, object] = {
"csv_path": str(csv_path),
"db_path": str(db_path),
"source_rows": len(rows),
"updated": 0,
"unchanged": 0,
"missing_email": [],
"missing_user": [],
"duplicate_source_username": [],
}
with sqlite3.connect(db_path) as conn:
conn.row_factory = sqlite3.Row
_ensure_email_column(conn)
for row in rows:
username = str(row.get("Username") or "").strip()
if not username:
continue
username_key = username.lower()
if username_key in duplicate_usernames:
cast_list = summary["duplicate_source_username"]
assert isinstance(cast_list, list)
if username not in cast_list:
cast_list.append(username)
continue
email = _normalize_email(row.get("Email"))
if not email:
cast_list = summary["missing_email"]
assert isinstance(cast_list, list)
cast_list.append(username)
continue
matches = _lookup_user(conn, username)
if not matches:
cast_list = summary["missing_user"]
assert isinstance(cast_list, list)
cast_list.append(username)
continue
current_emails = {
normalized.lower()
for normalized in (_normalize_email(row["email"]) for row in matches)
if normalized
}
if current_emails == {email.lower()}:
summary["unchanged"] = int(summary["unchanged"]) + 1
continue
conn.execute(
"""
UPDATE users
SET email = ?
WHERE username = ? COLLATE NOCASE
""",
(email, username),
)
summary["updated"] = int(summary["updated"]) + 1
summary["missing_email_count"] = len(summary["missing_email"]) # type: ignore[arg-type]
summary["missing_user_count"] = len(summary["missing_user"]) # type: ignore[arg-type]
summary["duplicate_source_username_count"] = len(summary["duplicate_source_username"]) # type: ignore[arg-type]
return summary
def main() -> None:
parser = argparse.ArgumentParser(description="Import user email addresses into Magent users.")
parser.add_argument(
"csv_path",
nargs="?",
default=str(DEFAULT_CSV_PATH),
help="CSV file containing Username and Email columns",
)
parser.add_argument(
"--db-path",
default=str(DEFAULT_DB_PATH),
help="Path to the Magent SQLite database",
)
args = parser.parse_args()
summary = import_user_emails(Path(args.csv_path), Path(args.db_path))
print(json.dumps(summary, indent=2, sort_keys=True))
if __name__ == "__main__":
main()
+316
View File
@@ -0,0 +1,316 @@
param(
[string]$CommitMessage,
[switch]$SkipCommit,
[switch]$SkipDiscord
)
$ErrorActionPreference = "Stop"
$repoRoot = Resolve-Path "$PSScriptRoot\.."
Set-Location $repoRoot
$Utf8NoBom = New-Object System.Text.UTF8Encoding($false)
$script:CurrentStep = "initializing"
function Write-TextFile {
param(
[Parameter(Mandatory = $true)][string]$Path,
[Parameter(Mandatory = $true)][string]$Content
)
$fullPath = Join-Path $repoRoot $Path
$normalized = $Content -replace "`r`n", "`n"
[System.IO.File]::WriteAllText($fullPath, $normalized, $Utf8NoBom)
}
function Assert-LastExitCode {
param([Parameter(Mandatory = $true)][string]$CommandName)
if ($LASTEXITCODE -ne 0) {
throw "$CommandName failed with exit code $LASTEXITCODE."
}
}
function Read-TextFile {
param([Parameter(Mandatory = $true)][string]$Path)
$fullPath = Join-Path $repoRoot $Path
return [System.IO.File]::ReadAllText($fullPath)
}
function Get-EnvFileValue {
param(
[Parameter(Mandatory = $true)][string]$Path,
[Parameter(Mandatory = $true)][string]$Name
)
if (-not (Test-Path $Path)) {
return $null
}
$match = Select-String -Path $Path -Pattern "^$([regex]::Escape($Name))=(.*)$" | Select-Object -First 1
if (-not $match) {
return $null
}
return $match.Matches[0].Groups[1].Value.Trim()
}
function Get-DiscordWebhookUrl {
$candidateNames = @(
"PROCESS_DISCORD_WEBHOOK_URL",
"MAGENT_NOTIFY_DISCORD_WEBHOOK_URL",
"DISCORD_WEBHOOK_URL"
)
foreach ($name in $candidateNames) {
$value = [System.Environment]::GetEnvironmentVariable($name)
if (-not [string]::IsNullOrWhiteSpace($value)) {
return $value.Trim()
}
}
foreach ($name in $candidateNames) {
$value = Get-EnvFileValue -Path ".env" -Name $name
if (-not [string]::IsNullOrWhiteSpace($value)) {
return $value.Trim()
}
}
$configPath = Join-Path $repoRoot "backend/app/config.py"
if (Test-Path $configPath) {
$configContent = Read-TextFile -Path "backend/app/config.py"
$match = [regex]::Match(
$configContent,
'discord_webhook_url:\s*Optional\[str\]\s*=\s*Field\(\s*default="([^"]+)"',
[System.Text.RegularExpressions.RegexOptions]::Singleline
)
if ($match.Success) {
return $match.Groups[1].Value.Trim()
}
}
return $null
}
function Send-DiscordUpdate {
param(
[Parameter(Mandatory = $true)][string]$Title,
[Parameter(Mandatory = $true)][string]$Body
)
if ($SkipDiscord) {
Write-Host "Skipping Discord notification."
return
}
$webhookUrl = Get-DiscordWebhookUrl
if ([string]::IsNullOrWhiteSpace($webhookUrl)) {
Write-Warning "Discord webhook not configured for Process 1."
return
}
$content = "**$Title**`n$Body"
Invoke-RestMethod -Method Post -Uri $webhookUrl -ContentType "application/json" -Body (@{ content = $content } | ConvertTo-Json -Compress) | Out-Null
}
function Get-BuildNumber {
$current = ""
if (Test-Path ".build_number") {
$current = (Get-Content ".build_number" -Raw).Trim()
}
$candidate = Get-Date
$buildNumber = $candidate.ToString("ddMMyyHHmm")
if ($buildNumber -eq $current) {
$buildNumber = $candidate.AddMinutes(1).ToString("ddMMyyHHmm")
}
return $buildNumber
}
function Wait-ForHttp {
param(
[Parameter(Mandatory = $true)][string]$Url,
[int]$Attempts = 30,
[int]$DelaySeconds = 2
)
$lastError = $null
for ($attempt = 1; $attempt -le $Attempts; $attempt++) {
try {
return Invoke-RestMethod -Uri $Url -TimeoutSec 10
} catch {
$lastError = $_
Start-Sleep -Seconds $DelaySeconds
}
}
throw $lastError
}
function Get-GitChangelogLiteral {
$scriptPath = Join-Path $repoRoot "scripts/render_git_changelog.py"
$literal = python $scriptPath --python-literal
Assert-LastExitCode -CommandName "python scripts/render_git_changelog.py --python-literal"
return ($literal | Out-String).Trim()
}
function Update-BuildFiles {
param([Parameter(Mandatory = $true)][string]$BuildNumber)
Write-TextFile -Path ".build_number" -Content "$BuildNumber`n"
$changelogLiteral = Get-GitChangelogLiteral
$buildInfoContent = @(
"BUILD_NUMBER = `"$BuildNumber`""
"CHANGELOG = $changelogLiteral"
""
) -join "`n"
Write-TextFile -Path "backend/app/build_info.py" -Content $buildInfoContent
$envPath = Join-Path $repoRoot ".env"
if (Test-Path $envPath) {
$envContent = Read-TextFile -Path ".env"
if ($envContent -match '^BUILD_NUMBER=.*$') {
$updatedEnv = [regex]::Replace(
$envContent,
'^BUILD_NUMBER=.*$',
"BUILD_NUMBER=$BuildNumber",
[System.Text.RegularExpressions.RegexOptions]::Multiline
)
} else {
$updatedEnv = "BUILD_NUMBER=$BuildNumber`n$envContent"
}
Write-TextFile -Path ".env" -Content $updatedEnv
}
$packageJson = Read-TextFile -Path "frontend/package.json"
$packageJsonRegex = [regex]::new('"version"\s*:\s*"\d+"')
$updatedPackageJson = $packageJsonRegex.Replace(
$packageJson,
"`"version`": `"$BuildNumber`"",
1
)
Write-TextFile -Path "frontend/package.json" -Content $updatedPackageJson
$packageLock = Read-TextFile -Path "frontend/package-lock.json"
$packageLockVersionRegex = [regex]::new('"version"\s*:\s*"\d+"')
$updatedPackageLock = $packageLockVersionRegex.Replace(
$packageLock,
"`"version`": `"$BuildNumber`"",
1
)
$packageLockRootRegex = [regex]::new(
'(""\s*:\s*\{\s*"name"\s*:\s*"magent-frontend"\s*,\s*"version"\s*:\s*)"\d+"',
[System.Text.RegularExpressions.RegexOptions]::Singleline
)
$updatedPackageLock = $packageLockRootRegex.Replace(
$updatedPackageLock,
'$1"' + $BuildNumber + '"',
1
)
Write-TextFile -Path "frontend/package-lock.json" -Content $updatedPackageLock
}
function Get-ChangedFilesSummary {
$files = git diff --cached --name-only
if (-not $files) {
return "No staged files"
}
$count = ($files | Measure-Object).Count
$sample = $files | Select-Object -First 8
$summary = ($sample -join ", ")
if ($count -gt $sample.Count) {
$summary = "$summary, +$($count - $sample.Count) more"
}
return "$count files: $summary"
}
$buildNumber = $null
$branch = $null
$commit = $null
$publicInfo = $null
$changedFiles = "No staged files"
try {
$branch = (git rev-parse --abbrev-ref HEAD).Trim()
$buildNumber = Get-BuildNumber
Write-Host "Process 1 build number: $buildNumber"
$script:CurrentStep = "updating build metadata"
Update-BuildFiles -BuildNumber $buildNumber
$script:CurrentStep = "running backend quality gate"
powershell -ExecutionPolicy Bypass -File (Join-Path $repoRoot "scripts\run_backend_quality_gate.ps1")
Assert-LastExitCode -CommandName "scripts/run_backend_quality_gate.ps1"
$script:CurrentStep = "rebuilding local docker stack"
docker compose up -d --build
Assert-LastExitCode -CommandName "docker compose up -d --build"
$script:CurrentStep = "verifying backend health"
$health = Wait-ForHttp -Url "http://127.0.0.1:8000/health"
if ($health.status -ne "ok") {
throw "Health endpoint returned unexpected payload: $($health | ConvertTo-Json -Compress)"
}
$script:CurrentStep = "verifying public build metadata"
$publicInfo = Wait-ForHttp -Url "http://127.0.0.1:8000/site/public"
if ($publicInfo.buildNumber -ne $buildNumber) {
throw "Public build number mismatch. Expected $buildNumber but got $($publicInfo.buildNumber)."
}
$script:CurrentStep = "committing changes"
git add -A
Assert-LastExitCode -CommandName "git add -A"
$changedFiles = Get-ChangedFilesSummary
if ((git status --short).Trim()) {
if (-not $SkipCommit) {
if ([string]::IsNullOrWhiteSpace($CommitMessage)) {
$CommitMessage = "Process 1 build $buildNumber"
}
git commit -m $CommitMessage
Assert-LastExitCode -CommandName "git commit"
}
}
$commit = (git rev-parse --short HEAD).Trim()
$body = @(
"Build: $buildNumber"
"Branch: $branch"
"Commit: $commit"
"Health: ok"
"Public build: $($publicInfo.buildNumber)"
"Changes: $changedFiles"
) -join "`n"
Send-DiscordUpdate -Title "Process 1 complete" -Body $body
Write-Host "Process 1 completed successfully."
} catch {
$failureCommit = ""
try {
$failureCommit = (git rev-parse --short HEAD).Trim()
} catch {
$failureCommit = "unknown"
}
$failureBody = @(
"Build: $buildNumber"
"Branch: $branch"
"Commit: $failureCommit"
"Step: $script:CurrentStep"
"Error: $($_.Exception.Message)"
) -join "`n"
try {
Send-DiscordUpdate -Title "Process 1 failed" -Body $failureBody
} catch {
Write-Warning "Failed to send Discord failure notification: $($_.Exception.Message)"
}
throw
}
+45
View File
@@ -0,0 +1,45 @@
from __future__ import annotations
import argparse
import subprocess
import sys
from pathlib import Path
def build_git_changelog(repo_root: Path, max_count: int) -> str:
result = subprocess.run(
[
"git",
"log",
f"--max-count={max_count}",
"--date=short",
"--pretty=format:%cs|%s",
"--",
".",
],
cwd=repo_root,
capture_output=True,
text=True,
check=True,
)
lines = [line.strip() for line in result.stdout.splitlines() if line.strip()]
return "\n".join(lines)
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--max-count", type=int, default=200)
parser.add_argument("--python-literal", action="store_true")
args = parser.parse_args()
repo_root = Path(__file__).resolve().parents[1]
changelog = build_git_changelog(repo_root, max_count=args.max_count)
if args.python_literal:
print(repr(changelog))
else:
sys.stdout.write(changelog)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+60
View File
@@ -0,0 +1,60 @@
$ErrorActionPreference = "Stop"
$repoRoot = Resolve-Path "$PSScriptRoot\.."
Set-Location $repoRoot
$env:PYTHONIOENCODING = "utf-8"
function Assert-LastExitCode {
param([Parameter(Mandatory = $true)][string]$CommandName)
if ($LASTEXITCODE -ne 0) {
throw "$CommandName failed with exit code $LASTEXITCODE."
}
}
function Get-PythonCommand {
$venvPython = Join-Path $repoRoot ".venv\Scripts\python.exe"
if (Test-Path $venvPython) {
return $venvPython
}
return "python"
}
function Ensure-PythonModule {
param(
[Parameter(Mandatory = $true)][string]$PythonExe,
[Parameter(Mandatory = $true)][string]$ModuleName,
[Parameter(Mandatory = $true)][string]$PackageName
)
& $PythonExe -c "import importlib.util, sys; sys.exit(0 if importlib.util.find_spec('$ModuleName') else 1)"
if ($LASTEXITCODE -eq 0) {
return
}
Write-Host "Installing missing Python package: $PackageName"
& $PythonExe -m pip install $PackageName
Assert-LastExitCode -CommandName "python -m pip install $PackageName"
}
$pythonExe = Get-PythonCommand
Write-Host "Installing backend Python requirements"
& $pythonExe -m pip install -r (Join-Path $repoRoot "backend\requirements.txt")
Assert-LastExitCode -CommandName "python -m pip install -r backend/requirements.txt"
Write-Host "Running Python dependency integrity check"
& $pythonExe -m pip check
Assert-LastExitCode -CommandName "python -m pip check"
Ensure-PythonModule -PythonExe $pythonExe -ModuleName "pip_audit" -PackageName "pip-audit"
Write-Host "Running Python vulnerability scan"
& $pythonExe -m pip_audit -r (Join-Path $repoRoot "backend\requirements.txt") --progress-spinner off --desc
Assert-LastExitCode -CommandName "python -m pip_audit"
Write-Host "Running backend unit tests"
& $pythonExe -m unittest discover -s backend/tests -p "test_*.py" -v
Assert-LastExitCode -CommandName "python -m unittest discover"
Write-Host "Backend quality gate passed"