feat: add automatic cleanup for cookbook test projects

- Add AUTO_TEARDOWN env var and --auto-teardown flag to cookbook scripts
- Scripts automatically delete created projects on exit (including Ctrl+C)
- Add DELETE /projects/cleanup API endpoint for bulk cleanup
- Supports shell-style glob patterns (e.g., "tree-test-*")
- Includes dry_run mode and older_than_hours filter for safety
- Requires admin scope for actual deletion
- Update cookbook scripts: landing-test, composable-test, template-validation,
  feature-test, tree-runner

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
jordan 2026-02-02 17:54:15 -07:00
parent 6c51469c89
commit 572b221e20
9 changed files with 396 additions and 7 deletions

View File

@ -177,6 +177,83 @@ func registerProjectPaths(spec *api.OpenAPISpec) {
"projects:read", "projects:read",
[]param{{Name: "id", In: "path", Description: "Project ID (e.g., 'pantheon')", Required: true}}, []param{{Name: "id", In: "path", Description: "Project ID (e.g., 'pantheon')", Required: true}},
)) ))
spec.AddPath("/projects/cleanup", "delete", map[string]any{
"summary": "Cleanup test projects",
"description": `Deletes test projects matching the given name patterns that are older than a specified age.
This is useful for cleaning up orphaned test projects created by cookbook scripts.
**Required scope**: ` + "`admin`" + `
## Pattern Syntax
Patterns support shell-style globs with ` + "`*`" + ` wildcards:
- ` + "`tree-test-*`" + ` matches ` + "`tree-test-123456`" + `
- ` + "`landing-test-*`" + ` matches ` + "`landing-test-1706889600`" + `
- ` + "`*-validation-*`" + ` matches ` + "`template-validation-abc`" + `
## Safety
- Use ` + "`dry_run: true`" + ` first to preview what would be deleted
- Only projects older than ` + "`older_than_hours`" + ` are affected
- Git repos are preserved; only K8s resources and DB records are removed`,
"tags": []string{"Projects"},
"security": []map[string]any{
{"ApiKeyAuth": []string{}},
},
"requestBody": map[string]any{
"required": true,
"content": map[string]any{
"application/json": map[string]any{
"schema": map[string]any{
"type": "object",
"required": []string{"patterns"},
"properties": map[string]any{
"patterns": map[string]any{
"type": "array",
"items": map[string]any{"type": "string"},
"description": "Name patterns to match (e.g., \"tree-test-*\", \"landing-test-*\")",
"example": []string{"tree-test-*", "landing-test-*", "template-validation-*"},
},
"older_than_hours": map[string]any{
"type": "integer",
"description": "Only delete projects older than this many hours (default: 0)",
"example": 1,
},
"dry_run": map[string]any{
"type": "boolean",
"description": "If true, don't actually delete, just return what would be deleted",
"example": true,
},
},
},
"example": `{
"patterns": ["tree-test-*", "landing-test-*", "template-validation-*"],
"older_than_hours": 1,
"dry_run": true
}`,
},
},
},
"responses": map[string]any{
"200": map[string]any{
"description": "Success",
"content": map[string]any{
"application/json": map[string]any{
"example": `{
"deleted": ["tree-test-1706889600", "landing-test-1706889601"],
"count": 2,
"dry_run": true
}`,
},
},
},
"400": map[string]any{"description": "Bad Request - Missing patterns or invalid input"},
"401": map[string]any{"description": "Unauthorized - Missing or invalid API key"},
"403": map[string]any{"description": "Forbidden - Requires admin scope"},
},
})
} }
func registerCommandPaths(spec *api.OpenAPISpec) { func registerCommandPaths(spec *api.OpenAPISpec) {

View File

@ -17,6 +17,47 @@ set -euo pipefail
: "${RDEV_API_URL:?RDEV_API_URL must be set}" : "${RDEV_API_URL:?RDEV_API_URL must be set}"
: "${RDEV_API_KEY:?RDEV_API_KEY must be set}" : "${RDEV_API_KEY:?RDEV_API_KEY must be set}"
# Auto-cleanup configuration
# Set AUTO_TEARDOWN=true to automatically clean up projects on exit
AUTO_TEARDOWN="${AUTO_TEARDOWN:-false}"
# Track created project for cleanup
# Scripts should set this after successful project creation
CLEANUP_PROJECT=""
# Cleanup handler for auto-teardown
# Called on script exit when AUTO_TEARDOWN=true
cleanup_on_exit() {
local exit_code=$?
if [[ -n "$CLEANUP_PROJECT" && "$AUTO_TEARDOWN" == "true" ]]; then
echo ""
echo -e "${CYAN}Auto-teardown: Cleaning up $CLEANUP_PROJECT...${NC}"
api_call DELETE "/project/$CLEANUP_PROJECT" > /dev/null 2>&1 || true
echo -e "${GREEN}✓ Project $CLEANUP_PROJECT deleted${NC}"
fi
exit $exit_code
}
# Register cleanup handler
# Scripts should call this after sourcing common.sh if they want auto-cleanup
register_cleanup_trap() {
trap cleanup_on_exit EXIT INT TERM
}
# Parse --auto-teardown from args and return remaining args
# Usage: args=$(parse_auto_teardown_flag "$@")
parse_auto_teardown_flag() {
local args=()
for arg in "$@"; do
if [[ "$arg" == "--auto-teardown" ]]; then
AUTO_TEARDOWN="true"
else
args+=("$arg")
fi
done
echo "${args[@]}"
}
# Colors for output # Colors for output
RED='\033[0;31m' RED='\033[0;31m'
GREEN='\033[0;32m' GREEN='\033[0;32m'

View File

@ -15,8 +15,21 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/common.sh" source "$SCRIPT_DIR/common.sh"
COMMAND="${1:-}" # Parse --auto-teardown flag from args
PROJECT_NAME="${2:-}" ARGS=("$@")
for i in "${!ARGS[@]}"; do
if [[ "${ARGS[$i]}" == "--auto-teardown" ]]; then
AUTO_TEARDOWN="true"
unset 'ARGS[$i]'
fi
done
ARGS=("${ARGS[@]}") # Re-index array
COMMAND="${ARGS[0]:-}"
PROJECT_NAME="${ARGS[1]:-}"
# Register cleanup trap for auto-teardown
register_cleanup_trap
if [[ -z "$COMMAND" || -z "$PROJECT_NAME" ]]; then if [[ -z "$COMMAND" || -z "$PROJECT_NAME" ]]; then
echo "Usage: $0 <command> <project-name>" echo "Usage: $0 <command> <project-name>"
@ -85,6 +98,9 @@ run_flow() {
exit 1 exit 1
fi fi
# Track project for auto-cleanup
CLEANUP_PROJECT="$PROJECT_NAME"
print_success "Project created with domain: $domain" print_success "Project created with domain: $domain"
# Step 2: Add backend service # Step 2: Add backend service

View File

@ -17,8 +17,21 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/common.sh" source "$SCRIPT_DIR/common.sh"
COMMAND="${1:-}" # Parse --auto-teardown flag from args
PROJECT_NAME="${2:-}" ARGS=("$@")
for i in "${!ARGS[@]}"; do
if [[ "${ARGS[$i]}" == "--auto-teardown" ]]; then
AUTO_TEARDOWN="true"
unset 'ARGS[$i]'
fi
done
ARGS=("${ARGS[@]}") # Re-index array
COMMAND="${ARGS[0]:-}"
PROJECT_NAME="${ARGS[1]:-}"
# Register cleanup trap for auto-teardown
register_cleanup_trap
if [[ -z "$COMMAND" || -z "$PROJECT_NAME" ]]; then if [[ -z "$COMMAND" || -z "$PROJECT_NAME" ]]; then
echo "Usage: $0 <command> <project-name>" echo "Usage: $0 <command> <project-name>"
@ -337,6 +350,9 @@ run_flow() {
exit 1 exit 1
fi fi
# Track project for auto-cleanup
CLEANUP_PROJECT="$PROJECT_NAME"
print_success "Project created with domain: $domain" print_success "Project created with domain: $domain"
# Step 2: Add backend service # Step 2: Add backend service

View File

@ -19,8 +19,21 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/common.sh" source "$SCRIPT_DIR/common.sh"
COMMAND="${1:-}" # Parse --auto-teardown flag from args
PROJECT_NAME="${2:-landing-test-$(date +%s)}" ARGS=("$@")
for i in "${!ARGS[@]}"; do
if [[ "${ARGS[$i]}" == "--auto-teardown" ]]; then
AUTO_TEARDOWN="true"
unset 'ARGS[$i]'
fi
done
ARGS=("${ARGS[@]}") # Re-index array
COMMAND="${ARGS[0]:-}"
PROJECT_NAME="${ARGS[1]:-landing-test-$(date +%s)}"
# Register cleanup trap for auto-teardown
register_cleanup_trap
if [[ -z "$COMMAND" ]]; then if [[ -z "$COMMAND" ]]; then
echo "Landing Page E2E Test Script" echo "Landing Page E2E Test Script"
@ -62,6 +75,9 @@ run_flow() {
exit 1 exit 1
fi fi
# Track project for auto-cleanup
CLEANUP_PROJECT="$PROJECT_NAME"
print_success "Project created: $PROJECT_NAME" print_success "Project created: $PROJECT_NAME"
echo " Domain: $domain" echo " Domain: $domain"
echo " Git: https://git.threesix.ai/$(get_git_owner)/$PROJECT_NAME" echo " Git: https://git.threesix.ai/$(get_git_owner)/$PROJECT_NAME"

View File

@ -15,9 +15,22 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/common.sh" source "$SCRIPT_DIR/common.sh"
COMMAND="${1:-run}" # Parse --auto-teardown flag from args
ARGS=("$@")
for i in "${!ARGS[@]}"; do
if [[ "${ARGS[$i]}" == "--auto-teardown" ]]; then
AUTO_TEARDOWN="true"
unset 'ARGS[$i]'
fi
done
ARGS=("${ARGS[@]}") # Re-index array
COMMAND="${ARGS[0]:-run}"
PROJECT_NAME="template-validation-$(date +%s)" PROJECT_NAME="template-validation-$(date +%s)"
# Register cleanup trap for auto-teardown
register_cleanup_trap
# Component types to test # Component types to test
COMPONENT_TYPES=( COMPONENT_TYPES=(
"service" "service"
@ -255,6 +268,9 @@ run_full_validation() {
exit 1 exit 1
fi fi
# Track project for auto-cleanup
CLEANUP_PROJECT="$PROJECT_NAME"
print_success "Project created with domain: $domain" print_success "Project created with domain: $domain"
# Step 2: Add all component types # Step 2: Add all component types

View File

@ -28,6 +28,17 @@ source "$SCRIPT_DIR/common.sh"
source "$SCRIPT_DIR/lib/checkpoint.sh" source "$SCRIPT_DIR/lib/checkpoint.sh"
source "$SCRIPT_DIR/lib/tree-parser.sh" source "$SCRIPT_DIR/lib/tree-parser.sh"
# Parse --auto-teardown flag from args
ARGS=("$@")
for i in "${!ARGS[@]}"; do
if [[ "${ARGS[$i]}" == "--auto-teardown" ]]; then
AUTO_TEARDOWN="true"
unset 'ARGS[$i]'
fi
done
ARGS=("${ARGS[@]}") # Re-index array
set -- "${ARGS[@]}" # Reset positional params
# Parse command # Parse command
COMMAND="${1:-}" COMMAND="${1:-}"
@ -302,6 +313,21 @@ build_outputs_from_checkpoint() {
# Commands # Commands
# ============================================================================ # ============================================================================
# Auto-teardown handler for tree runner
# Called on exit when AUTO_TEARDOWN=true
tree_auto_teardown() {
local exit_code=$?
if [[ -n "$TREE_AUTO_TEARDOWN_NAME" && "$AUTO_TEARDOWN" == "true" ]]; then
echo ""
echo -e "${CYAN}Auto-teardown: Running teardown for $TREE_AUTO_TEARDOWN_NAME...${NC}"
cmd_teardown "$TREE_AUTO_TEARDOWN_NAME" || true
fi
exit $exit_code
}
# Track tree name for auto-teardown (set during cmd_run)
TREE_AUTO_TEARDOWN_NAME=""
# Run a tree from the beginning # Run a tree from the beginning
cmd_run() { cmd_run() {
local tree_name="${1:-}" local tree_name="${1:-}"
@ -312,6 +338,10 @@ cmd_run() {
exit 1 exit 1
fi fi
# Register auto-teardown trap
TREE_AUTO_TEARDOWN_NAME="$tree_name"
trap tree_auto_teardown EXIT INT TERM
# Validate tree exists # Validate tree exists
if ! tree_parse "$tree_name" > /dev/null 2>&1; then if ! tree_parse "$tree_name" > /dev/null 2>&1; then
print_error "Tree '$tree_name' not found" print_error "Tree '$tree_name' not found"

View File

@ -56,6 +56,13 @@ func (h *ProjectManagementHandler) Mount(r api.Router) {
Get("/{name}", h.Status) Get("/{name}", h.Status)
}) })
// Bulk operations on projects (admin only)
r.Route("/projects", func(r chi.Router) {
// Cleanup test projects - admin only for safety
r.With(auth.RequireScope(auth.ScopeAdmin)).
Delete("/cleanup", h.CleanupTestProjects)
})
// Template endpoints (read-only) // Template endpoints (read-only)
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)). r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).
Get("/templates", h.ListTemplates) Get("/templates", h.ListTemplates)
@ -341,6 +348,59 @@ func (h *ProjectManagementHandler) GetTemplate(w http.ResponseWriter, r *http.Re
}) })
} }
// CleanupTestProjectsRequest is the request body for DELETE /projects/cleanup.
type CleanupTestProjectsRequest struct {
Patterns []string `json:"patterns"` // Name patterns to match (e.g., "tree-test-*", "landing-test-*")
OlderThanHrs int `json:"older_than_hours"` // Only delete projects older than this many hours
DryRun bool `json:"dry_run"` // If true, don't actually delete, just return what would be deleted
}
// CleanupTestProjects deletes test projects matching patterns that are older than a specified age.
// DELETE /projects/cleanup
// Admin scope required for safety.
func (h *ProjectManagementHandler) CleanupTestProjects(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), TimeoutLongRunning)
defer cancel()
if h.infraService == nil {
api.WriteInternalError(w, r, "project infrastructure service not configured")
return
}
var req CleanupTestProjectsRequest
if err := api.DecodeJSON(r, &req); err != nil {
api.WriteBadRequest(w, r, "invalid request body")
return
}
if len(req.Patterns) == 0 {
api.WriteBadRequest(w, r, "at least one pattern is required")
return
}
if req.OlderThanHrs < 0 {
api.WriteBadRequest(w, r, "older_than_hours must be non-negative")
return
}
result, err := h.infraService.CleanupTestProjects(ctx, service.CleanupTestProjectsRequest{
Patterns: req.Patterns,
OlderThanHrs: req.OlderThanHrs,
DryRun: req.DryRun,
})
if err != nil {
h.logger.Error("failed to cleanup test projects", "error", err)
api.WriteInternalError(w, r, "failed to cleanup test projects")
return
}
api.WriteSuccess(w, r, map[string]any{
"deleted": result.Deleted,
"count": result.Count,
"dry_run": result.DryRun,
})
}
// ListComponentTemplates returns available component templates. // ListComponentTemplates returns available component templates.
// GET /templates/components // GET /templates/components
func (h *ProjectManagementHandler) ListComponentTemplates(w http.ResponseWriter, r *http.Request) { func (h *ProjectManagementHandler) ListComponentTemplates(w http.ResponseWriter, r *http.Request) {

View File

@ -701,3 +701,120 @@ func (s *ProjectInfraService) ListComponentTemplates(ctx context.Context, compon
} }
return s.templateProvider.ListComponentTemplates(ctx, componentType) return s.templateProvider.ListComponentTemplates(ctx, componentType)
} }
// CleanupTestProjectsRequest contains parameters for cleaning up test projects.
type CleanupTestProjectsRequest struct {
Patterns []string // Name patterns to match (e.g., "tree-test-*", "landing-test-*")
OlderThanHrs int // Only delete projects older than this many hours
DryRun bool // If true, don't actually delete, just return what would be deleted
}
// CleanupTestProjectsResult contains the result of a cleanup operation.
type CleanupTestProjectsResult struct {
Deleted []string // Names of deleted projects
Count int // Number of projects deleted
DryRun bool // Whether this was a dry run
}
// CleanupTestProjects deletes test projects matching the given patterns that are older than the specified age.
// This is useful for cleaning up orphaned test projects created by cookbook scripts.
func (s *ProjectInfraService) CleanupTestProjects(ctx context.Context, req CleanupTestProjectsRequest) (*CleanupTestProjectsResult, error) {
if len(req.Patterns) == 0 {
return nil, fmt.Errorf("at least one pattern is required")
}
// Calculate cutoff time
cutoff := time.Now().Add(-time.Duration(req.OlderThanHrs) * time.Hour)
s.logger.Info("cleaning up test projects",
"patterns", req.Patterns,
"older_than_hours", req.OlderThanHrs,
"cutoff", cutoff,
"dry_run", req.DryRun,
)
// Find matching projects
projects, err := s.listProjectsByPatternOlderThan(ctx, req.Patterns, cutoff)
if err != nil {
return nil, fmt.Errorf("failed to list matching projects: %w", err)
}
result := &CleanupTestProjectsResult{
Deleted: make([]string, 0, len(projects)),
DryRun: req.DryRun,
}
for _, projectID := range projects {
if req.DryRun {
result.Deleted = append(result.Deleted, projectID)
continue
}
// Actually delete the project
if err := s.DeleteProject(ctx, projectID); err != nil {
s.logger.Warn("failed to delete project during cleanup",
"project", projectID,
"error", err,
)
// Continue with other projects even if one fails
continue
}
result.Deleted = append(result.Deleted, projectID)
s.logger.Info("deleted test project", "project", projectID)
}
result.Count = len(result.Deleted)
s.logger.Info("test project cleanup complete",
"deleted_count", result.Count,
"dry_run", req.DryRun,
)
return result, nil
}
// listProjectsByPatternOlderThan returns project IDs matching any of the given patterns
// that were created before the cutoff time.
// Patterns support SQL LIKE wildcards: % (any characters), _ (single character).
// For convenience, * is converted to % for shell-style glob patterns.
func (s *ProjectInfraService) listProjectsByPatternOlderThan(ctx context.Context, patterns []string, cutoff time.Time) ([]string, error) {
// Convert shell-style globs to SQL LIKE patterns
likePatterns := make([]string, len(patterns))
for i, p := range patterns {
// Replace * with % for SQL LIKE
likePatterns[i] = strings.ReplaceAll(p, "*", "%")
}
// Build query with OR conditions for each pattern
// Using parameterized query to prevent SQL injection
var queryBuilder strings.Builder
queryBuilder.WriteString(`SELECT id FROM projects WHERE created_at < $1 AND (`)
args := []any{cutoff}
for i, pattern := range likePatterns {
if i > 0 {
queryBuilder.WriteString(" OR ")
}
fmt.Fprintf(&queryBuilder, "name LIKE $%d", i+2)
args = append(args, pattern)
}
queryBuilder.WriteString(`) ORDER BY created_at ASC`)
rows, err := s.db.QueryContext(ctx, queryBuilder.String(), args...)
if err != nil {
return nil, fmt.Errorf("query failed: %w", err)
}
defer func() { _ = rows.Close() }()
var projectIDs []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
continue
}
projectIDs = append(projectIDs, id)
}
return projectIDs, rows.Err()
}