Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 55 additions & 6 deletions .github/workflows/backend-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,28 @@ name: Backend CI
# registry, pairing, and the panel<->agent command loop exercised end-to-end
# by tests/test_agent_poll_e2e.py — instead of finding out at install time.

# main is deliberately absent from `push`: release.yml gates itself on this
# workflow via `ci-gate`, so a push to main already runs the suite once. Listing
# main here ran it a second time, standalone, on every merge.
on:
push:
branches: [dev, main]
branches: [dev]
paths:
- 'backend/**'
- '.github/workflows/backend-ci.yml'
pull_request:
branches: [dev, main]
branches: [main]
paths:
- 'backend/**'
- '.github/workflows/backend-ci.yml'
workflow_call:

jobs:
pytest:
name: pytest
# The ratchet needs to see the WHOLE suite, so it cannot live inside a shard —
# each shard only collects its own quarter. Its own job costs no wall-clock
# time: it runs beside the shards and finishes in seconds.
ratchet:
name: test-count ratchet
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
Expand All @@ -42,6 +48,49 @@ jobs:
# Lowering the floor requires editing BASELINE_COUNT in the same commit.
working-directory: backend
run: python tests/check_test_count.py
- name: Run tests

# Sharded because this job WAS the entire wait: 21m50s of a ~22m pipeline,
# while every other workflow finished inside a minute. Splitting the 3173
# tests over 4 runners cuts the critical path to roughly a quarter.
#
# Sharding rather than pytest-xdist is deliberate: each shard is its own VM,
# so the process-shared state that makes in-process parallelism unsafe here
# (templates.json, APPS_DIR — see the per-PID DB dance in tests/conftest.py)
# simply isn't shared. No test code has to change.
#
# NOTE: with no .test_durations file, pytest-split balances by test COUNT,
# not by time — and these tests range from 0.1s to 10s+. If one shard lands
# most of the slow ones it becomes the new critical path. If that shows up,
# run once with `--store-durations` and commit backend/.test_durations to
# switch it to duration-based balancing.
pytest:
name: pytest (${{ matrix.group }}/4)
runs-on: ubuntu-latest
strategy:
# Report every failing shard in one pass instead of hiding shards 2-4
# behind the first failure — one round of fixes instead of four.
fail-fast: false
matrix:
group: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
cache: pip
cache-dependency-path: backend/requirements.txt
- name: Install dependencies
working-directory: backend
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install pytest pytest-split
- name: Run tests (shard ${{ matrix.group }} of 4)
# Scoped to `tests` rather than a bare `pytest`. Identical here (3173
# either way, since backend/dev-data/ is gitignored and absent from a
# CI checkout), but it makes the command reproducible on a dev box: a
# bare pytest there tries to collect the locally deployed apps under
# backend/dev-data/ and dies during collection. Copy this line verbatim
# to debug a red shard locally.
working-directory: backend
run: python -m pytest -v
run: python -m pytest tests -v --splits 4 --group ${{ matrix.group }}
4 changes: 2 additions & 2 deletions .github/workflows/extensions-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,14 @@ name: Extensions CI

on:
push:
branches: [dev, main]
branches: [dev]
paths:
- 'builtin-extensions/**'
- 'frontend/src/plugins/**'
- 'scripts/sync-builtin-frontends.mjs'
- '.github/workflows/extensions-ci.yml'
pull_request:
branches: [dev, main]
branches: [main]
paths:
- 'builtin-extensions/**'
- 'frontend/src/plugins/**'
Expand Down
58 changes: 58 additions & 0 deletions .github/workflows/frontend-ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
name: Frontend CI

# The frontend's lint gate ran nowhere until now — `npm run lint` was in
# package.json and in CLAUDE.md, but no workflow ever invoked it. That silently
# unenforced three project-specific checkers that exist precisely because a
# human review keeps missing what they catch:
#
# check-settings-index every Settings tab has a search-index entry
# check-theme-tokens the theme-token whitelist stays in 3-way sync
# check-html-sinks every raw-HTML sink is sanitized or annotated (XSS)
#
# Only `npm run lint` runs here. The frontend is already COMPILED in CI by
# Release Build Smoke Test, whose scripts/build-release.sh does `npm ci &&
# npm run build` — adding a build job here would just duplicate that.
#
# backend/app/** is in the paths because check-html-sinks scans it too (for
# `|safe`, `Markup(`, `render_template_string`), so a backend-only commit can
# introduce a sink this must catch.

on:
push:
branches: [dev]
paths:
- 'frontend/**'
- 'backend/app/**'
- 'scripts/check-html-sinks.mjs'
- '.github/workflows/frontend-ci.yml'
pull_request:
branches: [main]
paths:
- 'frontend/**'
- 'backend/app/**'
- 'scripts/check-html-sinks.mjs'
- '.github/workflows/frontend-ci.yml'
workflow_call:

jobs:
lint:
name: lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
working-directory: frontend
run: npm ci
- name: Lint
# eslint + the three checkers, chained by the package.json script.
# Currently 926 warnings / 0 errors, and eslint exits 0 on warnings —
# so this gates on errors only. If you ever want the warning count
# ratcheted the way backend/tests/BASELINE_COUNT ratchets test count,
# add --max-warnings=<N> here rather than mass-fixing in one commit.
working-directory: frontend
run: npm run lint
7 changes: 5 additions & 2 deletions .github/workflows/release-smoke.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,12 @@ name: Release Build Smoke Test
# frontend source files) before they reach main, instead of failing late in the
# release workflow.

# main is deliberately absent from `push`: on a merge to main, release.yml's
# `build-release` job runs this very script for real moments later, so building
# the tarball here too was pure duplication.
on:
push:
branches: [dev, main]
branches: [dev]
paths:
- 'frontend/**'
- 'backend/**'
Expand All @@ -16,7 +19,7 @@ on:
- '.github/workflows/release.yml'
- 'VERSION'
pull_request:
branches: [dev, main]
branches: [main]
paths:
- 'frontend/**'
- 'backend/**'
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/scripts-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ name: Scripts CI

on:
push:
branches: [dev, main]
branches: [dev]
paths:
- 'scripts/**'
- 'templates/**'
Expand All @@ -23,7 +23,7 @@ on:
- 'serverkit'
- '.github/workflows/scripts-ci.yml'
pull_request:
branches: [dev, main]
branches: [main]
paths:
- 'scripts/**'
- 'templates/**'
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/security-scan.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ on:
- 'backend/**'
- '.github/workflows/security-scan.yml'
pull_request:
branches: [main, dev]
branches: [main]
paths:
- 'backend/**'
- '.github/workflows/security-scan.yml'
Expand Down
33 changes: 9 additions & 24 deletions .github/workflows/test-system-utils.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,36 +9,21 @@ on:
- 'backend/tests/test_utils_system*.py'
- '.github/workflows/test-system-utils.yml'
pull_request:
branches: [main, dev]
branches: [main]
paths:
- 'backend/app/utils/system.py'
- 'backend/app/services/**'
- 'backend/tests/test_utils_system*.py'

# The mocked `unit-tests` job that used to lead this file was removed: it ran
# `pytest tests/test_utils_system.py` (44 tests), and Backend CI's bare
# `pytest -v` already collects that exact file — there is no pytest.ini,
# addopts, or collect_ignore narrowing it. What is left here is the part
# Backend CI genuinely cannot do: exercise the package-manager detection
# against real apt/dnf inside real distro images.
jobs:
# ──────────────────────────────────────────────────────────────────
# Job 1: Mocked unit tests — fast, validates all logic
# ──────────────────────────────────────────────────────────────────
unit-tests:
name: Unit Tests
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: '3.11'

- name: Install test deps
run: pip install pytest

- name: Run unit tests
working-directory: backend
run: python -m pytest tests/test_utils_system.py -v

# ──────────────────────────────────────────────────────────────────
# Job 2: Integration tests on real distros (no mocks)
# Job 1: Integration tests on real distros (no mocks)
# ──────────────────────────────────────────────────────────────────
integration-tests:
name: Integration (${{ matrix.distro }})
Expand Down Expand Up @@ -91,7 +76,7 @@ jobs:
run: python3 -m pytest tests/test_utils_system_integration.py -v

# ──────────────────────────────────────────────────────────────────
# Job 3: Audit — grep for raw subprocess patterns in services
# Job 2: Audit — grep for raw subprocess patterns in services
# ──────────────────────────────────────────────────────────────────
audit-raw-patterns:
name: Audit Raw Subprocess Patterns
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.7.82
1.7.83
76 changes: 69 additions & 7 deletions backend/app/services/template_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,15 +36,32 @@ class TemplateService:
INSTALLED_DIR = paths.APPS_DIR
TEMPLATE_CONFIG = os.path.join(CONFIG_DIR, 'templates.json')

# Default template repository
# Default template repository.
#
# serverkit.ai proxies the serverkit-templates registry and is built for
# exactly this consumer: it serves <repo_url>/index.json and the
# <repo_url>/templates/<id>.yaml path this class derives below, behind a
# TTL cache with last-good fallback. Pointing at the product domain rather
# than raw.githubusercontent also means a branch rename upstream cannot
# silently empty every panel's catalog.
DEFAULT_REPOS = [
{
'name': 'serverkit-official',
'url': 'https://raw.githubusercontent.com/serverkit/templates/main',
'url': 'https://serverkit.ai/templates',
'enabled': True
}
]

# Repo URLs that never worked and should be healed on read rather than
# left to rot in an operator's templates.json. `serverkit/templates` was a
# guess at the org name -- the registry is `jhd3197/serverkit-templates` --
# so this URL has 404'd for its entire existence and no panel has ever
# fetched a template through it. Nothing is lost by replacing it.
DEAD_REPO_URLS = {
'https://raw.githubusercontent.com/serverkit/templates/main',
'https://raw.githubusercontent.com/serverkit/templates',
}

# Provider-owned templates (plan 52 D4 hook inversion): these ids are
# listed and installable ONLY while the owning extension has registered as
# their provider (i.e. it is installed + active this boot — registration
Expand Down Expand Up @@ -167,11 +184,18 @@ def _run_provider_validate(cls, template_id, variables):

@classmethod
def get_config(cls) -> Dict:
"""Get template configuration."""
"""Get template configuration.

A panel that has ever saved this file keeps whatever repos were in it,
so fixing DEFAULT_REPOS alone would only help fresh installs. Dead URLs
are therefore corrected on read (see DEAD_REPO_URLS). Not written back
here -- a getter should not have a disk side effect -- so the repair
re-applies each read until something saves the config normally."""
if os.path.exists(cls.TEMPLATE_CONFIG):
try:
with open(cls.TEMPLATE_CONFIG, 'r') as f:
return json.load(f)
config = json.load(f)
return cls._heal_dead_repos(config)
except Exception:
pass
return {
Expand All @@ -180,6 +204,18 @@ def get_config(cls) -> Dict:
'last_sync': None
}

@classmethod
def _heal_dead_repos(cls, config: Dict) -> Dict:
"""Point any known-dead repo URL at the current default."""
repos = config.get('repos')
if not isinstance(repos, list):
return config
default_url = cls.DEFAULT_REPOS[0]['url']
for repo in repos:
if isinstance(repo, dict) and repo.get('url', '').rstrip('/') in cls.DEAD_REPO_URLS:
repo['url'] = default_url
Comment on lines +214 to +216
return config

@classmethod
def save_config(cls, config: Dict) -> Dict:
"""Save template configuration."""
Expand Down Expand Up @@ -2306,6 +2342,7 @@ def sync_templates(cls) -> Dict:

config = cls.get_config()
synced = 0
unverified = 0 # saved, but the index pinned no sha256 for them
errors = []

for repo in config.get('repos', []):
Expand All @@ -2331,10 +2368,34 @@ def sync_templates(cls) -> Dict:
response = requests.get(template_url, timeout=30)
response.raise_for_status()

# Save locally
# Verify against the checksum the index pinned for this
# entry. A template is a deploy definition -- images,
# ports, volumes, env -- so a swapped file is worth
# refusing outright, and the index already carries the
# hash for every official entry.
#
# Missing hash is allowed (third-party repos may not
# publish one) and counted, mirroring how extensions
# treat unsigned-vs-invalid: absent is a caveat, wrong
# is a hard stop.
expected = (template_info.get('sha256') or '').strip().lower()
if expected:
actual = hashlib.sha256(response.content).hexdigest()
if actual != expected:
errors.append(
f"Checksum mismatch for {template_id}: index pinned "
f"{expected[:12]}..., downloaded {actual[:12]}.... Not saved."
)
continue
else:
unverified += 1

# Written as bytes so what lands on disk is exactly the
# content that was hashed (text mode would rewrite line
# endings on Windows and no longer match).
filepath = os.path.join(cls.TEMPLATES_DIR, f"{template_id}.yaml")
with open(filepath, 'w') as f:
f.write(response.text)
with open(filepath, 'wb') as f:
f.write(response.content)

synced += 1
except Exception as e:
Expand All @@ -2349,6 +2410,7 @@ def sync_templates(cls) -> Dict:
return {
'success': True,
'synced': synced,
'unverified': unverified,
'errors': errors if errors else None
}

Expand Down
Loading