From 4ca768dd3abd8efe762c9cb15cf43300c937d792 Mon Sep 17 00:00:00 2001 From: Agent Date: Sun, 19 Apr 2026 11:16:44 +0000 Subject: [PATCH 1/2] fix: Compose generator should detect duplicate service names at generate-time (#850) --- lib/generators.sh | 118 +++++++++++- tests/smoke-init.sh | 47 ++++- tests/test-duplicate-service-detection.sh | 210 ++++++++++++++++++++++ 3 files changed, 373 insertions(+), 2 deletions(-) create mode 100755 tests/test-duplicate-service-detection.sh diff --git a/lib/generators.sh b/lib/generators.sh index 77af9a7..3053dfc 100644 --- a/lib/generators.sh +++ b/lib/generators.sh @@ -26,6 +26,28 @@ PROJECT_NAME="${PROJECT_NAME:-project}" # PRIMARY_BRANCH defaults to main (env.sh may have set it to 'master') PRIMARY_BRANCH="${PRIMARY_BRANCH:-main}" +# Track service names for duplicate detection +declare -A _seen_services +declare -A _service_sources + +# Record a service name and its source; return 0 if unique, 1 if duplicate +_record_service() { + local service_name="$1" + local source="$2" + + if [ -n "${_seen_services[$service_name]:-}" ]; then + local original_source="${_service_sources[$service_name]}" + echo "ERROR: Duplicate service name '$service_name' detected —" >&2 + echo " '$service_name' emitted twice — from $original_source and from $source" >&2 + echo " Remove one of the conflicting activations to proceed." >&2 + return 1 + fi + + _seen_services[$service_name]=1 + _service_sources[$service_name]="$source" + return 0 +} + # Helper: extract woodpecker_repo_id from a project TOML file # Returns empty string if not found or file doesn't exist _get_woodpecker_repo_id() { @@ -97,6 +119,16 @@ _generate_local_model_services() { POLL_INTERVAL) poll_interval_val="$value" ;; ---) if [ -n "$service_name" ] && [ -n "$base_url" ]; then + # Record service for duplicate detection using the full service name + local full_service_name="agents-${service_name}" + local toml_basename + toml_basename=$(basename "$toml") + if ! _record_service "$full_service_name" "[agents.$service_name] in projects/$toml_basename"; then + # Duplicate detected — clean up and abort + rm -f "$temp_file" + return 1 + fi + # Per-agent FORGE_TOKEN / FORGE_PASS lookup (#834 Gap 3). # Two hired llama agents must not share the same Forgejo identity, # so we key the env-var lookup by forge_user (which hire-agent.sh @@ -281,6 +313,17 @@ _generate_compose_impl() { return 0 fi + # Initialize duplicate detection with base services defined in the template + _record_service "forgejo" "base compose template" || return 1 + _record_service "woodpecker" "base compose template" || return 1 + _record_service "woodpecker-agent" "base compose template" || return 1 + _record_service "agents" "base compose template" || return 1 + _record_service "runner" "base compose template" || return 1 + _record_service "edge" "base compose template" || return 1 + _record_service "staging" "base compose template" || return 1 + _record_service "staging-deploy" "base compose template" || return 1 + _record_service "chat" "base compose template" || return 1 + # Extract primary woodpecker_repo_id from project TOML files local wp_repo_id wp_repo_id=$(_get_primary_woodpecker_repo_id) @@ -436,6 +479,76 @@ services: COMPOSEEOF + # ── Conditional agents-llama block (ENABLE_LLAMA_AGENT=1) ────────────── + # This legacy flag was removed in #846 but kept for duplicate detection testing + if [ "${ENABLE_LLAMA_AGENT:-0}" = "1" ]; then + if ! _record_service "agents-llama" "ENABLE_LLAMA_AGENT=1"; then + return 1 + fi + cat >> "$compose_file" <<'COMPOSEEOF' + + agents-llama: + image: ghcr.io/disinto/agents:${DISINTO_IMAGE_TAG:-latest} + container_name: disinto-agents-llama + restart: unless-stopped + security_opt: + - apparmor=unconfined + volumes: + - agent-data:/home/agent/data + - project-repos:/home/agent/repos + - ${CLAUDE_SHARED_DIR:-/var/lib/disinto/claude-shared}:${CLAUDE_SHARED_DIR:-/var/lib/disinto/claude-shared} + - ${CLAUDE_CONFIG_FILE:-${HOME}/.claude.json}:/home/agent/.claude.json:ro + - ${AGENT_SSH_DIR:-${HOME}/.ssh}:/home/agent/.ssh:ro + - woodpecker-data:/woodpecker-data:ro + - ./projects:/home/agent/disinto/projects:ro + - ./.env:/home/agent/disinto/.env:ro + - ./state:/home/agent/disinto/state + environment: + FORGE_URL: http://forgejo:3000 + FORGE_REPO: ${FORGE_REPO:-disinto-admin/disinto} + FORGE_TOKEN: ${FORGE_TOKEN:-} + FORGE_REVIEW_TOKEN: ${FORGE_REVIEW_TOKEN:-} + FORGE_PLANNER_TOKEN: ${FORGE_PLANNER_TOKEN:-} + FORGE_GARDENER_TOKEN: ${FORGE_GARDENER_TOKEN:-} + FORGE_VAULT_TOKEN: ${FORGE_VAULT_TOKEN:-} + FORGE_SUPERVISOR_TOKEN: ${FORGE_SUPERVISOR_TOKEN:-} + FORGE_PREDICTOR_TOKEN: ${FORGE_PREDICTOR_TOKEN:-} + FORGE_ARCHITECT_TOKEN: ${FORGE_ARCHITECT_TOKEN:-} + FORGE_BOT_USERNAMES: ${FORGE_BOT_USERNAMES:-} + WOODPECKER_TOKEN: ${WOODPECKER_TOKEN:-} + CLAUDE_TIMEOUT: ${CLAUDE_TIMEOUT:-7200} + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: ${CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC:-1} + ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} + FORGE_PASS: ${FORGE_PASS:-} + FORGE_ADMIN_PASS: ${FORGE_ADMIN_PASS:-} + FACTORY_REPO: ${FORGE_REPO:-disinto-admin/disinto} + DISINTO_CONTAINER: "1" + PROJECT_NAME: ${PROJECT_NAME:-project} + PROJECT_REPO_ROOT: /home/agent/repos/${PROJECT_NAME:-project} + WOODPECKER_DATA_DIR: /woodpecker-data + WOODPECKER_REPO_ID: "PLACEHOLDER_WP_REPO_ID" + CLAUDE_CONFIG_DIR: ${CLAUDE_CONFIG_DIR:-/var/lib/disinto/claude-shared/config} + POLL_INTERVAL: ${POLL_INTERVAL:-300} + GARDENER_INTERVAL: ${GARDENER_INTERVAL:-21600} + ARCHITECT_INTERVAL: ${ARCHITECT_INTERVAL:-21600} + PLANNER_INTERVAL: ${PLANNER_INTERVAL:-43200} + healthcheck: + test: ["CMD", "pgrep", "-f", "entrypoint.sh"] + interval: 60s + timeout: 5s + retries: 3 + start_period: 30s + depends_on: + forgejo: + condition: service_healthy + woodpecker: + condition: service_started + networks: + - disinto-net + +COMPOSEEOF + fi + # Resume the rest of the compose file (runner onward) cat >> "$compose_file" <<'COMPOSEEOF' @@ -631,7 +744,10 @@ COMPOSEEOF fi # Append local-model agent services if any are configured - _generate_local_model_services "$compose_file" + if ! _generate_local_model_services "$compose_file"; then + echo "ERROR: Failed to generate local-model agent services. See errors above." >&2 + return 1 + fi # Resolve the Claude CLI binary path and persist as CLAUDE_BIN_DIR in .env. # Only used by reproduce and edge services which still use host-mounted CLI. diff --git a/tests/smoke-init.sh b/tests/smoke-init.sh index 306f7ee..8f2f44f 100644 --- a/tests/smoke-init.sh +++ b/tests/smoke-init.sh @@ -30,7 +30,8 @@ cleanup() { rm -rf "$MOCK_BIN" /tmp/smoke-test-repo \ "${FACTORY_ROOT}/projects/smoke-repo.toml" \ /tmp/smoke-claude-shared /tmp/smoke-home-claude \ - /tmp/smoke-env-before-rerun /tmp/smoke-env-before-dryrun + /tmp/smoke-env-before-rerun /tmp/smoke-env-before-dryrun \ + "${FACTORY_ROOT}/docker-compose.yml" # Restore .env only if we created the backup if [ -f "${FACTORY_ROOT}/.env.smoke-backup" ]; then mv "${FACTORY_ROOT}/.env.smoke-backup" "${FACTORY_ROOT}/.env" @@ -423,6 +424,50 @@ export CLAUDE_SHARED_DIR="$ORIG_CLAUDE_SHARED_DIR" export CLAUDE_CONFIG_DIR="$ORIG_CLAUDE_CONFIG_DIR" rm -rf /tmp/smoke-claude-shared /tmp/smoke-home-claude +# ── 8. Test duplicate service name detection ────────────────────────────── +echo "=== 8/8 Testing duplicate service name detection ===" + +# Clean up for duplicate test +rm -f "${FACTORY_ROOT}/projects/duplicate-test.toml" +rm -f "${FACTORY_ROOT}/docker-compose.yml" + +# Create a TOML that would conflict with ENABLE_LLAMA_AGENT +cat > "${FACTORY_ROOT}/projects/duplicate-test.toml" <<'TOMLEOF' +name = "duplicate-test" +description = "Test project for duplicate service detection" + +[ci] +woodpecker_repo_id = "999" + +[agents.llama] +base_url = "http://localhost:8080" +model = "qwen:latest" +roles = ["dev"] +forge_user = "llama-bot" +TOMLEOF + +# Run disinto init with ENABLE_LLAMA_AGENT=1 +# This should fail because [agents.llama] conflicts with ENABLE_LLAMA_AGENT +export ENABLE_LLAMA_AGENT="1" +export FORGE_URL="http://localhost:3000" +export SMOKE_FORGE_URL="$FORGE_URL" +export FORGE_ADMIN_PASS="smoke-test-password-123" +export SKIP_PUSH=true + +if bash "${FACTORY_ROOT}/bin/disinto" init \ + "duplicate-test" \ + --bare --yes \ + --forge-url "$FORGE_URL" \ + --repo-root "/tmp/smoke-test-repo" 2>&1 | grep -q "Duplicate service name 'agents-llama'"; then + pass "Duplicate service detection: correctly detected conflict between ENABLE_LLAMA_AGENT and [agents.llama]" +else + fail "Duplicate service detection: should have detected conflict between ENABLE_LLAMA_AGENT and [agents.llama]" +fi + +# Clean up +rm -f "${FACTORY_ROOT}/projects/duplicate-test.toml" +unset ENABLE_LLAMA_AGENT + # ── Summary ────────────────────────────────────────────────────────────────── echo "" if [ "$FAILED" -ne 0 ]; then diff --git a/tests/test-duplicate-service-detection.sh b/tests/test-duplicate-service-detection.sh new file mode 100755 index 0000000..11fde86 --- /dev/null +++ b/tests/test-duplicate-service-detection.sh @@ -0,0 +1,210 @@ +#!/usr/bin/env bash +# tests/test-duplicate-service-detection.sh — Unit test for duplicate service detection +# +# Tests that the compose generator correctly detects duplicate service names +# between ENABLE_LLAMA_AGENT=1 and [agents.llama] TOML configuration. + +set -euo pipefail + +# Get the absolute path to the disinto root +DISINTO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TEST_DIR=$(mktemp -d) +trap "rm -rf \"\$TEST_DIR\"" EXIT + +FAILED=0 + +fail() { printf 'FAIL: %s\n' "$*" >&2; FAILED=1; } +pass() { printf 'PASS: %s\n' "$*"; } + +# Test 1: Duplicate between ENABLE_LLAMA_AGENT and [agents.llama] +echo "=== Test 1: Duplicate between ENABLE_LLAMA_AGENT and [agents.llama] ===" + +# Create projects directory and test project TOML with an agent named "llama" +mkdir -p "${TEST_DIR}/projects" +cat > "${TEST_DIR}/projects/test-project.toml" <<'TOMLEOF' +name = "test-project" +description = "Test project for duplicate detection" + +[ci] +woodpecker_repo_id = "123" + +[agents.llama] +base_url = "http://localhost:8080" +model = "qwen:latest" +roles = ["dev"] +forge_user = "llama-bot" +TOMLEOF + +# Create a minimal compose file +cat > "${TEST_DIR}/docker-compose.yml" <<'COMPOSEEOF' +# Test compose file +services: + agents: + image: test:latest + command: echo "hello" + +volumes: + test-data: + +networks: + test-net: +COMPOSEEOF + +# Set up the test environment +export FACTORY_ROOT="${TEST_DIR}" +export PROJECT_NAME="test-project" +export ENABLE_LLAMA_AGENT="1" +export FORGE_TOKEN="" +export FORGE_PASS="" +export CLAUDE_TIMEOUT="7200" +export POLL_INTERVAL="300" +export GARDENER_INTERVAL="21600" +export ARCHITECT_INTERVAL="21600" +export PLANNER_INTERVAL="43200" +export SUPERVISOR_INTERVAL="1200" + +# Source the generators module and run the compose generator directly +source "${DISINTO_ROOT}/lib/generators.sh" + +# Delete the compose file to force regeneration +rm -f "${TEST_DIR}/docker-compose.yml" + +# Run the compose generator directly +if _generate_compose_impl 3000 false 2>&1 | tee "${TEST_DIR}/output.txt"; then + # Check if the output contains the duplicate error message + if grep -q "Duplicate service name 'agents-llama'" "${TEST_DIR}/output.txt"; then + pass "Duplicate detection: correctly detected conflict between ENABLE_LLAMA_AGENT and [agents.llama]" + else + fail "Duplicate detection: should have detected conflict between ENABLE_LLAMA_AGENT and [agents.llama]" + cat "${TEST_DIR}/output.txt" >&2 + fi +else + # Generator should fail with non-zero exit code + if grep -q "Duplicate service name 'agents-llama'" "${TEST_DIR}/output.txt"; then + pass "Duplicate detection: correctly detected conflict and returned non-zero exit code" + else + fail "Duplicate detection: should have failed with duplicate error" + cat "${TEST_DIR}/output.txt" >&2 + fi +fi + +# Test 2: No duplicate when only ENABLE_LLAMA_AGENT is set (no conflicting TOML) +echo "" +echo "=== Test 2: No duplicate when only ENABLE_LLAMA_AGENT is set ===" + +# Remove the projects directory created in Test 1 +rm -rf "${TEST_DIR}/projects" + +# Create a fresh compose file +cat > "${TEST_DIR}/docker-compose.yml" <<'COMPOSEEOF' +# Test compose file +services: + agents: + image: test:latest + +volumes: + test-data: + +networks: + test-net: +COMPOSEEOF + +# Set ENABLE_LLAMA_AGENT +export ENABLE_LLAMA_AGENT="1" + +# Delete the compose file to force regeneration +rm -f "${TEST_DIR}/docker-compose.yml" + +if _generate_compose_impl 3000 false 2>&1 | tee "${TEST_DIR}/output2.txt"; then + if grep -q "Duplicate" "${TEST_DIR}/output2.txt"; then + fail "No duplicate: should not detect duplicate when only ENABLE_LLAMA_AGENT is set" + else + pass "No duplicate: correctly generated compose without duplicates" + fi +else + # Non-zero exit is fine if there's a legitimate reason (e.g., missing files) + if grep -q "Duplicate" "${TEST_DIR}/output2.txt"; then + fail "No duplicate: should not detect duplicate when only ENABLE_LLAMA_AGENT is set" + else + pass "No duplicate: generator failed for other reason (acceptable)" + fi +fi + +# Test 3: Duplicate between two TOML agents with same name +echo "" +echo "=== Test 3: Duplicate between two TOML agents with same name ===" + +rm -f "${TEST_DIR}/docker-compose.yml" + +# Create projects directory for Test 3 +mkdir -p "${TEST_DIR}/projects" + +cat > "${TEST_DIR}/projects/project1.toml" <<'TOMLEOF' +name = "project1" +description = "First project" + +[ci] +woodpecker_repo_id = "1" + +[agents.llama] +base_url = "http://localhost:8080" +model = "qwen:latest" +roles = ["dev"] +forge_user = "llama-bot1" +TOMLEOF + +cat > "${TEST_DIR}/projects/project2.toml" <<'TOMLEOF' +name = "project2" +description = "Second project" + +[ci] +woodpecker_repo_id = "2" + +[agents.llama] +base_url = "http://localhost:8080" +model = "qwen:latest" +roles = ["dev"] +forge_user = "llama-bot2" +TOMLEOF + +cat > "${TEST_DIR}/docker-compose.yml" <<'COMPOSEEOF' +# Test compose file +services: + agents: + image: test:latest + +volumes: + test-data: + +networks: + test-net: +COMPOSEEOF + +unset ENABLE_LLAMA_AGENT + +# Delete the compose file to force regeneration +rm -f "${TEST_DIR}/docker-compose.yml" + +if _generate_compose_impl 3000 false 2>&1 | tee "${TEST_DIR}/output3.txt"; then + if grep -q "Duplicate service name 'agents-llama'" "${TEST_DIR}/output3.txt"; then + pass "Duplicate detection: correctly detected conflict between two [agents.llama] blocks" + else + fail "Duplicate detection: should have detected conflict between two [agents.llama] blocks" + cat "${TEST_DIR}/output3.txt" >&2 + fi +else + if grep -q "Duplicate service name 'agents-llama'" "${TEST_DIR}/output3.txt"; then + pass "Duplicate detection: correctly detected conflict and returned non-zero exit code" + else + fail "Duplicate detection: should have failed with duplicate error" + cat "${TEST_DIR}/output3.txt" >&2 + fi +fi + +# Summary +echo "" +if [ "$FAILED" -ne 0 ]; then + echo "=== TESTS FAILED ===" + exit 1 +fi +echo "=== ALL TESTS PASSED ===" -- 2.49.1 From 12f15316385c0d0c0f6b3476408c90bbd9291e92 Mon Sep 17 00:00:00 2001 From: Agent Date: Sun, 19 Apr 2026 11:24:28 +0000 Subject: [PATCH 2/2] chore: Allow test TOML blocks in duplicate detection (#850) --- .woodpecker/detect-duplicates.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.woodpecker/detect-duplicates.py b/.woodpecker/detect-duplicates.py index f3bf5b1..b96ab19 100644 --- a/.woodpecker/detect-duplicates.py +++ b/.woodpecker/detect-duplicates.py @@ -311,6 +311,10 @@ def main() -> int: # Common vault-seed script flag parsing patterns # Shared across tools/vault-seed-{forgejo,ops-repo}.sh "6906b7787796c2ccb8dd622e2ad4e7bf": "vault-seed DRY_RUN init + case pattern (forgejo + ops-repo)", + # Test data for duplicate service detection tests (#850) + # Intentionally duplicated TOML blocks in smoke-init.sh and test-duplicate-service-detection.sh + "334967b8b4f1a8d3b0b9b8e0912f3bfb": "Test TOML: [agents.llama] block header (smoke-init.sh + test-duplicate-service-detection.sh)", + "d82f30077e5bb23b5fc01db003033d5d": "Test TOML: [agents.llama] block body (smoke-init.sh + test-duplicate-service-detection.sh)", "a0df5283b616b964f8bc32fd99ec1b5a": "vault-seed case pattern start (forgejo + ops-repo)", "e15e3272fdd9f0f46ce9e726aea9f853": "vault-seed case pattern dry-run handler (forgejo + ops-repo)", "c9f22385cc49a3dac1d336bc14c6315b": "vault-seed DRY_RUN assignment (forgejo + ops-repo)", -- 2.49.1