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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -189,3 +189,6 @@ staticfiles/
# Prevent accidental publishing of private settings
script_settings.plist
config.json

# Lock files
*.lock
21 changes: 14 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# AutoPkg Runner

[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg?style=for-the-badge)](LICENSE)<br>
![Version 3.0.1](https://img.shields.io/badge/version-3.0.1-green?style=for-the-badge)
![Version 3.0.2](https://img.shields.io/badge/version-3.0.2-green?style=for-the-badge)

A web-based management interface for [AutoPkg](https://github.com/autopkg/autopkg) - the macOS software packaging automation tool. AutoPkg Runner wraps your AutoPkg workflows in a Django web application with real-time run monitoring, a REST API, a mobile PWA, and scheduled execution.

Expand All @@ -10,7 +10,7 @@ A web-based management interface for [AutoPkg](https://github.com/autopkg/autopk
### Web UI

- **Dashboard** - last run summary, 30-day success rate, next scheduled run, and a one-click manual trigger
- **Run detail** - GitHub Actions-style stage timeline with live log streaming; stage status icons and the log panel update in real time without a page refresh
- **Run detail** - GitHub Actions-style stage timeline with live log streaming over SSE; stage status icons and the log panel update in real time without a page refresh; multiple concurrent viewers are supported with no additional database load per viewer
- **Run history** - paginated list of all pipeline executions with status badges and duration
- **Run cancellation** - cancel an in-progress run from the run detail page
- **Run sharing** - generate a shareable, unauthenticated link to any completed run report; optional expiry window configurable in notification settings
Expand Down Expand Up @@ -166,7 +166,7 @@ Open `http://127.0.0.1:8000` and log in with the credentials shown. All configur
| Command | Description |
|-|-|
| `autopkg-runner setup` | One-shot initialisation: migrate, create defaults, generate admin account |
| `autopkg-runner serve` | Start the server (`--bind`, `--port`, `--workers`, `--threads`) |
| `autopkg-runner serve` | Start the production server (`--bind`, `--port`, `--workers`) |
| `autopkg-runner resetpassword <user>` | Generate and set a new random password for a user account |
| `autopkg-runner generate_vapid_keys` | Generate VAPID keys for WebPush notifications and store them in the database |
| `autopkg-runner install_sftp_deps` | Install macFUSE and sshfs via Homebrew (required for SFTP repository connections) |
Expand Down Expand Up @@ -268,13 +268,21 @@ This installs macFUSE and sshfs via Homebrew. macFUSE requires a system reboot a

## Production deployment

The Django development server is single-threaded and will block on SSE connections. For production, use a multi-threaded WSGI server:
The Django development server (`manage.py serve`) is suitable for local testing but not for production. For production, AutoPkg Runner runs under **gunicorn with uvicorn workers** (ASGI), which provides:

- Async SSE streaming — each client watching a live run is a lightweight coroutine rather than a blocked thread, so the server handles many concurrent viewers without exhausting resources
- A single DB-polling thread per active run regardless of how many clients are connected (fan-out broadcaster)

```bash
pip3 install gunicorn
gunicorn autopkgrunner.wsgi:application --workers 1 --threads 8 --bind 0.0.0.0:8000
pip3 install gunicorn "uvicorn[standard]"
gunicorn autopkgrunner.asgi:application \
--worker-class uvicorn.workers.UvicornWorker \
--config python:autopkgrunner.gunicorn_conf \
--workers 1 --bind 0.0.0.0:8000
```

> **Workers and the scheduler** — APScheduler runs inside each gunicorn worker. With more than one worker, only the worker that holds `scheduler.lock` fires scheduled jobs; the rest stand by and take over automatically if that worker exits. Use `--workers 1` unless you specifically need process-level redundancy.

Set `DJANGO_DEBUG=false` and `DJANGO_ALLOWED_HOSTS` to your server's hostname. Restrict permissions on the database file since it contains API tokens and repository credentials:

```bash
Expand Down Expand Up @@ -316,7 +324,6 @@ A macOS authentication dialog will appear to request administrator credentials f
| `--bind <address>` | `127.0.0.1` | Address the server binds to |
| `--port <port>` | `8000` | Port the server listens on |
| `--workers <n>` | `1` | Number of worker processes |
| `--threads <n>` | `8` | Number of threads per worker |

### Useful commands

Expand Down
2 changes: 1 addition & 1 deletion __info__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Application Info
APP_NAME = "autopkg-runner"
FRIENDLY_APP_NAME = "AutoPkg Runner"
APP_VERSION_STR = "3.0.1"
APP_VERSION_STR = "3.0.2"
APP_CHANNEL = "Production"
BUNDLE_ID = "com.bytefloater.autopkg-runner"

Expand Down
4 changes: 4 additions & 0 deletions autopkg_runner.spec
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ hidden_imports = (
+ collect_submodules('cryptography')
+ collect_submodules('zeroconf')
+ collect_submodules('gunicorn')
+ collect_submodules('uvicorn')
+ collect_submodules('h11')
+ collect_submodules('httptools')
+ collect_submodules('websockets')
+ collect_submodules('argon2')
+ collect_submodules('dns')
+ collect_submodules('webapp')
Expand Down
63 changes: 62 additions & 1 deletion autopkgrunner/asgi.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,70 @@
import atexit
import fcntl
import logging
import os
import shutil
import sys
import tempfile
import warnings

from dotenv import load_dotenv
load_dotenv() # Load .env before Django reads settings

from django.core.asgi import get_asgi_application
# Django 4.2 bug: StreamingHttpResponse.__aiter__ issues this warning whenever
# a sync iterator (e.g. WhiteNoise FileResponse, GZipMiddleware output) is
# async-iterated by the ASGI handler. The fallback path works correctly; the
# warning is spurious. Fixed in Django 5.x.
warnings.filterwarnings(
'ignore',
message='StreamingHttpResponse must consume synchronous iterators',
category=Warning,
)

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'autopkgrunner.settings')

# -- Static-file collection ----------------------------------------------------
# Must happen before get_asgi_application() so WhiteNoise finds the files when
# it scans STATIC_ROOT during middleware initialisation.
#
# A file lock ensures only the first worker to boot runs collectstatic; the
# rest wait for the lock, see the files already exist, and skip it.
# This avoids the ObjC fork-safety crash that occurs when django.setup() is
# called in the gunicorn master (on_starting) before workers are forked.

_via_manage = (
sys.argv and os.path.basename(sys.argv[0]) in ('manage.py', 'manage')
or os.environ.get('AUTOPKG_MODE') == 'manage'
)

if not _via_manage:
import django
django.setup()

from django.conf import settings
from django.core.management import call_command

logger = logging.getLogger('autopkg_runner')
_static_root = settings.STATIC_ROOT

_lock_path = os.path.join(tempfile.gettempdir(), 'autopkg-runner-collectstatic.lock')
with open(_lock_path, 'w') as _lf:
fcntl.flock(_lf.fileno(), fcntl.LOCK_EX)
try:
if not _static_root.exists() or not any(_static_root.iterdir()):
logger.info('Collecting static files…')
call_command('collectstatic', '--noinput', verbosity=0)
_file_count = sum(1 for _ in _static_root.rglob('*') if _.is_file())
logger.info('Static files ready (%d files in %s).', _file_count, _static_root)

def _cleanup_static():
if _static_root.exists():
shutil.rmtree(_static_root, ignore_errors=True)

atexit.register(_cleanup_static)
finally:
fcntl.flock(_lf.fileno(), fcntl.LOCK_UN)

# -- ASGI application ----------------------------------------------------------

from django.core.asgi import get_asgi_application
application = get_asgi_application()
4 changes: 4 additions & 0 deletions autopkgrunner/gunicorn_conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@

Passed to gunicorn via --config python:autopkgrunner.gunicorn_conf.

on_starting runs once in the master process (pre-fork) and handles tasks that
must only happen once regardless of worker count: static file collection.

The post_fork hook starts background services (scheduler, stale-run cleanup,
recipe cache warm-up) inside each worker after forking, keeping the master
process completely thread-free. This prevents the macOS ObjC fork-safety
Expand All @@ -14,6 +17,7 @@
"""



def post_fork(server, worker):
"""Called in each worker process immediately after it is forked."""
from django.apps import apps
Expand Down
48 changes: 3 additions & 45 deletions autopkgrunner/wsgi.py
Original file line number Diff line number Diff line change
@@ -1,55 +1,13 @@
import atexit
import logging
import os
import shutil
import sys

from dotenv import load_dotenv
load_dotenv() # Load .env before Django reads settings

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'autopkgrunner.settings')

# -- Static-file collection ----------------------------------------------------
# Must happen *before* get_wsgi_application() so that WhiteNoise finds the
# files when it scans STATIC_ROOT during middleware initialisation.
# This also means files are ready before the first request is served.

import django
django.setup()

from django.conf import settings
from django.core.management import call_command

logger = logging.getLogger('autopkg_runner')

# Detect whether we were loaded by manage.py (dev server) vs a WSGI server
# (gunicorn). Must be set before the setup guard below.
_via_manage = (
sys.argv and os.path.basename(sys.argv[0]) in ('manage.py', 'manage')
or os.environ.get('AUTOPKG_MODE') == 'manage'
)

# Only collect static files when loaded directly by a WSGI server (gunicorn).
# The dev server serves static files itself, so collectstatic is not needed.
if not _via_manage:
_static_root = settings.STATIC_ROOT

logger.info('Collecting static files…')
call_command('collectstatic', '--noinput', verbosity=0)

_file_count = sum(1 for _ in _static_root.rglob('*') if _.is_file()) if _static_root.exists() else 0
logger.info('Static files ready (%d files in %s).', _file_count, _static_root)

def _cleanup_static():
"""Remove collected static files on process exit so the next startup
always starts from a clean slate (no stale renamed/deleted assets)."""
if _static_root.exists():
shutil.rmtree(_static_root, ignore_errors=True)

atexit.register(_cleanup_static)

# -- WSGI application ----------------------------------------------------------
# WhiteNoise middleware initialises here and scans the now-populated STATIC_ROOT.
# Static file collection is handled by gunicorn_conf.py on_starting (runs once
# in the master before workers fork). WhiteNoise finds the already-populated
# STATIC_ROOT when the WSGI application initialises here in each worker.

from django.core.wsgi import get_wsgi_application
application = get_wsgi_application()
12 changes: 10 additions & 2 deletions libs/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,12 @@ def configure_stages(self, override_stage_name):
enabled_stage_names = [cls.__class__.__name__ for cls in self.stages]
self.logger.info(f"Configured stages: {enabled_stage_names}")

def execute(self) -> bool:
"""Run all configured stages. Returns True on full success, False otherwise."""
def execute(self, cancel_flag=None) -> bool:
"""Run all configured stages. Returns True on full success, False otherwise.

cancel_flag is an optional threading.Event; when set the pipeline aborts
before the next stage and cleanup runs for all completed stages.
"""
if not hasattr(self, 'stages'):
self.logger.error('Stages not configured')
return False
Expand All @@ -68,6 +72,10 @@ def execute(self) -> bool:

try:
for stage in pipeline_stages:
if cancel_flag and cancel_flag.is_set():
self.logger.info('Run cancelled — stopping pipeline before next stage.')
success = False
break
current_stage = stage
self._notify(stage.name, 'running')
stage()
Expand Down
9 changes: 8 additions & 1 deletion libs/run_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,12 @@ def info(self, msg: str, /) -> None: ...
def error(self, msg: str, /) -> None: ...


def run_cmd(command: list[str], logger: _SupportsLogging):
def run_cmd(command: list[str], logger: _SupportsLogging, on_proc=None):
"""Run *command*, streaming stdout/stderr to *logger*.

on_proc: optional callback invoked with the Popen object immediately after
the process starts — used to register the process for external cancellation.
"""
# For Python children, ensure unbuffered output; harmless for others.
env = os.environ.copy()
env.setdefault("PYTHONUNBUFFERED", "1")
Expand All @@ -24,6 +29,8 @@ def run_cmd(command: list[str], logger: _SupportsLogging):
bufsize=1, # line-buffered
env=env,
)
if on_proc is not None:
on_proc(proc)

# Read both pipes in the *calling* thread rather than spawning reader
# threads. This is essential so that Logbook's thread-local handler
Expand Down
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ psutil==7.0.0
zeroconf>=0.148.0
pywebpush>=2.0
gunicorn>=23.0.0
uvicorn[standard]>=0.29
whitenoise>=6.0
pip-system-certs
certifi
Expand Down
13 changes: 5 additions & 8 deletions run.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,10 @@
_COL = 30 # left column width for alignment

_HELP_COMMANDS = [
('serve', 'Start the server (gunicorn)', [
('serve', 'Start the server (gunicorn + uvicorn)', [
('--bind ADDRESS', 'Bind address (default: 0.0.0.0)'),
('--port PORT', 'Port (default: 8000)'),
('--workers N', 'Worker processes (default: 2)'),
('--threads N', 'Threads per worker (default: 4)'),
]),
('setup', 'First-time initialisation', [
('--username USER', 'Admin username (default: admin)'),
Expand All @@ -44,7 +43,6 @@
('--port PORT', 'Port (default: 8000)'),
('--bind ADDRESS', 'Bind address (default: 127.0.0.1)'),
('--workers N', 'Gunicorn worker processes (default: 1)'),
('--threads N', 'Threads per worker (default: 8)'),
]),
('purge', 'Remove all app artefacts (plist, DB, logs)', [
('--keep-data', 'Preserve the database and Application Support directories'),
Expand Down Expand Up @@ -201,7 +199,6 @@ def main() -> None:
p.add_argument('--bind', default='0.0.0.0', metavar='ADDRESS')
p.add_argument('--port', default='8000', metavar='PORT')
p.add_argument('--workers', default='2', metavar='N')
p.add_argument('--threads', default='4', metavar='N')
opts = p.parse_args(sys.argv[2:])

class _NoWinch(logging.Filter):
Expand All @@ -212,20 +209,20 @@ def filter(self, record):
# runs. setup() replaces handlers but leaves logger-level filters intact,
# so this survives into the master process and all forked workers.
logging.getLogger('gunicorn.error').addFilter(_NoWinch())
# Defence-in-depth for subprocess.run() calls from gthread worker threads:
# Defence-in-depth for subprocess.run() calls from worker threads:
# if a worker thread has initialised ObjC and another thread calls
# subprocess.run() (which does fork+exec), the subprocess child would
# crash without this flag. The master→worker fork is safe without it
# (the post_fork hook keeps the master thread-free), but subprocess calls
# from workers are not, so we keep it here.
# from within workers are not, so we keep it here.
os.environ['OBJC_DISABLE_INITIALIZE_FORK_SAFETY'] = 'YES'
from gunicorn.app.wsgiapp import run as gunicorn_run
sys.argv = [
sys.argv[0], 'autopkgrunner.wsgi:application',
sys.argv[0], 'autopkgrunner.asgi:application',
'--config', 'python:autopkgrunner.gunicorn_conf',
'--worker-class', 'uvicorn.workers.UvicornWorker',
'--bind', f'{opts.bind}:{opts.port}',
'--workers', opts.workers,
'--threads', opts.threads,
]
gunicorn_run()
else:
Expand Down
14 changes: 14 additions & 0 deletions scripts/build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -101,10 +101,24 @@ else
codesign --sign - --deep --force "$APP"
fi

read APP_NAME VERSION_STR < <(python3 -c "
import importlib.util, pathlib
p = pathlib.Path('$REPO_ROOT/__info__.py')
s = importlib.util.spec_from_file_location('__info__', p)
m = importlib.util.module_from_spec(s); s.loader.exec_module(m)
print(m.APP_NAME, m.APP_VERSION_STR)
")
ZIP_NAME="${APP_NAME}_${VERSION_STR}.zip"
ZIP_PATH="dist/$ZIP_NAME"
echo "==> Creating $ZIP_PATH..."
(cd dist && zip -r --symlinks "$ZIP_NAME" "AutoPkg Runner.app")
echo " Created $ZIP_PATH ($(du -sh "$ZIP_PATH" | cut -f1))"

echo ""
echo "Build complete."
echo " App bundle : $APP"
echo " Binary : $BINARY"
echo " Archive : $ZIP_PATH"
echo ""
echo "First-run setup:"
echo " 1. Grant Full Disk Access to the app in:"
Expand Down
Loading