1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
use std::sync::Arc;
use std::time::Duration;
use tokio::time::interval;
use tracing::{debug, error, info};
use crate::db::models::DeploymentStatus;
use crate::db::{
deployments as db_deployments, extensions as db_extensions, projects as db_projects,
};
use crate::server::deployment::state_machine;
use crate::server::state::ControllerState;
/// Project controller handles project lifecycle operations
///
/// Currently implements:
/// - Deletion loop: processes projects in Deleting status
pub struct ProjectController {
state: Arc<ControllerState>,
deletion_interval: Duration,
}
impl ProjectController {
/// Create a new project controller
pub fn new(state: Arc<ControllerState>) -> Self {
Self {
state,
deletion_interval: Duration::from_secs(5),
}
}
/// Start deletion loop
pub fn start(self: Arc<Self>) {
tokio::spawn(async move {
self.deletion_loop().await;
});
}
/// Deletion loop - processes projects in Deleting status
///
/// Runs every 5 seconds and:
/// 1. Finds projects marked as Deleting
/// 2. For each project, checks all deployments
/// 3. Cancels pre-infrastructure deployments (Pending/Building/Pushing)
/// 4. Terminates post-infrastructure deployments (Deploying/Healthy/Unhealthy)
/// 5. Once all deployments are terminal, deletes the project
async fn deletion_loop(&self) {
info!("Project deletion loop started");
let mut ticker = interval(self.deletion_interval);
loop {
ticker.tick().await;
if let Err(e) = self.process_deleting_projects().await {
error!("Error in deletion loop: {}", e);
}
}
}
/// Process all projects in Deleting status
async fn process_deleting_projects(&self) -> anyhow::Result<()> {
let deleting = db_projects::find_deleting(&self.state.db_pool, 10).await?;
for project in deleting {
debug!("Processing deletion for project {}", project.name);
// Find all deployments for this project
let deployments =
db_deployments::list_for_project(&self.state.db_pool, project.id).await?;
// Check if any non-terminal deployments exist
let mut has_non_terminal = false;
for deployment in &deployments {
if state_machine::is_terminal(&deployment.status) {
continue;
}
has_non_terminal = true;
// Distinguish pre-infrastructure vs post-infrastructure
let is_pre_infrastructure = matches!(
deployment.status,
DeploymentStatus::Pending
| DeploymentStatus::Building
| DeploymentStatus::Pushing
);
if is_pre_infrastructure {
// Cancel pre-infrastructure deployments
// These haven't provisioned resources yet
if deployment.status != DeploymentStatus::Cancelling {
info!(
"Cancelling pre-infrastructure deployment {} (status={:?})",
deployment.deployment_id, deployment.status
);
db_deployments::mark_cancelling(&self.state.db_pool, deployment.id).await?;
}
} else {
// Terminate post-infrastructure deployments
// These have containers/resources that need cleanup
if deployment.status != DeploymentStatus::Terminating {
info!(
"Terminating post-infrastructure deployment {} (status={:?})",
deployment.deployment_id, deployment.status
);
db_deployments::mark_terminating(
&self.state.db_pool,
deployment.id,
crate::db::models::TerminationReason::UserStopped,
)
.await?;
}
}
}
// If all deployments are terminal, check finalizers and extensions before deleting
if !has_non_terminal {
// Check if any finalizers remain (e.g., ECR cleanup pending)
if db_projects::has_finalizers(&self.state.db_pool, project.id).await? {
debug!(
"Project {} has finalizers remaining, waiting for cleanup controllers",
project.name
);
continue;
}
// Check if any extensions remain (including soft-deleted ones)
// Extensions must be fully cleaned up by their controllers before project deletion
let extensions =
db_extensions::list_by_project(&self.state.db_pool, project.id).await?;
if !extensions.is_empty() {
debug!(
"Project {} has {} extension(s) remaining, waiting for extension controllers to clean up",
project.name,
extensions.len()
);
continue;
}
info!(
"All deployments for project {} are terminated and no finalizers or extensions remain, marking as Terminated",
project.name
);
// Transition to Terminated status before removal
db_projects::update_status(
&self.state.db_pool,
project.id,
crate::db::models::ProjectStatus::Terminated,
)
.await?;
info!(
"Project {} is Terminated, deleting from database",
project.name
);
db_projects::delete(&self.state.db_pool, project.id).await?;
} else {
debug!(
"Project {} still has non-terminal deployments, waiting",
project.name
);
}
}
Ok(())
}
}