Skip to main content

isb_apps/app/
projects.rs

1//! Projects and their environments: the records under
2//! `<org>/apps/projects`, one JSON file per project.
3
4use 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    /// The org's projects. Compose stacks that are gone are left out.
64    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    /// The project records as stored.
73    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    /// Delete an empty project (its apps go first).
92    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    /// `<project>-<env>` must be a stack name, and not a compose stack's.
120    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        // Records of its stacks that are gone go with it.
174        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}