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",
|
"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) {
|
||||||
|
|||||||
@ -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'
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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()
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user