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:
parent
6c51469c89
commit
572b221e20
@ -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) {
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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 <command> <project-name>"
|
||||
@ -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
|
||||
|
||||
@ -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 <command> <project-name>"
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user