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 contents(
94 &self,
95 org: &OrgId,
96 project: &str,
97 env: Option<&str>,
98 ) -> Result<(Vec<String>, Vec<String>)> {
99 let apps = self
100 .list(org)?
101 .into_iter()
102 .filter(|a| a.spec.project == project && env.is_none_or(|e| a.spec.environment == e))
103 .map(|a| a.spec.name)
104 .collect();
105 Ok((apps, self.live_compose(org, project, env)))
106 }
107
108 pub fn project_delete(&self, org: &OrgId, name: &str) -> Result<()> {
110 let _g = self.inner.edit.lock().unwrap();
111 self.project_get(org, name)?;
112 let (apps, stacks) = self.contents(org, name, None)?;
113 if !apps.is_empty() {
114 return Err(Error::invalid(format!(
115 "project {name} still has apps: {}; delete them first, or pass force",
116 apps.join(", ")
117 )));
118 }
119 if !stacks.is_empty() {
120 return Err(Error::invalid(format!(
121 "project {name} still has compose stacks: {}; remove them first (isb stack rm), or pass force",
122 stacks.join(", ")
123 )));
124 }
125 std::fs::remove_file(self.projects_dir(org).join(format!("{name}.json")))?;
126 self.invalidate_owners(org);
127 Ok(())
128 }
129
130 fn check_env_stack_free(&self, org: &OrgId, project: &str, env: &str) -> Result<()> {
132 let s = super::stack_name(project, env)?;
133 if self.compose_stack_exists(org, &s) {
134 return Err(Error::invalid(format!(
135 "{project} + {env}: the environment would run as stack {s}, which is a compose stack; pick another name"
136 )));
137 }
138 Ok(())
139 }
140
141 pub fn environment_create(&self, org: &OrgId, project: &str, env: &str) -> Result<Project> {
142 super::validate_part("environment", env)?;
143 self.check_env_stack_free(org, project, env)?;
144 let _g = self.inner.edit.lock().unwrap();
145 let mut p = self.project_get(org, project)?;
146 if p.environments.iter().any(|e| e == env) {
147 return Err(Error::AlreadyExists(format!(
148 "environment {env} in project {project}"
149 )));
150 }
151 p.environments.push(env.into());
152 self.save_project(org, &p)?;
153 Ok(p)
154 }
155
156 pub fn environment_delete(&self, org: &OrgId, project: &str, env: &str) -> Result<Project> {
157 let _g = self.inner.edit.lock().unwrap();
158 let mut p = self.project_get(org, project)?;
159 if !p.environments.iter().any(|e| e == env) {
160 return Err(Error::NotFound(format!(
161 "environment {env} in project {project}"
162 )));
163 }
164 let (apps, stacks) = self.contents(org, project, Some(env))?;
165 if !apps.is_empty() {
166 return Err(Error::invalid(format!(
167 "environment {env} still has apps: {}; delete them first, or pass force",
168 apps.join(", ")
169 )));
170 }
171 if !stacks.is_empty() {
172 return Err(Error::invalid(format!(
173 "environment {env} still has compose stacks: {}; remove them first (isb stack rm), or pass force",
174 stacks.join(", ")
175 )));
176 }
177 p.environments.retain(|e| e != env);
178 p.compose.retain(|r| r.environment != env);
180 self.save_project(org, &p)?;
181 Ok(p)
182 }
183
184 pub(super) fn save_project(&self, org: &OrgId, p: &Project) -> Result<()> {
185 super::write_atomic(
186 &self.projects_dir(org).join(format!("{}.json", p.name)),
187 &serde_json::to_vec_pretty(p)?,
188 )?;
189 self.invalidate_owners(org);
190 Ok(())
191 }
192}