1use std::path::PathBuf;
5
6use super::Project;
7use super::deploy::Apps;
8use crate::error::{Error, Result};
9use crate::org::OrgId;
10
11impl Apps {
12 fn projects_dir(&self, org: &OrgId) -> PathBuf {
13 self.apps_dir(org).join("projects")
14 }
15
16 pub fn project_create(
17 &self,
18 org: &OrgId,
19 name: &str,
20 description: &str,
21 environments: &[String],
22 ) -> Result<Project> {
23 super::validate_part("project", name)?;
24 let envs: Vec<String> = if environments.is_empty() {
25 vec![super::DEFAULT_ENVIRONMENT.into()]
26 } else {
27 environments.to_vec()
28 };
29 for e in &envs {
30 super::validate_part("environment", e)?;
31 self.check_env_stack_free(org, name, e)?;
32 }
33 let _g = self.inner.edit.lock().unwrap();
34 let p = self.projects_dir(org).join(format!("{name}.json"));
35 if p.exists() {
36 return Err(Error::AlreadyExists(format!("project {name}")));
37 }
38 let mut envs = envs;
39 envs.dedup();
40 let proj = Project {
41 name: name.into(),
42 description: description.into(),
43 environments: envs,
44 created_at: crate::stack::now_secs(),
45 compose: Vec::new(),
46 };
47 self.save_project(org, &proj)?;
48 Ok(proj)
49 }
50
51 pub fn project_get(&self, org: &OrgId, name: &str) -> Result<Project> {
52 super::validate_part("project", name)?;
53 let p = self.projects_dir(org).join(format!("{name}.json"));
54 match std::fs::read(&p) {
55 Ok(b) => Ok(serde_json::from_slice(&b)?),
56 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
57 Err(Error::NotFound(format!("project {name} in org {org}")))
58 }
59 Err(e) => Err(e.into()),
60 }
61 }
62
63 pub fn project_list(&self, org: &OrgId) -> Result<Vec<Project>> {
65 let mut out = self.projects_raw(org)?;
66 for p in &mut out {
67 p.compose.retain(|r| self.stack_live(org, &r.stack));
68 }
69 Ok(out)
70 }
71
72 pub(super) fn projects_raw(&self, org: &OrgId) -> Result<Vec<Project>> {
74 let mut out = Vec::new();
75 let Ok(rd) = std::fs::read_dir(self.projects_dir(org)) else {
76 return Ok(out);
77 };
78 for e in rd.flatten() {
79 let p = e.path();
80 if p.extension().is_some_and(|x| x == "json") {
81 match serde_json::from_slice::<Project>(&std::fs::read(&p)?) {
82 Ok(pr) => out.push(pr),
83 Err(e) => eprintln!("isb serve: skipping {}: {e}", p.display()),
84 }
85 }
86 }
87 out.sort_by(|a, b| a.name.cmp(&b.name));
88 Ok(out)
89 }
90
91 pub fn project_delete(&self, org: &OrgId, name: &str) -> Result<()> {
93 let _g = self.inner.edit.lock().unwrap();
94 self.project_get(org, name)?;
95 let apps: Vec<String> = self
96 .list(org)?
97 .into_iter()
98 .filter(|a| a.spec.project == name)
99 .map(|a| a.spec.name)
100 .collect();
101 if !apps.is_empty() {
102 return Err(Error::invalid(format!(
103 "project {name} still has apps: {}; delete them first",
104 apps.join(", ")
105 )));
106 }
107 let stacks = self.live_compose(org, name, None);
108 if !stacks.is_empty() {
109 return Err(Error::invalid(format!(
110 "project {name} still has compose stacks: {}; remove them first (isb stack rm)",
111 stacks.join(", ")
112 )));
113 }
114 std::fs::remove_file(self.projects_dir(org).join(format!("{name}.json")))?;
115 self.invalidate_owners(org);
116 Ok(())
117 }
118
119 fn check_env_stack_free(&self, org: &OrgId, project: &str, env: &str) -> Result<()> {
121 let s = super::stack_name(project, env)?;
122 if self.compose_stack_exists(org, &s) {
123 return Err(Error::invalid(format!(
124 "{project} + {env}: the environment would run as stack {s}, which is a compose stack; pick another name"
125 )));
126 }
127 Ok(())
128 }
129
130 pub fn environment_create(&self, org: &OrgId, project: &str, env: &str) -> Result<Project> {
131 super::validate_part("environment", env)?;
132 self.check_env_stack_free(org, project, env)?;
133 let _g = self.inner.edit.lock().unwrap();
134 let mut p = self.project_get(org, project)?;
135 if p.environments.iter().any(|e| e == env) {
136 return Err(Error::AlreadyExists(format!(
137 "environment {env} in project {project}"
138 )));
139 }
140 p.environments.push(env.into());
141 self.save_project(org, &p)?;
142 Ok(p)
143 }
144
145 pub fn environment_delete(&self, org: &OrgId, project: &str, env: &str) -> Result<Project> {
146 let _g = self.inner.edit.lock().unwrap();
147 let mut p = self.project_get(org, project)?;
148 if !p.environments.iter().any(|e| e == env) {
149 return Err(Error::NotFound(format!(
150 "environment {env} in project {project}"
151 )));
152 }
153 let apps: Vec<String> = self
154 .list(org)?
155 .into_iter()
156 .filter(|a| a.spec.project == project && a.spec.environment == env)
157 .map(|a| a.spec.name)
158 .collect();
159 if !apps.is_empty() {
160 return Err(Error::invalid(format!(
161 "environment {env} still has apps: {}; delete them first",
162 apps.join(", ")
163 )));
164 }
165 let stacks = self.live_compose(org, project, Some(env));
166 if !stacks.is_empty() {
167 return Err(Error::invalid(format!(
168 "environment {env} still has compose stacks: {}; remove them first (isb stack rm)",
169 stacks.join(", ")
170 )));
171 }
172 p.environments.retain(|e| e != env);
173 p.compose.retain(|r| r.environment != env);
175 self.save_project(org, &p)?;
176 Ok(p)
177 }
178
179 pub(super) fn save_project(&self, org: &OrgId, p: &Project) -> Result<()> {
180 super::write_atomic(
181 &self.projects_dir(org).join(format!("{}.json", p.name)),
182 &serde_json::to_vec_pretty(p)?,
183 )?;
184 self.invalidate_owners(org);
185 Ok(())
186 }
187}