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    /// What runs in a project, or in one of its environments: its apps and
92    /// its live compose stacks, by name.
93    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    /// Delete an empty project (its apps go first).
109    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    /// `<project>-<env>` must be a stack name, and not a compose stack's.
131    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        // Records of its stacks that are gone go with it.
179        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}