Setup Instance Wizard — Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Build a Django management command (manage.py setup_instance) that walks users through configuring their pipeline preset, stage drivers, and credentials — then writes .env updates and creates PipelineDefinition + NotificationChannel records.

Architecture: Single management command in apps/orchestration with step functions for each wizard phase. Input helpers (_prompt_choice, _prompt_multi, _prompt_input) wrap input() for consistent UX and testability. Config is applied atomically at the end.

Tech Stack: Django management commands, Django ORM (PipelineDefinition, NotificationChannel), Python input() for interactive prompts, dotenv-style .env file manipulation.

Design doc: docs/plans/2026-02-19-setup-instance-design.md


Task 1: Input Helpers + Preset Selection

Files:

  • Create: apps/orchestration/management/commands/setup_instance.py
  • Create: apps/orchestration/_tests/test_setup_instance.py

Step 1: Write failing tests for input helpers and preset selection

# apps/orchestration/_tests/test_setup_instance.py
"""Tests for the setup_instance management command."""

from io import StringIO
from unittest.mock import patch

from django.test import TestCase

from apps.orchestration.management.commands.setup_instance import Command


class PromptChoiceTests(TestCase):
    """Tests for _prompt_choice helper."""

    def setUp(self):
        self.cmd = Command(stdout=StringIO(), stderr=StringIO())

    @patch("builtins.input", return_value="2")
    def test_returns_selected_option(self, _mock_input):
        result = self.cmd._prompt_choice(
            "Pick one:", [("a", "Option A"), ("b", "Option B"), ("c", "Option C")]
        )
        assert result == "b"

    @patch("builtins.input", side_effect=["0", "5", "2"])
    def test_retries_on_invalid_input(self, _mock_input):
        result = self.cmd._prompt_choice(
            "Pick one:", [("a", "Option A"), ("b", "Option B")]
        )
        assert result == "b"

    @patch("builtins.input", side_effect=["abc", "1"])
    def test_retries_on_non_numeric_input(self, _mock_input):
        result = self.cmd._prompt_choice(
            "Pick one:", [("a", "Option A")]
        )
        assert result == "a"


class PromptMultiTests(TestCase):
    """Tests for _prompt_multi helper."""

    def setUp(self):
        self.cmd = Command(stdout=StringIO(), stderr=StringIO())

    @patch("builtins.input", return_value="1,3")
    def test_returns_selected_options(self, _mock_input):
        result = self.cmd._prompt_multi(
            "Pick:", [("a", "A"), ("b", "B"), ("c", "C")]
        )
        assert result == ["a", "c"]

    @patch("builtins.input", return_value="1, 2, 3")
    def test_handles_spaces_in_input(self, _mock_input):
        result = self.cmd._prompt_multi(
            "Pick:", [("a", "A"), ("b", "B"), ("c", "C")]
        )
        assert result == ["a", "b", "c"]

    @patch("builtins.input", side_effect=["", "1"])
    def test_retries_on_empty_input(self, _mock_input):
        result = self.cmd._prompt_multi(
            "Pick:", [("a", "A"), ("b", "B")]
        )
        assert result == ["a"]


class PromptInputTests(TestCase):
    """Tests for _prompt_input helper."""

    def setUp(self):
        self.cmd = Command(stdout=StringIO(), stderr=StringIO())

    @patch("builtins.input", return_value="hello")
    def test_returns_user_input(self, _mock_input):
        result = self.cmd._prompt_input("Enter value:")
        assert result == "hello"

    @patch("builtins.input", return_value="")
    def test_returns_default_when_empty(self, _mock_input):
        result = self.cmd._prompt_input("Enter value:", default="fallback")
        assert result == "fallback"

    @patch("builtins.input", side_effect=["", "val"])
    def test_retries_when_required_and_empty(self, _mock_input):
        result = self.cmd._prompt_input("Enter value:", required=True)
        assert result == "val"


class SelectPresetTests(TestCase):
    """Tests for _select_preset step."""

    def setUp(self):
        self.cmd = Command(stdout=StringIO(), stderr=StringIO())

    @patch("builtins.input", return_value="1")
    def test_select_direct_preset(self, _mock_input):
        preset = self.cmd._select_preset()
        assert preset["name"] == "direct"
        assert preset["has_checkers"] is False
        assert preset["has_intelligence"] is False

    @patch("builtins.input", return_value="4")
    def test_select_full_preset(self, _mock_input):
        preset = self.cmd._select_preset()
        assert preset["name"] == "full"
        assert preset["has_checkers"] is True
        assert preset["has_intelligence"] is True

    @patch("builtins.input", return_value="2")
    def test_select_health_checked_preset(self, _mock_input):
        preset = self.cmd._select_preset()
        assert preset["name"] == "health-checked"
        assert preset["has_checkers"] is True
        assert preset["has_intelligence"] is False

    @patch("builtins.input", return_value="3")
    def test_select_ai_analyzed_preset(self, _mock_input):
        preset = self.cmd._select_preset()
        assert preset["name"] == "ai-analyzed"
        assert preset["has_checkers"] is False
        assert preset["has_intelligence"] is True

Step 2: Run tests to verify they fail

Run: uv run pytest apps/orchestration/_tests/test_setup_instance.py -v Expected: FAIL — ModuleNotFoundError: No module named 'apps.orchestration.management.commands.setup_instance'

Step 3: Implement input helpers and preset selection

# apps/orchestration/management/commands/setup_instance.py
"""
Interactive setup wizard for configuring a server-maintanence instance.

Guides the user through selecting a pipeline preset, configuring drivers
per active stage, collecting credentials, and writing configuration.

Usage:
    python manage.py setup_instance
"""

from django.core.management.base import BaseCommand

# Preset definitions: name, label, description, active stages
PRESETS = [
    {
        "name": "direct",
        "label": "Alert → Notify",
        "description": "Direct forwarding",
        "has_checkers": False,
        "has_intelligence": False,
    },
    {
        "name": "health-checked",
        "label": "Alert → Checkers → Notify",
        "description": "Health-checked alerts",
        "has_checkers": True,
        "has_intelligence": False,
    },
    {
        "name": "ai-analyzed",
        "label": "Alert → Intelligence → Notify",
        "description": "AI-analyzed alerts",
        "has_checkers": False,
        "has_intelligence": True,
    },
    {
        "name": "full",
        "label": "Alert → Checkers → Intelligence → Notify",
        "description": "Full pipeline",
        "has_checkers": True,
        "has_intelligence": True,
    },
]


class Command(BaseCommand):
    help = "Interactive setup wizard for configuring your server-maintanence instance."

    def _prompt_choice(self, prompt, options):
        """
        Prompt user to select one option from a numbered list.

        Args:
            prompt: Question text to display.
            options: List of (value, label) tuples.

        Returns:
            The value of the selected option.
        """
        self.stdout.write(f"\n{prompt}")
        for i, (_, label) in enumerate(options, 1):
            self.stdout.write(f"  {i}) {label}")

        while True:
            try:
                choice = int(input("\n> "))
                if 1 <= choice <= len(options):
                    return options[choice - 1][0]
            except (ValueError, IndexError):
                pass
            self.stdout.write(self.style.WARNING(f"  Please enter 1-{len(options)}."))

    def _prompt_multi(self, prompt, options):
        """
        Prompt user to select one or more options (comma-separated numbers).

        Args:
            prompt: Question text to display.
            options: List of (value, label) tuples.

        Returns:
            List of selected values.
        """
        self.stdout.write(f"\n{prompt}")
        for i, (_, label) in enumerate(options, 1):
            self.stdout.write(f"  {i}) {label}")

        while True:
            raw = input("\n> (comma-separated, e.g. 1,3): ")
            try:
                indices = [int(x.strip()) for x in raw.split(",") if x.strip()]
                if indices and all(1 <= i <= len(options) for i in indices):
                    return [options[i - 1][0] for i in indices]
            except (ValueError, IndexError):
                pass
            self.stdout.write(
                self.style.WARNING(f"  Enter comma-separated numbers 1-{len(options)}.")
            )

    def _prompt_input(self, prompt, default=None, required=False):
        """
        Prompt user for free-text input.

        Args:
            prompt: Question text.
            default: Default value if user presses Enter.
            required: If True, retry on empty input.

        Returns:
            User input string, or default.
        """
        suffix = f" [{default}]" if default else ""
        while True:
            value = input(f"{prompt}{suffix}: ").strip()
            if value:
                return value
            if default is not None:
                return default
            if required:
                self.stdout.write(self.style.WARNING("  Value cannot be empty."))
                continue
            return ""

    def _select_preset(self):
        """
        Prompt user to select a pipeline preset.

        Returns:
            Dict with preset metadata (name, has_checkers, has_intelligence).
        """
        options = [
            (preset, f'{preset["label"]}  ({preset["description"]})')
            for preset in PRESETS
        ]
        selected = self._prompt_choice("? How will you use this instance?", options)
        return selected

    def handle(self, *args, **options):
        pass

Step 4: Run tests to verify they pass

Run: uv run pytest apps/orchestration/_tests/test_setup_instance.py -v Expected: All 13 tests PASS

Step 5: Commit

git add apps/orchestration/management/commands/setup_instance.py apps/orchestration/_tests/test_setup_instance.py
git commit -m "feat(setup_instance): add input helpers and preset selection"

Task 2: Alert and Checker Stage Configuration

Files:

  • Modify: apps/orchestration/management/commands/setup_instance.py
  • Modify: apps/orchestration/_tests/test_setup_instance.py

Step 1: Write failing tests for alert and checker configuration

Add to test_setup_instance.py:

class ConfigureAlertsTests(TestCase):
    """Tests for _configure_alerts step."""

    def setUp(self):
        self.cmd = Command(stdout=StringIO(), stderr=StringIO())

    @patch("builtins.input", return_value="1,2,8")
    def test_returns_selected_drivers(self, _mock_input):
        result = self.cmd._configure_alerts()
        assert result == ["alertmanager", "grafana", "generic"]

    @patch("builtins.input", return_value="1")
    def test_single_driver_selection(self, _mock_input):
        result = self.cmd._configure_alerts()
        assert result == ["alertmanager"]


class ConfigureCheckersTests(TestCase):
    """Tests for _configure_checkers step."""

    def setUp(self):
        self.cmd = Command(stdout=StringIO(), stderr=StringIO())

    @patch("builtins.input", return_value="1,2")
    def test_returns_selected_checkers(self, _mock_input):
        result = self.cmd._configure_checkers()
        assert "cpu" in result["enabled"]
        assert "memory" in result["enabled"]

    @patch("builtins.input", side_effect=["1,2,3", "/,/home"])
    def test_disk_checker_asks_for_paths(self, _mock_input):
        result = self.cmd._configure_checkers()
        assert "disk" in result["enabled"]
        assert result["disk_paths"] == "/,/home"

    @patch("builtins.input", side_effect=["7", "8.8.8.8"])
    def test_network_checker_asks_for_hosts(self, _mock_input):
        result = self.cmd._configure_checkers()
        assert "network" in result["enabled"]
        assert result["network_hosts"] == "8.8.8.8"

    @patch("builtins.input", side_effect=["8", "nginx,postgres"])
    def test_process_checker_asks_for_names(self, _mock_input):
        result = self.cmd._configure_checkers()
        assert "process" in result["enabled"]
        assert result["process_names"] == "nginx,postgres"

Step 2: Run tests to verify they fail

Run: uv run pytest apps/orchestration/_tests/test_setup_instance.py::ConfigureAlertsTests -v Expected: FAIL — AttributeError: 'Command' object has no attribute '_configure_alerts'

Step 3: Implement alert and checker configuration

Add to setup_instance.py Command class:

    def _configure_alerts(self):
        """
        Prompt user to select alert drivers.

        Returns:
            List of selected driver name strings.
        """
        from apps.alerts.drivers import DRIVER_REGISTRY

        self.stdout.write(self.style.HTTP_INFO("\n--- Stage: Alerts ---"))
        options = [(name, name) for name in DRIVER_REGISTRY]
        return self._prompt_multi("? Which alert drivers do you want to enable?", options)

    def _configure_checkers(self):
        """
        Prompt user to select health checkers and per-checker config.

        Returns:
            Dict with 'enabled' list and optional per-checker config keys:
            disk_paths, network_hosts, process_names.
        """
        from apps.checkers.checkers import CHECKER_REGISTRY

        self.stdout.write(self.style.HTTP_INFO("\n--- Stage: Checkers ---"))
        options = [(name, name) for name in CHECKER_REGISTRY]
        selected = self._prompt_multi(
            "? Which health checkers do you want to enable?", options
        )

        result = {"enabled": selected}

        if "disk" in selected:
            result["disk_paths"] = self._prompt_input(
                "  Disk paths to monitor", default="/"
            )
        if "network" in selected:
            result["network_hosts"] = self._prompt_input(
                "  Hosts to ping", default="8.8.8.8,1.1.1.1"
            )
        if "process" in selected:
            result["process_names"] = self._prompt_input(
                "  Process names to watch", required=True
            )

        return result

Step 4: Run tests to verify they pass

Run: uv run pytest apps/orchestration/_tests/test_setup_instance.py -v -k "Alerts or Checkers" Expected: All 6 new tests PASS

Step 5: Commit

git add apps/orchestration/management/commands/setup_instance.py apps/orchestration/_tests/test_setup_instance.py
git commit -m "feat(setup_instance): add alert and checker stage configuration"

Task 3: Intelligence and Notify Stage Configuration

Files:

  • Modify: apps/orchestration/management/commands/setup_instance.py
  • Modify: apps/orchestration/_tests/test_setup_instance.py

Step 1: Write failing tests for intelligence and notify configuration

Add to test_setup_instance.py:

class ConfigureIntelligenceTests(TestCase):
    """Tests for _configure_intelligence step."""

    def setUp(self):
        self.cmd = Command(stdout=StringIO(), stderr=StringIO())

    @patch("builtins.input", return_value="1")
    def test_local_provider_needs_no_config(self, _mock_input):
        result = self.cmd._configure_intelligence()
        assert result["provider"] == "local"
        assert "api_key" not in result

    @patch("builtins.input", side_effect=["2", "sk-test123", "gpt-4o-mini"])
    def test_openai_provider_collects_credentials(self, _mock_input):
        result = self.cmd._configure_intelligence()
        assert result["provider"] == "openai"
        assert result["api_key"] == "sk-test123"
        assert result["model"] == "gpt-4o-mini"

    @patch("builtins.input", side_effect=["2", "sk-test123", ""])
    def test_openai_uses_default_model(self, _mock_input):
        result = self.cmd._configure_intelligence()
        assert result["model"] == "gpt-4o-mini"


class ConfigureNotifyTests(TestCase):
    """Tests for _configure_notify step."""

    def setUp(self):
        self.cmd = Command(stdout=StringIO(), stderr=StringIO())

    @patch(
        "builtins.input",
        side_effect=["2", "https://hooks.slack.com/xxx", "ops-alerts"],
    )
    def test_slack_collects_webhook_url_and_name(self, _mock_input):
        result = self.cmd._configure_notify()
        assert len(result) == 1
        assert result[0]["driver"] == "slack"
        assert result[0]["config"]["webhook_url"] == "https://hooks.slack.com/xxx"
        assert result[0]["name"] == "ops-alerts"

    @patch(
        "builtins.input",
        side_effect=[
            "1",  # email
            "smtp.example.com",  # host
            "587",  # port
            "user@example.com",  # user
            "password123",  # password
            "noreply@example.com",  # from
            "ops@example.com",  # to
            "ops-email",  # channel name
        ],
    )
    def test_email_collects_smtp_settings(self, _mock_input):
        result = self.cmd._configure_notify()
        assert len(result) == 1
        assert result[0]["driver"] == "email"
        assert result[0]["config"]["smtp_host"] == "smtp.example.com"
        assert result[0]["config"]["smtp_port"] == "587"
        assert result[0]["name"] == "ops-email"

    @patch(
        "builtins.input",
        side_effect=["3", "R0123456789", "oncall-pd"],
    )
    def test_pagerduty_collects_routing_key(self, _mock_input):
        result = self.cmd._configure_notify()
        assert len(result) == 1
        assert result[0]["driver"] == "pagerduty"
        assert result[0]["config"]["routing_key"] == "R0123456789"

    @patch(
        "builtins.input",
        side_effect=["4", "https://example.com/hook", "", "my-webhook"],
    )
    def test_generic_collects_endpoint(self, _mock_input):
        result = self.cmd._configure_notify()
        assert len(result) == 1
        assert result[0]["driver"] == "generic"
        assert result[0]["config"]["endpoint_url"] == "https://example.com/hook"

Step 2: Run tests to verify they fail

Run: uv run pytest apps/orchestration/_tests/test_setup_instance.py -v -k "Intelligence or Notify" Expected: FAIL — AttributeError

Step 3: Implement intelligence and notify configuration

Add to setup_instance.py Command class:

    def _configure_intelligence(self):
        """
        Prompt user to select an AI provider and collect credentials.

        Returns:
            Dict with 'provider' and optional 'api_key', 'model'.
        """
        from apps.intelligence.providers import PROVIDERS

        self.stdout.write(self.style.HTTP_INFO("\n--- Stage: Intelligence ---"))
        options = [(name, name) for name in PROVIDERS]
        provider = self._prompt_choice("? Which AI provider do you want to use?", options)

        result = {"provider": provider}

        if provider == "openai":
            result["api_key"] = self._prompt_input("  OpenAI API key", required=True)
            result["model"] = self._prompt_input("  OpenAI model", default="gpt-4o-mini")

        return result

    def _configure_notify(self):
        """
        Prompt user to select notification channels and collect per-driver config.

        Returns:
            List of dicts, each with 'driver', 'name', 'config'.
        """
        from apps.notify.drivers import DRIVER_REGISTRY

        self.stdout.write(self.style.HTTP_INFO("\n--- Stage: Notify ---"))
        options = [(name, name) for name in DRIVER_REGISTRY]
        selected = self._prompt_multi(
            "? Which notification channels do you want to configure?", options
        )

        channels = []
        for driver_name in selected:
            self.stdout.write(f"\n  Configuring {driver_name}:")
            config = {}

            if driver_name == "email":
                config["smtp_host"] = self._prompt_input("    SMTP host", required=True)
                config["smtp_port"] = self._prompt_input("    SMTP port", default="587")
                config["smtp_user"] = self._prompt_input("    SMTP user", required=True)
                config["smtp_password"] = self._prompt_input(
                    "    SMTP password", required=True
                )
                config["smtp_from"] = self._prompt_input("    From address", required=True)
                config["smtp_to"] = self._prompt_input("    To address", required=True)
            elif driver_name == "slack":
                config["webhook_url"] = self._prompt_input(
                    "    Slack webhook URL", required=True
                )
            elif driver_name == "pagerduty":
                config["routing_key"] = self._prompt_input(
                    "    PagerDuty routing key", required=True
                )
            elif driver_name == "generic":
                config["endpoint_url"] = self._prompt_input(
                    "    Endpoint URL", required=True
                )
                headers = self._prompt_input("    Headers (JSON, optional)", default="")
                if headers:
                    config["headers"] = headers

            channel_name = self._prompt_input(
                f"    Channel name", default=f"ops-{driver_name}"
            )
            channels.append({"driver": driver_name, "name": channel_name, "config": config})

        return channels

Step 4: Run tests to verify they pass

Run: uv run pytest apps/orchestration/_tests/test_setup_instance.py -v -k "Intelligence or Notify" Expected: All 7 new tests PASS

Step 5: Commit

git add apps/orchestration/management/commands/setup_instance.py apps/orchestration/_tests/test_setup_instance.py
git commit -m "feat(setup_instance): add intelligence and notify stage configuration"

Task 4: Summary Display and Confirmation

Files:

  • Modify: apps/orchestration/management/commands/setup_instance.py
  • Modify: apps/orchestration/_tests/test_setup_instance.py

Step 1: Write failing tests for summary and confirmation

Add to test_setup_instance.py:

class ShowSummaryTests(TestCase):
    """Tests for _show_summary step."""

    def setUp(self):
        self.cmd = Command(stdout=StringIO(), stderr=StringIO())

    def test_summary_includes_preset_name(self):
        config = {
            "preset": {"name": "full", "label": "Full pipeline"},
            "alerts": ["alertmanager", "grafana"],
            "checkers": {"enabled": ["cpu", "memory"]},
            "intelligence": {"provider": "openai", "model": "gpt-4o-mini"},
            "notify": [{"driver": "slack", "name": "ops-alerts"}],
        }
        self.cmd._show_summary(config)
        output = self.cmd.stdout.getvalue()
        assert "full" in output.lower() or "Full pipeline" in output

    def test_summary_includes_all_drivers(self):
        config = {
            "preset": {"name": "direct", "label": "Direct"},
            "alerts": ["grafana"],
            "notify": [{"driver": "slack", "name": "ops-slack"}],
        }
        self.cmd._show_summary(config)
        output = self.cmd.stdout.getvalue()
        assert "grafana" in output
        assert "slack" in output


class ConfirmApplyTests(TestCase):
    """Tests for _confirm_apply step."""

    def setUp(self):
        self.cmd = Command(stdout=StringIO(), stderr=StringIO())

    @patch("builtins.input", return_value="Y")
    def test_returns_true_on_yes(self, _mock_input):
        assert self.cmd._confirm_apply() is True

    @patch("builtins.input", return_value="")
    def test_returns_true_on_empty_default_yes(self, _mock_input):
        assert self.cmd._confirm_apply() is True

    @patch("builtins.input", return_value="n")
    def test_returns_false_on_no(self, _mock_input):
        assert self.cmd._confirm_apply() is False

Step 2: Run tests to verify they fail

Run: uv run pytest apps/orchestration/_tests/test_setup_instance.py -v -k "Summary or Confirm" Expected: FAIL — AttributeError

Step 3: Implement summary and confirmation

Add to setup_instance.py Command class:

    def _show_summary(self, config):
        """
        Display a summary of collected configuration for user review.

        Args:
            config: Dict with all collected wizard state.
        """
        self.stdout.write(self.style.HTTP_INFO("\n--- Summary ---"))
        self.stdout.write(f"  Pipeline: {config['preset']['label']}")

        if "alerts" in config:
            self.stdout.write(f"  Alert drivers: {', '.join(config['alerts'])}")

        if "checkers" in config:
            self.stdout.write(
                f"  Checkers: {', '.join(config['checkers']['enabled'])}"
            )

        if "intelligence" in config:
            intel = config["intelligence"]
            provider_info = intel["provider"]
            if intel.get("model"):
                provider_info += f" ({intel['model']})"
            self.stdout.write(f"  Intelligence: {provider_info}")

        if "notify" in config:
            for ch in config["notify"]:
                self.stdout.write(f"  Notification: {ch['driver']} ({ch['name']})")

    def _confirm_apply(self):
        """
        Ask user to confirm applying configuration.

        Returns:
            True if user confirms, False otherwise.
        """
        response = input("\n? Apply this configuration? [Y/n]: ").strip().lower()
        return response in ("", "y", "yes")

Step 4: Run tests to verify they pass

Run: uv run pytest apps/orchestration/_tests/test_setup_instance.py -v -k "Summary or Confirm" Expected: All 5 new tests PASS

Step 5: Commit

git add apps/orchestration/management/commands/setup_instance.py apps/orchestration/_tests/test_setup_instance.py
git commit -m "feat(setup_instance): add summary display and confirmation"

Task 5: .env Writer

Files:

  • Modify: apps/orchestration/management/commands/setup_instance.py
  • Modify: apps/orchestration/_tests/test_setup_instance.py

Step 1: Write failing tests for .env writing

Add to test_setup_instance.py:

import os
import tempfile


class WriteEnvTests(TestCase):
    """Tests for _write_env helper."""

    def setUp(self):
        self.cmd = Command(stdout=StringIO(), stderr=StringIO())

    def test_adds_new_keys_to_env_file(self):
        with tempfile.NamedTemporaryFile(mode="w", suffix=".env", delete=False) as f:
            f.write("EXISTING_KEY=value\n")
            f.flush()
            env_path = f.name

        try:
            self.cmd._write_env(env_path, {"NEW_KEY": "new_value"})
            with open(env_path) as f:
                content = f.read()
            assert "EXISTING_KEY=value" in content
            assert "NEW_KEY=new_value" in content
        finally:
            os.unlink(env_path)

    def test_updates_existing_keys(self):
        with tempfile.NamedTemporaryFile(mode="w", suffix=".env", delete=False) as f:
            f.write("MY_KEY=old\nOTHER=keep\n")
            f.flush()
            env_path = f.name

        try:
            self.cmd._write_env(env_path, {"MY_KEY": "new"})
            with open(env_path) as f:
                content = f.read()
            assert "MY_KEY=new" in content
            assert "MY_KEY=old" not in content
            assert "OTHER=keep" in content
        finally:
            os.unlink(env_path)

    def test_creates_env_file_if_missing(self):
        env_path = tempfile.mktemp(suffix=".env")
        try:
            self.cmd._write_env(env_path, {"KEY": "val"})
            with open(env_path) as f:
                content = f.read()
            assert "KEY=val" in content
        finally:
            if os.path.exists(env_path):
                os.unlink(env_path)

    def test_adds_section_header_comment(self):
        with tempfile.NamedTemporaryFile(mode="w", suffix=".env", delete=False) as f:
            f.write("")
            f.flush()
            env_path = f.name

        try:
            self.cmd._write_env(env_path, {"KEY": "val"})
            with open(env_path) as f:
                content = f.read()
            assert "setup_instance" in content
        finally:
            os.unlink(env_path)

Step 2: Run tests to verify they fail

Run: uv run pytest apps/orchestration/_tests/test_setup_instance.py::WriteEnvTests -v Expected: FAIL — AttributeError: 'Command' object has no attribute '_write_env'

Step 3: Implement .env writer

Add to setup_instance.py Command class:

    def _write_env(self, env_path, updates):
        """
        Update .env file with new key-value pairs, preserving existing content.

        Args:
            env_path: Path to .env file.
            updates: Dict of key-value pairs to set.
        """
        import datetime

        lines = []
        existing_keys = set()

        # Read existing file
        if os.path.exists(env_path):
            with open(env_path) as f:
                for line in f:
                    stripped = line.strip()
                    # Check if this line sets a key we're updating
                    if "=" in stripped and not stripped.startswith("#"):
                        key = stripped.split("=", 1)[0].strip()
                        if key in updates:
                            lines.append(f"{key}={updates[key]}\n")
                            existing_keys.add(key)
                            continue
                    lines.append(line)

        # Append new keys that weren't already in the file
        new_keys = {k: v for k, v in updates.items() if k not in existing_keys}
        if new_keys:
            today = datetime.date.today().isoformat()
            lines.append(f"\n# --- setup_instance: Generated {today} ---\n")
            for key, value in new_keys.items():
                lines.append(f"{key}={value}\n")

        with open(env_path, "w") as f:
            f.writelines(lines)

Also add import os at the top of the file if not already present.

Step 4: Run tests to verify they pass

Run: uv run pytest apps/orchestration/_tests/test_setup_instance.py::WriteEnvTests -v Expected: All 4 tests PASS

Step 5: Commit

git add apps/orchestration/management/commands/setup_instance.py apps/orchestration/_tests/test_setup_instance.py
git commit -m "feat(setup_instance): add .env file writer"

Task 6: PipelineDefinition and NotificationChannel Creation

Files:

  • Modify: apps/orchestration/management/commands/setup_instance.py
  • Modify: apps/orchestration/_tests/test_setup_instance.py

Step 1: Write failing tests for DB record creation

Add to test_setup_instance.py:

from apps.notify.models import NotificationChannel
from apps.orchestration.models import PipelineDefinition


class CreatePipelineDefinitionTests(TestCase):
    """Tests for _create_pipeline_definition."""

    def setUp(self):
        self.cmd = Command(stdout=StringIO(), stderr=StringIO())

    def test_creates_direct_pipeline(self):
        config = {
            "preset": {"name": "direct", "has_checkers": False, "has_intelligence": False},
            "alerts": ["grafana"],
            "notify": [{"driver": "slack", "name": "ops-slack", "config": {}}],
        }
        defn = self.cmd._create_pipeline_definition(config)
        assert defn.name == "direct"
        assert defn.is_active is True
        assert "setup_wizard" in defn.tags
        nodes = defn.get_nodes()
        node_types = [n["type"] for n in nodes]
        assert "ingest" in node_types
        assert "notify" in node_types
        assert "context" not in node_types
        assert "intelligence" not in node_types

    def test_creates_full_pipeline(self):
        config = {
            "preset": {"name": "full", "has_checkers": True, "has_intelligence": True},
            "alerts": ["alertmanager"],
            "checkers": {"enabled": ["cpu", "memory"]},
            "intelligence": {"provider": "openai"},
            "notify": [{"driver": "slack", "name": "ops-slack", "config": {}}],
        }
        defn = self.cmd._create_pipeline_definition(config)
        nodes = defn.get_nodes()
        node_types = [n["type"] for n in nodes]
        assert node_types == ["ingest", "context", "intelligence", "notify"]

    def test_nodes_are_chained_with_next(self):
        config = {
            "preset": {"name": "full", "has_checkers": True, "has_intelligence": True},
            "alerts": ["alertmanager"],
            "checkers": {"enabled": ["cpu"]},
            "intelligence": {"provider": "local"},
            "notify": [{"driver": "slack", "name": "ops-slack", "config": {}}],
        }
        defn = self.cmd._create_pipeline_definition(config)
        nodes = defn.get_nodes()
        # Each node except last should have "next" pointing to next node
        for i, node in enumerate(nodes[:-1]):
            assert node["next"] == nodes[i + 1]["id"]
        assert "next" not in nodes[-1]

    def test_tags_include_setup_wizard(self):
        config = {
            "preset": {"name": "direct", "has_checkers": False, "has_intelligence": False},
            "alerts": ["generic"],
            "notify": [{"driver": "generic", "name": "wh", "config": {}}],
        }
        defn = self.cmd._create_pipeline_definition(config)
        assert "setup_wizard" in defn.tags


class CreateNotificationChannelsTests(TestCase):
    """Tests for _create_notification_channels."""

    def setUp(self):
        self.cmd = Command(stdout=StringIO(), stderr=StringIO())

    def test_creates_channel_records(self):
        channels_config = [
            {
                "driver": "slack",
                "name": "ops-slack",
                "config": {"webhook_url": "https://hooks.slack.com/xxx"},
            },
        ]
        channels = self.cmd._create_notification_channels(channels_config)
        assert len(channels) == 1
        ch = NotificationChannel.objects.get(name="ops-slack")
        assert ch.driver == "slack"
        assert ch.config["webhook_url"] == "https://hooks.slack.com/xxx"
        assert ch.is_active is True
        assert "[setup_wizard]" in ch.description

    def test_creates_multiple_channels(self):
        channels_config = [
            {"driver": "slack", "name": "slack-ch", "config": {}},
            {"driver": "email", "name": "email-ch", "config": {}},
        ]
        channels = self.cmd._create_notification_channels(channels_config)
        assert len(channels) == 2
        assert NotificationChannel.objects.count() == 2

Step 2: Run tests to verify they fail

Run: uv run pytest apps/orchestration/_tests/test_setup_instance.py -v -k "CreatePipeline or CreateNotification" Expected: FAIL — AttributeError

Step 3: Implement DB record creation

Add to setup_instance.py Command class:

    def _create_pipeline_definition(self, config):
        """
        Create a PipelineDefinition record from collected config.

        Args:
            config: Full wizard config dict.

        Returns:
            Created PipelineDefinition instance.
        """
        from apps.orchestration.models import PipelineDefinition

        preset = config["preset"]
        nodes = []

        # Build node chain based on preset
        node_defs = [("ingest_webhook", "ingest", {})]

        if preset["has_checkers"]:
            checker_config = config.get("checkers", {})
            node_defs.append((
                "check_health",
                "context",
                {"checker_names": checker_config.get("enabled", [])},
            ))

        if preset["has_intelligence"]:
            intel_config = config.get("intelligence", {})
            node_defs.append((
                "analyze_incident",
                "intelligence",
                {"provider": intel_config.get("provider", "local")},
            ))

        notify_drivers = [ch["driver"] for ch in config.get("notify", [])]
        node_defs.append((
            "notify_channels",
            "notify",
            {"drivers": notify_drivers},
        ))

        # Chain nodes with "next" pointers
        for i, (node_id, node_type, node_config) in enumerate(node_defs):
            node = {"id": node_id, "type": node_type, "config": node_config}
            if i < len(node_defs) - 1:
                node["next"] = node_defs[i + 1][0]
            nodes.append(node)

        pipeline_config = {
            "version": "1.0",
            "description": f"Setup wizard: {preset['name']}",
            "defaults": {"max_retries": 3, "timeout_seconds": 300},
            "nodes": nodes,
        }

        return PipelineDefinition.objects.create(
            name=preset["name"],
            description=f"Pipeline created by setup_instance wizard ({preset['name']})",
            config=pipeline_config,
            tags=["setup_wizard"],
            created_by="setup_instance",
        )

    def _create_notification_channels(self, channels_config):
        """
        Create NotificationChannel records from collected config.

        Args:
            channels_config: List of dicts with 'driver', 'name', 'config'.

        Returns:
            List of created NotificationChannel instances.
        """
        from apps.notify.models import NotificationChannel

        created = []
        for ch in channels_config:
            channel = NotificationChannel.objects.create(
                name=ch["name"],
                driver=ch["driver"],
                config=ch["config"],
                is_active=True,
                description=f"[setup_wizard] {ch['driver']} channel",
            )
            created.append(channel)
        return created

Step 4: Run tests to verify they pass

Run: uv run pytest apps/orchestration/_tests/test_setup_instance.py -v -k "CreatePipeline or CreateNotification" Expected: All 6 tests PASS

Step 5: Commit

git add apps/orchestration/management/commands/setup_instance.py apps/orchestration/_tests/test_setup_instance.py
git commit -m "feat(setup_instance): add PipelineDefinition and NotificationChannel creation"

Task 7: Re-run Detection

Files:

  • Modify: apps/orchestration/management/commands/setup_instance.py
  • Modify: apps/orchestration/_tests/test_setup_instance.py

Step 1: Write failing tests for re-run detection

Add to test_setup_instance.py:

class DetectExistingTests(TestCase):
    """Tests for _detect_existing."""

    def setUp(self):
        self.cmd = Command(stdout=StringIO(), stderr=StringIO())

    def test_returns_none_when_no_existing(self):
        result = self.cmd._detect_existing()
        assert result is None

    def test_returns_definition_when_exists(self):
        PipelineDefinition.objects.create(
            name="full",
            config={"version": "1.0", "nodes": []},
            tags=["setup_wizard"],
            created_by="setup_instance",
        )
        result = self.cmd._detect_existing()
        assert result is not None
        assert result.name == "full"

    def test_ignores_non_wizard_definitions(self):
        PipelineDefinition.objects.create(
            name="custom",
            config={"version": "1.0", "nodes": []},
            tags=["manual"],
            created_by="admin",
        )
        result = self.cmd._detect_existing()
        assert result is None


class HandleRerunTests(TestCase):
    """Tests for _handle_rerun."""

    def setUp(self):
        self.cmd = Command(stdout=StringIO(), stderr=StringIO())

    @patch("builtins.input", return_value="1")
    def test_reconfigure_deactivates_existing(self, _mock_input):
        defn = PipelineDefinition.objects.create(
            name="full",
            config={"version": "1.0", "nodes": []},
            tags=["setup_wizard"],
            created_by="setup_instance",
        )
        NotificationChannel.objects.create(
            name="ops-slack",
            driver="slack",
            config={},
            description="[setup_wizard] slack channel",
        )
        action = self.cmd._handle_rerun(defn)
        assert action == "reconfigure"
        defn.refresh_from_db()
        assert defn.is_active is False
        ch = NotificationChannel.objects.get(name="ops-slack")
        assert ch.is_active is False

    @patch("builtins.input", return_value="2")
    def test_add_another_keeps_existing(self, _mock_input):
        defn = PipelineDefinition.objects.create(
            name="full",
            config={"version": "1.0", "nodes": []},
            tags=["setup_wizard"],
            created_by="setup_instance",
        )
        action = self.cmd._handle_rerun(defn)
        assert action == "add"
        defn.refresh_from_db()
        assert defn.is_active is True

    @patch("builtins.input", return_value="3")
    def test_cancel_returns_cancel(self, _mock_input):
        defn = PipelineDefinition.objects.create(
            name="full",
            config={"version": "1.0", "nodes": []},
            tags=["setup_wizard"],
            created_by="setup_instance",
        )
        action = self.cmd._handle_rerun(defn)
        assert action == "cancel"

Step 2: Run tests to verify they fail

Run: uv run pytest apps/orchestration/_tests/test_setup_instance.py -v -k "DetectExisting or HandleRerun" Expected: FAIL — AttributeError

Step 3: Implement re-run detection

Add to setup_instance.py Command class:

    def _detect_existing(self):
        """
        Detect existing wizard-created pipeline definition.

        Returns:
            PipelineDefinition instance if found, None otherwise.
        """
        from apps.orchestration.models import PipelineDefinition

        return (
            PipelineDefinition.objects.filter(tags__contains="setup_wizard", is_active=True)
            .order_by("-updated_at")
            .first()
        )

    def _handle_rerun(self, existing):
        """
        Handle re-run when existing wizard config is detected.

        Args:
            existing: Existing PipelineDefinition instance.

        Returns:
            Action string: 'reconfigure', 'add', or 'cancel'.
        """
        from apps.notify.models import NotificationChannel

        action = self._prompt_choice(
            f'? Existing pipeline "{existing.name}" found. What would you like to do?',
            [
                ("reconfigure", "Reconfigure — Replace existing pipeline and channels"),
                ("add", "Add another — Create additional pipeline alongside existing"),
                ("cancel", "Cancel"),
            ],
        )

        if action == "reconfigure":
            existing.is_active = False
            existing.save(update_fields=["is_active"])
            NotificationChannel.objects.filter(
                description__startswith="[setup_wizard]", is_active=True
            ).update(is_active=False)

        return action

Step 4: Run tests to verify they pass

Run: uv run pytest apps/orchestration/_tests/test_setup_instance.py -v -k "DetectExisting or HandleRerun" Expected: All 6 tests PASS

Step 5: Commit

git add apps/orchestration/management/commands/setup_instance.py apps/orchestration/_tests/test_setup_instance.py
git commit -m "feat(setup_instance): add re-run detection and handling"

Task 8: Wire Up handle() — Full Flow

Files:

  • Modify: apps/orchestration/management/commands/setup_instance.py
  • Modify: apps/orchestration/_tests/test_setup_instance.py

Step 1: Write failing integration test

Add to test_setup_instance.py:

from django.core.management import call_command


class SetupInstanceIntegrationTests(TestCase):
    """Integration tests for the full setup_instance flow."""

    @patch(
        "builtins.input",
        side_effect=[
            "4",  # preset: full
            "1,2",  # alerts: alertmanager, grafana
            "1,2",  # checkers: cpu, memory
            "1",  # intelligence: local
            "2",  # notify: slack (2nd in registry: pagerduty=1? No — slack=1)
            "https://hooks.slack.com/xxx",  # slack webhook
            "ops-alerts",  # channel name
            "Y",  # confirm
        ],
    )
    def test_full_pipeline_flow(self, _mock_input):
        out = StringIO()
        call_command("setup_instance", stdout=out)

        # Verify PipelineDefinition created
        defn = PipelineDefinition.objects.get(tags__contains="setup_wizard")
        assert defn.is_active is True
        nodes = defn.get_nodes()
        node_types = [n["type"] for n in nodes]
        assert node_types == ["ingest", "context", "intelligence", "notify"]

        # Verify NotificationChannel created
        ch = NotificationChannel.objects.get(name="ops-alerts")
        assert ch.driver == "slack"
        assert ch.is_active is True

    @patch(
        "builtins.input",
        side_effect=[
            "1",  # preset: direct
            "1",  # alerts: alertmanager (or first)
            "1",  # notify: first driver
            "https://hooks.slack.com/xxx",  # config
            "ops-slack",  # channel name
            "Y",  # confirm
        ],
    )
    def test_direct_preset_skips_checkers_and_intelligence(self, _mock_input):
        out = StringIO()
        call_command("setup_instance", stdout=out)

        defn = PipelineDefinition.objects.get(tags__contains="setup_wizard")
        node_types = [n["type"] for n in defn.get_nodes()]
        assert "context" not in node_types
        assert "intelligence" not in node_types

    @patch("builtins.input", side_effect=["n"])
    def test_cancel_on_confirmation_creates_nothing(self, _mock_input):
        """When user cancels at confirmation, no DB records should be created."""
        out = StringIO()
        # This will need enough inputs to get to confirmation
        with patch(
            "builtins.input",
            side_effect=[
                "1",  # preset: direct
                "1",  # alerts
                "1",  # notify
                "https://example.com",  # config
                "test-ch",  # name
                "n",  # cancel
            ],
        ):
            call_command("setup_instance", stdout=out)
        assert PipelineDefinition.objects.count() == 0
        assert NotificationChannel.objects.count() == 0

Step 2: Run tests to verify they fail

Run: uv run pytest apps/orchestration/_tests/test_setup_instance.py::SetupInstanceIntegrationTests -v Expected: FAIL — handle() does nothing yet

Step 3: Implement the handle() method

Replace the handle method in setup_instance.py:

    def handle(self, *args, **options):
        from django.conf import settings

        self.stdout.write(self.style.HTTP_INFO(
            "\n╔══════════════════════════════════════════════════╗"
            "\n║     Server Maintenance — Instance Setup          ║"
            "\n╚══════════════════════════════════════════════════╝"
        ))

        # Check for existing wizard configuration
        existing = self._detect_existing()
        rerun_action = None
        if existing:
            rerun_action = self._handle_rerun(existing)
            if rerun_action == "cancel":
                self.stdout.write("Setup cancelled.")
                return

        # Step 1: Select pipeline preset
        preset = self._select_preset()

        # Step 2: Configure alerts (always present)
        alerts = self._configure_alerts()

        # Step 3: Configure checkers (if preset includes them)
        checkers = None
        if preset["has_checkers"]:
            checkers = self._configure_checkers()

        # Step 4: Configure intelligence (if preset includes it)
        intelligence = None
        if preset["has_intelligence"]:
            intelligence = self._configure_intelligence()

        # Step 5: Configure notifications (always present)
        notify = self._configure_notify()

        # Build full config
        config = {"preset": preset, "alerts": alerts, "notify": notify}
        if checkers:
            config["checkers"] = checkers
        if intelligence:
            config["intelligence"] = intelligence

        # Step 6: Show summary and confirm
        self._show_summary(config)
        if not self._confirm_apply():
            self.stdout.write("Setup cancelled.")
            return

        # Step 7: Apply configuration
        env_path = os.path.join(str(settings.BASE_DIR), ".env")
        env_updates = {}

        env_updates["ALERTS_ENABLED_DRIVERS"] = ",".join(alerts)

        if checkers:
            from apps.checkers.checkers import CHECKER_REGISTRY

            all_checkers = set(CHECKER_REGISTRY.keys())
            enabled = set(checkers["enabled"])
            skipped = all_checkers - enabled
            if skipped:
                env_updates["CHECKERS_SKIP"] = ",".join(sorted(skipped))

        if intelligence:
            env_updates["INTELLIGENCE_PROVIDER"] = intelligence["provider"]
            if intelligence.get("api_key"):
                env_updates["OPENAI_API_KEY"] = intelligence["api_key"]
            if intelligence.get("model"):
                env_updates["OPENAI_MODEL"] = intelligence["model"]

        self._write_env(env_path, env_updates)
        self.stdout.write(
            self.style.SUCCESS(f"✓ Updated .env with {len(env_updates)} setting(s)")
        )

        # Handle name collision for "add another" mode
        pipeline_name = preset["name"]
        if rerun_action == "add":
            from apps.orchestration.models import PipelineDefinition

            count = PipelineDefinition.objects.filter(
                name__startswith=pipeline_name
            ).count()
            if count > 0:
                pipeline_name = f"{pipeline_name}-{count + 1}"
            config["preset"] = {**preset, "name": pipeline_name}

        defn = self._create_pipeline_definition(config)
        self.stdout.write(
            self.style.SUCCESS(f'✓ Created PipelineDefinition "{defn.name}"')
        )

        channels = self._create_notification_channels(notify)
        for ch in channels:
            self.stdout.write(
                self.style.SUCCESS(f'✓ Created NotificationChannel "{ch.name}" ({ch.driver})')
            )

        self.stdout.write(self.style.SUCCESS("\n✓ Configuration complete!"))
        self.stdout.write(
            "\nNext steps:\n"
            "  uv run python manage.py run_pipeline --sample --dry-run\n"
        )

Step 4: Run tests to verify they pass

Run: uv run pytest apps/orchestration/_tests/test_setup_instance.py -v Expected: ALL tests PASS

Step 5: Run all project tests for regression

Run: uv run pytest --tb=short -q Expected: All tests pass, no regressions

Step 6: Commit

git add apps/orchestration/management/commands/setup_instance.py apps/orchestration/_tests/test_setup_instance.py
git commit -m "feat(setup_instance): wire up full wizard flow in handle()"

Task 9: Final Polish and Full Test Run

Files:

  • Modify: apps/orchestration/management/commands/setup_instance.py (if needed)
  • Modify: apps/orchestration/_tests/test_setup_instance.py (if needed)

Step 1: Run full test suite

Run: uv run pytest -v Expected: All tests pass

Step 2: Run code quality checks

Run: uv run black . && uv run ruff check . --fix && uv run mypy apps/orchestration/management/commands/setup_instance.py Expected: All pass clean

Step 3: Manual smoke test (optional)

Run: uv run python manage.py setup_instance Walk through the wizard manually to verify UX.

Step 4: Final commit if any fixes needed

git add -u
git commit -m "chore(setup_instance): polish and lint fixes"

Summary

Task What Tests
1 Input helpers + preset selection 13
2 Alert + checker config 6
3 Intelligence + notify config 7
4 Summary + confirmation 5
5 .env writer 4
6 PipelineDefinition + NotificationChannel creation 6
7 Re-run detection 6
8 Full handle() integration 3
9 Polish + full test run
Total   ~50

This site uses Just the Docs, a documentation theme for Jekyll.