diff --git a/cmd/rdev-api/openapi.go b/cmd/rdev-api/openapi.go index 77a986a..8908e90 100644 --- a/cmd/rdev-api/openapi.go +++ b/cmd/rdev-api/openapi.go @@ -177,6 +177,83 @@ func registerProjectPaths(spec *api.OpenAPISpec) { "projects:read", []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) { diff --git a/cookbooks/scripts/common.sh b/cookbooks/scripts/common.sh index 13ad05e..4b2e3ed 100755 --- a/cookbooks/scripts/common.sh +++ b/cookbooks/scripts/common.sh @@ -17,6 +17,47 @@ set -euo pipefail : "${RDEV_API_URL:?RDEV_API_URL 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 RED='\033[0;31m' GREEN='\033[0;32m' diff --git a/cookbooks/scripts/composable-test.sh b/cookbooks/scripts/composable-test.sh index 55e7305..ce944e5 100755 --- a/cookbooks/scripts/composable-test.sh +++ b/cookbooks/scripts/composable-test.sh @@ -15,8 +15,21 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/common.sh" -COMMAND="${1:-}" -PROJECT_NAME="${2:-}" +# 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]:-}" +PROJECT_NAME="${ARGS[1]:-}" + +# Register cleanup trap for auto-teardown +register_cleanup_trap if [[ -z "$COMMAND" || -z "$PROJECT_NAME" ]]; then echo "Usage: $0 " @@ -85,6 +98,9 @@ run_flow() { exit 1 fi + # Track project for auto-cleanup + CLEANUP_PROJECT="$PROJECT_NAME" + print_success "Project created with domain: $domain" # Step 2: Add backend service diff --git a/cookbooks/scripts/feature-test.sh b/cookbooks/scripts/feature-test.sh index d87ffb6..ead3588 100755 --- a/cookbooks/scripts/feature-test.sh +++ b/cookbooks/scripts/feature-test.sh @@ -17,8 +17,21 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/common.sh" -COMMAND="${1:-}" -PROJECT_NAME="${2:-}" +# 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]:-}" +PROJECT_NAME="${ARGS[1]:-}" + +# Register cleanup trap for auto-teardown +register_cleanup_trap if [[ -z "$COMMAND" || -z "$PROJECT_NAME" ]]; then echo "Usage: $0 " @@ -337,6 +350,9 @@ run_flow() { exit 1 fi + # Track project for auto-cleanup + CLEANUP_PROJECT="$PROJECT_NAME" + print_success "Project created with domain: $domain" # Step 2: Add backend service diff --git a/cookbooks/scripts/landing-test.sh b/cookbooks/scripts/landing-test.sh index 15c8f20..3f83586 100755 --- a/cookbooks/scripts/landing-test.sh +++ b/cookbooks/scripts/landing-test.sh @@ -19,8 +19,21 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/common.sh" -COMMAND="${1:-}" -PROJECT_NAME="${2:-landing-test-$(date +%s)}" +# 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]:-}" +PROJECT_NAME="${ARGS[1]:-landing-test-$(date +%s)}" + +# Register cleanup trap for auto-teardown +register_cleanup_trap if [[ -z "$COMMAND" ]]; then echo "Landing Page E2E Test Script" @@ -62,6 +75,9 @@ run_flow() { exit 1 fi + # Track project for auto-cleanup + CLEANUP_PROJECT="$PROJECT_NAME" + print_success "Project created: $PROJECT_NAME" echo " Domain: $domain" echo " Git: https://git.threesix.ai/$(get_git_owner)/$PROJECT_NAME" diff --git a/cookbooks/scripts/template-validation.sh b/cookbooks/scripts/template-validation.sh index 26b181c..e9543d0 100755 --- a/cookbooks/scripts/template-validation.sh +++ b/cookbooks/scripts/template-validation.sh @@ -15,9 +15,22 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 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)" +# Register cleanup trap for auto-teardown +register_cleanup_trap + # Component types to test COMPONENT_TYPES=( "service" @@ -255,6 +268,9 @@ run_full_validation() { exit 1 fi + # Track project for auto-cleanup + CLEANUP_PROJECT="$PROJECT_NAME" + print_success "Project created with domain: $domain" # Step 2: Add all component types diff --git a/cookbooks/scripts/tree-runner.sh b/cookbooks/scripts/tree-runner.sh index 883df1e..b3dddd7 100755 --- a/cookbooks/scripts/tree-runner.sh +++ b/cookbooks/scripts/tree-runner.sh @@ -28,6 +28,17 @@ source "$SCRIPT_DIR/common.sh" source "$SCRIPT_DIR/lib/checkpoint.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 COMMAND="${1:-}" @@ -302,6 +313,21 @@ build_outputs_from_checkpoint() { # 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 cmd_run() { local tree_name="${1:-}" @@ -312,6 +338,10 @@ cmd_run() { exit 1 fi + # Register auto-teardown trap + TREE_AUTO_TEARDOWN_NAME="$tree_name" + trap tree_auto_teardown EXIT INT TERM + # Validate tree exists if ! tree_parse "$tree_name" > /dev/null 2>&1; then print_error "Tree '$tree_name' not found" diff --git a/internal/handlers/project_management.go b/internal/handlers/project_management.go index fa615ab..02f796f 100644 --- a/internal/handlers/project_management.go +++ b/internal/handlers/project_management.go @@ -56,6 +56,13 @@ func (h *ProjectManagementHandler) Mount(r api.Router) { 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) r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)). 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. // GET /templates/components func (h *ProjectManagementHandler) ListComponentTemplates(w http.ResponseWriter, r *http.Request) { diff --git a/internal/service/project_infra_crud.go b/internal/service/project_infra_crud.go index e53397d..cfa0571 100644 --- a/internal/service/project_infra_crud.go +++ b/internal/service/project_infra_crud.go @@ -701,3 +701,120 @@ func (s *ProjectInfraService) ListComponentTemplates(ctx context.Context, compon } 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() +}