Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .envs/.ci/.django
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,8 @@ RABBITMQ_DEFAULT_PASS=rabbitpass
# NATS
# ------------------------------------------------------------------------------
NATS_URL=nats://nats:4222

# Enable idempotent bootstrap for CI so processing services can self-register.
DJANGO_SUPERUSER_EMAIL=antenna@insectai.org
DJANGO_SUPERUSER_PASSWORD=localadmin
ENSURE_DEFAULT_PROJECT=1
4 changes: 4 additions & 0 deletions .envs/.local/.django
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,7 @@ MINIO_BROWSER_REDIRECT_URL=http://minio:9001
DEFAULT_PROCESSING_SERVICE_NAME=Local Processing Service
DEFAULT_PROCESSING_SERVICE_ENDPOINT=http://ml_backend:2000
# DEFAULT_PIPELINES_ENABLED=random,constant # When set to None, all pipelines will be enabled.

# Idempotent local/CI bootstrap (ami/main/management/commands/ensure_default_project.py)
# Ensures a default superuser + project exist so processing services can self-register.
ENSURE_DEFAULT_PROJECT=1
132 changes: 132 additions & 0 deletions ami/main/management/commands/ensure_default_project.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
"""
Idempotent bootstrap command for local dev and CI.

Ensures a superuser and a named Project exist so that local-dev / CI processing
services can register pipelines against Antenna without any manual setup step.

Guarded behind the ENSURE_DEFAULT_PROJECT env var so production deployments
never run it accidentally. Intended to be called from compose/local/django/start.

Looks up / creates the Project by name (no slug field on Project) so running
this in a long-lived dev DB where PK 1 is already taken by a different project
doesn't conflict.
"""

import logging
import os
import uuid

from django.contrib.auth import get_user_model
from django.core.management import call_command
from django.core.management.base import BaseCommand
from django.db import transaction

from ami.main.models import Project

logger = logging.getLogger(__name__)

DEFAULT_PROJECT_NAME = "Default Project"
DEFAULT_COLLECTION_NAME = "Default Collection"


class Command(BaseCommand):
help = "Idempotently create a default superuser and project for local dev / CI."

def add_arguments(self, parser):
parser.add_argument(
"--project-name",
default=os.environ.get("ANTENNA_DEFAULT_PROJECT_NAME", DEFAULT_PROJECT_NAME),
help="Project name to ensure exists (default: env ANTENNA_DEFAULT_PROJECT_NAME or 'Default Project')",
)
parser.add_argument(
"--skip-seed",
action="store_true",
help="Do not seed a sample image collection (by default a small minio-backed "
"collection is created so the minimal worker has something to process).",
)

def handle(self, *args, **options):
User = get_user_model()

try:
call_command("createsuperuser", interactive=False)
self.stdout.write(self.style.SUCCESS("Created superuser from DJANGO_SUPERUSER_* env vars"))
except Exception as e:
# createsuperuser raises CommandError if the user already exists;
# that's the idempotent path we want.
logger.info("Superuser createsuperuser call reported: %s", e)

email = os.environ.get("DJANGO_SUPERUSER_EMAIL")
owner = User.objects.filter(email=email).first() if email else None
if owner is None:
self.stdout.write(
self.style.WARNING(
"No DJANGO_SUPERUSER_EMAIL env var (or user not found). "
"Project will be created without an owner."
)
)

project_name = options["project_name"]
with transaction.atomic():
project, created = Project.objects.get_or_create(
name=project_name,
defaults={"owner": owner, "description": "Bootstrap project for local dev and CI."},
)

if created:
self.stdout.write(self.style.SUCCESS(f"Created project '{project_name}' (id={project.pk})"))
else:
self.stdout.write(f"Project '{project_name}' already exists (id={project.pk})")

if not options["skip_seed"]:
self._seed_default_collection(project)

# Print in a stable, parseable format so shell wrappers can capture the
# ID. Compose files can't read command output — they use env vars — so
# the PS container reads ANTENNA_DEFAULT_PROJECT_NAME and resolves
# to a PK via the REST API rather than relying on PK being stable.
self.stdout.write(f"ANTENNA_DEFAULT_PROJECT_ID={project.pk}")

def _seed_default_collection(self, project: Project) -> None:
"""Seed a small minio-backed image collection so the minimal worker has real images
to process out of the box.

The minimal worker opens each image and reads its pixel dimensions, so path-only
rows won't do — the images must be reachable. This reuses the test fixtures, which
generate images, upload them to the local object store, and sync them as captures.

Idempotent: skips if a non-empty "Default Collection" already exists for the project
(keyed on the collection, not on any source images — a project may have images from
other sources but still lack the collection the worker needs). Best-effort: any failure
(e.g. no object store in this environment) is logged and swallowed so it can never
break the bootstrap that callers run on every startup.
"""
from ami.main.models import SourceImageCollection

existing = SourceImageCollection.objects.filter(project=project, name=DEFAULT_COLLECTION_NAME).first()
if existing and existing.images.exists():
self.stdout.write(
f"Project '{project.name}' already has a non-empty '{DEFAULT_COLLECTION_NAME}'; skipping image seed."
)
return

try:
from ami.tests.fixtures.main import create_captures_from_files, create_deployment
from ami.tests.fixtures.storage import create_storage_source

short_id = uuid.uuid4().hex[:8]
data_source = create_storage_source(project, f"Default Data Source {short_id}", prefix=short_id)
deployment = create_deployment(project, data_source, f"Default Deployment {short_id}")
frames = create_captures_from_files(deployment)
images = [source_image for source_image, _frame in frames]

collection, _ = SourceImageCollection.objects.get_or_create(project=project, name=DEFAULT_COLLECTION_NAME)
collection.images.set(images)
self.stdout.write(
self.style.SUCCESS(
f"Seeded {len(images)} images into '{DEFAULT_COLLECTION_NAME}' (id={collection.pk})"
)
)
except Exception as e:
logger.warning("Could not seed default image collection (continuing without it): %s", e)
self.stdout.write(self.style.WARNING(f"Skipped image seed (continuing): {e}"))
37 changes: 37 additions & 0 deletions ami/main/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -6300,3 +6300,40 @@ def test_commit_is_idempotent(self):
self._call_command(f"--project={self.project.pk}", "--commit")
second_run = self._call_command(f"--project={self.project.pk}", "--commit")
self.assertIn("Nothing to clean up", second_run)
class TestEnsureDefaultProjectSeed(TestCase):
"""
The ensure_default_project bootstrap seeds a small, reachable image collection so the
minimal v2 worker has real images to process out of the box (the worker opens each image
and reads its pixel dimensions, so path-only rows are not enough). Pins that the seed
creates a non-empty collection and that re-running is idempotent (does not re-seed).
"""

def test_seeds_a_nonempty_collection_with_reachable_images(self):
from django.core.management import call_command

call_command("ensure_default_project", "--project-name", "Seed Test Project")

project = Project.objects.get(name="Seed Test Project")
collection = SourceImageCollection.objects.get(project=project, name="Default Collection")
self.assertGreater(collection.images.count(), 0, "seed must create a non-empty collection")
# Images are attached to a deployment with a data source, so they are reachable
# (pixels live in the object store) rather than path-only placeholders.
seeded = SourceImage.objects.filter(deployment__project=project)
self.assertTrue(seeded.exists())
self.assertTrue(all(img.deployment_id is not None for img in seeded))

def test_seed_is_idempotent(self):
from django.core.management import call_command

call_command("ensure_default_project", "--project-name", "Seed Test Project")
before = SourceImage.objects.filter(deployment__project__name="Seed Test Project").count()
call_command("ensure_default_project", "--project-name", "Seed Test Project")
after = SourceImage.objects.filter(deployment__project__name="Seed Test Project").count()
self.assertEqual(before, after, "second run must skip seeding when images already exist")

def test_skip_seed_flag_creates_no_images(self):
from django.core.management import call_command

call_command("ensure_default_project", "--project-name", "No Seed Project", "--skip-seed")
project = Project.objects.get(name="No Seed Project")
self.assertFalse(SourceImage.objects.filter(deployment__project=project).exists())
8 changes: 8 additions & 0 deletions compose/local/django/start
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ set -o nounset

python manage.py migrate

# Idempotent bootstrap for local dev and CI. Creates the default superuser
# (from DJANGO_SUPERUSER_* env vars) and a named project so processing-service
# containers can self-register against Antenna with no manual setup.
# Safe to run in production: gated behind ENSURE_DEFAULT_PROJECT=1.
if [ "${ENSURE_DEFAULT_PROJECT:-0}" = "1" ]; then
python manage.py ensure_default_project || echo "ensure_default_project failed, continuing"
fi

# Set USE_UVICORN=1 to use the original raw uvicorn dev server instead of gunicorn
if [ "${USE_UVICORN:-0}" = "1" ]; then
if [ "${DEBUGGER:-0}" = "1" ]; then
Expand Down
Loading
Loading